feat(agent): enhance conversation flow with incremental updates and user-friendly summaries for reasoning, plans, commands, searches, and file changes

This commit is contained in:
HouYunFei
2026-07-29 12:19:13 +08:00
parent c6f8b1e394
commit ea67a5abf5
9 changed files with 554 additions and 94 deletions
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button, Tooltip } from "antd";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
import { ArrowUp, Brain, CheckCircle2, ChevronDown, CircleAlert, FilePenLine, FileText, ImagePlus, ListChecks, LoaderCircle, Search, Square, TerminalSquare, UserRound, Wrench, X, XCircle } from "lucide-react";
import { Streamdown } from "streamdown";
import { isPlainEnterKey } from "@/lib/keyboard-event";
@@ -20,8 +20,6 @@ export type CanvasAgentChatMessage = {
streamId?: string;
};
const WORKING_TEXT = "working...";
export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveTool }: { item: CanvasAgentChatMessage; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; user: LocalUser | null; onRejectTool?: (id: string) => void; onApproveTool?: (id: string) => void }) {
const isUser = item.role === "user";
const isSystem = item.role === "system";
@@ -66,38 +64,30 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
}
export function AgentPendingToolCard({ summary, detail, theme, onReject, onApprove }: { summary: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onReject?: () => void; onApprove?: () => void }) {
const view = userDetail(detail);
return (
<div className="flex items-start gap-3">
<AgentAvatar theme={theme} />
<div className="min-w-0 flex-1 rounded-xl border p-4" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<details>
<summary className="cursor-pointer list-none">
<div className="flex items-start gap-3">
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: "rgba(217,119,6,.24)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
<CircleAlert className="size-4" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
<span></span>
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: "rgba(217,119,6,.22)", color: "#d97706", background: "rgba(217,119,6,.04)" }}>
</span>
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}></span> : null}
</div>
<div className="mt-2 text-sm leading-6" style={{ color: theme.node.text }}>
{summary}
</div>
<div className="min-w-0 flex-1 rounded-xl border px-3 py-3" style={{ borderColor: "rgba(217,119,6,.28)", background: "rgba(217,119,6,.025)", color: theme.node.text }}>
<details className="group">
<summary className={`flex list-none items-start gap-2.5 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
<CircleAlert className="mt-0.5 size-4 shrink-0 text-amber-600" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium leading-5">
<span></span>
{view ? <ChevronDown className="ml-auto size-3.5 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
</div>
<div className="mt-1 text-sm leading-5" style={{ color: theme.node.muted }}>{summary}</div>
</div>
</summary>
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
</details>
{onReject || onApprove ? (
<div className="mt-4 grid grid-cols-2 gap-2">
<Button danger className="!h-9" icon={<XCircle className="size-4" />} onClick={() => onReject?.()}>
<div className="mt-3 flex justify-end gap-2 border-t pt-3" style={{ borderColor: theme.node.stroke }}>
<Button danger type="text" className="!h-8" icon={<XCircle className="size-3.5" />} onClick={() => onReject?.()}>
</Button>
<Button className="!h-9" icon={<CheckCircle2 className="size-4" />} style={{ borderColor: "rgba(22,163,74,.42)", color: "#16a34a", background: "transparent" }} onClick={() => onApprove?.()}>
<Button type="text" className="!h-8" icon={<CheckCircle2 className="size-3.5" />} style={{ color: "#16a34a" }} onClick={() => onApprove?.()}>
</Button>
</div>
@@ -109,50 +99,59 @@ export function AgentPendingToolCard({ summary, detail, theme, onReject, onAppro
export function AgentToolCard({ title, text, detail, theme }: { title: string; text: string; detail?: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const state = toolCardState(title, text, detail);
const view = userDetail(detail);
const kind = String(objectField(detail, "kind") || "");
return (
<details className="min-w-0 flex-1 rounded-xl border px-4 py-3.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<summary className="cursor-pointer list-none">
<div className="flex items-start gap-3">
<span className="mt-0.5 grid size-8 shrink-0 place-items-center rounded-lg border" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
{state.icon}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold leading-5">
<span className="min-w-0 truncate">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium" style={{ borderColor: state.softBorder, color: state.color, background: state.softBg }}>
{state.label}
</span>
{detail ? <span className="ml-auto text-xs font-normal" style={{ color: theme.node.muted }}></span> : null}
</div>
<div className="mt-2 text-sm leading-6" style={{ color: state.isError ? state.color : theme.node.muted }}>
{text}
</div>
<details className="group min-w-0 flex-1 rounded-xl border px-3 py-2.5 text-left" style={{ borderColor: theme.node.stroke, background: "transparent", color: theme.node.text }}>
<summary className={`flex list-none items-start gap-2.5 ${view ? "cursor-pointer" : "cursor-default"}`} onClick={(event) => { if (!view) event.preventDefault(); }}>
<span className="mt-0.5 shrink-0" style={{ color: state.color }}>{toolIcon(kind, state.icon)}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2 text-sm font-medium leading-5">
<span className="min-w-0 truncate">{title}</span>
<span className="inline-flex shrink-0 items-center gap-1 text-[11px] font-normal" style={{ color: state.color }}>
{state.label}
</span>
{view ? <ChevronDown className="ml-auto size-3.5 shrink-0 transition-transform group-open:rotate-180" style={{ color: theme.node.muted }} /> : null}
</div>
<div className={`mt-1 whitespace-pre-wrap break-words text-sm leading-5 ${kind === "command" ? "font-mono text-[12px]" : ""}`} style={{ color: state.isError ? state.color : theme.node.muted }}>
{text}
</div>
</div>
</summary>
{detail ? <AgentDetailBlock detail={detail} theme={theme} /> : null}
{view ? <AgentDetailBlock detail={view} theme={theme} /> : null}
</details>
);
}
export function AgentWorkingMessage({ theme }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const [length, setLength] = useState(1);
export function AgentWorkingMessage({ text, activityKey, theme }: { text: string; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setLength((value) => (value >= WORKING_TEXT.length + 4 ? 1 : value + 1)), 120);
const startedAt = Date.now();
setElapsed(0);
const timer = window.setInterval(() => setElapsed(Math.floor((Date.now() - startedAt) / 1000)), 1000);
return () => window.clearInterval(timer);
}, [setLength]);
}, [activityKey]);
return (
<div className="flex items-start gap-2.5">
<div className="flex items-start gap-3">
<AgentAvatar theme={theme} />
<div className="min-w-0 max-w-[82%]">
<div className="font-mono text-sm" style={{ color: theme.node.muted }} aria-label={WORKING_TEXT}>
<span className="inline-block w-[76px]">{WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))}</span>
<div className="min-w-0 flex-1 py-1" aria-live="polite">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm" style={{ color: theme.node.muted }}>
<LoaderCircle className="size-3.5 shrink-0 animate-spin" />
<span className="min-w-0">{text}</span>
{elapsed >= 5 ? <span className="shrink-0 text-[11px] tabular-nums opacity-60">{waitingTime(elapsed)}</span> : null}
</div>
{elapsed >= 30 ? <div className="mt-1 text-xs leading-5 opacity-65" style={{ color: theme.node.muted }}></div> : null}
</div>
</div>
);
}
function waitingTime(seconds: number) {
if (seconds < 60) return `已等待 ${seconds}`;
const minutes = Math.floor(seconds / 60);
return `已等待 ${minutes}${seconds % 60}`;
}
export function AgentChatComposer({
prompt,
attachments = [],
@@ -264,11 +263,40 @@ export function AgentPanelTabs<T extends string>({ value, items, theme, right, o
);
}
function AgentDetailBlock({ detail, theme }: { detail: unknown; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
type UserDetail = { kind?: string; status?: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
function AgentDetailBlock({ detail, theme }: { detail: UserDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) {
return (
<pre className="thin-scrollbar mt-3 max-h-64 overflow-auto rounded-lg border p-3 text-[11px] leading-4" style={{ borderColor: theme.node.stroke, background: theme.toolbar.panel, color: theme.node.muted }}>
{JSON.stringify(detail, null, 2)}
</pre>
<div className="mt-3 space-y-2.5 border-t pt-3 text-xs" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
{detail.rows?.length ? (
<dl className="space-y-1.5">
{detail.rows.map((row) => (
<div key={`${row.label}-${row.value}`} className="grid grid-cols-[64px_minmax(0,1fr)] gap-2">
<dt className="opacity-60">{row.label}</dt>
<dd className="min-w-0 break-words" style={{ color: theme.node.text }}>{row.value}</dd>
</div>
))}
</dl>
) : null}
{detail.files?.length ? (
<div className="space-y-1.5">
<div className="opacity-60"></div>
{detail.files.map((file) => (
<div key={`${file.action}-${file.path}`} className="flex items-start gap-2">
<FileText className="mt-0.5 size-3.5 shrink-0" />
<span className="min-w-0 flex-1 break-all" style={{ color: theme.node.text }}>{file.path}</span>
{file.action ? <span className="shrink-0 opacity-60">{file.action}</span> : null}
</div>
))}
</div>
) : null}
{detail.output ? (
<div className="space-y-1.5">
<div className="opacity-60">{detail.status === "failed" || detail.status === "error" ? "错误信息" : "运行输出"}</div>
<pre className="thin-scrollbar max-h-56 overflow-auto whitespace-pre-wrap break-words rounded-lg px-3 py-2 font-mono text-[11px] leading-4" style={{ background: theme.toolbar.panel, color: theme.node.text }}>{detail.output}</pre>
</div>
) : null}
</div>
);
}
@@ -302,19 +330,53 @@ function AgentMessageAttachments({ attachments }: { attachments: CanvasAgentChat
function toolCardState(title: string, text: string, detail?: unknown) {
const raw = `${title} ${text} ${normalizeText(objectField(detail, "error"))}`;
const lower = raw.toLowerCase();
const tool = String(objectField(detail, "name") || objectField(detail, "tool") || "");
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "工具完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
const status = String(objectField(detail, "status") || "").toLowerCase();
if (status === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", icon: <CircleAlert className="size-4" />, isError: false };
if (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) return { label: "已取消", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
if (["failed", "error"].includes(status) || /失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", icon: <XCircle className="size-4" />, isError: true };
if (["inprogress", "in_progress", "running", "started", "pending"].includes(status)) return { label: "进行中", color: "#d97706", icon: <LoaderCircle className="size-4 animate-spin" />, isError: false };
if (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: "已完成", color: "#16a34a", icon: <CheckCircle2 className="size-4" />, isError: false };
return { label: "已记录", color: "#2563eb", icon: <Wrench className="size-4" />, isError: false };
}
function toolIcon(kind: string | undefined, fallback: ReactNode) {
if (kind === "reasoning") return <Brain className="size-4" />;
if (kind === "command") return <TerminalSquare className="size-4" />;
if (kind === "search") return <Search className="size-4" />;
if (kind === "file") return <FilePenLine className="size-4" />;
if (kind === "plan") return <ListChecks className="size-4" />;
return fallback;
}
function userDetail(value: unknown): UserDetail | null {
if (!value || typeof value !== "object") return null;
const detail = value as Record<string, unknown>;
const rows = Array.isArray(detail.rows)
? detail.rows.flatMap((row) => {
if (!row || typeof row !== "object") return [];
const label = String((row as Record<string, unknown>).label || "");
const value = String((row as Record<string, unknown>).value || "");
return label && value ? [{ label, value }] : [];
})
: [];
const files = Array.isArray(detail.files)
? detail.files.flatMap((file) => {
if (!file || typeof file !== "object") return [];
const path = String((file as Record<string, unknown>).path || "");
return path ? [{ path, action: String((file as Record<string, unknown>).action || "") || undefined }] : [];
})
: [];
const error = objectField(detail.error, "message");
const output = typeof detail.output === "string" ? detail.output.trim() : typeof error === "string" ? error : "";
if (!rows.length && !files.length && !output) return null;
return { kind: typeof detail.kind === "string" ? detail.kind : undefined, status: typeof detail.status === "string" ? detail.status : undefined, rows, files, output };
}
function normalizeText(value: unknown) {
if (typeof value === "string") return value.trim();
if (value instanceof Error) return value.message;
if (value == null) return "";
return JSON.stringify(value, null, 2);
return String(objectField(value, "message") || "");
}
function objectField(value: unknown, key: string) {
@@ -42,7 +42,30 @@ type AgentEventPayload = {
usage?: Record<string, unknown>;
duration_ms?: number;
};
type AgentEventItem = { id?: string; type?: string; text?: unknown; delta?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
type AgentEventItem = {
id?: string;
type?: string;
text?: unknown;
delta?: unknown;
message?: unknown;
server?: string;
tool?: string;
status?: string;
arguments?: unknown;
result?: unknown;
error?: { message?: string };
command?: unknown;
cwd?: unknown;
aggregatedOutput?: unknown;
exitCode?: unknown;
durationMs?: unknown;
changes?: unknown;
summary?: unknown;
query?: unknown;
action?: unknown;
path?: unknown;
};
type AgentUserDetail = { kind: string; status: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }> };
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
@@ -190,11 +213,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
source.addEventListener("workspace_changed", (event) => {
const data = parseEventData<AgentWorkspaceEvent>(event);
if (!data) return;
enqueueEvent(() => {
enqueueEvent(async () => {
const nextThreadId = data.activeThreadId ?? data.threadId ?? "";
pendingToolRef.current = null;
setAgentState({ activeThreadId: nextThreadId, messages: [], tokenUsage: null, pendingTool: null });
void loadThreads(Boolean(data.emptyThread));
await loadThreads(Boolean(data.emptyThread));
});
});
source.addEventListener("chat_message", (event) => {
@@ -371,10 +394,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const result = await runSiteTool(payload.name, payload.input || {}, navigate, { canvasSnapshot: canvasContextRef.current?.snapshot || null });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: siteToolSummary(payload.name, result), detail: { requestId: payload.requestId, name: payload.name, input: payload.input, result } });
addMessage({ role: "tool", title: toolName(payload.name), text: siteToolSummary(payload.name, result), detail: toolCallDetail(payload.name, payload.input, "completed") });
} catch (error) {
const message = error instanceof Error ? error.message : "工具执行失败";
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
}
return;
@@ -408,13 +431,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({
role: "tool",
title: `${toolName(payload.name)}完成`,
text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "画布操作" : payload.name === "site_navigate" ? `跳转到 ${input.path || "/"}` : "已完成",
detail: { requestId: payload.requestId, name: payload.name, input, result },
title: toolName(payload.name),
text: appliedOps.length ? summarizeCanvasAgentOps(appliedOps) || "已完成画布操作" : payload.name === "site_navigate" ? `打开${routeName(input.path || "/")}` : "已完成",
detail: toolCallDetail(payload.name, input, "completed"),
});
} catch (error) {
const message = error instanceof Error ? error.message : "画布操作失败";
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
addMessage({ role: "tool", title: toolName(payload.name), text: message, detail: toolCallDetail(payload.name, payload.input, "failed", message) });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
}
};
@@ -422,7 +445,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const rejectPendingTool = async () => {
if (!pendingTool) return;
await postToolResult(endpoint, token, clientIdRef.current, { requestId: pendingTool.requestId, error: "用户取消了画布工具调用" });
addMessage({ role: "tool", title: "拒绝执行", text: toolName(pendingTool.name), detail: { requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input } });
addMessage({ role: "tool", title: toolName(pendingTool.name), text: "用户已取消本次操作", detail: toolCallDetail(pendingTool.name, pendingTool.input, "declined") });
pendingToolRef.current = null;
setAgentState({ pendingTool: null });
};
@@ -594,6 +617,40 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
pushEventLog({ id: `${Date.now()}-${Math.random()}`, time: new Date().toLocaleTimeString(), title, text: value, raw });
};
const upsertActivityMessage = (item: AgentChatItem) => {
const currentMessages = useAgentStore.getState().messages;
const index = currentMessages.findIndex((message) => message.id === item.id);
if (index < 0) {
pushMessage(item);
return;
}
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...item } : message)) });
};
const appendActivityDelta = (item: AgentEventItem) => {
if (!item.id) return;
const delta = stringText(item.delta);
if (!delta) return;
const currentMessages = useAgentStore.getState().messages;
const index = currentMessages.findIndex((message) => message.id === item.id);
if (index < 0) {
if (!delta.trim()) return;
upsertActivityMessage(activityDeltaFallback(item, delta));
return;
}
const current = currentMessages[index];
if (item.type === "command_execution") {
const detail = activityDetail(current.detail, "command", "inProgress");
detail.output = `${detail.output || ""}${delta}`;
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) });
return;
}
const placeholder = activityPlaceholder(item.type);
if (!delta.trim() && current.text === placeholder) return;
const text = current.text === placeholder ? delta : `${current.text}${delta}`;
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message)) });
};
const handleAgentEvent = (event: AgentEventPayload) => {
if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) });
const log = formatAgentEventLog(event);
@@ -603,6 +660,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
appendStreamDelta(event.item.id, stringText(event.item.delta));
return;
}
if (event.type === "item.updated" && event.item) {
appendActivityDelta(event.item);
return;
}
if (event.type === "item.completed" && event.item?.type === "agent_message" && event.item.id) {
const currentMessages = useAgentStore.getState().messages;
const index = currentMessages.findIndex((message) => message.streamId === event.item?.id);
@@ -612,6 +673,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
return;
}
}
const activity = formatAgentActivity(event);
if (activity && event.item?.id) {
upsertActivityMessage({ ...activity, id: event.item.id });
return;
}
if (event.type === "turn.completed") setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) });
const item = formatAgentEvent(event);
if (item) addMessage(item);
@@ -742,6 +808,7 @@ function AgentChatTimeline({
const followMessagesRef = useRef(true);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
const streaming = messages.some((message) => message.streamId);
const working = workingActivity(messages.at(-1));
const updateScrollState = useCallback(() => {
const list = listRef.current;
if (!list) return;
@@ -769,13 +836,13 @@ function AgentChatTimeline({
{pendingTool ? (
<AgentPendingToolCard
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }}
detail={toolCallDetail(pendingTool.name, pendingTool.input, "pending")}
theme={theme}
onReject={onRejectTool}
onApprove={onApproveTool}
/>
) : null}
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
{(sending || waiting) && !streaming && !pendingTool ? <AgentWorkingMessage text={working.text} activityKey={working.key} theme={theme} /> : null}
</div>
{showScrollToBottom ? (
<Tooltip title="滚动到底部" placement="left">
@@ -1191,10 +1258,119 @@ function formatAgentEvent(event: AgentEventPayload): Omit<AgentChatItem, "id"> |
const item = event.item;
if (event.type === "item.completed" && item?.type === "error") return { role: "error", title: "错误", text: normalizeText(item.message), detail: item };
if (event.type === "item.completed" && item?.type === "agent_message") return { role: "assistant", title: "Codex", text: stringText(item.text) };
if (event.type === "item.completed" && isMcpToolItem(item) && isReadTool(String(item?.tool || ""))) return { role: "tool", title: `${toolName(String(item?.tool || ""))}完成`, text: item?.error?.message || toolSummary(item), detail: toolDetail(item) };
return null;
}
function formatAgentActivity(event: AgentEventPayload): Omit<AgentChatItem, "id"> | null {
const item = event.item;
if (!item || (event.type !== "item.started" && event.type !== "item.completed")) return null;
const completed = event.type === "item.completed";
const status = String(item.status || (completed ? "completed" : "inProgress"));
if (item.type === "reasoning") {
const text = readableText(item.summary) || (completed ? "已完成分析" : activityPlaceholder(item.type));
return { role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status } };
}
if (item.type === "plan") {
const text = stringText(item.text) || activityPlaceholder(item.type);
return { role: "tool", title: "执行计划", text, detail: { kind: "plan", status } };
}
if (item.type === "command_execution") {
const command = stringText(item.command) || activityPlaceholder(item.type);
return { role: "tool", title: "执行命令", text: command, detail: commandActivityDetail(item, status) };
}
if (item.type === "file_change") {
const files = activityFiles(item.changes);
return { role: "tool", title: "修改文件", text: fileActivitySummary(files, completed), detail: { kind: "file", status, files } };
}
if (item.type === "web_search") {
return { role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status, rows: webSearchDetailRows(item) } };
}
if (item.type === "image_view") return { role: "tool", title: "查看图片", text: stringText(item.path) || "正在查看图片", detail: { kind: "image", status } };
if (item.type === "context_compaction") return { role: "tool", title: "整理上下文", text: completed ? "已整理当前对话,继续处理任务" : "正在整理当前对话…", detail: { kind: "context", status } };
if (isMcpToolItem(item) && isReadTool(String(item.tool || ""))) {
const name = String(item.tool || "");
return { role: "tool", title: toolName(name), text: completed ? item.error?.message || toolSummary(item) : `正在${toolAction(name)}`, detail: toolDetail(item, item.error ? "failed" : status) };
}
if (item.type === "dynamic_tool_call") return { role: "tool", title: "使用工具", text: completed ? "已完成工具操作" : "正在执行工具操作…", detail: { kind: "tool", status } };
if (item.type === "collab_tool_call") return { role: "tool", title: "协作处理", text: completed ? "已完成协作任务" : "正在协作处理任务…", detail: { kind: "tool", status } };
return null;
}
function activityDeltaFallback(item: AgentEventItem, delta: string): AgentChatItem {
if (item.type === "command_execution") return { id: item.id || createId(), role: "tool", title: "执行命令", text: activityPlaceholder(item.type), detail: { kind: "command", status: "inProgress", output: delta } };
return { id: item.id || createId(), role: "tool", title: item.type === "plan" ? "执行计划" : "思考摘要", text: delta, detail: { kind: activityKind(item.type), status: "inProgress" } };
}
function activityPlaceholder(type?: string) {
if (type === "plan") return "正在整理执行步骤…";
if (type === "command_execution") return "正在执行命令…";
return "正在分析任务…";
}
function activityKind(type?: string) {
if (type === "command_execution") return "command";
if (type === "plan") return "plan";
return "reasoning";
}
function activityDetail(value: unknown, kind: string, status: string): AgentUserDetail {
const current = value && typeof value === "object" ? (value as Partial<AgentUserDetail>) : {};
return { kind, status, rows: current.rows, output: current.output, files: current.files };
}
function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail {
const rows = [detailRow("工作目录", item.cwd), detailRow("退出状态", item.exitCode), durationDetailRow(item.durationMs)].flatMap((row) => (row ? [row] : []));
return { kind: "command", status, rows, output: item.error?.message || stringText(item.aggregatedOutput) };
}
function activityFiles(value: unknown) {
return (Array.isArray(value) ? value : []).flatMap((change) => {
const path = stringText(objectField(change, "path"));
return path ? [{ path, action: changeAction(objectField(change, "kind")) }] : [];
});
}
function fileActivitySummary(files: Array<{ path: string; action?: string }>, completed: boolean) {
if (!files.length) return completed ? "已完成文件修改" : "正在准备文件修改…";
if (files.length === 1) return `${completed ? "已" : "正在"}${files[0].action || "修改"} ${files[0].path}`;
const names = files.slice(0, 3).map((file) => file.path).join("、");
return `${completed ? "已修改" : "正在修改"} ${files.length} 个文件:${names}${files.length > 3 ? " 等" : ""}`;
}
function webSearchSummary(item: AgentEventItem) {
const action = item.action;
const type = stringText(objectField(action, "type"));
if (type === "openPage") return `打开网页:${stringText(objectField(action, "url"))}`;
if (type === "findInPage") return `在网页中查找“${stringText(objectField(action, "pattern")) || "相关内容"}`;
return `搜索:${stringText(item.query) || stringText(objectField(action, "query")) || "相关资料"}`;
}
function webSearchDetailRows(item: AgentEventItem) {
const action = item.action;
return [detailRow("关键词", item.query || objectField(action, "query")), detailRow("网页", objectField(action, "url"))].flatMap((row) => (row ? [row] : []));
}
function readableText(value: unknown): string {
if (typeof value === "string") return value.trim();
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
return readableText(objectField(value, "text"));
}
function detailRow(label: string, value: unknown) {
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
}
function durationDetailRow(value: unknown) {
const duration = Number(value || 0);
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)}` } : null;
}
function changeAction(value: unknown) {
if (value === "add") return "新增";
if (value === "delete") return "删除";
return "修改";
}
function parseEventData<T>(event: Event) {
try {
return JSON.parse((event as MessageEvent).data) as T;
@@ -1285,9 +1461,9 @@ function toolName(name: string) {
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
if (name === "site_navigate") return "网站跳转";
if (name === "site_navigate") return "打开页面";
if (isSiteTool(name)) return SITE_TOOL_LABELS[name];
return name;
return "工具操作";
}
function siteToolSummary(name: string, result: unknown) {
@@ -1313,8 +1489,22 @@ function isMcpToolItem(item?: AgentEventItem) {
return item?.type === "mcp_tool_call";
}
function toolDetail(item?: AgentEventItem) {
return { server: item?.server, tool: item?.tool, status: item?.status, arguments: item?.arguments, result: parseToolResult(item?.result), error: item?.error };
function toolDetail(item: AgentEventItem | undefined, status: string): AgentUserDetail {
const name = String(item?.tool || "");
return { kind: "tool", status, rows: toolInputRows(name, item?.arguments), ...(item?.error?.message ? { output: item.error.message } : {}) };
}
function toolCallDetail(name: string, input: unknown, status: string, error = ""): AgentUserDetail {
return { kind: "tool", status, rows: toolInputRows(name, input), ...(error ? { output: error } : {}) };
}
function toolInputRows(name: string, input: unknown) {
if (name === "site_navigate") return [detailRow("目标页面", routeName(stringText(objectField(input, "path")) || "/"))].flatMap((row) => (row ? [row] : []));
if (name === "prompts_search") return [detailRow("搜索内容", objectField(input, "query"))].flatMap((row) => (row ? [row] : []));
if (name === "canvas_create_text_node") return [detailRow("文本内容", objectField(input, "text"))].flatMap((row) => (row ? [row] : []));
if (name === "canvas_apply_ops") return [detailRow("操作内容", summarizeCanvasAgentOps((objectField(input, "ops") as CanvasAgentOp[] | undefined) || []))].flatMap((row) => (row ? [row] : []));
if (name === "canvas_create_attachment_nodes") return [detailRow("图片数量", Array.isArray(objectField(input, "nodes")) ? (objectField(input, "nodes") as unknown[]).length : 0)].flatMap((row) => (row ? [row] : []));
return [];
}
function toolSummary(item?: AgentEventItem) {
@@ -1327,6 +1517,33 @@ function toolSummary(item?: AgentEventItem) {
return "工具调用完成";
}
function toolAction(name: string) {
const label = toolName(name);
if (label.startsWith("读取") || label.startsWith("查看") || label.startsWith("搜索") || label.startsWith("打开")) return label;
return `执行${label}`;
}
function routeName(path: string) {
if (path === "/") return "首页";
if (path === "/canvas") return "画布页面";
if (path.startsWith("/canvas/")) return "指定画布";
if (path.startsWith("/image")) return "生图工作台";
if (path.startsWith("/video")) return "视频工作台";
if (path.startsWith("/prompts")) return "提示词中心";
if (path.startsWith("/assets")) return "我的素材";
if (path.startsWith("/config")) return "配置页面";
return path;
}
function workingActivity(item?: AgentChatItem) {
const status = String(objectField(item?.detail, "status") || "");
const key = `${item?.id || "waiting"}-${status}`;
if (item?.role !== "tool") return { key, text: "正在思考..." };
if (["inProgress", "in_progress", "running", "pending"].includes(status)) return { key, text: `${item.title || "工具操作"}正在进行...` };
if (item.title === "读取画布") return { key, text: "画布已读取,Codex 正在整理结果..." };
return { key, text: `${item.title || "工具操作"}已完成,Codex 正在继续处理...` };
}
function parseToolResult(result: unknown) {
const content = objectField(result, "content");
const text = Array.isArray(content)