dsh-session-navigator

架构

本文按代码实际结构写,不是按设计稿。凡是本文与代码冲突的地方,以代码为准,并请提 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-navigator/api,所有端点都在 它下面(lib/index.js:333-337)。Client 半体往 DSH 的 UI 插槽里注册 5 个组件,并把浮窗 portal 到 <body> 上的固定层。

加载链路

package.json
├─ dsh.bundle.patch = "./cordis.patch.yml"
│    └─ - insert: [{ id: session-navigator, name: 'dsh-session-navigator', config: {} }]
│         → Cordis 按包名 import,取 name / inject / apply
│           (lib/index.js:109 name, lib/index.js:114 inject, lib/index.js:123 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:123-353,顺序有意义:

  1. 拿 webServer(:122-123)。inject = ["webServer"] 是唯一硬依赖:没有 HTTP 服务器的 profile 就没有浏览器半体,此时直接 return,让 loader 跳过这一行。
  2. 开数据目录(:127-138):resolveDataDir() → dataPaths() → openDocuments()。 openAt(dir) 让迁移完成后能在不重启的情况下切到新目录。
  3. 四个可选件(:140-184):
    • component:浏览器组件状态机(Electron 运行时);
    • detached:独立窗口 helper 进程控制器(懒创建);
    • bridge:浮窗 ↔ 独立窗口的命令总线(内存);
    • theme:浮窗上报、窗口读取的调色板。
  4. 组装 api 对象(:187-273)。这是路由处理器唯一的依赖入口: ctx / dataDir / paths / documents / migration / reopen / component / detached / bridge / theme / capabilities / reader / content / indexBuilder / summarizer。dataDir 与 paths 是 getter,迁移后自动指向新值——内容索引的缓存文件也跟着走(content 用 getter 拿路径,见 api.md §4.14)。 summarizer 在字面量之后单独赋值(:250 附近):它要引用正在定义的 api,而且它的 summaries / settings 必须每次现读 documents——迁移会重新赋值那个变量, 闭包里抓一份旧引用就会写进旧目录。
  5. 启动自愈(:275-296):reconcileTrash(api) 收尾”索引已写、payload 未搬”的中断; reconcileMoves(api) 做同一件事的移动版(payload 已搬到目标、注册表还没跟上); component.sweep() 删除上一次卸载时被占用、删不掉的目录。三者都 best-effort、不 await。
  6. 注册前缀路由(:298-306),并把返回的 dispose 挂到 ctx.effect。
  7. 退出时收尾(:308-317):插件被禁用/重载时 api.detached.stop(),否则 helper 进程会 活过拥有它的代码。

3. 一次请求的生命周期

handle(api, req, res)(lib/index.js:2184-2231):

routeOf(req.url)                 // lib/http.js:88  剥掉 /session-navigator/api
  ↓ null → 404 bad-request
compileRoute 匹配表               // lib/index.js:2140  段数相等 + :param 解码
  ↓ 无 → 404 no route for <METHOD> <path>
迁移守卫                          // 非 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:2310  错误码 → HTTP 状态
           fail(res, status, code, message)

三条不变量:

4. 分层

lib/
├─ index.js          2417  插件装配 + 路由表(76 条)+ 错误映射(唯一编排层)
├─ 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           365  数据目录解析、`.location` 指针、原子写临时目录清理
├─ fs.js               52  存在与列举:宿主与组件安装共用的两个判断
│
├─ sources/                 读 DSH 的数据(只读,除 move 的受控重写)
│  ├─ heads.js        233  L2:sessions 目录遍历(按实际版本名取最新日志,并发 8,结果排序)
│  ├─ projcache.js    228  L1:storages/session_projcache 的每会话小 JSON
│  ├─ workspaces.js   127  workspace registry:优先活服务,回退 storages/workspace.json
│  ├─ logfacts.js     154  日志只回答一件事:这个会话有没有过一轮对话(空白会话),按 path|size|mtime 有界缓存
│  ├─ live.js         122  哪些会话真的在跑:只信 ctx.agents 的 status,绝不从 ctx.sessions 或日志推断
│  ├─ artifact.js     227  会话文件路径编码/解码、读日志、重写 header
│  ├─ content.js      389  会话内容索引:按 path|size|activityAt 判失效,逐份构建、可续查,总是落盘
│  └─ reader.js       313  三源合并 + TTL 缓存策略(/bootstrap 的数据源)
│
├─ domain/                  纯规则,无 IO,可直接单测
│  ├─ sessions.js     310  合并三源 → 扁平会话列表(header 定身份,cache 只填空)
│  ├─ tree.js         477  树投影:workspace 节点 + 固定节点 + 用户文件夹
│  ├─ tree-rules.js   487  规则表 R1–R5 的唯一实现(accepts / scope / ref 装饰)
│  ├─ folders.js      335  新建/重命名/移动/删除文件夹(改 tree draft)
│  ├─ shortcuts.js    259  快捷方式:引用而非移动
│  ├─ annotations.js  276  标签、备注、收藏/待办
│  ├─ settings.js     439  设置文档形状与归一化(搜索索引只剩 indexIdle 一个开关)
│  ├─ trash.js        210  回收站条目形状、恢复阶梯
│  ├─ subagents.js    107  子智能体绑定:isBound / descendantsOf / withDescendants / boundIds
│  ├─ export.js       681  日志 → Markdown / JSONL(surfaceOp 与压缩只报告、不过滤)
│  ├─ summaries.js    763  AI 字段的提示词契约、回答解析、预算、花费账本、待补清单(纯函数)
│  └─ session-state.js 68  单个会话的布尔位(给 DSH 自己的按钮用,含 subagent)
│
├─ store/                   自己的持久化
│  ├─ sidecar.js      238  原子写 + revision + 串行化 + mtime/size 缓存
│  └─ documents.js    151  五份侧车文档的注册表
│
├─ host/                    需要动文件系统或跨进程的操作
│  ├─ actions.js      701  改名/派生/归档/置顶 → 调 DSH 自己的服务
│  ├─ trash.js        496  删除/恢复/清空的字节搬运
│  ├─ move.js         777  跨 workspace 移动/复制(重写 header cwd + 换登记)
│  ├─ move-journal.js 101  移动的意图日志:崩在两次写之间时,启动收尾而不是留下孤儿
│  ├─ datadir.js      367  数据目录检查与七步迁移
│  ├─ bridge.js       170  命令环形缓冲 + 游标
│  ├─ session-sync.js 238  跟 DSH 保持同步:订阅 session/* 与 agent/* 事件,合并成一次刷新
│  ├─ storage-watch.js 146  盯 storages/ 目录的 mtime:DSH 改了投影缓存就重读,事件漏了也有兜底
│  ├─ theme.js        103  调色板令牌白名单与校验
│  ├─ native-reveal.js 107 koffi → `SHOpenFolderAndSelectItems`,"在文件夹中显示"
│  ├─ reveal-agent.js  203 常驻 PowerShell:省掉每次 reveal 一秒多的进程启动
│  ├─ update.js       210  更新检查:版本比较、GitHub release 解析、5 分钟缓存
│  ├─ open-link.js    161  链接白名单(repo / star / issues / releases)→ 系统浏览器(带降级链)
│  ├─ fsutil.js        22  psQuote / dshHomeOf:两条 PowerShell 命令行共用的引用与 home
│  ├─ maintenance.js  229  缓存清理:内存三件 + `.tmp` / 下载目录 / staging,跳过在装的组件
│  ├─ summarize.js    787  AI 字段队列:模型目录、路由、单飞 worker、重试与 token 落账(调 DSH 的 llm 服务)
│  └─ reset.js        209  清除数据:五份侧车 + 回收站净内容 + 隔离区 + `.tmp`,`DELETE` 把关
│
├─ component/               可选 Electron 运行时
│  ├─ layout.js       226  路径、版本、SHA-256、镜像、体积
│  ├─ state.js        168  state.v1.json 生命周期机
│  ├─ downloader.js   339  curl 流式下载(断点续传、镜像、进度)
│  ├─ install.js      541  校验 → 解压 → 原子发布
│  └─ manager.js      688  单飞状态机(install/cancel/remove 共享一把锁)
│
├─ detached/
│  ├─ launcher.js     415  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 唯一一次向外发请求:AI 字段

AI 字段(标题 / 描述 / 标签)是插件里唯一会把会话内容送到模型的地方,也因此是唯一需要 llm 服务的功能。

4.2 一件只能 host 做的事:更新检查

设置页「关于」组的三个入口里有两个不能由客户端完成,这是它们单独放在 host/ 的原因:

第三个(缓存清理,host/maintenance.js)固然是 host 的活,但它有一条同样值得记住的边界: 只清插件自己的缓存(内存里的三处与 .tmp / 下载目录 / staging),quarantine/ 与 DSH 自己的 storages/session_projcache 一律不碰;组件正在下载/校验/解压时跳过那两项。 浏览器缓存也不归它管:面板的 /ui/* 本来就不缓存,其余属于 DSH 的渲染进程和独立窗口。

4.3 唯一不可逆的操作:清除数据

设置页最后一组(「危险操作」)只有一个按钮,它删的是用户自己的东西:虚拟树(文件夹、工作区 摆放与顺序)、标签、备注、设置、摘要与它的花费账本、回收站索引,以及回收站里的会话目录本体。回收站是 move 不是 copy(host/trash.js), 那些目录曾经就是 DSH 的会话目录——这一步没有”再点一次还原”可退。

所以判断全在 host/reset.js,客户端只把用户打的字原样送上去:

页面上的红按钮与输入框只是礼貌。这条路由没有 token,真正的门必须在 host:

if (typed !== RESET_CONFIRMATION) throw codedError("confirm-required", "…");   // 400,一个字节都没动
if (!isUsableDataDir(dataDir)) throw codedError("reset-refused", "…");        // 409

5. Client 半体:apply(ctx) 注册了什么

lib/client.js:14190-14363。inject = ["slots", "locale", "uiWorkspace"](:13067)。

插槽(order 决定与 DSH 自带条目的相对位置):

插槽 id order 组件 作用
sidebar.footer.action session-navigator-footer 40 FooterAction 侧栏底部入口,开/关浮窗
settings.section session-navigator 510 SettingsSection 设置页分组(label 是 thunk,跟随语言);组序 打开方式 → 列表 → 会话界面 → 浮窗 → 浏览器组件 → 数据目录 → 关于 → 危险操作
settings.onboarding session-navigator-opener 900 匿名 只为拿到 openSection 后立即 complete(),不留 UI
conversation.session.header.actions session-navigator-header 60 SessionHeaderActions 会话标题栏五个按钮(样式跟随设置 shell.header.style)
sidebar.workspaces.session.menu.item session-navigator-rows 500 SessionMenuItems 会话行 “…” 菜单两条

effect(随插件卸载自动清理):

导出:apply / inject / panelStore / SettingsSection(:13066-13070)。

浮窗本体是 Panel(lib/client.js:6356,单函数约 5500 行,内部按功能分段), 它被 portal 到 <body> 上的固定层——侧栏有 overflow: hidden,原地渲染会被裁掉 (lib/client.js:879 的注释)。

5.1 面板与独立窗口的双语:两套机制,一条链路

按「这一屏的标签是不是固定的」分工:

T 读模块级的 panelLang,而 Panel 每次渲染都从 props 的翻译器刷新它: panelLang = typeof t === "function" && t("langCode") === "en" ? "en" : "zh";(lib/client.js:6359)。 语言开关一动,DSH 用新的 t 重渲染插槽,下一帧就是新语言。代价是模块级表(FIXED_META、 COLUMNS)的 label 必须写成 get label() { return T("…"); }——直接赋值会在模块加载时求值、 永久冻在中文。英文表在 panelDict()(lib/client.js:13228)。

宿主发来的名字也属于同一类,只是来源不同:固定目录(所有会话 / 未分组 / 待办 / 收藏 / 回收站 / 根目录) 的 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:2216),经控制器 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() 的每个键都在表里、表里没有空值或中文、 占位符两边一致,以及渲染树里不许有裸中文字面量(注释与样式表除外)。

5.2 列表的「带」:先切数据,再画标题

列表不是一棵树长出来的,是一串带(band)。ListPane 先把行分组,再统一画成 button.grp(标题行:折叠箭头、图标、名字、计数)+ 该带的行,行本身由 rowsOf() 生成:

groups.push({ key, title, icon, count, rows });

带是渲染前的数据结构,不是 CSS 分组,「这一带有什么」和「这一带画不画」因此在同一个对象里 回答。带按固定优先级排:置顶、搜索/筛选结果、特殊文件夹、容器(文件夹 / 工作区 / 快捷方式)、 所有会话 的行、已归档(lib/client.js:4599-4700 的九处 groups.push)。

所有会话 在没有搜索词、没有筛选时不是一条平带,而是一条带一个工作区: workspaceBands(sessions, tree)(lib/client.js:3962)按 session.workspaceId 分桶, 标题取会话视图里的 workspaceTitle——跟树画的是同一个名字,视图过期时回落到 id; 顺序用 DFS 从整棵树取(进到 workspace 节点就记位置、不再下钻,所以拖进用户文件夹的工作区 仍留在文件夹的位置上),树不认识的工作区排在已知之后,没有工作区的沉到最末。 搜索与筛选一旦生效就退回单带(lib/client.js:4672 的 byWorkspace):那些列表回答 「哪几行匹配」,每行一条工作区标题只是复读。

折叠只改画不改数据。foldedBands 是 Panel 的 Set 状态(lib/client.js:6483,初值来自 布局记忆),键是 scope + "\u0000" + bandKey(lib/client.js:4337)——同一个「会话」带在 根目录和某个工作区里是两个开关,否则折一次全站没有行。折起来的带在建行之前短路: rows: bandFolded("normal") ? null : rowsOf(normal)(lib/client.js:4690)。所以不是渲染完 再藏起来——被折的行既不在 DOM 里,也不进框选,删不掉看不见的东西。状态随布局记忆落盘 (foldedBands: [...foldedBands],lib/client.js:6672)。

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 位,不自己判断。

状态不在 /bootstrap 里。 一行会话是”进行中 / 等待审批 / 已完成 / 空闲”这件事,只有 DSH 自己的客户端知道:uiSession.sessionStatus 是一个 HostObservable,插件用 useSyncExternalStore 订阅它(useDshSessionStatus),再用 withDshStatus 把 pendingInteraction / running / completionUnread / 运行中的子会话数折进列表行,statusOf 按 DSH 的阶梯取第一档。这样侧栏和面板 看的是同一份内存状态,不必等下一次扫描,也不会各自推断出不同的答案——日志只回答”有没有过一轮”, 不再回答”这轮是不是断了”。

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 索引、阅读顺序、三条最容易违反的约定
api.md 全部路由、信封、错误码 → 状态、revision 契约
data-directory.md 数据目录解析、侧车文件、原子写、七步迁移
tree-and-placement.md 树结构、R1–R5、节点 id、快捷方式、虚拟放置
bridge-and-theme.md 浮窗 ↔ 独立窗口的命令总线与调色板同步
component.md 浏览器组件:下载、校验、安装、独立窗口进程
testing.md 测试矩阵与怎么跑

历史文档(已移出仓库,归档在 C:\Users\mi\Desktop\project_old\docs-archive\:交接文档.md、 实现粗规划.md、规划.md、独立窗口复刻规格.md、重构方案-核心UI复用.md、 session-manager-prototype.html 等)。它们描述的是开发过程中的计划,与当前代码有多处不一致, 不要照着它们改代码——这正是这套文档改为”照代码写”的原因。