ReadAware

外掛 API 參考

外掛是一個資料夾,裡面有一份 manifest.json 和一個 JavaScript 模組。本頁就是編寫契約;同一份契約以 TypeScript 宣告檔案(types/plugin-api.d.ts)的形式隨外掛市集儲存庫一起發佈,編輯器可以對下文的一切自動補全。

結構

my-plugin/
  manifest.json
  main.js        # 單個自包含的 ES module

main.js 預設匯出一個生命週期物件。外掛能觸及的一切都來自傳給 activate 的上下文;每個 register* on 呼叫都回傳一個 disposable,外掛被停用或解除安裝時由應用程式統一回收,因此 deactivate 只需釋放外掛自己的外部資源。

export default {
  activate(ctx) {
    // 透過 ctx 註冊貢獻點
  },
  deactivate() {
    // 可選:關閉通訊端、清空佇列
  },
};

啟用與停用立即生效——無需重新啟動應用程式。願意的話可以用 TypeScript 編寫(推薦;見發佈上架)——應用程式載入的始終是組建出的 main.js

manifest.json

{
  "id": "anki-sync",
  "name": "Anki Sync",
  "version": "0.1.0",
  "minAppVersion": "0.3.0",
  "description": "Send looked-up words to Anki.",
  "author": "you",
  "permissions": ["service:network", "annotations:read"],
  "main": "main.js"
}
欄位含義
id小寫字母、數字和連字號(最長 64)。必須與資料夾名稱一致;作為外掛儲存與工具的命名空間。
nameversion顯示在「設定 → 外掛」和外掛市集中。
minAppVersion外掛支援的最低應用程式版本。本契約要求 0.3.0 或更新的版本。
permissions外掛使用的能力(見下表)。會在安裝前展示給使用者。
main相對於外掛資料夾的入口模組;預設為 main.js
settings可選的宣告式設定(欄位形態與表單檢視相同,另有 secret)。應用程式會把它渲染成外掛自己的設定分類,並把所有值作為一個物件持久化在儲存鍵 settings 下——見 儲存與設定
schedules可選的週期任務,宣告在此以便使用者安裝前可見——見 排程任務
themesfonts可選的宣告式主題與自帶字型(需要 ui:themes 權限)——見主題與自帶字型

領域模型

資料表面衍生自應用程式的領域模型,而不是在它旁邊另行編寫。每個領域—— shelf(書庫管理的全部:書目、分組與閱讀統計)、annotationsconversations——都是 ctx 上的一個命名空間,暴露三樣東西:

  • 讀取——該領域的讀模型(應用程式自己的介面渲染的正是它們);
  • 寫入——.write 下的命令,與該領域的事件動詞嚴格一一對應,並走應用程式自己的事件溯源寫入路徑,在事件日誌中標記為 plugin:<id>,因此每一次外掛寫入都可追溯;
  • 訂閱——.on(event, handler),以規範名稱(book.starredhighlight.created……)訂閱該領域的事件——與應用程式自身記錄事實所用的是同一套詞彙。

權限遵循同樣的形態:<domain>:read / <domain>:write,且在一個領域內,寫入權限蘊含讀取權限。裝置本機狀態(檢視偏好、閱讀器外觀、同步內部資料)與自由渲染刻意不屬於外掛表面——UI 一律經由下文的宣告式檢視。

權限

沒有宣告對應權限時,ctx 上的能力組乾脆不存在——在 API 層面防範無意的越界。命名空間儲存、UI 貢獻點、工作階段事件和閱讀器導覽不是權限;每個外掛都擁有它們。

