建立畫布應用程式
一個「電池隨附」的建置 Copilot 應用程式畫布延伸功能 的方式。它的存在是因為手刻的畫布總是遇到相同的瓶頸:一個會吃掉按鍵輸入的 innerHTML 重繪迴圈、只存在於單一面板的狀態、忽略主機主題的臨時樣式,以及不一致的圖示。此工具包解決了這四個問題。
什麼是畫布(以及何時該建置一個)
畫布是一個透過 open_canvas 在側邊面板中開啟的互動式表面。代理和使用者都是透過相同的動作處理器對相同的狀態進行操作。當聊天文字或差異比對(diff)不夠用時,可以使用畫布:例如即時儀表板、編輯器、試算表、追蹤器、看板/面板視圖、文件預覽、特定工具的工作流程。
如果使用者只需要一個非視覺化的代理工具,請改為建立一般的延伸功能工具,而不是畫布。
渲染層級 — 先決定
| 層級 | 使用時機 | 做法 |
|---|---|---|
| 靜態 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 視圖
- 狀態是共享且持久的。 它的鍵值(key)是從開啟輸入中解析出的領域 id (
resolveDomainId),而不是instanceId— 在兩個面板中開啟同一個領域,它們會顯示相同的資料。持久化透過userStore(extName, file)→$COPILOT_HOME/extensions/<name>/artifacts/<domain>.json進行。 - 代理和使用者共享處理器。 由代理調用和由按鈕調用的相同動作,會執行完全相同的處理器並產生完全相同的狀態變異。只需在
canvas.mjs中編寫一次邏輯。 - 即時更新是自動的。 工具包提供
GET /state、GET /events(SSE) 和POST /action。mountCanvas將它們連接起來;每次狀態變更都會分發到所有已開啟的面板。 server.mjs是無 SDK 的,因此整個執行環境可以僅使用普通的 Node HTTP 進行測試(參閱test/http.test.mjs)。僅在extension.mjs中保留 SDK 呼叫。
視圖中的兩種狀態 — 將它們分開
- 共享領域狀態 透過 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} />`
合約:名稱使用短橫線命名(kebab-case) (circle-check、trash-2、list-todo)。viewBox 固定為 0 0 24 24,stroke-width 為 2 — 絕不傳遞 strokeWidth。使用尺寸 12 · 14 · 16 · 20 · 24 · 32。當有明顯的對應關係時,比照 github-app 自己的字形選擇(例如 open=circle-dot、done=circle-check、bookmark=bookmark)。如果需要驗證,可以使用 iconNames() / hasIcon(name)。
主題 — Primer tokens,附帶備用方案
連結 /kit/theme.css 並建立在其 ck-* 類別之上(ck-card、ck-btn、ck-btn-primary、ck-input、ck-badge、ck-tabs、ck-status、ck-empty……)。它們對應到執行環境注入到畫布文件中的 Primer tokens,每個都有一個硬編碼的 GitHub 深色模式備用方案,因此即使缺少某個 token,畫布看起來仍然是對的。在其上添加畫布專用的 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— 用於失敗的 fetch 操作的內嵌通知/錯誤表面,例如html\<${Icon} name="circle-x" size=${16} />${state.error}``。
動畫安全是全球性的。 theme.css 已經包含一個 @media (prefers-reduced-motion: reduce) 保護,可以中和所有動畫和過渡效果(工具包 spinner/skeleton 以及你添加的任何動畫)。你不需要為每個畫布重複這項操作 — 但請確保動畫僅用於裝飾,絕不能是唯一的信號。
視圖 API — Hooks、輔助函式、Fragment
/kit/client.mjs 重新導出了完整的銷售版 Preact hook 集,而不僅僅是 useState:useEffect、useRef、useMemo、useCallback、useReducer、useContext、useLayoutEffect、useImperativeHandle。在元件的頂端無條件地呼叫 hooks — 在任何提前 return(例如 if (!state) return … 載入分支)之前。
它還導出了格式化 + id 輔助函式,這樣你就不用重新發明它們了:
import { nid, relativeTime, compactNumber, percent } from "/kit/client.mjs";
nid(); // "lq3k9f2a" — 短唯一 id
relativeTime(iso); // "5 分鐘前" / "3 小時前" / "2 天前" / 一個日期
compactNumber(2_300_000); // "230 萬"
percent(1.25); // "+1.25%"
nid 也可以在伺服器端的 canvas.mjs 中透過 import { nid } from "./canvas-kit/format.mjs" 匯入(產生器會這樣做)。
格式化函式是感知在地語系的 — relativeTime 結尾是一個 toLocaleDateString(),而 compactNumber/percent 則透過 Number.prototype.toLocaleString,因此它們的輸出取決於主機的在地語系設定("1.5K" vs "1,5 K"、2025-01-31 vs 31/01/2025)。將原始值保存在持久狀態中(一個 ISO 字串、一個 Number),並在視圖中進行格式化。永遠不要儲存格式化後的字串,也永遠不要解析回一個 — 格式化是呈現層級的關注點,而在一個在地語系下格式化的值無法在另一種語系下可靠地重新讀取。
Fragment 沒有被匯出。 要回傳兄弟節點而不使用包裝器,請使用 htm 內建的空標籤語法 — html`<><${A} /><${B} /></>` — 或者直接回傳一個 vnode 陣列。不要 import { Fragment };它不存在。
外部資料畫布 — fetch + 自動重新整理
大多數實用的畫布不是使用者輸入的 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()); }fetch 操作在伺服器端的環回(loopback)執行環境上執行,因此產生的
data範本使用一個 SSRF 檢查 來保護它:它只允許對公開主機進行http/https連接,並拒絕環回、連結本地(包括雲端 metadata)和私有範圍 — 包括主機名稱解析到的每個位址。你回饋給代理的任何內容(例如透過list_items)仍然是受攻擊者影響的文字,因此請確保來源是你選擇的,並將 fetch 到的內容視為不受信任的(將其渲染為文字,永遠不要使用innerHTML)。 -
暴露一個
refresh動作,該動作會 fetch 資料並將結果寫入共享狀態。將失敗捕獲到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"),並將輸入 schema 屬性命名為profile。將其用於不應跨主題洩漏的每個學習者/每個使用者的資料。
明智地選擇名詞(domain vs profile)— 這是關於誰共享什麼的完整合約。
模式與限制(了解這些)
- 捆綁目錄。 對於策劃的種子資料(課程、預設),提供一個單獨的
catalog.mjs:純資料加上一個buildCourse(...)風格的建構器,該建構器分配確定性的位置 id (es-u1-l2),這樣同一個目錄總是產生相同的 id。使用簡潔的建構器輔助函式來保持大量資料的精簡。 statusLine在開啟時計算一次。 面板的狀態字串在openInstance上設定,並且當動作變異狀態時不會重新推送,因此statusLine中的計數會過時。在畫布主體內部顯示即時計數(它會在每次 SSE 推送時重新渲染),並將statusLine視為僅在開啟時的摘要。- 豐富負載的動作。 動作的
inputSchema預設有意地將additionalProperties設為false:schema 是代理填寫的合約,一個嚴格的 schema 可以防止模型偷渡未驗證/拼錯的欄位 — 只有你宣告的屬性才會到達你的處理器。當 UI 確實需要將一個完整的物件(例如正在儲存的文章)而不是純量欄位傳遞給處理器時,在該動作的inputSchema上設定additionalProperties: true,並在處理器中將你需要的內容反正規化為持久狀態。有意識地、每個動作地切換它 — 而不是作為一個全域預設值。 - 安裝佈局 / SDK 解析。 一個已出貨的延伸功能資料夾僅包含
copilot-extension.json({ "name", "version": 1 }— 沒有package.json)、extension.mjs、canvas.mjs、web/和巢狀的canvas-kit/。此工具包是 Node 可以直接執行的純 ESM — 在延伸功能中沒有打包工具、沒有轉譯、沒有建置步驟、沒有package.json(蓋章的test/smoke.test.mjs使用純粹的node執行)。主機為你解析@github/copilot-sdk/extension;extension.mjs是唯一匯入它的檔案。不要為已出貨的畫布添加package.json或建置步驟。
建立一個畫布 — 最快路徑
- 蓋章產生它(一個可工作的畫布,工具包已巢狀包含,包含一個冒煙測試):
node scripts/new-ca
…(原文截斷以利翻譯)