客户端:从会话投影到桌面壳

📑 目录

前 12 篇我们沿着 Host 侧一路向下:启动与插件树、Agent 循环、会话日志、LLM 适配层。这一篇掉头向上,回答一个更贴近日常体验的问题——当我们在浏览器或桌面窗口里看到模型逐字输出、工具卡片逐张展开时,这些画面背后发生了什么。

要处理的核心矛盾是:会话状态活在 Host 进程的 Cordis 插件树里,而 UI 活在浏览器(或 Electron 窗口)的 React 组件树里。两者隔着网络与进程,还要支持断线重连。本文基于 2026 年 10 月的 master 分支(0.2.1-alpha.1 前后,处于 developer preview 阶段,API 可能变动)。

我们的路线分五步:先划定 host/client 的六层所有权,再看 Web 服务端的路由、信任围栏与认证,然后跟踪一次会话从 live event 到重连基线的折叠过程,接着剖析客户端的两个巧妙设计——分数 seq 与结算暂存,最后回答"桌面应用与 Web 到底有多像"。

host/client 的六层所有权

dsh 的界面不是 Host 的附属品,而是有明确所有权划分的六层。官方文档在 docs/subsystems/web-client.zh.md:11 给出这张所有权表,我们把要点转述如下:

层职责
Host 应用真正的会话、Agent、工具执行
传输/API assemblytypert RPC、WS 多路复用、快照与事件帧
Client modelClientSessions、连接代际、事件校验
UI adapterConversation 组装、store、可见性
Conversation 数据event/transient 窗口、Context、Node
组合渲染Slots、keyed renderer、React

依赖方向是单向的:Host 状态 → Remote → Client model → UI adapter → Slots → React。越靠左越接近真相,越靠右越接近像素;任何一层都不允许回头依赖更靠左的层。这条单向链让"换皮"成为可能:同一 Client model 之上可以长 CLI、Web、桌面多种外壳,而外壳本身不含业务。

资源寻址也有对应分层:client-resources 用 dsh-resource://<type>/... 地址加单提供方协议,组件经 useResource hook 取用,谁提供、谁消费都由注册表收口,组件不做自己的网络请求。

这条链上立着一条铁律:组件绝不接收 Cordis ctx。打开 packages/client/AGENTS.md 可以看到专门的一节「ctx discipline」——ctx 只属于 apply 世界(插件体和 inject 工厂),组件拿到的全部数据与回调都来自推导出的 props 五要素(owner 数据、children slots、factories、store、inject 面)。为什么如此严格?因为浏览器里的 Cordis 是壳引导时注入的门面(window.__DSH_BOOT__ 加 window.__ModuleLoader__,见 packages/client/web/src/boot.ts:23 的 AppWebEntry.run()),组件一旦伸手拿 ctx,就绕过了 slot 声明与依赖审计,插件之间便无法再独立演化、独立回滚。

浏览器端不是静态打包的一整块,而是按插件动态组装的。Node 半的 ctx.clientModules 扫描各包 package.json 的 dsh.client 声明,产出 WebBootGraph(entries + batches,URL 超过 3 KiB 就贪心切分,修订号由 mtime/ctime/size 派生),再由 /plugins/??a/client.js,b/client.js&rev= 这样的 combo 路由下发;同时响应 webserver/index-inject,把这张引导图写进 index 页面。Electron 不走这条路——它加载打包资源,这是后话。

组件之间也不许随便互相引用:插件包不能 import 另一个插件包的组件,共享控件只能来自 ui-primitives 这样的窄静态宿主。跨插件的 UI 组合全部经 Slots 完成——父组件在注册时声明 children(渲染哪些插槽既是声明也是授权,渲染未声明的插槽在加载期即报错),贡献方用 ctx.slots.inject() 等待声明就绪再注册自己的组件。这套纪律保证了六层所有权在代码层面可执行,而不只是文档约定。

Web 服务端:路由、信任围栏与认证

浏览器里的一切首先从 WebServer 服务开始,packages/host/webserver/src/index.ts:125。它是普通 Cordis Service,Service.init(:221)里 createServer 立即监听,fiber 在端口绑定完成前不 settle。

