# 前端架构

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 套件。这让功能与外壳密不可分：
没有什么东西可以被单独测试、复用或推理。工作区把这一点倒了过来。包承载功能逻辑，并为它需要的一切声明
称为端口的窄接口。应用在自己的 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` | 贡献注册表：侧栏标签页、面板/omnibar 命令、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 重建流水线：行分组、标题和分栏检测、样式推断、符号映射、图片提取、转义和文档组装。出于同样的 node 测试依赖图原因，pdf.js 适配器以 `@oleafly/pdf-to-latex/pdf-adapter` 子路径发布 | 无（纯） | `pdfjs-dist`（仅子路径） |
| `@oleafly/wysiwyg` | 受约束的可视化编辑器核心：共享 schema、LaTeX 和 Markdown 的解析/序列化，以及让不受支持的源码保持可见而不是丢弃的原始行内/块节点 | 无（纯） | 无 |
| `@oleafly/editor` | 用于 LaTeX、Typst 和 Markdown 的 CodeMirror 核心：`CodeMirrorEditor`、编辑器视图控制器（`insertAtCursor`、`gotoLine` 等）、语言与补全、主题、折叠、粘性标题、linter、latex-mask、公式预览（LaTeX 用 KaTeX，Typst 用 Typst）、Typst 编辑辅助和 LaTeX 到 Typst 的公式转换、LaTeX 和 Typst 的原位可视化模式（`@oleafly/editor/visual`）、搜索面板、拼写/语法 linter | `EditorHost`（文档模型和设置，钩子形）、`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` 属性（系统浏览器链接）、`setPdfLogger` | `pdfjs-dist` |
| `@oleafly/diagram` | 用于 TikZ、Typst（fletcher）和 Mermaid 的可视化图表编排器：React Flow 画布、形状检查器、绘制/代码标签页、编译-预览-插入流程。`src/languages/` 中的每种语言都有写入器、读取器，以及它的插入形式和文件形式，画布和代码双向保持同步 | `DiagramHost`（LaTeX 编译、Typst 和 Mermaid 渲染、文件 IO、编辑器插入、AI 修复）、`DiagramKit` 上下文（Button/Tooltip/Select/toast/主题） | `@xyflow/react`、`@codemirror/*`、`@oleafly/latex`、`@oleafly/editor` |
| `@oleafly/ai-tools` | 项目、研究、记忆和图片工具的定义，其变更操作会考虑审批 | `AiToolsHost`（文件、编译、符号索引、图片流水线、编辑器） | `@oleafly/latex` |
| `@oleafly/templates` | 新建项目的模板库（两步向导、分类、预览、带进度的一次性资源下载） | `TemplatesHost`（预览、资源下载、日志）、`TemplatesKit`（Button/Tooltip），颜色选项作为属性传入 | 除 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 上下文（或属性）：
`Button: ComponentType<{ variant?: …; onClick?: () => void }>`。应用把
它的 shadcn 组件原样传入，因为较小的结构化类型是兼容的。
套件还携带 `toast` 和一个 `useThemeMode()` 钩子。

**钩子形端口**：用于必须*订阅*应用状态的包。
编辑器的 `EditorHost` 有钩子成员（`useActivePath()`、`useSettings()`），
以及供事件监听器使用的命令式对应成员（`getActivePath()`）。规则：宿主
对象必须在模块层级（身份稳定），它的 `use*` 成员必须无条件调用钩子。

**模块单例**：用于横切的粘合代码，`setPdfLogger(fn)`、
`setSpellHost(host)`、`setBibKeysProvider(fn)` 有空操作的默认值，
并由应用的 shim 作为导入的副作用来安装。

### Shim

每次提取都在旧的 `@/…` 路径上留下一个很薄的文件，用来重新导出（或
包装）这个包，所以使用方和现有的 `vi.mock(...)` 路径都不会被打乱。例如：`src/components/editor/cm/controller.ts`（重新导出）、
`src/components/pdf/PdfViewer.tsx`（注入 shell 插件链接
打开器的包装）、`src/lib/ai-tools.ts`（适配器加原有的工厂签名）。新
代码可以直接导入 `@oleafly/*`。Shim 的存在是为了兼容，而不是作为
首选路径。

## 贡献注册表

`@oleafly/registry` 是功能接入应用外壳而无需外壳
认识它们的方式。有三种贡献类型：

- **侧栏标签页**（`registerRailTab`）：图标、分区（`explore` / `review` /
  `assist`，非空分区之间会画分隔线）、可选的 `when(ctx)`
  可见性、可选的 `useBadge()` 钩子（例如 Git 更改数量），以及侧边栏的
  `panel` 组件。
- **命令**（`registerCommand`）：通过 `surfaces` 提供给命令面板（Cmd+K，
  按 `group` 分组）和/或 omnibar（Cmd+P）。标签和
  图标可以是上下文的函数（例如“切换到浅色主题”）。
- **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：形态可能随意更改，
也没有任何东西是动态加载的。在出现真正的使用方之前暂缓处理：导出菜单
格式、设置 schema 以及作为贡献的项目类型行为。

## 模块解析

因为包是以源码形式被使用的，同一个别名要在三个
地方声明，添加包时这三处必须保持同步：

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 扫描
包的源码以查找类名。

仅限 Vite 的导入（`?worker&url` worker、CSS 导入）可以原样在包的源码中使用，
因为别名指向源码。不要让任何会拖入
pdf.js 的内容进入 node 环境测试所导入的桶文件：这就是为什么
`pdf-extract` 和预览包的查看器只能在存在
DOM 的地方访问。

## 开发这些包

- **测试**放在源码旁边（`packages/*/src/*.test.ts`），并与应用测试一起在
  同一个 `pnpm test` 中运行。移动文件时留意测试的*数量*。
- **依赖**声明得比较宽松（`"react": "*"`）。pnpm 会与根目录去重，
  真正的版本下限在根目录。不要声明包
  没有导入的依赖。
- **任何包更改的关卡**：`pnpm exec tsc --noEmit`、`pnpm build`、
  `pnpm test`（数量不变），以及上面的纯度 grep。
- **提取新的东西**：用 `git mv` 移动文件（历史会跟随），
  定义端口，在旧路径上留下 shim，接好三处别名，然后运行
  这些关卡。不要在正积极迭代一个功能时提取它。
- **许可证**：每个包都是 `AGPL-3.0-or-later`，与应用相同。

## 留在应用中的内容

`src/` 保留真正属于前端应用的一切：Zustand store、
Tauri IPC 客户端（`src/lib/tauri.ts`）、shadcn UI 套件、布局/外壳
组件、端口适配器和 shim、`src/contributions/`，以及
本质上与 store 耦合的功能粘合代码（预检面板 UI、
行内 AI 编辑插件，以及作为 `extraExtensions` 注入编辑器的代码智能或悬停智能）。Tauri 命令和操作系统集成留在
`src-tauri/` 中。提供方客户端、流式传输和智能体编排属于
`crates/oleafly-agent/`，这样密钥和提供方请求就留在 webview 之外。
