dsh-surface-bridge
v0.1.0-alpha.9
Published
Shared seam between a right-Sidebar business surface and the DSH composer: one selection chip, one Host-context injection point, one write-back channel
Readme
dsh-surface-bridge
DSH 右侧栏的业务面(画布、表格、Notebook)与 DSH 输入栏之间的唯一接缝:一条 chip、一处上下文注入点、一条回写通道。
┌─────────────┐ publish(selection) ┌────────────────────────┐
│ 业务插件 │ ───────────────────► │ dsh-surface-bridge │
│ (画布/表格) │ ◄─────────────────── │ · 输入行上的 chip │
└─────────────┘ 同一个长轮询 │ · 注册表(仅本地) │
▲ └───────────┬────────────┘
│ read:现在选了什么? │ agent/pre-step
└──────────────────────────────────────────┘
▼
user/message(上下文)关键:选择是"被读"的,不是"被推"的。 用户画图、框选时没有任何网络流量;只有当一条消息要发出去、模型步骤开始准备时才向浏览器要一次。publish 是纯本地动作(驱动 chip + 作为那次读取的答案),不碰网络。
为什么它必须是一个已挂载的 bundle,而不是 packages/ 里的纯库
这是本包存在的全部理由。设想把联动逻辑写成一个库,让每个业务插件 import:
| 方案 | 结果 |
| --- | --- |
| 每个插件各写一套 chip + 注入 | 输入栏里 N 个 chip 抢位置;同一套 Host 注入被复制 N 次;视觉与行为各自漂移 |
| 联动逻辑做成纯库,各插件 import | 看起来更轻,但库会被分别打进每个插件自己的 client bundle。于是 ctx 上出现 N 份服务实例,各自注册一个 conversation.input.dock 条目 —— chip 依然是 N 个。纯库无法保证"全局唯一" |
| 联动逻辑做成独立 bundle(本包) | 唯一实例由 DSH 的 Loader 保证;业务插件只 inject 服务 + registerSource(...) |
因此本包是一个独立 bundle,而不是一个给各插件 import 的库:唯一实例由 DSH 的
Loader 保证,业务插件只 inject 服务 + registerSource(...)。
给业务插件用的 API
浏览器侧:ctx.surfaceBridge
export const inject = ['surfaceBridge', 'locale'] as const
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.surfaceBridge.registerSource({
id: 'excalidraw',
label: '画布',
icon: IconEditOutlineRegular,
order: 10,
reveal: () => { ctx.sidebarRight.openTab('data-excalidraw') }, // chip 上的「定位」
focusElement: (id) => { canvas.focusElement(id) }, // 点摘要某一行
}), 'my-surface: source')
// 用户选择变化时:
ctx.surfaceBridge.publish('excalidraw', selectionOrNull)
}面板侧还要接住"点击 transcript 里那条选区 chip":桥把请求放进注册表,面板取走并选中元素。
const request = ctx.surfaceBridge.takeFocus('excalidraw', scene) // 只取点名自己这张图的请求
if (request !== null) selectAndScrollIntoView(request.elementIds)selection 的形状见 src/contract.ts 的 SurfaceSelection:源只投影元素行(id / 类型 / 文本 / 坐标 / 尺寸 / 非默认样式 / 连接关系),桥拥有信封(revision、count、bounds、模型文本、可选图片)。文件类的面还可以给 resource.display(人读的名字,如 main)——桥不知道什么是扩展名,也不该知道。
图片不走文本。 大载荷放进 selection.images[],每条用 elementId 指向自己的元素;Host 把它们落成 durable 附件并以真正的 image 块追加到上下文。没有图片元素的选择,一个字节的图片都不产生。
Host 侧:ctx.surfaceBridgeHost
const live = ctx.surfaceBridgeHost.isSurfaceLive(sessionId) // 画布是否开着
const all = await ctx.surfaceBridgeHost.readSelections(sessionId, signal) // 现在选了什么
const one = await ctx.surfaceBridgeHost.readSelection(sessionId, 'excalidraw', signal)
const results = await ctx.surfaceBridgeHost.apply(sessionId, 'excalidraw', // 回写并等结果
[{ op: 'update', payload: { id, fields } }], exec.signal)apply 在画布未打开或超时未回传时返回明确的失败结果,绝不假装成功。
上下文是怎么进模型的
Host 半在 agent/pre-step 里做三件事,而且只在带着新用户输入的那一步做:
- 若该 Session 有面在轮询(
isSurfaceLive),向浏览器读一次当前选择; - 把选择并进用户自己那条消息的最前面:一个
@"<显示名> · N 个元素"mention(shell 把整个引号内容渲染成用户气泡里那个蓝色、带文件图标的引用 chip);图片以真正的image块加进同一条消息; - 追加一条隐藏的明细消息(
source.kind = 'surface-selection')装元素表,并记下这个 turn 已经读过,同一 turn 的后续 step 不再读。
为什么是一行、而且长在用户自己的气泡里。 Chat 视图对非 user 源的上下文节点不渲染任何行(isVisibleChatNode 把普通 Context 全部排除),所以可见的那一半必须是 source.kind = 'user' 的消息。而用户消息里行内有颜色的东西只有一种:@… 引用 chip,而且它的显示文本就是 token 里的最后一段——所以「文件 + 个数一起变蓝」只能把它们塞进同一个引号 token 里。于是选择以 mention 的形式并进用户那句话 —— 另起一条消息就会变成用户看到的"两行"。
- 只在发送时读取:用户画图期间什么都不发。这也让"发送时看到的选择"就是消息真正带上的选择。
- 一个 turn 只读一次:一个 turn 有多个 step(模型调用、工具结果、再调用),否则同一张图会被注入三遍。
- 不可见的面答空:读取的答案来自同一个注册表,而注册表只列"在屏幕上且有选择"的面,所以切会话/切标签后,旧选择既不会显示也不会被发送。
- 无浏览器则跳过:没有面在轮询(CLI、未打开过画布标签页)直接返回,不阻塞消息。
- 没有用户消息可并时(turn 由通知、定时唤醒或恢复的 goal 打开):可见块另起一条
user消息 —— 宁可多一行,也不能让选择只进模型、不进对话。 - 形态与
dsh-time-context、dsh-session-reference一致:都是官方认可的"生产者注入 user-role 上下文"路径。
点击那条 chip 之后。 chip 的文本是给人读的(main · 3 个元素),不是路径 —— 所以 shell 自己那个"打开这个文件"的动作在这里用不了(它会把整串当路径去 claim,然后抛错)。于是这次点击由桥的客户端半边接管:捕获阶段 stopPropagation,自己打开这张图的文件地址(和文件树同一个标签页),再把请求交给画布面板。识别、定位、打开都靠下面这张表:
| 需要的东西 | 来自哪里 |
| --- | --- |
| 哪条消息 | 行上的 data-chat-node-key(shell 自己滚动/锚点也用它)+ data-conversation-session |
| 这是不是本包的 chip | chip 的 title 是否形如 @"<名称> · N 个元素" |
| 这条消息带了哪个选择 | 隐藏明细消息的 source:surface / message / document / elements |
| 打开哪个文件 | 同一条明细的 source.path(绝对路径)→ 按 shell 的语法组成 dsh-resource://file/…,与文件树点开同一个标签页 |
| 谁来选中元素 | 该面自己的面板:requestFocus / takeFocus,请求一直留着直到面板接住(点击本身往往就是打开这个面板的动作) |
监听是捕获阶段、只读、从不 preventDefault;只对本包自己的 label 形态(@"… · N 个元素")接管,别人手打的 @path 或助手消息里的文件引用照旧由 shell 打开。任何一步缺失(老 shell 没有那个属性、明细消息不在已加载窗口里、面已卸载)都只是这一次点击什么都不做,不会抛错、也不会误开别的文件。明细消息的 source 是簿记而非模型内容,因此不花一个 prompt token。
路由
全部挂在平台共享 API 通道(ctx.connection.fetch.register)上,因此继承平台的信任与鉴权围栏,不另开 webserver:
只有两条路由,服务于一个双向长轮询:
| 路由 | 方向 | 用途 |
| --- | --- | --- |
| GET /api/data-canvas/ops?sessionId=&hold= | 浏览器 ← Host | 长轮询(最长 20s):取待执行操作,以及 Host 的读取请求 |
| POST /api/data-canvas/settle | 浏览器 → Host | 回传执行结果,或回传一次读取的答案 |
读取复用了同一条队列(READ_SELECTION_OP,不带 source —— 只有浏览器知道有哪些面、哪些在屏幕上),所以浏览器不需要第二个监听器,一次读取就是一个已经parked 的连接上的往返。路径常量在 src/contract.ts 里只写一遍。
校验没有消失,只是换了一端:旧设计要在 ingest 端校验推送来的选择,现在改为在读取答案上校验(src/host/narrow.ts)——浏览器仍然是边界,只是方向反了。
扩展一个新业务面
- 建一个带
dsh.client的 bundle,dsh.client.inject里加上dsh-surface-bridge(Loader 会保证桥先注册)。 inject: ['surfaceBridge', ...],registerSource一个描述符,并在可见性变化时setVisible。- 用户选择变化时把投影好的
SurfaceSelectionpublish到注册表(本地)。 - 自己的操作循环里把
READ_SELECTION_OP答成[selection]或[]。 - Host 侧工具通过
ctx.surfaceBridgeHost读选择、送操作。
chip、输入行占位、agent/pre-step 注入、图片附件化、长轮询回写全部由本包负责。
已知边界
- 读取窗口是 600ms。 一次读取最多等这么久就拿不到答案(消息照发,只是不带选择)。本地环回上是毫秒级;设短是为了不为了上下文而拖延用户的消息。
- 读取靠"有面在轮询"判定。 从未打开过画布标签页的会话读不到东西——这是对的,那张画布根本不在屏幕上。
- 每个面各自轮询。 多个面同时挂在一个会话上时,读取请求会被先轮询到的那个取走。当前只有一个面,此路径正确;接第二个面时应把轮询收归桥的客户端半边(按
operation.source分派)。 - 回写不做持久队列。 操作队列是同一个进程里两半之间的交接通道,画布关掉即失去意义;因此工具失败要响,而不是排队等。
- 点击定位依赖 shell 的 DOM 标识。 行上的
data-chat-node-key/data-conversation-session和 chip 上的data-ref-chip不是本包定义的接口,是 shell 自己导航时也在读的属性(它的 Esc 处理同样closest("[data-conversation-session]"))。升级 DSH 后要复验这三处:transcript-focus.test.mjs里有一条构建产物断言盯着它们。真要彻底不依赖,就得让 shell 提供自定义节点渲染器,那属于 DSH 侧改动。 - chip 的 label 形态是握手协议。 宿主写
@"<显示名> · N 个元素",客户端按同一条正则读回来;两边改动必须同时。写歪了不会误伤别人:识别不出来的 chip 交回 shell,只是点不动。 - 一个面同时只能挂一个待处理定位请求。 连点两次 chip 是"看后一次",不排队;这符合"再点一次"的直觉,也避免旧请求在新画布上突然生效。
源码
发布的包里带 src/。构建是两个 half 一起出:
pnpm build # tsc(Host 半 + 类型)+ esbuild(唯一 client.js)
pnpm test