路由语义被刻意做得极简单。match()(:319)只有三步:exact 表命中 → 最长前缀命中 → 唯一 fallback 席。注册顺序没有任何语义;重复路由直接 throw——普通路由重复(:169)、upgrade 路由重复(:183)、第二个 fallback 抢席(:199),全都视为配置错误,必须在加载期暴露,而不是留到运行时竞争。与之配套的还有三条:压缩只包装有 socket 的响应,且对 text/event-stream 直接豁免(:97),SSE 流不容许被 gzip 打断;逐请求异常只记 400 不杀进程(:251);dispose 时 close()+closeAllConnections() 并自跟踪销毁 upgrade socket,因为 Node 不把升级连接算进 closeAllConnections()。

// packages/host/webserver/src/index.ts:319 match() 的优先级
const exact = this.exact.get(pathname)
if (exact !== undefined) return exact
// …遍历 prefix 表取最长命中…
return best ?? this.fallback

信任围栏在 connection 插件。packages/client/connection/src/index.ts:145 注册 /api 前缀路由,每个请求先过 connection.admit(req):Host 是回环地址,或落在 trustedHosts 声明的精确 host:port 名单内,才放行;否则直接 401/403。config schema 在加载期校验名单格式(:132),畸形条目 fail loud——不信任边界上不允许"先跑起来再说"。通过 admit 之后,请求还要走 connection/request waterfall,默认落到 bridge()——一个把 HTTP body 桥进 typert fetch handler 的适配器,请求体上限默认 300 MiB。

admit 之上还有一层认证。BrowserAuth(packages/client/connection/src/browser-auth.ts:185)持有 32 字节 HMAC 密钥(SECRET_BYTES = 32,存 credentials 服务),进程启动时生成一次性 token 拼进 authenticatedUrl();浏览器首次访问经 303 重定向,用 token 换签名 cookie dsh-auth-<sha256(authority)>——cookie 带 authority、签发与过期时间,HttpOnly + SameSite=Strict,默认 30 天。按 authority 派生 cookie 名,意味着同一浏览器里访问多个 dsh 实例互不串票;token 一次性的设计则保证它不会被写进书签之后长期有效。桌面壳走的也是这条 303 路径,只是换它亲手来换(后文详述)。

流式通道走 WS 多路复用。普通 RPC 与流式 RPC 都经 typert 网关分发:TypertGatewayService(packages/api/gateway/src/index.ts:199)用 connection.rpc.intercept('/api', ...) 认领 unary 请求(:233),再经 registerUpgrade(:251)在 /api/remote.mux(stream-protocol.ts:7 的 REMOTE_STREAM_MUX_PATH)挂 upgrade,admit 不过即拒;网关还可以选择等待 appReady 之后才监听(:267),让"端口已开"与"应用已就绪"解耦。

RemoteStreamMuxServer(stream-server.ts:43)每 2 秒 ping、漏 2 次即 terminate();每个逻辑流按 streamId 多路复用,客户端帧 open/item/end/cancel,服务端下行 item/end/error;上行缓冲 256 KiB,超限即失败。背压是显式的,不是隐式的:与其让内存默默涨,不如让这一条流报错。心跳由服务端主动发出也省去客户端计时器的对齐问题——判定"对方死了"的逻辑只存在一处。

会话跟踪:从 live event 到重连基线

模型流式输出在 Host 侧是 agent/assistant-stream 事件(packages/core/agent/src/runtime-types.ts:128 的 AssistantStreamFrame:start/chunk/end,revision 单调、chunk index 稠密,end 帧要么携带 durable settlement seq,要么标记 abandoned)。这是进程内实时事件,不进会话日志——会话日志里只有结算后的紧凑流。

packages/api/session-controller/src/history.ts:54 起了一个全局监听器,把每个 session 的帧喂给 SessionAssistantStreamAccumulator(assistant-stream.ts:27)。它把稠密帧折叠成每 revision 一份不可变的 reconnect baseline:{revision, activeAttempt: {attemptId, startedAfterSeq, turn, step, nextIndex, stream}}——一个仍在进行的 attempt 的完整压缩快照。换句话说,重连恢复不需要回放历史 token 行,只要这一份 baseline 就能把"正在打字"的状态整个搬过去。

注意这个折叠动作的位置:它在 Host 侧的 session-controller 里完成,而不是在浏览器端。这样客户端拿到的永远是"已经折好"的基线,重连重放的计算量在服务端一次性付出,浏览器只负责按公式展开——把复杂度放在拥有完整事件流的一侧,是这个子系统一贯的分配方式。

