From 97e9d964a4b78bb645b92ac508286a64fcdb283d Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 18 May 2026 14:42:38 +0800 Subject: [PATCH 1/6] fix(agent): omit null action_type --- .../plan.md | 14 +++ .../spec.md | 24 +++++ .../tasks.md | 6 ++ .../agentRuntimePresenter/messageStore.ts | 17 ++- .../messageStore.test.ts | 100 ++++++++++++++++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 docs/issues/assistant-action-type-null-renderer-crash/plan.md create mode 100644 docs/issues/assistant-action-type-null-renderer-crash/spec.md create mode 100644 docs/issues/assistant-action-type-null-renderer-crash/tasks.md diff --git a/docs/issues/assistant-action-type-null-renderer-crash/plan.md b/docs/issues/assistant-action-type-null-renderer-crash/plan.md new file mode 100644 index 000000000..d564ab21a --- /dev/null +++ b/docs/issues/assistant-action-type-null-renderer-crash/plan.md @@ -0,0 +1,14 @@ +# Assistant `action_type` Null Renderer Crash Plan + +## Approach + +- Normalize persisted action types inside `DeepChatMessageStore` while converting `DeepChatAssistantBlockRow` rows into `AssistantMessageBlock` objects. +- Return only `tool_call_permission`, `question_request`, or `rate_limit`; treat `null` and unknown strings as absent. +- Build the hydrated block with conditional spreading so omitted action types are not serialized as `null` or `undefined`. +- Keep `AssistantMessageBlockSchema` unchanged to preserve route/event validation. + +## Tests + +- Extend `messageStore` tests to materialize assistant rows containing nullable content/tool blocks and assert the resulting JSON omits `action_type`. +- Add a mixed persisted-block regression where an unknown value is omitted and a valid action block is retained. +- Pass hydrated blocks through `cloneBlocksForRenderer()` to verify the renderer snapshot contract accepts them. diff --git a/docs/issues/assistant-action-type-null-renderer-crash/spec.md b/docs/issues/assistant-action-type-null-renderer-crash/spec.md new file mode 100644 index 000000000..b090c10af --- /dev/null +++ b/docs/issues/assistant-action-type-null-renderer-crash/spec.md @@ -0,0 +1,24 @@ +# Assistant `action_type` Null Renderer Crash + +## Problem + +Resuming a tool interaction can reload assistant message blocks from the normalized SQLite table. That table stores `action_type` as nullable text, and hydrated non-action blocks currently carry `action_type: null` into runtime message content. Renderer flushes then validate the block array with `AssistantMessageBlockSchema`, which allows an omitted `action_type` but rejects `null`, causing stream finalization and tool-interaction routes to fail. + +## Goals + +- Keep nullable `action_type` as a storage detail only. +- Materialize assistant blocks with `action_type` omitted unless the persisted value is a supported renderer action type. +- Preserve the strict renderer/event contract so invalid message shapes are still rejected before publication. + +## Non-Goals + +- No schema migration for `deepchat_assistant_blocks`. +- No IPC, route, renderer component, or public type changes. +- No behavior change for valid `tool_call_permission`, `question_request`, or `rate_limit` blocks. + +## Acceptance Criteria + +- Hydrated content/tool blocks with `action_type = NULL` do not include an `action_type` property. +- Hydrated rows with unknown `action_type` values omit the property instead of crashing renderer publication. +- Valid persisted action blocks keep their action type. +- Regression tests cover hydration and renderer cloning for the affected shapes. diff --git a/docs/issues/assistant-action-type-null-renderer-crash/tasks.md b/docs/issues/assistant-action-type-null-renderer-crash/tasks.md new file mode 100644 index 000000000..fd0ff40cc --- /dev/null +++ b/docs/issues/assistant-action-type-null-renderer-crash/tasks.md @@ -0,0 +1,6 @@ +# Assistant `action_type` Null Renderer Crash Tasks + +- [x] Document the issue spec, implementation plan, and task list. +- [x] Normalize persisted `action_type` values during assistant block hydration. +- [x] Add regression tests for nullable and unknown persisted action types. +- [x] Run targeted tests and final repository gates. diff --git a/src/main/presenter/agentRuntimePresenter/messageStore.ts b/src/main/presenter/agentRuntimePresenter/messageStore.ts index 8169a1bf6..383c4bea5 100644 --- a/src/main/presenter/agentRuntimePresenter/messageStore.ts +++ b/src/main/presenter/agentRuntimePresenter/messageStore.ts @@ -65,6 +65,20 @@ type StructuredMessageMaps = { assistantRows: Map } +function normalizePersistedActionType( + actionType: string | null +): AssistantMessageBlock['action_type'] | undefined { + if ( + actionType === 'tool_call_permission' || + actionType === 'question_request' || + actionType === 'rate_limit' + ) { + return actionType + } + + return undefined +} + function extractSearchableMessageContent(rawContent: string): string { try { const parsed = JSON.parse(rawContent) as @@ -761,6 +775,7 @@ export class DeepChatMessageStore { : undefined const imageData = extra.imageData?.trim() + const actionType = normalizePersistedActionType(row.action_type) return { id: extra.id, @@ -778,7 +793,7 @@ export class DeepChatMessageStore { : undefined, tool_call: toolCall as AssistantMessageBlock['tool_call'], extra: extra.extra, - action_type: row.action_type as AssistantMessageBlock['action_type'] + ...(actionType ? { action_type: actionType } : {}) } } diff --git a/test/main/presenter/agentRuntimePresenter/messageStore.test.ts b/test/main/presenter/agentRuntimePresenter/messageStore.test.ts index d926f8593..a44f3ca1e 100644 --- a/test/main/presenter/agentRuntimePresenter/messageStore.test.ts +++ b/test/main/presenter/agentRuntimePresenter/messageStore.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { DeepChatMessageStore } from '@/presenter/agentRuntimePresenter/messageStore' +import { cloneBlocksForRenderer } from '@/presenter/agentRuntimePresenter/echo' import logger from '@shared/logger' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-msg-id') })) @@ -88,6 +89,27 @@ function createMockSqlitePresenter() { } as any } +function createAssistantBlockRow(overrides: Record = {}) { + return { + message_id: 'm1', + block_index: 0, + block_type: 'content', + status: 'success', + text_content: null, + tool_call_id: null, + tool_name: null, + tool_params: null, + tool_response: null, + action_type: null, + image_mime_type: null, + reasoning_start_at: null, + reasoning_end_at: null, + extra_json: '{}', + updated_at: 1000, + ...overrides + } +} + describe('DeepChatMessageStore', () => { let sqlitePresenter: ReturnType let store: DeepChatMessageStore @@ -366,6 +388,84 @@ describe('DeepChatMessageStore', () => { sqlitePresenter.deepchatMessagesTable.get.mockReturnValue(undefined) expect(store.getMessage('missing')).toBeNull() }) + + it('omits nullable action_type values when materializing assistant blocks', () => { + sqlitePresenter.deepchatMessagesTable.get.mockReturnValue({ + id: 'm1', + session_id: 's1', + order_seq: 1, + role: 'assistant', + content: '[]', + status: 'sent', + is_context_edge: 0, + metadata: '{}', + created_at: 1000, + updated_at: 1000 + }) + sqlitePresenter.deepchatAssistantBlocksTable.listByMessageIds.mockReturnValue([ + createAssistantBlockRow({ + block_index: 0, + block_type: 'content', + text_content: 'hello', + action_type: null + }), + createAssistantBlockRow({ + block_index: 1, + block_type: 'tool_call', + tool_call_id: 'tc1', + tool_name: 'read_file', + tool_params: '{}', + tool_response: 'ok', + action_type: null + }) + ]) + + const msg = store.getMessage('m1') + const blocks = JSON.parse(msg!.content) + + expect(blocks[0]).not.toHaveProperty('action_type') + expect(blocks[1]).not.toHaveProperty('action_type') + expect(() => cloneBlocksForRenderer(blocks)).not.toThrow() + }) + + it('keeps only valid persisted action_type values on assistant blocks', () => { + sqlitePresenter.deepchatMessagesTable.get.mockReturnValue({ + id: 'm1', + session_id: 's1', + order_seq: 1, + role: 'assistant', + content: '[]', + status: 'sent', + is_context_edge: 0, + metadata: '{}', + created_at: 1000, + updated_at: 1000 + }) + sqlitePresenter.deepchatAssistantBlocksTable.listByMessageIds.mockReturnValue([ + createAssistantBlockRow({ + block_index: 0, + block_type: 'content', + text_content: 'before action', + action_type: 'legacy_bad_value' + }), + createAssistantBlockRow({ + block_index: 1, + block_type: 'action', + status: 'pending', + text_content: 'Need permission', + tool_call_id: 'tc1', + tool_name: 'write_file', + action_type: 'tool_call_permission' + }) + ]) + + const msg = store.getMessage('m1') + const blocks = JSON.parse(msg!.content) + + expect(blocks[0]).not.toHaveProperty('action_type') + expect(blocks[1].action_type).toBe('tool_call_permission') + expect(() => cloneBlocksForRenderer(blocks)).not.toThrow() + }) }) describe('getNextOrderSeq', () => { From d3b46224e4a4d3e27d4ff1ca1a771c1ad748f6cd Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 18 May 2026 14:49:33 +0800 Subject: [PATCH 2/6] feat(agent): add progress todo tool --- docs/features/agent-progress-todo/plan.md | 175 ++++++++++++++++ docs/features/agent-progress-todo/spec.md | 194 ++++++++++++++++++ docs/features/agent-progress-todo/tasks.md | 79 +++++++ .../agentRuntimePresenter/dispatch.ts | 39 ++++ .../toolPresenter/agentTools/agentPlanTool.ts | 158 ++++++++++++++ .../agentTools/agentToolManager.ts | 19 ++ .../toolPresenter/agentTools/index.ts | 1 + src/main/presenter/toolPresenter/index.ts | 29 ++- src/renderer/api/ChatClient.ts | 8 +- .../components/chat/AgentProgressFloat.vue | 118 +++++++++++ .../src/components/chat/messageListItems.ts | 6 + .../components/message/MessageBlockPlan.vue | 120 +++++++++-- .../message/MessageItemAssistant.vue | 6 +- src/renderer/src/pages/ChatPage.vue | 31 +++ src/renderer/src/stores/ui/agentPlan.ts | 56 +++++ src/shared/chat.d.ts | 6 + src/shared/contracts/common.ts | 11 +- src/shared/contracts/events.ts | 2 + src/shared/contracts/events/chat.events.ts | 18 ++ src/shared/types/agent-interface.d.ts | 7 + src/shared/types/agent-plan.ts | 31 +++ src/shared/types/core/chat.ts | 11 +- .../types/presenters/tool.presenter.d.ts | 19 +- .../agentRuntimePresenter/dispatch.test.ts | 88 ++++++++ .../agentTools/agentPlanTool.test.ts | 132 ++++++++++++ .../toolPresenter/toolPresenter.test.ts | 44 ++++ test/main/routes/contracts.test.ts | 1 + test/renderer/api/clients.test.ts | 2 + .../components/AgentProgressFloat.test.ts | 68 ++++++ test/renderer/components/ChatPage.test.ts | 22 +- test/renderer/components/McpIndicator.test.ts | 34 ++- .../message/MessageBlockBasics.test.ts | 17 +- test/renderer/stores/agentPlanStore.test.ts | 43 ++++ 33 files changed, 1561 insertions(+), 34 deletions(-) create mode 100644 docs/features/agent-progress-todo/plan.md create mode 100644 docs/features/agent-progress-todo/spec.md create mode 100644 docs/features/agent-progress-todo/tasks.md create mode 100644 src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts create mode 100644 src/renderer/src/components/chat/AgentProgressFloat.vue create mode 100644 src/renderer/src/stores/ui/agentPlan.ts create mode 100644 src/shared/types/agent-plan.ts create mode 100644 test/main/presenter/toolPresenter/agentTools/agentPlanTool.test.ts create mode 100644 test/renderer/components/AgentProgressFloat.test.ts create mode 100644 test/renderer/stores/agentPlanStore.test.ts diff --git a/docs/features/agent-progress-todo/plan.md b/docs/features/agent-progress-todo/plan.md new file mode 100644 index 000000000..bf4895e43 --- /dev/null +++ b/docs/features/agent-progress-todo/plan.md @@ -0,0 +1,175 @@ +# Agent Progress Todo 实施计划 + +## 当前基线 + +- Agent 工具定义由 `AgentToolManager.getAllToolDefinitions()` 汇总,`ToolPresenter` 根据 `disabledAgentTools` 过滤内置工具。 +- 高级工具面板 `McpIndicator.vue` 已按 `tool.server.name` 对内置工具分组,YoBrowser 通过 `yobrowser` 分区显示和开关。 +- DeepChat agent 执行链路为 `agentRuntimePresenter -> processStream -> dispatch.executeTools -> ToolPresenter.callTool -> AgentToolManager.callTool`。 +- Renderer 实时更新走 `chat.stream.updated` typed event,payload 是完整 assistant blocks snapshot。 +- 当前 shared `@shared/chat` 和 renderer display type 已支持 `plan` block,但 `@shared/types/agent-interface.d.ts` 与 `AssistantMessageBlockSchema` 还未完整纳入 `plan`。 +- ACP 已能把外部 `plan` notification 映射为 `plan` block,但 `MessageBlockPlan.vue` 目前只显示摘要和进度条,不显示完整 checklist。 + +## 架构决策 + +1. `update_plan` 是 DeepChat built-in agent tool,不是 MCP server。 +2. 新工具归入现有 Core 工具分区: + - `server.name = 'agent-core'` + - `function.name = 'update_plan'` + - 这样可直接复用 `disabledAgentTools` 和高级工具面板的单工具开关,不额外增加单工具分区。 +3. `AgentToolManager` 持有 session-scoped `PlanState`,按 `conversationId` 维护 `current/revision/updatedAt`。 +4. 工具调用成功后通过 `AgentToolProgressUpdate` 扩展出 `kind: 'agent_plan'` 把 snapshot 交给 dispatch。 +5. `dispatch.ts` 负责发布 `chat.plan.updated` typed event,不把 DeepChat `update_plan` snapshot 插入当前 assistant message。 +6. `update_plan` tool call block 标记为 internal progress tool,renderer 默认隐藏该 pill,避免重复展示“update_plan 调用完成”。 +7. `chat.plan.updated` event 和 renderer plan store 是 DeepChat todo 的实时来源。若后续需要持久化,应设计独立 progress 存储,不复用 assistant message blocks。 +8. MVP 使用可收起浮层,不把 Progress 放入 Workspace 内容区,不增加 sidepanel 顶层 tab。 + +## 数据与类型 + +新增或扩展: + +- `src/shared/types/agent-plan.ts` + - `AgentPlanStepStatus` + - `AgentPlanItem` + - `UpdatePlanArgs` + - `AgentPlanSnapshot` + - `AgentPlanState` +- `src/shared/types/agent-interface.d.ts` + - `AssistantBlockType` 增加 `plan` + - `AssistantMessageExtra` 增加 plan 相关字段类型 +- `src/shared/contracts/common.ts` + - `AssistantMessageBlockSchema.type` 增加 `plan` + - `extra` schema 保持 json record +- `src/shared/contracts/events/chat.events.ts` + - 新增 `chatPlanUpdatedEvent` +- `src/shared/contracts/events.ts` + - 导出并登记新事件 +- `src/shared/types/presenters/tool.presenter.d.ts` + - `AgentToolProgressUpdate` 增加 `agent_plan` variant + +Plan snapshot: + +```ts +interface AgentPlanSnapshot { + sessionId: string + toolCallId?: string + explanation?: string + plan: AgentPlanItem[] + revision: number + updatedAt: string +} +``` + +## Main Process Flow + +```text +Model emits update_plan tool call + -> accumulator adds tool_call block + -> dispatch.executeTools runs ToolPresenter.callTool + -> AgentToolManager validates args and updates PlanState + -> AgentToolManager emits AgentToolProgressUpdate(kind: agent_plan) + -> dispatch marks the update_plan tool call internal and publishes chat.plan.updated + -> renderer receives chat.stream.updated snapshot and chat.plan.updated live event + -> model receives tiny success result +``` + +Implementation pieces: + +- Add `agentPlanTool.ts` under `src/main/presenter/toolPresenter/agentTools/`. +- Add zod schema with `.strict()` objects and max length 12. +- Register tool definition from `AgentToolManager.getAllToolDefinitions()` only in `chatMode === 'agent'`. +- Route `toolName === UPDATE_PLAN_TOOL_NAME` in `AgentToolManager.callTool()`. +- Add helper in `dispatch.ts`: + - `markInternalPlanToolCall(blocks, toolCallId)` + - `publishPlanUpdated(snapshot, messageId)` +- Keep tool output small and context-friendly. The tool response to the model should be `{}` or `Plan updated`, not the full plan. + +## Renderer Flow + +- `MessageBlockPlan.vue` + - Replace summary-only rendering with full checklist. + - Support existing ACP `plan_entries` with `{ content, status }` and new DeepChat `{ step, status }`. + - Keep progress count in the header. + - Render empty state when `plan_entries` is empty. + - This component is compatibility UI for ACP/history; DeepChat `update_plan` does not create these blocks. +- New `AgentProgressFloat.vue` + - Input: latest active plan snapshot for current session. + - Collapsed by default. Collapsed state stored in sidepanel/session UI store or local `useStorage` keyed by session id. + - Expands and collapses with a short height/opacity transition. + - Shows only when current active session has a non-empty active plan during generation or a latest settled plan from current turn. +- Chat page integration: + - Subscribe through a small renderer client method for `chat.plan.updated`. + - Maintain `latestPlanBySession` in a small Pinia/composable store. + - Clear active floating plan when a new user turn starts and no plan has arrived yet. +- `MessageItemAssistant.vue` + - Skip rendering internal `update_plan` tool_call blocks by checking `block.extra.internalTool === true` and `block.tool_call.name === 'update_plan'`. + +## Tool Toggle UI + +- `McpIndicator.vue` + - Keep `update_plan` under existing `agent-core` grouping. + - Do not add a separate Progress group or group label. +- Existing `disabledAgentTools` storage works without a schema migration because it stores tool names. +- New sessions inherit agent default `disabledAgentTools`; built-in DeepChat default remains enabled. + +## Prompting + +Update `ToolPresenter.buildToolSystemPrompt()`: + +- Add `buildProgressPrompt(toolNames)`. +- Include rules only when `toolNames.has('update_plan')`. +- Keep this separate from formal planning responses and from question tool rules. + +## Compatibility + +- Existing ACP plan blocks should continue rendering because `MessageBlockPlan` will normalize both `{ content }` and `{ step }`. +- Existing assistant messages with summary-only `plan_entries` still render. +- DeepChat `update_plan` no longer creates new assistant `plan` blocks; this intentionally keeps the message list free of process-state todo items. +- Sessions with `disabledAgentTools` do not need migration. If a user had disabled all tools manually, `update_plan` starts enabled unless agent config later explicitly disables it. +- If `update_plan` is disabled during an active generation, the current request's tool list is not retroactively mutated; the change applies to subsequent tool refreshes, matching existing tool toggle behavior. + +## Test Strategy + +Main tests: + +- `AgentPlanTool` validation rejects unknown status, empty step, extra fields, multiple `in_progress`, and more than 12 steps. +- Valid payload increments revision and normalizes trimmed steps. +- Empty plan clears current snapshot and emits a snapshot with `plan: []`. +- `AgentToolManager` lists `update_plan` in `agent-core` for DeepChat agent mode and omits it when disabled through `ToolPresenter`. +- `ToolPresenter.buildToolSystemPrompt()` includes progress rules only when enabled. +- `dispatch.executeTools` handles `agent_plan` progress update by publishing event and not inserting any `plan` block. + +Renderer tests: + +- `MessageBlockPlan` renders completed / in_progress / pending entries with accessible status text. +- Long step text wraps without changing icon alignment. +- ACP-style `{ content }` plan entries still render. +- Internal `update_plan` tool_call block is hidden. +- `McpIndicator` shows `update_plan` inside Agent Core and toggles it through `disabledAgentTools`. +- Floating panel renders collapsed by default, animates expand/collapse, and ignores stale lower revision updates. + +Validation commands after implementation: + +```bash +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm test -- test/main/presenter/toolPresenter test/main/presenter/agentRuntimePresenter test/renderer/components/message test/renderer/components/McpIndicator.test.ts +``` + +## Risks + +- Tool-call UI noise: hidden internal tool call must be scoped only to `update_plan`, not all agent-core tools. +- Message pollution: DeepChat `update_plan` must not append process-state todo items to assistant message blocks. +- Type drift: there are multiple assistant block type definitions; all active shared/renderer schemas must include `plan`. +- Event ordering: floating panel should compare `revision` and ignore stale updates for the same session. +- Overuse by model: system prompt must explicitly skip simple one-shot tasks. + +## Rollout + +1. Implement tool and validation behind normal tool availability. +2. Add event/block support and renderer checklist. +3. Add floating panel. +4. Add tool toggle group and prompt rules. +5. Run tests and validation commands. +6. If floating panel feels intrusive in QA, keep message block as canonical and ship panel collapsed by default. diff --git a/docs/features/agent-progress-todo/spec.md b/docs/features/agent-progress-todo/spec.md new file mode 100644 index 000000000..f6ed4ea58 --- /dev/null +++ b/docs/features/agent-progress-todo/spec.md @@ -0,0 +1,194 @@ +# Agent Progress Todo 规格 + +## 背景 + +DeepChat agent 已经具备文件、命令、YoBrowser、skills、subagent、提问与权限交互等能力,但缺少一个轻量的“执行进度 / todo”工具。用户在多步骤任务中只能从文本和 tool call pill 推断当前状态,无法稳定看到“已完成、正在做、待处理”。 + +用户提供的参考文档是 `/Users/zerob13/Downloads/agent-progress-todo-spec.md`,核心模型参考 Codex 的 `update_plan`:一次工具调用提交完整 checklist snapshot,runtime 替换当前计划并发出稳定事件,UI 渲染 progress checklist。 + +## 目标 + +- DeepChat agent 可调用 `update_plan` 更新当前任务进度。 +- 用户能在生成过程中看到当前 plan 的 completed / in_progress / pending 状态。 +- `update_plan` 作为内置 agent 核心工具出现,可在 Core 分组中按工具单独启用或停用。 +- checklist 是 agent turn 的过程态,不插入 assistant message 列表;实时展示由独立 plan event/store 驱动。 +- 生成中提供一个可收起的实时 Progress 浮层,避免用户滚动离开最新消息后失去进度可见性。 + +## 用户故事 + +### US-1:多步骤任务可见进度 + +作为用户,当我让 DeepChat agent 执行跨文件或跨阶段任务时,我希望看到已完成、正在处理、待处理的 checklist,而不是只能读 tool call 和中间文本。 + +### US-2:可控的核心工具 + +作为用户,我希望 Progress/Todo 保持在核心工具区域里,不额外增加单工具分区;如果我不希望 agent 展示计划,可以在 Core 分组中停用 `update_plan`。 + +### US-3:消息列表保持干净 + +作为用户,我希望 todo/progress 不被写入聊天消息列表,避免中间状态污染最终回答。即使后续需要持久化,也应进入独立的 progress 存储,而不是 assistant message blocks。 + +### US-4:实时但不打扰 + +作为用户,我希望执行中有一个可收起的 Progress 浮层显示最新进度;如果我不需要看,可以折叠,不影响输入区和消息阅读。 + +## MVP 范围 + +- 新增 DeepChat 内置 agent 工具 `update_plan`。 +- 工具 schema 与参考文档一致:`explanation?: string`,`plan: { step: string; status: "pending" | "in_progress" | "completed" }[]`。 +- 工具校验: + - `plan` 必须为数组,允许空数组用于清空 checklist。 + - `step` trim 后必须为非空字符串。 + - `status` 只允许 `pending | in_progress | completed`。 + - 同一 snapshot 最多一个 `in_progress`。 + - 拒绝额外字段。 + - MVP 最多 12 个 step,超出返回模型可读错误。 +- Runtime 维护 session-scoped latest plan state:`current`、`revision`、`updatedAt`。 +- DeepChat typed event 使用 `chat.plan.updated` 承载参考文档中的 `plan.update` 语义。 +- DeepChat `update_plan` 不新增 assistant `plan` block,也不在消息列表中渲染 todo。 +- 生成中浮层从最新 plan snapshot 渲染,默认收起,支持带动画的展开 / 折叠。 +- 高级工具面板在 `agent-core` 分区显示 `update_plan`,支持按单个工具停用。 +- Agent system tooling prompt 注入使用规则,避免简单任务滥用 checklist。 +- 覆盖 validation、handler、dispatch/event、UI 渲染、工具分区开关测试。 + +## UX 决策 + +MVP 不重做 Workspace 信息架构,也不把 Progress 作为 Workspace 主内容区的一部分。原因: + +- 当前右侧 sidepanel 已经有 `workspace` 与 `browser` 两个顶层 tab,Workspace 内部又包含 Files、Git、Artifacts。直接塞入实时任务状态会让 Workspace 承担过多职责。 +- Progress 是 agent turn 的执行状态,不是 workspace 文件资产。它应优先跟随 chat generation,而不是跟随文件预览。 +- 用户截图更接近一个轻量 progress panel;可收起浮层能满足实时可见性,同时保留消息内持久记录。 + +MVP UI 形态: + +- Message list:DeepChat `update_plan` 不插入 `plan` block;`update_plan` 自身 tool call pill 标记为 internal,默认不渲染。 +- Floating panel:仅当前 session 有 active plan 时显示;desktop 固定在输入区上方右侧,mobile 使用输入区上方全宽紧凑条;默认收起,可动画展开 / 折叠,折叠状态按 session 保存。 +- Tool toggle:在 Advanced Settings -> Built-in Tools 的 Core 分组中显示 `update_plan`。关闭该工具后,本轮之后的工具列表不再暴露该工具。 + +## Tool Contract + +工具名:`update_plan` + +```ts +type AgentPlanStepStatus = 'pending' | 'in_progress' | 'completed' + +interface AgentPlanItem { + step: string + status: AgentPlanStepStatus +} + +interface UpdatePlanArgs { + explanation?: string + plan: AgentPlanItem[] +} +``` + +成功结果应保持极简,推荐: + +```json +{} +``` + +错误结果必须可被模型自修复,例如: + +```text +invalid update_plan arguments: at most one step can be in_progress +``` + +## Event Contract + +Codex 参考文档里的 `plan.update` 在 DeepChat 中落为 typed event: + +```ts +interface ChatPlanUpdatedEvent { + sessionId: string + messageId: string + toolCallId?: string + plan: AgentPlanItem[] + explanation?: string + revision: number + updatedAt: string +} +``` + +语义: + +- 每个事件代表一次完整 snapshot 替换。 +- `revision` 在 session 内单调递增。 +- UI 收到更高 revision 后覆盖当前 checklist。 +- `plan.length === 0` 表示清空 active checklist。 +- 事件用于实时浮层;持久历史仍以 assistant `plan` block 为准。 + +## Assistant Block Compatibility + +DeepChat `update_plan` 不写入 assistant `plan` block。现有 `plan` block 兼容逻辑仅用于 ACP agent notification 和已有历史消息。 + +兼容的 `plan` block `extra` 存储结构: + +```ts +{ + plan_entries: AgentPlanItem[] + plan_explanation?: string + plan_revision: number + plan_updated_at: string +} +``` + +渲染规则: + +- `explanation` 存在时显示在 checklist 上方一行。 +- `completed` 使用 dimmed style 和 check icon。 +- `in_progress` 使用 active style 和 running indicator。 +- `pending` 使用 normal/muted style 和 hollow circle。 +- 长 step 必须换行,第二行缩进到文本起始位置。 +- screen reader 文本包含原始 status,例如 `[in_progress] Implement update_plan handler`。 + +## Agent 使用规则 + +注入到工具系统 prompt: + +```text +Use update_plan for non-trivial multi-step tasks. +Skip update_plan for simple one-shot answers or trivial edits. +Keep each plan step short, concrete, and verifiable. +Keep the plan current as work progresses. +At most one step may be in_progress at a time. +When a step completes, update the plan immediately and move the next active step to in_progress in the same call. +Use explanation only when the plan changes materially or when progress would otherwise be unclear. +``` + +## 验收标准 + +- `update_plan` 出现在 DeepChat agent 的内置工具列表中,且 `server.name` 为 `agent-core`。 +- Advanced Settings 的 Built-in Tools 中,`update_plan` 出现在 Core 分组内,可作为单个工具开关。 +- 关闭 `update_plan` 后,新请求工具定义不再包含该工具,系统 prompt 也不再包含 Progress 使用规则。 +- 有效 payload 更新 session plan state,`revision` 递增,`updatedAt` 为 ISO 8601 UTC string。 +- 无效 payload 返回模型可读错误,不更新 state,不发 `chat.plan.updated`。 +- 每次有效调用发出一个 `chat.plan.updated` event,并更新独立 renderer plan store。 +- `update_plan` 不会向当前 assistant message 插入 `plan` block。 +- `update_plan` 自身 tool call pill 不在默认消息视图中制造额外噪声。 +- 浮层默认收起,展开 / 折叠有过渡动画。 +- 浮层能渲染三种状态、长文本换行与空 plan。 +- ACP 或历史 assistant message 的 plan block 仍可正常显示。 +- 测试覆盖 validation、tool handler、dispatch/event、message block、floating panel、tool toggle。 + +## 非目标 + +- 不做 owner、deadline、priority、子任务层级。 +- 不接入外部项目管理系统。 +- 不做长期任务数据库或跨 session 任务看板。 +- 不做自动任务拆解 planner。 +- 不重做 Workspace 顶层布局。 +- 不改变 ACP agent 已有 plan notification 映射,只在必要时复用 UI 组件。 + +## 约束 + +- 遵循 DeepChat 新 renderer-main typed route / typed event 模式,不新增 legacy IPC。 +- 用户可见文案必须走 `src/renderer/src/i18n`。 +- DeepChat agent 新能力优先放在 `src/main/presenter/toolPresenter/agentTools` 与 `agentRuntimePresenter` 现有链路中。 +- 保持实现轻量,避免引入状态管理系统级复杂度。 +- 不破坏现有 ACP `plan` block 兼容展示。 + +## 开放问题 + +无。 diff --git a/docs/features/agent-progress-todo/tasks.md b/docs/features/agent-progress-todo/tasks.md new file mode 100644 index 000000000..3a2fb3e0a --- /dev/null +++ b/docs/features/agent-progress-todo/tasks.md @@ -0,0 +1,79 @@ +# Agent Progress Todo 任务清单 + +## T0 规格冻结 + +- [x] 阅读 SDD 规范与用户提供的 Codex progress 设计文档。 +- [x] 梳理 DeepChat 当前 agent tool、dispatch、message block、tool toggle、sidepanel/workspace 基线。 +- [x] 明确 MVP UI 决策:聊天内 checklist + 可收起浮层,不重做 Workspace。 +- [x] 移除开放澄清项。 + +## T1 Shared Types 与 Event Contract + +- [x] 新增 `src/shared/types/agent-plan.ts`。 +- [x] 更新 `src/shared/types/agent-interface.d.ts` 的 assistant block 类型与 extra 字段。 +- [x] 更新 `src/shared/contracts/common.ts` 允许 `plan` block。 +- [x] 新增并登记 `chat.plan.updated` typed event。 +- [x] 更新 renderer display type 中 plan extra 的更具体类型。 + +## T2 update_plan 工具与校验 + +- [x] 新增 `src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts`。 +- [x] 定义 `UPDATE_PLAN_TOOL_NAME` 与 zod schema。 +- [x] 实现 strict validation、最多 12 steps、最多一个 `in_progress`。 +- [x] 实现 session-scoped `PlanState` 和 revision 递增。 +- [x] 成功时返回极简 tool result,失败时返回模型可读错误。 +- [x] 添加 main 单测覆盖 validation 和 state 更新。 + +## T3 ToolPresenter 集成与提示词 + +- [x] 在 `AgentToolManager.getAllToolDefinitions()` 注册 `agent-core/update_plan`。 +- [x] 在 `AgentToolManager.callTool()` 路由 `update_plan`。 +- [x] 扩展 `AgentToolProgressUpdate` 支持 `agent_plan`。 +- [x] 在 `ToolPresenter.buildToolSystemPrompt()` 注入 Progress 使用规则。 +- [x] 确认 `disabledAgentTools` 可过滤 `update_plan`。 +- [x] 添加 ToolPresenter/AgentToolManager 单测。 + +## T4 Dispatch 与 Message Block 更新 + +- [x] 在 `dispatch.ts` 处理 `agent_plan` progress update。 +- [x] 不向 current assistant message 插入 `plan` block。 +- [x] 标记 `update_plan` tool_call block 为 internal。 +- [x] 发布 `chat.plan.updated` event。 +- [x] 确保 empty plan 清空 active checklist。 +- [x] 添加 dispatch 单测覆盖 update 只发 event 且不插入 plan block。 + +## T5 Renderer Checklist + +- [x] 重写 `MessageBlockPlan.vue` 为完整 checklist。 +- [x] 兼容 ACP `{ content, status }` 与 DeepChat `{ step, status }` 两种 entry。 +- [x] 增加 completed / in_progress / pending 三态样式。 +- [x] 增加 empty state 与 screen reader 文本。 +- [x] 在 `MessageItemAssistant.vue` 隐藏 internal `update_plan` tool call。 +- [x] 添加 renderer 组件测试。 + +## T6 Floating Progress Panel + +- [x] 新增 `AgentProgressFloat.vue`。 +- [x] 新增或扩展 renderer store/composable 维护 latest plan snapshot。 +- [x] 订阅 `chat.plan.updated`,按 session 和 revision 去重。 +- [x] 在 `ChatPage.vue` 输入区上方挂载浮层。 +- [x] 支持 per-session collapsed state,默认收起。 +- [x] 增加展开 / 收起动画。 +- [x] 添加浮层渲染与折叠测试。 + +## T7 工具分区开关与 i18n + +- [x] 确认 `update_plan` 出现在现有 Agent Core 分组中。 +- [x] 不新增单独 Progress 分组或分组 i18n 文案。 +- [x] 确认草稿会话和已有 session 都能开关 `update_plan`。 +- [x] 更新 DeepChat agent settings 工具列表展示需要的分组标签。 +- [x] 添加 `McpIndicator` 测试覆盖 Core 内单工具 toggle。 + +## T8 验证 + +- [x] 运行 `pnpm run format`。 +- [x] 运行 `pnpm run i18n`。 +- [x] 运行 `pnpm run lint`。 +- [x] 运行 `pnpm run typecheck`。 +- [x] 运行相关 main/renderer 测试。 +- [ ] 手动验证一个多步骤 agent 任务能显示、更新、完成和隐藏 progress。 diff --git a/src/main/presenter/agentRuntimePresenter/dispatch.ts b/src/main/presenter/agentRuntimePresenter/dispatch.ts index 81ff3ac2f..9718c6f06 100644 --- a/src/main/presenter/agentRuntimePresenter/dispatch.ts +++ b/src/main/presenter/agentRuntimePresenter/dispatch.ts @@ -12,6 +12,7 @@ import type { SearchResult } from '@shared/types/core/search' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.presenter' import type { AssistantMessageBlock, PermissionMode } from '@shared/types/agent-interface' +import type { AgentPlanSnapshot } from '@shared/types/agent-plan' import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../../lib/agentRuntime/questionTool' import type { InterleavedReasoningConfig, @@ -354,6 +355,32 @@ function updateSubagentToolCallBlock( } } +function markInternalPlanToolCallBlock(blocks: AssistantMessageBlock[], toolCallId: string): void { + const block = blocks.find( + (item) => item.type === 'tool_call' && item.tool_call?.id === toolCallId + ) + if (!block?.tool_call || block.tool_call.name !== 'update_plan') { + return + } + + block.extra = { + ...block.extra, + internalTool: true + } +} + +function publishPlanUpdated(io: IoParams, snapshot: AgentPlanSnapshot): void { + publishDeepchatEvent('chat.plan.updated', { + sessionId: io.sessionId, + messageId: io.messageId, + ...(snapshot.toolCallId ? { toolCallId: snapshot.toolCallId } : {}), + plan: snapshot.plan, + ...(snapshot.explanation ? { explanation: snapshot.explanation } : {}), + revision: snapshot.revision, + updatedAt: snapshot.updatedAt + }) +} + function extractSubagentToolState(rawData: MCPToolResponse): { subagentProgress?: string subagentFinal?: string @@ -799,6 +826,18 @@ async function runToolCall(params: { try { const applyProgressUpdate = (update: AgentToolProgressUpdate) => { + if ( + update.kind === 'agent_plan' && + update.toolCallId === completedToolCall.id && + allowProgressUpdates + ) { + markInternalPlanToolCallBlock(state.blocks, completedToolCall.id) + publishPlanUpdated(io, update.snapshot) + state.dirty = true + scheduleRendererFlush(state, rendererFlushHandle) + return + } + if ( !allowProgressUpdates || update.kind !== 'subagent_orchestrator' || diff --git a/src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts b/src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts new file mode 100644 index 000000000..a2c4447c0 --- /dev/null +++ b/src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts @@ -0,0 +1,158 @@ +import { z } from 'zod' +import { zodToJsonSchema } from 'zod-to-json-schema' +import type { MCPToolDefinition } from '@shared/presenter' +import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.presenter' +import type { AgentPlanState, AgentPlanSnapshot, UpdatePlanArgs } from '@shared/types/agent-plan' + +export const UPDATE_PLAN_TOOL_NAME = 'update_plan' +export const AGENT_CORE_TOOL_SERVER_NAME = 'agent-core' + +const MAX_PLAN_ITEMS = 12 + +const planItemSchema = z + .object({ + step: z + .string() + .transform((value) => value.trim()) + .refine((value) => value.length > 0, 'step must be a non-empty string'), + status: z.enum(['pending', 'in_progress', 'completed']) + }) + .strict() + +export const updatePlanToolArgsSchema = z + .object({ + explanation: z.string().optional(), + plan: z.array(planItemSchema).max(MAX_PLAN_ITEMS) + }) + .strict() + .superRefine((value, context) => { + const inProgressCount = value.plan.filter((item) => item.status === 'in_progress').length + if (inProgressCount > 1) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['plan'], + message: 'at most one step can be in_progress' + }) + } + }) + +export interface AgentPlanToolCallOptions { + toolCallId?: string + onProgress?: (update: AgentToolProgressUpdate) => void +} + +const formatValidationError = (error: z.ZodError): string => { + const firstIssue = error.issues[0] + if (!firstIssue) { + return 'invalid update_plan arguments' + } + + const path = firstIssue.path.length > 0 ? `${firstIssue.path.join('.')}: ` : '' + return `invalid update_plan arguments: ${path}${firstIssue.message}` +} + +export class AgentPlanTool { + private readonly states = new Map() + + getToolDefinition(): MCPToolDefinition { + return { + type: 'function', + function: { + name: UPDATE_PLAN_TOOL_NAME, + description: + 'Update the visible progress checklist for the current multi-step task. Provide the complete current plan snapshot every time. Use short, concrete, verifiable steps. At most one step may be in_progress.', + parameters: zodToJsonSchema(updatePlanToolArgsSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: AGENT_CORE_TOOL_SERVER_NAME, + icons: 'list-checks', + description: 'Agent core tools' + } + } + } + + call( + args: Record, + conversationId?: string, + options?: AgentPlanToolCallOptions + ): { content: string; rawData: { content: string; isError: boolean; toolResult: unknown } } { + const sessionId = conversationId?.trim() + if (!sessionId) { + throw new Error('update_plan requires a conversation ID') + } + + const validationResult = updatePlanToolArgsSchema.safeParse(args) + if (!validationResult.success) { + throw new Error(formatValidationError(validationResult.error)) + } + + const normalizedArgs = this.normalizeArgs(validationResult.data) + const previous = this.states.get(sessionId) + const revision = (previous?.revision ?? 0) + 1 + const updatedAt = new Date().toISOString() + const toolCallId = options?.toolCallId?.trim() || undefined + const snapshot: AgentPlanSnapshot = { + sessionId, + ...(toolCallId ? { toolCallId } : {}), + ...(normalizedArgs.explanation ? { explanation: normalizedArgs.explanation } : {}), + plan: normalizedArgs.plan, + revision, + updatedAt + } + + this.states.set(sessionId, { + current: normalizedArgs, + revision, + updatedAt + }) + + if (toolCallId) { + options?.onProgress?.({ + kind: 'agent_plan', + toolCallId, + snapshot + }) + } + + return { + content: '{}', + rawData: { + content: '{}', + isError: false, + toolResult: { + kind: 'agent_plan', + snapshot + } + } + } + } + + getState(conversationId: string): AgentPlanState { + return ( + this.states.get(conversationId) ?? { + current: null, + revision: 0, + updatedAt: null + } + ) + } + + clearState(conversationId: string): void { + this.states.delete(conversationId) + } + + private normalizeArgs(args: z.output): UpdatePlanArgs { + const explanation = args.explanation?.trim() + return { + ...(explanation ? { explanation } : {}), + plan: args.plan.map((item) => ({ + step: item.step, + status: item.status + })) + } + } +} diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index c20da81e8..c1c239c29 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -28,6 +28,7 @@ import { SubagentOrchestratorTool } from './subagentOrchestratorTool' import { AgentImageGenerationTool, IMAGE_GENERATE_TOOL_NAME } from './agentImageGenerationTool' +import { AgentPlanTool, UPDATE_PLAN_TOOL_NAME } from './agentPlanTool' // Consider moving to a shared handlers location in future refactoring import { @@ -121,6 +122,7 @@ export class AgentToolManager { private chatSettingsHandler: ChatSettingsToolHandler | null = null private subagentOrchestratorTool: SubagentOrchestratorTool | null = null private imageGenerationTool: AgentImageGenerationTool | null = null + private planTool: AgentPlanTool | null = null private static readonly READ_FILE_AUTO_TRUNCATE_THRESHOLD = 4500 private readonly fileSystemSchemas = { @@ -285,6 +287,7 @@ export class AgentToolManager { configPresenter: this.configPresenter, runtimePort: this.runtimePort }) + this.planTool = new AgentPlanTool() if (this.agentWorkspacePath) { this.fileSystemHandler = new AgentFileSystemHandler([this.agentWorkspacePath]) this.bashHandler = new AgentBashHandler( @@ -345,6 +348,11 @@ export class AgentToolManager { // 2. Built-in question tool (all modes) defs.push(...this.getQuestionToolDefinitions()) + // 2.1. Progress checklist tool (deepchat regular sessions only) + if (isAgentMode && this.planTool) { + defs.push(this.planTool.getToolDefinition()) + } + // 2.25. Image generation tool (deepchat agent sessions with an image model) if (isAgentMode && this.imageGenerationTool) { try { @@ -430,6 +438,17 @@ export class AgentToolManager { conversationId?: string, options?: AgentToolExecutionOptions ): Promise { + if (toolName === UPDATE_PLAN_TOOL_NAME) { + if (!this.planTool) { + throw new Error('Progress tool is not available.') + } + + return this.planTool.call(args, conversationId, { + toolCallId: options?.toolCallId, + onProgress: options?.onProgress + }) + } + if (toolName === QUESTION_TOOL_NAME) { const validationResult = questionToolSchema.safeParse(args) if (!validationResult.success) { diff --git a/src/main/presenter/toolPresenter/agentTools/index.ts b/src/main/presenter/toolPresenter/agentTools/index.ts index e6a0dff9f..e91f1cfb8 100644 --- a/src/main/presenter/toolPresenter/agentTools/index.ts +++ b/src/main/presenter/toolPresenter/agentTools/index.ts @@ -11,3 +11,4 @@ export { CHAT_SETTINGS_SKILL_NAME, CHAT_SETTINGS_TOOL_NAMES } from './chatSettingsTools' +export { AGENT_CORE_TOOL_SERVER_NAME, UPDATE_PLAN_TOOL_NAME, AgentPlanTool } from './agentPlanTool' diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 6d876919d..e7195bde3 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -10,7 +10,12 @@ import type { PermissionMode } from '@shared/types/agent-interface' import { resolveToolOffloadTemplatePath } from '@/lib/agentRuntime/sessionPaths' import { QUESTION_TOOL_NAME } from '@/lib/agentRuntime/questionTool' import { ToolMapper, type ToolSource } from './toolMapper' -import { AgentToolManager, IMAGE_GENERATE_TOOL_NAME, type AgentToolCallResult } from './agentTools' +import { + AgentToolManager, + IMAGE_GENERATE_TOOL_NAME, + UPDATE_PLAN_TOOL_NAME, + type AgentToolCallResult +} from './agentTools' import type { AgentToolRuntimePort } from './runtimePorts' import { createAgentToolErrorResult, @@ -89,7 +94,8 @@ const FILESYSTEM_TOOL_ORDER = ['read', 'write', 'edit', 'exec', 'process'] const OFFLOAD_TOOL_NAMES = new Set(['exec', 'cdp_send']) const RESERVED_AGENT_TOOL_NAMES = new Set([ ...YO_BROWSER_TOOL_NAMES, - IMAGE_GENERATE_TOOL_NAME + IMAGE_GENERATE_TOOL_NAME, + UPDATE_PLAN_TOOL_NAME ]) const withToolSource = (tools: MCPToolDefinition[], source: 'mcp' | 'agent'): MCPToolDefinition[] => @@ -453,6 +459,7 @@ export class ToolPresenter implements IToolPresenter { this.buildFilesystemPrompt(toolNames, offloadPath), this.buildQuestionPrompt(toolNames), this.buildImageGenerationPrompt(toolNames), + this.buildProgressPrompt(toolNames), this.buildSkillsPrompt(toolNames), this.buildSettingsPrompt(groupedTools.get('deepchat-settings') ?? []), this.buildYoBrowserPrompt(groupedTools.get('yobrowser') ?? []) @@ -606,6 +613,24 @@ export class ToolPresenter implements IToolPresenter { ].join('\n') } + private buildProgressPrompt(toolNames: Set): string { + if (!toolNames.has(UPDATE_PLAN_TOOL_NAME)) { + return '' + } + + return [ + '## Progress Checklist Tool', + `Use \`${UPDATE_PLAN_TOOL_NAME}\` for non-trivial multi-step tasks.`, + 'Skip it for simple one-shot answers or trivial edits.', + 'Each call must provide the complete current checklist snapshot.', + 'Keep each step short, concrete, and verifiable.', + 'Keep the checklist current as work progresses.', + 'At most one step may be in_progress at a time.', + 'When a step completes, update the checklist immediately and move the next active step to in_progress in the same call.', + 'Use explanation only when the plan changes materially or progress would otherwise be unclear.' + ].join('\n') + } + private buildSettingsPrompt(tools: MCPToolDefinition[]): string { if (tools.length === 0) { return '' diff --git a/src/renderer/api/ChatClient.ts b/src/renderer/api/ChatClient.ts index 303cbafb1..d6bb6f54c 100644 --- a/src/renderer/api/ChatClient.ts +++ b/src/renderer/api/ChatClient.ts @@ -1,5 +1,6 @@ import type { DeepchatBridge } from '@shared/contracts/bridge' import { + chatPlanUpdatedEvent, chatStreamCompletedEvent, chatStreamFailedEvent, chatStreamUpdatedEvent, @@ -66,6 +67,10 @@ export function createChatClient(bridge: DeepchatBridge = getDeepchatBridge()) { return bridge.on(chatStreamFailedEvent.name, listener) } + function onPlanUpdated(listener: (payload: DeepchatEventPayload<'chat.plan.updated'>) => void) { + return bridge.on(chatPlanUpdatedEvent.name, listener) + } + return { sendMessage, steerActiveTurn, @@ -73,7 +78,8 @@ export function createChatClient(bridge: DeepchatBridge = getDeepchatBridge()) { respondToolInteraction, onStreamUpdated, onStreamCompleted, - onStreamFailed + onStreamFailed, + onPlanUpdated } } diff --git a/src/renderer/src/components/chat/AgentProgressFloat.vue b/src/renderer/src/components/chat/AgentProgressFloat.vue new file mode 100644 index 000000000..8aad428ea --- /dev/null +++ b/src/renderer/src/components/chat/AgentProgressFloat.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/src/renderer/src/components/chat/messageListItems.ts b/src/renderer/src/components/chat/messageListItems.ts index 1c451391d..584c84185 100644 --- a/src/renderer/src/components/chat/messageListItems.ts +++ b/src/renderer/src/components/chat/messageListItems.ts @@ -1,4 +1,5 @@ import type { MessageFile } from '@shared/types/agent-interface' +import type { AgentPlanDisplayItem } from '@shared/types/agent-plan' import type { ToolCallImagePreview } from '@shared/types/core/mcp' export type DisplayMessageUsage = { @@ -71,6 +72,11 @@ export type DisplayAssistantMessageExtra = Record -
- +
- - {{ t('plan.title') }} + + {{ t('chat.workspace.plan.section') }} - {{ completedCount }}/{{ totalCount }} {{ t('plan.completed') }} + {{ completedCount }}/{{ totalCount }} {{ t('chat.workspace.plan.status.completed') }}
- -
+
+ +