權限授予
shelf:readctx.shelf——書目(含一本書的目錄與章節文字)、分組與歸屬,以及閱讀統計(stats.forBook / stats.list / stats.overview——統計沒有寫入面:它的事件是閱讀器活動被記錄下來的事實,而非使用者命令)。
shelf:writectx.shelf.books.write——匯入檔案、編輯中繼資料、標星、標記讀完、移除;以及內容供應商與虛擬書籍。ctx.shelf.collections.write——建立、重新命名、移除、為書籍指派分組。
annotations:read / annotations:writectx.annotations——螢光標示、筆記與提問;建立、改色、編輯、刪除螢光標示與筆記(提問由助理寫入,唯讀)。
conversations:readctx.conversations——每本書的 AI 討論串與全域討論串(唯讀)。
ui:themesmanifest 中宣告式的 themes / fonts 欄位(見下文)——應用程式與閱讀頁主題,可附帶字型。它是唯一需要權限的 UI 貢獻點:它對整個應用程式有視覺影響力,安裝確認必須把它亮出來。
ui:appearancectx.appearance —— 列出兩個外觀面目前提供的全部主題、讀目前外觀、切換應用程式主題或閱讀頁配色。與 ui:themes 刻意分開:提供主題是被動的,切換主題不是。
agent:toolsctx.agent.registerTool——為閱讀助理註冊工具。
service:networkctx.network.fetch——對外的 HTTP 請求,走應用程式的原生用戶端(沒有 CORS 約束)。
service:llmctx.llm.ask——使用使用者設定的帳號發起一次性模型呼叫。沒有討論串、沒有記憶、沒有工具;支援以 schema 輸出結構化 JSON,或以 onText 串流接收文字。
service:clipboardctx.clipboard.writeText

reader:modes——宿主渲染的引導式閱讀模式——在這份特權契約穩定下來之前,暫時僅限隨應用程式內建的第一方外掛使用。)

貢獻點

選取動作

閱讀器選取選單與標註選單中的條目。處理函數會收到選取的文字、它的 CFI 範圍、所在章節和書籍;當閱讀器能夠恢復時,context 還帶有選取周圍的上下文段落。在閱讀器內,一個動作要麼靜默執行(回傳 toast),要麼開啟對話框(回傳檢視)——只有這兩種結果。 非同步動作宣告 presentation: "dialog" 後,宿主會立刻開啟 載入狀態對話框,並在 run 完成時把結果填入同一次請求。 字典類動作可以宣告 role: "lookup":宿主會把現有的 「查詢」鍵盤命令路由到該外掛動作,而不是維護第二條內建查詞路徑。

ctx.ui.registerSelectionAction({
  id: "save-quote",
  title: "Save quote",
  icon: "quotes",
  presentation: "dialog",
  run: (input) => {
    // input: { text, context?, cfiRange, chapterHref, book, source }
    return { toast: "Quote saved." };
  },
});

頂欄動作

頂欄上的一個圖示按鈕。在閱讀器介面,檢視以錨定的彈出層開啟;在書架上,則依 presentation 以彈出層或完整頁面開啟。閱讀器永遠不允許整頁打斷。

ctx.ui.registerHeaderAction({
  id: "reading-report",
  title: "Reading report",
  icon: "chart-line-up",
  surface: "shelf",
  presentation: "page",
  view: async () => ({
    kind: "markdown",
    title: "This week",
    markdown: "You read **4h 12m** across 3 books.",
  }),
});

命令

命令面板中的一個條目。所有外掛動作都會自動出現在面板裡;顯式命令用於那些沒有按鈕的動作。

ctx.ui.registerCommand({
  id: "sync-now",
  title: "Anki Sync: sync now",
  run: async () => ({ toast: "Synced." }),
});

助理工具

閱讀助理在對話中可以呼叫的工具(需要 agent:tools 權限)。parameters 是描述參數物件的普通 JSON Schema;無參數的工具可以省略。工具在送達模型之前會被命名空間化為 plugin_<pluginId>_<name>,呼叫過程會以工具步驟的形式在對話中對使用者可見。

ctx.agent?.registerTool({
  name: "search_deck",
  label: "Searching your Anki deck",
  description: "Search the user's Anki collection for a term.",
  parameters: {
    type: "object",
    properties: { query: { type: "string" } },
    required: ["query"],
  },
  execute: async ({ query }) => {
    const res = await ctx.network.fetch("http://127.0.0.1:8765", {
      method: "POST",
      body: JSON.stringify({ action: "findNotes", query }),
    });
    return res.json();
  },
});

