Skip to content

3.12 Web 前端架构(浏览器 UI 是如何搭起来的)

本章概览:DSH 的浏览器 UI(dsh --profile web)是"宿主进程 + 浏览器插件图"的双端架构。本章拆解它的分层骨架:Node 侧承载 HTTP 与 API 网关的 host 插件族、注入浏览器入口图的 client 插件族、两者之间的传输与信任围栏,以及 dsh-web-app 组合包如何把它们拼成一个可运行的 Web GUI。

概述 / 定位

Web GUI 不是"一个前端项目",而是 DSH"一切皆插件"原则在浏览器端的延伸。业务状态(会话日志、Agent 轮次、工具执行、模型请求)全部活在 Node 宿主进程里;浏览器只负责渲染与交互,通过一条 API 通道读写宿主状态、通过两条下行流接收事件推送。代码分为三块:

  • host 插件族packages/host/):HTTP 载体(webserver)、传输无关的 API 网关(apiproxy)、SPA 静态服务(frontend-static)、目录选择 seam(directory-picker*)、插件清单投影(plugin-inventory);
  • client 插件族packages/client/):shell 内核(web)、模块表(modules)、浏览器-宿主连接(connection)、React 无关的对象层(runtime)、热重载(hmr)、设置表单模型(schema-form)、i18n(locale),以及约三十个 dsh-client-ui-* 功能插件;
  • 组合包与入口packages/bundle/web-app/ + apps/web/):dsh-web-app 决定"Web 表面装配什么",apps/web(即 @deepseek-ai/dsh-web-frontend)是 Vite 构建 shell 的产物包,其 dist/ 由宿主进程直接伺服。

出处:packages/client/README.mdpackages/host/README.md(本地 deepseek-harness-src/packages/client/README.mddeepseek-harness-src/packages/host/README.md

用户视角的入门路径(配置模型、选择 workspace、运行任务)见官方 Use the Web UI 指南deepseek-harness-src/docs/user/guide/index.md)。

核心机制

总体分层:宿主、浏览器与中间网关

宿主与浏览器共享同一份 TypeScript 线协议契约(packages/host/apiproxy/src/api/,零 Node 依赖、可被浏览器直接 import),所有消息构成"谁发起 × 请求/响应"的四象限判别联合:ClientRequest(POST /api/<method> 体)、ServerResponse(该 POST 的响应体)、ServerRequest(SSE/WebSocket 帧)、ClientResponse(POST /api/respond 体),响应永远回显匹配请求的 rpcId。载体(carrier)与业务域解耦:ctx.apiProxy 不知道物理通道,HTTP 载体由 dsh-client-connection 提供,同进程测试走 toFetchHandler 的 isomorphic 载体。

出处:packages/host/apiproxy/README.mddeepseek-harness-src/packages/host/apiproxy/README.md

webserver:命名路由与静态回退

ctx.webServer 是一个 node:http 服务器插件(默认导出 WebServer,配置 {host, port}),本身不感知任何 harness 概念、不伺服任何文件——/api 桥、插件 bundle、HMR 事件流、SPA dist 全由其他插件以"命名路由"方式注册进来。它的 API 面有四件事:

  • register(route) / registerUpgrade(route):注册 exact/prefix 类型 HTTP 路由与精确路径的 upgrade 路由,返回 disposer;同表内重复路径直接抛错(路由模式是组合层契约,碰撞即配置错误);
  • registerFallback(handler):注册唯一的兜底处理器,回答所有未命中命名路由的请求;第二个注册者抛错,未注册时服务器对未匹配请求答 404;
  • tapIndex(transform) / applyIndexTaps(html):注册 index.html 变换,按注册顺序应用于每次 index 响应——ctx.clientModules 正是借此把 boot 清单注入页面;
  • 匹配顺序固定:整表 exact → 最长 prefix → fallback;upgrade 只做精确匹配。

dsh-host-frontend-static 占用 fallback 席位,以锁定语义伺服构建产物:目录穿越出 dist 根答 403,任何 miss 都回退 index.html 且答 HTTP 200(SPA 路由),非 GET/HEAD 答 405,未知扩展名按 application/octet-stream 发送。每个 index 响应都过 index taps,于是"刷新一次页面 = 拿到最新 boot 清单"。host 只接受 127.0.0.1(默认姿态)与 0.0.0.0(刻意网络暴露),没有 TLS、认证或 origin 策略;port: 0 由操作系统分配端口。

