ARTICLE · 999371
DeepSeek-Harness 源码剖析(十):运行时不知道 UI 存在,web-ui 插件凭什么联动
摘要:dsh web 的联动是单向的:上行一条 HTTP POST 向事件日志投递消息,下行两条只读 WebSocket 流把日志投影回浏览器。UI 不持有任何业务状态,断线恢复就是重开流加重拉尾页。这篇跟一次 Enter 走完全程。
上一篇结尾是个安静的画面:父代理睡得正沉,一条报告躺在 inbox 里等唤醒。这篇把镜头切到浏览器:坐在屏幕前的人,怎么看见这群分身和它们背后的事件流账本,还能直接插手。
给事件溯源的运行时配 GUI,直觉做法是请求-响应:前端每次操作发一个「干活」请求,后端算完把结果塞进 HTTP 响应。这条路死在流式上。LLM 的输出逐 chunk 到达,工具在执行,审批在等待,这些全都发生在「请求-响应」的模型之外。于是多数项目会长出第二套机制,轮询或者自定义推送,前后端各自维护一份会话状态,然后在两边不一致的泥潭里度日。
dsh 没走这条路。先说结论:上行是一条一元 RPC 通道,向事件日志投递消息;下行是两条只读流,把日志投影回浏览器。UI 不持有任何业务真相,它只是 host 会话日志在浏览器里的一个视图。全文剩下的一切,帧结构、重连、缝合、渲染分级,都在回答同一个问题:怎么把这个视图做得实时、抗断、可恢复。
主线也定下来:跟一次 Enter 走完全程。
一、两段式:浏览器 shell 加 Node host
web-ui 不是单个插件,是「浏览器 shell + Node host」的两段式插件族。host 侧三件:webserver 提供 ctx.webServer 的路由注册,apiproxy 是 RPC 网关(api-proxy.ts,3671 行,我数的,教程初稿记的 3744 已经漂移),client-connection 把 /api 路由和两条 WS 下行泵挂上 webserver。浏览器侧一片:web 是入口,connection 管重连,runtime 是 Session 对象层,ui-* 一族是 React 特性插件,经 cordis 插槽组合。
传输是非对称双通道。上行纯 HTTP POST,每个 RPC 方法一个路径,比如 POST /api/session.prompt;下行两条只读 WebSocket 流,客户端发任何 WS 消息都是协议违例:/api/events.mux 是全 session 聚合流,/api/events.host 推 session 增删和 running 位翻转。
浏览器入口有多薄?apps/web/src/main.ts 全文 10 行,摘核心四行:
import { AppWebEntry } from '@deepseek-ai/dsh-client-web'const el = document.getElementById('root')if (el === null) throw new Error('web app: missing #root')void new AppWebEntry(el).run()这段是浏览器侧唯一的应用入口。注意它只找 #root 挂上去,别的什么都不干。插件图由 host 序列化进 window.__DSH_BOOT__(boot.ts:62),shell 按清单逐个 fetch 插件包、经 cordis Loader 挂成插件树,不打成一个巨型 bundle。
入口只有 10 行,那真正的复杂性从哪开始?连接。
二、连接:一代一握手的重连循环
浏览器侧的连接由 ConnectionController.loop()(connection.ts:107)驱动,它为每个「连接代(generation)」做三件事:并行打开两条 WS;发一次 host.describe 一元 RPC 做严格就绪握手,两流 onOpen 加 describe 成功才算连上,等不到就 3 秒超时放行,漏掉的帧交给缺口修复去补(connection.ts:133-148);流一断,进指数退避,开下一代。
退避的默认参数就四个数,在文件头:
const CONNECTION_DEFAULTS: Required<ConnectionConfig> = { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, streamOpenTimeoutMs: 3_000,}这段是重连策略的全部默认值。注意抖动不在这张表里,而在计算处:实际延迟取 cap/2 到 cap 之间(connection.ts:93-94),同一批断线的客户端不会踩着同一个节奏回撞。
握手为什么必须严格?注释给了时序理由:describe 证明一元通道可达,resync 由 onConnected 触发,握手放松一步,恢复流程就会跑在订阅基线前面,缝隙里的帧没人认领。
连接活了。轮到主线:按下 Enter。
三、Enter 之后:状态机与乐观提交
InputBar 的 keydown 第一道是 IME 守卫(InputBar.tsx:144-145):输入法合成期的 Enter 是选词,不发送。真正的提交进 InputMachine,一个 session 一个实例,草稿、撤销栈、占位符全在里面;状态机产出的 effect 由 shell 执行,最终落到 InputHub.sink。
sink 的策略写在头注释里(hub.ts:160-165):optimistic clear + prompt,失败才把草稿恢复回来。清稿不等服务器确认,Enter 的手感才不会卡在一次网络往返上。
真正发请求的是 runtime 的 Session.prompt。它在第一个 await 之前干的事值得整段看:
// Synchronous, before the first await: the blank → engaging edge must be// visible on the session area's very first frame when a caller sends// ahead of navigation (first-send flow).this.promptAttempted = trueif (this.blankBit) this.firstPromptPendingTurn = truethis.notifier.markDirty()这段是 prompt() 的开头(session.ts:196-201)。注意注释给的理由:用户在页面跳转前抢先发送时,「空白 → 启动中」的相位切换必须赶上渲染的第一帧,慢一个 await 就会闪一帧空白。相位的推导函数就一行(session.ts:806-808):有内容看内容,没内容看 promptAttempted。
mode 只有两个值:queue 排下一个 turn,steer 打断当前 turn 注入下一步。普通会话走 api.sessions.prompt,子代理续写地址走 api.subagents.prompt(session.ts:206/:232),《源码剖析(九)》里那条续写地址在这里落地。
请求的载体是一元 POST。WebApiClient.callUnary 生成 rpcId、POST、校验回显的 rpcId、zod 解析响应(fetch/client.ts:333-344)。请求出浏览器,到 host 侧了。
四、host 准入:编译期锁死的路由表
/api 路由先过一道信任栅栏 isTrustedApiRequest(connection/src/index.ts:165)。栅栏防什么,头注释说得很直白:DNS rebinding 与跨站请求,并且明说网络可达性与认证不在它的职责内(api-request-trust.ts:1-12)。本地工具的信任模型,这句话划清了边界。
路由表本身是个类型技巧:
/** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a * route row fails to compile, and each row's schema/invoke pair is checked against that row's * … */const UNARY_ROUTES: UnaryRoutes = {这段是 fetch/handler.ts:75-90 的路由表声明。注意 compiler-locked 这个词:RPC 方法表里加一行签名却忘了登记路由,编译直接失败;schema 贴错行这类运行期事故,在类型层就死了。
session.prompt 的准入在 api-proxy.ts:2390-2443,核心投递动作只有四行:
const durable = await durablePromptContent(ctx, content)const message: UserMessage = createUserMessage({ content: durable, source })if (mode === 'steer') agent.steer(message)else agent.followup(message)这段是 admit 闭包的出口(api-proxy.ts:2425-2428)。注意只有两个分支:steer 注入当前 turn,followup 开新 turn。web 端对运行时的全部写操作,就是这两个入口加一个审批应答 api.respond,没有第三个。
还有一笔账在 agentFor 里。会话没有活 agent 时,resolver 从持久日志冷恢复重建,注释的原话是 「ordinary cold sessions resume once per identity」(api/remotes/src/agent-lookup.ts:122)。浏览器对着一个重启过的 host 发 prompt,照样接得住。
返回的 {accepted:true} 只表示消息进了 inbox,不含任何内容。屏幕上的字从哪来?下行流。
五、下行泵:从 session.append 到 WebSocket
先把全景图放这儿,一次 Enter 从按下到屏幕出字的三段路——上行一票、下行一整条、浏览器再折一次——后面每一小节都在填它的一段:

上行一票,下行一整条投影
《源码剖析(五)》拆过 append 的四步,其中一步在这里点火:事件 push 进 log 之后,同步派发 session/event 回调(core/session/src/index.ts:641-644)。下行流从这一行发源。
host 侧 events.mux 流打开时订阅 session/event(api-proxy.ts:3358 起),每个事件包成帧 push 进该消费者的 FrameQueue,队列的迭代被 WebSocketDownlinks 泵出,逐帧 JSON.stringify 写进 WS。审批与提问的等待帧走同一条流;pending inbox 不是持久事件,host 每次变更广播全量快照,监听点在 agent/inbox/spliced(api-proxy.ts:1302-1305),浏览器永远以最新一帧为准。
我们把一次 Enter 的完整时序压缩成一段:
Enter → InputMachine → InputHub.sink(乐观清稿) → POST /api/session.prompt → admit → followup/steer ← {accepted:true}(独立返回,不含内容) → agent turn: turn/start, user/message, step/start, assistant/chunk ×N, assistant/message, tool/call, tool/result, turn/end ← 每条事件: session.append → session/event → FrameQueue → WS → ConnectionController 泵 → SessionManager 路由 → Assembler 折叠 → Notifier(60fps 合并)→ ChatView 重渲这段是上行与下行的合流图。注意 assistant/chunk ×N:模型每吐一个 chunk,就是一条落了日志的事件,整条链上没有「攒一批再推」的批处理。
帧进浏览器之后,怎么吃不撕裂?
六、浏览器怎么吃:守卫、缝合、60fps
帧先过 seq 守卫。acceptLiveEvent 发现序号有缺口,不硬拼:
const tailSeq = this.windowTailSeq()if (tailSeq !== null && event.seq > tailSeq + 1) { this.liveBuffer.push({ event, view }) void this.repairGap() return}this.scheduleConversation(this.appendLive(event, view))这段是缺口守卫的本体(runtime session.ts:693-699)。注意顺序:先缓冲,再异步补拉缺失的尾页,序号接上了才 appendLive。重连窗口里丢的帧在这里静默补齐,UI 上没有 loading 闪烁。
缝合在更早一层。onConnected 之后,每个打开的会话 resync() 拉历史尾页(一页 50 条,session.ts:420);mux 帧可能跑在 history 响应前面,先缓冲,installWindow 按 seq 唯一键把实时流与历史页缝成一条无缝序列(session.ts:651-667,注释专门强调缝合不许走 acceptLiveEvent 这条路)。恢复没有续传游标,events.ts 的注释写明 since 钩子 v1 未实现,恢复就是重开流加重拉。
渲染侧做压力分级。ConversationPublication 三档:none、animation-frame、immediate(contract/conversation.ts:165)。assistant 节点的增量折叠长这样:
case 'text-delta': { const previous = blocks[chunk.index] blocks[chunk.index] = { kind: 'text', text: (previous?.kind === 'text' ? previous.text : '') + chunk.text } break}这段是 assistant 节点对 chunk 的折叠(conversation-nodes/assistant.ts:88-92)。注意 blocks[chunk.index]:每个 chunk 按自己的下标累加文本,乱序到达也不会串行。纯流式文本的发布节奏是 animation-frame,Notifier 的头注释写明 「N markFrameDirty calls collapse into one animation-frame flush」(notifier.ts:2-3);结构性变化走 immediate,微任务批量。chunk 风暴被压到每帧一次渲染。
turn 收尾的画面:turn/end 让节点收边,host 流推 running:false,Session.handleRunning 翻转快照(session.ts:525),输入框回到可输入态;侧栏的「完成」提醒由 running 位 true→false 的边沿武装(manager.ts:122-124)。
七、设计取舍
逐条对账。
联动为什么做成单向?agent 循环里没有一行 UI 代码,CLI 和 web 共用同一个运行时;反过来,UI 的每个状态都能从日志加帧序列推出来,界面测试退化成事件序列断言。换掉整条 UI 链,运行时一行不改。
一元上行换来什么?客户端只发 HTTP POST,host 不需要处理第二种入站消息,代理、缓存、加认证全按 HTTP 的规矩来。同一份 ApiProxy 契约可以换载体,测试环境用 SSE fixture 就能跑全套 UI。
代价两条,源码注释都认账。其一,每 chunk 一条日志事件加一条 WS 帧,存储与下行放大不小,渲染侧靠 rAF 合并,传输侧没有合批。其二,信任栅栏只防 DNS rebinding,session.prompt 能执行任意 host 工具,LAN 部署等于把 shell 交给局域网里任何人。栅栏的边界注释原文是这么写的:
* Network reachability and authentication stay out of scope: binding policy这段是 api-request-trust.ts 的范围声明。注意它连「不管什么」都写成了文档:能管的是绑定策略,管不了可达性与认证。少数管理方法被 PRIVILEGED_METHODS 钉在 loopback(connection/src/index.ts:89),我翻这段注释时,它还专门论证了「模型目录为什么不进这张表」,坦白程度少见。
还有一笔理解成本:3671 行集中在一个 api-proxy.ts 闭包里,本篇行号大半指向它。
八、结语
拔掉网线 30 秒再插上,会发生什么?ConnectionController 沿退避曲线重连,新一代握手成功后 resync 拉回尾页;断线窗口里落了日志的那些 chunk,由 repairGap 按 seq 缺口补拉;blocks[chunk.index] 接着往下累加,输入框里的草稿还在。UI 没有迁移过任何状态,它本来就没有状态可迁移。
下一篇倒回起点:dsh web 敲下去到浏览器可用,Web 服务器、API、UI 每一层何时就位,六个就绪台阶逐个落地。
你的 Agent 界面和运行时是怎么通信的?轮询、WebSocket,还是也做成了事件投影?评论区聊聊。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。