朗讀聲音供應商

ctx.audio.registerVoiceProvider 把一個文字轉語音引擎接進閱讀頁的朗讀功能。外掛只負責把文字變成編碼後的音訊位元組(mp3/wav——webview 能解碼的都行);播放、逐句推進、預先擷取與跟讀標示全部由應用程式負責。註冊本身不需要權限——合成所需的能力(網路、金鑰)已由外掛自己的其他權限門控。

ctx.audio.registerVoiceProvider({
  id: "voices",
  label: "My TTS",
  listVoices: () => [{ id: "default", label: "My TTS · warm" }],
  synthesize: async ({ text, voiceId }) => {
    const res = await ctx.network.fetch("http://127.0.0.1:8880/v1/audio/speech", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ input: text, response_format: "mp3" }),
    });
    return res.arrayBuffer();
  },
});

註冊的聲音會被自動採用——使用者啟用你的外掛即是選擇,宿主不再另設選擇器;某一句合成失敗時會退回系統語音,朗讀只會降級、不會中斷。外掛設定變化時會重新列舉聲音。

排程任務

manifest 負責宣告週期任務,activate 負責繫結實際工作。應用程式在開啟期間至少每 everyMinutes 分鐘(下限 15)執行一次,逾期未跑的會在啟動後補一次——從不承諾精確時刻,應用程式關閉時也不會執行。同一任務的重疊執行會被跳過;失敗的一次只需等待下個週期。

// manifest.json
"schedules": [{ "id": "refresh", "label": "Refresh feeds", "everyMinutes": 60 }]

// main.js
ctx.schedule.on("refresh", async () => {
  // 擷取、比對,經由領域 API 寫回
});

主題與自帶字型

宣告 ui:themes 後,manifest 可以為兩個相互獨立的掛載點——應用程式介面與書頁——宣告主題,並附帶隨外掛資料夾分發的字型檔案。這類貢獻是純資料:應用程式校驗每一個值並自行產生全部 CSS,且在使用者於「設定 → 外觀」或閱讀器的頁面顏色控制項裡選取之前,什麼都不會生效。純主題外掛的 main.js 只需 export default { activate() {} }

{
  "permissions": ["ui:themes"],
  "fonts": [
    {
      "id": "my-serif",
      "family": "My Serif",
      "kind": "serif",
      "files": [{ "path": "assets/my-serif-400.woff2", "weight": 400 }]
    }
  ],
  "themes": [
    {
      "id": "dusk",
      "name": { "default": "Dusk", "translations": { "zh-Hans": "暮色" } },
      "polarity": "dark",
      "app": { "paper": "#14171e", "fg": "#e3e6ec" },
      "reader": {
        "palette": {
          "bg": "#161a22", "text": "#ccd2dd",
          "selection": "rgba(154, 162, 177, 0.28)",
          "rule": "rgba(204, 210, 221, 0.18)",
          "faint": "rgba(204, 210, 221, 0.07)",
          "muted": "rgba(204, 210, 221, 0.55)"
        },
        "typography": { "fontFamily": "plugin:my-serif", "fontSize": "large" }
      }
    }
  ]
}
  • polarity——主題讀起來偏亮還是偏暗。它驅動 color-scheme、主題未覆寫的應用程式 token 所繼承的明暗預設值,以及主題生效期間閱讀器「自動」頁面顏色的解析。
  • app——對應用程式固定 token 詞彙(畫布、文字層級、表面、填充、邊框——見型別宣告中的 PluginAppThemeTokens)的覆寫。未覆寫的 token 保持對應明暗極性自己的值。
  • reader——與內建頁面顏色同一套的六色調色盤(六色缺一不可),外加一個可選的排版預設:在使用者選取主題的那一刻一次性套用,之後使用者可以隨意調整。
  • fonts——.woff2/.woff/.ttf/.otf 字型直接從外掛資料夾提供服務;外掛啟用期間,每個字體都會出現在閱讀器的字體選擇器裡。主題以 plugin:<fontId> 引用自己的字型。上架市集的外掛必須把字型檔案列進 registry 條目的 files
  • 顏色值按嚴格語法校驗——純 hex 或 rgb()/rgba()/hsl()/hsla();關鍵字、var()url() 一律拒絕。

