Files
deepseek-harness/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md

25 KiB
Raw Blame History

RFC: GUI 分层与 RPC 协议——host/client 按能力支持方分层、四象限消息模型与 fetch 载体

Status: implemented

English | 中文

分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 WebSocket 下行载体组成,浏览器对象层见 Web 客户端架构笔记

Problem

需要提供 UI 对接层,除已有 ACPAgent Client Protocol/stdio 基线外,还需要 Webserver、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力

  • 一个 dsh 进程同时支持 dsh web(启动)和 dsh --profile headlessheadless一个进程两种模式设计预留
  • 在 Electron 中使用与 dsh web 相同的 Web 技术启动

那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。

同时各消费端的物理通道不同(浏览器 HTTPWebSocket、进程内 fetch/SSE、将来 IPC还需要一个通道无关的消息模型和单一约定事实源让「加一个方法」「换一种载体」互不牵连且 wire 上的每条消息可类型校验、可观测、可对账。

Decision

分层

目录按照如下分层:

  • packages/host/*: 包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含
    • 统一后端协议fetch、HTTP、流式接口等定义和支持见本篇「消息协议」起各节
  • packages/client/*:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 client 插件装载笔记 所有):
    • 纯库ui-slotsweb-reactui-primitives,外加内核包 loader):普通根入口包,静态打包进壳;前三者播种进模块表。
    • 静态到达 entry 包connectionruntimeui-themei18nhmr):无 dsh.client 键、无浏览器 bundle——壳把它们的 src/client/ 半边打进自己的 bundle 并向 ctx.modules 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。
    • fetch 到达插件包ui-layoutui-sidebarui-conversationui-trajectory):双入口——根入口是 node 半边(空 apply,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 dsh.client 声明);实现住在 src/client/ 下,经 ./client 子路径发布tsdown 闭包工厂 bundle。跨插件消费 /client 只限类型;值层面的协作走 cordis 服务。
  • apps/ 作为对外导出的应用入口,可以由 Client / Host 混合组装。
    • apps/webdsh-frontend)是 vite 应用:dsh-client-web 导出的壳 API 之上的一层薄 main.ts
    • apps/cli@deepseek-ai/dsh)分发命令:dsh web = Host + webserver + 构建出的 dsh-frontend distdsh --profile headless = 直接使用核心 AgentSession 的入口,不含 Host、HTTP 或浏览器层。
    • 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。
apps/*  (applications: apps/web = vite app, apps/cli = bin dispatch)
  │ consume
  ▼
packages/host/*                      packages/client/*
  apiproxy   front layer: protocol     pure libs: ui-slots / web-react / ui-primitives
  runtime    assembly / host entity    dsh.client plugins ×8 (node half = empty apply,
  webserver  Web HTTP carriage                              client half = src/client/)
  │ ctx.plugin(...)                      ▲ import only apiproxy's /api /client subpaths
  ▼                                      │ (type-only + the client base class)
harness core packages ──────────────────┘ (types reach the browser via import type)

方向纪律(每条都由包 deps 可核):

  • runtime → apiproxy 单向apiproxy 仅依赖类型定义。
  • client 侧包永不 import host 侧包的运行时(只吃 /api/client 两个浏览器安全子路径)。
  • webserver 不依赖 runtime:它提供 { fetch } 特定实现 ——「webserver ← runtime」只是运行时注入关系不是包依赖。
  • client 侧跨包 import 插件包一律走 /client 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 client 插件装载笔记 所有)。

TypeScript 以 solution 根引用的两个聚合 program 检查(tsconfig.json = solutiontsconfig.host.json = host 侧 + 测试,排除 packages/clienttsconfig.client.json = client 各包及其测试):两侧在相同键(sessionsloader)下以不同服务合并 cordis Context 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用(拓扑)。

协议侧TS interfacepackages/host/apiproxy/src/api/,零 Node 依赖,浏览器可 importwire 消息统一为双向模型——每条逻辑消息按「谁发起 × request/response」分类两轴四格后文称四象限与物理通道解耦客户端统一继承 AbstractApiClient(协议不变量全在基类,平台差异只是 doFetch 传输切面)。

分层角色

职责 关键纪律
前置层 dsh-host-apiproxy TS/zod 定义 (api/)+ fetch 抽象 (fetch/handler + 客户端基类) 做简单、所有接入方都要Node/浏览器皆可 import协议内容见下文「消息协议」起各节client 不得经 ctx 绕开 api
装配层 dsh-host-runtime 插件组合 + ApiProxy 集成 + web UI 插件挂载(覆盖八个 dsh.client 包的内存 Loader 树host 级配置归属地defaults/persistenceRoot将来用户 profile 装什么插件、给什么默认值只在这里定;壳不得改装配
承载层 dsh-host-webserver Web HTTP 与 upgrade静态服务 + /api/*→handler 转发 + WebSocket upgrade route + close 语义;插件 bundle 端点 + __DSH_BOOT__ manifest元数据清单注入由 web 插件注册表供给) Web浏览器访问专用零 workspace 依赖注册表经结构注入到达Electron 不复用它
client 库 dsh-client-ui-slots / dsh-client-web-react / dsh-client-ui-primitives slot 注册表核心 / ctx↔React 胶合 / 纯 React 原子组件 组件零 cordis 运行时依赖;由壳播种进 loader 模块表
client 插件 dsh-client-connection / dsh-client-runtime / dsh-client-ui-theme / dsh-client-i18n / dsh-client-ui-layout / dsh-client-ui-sidebar / dsh-client-ui-conversation / dsh-client-ui-trajectory 浏览器侧 cordis 插件树wire 消费者、核心服务、主题、i18n、布局、侧栏、对话、轨迹——见 Web 客户端架构笔记 双入口node 半边=空 apply实现在 src/client/);消费面唯一经 ApiProxy
应用 @deepseek-ai/dshapps/cli+ dsh-frontendapps/webvite 应用) bin 粗分发 + 每个应用一个拼装模块web.ts / headless.tsvite 应用是 dsh-client-web 壳表面之上的薄 main 各应用使用动态 import因此不会互相加载dist 定位等 workspace 知识留在 app

命名规则

packages/host/*packages/client/* 下的包名必须含目录组前缀host/runtime → dsh-host-runtime、client/runtime → dsh-client-runtime。目录名不重复组前缀host/ 已表达)。因此包名尾段 ≠ 目录名tsconfig.base.json 的 dsh-* 通配(按目录名解析)命不中——这两组的每包需显式 paths 条目,且 client 各包的 /client 子路径要单列条目,使源码级解析与 exports map 一致。

怎么接入一个新应用(操作清单)

  1. 选 fetch 伪造方式:浏览器同源 HTTP / 进程内 host.handler.fetch 注入 / 自写传输切面子类(如将来 Electron IPC见下文「子类表」
  2. apps/ 下写拼装模块startHost() + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。
  3. 需要 HTTP 承载才 import dsh-host-webserver,否则零端口。

现有两个应用保持这一区分Web 应用挂载 Host、载体与浏览器组合dsh --profile headless 挂载直接使用核心服务的 runner不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 ctx.plugin(入口插件) 挂载,不使用 fetch。

消息协议

以下各节是前置层(dsh-host-apiproxy承载的协议本体。wire 上只有四种消息(四象限)——右列的 Web 承载只是示例,换载体(进程内/IPC时四象限不变

                 client 发起                      server 发起
  request   ① ClientRequest                 ③ ServerRequest
            POST /api/<method> body      WebSocket messagesession 事件、审批/问答 requested
  response  ② ServerResponse                ④ ClientResponse
            (该 POST 的 HTTP 应答体)        POST /api/respond body回填 ③ 的 rpcId

wire 全形:四具名判别 unionapi/rpc.ts

类型 判别 tag 字段 rpcId 归属 Web 承载
ClientRequest 'client-request' rpcId method payload client mint POST /api/<method> body
ServerResponse 'server-response' rpcId result 回填 ① 该 POST 的应答体(恒 HTTP 200
ServerRequest 'server-request' rpcId method payload server mint WebSocket text message
ClientResponse 'client-response' rpcId result 回填 ③ POST /api/respond body

RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponseswitch (message.type) 窄化。

rpcId 纪律RpcId 是 branded string构造函数 RpcId()

  • 谁发起谁 mint应答一律回填对应 request 的 rpcId绝不 mint 新 id
  • server-request 分两类,静态按 method=帧 type区分不设第三种 kind:可应答帧(approval/requestedquestion/requested)的 rpcId 是稳定逻辑请求 id受理时 mint 一次、基线重放原样复用、client 以它回填应答);纯推送帧(session/event 等)的 rpcId 标识该次推送(每次新 mint
  • 业务代码不 mintunary 的 mint 收口在客户端基类 callUnary,帧的 mint 收口在 host 侧。

签名窄形与载体补全

域接口签名只感知窄形:RpcRequest<P> = { rpcId, payload }RpcResponse<T> = { rpcId, result: RpcResult<T> }。载体层把窄形补全为全形(补 type tag 与 method),方向不靠通道推断。RpcResult<T> = { ok: true; value } | { ok: false; error: RpcError }——方法不 throw 业务错误。

RpcReceipt载体回执

ClientResponse 的 HTTP 应答体是 RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }——载体层回执,不是 RpcMessageresponse 不再有 response迟到/重复应答收 not-pending,逻辑收敛点是 */resolved 帧。

类型体系:函数签名即事实源

RpcMethodMap 与派生泛型(api/rpc-map.ts

方法的参数/返回结构只住在接口方法签名里map 登记方法本身其余一切位置handler、client、store、测试引用派生泛型禁止复写字面量或另起平铺具名类型

export interface RpcMethodMap {
  'session.list': SessionsApi['list']        // map key 即 wire 路径段
  // …其余方法同形登记,全集见 api/rpc-map.ts
}
// 派生泛型(穿透窄形取业务类型;实际声明带 K extends keyof RpcMethodMap 约束)
export type RequestPayload<K> = Parameters<RpcMethodMap[K]>[0]['payload']
export type ResponseValue<K> =
  Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never

流方法(events.mux/events.host)不进 map不是 unaryrespond 不进 map是 client-response 不是方法调用)。

错误模型(RpcErrorDetailsMap

错误码示例一行:

code details 何时
bad-request { issues: ZodIssue[] } wire/payload zod 校验失败

码全集见 api/rpc.tsRpcErrorDetailsMapRpcError 是 map 展开的分布式 unioncode 判别、switchdetails 自动窄化;details 必填——新码=map 加一行+错误 schema 加一支漏填是编译错误。transport 故障断网、host 没起)由载体抛异常,与业务错误两层不混。

zod 双向校验与锚定

  • 两级 parse:全形 schema 一次type/rpcId/method 结构 + handler 校验 path==method→ 业务 payload 按 method/帧型分派二次 parse拒收 = bad-request
  • 锚定schema 统一 satisfies z.ZodType<Wire<T>>api/rpc.schema.ts)。Wire<T> 是深度「| undefined」宽化——仓库开 exactOptionalPropertyTypes 而 zod .optional() 输出 T | undefined直接锚原类型全线不可用JSON wire 上缺席与 undefined 同形,宽化不损失校验语义。透传宽分支(SessionEvent/ContentBlock/帧 union/RpcError)与 brand id schema 用显式 cast + 注释。
  • brand cast 单点:每个 schema 文件的 id cast 收口一处(rpcIdSchema 是 rpc.schema.ts 唯一 cast 点)。