+ {{ explanation }} +

+ +
+
+
+
+ +
+ {{ t('chat.workspace.plan.empty') }} +
@@ -23,26 +55,88 @@ import { computed } from 'vue' import { Icon } from '@iconify/vue' import { useI18n } from 'vue-i18n' +import type { AgentPlanStepStatus } from '@shared/types/agent-plan' import type { DisplayAssistantMessageBlock } from '@/components/chat/messageListItems' +type NormalizedPlanEntry = { + label: string + status: AgentPlanStepStatus +} + const props = defineProps<{ block: DisplayAssistantMessageBlock }>() const { t } = useI18n() -const planEntries = computed(() => { - return (props.block.extra?.plan_entries as Array<{ status?: string | null }>) || [] +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const normalizeStatus = (value: unknown): AgentPlanStepStatus => { + if (value === 'completed' || value === 'done') { + return 'completed' + } + if (value === 'in_progress') { + return 'in_progress' + } + return 'pending' +} + +const entries = computed(() => { + const rawEntries = props.block.extra?.plan_entries + if (!Array.isArray(rawEntries)) { + return [] + } + + return rawEntries + .map((entry) => { + if (!isRecord(entry)) { + return null + } + + const rawLabel = typeof entry.step === 'string' ? entry.step : entry.content + const label = typeof rawLabel === 'string' ? rawLabel.trim() : '' + if (!label) { + return null + } + + return { + label, + status: normalizeStatus(entry.status) + } + }) + .filter((entry): entry is NormalizedPlanEntry => entry !== null) }) -const totalCount = computed(() => planEntries.value.length) +const explanation = computed(() => { + const value = props.block.extra?.plan_explanation + if (typeof value === 'string' && value.trim()) { + return value.trim() + } -const completedCount = computed(() => { - return planEntries.value.filter((e) => e.status === 'completed' || e.status === 'done').length + return props.block.content?.trim() ?? '' }) +const totalCount = computed(() => entries.value.length) + +const completedCount = computed( + () => entries.value.filter((entry) => entry.status === 'completed').length +) + const progressPercent = computed(() => { if (totalCount.value === 0) return 0 return Math.round((completedCount.value / totalCount.value) * 100) }) + +const getStatusIcon = (status: AgentPlanStepStatus): string => { + if (status === 'completed') return 'lucide:circle-check' + if (status === 'in_progress') return 'lucide:loader-circle' + return 'lucide:circle' +} + +const getStatusIconClass = (status: AgentPlanStepStatus): string => { + if (status === 'completed') return 'text-muted-foreground' + if (status === 'in_progress') return 'animate-spin text-primary' + return 'text-muted-foreground/80' +} diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index 4ce8c6ab3..bb7da5f39 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -53,7 +53,7 @@ /> { return false } +const isInternalToolCall = (block: DisplayAssistantMessageBlock): boolean => { + return block.tool_call?.name === 'update_plan' && block.extra?.internalTool === true +} + // 定义事件 const emit = defineEmits<{ copyImage: [ diff --git a/src/renderer/src/pages/ChatPage.vue b/src/renderer/src/pages/ChatPage.vue index e587ee40e..f2688b51f 100644 --- a/src/renderer/src/pages/ChatPage.vue +++ b/src/renderer/src/pages/ChatPage.vue @@ -76,6 +76,13 @@ @delete-queue="onPendingInputDelete" @resume-queue="onResumePendingQueue" /> +
+ +