diff --git a/CHANGELOG.md b/CHANGELOG.md index 0aac6d3..5a4f6f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ + [优化] Agent 对话采用透明无气泡的简洁用户消息样式,输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。 + [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。 + [优化] Agent 对话新增思考摘要、计划、命令、搜索和文件变更时间线,工具卡片改为中文摘要与用户可读详情,不再展示原始 JSON。 ++ [新增] Agent 对话支持 Codex 结构化任务进度,逐项展示待处理、进行中和已完成状态并实时原位更新。 + [优化] Agent 长时间等待时显示当前处理阶段、已等待时长和停止提示,并避免新建空会话反复记录历史读取失败。 + [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。 + [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。 diff --git a/canvas-agent/README.md b/canvas-agent/README.md index dacac2b..32527e4 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` 等结构化事件;Canvas Agent 会合并短时间内的回复、思考摘要和命令输出增量,网页使用同一条消息持续更新,并把计划、搜索、文件修改与工具操作整理为中文过程时间线。 +侧边栏会展示 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 c777235..ed51d7a 100644 --- a/canvas-agent/src/agent/codex-client.ts +++ b/canvas-agent/src/agent/codex-client.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import { VERSION } from "../config.js"; import { logger } from "../utils/logger.js"; import { field, type JsonRecord } from "../utils/value.js"; -import type { CodexNotificationParams, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js"; +import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js"; import type { AgentEmit } from "./types.js"; type AgentEvent = JsonRecord & { type: string; usage?: unknown }; @@ -30,6 +30,7 @@ export class CodexAppClient { private activeTurns = new Map(); private completedTurns = new Map(); private pendingDeltas = new Map(); + private plansByTurn = new Map(); /** 保存 app-server 子进程和事件出口。 */ private constructor(private child: ChildProcess, private emit: AgentEmit) {} @@ -89,12 +90,24 @@ export class CodexAppClient { return this.request("thread/archive", { threadId }); } + /** 返回指定线程在当前进程中收到的最新任务计划。 */ + planUpdates(threadId: string) { + return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId); + } + + /** 清理已归档线程的任务计划缓存。 */ + clearPlanUpdates(threadId: string) { + this.plansByTurn.forEach((item, turnId) => { + if (item.threadId === threadId) this.plansByTurn.delete(turnId); + }); + } + /** 启动一个 Codex turn 并等待完成通知。 */ async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) { + this.currentThreadId = threadId; const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" }); const turnId = turn.id; if (!turnId) throw new Error("Codex app-server 没有返回 turn id"); - this.currentThreadId = threadId; this.currentTurnId = turnId; onTurn?.(turnId); const completed = this.completedTurns.get(turnId); @@ -174,6 +187,7 @@ export class CodexAppClient { /** 转换并广播 app-server 通知。 */ private handleNotification(method: string, params: JsonRecord) { + if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated")) params = { ...params, threadId: this.currentThreadId }; 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}`); @@ -182,6 +196,12 @@ export class CodexAppClient { 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 === "turn/plan/updated") { + const value = params as unknown as CodexNotificationParams<"turn/plan/updated">; + const update: CodexPlanUpdate = { ...value, threadId: value.threadId || "" }; + if (update.threadId && update.turnId) this.plansByTurn.set(update.turnId, update); + params = update as unknown as JsonRecord; + } 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) }); @@ -197,6 +217,12 @@ export class CodexAppClient { if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText; if (id) this.textByItem.delete(id); } + if (event.type === "turn.completed") { + const turn = field(params, "turn"); + const turnId = String(field(turn, "id") || field(params, "turnId") || ""); + const plan = this.plansByTurn.get(turnId); + if (plan) this.plansByTurn.set(turnId, { ...plan, turnStatus: String(field(turn, "status") || "completed") }); + } if (event.type === "turn.completed") event.usage = this.lastUsage; this.emit("agent_event", { agent: "codex", ...event }); if (event.type === "turn.completed") { @@ -301,7 +327,8 @@ function normalizeCodexNotification(method: string, params: JsonRecord): AgentEv const scope = codexEventScope(params); if (method === "thread/started") return { type: "thread.started", ...scope }; if (method === "turn/started") return { type: "turn.started", ...scope }; - if (method === "turn/completed") return { type: "turn.completed", usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope }; + if (method === "turn/completed") return { type: "turn.completed", status: field(field(params, "turn"), "status"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope }; + if (method === "turn/plan/updated") return { type: "plan.updated", explanation: field(params, "explanation"), plan: field(params, "plan"), ...scope }; if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope }; if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope }; if (method === "error") return { type: "error", message: field(field(params, "error"), "message"), ...scope }; diff --git a/canvas-agent/src/agent/codex-history.ts b/canvas-agent/src/agent/codex-history.ts index 0680797..423e2f6 100644 --- a/canvas-agent/src/agent/codex-history.ts +++ b/canvas-agent/src/agent/codex-history.ts @@ -1,4 +1,5 @@ import { field } from "../utils/value.js"; +import type { CodexPlanUpdate } from "./codex-protocol.js"; type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string }; @@ -19,16 +20,24 @@ export function summarizeCodexThread(thread: unknown) { } /** 将 Codex turn items 转换为网页聊天历史。 */ -export function threadMessages(thread: unknown): AgentHistoryMessage[] { +export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = []): AgentHistoryMessage[] { const turns = arrayValue(field(thread, "turns")); + const plansByTurn = new Map(planUpdates.map((item) => [item.turnId, item])); const messages: AgentHistoryMessage[] = []; turns.forEach((turn, turnIndex) => { + const turnId = String(field(turn, "id") || turnIndex); + const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId: "", turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") }); + let planAdded = false; arrayValue(field(turn, "items")).forEach((item, itemIndex) => { const type = String(field(item, "type") || ""); const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`); if (type === "userMessage") { const text = displayUserText(userInputText(field(item, "content"))); if (text) messages.push({ id, role: "user", text }); + if (planMessage && !planAdded) { + messages.push(planMessage); + planAdded = true; + } } if (type === "agentMessage") { const text = String(field(item, "text") || "").trim(); @@ -62,10 +71,36 @@ export function threadMessages(thread: unknown): AgentHistoryMessage[] { 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") } }); }); + if (planMessage && !planAdded) messages.push(planMessage); }); return messages.filter((item) => item.text).slice(-120); } +/** 将结构化任务计划转换为聊天进度卡片。 */ +function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | null { + const tasks = arrayValue(update.plan).flatMap((item) => { + const step = String(field(item, "step") || "").trim(); + return step ? [{ step, status: String(field(item, "status") || "pending") }] : []; + }); + if (!tasks.length) return null; + const completed = tasks.filter((item) => item.status === "completed").length; + return { + id: `plan-${update.turnId}`, + role: "tool", + title: "任务进度", + text: `已完成 ${completed}/${tasks.length} 项`, + detail: { kind: "todo", status: planStatus(tasks, update.turnStatus), tasks, explanation: update.explanation || "" }, + }; +} + +/** 根据步骤和 turn 状态生成任务卡片状态。 */ +function planStatus(tasks: Array<{ status: string }>, turnStatus?: string) { + if (turnStatus === "failed") return "failed"; + if (turnStatus === "interrupted") return "interrupted"; + if (tasks.every((item) => item.status === "completed")) return "completed"; + return turnStatus === "completed" ? "finished" : "inProgress"; +} + /** 提取用户输入条目中的文本与附件占位信息。 */ function userInputText(content: unknown) { return arrayValue(content) diff --git a/canvas-agent/src/agent/codex-protocol.ts b/canvas-agent/src/agent/codex-protocol.ts index 1919937..58cab06 100644 --- a/canvas-agent/src/agent/codex-protocol.ts +++ b/canvas-agent/src/agent/codex-protocol.ts @@ -4,6 +4,8 @@ export type CodexThread = JsonRecord & { id: string; cwd: string; turns?: CodexT export type CodexTurn = JsonRecord & { id: string; error?: CodexTurnError | null; durationMs?: number | null }; export type CodexTurnError = JsonRecord & { message: string }; export type CodexItem = JsonRecord & { id: string; type: string; text?: string }; +export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" }; +export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string }; export type CodexTurnInput = | { type: "text"; text: string; text_elements: [] } @@ -74,8 +76,9 @@ type TokenUsageBreakdown = { type CodexNotificationSpec = { "thread/started": { thread: CodexThread }; - "turn/started": { threadId: string; turn: CodexTurn }; - "turn/completed": { threadId: string; turn: CodexTurn }; + "turn/started": { threadId?: string; turn: CodexTurn }; + "turn/completed": { threadId?: string; turn: CodexTurn }; + "turn/plan/updated": { threadId?: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[] }; "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 }; diff --git a/canvas-agent/src/agent/codex.ts b/canvas-agent/src/agent/codex.ts index 0a00267..d97c312 100644 --- a/canvas-agent/src/agent/codex.ts +++ b/canvas-agent/src/agent/codex.ts @@ -47,7 +47,7 @@ export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: const thread = await app.resumeThread(threadId, cwd); assertThreadWorkspace(thread, cwd); codexThreadId = String(field(thread, "id") || threadId); - return { thread, messages: threadMessages(thread) }; + return { thread, messages: threadMessages(thread, app.planUpdates(threadId)) }; } /** 查询当前工作空间中的 Codex 线程。 */ @@ -67,6 +67,7 @@ export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; /** 读取指定 Codex 线程及其聊天历史。 */ export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { + const app = await getCodexApp(emit); let thread: unknown; try { thread = await loadCodexThread(emit, threadId, cwd, !unmaterializedThreadIds.has(threadId)); @@ -75,7 +76,7 @@ export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: s unmaterializedThreadIds.add(threadId); thread = await loadCodexThread(emit, threadId, cwd, false); } - return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) }; + return { thread: summarizeCodexThread(thread), messages: threadMessages(thread, app.planUpdates(threadId)) }; } /** 确认指定 Codex 线程属于当前工作空间。 */ @@ -88,6 +89,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); + app.clearPlanUpdates(threadId); unmaterializedThreadIds.delete(threadId); } diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index de11389..c307764 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -11,7 +11,7 @@ description: 当前版本已实现但仍需人工验证的变更项 - Agent 对话统计:用户消息应右对齐并使用透明无边框、无气泡背景的简洁样式,用户消息和 Codex 回复下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。 - Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现 GPT 图标和“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。新建尚未发送首条消息的空会话不应反复出现历史读取失败。 - Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。 -- Agent 过程时间线:Codex 运行时应在对话中依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动,同一操作应从「进行中」原位更新为「已完成」而不是生成重复卡片;命令详情只展示工作目录、耗时、退出状态和运行输出,文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON,刷新历史会话后仍保持相同展示。 +- 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 b7de3cf..67c391b 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, Brain, CheckCircle2, ChevronDown, CircleAlert, FilePenLine, FileText, ImagePlus, ListChecks, LoaderCircle, Search, Square, TerminalSquare, UserRound, Wrench, X, XCircle } from "lucide-react"; +import { ArrowUp, Brain, CheckCircle2, ChevronDown, Circle, 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"; @@ -98,6 +98,8 @@ 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 plan = planDetail(detail); + if (plan) return ; const state = toolCardState(title, text, detail); const view = userDetail(detail); const kind = String(objectField(detail, "kind") || ""); @@ -123,6 +125,34 @@ export function AgentToolCard({ title, text, detail, theme }: { title: string; t ); } +function AgentPlanCard({ title, plan, theme }: { title: string; plan: PlanDetail; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { + const completed = plan.tasks.filter((item) => item.status === "completed").length; + const state = planCardState(plan, completed); + return ( +
+
+ + {title} + {state.label} + {completed}/{plan.tasks.length} +
+ {plan.explanation ?
{plan.explanation}
: null} +
+ {plan.tasks.map((item, index) => { + const task = planTaskState(item.status, theme.node.muted); + return ( +
+ {task.icon} + {item.step} + {task.label} +
+ ); + })} +
+
+ ); +} + export function AgentWorkingMessage({ text, activityKey, theme }: { text: string; activityKey: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes] }) { const [elapsed, setElapsed] = useState(0); useEffect(() => { @@ -263,6 +293,8 @@ export function AgentPanelTabs({ value, items, theme, right, o ); } +type PlanTask = { step: string; status: string }; +type PlanDetail = { status: string; tasks: PlanTask[]; explanation?: string }; 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] }) { @@ -348,6 +380,33 @@ function toolIcon(kind: string | undefined, fallback: ReactNode) { return fallback; } +function planCardState(plan: PlanDetail, completed: number) { + if (plan.status === "failed") return { label: "执行失败", color: "#dc2626" }; + if (["interrupted", "cancelled", "canceled"].includes(plan.status)) return { label: "已停止", color: "#d97706" }; + if (completed === plan.tasks.length) return { label: "已完成", color: "#16a34a" }; + if (plan.status === "finished") return { label: "已结束", color: "#2563eb" }; + return { label: "进行中", color: "#d97706" }; +} + +function planTaskState(status: string, muted: string) { + if (status === "completed") return { label: "已完成", color: "#16a34a", icon: }; + if (status === "inProgress") return { label: "进行中", color: "#d97706", icon: }; + return { label: "待处理", color: muted, icon: }; +} + +function planDetail(value: unknown): PlanDetail | null { + if (!value || typeof value !== "object" || objectField(value, "kind") !== "todo") return null; + const tasks = Array.isArray(objectField(value, "tasks")) + ? (objectField(value, "tasks") as unknown[]).flatMap((item) => { + const step = String(objectField(item, "step") || "").trim(); + return step ? [{ step, status: String(objectField(item, "status") || "pending") }] : []; + }) + : []; + if (!tasks.length) return null; + const explanation = String(objectField(value, "explanation") || "").trim(); + return { status: String(objectField(value, "status") || "inProgress"), tasks, ...(explanation ? { explanation } : {}) }; +} + function userDetail(value: unknown): UserDetail | null { if (!value || typeof value !== "object") return null; const detail = value as Record; diff --git a/web/src/components/canvas/canvas-local-agent-panel.tsx b/web/src/components/canvas/canvas-local-agent-panel.tsx index 3501a2f..c1550a6 100644 --- a/web/src/components/canvas/canvas-local-agent-panel.tsx +++ b/web/src/components/canvas/canvas-local-agent-panel.tsx @@ -39,6 +39,9 @@ type AgentEventPayload = { item?: AgentEventItem; error?: { message?: string }; message?: string; + status?: string; + explanation?: unknown; + plan?: unknown; usage?: Record; duration_ms?: number; }; @@ -65,7 +68,7 @@ type AgentEventItem = { 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 AgentUserDetail = { kind: string; status: string; rows?: Array<{ label: string; value: string }>; output?: string; files?: Array<{ path: string; action?: string }>; tasks?: Array<{ step: string; status: string }>; explanation?: 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 }; @@ -651,6 +654,16 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, text, detail: { ...activityDetail(message.detail, activityKind(item.type), "inProgress") } } : message)) }); }; + const finishPlanActivity = (turnId: string, status?: string) => { + const id = `plan-${turnId}`; + const currentMessages = useAgentStore.getState().messages; + const index = currentMessages.findIndex((message) => message.id === id); + if (index < 0) return; + const current = currentMessages[index]; + const detail = activityDetail(current.detail, "todo", turnPlanStatus(current.detail, status)); + setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, detail } : message)) }); + }; + const handleAgentEvent = (event: AgentEventPayload) => { if (event.type === "usage.updated") setAgentState({ tokenUsage: eventUsage(event) }); const log = formatAgentEventLog(event); @@ -664,6 +677,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb appendActivityDelta(event.item); return; } + if (event.type === "plan.updated" && event.turn_id) { + const plan = formatAgentPlan(event); + if (plan) upsertActivityMessage({ ...plan, id: `plan-${event.turn_id}` }); + 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); @@ -678,7 +696,10 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb 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)) }); + if (event.type === "turn.completed") { + if (event.turn_id) finishPlanActivity(event.turn_id, event.status); + setAgentState({ messages: useAgentStore.getState().messages.map((message) => (message.streamId ? { ...message, streamId: undefined } : message)) }); + } const item = formatAgentEvent(event); if (item) addMessage(item); }; @@ -1296,6 +1317,33 @@ function formatAgentActivity(event: AgentEventPayload): Omit | null { + const tasks = planTasks(event.plan); + if (!tasks.length) return null; + const completed = tasks.filter((item) => item.status === "completed").length; + return { + role: "tool", + title: "任务进度", + text: `已完成 ${completed}/${tasks.length} 项`, + detail: { kind: "todo", status: completed === tasks.length ? "completed" : "inProgress", tasks, explanation: stringText(event.explanation) }, + }; +} + +function planTasks(value: unknown) { + return (Array.isArray(value) ? value : []).flatMap((item) => { + const step = stringText(objectField(item, "step")).trim(); + return step ? [{ step, status: stringText(objectField(item, "status")) || "pending" }] : []; + }); +} + +function turnPlanStatus(detail: unknown, turnStatus?: string) { + const tasks = planTasks(objectField(detail, "tasks")); + if (turnStatus === "failed") return "failed"; + if (turnStatus === "interrupted") return "interrupted"; + if (tasks.length && tasks.every((item) => item.status === "completed")) return "completed"; + return turnStatus === "completed" ? "finished" : "inProgress"; +} + 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" } }; @@ -1315,7 +1363,7 @@ function activityKind(type?: string) { 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 }; + return { kind, status, rows: current.rows, output: current.output, files: current.files, tasks: current.tasks, explanation: current.explanation }; } function commandActivityDetail(item: AgentEventItem, status: string): AgentUserDetail { @@ -1403,6 +1451,10 @@ function formatAgentEventLog(event: AgentEventPayload) { const item = event.item; if (event.type === "thread.started") return { title: "创建会话", text: shortId(event.thread_id) }; if (event.type === "turn.started") return { title: "开始处理", text: shortId(event.turn_id) }; + if (event.type === "plan.updated") { + const tasks = planTasks(event.plan); + return { title: "更新任务进度", text: `已完成 ${tasks.filter((item) => item.status === "completed").length}/${tasks.length} 项` }; + } if (event.type === "turn.completed") return { title: "处理完成", text: turnSummary(event) }; if (event.type === "turn.failed" || event.type === "error") return { title: "处理失败", text: event.message || event.error?.message || "未知错误" }; if (event.type === "item.started" && isMcpToolItem(item)) return { title: "调用工具", text: toolName(String(item?.tool || "")) }; @@ -1537,7 +1589,8 @@ function routeName(path: string) { function workingActivity(item?: AgentChatItem) { const status = String(objectField(item?.detail, "status") || ""); - const key = `${item?.id || "waiting"}-${status}`; + const output = stringText(objectField(item?.detail, "output")); + const key = `${item?.id || "waiting"}-${status}-${item?.text || ""}-${output.length}`; 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 正在整理结果..." };