@dp-bohrium/widget-sdk
Widget 开发者 SDK,也是创建、构建、校验、开发和打包 Widget 的统一安装入口。普通作者只实现
activate(context) 和可选的 deactivate();SDK 自带的 bohr-widget 会生成 Widget Host 入口,
负责向 Host 宣告 Bundle、接收转移过来的 MessagePort,并暴露共享的
生命周期与能力协议。作者无需直接处理 window.postMessage,也无需手动启动插件。
使用 Agent 开发时,先读 llms.txt 获取文档索引,再读 AGENTS.md
了解安装、运行时调用和错误恢复规则。
激活事件目录、context.events 与低层 topic 的边界以本包的 AGENTS.md、本 README
和本包公开的类型与 Schema 为准;协议实现保持同一份事实源。
环境要求
| 项 | 要求 |
|---|---|
| Node.js | ≥ 20(执行 bohr-widget 与构建时;Widget 产物运行在浏览器 iframe 内) |
| 源码格式 | 纯 ESM,必须经打包器处理后交付 |
| 运行环境 | 必须由 Bohrium Host 加载,脱离 Host 无法完成握手 |
| 产物格式 | Host iframe 为 sandbox="allow-scripts" 且无 allow-same-origin 时,打成 classic script / IIFE(隔离源下 type="module" 需要 CORS) |
安装
安装 SDK 即可获得 Widget API、协议类型、JSON Schema、测试工具和 bohr-widget 命令。
npm install @dp-bohrium/widget-sdk
pnpm add @dp-bohrium/widget-sdk
yarn add @dp-bohrium/widget-sdk
新建工程只需调用 SDK 自带的命令,生成结果也只直接声明精确 SDK 版本:
pnpm dlx @dp-bohrium/widget-sdk create my-widget --template panel
cd my-widget
pnpm install
pnpm exec bohr-widget build .
pnpm exec bohr-widget check .
常用作者类型从本包根入口导入;完整协议从 @dp-bohrium/widget-sdk/contracts 导入,
编辑器 Schema 使用 @dp-bohrium/widget-sdk/schemas/*.json,协议测试语料使用
@dp-bohrium/widget-sdk/conformance/*。升级已有工程时,参照
版本兼容。
在自己的 package.json 里钉死精确 SemVer,不要用 ^ 或裸 latest。SDK 运行时会从已安装
包自身读取版本,插件源码和 Manifest 不需要再次填写 SDK 版本。
校验工程
使用 SDK 自带的命令检查 Widget 工程:
pnpm exec bohr-widget build .
pnpm exec bohr-widget check .
pnpm exec bohr-widget pack . --out widget.zip
命令复用同一份协议 Schema 校验 Manifest 与样例,并检查入口、资源和打包要求。 校验实现供内部工具使用,不提供独立公开子入口,普通 SDK 导入不加载校验器或 AJV。 校验通过只表示本地工程符合检查要求,Host 仍负责实际权限、网络策略与运行隔离。
快速开始
import type { WidgetContext } from "@dp-bohrium/widget-sdk";
export function activate(context: WidgetContext) {
context.render((request, renderContext) => {
renderContribution(request.contribution, request.content, renderContext.phase);
});
context.events.on("tool-call-result", () => {
void context.ui.present("floating");
});
}
CLI 生成的 src/widget-host.ts 会调用平台入口 runWidgetHost(widget)。
这借鉴 VS Code 的激活入口。最终模型按 Binding 建立一个 Extension Runtime,activate(context)
在 Runtime 内只执行一次;工具、文件或面板目标是挂载到 Runtime 的 Surface Instance,拥有
独立的快照和释放句柄,不会再次触发 activate。开发者实现 activate(context),可选实现
deactivate();Widget Host/SDK 负责加载、等待初始化、报告启动失败和执行清理。普通插件
代码不需要调用 ready(),也不应该写 plugin.ready().catch(() => {})。whenReady 只保留
给测试和高级宿主编排;whenDisposed 可用于等待已完成激活插件的异步 deactivate()。
停用不会等待一个永不结束的 activate();若激活在停用后迟到完成,SDK 会在已 abort 的生命周期上补偿释放并调用一次 deactivate(),但这不再延长 whenDisposed。
这是 SDK 的目标生命周期契约;当前 Widget Host 的动态加载仍由 Surface-bound Adapter 接线, Binding 级 Runtime Loader 尚未开放。插件可以按 Runtime/Surface 分层设计,但不能把当前版本 当成“无界面必然已启动”的生产保证。
context.signal 会在 Host 停用插件或 Bridge 断开时 abort。context.render 和 context.events
会自动登记到当前插件生命周期;subscriptions 只用于
第三方计时器、观察器或其它需要在停用时释放的资源。
三条时序约定:
ready()只在activate(context)返回(含其 Promise settle)之后才发给 Host,handshakeTimeoutMs同时约束这一步;- 初始化失败时,SDK 先报结构化错误,再调用已登记的插件资源清理;成功激活后的释放会
最多调用一次
deactivate(),已完成激活的异步清理可通过whenDisposed等待; context.render首帧和更新都会收到完整快照,通过renderContext.phase区分。
渲染失败与本地诊断
runWidgetHost 会隔离 context.render 处理器的同步异常和异步拒绝,并在 Guest console
记录原始异常。已有 onError 和 events.onError 也会收到本地结构化诊断:首帧使用
RENDER_FAILED/render,更新使用 UPDATE_FAILED/update,correlationId 对应失败快照的
requestId,retryable 为 false。结构化消息不包含渲染负载或原始异常。
这类通知本身不会让 SDK 终止 Bridge、销毁界面或自动重试;其他处理器和后续快照仍可继续,
插件应保留草稿并提供可恢复的提示。观察者显式调用 dispose() 时,SDK 仍尊重停用意图。
SDK 不回滚处理器失败前已执行的 DOM 修改;界面和草稿的恢复仍由插件拥有。
观察者自身的同步异常、异步拒绝和 console 失败不会阻断渲染,也不会递归触发 onError;
停用后迟到的渲染拒绝会被消费,但不再产生本地诊断。
这些是 Guest 本地出口,SDK 不通过 Bridge 把它们发送给 Host。
旧 Host 会把 Widget 的协议 error 当作致命失败,
不能用它承载可恢复的渲染诊断;Host 的 ready 或 render-sent 也不能证明 Guest DOM 渲染成功。
从 Manifest 到插件运行
Manifest 是插件的静态声明,SDK 代码是隔离 iframe 内的运行时实现,Host 负责二者之间的生命周期编排。一个插件可以这样声明:
{
"$schema": "@dp-bohrium/widget-sdk/schemas/widget-plugin-manifest.schema.json",
"name": "research",
"version": "1.0.0",
"entry": "src/widget.ts",
"displayName": "研究面板",
"description": "展示研究工具的结构化结果",
"activationEvents": ["session.start", "tool-call-result:ScholarSearch", "ui-tool-opened"],
"contributes": {
"widget": { "type": "panel", "id": "research", "surfaces": ["workspace", "floating"] }
},
"capabilities": {
"files": { "read": true },
"events": { "topics": ["tool-call-result", "workspace-file-opened"] }
}
}
需要接管完整的会话右侧区域时,在同一个 Panel contribution 上声明结构性放置意图,并同时申请匹配的
ui.region capability:
{
"contributes": {
"widget": {
"type": "panel",
"id": "better-sidebar",
"placement": { "kind": "region", "region": "session.rightRail", "mode": "replace" }
}
},
"capabilities": {
"region": { "regions": ["session.rightRail"], "mode": "replace" }
}
}
placement 只表达贡献点的结构性意图;区域租约、确认、几何、焦点、原生面板恢复和失败回退仍由 Host
负责。没有匹配 capability 时,SDK 编译阶段直接拒绝清单。
activationEvents 只决定 Host 何时创建插件运行时;它不授予事件读取权限,也不负责打开思考过程或某个面板。插件激活后通过 context.events 和 context.render 注册监听与渲染器。Host 会对每个事件和能力请求做白名单投影与授权,插件收到的事件可能是有限回放,也可能是实时事件。
实现建议:事件处理器保持幂等,按 toolCallId 或 requestId 去重;不要从 iframe URL、DOM 或宿主绝对路径推断内部数据;当能力未授予时显示结构化空态并继续保留可渲染的主界面。声明但没有实际使用的能力会被 Host 记录为诊断信息,未声明能力的调用会被拒绝并结束当前 Bridge 会话。
统一插件入口
所有公开插件类型都使用同一个 activate(context) 约定。面板和文件预览由
Manifest 的 contributes.widget 决定;运行时代码只认识统一的 context.events、
context.render、context.capabilities 和 context.ui。首帧和后续快照通过
renderContext.phase 区分,不需要维护两套回调。
Manifest 的编辑器提示来自本包 /schemas 的 JSON Schema 和公开 TypeScript 类型,
不依赖某个 defineManifest 工厂。构建工具调用
compileWidgetPluginManifest,自动补齐 schemaVersion、hostApiVersion、SDK 版本和
宿主加载字段;它会直接读取 Manifest 内的 samples,只有构建器从别处生成样例时
才需要传入第二个参数。createWidgetClient 是协议级基础实现,不属于 Widget 作者 API;当前根入口只提供统一 Widget API,协议级代码应从
./runtime 显式引入。
受信任的内部 Host/协议测试仍可直接构造低层 Tool Result RenderRequest,但该能力不属于外部作者清单或公共示例。
SDK 发布包会生成 dist/index.d.ts 与各子入口声明,并通过 package.json 的
types/exports 暴露。插件安装 @dp-bohrium/widget-sdk 后,编辑器会直接获得
WidgetContext、事件名、能力请求、渲染请求和生命周期返回值的类型提示;Manifest
编辑器提示由本包的 /schemas 和声明文件提供,Schema 在构建时从协议唯一源生成。
包入口
| 入口 | 内容 | 用途 |
|---|---|---|
. | runWidgetHost、compileWidgetPluginManifest 和常用作者类型 | 生成的生产 Bundle |
./contracts | 完整协议类型、事件目录与判定函数 | 高级协议集成;同名协议类型显式从此处获取 |
./conformance/* | 同源协议校验语料 | Host/控制面验证 |
./schemas/*.json | 作者清单、宿主清单、运行配置与消息 Schema | 编辑器提示和工具校验 |
./testing | MemoryTransport、MockWidgetHost | 单元测试 |
./runtime | 低层 WidgetClient、transport 与 bootstrap 常量 | 协议级集成 |
./package.json | 已安装包的元数据 | SDK 和构建工具自动读取版本,作者无需复制版本字符串 |
bohr-widget | Node.js 命令入口 | 创建、构建、校验、开发和打包工程;不进入浏览器 Bundle |
根入口只导出插件运行时和 Manifest 编译所需的少量值,其余均为类型。低层
MessagePortTransport 与 bootstrap 消息不属于普通 Widget API。
runWidgetHost 是生成的 widget-host.ts 使用的 Widget Host 启动边界;
插件作者只维护 widget.ts 中的 activate/deactivate。
能力
公开能力要在 Manifest 的 capabilities 里声明,并由 Host 实际 grant;
service.invoke 需要由 Host binding 显式授权。缺任一侧,调用在 Widget 本地即被拒绝。
所有能力调用都必须在 ready() 之后。
下面列出的能力是当前 SDK 支持的能力。grant 名与 Manifest 申请键不一定同名 —— ui.presentation
对应的作者侧申请键是 capabilities.presentation(编译后的 宿主格式字段才叫
requestedCapabilities.presentation)。
| grant 名 | Manifest 申请键 | SDK 调用 |
|---|---|---|
files.list / files.metadata | files | context.capabilities.request({ operation, input }) |
files.read | files.read | context.files.read({ file, offset, length, encoding, expectedRevision? }) |
files.write | files.write | context.files.write({ file, content, encoding, expectedRevision, mutationId }) |
files.download | files.download | context.files.download({ file, suggestedName }) |
events.read | events(需列出 topics) | 低层分页订阅;context.events.on(…) 是 Host 主动推送,不需要该 grant |
ui.state | state(read / write) | context.ui.state.read(key) / write(key, value) |
actions.invoke | actions | context.capabilities.request({ operation: "actions.invoke", input }) |
network.fetch | network | context.network.fetch(…) |
service.invoke | 由 Host binding 显式授权 | context.services.invoke({ service, action, payload }) |
ui.presentation | presentation | context.ui.present(…) / changeSurface / close |
ui.region | region(固定申请 session.rightRail) | context.ui.region.acquire() / release() |
除 Host 主动推送事件外,其余能力共用同一套调用范式,掌握下面这一个就会调其余能力:
const controller = new AbortController();
const result = await context.network.fetch(
{ url: "https://api.example.org/papers", method: "GET", auth: "optional" },
{ signal: controller.signal, timeoutMs: 15_000 },
);
if (result.ok) {
consume(result.data.status, result.data.headers, result.data.body);
} else {
// 策略拒绝、用户拒绝、未授权都走这里,不是 throw。
showNetworkFailure(result.reason, result.retryable);
}
跨能力约定:
- Host 拒绝以
{ ok: false, reason, message, retryable }正常兑现;本地状态、输入、客户端超时、 中止、dispose 和畸形响应可 reject。files.write发出后的结果不确定时兑现为write-outcome-unknown,处理方式见下文。 - 中止与超时统一用
{ signal, timeoutMs },复用既有 MessageChannel 能力生命周期。 - Widget 永远拿不到 Host 凭据,也不应直接调用浏览器
fetch:是否弹窗、记住 Origin 授权、跟随重定向还是拒绝,全部由 Host 决定。 context.services仅在 Host binding 授予service.invoke后可用。可调用的服务、操作、请求/响应 大小和有效期由 Host 的授权策略决定;未获授权时调用会被拒绝。认证状态由宿主侧管理,SDK 只收发 业务 payload,不会把 Cookie、Bearer Token 或其他凭据放入 iframe。context.files.download只提交文件引用和建议文件名;Host 在用户确认后经宿主 Download Adapter 惰性打开内容源。Widget 不会收到源 URL、保存路径或凭据。context.ui.state只保存按 Widget + Session 隔离的 JSON UI 状态;Host 按 key、单值大小、总字节数和调用 频率限流。它不能保存任务执行、凭据、文件内容或 Workspace 权限状态。- 外部插件的工具结果场景通过
tool-call-result事件作为可选运行时信号;内部 Host/协议测试仍可使用低层 Tool Result RenderRequest 或展示槽位。插件应把结果转换成自己的 Panel 数据,再通过context.render更新面板。 - 呈现只是请求。Host 可能降级到别的表面,成功结果里的
surface才是真实表面; Widget 不能指定目标 Panel、身份、运行时 generation、坐标或层级。 - 高层插件 API 使用
context.events.on(handler)接收 Host 从当前 Runtime 主动推送的 聚合事件,只按type分发,不依赖 Manifest Topic 或events.readgrant。需要直接使用有界 分页读取时,从@dp-bohrium/widget-sdk/runtime使用subscribeEvents/onTool。 - 低层
subscribeEvents/onTool的所有订阅共享同一实例的 events 读预算,并各自遵守 topic 最小轮询间隔 —— 订阅越多,单个 topic 刷新越慢。游标只在onBatch成功后 提交,重新订阅需显式传回events.cursor。工具 Hook 观察的是已经发生的服务端 事实,不是执行前拦截器。
读取和保存当前文件
作者 Manifest 申请 "capabilities": { "files": { "read": true, "write": true } },Host 仍需独立
授权两个能力。files.write 对 Host 签发引用指向的已有文件作整文件条件替换:在 file-preview
中引用来自 request.content.file;在获得写 grant 的 Panel 中引用来自 data.root 或 files.list
的结果。不能传路径、URL、自造的文件 id 或指定另存位置。Host 校验引用、作用域、
写权限和实际字节预算,成功回执表示执行端已确认持久化。SDK 不负责 ASE、晶格或文件格式转换,
插件应使用自己的序列化器生成最终字节。
FileReadInput / FileReadResult、FileWriteInput / FileWriteResult、FileRevision、
FileMutationId 与两个 *CapabilityResult 类型均可从 SDK 根入口导入。revision 是文件内容的
不透明版本,与渲染快照的 RenderRequest.revision 无关。下面的 file 来自当前文件预览:
const loaded = await context.files.read({
file, offset: 0, length: 64_000, encoding: "utf8",
...(file.revision ? { expectedRevision: file.revision } : {}),
});
if (!loaded.ok) return showReadFailure(loaded.reason);
if (!loaded.data.revision) return showReadOnlyFile(loaded.data.content);
if (loaded.data.eof === false || loaded.data.truncated === true) {
return showFileNeedsChunkedReading();
}
const expectedRevision = loaded.data.revision;
const content = serializeEditedModel(loaded.data.content);
// 一次逻辑保存只生成一次;核对或重试同一内容时复用。
const mutationId = crypto.randomUUID();
const saved = await context.files.write({
file, content, encoding: "utf8", expectedRevision, mutationId,
}, { signal: saveController.signal, timeoutMs: 15_000 });
if (saved.ok) {
markSaved(saved.data.file, saved.data.revision);
} else {
keepUnsavedChanges(saved.reason, mutationId);
}
read 兼容旧 Host 的 { content, encoding } 结果;offset、bytesRead、nextOffset、eof、
truncated 和 revision 均为可选扩展。SDK 原样返回结果,只验证已提供字段,不补造游标或版本。
没有 revision 时内容可展示,但不能据此保存;带 expectedRevision 的读取必须返回有效且匹配的
版本,不能降级为无版本读取。Host 必须从同次读取的内容快照签发版本,不能另用 metadata/stat 补取。
有游标的 read 按原始字节计数:使用 nextOffset 继续;若首块带版本,后续块携带对应
expectedRevision,防止混合版本。utf8 字节区间可能切断多字节字符,不能用重新编码后的文本长度推断 bytesRead。
二进制文件或需完整解码的分块文本使用 base64,先将每块解码为字节并拼接,再解码文本。
base64 使用标准、带正确 padding 的编码;空文件和空字符串替换均合法。
上面的整文件保存示例以 Host 返回完整内容为前提;缺少完整性标记时,插件需依据 Host 的读取约定
确认内容完整,不能把一个未完成的分块直接替换为整个文件。
write 必填读取时的 expectedRevision 和一次逻辑保存的 mutationId(1–128 个字符,只含
字母、数字、_ . : - 且以字母或数字开头)。revision-conflict 表示当前文件已变化,应重新读取
并由用户合并;相同 mutationId 携带不同内容会返回 mutation-conflict。保存成功后更新本地文件引用
和基础版本;未修改内容的保存可以返回相同版本。
游标元信息不是保存的前提;只有读取内容所绑定的有效版本才能用作 expectedRevision。
SDK 不自动重试保存。调用前已中止、客户端未就绪或输入非法会 reject,且不发送请求;缺少 grant
返回 permission-denied。发出后超时、中止、断线或畸形回执都不能证明未写入,返回
{ ok: false, reason: "write-outcome-unknown", retryable: false }。此时保留未保存内容、原基础版本
和 mutationId,通过 Host 核对文件版本或用同一 mutationId、同一内容确认结果;不要直接生成新 id
重试,也不要提前标记已保存。新的编辑内容属于新的逻辑保存,应使用新的 mutationId。
测试
import { MockWidgetHost } from "@dp-bohrium/widget-sdk/testing";
MockWidgetHost 负责 init/ready 时序、首帧 render 与 update 选择、resize 事件、
能力 adapter 响应与终态 dispose;capabilityResult 给出确定性的默认允许/拒绝,
同时提供 handleCapability 时后者优先。host.capabilityRequests 返回有序的防御性
拷贝,断言不会改动 Mock 状态。
它使用与生产 Host 相同的公开 contracts,但不等同于生产 iframe、CSP、授权或资源 隔离保证。
限制
- SDK 不做授权决策,能力是否可用最终由 Host 判定。
invokeAction不自动重试,回调完成也不代表工具执行完成。subscribeEvents的events.read是有界分页读取,不是 websocket;实时推送走onEvent。当前版本没有单请求取消的独立协议消息:unsubscribe会停止后续轮询并取消 本地等待,已发出的服务端读取可能完成,但结果被忽略。- Host 发来畸形 Bridge 信封或载荷时,transport 报结构化
MESSAGE_INVALID并关闭 连接,不会让 Widget 悬等。
许可证
MIT。