约定面ApiProxy

根接口 ApiProxy = { sessions, host, events, respond }api/index.ts)。新 client-request 域 = 新的一对文件(<域>.ts + <域>.schema.ts+ 根接口一个字段 + map 加行。

unary 方法表

方法示例一行(表结构即读法):

method key 请求 payload 返回 value 语义
session.list { cursor?: string }cursor 留座不实现) { items: SessionSummary[] } 已持久化 sessionupdatedAt 倒序v1 不建索引

其余方法(session.create/session.history/session.rename/session.prompt/session.cancel/host.describe)的参数与返回不在此复写——签名即事实源,见 api/sessions.tsapi/host.tsRpcMethodMap

server→client具名 union

两条逻辑流mux 流(/api/events.mux,全 session 聚合)与 host 流(/api/events.hosthost 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 WebSocket 下行载体。帧示例一行:

帧 type 载荷 何时发
session/event { sessionId; event: SessionEvent } 核心透传core 事件原样过,assistant/chunk 即 token 流,无独立 delta 帧

其余帧型不在此复写union 全集见 api/events.tsMuxFrame/HostFrame。语义上须知三点:session/subscribed 的 lastSeq 供 history 竞态检测;approval/question 的 requested 帧可应答rpcId 稳定、resolved 帧是收敛面;host/agent-error 是无 turn 位置 live 失败的唯一出口。

透传纪律wire 上的事件/消息/内容块就是 core 类型(SessionEvent/ContentBlock),不造第二套 DTO类型经 import type 依赖链直达浏览器。SessionEventMap merge-extensibleclient 对未知 type documented-default忽略事件 schema 留「合法信封+未知类型」分支——信封仍严格,不是字段级 passthrough。

会话语义impl 侧承诺)

  • 历史 = 事件重放:一套 foldclient 侧),历史分页与 live 增量同一条代码路径server 不做物化快照第二套。history 页边界对齐消息边界绝不从消息中间截断chunk 随定稿消息归组),尾页含进行中 partial 的 chunk。
  • prompt 关联prompt 的 rpcId 经 MessageSource'user-rpc')透传进 user/message 事件client 以此把乐观回显转正。
  • 重连 = 重建:不做续传 cursormuxsince 签名留座、传了忽略);断线重开流 + 重拉 historysubscribed.lastSeq 与 history 尾 seq 比对,有缝再补拉一次。
  • 冷会话处理遵循所有权session.historysession.fork 的源端读取会在不获取 Agent 的情况下检查持久化存储,而绑定到 Agent 的普通会话方法(如 prompt)则通过在途表去重后恢复会话。由会话支撑的 subagent 会拒绝这条通用恢复路径,且附加状态不对客户端暴露(running 已经覆盖)。
  • 审批/问答requested 帧受理时 mint 稳定 rpcId先到先赢host 内存 pending 表keyed by rpcId是唯一裁判mux 重开后在 subscribed 帧后重放仍 pending 的 requested 帧rpcId 原样复用,刷新恢复)。审计事件 approval/asked/decided 照旧走 durable 日志——帧=live 控制面,事件=durable 审计。现状:约定与帧类型已 shippedhost 侧 pending 表/wire answerer 未实现(api-proxy.tsrespond 是 stub恒回 not-pendingPendingCard v1 只展示。
  • 不设协议版本client 与 host 绑定发布,host.describe 无 protocolVersion 字段;出现独立发布的 client 时再引入。
  • 预留方法纪律map 只含已实现方法,未知 method 在信封 parse 即 fail loudbad-request),不设 not-implemented 兜底码。预留清单(实现时把签名抄进域接口+map 加行+schema 加对即升格):session.forkprompt.mode'inject'task.listhost.listModels、describe 加 hostInstanceId。(session.rename 已从本清单毕业:追加 user 来源的 session/title 事件。)