客户端经 follow()(history.ts:120,@Remote({mode:'stream'})订阅。首帧是 snapshot(header + cursor + records + projections + assistantStream baseline),随后从 Deque 产出 gap-free 事件,以及 ordinal 切点之后的 assistant 帧——两条流在发送端就排好了先后,不存在"先到的后看到"。

客户端侧 SessionEventStream(src/client/transport.ts:137)校验 baseline 存在且 revision 连续,否则抛错触发重连——校验失败是被设计出来的重连信号,不是异常处理:连接层看到流报错后按 Connection generation 指数退避重建物理连接,新连接上的 follow 用 cursor 语义替换整个逻辑窗口。物理恢复与逻辑恢复由此彻底分离,dsh 不提供统一的 resync;每一层只管自己这一层的连续性,这正是"无统一 resync"决策在代码里的样子。

客户端的两个巧妙设计

第一个设计是分数 seq。瞬态 chunk 不该占用持久 seq,却又必须与持久事件排在同一全序里。ClientAssistantStream(packages/api/session-controller/src/client/sessions/assistant-stream.ts:44)的解法,是把重连后第 n 个瞬态块的 seq 定为 durableCursor + 1 - 1/(n+1):

// packages/api/session-controller/src/client/sessions/assistant-stream.ts:88
seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1),

它挤在下一个持久事件之前,不重号、不越界,而且纯函数可重放:重连展开 baseline 时用同一公式重建瞬态行,渲染状态确定性地复现。一次排序同时解决"流式 + 历史"的全序问题——这正是分数序列在数据同步里的经典用法,dsh 把它压缩成了一行算术。

分数 seq:瞬态块与持久事件共用全序

第二个设计是结算暂存。持久的 assistant/message 在它的 attempt 存活期间先进入 pending Map,对 UI 不可见;end 帧到来才释放:成功则消息立即可见,瞬态行保留到 step/end 才退休;interrupted/failed 用 settlement 替换瞬态行;abandoned 整体删除。这样 UI 永远不会看到"半结算"的助理消息——要么在流式,要么是完整一条。顺序上还有一层讲究:结算先于退休,用户在 step 结束前看到的最后画面,与事后回放历史时看到的画面一致。另外,持久消息内嵌压缩后的紧凑流(expandAssistantStream 展开),历史分页与重连无需逐条 token 行即可复现同一渲染状态。整个生命周期可以画成一张状态机:

stateDiagram-v2
    [*] --> 空闲
    空闲 --> 流式: start 帧打开 attempt
    流式 --> 流式: chunk 帧(分数 seq 瞬态行)
    流式 --> 待结算: end 帧(持久消息进 pending)
    待结算 --> 已释放: 成功(message 立即可见)
    待结算 --> 已替换: interrupted / failed
    待结算 --> 已删除: abandoned
    已释放 --> 空闲: step/end 退休瞬态行
    已替换 --> 空闲
    已删除 --> 空闲

渲染侧由 ConversationNodeAssembler(packages/client/ui-conversation/src/client/conversation/assembler.ts:174)收口:每个 Session 一个增量引擎,输入是 SessionEventLikeEntry 窗口——{type:'event'} 是持久事件,{type:'transient'} 是瞬态行;Context 由 (kind, id) 定位,只有 update 证据的 Context 保持 pending,直到对应 start 被 prepend 进来。事件进来走 replace/append/prepend 三条路径,每来一个事件,对每个已注册的 ConversationNodeDefinition 调一次 match(event),返回 {id, role:'start'|'update'} 或 null——Definition 就是渲染侧的扩展点,浏览器插件注册自己的 Definition,再经 ctx.slots.inject('conversation.chat.node', ...) 挂上 keyed renderer,第 14 篇会用到这个入口。助理节点由 ui-chat 的 AssistantState 把 StreamChunk 折叠成 blocks,updateChunk()(:89)对 live chunk 与持久结算走同一接口。publication 三档 none|animation-frame|immediate 控制发布节奏,流式输出按 animation-frame 批量刷帧,避免每来一个 chunk 就重渲染一次。把整条链画出来:

flowchart TD
    A["agent/assistant-stream(Host 内实时帧)"] --> B["Accumulator 折叠成 baseline"]
    C["session/event 持久日志"] --> D["follow():snapshot + gap-free 事件"]
    B --> D
    D -->|"WS /api/remote.mux"| E["SessionEventStream 校验 baseline 与 revision"]
    E --> F["ClientAssistantStream:分数 seq + 结算暂存"]
    F --> G["ConversationNodeAssembler 每事件 match 各 Definition"]
    G --> H["keyed Chat renderer(AssistantState 折叠 blocks)"]
    H --> I["React 组合渲染"]

桌面壳:薄壳 + 同一 Host