出处:packages/host/webserver/README.mdpackages/host/frontend-static/README.md(本地 deepseek-harness-src/packages/host/webserver/README.mddeepseek-harness-src/packages/host/frontend-static/README.md);子系统页见 docs/subsystems/web-server.md

为什么"前端 dist 必须构建":dist 位置是组合包的装配事实(assembly fact),由 dsh-web-appweb-runtime 胶水通过 require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') 解析(见 packages/bundle/web-app/src/index.tsresolveDistIndex)。解析失败会在激活时抛 frontend dist not built; run pnpm run build from the repository root first,使该 fiber 激活失败(fail loud)——不存在源码伺服的回退。同理,ctx.clientModules 对未构建的插件 bundle 答 404,而非让 SPA fallback 把 HTML 当 JavaScript 返回。

出处:packages/bundle/web-app/README.mddeepseek-harness-src/packages/bundle/web-app/README.md

apiProxy:传输无关网关与 Typert RPC 网关

ctx.apiProxyApiProxyService)是所有客户端形状共享的宿主 API 网关:它不注册任何路由,HTTP 载体自行把 ctx.apiProxy 包装成 /api。业务域以 RpcMethodMap 注册(SessionsApiHostApiEventsApi 等域接口持有方法参数/返回类型),Zod schema 在信封层与业务负载层双重解析;每个 /api POST 必须声明 application/json,否则 415——这样浏览器"简单请求"(无 CORS 预检)永远无法盲执行有副作用的远端方法。

apiProxy 之上叠加了 Typert RPC 网关dsh-api-gateway 的宿主侧提供 ctx.typertGateway,客户端侧提供 ctx.remote,两端消费同一份生成的 InvocationDescriptor 契约。宿主侧 invoke() 为每次调用解析"当前描述符 + 活 Cordis 服务",在 /api 的 Fetch 桥上注册 trusted-host 拦截器:声称的 Remote 端点先于 apiProxy 兜底分派,未声称的落到传统方法。业务包以 @Remote/@RemoteScope 标记方法;dsh-api-remotes 是应用级 BFF 装配(当前挂载 Goal 与 plugin-inventory 两个贡献)。客户端 ctx.remote.$mount() 校验注册生成贡献,$on() 订阅宿主转发的白名单事件(API_REMOTE_FORWARDED_EVENTS,逐字转发)。

开放主机流是"订阅转发"而非广播:网关订阅注册中心的变更 feed(ctx.sessionProjectionsctx.jobs),每单位变更铸造一条 session/projection 帧({sessionId, key, value, seq})或整快照 session/jobs 帧,只推给已订阅该会话的客户端,并在重连/订阅时发送基线;session/queue 同理由持久的 agent/inbox/spliced 变更派生。帧是实时状态,永不写入会话日志。

出处:packages/api/gateway/README.mdpackages/api/remotes/README.md(本地 deepseek-harness-src/packages/api/gateway/README.mddeepseek-harness-src/packages/api/remotes/README.md

client modules:__DSH_BOOT__ 入口图与浏览器模块表

ctx.clientModulesClientModuleRegistry)是"web 插件表"的 Node 半身,四个面合成一个服务:扫描、组合、伺服、注入。

  • 增量扫描:扫描 Loader 条目中声明 dsh.client 的包(platform: 'web',可选 inject 依赖边与 immediately 预取标记)。扫描按包增量进行——每条 internal/plugin 发射把该条目标记为脏,微任务冲刷时只对账脏名。包元数据(含"非客户端包"的否定结论)按名缓存且永不过期,插件集合变化需重启生效。
  • 组合入口图:为每个包解析 exports["./client"],哈希构建产物得到 rev,组合出 WebBootEntry 行(idurlrevinject?immediately?)。图被注入为 <head> 首个脚本 window.__DSH_BOOT__< 转义,防插件字符串逃出 script 元素);没有合法清单的页面无法启动——浏览器解析器对缺失/畸形图直接 loud throw。
  • 伺服 bundleGET/HEAD /plugins/<id>/client.js 从磁盘伺服注册的 bundle,带 no-cache(一致性由 URL 上的 rev 查询锚定,而非 HTTP 缓存);未知 id 或未构建 bundle 答 404。
  • 注入清单:index tap 在每次 index 渲染时注入当前图,所以刷新永远对着活组合启动。

