Communitygithub.com

jongio/skills

適用於 AI 程式碼輔助代理(GitHub Copilot、Claude 和 Codex)的跨代理技能外掛,可透過技能 CLI(npx skills add)安裝,內含用於建構 Copilot 畫布延伸功能的 create-canvas-kit。

skills 是什麼?

jongio/skills 是一個技能集合,讓開發者能為 GitHub Copilot、Claude 和 Codex 等多個 AI 編碼代理建立可重複使用的功能。其核心是 **create-canvas-kit**,這是一個用於建構 Copilot 應用程式畫布延伸功能的完整套件。此工具包解決了自訂畫布常見的問題,例如使用 innerHTML 更新時會遺失鍵盤輸入、狀態無法跨面板共享、樣式不符合主機主題以及圖示不一致等。 套件採用用戶端/伺服器架構,結合 Preact、htm 和 DOM diffing(差異比對)技術,確保使用者輸入不會被代理的狀態更新干擾。狀態在所有開啟相同「領域」的面板之間持久化且共享,代理和使用者觸發的動作都透過相同的處理器執行,確保一致性。伺服器端邏輯不依賴 Copilot SDK,因此可以獨立於代理環境進行測試。 此工具包包含數個實用功能:完整的 Lucide 圖示庫、基於 GitHub Primer 設計語言的 CSS 主題(附帶備用方案)、用於格式化數字和日期的輔助函式(支援在地化),以及一個可用於輪詢外部資料來源,同時避免背景面板耗用網路資源的「可見時輪詢」(poll-while-visible) 機制。安全性方面亦有考量,伺服器端的 fetch 請求會經過 SSRF(伺服器端請求偽造)檢查,以防止存取內部或私有網路位址。 總體而言,create-canvas-kit 為在 Copilot 內部建置互動式、資料驅動的面板(如儀表板、編輯器和看板)提供了結構化、可測試且高效的路徑。對於需要在開發者工作流程中建置超越純文字對話的互動體驗的 AI 代理使用者而言,這項技能特別有用。

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

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

建立畫布應用程式

一個「電池隨附」的建置 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 列表;它們從網路拉取資料並重新整理。形狀如下:

  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());
    }
    

    fetch 操作在伺服器端的環回(loopback)執行環境上執行,因此產生的 data 範本使用一個 SSRF 檢查 來保護它:它只允許對公開主機進行 http/https 連接,並拒絕環回、連結本地(包括雲端 metadata)和私有範圍 — 包括主機名稱解析到的每個位址。你回饋給代理的任何內容(例如透過 list_items)仍然是受攻擊者影響的文字,因此請確保來源是你選擇的,並將 fetch 到的內容視為不受信任的(將其渲染為文字,永遠不要使用 innerHTML)。

  2. 暴露一個 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) };
        }
      },
    },
    
  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"),並將輸入 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 或建置步驟。

建立一個畫布 — 最快路徑

  1. 蓋章產生它(一個可工作的畫布,工具包已巢狀包含,包含一個冒煙測試):
    node scripts/new-ca
    

…(原文截斷以利翻譯)

相關技能