檢視

外掛宣告的是宿主元件樹,由應用程式渲染所有視覺原語和控制項;外掛不能提供 JSX、HTML、CSS 或 className。

  • markdown——一個 markdown 字串,由應用程式排版。
  • list——宿主提供固定 debounce 的搜尋、keywords、 accessories 與空狀態;timeline 提供今天/本週/本月/ 全部篩選和本機日期分組,條目可用 presentation: "dialog"在清單上方開啟回傳檢視,而不是下鑽成子頁面。
  • form——使用 ReadAware 元件庫的 text、textarea、number、time、select、choice、checkbox、toggle,加上 onSubmit;後者接收表單值,可回傳結果檢視或欄位錯誤。
  • detail——Raycast 式主內容、metadata 與宿主 actions;宿主把 actions 渲染成內容標題右側的圖示按鈕,把來源、日期和 tags 等 metadata 收進安靜的內容底部。
  • blocks——宿主 typography、markdown、字典、metadata、引文、動作、指標、進度、標籤、提示、section、group 與響應式 columns。columns 只開放相對 weight、間距檔位、最小寬度檔位和語義對齊,具體 CSS 與換行仍歸設計系統;所有宣告都會在執行時校驗並限制巢狀深度。

處理函數(runonSelectonSubmit)都回傳同一種結果形態:

  • 什麼都不回傳——介面保持原樣;
  • { toast: "…" }——一條短暫的提示;
  • { view }——開啟介面,或在其上推入一層新檢視;
  • { view, navigation: "replace" | "reset" }——替換目前檢視,或回到一棵新的根檢視;
  • { close: true }——關閉介面(可與 toast 組合);
  • { fieldErrors }——來自表單提交:停留在表單上,並在欄位下方顯示錯誤。

非同步工作不值一提:回傳一個 promise,應用程式會顯示載入狀態。圖示按名稱從應用程式精選的 Phosphor 集合中選取——不支援自訂 SVG。

領域資料

每個已授權的領域命名空間都提供讀取、規範事件訂閱,以及(擁有寫入權限時)命令。概覽:

  • ctx.shelf.books——list()get(id)getToc(id)getChapterText(id, index);寫入:importeditMetadatasetStarredsetFinishedremove,外加內容供應商(見下文)。
  • ctx.shelf.collections——list()booksIn(id);寫入:createrenameremoveassignBooks(bookIds, collectionId | null)
  • ctx.shelf.stats——forBook(bookId)list()overview()(閱讀位置、閱讀狀態與實際閱讀時長;對任何行動者都唯讀)。
  • ctx.annotations—— list({ bookId?, kind?, query? }) 回傳由螢光標示、筆記與提問構成的可辨別聯集;寫入: createHighlightrecolorHighlightremoveHighlightcreateNoteupdateNoteremoveNote
  • ctx.conversations——getBookThread(bookId)listThreads()getThread(id);透過 on 訂閱(aiConversation.startedaiMessage.appendedaiMessage.removedaiConversation.cleared)。

事件

兩類事件,刻意分開。領域事件是應用程式記錄下來的事實;按領域訂閱,使用規範名稱,需要該領域的讀取權限。每次投遞的形態是 { type, payload, createdAt, origin }——origin 表明是哪個軟體行動者產生了這一事實(useragentsystem,或 plugin:<id>)。

ctx.annotations?.on("highlight.created", ({ payload, origin }) => {
  // payload: { highlightId, bookId, text, color?, … }
});
ctx.shelf?.on("book.removed", ({ payload }) => { /* { bookId } */ });

工作階段事實描述此刻螢幕上正在發生的事。它們從不進入事件日誌,也無需任何權限: ctx.session.on(event, handler)

工作階段事件承載
book-opened{ book: { id, title, author? } }
book-closed{ bookId }
chapter-changed{ bookId, chapterHref }
reading-progress{ bookId, fraction }——翻頁時觸發,fraction 取值 0..1

