feat(webui): polish agent output and app discovery

This commit is contained in:
Xubin Ren
2026-07-22 22:42:31 +08:00
parent b189a37648
commit aa8387fb4d
87 changed files with 6225 additions and 2379 deletions
+1 -159
View File
@@ -1,44 +1,9 @@
import { toMediaAttachment } from "@/lib/media";
import type { ToolProgressEvent, UIMediaAttachment, UIMessage } from "@/lib/types";
export type ActivityItemType = "reasoning" | "tool" | "cli" | "mcp" | "file_edit" | "media";
export type ActivityStepStatus = "pending" | "running" | "done" | "error";
export type ActivityStepSource = "reasoning" | "tool" | "web" | "browser" | "shell" | "mcp" | "file" | "media";
export interface ActivityItem {
type: ActivityItemType;
message: UIMessage;
}
export interface ActivityEvidence {
id: string;
attachment: UIMediaAttachment;
caption?: string;
source: ActivityStepSource;
}
export interface ActivityStepItem {
id: string;
label: string;
detail?: string;
status: ActivityStepStatus;
source: ActivityStepSource;
preview?: ActivityEvidence[];
error?: string;
}
export interface ActivityGroup {
id: string;
title: string;
source: ActivityStepSource;
steps: ActivityStepItem[];
}
import type { UIMessage } from "@/lib/types";
export type TurnUnit =
| {
type: "activity";
messages: UIMessage[];
items: ActivityItem[];
turnLatencyMs?: number;
startedAtMs?: number;
}
@@ -243,7 +208,6 @@ function pushActivityUnits(
units.push({
type: "activity",
messages: runMessages,
items: runMessages.flatMap(activityItemsForMessage),
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
startedAtMs,
});
@@ -306,35 +270,6 @@ function stripInlineReasoning(message: UIMessage): UIMessage {
return next;
}
function activityItemsForMessage(message: UIMessage): ActivityItem[] {
if (isReasoningOnlyAssistant(message)) {
return [{ type: "reasoning", message }];
}
if (message.kind !== "trace") return [];
const items: ActivityItem[] = [];
if (message.fileEdits?.length) {
items.push({ type: "file_edit", message });
}
for (const event of message.toolEvents ?? []) {
const name = String(event.name ?? "").toLowerCase();
if (name === "run_cli_app") {
items.push({ type: "cli", message });
} else if (name === "mcp") {
items.push({ type: "mcp", message });
} else {
items.push({ type: "tool", message });
}
}
if (items.length === 0 && (message.traces?.length || message.content.trim())) {
items.push({ type: "tool", message });
}
if (message.media?.length) {
items.push({ type: "media", message });
}
return items;
}
function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: UIMessage[]): number | undefined {
for (let i = visibleMessages.length - 1; i >= 0; i -= 1) {
const latency = visibleMessages[i].latencyMs;
@@ -350,96 +285,3 @@ function activityTurnLatencyMs(activityMessages: UIMessage[], visibleMessages: U
function isValidLatency(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
}
export function activityEvidenceFromToolEvent(event: ToolProgressEvent): ActivityEvidence[] {
const source = activitySourceFromToolName(toolEventName(event));
const evidence: ActivityEvidence[] = [];
const extras = [
...unknownList((event as { embeds?: unknown }).embeds),
...unknownList((event as { files?: unknown }).files),
];
extras.forEach((value, index) => {
const attachment = mediaAttachmentFromUnknown(value);
if (!attachment) return;
evidence.push({
id: `${event.call_id || toolEventName(event) || "tool"}:${index}:${attachment.url || attachment.name || attachment.kind}`,
attachment,
caption: attachment.name,
source,
});
});
return evidence;
}
export function activityEvidenceFromMessageMedia(message: UIMessage): ActivityEvidence[] {
return (message.media ?? []).map((attachment, index) => ({
id: `${message.id}:media:${index}:${attachment.url || attachment.name || attachment.kind}`,
attachment,
caption: attachment.name,
source: "media",
}));
}
function unknownList(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toolEventName(event: ToolProgressEvent): string {
return typeof (event as { function?: { name?: unknown } }).function?.name === "string"
? String((event as { function?: { name?: unknown } }).function?.name)
: typeof event.name === "string"
? event.name
: "";
}
function activitySourceFromToolName(name: string): ActivityStepSource {
const compact = name.toLowerCase();
if (compact.includes("browser") || compact.includes("screenshot")) return "browser";
if (compact.includes("web") || compact.includes("search") || compact.includes("fetch") || compact.includes("read")) return "web";
if (compact.includes("exec") || compact.includes("shell") || compact.includes("cli")) return "shell";
if (compact.startsWith("mcp_") || compact === "mcp") return "mcp";
if (compact.includes("file") || compact.includes("patch")) return "file";
if (compact.includes("image") || compact.includes("video") || compact.includes("media")) return "media";
return "tool";
}
function mediaAttachmentFromUnknown(value: unknown): UIMediaAttachment | null {
if (typeof value === "string") {
const text = value.trim();
if (!text) return null;
return toMediaAttachment({ url: looksLikeUrl(text) ? text : undefined, name: baseName(text) });
}
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const url = stringField(record, ["url", "href", "src", "uri", "signed_url", "thumbnail_url"]);
const path = stringField(record, ["path", "absolute_path", "file", "filename"]);
const name = stringField(record, ["name", "filename", "title", "label"]) ?? baseName(url ?? path ?? "");
const kind = mediaKindFromRecord(record, url, name);
return toMediaAttachment({ url, name, kind });
}
function stringField(record: Record<string, unknown>, keys: string[]): string | undefined {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
function mediaKindFromRecord(record: Record<string, unknown>, url?: string, name?: string): UIMediaAttachment["kind"] | undefined {
const raw = stringField(record, ["kind", "type", "mime", "mime_type", "content_type"])?.toLowerCase() ?? "";
if (raw.includes("image") || raw.includes("screenshot")) return "image";
if (raw.includes("video") || raw.includes("mp4") || raw.includes("quicktime")) return "video";
if (raw.includes("file") || raw.includes("document")) return "file";
return toMediaAttachment({ url, name }).kind;
}
function looksLikeUrl(value: string): boolean {
return /^(https?:|data:|\/api\/|blob:)/i.test(value);
}
function baseName(value: string): string | undefined {
const clean = value.split(/[?#]/, 1)[0] ?? "";
const last = clean.split(/[\\/]/).filter(Boolean).pop();
return last || undefined;
}
+2
View File
@@ -386,6 +386,7 @@ export class NanobotClient {
options?: {
cliApps?: OutboundCliAppMention[];
mcpPresets?: OutboundMcpPresetMention[];
quotedContext?: string;
workspaceScope?: WorkspaceScopePayload | null;
turnId?: string;
},
@@ -398,6 +399,7 @@ export class NanobotClient {
...(media && media.length > 0 ? { media } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
...(options?.mcpPresets?.length ? { mcp_presets: options.mcpPresets } : {}),
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
webui: true,
+36 -1
View File
@@ -17,6 +17,10 @@ function googleFaviconUrl(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`;
}
function faviconImUrl(domain: string): string {
return `https://favicon.im/${encodeURIComponent(domain)}?larger=true`;
}
export function faviconUrls(domain: string): string[] {
const faviconDomain = faviconDomainFromValue(domain);
return [
@@ -26,6 +30,22 @@ export function faviconUrls(domain: string): string[] {
];
}
/**
* Cross-origin page favicons commonly opt into same-origin resource policy.
* Prefer image proxies for arbitrary links while retaining the official icon
* as a final fallback. Explicit first-party brand assets remain first when a
* provider supplies them.
*/
export function browserSafeFaviconUrls(domain: string): string[] {
const faviconDomain = faviconDomainFromValue(domain);
return [
faviconImUrl(faviconDomain),
googleFaviconUrl(domain),
duckDuckGoFaviconUrl(faviconDomain),
officialFaviconUrl(faviconDomain),
];
}
function brand(
domain: string,
color: string,
@@ -33,7 +53,7 @@ function brand(
logoOverrides: string[] = [],
): ProviderBrand {
const logoUrls = [...logoOverrides];
faviconUrls(domain).forEach((url) => addUniqueLogoUrl(logoUrls, url));
browserSafeFaviconUrls(domain).forEach((url) => addUniqueLogoUrl(logoUrls, url));
return {
logoUrl: logoUrls[0],
logoUrls,
@@ -60,12 +80,27 @@ function domainFromLogoUrl(url: string): string | null {
const match = parsed.pathname.match(/^\/ip3\/(.+)\.ico$/);
return match ? decodeURIComponent(match[1]) : null;
}
if (host === "favicon.im") {
return decodeURIComponent(parsed.pathname.replace(/^\//, "")) || null;
}
return host.replace(/^www\./, "");
} catch {
return null;
}
}
/**
* A repository favicon identifies the hosting service, not the app itself.
* Apps backed by GitHub repositories should keep their distinct initials
* instead of appearing to share one GitHub identity.
*/
export function isGenericRepositoryLogoUrl(logoUrl: string | null | undefined): boolean {
const value = logoUrl?.trim();
if (!value) return false;
const domain = domainFromLogoUrl(value)?.toLowerCase();
return domain === "github.com" || domain?.startsWith("github.com/") === true;
}
function faviconDomainFromValue(value: string): string {
const host = value.split("/")[0]?.trim();
return host || value;
+17 -3
View File
@@ -20,6 +20,19 @@ export function formatToolCallTrace(call: unknown): string | null {
return `${name}()`;
}
export function canonicalToolTrace(line: string): string {
const trimmed = line.trim();
const match = /^([a-zA-Z0-9_.-]+)\((.*)\)$/.exec(trimmed);
if (!match) return trimmed;
const args = match[2].trim();
if (!args) return `${match[1]}()`;
try {
return `${match[1]}(${JSON.stringify(JSON.parse(args))})`;
} catch {
return trimmed;
}
}
const VALID_PHASES = new Set(["start", "end", "error"]);
const PHASE_RANK: Record<string, number> = { start: 1, end: 2, error: 3 };
@@ -91,12 +104,13 @@ export function mergeUniqueToolTraceLines(
previousTraces: string[],
lines: string[],
): { traces: string[]; added: boolean } {
const seen = new Set(previousTraces);
const seen = new Set(previousTraces.map(canonicalToolTrace));
const traces = [...previousTraces];
let added = false;
for (const line of lines) {
if (seen.has(line)) continue;
seen.add(line);
const key = canonicalToolTrace(line);
if (seen.has(key)) continue;
seen.add(key);
traces.push(line);
added = true;
}
+3
View File
@@ -1044,6 +1044,8 @@ export type InboundEvent =
chat_id: string;
stream_id?: string;
text?: string;
/** This answer segment ended, but the active agent turn will continue. */
resuming?: boolean;
} & InboundTurnMetadata)
| ({
event: "reasoning_delta";
@@ -1171,6 +1173,7 @@ export type Outbound =
media?: OutboundMedia[];
cli_apps?: OutboundCliAppMention[];
mcp_presets?: OutboundMcpPresetMention[];
quoted_context?: string;
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
/** Marks messages sent by the embedded WebUI, without changing the