前端架构
Oleafly 如何在前端工作区、Rust 服务和固定版本的 sidecar 之间划分桌面应用。
Oleafly 的前端是一个 pnpm 工作区:应用外壳位于仓库根目录
(src/),可复用的前端代码以十二个职责单一的包的形式放在 packages/* 中。
Tauri 后端位于 src-tauri/,可复用的 AI 提供方和智能体代码位于 crates/oleafly-agent/。前端、Rust 和 sidecar 的整体情况见 README 中的架构图 (在新标签页中打开),日常
命令见开发。
本文涵盖:每个包是什么,让它们保持纯净的依赖反转规则,把功能接入外壳的 贡献注册表,模块解析的工作方式,以及如何添加或提取一个包。
为什么用工作区
标题为“为什么用工作区”的部分以前,每个功能都直接伸手去用应用的 Zustand store、Tauri IPC 客户端和 shadcn UI 套件。这让功能与外壳密不可分: 没有什么东西可以被单独测试、复用或推理。工作区把这一点倒了过来。包承载功能逻辑,并为它需要的一切声明 称为端口的窄接口。应用在自己的 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 | 贡献注册表:侧栏标签页、面板/omnibar 命令、AI 工具集(详见下文) | 无(贡献自带行为) | 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 组件导入。纯度检查是提取过程中卫生措施的一部分:
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 上下文(或属性):
Button: ComponentType<{ variant?: …; onClick?: () => void }>。应用把
它的 shadcn 组件原样传入,因为较小的结构化类型是兼容的。
套件还携带 toast 和一个 useThemeMode() 钩子。
钩子形端口:用于必须订阅应用状态的包。
编辑器的 EditorHost 有钩子成员(useActivePath()、useSettings()),
以及供事件监听器使用的命令式对应成员(getActivePath())。规则:宿主
对象必须在模块层级(身份稳定),它的 use* 成员必须无条件调用钩子。
模块单例:用于横切的粘合代码,setPdfLogger(fn)、
setSpellHost(host)、setBibKeysProvider(fn) 有空操作的默认值,
并由应用的 shim 作为导入的副作用来安装。
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 只渲染已注册的内容。
要添加某个功能的标签页或命令,注册它即可。无需修改外壳:
// 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 以及作为贡献的项目类型行为。
模块解析
标题为“模块解析”的部分因为包是以源码形式被使用的,同一个别名要在三个 地方声明,添加包时这三处必须保持同步:
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 扫描
包的源码以查找类名。
仅限 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 之外。