feat(webui): polish agent output and app discovery
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user