# Kiến trúc frontend

Cách Oleafly phân chia ứng dụng desktop giữa workspace frontend, các dịch vụ Rust và các sidecar được ghim phiên bản.

Frontend của Oleafly là một pnpm workspace: app shell nằm ở thư mục gốc của repo (`src/`), còn mã frontend dùng lại được nằm trong `packages/*` dưới dạng mười hai package tập trung. Backend Tauri nằm trong `src-tauri/`, còn mã provider AI và agent dùng lại được nằm trong `crates/oleafly-agent/`. Xem [sơ đồ kiến trúc trong README](https://github.com/Oleafly/Oleafly/blob/main/README.md#architecture) để có bức tranh frontend/Rust/sidecar, và xem [Phát triển](/docs/engineering/development/) để biết các lệnh hằng ngày.

Tài liệu này bao gồm: mỗi package là gì, các quy tắc đảo ngược phụ thuộc giữ cho chúng thuần khiết, contribution registry gắn các tính năng vào shell, cách phân giải module vận hành, và cách thêm hay tách một package.

## Tại sao dùng workspace

Trước đây, mọi tính năng đều truy cập trực tiếp vào các Zustand store của app, Tauri IPC client và bộ shadcn UI kit. Điều đó khiến các tính năng không thể tách rời shell: không gì có thể được kiểm thử, dùng lại hay suy luận một cách độc lập. Workspace đảo ngược điều đó. Các package giữ logic của tính năng và khai báo các interface hẹp gọi là port cho mọi thứ chúng cần. App hiện thực các port đó trên nền store và Tauri client của mình rồi tiêm chúng vào. App phụ thuộc vào package, không bao giờ ngược lại.

Các package được tiêu thụ **dưới dạng mã nguồn TypeScript** (`main` của chúng trỏ tới `src/index.ts`). Không có bước build, không xuất bản, không lệch phiên bản: Vite, Vitest và `tsc` đều phân giải `@oleafly/*` thẳng tới mã nguồn của package qua alias (xem [Phân giải module](#module-resolution)).

## Các package

| Package | Là gì | Port được tiêm | Dependency đáng chú ý |
| --- | --- | --- | --- |
| `@oleafly/latex` | Logic thuần cho LaTeX và hình vẽ: mô hình diagram, bộ tuần tự hóa TikZ (`modelToTikz`, khứ hồi embedded-model), bộ dựng tài liệu standalone (`buildStandaloneDoc`), các hàm hỗ trợ về tên hình và kích thước byte | không có (thuần khiết) | không có |
| `@oleafly/ai-core` | Siêu dữ liệu hiển thị của provider và việc chọn model phía frontend, được giữ ngang hàng với catalog Rust | không có (thuần khiết) | không có |
| `@oleafly/latex-intelligence` | Các kiểu đã xác thực và truy cập dữ liệu cho kho ngữ liệu LaTeX được dùng bởi trí thông minh của trình soạn thảo | không có (thuần khiết) | không có |
| `@oleafly/registry` | Contribution registry: thẻ rail, lệnh palette/omnibar, bộ công cụ AI ([chi tiết bên dưới](#the-contribution-registry)) | không có (mỗi contribution tự mang hành vi của nó) | react (types) |
| `@oleafly/preflight` | Bộ máy preflight: phân loại tài liệu, quy tắc cho nguồn/PDF/tham chiếu, phân tích ATS, chuẩn bị xuất bản tiếp cận được, chấm điểm. `pdf-extract` phát hành dưới dạng subpath `@oleafly/preflight/pdf-extract` để pdf.js không lọt vào đồ thị kiểm thử node | không có (thuần khiết) | `pdfjs-dist` (chỉ subpath) |
| `@oleafly/pdf-to-latex` | Pipeline phục hồi PDF sang LaTeX chạy cục bộ: gom dòng, nhận diện tiêu đề và cột, suy luận kiểu dáng, ánh xạ ký hiệu, trích xuất hình, thoát ký tự và lắp ráp tài liệu. Adapter pdf.js phát hành dưới dạng subpath `@oleafly/pdf-to-latex/pdf-adapter` vì cùng lý do đồ thị kiểm thử node | không có (thuần khiết) | `pdfjs-dist` (chỉ subpath) |
| `@oleafly/wysiwyg` | Lõi trình soạn thảo trực quan có ràng buộc: schema dùng chung, phân tích và tuần tự hóa LaTeX với Markdown, cùng các node inline/block thô giúp phần nguồn chưa được hỗ trợ vẫn hiển thị thay vì bị loại bỏ | không có (thuần khiết) | không có |
| `@oleafly/editor` | Lõi CodeMirror cho LaTeX, Typst và Markdown: `CodeMirrorEditor`, bộ điều khiển editor-view (`insertAtCursor`, `gotoLine`, …), ngôn ngữ + hoàn thiện, theme, gấp đoạn, tiêu đề dính, linter, latex-mask, xem trước công thức (KaTeX cho LaTeX, Typst cho Typst), công cụ hỗ trợ soạn thảo Typst và chuyển đổi công thức LaTeX sang Typst, chế độ Visual tại chỗ cho LaTeX và Typst (`@oleafly/editor/visual`), panel tìm kiếm, linter chính tả và ngữ pháp | `EditorHost` (mô hình tài liệu + cài đặt, dạng hook), `SpellHost` (kiểm tra chính tả/Harper/từ điển), `setBibKeysProvider`, `extraExtensions`/`extraKeymap` | `@codemirror/*`, `@replit/codemirror-vim`, `@replit/codemirror-emacs`, `katex` |
| `@oleafly/preview` | Trình xem pdf.js ảo hóa (`PdfViewer`), bộ điều khiển trang SyncTeX (`gotoRect`, `pageClickToBp`), pdf.js worker, và các polyfill WebView (`@oleafly/preview/polyfills`, được import đầu tiên trong `main.tsx`) | prop `onOpenLink` (liên kết mở bằng trình duyệt hệ thống), `setPdfLogger` | `pdfjs-dist` |
| `@oleafly/diagram` | Trình soạn diagram trực quan cho TikZ, Typst (fletcher) và Mermaid: canvas React Flow, trình kiểm tra hình khối, các thẻ Draw/Code, luồng biên dịch, xem trước và chèn. Mỗi ngôn ngữ trong `src/languages/` có một writer, một reader cùng dạng chèn và dạng tệp của nó, và canvas với mã luôn bước đồng bộ theo cả hai chiều | `DiagramHost` (biên dịch LaTeX, kết xuất Typst và Mermaid, IO tệp, chèn vào trình soạn thảo, sửa bằng AI), ngữ cảnh `DiagramKit` (Button/Tooltip/Select/toast/theme) | `@xyflow/react`, `@codemirror/*`, `@oleafly/latex`, `@oleafly/editor` |
| `@oleafly/ai-tools` | Định nghĩa công cụ cho dự án, nghiên cứu, bộ nhớ và hình vẽ, kèm các mutation có kiểm soát phê duyệt | `AiToolsHost` (tệp, biên dịch, chỉ mục ký hiệu, pipeline hình vẽ, trình soạn thảo) | `@oleafly/latex` |
| `@oleafly/templates` | Thư viện mẫu dự án mới (thuật sĩ hai bước, phân loại, xem trước, tài nguyên tải về một lần kèm tiến độ) | `TemplatesHost` (xem trước, tải tài nguyên, ghi nhật ký), `TemplatesKit` (Button/Tooltip), tùy chọn màu truyền qua props | không có ngoài các tiện ích UI |

Hướng phụ thuộc, được thi hành nghiêm ngặt:

Một package tuyệt đối không được import từ `src/` (`@/…`), từ một Zustand store, từ `@tauri-apps/*`, hay từ các UI component của app. Việc kiểm tra độ thuần khiết là một phần của quy trình vệ sinh khi tách package:

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

## Mô hình port

Các package khai báo interface. App hiện thực chúng một lần, thường dưới dạng một object adapter cấp module bao lấy các store của mình. Bốn dạng thường gặp:

**Host port**: mỗi package có một `interface XHost` bao quát các dịch vụ mà nó cần. App dựng adapter bằng các lệnh đọc store trực tiếp:

```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);
```

Giữ các mối quan tâm riêng của app bên trong adapter cũng giữ các dependency khỏi lọt vào package. Thông tin xác thực và các yêu cầu tới provider nằm ở phía Rust, nên các package frontend chỉ nhận đúng trạng thái và kết quả mà interface của chúng yêu cầu.

**UI kit**: một React context (hoặc prop) chứa các component định kiểu theo cấu trúc: `Button: ComponentType<{ variant?: …; onClick?: () => void }>`. App truyền các component shadcn của mình qua nguyên dạng vì kiểu cấu trúc nhỏ hơn vẫn tương thích. Bộ kit còn mang `toast` và hook `useThemeMode()`.

**Port dạng hook**: dành cho các package bắt buộc phải *theo dõi* trạng thái của app. `EditorHost` của trình soạn thảo có các thành viên dạng hook (`useActivePath()`, `useSettings()`) cùng cặp song sinh trực tiếp (`getActivePath()`) cho các trình lắng nghe sự kiện. Quy tắc: object host phải nằm ở cấp module (định danh ổn định) và các thành viên `use*` của nó phải gọi hook một cách vô điều kiện.

**Singleton cấp module**: cho phần nối xuyên suốt, `setPdfLogger(fn)`, `setSpellHost(host)`, `setBibKeysProvider(fn)` có default không làm gì và được shim của app cài đặt như một tác dụng phụ khi import.

### Các shim

Mỗi lần tách đều để lại một tệp mỏng tại đường dẫn `@/…` cũ, tệp này tái xuất (hoặc bọc) package, để các bên tiêu thụ và các đường dẫn `vi.mock(...)` sẵn có không hề xáo trộn. Ví dụ: `src/components/editor/cm/controller.ts` (tái xuất), `src/components/pdf/PdfViewer.tsx` (wrapper tiêm bộ mở liên kết của shell plugin), `src/lib/ai-tools.ts` (adapter + các chữ ký factory gốc). Mã mới có thể import `@oleafly/*` trực tiếp. Shim tồn tại để tương thích, không phải là đường dẫn ưu tiên.

## Contribution registry

`@oleafly/registry` là cách các tính năng cắm vào app shell mà shell không cần biết đến chúng. Ba loại contribution:

- **Thẻ rail** (`registerRailTab`): biểu tượng, nhóm (`explore` / `review` / `assist`, vạch phân cách vẽ giữa các nhóm không trống), khả năng hiển thị qua `when(ctx)` tùy chọn, hook `useBadge()` tùy chọn (ví dụ số thay đổi của Git), và component `panel` của sidebar.
- **Lệnh** (`registerCommand`): được phục vụ cho command palette (Cmd+K, nhóm theo `group`) và/hoặc omnibar (Cmd+P) qua `surfaces`. Nhãn và icon có thể là hàm theo ngữ cảnh (ví dụ: "Switch to light theme").
- **Bộ công cụ AI** (`registerAiToolset`): một chế độ chat (`"chat"`, `"figure"`) được ánh xạ tới một factory dựng bộ công cụ. Panel chat tra bộ công cụ của chế độ đang hoạt động thay vì ghi cứng một bộ.

`AppContext` (`{ projectId, projectKind, theme }`) được từng bề mặt kết dựng tạo ra từ các subscription của chính nó, nên các điều kiện `when`/`label` luôn phản ứng kịp thời.

Việc đăng ký là **tĩnh**: `src/contributions/` đăng ký mọi thứ tích hợp sẵn (thẻ, lệnh, bộ công cụ) và `main.tsx` gọi `registerContributions()` một lần trước khi shell được gắn. `Rail`, `Sidebar`, `CommandPalette`, `SearchOmnibar` và `ChatPanel` chỉ kết xuất những gì đã đăng ký.

Để thêm thẻ hay lệnh cho một tính năng, hãy đăng ký nó. Không cần sửa shell:

```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"),
});
```

Đây là API nội bộ, không phải plugin SDK: các hình dạng có thể thay đổi tự do, và không gì được nạp động. Trì hoãn tới khi có bên tiêu thụ thực sự: định dạng của menu xuất, schema cài đặt, và hành vi theo loại dự án dưới dạng contribution.

## Phân giải module

Vì các package được tiêu thụ dưới dạng mã nguồn, cùng một alias được khai báo ở ba nơi, và cả ba phải giữ đồng bộ khi thêm một package:

1. `tsconfig.json` → `compilerOptions.paths`. Subpath như `@oleafly/preflight/pdf-extract` cần mục khai báo riêng tường minh.
2. `vite.config.ts` → `resolve.alias`. Alias dạng chuỗi khớp theo tiền tố, nên subpath phân giải được mà không cần thêm mục nào.
3. `vitest.config.ts` → cùng `resolve.alias` đó, **cộng thêm** `test.include` phải bao `packages/**/*.test.ts`. Nếu không, các bài kiểm thử đã di chuyển sẽ lặng lẽ rơi khỏi lượt chạy.

Ngoài ra: `package.json` gốc phụ thuộc vào mỗi package qua `"workspace:*"`, và `src/styles/globals.css` có `@source "../../packages"` để Tailwind v4 quét mã nguồn các package tìm tên lớp.

Các import chỉ dành cho Vite (worker `?worker&url`, import CSS) hoạt động nguyên trạng từ mã nguồn package vì alias trỏ thẳng vào mã nguồn. Hãy giữ mọi thứ kéo theo pdf.js ra khỏi các barrel mà bài kiểm thử môi trường node import: đó là lý do `pdf-extract` và trình xem của package preview chỉ truy cập được ở nơi có DOM.

## Làm việc trên các package

- **Bài kiểm thử** nằm cạnh mã nguồn (`packages/*/src/*.test.ts`) và chạy trong cùng `pnpm test` với bài kiểm thử của app. Hãy để ý *số lượng* bài kiểm thử khi di chuyển tệp.
- **Dependency** được khai báo lỏng (`"react": "*"`). pnpm khử trùng lặp theo gốc, nơi đặt giới hạn dưới thực của các phiên bản. Đừng khai báo dependency mà package không import.
- **Cổng kiểm soát cho mọi thay đổi package**: `pnpm exec tsc --noEmit`, `pnpm build`, `pnpm test` (số lượng không đổi), và lệnh grep độ thuần khiết ở trên.
- **Tách phần mới**: di chuyển tệp bằng `git mv` (lịch sử đi theo), định nghĩa các port, để shim tại đường dẫn cũ, nối dây ba alias, rồi chạy các cổng kiểm soát. Đừng tách một tính năng trong lúc bạn đang chỉnh sửa nó liên tục.
- **Giấy phép**: mọi package đều là `AGPL-3.0-or-later`, giống app.

## Những gì nằm lại trong app

`src/` giữ mọi thứ thực sự thuộc về app frontend: các Zustand store, Tauri IPC client (`src/lib/tauri.ts`), bộ shadcn UI kit, các component layout/shell, các adapter và shim của port, `src/contributions/`, và phần keo nối tính năng vốn gắn chặt store (UI panel preflight, plugin chỉnh sửa inline-AI, và trí thông minh mã hoặc hover được tiêm vào trình soạn thảo dưới dạng `extraExtensions`). Lệnh Tauri và phần tích hợp hệ điều hành nằm trong `src-tauri/`. Client provider, streaming và điều phối agent thuộc về `crates/oleafly-agent/` để thông tin mật và yêu cầu tới provider luôn nằm ngoài webview.
