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
+2
View File
@@ -9,6 +9,8 @@
+ [优化] 精简网页与本地 Agent 的 HTTP 诊断日志,过滤轮询和流式重复事件并改用任务摘要展示。
+ [优化] Agent 对话采用透明无气泡的简洁用户消息样式,输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。
+ [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。
+ [优化] Agent 对话新增思考摘要、计划、命令、搜索和文件变更时间线,工具卡片改为中文摘要与用户可读详情,不再展示原始 JSON。
+ [优化] Agent 长时间等待时显示当前处理阶段、已等待时长和停止提示,并避免新建空会话反复记录历史读取失败。
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
+1 -1
View File
@@ -134,7 +134,7 @@ default_tools_approval_mode = "approve"
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
侧边栏会展示 Codex 返回的 `thread.started``turn.started``item.*``turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会合并短时间内的文本增量并转成 `item.updated`,网页用同一条消息做真实流式更新,并把工具细节收进运行日志
侧边栏会展示 Codex 返回的 `thread.started``turn.started``item.*``turn.completed` 等结构化事件;Canvas Agent 会合并短时间内的回复、思考摘要和命令输出增量,网页使用同一条消息持续更新,并把计划、搜索、文件修改与工具操作整理为中文过程时间线
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
+24 -7
View File
@@ -11,7 +11,8 @@ import type { AgentEmit } from "./types.js";
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
type PendingDelta = { delta: string; params: CodexNotificationParams<"item/agentMessage/delta">; timer: ReturnType<typeof setTimeout> };
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string };
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
const canvasAgentMcp = canvasAgentMcpCommand();
const require = createRequire(import.meta.url);
@@ -162,7 +163,8 @@ export class CodexAppClient {
const id = Number(message.id);
if (message.error && this.pending.has(id)) {
const error = String(field(message.error, "message") || "Codex request failed");
logger.warn("Codex request failed", { id, error });
if (/not materialized yet.*includeTurns/i.test(error)) logger.debug("Codex thread has no messages yet", { id });
else logger.warn("Codex request failed", { id, error });
return this.reject(id, error);
}
if (this.pending.has(id)) return this.resolve(id, message.result);
@@ -172,7 +174,14 @@ export class CodexAppClient {
/** 转换并广播 app-server 通知。 */
private handleNotification(method: string, params: JsonRecord) {
if (method === "item/agentMessage/delta") return this.emitDelta(params as unknown as CodexNotificationParams<"item/agentMessage/delta">);
if (method === "item/agentMessage/delta") {
const value = params as unknown as CodexNotificationParams<"item/agentMessage/delta">;
this.textByItem.set(value.itemId, `${this.textByItem.get(value.itemId) || ""}${value.delta}`);
return this.emitDelta("agent_message", value);
}
if (method === "item/plan/delta") return this.emitDelta("plan", params as unknown as CodexNotificationParams<"item/plan/delta">);
if (method === "item/reasoning/summaryTextDelta") return this.emitDelta("reasoning", params as unknown as CodexNotificationParams<"item/reasoning/summaryTextDelta">);
if (method === "item/commandExecution/outputDelta") return this.emitDelta("command_execution", params as unknown as CodexNotificationParams<"item/commandExecution/outputDelta">);
if (method === "thread/tokenUsage/updated") {
this.lastUsage = normalizeUsage(params as unknown as CodexNotificationParams<"thread/tokenUsage/updated">);
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
@@ -209,18 +218,19 @@ export class CodexAppClient {
}
}
/** 合并并广播 Agent 文本增量。 */
private emitDelta(params: CodexNotificationParams<"item/agentMessage/delta">) {
/** 合并并广播 Agent 文本或执行输出增量。 */
private emitDelta(itemType: string, params: ItemDeltaParams) {
const id = params.itemId;
this.textByItem.set(id, `${this.textByItem.get(id) || ""}${params.delta}`);
const pending = this.pendingDeltas.get(id);
if (pending) {
pending.delta += params.delta;
pending.itemType = itemType;
pending.params = params;
return;
}
this.pendingDeltas.set(id, {
delta: params.delta,
itemType,
params,
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
});
@@ -232,7 +242,7 @@ export class CodexAppClient {
if (!pending) return;
clearTimeout(pending.timer);
this.pendingDeltas.delete(id);
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", delta: pending.delta }, ...codexEventScope(pending.params) });
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
}
/** 自动回复 app-server 发起的授权或交互请求。 */
@@ -310,6 +320,13 @@ function normalizeItem(item: unknown) {
const value = item && typeof item === "object" ? { ...(item as JsonRecord) } : {};
if (value.type === "agentMessage") value.type = "agent_message";
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
if (value.type === "commandExecution") value.type = "command_execution";
if (value.type === "fileChange") value.type = "file_change";
if (value.type === "dynamicToolCall") value.type = "dynamic_tool_call";
if (value.type === "collabToolCall") value.type = "collab_tool_call";
if (value.type === "webSearch") value.type = "web_search";
if (value.type === "imageView") value.type = "image_view";
if (value.type === "contextCompaction") value.type = "context_compaction";
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
return value;
+150 -5
View File
@@ -36,14 +36,31 @@ export function threadMessages(thread: unknown): AgentHistoryMessage[] {
}
if (type === "mcpToolCall") {
const tool = String(field(item, "tool") || "工具调用");
const error = field(field(item, "error"), "message");
messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item });
const error = String(field(field(item, "error"), "message") || "");
const input = toolArguments(field(item, "arguments"));
messages.push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, input), detail: toolHistoryDetail(tool, item, input, error) });
}
if (type === "commandExecution") {
const command = String(field(item, "command") || "").trim();
if (command) messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } });
if (command) messages.push({ id, role: "tool", title: "执行命令", text: command, detail: commandDetail(item) });
}
if (type === "fileChange") messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item });
if (type === "fileChange") {
const changes = arrayValue(field(item, "changes"));
messages.push({ id, role: "tool", title: "修改文件", text: fileChangeSummary(changes), detail: { kind: "file", status: field(item, "status"), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })) } });
}
if (type === "reasoning") {
const text = readableText(field(item, "summary"));
if (text) messages.push({ id, role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status: "completed" } });
}
if (type === "plan") {
const text = String(field(item, "text") || "").trim();
if (text) messages.push({ id, role: "tool", title: "执行计划", text, detail: { kind: "plan", status: "completed" } });
}
if (type === "webSearch") messages.push({ id, role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status: "completed", rows: webSearchRows(item) } });
if (type === "imageView") messages.push({ id, role: "tool", title: "查看图片", text: String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: "completed" } });
if (type === "contextCompaction") messages.push({ id, role: "tool", title: "整理上下文", text: "已整理当前对话,继续处理任务", detail: { kind: "context", status: "completed" } });
if (type === "dynamicToolCall") messages.push({ id, role: "tool", title: "使用工具", text: "已完成工具操作", detail: { kind: "tool", status: field(item, "status") } });
if (type === "collabToolCall") messages.push({ id, role: "tool", title: "协作处理", text: "已完成协作任务", detail: { kind: "tool", status: field(item, "status") } });
});
});
return messages.filter((item) => item.text).slice(-120);
@@ -81,20 +98,148 @@ function stringOrNull(value: unknown) {
return typeof value === "string" && value.trim() ? value : null;
}
/** 生成命令执行的用户可读详情。 */
function commandDetail(item: unknown) {
const rows = [
textRow("工作目录", field(item, "cwd")),
textRow("退出状态", field(item, "exitCode")),
durationRow(field(item, "durationMs")),
].filter(Boolean);
return { kind: "command", status: field(item, "status"), rows, output: String(field(item, "aggregatedOutput") || "").trim() };
}
/** 生成 MCP 工具的用户可读详情。 */
function toolHistoryDetail(tool: string, item: unknown, input: unknown, error: string) {
return { kind: "tool", status: error ? "failed" : field(item, "status"), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
}
/** 生成 MCP 工具在对话中的结果摘要。 */
function toolHistorySummary(tool: string, input: unknown) {
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
if (tool === "canvas_list_projects") return "已读取画布列表";
if (tool === "canvas_get_state") return "已读取当前画布内容";
if (tool === "canvas_get_selection") return "已读取当前选中内容";
if (tool === "prompts_search") return `已搜索提示词“${String(field(input, "query") || "") || "全部"}`;
if (tool === "assets_list") return "已读取我的素材";
if (tool === "generation_get_status") return "已检查生成任务状态";
return `${toolName(tool)}已完成`;
}
/** 提取工具参数中适合普通用户查看的信息。 */
function toolInputRows(tool: string, input: unknown) {
if (tool === "site_navigate") return [textRow("目标页面", routeName(String(field(input, "path") || "/")))].filter(Boolean);
if (tool === "prompts_search") return [textRow("搜索内容", field(input, "query"))].filter(Boolean);
if (tool === "canvas_create_text_node") return [textRow("文本内容", field(input, "text"))].filter(Boolean);
if (tool === "canvas_apply_ops") return [textRow("操作数量", arrayValue(field(input, "ops")).length)].filter(Boolean);
return [];
}
/** 生成人类可读的文件变更摘要。 */
function fileChangeSummary(changes: unknown[]) {
if (!changes.length) return "已完成文件修改";
const names = changes.slice(0, 3).map((change) => String(field(change, "path") || "未知文件"));
if (changes.length === 1) return `${changeKind(field(changes[0], "kind"))}${names[0]}`;
return `涉及 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
}
/** 生成网页搜索摘要。 */
function webSearchSummary(item: unknown) {
const action = field(item, "action");
const type = String(field(action, "type") || "");
if (type === "openPage") return `打开网页:${String(field(action, "url") || "")}`;
if (type === "findInPage") return `在网页中查找“${String(field(action, "pattern") || "内容")}`;
return `搜索:${String(field(item, "query") || field(action, "query") || "相关资料")}`;
}
/** 生成网页搜索详情行。 */
function webSearchRows(item: unknown) {
const action = field(item, "action");
return [textRow("关键词", field(item, "query") || field(action, "query")), textRow("网页", field(action, "url"))].filter(Boolean);
}
/** 从 reasoning 结构中提取可读文本。 */
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(field(value, "text"));
}
/** 将历史工具参数解析为对象。 */
function toolArguments(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value) as unknown;
} catch {
return {};
}
}
/** 创建非空详情行。 */
function textRow(label: string, value: unknown) {
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
}
/** 创建命令耗时详情行。 */
function durationRow(value: unknown) {
const duration = Number(value || 0);
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)}` } : null;
}
/** 将文件变更类型转换为中文。 */
function changeKind(value: unknown) {
if (value === "add") return "新增";
if (value === "delete") return "删除";
return "修改";
}
/** 将站点路由转换为中文页面名称。 */
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;
}
/** 将 MCP 工具名称转换为聊天记录中的中文标题。 */
function toolName(name: string) {
if (name === "site_navigate") return "打开页面";
if (name === "canvas_list_projects") return "查看画布列表";
if (name === "canvas_apply_ops") return "画布操作";
if (name === "canvas_get_state") return "读取画布";
if (name === "canvas_get_selection") return "读取选区";
if (name === "canvas_export_snapshot") return "导出快照";
if (name === "canvas_create_node") return "创建节点";
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
if (name === "canvas_create_text_node") return "创建文本";
if (name === "canvas_create_text_nodes") return "批量创建文本";
if (name === "canvas_create_config_node") return "创建生成配置";
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
if (name === "canvas_create_generation_flow") return "创建生成流程";
if (name === "canvas_generate_text") return "生成文本";
if (name === "canvas_generate_image") return "生成图片";
if (name === "canvas_generate_video") return "生成视频";
if (name === "canvas_generate_audio") return "生成音频";
if (name === "canvas_update_node") return "更新节点";
if (name === "canvas_update_node_text") return "更新文本";
if (name === "canvas_move_nodes") return "移动节点";
if (name === "canvas_resize_node") return "调整节点尺寸";
if (name === "canvas_delete_nodes") return "删除节点";
if (name === "canvas_connect_nodes") return "连接节点";
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
return name;
if (name === "workbench_image_get_config") return "读取生图设置";
if (name === "workbench_image_generate") return "在生图工作台生成";
if (name === "workbench_video_get_config") return "读取视频设置";
if (name === "workbench_video_generate") return "在视频工作台生成";
if (name === "prompts_search") return "搜索提示词";
if (name === "assets_list") return "查看我的素材";
if (name === "assets_add") return "添加到我的素材";
if (name === "generation_get_status") return "查看生成状态";
return "工具操作";
}
+3
View File
@@ -79,6 +79,9 @@ type CodexNotificationSpec = {
"item/started": { threadId: string; turnId: string; item: CodexItem };
"item/completed": { threadId: string; turnId: string; item: CodexItem };
"item/agentMessage/delta": { threadId: string; turnId: string; itemId: string; delta: string };
"item/plan/delta": { threadId: string; turnId: string; itemId: string; delta: string };
"item/reasoning/summaryTextDelta": { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex: number };
"item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string };
"thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } };
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
};
+14 -1
View File
@@ -14,6 +14,7 @@ let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null;
let codexAppStart: Promise<CodexAppClient> | null = null;
let codexThreadId = "";
const unmaterializedThreadIds = new Set<string>();
export { summarizeCodexThread } from "./codex-history.js";
@@ -35,6 +36,7 @@ export async function startCodexThread(emit: AgentEmit, cwd?: string) {
const app = await getCodexApp(emit);
const thread = await app.startThread(cwd);
codexThreadId = String(field(thread, "id") || "");
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
return thread;
}
@@ -65,7 +67,14 @@ export async function listCodexThreads(emit: AgentEmit, options: { cwd: string;
/** 读取指定 Codex 线程及其聊天历史。 */
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
const thread = await loadCodexThread(emit, threadId, cwd, true);
let thread: unknown;
try {
thread = await loadCodexThread(emit, threadId, cwd, !unmaterializedThreadIds.has(threadId));
} catch (error) {
if (!/not materialized yet.*includeTurns/i.test(errorMessage(error))) throw error;
unmaterializedThreadIds.add(threadId);
thread = await loadCodexThread(emit, threadId, cwd, false);
}
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
}
@@ -79,6 +88,7 @@ export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
await app.archiveThread(threadId);
unmaterializedThreadIds.delete(threadId);
}
/** 判断线程异常是否允许自动新建线程后重试。 */
@@ -95,6 +105,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
const app = await getCodexApp(options.appEmit || emit);
let threadId = await ensureCodexThread(app, options, emit);
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
try {
await app.startTurn(threadId, prompt, files, options.onTurn);
} catch (error) {
@@ -103,6 +114,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
codexThreadId = "";
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
await app.startTurn(threadId, prompt, files, options.onTurn);
}
} catch (error) {
@@ -133,6 +145,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
if (!codexThreadId) {
const thread = await app.startThread(options.cwd);
codexThreadId = String(field(thread, "id") || "");
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
}
return codexThreadId;
}
+2 -1
View File
@@ -9,8 +9,9 @@ description: 当前版本已实现但仍需人工验证的变更项
- Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
- Agent 对话统计:用户消息应右对齐并使用透明无边框、无气泡背景的简洁样式,用户消息和 Codex 回复下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和 `working...`,任务运行期间不应闪退;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和“正在思考...,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
- Agent 过程时间线:Codex 运行时应在对话中依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动,同一操作应从「进行中」原位更新为「已完成」而不是生成重复卡片;命令详情只展示工作目录、耗时、退出状态和运行输出,文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON,刷新历史会话后仍保持相同展示。
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`OpenAI 模板应按自动或指定档位正确组装请求。
@@ -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)