跳转到内容
↑↓导航 ↵打开 esc关闭

构建扩展

使用 @openchamber/sdk 为 OpenChamber 添加你自己的面板。扩展是一个小型网页,OpenChamber 会把它显示在右侧栏。它通过 connectHost() 与应用交互:可以读取当前项目和会话、显示提示消息、把文本放进聊天框、把任务附加到会话,并在用户批准后创建会话和发送提示词。

扩展可以在 OpenChamber 的网页版和桌面版中运行。VS Code 和移动端暂不加载扩展。

扩展是什么

一个包含三个文件的文件夹:

  • package.json,其中带有 openchamber 块(清单)
  • panel/index.html,OpenChamber 显示的页面
  • panel/main.js,你的脚本,需构建为单个经典文件(IIFE,而不是 ES 模块,因为页面加载在沙箱化的 iframe 中)

OpenChamber 从不编译你的代码。请直接发布构建好的 .js 文件。SDK 附带一个打包命令;任何能输出 IIFE 的打包器也都可以。

Terminal window
npm install @openchamber/sdk
bunx openchamber-guest-bundle panel/main.ts panel/main.js

打包命令依赖 Bun 运行。没有 Bun 时,可以用 esbuild 或其他打包器,并加上 --format=iife --platform=browser。

一个完整可运行的扩展(全部三个文件)在示例页面。复制它,然后改成你自己的 API 和列表。

在 index.html 中用普通的 <script src="main.js"></script> 引用 main.js。

开发时安装

  1. 在网页版或桌面版运行 OpenChamber。
  2. 打开 设置 → 扩展。
  3. 粘贴你的文件夹的绝对路径,然后点击添加。

OpenChamber 会读取清单,并弹出一个对话框列出扩展所请求的内容(见能力)。批准后,你的图标会出现在侧栏。点击它,你的页面就会加载。

你也可以添加本地 .zip 文件,或指向 git 仓库或 zip 文件的 https 链接。只有 git 安装能够更新:提高 package.json 里的 version 并推送,OpenChamber 会在启动后不久和每隔几小时检查一次,通知用户一次,并在 设置 → 扩展 中提供更新。任何页面上指向 openchamber://extensions/install?url=<encoded repository URL> 的链接都会打开桌面应用,并把该仓库填入添加对话框,因此你可以提供一个安装按钮。URL 上的 #tag 或 #branch 固定它跟随的是哪一个。这些会被复制到 OpenChamber 的数据文件夹,并从该副本运行,因此请发布构建好的文件,而不是 node_modules 或 TypeScript 源码。移除扩展时会删除该副本。从文件夹安装则直接从你的文件夹运行,你可以编辑、重新构建、再重新加载。

清单

{
"name": "@acme/hello",
"version": "1.0.0",
"openchamber": {
"apiVersion": 1,
"engines": { "openchamber": ">=1.24.0" },
"id": "acme-hello",
"name": "Hello",
"icon": "window",
"contributes": {
"panel": { "entry": "panel/index.html" },
"attach": "dialog",
"page": true,
"capabilities": ["prompt", "sessions"],
"integration": {
"name": "Acme",
"description": "Tasks from Acme",
"token": {
"apiOrigin": "https://api.acme.example",
"account": { "path": "/me", "name": "login" },
"scheme": "bearer"
},
"settings": [{ "id": "list-id", "label": "List ID" }]
}
}
}
}

version 是必填项,且必须是 semver 格式。设置 → 扩展 会在卡片上显示它。

apiVersion 为 1。OpenChamber 拒绝任何其他值。

engines.openchamber 可选。填写你的扩展支持的最低 OpenChamber 版本,写成 1.24.0 或 >=1.24.0。更旧的版本会拒绝安装,而不是之后才出错。

id、name 和 icon 用来标识扩展。id 使用 kebab-case,且在已安装扩展中必须唯一。icon 是 Remixicon 图标名(RiWindowLine 写成 window),或者你文件夹内的 SVG 文件,如 icon.svg。contributes.panel 是扩展在侧栏中的面板:它的 entry 是你文件夹内的 HTML 文件。没有侧栏面板的扩展可以不写 panel。旧版包把 id、name 和 icon 放在 panel 内,这种写法仍然有效。

