跳到主要内容
路径文档

Web UI 架构

一句话版:Web UI 是"宿主进程 + 浏览器端"双进程架构:宿主(host/)持有 agent 与能力,浏览器端(client/)是 React 插件外壳,经连接层(client-connection)通信;client 插件是"dual-face"包的浏览器半部,靠 HMR 热更新。

一、双进程架构

为什么双进程:能力(agent loop、工具、沙箱)在 host 里跑,UI 在浏览器里渲染。浏览器不持有任何真实能力,只通过连接层订阅 event 流。

动态 Cordis 插件同样遵守双面边界:tool-cordis 把不可变 Package 交给 Host runner;Host 半部在宿主运行,Client 半部经浏览器授权后由 Client runner 异步启动,client-ui-cordis 负责会话卡片。完整生命周期见 运行时自省与动态插件

二、连接层:前后端怎么通信

client-connection 是浏览器与 host 之间的通道。前端订阅 session/event(token 流、轮次边界、工具活动)渲染对话,接收 agent/* 控制事件(agent/statusagent/created/agent/disposed)反映状态。

  • 对话内容来自持久化的 session 事件流(见 事件系统)
  • HMR:client 插件改了?pnpm run dev:web 重建 lib/client.js,webserver 轮询到重建自动热更

三、API 网关(apiproxy)

apiproxy 是每个 client 共用的 API 网关,由三部分组成:TypeScript API 契约(src/api/,零 Node 依赖,浏览器可直接导入)、fetch carrier 对(toFetchHandler 在 host 侧,AbstractApiClient + 平台子类在 client 侧)、host 侧实现(createApiProxy + 默认导出的 ApiProxyService 网关插件,config {nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?},提供 ctx.apiProxy)。本包不注册 route;HTTP 这类 carrier 自己 wrap ctx.apiProxy

它消费 ctx.agentDefaultModel(不拥有 provider/model 配置),默认模型选择属于 模型路由 讲的 agent-default-model service。

契约层(/api):wire 消息是四象限判别联合(谁发起 × 请求/响应),与物理通道解耦——ClientRequest(POST /api/<method> body)、ServerResponse(该 POST 的响应体)、ServerRequest(SSE frame)、ClientResponse(POST /api/respond body)。响应永远 echo 匹配请求的 rpcId;Zod schema 两层 parse(envelope 先、business payload 后)。

它承载一批会话域:如 session.history 读 attached Session 或经 persistence 检查 cold log(不 resume、不发布 Agent),按 append-origin 消息边界分页,tail page 附带 projections block(投影 watermark 快照)。网关自有 sessionListMetadataimageLimits 两个投影;coldBlankProbeMaxBytes 默认 1 KiB,只对 eligible 小型冷日志做有界读取以核实 blank 与最后人工提示时间。

会话导出是 host-only 下载面(GET /api/session.export),不是 RPC。Web 组合的 /export 命令与 Header Session log 按钮共用控制器,先做 HEAD 预检,再把包含后代会话与附件的 ZIP 交给浏览器下载管理器;/export <path> 返回参数错误。目录选择委托给下一节讲的 ctx.directoryPicker

挂载状态:默认挂载(Web 组合,id api-gateway)。

四、Client 插件(dual-face)

一个包可以同时提供 host 半部 + client 半部。client 半部是懒 CJS 模块表:host 扫描 Loader 条目组成 window.__DSH_BOOT__ 启动图,伺服 /plugins/<id>/client.js?rev=<rev>,浏览器半部副作用在首 require 时才执行(window.__ModuleLoader__.load({id, factory}))。

{
"exports": {
".": "./lib/index.js", // host 半部
"./client": "./lib/client.js" // 浏览器半部
},
"dsh": { "client": { "inject": ["slots", "connection"], "platform": "web" } }
}

浏览器半部注册 UI(如设置面板 section):

export function apply(ctx: ClientContext): void {
ctx.slots.inject('settings.section', () =>
ctx.slots.register({
name: 'settings.section', id: 'plugins', order: 60, label: () => '插件',
}, PluginPanel))
}

五、slot 体系:三方可扩展 UI

slot作用
settings.section设置面板分区(插件在此挂自己的设置卡)
settings.*其它设置位
其它命名 slotdsh-client-ui-slots 提供,三方插件按需注册

第三方插件不需要硬编码在 Web UI 源码里:通过 slot 挂进对应位置即可。可配置项在 Agent 预设 里聊。

六、session 事件流怎么被前端消费

UI 分两条:

事件类作用
session/event渲染对话(token 流、边界、工具活动):"内容"
agent/*控制面(状态、创建/销毁、请求错误):"状态"

前端基于 session/event 的 surface 渲染消息,基于 agent/* 更新 agent 状态灯 / 轮次进度。

七、访问模式入口

UI 里的 "Full access / 标准模式" 选择 = permission preset 的入口(见 权限),不是沙箱本身。

八、工作区目录选择(directory-picker)

directory-picker 是能力 seam:ctx.directoryPicker 是其 Service Definition,唯一方法 capability() 返回判别联合,描述"操作员如何选目录"。后端差别在于用户交互,不只是实现:

kind能力用途
nativepick(signal):开一个原生 OS 选择器操作员坐在宿主显示器前
browselist(path?) / createDirectory(path, name):in-app 列目录、建目录够不到 OS 选择器的 remote client

directory-picker-auto 是自适应选择器:boot 时一次性采样(loopback-only bind、非 SSH launch、有可用显示会话;Linux 需 DISPLAY/WAYLAND_DISPLAY + zenity/kdialog 在 PATH),挂载匹配的 dual-face 后端(native 或 browse)作为 in-memory root tree 的真实 Loader entry(不持久化)。任何模糊都落 browse。

两个后端:

  • directory-picker-native:native capability,pick 每次开一个原生选择器、解析绝对路径(取消返 null);平台工具不用 shell(macOS osascript,Linux Zenity→KDialog,Windows 现代 IFileOpenDialog in spawned child);caller abort 终止 native 进程
  • directory-picker-browse:browse capability,一级目录列目录 + 建子目录(走 Node stdlib,自带 per-OS 适配);只列目录、名字排序、symlink-to-directory 跟随、host 拥有 hidden 标记;crumbs 是 root→target 祖先链;createDirectory 非递归、校验名字为单段

browse primitive 失败抛类型化 DirectoryPickerError(directory-unreadable/directory-exists/directory-create-failed),网关 1:1 映射到 wire error code。

挂载状态:auto 默认挂载(Web 组合,id directory-picker);native/browse 是它 boot 时二选一挂载,也可在 overlay 里直接挂其中之一来 pin 交互。

九、工作区注册表(workspace)

ctx.workspaceRegistry 是工作区实体注册表:durable workspace record、稳定顺序、newest-first candidate session index,经 domain data form 存储。消费者看到 Workspace 接口,实体实现 package-private。

API约定
create(path, title?)fs.realpath 规范化,拒绝不存在/非目录,每个 canonical path 至多一条 record,prepend 到 durable 顺序
get(id) / list() / resolveByPath(path)缓存命中查询;list() 同步按 durable 顺序;resolveByPath 异步(同样 realpath,拒绝 missing 而非创建)
delete(id)只删 Workspace 注册、顺序 entry、session account;目录/文件/live Session/持久化日志不动,那些 Session 变 Ungrouped
attachSession(id)校验 header cwd 对 workspace path,prepend 新 id;detachSession 只删 candidate index entry
insertSessionBefore(id, before?)DOM-insertBefore 式手动排序;workspace 顺序不变
archiveSession(id) / archivedSessionIdsregistry-global archive set;归档从分组面消失但保留日志与 sessionIds slot
status()未缓存目录检查,`'ok'

挂载状态:默认挂载(Web 组合)。

十、前端静态伺服(host-frontend-static)

host-frontend-static 是 SPA dist server:function 插件(config {distIndex}),占据 webserver 的唯一 fallback seat,伺服构建好的前端目录,带 shell 的锁定语义——穿越 dist root 之外 403、任何 miss 回落到 index.html HTTP 200(SPA routing)、未知扩展名 application/octet-stream、无匹配命名 route 的 non-GET/HEAD 405。每个 index response 过 webserver 的 index taps(applyIndexTaps),boot manifest 经此到达页面。

distIndex 是组合应用的 assembly fact:dsh-web-app 经前端包的 exports 解析它并挂载此插件。fallback seat 单 owner(第二个 claim 抛错)、effect-scoped(dispose 释放后未 claim 的 webserver 回 404)。

挂载状态:默认挂载(由 Web 组合的 web-runtime row 挂)。

十一、构建产物

pnpm dsh web # 自动 build:lib + build:web 后启动
pnpm run build:web # 只构建前端
  • 开发 HMR 在源码目录跑 pnpm run dev:web(重建 lib/client.js),dsh web 的 webserver 轮询到重建自动热更

十二、验证

# 看 host 进程监听的端口
lsof -nP -iTCP:3080 -sTCP:LISTEN

# 看 client 插件是否被装载
dsh web --dump-config | grep -i client

# 看 API 网关 / 目录选择 / 工作区 / 前端伺服是否装载
dsh web --dump-config | grep -iE "apiproxy|directory-picker|workspace|host-frontend-static"

下一步