diff --git a/.agents/skills/deepchat-release/SKILL.md b/.agents/skills/deepchat-release/SKILL.md index 671c7ab2e..1ced084ab 100644 --- a/.agents/skills/deepchat-release/SKILL.md +++ b/.agents/skills/deepchat-release/SKILL.md @@ -42,6 +42,7 @@ When preparing a release on `dev`: - Add a new `CHANGELOG.md` section at the top. - Summarize only user-visible or release-relevant changes since the previous tag. - Prefer deriving the notes from recent commits or the diff since the previous release tag. +- Do not create SDD folders for pure release metadata, branch, tag, or release PR work. For `v1.0.1` and later, format changelog entries in this order: diff --git a/AGENTS.md b/AGENTS.md index c94e97606..4fc5f9992 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,9 @@ Follow the SDD methodology before changing code, tests, configuration, documentation, build scripts, or project structure. See [docs/spec-driven-dev.md](docs/spec-driven-dev.md). +Pure release metadata work does not require SDD. Version bumps, `CHANGELOG.md` updates, release branch management, tags, and release PR preparation should follow [docs/release-flow.md](docs/release-flow.md) without creating +`docs/features/*release*` folders. + Create one kebab-case folder per goal and keep `spec.md`, `plan.md`, and `tasks.md` together: - `docs/features//` for new features, user-visible capabilities, integrations, and tools. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3141197a9..7ae3ca8be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.0.5-beta.8 (2026-06-02) +- Added a collapsible workspace file tree sidebar and an animated theme toggle in the app sidebar +- Added automatic chat activity collapsing so completed reasoning and tool-call work stays easier to scan +- Improved ACP v1 reliability with stronger capability handling, session persistence, terminal behavior, diagnostics, and protocol coverage +- Fixed model capability handling for temperature controls and provider database budget sentinels +- 新增可折叠的工作区文件树侧栏和应用侧栏动态主题切换按钮 +- 新增聊天活动自动折叠,让完成后的思考和工具调用内容更易扫读 +- 提升 ACP v1 可靠性,完善能力处理、会话持久化、终端行为、诊断和协议覆盖 +- 修复温度控制和 Provider 数据库预算特殊值的模型能力处理 + ## v1.0.5-beta.7 (2026-06-01) - Added agent session transfer so chats can be preserved or moved when changing agent ownership - Added a richer workspace Git diff panel rendering experience diff --git a/docs/features/acp-v1-reliability/plan.md b/docs/features/acp-v1-reliability/plan.md new file mode 100644 index 000000000..9b509f829 --- /dev/null +++ b/docs/features/acp-v1-reliability/plan.md @@ -0,0 +1,357 @@ +# ACP v1 Reliability Implementation Plan + +## 总体策略 + +本次不重写 ACP 子系统,而是在现有模块上补齐协议边界和状态闭环: + +- `acpProcessManager.ts` 负责 launch、initialize、client method dispatch、capability snapshot、debug log、session update buffer。 +- `acpSessionManager.ts` 负责 `new/load/resume/close/list` 的选择、持久化、listener 注册和 terminal/session cleanup。 +- `acpProvider.ts` 负责 chat turn、debug action、renderer 状态事件和 DeepChat stream event 输出。 +- `acpMessageFormatter.ts`、`acpContentMapper.ts`、`acpTerminalManager.ts`、`acpFsHandler.ts` 分别收口 prompt、update、terminal、fs 规范。 +- shared contracts / presenter types 只增加必要字段,不新增并行的 ACP 框架。 + +推荐分 5 个可 review 增量落地:capabilities/auth、session lifecycle、prompt/content/update、terminal/fs、UI diagnostics + E2E matrix。 + +数据所有权原则:DeepChat conversation/message records 是事实源。ACP agent session 是外部 session catalog 和运行时上下文,进入 DeepChat 后必须先形成本地 link,再转换、去重、持久化为 DeepChat 自己的消息和 metadata。 + +## Runtime Flow + +```text +Registry/Local command + | + v +Launch subprocess + JSON-RPC stdio + | + v +initialize(protocolVersion, clientCapabilities, clientInfo) + | + +--> auth required? --> authenticate/logout/debug/UI + | + v +resolve DeepChat conversation: + existing AcpSessionLink -> resume/load remote context + imported remote session -> attach link + optional load import + new local conversation -> session/new remote context + | + v +bind listener + flush buffered session/update + | + v +session/prompt(current user content blocks) + | + v +session/update -> mapper -> stream events + state + debug log + | + v +cancel/detach/explicit remote close/release terminals/process cleanup +``` + +## Data Ownership and Sync Model + +DeepChat 不把远端 agent session 当作本地数据库的事实源。远端 session 只提供三类信息: + +- catalog:`session/list` 返回的 sessionId、cwd、title、updatedAt、`_meta`。 +- replay:`session/load` 可能重放历史 update,用于导入远端历史。 +- runtime context:`session/resume` 或 `session/new` 后承接新的 prompt turn。 + +本地用一个 link 记录 DeepChat conversation 与远端 ACP session 的关系: + +```typescript +interface AcpSessionLink { + conversationId: string + agentId: string + canonicalWorkdir: string + remoteSessionId: string + remoteTitle?: string + remoteUpdatedAt?: string + lastImportedRemoteUpdatedAt?: string + lastImportFingerprint?: string + importedMessageFingerprints: string[] + syncState: 'cataloged' | 'imported' | 'attached' | 'stale' | 'error' +} +``` + +约束: + +- 稳定去重 key 是 `agentId + canonicalWorkdir + remoteSessionId`。 +- `session/list` 只更新 catalog/link metadata,不创建重复 DeepChat conversation。 +- 用户选择导入时,如果 link 已存在,打开/更新已有 DeepChat conversation;如果不存在,创建本地 conversation 并写 link。 +- `session/load` 重放内容先进入 staging buffer;转换为 DeepChat message/block 后,再按 message fingerprint 落库。 +- fingerprint 使用远端 session id、update type、role/channel、规范化 content、tool id、turn boundary 等字段生成;没有足够字段时仍要在同一次 import 内去重。 +- `session_info_update` 只更新 link metadata;本地会话标题只有在“自动标题”状态下才可被建议更新。 +- 本地删除或关闭 conversation 默认只 detach link,不调用远端 `session/close`。 +- 默认只有两种情况写远端 session:用户在已绑定 conversation 中继续发送 prompt;用户显式选择 `Close Remote Session`。 +- 普通 app shutdown、conversation close、process cleanup 只释放本地 handle/listener/terminal,不自动调用远端 `session/close`。 + +## Protocol 对接设计 + +### 1. Transports and Registry Launch + +- 保持 registry launch spec 为首选:binary > npx > uvx 的现有顺序不变。 +- 在 diagnostics 中显示实际 command、args count、distribution type、registry version、local/global version hint。 +- 每个初始化、认证、list/resume/close probe 都必须带 timeout;timeout 后清理子进程和其子进程树。 +- MCP transport 继续按 `mcpCapabilities` 过滤:`stdio` 默认可用,`http`/`sse` 仅 agent 声明后启用。 +- 对 Claude/Codex 这种可能拉起二级 CLI 的 wrapper,E2E probe 需要固定短超时和 cleanup 审计,避免残留进程。 + +### 2. Initialization and Capability Snapshot + +新增一个轻量 snapshot 类型,挂在现有 process handle 上,不新增独立 manager: + +```typescript +interface AcpCapabilitySnapshot { + protocolVersion: number + agentInfo?: schema.AgentInfo + agentCapabilities?: schema.AgentCapabilities + sessionCapabilities?: schema.SessionCapabilities + promptCapabilities?: schema.PromptCapabilities + authMethods: schema.AuthMethod[] + mcpCapabilities?: schema.McpCapabilities + supports: { + loadSession: boolean + sessionList: boolean + sessionResume: boolean + sessionClose: boolean + sessionFork: boolean + authLogout: boolean + } +} +``` + +- `buildClientCapabilities` 只声明 DeepChat 已真实支持的能力。 +- 首轮实现中,`fs`、`terminal` 继续声明;`auth.terminal` 只能在 terminal auth flow 完成后声明。 +- 初始化失败分三类展示:protocol version mismatch、process exited、timeout。 +- 初始化返回的 `models`、`modes`、`configOptions` 统一走 `normalizeAcpConfigState`,并发布 ready event。 + +### 3. Authentication and Logout + +认证入口分三层: + +- Presenter/debug:`authenticate(agentId, methodId, workdir?)`、`logout(agentId, workdir?)`。 +- Settings/diagnostics UI:展示 auth methods,并提供 Authenticate 按钮。 +- Chat flow:遇到 ACP auth required 错误时,停止当前 turn,展示可操作的 auth state。 + +各 auth method 处理方式: + +| Auth type | 对接方式 | +| --- | --- | +| `agent` 或默认类型 | 直接调用 `connection.authenticate({ methodId })`,成功后刷新 status;失败保留错误详情 | +| `env_var` | 在 agent settings 中标出必需 env var;缺失时不启动 prompt;设置后重启 agent 并重新 initialize | +| `terminal` | 在 DeepChat 控制的 terminal/auth runner 中执行 agent 指定流程;完成后重新 initialize;只有该能力完成后才声明 `auth.terminal=true` | + +`logout` 只在 `agentCapabilities.auth.logout` 存在时启用。logout 成功后关闭或失效当前 ACP session handle,避免继续使用旧认证上下文。 + +### 4. Session Lifecycle and Import + +`acpSessionManager` 增加 capability-gated lifecycle: + +- `listSessions(agentId, cwd?, cursor?)`:循环读取分页,按 workspace 同步 external catalog。 +- `importSession(agentId, remoteSessionId, cwd)`:创建或复用 DeepChat conversation,写入 `AcpSessionLink`。 +- `resumeSession(agentId, remoteSessionId, cwd)`:仅用于已绑定 conversation 的运行时上下文恢复。 +- `detachSessionLink(conversationId)`:解除本地 link,不写远端。 +- `closeRemoteSession(agentId, remoteSessionId)`:仅用户显式操作或活跃 runtime cleanup 时调用。 +- `loadSession(agentId, remoteSessionId, cwd)`:用于远端历史重放导入。 +- `newSession(agentId, cwd, mcpServers)`:只在新的 DeepChat conversation 需要远端上下文时调用。 + +本地 conversation 打开后的远端上下文恢复优先级固定为: + +```text +existing AcpSessionLink + supports.sessionResume -> session/resume +existing AcpSessionLink + supports.loadSession -> session/load for import/replay, then attach +no AcpSessionLink -> session/new +``` + +清理策略: + +- 用户停止当前生成:只调用 `session/cancel`。 +- 用户关闭本地 conversation:默认 detach link,不调用远端 close。 +- 用户显式关闭远端 session:若支持 `session/close`,调用 close;然后 release terminal/listener;最后更新 link state。 +- agent process 异常退出:标记 handle unhealthy,清理 listener/terminal,不删除用户可恢复的 session id。 +- `sessionCapabilities.fork` 先做 debug-only,只有 capability 存在时开放,不进入主聊天流程。 + +导入策略: + +- `session/list` 结果只写 external catalog,不直接生成 messages。 +- `session/load` 重放用于导入历史;导入过程先汇总成 DeepChat turn,再落库。 +- 已导入过的远端 session 再次同步时,先比较 `remoteUpdatedAt` 和 `lastImportedRemoteUpdatedAt`;未变化则跳过。 +- 即使 `updatedAt` 变化,也必须用 message fingerprint 去重,避免重复导入相同 replay 内容。 +- 新 prompt turn 由 DeepChat 产生并持久化;agent response 通过 mapper 转换后追加到同一个本地 conversation。 + +### 5. Session Update Buffer + +当前风险是 `session/new` 返回前后,agent 已经发送 `session/update`,但 DeepChat listener 尚未注册,导致 commands/modes/config 早期状态丢失。 + +修复方式: + +- `dispatchSessionUpdate` 找不到 listener 时,不立即 drop;按 `sessionId` 写入短期 buffer。 +- buffer 带 TTL 和最大条数,例如 30 秒、每 session 100 条,避免异常 agent 无限占内存。 +- `registerSessionListener(sessionId, ...)` 后立即 flush buffer,并保留原始顺序。 +- 如果 TTL 过期仍无 listener,再写 debug warning 并丢弃。 + +### 6. Prompt Turn and Content Mapping + +`acpMessageFormatter` 改成当前 turn only: + +- 从 DeepChat messages 中提取最后一个 user message。 +- 不再把完整历史拼成 `USER:`/`ASSISTANT:` 文本。 +- 不再把 temperature、maxTokens 注入 prompt 文本。 +- 若 DeepChat session 有 system prompt,只在本地 conversation 首次绑定远端 runtime 时作为 context text 发送一次。 +- 每个 content block 先判断 agent `promptCapabilities`,不支持则降级。 + +输入映射策略: + +| DeepChat content | ACP content | +| --- | --- | +| text | `text` | +| local/remote URL attachment | `resource_link` | +| base64 image + image supported | `image` | +| image unsupported | `resource_link` 或文本 fallback | +| audio + audio supported | `audio` | +| audio unsupported | 文本 fallback | +| embedded file/context + embeddedContext supported | `resource` 或 text context | + +输出映射策略: + +- `agent_message_chunk` -> text stream + content block。 +- `agent_thought_chunk` -> reasoning stream + reasoning block。 +- image/audio/resource/resource_link 尽量保留结构;UI 暂不支持的类型转可读文本,不丢 debug payload。 +- `usage_update` -> turn metadata + debug log;后续可在状态栏展示。 +- `session_info_update` -> `AcpSessionLink` metadata;自动标题可以更新,用户手工标题不覆盖。 + +### 7. Tool Calls and Permission + +工具调用保持现有 mapper,但修正语义: + +- `tool_call` 表示工具生命周期,不默认当作权限请求。 +- 只有 ACP `session/request_permission` 才进入 DeepChat permission overlay。 +- `tool_call_update.content` 中的 `terminal`、`diff`、`content`、`locations`、raw input/output 都保留到 block extra/debug。 +- permission resolver 增加 timeout 默认 outcome,用户取消或窗口关闭时返回 cancelled。 +- remote control 侧沿用现有 permission/question 交互模型。 + +### 8. File System + +`acpFsHandler` 当前方向正确,计划以测试加固为主: + +- read/write 继续要求 session workdir 已注册。 +- 路径必须在允许 workspace 内;跨 workspace 写入拒绝。 +- line number 按 1-based 处理。 +- binary file、超大文件、无权限路径给结构化错误。 +- `clientCapabilities.fs` 只有 handler 可用时声明;handler 初始化失败时不声明。 + +### 9. Terminals + +`acpTerminalManager` 需要改协议细节: + +- `terminal/create` 用 `params.command` + `params.args` 直接 spawn,不拼接 shell 字符串。 +- Windows 不默认包 `powershell.exe -Command`;只有 agent 明确要求 shell 时,command 本身就是 shell。 +- `params.cwd` 必须 resolve 到允许 workspace 或明确的 fallback;fallback 只能用于无 cwd 的 agent 兼容,并写 warning。 +- `outputByteLimit` 超限时从 buffer 开头裁掉,保留最新输出;裁剪必须在 UTF-8 字符边界。 +- `terminal/output` 返回当前 buffer、`truncated`、`exitStatus`。 +- `kill` 幂等;`release` 释放 PTY 资源但不删除已进入 chat block/debug log 的输出。 + +### 10. Plan, Modes, Config Options, Slash Commands + +- `plan` update 每次替换当前 plan entries,避免重复追加。 +- `current_mode_update` 同步 ChatStatusBar 当前 mode。 +- `session/set_mode` 继续作为 legacy mode 能力;若 agent 用 config options 暴露 mode,UI 统一展示在 config options 区。 +- `config_option_update` 要覆盖 initialize/new/load/resume 后的所有路径。 +- `available_commands_update` 进入 active session state;输入框 slash suggestions 使用该 state。 +- 用户输入 `/command arg` 仍走普通 `session/prompt`,不新增 agent-specific command RPC。 + +### 11. Extensibility + +- 所有 official update type 必须有已知处理或显式 ignored reason。 +- `_meta` 保留在 diagnostics/session metadata 中,不随意解析成业务字段。 +- 自定义 extension method/notification 继续走现有 ext debug action;名称必须保持下划线前缀约束。 +- 未知 custom update 不打断 turn,只进入 debug log。 + +## Shared Types and IPC Surface + +优先扩展已有 shared presenter/debug 类型: + +- `AcpDebugActionType` 增加 `authenticate`、`logout`、`sessionList`、`sessionImport`、`sessionResume`、`sessionDetach`、`sessionCloseRemote`、`sessionFork`。 +- 增加 renderer-safe status payload:`authMethods`、`authRequired`、`capabilities`、`externalSessions`、`sessionLinks`、`lastUsage`、`lastSessionInfo`。 +- 新 typed route/client 用于 Settings/diagnostics 查询 ACP status 和执行 auth/session debug action;legacy presenter 只保留兼容。 +- 所有用户可见 label/error 走 `src/renderer/src/i18n`。 + +## UI/UX + +Settings 中 ACP agent 详情页增加一个紧凑 diagnostics 区,不做独立大页面: + +```text +ACP Agent Detail ++--------------------------------------------------+ +| DimCode Ready v1 | +| Auth: Not required FS: on Terminal: on | +| Sessions: list/resume/close Prompt: image | ++--------------------------------------------------+ +| [Authenticate] [Sync Sessions] [Run Diagnostics] | ++--------------------------------------------------+ +| Workspace sessions | +| New Session 2026-06-02 10:10 [Import] | +| Refactor Thread linked [Open] | ++--------------------------------------------------+ +| Last update | +| available_commands_update: /web, /init | ++--------------------------------------------------+ +``` + +ChatStatusBar 保持紧凑: + +```text ++--------------------------------------------------+ +| ACP: DimCode | Mode: Agent | Model: MiMo | / cmds | ++--------------------------------------------------+ +``` + +错误态: + +```text ++--------------------------------------------------+ +| ACP auth required: Claude Login | +| [Authenticate] [Open Diagnostics] | ++--------------------------------------------------+ +``` + +## Test Strategy + +Unit tests: + +- capability snapshot parser:完整/缺失/未知字段。 +- initialize client capabilities:auth terminal 声明受实现开关控制。 +- session lifecycle gate:无 capability 不调用;有 capability 调正确 RPC。 +- session import sync:同一 `agentId + workdir + remoteSessionId` 不重复创建 conversation。 +- replay idempotency:重复 `session/load` 不重复落 message/block。 +- update buffer:`session/update` 早到、listener 后到、TTL 过期。 +- prompt formatter:只发送最后 user message;system prompt only once;image/audio/resource fallback。 +- content mapper:usage/session info/tool terminal/diff/plan/mode/config/slash commands。 +- terminal manager:tail truncation、UTF-8 boundary、args 不拼接 shell。 +- fs handler:workspace guard、1-based line、binary/large file error。 +- permission resolver:approve/deny/cancel/timeout。 + +Integration/manual matrix: + +- DimCode:init -> list -> new -> commands -> close -> resume -> prompt。 +- Claude Code ACP:init -> auth required -> authenticate flow -> cleanup。 +- Codex ACP:registry launch spec -> version drift diagnostics -> auth methods。 +- Regression:普通 non-ACP chat、MCP permission、DeepChat internal agent 不受影响。 + +Quality gates: + +```bash +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm test -- test/main/presenter/llmProviderPresenter +pnpm test -- test/main/presenter/acpProvider.test.ts +``` + +## Risks and Mitigations + +| Risk | Mitigation | +| --- | --- | +| 不同 ACP wrapper 对 auth method 字段解释不一致 | diagnostics 显示 raw auth method;未知字段保留 `_meta`;只按官方 required 字段做控制流 | +| Claude/Codex wrapper 拉起子进程后 probe 卡住 | 所有 real-agent probe 必须 timeout + process tree cleanup | +| resume/load/new 语义混用导致历史重复 | DeepChat conversation 为事实源;远端 replay 先 staging 再 fingerprint 去重;prompt formatter current-turn-only | +| terminal command 兼容性变化 | 直接 spawn 是协议正确行为;若 agent 要 shell,agent 应把 shell 作为 command | +| session title 更新覆盖用户标题 | 只更新 ACP metadata;DeepChat 用户手工标题优先 | diff --git a/docs/features/acp-v1-reliability/spec.md b/docs/features/acp-v1-reliability/spec.md new file mode 100644 index 000000000..9169e00d2 --- /dev/null +++ b/docs/features/acp-v1-reliability/spec.md @@ -0,0 +1,93 @@ +# ACP v1 Reliability Specification + +Last reviewed: 2026-06-02 + +## 背景 + +DeepChat 已经具备 ACP agent 的基本启动、初始化、`session/new`、`session/load`、`session/prompt`、文件系统、终端、模式、模型和部分 session update 映射能力。但按 ACP v1 官方协议逐项核对后,当前实现仍有几个影响可靠性的缺口:认证流程未产品化,session lifecycle 不完整,部分通知会丢,Prompt 会重复发送历史,Terminal 输出截断方向不符合规范,部分状态更新没有进入 DeepChat 状态层。 + +本目标是把 DeepChat 的 ACP 能力修到“全功能可靠”的 v1 版本:按 agent 初始化返回的 capability 精准启用功能,能稳定接入 registry agent、本地 DimCode、Claude Code ACP 和 Codex ACP,且所有协议行为都有测试或手工矩阵覆盖。 + +会话数据事实源以 DeepChat records 为准。远端 ACP agent 返回的 session 是外部资源目录,DeepChat 只做 workspace 维度的导入、同步和绑定;导入后必须转换成 DeepChat 自己的消息格式并持久化。同步不得反复重复导入,也不得把远端 metadata 直接覆盖用户在 DeepChat 内手工维护的会话数据。 + +## 资料来源 + +- ACP v1 官方入口:[Overview](https://agentclientprotocol.com/protocol/v1/overview) +- 关键协议页:[Initialization](https://agentclientprotocol.com/protocol/v1/initialization)、[Authentication](https://agentclientprotocol.com/protocol/v1/authentication)、[Session Setup](https://agentclientprotocol.com/protocol/v1/session-setup)、[Session List](https://agentclientprotocol.com/protocol/v1/session-list)、[Prompt Turn](https://agentclientprotocol.com/protocol/v1/prompt-turn) +- 客户端能力页:[Content](https://agentclientprotocol.com/protocol/v1/content)、[Tool Calls](https://agentclientprotocol.com/protocol/v1/tool-calls)、[File System](https://agentclientprotocol.com/protocol/v1/file-system)、[Terminals](https://agentclientprotocol.com/protocol/v1/terminals) +- 状态增强页:[Agent Plan](https://agentclientprotocol.com/protocol/v1/agent-plan)、[Session Modes](https://agentclientprotocol.com/protocol/v1/session-modes)、[Session Config Options](https://agentclientprotocol.com/protocol/v1/session-config-options)、[Slash Commands](https://agentclientprotocol.com/protocol/v1/slash-commands)、[Extensibility](https://agentclientprotocol.com/protocol/v1/extensibility)、[Transports](https://agentclientprotocol.com/protocol/v1/transports) +- 本仓库 registry snapshot:`resources/acp-registry/registry.json` +- 现有 ACP 入口:`src/main/presenter/llmProviderPresenter/acp/*`、`src/main/presenter/llmProviderPresenter/providers/acpProvider.ts` + +## 用户故事 + +- 作为 DeepChat 用户,我可以从 ACP registry 或本地命令启动 agent,并清楚看到该 agent 支持哪些 ACP v1 能力。 +- 作为使用 Claude Code ACP 或 Codex ACP 的用户,我可以在 DeepChat 内完成 agent 暴露的认证流程,而不是只看到失败或需要手工猜测环境变量。 +- 作为使用 DimCode 的用户,我可以按 workspace 看到远端已有 session,把需要的 session 导入或绑定到 DeepChat 会话,并持续收到 slash commands、模式、配置项和标题更新。 +- 作为开发者,我可以通过 ACP diagnostics/debug action 复现每个协议方法,定位 agent、registry、认证、session 或 terminal 问题。 +- 作为 reviewer,我可以用固定测试矩阵判断 ACP 是否符合 v1 协议,而不是只依赖单 agent 的 happy path。 + +## 成功标准 + +- `initialize` 会声明 DeepChat 实际支持的 client capabilities,并保存 agent 返回的完整 capabilities、auth methods、agent info。 +- 所有可选协议方法都按 capability gate 调用;没有 capability 时不调用、不误报。 +- `authenticate`、`logout`、`session/list`、`session/resume`、`session/close` 有可复用 presenter/debug 入口。 +- `session/update` 不会因为 listener 注册时序丢失早期通知,尤其是 DimCode 的 `available_commands_update`。 +- `session/prompt` 只发送当前用户 turn,按 prompt capabilities 发送 text/image/audio/resource/resource_link。 +- `terminal/create/output/wait_for_exit/kill/release` 符合 output byte limit 和 command args 语义。 +- `usage_update`、`session_info_update`、plan、mode、config options、slash commands 都能进入 DeepChat 状态或 debug log。 +- DeepChat conversation 是最终持久化事实源;远端 session list/load/resume 只建立或更新 `AcpSessionLink`,不会重复创建同一远端 session 的本地会话。 +- 远端历史消息导入会先转换为 DeepChat message/block 格式,再按稳定 fingerprint 去重持久化。 +- 完成后运行 `pnpm run format`、`pnpm run i18n`、`pnpm run lint`、`pnpm run typecheck` 和 ACP 相关 Vitest。 + +## 本地 agent 样本 + +| Agent | Registry/local observation | 需要覆盖的关键路径 | +| --- | --- | --- | +| DimCode | registry `dimcode@0.0.75`,本地 `dimcode 0.0.75`;`loadSession=true`;`sessionCapabilities.list/resume/close`;`promptCapabilities.image=true`、`embeddedContext=true`;`mcpCapabilities.http=true`、`sse=false` | `session/list`、`session/new`、早期 `available_commands_update`、`session/close`、`session/resume`、config options、slash commands | +| Claude Code ACP | registry `@agentclientprotocol/claude-agent-acp@0.39.0`;本地存在 `claude` 和 global `@zed-industries/claude-code-acp@0.11.0`;有 `claude-login` auth method | initialization、auth required、authenticate、process cleanup、HTTP/SSE MCP filtering | +| Codex ACP | registry `@zed-industries/codex-acp@0.15.0`,global wrapper 观察到旧版本 `0.6.0`;auth methods 包含 ChatGPT/API key 类 | registry 优先、版本漂移诊断、auth methods、no session list fallback | +| acpx | 本机 PATH 未发现名为 `acpx` 的可执行命令 | 不阻塞本目标;若后续提供准确命令名或路径,加入同一 diagnostics matrix | + +## Protocol 覆盖矩阵 + +| Protocol | ACP v1 期望 | DeepChat 当前状态 | 修复/对接目标 | +| --- | --- | --- | --- | +| Transports | ACP 使用 JSON-RPC 2.0;常见 client 以 agent subprocess + stdio 通信;MCP stdio 必须支持,HTTP/SSE 按 agent capability 过滤 | 已有 subprocess/stdout/stderr 连接、registry launch spec、MCP transport filter;需要加强版本漂移和进程树清理 | registry launch spec 仍为首选;global/local 命令只做 fallback/diagnostics;初始化、认证、E2E probe 都有 timeout 和 process tree cleanup | +| Initialization | Client 调 `initialize`,发送 `protocolVersion`、`clientCapabilities`、`clientInfo`;Agent 返回 `agentCapabilities`、`authMethods`、`agentInfo` | 已发送 `fs`、`terminal`;未声明/实现 auth capability;只解析部分 capability | 解析并保存完整 capability snapshot;不支持协议版本时关闭连接并展示错误;只声明已实现 client capabilities | +| Authentication | Agent 用 `authMethods` 暴露方法;Client 调 `authenticate({ methodId })`;`logout` 只能在 `agentCapabilities.auth.logout` 存在时调用 | 有 auth method 日志字段,但没有产品化 authenticate/logout 入口 | 增加 authenticate/logout presenter/debug/UI 入口;处理 `agent`、`env_var`、`terminal` 类型;auth required 错误转成可操作状态 | +| Session Setup: `session/new` | 创建新 session,传 `cwd` 和 MCP servers,返回 `sessionId`,可带初始 modes/models/config options | 已支持;但 listener 通常在返回后注册,早期 update 可能丢 | 新 DeepChat 会话首次使用 ACP agent 时才创建远端 session;返回后写入本地 `AcpSessionLink`;缓冲并 flush 早期 update | +| Session Setup: `session/load` | 仅 `loadSession=true` 时调用;agent 会重放历史 update,再响应 load 完成 | 已支持并在 load 前注册 listener | 用作远端 session 历史导入/重放;进入 staging buffer,转换为 DeepChat message/block 后按 fingerprint 幂等落库 | +| Session Setup: `session/resume` | 仅 `sessionCapabilities.resume` 存在时调用;不重放历史,恢复上下文后返回 | 未接入 | 用于已绑定 DeepChat conversation 的继续对话;不把远端 session 当事实源覆盖本地消息 | +| Session Setup: `session/close` | 仅 `sessionCapabilities.close` 存在时调用;agent 取消该 session 活动并释放资源 | 当前 clearSession 主要 cancel/unbind/clear persistence,没有 close 协议 | 默认本地关闭/删除只 detach link;只有用户显式选择 close remote 时才调用 `session/close` | +| Session Setup: additional directories | 仅 `sessionCapabilities.additionalDirectories` 存在时发送;必须为绝对路径 | 当前主要使用单 `cwd` | 首版保留单 `cwd`;后续若 workspace 多根目录可从能力 gate 接入,不默认发送 | +| Session List | 仅 `sessionCapabilities.list` 存在时调用;支持 `cwd` filter、cursor pagination;`session_info_update` 同步标题/更新时间 | 未接入 list;`session_info_update` 被忽略 | 按 workspace 同步远端 session catalog;用 `agentId + canonicalWorkdir + remoteSessionId` 去重;只更新 link metadata,不直接覆盖 DeepChat conversation | +| Prompt Turn | `session/prompt` 发送当前用户 message 的 ContentBlock[];`session/cancel` 中断当前 turn;prompt content 必须受 capabilities 限制 | 已 prompt/cancel;formatter 会拼温度、maxTokens 和历史 USER/ASSISTANT 文本,容易重复上下文 | formatter 改为当前 turn only;system prompt 只作为首次 session context;cancel 只针对活跃 turn | +| Content | Baseline 支持 text/resource_link;image/audio/resource 由 `promptCapabilities` 决定 | 输入侧 image 多数降级为 resource_link;输出侧 image 可转为 image block,audio/resource 偏文本化 | 输入侧按 capability 发送 image/audio/resource/resource_link/text;输出侧保留结构,不能显示的内容给清晰文本 fallback | +| Tool Calls | Agent 通过 `tool_call`、`tool_call_update` 汇报工具状态、内容、locations、raw input/output;可嵌入 terminal/diff/content | 已映射 tool_call/update,但把部分状态当 permission block;terminal/diff/locations/raw 字段展示不完整 | 保留工具 call 生命周期;补 terminal/diff/location 展示和 raw metadata;不要把普通 tool progress 误标为权限 | +| Client Permission | Client baseline method `session/request_permission` 用于工具权限确认 | 主进程已有 resolver 分发底座;需要 UI/超时/debug/test 闭环 | 复用现有 DeepChat permission overlay;补 timeout/cancel 默认 outcome;debug log 记录 permission request/result | +| File System | `fs/read_text_file`、`fs/write_text_file` 只在 client capability 声明后可用;路径绝对;line 为 1-based | 已有 handler,包含 workspace guard、二进制/大小控制 | 保持安全边界;补 1-based、越界、二进制、跨 workspace 写入测试;声明能力与真实 handler 绑定 | +| Terminals | `terminal/create` 用 `command` + `args` + `env` + `cwd`;`outputByteLimit` 超限时从开头截断,保留最新输出且字符边界有效 | 已有 terminal manager;当前把 command/args 拼进 shell,输出超限时保留开头 | 改为直接 spawn command + args;仅显式 shell 场景使用 shell;输出 buffer 保留尾部;release 后仍允许已渲染内容留在 tool call | +| Agent Plan | `plan` update 每次发送完整 entries,client 应替换当前 plan | 已映射为 plan block | 保持替换语义,补测试;plan update 不追加成重复计划 | +| Session Modes | Session 返回 modes;client 可调 `session/set_mode`;agent 可发 `current_mode_update`;官方建议逐步转向 config options | 已支持 mode 初始状态、set mode、mode update | 保持兼容;当 config options 提供 mode 等价项时,UI 优先统一展示 config options,legacy mode 继续可用 | +| Session Config Options | Session 返回 `configOptions`;client 可设置配置;agent 可发 `config_option_update` | 已有 normalize 和 state update | 补初始化/new/load/resume 全路径同步;debug action 覆盖设置失败和状态回滚 | +| Slash Commands | Agent 用 `available_commands_update` 发布命令;用户执行时作为普通 prompt 文本如 `/web query` | 已解析 available commands;早期通知可能丢 | 通过 update buffer 保证 commands 到达;UI 输入框命令候选来自 session state;执行仍走普通 prompt | +| Usage Update | Agent 可发 usage/cost/token 类状态 | 当前 mapper 明确忽略 | 增加 metadata/event/state 映射,至少在 debug 和 turn metadata 可见;后续 UI 可展示 token/cost | +| Session Info Update | Agent 可发 title、updatedAt、`_meta` 更新 session metadata | 当前 mapper 明确忽略 | 更新 `AcpSessionLink` metadata;只在本地标题仍为自动标题时建议更新,不覆盖用户手工标题 | +| Extensibility | `_meta` 可携带自定义数据;自定义 method 以前缀 `_` 命名;未知字段应兼容 | 已有 ext debug action 和部分 passthrough;未知 update 多为 warn | 保留 `_meta` 到 debug/state;未知 official update 不崩溃;未知 custom update 记录 diagnostics | +| Experimental schema fields | SDK 可能出现官网主流程未文档化的字段,例如 `sessionCapabilities.fork` | 未接入 | 只在 capability 存在时提供 debug-only 支持;不作为主 chat flow 前置条件 | + +## 非目标 + +- 不在本目标内实现 ACP v2 或未发布协议。 +- 不为某个单独 agent 写硬编码行为;DimCode、Claude Code ACP、Codex ACP 只作为兼容样本。 +- 不改变非 ACP provider 的现有 prompt、MCP、权限或 terminal 行为。 +- 不默认扩大文件系统权限;ACP fs/terminal 继续受 session workdir 和 DeepChat 安全策略约束。 +- 不做远端 session 的主动批量写入或双向同步;远端 session catalog 是可导入资源,DeepChat conversation 才是本地事实源。 + +## 约束 + +- 新 renderer-main 能力优先走 typed route / typed event / renderer API client,不复制新的 `useLegacyPresenter()` 调用模式。 +- 用户可见字符串必须加 i18n。 +- UI 改动需要保持 ChatStatusBar/Settings 现有视觉密度,不做大面积营销式页面。 +- 代码、注释、类型名、commit message 使用英文;面向 reviewer 的 SDD 文档使用中文。 diff --git a/docs/features/acp-v1-reliability/tasks.md b/docs/features/acp-v1-reliability/tasks.md new file mode 100644 index 000000000..60906abce --- /dev/null +++ b/docs/features/acp-v1-reliability/tasks.md @@ -0,0 +1,149 @@ +# ACP v1 Reliability Tasks + +## 0. Review Gate + +- [ ] Review `spec.md` protocol coverage matrix with maintainers. +- [ ] Review `plan.md` runtime flow, UI shape, and test matrix. +- [ ] Confirm all open questions are resolved before implementation. +- [ ] Keep this SDD folder active until ACP v1 reliability work is merged or deliberately abandoned. + +## 1. Capability and Initialization + +- [ ] Add tests for parsing full initialize result: `agentInfo`, `agentCapabilities`, `sessionCapabilities`, `promptCapabilities`, `authMethods`, `mcpCapabilities`. +- [ ] Extend ACP process handle with a lightweight capability snapshot. +- [ ] Parse support booleans from snapshot: `loadSession`, `sessionList`, `sessionResume`, `sessionClose`, `sessionFork`, `authLogout`. +- [ ] Update initialize debug log to include protocol version, client capabilities, agent capabilities, and auth methods. +- [ ] Ensure `buildClientCapabilities` only declares implemented capabilities. +- [ ] Add explicit initialize error categories: protocol mismatch, process exit, protocol stream closed, timeout. + +## 2. Authentication + +- [ ] Extend shared ACP debug action type with `authenticate` and `logout`. +- [ ] Add presenter/debug route for `authenticate({ agentId, methodId, workdir? })`. +- [ ] Add presenter/debug route for `logout({ agentId, workdir? })`, gated by `auth.logout`. +- [ ] Map auth-required failures into renderer-safe ACP status payload. +- [ ] Implement `agent` auth method by calling `connection.authenticate({ methodId })`. +- [ ] Implement `env_var` auth UX by surfacing missing env vars in agent settings and requiring restart/reinitialize. +- [ ] Implement `terminal` auth flow before declaring `clientCapabilities.auth.terminal=true`. +- [ ] Add auth tests for success, failure, missing method id, unsupported logout, and process cleanup. + +## 3. Session Catalog, Import, and Lifecycle + +- [ ] Extend shared ACP debug action type with `sessionList`, `sessionImport`, `sessionResume`, `sessionDetach`, `sessionCloseRemote`, and `sessionFork`. +- [ ] Add `session/list` presenter/debug path with workspace `cwd` filter and cursor pagination. +- [ ] Add `AcpSessionLink` persistence keyed by `agentId + canonicalWorkdir + remoteSessionId`. +- [ ] Add external session catalog sync that updates link metadata without creating duplicate DeepChat conversations. +- [ ] Add import path that creates or reuses a DeepChat conversation for a remote session. +- [ ] Add `session/load` import path gated by top-level `loadSession`. +- [ ] Stage replayed remote updates before converting them to DeepChat messages. +- [ ] Add message/block fingerprinting so repeated imports do not duplicate persisted messages. +- [ ] Add `session/resume` path gated by `sessionCapabilities.resume` for already linked conversations. +- [ ] Fix local runtime restore priority: linked `resume` > linked `loadSession` import/replay > `newSession`. +- [ ] Change local conversation close/delete to detach ACP link by default, without remote writes. +- [ ] Add explicit remote close path gated by `sessionCapabilities.close`. +- [ ] Change session cleanup so user stop uses `session/cancel`, while explicit remote close uses `session/close` when available. +- [ ] Preserve persisted ACP session link after process crash so recoverable agents can resume later. +- [ ] Add debug-only `session/fork` path gated by capability; do not wire it into normal chat flow yet. +- [ ] Add DimCode-shaped lifecycle tests: list empty, catalog sync, import, repeated import no duplicate messages, resume, explicit remote close. + +## 4. Session Update Routing + +- [ ] Add session update buffer keyed by `sessionId`. +- [ ] Buffer updates that arrive before listener registration. +- [ ] Flush buffered updates in order when `registerSessionListener` runs. +- [ ] Apply TTL and max-entry guard to avoid unbounded memory growth. +- [ ] Record expired buffered updates in ACP debug log. +- [ ] Add regression test for early `available_commands_update` during `session/new`. + +## 5. Prompt Turn and Input Content + +- [ ] Replace history-based ACP formatter with current-turn-only formatter. +- [ ] Remove temperature/maxTokens prompt text injection. +- [ ] Send DeepChat system prompt only once when a local conversation first binds to ACP runtime. +- [ ] Add input content mapping for text, image, audio, resource, and resource_link. +- [ ] Gate image/audio/resource by `promptCapabilities`. +- [ ] Add fallback behavior for unsupported multimodal content. +- [ ] Add tests for text-only, image-supported, image-unsupported, audio-supported, embedded context, and system prompt once. + +## 6. Session Updates and Output Content + +- [ ] Keep `agent_message_chunk` mapped to text stream and content block. +- [ ] Keep `agent_thought_chunk` mapped to reasoning stream and reasoning block. +- [ ] Update image/audio/resource/resource_link output handling to preserve structure in metadata/debug. +- [ ] Map `usage_update` into turn metadata and ACP debug log. +- [ ] Map `session_info_update` into `AcpSessionLink` metadata. +- [ ] Ensure session title update does not override user-edited DeepChat titles. +- [ ] Keep `plan` update replacement semantics. +- [ ] Add tests for usage, session info, plan replacement, and unsupported output fallback. + +## 7. Tool Calls and Permission + +- [ ] Stop treating ordinary `tool_call` progress as permission UI. +- [ ] Route only `session/request_permission` into DeepChat permission overlay. +- [ ] Preserve tool terminal output, diff path/content, locations, raw input, and raw output in block metadata/debug. +- [ ] Add permission resolver timeout with cancelled default outcome. +- [ ] Clear stale ACP permission overlays after interrupted sessions instead of throwing on unknown request ids. +- [ ] Add tests for approve, deny, cancel, timeout, missing resolver, and tool update rendering. + +## 8. File System + +- [ ] Keep `fs/read_text_file` and `fs/write_text_file` behind declared client fs capability. +- [ ] Add tests for registered workdir requirement. +- [ ] Add tests for 1-based line handling. +- [ ] Add tests for cross-workspace path rejection. +- [ ] Add tests for binary read rejection and max-size error. +- [ ] Verify write path creates only allowed files and returns protocol-shaped errors. + +## 9. Terminals + +- [ ] Change `terminal/create` to spawn `command` with `args` directly. +- [ ] Remove default command/args shell string concatenation. +- [ ] Keep cwd resolution guarded by workspace rules or explicit fallback warning. +- [ ] Change output buffer truncation to keep latest tail output. +- [ ] Preserve UTF-8 character boundary after truncation. +- [ ] Keep `kill` and `release` idempotent. +- [ ] Add tests for args quoting, tail truncation, multibyte truncation, exit status, kill, release, and missing terminal. + +## 10. Modes, Config Options, Slash Commands + +- [ ] Ensure initialize, new, load, and resume all publish normalized config state. +- [ ] Keep `session/set_mode` compatibility for agents still using session modes. +- [ ] Prefer config options in UI when both legacy mode and config option exist. +- [ ] Keep `current_mode_update` synchronized with ChatStatusBar. +- [ ] Keep `config_option_update` synchronized with config state. +- [ ] Ensure `available_commands_update` populates slash suggestions after update buffer fix. +- [ ] Skip ACP warmup when the selected workdir is unavailable, while preserving session-start fallback behavior. +- [ ] Add tests for set mode, set model/config option, current mode update, config option update, and slash command availability. + +## 11. Diagnostics UI + +- [ ] Add compact ACP diagnostics section in agent settings. +- [ ] Show protocol version, readiness, auth state, capabilities, launch source, and last error. +- [ ] Add Authenticate button only when auth methods exist. +- [ ] Add Sync Sessions button only when `sessionCapabilities.list` exists. +- [ ] Add Import/Open action for listed remote sessions. +- [ ] Add Detach action for linked local conversations. +- [ ] Add Close Remote action only behind explicit user intent and `sessionCapabilities.close`. +- [ ] Add Run Diagnostics action that executes safe initialize/list capability probes with timeout. +- [ ] Add i18n keys for all new labels and error messages. +- [ ] Add renderer tests for ready, auth required, no session list, catalog sync, imported link, duplicate import prevention, and error states. + +## 12. Registry and Real-Agent Matrix + +- [ ] Verify registry `dimcode@0.0.75` launch spec on Windows. +- [ ] Verify DimCode lifecycle: initialize, list, catalog sync, import, repeated import no duplication, commands, resume, prompt, explicit remote close. +- [ ] Verify Claude Code ACP initialize and auth-required path with timeout cleanup. +- [ ] Verify Codex ACP registry launch and local/global version drift diagnostics. +- [ ] Record exact command, version, capabilities, and result in ACP debug log or test notes. +- [ ] Keep `acpx` out of the matrix until an executable path or exact package name is available. + +## 13. Final Quality Gates + +- [ ] Run `pnpm run format`. +- [ ] Run `pnpm run i18n`. +- [ ] Run `pnpm run lint`. +- [ ] Run `pnpm run typecheck`. +- [ ] Run ACP main tests under `test/main/presenter/llmProviderPresenter`. +- [ ] Run `test/main/presenter/acpProvider.test.ts`. +- [ ] Run renderer tests for diagnostics UI if UI is changed. +- [ ] Update durable docs or archive this SDD folder after implementation is merged. diff --git a/docs/features/automatic-turn-activity-collapse/plan.md b/docs/features/automatic-turn-activity-collapse/plan.md new file mode 100644 index 000000000..f7da5f43e --- /dev/null +++ b/docs/features/automatic-turn-activity-collapse/plan.md @@ -0,0 +1,345 @@ +# Plan + +## Approach + +Implement a render-only activity grouping layer inside assistant message rendering: + +1. Expose assistant `updatedAt` on `DisplayMessage`. +2. Add a small pure helper that turns assistant blocks into render items. +3. Add a compact `MessageBlockActivityGroup.vue` component that renders the title and, when expanded, + delegates to the existing reasoning/tool-call block components. +4. Update `MessageItemAssistant.vue` to render grouped items only when the turn is settled. +5. Add localized title strings and duration formatter output. +6. Cover the helper and component behavior with renderer tests. + +No main-process, preload, IPC, route, database, or shared event contract changes are planned for the +first increment. + +Persistence decision: + +- Do not persist derived activity groups. +- Do not persist per-group expanded/collapsed state. +- Keep the default state collapsed each time a completed assistant message group is mounted. +- Use renderer computation first; add only an in-memory cache if profiling shows a real bottleneck. + +## Affected Files + +Expected renderer files: + +- `src/renderer/src/components/chat/messageListItems.ts` +- `src/renderer/src/pages/ChatPage.vue` +- `src/renderer/src/components/message/MessageItemAssistant.vue` +- `src/renderer/src/components/message/MessageBlockActivityGroup.vue` +- `src/renderer/src/components/message/messageActivityGroups.ts` +- `src/renderer/src/i18n/*/chat.json` + +Expected tests: + +- `test/renderer/components/message/MessageItemAssistant.test.ts` +- `test/renderer/components/message/MessageBlockActivityGroup.test.ts` +- `test/renderer/components/message/messageActivityGroups.test.ts` + +## Display Model + +Add `updatedAt` to the renderer-only `DisplayMessageBase`. + +```typescript +type DisplayMessageBase = { + id: string + timestamp: number + updatedAt: number + // existing fields... +} +``` + +Populate it in: + +- `toDisplayMessage(record)`: `updatedAt: record.updatedAt` +- `toStreamingMessage(...)`: `updatedAt: Date.now()` for type completeness, though streaming messages + must not be auto-grouped. + +## Render Item Model + +Keep the persisted `DisplayAssistantMessageBlock[]` unchanged. Add only a local UI render model. + +```typescript +type AssistantRenderItem = + | { + kind: 'block' + key: string + block: DisplayAssistantMessageBlock + } + | { + kind: 'activity-group' + key: string + blocks: DisplayAssistantMessageBlock[] + startedAt: number + endedAt: number + durationMs: number + reasoningCount: number + toolCallCount: number + } +``` + +This is not written to message content and is not sent over IPC. + +## Performance Model + +The grouping helper is an O(n) pass over the assistant block array, where n is the number of blocks in +one assistant message. This is expected to be cheaper than markdown rendering, tool-call detail +rendering, syntax highlighting, and media previews. + +Renderer computation is preferred over persistence because the renderer already needs the block array +to decide which existing component to render. Persisting grouping state would not remove the need to +parse/render the message content, but would add: + +- storage reads/writes for every manual toggle if UI state is persisted, +- schema or settings-key lifecycle concerns, +- stale synthetic ids after retry/regeneration/import, +- cleanup work when messages are deleted, +- extra compatibility surface for exports and variants. + +Implementation should keep the helper pure and easy to memoize. If needed, cache render items in +memory by: + +```text +messageId + content reference/hash + updatedAt + status + shouldGroupActivity +``` + +Do not add disk persistence as a performance optimization without measured renderer cost. + +## Grouping Helper + +Create a pure helper near message components: + +```typescript +buildAssistantRenderItems({ + blocks, + messageId, + messageUpdatedAt, + shouldGroup, + isInternalToolCall +}): AssistantRenderItem[] +``` + +Rules: + +- `shouldGroup === false`: every visible block returns as `kind: 'block'`. +- A block is activity when it matches the spec's collapsible activity definition. +- A block is groupable only when its status is not `loading` or `pending`. +- Consecutive groupable activity blocks are buffered. +- Any non-groupable visible block flushes the buffer before itself. +- Internal hidden tool calls are skipped exactly as they are today. + +Keying: + +- Use stable block ids/tool call ids when present. +- Fall back to `messageId:index`. +- Group key can be `activity:${messageId}:${firstIndex}:${lastIndex}`. + +## Turn Settled Gate + +In `MessageItemAssistant.vue`, compute: + +```typescript +const shouldGroupActivity = computed(() => { + if (resolvedIsInGeneratingThread.value) return false + if (currentMessage.value.status === 'pending') return false + return true +}) +``` + +If implementation finds paused user-interaction turns have `status: pending` even after stream end, +do not broaden the first increment. Keep pending turns ungrouped unless a reliable existing signal +already distinguishes inactive pending from active streaming. + +This keeps the behavior strict and avoids hiding activity while the turn is still live. + +## Activity Group Component + +`MessageBlockActivityGroup.vue` props: + +```typescript +defineProps<{ + blocks: DisplayAssistantMessageBlock[] + messageId: string + threadId: string + usage: DisplayMessageUsage + startedAt: number + endedAt: number + reasoningCount: number + toolCallCount: number +}>() +``` + +State: + +- `isExpanded = false` by default. +- Local state only; no config setting and no message metadata write. +- Toggling emits `toggle-collapse` so `MessageItemAssistant` can reuse the existing + `variantChanged` notification path. + +Rendering: + +- Title row mirrors `ThinkContent` text size/color/chevron. +- The title row is a real button with reset button styles. +- Expanded children render in a flat `flex flex-col gap-1.5` container with no added left padding. +- Reasoning blocks use `MessageBlockThink`. +- Tool-call blocks use `MessageBlockToolCall`. +- Artifact thinking can reuse the same visual pattern as reasoning if the codebase already renders + it through `MessageBlockThink`; otherwise keep it as an explicit non-goal for the first + implementation pass. + +## Title Text + +Recommended Chinese title: + +```text +已经工作了 {duration} · {reasoningCount} 段思考 · {toolCallCount} 次工具调用 +``` + +Recommended English title: + +```text +Worked for {duration} · {reasoningCount} thought(s) · {toolCallCount} tool call(s) +``` + +Omit count segments when the count is `0`: + +- reasoning only: `已经工作了 12秒 · 1 段思考` +- tool calls only: `已经工作了 12秒 · 2 次工具调用` + +## Duration Formatting + +Add a local formatter in the grouping helper or a small companion file. Keep the formatter pure and +pass localized unit labels from `MessageBlockActivityGroup.vue`: + +```typescript +type ActivityDurationLabels = { + day: string + hour: string + minute: string + second: string +} + +formatActivityDuration(durationMs: number, labels: ActivityDurationLabels): string +``` + +Implementation detail: + +- Clamp non-finite values to `0`. +- `totalSeconds = Math.max(0, Math.floor(durationMs / 1000))`. +- Compute days/hours/minutes/seconds. +- Concatenate non-zero units and include seconds when all larger units are zero. +- Use localized unit labels from `chat.activityCollapse.duration.*`. +- Unit labels may include spacing when the locale needs spaces between duration segments. + +Avoid `Intl.DurationFormat` for now because support varies and would add fallback complexity. + +## Styling + +Use the current reasoning header as the visual reference: + +- `text-xs` +- `leading-4` +- muted foreground color consistent with `ThinkContent` +- `lucide:chevron-right` when collapsed +- `lucide:chevron-down` when expanded +- no border/card wrapper +- no additional content indentation +- `self-start` width title, not full-width panel + +ASCII layout target: + +```text +Assistant row + icon | message column + | info line + | > Worked for 1m 12s · 1 thought · 2 tool calls + | visible answer text +``` + +Expanded target: + +```text +Assistant row + icon | message column + | v Worked for 1m 12s · 1 thought · 2 tool calls + | Thinking for 18s + | [tool] shell_command + | [tool] read_file + | visible answer text +``` + +## Compatibility + +- Existing persisted messages render unchanged except for the new collapsed presentation. +- Export, context building, compaction, and model input are unaffected because stored blocks are not + changed. +- Copy behavior remains based on `currentContent`. +- Existing reasoning global setting `think_collapse` remains scoped to individual reasoning content + when a group is expanded. The group's default collapsed state is independent. +- Manual activity-group expansion is intentionally not remembered across reloads in the first + increment. +- Search and trace behavior are unaffected. + +## Risks + +1. **Duration overcounts separate groups in the same message.** + - Mitigation: first increment uses the only reliable end timestamp available in the current display + model, the final assistant message `updatedAt`. Do not add backend block update timestamps unless + the UX proves this is misleading. +2. **Pending paused turns may remain long.** + - Mitigation: keep first increment strict. If needed later, add a reliable settled-state signal + rather than guessing from block shapes. +3. **Rendering logic duplication.** + - Mitigation: group component renders only the narrow set of groupable block types. +4. **Layout shift after reload.** + - Mitigation: collapse only after stream completion reload; this is expected and solves transcript + length. Keep scrolling behavior covered by existing message-list auto-scroll tests if affected. +5. **Repeated renderer grouping work.** + - Mitigation: keep grouping pure and O(n). Add in-memory memoization only if profiling shows the + helper is material compared with existing block rendering. + +## Test Strategy + +Unit tests: + +- Group consecutive reasoning/tool-call blocks after a settled turn. +- Split groups around `content` blocks. +- Skip grouping when `shouldGroup` is false. +- Skip `loading` and `pending` activity blocks. +- Preserve internal hidden tool-call behavior. +- Format duration for seconds, minutes, hours, and days. +- Verify grouping helper remains deterministic so in-memory memoization is safe if added later. + +Component tests: + +- `MessageItemAssistant` renders `MessageBlockActivityGroup` for final messages. +- `MessageItemAssistant` renders raw `MessageBlockThink` / `MessageBlockToolCall` while pending. +- `MessageBlockActivityGroup` starts collapsed. +- Clicking title expands and shows child reasoning/tool-call stubs. +- Clicking again collapses. +- Remounting a group returns it to the default collapsed state. +- Title includes duration and counts. + +Manual QA: + +- Long agent turn with multiple tool calls. +- Turn with only final text and no activity. +- Turn with reasoning only. +- Turn with tool calls only. +- Error final message. +- Dark mode. +- Narrow chat width. + +Quality gates after implementation: + +```text +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm test -- MessageItemAssistant +pnpm test -- MessageBlockActivityGroup +``` diff --git a/docs/features/automatic-turn-activity-collapse/spec.md b/docs/features/automatic-turn-activity-collapse/spec.md new file mode 100644 index 000000000..be7fa8007 --- /dev/null +++ b/docs/features/automatic-turn-activity-collapse/spec.md @@ -0,0 +1,285 @@ +# Automatic Turn Activity Collapse + +## User Need + +DeepChat agent turns can include multiple reasoning sections and tool calls before the final answer. +When every intermediate block remains expanded, one assistant turn becomes too long to scan and the +final conclusion is pushed far down the transcript. + +Users need completed thinking/tool activity to collapse automatically after the turn settles, while +keeping the final text answer visible and preserving the current message layout. + +## Goal + +After an assistant turn is complete, automatically group completed reasoning and visible tool-call +blocks into a compact collapsible title row. The title should reuse the visual language of the +current reasoning-content header, show how long the grouped work took, and expand/collapse on click. + +The grouping is a renderer-only presentation transform over the existing assistant message blocks. +It must not create a new persisted message type, database table, or backend transport contract unless +implementation proves the renderer cannot compute the required state reliably. + +## Current Structure Summary + +- Chat messages are loaded in `ChatPage.vue` from `ChatMessageRecord` into `DisplayMessage`. +- Assistant content is stored as a JSON array of `AssistantMessageBlock`. +- `MessageItemAssistant.vue` renders assistant blocks in order: + - `content` through `MessageBlockContent` + - `reasoning_content` through `MessageBlockThink` + - `tool_call` through `MessageBlockToolCall` + - other block types through their existing block components +- Streaming updates apply blocks inline while the assistant message is pending, then stream end reloads + the persisted message. +- Reasoning UI uses `ThinkContent.vue`, whose title row is compact text plus a chevron/ellipsis. + +## UX Requirements + +1. During streaming, keep the current layout and do not auto-collapse new blocks. +2. After the turn settles, collapse completed reasoning/tool-call activity by default. +3. Keep regular assistant text content visible by default. +4. Clicking the activity title toggles the grouped activity open/closed. +5. The activity title must not introduce a new indentation level for the expanded blocks. +6. The expanded content must reuse existing reasoning/tool-call block rendering. +7. Pending user actions, errors, media, plans, and normal text content must remain visible unless they + already have their own existing collapsed behavior. +8. Internal tool calls that are already hidden, such as internal `update_plan`, must stay hidden. +9. Copy behavior must continue to use the original assistant content array, so existing + `copyWithCotEnabled` behavior remains unchanged. +10. The feature should not add a user setting in the first increment. + +## Collapsible Activity Definition + +The first increment treats these completed block types as collapsible activity: + +- `reasoning_content` with non-empty `content` +- `artifact-thinking` with non-empty `content` +- visible `tool_call` blocks, excluding current internal tool calls + +The first increment does not auto-collapse: + +- `content` +- `plan` +- `action` +- `error` +- `search` +- `image` +- `audio` +- `video` +- any block with `status` still `loading` or `pending` + +## Grouping Rules + +1. Build render groups only when the assistant turn is settled. +2. Preserve the original block order. +3. Consecutive collapsible activity blocks become one activity group. +4. A visible non-activity block flushes the current activity group before rendering that block. +5. If the final blocks are collapsible activity and the turn is settled, render them as the final + activity group. +6. If a group contains only one collapsible block, still collapse it automatically after turn end. + +## Settled Turn Definition + +A turn is considered settled when the renderer is no longer receiving stream updates for that +assistant message and the persisted message has been reloaded after stream completion or failure. + +Implementation should prefer existing signals: + +- `chat.stream.completed` / `chat.stream.failed` already trigger `loadMessages`. +- Persisted `ChatMessageRecord.updatedAt` is available after reload. +- `MessageItemAssistant` already receives message status and generating-thread state. + +The feature must not collapse blocks while the current assistant message is actively streaming. + +## Persistence Decision + +The first increment must not persist either the derived activity groups or each group's expanded / +collapsed UI state. + +Keep these concepts separate: + +- Stored assistant blocks: existing source of truth, unchanged. +- Derived activity groups: renderer-only, rebuilt from the block array. +- Expanded/collapsed state: local component state, default collapsed after the group is mounted. + +Reasons: + +1. Grouping is a cheap linear pass over blocks that are already parsed for rendering. +2. Persisting derived groups would duplicate source data and introduce invalidation rules when a + message is edited, retried, regenerated, imported, or rendered as a variant. +3. Persisting per-group UI state would require stable synthetic group ids and storage cleanup for + deleted messages, without improving the main goal of reducing long completed turns. +4. A stored state could make old long turns unexpectedly expanded on later visits, weakening the + default compact transcript behavior. + +If profiling later shows grouping is expensive, prefer an in-memory renderer cache keyed by message +id, content identity, `updatedAt`, status, and the grouping gate. Do not add disk persistence for +performance unless measurements show the renderer pass is a bottleneck. + +## Duration Display + +The collapsed title shows the duration from the grouped work's first creation timestamp to the +containing assistant message's final update timestamp. + +Start timestamp: + +- Use the first folded block's `timestamp`. + +End timestamp: + +- Use the containing assistant message's `updatedAt` after it is exposed to the display message. +- Clamp to the start timestamp if malformed or earlier than start. + +Formatting: + +- Use whole seconds. +- Omit leading zero units. +- Always include seconds when duration is under one minute. +- Maximum display granularity is days, hours, minutes, seconds. +- Unit labels must come from i18n, not hardcoded locale checks. + +Examples: + +- `已经工作了 8秒` +- `已经工作了 3分钟12秒` +- `已经工作了 2小时4分钟9秒` +- `已经工作了 1天3小时10分钟2秒` + +English equivalent: + +- `Worked for 8s` +- `Worked for 3m 12s` +- `Worked for 2h 4m 9s` +- `Worked for 1d 3h 10m 2s` + +## ASCII UI + +Before: + +```text +Assistant GPT-5 10:20 + +Thinking for 18s v +Reasoning text... +More reasoning text... + +[tool] shell_command pnpm run lint + params... + response... + +[tool] read_file MessageItemAssistant.vue + params... + response... + +Final answer starts here... +``` + +After, collapsed: + +```text +Assistant GPT-5 10:20 + +> 已经工作了 2分钟13秒 · 1 段思考 · 2 次工具调用 + +Final answer starts here... +``` + +After, expanded: + +```text +Assistant GPT-5 10:20 + +v 已经工作了 2分钟13秒 · 1 段思考 · 2 次工具调用 +Thinking for 18s v +Reasoning text... +More reasoning text... + +[tool] shell_command pnpm run lint + params... + response... + +[tool] read_file MessageItemAssistant.vue + params... + response... + +Final answer starts here... +``` + +No extra indentation: + +```text +v 已经工作了 2分钟13秒 · 1 段思考 · 2 次工具调用 +Thinking for 18s +[tool] shell_command +Final answer starts here +^ same left edge +``` + +Multiple activity phases in one turn: + +```text +Assistant GPT-5 10:20 + +> 已经工作了 42秒 · 1 段思考 · 1 次工具调用 + +I found the relevant file and will adjust the renderer grouping. + +> 已经工作了 1分钟9秒 · 1 段思考 · 2 次工具调用 + +The implementation is complete. +``` + +## Accessibility + +1. The title row is a `button`. +2. It exposes `aria-expanded`. +3. It has an accessible label that includes the duration and whether it expands or collapses the + activity group. +4. Keyboard activation uses native button behavior. +5. The focus ring should match existing button/focus styling. + +## Acceptance Criteria + +1. Completed assistant messages with reasoning/tool-call blocks show collapsed activity groups by + default. +2. Active streaming messages continue to show reasoning/tool-call progress as they do today. +3. Clicking a collapsed group expands it and shows the original reasoning/tool-call components in the + original order. +4. Clicking an expanded group collapses it again. +5. The expanded content aligns with the group title and does not shift right compared with the + collapsed title. +6. The final assistant text content remains visible without an extra click. +7. Duration text is computed from the first folded block timestamp to the assistant message + `updatedAt`, formatted up to days/hours/minutes/seconds. +8. Existing hidden internal tool calls remain hidden. +9. Existing message copy behavior is unchanged. +10. Grouping and expanded/collapsed UI state are not persisted in the first increment. +11. Tests cover grouping, duration formatting, default collapsed state after turn completion, and no + grouping during active streaming. + +## Non-goals + +- No backend compaction or summarization of reasoning/tool results. +- No deletion or mutation of stored assistant blocks. +- No persistent per-user collapse preference. +- No persisted per-message or per-group expansion state. +- No new database table or migration. +- No auto-collapse for pending permission/question cards. +- No visual redesign of tool-call details. + +## Constraints + +- Keep the change focused in renderer message rendering. +- Follow Vue 3 Composition API and existing Tailwind utility style. +- Use existing i18n files for user-facing text. +- Do not duplicate large message rendering logic unless extracted into a small local helper. +- Avoid adding broad fallback heuristics for malformed historical data; clamp invalid duration and + render original blocks if grouping cannot be computed. + +## Open Questions + +Resolved: the first increment should be renderer-only and should not persist synthetic activity +blocks. + +Resolved: grouping should happen only after a turn settles, not during streaming. + +Resolved: the group title should not create an indentation wrapper around expanded content. diff --git a/docs/features/automatic-turn-activity-collapse/tasks.md b/docs/features/automatic-turn-activity-collapse/tasks.md new file mode 100644 index 000000000..0192ccd0e --- /dev/null +++ b/docs/features/automatic-turn-activity-collapse/tasks.md @@ -0,0 +1,21 @@ +# Tasks + +- [x] Inspect current assistant message rendering, reasoning header, tool-call block, stream end flow, + and display message conversion. +- [x] Write SDD spec with UX requirements, grouping rules, duration rules, ASCII UI, and acceptance + criteria. +- [x] Write implementation plan with affected files, render-only data model, component plan, and test + strategy. +- [x] Document persistence and performance decision: renderer-derived grouping, local-only expansion + state, no disk persistence in the first increment. +- [x] Add `updatedAt` to renderer display message types and conversion. +- [x] Add pure activity grouping and duration formatting helper. +- [x] Add `MessageBlockActivityGroup.vue`. +- [x] Update `MessageItemAssistant.vue` to render grouped activity after settled turns. +- [x] Add i18n keys for activity group title and duration units. +- [x] Add renderer unit/component tests. +- [x] Run `pnpm run format`. +- [x] Run `pnpm run i18n`. +- [x] Run `pnpm run lint`. +- [x] Run focused renderer tests. +- [x] Run `pnpm run typecheck` if the touched type surface is broader than expected. diff --git a/docs/features/v1-0-5-beta-7-release/plan.md b/docs/features/v1-0-5-beta-7-release/plan.md deleted file mode 100644 index a52636860..000000000 --- a/docs/features/v1-0-5-beta-7-release/plan.md +++ /dev/null @@ -1,50 +0,0 @@ -# Implementation Plan - v1.0.5-beta.7 Release - -## Current State - -- Current branch: `dev`. -- Current version: `1.0.5-beta.7`. -- Target version: `1.0.5-beta.7`. -- Remote `v1.0.5-beta.7` exists on `894110a4a`. -- Latest `origin/dev` includes additional commits after `894110a4a`. -- No local or remote `release/v1.0.5-beta.7` branch exists. -- `git fetch --tags --prune` reports historical tag mismatches for `v1.0.0-beta.7` and - `v1.0.5-beta.3`; these are unrelated to the target release and must not be replaced. - -## Release Notes Source - -Summarize first-parent commits after `v1.0.5-beta.6`: - -- Agent session transfer flow. -- NewAPI routing and capability overlay fixes. -- AI SDK system prompt request handling. -- Workspace file reference insertion fix. -- Floating button position persistence. -- Image-capable model chat switching fix. -- Collapsed sidebar agent click expansion fix. -- Workspace Git diff panel rendering improvements. -- Plan model styling fix. - -## Steps - -1. Keep `package.json` on `1.0.5-beta.7`. -2. Update the `v1.0.5-beta.7` changelog entry for the latest merged commits. -3. Run release checks. -4. Commit release metadata on `dev`. -5. Push `dev`. -6. Fast-forward `main` to the latest release-ready commit. -7. Move the local `v1.0.5-beta.7` tag to the latest release-ready commit. -8. Do not push the moved tag until the maintainer wants to rerun the release action. - -## Validation - -- `pnpm run format` -- `pnpm run i18n` -- `pnpm run lint` -- `pnpm run typecheck` - -## Rollback - -If validation fails before pushing, keep the branch on `dev` and fix the metadata or report the -blocking check. If the retargeted tag must be abandoned before triggering the action, reset only the -local tag back to the remote tag commit and leave the remote tag unchanged. diff --git a/docs/features/v1-0-5-beta-7-release/spec.md b/docs/features/v1-0-5-beta-7-release/spec.md deleted file mode 100644 index b3099e0c7..000000000 --- a/docs/features/v1-0-5-beta-7-release/spec.md +++ /dev/null @@ -1,53 +0,0 @@ -# v1.0.5-beta.7 Release - -## Context - -DeepChat v1.0.5-beta.6 has been published. The `dev` branch now contains a small set of -user-visible fixes and one agent session transfer feature that should be made available through the -next beta release. - -An initial `v1.0.5-beta.7` tag was created on `894110a4a`, but new code was merged afterward. The -beta should be retargeted to the latest release-ready commit before rerunning the release action. - -## User Need - -Beta users need a new prerelease build that includes the latest agent transfer flow, workspace input -fixes, model routing fixes, and UI stability improvements without waiting for the next stable -release. - -## Goals - -- Prepare release metadata for `v1.0.5-beta.7`. -- Keep release notes concise and bilingual, with English bullets first and Chinese bullets second. -- Cut a disposable `release/v1.0.5-beta.7` branch from the release-ready `dev` commit. -- Retarget the beta to the latest release-ready commit without triggering a new release action until - the maintainer is ready. - -## Non-goals - -- No product behavior changes beyond release metadata. -- No release-only code changes outside `package.json`, `CHANGELOG.md`, and this SDD record. -- No replacement of existing tags that differ between local and remote history. - -## Acceptance Criteria - -1. `package.json` reports version `1.0.5-beta.7`. -2. `CHANGELOG.md` contains a top entry for `v1.0.5-beta.7` dated `2026-06-01`. -3. The changelog entry summarizes commits after `v1.0.5-beta.6`. -4. Required release checks pass: `pnpm run format`, `pnpm run i18n`, and `pnpm run lint`. -5. `main` is fast-forwarded to the latest release-ready commit. -6. The local `v1.0.5-beta.7` tag points to that latest release-ready commit. -7. The remote `v1.0.5-beta.7` tag is not replaced until the maintainer explicitly wants to trigger - the release action again. - -## Constraints - -- Follow the repository release flow in `docs/release-flow.md`. -- Keep `dev` as the integration branch. -- Treat `release/v1.0.5-beta.7` as disposable and identical to a commit already on `dev`. -- Do not replace or delete existing mismatched historical tags without maintainer approval. -- Do not push the retargeted `v1.0.5-beta.7` tag while the release action should remain paused. - -## Open Questions - -None. diff --git a/docs/features/v1-0-5-beta-7-release/tasks.md b/docs/features/v1-0-5-beta-7-release/tasks.md deleted file mode 100644 index caec05ab3..000000000 --- a/docs/features/v1-0-5-beta-7-release/tasks.md +++ /dev/null @@ -1,24 +0,0 @@ -# Tasks - v1.0.5-beta.7 Release - -- [x] Confirm current branch, working tree, existing release branches, and existing tags. -- [x] Confirm `v1.0.5-beta.7` and `release/v1.0.5-beta.7` do not exist locally or remotely. -- [x] Pull latest `dev` with fast-forward-only behavior. -- [x] Update `package.json` to `1.0.5-beta.7`. -- [x] Add `CHANGELOG.md` notes for `v1.0.5-beta.7`. -- [x] Run `pnpm run format`. -- [x] Run `pnpm run i18n`. -- [x] Run `pnpm run lint`. -- [x] Run `pnpm run typecheck`. -- [x] Commit initial release metadata on `dev`. -- [x] Push initial release metadata to `dev`. -- [x] Create and push initial `v1.0.5-beta.7`. -- [x] Fast-forward initial `main` to `v1.0.5-beta.7`. -- [x] Clean up initial `release/v1.0.5-beta.7`. -- [x] Pull latest merged code after the initial tag. -- [x] Update `v1.0.5-beta.7` notes for the latest merged commits. -- [x] Run release checks after the latest notes update. -- [ ] Commit latest release metadata on `dev`. -- [ ] Push latest `dev`. -- [ ] Fast-forward `main` to the latest release-ready commit. -- [ ] Move local `v1.0.5-beta.7` to the latest release-ready commit. -- [ ] Push the moved `v1.0.5-beta.7` tag only when ready to trigger release action. diff --git a/docs/issues/remove-gpt5-temperature-hardcode/plan.md b/docs/issues/remove-gpt5-temperature-hardcode/plan.md new file mode 100644 index 000000000..3a180f6b8 --- /dev/null +++ b/docs/issues/remove-gpt5-temperature-hardcode/plan.md @@ -0,0 +1,19 @@ +# Remove GPT-5 Temperature Hardcode Plan + +## Implementation + +- Extend `useModelCapabilities` to expose `supportsTemperatureControl`, using + `supportsTemperatureControl` first and `temperatureCapability` as the fallback. +- Remove `isGPT5Model` from `useModelTypeDetection` and stop passing it through `ChatConfig`. +- Update `useChatConfigFields` to show temperature unless `supportsTemperatureControl` is exactly + `false`. + +## Test Strategy + +- Update model type detection tests to cover only type and provider detection plus reasoning loading. +- Add focused `useChatConfigFields` tests for unsupported, supported, and unknown temperature + capability states. + +## Compatibility + +- Capability `null` preserves the previous default-visible behavior for unknown and custom models. diff --git a/docs/issues/remove-gpt5-temperature-hardcode/spec.md b/docs/issues/remove-gpt5-temperature-hardcode/spec.md new file mode 100644 index 000000000..d4538fc57 --- /dev/null +++ b/docs/issues/remove-gpt5-temperature-hardcode/spec.md @@ -0,0 +1,26 @@ +# Remove GPT-5 Temperature Hardcode + +## User Story + +As a user configuring chat generation settings, I want temperature controls to follow model +capability metadata instead of frontend model-name matching, so supported models keep their controls +and unsupported models hide them consistently. + +## Acceptance Criteria + +- ChatConfig hides the temperature slider only when model capabilities explicitly report + temperature control as unsupported. +- GPT-5-like model IDs do not automatically hide temperature when capabilities report support. +- Missing or unavailable capability data keeps the existing conservative behavior and shows + temperature. + +## Non-goals + +- Do not change backend request filtering. +- Do not update provider model database contents. +- Do not introduce OpenAI-specific frontend special cases. + +## Constraints + +- Reuse the existing `models.getCapabilities` surface. +- Keep the change scoped to renderer configuration UI behavior. diff --git a/docs/issues/remove-gpt5-temperature-hardcode/tasks.md b/docs/issues/remove-gpt5-temperature-hardcode/tasks.md new file mode 100644 index 000000000..81e08582c --- /dev/null +++ b/docs/issues/remove-gpt5-temperature-hardcode/tasks.md @@ -0,0 +1,7 @@ +# Remove GPT-5 Temperature Hardcode Tasks + +- [x] Add SDD records. +- [x] Expose temperature support from `useModelCapabilities`. +- [x] Replace GPT-5 string matching in ChatConfig field generation. +- [x] Update and add renderer composable tests. +- [x] Run targeted tests and project checks. diff --git a/docs/spec-driven-dev.md b/docs/spec-driven-dev.md index 9a89f76c9..01fbe16ca 100644 --- a/docs/spec-driven-dev.md +++ b/docs/spec-driven-dev.md @@ -14,6 +14,10 @@ Keep every active change in a lightweight SDD folder so reviewers can find the i - `docs/issues//` - bug fixes, regressions, failing tests, CI failures, reliability issues, and prompt/runtime problems - `docs/architecture//` - refactors, migrations, dependency boundaries, shared contracts, runtime architecture, and cross-module design +Pure release metadata work is exempt from SDD. Version bumps, `CHANGELOG.md` updates, release branch +management, tags, and release PR preparation should follow `docs/release-flow.md` without creating a +release-specific SDD folder. + Each active goal folder contains: - `spec.md` - user stories, acceptance criteria, non-goals, constraints, open questions diff --git a/package.json b/package.json index 49847f82c..49928257b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "1.0.5-beta.7", + "version": "1.0.5-beta.8", "description": "DeepChat,一个简单易用的 Agent 客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", diff --git a/src/main/presenter/agentRuntimePresenter/index.ts b/src/main/presenter/agentRuntimePresenter/index.ts index 6b43db139..d6c56b45b 100644 --- a/src/main/presenter/agentRuntimePresenter/index.ts +++ b/src/main/presenter/agentRuntimePresenter/index.ts @@ -202,6 +202,15 @@ type ActiveProviderPermission = { resolve: (granted: boolean) => Promise } +type ProviderPermissionInteractionInput = { + sessionId: string + messageId: string + toolCallId: string + requestId: string + permissionType: 'read' | 'write' | 'all' | 'command' + granted: boolean +} + type PersistedSessionGenerationRow = { provider_id: string model_id: string @@ -4528,32 +4537,68 @@ export class AgentRuntimePresenter implements IAgentImplementation { }) } - private async resolveProviderPermissionInteraction(input: { - sessionId: string - messageId: string - toolCallId: string - requestId: string - permissionType: 'read' | 'write' | 'all' | 'command' - granted: boolean - }): Promise { + private async resolveProviderPermissionInteraction( + input: ProviderPermissionInteractionInput + ): Promise { const active = this.activeProviderPermissions.get(input.requestId) + let resolution: { status: 'resolved' } | { status: 'stale'; error: unknown } try { - if (active) { - await active.resolve(input.granted) - } else { - await this.llmProviderPresenter.resolveAgentPermission(input.requestId, input.granted) - this.updatePersistedProviderPermissionState( - input.messageId, - input.toolCallId, - input.requestId, - input.permissionType, - input.granted - ) - } + resolution = await this.resolveProviderPermissionSafely( + active + ? () => active.resolve(input.granted) + : () => this.llmProviderPresenter.resolveAgentPermission(input.requestId, input.granted) + ) } finally { this.activeProviderPermissions.delete(input.requestId) } + + if (active && resolution.status === 'resolved') { + return + } + + if (resolution.status === 'stale') { + console.warn( + `[DeepChatAgent] Clearing stale ACP permission request ${input.requestId}:`, + resolution.error + ) + } + + this.updatePersistedProviderPermissionState( + input.messageId, + input.toolCallId, + input.requestId, + input.permissionType, + resolution.status === 'resolved' ? input.granted : false, + resolution.status === 'stale' ? 'Permission request expired.' : undefined + ) + this.finishProviderPermissionInteraction(input.sessionId, input.messageId) + } + + private async resolveProviderPermissionSafely( + task: () => Promise + ): Promise<{ status: 'resolved' } | { status: 'stale'; error: unknown }> { + try { + await task() + return { status: 'resolved' } + } catch (error) { + if (!this.isUnknownAcpPermissionRequestError(error)) { + throw error + } + return { status: 'stale', error } + } + } + + private isUnknownAcpPermissionRequestError(error: unknown): boolean { + const message = + error instanceof Error ? error.message : typeof error === 'string' ? error : undefined + return Boolean(message?.startsWith('Unknown ACP permission request:')) + } + + private finishProviderPermissionInteraction(sessionId: string, messageId: string): void { + this.messageStore.updateMessageStatus(messageId, 'sent') + this.setSessionStatus(sessionId, 'idle') + this.emitMessageRefresh(sessionId, messageId) } private updatePersistedProviderPermissionState( @@ -4561,7 +4606,8 @@ export class AgentRuntimePresenter implements IAgentImplementation { toolCallId: string, requestId: string, permissionType: 'read' | 'write' | 'all' | 'command', - granted: boolean + granted: boolean, + deniedMessage = 'User denied the request.' ): void { const message = this.messageStore.getMessage(messageId) if (!message || message.role !== 'assistant') { @@ -4582,6 +4628,9 @@ export class AgentRuntimePresenter implements IAgentImplementation { } this.markPermissionResolved(actionBlock, granted, permissionType) + if (!granted) { + actionBlock.content = deniedMessage + } this.messageStore.updateAssistantContent(messageId, blocks) } diff --git a/src/main/presenter/llmProviderPresenter/acp/acpCapabilities.ts b/src/main/presenter/llmProviderPresenter/acp/acpCapabilities.ts index 0aad138c2..b1e44459a 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpCapabilities.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpCapabilities.ts @@ -3,6 +3,50 @@ import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' export interface AcpCapabilityOptions { enableFs?: boolean enableTerminal?: boolean + enableTerminalAuth?: boolean +} + +export interface AcpCapabilitySupport { + loadSession: boolean + sessionList: boolean + sessionResume: boolean + sessionClose: boolean + sessionFork: boolean +} + +export interface AcpCapabilitySnapshot { + protocolVersion?: schema.ProtocolVersion + agentInfo?: schema.Implementation | null + agentCapabilities?: schema.AgentCapabilities + sessionCapabilities?: schema.SessionCapabilities + promptCapabilities?: schema.PromptCapabilities + authMethods: schema.AuthMethod[] + mcpCapabilities?: schema.McpCapabilities + supports: AcpCapabilitySupport +} + +export function buildCapabilitySnapshot( + initializeResult: schema.InitializeResponse +): AcpCapabilitySnapshot { + const agentCapabilities = initializeResult.agentCapabilities + const sessionCapabilities = agentCapabilities?.sessionCapabilities + + return { + protocolVersion: initializeResult.protocolVersion, + agentInfo: initializeResult.agentInfo, + agentCapabilities, + sessionCapabilities, + promptCapabilities: agentCapabilities?.promptCapabilities, + authMethods: initializeResult.authMethods ?? [], + mcpCapabilities: agentCapabilities?.mcpCapabilities, + supports: { + loadSession: Boolean(agentCapabilities?.loadSession), + sessionList: Boolean(sessionCapabilities?.list), + sessionResume: Boolean(sessionCapabilities?.resume), + sessionClose: Boolean(sessionCapabilities?.close), + sessionFork: Boolean(sessionCapabilities?.fork) + } + } } /** @@ -27,5 +71,11 @@ export function buildClientCapabilities( caps.terminal = true } + if (options.enableTerminal !== false && options.enableTerminalAuth) { + caps.auth = { + terminal: true + } + } + return caps } diff --git a/src/main/presenter/llmProviderPresenter/acp/acpContentMapper.ts b/src/main/presenter/llmProviderPresenter/acp/acpContentMapper.ts index 6b9abb4ce..241c09f93 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpContentMapper.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpContentMapper.ts @@ -25,6 +25,19 @@ export interface MappedContent { }> /** Unified ACP session config state */ configState?: AcpConfigState + /** ACP session metadata update */ + sessionInfo?: { + title?: string | null + updatedAt?: string | null + meta?: Record | null + } + /** ACP session usage/context update */ + usage?: { + used: number + size: number + cost?: schema.Cost | null + meta?: Record | null + } } interface ToolCallState { @@ -76,8 +89,10 @@ export class AcpContentMapper { this.handleConfigOptionUpdate(update, payload) break case 'session_info_update': + this.handleSessionInfoUpdate(update, payload) + break case 'usage_update': - // These updates are useful for stateful clients but do not affect chat rendering. + this.handleUsageUpdate(update, payload) break case 'user_message_chunk': // ignore echo @@ -291,6 +306,29 @@ export class AcpContentMapper { }) } + private handleSessionInfoUpdate( + update: Extract, + payload: MappedContent + ) { + payload.sessionInfo = { + title: update.title, + updatedAt: update.updatedAt, + meta: update._meta ?? null + } + } + + private handleUsageUpdate( + update: Extract, + payload: MappedContent + ) { + payload.usage = { + used: update.used, + size: update.size, + cost: update.cost ?? null, + meta: update._meta ?? null + } + } + private formatToolCallContent( contents?: schema.ToolCallContent[] | null, joiner: string = '\n' diff --git a/src/main/presenter/llmProviderPresenter/acp/acpMessageFormatter.ts b/src/main/presenter/llmProviderPresenter/acp/acpMessageFormatter.ts index 976f4b32e..bcebbb967 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpMessageFormatter.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpMessageFormatter.ts @@ -1,60 +1,73 @@ import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' -import type { ChatMessage, ModelConfig } from '@shared/presenter' +import type { ChatMessage } from '@shared/presenter' -interface NormalizedContent { - type: 'text' | 'resource_link' - value: string +interface FormatOptions { + promptCapabilities?: schema.PromptCapabilities + includeSystemPrompt?: boolean } +interface FormatResult { + blocks: schema.ContentBlock[] + includedSystemPrompt: boolean +} + +type NormalizedContent = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string; uri?: string } + | { type: 'audio'; data: string; mimeType: string } + | { type: 'resource_link'; uri: string; name?: string; mimeType?: string } + | { type: 'resource'; uri: string; text: string; mimeType?: string } + +const DATA_URL_PATTERN = /^data:([^;,]+);base64,(.*)$/s + export class AcpMessageFormatter { - format(messages: ChatMessage[], modelConfig: ModelConfig): schema.ContentBlock[] { + format(messages: ChatMessage[], options: FormatOptions = {}): FormatResult { const blocks: schema.ContentBlock[] = [] - const configLine = this.buildConfigLine(modelConfig) - if (configLine) { - blocks.push({ type: 'text', text: configLine }) + const systemPrompt = options.includeSystemPrompt ? this.extractSystemPrompt(messages) : null + if (systemPrompt) { + blocks.push({ + type: 'text', + text: `System instructions:\n${systemPrompt}` + }) } - messages.forEach((message) => { - const prefix = (message.role || 'unknown').toUpperCase() - const normalized = this.normalizeContent(message) - if (normalized.length === 0) { - blocks.push({ type: 'text', text: `${prefix}:` }) - return - } - - normalized.forEach((item, index) => { - if (item.type === 'text') { - const label = index === 0 ? `${prefix}: ` : '' - blocks.push({ type: 'text', text: `${label}${item.value}` }) - } else if (item.type === 'resource_link') { - blocks.push({ type: 'resource_link', uri: item.value, name: prefix }) - } + const userMessage = this.findLastUserMessage(messages) + if (userMessage) { + this.normalizeContent(userMessage).forEach((item) => { + blocks.push(this.toContentBlock(item, options.promptCapabilities)) }) + } - if (message.tool_calls && message.tool_calls.length > 0) { - message.tool_calls.forEach((toolCall) => { - blocks.push({ - type: 'text', - text: `${prefix} TOOL CALL ${toolCall.id || ''}: ${toolCall.function?.name || 'unknown'} ${toolCall.function?.arguments || ''}` - }) - }) - } + if (!blocks.length) { + blocks.push({ type: 'text', text: '' }) + } - if (message.role === 'tool' && typeof message.content === 'string') { - blocks.push({ - type: 'text', - text: `TOOL RESPONSE${message.tool_call_id ? ` (${message.tool_call_id})` : ''}: ${message.content}` - }) - } - }) + return { + blocks, + includedSystemPrompt: Boolean(systemPrompt) + } + } - return blocks + private findLastUserMessage(messages: ChatMessage[]): ChatMessage | null { + for (let index = messages.length - 1; index >= 0; index -= 1) { + if (messages[index]?.role === 'user') { + return messages[index] + } + } + return null } - private buildConfigLine(modelConfig: ModelConfig): string { - const temperature = modelConfig.temperature ?? 0.6 - const maxTokens = modelConfig.maxTokens ?? modelConfig.maxCompletionTokens ?? 4096 - return `temperature=${temperature}, maxTokens=${maxTokens}` + private extractSystemPrompt(messages: ChatMessage[]): string | null { + const systemMessage = messages.find((message) => message.role === 'system') + if (!systemMessage) return null + + const text = this.normalizeContent(systemMessage) + .filter((item): item is Extract => item.type === 'text') + .map((item) => item.text.trim()) + .filter(Boolean) + .join('\n') + + return text || null } private normalizeContent(message: ChatMessage): NormalizedContent[] { @@ -63,31 +76,129 @@ export class AcpMessageFormatter { if (typeof content === 'string') { if (content.trim().length > 0) { - normalized.push({ type: 'text', value: content }) + normalized.push({ type: 'text', text: content }) + } + return normalized + } + + if (!Array.isArray(content)) { + return normalized + } + + content.forEach((rawPart) => { + const part = rawPart as Record + const type = typeof part.type === 'string' ? part.type : undefined + + if ((type === 'text' || type === 'input_text') && typeof part.text === 'string') { + normalized.push({ type: 'text', text: part.text }) + return } - } else if (Array.isArray(content)) { - content.forEach((rawPart) => { - const part = rawPart as Record - const type = typeof part.type === 'string' ? part.type : undefined - - if ((type === 'text' || type === 'input_text') && typeof part.text === 'string') { - normalized.push({ type: 'text', value: part.text }) - } else if (type === 'image_url') { - const imageUrl = part['image_url'] as { url?: string } | undefined - if (imageUrl?.url) { - normalized.push({ type: 'resource_link', value: imageUrl.url }) + + if (type === 'image_url' || type === 'input_image') { + const url = this.extractImageUrl(part) + if (!url) return + const image = this.parseDataUrl(url) + if (image) { + normalized.push({ type: 'image', data: image.data, mimeType: image.mimeType, uri: url }) + } else { + normalized.push({ type: 'resource_link', uri: url, name: 'image' }) + } + return + } + + if (type === 'input_audio' || type === 'audio') { + const data = typeof part.data === 'string' ? part.data : undefined + const mimeType = + typeof part.mimeType === 'string' + ? part.mimeType + : typeof part.mime_type === 'string' + ? part.mime_type + : 'audio/mpeg' + if (data) { + normalized.push({ type: 'audio', data, mimeType }) + } + return + } + + if (type === 'resource_link' && typeof part.uri === 'string') { + normalized.push({ + type: 'resource_link', + uri: part.uri, + name: typeof part.name === 'string' ? part.name : undefined, + mimeType: typeof part.mimeType === 'string' ? part.mimeType : undefined + }) + return + } + + if (typeof part.text === 'string') { + normalized.push({ type: 'text', text: part.text }) + } + }) + + return normalized + } + + private extractImageUrl(part: Record): string | undefined { + const imageUrl = part.image_url as { url?: unknown } | undefined + const source = part.source as { data?: unknown; url?: unknown } | undefined + const candidates = [imageUrl?.url, source?.data, source?.url, part.data, part.url, part.uri] + return candidates.find((candidate): candidate is string => { + return typeof candidate === 'string' && candidate.trim().length > 0 + }) + } + + private toContentBlock( + item: NormalizedContent, + capabilities?: schema.PromptCapabilities + ): schema.ContentBlock { + switch (item.type) { + case 'text': + return { type: 'text', text: item.text } + case 'image': + if (capabilities?.image) { + return { + type: 'image', + data: item.data, + mimeType: item.mimeType, + ...(item.uri ? { uri: item.uri } : {}) } - } else if (type === 'input_image') { - const imageUrl = part['image_url'] as { url?: string } | undefined - if (imageUrl?.url) { - normalized.push({ type: 'resource_link', value: imageUrl.url }) + } + return item.uri + ? { type: 'resource_link', uri: item.uri, name: 'image', mimeType: item.mimeType } + : { type: 'text', text: `[image ${item.mimeType}]` } + case 'audio': + if (capabilities?.audio) { + return { type: 'audio', data: item.data, mimeType: item.mimeType } + } + return { type: 'text', text: `[audio ${item.mimeType}]` } + case 'resource': + if (capabilities?.embeddedContext) { + return { + type: 'resource', + resource: { + uri: item.uri, + text: item.text, + ...(item.mimeType ? { mimeType: item.mimeType } : {}) + } } - } else if (typeof part.text === 'string') { - normalized.push({ type: 'text', value: part.text }) } - }) + return { type: 'text', text: item.text } + case 'resource_link': + return { + type: 'resource_link', + uri: item.uri, + name: item.name ?? item.uri, + ...(item.mimeType ? { mimeType: item.mimeType } : {}) + } } + } - return normalized + private parseDataUrl(value: string): { mimeType: string; data: string } | null { + const match = DATA_URL_PATTERN.exec(value) + if (!match) return null + return { + mimeType: match[1], + data: match[2] + } } } diff --git a/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts b/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts index 79ef003d7..cf800fea0 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpProcessManager.ts @@ -27,7 +27,11 @@ import { setPathEntriesOnEnv } from '@/lib/agentRuntime/shellEnvHelper' import { RuntimeHelper } from '@/lib/runtimeHelper' -import { buildClientCapabilities } from './acpCapabilities' +import { + buildCapabilitySnapshot, + buildClientCapabilities, + type AcpCapabilitySnapshot +} from './acpCapabilities' import { AcpFsHandler } from './acpFsHandler' import { AcpTerminalManager } from './acpTerminalManager' import { @@ -54,9 +58,17 @@ export interface AcpProcessHandle extends AgentProcessHandle { availableModes?: Array<{ id: string; name: string; description: string }> currentModeId?: string agentCapabilities?: schema.AgentCapabilities + agentInfo?: schema.Implementation | null + capabilitySnapshot?: AcpCapabilitySnapshot + sessionCapabilities?: schema.SessionCapabilities + promptCapabilities?: schema.PromptCapabilities authMethods?: schema.AuthMethod[] mcpCapabilities?: schema.McpCapabilities supportsLoadSession?: boolean + supportsSessionList?: boolean + supportsSessionResume?: boolean + supportsSessionClose?: boolean + supportsSessionFork?: boolean launchSignature: string } @@ -105,6 +117,11 @@ interface PermissionResolverEntry { resolver: PermissionResolver } +interface BufferedSessionUpdate { + notification: schema.SessionNotification + receivedAt: number +} + type JsonRpcId = string | number type JsonRpcMessageRecord = Record type ProtocolDirection = 'in' | 'out' @@ -128,13 +145,20 @@ interface ProtocolMessageSummary { const MAX_PROTOCOL_LOG_LINE_LENGTH = 4000 const IMPORTANT_PROTOCOL_METHODS = new Set([ 'initialize', + 'authenticate', 'session/new', 'session/load', + 'session/list', + 'session/resume', + 'session/close', + 'session/fork', 'session/prompt', - 'session/cancel', - 'authenticate' + 'session/cancel' ]) +const SESSION_UPDATE_BUFFER_TTL_MS = 30_000 +const MAX_BUFFERED_SESSION_UPDATES = 100 + const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value) @@ -182,6 +206,7 @@ export class AcpProcessManager implements AgentProcessManager() private readonly pendingHandles = new Map>() private readonly sessionListeners = new Map() + private readonly bufferedSessionUpdates = new Map() private readonly permissionResolvers = new Map() private readonly runtimeHelper = RuntimeHelper.getInstance() private readonly terminalManager = new AcpTerminalManager() @@ -329,9 +354,21 @@ export class AcpProcessManager implements AgentProcessManager { const existingEntry = this.sessionListeners.get(sessionId) if (!existingEntry) return @@ -713,6 +752,7 @@ export class AcpProcessManager implements AgentProcessManager this.deliverSessionUpdate(entry, notification)) + } + + private pruneBufferedSessionUpdates(now = Date.now()): void { + for (const [sessionId, updates] of this.bufferedSessionUpdates.entries()) { + const fresh = updates.filter( + (update) => now - update.receivedAt <= SESSION_UPDATE_BUFFER_TTL_MS + ) + if (fresh.length === updates.length) continue + if (fresh.length) { + this.bufferedSessionUpdates.set(sessionId, fresh) + } else { + this.bufferedSessionUpdates.delete(sessionId) + } + } + } + private async dispatchPermissionRequest( params: schema.RequestPermissionRequest ): Promise { diff --git a/src/main/presenter/llmProviderPresenter/acp/acpSessionManager.ts b/src/main/presenter/llmProviderPresenter/acp/acpSessionManager.ts index 7c4986547..06a35bf14 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpSessionManager.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpSessionManager.ts @@ -33,6 +33,16 @@ interface SessionHooks { onPermission: PermissionResolver } +type AcpConnectionWithUnstableSessionLifecycle = ClientSideConnectionType & { + unstable_resumeSession?: ( + params: schema.ResumeSessionRequest + ) => Promise + unstable_closeSession?: ( + params: schema.CloseSessionRequest + ) => Promise + unstable_forkSession?: (params: schema.ForkSessionRequest) => Promise +} + const summarizeMcpServers = (mcpServers: schema.McpServer[]) => mcpServers.map((server) => { const record = server as Record @@ -43,7 +53,7 @@ const summarizeMcpServers = (mcpServers: schema.McpServer[]) => }) const summarizeSessionResponse = ( - response: schema.LoadSessionResponse | schema.NewSessionResponse + response: schema.LoadSessionResponse | schema.NewSessionResponse | schema.ResumeSessionResponse ) => ({ sessionId: 'sessionId' in response ? response.sessionId : undefined, keys: Object.keys(response as Record), @@ -59,6 +69,8 @@ export interface AcpSessionRecord extends AgentSessionState { detachHandlers: Array<() => void> workdir: string configState?: AcpConfigState + promptCapabilities?: schema.PromptCapabilities + systemPromptSent?: boolean availableModes?: Array<{ id: string; name: string; description: string }> currentModeId?: string availableCommands?: Array<{ @@ -167,12 +179,6 @@ export class AcpSessionManager { this.processManager.clearSession(session.sessionId) - try { - await session.connection.cancel({ sessionId: session.sessionId }) - } catch (error) { - console.warn(`[ACP] Failed to cancel session ${session.sessionId}:`, error) - } - try { await this.processManager.unbindProcess(session.agentId, conversationId) } catch (error) { @@ -303,7 +309,8 @@ export class AcpSessionManager { workdir, configState, availableModes, - currentModeId + currentModeId, + promptCapabilities: handle.promptCapabilities } } @@ -334,6 +341,7 @@ export class AcpSessionManager { ): Promise<{ sessionId: string configState: AcpConfigState + promptCapabilities?: schema.PromptCapabilities availableModes?: Array<{ id: string; name: string; description: string }> currentModeId?: string detachHandlers?: Array<() => void> @@ -356,17 +364,95 @@ export class AcpSessionManager { currentModeId?: string } | undefined - let sessionResponse: schema.LoadSessionResponse | schema.NewSessionResponse | undefined + let sessionResponse: + | schema.LoadSessionResponse + | schema.NewSessionResponse + | schema.ResumeSessionResponse + | undefined + const connection = handle.connection as AcpConnectionWithUnstableSessionLifecycle + const canResumeSession = Boolean( + handle.supportsSessionResume && connection.unstable_resumeSession + ) const canLoadSession = Boolean(handle.supportsLoadSession) console.info(`[ACP] Initializing ACP session for agent ${agent.id}:`, { conversationId, workdir, + canResumeSession, canLoadSession, persistedSessionId, mcpServerCount: mcpServers.length }) - if (canLoadSession && persistedSessionId) { + if (canResumeSession && persistedSessionId) { + try { + const resumeRequestSummary = { + cwd: workdir, + sessionId: persistedSessionId, + mcpServerCount: mcpServers.length, + mcpServers: summarizeMcpServers(mcpServers) + } + console.info( + `[ACP] Resuming persisted ACP session ${persistedSessionId} for conversation ${conversationId}`, + resumeRequestSummary + ) + this.processManager.appendDebugEvent?.(agent.id, { + kind: 'request', + action: 'session/resume', + sessionId: persistedSessionId, + payload: resumeRequestSummary + }) + this.processManager.registerSessionWorkdir(persistedSessionId, workdir, conversationId) + detachHandlers = this.attachSessionHooks(agent.id, persistedSessionId, hooks) + const resumeResponse = await connection.unstable_resumeSession!({ + cwd: workdir, + mcpServers, + sessionId: persistedSessionId + }) + sessionId = persistedSessionId + sessionResponse = resumeResponse + responseModeState = resumeResponse.modes ?? undefined + const resumedConfigState = normalizeAcpConfigState({ + configOptions: resumeResponse.configOptions, + models: resumeResponse.models, + modes: resumeResponse.modes + }) + if (hasAcpConfigStateData(resumedConfigState)) { + configState = resumedConfigState + } + console.info( + `[ACP] Resumed persisted session ${sessionId} for conversation ${conversationId} (agent ${agent.id})` + ) + this.processManager.appendDebugEvent?.(agent.id, { + kind: 'response', + action: 'session/resume', + sessionId, + payload: summarizeSessionResponse(resumeResponse) + }) + } catch (error) { + detachHandlers?.forEach((dispose) => { + try { + dispose() + } catch (disposeError) { + console.warn('[ACP] Failed to detach resumed session handler:', disposeError) + } + }) + detachHandlers = undefined + this.processManager.clearSession(persistedSessionId) + console.warn( + `[ACP] Failed to resume persisted session ${persistedSessionId} for conversation ${conversationId}; trying load/new fallback.`, + error + ) + this.processManager.appendDebugEvent?.(agent.id, { + kind: 'error', + action: 'session/resume', + sessionId: persistedSessionId, + message: error instanceof Error ? error.message : String(error), + payload: error instanceof Error ? { name: error.name, stack: error.stack } : error + }) + } + } + + if (!sessionId && canLoadSession && persistedSessionId) { try { const loadRequestSummary = { cwd: workdir, @@ -529,7 +615,8 @@ export class AcpSessionManager { configState, availableModes, currentModeId, - detachHandlers + detachHandlers, + promptCapabilities: handle.promptCapabilities } } catch (error) { console.error(`[ACP] Failed to initialize session for agent ${agent.id}:`, error) diff --git a/src/main/presenter/llmProviderPresenter/acp/acpSessionPersistence.ts b/src/main/presenter/llmProviderPresenter/acp/acpSessionPersistence.ts index 033ae058b..b3301ace5 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpSessionPersistence.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpSessionPersistence.ts @@ -1,5 +1,8 @@ import { app } from 'electron' +import * as fs from 'fs' +import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' import type { + CONVERSATION_SETTINGS, AcpTurnFinishPayload, AcpTurnStartPayload, AcpSessionEntity, @@ -7,7 +10,32 @@ import type { ISQLitePresenter } from '@shared/presenter' +export interface AcpRemoteSessionSyncInput { + agentId: string + agentName: string + providerId: string + workdir: string + sessions: schema.SessionInfo[] +} + +export interface AcpRemoteSessionSyncItem { + sessionId: string + conversationId: string + status: 'imported' | 'updated' | 'skipped' + title?: string | null +} + +export interface AcpRemoteSessionSyncResult { + imported: number + updated: number + skipped: number + sessions: AcpRemoteSessionSyncItem[] +} + export class AcpSessionPersistence { + private readonly remoteSessionSyncLocks = new Map>() + private readonly metadataMergeLocks = new Map>() + constructor(private readonly sqlitePresenter: ISQLitePresenter) {} async getSessionData(conversationId: string, agentId: string): Promise { @@ -59,12 +87,220 @@ export class AcpSessionPersistence { await this.sqlitePresenter.updateAcpSessionStatus(conversationId, agentId, status) } + async mergeMetadata( + conversationId: string, + agentId: string, + metadata: Record + ): Promise { + await this.withKeyLock(this.metadataMergeLocks, `${conversationId}::${agentId}`, async () => { + const existing = await this.getSessionData(conversationId, agentId) + await this.saveSessionData( + conversationId, + agentId, + existing?.sessionId ?? null, + existing?.workdir ?? null, + existing?.status ?? 'idle', + { + ...existing?.metadata, + ...metadata + } + ) + }) + } + + async syncRemoteSessions(input: AcpRemoteSessionSyncInput): Promise { + const now = new Date().toISOString() + const result: AcpRemoteSessionSyncResult = { + imported: 0, + updated: 0, + skipped: 0, + sessions: [] + } + + for (const remoteSession of input.sessions) { + if (!remoteSession.sessionId) { + result.skipped += 1 + result.sessions.push({ + sessionId: '', + conversationId: '', + status: 'skipped', + title: remoteSession.title + }) + continue + } + + const item = await this.withRemoteSessionSyncLock( + input.agentId, + remoteSession.sessionId, + () => this.syncRemoteSession(input, remoteSession, now) + ) + + result[item.status] += 1 + result.sessions.push(item) + } + + return result + } + + private async syncRemoteSession( + input: AcpRemoteSessionSyncInput, + remoteSession: schema.SessionInfo, + syncedAt: string + ): Promise { + const sessionWorkdir = this.resolveRemoteSessionWorkdir(remoteSession, input.workdir) + const metadata = this.buildRemoteSessionMetadata(input.agentName, remoteSession, syncedAt) + const existing = await this.sqlitePresenter.getAcpSessionByAgentAndSessionId( + input.agentId, + remoteSession.sessionId + ) + + if (existing) { + return this.updateRemoteSessionLink( + input, + remoteSession, + existing, + sessionWorkdir, + metadata, + syncedAt + ) + } + + const conversationId = await this.sqlitePresenter.createConversation( + this.buildRemoteSessionTitle(input.agentName, remoteSession), + this.buildConversationSettings(input.providerId, input.agentId, sessionWorkdir) + ) + + try { + await this.saveSessionData( + conversationId, + input.agentId, + remoteSession.sessionId, + sessionWorkdir, + 'idle', + { + ...metadata, + acpSync: { + importedAt: syncedAt, + lastSyncedAt: syncedAt, + source: 'session/list' + } + } + ) + } catch (error) { + const concurrentExisting = await this.sqlitePresenter.getAcpSessionByAgentAndSessionId( + input.agentId, + remoteSession.sessionId + ) + if (!concurrentExisting) { + await this.deleteConversationSilently(conversationId) + throw error + } + + await this.deleteConversationSilently(conversationId) + return this.updateRemoteSessionLink( + input, + remoteSession, + concurrentExisting, + sessionWorkdir, + metadata, + syncedAt + ) + } + + return { + sessionId: remoteSession.sessionId, + conversationId, + status: 'imported', + title: remoteSession.title + } + } + + private async updateRemoteSessionLink( + input: AcpRemoteSessionSyncInput, + remoteSession: schema.SessionInfo, + existing: AcpSessionEntity, + syncedWorkdir: string, + metadata: Record, + syncedAt: string + ): Promise { + const existingSync = this.getRecord(existing.metadata?.acpSync) + const existingWorkdir = this.resolveExistingSessionWorkdir(existing.workdir, syncedWorkdir) + await this.saveSessionData( + existing.conversationId, + input.agentId, + remoteSession.sessionId, + existingWorkdir, + existing.status ?? 'idle', + { + ...existing.metadata, + ...metadata, + acpSync: { + ...existingSync, + lastSyncedAt: syncedAt, + source: 'session/list' + } + } + ) + + return { + sessionId: remoteSession.sessionId, + conversationId: existing.conversationId, + status: 'updated', + title: remoteSession.title + } + } + + private async withRemoteSessionSyncLock( + agentId: string, + sessionId: string, + task: () => Promise + ): Promise { + return this.withKeyLock(this.remoteSessionSyncLocks, `${agentId}::${sessionId}`, task) + } + + private async withKeyLock( + locks: Map>, + key: string, + task: () => Promise + ): Promise { + const previous = locks.get(key) + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + const next = previous ? previous.catch(() => undefined).then(() => current) : current + locks.set(key, next) + + if (previous) { + await previous.catch(() => undefined) + } + + try { + return await task() + } finally { + release() + if (locks.get(key) === next) { + locks.delete(key) + } + } + } + + private async deleteConversationSilently(conversationId: string): Promise { + try { + await this.sqlitePresenter.deleteConversation(conversationId) + } catch (error) { + console.warn( + `[ACP] Failed to delete duplicate imported conversation ${conversationId}:`, + error + ) + } + } + async deleteSession(conversationId: string, agentId: string): Promise { await this.sqlitePresenter.deleteAcpSession(conversationId, agentId) } async clearSession(conversationId: string, agentId: string): Promise { - await this.updateSessionId(conversationId, agentId, null) await this.updateStatus(conversationId, agentId, 'idle') } @@ -82,17 +318,105 @@ export class AcpSessionPersistence { } resolveWorkdir(workdir?: string | null): string { - if (workdir && workdir.trim().length > 0) { - return workdir + if (workdir && this.isWorkdirUsable(workdir)) { + return workdir.trim() } return this.getDefaultWorkdir() } + isWorkdirUsable(workdir?: string | null): boolean { + const trimmed = workdir?.trim() + if (!trimmed) return false + + try { + return Boolean(fs.existsSync(trimmed) && fs.statSync(trimmed).isDirectory()) + } catch { + return false + } + } + getDefaultWorkdir(): string { try { - return app.getPath('home') + const home = app.getPath('home') + if (this.isWorkdirUsable(home)) { + return home + } } catch { - return process.env.HOME || process.cwd() + // fall through to process fallbacks + } + + if (this.isWorkdirUsable(process.env.HOME)) { + return process.env.HOME as string } + + return process.cwd() + } + + private resolveRemoteSessionWorkdir(session: schema.SessionInfo, fallback: string): string { + if (this.isWorkdirUsable(session.cwd)) { + return session.cwd.trim() + } + return this.resolveWorkdir(fallback) + } + + private resolveExistingSessionWorkdir( + existingWorkdir: string | null | undefined, + syncedWorkdir: string + ): string { + const trimmed = existingWorkdir?.trim() + if (trimmed && this.isWorkdirUsable(trimmed)) { + return trimmed + } + return syncedWorkdir + } + + private buildConversationSettings( + providerId: string, + agentId: string, + workdir: string + ): Partial { + return { + providerId, + modelId: agentId, + chatMode: 'acp agent', + agentWorkspacePath: workdir, + acpWorkdirMap: { + [agentId]: workdir + } + } + } + + private buildRemoteSessionTitle(agentName: string, session: schema.SessionInfo): string { + const title = session.title?.trim() + if (title) return title + + const shortSessionId = + session.sessionId.length > 12 ? session.sessionId.slice(0, 12) : session.sessionId + return `${agentName} ${shortSessionId}` + } + + private buildRemoteSessionMetadata( + agentName: string, + session: schema.SessionInfo, + syncedAt: string + ): Record { + return { + agentName, + remoteSession: { + protocol: 'acp', + sessionId: session.sessionId, + cwd: session.cwd, + title: session.title ?? null, + updatedAt: session.updatedAt ?? null, + meta: session._meta ?? null, + syncedAt + } + } + } + + private getRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null } } diff --git a/src/main/presenter/llmProviderPresenter/acp/acpTerminalManager.ts b/src/main/presenter/llmProviderPresenter/acp/acpTerminalManager.ts index 5fbb8fdae..f9510aa83 100644 --- a/src/main/presenter/llmProviderPresenter/acp/acpTerminalManager.ts +++ b/src/main/presenter/llmProviderPresenter/acp/acpTerminalManager.ts @@ -75,19 +75,6 @@ export class AcpTerminalManager { } ) - // Build command based on platform - const platform = process.platform - let shell: string - let shellArgs: string[] - - if (platform === 'win32') { - shell = 'powershell.exe' - shellArgs = ['-NoLogo', '-Command', params.command, ...(params.args ?? [])] - } else { - shell = '/bin/bash' - shellArgs = ['-c', [params.command, ...(params.args ?? [])].join(' ')] - } - // Build environment from env array const env: Record = { ...process.env } as Record if (params.env) { @@ -96,7 +83,7 @@ export class AcpTerminalManager { } } - const ptyProcess = spawn(shell, shellArgs, { + const ptyProcess = spawn(params.command, params.args ?? [], { name: 'xterm-256color', cols: 120, rows: 30, @@ -121,20 +108,10 @@ export class AcpTerminalManager { // Collect output ptyProcess.onData((data) => { if (state.released) return - - const currentBytes = Buffer.byteLength(state.outputBuffer, 'utf-8') - const newBytes = Buffer.byteLength(data, 'utf-8') - - if (currentBytes + newBytes <= state.maxOutputBytes) { - state.outputBuffer += data - } else { - // Truncate at UTF-8 boundary - const remaining = state.maxOutputBytes - currentBytes - if (remaining > 0) { - state.outputBuffer += this.truncateAtCharBoundary(data, remaining) - } - state.truncated = true - } + const nextBuffer = state.outputBuffer + data + state.outputBuffer = this.retainTailAtCharBoundary(nextBuffer, state.maxOutputBytes) + state.truncated = + state.truncated || Buffer.byteLength(nextBuffer, 'utf-8') > state.maxOutputBytes }) // Handle exit @@ -249,17 +226,17 @@ export class AcpTerminalManager { return state } - private truncateAtCharBoundary(str: string, maxBytes: number): string { + private retainTailAtCharBoundary(str: string, maxBytes: number): string { + if (maxBytes <= 0) return '' + const buf = Buffer.from(str, 'utf-8') if (buf.length <= maxBytes) return str - // Find valid UTF-8 boundary by slicing and checking - let truncated = buf.subarray(0, maxBytes) - while (truncated.length > 0) { - try { - return truncated.toString('utf-8') - } catch { - truncated = truncated.subarray(0, truncated.length - 1) + for (let start = buf.length - maxBytes; start < buf.length; start += 1) { + const tail = buf.subarray(start) + const decoded = tail.toString('utf-8') + if (!decoded.startsWith('\uFFFD')) { + return decoded } } return '' diff --git a/src/main/presenter/llmProviderPresenter/acp/index.ts b/src/main/presenter/llmProviderPresenter/acp/index.ts index 0b42d49d2..1170a048e 100644 --- a/src/main/presenter/llmProviderPresenter/acp/index.ts +++ b/src/main/presenter/llmProviderPresenter/acp/index.ts @@ -8,7 +8,12 @@ export { } from './acpProcessManager' export { AcpSessionManager, type AcpSessionRecord } from './acpSessionManager' export { AcpSessionPersistence } from './acpSessionPersistence' -export { buildClientCapabilities, type AcpCapabilityOptions } from './acpCapabilities' +export { + buildCapabilitySnapshot, + buildClientCapabilities, + type AcpCapabilityOptions, + type AcpCapabilitySnapshot +} from './acpCapabilities' export { AcpMessageFormatter } from './acpMessageFormatter' export { AcpContentMapper } from './acpContentMapper' export { diff --git a/src/main/presenter/llmProviderPresenter/index.ts b/src/main/presenter/llmProviderPresenter/index.ts index b7bab7c04..58e66d342 100644 --- a/src/main/presenter/llmProviderPresenter/index.ts +++ b/src/main/presenter/llmProviderPresenter/index.ts @@ -831,7 +831,7 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { async getAcpWorkdir(conversationId: string, agentId: string): Promise { const record = await this.acpSessionPersistence.getSessionData(conversationId, agentId) const path = this.acpSessionPersistence.resolveWorkdir(record?.workdir) - const isCustom = Boolean(record?.workdir && record.workdir.trim().length > 0) + const isCustom = this.acpSessionPersistence.isWorkdirUsable(record?.workdir) return { path, isCustom } } @@ -846,7 +846,16 @@ export class LLMProviderPresenter implements ILlmProviderPresenter { return } - const trimmed = workdir?.trim() ? workdir : null + const requestedWorkdir = workdir?.trim() ? workdir.trim() : null + const trimmed = + requestedWorkdir && this.acpSessionPersistence.isWorkdirUsable(requestedWorkdir) + ? requestedWorkdir + : null + if (requestedWorkdir && !trimmed) { + console.warn( + `[ACP] Ignoring unavailable ACP workdir "${requestedWorkdir}" for conversation ${conversationId} (agent ${agentId}); using default workdir.` + ) + } await this.acpSessionPersistence.updateWorkdir(conversationId, agentId, trimmed) } diff --git a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts index 6f7f3d043..ae615f845 100644 --- a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts @@ -1,4 +1,5 @@ import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' +import type { ClientSideConnection as ClientSideConnectionType } from '@agentclientprotocol/sdk' import { BaseLLMProvider, SUMMARY_TITLES_PROMPT } from '../baseProvider' import type { AcpConfigState, @@ -54,6 +55,10 @@ type EventQueue = { done: () => void } +type RunPromptOptions = { + onPromptSucceeded?: () => void +} + type PermissionRequestContext = { agent: AcpAgentConfig conversationId: string @@ -98,6 +103,21 @@ type AcpConnectionWithModelSelection = { ) => Promise } +type AcpConnectionWithDebugLifecycle = ClientSideConnectionType & + AcpConnectionWithModelSelection & { + authenticate?: (params: schema.AuthenticateRequest) => Promise + listSessions?: (params: schema.ListSessionsRequest) => Promise + unstable_resumeSession?: ( + params: schema.ResumeSessionRequest + ) => Promise + unstable_closeSession?: ( + params: schema.CloseSessionRequest + ) => Promise + unstable_forkSession?: ( + params: schema.ForkSessionRequest + ) => Promise + } + const isRecord = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value) @@ -402,8 +422,18 @@ export class AcpProvider extends BaseLLMProvider { ) this.emitSessionCommandsReady(conversationKey, agent.id, session.availableCommands ?? []) - const promptBlocks = this.messageFormatter.format(messages, modelConfig) - void this.runPrompt(session, promptBlocks, queue, modelConfig) + const formattedPrompt = this.messageFormatter.format(messages, { + promptCapabilities: session.promptCapabilities, + includeSystemPrompt: !session.systemPromptSent + }) + const activeSession = session + void this.runPrompt(activeSession, formattedPrompt.blocks, queue, modelConfig, { + onPromptSucceeded: formattedPrompt.includedSystemPrompt + ? () => { + activeSession.systemPromptSent = true + } + : undefined + }) } } } catch (error) { @@ -440,7 +470,16 @@ export class AcpProvider extends BaseLLMProvider { agentId: string, workdir: string | null ): Promise { - const trimmed = workdir?.trim() ? workdir : null + const requestedWorkdir = workdir?.trim() ? workdir.trim() : null + const trimmed = + requestedWorkdir && this.sessionPersistence.isWorkdirUsable(requestedWorkdir) + ? requestedWorkdir + : null + if (requestedWorkdir && !trimmed) { + console.warn( + `[ACP] Ignoring unavailable ACP workdir "${requestedWorkdir}" for conversation ${conversationId} (agent ${agentId}); using default workdir.` + ) + } const existing = await this.sessionPersistence.getSessionData(conversationId, agentId) const previous = existing?.workdir ?? null await this.sessionPersistence.updateWorkdir(conversationId, agentId, trimmed) @@ -460,9 +499,16 @@ export class AcpProvider extends BaseLLMProvider { agentId: string, workdir: string ): Promise { - const normalizedWorkdir = workdir?.trim() - if (!normalizedWorkdir) { - throw new Error('[ACP] Workdir is required to prepare ACP session.') + const requestedWorkdir = workdir?.trim() + const persistedWorkdir = + requestedWorkdir && this.sessionPersistence.isWorkdirUsable(requestedWorkdir) + ? requestedWorkdir + : null + const normalizedWorkdir = this.sessionPersistence.resolveWorkdir(persistedWorkdir) + if (requestedWorkdir && !persistedWorkdir) { + console.warn( + `[ACP] Prepare requested unavailable workdir "${requestedWorkdir}" for conversation ${conversationId}; using "${normalizedWorkdir}".` + ) } const agent = await this.getAgentById(agentId) @@ -470,7 +516,7 @@ export class AcpProvider extends BaseLLMProvider { throw new Error(`[ACP] ACP agent not found: ${agentId}`) } - await this.sessionPersistence.updateWorkdir(conversationId, agent.id, normalizedWorkdir) + await this.sessionPersistence.updateWorkdir(conversationId, agent.id, persistedWorkdir) const session = await this.sessionManager.getOrCreateSession( conversationId, @@ -503,6 +549,14 @@ export class AcpProvider extends BaseLLMProvider { const agent = await this.getAgentById(agentId) if (!agent) return + const requestedWorkdir = workdir?.trim() + if (requestedWorkdir && !this.sessionPersistence.isWorkdirUsable(requestedWorkdir)) { + console.info( + `[ACP] Skipping warmup for agent ${agentId}: selected workdir "${requestedWorkdir}" is unavailable.` + ) + return + } + try { await this.processManager.warmupProcess(agent, workdir) } catch (error) { @@ -563,7 +617,7 @@ export class AcpProvider extends BaseLLMProvider { } throw error } - const connection = handle.connection + const connection = handle.connection as AcpConnectionWithDebugLifecycle const events: AcpDebugEventEntry[] = typeof this.processManager.getDebugEvents === 'function' ? [...this.processManager.getDebugEvents(agent.id)] @@ -635,9 +689,51 @@ export class AcpProvider extends BaseLLMProvider { ) } - const resolveWorkdir = (): string | undefined => { - const cwd = request.workdir ?? handle.workdir - return cwd?.trim() || undefined + const resolveHandleWorkdir = (): string => { + const handleWorkdir = handle.workdir?.trim() + if ( + handleWorkdir && + (!this.sessionPersistence || + typeof this.sessionPersistence.isWorkdirUsable !== 'function' || + this.sessionPersistence.isWorkdirUsable(handleWorkdir)) + ) { + return handleWorkdir + } + const requestedWorkdir = request.workdir?.trim() + if (this.sessionPersistence && typeof this.sessionPersistence.resolveWorkdir === 'function') { + return this.sessionPersistence.resolveWorkdir(requestedWorkdir) + } + return requestedWorkdir || process.cwd() + } + + const normalizeWorkdir = (workdir?: string | null): string => { + const fallback = resolveHandleWorkdir() + const trimmed = workdir?.trim() + if (!trimmed) { + return fallback + } + if ( + this.sessionPersistence && + typeof this.sessionPersistence.isWorkdirUsable === 'function' && + !this.sessionPersistence.isWorkdirUsable(trimmed) + ) { + return fallback + } + if (this.sessionPersistence && typeof this.sessionPersistence.resolveWorkdir === 'function') { + return this.sessionPersistence.resolveWorkdir(trimmed) + } + return trimmed + } + + const resolveWorkdir = (): string => { + return resolveHandleWorkdir() + } + + const resolvePayloadWorkdir = (workdir: unknown): string | undefined => { + if (typeof workdir !== 'string' || !workdir.trim()) { + return undefined + } + return normalizeWorkdir(workdir) } const resolveMcpServers = async (): Promise => { @@ -659,15 +755,41 @@ export class AcpProvider extends BaseLLMProvider { }) break } + case 'authenticate': { + if (!connection.authenticate) { + throw new Error('authenticate is not supported by this SDK connection') + } + const methodId = + isPlainObject(request.payload) && typeof request.payload.methodId === 'string' + ? request.payload.methodId + : undefined + if (!methodId) { + throw new Error('methodId is required for authenticate') + } + const body: schema.AuthenticateRequest = { methodId } + if (isPlainObject(request.payload?._meta)) { + body._meta = request.payload._meta + } + pushEvent({ kind: 'request', action: 'authenticate', payload: body }) + const response = await connection.authenticate(body) + pushEvent({ + kind: 'response', + action: 'authenticate', + sessionId: activeSessionId, + payload: response ?? {} + }) + break + } case 'newSession': { const basePayload: schema.NewSessionRequest = { - cwd: resolveWorkdir() ?? process.cwd(), + cwd: resolveWorkdir(), mcpServers: await resolveMcpServers() } const body = { ...basePayload } if (isPlainObject(request.payload)) { - if (typeof request.payload.cwd === 'string' && request.payload.cwd.trim()) { - body.cwd = request.payload.cwd + const payloadWorkdir = resolvePayloadWorkdir(request.payload.cwd) + if (payloadWorkdir) { + body.cwd = payloadWorkdir } if (Array.isArray(request.payload.mcpServers)) { body.mcpServers = request.payload.mcpServers as schema.McpServer[] @@ -700,13 +822,14 @@ export class AcpProvider extends BaseLLMProvider { throw new Error('Session ID is required for loadSession') } const body: schema.LoadSessionRequest = { - cwd: resolveWorkdir() ?? process.cwd(), + cwd: resolveWorkdir(), mcpServers: await resolveMcpServers(), sessionId: sessionToLoad } if (payloadOverrides) { - if (typeof payloadOverrides.cwd === 'string') { - body.cwd = payloadOverrides.cwd + const payloadWorkdir = resolvePayloadWorkdir(payloadOverrides.cwd) + if (payloadWorkdir) { + body.cwd = payloadWorkdir } if (Array.isArray(payloadOverrides.mcpServers)) { body.mcpServers = payloadOverrides.mcpServers as schema.McpServer[] @@ -733,6 +856,201 @@ export class AcpProvider extends BaseLLMProvider { }) break } + case 'sessionList': { + if (!connection.listSessions) { + throw new Error('session/list is not supported by this SDK connection') + } + if (!handle.supportsSessionList) { + throw new Error('Agent did not advertise sessionCapabilities.list') + } + const payloadOverrides = isPlainObject(request.payload) ? request.payload : undefined + const body: schema.ListSessionsRequest = { + cwd: resolveWorkdir() + } + if (payloadOverrides) { + const payloadWorkdir = resolvePayloadWorkdir(payloadOverrides.cwd) + if (payloadWorkdir) { + body.cwd = payloadWorkdir + } + if (typeof payloadOverrides.cursor === 'string') { + body.cursor = payloadOverrides.cursor + } + if (isPlainObject(payloadOverrides._meta)) { + body._meta = payloadOverrides._meta + } + } + const shouldSyncRemoteSessions = Boolean(payloadOverrides?.sync) + const allSessions: schema.SessionInfo[] = [] + let cursor: string | null | undefined = body.cursor + do { + const pageBody = { ...body, cursor } + pushEvent({ kind: 'request', action: 'session/list', payload: pageBody }) + const response = await connection.listSessions(pageBody) + allSessions.push(...response.sessions) + cursor = response.nextCursor + pushEvent({ + kind: 'response', + action: 'session/list', + payload: response + }) + } while (cursor) + pushEvent({ + kind: 'lifecycle', + action: 'session/list.complete', + payload: { count: allSessions.length } + }) + if (shouldSyncRemoteSessions) { + const syncResult = await this.sessionPersistence.syncRemoteSessions({ + agentId: agent.id, + agentName: agent.name, + providerId: this.provider.id, + workdir: body.cwd ?? resolveWorkdir(), + sessions: allSessions + }) + pushEvent({ + kind: 'lifecycle', + action: 'session/list.sync', + payload: syncResult + }) + } + break + } + case 'sessionResume': { + if (!connection.unstable_resumeSession) { + throw new Error('session/resume is not supported by this SDK connection') + } + if (!handle.supportsSessionResume) { + throw new Error('Agent did not advertise sessionCapabilities.resume') + } + const payloadOverrides = isPlainObject(request.payload) ? request.payload : undefined + const sessionToResume = + payloadOverrides && typeof payloadOverrides.sessionId === 'string' + ? payloadOverrides.sessionId + : activeSessionId + if (!sessionToResume) { + throw new Error('sessionId is required for sessionResume') + } + const body: schema.ResumeSessionRequest = { + cwd: resolveWorkdir(), + mcpServers: await resolveMcpServers(), + sessionId: sessionToResume + } + if (payloadOverrides) { + const payloadWorkdir = resolvePayloadWorkdir(payloadOverrides.cwd) + if (payloadWorkdir) { + body.cwd = payloadWorkdir + } + if (Array.isArray(payloadOverrides.mcpServers)) { + body.mcpServers = payloadOverrides.mcpServers as schema.McpServer[] + } + if (isPlainObject(payloadOverrides._meta)) { + body._meta = payloadOverrides._meta + } + } + pushEvent({ + kind: 'request', + action: 'session/resume', + sessionId: sessionToResume, + payload: body + }) + this.processManager.registerSessionWorkdir(sessionToResume, body.cwd) + attachSession(sessionToResume) + const response = await connection.unstable_resumeSession(body) + activeSessionId = sessionToResume + pushEvent({ + kind: 'response', + action: 'session/resume', + sessionId: activeSessionId, + payload: response + }) + break + } + case 'sessionClose': { + if (!connection.unstable_closeSession) { + throw new Error('session/close is not supported by this SDK connection') + } + if (!handle.supportsSessionClose) { + throw new Error('Agent did not advertise sessionCapabilities.close') + } + const payloadOverrides = isPlainObject(request.payload) ? request.payload : undefined + const sessionToClose = + payloadOverrides && typeof payloadOverrides.sessionId === 'string' + ? payloadOverrides.sessionId + : activeSessionId + if (!sessionToClose) { + throw new Error('sessionId is required for sessionClose') + } + const body: schema.CloseSessionRequest = { sessionId: sessionToClose } + if (payloadOverrides && isPlainObject(payloadOverrides._meta)) { + body._meta = payloadOverrides._meta + } + pushEvent({ + kind: 'request', + action: 'session/close', + sessionId: sessionToClose, + payload: body + }) + const response = await connection.unstable_closeSession(body) + this.processManager.clearSession(sessionToClose) + activeSessionId = undefined + pushEvent({ + kind: 'response', + action: 'session/close', + sessionId: sessionToClose, + payload: response + }) + break + } + case 'sessionFork': { + if (!connection.unstable_forkSession) { + throw new Error('session/fork is not supported by this SDK connection') + } + if (!handle.supportsSessionFork) { + throw new Error('Agent did not advertise sessionCapabilities.fork') + } + const payloadOverrides = isPlainObject(request.payload) ? request.payload : undefined + const sessionToFork = + payloadOverrides && typeof payloadOverrides.sessionId === 'string' + ? payloadOverrides.sessionId + : activeSessionId + if (!sessionToFork) { + throw new Error('sessionId is required for sessionFork') + } + const body: schema.ForkSessionRequest = { + cwd: resolveWorkdir(), + mcpServers: await resolveMcpServers(), + sessionId: sessionToFork + } + if (payloadOverrides) { + const payloadWorkdir = resolvePayloadWorkdir(payloadOverrides.cwd) + if (payloadWorkdir) { + body.cwd = payloadWorkdir + } + if (Array.isArray(payloadOverrides.mcpServers)) { + body.mcpServers = payloadOverrides.mcpServers as schema.McpServer[] + } + } + if (payloadOverrides && isPlainObject(payloadOverrides._meta)) { + body._meta = payloadOverrides._meta + } + pushEvent({ + kind: 'request', + action: 'session/fork', + sessionId: sessionToFork, + payload: body + }) + const response = await connection.unstable_forkSession(body) + activeSessionId = response.sessionId + this.processManager.registerSessionWorkdir(activeSessionId, body.cwd) + attachSession(activeSessionId) + pushEvent({ + kind: 'response', + action: 'session/fork', + sessionId: activeSessionId, + payload: response + }) + break + } case 'prompt': { if (!activeSessionId) { throw new Error('Session ID is required for prompt') @@ -911,7 +1229,8 @@ export class AcpProvider extends BaseLLMProvider { session: AcpSessionRecord, prompt: schema.ContentBlock[], queue: EventQueue, - modelConfig: ModelConfig + modelConfig: ModelConfig, + options: RunPromptOptions = {} ): Promise { const timeoutMs = this.resolveModelRequestTimeout(modelConfig) let timeoutId: NodeJS.Timeout | null = null @@ -973,6 +1292,7 @@ export class AcpProvider extends BaseLLMProvider { }) ]) : promptRequest) + options.onPromptSucceeded?.() const responseSummary = { sessionId: session.sessionId, conversationId, @@ -1104,6 +1424,28 @@ export class AcpProvider extends BaseLLMProvider { mapped.configState ) } + + if ((mapped.sessionInfo || mapped.usage) && currentSession) { + const metadata = { + ...currentSession.metadata, + ...(mapped.sessionInfo + ? { + acpSessionInfo: mapped.sessionInfo + } + : {}), + ...(mapped.usage + ? { + acpUsage: mapped.usage + } + : {}) + } + currentSession.metadata = metadata + void this.sessionPersistence + .mergeMetadata(conversationId, agentId, metadata) + .catch((error) => { + console.warn('[ACP] Failed to persist ACP session update metadata:', error) + }) + } } private emitSessionModesReady( diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index d45a77767..d98ea7632 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -770,6 +770,14 @@ export class SQLitePresenter implements ISQLitePresenter { return row ? (row as AcpSessionEntity) : null } + public async getAcpSessionByAgentAndSessionId( + agentId: string, + sessionId: string + ): Promise { + const row = await this.acpSessionsTable.getByAgentAndSessionId(agentId, sessionId) + return row ? (row as AcpSessionEntity) : null + } + public async upsertAcpSession( conversationId: string, agentId: string, diff --git a/src/main/presenter/sqlitePresenter/tables/acpSessions.ts b/src/main/presenter/sqlitePresenter/tables/acpSessions.ts index ad9df040b..b5218261c 100644 --- a/src/main/presenter/sqlitePresenter/tables/acpSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/acpSessions.ts @@ -44,25 +44,71 @@ export class AcpSessionsTable extends BaseTable { id INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, agent_id TEXT NOT NULL, - session_id TEXT UNIQUE, + session_id TEXT, workdir TEXT, status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle', 'active', 'error')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, metadata TEXT, - UNIQUE(conversation_id, agent_id) + UNIQUE(conversation_id, agent_id), + UNIQUE(agent_id, session_id) ); CREATE INDEX IF NOT EXISTS idx_acp_sessions_session_id ON acp_sessions(session_id); CREATE INDEX IF NOT EXISTS idx_acp_sessions_agent ON acp_sessions(agent_id); ` } - getMigrationSQL(_version: number): string | null { + getMigrationSQL(version: number): string | null { + if (version === 30) { + return ` + DROP TABLE IF EXISTS acp_sessions_migrated; + CREATE TABLE acp_sessions_migrated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + session_id TEXT, + workdir TEXT, + status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle', 'active', 'error')), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + metadata TEXT, + UNIQUE(conversation_id, agent_id), + UNIQUE(agent_id, session_id) + ); + INSERT OR IGNORE INTO acp_sessions_migrated ( + id, + conversation_id, + agent_id, + session_id, + workdir, + status, + created_at, + updated_at, + metadata + ) + SELECT + id, + conversation_id, + agent_id, + session_id, + workdir, + status, + created_at, + updated_at, + metadata + FROM acp_sessions + ORDER BY updated_at DESC; + DROP TABLE acp_sessions; + ALTER TABLE acp_sessions_migrated RENAME TO acp_sessions; + CREATE INDEX IF NOT EXISTS idx_acp_sessions_session_id ON acp_sessions(session_id); + CREATE INDEX IF NOT EXISTS idx_acp_sessions_agent ON acp_sessions(agent_id); + ` + } return null } getLatestVersion(): number { - return 0 + return 30 } async getByConversationAndAgent( @@ -83,6 +129,21 @@ export class AcpSessionsTable extends BaseTable { return row ? this.mapRow(row) : null } + async getByAgentAndSessionId(agentId: string, sessionId: string): Promise { + const row = this.db + .prepare( + ` + SELECT * + FROM acp_sessions + WHERE agent_id = ? AND session_id = ? + LIMIT 1 + ` + ) + .get(agentId, sessionId) as AcpSessionDbRow | undefined + + return row ? this.mapRow(row) : null + } + async upsert(conversationId: string, agentId: string, data: AcpSessionUpsertData): Promise { const now = Date.now() const payload = { diff --git a/src/renderer/settings/components/AcpDebugDialog.vue b/src/renderer/settings/components/AcpDebugDialog.vue index f388c633d..a607ecac8 100644 --- a/src/renderer/settings/components/AcpDebugDialog.vue +++ b/src/renderer/settings/components/AcpDebugDialog.vue @@ -252,6 +252,10 @@ const methodOptions = computed(() => [ value: 'initialize' as const, label: t('settings.acp.debug.methods.initialize') }, + { + value: 'authenticate' as const, + label: t('settings.acp.debug.methods.authenticate') + }, { value: 'newSession' as const, label: t('settings.acp.debug.methods.newSession') @@ -260,6 +264,22 @@ const methodOptions = computed(() => [ value: 'loadSession' as const, label: t('settings.acp.debug.methods.loadSession') }, + { + value: 'sessionList' as const, + label: t('settings.acp.debug.methods.sessionList') + }, + { + value: 'sessionResume' as const, + label: t('settings.acp.debug.methods.sessionResume') + }, + { + value: 'sessionClose' as const, + label: t('settings.acp.debug.methods.sessionClose') + }, + { + value: 'sessionFork' as const, + label: t('settings.acp.debug.methods.sessionFork') + }, { value: 'prompt' as const, label: t('settings.acp.debug.methods.prompt') @@ -287,9 +307,16 @@ const methodOptions = computed(() => [ ]) const requiresSession = computed(() => - ['prompt', 'cancel', 'setSessionMode', 'setSessionModel', 'loadSession'].includes( - selectedMethod.value - ) + [ + 'prompt', + 'cancel', + 'setSessionMode', + 'setSessionModel', + 'loadSession', + 'sessionResume', + 'sessionClose', + 'sessionFork' + ].includes(selectedMethod.value) ) const requiresCustomMethod = computed(() => @@ -334,6 +361,8 @@ const templateForMethod = (method: AcpDebugRequest['action']) => { switch (method) { case 'initialize': return {} + case 'authenticate': + return { methodId: '' } case 'newSession': return { ...(workdirPath.value ? { cwd: workdirPath.value } : {}), @@ -344,6 +373,27 @@ const templateForMethod = (method: AcpDebugRequest['action']) => { sessionId: debugSessionId.value, ...(workdirPath.value ? { cwd: workdirPath.value } : {}) } + case 'sessionList': + return { + ...(workdirPath.value ? { cwd: workdirPath.value } : {}), + sync: true + } + case 'sessionResume': + return { + sessionId: debugSessionId.value, + ...(workdirPath.value ? { cwd: workdirPath.value } : {}), + mcpServers: [] + } + case 'sessionClose': + return { + sessionId: debugSessionId.value + } + case 'sessionFork': + return { + sessionId: debugSessionId.value, + ...(workdirPath.value ? { cwd: workdirPath.value } : {}), + mcpServers: [] + } case 'prompt': return { prompt: [{ type: 'text', text: 'ping' }] @@ -373,7 +423,11 @@ const resetPayload = () => { const applyWorkdirToPayload = ( payload: Record | undefined ): Record | undefined => { - if (!['newSession', 'loadSession'].includes(selectedMethod.value)) { + if ( + !['newSession', 'loadSession', 'sessionList', 'sessionResume', 'sessionFork'].includes( + selectedMethod.value + ) + ) { return payload } const base = payload ?? {} @@ -384,7 +438,12 @@ const applyWorkdirToPayload = ( } const syncWorkdirIntoPayload = () => { - if (!['newSession', 'loadSession'].includes(selectedMethod.value)) return + if ( + !['newSession', 'loadSession', 'sessionList', 'sessionResume', 'sessionFork'].includes( + selectedMethod.value + ) + ) + return if (!payloadText.value.trim()) return try { const parsed = JSON.parse(payloadText.value) ?? {} @@ -452,6 +511,9 @@ const parsePayload = () => { return JSON.parse(payloadText.value) } +const isPlainObject = (value: unknown): value is Record => + Boolean(value) && typeof value === 'object' && !Array.isArray(value) + const handleSend = async () => { let parsedPayload: Record | undefined try { @@ -481,7 +543,16 @@ const handleSend = async () => { return } - const sessionId = requiresSession.value ? debugSessionId.value : undefined + const payloadSessionId = + isPlainObject(parsedPayload) && + typeof parsedPayload.sessionId === 'string' && + parsedPayload.sessionId.trim() + ? parsedPayload.sessionId.trim() + : undefined + const fallbackSessionId = requiresSession.value + ? debugSessionId.value.trim() || undefined + : undefined + const sessionId = payloadSessionId ?? fallbackSessionId const payloadToSend = applyWorkdirToPayload(parsedPayload) loading.value = true diff --git a/src/renderer/src/App.vue b/src/renderer/src/App.vue index 01ad7b561..8bbae8853 100644 --- a/src/renderer/src/App.vue +++ b/src/renderer/src/App.vue @@ -91,12 +91,23 @@ const syncAppearanceClasses = (themeName: string, fontSizeClass: string) => { return } - for (const target of [document.documentElement, document.body]) { + const root = document.documentElement + // 切换期间临时禁用过渡,让主题瞬时生效,避免大量元素同时跑颜色过渡造成的重绘卡顿 + root.classList.add('dc-theme-switching') + + for (const target of [root, document.body]) { target.classList.remove('light', 'dark', 'system') target.classList.add(themeName) target.classList.remove('text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl') target.classList.add(fontSizeClass) } + + // 强制同步重算,使本次类切换在「过渡已禁用」的状态下完成 + void root.offsetWidth + // 下一帧恢复过渡,不影响日常 hover 等交互动画 + requestAnimationFrame(() => { + root.classList.remove('dc-theme-switching') + }) } watch( diff --git a/src/renderer/src/assets/style.css b/src/renderer/src/assets/style.css index ceedebece..8160ad045 100644 --- a/src/renderer/src/assets/style.css +++ b/src/renderer/src/assets/style.css @@ -839,6 +839,14 @@ outline-color: color-mix(in oklab, var(--ring) 50%, transparent); } + /* 主题切换瞬间临时关闭所有过渡,避免上百个元素同时触发颜色过渡导致的重绘卡顿 */ + .dc-theme-switching, + .dc-theme-switching *, + .dc-theme-switching *::before, + .dc-theme-switching *::after { + transition: none !important; + } + body { color: var(--foreground); font-weight: var(--text-weight); diff --git a/src/renderer/src/components/ChatConfig.vue b/src/renderer/src/components/ChatConfig.vue index 044aaf584..f67a12afa 100644 --- a/src/renderer/src/components/ChatConfig.vue +++ b/src/renderer/src/components/ChatConfig.vue @@ -78,8 +78,7 @@ const thinkingBudget = useThinkingBudget({ thinkingBudget: toRef(props, 'thinkingBudget'), budgetRange: capabilities.budgetRange, modelReasoning: modelTypeDetection.modelReasoning, - supportsReasoning: capabilities.supportsReasoning, - isGeminiProvider: modelTypeDetection.isGeminiProvider + supportsReasoning: capabilities.supportsReasoning }) // === Utility Functions === @@ -110,8 +109,7 @@ const { sliderFields, inputFields, selectFields } = useChatConfigFields({ providerId: toRef(props, 'providerId'), // Composables - isGPT5Model: modelTypeDetection.isGPT5Model, - isImageGenerationModel: modelTypeDetection.isImageGenerationModel, + supportsTemperatureControl: capabilities.supportsTemperatureControl, showThinkingBudget: thinkingBudget.showThinkingBudget, thinkingBudgetError: thinkingBudget.validationError, budgetRange: capabilities.budgetRange, diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 03fac201e..0a8463eb9 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -94,6 +94,26 @@ + + + + + + + {{ t('chat.sidebar.themeToggle') }} · {{ themeModeLabel }} + + + @@ -391,6 +411,7 @@ import AgentAvatar from './icons/AgentAvatar.vue' import WindowSideBarSessionItem from './WindowSideBarSessionItem.vue' import { useI18n } from 'vue-i18n' import { useSidebarStore } from '@/stores/ui/sidebar' +import { useThemeStore } from '@/stores/theme' type PinFeedbackMode = 'pinning' | 'unpinning' @@ -419,6 +440,33 @@ const agentStore = useAgentStore() const sessionStore = useSessionStore() const sidebarStore = useSidebarStore() const spotlightStore = useSpotlightStore() +const themeStore = useThemeStore() + +// line-md 过渡图标自带线条流动动画:切到该模式时,线条会绘制/morph 成对应形状 +const themeIcon = computed(() => { + switch (themeStore.themeMode) { + case 'light': + // 线条流动收拢成太阳(光线逐根画出) + return 'line-md:moon-to-sunny-outline-transition' + case 'dark': + // 太阳线条流动 morph 成月亮 + return 'line-md:sunny-outline-to-moon-transition' + default: + // 显示器轮廓线条逐段绘制 + return 'line-md:monitor' + } +}) + +const themeModeLabel = computed(() => { + switch (themeStore.themeMode) { + case 'light': + return t('chat.sidebar.themeLight') + case 'dark': + return t('chat.sidebar.themeDark') + default: + return t('chat.sidebar.themeSystem') + } +}) const fallbackRemoteChannels: RemoteChannelDescriptor[] = [ { @@ -1179,10 +1227,55 @@ input { margin-left: var(--pin-text-shift) !important; } +.theme-icon-wrap { + display: grid; + place-items: center; + width: 1.15rem; + height: 1.15rem; +} + +.theme-icon { + /* 两个图标堆叠在同一网格单元,自动居中且不占额外空间 */ + grid-area: 1 / 1; + width: 1.15rem; + height: 1.15rem; + /* 提升到独立合成层,让过渡跑在 GPU 合成线程上, + 避免被切换主题时的全局重绘阻塞而掉帧 */ + will-change: transform, opacity; +} + +/* 形态变化交给 line-md 的线条流动动画;这里再叠加一个缩放"弹出"增强存在感 */ +.theme-icon-enter-active { + transition: + opacity 0.25s ease, + transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.theme-icon-leave-active { + transition: + opacity 0.2s ease, + transform 0.2s ease; +} + +.theme-icon-enter-from { + opacity: 0; + transform: scale(0.4); +} + +.theme-icon-leave-to { + opacity: 0; + transform: scale(0.7); +} + @media (prefers-reduced-motion: reduce) { .window-sidebar-shell, .window-sidebar-session-column { transition: none; } + + .theme-icon-enter-active, + .theme-icon-leave-active { + transition: none; + } } diff --git a/src/renderer/src/components/chat/messageListItems.ts b/src/renderer/src/components/chat/messageListItems.ts index f0370a21f..6033589bf 100644 --- a/src/renderer/src/components/chat/messageListItems.ts +++ b/src/renderer/src/components/chat/messageListItems.ts @@ -160,6 +160,7 @@ type DisplayMessageBase = { id: string role: 'user' | 'assistant' timestamp: number + updatedAt: number avatar: string name: string model_name: string diff --git a/src/renderer/src/components/message/MessageBlockActivityGroup.vue b/src/renderer/src/components/message/MessageBlockActivityGroup.vue new file mode 100644 index 000000000..854708d5e --- /dev/null +++ b/src/renderer/src/components/message/MessageBlockActivityGroup.vue @@ -0,0 +1,114 @@ + + + diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index 142687322..8a240850e 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -37,59 +37,76 @@ class="size-3 text-muted-foreground" />
-