contributes.attach 可选。它会把你的扩展加入聊天框旁的 + 菜单,用户可以从中挑选一个任务并附加到会话。

  • "dialog" 在一个窗口中打开你的页面
  • true 或 "panel" 改为打开侧栏面板
  • { "mode": "dialog", "entry": "panel/attach.html" } 在窗口中打开你的另一个页面,这样选择器就不必和侧栏面板共用代码
  • 省略它,扩展就不会出现在该菜单中

ctx.surface 为 panel、dialog、page、status 或 block。点击附加条目时,ctx.item 包含其数据;其他情况下为 null。

contributes.page: true 将面板作为全屏页面加入会话列表上方的扩展页面菜单。{ "entry": "panel/page.html", "title": "Board" } 使用独立 HTML 和可选标题。仍需 panel.entry,并使用相同沙箱和权限。只有用户可以打开页面。重新加载、暂停、移除或切换服务器时会关闭页面。

看板可以使用 host.storage,以及项目、worktree、会话列表和实时状态。startSession 可以选择其他项目而不关闭看板。详见 Host API。

contributes.integration 可选。它会在 设置 → 集成 中添加一张卡片,用户在那里连接账户。见账户与网络。

contributes.service 可选。它声明一个由 OpenChamber 在扩展旁启动的本地进程,用于网页无法访问的东西,比如 Docker socket。该进程以用户的完整权限运行,且没有沙箱隔离,因此批准对话框会对此发出警告;只有当网页无法完成这项工作时才声明它。见包内文件 GUEST_SERVICES.md。 带有 provides: ["browser"] 的服务还可以替代智能体的浏览器:它在服务器上响应 browser.* 动作,因此智能体无需打开桌面应用即可浏览,用户在设置 → OpenChamber 工具中选择它。这样的服务不需要面板。服务还可以得知用户会话中发生的事。在它旁边声明 contributes.sessionEvents,OpenChamber 就会把每一轮完成或失败的对话,以及智能体请求的每个权限和提出的每个问题,从每个项目发送给服务,无需打开窗口。见会话事件。带有 provides: ["notifications"] 的服务则在桌面端和手机之外,把用户的通知投递到 Slack、Matrix 或你自己的服务器。用户在设置 → 集成中连接账户,令牌保留在 OpenChamber 中。见通知渠道。 带有 provides: ["tracker"] 的服务是 Linear 旁边的工作项跟踪器,它的账户卡片在扩展账户下;见跟踪器。而带有 provides: ["quota"] 的服务则在内置提供商旁边报告用量,读取 OpenCode 为清单中列出的提供商保存的凭据;见用量提供商。

能力

绘制面板和读取当前会话无需任何权限。凡是代表用户执行操作的,都需要。把它们列在 contributes.capabilities 中:

能力允许的操作
prompt向用户的会话发送消息(prompt({ send: true })、带 text 的 startSession)
sessions列出项目、worktree 和会话状态;在已添加的项目中创建和打开会话
files读写打开项目中的文件(使用相对路径的 readFile、writeFile、listDir、stat)
model使用用户的 Small Model 进行一次性文本生成(generate),不在任何会话中

另有十一项会自动为你加上:声明了 integration 时加上 network,声明了 service 时加上 service,声明了 filesystem 模式时加上 filesystem,声明了 origins 时加上 origins,会话操作请求消息时加上 conversation(见操作、命令和徽标),sessionEvents 或 sessionControl 请求 content 时加上 transcript(见会话事件),服务操作会话时加上 sessionControl(见调用 OpenChamber),服务代用户回答智能体的权限请求和表单时加上 sessionAnswers(sessionControl: { answers: true }),补充智能体读取的内容时加上 instructions,声明了 contributes.skills 时加上 skills。contributes.skills 是包内的一个技能文件夹,扩展开启期间这些技能会提供给每个智能体。声明了 contributes.opencodePlugin 时加上 opencodePlugin(见下文)。