浏览器半身是 Node 内部 ESM loader 的浏览器对等物:懒 CJS 表。执行插件 bundle 只注册工厂(window.__ModuleLoader__.load({id, factory})),所有模块体副作用(含 CSS 注入)都活在工厂闭包里,待物化(materialization)时才运行;<id>/client 与裸 id 解析到同一份导出(一个插件 bundle 就是它所在包的客户端半身)。解析分支顺序固定:平台种子词 → shell 实例 → 静态注册表(registerStatic,app-shell 汇编入口)→ 已注册工厂 → 图行(异步加载外部 classic script + 物化)→ 否则抛错。

出处:packages/client/modules/README.mdpackages/client/web/README.md(本地 deepseek-harness-src/packages/client/modules/README.mddeepseek-harness-src/packages/client/web/README.md);子系统页见 docs/subsystems/client-modules.md

浏览器启动走 shell 内核(dsh-client-web)的两段式启动(web2):**阶段一(module face)**在宿主推送的入口图上构建模块系统并并行预取 immediately 层(仅注册工厂);**阶段二(plugin face)**以模块系统经 internal 契约挂载 vendored Cordis Loader,为每个图行加 shell 自有的 app-shell 汇编条目建一个 loader entry,AppRoot 以"Loader 静默 + 每个 entry fiber ACTIVE"为门槛一次性切换。组合决策全部属于宿主图,shell 零组合决策。注意 apps/web 的 Vite 入口会拒绝独立 serve(rejectStandaloneServe 抛错),因为裸 Vite 无法注入 window.__DSH_BOOT__——它只能由 dsh web 伺服。

客户端 HMR 链

dsh-client-hmr 是客户端插件的热重载链,总是挂载但默认闲置:它自带 stat-poll 间隔逐条对比 bundle 哈希基线;只有 tsdown watch 进程(pnpm run dev:web)真正重写 client bundle,轮询才观察到变化并触发链路。换言之,无刷新重载只在 pnpm run dev:web 运行时才成立——这也写进了模型可见的 app:web-surface 提示段。

  • Node 半身:以同步基线 stat-poll 每个图行 bundle,变更即调 ctx.clientModules.rebuilt(id)(只有 rev 真变才重组图并通知),保留缺失行作脏,通过 SSE(GET /plugins/events)向浏览器广播 rebuilt 帧;
  • 浏览器半身:订阅系统 SSE 通道,按序列化队列逐插件重载:invalidateprefetch(旧 fiber 仍在服务时加载并注册新 bundle)→ registry.delete(裸 fiber dispose 会触发 Loader 的自我 dispose 分支把条目标记禁用,须先删注册)→ 排干旧 fiber → 移除旧 <style data-plugin> 标签 → entry.refresh() 重新 import 并重挂载。依赖者靠 Cordis 自身级联:fiber 激活纪元串起其服务提供者的 uid,替换提供者 fiber 即级联重载所有依赖者。

重载是粗粒度的:新 fiber + 新组件,重载插件内部的 React 状态丢失(数据层 fiber 不受影响);失败不自动回滚,条目停在 FAILED 并可见于 loader 状态投影。

出处:packages/client/hmr/README.mddeepseek-harness-src/packages/client/hmr/README.md

客户端 UI 插件:插槽、会话渲染与投影

UI 功能 = 一个 dsh-client-ui-* 插件包,通过插槽系统dsh-client-ui-slots)组合。唯一组合 API 是 ctx.slots.register({ name, children?, store?, inject? }, Component):一次调用同时贡献组件、声明子插槽(children 的键即渲染授权)、声明 store 席位与注入面;插槽名镜像组合路径(如 conversation.chat.nodetool.call.toolview),渲染未声明或重复声明的插槽在加载期抛错。组件 props 是四个派生 share(runtime / renderSlots / store / inject);shell 只渲染 'root' 席位,其余全部由插件贡献。

