跳转到内容
Oleaflyv0.4.7

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)、搜索面板、拼写/语法 linterEditorHost(文档模型和设置,钩子形)、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 属性(系统浏览器链接)、setPdfLoggerpdfjs-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 状态存储、UI 套件、Tauri 客户端和端口适配器。适配器实现 @oleafly 功能包声明的端口(例如 EditorHost),并由应用注入。功能包可以依赖 latex、ai-core 和 registry 等纯叶子包。Tauri 客户端通过 Tauri IPC 与 Rust 后端通信。包永远不会导入应用、它的状态存储或 Tauri API。应用外壳,src/@oleafly/* 包状态存储Zustand适配器实现端口UI 套件shadcnTauri 客户端src/lib/tauri.ts功能包声明端口,如 EditorHost叶子包latex, ai-core, registryRust 后端src-tauri, crates/oleafly-agent注入Tauri IPC包永远不会导入应用、它的状态存储或 Tauri API。应用外壳与各个包如何相互依赖src/ 中的应用外壳包含 Zustand 状态存储、UI 套件、Tauri 客户端和端口适配器。适配器实现 @oleafly 功能包声明的端口(例如 EditorHost),并由应用注入。功能包可以依赖 latex、ai-core 和 registry 等纯叶子包。Tauri 客户端通过 Tauri IPC 与 Rust 后端通信。包永远不会导入应用、它的状态存储或 Tauri API。应用外壳,src/@oleafly/* 包状态存储UI 套件Tauri 客户端适配器Rust 后端src-tauri, crates功能包声明端口叶子包latex, ai-core, registryTauri IPC注入包永远不会导入应用、它的状态存储或 Tauri API。
应用依赖各个包,并为它们声明的端口注入适配器。包永远不会导入应用。

包绝不能从 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 作为导入的副作用来安装。

每次提取都在旧的 @/… 路径上留下一个很薄的文件,用来重新导出(或 包装)这个包,所以使用方和现有的 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 以及作为贡献的项目类型行为。

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

  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 之外。

图片预览

无法加载 AI 回答

请重试,或搜索文档。