用户在安装扩展时会看到完整列表一次,然后批准或移除扩展。如果新版本请求更多权限,对话框会再次出现。调用需要用户尚未批准的能力时,会以 NOT_GRANTED 失败。

在你的包里带上一个 OpenCode 插件

扩展可以自带一个 OpenCode 插件,这样用户只装一样东西而不是两样。声明 "opencodePlugin": "plugin":包内的一个文件夹,其根目录下有 index.js 或 server.js。OpenCode 不读取 main,所以如果入口文件在别处,请在该文件夹的 package.json 中设置 name 和 exports。入口文件默认导出 { id, setup },也就是 OpenCode 加载的形状;它不导入任何东西,因为 OpenCode 用一个简单的 import() 从你的文件夹加载它,而那里什么都没有安装。工具的 input 是普通的 JSON Schema,它的 execute 返回 { content }。

{
"contributes": {
"panel": { "id": "teapot-tools", "name": "Teapot Tools", "icon": "cup" },
"opencodePlugin": "plugin"
}
}

批准对话框会多出一行 向 OpenCode 添加插件,明确说明插件代码在 OpenCode 内部以用户的全部权限运行。允许之后,OpenChamber 会把你的文件夹写进它管理的 OpenCode 配置,也就是承载 contributes.skills 的同一个文件;它运行的 OpenCode 监视该文件,几秒内加载插件。停用或移除扩展会以同样的方式把插件拿掉。用户自己的 opencode.json 不会被写入任何内容:那是用户的文件,写在那里的插件会比你的扩展活得更久,并且在这台机器上的每个 OpenCode 中运行。

当该文件无法到达 OpenCode 时,扩展的页面会说明情况并提供 添加到全局配置,它会把你的文件夹写入用户的全局 OpenCode 配置,成为从此由用户拥有的条目。这发生在 OpenChamber 没有启动它所对话的 OpenCode 时,或者服务器环境设置了自己的 OPENCODE_CONFIG 时(此时插件在 OpenCode 下次启动时加载)。另一台机器上的 OpenCode 无法加载这台机器上的文件夹;页面会说明这一点,不提供任何按钮。加载失败的插件,在设置 → 插件中有条目时由 OpenCode 报告;无论如何你的扩展都继续工作。插件文件夹不存在、或其 package.json 没有指定入口文件的包,安装会被拒绝。

VS Code 和移动端应用不加载扩展,所以插件不会到达那里。隔离空间内的会话运行自己的 OpenCode,它从不读取宿主机的配置。企业模式把插件当作本地服务对待:只能从管理员列出的仓库安装。SDK 中有一个可运行的示例 examples/opencode-plugin。

生成文本

有了 model 能力,host.generate 会向用户的 Small Model 请求一次性回答:摘要、标题、草稿。内容不会进入任何会话,也不保留历史记录,扩展永远不能选择提供方。OpenChamber 使用它自己后台工作所用的同一个模型,即在 设置 → 会话 → Small Model 中选择的模型,或用户正在使用的提供方的小模型,或用户 OpenCode 配置中的默认模型。

const { text } = await host.generate({
prompt: task.description,
system: "Write a one-line summary. Return only the summary.",
maxOutputTokens: 200,
});

当没有可用模型时,调用会以 NO_MODEL 失败;出错的模型是 MODEL_FAILED。提示词限制在 64,000 字符以内,调用最多等待 90 秒。用户会看到这项权限显示为”使用你的 Small Model”,这会消耗他们的令牌,所以提示词要简短,并在点击时调用,而不是在每次按键时调用。

账户与网络

你的页面运行在沙箱中,无法自行访问网络:脚本、样式、图片、字体和媒体只能从你的包中加载,fetch 也只能访问你包里的文件。请把字体和图片放进包里,而不是从 CDN 加载。

要与外部服务通信,请声明一个 integration,OpenChamber 就会通过 host.request 替你发起调用,并附上用户的令牌。令牌永远不会到达你的页面。