会话渲染是 session/event 驱动的。React 无关的对象层(dsh-client-runtimeConnectionController → SessionManager → Session)持有全部业务状态,把共享的宿主流扇出到 Session/Workspace 属主;ConversationNodeAssembler 把每个 Session 的连续事件窗口交给业务 Definition 装配。以 ui-conversation 为范本:一个会话业务特性注册一个 ConversationNodeDefinition(挂在 ctx.conversationEvents)加一个键控渲染器(挂在 conversation.chat.node),match(event) 只读当前事件、update 按日志 seq 确定性回放折叠、渲染器消费最终 Node 数据而非扫描事件窗口——追加、历史翻页、重连重建共用同一套 Definition。ui-tool 渲染运行时投影的递归工具调用树与按名字分派的键控工具视图;ui-workflow-run 消费四个 tool-workflow/* 事件,把持久化工作流运行回放为 run/phase/member 三层嵌套 Chat 披露行;ui-trajectory 在同一个事件窗口上装配独立的 turn 感知轨迹账本;ui-goal 的 GoalBar 只读 useProjection('goal') 投影、通过 ctx.remote.goals 暴露四个变更动词;ui-jobsjobsBySession 列表镜像渲染会话头部的后台任务入口,不发起任何 RPC。

projection(投影)是"宿主计算、浏览器渲染"的分界:历史尾页携带 projections 水位块(asOfSeq = 数值反映的最后事件 seq),session/projection 帧推送增量;客户端 ProjectionValueStore 以"更高 seq 胜出"合并。设置域走同一模式:settings.describe 携带每个 namespace 的序列化 schemastery schema,schema-formrehydrateSchemanew Schema(json) 还原活校验器——宿主与浏览器校验草稿用同一个 schema 对象,永不漂移;草稿语义按"字段存在即覆盖"(setPath/deletePath/hasPath),validateDraft 在写回前拒绝非法草稿。界面文案经 dsh-client-locale 的 ns×locale 字典(zh/en,偏好存 settings.yamllocale.preference)与插槽系统的 LocaleFace 注入 t 标准席位。

出处:packages/client/ui-slots/README.mdpackages/client/runtime/README.mdpackages/client/ui-conversation/README.mdpackages/client/schema-form/README.md(本地 deepseek-harness-src/packages/client/…

安全:浏览器信任围栏与托管环境

dsh-client-connection 的 Node 半身守卫 /api 下的每个入口(src/api-request-trust.ts),桥接或 upgrade 之前先过 浏览器信任围栏:每个请求(无论是否带浏览器标记)的 Host 必须是 loopback 权威,或匹配 trustedHosts 条目(host:port 精确匹配、无端口条目任意端口,双侧经 WHATWG 规范化比较——DNS 重绑定防御)。明文 HTTP 上浏览器不会给图片/导航读附加 Origin/Fetch-Metadata,未标记请求仍可能是可读响应的重绑定浏览器读,而 Host 是重绑定无法伪造的唯一头;带标记时 Origin 必须等于 Host 权威,显式 sec-fetch-site: cross-site 标记直接拒绝。HTTP 失败在 RPC 分派前答 403,upgrade 失败在事件流开始前拒绝握手。围栏是可达性策略而非认证——Web 载体没有认证层。另有一组 loopback 专属特权方法(host.pickDirectoryhost.openPath、整个配置平面 settings.*/credentials.*、agent preset 的 read/copy/openDocument/remove);trustedHosts 条目若不是裸的规范 host[:port] 权威(WHATWG 解析原样读回),插件加载直接失败——解析会把 harness.internal/path 里的主机名静默授权出去。

信任值来自 dsh-web-appweb-runtime 胶水webRuntime 服务):绑定全接口时从 networkInterfaces() 采样一次 LAN IPv4 字面量(lanAddresses,引导期快照),再拼上显式权威(cordis.yml 配置 + CLI --trusted-host)交给围栏。CLI 参数由 web-startup 提供方(packages/bundle/web-app/src/startup.ts)解析:--host--port0 表示 OS 分配)、可重复的 --trusted-host--help--host 0.0.0.0 在发布服务前被拒绝("intentionally not supported yet for safety: it would expose remote code execution to the network"),非数字 --port 同样报用法错误。flag 配置的行注入该服务后才从懒配置读取,因此 dsh --profile web --help 不绑定任何端口

