# 前端架構

Oleafly 如何在前端工作區、Rust 服務和固定版本的 sidecar 之間劃分桌面應用程式。

Oleafly 的前端是一個 pnpm 工作區：應用程式殼層位於儲存庫根目錄（`src/`），可重複使用的前端程式碼則以十二個職責明確的套件形式放在 `packages/*`。Tauri 後端位於 `src-tauri/`，可重複使用的 AI 供應商與代理程式碼則位於 `crates/oleafly-agent/`。前端／Rust／sidecar 的整體圖像請參閱 [README 的架構圖](https://github.com/Oleafly/Oleafly/blob/main/README.md#architecture)，日常指令請參閱[開發](/docs/engineering/development/)。

本文件涵蓋：每個套件是什麼、讓它們保持純粹的相依反轉規則、把功能接到殼層的貢獻註冊表、模組解析如何運作，以及如何新增或抽出套件。

## 為什麼使用工作區

過去每個功能都直接伸進應用程式的 Zustand store、Tauri IPC 用戶端和 shadcn UI 套件。這讓功能與殼層無法分開：沒有任何東西能單獨測試、重複使用或推理。工作區把這件事反過來。套件持有功能邏輯，並為它們需要的一切宣告稱為埠（port）的窄介面。應用程式在它的 store 和 Tauri 用戶端之上實作這些埠，並注入進去。應用程式相依於套件，絕不反過來。

套件是**以 TypeScript 原始碼**的形式使用的（它們的 `main` 指向 `src/index.ts`）。沒有建置步驟、沒有發布，也沒有版本落差：Vite、Vitest 和 `tsc` 全都透過別名把 `@oleafly/*` 直接解析到套件原始碼（請參閱[模組解析](#module-resolution)）。

## 套件

| 套件 | 它是什麼 | 注入的埠 | 主要相依套件 |
| --- | --- | --- | --- |
| `@oleafly/latex` | 純 LaTeX／圖片邏輯：圖表模型、TikZ 序列化器（`modelToTikz`，內嵌模型的往返轉換）、獨立文件建構器（`buildStandaloneDoc`）、圖片名稱／位元組輔助函式 | 無（純函式） | 無 |
| `@oleafly/ai-core` | 供應商顯示中繼資料和前端模型選擇，與 Rust 目錄保持一致 | 無（純函式） | 無 |
| `@oleafly/latex-intelligence` | 編輯器智慧功能所用的 LaTeX 語料庫，其經過驗證的型別和資料存取 | 無（純函式） | 無 |
| `@oleafly/registry` | 貢獻註冊表：側欄分頁、面板／全能列命令、AI 工具集（[詳見下方](#the-contribution-registry)） | 無（貢獻自帶行為） | 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 元件匯入。純粹性檢查是抽出作業衛生的一部分：

```bash
grep -rn 'from "@/\|@tauri\|zustand' packages/*/src   # must return nothing
```

## 埠模式

套件宣告介面。應用程式只實作一次，通常是以模組層級的轉接器物件，並包住它的 store。有四種反覆出現的形式：

**主機埠**：每個套件一個 `interface XHost`，涵蓋它需要的服務。應用程式用命令式的 store 讀取來建立轉接器：

```ts
// 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）

每次抽出都在舊的 `@/…` 路徑留下一個很薄的檔案，重新匯出（或包裝）該套件，所以使用端和現有的 `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` 只繪製已註冊的內容。

要新增某個功能的分頁或命令，只要註冊它。不用改殼層：

```ts
// 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：形式可以隨時變更，也沒有任何東西是動態載入的。在出現真正的使用者之前，暫不處理：把匯出選單格式、設定結構描述和專案類型行為做成貢獻。

## 模組解析

因為套件是以原始碼的形式使用，同一個別名要在三個地方宣告，新增套件時三處都必須保持同步：

1. `tsconfig.json` → `compilerOptions.paths`。像 `@oleafly/preflight/pdf-extract` 這樣的子路徑需要自己明確的項目。
2. `vite.config.ts` → `resolve.alias`。字串別名以前綴比對，所以子路徑不需要另外的項目就能解析。
3. `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 之外。
