コンテンツにスキップ
Oleaflyv0.4.7

フロントエンドのアーキテクチャ

Oleafly が、デスクトップアプリをフロントエンドのワークスペース、Rust のサービス、固定されたサイドカーにどのように分けているかを説明します。

Oleafly のフロントエンドは pnpm ワークスペースです。アプリシェルはリポジトリのルート(src/)にあり、再利用できるフロントエンドのコードは、役割ごとに分かれた 12 個のパッケージとして packages/* にあります。Tauri のバックエンドは src-tauri/ にあり、再利用できる AI プロバイダとエージェントのコードは crates/oleafly-agent/ にあります。フロントエンド、Rust、サイドカーの全体像は README のアーキテクチャ図 (新しいタブで開きます)を、日常的なコマンドは開発を参照してください。

このドキュメントでは、各パッケージの役割、パッケージを純粋に保つ依存性逆転のルール、機能をシェルに接続するコントリビューションレジストリ、モジュール解決のしくみ、パッケージの追加と切り出しの方法を説明します。

以前は、どの機能もアプリの Zustand ストア、Tauri の IPC クライアント、shadcn の UI キットに直接アクセスしていました。そのため機能をシェルから切り離せず、単独でテストしたり、再利用したり、理解したりすることができませんでした。ワークスペースは、この関係を逆転させます。パッケージは機能のロジックを持ち、必要なものすべてについて、ポートと呼ぶ狭いインターフェースを宣言します。アプリは、自身のストアと 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コントリビューションレジストリ:レールのタブ、パレットとオムニバーのコマンド、AI ツールセット(詳細は後述)なし(コントリビューションが自分の動作を持ちます)react(型)
@oleafly/preflightプリフライトエンジン:文書の種類の判定、ソース、PDF、参照のルール、ATS の解析、アクセシブルなエクスポートの準備、スコアリング。pdf-extract は @oleafly/preflight/pdf-extract サブパスとして提供され、pdf.js が Node のテストの依存グラフに入らないようにしていますなし(純粋)pdfjs-dist(サブパスのみ)
@oleafly/pdf-to-latexローカルで動く、PDF から LaTeX への復元パイプライン:行のグルーピング、見出しと段組みの検出、スタイルの推定、記号の対応付け、図の抽出、エスケープ、文書の組み立て。pdf.js のアダプターは、同じく Node のテストの依存グラフに入れないために、@oleafly/pdf-to-latex/pdf-adapter サブパスとして提供されますなし(純粋)pdfjs-dist(サブパスのみ)
@oleafly/wysiwyg機能を絞ったビジュアルエディタのコア:共通のスキーマ、LaTeX と Markdown のパースとシリアライズ、未対応のソースを破棄せずに見える状態で残す生のインラインノードとブロックノードなし(純粋)なし
@oleafly/editorLaTeX、Typst、Markdown 向けの CodeMirror のコア:CodeMirrorEditor、エディタビューのコントローラー(insertAtCursor、gotoLine、…)、言語と補完、テーマ、折りたたみ、固定される見出し、リンター、latex-mask、数式プレビュー(LaTeX は KaTeX、Typst は Typst)、Typst の入力補助と LaTeX から Typst への数式変換、LaTeX と Typst でその場で切り替わるビジュアルモード(@oleafly/editor/visual)、検索パネル、スペルと文法のリンターEditorHost(文書モデルと設定。フック形式)、SpellHost(スペルチェッカー、Harper、辞書)、setBibKeysProvider、extraExtensions/extraKeymap@codemirror/*、@replit/codemirror-vim、@replit/codemirror-emacs、katex
@oleafly/preview仮想化された pdf.js ビューア(PdfViewer)、SyncTeX のページコントローラー(gotoRect、pageClickToBp)、pdf.js ワーカー、WebView のポリフィル(@oleafly/preview/polyfills。main.tsx で最初にインポートされます)onOpenLink プロパティ(システムブラウザーでリンクを開く)、setPdfLoggerpdfjs-dist
@oleafly/diagramTikZ、Typst(fletcher)、Mermaid 向けのビジュアルなダイアグラムコンポーザー:React Flow のキャンバス、図形インスペクタ、描画とコードのタブ、コンパイル、プレビュー、挿入の流れ。src/languages/ の言語ごとに、ライター、リーダー、挿入用とファイル用の形式があり、キャンバスとコードは双方向で同期されますDiagramHost(LaTeX のコンパイル、Typst と Mermaid のレンダリング、ファイル入出力、エディタへの挿入、AI による修正)、DiagramKit コンテキスト(Button/Tooltip/Select/toast/theme)@xyflow/react、@codemirror/*、@oleafly/latex、@oleafly/editor
@oleafly/ai-toolsプロジェクト、リサーチ、メモリ、図のツール定義。承認を考慮した変更を伴いますAiToolsHost(ファイル、コンパイル、シンボル索引、図のパイプライン、エディタ)@oleafly/latex
@oleafly/templates新規プロジェクトのテンプレートギャラリー(2 ステップのウィザード、カテゴリ、プレビュー、進捗付きの 1 回限りのアセットダウンロード)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 のストア、@tauri-apps/*、アプリの UI コンポーネントから、決してインポートしてはいけません。この純粋性のチェックは、パッケージを切り出すときの衛生管理の一部です。

ターミナルウィンドウ
grep -rn 'from "@/\|@tauri\|zustand' packages/*/src # must return nothing

