第六部分 · 界面与协议
第 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.slots、ctx.remote、ctx.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-layout 的 AppFrame 注册在 root 上,网格是 sidebar | conversation | details 三栏(columns.ts 让步链:details 先让、auto-close,sidebar 永不让步);子槽 'sidebar'/'conversation'/'details'/'shell.overlay'。composer 是 InputBar,设置是 ui-settings* 系列——每个面板都是一个 slot 贡献,任何插件都可以往界面里加面板。
ConversationNode:UI 是日志的纯函数
聊天界面渲染的核心契约是 ConversationNodeDefinition(client/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 全走
fetch(POST /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|resolved、session/queue 与 session/jobs(全量快照帧,保证并发/重连收敛)、session/projection(宿主算好的投影值,高 seq 胜)。Host 帧是会话/工作区/远端事件。连接有严格就绪握手(双流 onOpen + host.describe 成功才 onConnected)、指数退避重连、重连 = 重开流 + 重取历史。信任围栏:每个 /api 请求先过 isTrustedApiRequest(DNS-rebinding/跨站防御,trustedHosts 仅 LAN 名列表);PRIVILEGED_METHODS(settings/credentials/agentPreset 管理)额外要求 loopback。
Typert:把类型系统变成分布式 IDL
packages/api 与 packages/typert 解决了「宿主方法如何被浏览器类型安全地调用」——答案是把 TypeScript 类型系统当分布式 IDL。四件套:
| 件 | 包 | 作用 |
|---|---|---|
| protocol | typert/protocol | 类型 + 契约(零运行时):InvocationDescriptor、codec、TypertClientRemote |
| generator | typert/generator | 构建期:WorkspaceAnalyzer 以 Host ts.Program 为输入,把符号/语法节点提取成编译器无关模型,FaceModelEmitter 产出 typert.host.js(反射 + strict 描述符 + zod schema)与 typert.remote-client.js(可挂载的 TypertRemoteContribution + 声明合并) |
| loader | typert/loader | 按 loader 入口增量扫描 ./typert export,校验后 ctx.typert.register(manifest),卸载即撤回 |
| registry | typert/registry | 运行时注册表:local(endpoint→descriptor)、remotes、lookups、contexts 四个 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 → 逐参 resolveParameter → decode 边界校验(zod schema + JSON 安全性递归检查)→ 调业务方法 → 结果再 decode。源码启动(tsx)时生成器不运行,Gateway 用装饰器 WeakMap + 反射构造弱描述符(SRC 回退)——Client 侧永远只用严格产物。
web capability:搜索与抓取
浏览器能联网,agent 也能——packages/web/web 是 ctx.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 重算。
前两本书的界面都是终端 TUI(pi 的字符串差分渲染、Prime 的 ipython-cell 视图)。dsh 把界面整个搬进浏览器,并且不是「前端应用 + REST」,而是「第二棵插件树 + 事件流」——UI 渲染器与后端共享同一套 SessionEvent 词表,聊天界面是日志的纯函数。这是「一切皆插件」在界面层的兑现:连 UI 都是插件的组合。
实践应用
- 宿主注入启动清单:
window.__DSH_BOOT__式「宿主注入的 boot manifest + 浏览器内模块表」——前端框架不必自带构建编排,插件代码到达方式由宿主决定。 - UI 是日志的纯函数:
ConversationNodeDefinition状态机按 seq 折入、可窗口化、可重放——重连补洞与虚拟化是「日志投影」的自然推论,不是额外工程。 - 非对称信道:上行 fetch + 下行只写 WebSocket,请求-响应与推送分离;全量快照帧(queue/jobs)保证多标签页与断线收敛。
- 类型图即协议:构建期从
ts.Program生成 wire 契约与 zod schema,类型安全从浏览器一路穿透到 Host 方法参数——「编译器当代码生成器」。
总结
这一章拆完了 Web 界面与 API 网关:浏览器里的第二棵插件树、事件驱动的 ConversationNode 渲染、非对称传输、Typert 类型图协议与 web capability。图形界面只是 dsh 的一个表面——下一章看协议面:当对话的对方不是人而是机器(ACP、JSON-RPC 与 Python SDK)时,dsh 怎么把同样的内核暴露出去。