diff --git a/CHANGELOG.md b/CHANGELOG.md index a5107cd..0aac6d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ + [优化] 精简网页与本地 Agent 的 HTTP 诊断日志,过滤轮询和流式重复事件并改用任务摘要展示。 + [优化] Agent 对话采用透明无气泡的简洁用户消息样式,输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。 + [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。 ++ [优化] Agent 对话新增思考摘要、计划、命令、搜索和文件变更时间线,工具卡片改为中文摘要与用户可读详情,不再展示原始 JSON。 ++ [优化] Agent 长时间等待时显示当前处理阶段、已等待时长和停止提示,并避免新建空会话反复记录历史读取失败。 + [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。 + [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。 + [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。 diff --git a/canvas-agent/README.md b/canvas-agent/README.md index b22989b..dacac2b 100644 --- a/canvas-agent/README.md +++ b/canvas-agent/README.md @@ -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。 diff --git a/canvas-agent/src/agent/codex-client.ts b/canvas-agent/src/agent/codex-client.ts index 12de4cd..c777235 100644 --- a/canvas-agent/src/agent/codex-client.ts +++ b/canvas-agent/src/agent/codex-client.ts @@ -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 }; +type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string }; +type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType }; 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; diff --git a/canvas-agent/src/agent/codex-history.ts b/canvas-agent/src/agent/codex-history.ts index 70d0486..0680797 100644 --- a/canvas-agent/src/agent/codex-history.ts +++ b/canvas-agent/src/agent/codex-history.ts @@ -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 "工具操作"; } diff --git a/canvas-agent/src/agent/codex-protocol.ts b/canvas-agent/src/agent/codex-protocol.ts index 639e129..1919937 100644 --- a/canvas-agent/src/agent/codex-protocol.ts +++ b/canvas-agent/src/agent/codex-protocol.ts @@ -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 }; }; diff --git a/canvas-agent/src/agent/codex.ts b/canvas-agent/src/agent/codex.ts index 01af3dc..0a00267 100644 --- a/canvas-agent/src/agent/codex.ts +++ b/canvas-agent/src/agent/codex.ts @@ -14,6 +14,7 @@ let codexQueue: Promise = Promise.resolve(); let codexApp: CodexAppClient | null = null; let codexAppStart: Promise | null = null; let codexThreadId = ""; +const unmaterializedThreadIds = new Set(); 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; } diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index f52d9df..de11389 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -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 模板应按自动或指定档位正确组装请求。 diff --git a/web/src/components/canvas/canvas-agent-chat-ui.tsx b/web/src/components/canvas/canvas-agent-chat-ui.tsx index 1a8bd3c..b7de3cf 100644 --- a/web/src/components/canvas/canvas-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-agent-chat-ui.tsx @@ -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 (
-
-
- -
- - - -
-
- 确认工具调用 - - 等待确认 - - {detail ? 详情 : null} -
-
- {summary} -
+
+
+ { if (!view) event.preventDefault(); }}> + +
+
+ 等待确认 + {view ? : null}
+
{summary}
- {detail ? : null} + {view ? : null}
{onReject || onApprove ? ( -
- -
@@ -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 ( -
- -
- - {state.icon} - -
-
- {title} - - {state.label} - - {detail ? 详情 : null} -
-
- {text} -
+
+ { if (!view) event.preventDefault(); }}> + {toolIcon(kind, state.icon)} +
+
+ {title} + + {state.label} + + {view ? : null} +
+
+ {text}
- {detail ? : null} + {view ? : null}
); } -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 ( -
+
-
-
- {WORKING_TEXT.slice(0, Math.min(length, WORKING_TEXT.length))} +
+
+ + {text} + {elapsed >= 5 ? {waitingTime(elapsed)} : null}
+ {elapsed >= 30 ?
响应时间较长,但任务仍在运行。可以继续等待,或点击输入框右侧的停止按钮结束本轮。
: null}
); } +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({ 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 ( -
-            {JSON.stringify(detail, null, 2)}
-        
+
+ {detail.rows?.length ? ( +
+ {detail.rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ ) : null} + {detail.files?.length ? ( +
+
涉及文件
+ {detail.files.map((file) => ( +
+ + {file.path} + {file.action ? {file.action} : null} +
+ ))} +
+ ) : null} + {detail.output ? ( +
+
{detail.status === "failed" || detail.status === "error" ? "错误信息" : "运行输出"}
+
{detail.output}
+
+ ) : null} +
); } @@ -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: , 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: , 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: , 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: , isError: false }; - return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: , isError: false }; + const status = String(objectField(detail, "status") || "").toLowerCase(); + if (status === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", icon: , isError: false }; + if (["declined", "rejected", "cancelled", "canceled"].includes(status) || /拒绝|取消/.test(raw)) return { label: "已取消", color: "#dc2626", icon: , isError: true }; + if (["failed", "error"].includes(status) || /失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", icon: , isError: true }; + if (["inprogress", "in_progress", "running", "started", "pending"].includes(status)) return { label: "进行中", color: "#d97706", icon: , isError: false }; + if (["completed", "succeeded", "success"].includes(status) || /完成|成功/.test(raw)) return { label: "已完成", color: "#16a34a", icon: , isError: false }; + return { label: "已记录", color: "#2563eb", icon: , isError: false }; +} + +function toolIcon(kind: string | undefined, fallback: ReactNode) { + if (kind === "reasoning") return ; + if (kind === "command") return ; + if (kind === "search") return ; + if (kind === "file") return ; + if (kind === "plan") return ; + return fallback; +} + +function userDetail(value: unknown): UserDetail | null { + if (!value || typeof value !== "object") return null; + const detail = value as Record; + const rows = Array.isArray(detail.rows) + ? detail.rows.flatMap((row) => { + if (!row || typeof row !== "object") return []; + const label = String((row as Record).label || ""); + const value = String((row as Record).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).path || ""); + return path ? [{ path, action: String((file as Record).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) { diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index a8eea19..3501a2f 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -42,7 +42,30 @@ type AgentEventPayload = { usage?: Record; 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(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 ? ( ) : null} - {(sending || waiting) && !streaming && !pendingTool ? : null} + {(sending || waiting) && !streaming && !pendingTool ? : null}
{showScrollToBottom ? ( @@ -1191,10 +1258,119 @@ function formatAgentEvent(event: AgentEventPayload): Omit | 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 | 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) : {}; + 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(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)