パッケージはインターフェースを宣言します。アプリはそれを 1 回だけ、通常はストアをクロージャで参照するモジュールレベルのアダプターオブジェクトとして実装します。繰り返し現れる形が 4 つあります。

ホストポートは、パッケージごとに 1 つある interface XHost で、そのパッケージが必要とするサービスをまとめたものです。アプリは、ストアの命令的な読み取りでアダプターを作ります。

// 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) は、何もしないデフォルトを持ち、アプリのシムがインポートの副作用としてインストールします。

切り出しのたびに、古い @/… のパスにパッケージを再エクスポート(またはラップ)する薄いファイルを残したので、利用側や既存の vi.mock(...) のパスは変わりませんでした。例:src/components/editor/cm/controller.ts(再エクスポート)、src/components/pdf/PdfViewer.tsx(シェルプラグインのリンクオープナーを注入するラッパー)、src/lib/ai-tools.ts(アダプターと元のファクトリのシグネチャ)。新しいコードは @oleafly/* を直接インポートしてかまいません。シムは互換性のために存在するもので、推奨される経路ではありません。

コントリビューションレジストリ

「コントリビューションレジストリ」セクション

@oleafly/registry は、シェルが個々の機能を知らなくても、機能をアプリのシェルに接続するしくみです。コントリビューションには 3 つの種類があります。

  • レールのタブ(registerRailTab):アイコン、セクション(explore / review / assist。空でないセクションの間に区切り線が引かれます)、任意の when(ctx) による表示条件、任意の useBadge() フック(例:Git の変更数)、そしてサイドバーの panel コンポーネントです。
  • コマンド(registerCommand):surfaces によって、コマンドパレット(Cmd+K、group ごとにグループ化)かオムニバー(Cmd+P)、またはその両方に提供されます。ラベルとアイコンは、コンテキストの関数でもかまいません(例:「ライトテーマに切り替え」)。
  • AI ツールセット(registerAiToolset):チャットモード("chat"、"figure")をツールセットのファクトリに対応付けます。チャットパネルは、ツールセットを決め打ちするのではなく、現在のモードのツールセットを検索します。

AppContext({ projectId, projectKind, theme })は、描画を行う各画面が自分の購読から組み立てるため、when と label の条件はリアクティブなままです。

登録は静的です。src/contributions/ が組み込みのものすべて(タブ、コマンド、ツールセット)を登録し、main.tsx がシェルをマウントする前に registerContributions() を 1 回呼び出します。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 ではありません。形は自由に変更される可能性があり、動的に読み込まれるものもありません。実際の利用者が現れるまで見送っているのは、エクスポートメニューの形式、設定のスキーマ、プロジェクト種別ごとの動作をコントリビューションとして扱うことです。

パッケージはソースのまま使われるため、同じエイリアスが 3 か所で宣言されており、パッケージを追加するときは 3 か所すべてを同期させておく必要があります。

  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 のワーカー、CSS のインポート)は、エイリアスがソースを指しているため、パッケージのソースからでもそのまま動きます。pdf.js を引き込むものは、Node 環境のテストがインポートするバレルファイルに入れないでください。pdf-extract と preview パッケージのビューアが、DOM のある場所からしか到達できないのは、そのためです。

  • テストはソースの隣(packages/*/src/*.test.ts)にあり、アプリのテストと同じ pnpm test で実行されます。ファイルを移動するときは、テストの件数に注意してください。
  • 依存関係は緩く宣言します("react": "*")。実際のバージョンの下限があるルートに対して、pnpm が重複を排除します。パッケージがインポートしない依存関係は、宣言しないでください。
  • パッケージを変更するときのゲート:pnpm exec tsc --noEmit、pnpm build、pnpm test(件数が変わらないこと)、そして上記の純粋性の grep です。
  • 新しく切り出すとき:git mv でファイルを移動し(履歴も引き継がれます)、ポートを定義し、古いパスにシムを残し、3 つのエイリアスを接続してから、ゲートを実行します。活発に作り込んでいる最中の機能は、切り出さないでください。
  • ライセンス:すべてのパッケージは、アプリと同じ AGPL-3.0-or-later です。

src/ には、フロントエンドアプリそのものであるものがすべて残ります。Zustand のストア、Tauri の IPC クライアント(src/lib/tauri.ts)、shadcn の UI キット、レイアウトとシェルのコンポーネント、ポートのアダプターとシム、src/contributions/、そして性質上ストアと結び付いている機能のつなぎ込み(プリフライトパネルの UI、インライン AI 編集プラグイン、extraExtensions としてエディタに注入されるコードインテリジェンスやホバーインテリジェンス)です。Tauri のコマンドとオペレーティングシステムとの連携は src-tauri/ に残ります。プロバイダのクライアント、ストリーミング、エージェントのオーケストレーションは crates/oleafly-agent/ に置きます。シークレットとプロバイダへのリクエストを、webview の外に置くためです。

画像のプレビュー

AI の回答を読み込めませんでした

もう一度試すか、ドキュメントを検索してください。