第六部分 · 界面与协议

第 17 章:Web 界面与 API 网关

把 Cordis 运行时塞进浏览器:`window.__DSH_BOOT__`、事件驱动渲染、类型图跨传输

浏览器里跑着另一棵插件树

dsh web 打开的界面不是「前端调 REST」——它是另一棵 Cordis 插件树,跑在浏览器进程里。宿主把插件清单经 window.__DSH_BOOT__ 注入页面,浏览器端把 vendored Cordis Loader 浏览器化(node:module stub、process.* define 替换、模块系统注入 internal 槽),然后整棵 client 树在浏览器里活起来——ctx.slotsctx.remotectx.web 全是真实的 Cordis 服务。

apps/web/src/main.ts 只有 10 行,真正的引导在 packages/client/web/src/boot.tsx

async run(): Promise<void> {
  this.manifest = parseBootManifest((globalThis as DshWindow).__DSH_BOOT__)

  this.modules = new ClientModuleSystem({
    modules: this.manifest.modules, staticModules: getStaticModules(), ...this.seams,
  })
  // The app-shell assembly is the only shell-own module: every other graph
  // row is a plugin bundle arriving through fetch.
  this.modules.registerStatic(APP_SHELL_ID, AppShell)
  // ...
  this.root = createRoot(this.el)
  this.root.render(<AppRoot ... />)
  const prefetching = this.prefetchImmediateTier()
  this.ctx = new Context()
  try {
    await this.runPluginBoot(prefetching)
    this.settled.set(true)
  } catch (reason) { /* stay on the loading page; surface the sweep report */ }
}

两阶段引导:先渲染加载页(AppRoot 未 settled),再 new Context()ctx.plugin(Loader) → 把浏览器模块系统注入 Loader 的 internal 槽(否则 tree.import 会在浏览器里裸 import 而必然失败)→ 预取 immediately 层 → 为每个插件行 + app-shell 创建 loader entry → loader.await()assertEntriesActive() 全量清扫(fiber 缺失/非 ACTIVE 即 fail-loud 列出谁缺哪个服务)→ settled.set(true),一次性切到真实 UI。加载页永不黑屏——shell 自足原则保证插件全挂时加载页仍能工作。

UI 结构:真实 UI 只渲染内置 'root' 槽;ui-layoutAppFrame 注册在 root 上,网格是 sidebar | conversation | details 三栏(columns.ts 让步链:details 先让、auto-close,sidebar 永不让步);子槽 'sidebar'/'conversation'/'details'/'shell.overlay'。composer 是 InputBar,设置是 ui-settings* 系列——每个面板都是一个 slot 贡献,任何插件都可以往界面里加面板。

ConversationNode:UI 是日志的纯函数

聊天界面渲染的核心契约是 ConversationNodeDefinitionclient/runtime/src/client/contract/conversation.ts)——「事件 → 节点」的独立状态机:

/** One independently registered business Event-to-Node state machine. */
export interface ConversationNodeDefinition<State = unknown> {
  readonly kind: string
  /** Sole view target owned by this Definition; omitted for state-only Contexts. */
  readonly target?: string
  match(event: SessionEvent): ConversationMatchResult | null
  start(context: ConversationNodeContext<State>, match: ConversationMatch,
    reader: ConversationContextReader): State
  update(context: ConversationNodeContext<State> & { readonly state: State },
    match: ConversationMatch): State
  publication?(match: ConversationMatch): ConversationPublication
  buildLocationData?(context: ConversationNodeContext<State>,
    scope: ConversationLocationDataScope): ConversationLocationData | null
  buildViewNode?(context: ConversationNodeContext<State>): ConversationViewNode | null
}
// ...
export function conversationContextKey(kind: string, id: string): string {
  return `${kind.length}:${kind}${id}`
}

装配器(conversation-assembler.ts)按 seq 排序输入、增量重放出节点集合;每个节点经 renderSlot('conversation.chat.node', routedOwner, {entryKey: routedNode.kind}) 渲染——节点的 kind 判别字段就是 keyed slot 的 entryKey。注册表为 user/steering/context/assistant-step/command/manual-compaction/compaction/model-retry/turn-error/turn-max-tokens/turn-tail/unknown 各注册一个组件(register-node-renderers.ts)。

一个节点定义的实例——消息分类(conversation-nodes/message.ts):

/** User, steering, and injected-context message classification Definition. */
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
  kind: 'input-message',
  target: 'chat',
  match: event => event.type === 'user/message'
    && isAppendSurfaceEvent(event)
    && !isCompactionCheckpoint(event)
    ? { id: String(event.data.id), role: 'start' }
    : null,
  start: (_context, match, reader) => {
    if (match.event.type !== 'user/message') throw new Error('input-message start requires user/message')
    const event = match.event
    if (event.data.source.kind !== 'user') { /* context 分支 */ }
    const claimed = reader.previous<InboxState>('inbox-next-step')?.state.claimed.has(String(event.data.id)) === true
    return claimed ? { kind: 'steering', /* ... */ } : { kind: 'user', /* ... */ }
  },
  // ...
}

注意 reader.previous('inbox-next-step')——它读第 3 章的 agent/inbox/spliced 事件投影,判断这条用户消息是否被 claim 过,从而把「用户输入」与「转向指令(steering)」区分开。UI 与后端共享同一套事件词表(第 4 章),这是「日志即真相源」延伸到 DOM 的方式:重放、断线补洞、窗口虚拟化都是日志投影的自然推论

非对称传输:上行 fetch、下行只写 WebSocket

