# DSH 客户端插件技术参考 > 本文档基于 `dsh-assistant-optimization` 插件的开发和调试过程,整理 DSH 客户端插件体系的核心机制,作为后续开发新能力的技术参考。 --- ## 1. DSH 客户端架构概览 ``` DSH 进程 (Node.js) ├── Host 半体 — 服务端能力(文件、网络、命令、RPC handler) │ └── 通过 harness.handle(method, handler) 注册 JSON RPC │ └── Web GUI (浏览器) └── Agent 会话 Client 半体 ├── Cordis 插件系统 │ ├── Service 层(slots, harness, locale, session…) │ ├── Event 层(node change, session lifecycle…) │ └── Slot 渲染层( 组合渲染) └── React 渲染器 (scoped-slots.tsx) ``` **关键架构决策**:Cordis 是"能力组合"系统,而非传统 MVC。每个功能是一个插件(plugin),插件注册到 **Slot**(插槽),Slot 在运行时按优先级排序渲染。 --- ## 2. Slot 系统(核心) ### 2.1 什么是 Slot Slot 是 DSH 的声明式 UI 扩展点。类似 React 插槽 / Web Component 的 ``,但更强大: - **命名**:`name` 是完整的 slot 标识(如 `conversation.chat.node`) - **种类**:`kind` 可选 `single` / `keyed` / `list` / `chain` - **作用域**:`scope` 可选 `root` / `session`(session-scoped 的 slot 只在会话生命周期内存在) - **优先级(priority)**:同一 slot 的多个 occupant 按 priority 升序排序,**低 priority 胜出渲染** ### 2.2 Slot 种类 | 种类 | 说明 | 常用场景 | |------|------|----------| | `single` | 单 occupant,优先级最低的渲染 | 简单覆盖 | | `keyed` | 按 key 分组的多个 occupant,每个 key 最低优先级渲染 | 消息节点类型(assistant-step、tool-call)、工具视图(write/edit/bash) | | `list` | 按 id 排序的列表,所有 id 都渲染 | 设置页面项、侧边栏项 | | `chain` | 链式选择,selector 匹配则渲染 | 复杂路由 | ### 2.3 Slot 注册 ```js ctx.slots.register({ name: 'slot.name', // 完整的 slot 路径 key: 'entry-key', // keyed 时必填 id: 'list-id', // list 时必填 priority: 0, // 默认 0;低优先胜出 locale: 'conversation', // 国际化命名空间 children: { // 声明子 slot(可选) 'child.slot': { kind: 'keyed', scope: 'session' }, }, }, ComponentFunction) ``` ### 2.4 优先级规则(最重要) ``` priority: -1 < priority: 0 < priority: 1 胜出 失败 失败 ``` **关键**:`keyed` slot 中,每个 key 的所有 occupant 按 priority 升序排序,**最低的渲染**。所以 `priority: -1` 会 shadow 官方的 `priority: 0`。 ### 2.5 安全注入(slots.inject) ```js ctx.slots.inject('target.slot', function () { return ctx.slots.register({ … }, Component) }) ``` `slots.inject` 确保在 slot 声明存在后才注册,disposer 随注入者的上下文生命周期自动清理。 --- ## 3. 消息节点渲染管线(conversation.chat.node) 这是整个会话消息渲染的核心 slot。 ### 3.1 Slot 结构 ``` conversation.chat.node (keyed slot, scope: session) children: tool.call.toolview (keyed slot, scope: session) ``` ### 3.2 注册的 key(官方 + 插件) | key | 官方 priority | 插件 priority | 说明 | |-----|--------------|--------------|------| | `assistant-step` | 0 | -1(插件 wrapper) | 助手消息(文本 + 推理 + 工具调用) | | `tool-call` | 0 | -1(插件 wrapper) | 工具调用树(旧版方式,现已弃用) | | 其他 | 0 | — | 用户消息、系统消息等 | ### 3.3 渲染流程 ``` ChatConversationViewNode └─ renderSlot('conversation.chat.node', owner, { entryKey: kind }) ├─ key = 'assistant-step' → priority -1 胜出 → 插件 wrapper │ └─ 插件 wrapper 渲染官方 priority 0 组件 │ └─ 内部渲染 text / reasoning / tool-call 等 blocks │ ├─ key = 'tool-call' → priority -1 胜出 → 插件 wrapper(旧版) │ └─ 内部渲染 ToolCallTree │ └─ renderSlot('tool.call.toolview', owner, { entryKey: toolName }) │ ├─ key = 'write' → FileMutationRow │ ├─ key = 'edit' → FileMutationRow │ ├─ key = 'read' → ReadRow │ ├─ key = 'bash' → BashRow │ └─ 其他 → GenericToolCard (fallback) │ └─ 其他 key → 官方渲染 ``` ### 3.4 重要:新式工具调用路由 **DSH 新架构下,`tool-call` 节点不再经过 `conversation.chat.node` 的 keyed slot**,而是作为 `assistant-step` 节点内部的 `tool.call.toolview` 子 slot 渲染。这意味着: - **拦截 `tool-call` key(旧版 wrapper)在新版消息中可能不生效**——因为工具调用已嵌入 assistant-step 内部 - 正确拦截工具视图的方式是**注册到 `tool.call.toolview` 子 slot**(如 `dsao-1` 动态插件所做) --- ## 4. 工具调用视图(tool.call.toolview) ### 4.1 Slot 属性 ``` tool.call.toolview (keyed, scope: session) OwnerProps: ToolCallViewProps - callId: string - toolName: string - block: ToolCallBlock - openFile: (path: string) => void - cwd: string - inspect: () => void ``` ### 4.2 已注册的 key | key | 组件 | 插件来源 | |-----|------|---------| | `write` | `FileMutationRow` | `file-mutation-toolview` | | `edit` | `FileMutationRow` | `file-mutation-toolview` | | `read` | `ReadRow` | `read-toolview` | | `bash` | `BashRow` | `bash-toolview` | | `search` | `SearchRow` | `search-toolview` | | `web` | `WebRow` | `web-toolview` | | `todo` | `TodoRow` | `todo-toolview` | | `ask_question` | `AskQuestionRow` | `ask-question-toolview` | ### 4.3 ToolCallBlock 数据结构 ```typescript // 运行中(未完成) interface RunningCall { name: string callId: string callView: { card: 'diff', diffs: Hunk[] } | { card: 'text', text: string } argsRaw: string subCalls: ToolCallBlock[] } // 已结束(有结果) interface SettledCall { call: { name: string, argsRaw: string } callId: string resultView: { card: 'diff', diffs: Hunk[] } | { card: 'text', text: string } subCalls: ToolCallBlock[] } // Hunk (diff 块) interface Hunk { path: string oldText: string | null newText: string } ``` ### 4.4 FileMutationRow 渲染结构 ``` FileMutationRow └─ ToolRow └─ DisclosureRow (CSS: .row, data-disclosure-row, data-expandable) ├─ leading: IconEditOutline16 ├─ title: "Write" | "Edit" ├─ collapsedContent: button.fileLink → "path/to/file.txt" │ └─ flex: 1 1 auto; min-width: 0; overflow: hidden; │ text-overflow: ellipsis; white-space: nowrap; └─ children (展开时): DiffBlock └─ hunk lines (diff) └─ footer: "└ +A -R · N file(s)" ``` --- ## 5. 文件编辑 Diff 徽章(我们的实现) ### 5.1 方案演进 | 版本 | 注册点 | 优先级 | 问题 | |------|--------|--------|------| | **v1 (debug-1)** | `conversation.chat.node` key `tool-call` | `priority: 1` | ❌ priority 1 > 0,从不渲染 | | **v1 (debug-2/3)** | `conversation.chat.node` key `tool-call` | `priority: -1` | ❌ 声明 children 与官方冲突 | | **v1 (debug-4)** | `tool.call.toolview` key `write/edit` | `priority: -1` | ❌ 卡死(MutationObserver 自触发) | | **v3** | `tool.call.toolview` key `write/edit` | `priority: -1` | ⚠️ 稳定但出错的调用也显示徽章 | | **v4 (final)** | `tool.call.toolview` key `write/edit` | `priority: -1` | ✅ 稳定 + 幂等 + 出错抑制 | ### 5.2 最终方案 ``` 注册点: tool.call.toolview → key 'write' / 'edit', priority -1 组件: 叶子层(不声明 children→无需 renderSlot) 包装官方 FileMutationRow(display:contents wrapper) 挂 MutationObserver 检测文件链接出现后插入徽章 MutationObserver 收敛保证(核心): 1. ensureBadge 幂等:fileLink.nextElementSibling 已是徽章 且 title 签名一致 → 0 DOM 改动早退 2. 不观察 characterData(只观察 childList + subtree) 3. 只有徽章缺失或数值变化时才有一次删除+插入操作,之后收敛到稳态 ``` ### 5.3 行数来源与出错抑制 行数取自 block 的 diff 渲染意图,必须与官方 `diffCardModel` 完全一致: ```js // 官方 dsh-client-ui-tool/lib/client.js function diffCardModel(block) { if (!("kind" in block)) { // 运行中 const call = block.callView?.card === "diff" ? block.callView : null ... } const result = block.resultView?.card === "diff" ? block.resultView : null // 已结算:只读 resultView ... } ``` 关键契约(官方 `file-mutation-row.d.ts` 明确写出): > An errored mutation has no diff card, so ToolRow surfaces the model-facing > error text through its Output section and its first line in the collapsed > summary instead. 因此已结算的调用**不能**回退到 `callView`——`callView` 描述的是"打算改什么",编辑失败时它依然存在,把它当结果会渲染出不存在的变更。 ```js function diffView(block) { if ('kind' in block) { if (block.isError) return null // 出错 → 无 diff card return block.resultView || null // 不回退 callView } return block.callView || null // 运行中:callView 是唯一来源 } ``` ### 5.4 陷阱:运行中→出错 的残留徽章 出错的行在 DOM 上是**另一种结构**。官方 `ToolRow` 只在 `failureLine === null` 时渲染 `button.fileLink`: ```js // 官方 ToolRow const failureLine = state === "error" ? errorSummary ?? null : null; const fileLink = filePath !== void 0 && onOpenFile !== void 0 && failureLine === null; // fileLink ?