內容供應商與虛擬書籍

宣告 shelf:write 後,外掛可以把真正的書放上書架。import 接收檔案位元組。內容供應商則完全跳過檔案:註冊一個供應商,新增繫結到它的虛擬書籍,並在書被開啟時提供 HTML 章節。閱讀器會像對待任何書一樣為它們分頁、標註、記錄進度——「把 RSS 訂閱源當書讀」正是這麼實作的。

ctx.shelf?.books.write?.registerContentProvider({
  id: "rss",
  async load(key) {
    const feed = await fetchFeed(key); // 你的程式碼,經由 ctx.network.fetch
    return {
      title: feed.title,
      sections: feed.items.map((item) => ({
        title: item.title,
        html: item.contentHtml,
      })),
    };
  },
});

await ctx.shelf?.books.write?.addVirtualBook({
  providerId: "rss",
  key: "https://example.com/feed.xml",
  title: "Example Weekly",
});

儲存與設定

ctx.storage 是隨應用程式本機資料一起持久化的命名空間鍵值儲存——getsetremove。如果 manifest 宣告了 settings 欄位,應用程式會把它們渲染成外掛自己的設定分類,所有值會以一個物件出現在 ctx.storage.get("settings")。閱讀助理也能查看和修改這些設定(標記 agentHidden 的欄位對它不可見)。有三種超出普通表單的欄位能力:

  • visibleWhen: { field, equals } 讓欄位只在另一欄位取給定值時顯示。隱藏欄位的存量值會保留——一個設定物件即可按變體各存一套值(TTS 外掛正是這樣為每個供應商各記一個聲音)。
  • select 配合 dynamicOptions: true 可以在執行時解析選項:在 activate 裡用 ctx.settings.provideOptions(fieldId, async (values) => [...]) 繫結來源。來源給不出選項時(還沒設定金鑰、端點不可達),欄位會退回自由文字輸入——清單是便利,絕不是門檻。
  • kind: "secret" 宣告一個憑證欄位:應用程式渲染密碼輸入框並直寫加密的 secret store——欄位 id 就是你程式碼裡 ctx.secrets 讀回的鍵名——絕不進明文設定,也不進助理的目錄。存量值從不回顯;欄位以「已設定」狀態展示,並提供清除入口。

對於結構化資料,ctx.storage.collection(name) 會開啟一個具名的文件集合——對逐條文件記錄進行 put / get / delete / list,記錄可選攜帶 bookId / anchor 出處資訊,並可據此篩選。出處是索引而非所有權:被引用的書刪除後,文件依然存在;而集合的生命週期歸屬於外掛(解除安裝即清空)。內建的詞彙表外掛正是完全建構在這一層之上。

常駐上下文

始終可用,無需任何權限:

  • ctx.manifestctx.appVersionctx.locale(應用程式介面目前的 BCP-47 語言標籤——用時再讀,它隨語言設定即時變化);
  • ctx.ui.showToast(message)
  • ctx.ui.exportFile({ filename, content, mimeType? })——開啟宿主的儲存流程,匯出生成的文字(CSV、JSON、Markdown)或二進位位元組;
  • ctx.secrets——按外掛命名空間隔離的加密憑證儲存(API 權杖等);存放在 SQLite 與備份之外,解除安裝後依然保留;
  • ctx.session.on(…)——上文的工作階段事實;
  • ctx.reader.openBook(bookId) ctx.reader.goTo({ bookId?, cfi?, href? })——導覽閱讀器(使用者可見的控制,不暴露資料)。

穩定性

這是契約 v2,隨應用程式 0.3.0 發佈——一次有意為之的破壞性重建,把整個外掛表面從領域模型衍生出來(v1 的 manifest 會安裝失敗,並給出可讀的錯誤訊息)。自此 API 只做加法式增長:新的領域、新的事件名、新的區塊類型——宣告式主題(ui:themes)就是第一個這樣的新增。對本頁已記載內容的破壞性變更會被當作 bug 處理。任何依賴較新能力的外掛,請宣告 minAppVersion