对于页面需要直接访问的其他内容,比如来自 CDN 的字体或图片、公开 API,最多列出 8 个 https 源:

"origins": ["https://fonts.example.com"]

用户会在批准对话框中看到它们,作为你的扩展可以发送和接收数据的地址;如果更新新增了源,需要再次批准。已批准的源对 fetch、图片、字体、样式和媒体开放,但永远不对脚本开放:代码必须随包一起提供。页面的源是 null,所以要读取 fetch 响应,服务器必须为该源允许 CORS。

在企业模式下,带有 origins、integration 或 service 的扩展只能从管理员列出的 Git 仓库安装。如果你的扩展不需要它们也能工作,这些团队仍然可以安装它。要在这样的机器上开发扩展,请向管理员申请 allowLocalExtensions,这样就能从扩展所在的文件夹安装它。

  • token:用户在 设置 → 集成 的卡片上粘贴 API 令牌。apiOrigin 是 request 唯一可以调用的源。account 可选:一个 GET 路径和一个字段名,让卡片能显示已连接的是谁。scheme 决定令牌如何发送,见下表。请查看提供方的 API 文档,确认它期望哪种请求头。
  • oauth:用户粘贴 client id,由 OpenChamber 执行授权流程。需要 authorizeUrl、tokenUrl 和 apiOrigin。
  • host: { "provider": "linear" }:复用 OpenChamber 中已连接的 Linear 账户。无需 client id。

每种 scheme 发送的内容:

schemeOpenChamber 发送的请求头何时使用
省略Authorization: <token>API 文档中要求在请求头里放不带前缀的令牌
"bearer"Authorization: Bearer <token>API 文档中要求 bearer 令牌或 personal access token
"basic"Authorization: Basic base64(username:token)API 文档中要求使用用户名(通常是邮箱)加 API 令牌的 HTTP Basic 认证;卡片会要求填写两者,usernameLabel 指定第一个字段的名称

settings 会在卡片上添加纯文本字段。它们的值会通过 ctx.settings 传给你。带 "secret": true 的字段在卡片上会被遮盖,也不会传给页面;你的服务用 readSettings() 读取它。

文件

你的页面无法直接访问磁盘。OpenChamber 替它读写,并限制在用户批准的范围内。

  • 相对路径(README.md、src/index.ts、.)指打开的项目。需要 files 能力。没有打开的项目时为 NO_DIRECTORY。
  • 以 / 或 ~/ 开头的路径指其他任何位置。它必须匹配你在 contributes.filesystem 中声明的某个模式,用户会在批准对话框中看到这些原样的模式:
"filesystem": ["~/.config/opencode/opencode.json", "~/notes/**"]

模式中 * 匹配一个路径段,** 匹配任意深度。超出模式范围的一律为 BAD_PATH,即使已获批准也是如此。.. 永远不允许。

const config = await host.readFile("~/.config/opencode/opencode.json");
await host.writeFile("~/.config/opencode/opencode.json", nextJson);
const { entries } = await host.listDir(".");

写入是原子的:OpenChamber 先写入临时文件再重命名,读取方永远不会看到写了一半的文件。文件以 UTF-8 文本读写,最大 2 MB。

Work Status 中的分区

contributes.statusSection 会在聊天旁的 Work Status 面板里添加你自己的分区。用户可以在该面板的分区列表中开关和移动它,就像内置分区一样。

"statusSection": { "entry": "status/index.html", "title": "Recent commits", "height": 160 }
  • true 复用 panel.entry。
  • entry 是你文件夹里单独的 HTML 页面。有了它就不需要 panel.entry,扩展可以只有这个分区,没有侧栏图标。
  • title 替换分区标题中的面板名称,最多 60 个字符。
  • height 是初始高度(像素),24 到 320,默认 120。内容尺寸变化时调用 host.setHeight()。

你的页面看到的 ctx.surface 是 status。只有在 Work Status 面板可见且你的分区展开时,OpenChamber 才会加载它;用户折叠分区时会卸载,所以不要在里面保存无法重建的状态。只有分区的扩展可以使用权限、服务、集成和文件,但不能用 page、attach、actions 或 commands。仅限 Web 和桌面版。示例 git-graph-status 就是一个完整的分区。