surfaceContext: true(默认)时,胶水还向模型暴露托管环境:harness:source 段标明磁盘上的 Harness 实现位置(不声称是工作目录),app:web-surface 全局段(order −98)把模型定向到 GUI——规范本地 URL、"this page" 指代、更新契约(无刷新重载需 pnpm run dev:web watcher)、不得另起替换服务器;DSH_WEB_URL 同时作为 bash 可见环境变量按调用实时解析。URL 行(dsh web: http://127.0.0.1:<port>,全接口时附 LAN 候选)在 Loader 树静默后打印——兄弟行失败不会宣告一个死应用。

出处:packages/client/connection/README.mdpackages/bundle/web-app/src/index.tspackages/bundle/web-app/src/startup.ts(本地 deepseek-harness-src/packages/…

关键设计决策

  • 宿主持有全部业务状态,浏览器只是渲染面:会话、工具、模型全在 Node 进程;客户端对象层无 React 依赖,UI 域之间只共享 JSON 兼容数据与回调(web client architecture note)。
  • 网关传输无关apiProxy 只定义线协议与分派,HTTP/WebSocket/同进程载体可替换;协议细节记录在 GUI layering and RPC protocol RFC
  • 推送按订阅转发而非广播:投影/任务/队列帧只发给已订阅会话,重连以基线收敛,帧永不入会话日志("模型可见 ⟺ 已记录"不被 UI 推送破坏)。
  • 组合权归宿主图:shell 零组合决策,window.__DSH_BOOT__ 是宿主与浏览器的线协议单一来源;坏清单 loud throw,单入口失败停在加载页并逐条报告。
  • 前端 dist 必须构建require.resolve 失败即激活失败,无源码伺服回退;bundle 端点对未构建产物答 404 而非让 SPA fallback 把 HTML 当 JS。
  • HMR 粗粒度、默认闲置:重载即"新 fiber + 新组件",React 状态丢失是设计取舍;无刷新重载依赖 pnpm run dev:web watcher,文档与模型提示都显式声明该前提。
  • 信任围栏宁可错杀:未标记 HTTP 请求也过 Host 比较(防 DNS 重绑定)、非规范 trustedHosts 条目加载失败、--host 0.0.0.0 整体拒绝——围栏是可达性策略,认证留给未来远程访问层。

扩展点

  • 新增一个客户端 UI 插件:按 dsh.client manifest(platform: 'web')声明包,在 packages/bundle/web-app/cordis.patch.yml 加一行、在 dsh-web-app 的 package.json 加依赖;插件体在 apply 里用 ctx.slots.register 贡献组件、用 ctx.conversationEvents 注册 ConversationNodeDefinition 与会话节点渲染器(完整路径见官方 Conversation Node cookbook)。重构建 bundle(pnpm --filter <pkg> bundle)后访问 dsh web——registry 伺服的是 lib/client.js 而非源码。
  • 新增宿主路由或域:任何插件都能向 ctx.webServer 注册命名路由/upgrade/fallback,或向 RpcMethodMap 注册新 RPC 域;挂到开放流则订阅 ctx.sessionProjections/ctx.jobs 的变更 feed 并向浏览器铸造帧。
  • 新增设置平面:向 ui-settings 声明 settings.section/settings.plugins.tab 等插槽,用 schema-form 的草稿原语做表单;注意 settings.* RPC 只暴露注册的 configurable provider + 显式白名单。
  • 替换目录选择交互:实现 DirectoryPicker seam 的新 kind(声明合并扩展 DirectoryPickerCapabilities),并在 ui-workspace 的 directory-flow 插槽注册配套浏览器交互;用 -auto 在引导期按宿主环境选择后端。
  • 转发新主机事件:在 dsh-api-remotesAPI_REMOTE_FORWARDED_EVENTS 数组加一项,类型投影、消费键面与转发循环全部随之派生。

参考资料

本文档为学习用途的原创讲解,基于 MIT 许可的开源项目 deepseek-ai/deepseek-harness 编写;所有引用均注明出处。