Communitygithub.com

创建画布应用

一款用于AI编码代理(包括GitHub Copilot、Claude和Codex)的跨代理技能插件,可通过 skills CLI(npx skills add)安装。包含用于构建 Copilot 画布扩展的 create-canvas-kit。

创建画布应用 是什么?

该技能提供了一种“开箱即用”的方式,用于构建 **Copilot 应用画布扩展**。它解决了手写画布常见的四个痛点:`innerHTML` 重绘循环导致按键丢失、状态仅存在于单个面板、忽略宿主主题的自定义样式以及不一致的图标。通过使用 Preact + htm 实现 DOM 差异更新,它保证了在代理实时推送状态更新时,不会破坏输入框的焦点、光标位置或用户正在输入的内容。该技能支持静态 HTML 和 Preact 两种渲染层级,并与 GitHub Copilot、Claude 和 Codex 等多种 AI 编码代理兼容。它通过 GitHub Lucide 图标集和 Primer 主题令牌提供一致的用户体验,并支持通过服务器发送事件 (SSE) 进行实时更新,以及安全的网络数据获取。

兼容平台✓Claude Code✓Codex CLI~Cursor
npx skills add jongio/skills

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

创建画布应用 是做什么的?

一个“开箱即用”的方式,用于构建 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 列表;它们从网络拉取并刷新。结构如下:

  1. 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)。

  2. 暴露一个 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) };
        }
      },
    },
    
  3. 使用工具包进行轮询,而不是原始的 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 或构建步骤。

构建画布——最快路径

  1. 生成它(一个可用的画布,工具包已嵌套,包含冒烟测试):

    node scripts/new-canvas.mjs <name>
    

    这会生成 web/app.mjs、canvas.mjs 和一个通过运行 node test/smoke.test.mjs 验证的冒烟测试。

  2. 选择模板:

    node scripts/new-canvas.mjs kanban --template data   # 网络数据 + 轮询
    node scripts/new-canvas.mjs tracker --template crud  # 用户输入 CRUD
    

    省略 --template 会创建一个裸的最小画布(test/smoke.test.mjs 仍然会运行)。

  3. 在画布目录中运行冒烟测试:

    cd canvases/<name> && node test/smoke.test.mjs
    

    该测试启动服务器,验证 GET /state 和 POST /action 是否工作,并检查是否没有未处理的拒绝。

  4. 将扩展注册到 Copilot(一次):

    copilot extensions add ./canvases/<name>
    copilot extensions list           # 确认它已列出
    
  5. 在聊天中测试:

    "创建一个名为 <domain> 的 <name> 画布"
    

测试——针对每个画布

每个生成的画布包含 test/smoke.test.mjs,它:

  • 启动服务器(使用 canvas.mjs 作为源)
  • 发送 GET /state 并验证响应为 200
  • 发送 POST /action 并验证响应为 200
  • 检查是否有未处理的异常

要在你的编辑器中运行它:node test/smoke.test.mjs。

你也可以从仓库根目录运行所有画布的冒烟测试:npm run test:smoke。

相关技能