# dsh-wander-chat 插件设计方案 > 状态:设计定稿,尚未实现 > 目标 DSH 版本:`0.2.0-rc.2-5e9e301`(本机 desktop profile) --- ## 1. 目标 1. 在 DSH 客户端侧边栏「新会话」按钮**正下方**新增一行按钮,名称为「新建漫游会话」。 2. 点击后**直接创建会话,完全不弹出本地目录选择器**。 3. 所有漫游会话在侧边栏归入同一个固定工作区分组,分组显示名为「漫游会话」。 4. 该工作区的实际目录可配置,默认 `~/.dsh/wander-chat`。目录对用户基本隐形,但真实存在于本地。 5. 对 DSH 官方的侵入降到最低。 --- ## 2. 可行性结论 **可以实现。** 所需能力全部是 DSH 官方现成的,插件不需要自己实现会话创建、会话存储或工作区归属逻辑。 ### 2.1 三个官方 API 就够 | 步骤 | API | 签名 | 关键性质 | | --- | --- | --- | --- | | 保证目录存在 | Host 半边 `node:fs` | `mkdir(path, { recursive: true })` | 幂等,不抛 EEXIST | | 注册工作区 | 客户端 `workspaces.create` | `create(input: { path: string }): Promise` | **按 canonical path 幂等**;返回后立即 upsert 进客户端投影 | | 开会话 | 客户端 `uiWorkspace.startSession` | `startSession(workspaceId?: WorkspaceId): void` | 指定工作区建空白会话并跳转,**不弹选择器** | `startSession(workspaceId)` 正是侧边栏「新会话」按钮自己调用的那个 API(`@deepseek-ai/dsh-client-ui-sidebar` `lib/client.js:359-361` 调用的是无参形式),只是我们显式传入工作区 id。 ### 2.2 DSH 原生已经提供「每会话独立目录」 `~/.dsh/sessions/` 的真实布局: ``` ~/.dsh/sessions/--Users-hupc-AgentTest-dsh-git-branch--/ session-4a3bc471-33e8-4046-97a2-3b5ba7dc3389/ session.lock session.v4.jsonl.zstd ``` DSH 把会话按 **cwd 编码成桶目录名**,每个会话在自己的 `session-/` 子目录里存日志。也就是说: - 同一 cwd 的会话自动归入同一个桶 → 恰好是我们要的「统一到一个目录下」 - 每个会话自动获得独立的 UUID 目录 → 恰好是「每会话一个目录」的诉求 **结论:插件零存储代码。** 不需要自建 sessions 目录,不需要碰会话日志格式。 --- ## 3. 被否决的方案 记录在这里,避免后续重新踩坑。 ### 3.1 每会话一个 UUID 工作区 —— 不可行 曾考虑:工作区固定为 `wander-chat`,每个会话的 cwd 设为 `wander-chat/`。 **被硬约束否决。** `@deepseek-ai/dsh-workspace` 的 `attachSession` 要求会话 cwd 与工作区路径**严格相等**(`lib/index.js:111-127`): ```js cwd = await realpathNormalize(header.cwd) if (cwd !== this.record.path) throw new Error(`cannot attach session '${sessionId}' to workspace '${this.record.path}': its cwd resolves to '${cwd}'`) ``` 同一文件 `lib/index.js:102-103` 的 `sessionIds` getter 还会二次过滤: ```js return this.record.sessionIds.filter((id) => this.host.sessionPath(id) === this.record.path) ``` 更麻烦的是启动对账逻辑(`lib/index.js:779-807`):DSH 扫描所有会话 header,**按 cwd 分组自动补建工作区记录**。所以只要 cwd 是 UUID 子目录,DSH 就会给每个 UUID 目录各建一个独立工作区 —— 与「统一到一个目录下」完全相反。 附带三个事实: - `WorkspaceCreateRequest` 只有 `path` 一个字段,**没有 title**(`@deepseek-ai/dsh-api-workspace-controller` `lib/typert.host.js:673-674`),所以客户端建的工作区标题只能等于路径末段。 - DSH 确实有父子工作区嵌套(`owningParentFolder`,`@deepseek-ai/dsh-client-ui-workspace` `lib/client.js:626-638`),但只在分组方式切到「按工作区树」时生效(同文件 `:3288`,`nestWorkspaces: groupBy === "workspace-tree"`),默认是「按工作区」(`:661`,`groupBy: "workspace"`)。插件没有可靠途径替用户改这个浏览器本地视图状态。 - 即使嵌套成功,子分组标题仍是裸 UUID。 ### 3.2 插件自建一套 sessions 目录 —— 多余 见 §2.2,DSH 已原生提供。 --- ## 4. 最终架构 ### 4.1 职责划分 ``` ┌─ Host 半边 (index.js) ─────────────────────────────┐ │ · Config schema:root 路径 + 分组显示名 │ │ · 路由 GET /dsh-wander-chat/ensure │ │ - mkdir -p root │ │ - workspaceRegistry.create(root, title) │ │ - 返回 { ok, path, title } │ └────────────────────────────────────────────────────┘ ▲ fetch ┌─ Client 半边 (client.js) ──────────────────────────┐ │ · sidebar.panellist 注册 id=wander-chat → 按钮行 │ │ · main 注册 key=wander-chat → 启动面板 │ │ · 启动面板挂载时执行三步流程并跳转 │ └────────────────────────────────────────────────────┘ ``` **侵入面**:一个目录 + 一个侧边栏条目 + 一条只读路由。 **明确不碰**:会话存储、会话格式、Agent 循环、工作区归属规则、`~/.dsh/sessions/` 下任何内容、官方 UI 的既有插槽占用。 ### 4.2 按钮落点:为什么是 `sidebar.panellist` + `main` 配对 侧边栏 DOM 顺序(`@deepseek-ai/dsh-client-ui-sidebar` `lib/client.js:346-408`): ``` logoRow(品牌) → 【新会话按钮】 → panelList(全局面板) → regionArea(sidebar.workspaces) → footArea(sidebar.footer.action + sidebar.settings) ``` **「新会话」按钮正下方没有任何专用插槽**,唯一的空位就是 `panelList` 这一块,而它的内容由 `sidebar.panellist` 插槽填充。 `sidebar.panellist` 的语义(`lib/client.js:173-200`、`:452-469`): - 侧边栏只从注册项读取 `id` / `order` / `label`,**渲染成一个导航行** - 行内只放你返回的**图标组件**(`renderSlot("sidebar.panellist", {size, active}, {only: id})`) - 行的可见标题和 tooltip 来自注册项的 `label`,不是来自组件 - 点击行为被固定为 `selectPanel(id)` 关键约束:`layout.selectPanel` 在找不到同 key 的 `main` 面板时**会直接抛错**(`@deepseek-ai/dsh-client-ui-layout` `lib/client.js:462-465`): ```js selectPanel(panelId) { if (panelId !== null && !this.hasMainPanel(panelId)) throw new Error(`layout.selectPanel: main panel "${panelId}" is not registered`) ... } ``` 而 `main` 插槽的定义就是「**Central panel selected by sidebar entry id**」,派发方式为(同文件 `:118`): ```js renderSlot("main", {}, { entryKey: usePanelInfo((info) => info.activePanelId) ?? "conversation" }) ``` **所以正解是配对注册**:`sidebar.panellist` 的 `id` 与 `main` 的 `key` 都取 `wander-chat`。这样: - 行出现在「新会话」正下方 ✓ - 鼠标点击与键盘 Enter **行为完全一致** ✓ - 不需要 `stopPropagation` 之类的事件劫持 hack ✓ - `main` 的 keyDomain 是开放的(`already taken: conversation`),新增 key 不会遮蔽官方 `conversation` 面板 ✓ --- ## 5. 目录结构 参照现有的 `dsh-git-branch`(`link:` 方式装入 profile): ``` dsh-wander-chat/ ├── package.json # dsh.bundle + dsh.client 声明 ├── cordis.patch.yml # insert 一行 Host 插件,含默认 config ├── index.js # Host 半边 ├── client.js # Client 半边(手写浏览器 bundle,无构建步骤) ├── locale/ │ ├── zh.json │ └── en.json ├── icon.svg ├── README.md └── DESIGN.md # 本文档 ``` 无构建步骤:`client.js` 是手写的 classic script,只注册一个惰性工厂,形态与 `dsh-git-branch/client.js` 一致。 --- ## 6. 关键流程 点击「新建漫游会话」→ 打开 `main` 面板 `wander-chat` → 挂载 `WanderLauncher`: ``` WanderLauncher 挂载 │ ├─ 1. fetch GET /dsh-wander-chat/ensure │ Host: mkdir -p │ Host: workspaceRegistry.create(, "漫游会话") ← 幂等,仅新建时使用 title │ ← { ok: true, path, title } │ ├─ 2. ws = await ctx.workspaces.create({ path }) │ 幂等;按 canonical path 命中已有记录,保留自定义 title │ 返回后立即 upsert 进客户端投影 │ ├─ 3. ctx.uiWorkspace.startSession(ws.workspaceId) │ shell 切到 conversation 面板,本组件卸载 │ └─ 失败 → 保持在本面板,渲染错误信息 + 「重试」按钮 ``` ### 6.1 为什么第 1 步之后还要第 2 步 看起来 Host 已经建好工作区了,客户端为何还要再 `create` 一次?两个原因: 1. **竞态**:`uiWorkspace.connectWorkspace` 在客户端投影里找不到工作区时**会抛错**(`@deepseek-ai/dsh-client-ui-workspace` `lib/client.js:790-791`): ```js const workspace = this.workspaces.list.getSnapshot().items.find((item) => item.workspaceId === workspaceId) if (workspace === undefined) throw new Error(`uiWorkspace.connectWorkspace: unknown workspace ${workspaceId}`) ``` 客户端投影由 Host 的 `follow()` 流异步推送,只依赖 Host 创建会有竞态。 2. **标题**:`workspaces.create` 按 canonical path 幂等命中已有记录,会把**带自定义 title 的权威行**同步 upsert 进投影(`@deepseek-ai/dsh-api-workspace-controller` `lib/client.js:51-55`、`:393-397`)。 所以第 2 步既消除竞态,又把「漫游会话」这个显示名带进客户端。 --- ## 7. 实现要点 ### 7.1 `package.json` ```jsonc { "name": "dsh-wander-chat", "type": "module", "main": "./index.js", "exports": { ".": "./index.js", "./client": "./client.js", "./package.json": "./package.json", "./cordis.patch.yml": "./cordis.patch.yml", "./locale/*.json": "./locale/*.json" }, "dsh": { "manifestVersion": 1, "bundle": { "patch": "./cordis.patch.yml" }, "client": { "platform": "web", "immediately": true, "inject": ["@deepseek-ai/dsh-client-ui-workspace"] } } } ``` `dsh.client.inject` 声明该浏览器 bundle 依赖的模块 id,进入 boot graph 决定加载顺序。`ui-workspace` 是硬依赖(我们要调它的服务)。`sidebar.panellist` 与 `main` 两个插槽通过 `ctx.slots.inject()` 注册,会等待插槽声明,所以不必额外声明 `ui-sidebar` / `ui-layout`。 ### 7.2 `cordis.patch.yml` ```yaml - insert: - id: dsh-wander-chat name: 'dsh-wander-chat' config: root: ~/.dsh/wander-chat title: 漫游会话 ``` ### 7.3 `index.js`(Host 半边) ```js export const name = 'dsh-wander-chat' export const inject = ['webServer', 'workspaceRegistry'] export const Config = Schema.object({ root: Schema.string().default('~/.dsh/wander-chat'), title: Schema.string().default('漫游会话'), }) ``` `apply(ctx)` 要点: - 用 `@deepseek-ai/dsh-home-paths` 的 `expandHomePath()` 展开 `~`(该包导出 `expandHomePath` / `dshHomePath` / `defaultDshHome`)。 - `mkdir(root, { recursive: true })` —— 用 `recursive: true` 而不是客户端那个 `createDirectory`,因为后者在目录已存在时抛 `directory-exists`(`@deepseek-ai/dsh-host-directory-picker-browse` `lib/index.js:219-231`),每次点击都要额外处理。 - `ctx.workspaceRegistry.create(root, title)` —— Host 侧 `create(path, title)` **接受 title**(`@deepseek-ai/dsh-workspace` `lib/index.js:406-410`),且只在新建记录时使用;已存在时返回原记录并保留其 title。返回的 `Workspace` 实体有 `.id` / `.path` / `.title`(同文件 `:85-100`)。 - 注意 `workspaceRegistry.create` 要求目录**必须已存在**(同文件 `:407-408`,会 `stat` 校验并抛「path is not a directory」),所以必须先 mkdir。 - 路由 `/dsh-wander-chat/ensure` 只做 GET,安全栅栏直接复用 `dsh-git-branch/index.js` 的 `requestAllowed()`:校验 `Host` 为本机 loopback(或 `webRuntime.trustedHosts` 中声明的权威)、拒绝 `sec-fetch-site: cross-site`、`Origin` 主机名一致。这是 DNS rebinding 与跨站防护。 - 用 `ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-wander-chat', handler }), ...)` 注册,插件卸载时自动摘除。 - 响应 `cache-control: no-store`。 ### 7.4 `client.js`(Client 半边) 工厂返回: ```js return { inject: ['slots', 'locale', 'uiWorkspace', 'workspaces'], apply(ctx) { /* ... */ }, } ``` **a. 样式与词典**:与 `dsh-git-branch` 一致,用 `ctx.effect` 安装 `