为你的文件类型提供编辑器

contributes.fileEditors 让文件在你的页面中打开,而不是在文本编辑器中。用户在 Files 中打开匹配的文件时,OpenChamber 会在那里显示你的编辑器,并带有和内置查看器相同的源码切换、全屏按钮和保存按钮。

"fileEditors": [
{ "id": "canvas", "title": "Excalidraw", "match": ["*.excalidraw", "*.excalidraw.md"], "entry": "editor/index.html" }
]
  • match 只比较文件名,不区分大小写。* 匹配任意长度的字符,? 匹配单个字符。模式不能包含 /,也不能只由通配符组成。
  • entry 是你文件夹里的 HTML 页面。不需要 panel.entry,扩展可以只有这个编辑器,没有侧栏图标。
  • content 为 "text"(默认)或 "binary",见下文。
  • 每个扩展最多 8 个编辑器,每个编辑器最多 16 个模式。

文件由 OpenChamber 管理。你的页面从不读写它,也不需要为此申请任何权限。OpenChamber 把文本交给你,保存时向你要编辑后的文本,并处理 Cmd/Ctrl+S、自动保存、未保存更改提示、换行符以及磁盘上的更改:

import { connectHost, createFileSaveTracker } from "@openchamber/sdk";
const host = connectHost();
let tracker = createFileSaveTracker(null);
host.onFileOpen((file) => {
if (file.encoding !== "text") return; // 文本编辑器拿到的总是文本
load(file.content); // 还有 file.path、file.name、file.readOnly
tracker = createFileSaveTracker(versionOf(currentText()));
});
// 每次编辑后调用。`edited` 会推迟自动保存,直到用户停下来。
const changed = () => host.reportFileChange(tracker.observe(versionOf(currentText())));
host.onFileSnapshot(() => ({ content: currentText(), version: versionOf(currentText()) }));
// OpenChamber 写入期间做出的编辑会让文件保持未保存状态。
host.onFileSaved((version) => host.reportFileChange({ dirty: tracker.markSaved(version), edited: false }));

version 是任意一个随文档变化而变化的短字符串,例如哈希值。如果某个文件不是你的编辑器能打开的,调用 host.reportFileUnsupported(),OpenChamber 会显示它的源码并附带提示。超过 20 MB 的文件保持在源码视图中。

二进制文件

设置 "content": "binary" 即可打开非文本文件,例如电子表格、文档或图片。你的编辑器拿到文件的字节,再交回字节;其余一切照常。

host.onFileOpen((file) => {
if (file.encoding !== "binary") return;
open(file.bytes); // 一个 Uint8Array,与磁盘上完全一致
});
host.onFileSnapshot(() => ({ bytes: currentBytes(), version: versionOf(currentBytes()) }));

二进制编辑器可以接管任何与其模式匹配的文件,包括 OpenChamber 自己会预览的文件,比如图片和 PDF。它没有源码视图;当用户在有未保存更改时将它切换到全屏或退出全屏,OpenChamber 会先保存这些更改。读写该格式由你的编辑器负责:OpenChamber 会原样写回你返回的字节。

你的页面看到的 ctx.surface 是 file。当文件在磁盘上发生变化、用户放弃更改,或编辑器进入或退出全屏时,OpenChamber 会重新加载页面并再次交给它文件。第一个模式匹配且已启用的扩展胜出,优先于 OpenChamber 自带的预览。只有编辑器的扩展可以使用权限、服务、集成和文件,但不能用 page、attach、actions 或 commands。仅限 Web 和桌面版。示例 checklist-editor 就是一个完整的编辑器。

操作、命令和徽标

除了侧栏和 + 菜单,扩展还可以出现在另外三个地方。它们都以和点击标签相同的方式把 item 交给扩展。

消息和会话上的操作。 声明菜单项,OpenChamber 会把它们显示在内置项旁边:

