前端架構
Oleafly 如何在前端工作區、Rust 服務和固定版本的 sidecar 之間劃分桌面應用程式。
Oleafly 的前端是一個 pnpm 工作區:應用程式殼層位於儲存庫根目錄(src/),可重複使用的前端程式碼則以十二個職責明確的套件形式放在 packages/*。Tauri 後端位於 src-tauri/,可重複使用的 AI 供應商與代理程式碼則位於 crates/oleafly-agent/。前端/Rust/sidecar 的整體圖像請參閱 README 的架構圖 (在新分頁開啟),日常指令請參閱開發。
本文件涵蓋:每個套件是什麼、讓它們保持純粹的相依反轉規則、把功能接到殼層的貢獻註冊表、模組解析如何運作,以及如何新增或抽出套件。
為什麼使用工作區
標題為「為什麼使用工作區」的段落過去每個功能都直接伸進應用程式的 Zustand store、Tauri IPC 用戶端和 shadcn UI 套件。這讓功能與殼層無法分開:沒有任何東西能單獨測試、重複使用或推理。工作區把這件事反過來。套件持有功能邏輯,並為它們需要的一切宣告稱為埠(port)的窄介面。應用程式在它的 store 和 Tauri 用戶端之上實作這些埠,並注入進去。應用程式相依於套件,絕不反過來。
套件是以 TypeScript 原始碼的形式使用的(它們的 main 指向 src/index.ts)。沒有建置步驟、沒有發布,也沒有版本落差:Vite、Vitest 和 tsc 全都透過別名把 @oleafly/* 直接解析到套件原始碼(請參閱模組解析)。
| 套件 | 它是什麼 | 注入的埠 | 主要相依套件 |
|---|---|---|---|
@oleafly/latex | 純 LaTeX/圖片邏輯:圖表模型、TikZ 序列化器(modelToTikz,內嵌模型的往返轉換)、獨立文件建構器(buildStandaloneDoc)、圖片名稱/位元組輔助函式 | 無(純函式) | 無 |
@oleafly/ai-core | 供應商顯示中繼資料和前端模型選擇,與 Rust 目錄保持一致 | 無(純函式) | 無 |
@oleafly/latex-intelligence | 編輯器智慧功能所用的 LaTeX 語料庫,其經過驗證的型別和資料存取 | 無(純函式) | 無 |
@oleafly/registry | 貢獻註冊表:側欄分頁、面板/全能列命令、AI 工具集(詳見下方) | 無(貢獻自帶行為) | react(型別) |
@oleafly/preflight | 預檢引擎:文件型別判斷、原始碼/PDF/參照規則、ATS 解析、無障礙匯出準備、評分。pdf-extract 以 @oleafly/preflight/pdf-extract 子路徑提供,讓 pdf.js 不進入 node 測試圖 | 無(純函式) | pdfjs-dist(僅限子路徑) |
@oleafly/pdf-to-latex | 本機的 PDF 轉 LaTeX 重建管線:行分組、標題與欄位偵測、樣式推斷、符號對應、圖片擷取、跳脫處理和文件組裝。pdf.js 轉接器以 @oleafly/pdf-to-latex/pdf-adapter 子路徑提供,原因同樣是 node 測試圖 | 無(純函式) | pdfjs-dist(僅限子路徑) |
@oleafly/wysiwyg | 受限的視覺化編輯器核心:共用的結構描述、LaTeX 和 Markdown 的剖析/序列化,以及讓不支援的原始碼保持可見而不是被丟棄的原始行內/區塊節點 | 無(純函式) | 無 |
@oleafly/editor | LaTeX、Typst 和 Markdown 的 CodeMirror 核心:CodeMirrorEditor、編輯器檢視控制器(insertAtCursor、gotoLine……)、語言與自動完成、主題、摺疊、固定標題、檢查器、latex-mask、公式預覽(LaTeX 用 KaTeX,Typst 用 Typst)、Typst 編輯輔助與 LaTeX 轉 Typst 的數學式轉換、LaTeX 和 Typst 的就地視覺化模式(@oleafly/editor/visual)、搜尋面板、拼字/文法檢查器 | EditorHost(文件模型與設定,hook 形式)、SpellHost(拼字檢查器/Harper/字典)、setBibKeysProvider、extraExtensions/extraKeymap | @codemirror/*、@replit/codemirror-vim、@replit/codemirror-emacs、katex |
@oleafly/preview | 虛擬化的 pdf.js 檢視器(PdfViewer)、SyncTeX 頁面控制器(gotoRect、pageClickToBp)、pdf.js worker,以及 WebView polyfill(@oleafly/preview/polyfills,在 main.tsx 中最先匯入) | onOpenLink prop(系統瀏覽器連結)、setPdfLogger | pdfjs-dist |
@oleafly/diagram | TikZ、Typst(fletcher)和 Mermaid 的視覺化圖表編輯器:React Flow 畫布、形狀檢查器、繪圖/程式碼分頁,以及編譯、預覽、插入的流程。src/languages/ 中的每種語言都有寫入器、讀取器,以及它的插入與檔案形式,而畫布與程式碼雙向保持同步 | DiagramHost(LaTeX 編譯、Typst 與 Mermaid 繪製、檔案 IO、編輯器插入、AI 修正)、DiagramKit context(Button/Tooltip/Select/toast/主題) | @xyflow/react、@codemirror/*、@oleafly/latex、@oleafly/editor |
@oleafly/ai-tools | 專案、研究、記憶和圖片工具的定義,其變更操作會配合核准機制 | AiToolsHost(檔案、編譯、符號索引、圖片管線、編輯器) | @oleafly/latex |
@oleafly/templates | 新專案範本庫(兩步驟精靈、分類、預覽、附進度的一次性資產下載) | TemplatesHost(預覽、資產下載、記錄)、TemplatesKit(Button/Tooltip),顏色選項以 props 傳入 | 除了 UI 工具之外沒有其他 |
相依方向,嚴格執行:
套件絕不能從 src/(@/…)、Zustand store、@tauri-apps/* 或應用程式的 UI 元件匯入。純粹性檢查是抽出作業衛生的一部分:
grep -rn 'from "@/\|@tauri\|zustand' packages/*/src # must return nothing埠模式
標題為「埠模式」的段落套件宣告介面。應用程式只實作一次,通常是以模組層級的轉接器物件,並包住它的 store。有四種反覆出現的形式:
主機埠:每個套件一個 interface XHost,涵蓋它需要的服務。應用程式用命令式的 store 讀取來建立轉接器:
// src/lib/ai-tools.ts (app adapter, abridged)const HOST: AiToolsHost = { getProjectId: () => useFilesStore.getState().projectId, readFileContent, // Tauri client passthrough recompile: () => useCompileStore.getState().recompile(), insertAtCursor, // editor controller // ...};export const createOleaflyTools = (opts?) => createOleaflyToolsCore(HOST, opts);把僅限應用程式的關注點留在轉接器內,也讓相依關係不會進入套件。供應商憑證和請求留在 Rust 中,所以前端套件只會收到它們的介面所需要的狀態和結果。
UI 套件組:結構型別元件的 React context(或 prop):Button: ComponentType<{ variant?: …; onClick?: () => void }>。應用程式原封不動地傳入它的 shadcn 元件,因為較小的結構型別是相容的。套件組也帶有 toast 和 useThemeMode() hook。
hook 形式的埠:用於必須訂閱應用程式狀態的套件。編輯器的 EditorHost 有 hook 成員(useActivePath()、useSettings()),以及給事件監聽器用的命令式對應成員(getActivePath())。規則:主機物件必須是模組層級的(身分穩定),而它的 use* 成員必須無條件呼叫 hooks。
模組單例:用於橫切的黏合程式碼,setPdfLogger(fn)、setSpellHost(host)、setBibKeysProvider(fn) 有無作用的預設值,並由應用程式的 shim 在匯入時的副作用中安裝。
相容層(shim)
標題為「相容層(shim)」的段落每次抽出都在舊的 @/… 路徑留下一個很薄的檔案,重新匯出(或包裝)該套件,所以使用端和現有的 vi.mock(...) 路徑從未變動。例子有:src/components/editor/cm/controller.ts(重新匯出)、src/components/pdf/PdfViewer.tsx(注入 shell-plugin 連結開啟器的包裝)、src/lib/ai-tools.ts(轉接器加上原本的工廠簽章)。新的程式碼可以直接匯入 @oleafly/*。相容層是為了相容性而存在,不是偏好的路徑。
貢獻註冊表
標題為「貢獻註冊表」的段落@oleafly/registry 是功能在殼層不認識它們的情況下接入應用程式殼層的方式。共有三種貢獻類型:
- 側欄分頁(
registerRailTab):圖示、區段(explore/review/assist,在非空的區段之間繪製分隔線)、選用的when(ctx)可見性、選用的useBadge()hook(例如 Git 變更數量),以及側邊欄的panel元件。 - 命令(
registerCommand):透過surfaces提供給指令面板(Cmd+K,依group分組)和/或全能列(Cmd+P)。標籤和圖示可以是 context 的函式(例如「切換至淺色主題」)。 - AI 工具集(
registerAiToolset):把聊天模式("chat"、"figure")對應到工具集工廠。聊天面板查詢使用中模式的工具集,而不是寫死一個。
AppContext({ projectId, projectKind, theme })由每個繪製介面根據自己的訂閱建立,所以 when/label 條件保持響應式。
註冊是靜態的:src/contributions/ 註冊每個內建項目(分頁、命令、工具集),而 main.tsx 在殼層掛載之前呼叫一次 registerContributions()。Rail、Sidebar、CommandPalette、SearchOmnibar 和 ChatPanel 只繪製已註冊的內容。
要新增某個功能的分頁或命令,只要註冊它。不用改殼層:
// src/contributions/my-feature.tsx (called from src/contributions/index.ts)registerRailTab({ id: "review", label: "Review", icon: MessageSquare, section: "review", order: 55, when: (ctx) => ctx.projectKind !== "image", panel: ReviewPanel,});registerCommand({ id: "omnibar.review", surfaces: ["omnibar"], order: 45, label: "Open review", keywords: "review comments", run: () => useSettingsStore.getState().setRailTab("review"),});這是內部 API,不是外掛 SDK:形式可以隨時變更,也沒有任何東西是動態載入的。在出現真正的使用者之前,暫不處理:把匯出選單格式、設定結構描述和專案類型行為做成貢獻。
模組解析
標題為「模組解析」的段落因為套件是以原始碼的形式使用,同一個別名要在三個地方宣告,新增套件時三處都必須保持同步:
tsconfig.json→compilerOptions.paths。像@oleafly/preflight/pdf-extract這樣的子路徑需要自己明確的項目。vite.config.ts→resolve.alias。字串別名以前綴比對,所以子路徑不需要另外的項目就能解析。vitest.config.ts→ 同樣的resolve.alias,加上test.include必須涵蓋packages/**/*.test.ts。否則被搬移的測試會悄悄地從執行中消失。
另外:根目錄的 package.json 透過 "workspace:*" 相依於每個套件,而 src/styles/globals.css 有 @source "../../packages",讓 Tailwind v4 掃描套件原始碼中的 class 名稱。
僅限 Vite 的匯入(?worker&url worker、CSS 匯入)從套件原始碼使用時可以照常運作,因為別名指向原始碼。請讓任何會牽入 pdf.js 的東西遠離 node 環境測試所匯入的 barrel:這就是 pdf-extract 和 preview 套件的檢視器只能在有 DOM 的地方取得的原因。
開發套件
標題為「開發套件」的段落- 測試與原始碼放在一起(
packages/*/src/*.test.ts),並與應用程式測試在同一個pnpm test中執行。搬移檔案時請留意測試的數量。 - 相依套件以寬鬆的方式宣告(
"react": "*")。pnpm 會與根目錄去重,真正的版本下限在根目錄。不要宣告套件沒有匯入的相依套件。 - 任何套件變更的檢查關卡:
pnpm exec tsc --noEmit、pnpm build、pnpm test(數量不變),以及上面的純粹性 grep。 - 抽出新東西:用
git mv搬移檔案(歷史會跟著走)、定義埠、在舊路徑留下相容層、接好三個別名,然後執行各項檢查。不要在積極迭代某個功能時抽出它。 - 授權:每個套件都是
AGPL-3.0-or-later,與應用程式相同。
留在應用程式中的部分
標題為「留在應用程式中的部分」的段落src/ 保留真正屬於前端應用程式的一切:Zustand store、Tauri IPC 用戶端(src/lib/tauri.ts)、shadcn UI 套件、版面配置/殼層元件、埠的轉接器和相容層、src/contributions/,以及天生與 store 耦合的功能黏合程式碼(預檢面板 UI、行內 AI 編輯外掛,以及以 extraExtensions 注入編輯器的程式碼或懸停智慧功能)。Tauri 命令和作業系統整合留在 src-tauri/。供應商用戶端、串流和代理程式編排屬於 crates/oleafly-agent/,這樣密鑰和供應商請求就留在 webview 之外。