创建画布应用 是做什么的?
一个“开箱即用”的方式,用于构建 Copilot 应用画布扩展。之所以需要它,是因为手写画布总会遇到同样的问题:innerHTML 重绘循环会吞噬按键,状态仅存在于一个面板中,临时样式忽略宿主主题,以及不一致的图标。此工具包修复了这四个问题。
什么是画布(以及何时构建一个)
画布是一个交互式界面,代理通过 open_canvas 在侧面板中打开。代理和用户都通过相同的操作处理器作用于相同的状态。当聊天文本或差异视图不足以满足需求时,使用画布:实时仪表板、编辑器、电子表格、追踪器、看板视图、文档预览、特定工具的工作流。
如果用户只需要一个非可视化的代理工具,则应构建一个正常的扩展工具——而不是画布。
渲染层级——首先决定
| 层级 | 使用场景 | 方式 |
|---|---|---|
| 静态 HTML 字符串 | 只读或近乎静态的内容;无需保护输入 | 运行时的原始脚手架 (extensions_manage scaffold kind:canvas)。 |
| Preact + htm + 本工具包 | 任何交互性内容:输入、实时更新、列表、表单、共享状态 | 本工具包。真实画布的默认选择。 |
使用本工具包最重要的一个原因是:Preact 对 DOM 进行差异更新,因此代理的实时状态推送不会破坏输入框的焦点、光标位置或用户正在输入的内容。大多数早期画布使用的 innerHTML = ... 模式会重绘整个树,并在每次推送时丢失按键。不要那样做。
模型(编码前阅读)
extension.mjs ── 唯一导入 Copilot SDK 的文件(薄适配器)
canvas.mjs ── 你的画布:id、schema、状态加载/保存、操作处理器(无 SDK)
canvas-kit/ ── 工具包(原样复制;请勿编辑)
web/index.html ── 外壳:加载 /kit/theme.css 和 ./app.mjs
web/app.mjs ── 你的 Preact 视图
- 状态是共享且持久的。 它由从开放输入中解析出的 domain id(
resolveDomainId)作为键,而不是instanceId——在两个面板中打开同一个域名,它们会显示相同的数据。持久化通过userStore(extName, file)实现 →$COPILOT_HOME/extensions/<name>/artifacts/<domain>.json。 - 代理和 UI 共享处理器。 由代理调用的操作与从按钮调用的相同操作,运行相同的处理器并产生相同的状态突变。在
canvas.mjs中编写一次逻辑。 - 实时更新是自动的。 工具包提供
GET /state、GET /events(SSE) 和POST /action。mountCanvas将它们连接起来;每次状态变更都会分发到所有打开的面板。 server.mjs无 SDK,因此整个运行时可以使用纯 Node HTTP 进行测试(参见test/http.test.mjs)。仅将 SDK 调用保留在extension.mjs中。
视图中的两种状态——保持分离
- 共享域名状态 通过 SSE 到达,并在每次推送时重新渲染。在视图中将其视为只读;仅通过调用
invoke(action, input)来更改它。 - 本地 UI 状态(草稿输入值、活动标签页/筛选器)存在于
useState中,并且在用户提交之前绝不能推送到服务器。
操作结果与错误——失败如何显现
两个调用方(代理和 UI)都访问同一个处理器,并且运行时将每个调用包装在同一个信封中,因此要有意地模拟故障:
- 返回一个小的普通对象表示成功(
{ id, status }、{ count })。POST /action响应{ ok: true, result }。 - 抛出表示硬性失败(错误输入、缺少 id)。运行时响应
{ ok: false, code, message }——对于已知的工具包错误(例如未知操作)返回 HTTP 400,对于处理器throw返回 500——并且mountCanvas的invoke会拒绝并携带一个包含该code/message的CanvasActionError,因此按钮处理器可以try/catch它。在代理端,extension.mjs将相同的错误映射到CanvasError。抛出错误会被展现,而不会崩溃。 - 对于预期中的、临时性的失败(后台轮询中上游获取不稳定),不要抛出——将其捕获到
state.error中并返回,这样在轮询保持安静时(参见数据模板的refresh)会显示错误卡片。
图标——始终使用官方 GitHub Lucide
该工具包提供了 github-app 所使用的精确 Lucide 集([email protected],字节相同)。对每个图标都使用它;不要手写 SVG 或从 CDN 拉取。
import { Icon } from "/kit/client.mjs";
html`<\${Icon} name="circle-check" size=\${16} />`
约定:名称是短横线命名(circle-check、trash-2、list-todo)。viewBox 固定为 0 0 24 24,stroke-width 为 2——永远不要传递 strokeWidth。使用尺寸 12 · 14 · 16 · 20 · 24 · 32。当存在明显的映射关系时(例如 open=circle-dot、done=circle-check、bookmark=bookmark),匹配 github-app 自身的字形选择。如果需要验证,可以使用 iconNames() / hasIcon(name)。
主题——Primer 令牌,带后备
链接 /kit/theme.css 并构建在其 ck-* 类之上(ck-card、ck-btn、ck-btn-primary、ck-input、ck-badge、ck-tabs、ck-status、ck-empty……)。它们映射到运行时注入到画布文档中的 Primer 令牌,每个令牌都带有硬编码的 GitHub 深色后备,因此即使缺少令牌,画布也能正确显示。在此基础上添加画布特定的 CSS;不要重新设置原语的样式。
徽章是通用的。 使用语义变体 ck-badge-success、ck-badge-accent、ck-badge-muted、ck-badge-danger、ck-badge-attention,并将你的状态映射到视图中的它们(参考做法是 open → success, decided → accent, parked → muted)。不要将应用特定的状态类名添加到共享主题中。
加载与错误原语(不要手写这些):
ck-spinner——旋转动画;与 Lucide 加载器配对,例如html\<\${Icon} name="loader-circle" class="ck-spinner" />``。ck-skeleton——用于仍在加载的内容的闪烁占位块。ck-callout/ck-callout ck-error——用于失败的获取操作的内联通知/错误界面,例如html\<\${Icon} name="circle-x" size=\${16} />\${state.error}``。
运动安全是全局性的。 theme.css 已经内置了一个 @media (prefers-reduced-motion: reduce) 守卫,它会禁用所有动画和过渡(工具包的旋转器和骨架动画以及你添加的任何动画)。你不需要在每个画布中重复它——但要确保运动是装饰性的,绝不是唯一的信号。
视图 API——Hooks、辅助函数、片段
/kit/client.mjs 重新导出完整的预置 Preact hook 集,不仅仅是 useState:useEffect、useRef、useMemo、useCallback、useReducer、useContext、useLayoutEffect、useImperativeHandle。在组件顶部无条件地调用 hooks——在任何早期的 return 之前(例如,if (!state) return … 加载分支应放在 hooks 之后)。
它还导出格式化和 ID 辅助函数,这样你就不用再重复发明轮子了:
import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid(); // "lq3k9f2a" — 短唯一 ID
relativeTime(iso); // "5m ago" / "3h ago" / "2d ago" / 一个日期
compactNumber(2_300_000); // "2.3M"
percent(1.25); // "+1.25%"
nid 也可以在服务器端的 canvas.mjs 中通过 import { nid } from "./canvas-kit/format.mjs" 导入(生成器会这样做)。
格式化器是区域感知的——relativeTime 以 toLocaleDateString() 结尾,而 compactNumber/percent 通过 Number.prototype.toLocaleString 实现,因此它们的输出取决于宿主区域("1.5K" 对比 "1,5 K",2025-01-31 对比 31/01/2025)。将原始值保存在持久状态中(一个 ISO 字符串、一个 Number),并在视图中进行格式化。永远不要存储格式化的字符串,也永远不要解析回——格式化是表现层的问题,在一个区域下格式化的值无法在另一个区域下可靠地重新读取。
Fragment 未导出。 要返回没有包装器的兄弟节点,请使用 htm 内置的空标签语法——html`<><\${A} /><\${B} /></>`——或者直接返回一个 vnode 数组。不要 import { Fragment };它不存在。
外部数据画布——获取 + 自动刷新
大多数真实画布不是用户输入的 CRUD 列表;它们从网络拉取并刷新。结构如下:
-
fetch()存在于操作处理器(或其调用的辅助函数)中,绝不在视图中。 始终为其绑定超时,这样缓慢的上游服务就不会卡住面板:async function fetchItems(url) { const res = await fetch(url, { signal: AbortSignal.timeout(12000) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return mapItems(await res.json()); }获取操作在回环运行时上服务器端运行,因此生成的
data模板会使用 SSRF 检查来保护它:它只允许对公共主机的http/https,并拒绝回环、链路本地(包括云元数据)和私有范围——包括主机名解析到的每个地址。你反馈给代理的任何内容(例如通过list_items)仍然是受攻击者影响的文本,因此请确保来源是你选择的,并将获取的内容视为不受信任的(以文本形式渲染,切勿使用innerHTML)。 -
暴露一个
refresh操作,该操作获取结果并将其写入共享状态。将失败捕获到state.error中并返回(不要抛出),这样后台轮询在显示错误卡片时保持安静。在await之前捕获输入,并使用函数式set写回,这样并发的操作(另一个轮询、一个set_source)就不会被覆盖:refresh: { inputSchema: { type: "object", properties: {}, additionalProperties: false }, handler: async ({ state, set }) => { const sourceUrl = state.sourceUrl; // 在 await 之前捕获 try { const items = await fetchItems(sourceUrl); set((cur) => cur.sourceUrl !== sourceUrl // 丢弃过时的响应 ? cur : { ...cur, items, error: null, lastRefresh: new Date().toISOString() }); return { count: items.length }; } catch (err) { set((cur) => ({ ...cur, error: `无法加载: ${err.message}`, lastRefresh: new Date().toISOString() })); return { ok: false, error: String(err.message) }; } }, }, -
使用工具包进行轮询,而不是原始的
setInterval。pollWhileVisible(tick, seconds)仅在面板可见时按间隔运行tick(因此后台画布停止访问网络),并返回一个清理函数——可直接放入useEffect的返回值中:import { pollWhileVisible } from "/kit/client.mjs"; // 间隔绑定到持久状态;在它改变时重建: useEffect(() => pollWhileVisible(() => invoke("refresh"), state.autoRefreshSec || 0), [state.autoRefreshSec]);对于固定间隔,你可以完全跳过 effect:
mountCanvas({ view, poll: { action: "refresh", seconds: 60, immediate: true } })。
使用 node scripts/new-canvas.mjs <name> --template data 来生成此整个结构。
域名策略——共享面板 vs 个人资料
状态由 resolveDomainId 返回的域名 ID 作为键。两个意图,相同的机制(fileFor 将 ID 清理为文件名——原样复制):
- 共享面板(大多数画布):以代理和用户共同命名的主题为键。
resolveDomainId: (input) => (input?.domain ? String(input.domain) : "default")。任何打开domain: "tech"的人都会看到相同的面板。 - 个人存储:以身份为键,而不是主题。
resolveDomainId: (input) => (input?.profile ? String(input.profile) : "default"),输入模式属性命名为profile。用于不应跨主题共享的每个学习者/每个用户的数据。
有意识地选择名词(domain 对比 profile)——这是关于谁分享什么内容的整个契约。
模式与限制(了解这些)
- 捆绑目录。 对于策划的种子数据(课程、预设),请单独提供一个
catalog.mjs:纯数据加上一个buildCourse(...)风格的构建器,该构建器分配确定性的位置 ID(es-u1-l2),这样相同的目录总是产生相同的 ID。使用微小的构造函数辅助函数保持批量数据简洁。 statusLine在打开时计算一次。 面板的状态字符串是在openInstance上设置的,并且当操作改变状态时不会重新推送,因此statusLine中的计数会过时。在画布主体内部显示实时计数(它会在每次 SSE 推送时重新渲染),并将statusLine视为仅在打开时使用的摘要。- 富负载操作。 操作
inputSchema默认有意设置additionalProperties: false:模式是代理填充的契约,严格的模式可以防止模型携带未验证/拼写错误的字段——只有你声明的属性会到达你的处理器。当 UI 确实需要将整个对象(例如正在保存的文章)而不是标量字段传递给处理器时,在那个操作的inputSchema上设置additionalProperties: true,并在处理器中将要的内容反规范化为持久状态。有意识地,在每个操作的基础上切换它——而不是作为全局默认设置。 - 安装布局 / SDK 解析。 一个发布的扩展文件夹仅包含
copilot-extension.json({ "name", "version": 1 }——无package.json)、extension.mjs、canvas.mjs、web/和嵌套的canvas-kit/。该工具包是纯 ESM,Node 直接运行——扩展中没有捆绑器、无转译、无构建步骤、无package.json(盖章的test/smoke.test.mjs使用纯node运行)。宿主为你解析@github/copilot-sdk/extension;extension.mjs是唯一导入它的文件。不要向已发布的画布添加package.json或构建步骤。
构建画布——最快路径
-
生成它(一个可用的画布,工具包已嵌套,包含冒烟测试):
node scripts/new-canvas.mjs <name>这会生成
web/app.mjs、canvas.mjs和一个通过运行node test/smoke.test.mjs验证的冒烟测试。 -
选择模板:
node scripts/new-canvas.mjs kanban --template data # 网络数据 + 轮询 node scripts/new-canvas.mjs tracker --template crud # 用户输入 CRUD省略
--template会创建一个裸的最小画布(test/smoke.test.mjs仍然会运行)。 -
在画布目录中运行冒烟测试:
cd canvases/<name> && node test/smoke.test.mjs该测试启动服务器,验证
GET /state和POST /action是否工作,并检查是否没有未处理的拒绝。 -
将扩展注册到 Copilot(一次):
copilot extensions add ./canvases/<name> copilot extensions list # 确认它已列出 -
在聊天中测试:
"创建一个名为 <domain> 的 <name> 画布"
测试——针对每个画布
每个生成的画布包含 test/smoke.test.mjs,它:
- 启动服务器(使用
canvas.mjs作为源) - 发送
GET /state并验证响应为200 - 发送
POST /action并验证响应为200 - 检查是否有未处理的异常
要在你的编辑器中运行它:node test/smoke.test.mjs。
你也可以从仓库根目录运行所有画布的冒烟测试:npm run test:smoke。