"actions": [
{ "id": "create-task", "label": "Create task", "icon": "add-line", "where": "message", "roles": ["assistant"] },
{ "id": "summarize", "label": "Summarize session", "where": "session", "payload": ["messages"] }
]

where 还可以取 selection(消息中选中的文本)、file(Files 中的一行)和 diff(文件或 diff 中选中的行)。它们的条目带有同名的 kind:selection 含文本,file 含路径,diff 含路径和行范围。在选中内容上,display 决定操作的显示方式:label(默认)、以 label 文本作为提示的 icon,或 both。

消息操作会以 kind: "message" 的 ctx.item 打开你的扩展:会话 id 和标题、项目目录、消息 id、它的角色和文本。会话操作则以 kind: "session" 打开,附带会话 id、标题和目录。加上 "payload": ["messages"],条目还会携带整段对话,从最早的消息开始,与 Markdown 导出产生的文本相同。这需要 conversation 能力,用户会在批准对话框中看到它单独占一行。item.action 告诉你点击的是哪一项。

用于附加的斜杠命令。 声明一个命令并在页面里处理它:

"commands": [{ "name": "task", "description": "Attach a task by id" }]
host.onResolve(async ({ command, args }) => {
const task = await findTask(args.trim());
return task ? { providerId: "acme-tasks", id: task.id, title: task.title, url: task.url } : null;
});

当用户在聊天框中输入 /task ABC-12 时,OpenChamber 会请你的扩展解析它,并把你返回的内容作为标签附加上去,不打开任何东西。找不到时返回 null。如果你的面板是关闭的,OpenChamber 会为这次调用在后台加载它。OpenChamber 或 OpenCode 已经使用的名称会被忽略。

侧栏图标上的徽标。 host.setBadge(3) 在你的图标上显示一个圆点,并在提示中显示数字,比如未完成任务数;host.setBadge(null) 清除它。打开面板也会清除它。

你的工具在聊天中的样子

当你的 OpenCode 插件或 MCP 服务器添加一个工具时,聊天会用通用图标和原始输出来显示它的调用。改为声明它们应该长什么样,不需要写代码:

"tools": [
{
"match": "mcp.jira.*",
"name": "Jira",
"icon": "task-line",
"title": "{input.key}",
"subtitle": "{output.status}",
"output": "table",
"columns": ["key", "summary", "status"]
}
]

match 是 OpenCode 报告的工具名;末尾的 * 匹配前缀。icon 是 Remixicon 图标名或你包内的 .svg 文件,和 panel.icon 一样。title 和 subtitle 是基于这次调用的 input、output 和 metadata 的模板。output 决定正文怎么渲染:text、json(树形)、markdown、带 language 的 code、带 columns 的 table(行来自输出数组或 output.items),或者 auto 使用默认方式。精确的 match 优先于通配符,平局时先安装的扩展胜出。不需要任何权限:这只改变聊天里已有数据的绘制方式。

只给工具定制样式的扩展根本不需要页面:不写 panel.entry,它就没有侧栏图标,只在 设置 → 扩展 里有一张卡片。没有页面时,它可以只声明 tools,别的什么都不写。另一个例外是带自己 entry 的 Work Status 分区或文件编辑器,见上文。

面板里的前几行代码

import { connectHost } from "@openchamber/sdk";
const host = connectHost();
host.onReady((ctx) => {
document.body.dataset.theme = ctx.theme.mode;
document.body.dataset.surface = ctx.surface;
});

connectHost 只在 OpenChamber 内部可用。作为普通文件打开时,每次调用都会以 HOST_UNAVAILABLE 拒绝。

先用 @openchamber/sdk/ui 提供的构件搭建面板:按钮、输入框、下拉框、标签页、列表等,都采用应用的颜色和字体,让面板像是 OpenChamber 的一部分。只为套件没有的东西自己编写 HTML 和 CSS。见 UI 套件。

相关页面

  • 扩展介绍 SSH 安装和服务器 Git 身份选择
  • Host API:每个 connectHost 方法、其限制和错误码
  • UI 套件:构件
  • 示例:一个可直接复制的完整三文件扩展