Electron 桌面应用最容易被误解的一点是:它不包含任何 Host 逻辑。壳只做载体与认证,剩下的与 dsh web 是同一组合、同一进程内代码。启动时序值得完整看一遍:

sequenceDiagram
    participant M as Electron main
    participant H as DesktopHostProcess
    participant D as desktop-host(RunAsNode)
    participant P as 渲染页
    M->>H: start()
    H->>D: spawn(Electron 可执行文件, --expose-internals)
    D->>D: runProfile(desktop, --no-open --port 0)
    D->>M: Node IPC: ready(url, injections)
    M->>D: 手动 fetch 换 303 + Set-Cookie
    M->>P: loadURL(dsh-app://app/)
    P->>P: __DSH_BOOT_READY__ 注入后激活客户端
    P->>D: WS /api/remote.mux(主进程改写 origin 并附 cookie)

apps/desktop/src/main.ts:132 注册特权 scheme dsh-app(standard、secure、stream 等特权),拿单实例锁,读登录 shell 环境(GUI 应用拿不到交互式 shell 里的 PATH,必须主动继承),准备 $DSH_HOME/profiles/desktop,先离屏加载页面,避免白屏。DesktopHostProcess.start()(apps/desktop/src/host-process.ts:186)spawn 的不是 node,而是 Electron 可执行文件本身,加 --expose-internals 跑 app.asar 内的 dsh-desktop-host/lib/index.js——这正是 Electron 的 RunAsNode 模式,一个进程跑完整 dsh Host。

apps/desktop-host/src/index.ts:25 调用 runProfile({profile:'desktop', args:['--no-open','--port','0']})——与 CLI 的 dsh web 完全同一组合;--port 0 让系统分配端口,避免与 Web 默认的 3080 冲突,profile 隔离保证数据互不串扰。就绪后经 Node IPC 发 {type:'ready', url: authenticatedUrl, injections},这条 IPC 同时还承载退出检查、更新任务锁与平台账号会话。

壳的认证在 apps/desktop/src/web-document.ts:43:authenticateWebHost() 手动 fetch 启动 URL,要求响应恰好是 303 且带 set-cookie,cookie 留在主进程,永不进渲染进程。随后 window.loadURL('dsh-app://app/'),protocol.handle(:662)对 dsh-app 协议分发:静态文件直读 dist;index 注入 __DSH_BOOT_READY__ Promise;其余请求经 forwardWebRequest 转发到 127.0.0.1:port,剥掉 hop-by-hop 头,插件 bundle 强制 no-store。preload 暴露 dshDesktopBoot.ready() 返回 {injections, streamBaseUrl},apps/web 的注入脚本 resolve 这个 boot gate 后,在同一文档里激活客户端插件——所以 Web 的 Vite shell 自己跑不起来(缺 __DSH_BOOT__ 门面),dsh web 始终是唯一启动者,系统提示词里甚至教模型"不要另起服务器替换本 GUI"。

最后一条链路是 WS。浏览器连 ws://127.0.0.1:*/api/remote.mux,回环 WS 请求不带浏览器 cookie,身份由主进程代言:onBeforeSendHeaders 只对应用窗口把 origin 改写为 http origin 并附上 cookie。首次启动零 pnpm 安装——app.asar 内的 dsh 携带全量生产依赖树;壳与运行时同版本、同签名更新单元,发布身份绑定在一起,不会出现"壳升了、运行时没升"的错位。桌面甚至复用同一套插件管理:内置 CLI 以 --profile desktop 管理桌面自己的 profile(apps/cli/src/plugin.ts:70 的 runPlugin),只是要求桌面完全退出后才允许执行插件事务。

小结

客户端没有发明新机制:它复用同一 Host、同一认证、同一 WS 多路复用。它真正解决的是三个问题——全序(分数 seq 把瞬态块挤进持久事件的全序空间)、结算时机(持久消息暂存到 end 帧,UI 永远不见半结算状态)、载体无关(桌面只是薄壳 + 同一 Host,连启动命令行都只差 --port 0)。安全边界同样分层:admit 管"谁可以靠近",BrowserAuth 管"你是谁",两道都不过的请求到不了 RPC 分发。而 WebServer 那套"exact → 最长前缀 → 唯一 fallback,重复即 throw"的路由哲学,与本系列第 2 篇看过的配置 fail-loud 一脉相承:错误应当死于加载期。

至此,读代码的部分结束。下一篇是系列收尾:我们离开阅读模式,亲手写第一个插件,把 13 篇里见过的扩展点逐一用起来。