# 架构 > 本文按代码实际结构写,不是按设计稿。凡是本文与代码冲突的地方,以代码为准,并请提 issue。 > 历史设计文档(交接文档、实现粗规划、规划、独立窗口复刻规格等)已归档到仓库外,见第 8 节。 ## 1. 两个半体 一个 DSH 插件包 = 一个 bundle row + 两个半体。两者没有共享模块,只共享字符串常量 (`lib/constants.js`)和一套 HTTP 契约。 | 半体 | 入口 | 由谁加载 | 运行环境 | |---|---|---|---| | Host | `lib/index.js` | Cordis,应用启动时加载一次 | DSH 主进程(Node ESM) | | Client | `lib/client.js` | `window.__ModuleLoader__.load({ id, factory })`,无构建步骤 | 渲染进程(浏览器环境) | Host 半体在 DSH 的 web 服务器上注册**一个**前缀路由 `/session-explorer/api`,所有端点都在 它下面(`lib/index.js:248-252`)。Client 半体往 DSH 的 UI 插槽里注册 5 个组件,并把浮窗 portal 到 `` 上的固定层。 ### 加载链路 ``` package.json ├─ dsh.bundle.patch = "./cordis.patch.yml" │ └─ - insert: [{ id: session-explorer, name: '@anlerway/dsh-session-explorer', config: {} }] │ → Cordis 按包名 import,取 name / inject / apply │ (lib/index.js:104 name, lib/index.js:109 inject, lib/index.js:118 apply) └─ dsh.client.inject = "@deepseek-ai/dsh-client-ui-slots" dsh.client.platform = "web" → 渲染进程把 lib/client.js 当作 out-of-tree client 模块执行 ``` `cordis.patch.yml` 只做**增量 insert**,不覆盖任何已发布的行(补丁会替换目标行的整个 `config`,覆盖是危险的)。 ## 2. Host 半体:`apply(ctx)` 做了七件事 `lib/index.js:101-268`,顺序有意义: 1. **拿 webServer**(`:102-103`)。`inject = ["webServer"]` 是唯一硬依赖:没有 HTTP 服务器的 profile 就没有浏览器半体,此时直接 return,让 loader 跳过这一行。 2. **开数据目录**(`:107-118`):`resolveDataDir()` → `dataPaths()` → `openDocuments()`。 `openAt(dir)` 让迁移完成后能在**不重启**的情况下切到新目录。 3. **四个可选件**(`:120-164`): - `component`:浏览器组件状态机(Electron 运行时); - `detached`:独立窗口 helper 进程控制器(懒创建); - `bridge`:浮窗 ↔ 独立窗口的命令总线(内存); - `theme`:浮窗上报、窗口读取的调色板。 4. **组装 `api` 对象**(`:167-227`)。这是路由处理器唯一的依赖入口: `ctx / dataDir / paths / documents / migration / reopen / component / detached / bridge / theme / capabilities / reader`。`dataDir` 与 `paths` 是 getter,迁移后自动指向新值。 5. **启动自愈**(`:232-246`):`reconcileTrash(api)` 收尾"索引已写、payload 未搬"的中断; `reconcileMoves(api)` 做同一件事的移动版(payload 已搬到目标、注册表还没跟上); `component.sweep()` 删除上一次卸载时被占用、删不掉的目录。三者都 best-effort、不 await。 6. **注册前缀路由**(`:248-256`),并把返回的 dispose 挂到 `ctx.effect`。 7. **退出时收尾**(`:261-267`):插件被禁用/重载时 `api.detached.stop()`,否则 helper 进程会 活过拥有它的代码。 ## 3. 一次请求的生命周期 `handle(api, req, res)`(`lib/index.js:1189-1248`): ``` routeOf(req.url) // lib/http.js:88 剥掉 /session-explorer/api ↓ null → 404 bad-request compileRoute 匹配表 // lib/index.js:1145 段数相等 + :param 解码 ↓ 无 → 404 no route for 迁移守卫 // 非 GET/HEAD 且非 /datadir* → migration-in-progress assertSameOrigin(req) // 非 GET/HEAD:跨站 / 非 JSON → 403 / 415 readJsonBody(req) // 非 GET/HEAD,上限 8 MiB(lib/http.js:57) handler(api, req, res, query, params) ↓ 正常 → ok(res, result) // { ok: true, result } ↓ 抛错 → statusFor(code) // lib/index.js:1310 错误码 → HTTP 状态 fail(res, status, code, message) ``` 三条不变量: - **信封唯一**:`{ ok: true, result }` / `{ ok: false, error, code }`(`lib/http.js:22-48`)。 客户端只对 `code` 分支,`error` 只给人看。 - **错误码直通**:DSH 原生错误带的命名空间码(`workspace/name-conflict`、 `session/title-invalid`)原样透出,不翻译,避免多一层会漂移的映射(`lib/index.js:1274`)。 - **SSE 例外**:`GET /bridge/events` 自己接管响应,`handle` 在 `headersSent` 时不再写信封 (`lib/index.js:1223`)。 - **写请求必须同源**:没有 token,所以靠 `Sec-Fetch-Site` 与 `Content-Type: application/json` 这两个浏览器强制的信号拒绝跨站写(`lib/index.js` 的 `assertSameOrigin()`,见 [api.md](api.md#31-写请求的同源约束))。 ## 4. 分层 ``` lib/ ├─ index.js 1717 插件装配 + 路由表(62 条)+ 错误映射(唯一编排层) ├─ http.js 101 信封、body 解析、URL 切分 ├─ constants.js 39 API_PREFIX / PLUGIN_ID / REPO_URL / FIXED_NODES / WORKSPACE_NODE_PREFIX ├─ capabilities.js 73 Cordis 服务的防御式探测 ├─ paths.js 327 数据目录解析、`.location` 指针、原子写临时目录清理 │ ├─ sources/ 读 DSH 的数据(只读,除 move 的受控重写) │ ├─ heads.js 233 L2:sessions 目录遍历(按实际版本名取最新日志,并发 8,结果排序) │ ├─ projcache.js 177 L1:storages/session_projcache 的每会话小 JSON │ ├─ workspaces.js 127 workspace registry:优先活服务,回退 storages/workspace.json │ ├─ logfacts.js 250 日志两端:空白会话 / 已中断(按 path|size|mtime 有界缓存) │ ├─ artifact.js 227 会话文件路径编码/解码、读日志、重写 header │ └─ reader.js 264 三源合并 + TTL 缓存策略(/bootstrap 的数据源) │ ├─ domain/ 纯规则,无 IO,可直接单测 │ ├─ sessions.js 310 合并三源 → 扁平会话列表(header 定身份,cache 只填空) │ ├─ tree.js 450 树投影:workspace 节点 + 固定节点 + 用户文件夹 │ ├─ tree-rules.js 451 规则表 R1–R5 的唯一实现(accepts / scope / ref 装饰) │ ├─ folders.js 330 新建/重命名/移动/删除文件夹(改 tree draft) │ ├─ shortcuts.js 233 快捷方式:引用而非移动 │ ├─ annotations.js 276 标签、备注、收藏/待办 │ ├─ settings.js 315 设置文档形状与归一化 │ ├─ trash.js 210 回收站条目形状、恢复阶梯 │ ├─ subagents.js 107 子智能体绑定:isBound / descendantsOf / withDescendants / boundIds │ ├─ export.js 258 日志 → Markdown / JSONL(处理 surfaceOp 遮蔽) │ └─ session-state.js 38 单个会话的布尔位(给 DSH 自己的按钮用,含 subagent) │ ├─ store/ 自己的持久化 │ ├─ sidecar.js 238 原子写 + revision + 串行化 + mtime/size 缓存 │ └─ documents.js 130 四份侧车文档的注册表 │ ├─ host/ 需要动文件系统或跨进程的操作 │ ├─ actions.js 697 改名/派生/归档/置顶 → 调 DSH 自己的服务 │ ├─ trash.js 496 删除/恢复/清空的字节搬运 │ ├─ move.js 683 跨 workspace 移动/复制(重写 header cwd + 换登记) │ ├─ move-journal.js 101 移动的意图日志:崩在两次写之间时,启动收尾而不是留下孤儿 │ ├─ datadir.js 380 数据目录检查与七步迁移 │ ├─ bridge.js 170 命令环形缓冲 + 游标 │ ├─ theme.js 103 调色板令牌白名单与校验 │ ├─ native-reveal.js 107 koffi → `SHOpenFolderAndSelectItems`,"在文件夹中显示" │ ├─ reveal-agent.js 203 常驻 PowerShell:省掉每次 reveal 一秒多的进程启动 │ ├─ update.js 210 更新检查:版本比较、GitHub release 解析、5 分钟缓存 │ ├─ open-link.js 169 链接白名单(repo / star / issues / releases)→ 系统浏览器(带降级链) │ ├─ maintenance.js 215 缓存清理:内存三件 + `.tmp` / 下载目录 / staging,跳过在装的组件 │ └─ reset.js 193 清除数据:四份侧车 + 回收站净内容 + 隔离区 + `.tmp`,`DELETE` 把关 │ ├─ component/ 可选 Electron 运行时 │ ├─ layout.js 226 路径、版本、SHA-256、镜像、体积 │ ├─ state.js 160 state.v1.json 生命周期机 │ ├─ downloader.js 339 curl 流式下载(断点续传、镜像、进度) │ ├─ install.js 562 校验 → 解压 → 原子发布 │ └─ manager.js 688 单飞状态机(install/cancel/remove 共享一把锁) │ ├─ detached/ │ ├─ launcher.js 390 Host 侧:spawn / 重试 / 聚焦 / 关闭、net 控制通道 │ └─ main.cjs — 跑在 Electron 运行时里,拥有 BrowserWindow │ └─ dsh/ ├─ home.js 39 DSH home 解析(与 kernel 同规则) └─ zstd.js 197 zstd 帧扫描器(只为跳过帧,不解码) ``` **依赖方向是单向的**:`index` → `host` / `store` / `domain` / `sources`; `host` → `domain` + `store`;`domain` 不 import 任何 IO 模块;`sources` 不 import `domain` 的规则 (只 import 纯合并函数)。`domain/*` 全部是"传入 draft、返回 draft",所以测试不需要文件系统。 ### 4.1 一件只能 host 做的事:更新检查 设置页「关于」组的三个入口里有两个**不能**由客户端完成,这是它们单独放在 `host/` 的原因: - **更新检查**:客户端是个普通网页,直接问 `api.github.com` 会撞 CORS;独立窗口里 `window.open` 与 `fetch` 也被主进程限定。所以由 `host/update.js` 代问、代比较, 结果缓存 5 分钟——设置页每打开一次就问一次的话,未登录的 60 次/小时会很快用完。 - **打开链接**:`host/open-link.js` 用系统浏览器打开,但**只认四个名字** (`repo` / `star` / `issues` / `releases`),不收 URL。这条 API 挂在 DSH 的端口上、没有 token, 接受任意 URL 就等于送给每个网页一个"用系统浏览器打开这个地址"的按钮。 打开本身按平台走一条降级链(Windows:`rundll32 url.dll,FileProtocolHandler` → `Start-Process`, macOS `open`,Linux `xdg-open` → `gio open`),认的是子进程**退出码**而不是"spawn 成功"—— 后者会让一个起得来、却打不开任何东西的壳子也报成功,用户看到的就是"点了没反应";全链失败才 报错,且错误消息里带着那个 URL,面板原样显示,至少还能复制。 第三个(缓存清理,`host/maintenance.js`)固然是 host 的活,但它有一条同样值得记住的边界: 只清**插件自己的**缓存(内存里的三处与 `.tmp` / 下载目录 / staging),`quarantine/` 与 DSH 自己的 `storages/session_projcache` 一律不碰;组件正在下载/校验/解压时跳过那两项。 浏览器缓存也不归它管:面板的 `/ui/*` 本来就不缓存,其余属于 DSH 的渲染进程和独立窗口。 ### 4.2 唯一不可逆的操作:清除数据 设置页最后一组(「危险操作」)只有一个按钮,它删的是用户自己的东西:**虚拟树**(文件夹、工作区 摆放与顺序)、标签、备注、设置、回收站索引,以及回收站里的**会话目录本体**。回收站是 move 不是 copy(`host/trash.js`), 那些目录曾经就是 DSH 的会话目录——这一步没有"再点一次还原"可退。 所以判断全在 `host/reset.js`,客户端只把用户打的字原样送上去: - 字面上的 `DELETE`(去掉首尾空白)是**第一件**检查的事,不是删到一半才检查; - `api.dataDir` 必须过 `isUsableDataDir()`——越是要清空一个目录,越要先确认那是它自己的; - 它清空的三个目录(`trash/` / `quarantine/` / `.tmp/`)保留目录本身:插件下次还得往里写; - 浏览器组件不在其中:那是安装,不是用户数据,它的按钮是清理缓存; - 删完立刻 `invalidateMemory(api)`:面板每张列表都是 memo 过的,不抖掉的话,被删掉的文件夹 会一直答到进程重启。 页面上的红按钮与输入框只是礼貌。这条路由没有 token,真正的门必须在 host: ```js if (typed !== RESET_CONFIRMATION) throw codedError("confirm-required", "…"); // 400,一个字节都没动 if (!isUsableDataDir(dataDir)) throw codedError("reset-refused", "…"); // 409 ``` ## 5. Client 半体:`apply(ctx)` 注册了什么 `lib/client.js:10734-10905`。`inject = ["slots", "locale", "uiWorkspace"]`(`:10908`)。 **插槽**(order 决定与 DSH 自带条目的相对位置): | 插槽 | id | order | 组件 | 作用 | |---|---|---|---|---| | `sidebar.footer.action` | `session-explorer-footer` | 40 | `FooterAction` | 侧栏底部入口,开/关浮窗 | | `settings.section` | `session-explorer` | 510 | `SettingsSection` | 设置页分组(label 是 thunk,跟随语言);组序 打开方式 → 列表 → 会话界面 → 浮窗 → 浏览器组件 → 数据目录 → 关于 → 危险操作 | | `settings.onboarding` | `session-explorer-opener` | 900 | 匿名 | 只为拿到 `openSection` 后立即 `complete()`,不留 UI | | `conversation.session.header.actions` | `session-explorer-header` | 60 | `SessionHeaderActions` | 会话标题栏五个按钮(样式跟随设置 `shell.header.style`) | | `sidebar.workspaces.session.menu.item` | `session-explorer-rows` | 500 | `SessionMenuItems` | 会话行 "…" 菜单两条 | **effect**(随插件卸载自动清理): - 主题上报:`reportTheme()` + `MutationObserver` 监听 `documentElement` 的 `class` / `data-theme` / `style`(`:10754-10774`); - 桥订阅:`subscribeBridge(...)`,处理 `open-session` / `new-session` / `open-settings` / `reveal` / `window-closed`(`:10776-10825`); - 词典注册:`ctx.locale.register(NS, { zh, en })`(`:10827`)。 **导出**:`apply` / `inject` / `panelStore` / `SettingsSection`(`:10907-10910`)。 浮窗本体是 `Panel`(`lib/client.js:4729`,单函数约 4000 行,内部按功能分段), 它被 portal 到 `` 上的固定层——侧栏有 `overflow: hidden`,原地渲染会被裁掉 (`lib/client.js:594` 的注释)。 ### 5.1 面板与独立窗口的双语:两套机制,一条链路 按「这一屏的标签是不是固定的」分工: - **设置页**用 DSH 的命名键字典(`t("setStarHint")`):一屏固定表单,键名可读比字面量值钱。 - **面板与独立窗口**用 `T(中文原文, params)`(`lib/client.js:1786` 起的注释块下方):键就是中文原文。 三百多条散在渲染树里的短标签,命名键只会多一层间接;而且改字时字典未命中的后果是 「显示中文」而不是「在页面上印出 key」。插值统一 `{0}` / `{1}`,和设置页的 `{size}` 风格互不干扰。 `T` 读模块级的 `panelLang`,而 `Panel` 每次渲染都从 props 的翻译器刷新它: `panelLang = typeof t === "function" && t("langCode") === "en" ? "en" : "zh";`(`lib/client.js:4732`)。 语言开关一动,DSH 用新的 `t` 重渲染插槽,下一帧就是新语言。代价是模块级表(`FIXED_META`、 `COLUMNS`)的 `label` 必须写成 `get label() { return T("…"); }`——直接赋值会在模块加载时求值、 永久冻在中文。英文表在 `panelDict()`(`lib/client.js:9910`)。 宿主发来的名字也属于同一类,只是来源不同:固定目录(所有会话 / 未分组 / 待办 / 收藏 / 回收站 / 根目录) 的 `name` 在 host 那边是标识符、写法就是中文,所以 `nodeLabel()` 不印它,而是取 `FIXED_META[node.id].label`。默认工作区连固定 id 都没有——注册表里它的标题是 `default-workspace`, host 画成「默认工作区」——节点因此带一个 `default: true` 标志,`nodeLabel()` 看到标志就取 `T("默认工作区")`;用户自己改过名的工作区保留原名,这也是判据用标志而不是字符串的原因。 独立窗口是另一侧:`assets/detached` 是静态页 + 冒充 DSH 的 shim,它读不到 DSH 的语言开关, 也不会跟着 DSH 重渲染。所以语言由面板在 `POST /detached/open` 里报给 host(`uiLang()`, `lib/client.js:1807`),经控制器 `lib/detached/launcher.js` 放进 helper 的 env(`DSH_SE_LANG`), helper 拼进页面 URL(`?lang=`,`lib/detached/main.cjs` 的 `pageUrl()`),shim 从 URL 读出 `LANG`, 之后每句都过 `L(zh, en)`。中途切换语言时控制器用控制通道广播 `{ type: "lang" }`,helper 直接 `win.loadURL()` reload——字典是页面加载时定死的,reload 是唯一能让新字典生效的方式。 `index.html` 仍然随中文首发(HTML 在任何脚本之前就被解析完),英文窗口的标题与启动语由 shim 事后改写。 `test/panel-copy.test.mjs` 守着这条链的可见后果:`T()` 的每个键都在表里、表里没有空值或中文、 占位符两边一致,以及渲染树里不许有裸中文字面量(注释与样式表除外)。 ## 6. 数据流 ``` ┌─ L2 heads(sessions 目录 + zstd 第一帧) ← 真相:存在性、id、cwd reader.snapshot() ←───┼─ L1 projcache(每会话小 JSON) ← 便宜:标题、时间、计数 └─ workspace registry(活服务 → 文件) ← 归属、归档、置顶 ↓ 合并(domain/sessions.js) ↓ 叠加侧车(tree / annotations / trash index) GET /bootstrap → 一次往返把面板需要的全部东西给客户端 ``` 客户端**不重算任何规则**:`tree-rules.js` 在节点上装饰 `accepts` / `scope` / `ref` / `hasWorkspace`,客户端只读这些字段、只沿 `children` 走两件事(子树、父链)。 `test/tree-mirror.test.mjs` 守着这条线:客户端可以留一个"拖拽时先灰掉"的谓词,但不能有第二套规则表。 归属(`workspaceId`)在 `domain/sessions.js` 里定,且**沿父链继承**:DSH 的 `workspace.sessionIds` 只登记用户从工作区打开的会话,子代理是父会话创建的,永远不在里面。 所以一个 `isSubagent` 且没有登记的会话,按 `parentSession` 向上找最近一个已归属的祖先, 继承它的 `workspaceId` / `workspaceTitle`;整条链都没有归属时才退回到 `cwd` 与工作区 路径的**精确**比较。fork 不是子代理(只有 `parentSession` + `isSeeded`),不参与继承。 子智能体的**操作**也绑定在父会话上,规则同样只在一处:`domain/subagents.js`。它回答三个问题—— `isBound`(能不能单独动)、`descendantsOf`(父会话走了谁跟着走)、`withDescendants`(级联的实际 id 列表)。路由侧统一用 `assertStandalone()` 拒绝单独操作(`subagent-unsupported`),回收站三条 路由再用 `withDescendants()` 把整条委派链一起带走;归档不写子会话,靠 `sessions.js` 的 `archivedUp` 从祖先派生。客户端只消费 `session-state` 里的 `subagent` 位,不自己判断。 ## 7. 硬约束(写代码前先读这一节) 1. **零运行时依赖**。`dependencies` 为空,`peerDependencies` 只有 Cordis(optional)。 React 由宿主提供,`assets/vendor/` 里的两个文件只给独立窗口用。 2. **无构建步骤**。`lib/client.js` 直接是浏览器可执行的文件;`node --check` 只证明能解析, 行为靠 `test/render-smoke.mjs` 与几个真实渲染测试兜底。 3. **`lib/client.js` 是单文件,且是刻意的**。模块加载器只给一个 factory,拆文件就要自己实现 模块系统。不要在"整理代码"时拆它。 4. **绝不背着 kernel 写 DSH 的存储**。标题、归档、置顶、派生都调 DSH 自己的服务 (`lib/host/actions.js`)。两个受控例外:`host/move.js` 移动会话文件并重写 header 的 `cwd` (必须同时走 registry 的写路径,否则下次启动会话就丢了),`host/trash.js` 把会话目录整体 移进/移出回收站(DSH 与插件对"它不在了"的理解因此天然一致)。两个例外都用"先记账、后动手" 兜住崩溃:回收站写 `pending` 条目,移动写 `move-journal.v1.json`,启动时各自对账。 5. **侧车可删**。`tree.v1.json` / `annotations.v1.json` / `settings.v1.json` / `trash/index.v1.json` 只存 DSH 不知道的东西;删掉它们不会丢会话。 `move-journal.v1.json` 是例外:它记录的是**正在进行**的一次移动,正常情况下是空文件(不存在)。 6. **服务探测全部防御式**。缺服务只降级一个功能(`/bootstrap` 的 `capabilities` 会告诉客户端 哪些不可用),不能让整个插件挂掉。 7. **副作用都要挂 `ctx.effect`**,否则热重载会漏掉清理(路由 dispose、主题 observer、桥订阅、 helper 进程)。 ## 8. 文档地图 | 文件 | 内容 | |---|---| | [README.md](README.md) | 索引、阅读顺序、三条最容易违反的约定 | | [api.md](api.md) | 全部路由、信封、错误码 → 状态、revision 契约 | | [data-directory.md](data-directory.md) | 数据目录解析、侧车文件、原子写、七步迁移 | | [tree-and-placement.md](tree-and-placement.md) | 树结构、R1–R5、节点 id、快捷方式、虚拟放置 | | [bridge-and-theme.md](bridge-and-theme.md) | 浮窗 ↔ 独立窗口的命令总线与调色板同步 | | [component.md](component.md) | 浏览器组件:下载、校验、安装、独立窗口进程 | | [testing.md](testing.md) | 测试矩阵与怎么跑 | 历史文档(已移出仓库,归档在 `C:\Users\mi\Desktop\project_old\docs-archive\`:`交接文档.md`、 `实现粗规划.md`、`规划.md`、`独立窗口复刻规格.md`、`重构方案-核心UI复用.md`、 `session-manager-prototype.html` 等)。它们描述的是开发过程中的计划,与当前代码有多处不一致, **不要照着它们改代码**——这正是这套文档改为"照代码写"的原因。