客户端载体AbstractApiClient 类体系(fetch/client.ts

协议不变量住基类,平台差异是两个切面:抽象方法 doFetch(url, init)(传输)+ 可覆写 onEnvelope(观测)。

IApiClientcaller 视图

ApiProxy 同域树,但 unary 方法收业务 payload 直传——载体 mint rpcId 并包信封,业务代码永不 mint需要本次调用 rpcId 的从返回的 RpcResponse 回显里读。ApiProxy 是 impl 侧实现的窄形签名约定,IApiClient 是 client 侧消费的 payload 直传视图,AbstractApiClient 桥接两者。方法逐 key 从 RpcMethodMap 派生——map 加行即机械更新。

基类持有的协议路径

路径 内容
callUnary mint → tap → POST 全形 → serverResponseSchema parse → rpcId 回显校验(不符即 throw→ tap → 吐窄形
readSse streaming fetch非 EventSource\n\n 分帧、data: 拼接、ServerRequest 全形 parse、tap、吐窄形 RpcRequest<帧>
respond client-response 透传rpcId 是回填,此处不 mint应答体 rpcReceiptSchema parse
unary 时限 普通 unary 调用使用 AbortSignal.timeout(默认 30s构造参数可调由用户掌控节奏的 host.pickDirectorycommand.execute 不设该时限,但保留调用方/连接取消;流不设时限
resolveBase 浏览器=同源 origin无 location 环境Node=http://dsh.internal 假 authority

实例级 envelope 观测切面

四象限全形均过 onEnvelope;基类实现是实例持有的微任务合批缓冲(帧风暴不逐帧惊扰消费者;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 subscribeEnvelopes(listener) 订阅(收整批 readonly RpcMessage[]返回退订函数listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费者订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费者,将来的诊断消费者接入时不动载体)。

子类表(传输承载)

子类 所在包 doFetch 用途
InProcessApiClient apiproxy 本包 注入的 { fetch } handler 同构点new InProcessApiClient(toFetchHandler(api)) 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 dsh --profile headless 直接驱动 core
WebApiClient dsh-client-connection globalThis.fetch 上行 + 每逻辑流一条同源 WebSocket 下行 浏览器客户端;物理边界见 WebSocket 下行载体
FixtureApiClient dsh-client-connection 不用(协议层覆写) 无 server 的 UI 开发(?fixture):覆写 callUnary/openMux/openHost/respond 虚方法,自己就是假 server帧 rpcId 由它 mint语义自洽
IPC 桥子类(假想示例——尚无此形态) Electron 壳 IPC 序列化往返 只需换 doFetch约定/基类零改

怎么扩展(操作清单)

加一个 unary 方法5 步):①域接口加方法签名(参数/返回内联,这是唯一事实源);②RpcMethodMap 加一行;③<域>.schema.ts 加 request/value schema 对(锚 Wire<RequestPayload<'…'>>④handler UNARY_ROUTES 加一行handler 的 Web 承载见 Web 客户端架构笔记⑤impl 实现(回显 request.rpcId。client 侧 IApiClient/AbstractApiClient 的域方法表同步加一行透传。

加一个帧型3 步):①MuxFrame/HostFrame union 加一支(可应答帧须注明 rpcId 稳定语义);②帧 schema 加一支;③消费端 fold/路由的 documented-default 已兜底未知型,按需加显式分支。

加一个错误码2 步):①RpcErrorDetailsMap 加一行details 必填);②rpcErrorSchema discriminatedUnion 加一支。

接一种新载体:继承 AbstractApiClient 只实现 doFetch;需要拦截协议层(如 fixture再覆写 callUnary/openMux/openHost 虚方法。约定与基类零改。

升格一个预留方法:把预留签名抄进域接口 → map 加行 → schema 加对 → UNARY_ROUTES 加行 → impl 实现。

Consequences

所有 client 使用同一约定:加一个 unary 方法是从单一签名出发的五步机械改动,换载体只动一个 doFetch 子类wire 上每条消息可 zod 校验、可经 envelope tap 观测、可按 rpcId 对账。普通 unary 调用仍受时限约束,而 host.pickDirectorycommand.execute 可保持挂起,直到操作完成或调用方/连接取消到来;若由用户掌控节奏的操作不自行结束,请求可能一直挂起,这是为避免把合理的操作时长视为传输失败而接受的代价。其余接受的代价:两组包需要显式 tsconfig paths 条目预留方法fork/inject/task.list/listModels/hostInstanceId在真实消费者出现前保持休眠。

Alternatives considered

放弃项 一句话理由
按产品分包web 一族、electron 一族) 产品共享的是 host/client 两侧能力,而不是某个应用实现;能力支持方分层让新应用零新包
混合体建包(如 headless 独立包) 混合体只有一个消费者(它自己的 app建包是无主抽象拼装写在 app 里可读可弃
消费型 client 直连 ctx省 apiproxy 一层) client 需要 wire 校验、观测与多 client 一致性。直接 headless 是没有 client 边界的本地入口,使用公开的 AgentSession seam而不是 client 命令面
webserver 依赖 runtime省 handler 注入) 结构 typing 注入让 webserver 可被 sidecar/测试复用且零 workspace 依赖;包依赖会把装配知识拖进承载层
包名不带组前缀(沿用 dsh-<尾段> dsh-runtime/dsh-web-ui 在扁平 npm 命名空间里失去归属信息;代价只是每包一条显式 paths
复用仓内 JSON-RPC 2.0dsh-jsonrpc 数字错误码退化成单码兜底、约定双份人肉对齐、命名无 convention 自然漂移
三信封模型Request/Response/Frame 各一信封,签名不感知方向) rpcId 是逻辑层关联,帧与应答的方向语义靠通道推断在换载体时即失效
具名 Request/Response 类型对为事实源map 登记类型对) 平铺具名类型是同一事实的第二个名字;签名 infer 反推让加方法只改一处
REST 风格路径 消费者是自家 client无第三方 REST 体验诉求RPC 直映方法表更机械
DTO 层wire 专用第二套结构) core 类型 type-only 直达浏览器零成本DTO 是永久的双向同步税
cursor 续传mux since 实装) 重连=重建opencode 同款)覆盖 v1 全部需求;签名留座,实装等真实消费者
createApiClient 工厂函数(原实现) 平台差异(传输/观测)是继承切面不是参数;类体系让 fixture 在协议层替换而不是包一层假信封
command.execute 应用 30 秒传输时限 命令耗时属于操作本身,而非传输健康预算;该时限会终止本应继续运行的长时处理器,调用方/连接取消已提供所需的停止路径