浏览器与宿主的连接是两条正交信道:

  • 上行:一元 RPC 全走 fetchPOST /api/<namespace>/<method>,payload 只含一个命名 args 对象,响应 {ok, value}{ok: false, error});
  • 下行:两条只写 WebSocket/api/events.mux/api/events.host)——宿主只向 socket 泵帧,收到客户端消息即 1008 'downlink only' 关闭。

Mux 帧是会话聚合流:session/event(原始 SessionEvent 透传 + 可选 ToolEventView——渲染意图发射时现算、不落盘)、session/subscribed(含 lastSeq 重连基线)、approval/requested|resolvedsession/queuesession/jobs全量快照帧,保证并发/重连收敛)、session/projection(宿主算好的投影值,高 seq 胜)。Host 帧是会话/工作区/远端事件。连接有严格就绪握手(双流 onOpen + host.describe 成功才 onConnected)、指数退避重连、重连 = 重开流 + 重取历史。信任围栏:每个 /api 请求先过 isTrustedApiRequest(DNS-rebinding/跨站防御,trustedHosts 仅 LAN 名列表);PRIVILEGED_METHODS(settings/credentials/agentPreset 管理)额外要求 loopback。

Typert:把类型系统变成分布式 IDL

packages/apipackages/typert 解决了「宿主方法如何被浏览器类型安全地调用」——答案是把 TypeScript 类型系统当分布式 IDL。四件套:

作用
protocoltypert/protocol类型 + 契约(零运行时):InvocationDescriptor、codec、TypertClientRemote
generatortypert/generator构建期:WorkspaceAnalyzer 以 Host ts.Program 为输入,把符号/语法节点提取成编译器无关模型FaceModelEmitter 产出 typert.host.js(反射 + strict 描述符 + zod schema)与 typert.remote-client.js(可挂载的 TypertRemoteContribution + 声明合并)
loadertypert/loader按 loader 入口增量扫描 ./typert export,校验后 ctx.typert.register(manifest),卸载即撤回
registrytypert/registry运行时注册表:local(endpoint→descriptor)、remoteslookupscontexts 四个 store

业务侧用 @Remote('exportName')/@RemoteScope(key) 装饰器声明,编辑器可以从 ctx.remote.goals.create(...) 直接跳到 Host 源方法。复杂 Host 对象(如 Agent)不直接过线:经 TypertLookupMap 声明「Host 类型 ↔ wire id」,运行期由 lookup provider 解析。TypertGatewayService/api 前缀认领恰好两段的端点(有 strict descriptor 或 SRC marker 才认领),invoke() 流程:resolveDescriptor → assertExactArguments(精确匹配 wire 字段)→ resolveReceiverContext → 逐参 resolveParameterdecode 边界校验(zod schema + JSON 安全性递归检查)→ 调业务方法 → 结果再 decode。源码启动(tsx)时生成器不运行,Gateway 用装饰器 WeakMap + 反射构造弱描述符(SRC 回退)——Client 侧永远只用严格产物。

web capability:搜索与抓取

浏览器能联网,agent 也能——packages/web/webctx.web 接缝(web_search/web_fetch 工具,第 9 章的 timeoutMs 与第 5 章的 presentationMeta 都见过)。Service Definition 是双注册表(searchProviders/fetchProviders),resolveProvider执行时解析、绝不依赖注册顺序:配置 id 且可用 → 用它;配置缺失/不可用/未配置多可用/零可用分别抛 WEB_PROVIDER_CONFIGURED_MISSING/UNAVAILABLE/AMBIGUOUS/UNAVAILABLE。提供商有 deepseek/exa/perplexity 搜索与 fetch-http;凭证型 provider 拒绝重定向(先于跟随重定向失败,防止凭据外泄)。web_search 的 schema 只有 query 一个参数,输出 content/sources/truncated,presentationMeta 把结构化 sources 投影成搜索卡片存进日志——渲染意图不落盘,重放时靠 meta 重算

图 1:Web 架构。浏览器里跑着 client 插件树(模块表 + Loader),经非对称信道连到宿主的 webserver / API 网关 / Typert,事件下行、RPC 上行。
与前作对照

前两本书的界面都是终端 TUI(pi 的字符串差分渲染、Prime 的 ipython-cell 视图)。dsh 把界面整个搬进浏览器,并且不是「前端应用 + REST」,而是「第二棵插件树 + 事件流」——UI 渲染器与后端共享同一套 SessionEvent 词表,聊天界面是日志的纯函数。这是「一切皆插件」在界面层的兑现:连 UI 都是插件的组合。

实践应用

  1. 宿主注入启动清单window.__DSH_BOOT__ 式「宿主注入的 boot manifest + 浏览器内模块表」——前端框架不必自带构建编排,插件代码到达方式由宿主决定。
  2. UI 是日志的纯函数ConversationNodeDefinition 状态机按 seq 折入、可窗口化、可重放——重连补洞与虚拟化是「日志投影」的自然推论,不是额外工程。
  3. 非对称信道:上行 fetch + 下行只写 WebSocket,请求-响应与推送分离;全量快照帧(queue/jobs)保证多标签页与断线收敛。
  4. 类型图即协议:构建期从 ts.Program 生成 wire 契约与 zod schema,类型安全从浏览器一路穿透到 Host 方法参数——「编译器当代码生成器」。

总结

这一章拆完了 Web 界面与 API 网关:浏览器里的第二棵插件树、事件驱动的 ConversationNode 渲染、非对称传输、Typert 类型图协议与 web capability。图形界面只是 dsh 的一个表面——下一章看协议面:当对话的对方不是人而是机器(ACP、JSON-RPC 与 Python SDK)时,dsh 怎么把同样的内核暴露出去。