Mayo commited on
Commit
c1298ea
·
1 Parent(s): fe48401

refactor: rewrite backend storage, config, and runtime

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Cargo.lock +26 -2
  2. Cargo.toml +3 -0
  3. bun.lock +3 -0
  4. docs/en-US/reference/http-api.md +15 -15
  5. docs/en-US/reference/mcp-tools.md +2 -2
  6. docs/ja-JP/reference/http-api.md +14 -14
  7. docs/ja-JP/reference/mcp-tools.md +2 -2
  8. docs/zh-CN/reference/http-api.md +14 -14
  9. docs/zh-CN/reference/mcp-tools.md +2 -2
  10. koharu-app/Cargo.toml +4 -2
  11. koharu-app/src/blob_store.rs +46 -0
  12. koharu-app/src/config.rs +317 -128
  13. koharu-app/src/{ops/edit.rs → edit.rs} +191 -483
  14. koharu-app/src/{ops/core.rs → io.rs} +49 -48
  15. koharu-app/src/lib.rs +9 -5
  16. koharu-app/src/llm.rs +385 -75
  17. koharu-app/src/manifest.rs +104 -0
  18. koharu-app/src/ml.rs +61 -1
  19. koharu-app/src/operations.rs +0 -1
  20. koharu-app/src/ops/llm.rs +0 -191
  21. koharu-app/src/ops/mod.rs +0 -13
  22. koharu-app/src/ops/process.rs +0 -56
  23. koharu-app/src/ops/vision.rs +0 -72
  24. koharu-app/src/page_cache.rs +289 -0
  25. koharu-app/src/pipeline.rs +171 -121
  26. koharu-app/src/state_tx.rs +0 -224
  27. koharu-app/src/{ops/utils.rs → utils.rs} +5 -10
  28. koharu-core/src/commands.rs +68 -235
  29. koharu-core/src/lib.rs +6 -32
  30. koharu-core/src/protocol.rs +113 -69
  31. koharu-llm/src/api.rs +0 -60
  32. koharu-llm/src/lib.rs +0 -1
  33. koharu-llm/src/providers/chat_completions.rs +80 -0
  34. koharu-llm/src/providers/deepseek.rs +16 -50
  35. koharu-llm/src/providers/mod.rs +227 -41
  36. koharu-llm/src/providers/openai.rs +16 -48
  37. koharu-llm/src/providers/openai_compatible.rs +22 -84
  38. koharu-llm/tests/llm.rs +12 -7
  39. koharu-psd/examples/full_fixture.rs +0 -1
  40. koharu-psd/tests/export.rs +0 -1
  41. koharu-rpc/src/api.rs +291 -524
  42. koharu-rpc/src/lib.rs +1 -1
  43. koharu-rpc/src/mcp/mod.rs +123 -96
  44. koharu-rpc/src/shared.rs +4 -59
  45. koharu-rpc/src/tracker.rs +23 -89
  46. koharu-runtime/Cargo.toml +1 -0
  47. koharu-runtime/src/archive.rs +1 -1
  48. koharu-runtime/src/artifacts.rs +1 -1
  49. koharu-runtime/src/cuda.rs +1 -1
  50. koharu-runtime/src/http.rs +4 -10
Cargo.lock CHANGED
@@ -3908,6 +3908,7 @@ name = "koharu"
3908
  version = "0.41.4"
3909
  dependencies = [
3910
  "anyhow",
 
3911
  "clap",
3912
  "dirs",
3913
  "git-version",
@@ -3923,6 +3924,7 @@ dependencies = [
3923
  "serde_json",
3924
  "tauri",
3925
  "tauri-build",
 
3926
  "tauri-plugin-updater",
3927
  "tokio",
3928
  "tracing",
@@ -3937,6 +3939,8 @@ name = "koharu-app"
3937
  version = "0.41.4"
3938
  dependencies = [
3939
  "anyhow",
 
 
3940
  "dirs",
3941
  "image",
3942
  "imageproc",
@@ -3945,13 +3949,13 @@ dependencies = [
3945
  "koharu-ml",
3946
  "koharu-renderer",
3947
  "koharu-runtime",
3948
- "once_cell",
3949
  "open",
3950
  "rayon",
3951
  "reqwest-middleware",
3952
  "rfd",
3953
  "serde",
3954
- "serde_bytes",
3955
  "strum",
3956
  "tempfile",
3957
  "tokio",
@@ -4119,6 +4123,7 @@ version = "0.41.4"
4119
  dependencies = [
4120
  "anyhow",
4121
  "axum",
 
4122
  "dirs",
4123
  "flate2",
4124
  "futures",
@@ -4324,6 +4329,15 @@ dependencies = [
4324
  "imgref",
4325
  ]
4326
 
 
 
 
 
 
 
 
 
 
4327
  [[package]]
4328
  name = "lru-slab"
4329
  version = "0.1.2"
@@ -7640,6 +7654,16 @@ dependencies = [
7640
  "walkdir",
7641
  ]
7642
 
 
 
 
 
 
 
 
 
 
 
7643
  [[package]]
7644
  name = "tauri-plugin-updater"
7645
  version = "2.10.0"
 
3908
  version = "0.41.4"
3909
  dependencies = [
3910
  "anyhow",
3911
+ "camino",
3912
  "clap",
3913
  "dirs",
3914
  "git-version",
 
3924
  "serde_json",
3925
  "tauri",
3926
  "tauri-build",
3927
+ "tauri-plugin-process",
3928
  "tauri-plugin-updater",
3929
  "tokio",
3930
  "tracing",
 
3939
  version = "0.41.4"
3940
  dependencies = [
3941
  "anyhow",
3942
+ "blake3",
3943
+ "camino",
3944
  "dirs",
3945
  "image",
3946
  "imageproc",
 
3949
  "koharu-ml",
3950
  "koharu-renderer",
3951
  "koharu-runtime",
3952
+ "lru",
3953
  "open",
3954
  "rayon",
3955
  "reqwest-middleware",
3956
  "rfd",
3957
  "serde",
3958
+ "serde_json",
3959
  "strum",
3960
  "tempfile",
3961
  "tokio",
 
4123
  dependencies = [
4124
  "anyhow",
4125
  "axum",
4126
+ "camino",
4127
  "dirs",
4128
  "flate2",
4129
  "futures",
 
4329
  "imgref",
4330
  ]
4331
 
4332
+ [[package]]
4333
+ name = "lru"
4334
+ version = "0.14.0"
4335
+ source = "registry+https://github.com/rust-lang/crates.io-index"
4336
+ checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198"
4337
+ dependencies = [
4338
+ "hashbrown 0.15.5",
4339
+ ]
4340
+
4341
  [[package]]
4342
  name = "lru-slab"
4343
  version = "0.1.2"
 
7654
  "walkdir",
7655
  ]
7656
 
7657
+ [[package]]
7658
+ name = "tauri-plugin-process"
7659
+ version = "2.3.1"
7660
+ source = "registry+https://github.com/rust-lang/crates.io-index"
7661
+ checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a"
7662
+ dependencies = [
7663
+ "tauri",
7664
+ "tauri-plugin",
7665
+ ]
7666
+
7667
  [[package]]
7668
  name = "tauri-plugin-updater"
7669
  version = "2.10.0"
Cargo.toml CHANGED
@@ -74,6 +74,7 @@ toml = "1.1"
74
  inventory = "0.3"
75
  url = { version = "2.5", features = ["serde"] }
76
  arc-swap = "1.7"
 
77
  indexmap = "2.11"
78
  flate2 = { version = "1.0", default-features = false, features = [
79
  "zlib-ng-compat",
@@ -134,6 +135,8 @@ tower-http = { version = "0.6", features = ["cors"] }
134
  criterion = { version = "0.8", features = ["html_reports"] }
135
  git-version = "0.3"
136
  keyring = "3.6"
 
 
137
 
138
  [patch.crates-io]
139
  candle-transformers = { git = "https://github.com/mayocream/candle", branch = "cuda-dynamic-loading" }
 
74
  inventory = "0.3"
75
  url = { version = "2.5", features = ["serde"] }
76
  arc-swap = "1.7"
77
+ camino = { version = "1.1", features = ["serde1"] }
78
  indexmap = "2.11"
79
  flate2 = { version = "1.0", default-features = false, features = [
80
  "zlib-ng-compat",
 
135
  criterion = { version = "0.8", features = ["html_reports"] }
136
  git-version = "0.3"
137
  keyring = "3.6"
138
+ lru = "0.14"
139
+ tauri-plugin-process = "2.3"
140
 
141
  [patch.crates-io]
142
  candle-transformers = { git = "https://github.com/mayocream/candle", branch = "cuda-dynamic-loading" }
bun.lock CHANGED
@@ -32,6 +32,7 @@
32
  "@tanstack/react-query": "^5.96.1",
33
  "@tanstack/react-virtual": "^3.13.23",
34
  "@tauri-apps/api": "^2.10.1",
 
35
  "@use-gesture/react": "^10.3.1",
36
  "@xstate/react": "^6.1.0",
37
  "browser-fs-access": "^0.38.0",
@@ -432,6 +433,8 @@
432
 
433
  "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
434
 
 
 
435
  "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
436
 
437
  "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
 
32
  "@tanstack/react-query": "^5.96.1",
33
  "@tanstack/react-virtual": "^3.13.23",
34
  "@tauri-apps/api": "^2.10.1",
35
+ "@tauri-apps/plugin-process": "^2.3.1",
36
  "@use-gesture/react": "^10.3.1",
37
  "@xstate/react": "^6.1.0",
38
  "browser-fs-access": "^0.38.0",
 
433
 
434
  "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="],
435
 
436
+ "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="],
437
+
438
  "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
439
 
440
  "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
docs/en-US/reference/http-api.md CHANGED
@@ -120,24 +120,24 @@ Single-document export endpoints return binary file content. Bulk export returns
120
 
121
  | Method | Path | Purpose |
122
  | --- | --- | --- |
123
- | `GET` | `/llm/models` | list local and API-backed translation models |
124
- | `GET` | `/llm/state` | get the current LLM status |
125
- | `POST` | `/llm/load` | load a local or API-backed model |
126
- | `POST` | `/llm/offload` | unload the current model |
127
- | `POST` | `/llm/ping` | test an OpenAI-compatible base URL |
128
 
129
  Useful request details:
130
 
131
- - `/llm/models` accepts optional `language` and `openaiCompatibleBaseUrl` query parameters
132
- - `/llm/load` accepts `id`, `apiKey`, `baseUrl`, `temperature`, `maxTokens`, and `customSystemPrompt`
133
- - `/llm/ping` accepts `baseUrl` and optional `apiKey`
134
 
135
- ## Provider API keys
136
 
137
- | Method | Path | Purpose |
138
- | --- | --- | --- |
139
- | `GET` | `/providers/{provider}/api-key` | read a saved API key for a provider |
140
- | `PUT` | `/providers/{provider}/api-key` | store or overwrite a provider API key |
 
141
 
142
  Current built-in provider ids include:
143
 
@@ -157,7 +157,7 @@ Current built-in provider ids include:
157
  The pipeline job request can include:
158
 
159
  - `documentId` to target one page, or omit it to process all loaded pages
160
- - LLM settings such as `llmModelId`, `llmApiKey`, `llmBaseUrl`, `llmTemperature`, `llmMaxTokens`, and `llmCustomSystemPrompt`
161
  - render settings such as `shaderEffect`, `shaderStroke`, and `fontFamily`
162
  - `language`
163
 
@@ -187,7 +187,7 @@ The normal API order for one page is:
187
  1. `POST /documents/import?mode=replace`
188
  2. `POST /documents/{documentId}/detect`
189
  3. `POST /documents/{documentId}/ocr`
190
- 4. `POST /llm/load`
191
  5. `POST /documents/{documentId}/translate`
192
  6. `POST /documents/{documentId}/inpaint`
193
  7. `POST /documents/{documentId}/render`
 
120
 
121
  | Method | Path | Purpose |
122
  | --- | --- | --- |
123
+ | `GET` | `/llm/catalog` | list the grouped local/provider LLM catalog |
124
+ | `GET` | `/llm` | get the current LLM status |
125
+ | `PUT` | `/llm` | load a local or provider-backed model target |
126
+ | `DELETE` | `/llm` | unload the current model |
 
127
 
128
  Useful request details:
129
 
130
+ - `/llm/catalog` accepts optional `language`
131
+ - `PUT /llm` accepts `target` plus optional `options { temperature, maxTokens, customSystemPrompt }`
132
+ - provider targets use `{ kind: "provider", providerId, modelId }`; local targets use `{ kind: "local", modelId }`
133
 
134
+ ## Provider configuration
135
 
136
+ Provider settings now live under `GET /config` and `PUT /config`.
137
+
138
+ - non-secret provider fields such as `baseUrl` are stored in `llm.providers`
139
+ - provider reads expose `hasApiKey`, not the raw saved key
140
+ - provider updates can set or clear API keys through `PUT /config`
141
 
142
  Current built-in provider ids include:
143
 
 
157
  The pipeline job request can include:
158
 
159
  - `documentId` to target one page, or omit it to process all loaded pages
160
+ - `llm { target, options }` to choose a local/provider model and optional generation overrides
161
  - render settings such as `shaderEffect`, `shaderStroke`, and `fontFamily`
162
  - `language`
163
 
 
187
  1. `POST /documents/import?mode=replace`
188
  2. `POST /documents/{documentId}/detect`
189
  3. `POST /documents/{documentId}/ocr`
190
+ 4. `PUT /llm`
191
  5. `POST /documents/{documentId}/translate`
192
  6. `POST /documents/{documentId}/inpaint`
193
  7. `POST /documents/{documentId}/render`
docs/en-US/reference/mcp-tools.md CHANGED
@@ -71,7 +71,7 @@ Valid `view_text_block` layers:
71
  | `ocr` | run OCR on detected blocks | `index` |
72
  | `inpaint` | remove text using the current mask | `index` |
73
  | `render` | draw translated text back onto the page | `index`, optional `text_block_index`, `shader_effect`, `font_family` |
74
- | `process` | start detect -> OCR -> inpaint -> translate -> render | optional `index`, `llm_model_id`, `language`, `shader_effect`, `font_family` |
75
 
76
  `process` is the coarse-grained convenience tool. If you need more control or easier debugging, use the stage tools separately.
77
 
@@ -79,7 +79,7 @@ Valid `view_text_block` layers:
79
 
80
  | Tool | What it does | Key parameters |
81
  | --- | --- | --- |
82
- | `llm_load` | load a translation model | `id`, optional `temperature`, `max_tokens`, `custom_system_prompt` |
83
  | `llm_offload` | unload the current model | none |
84
  | `llm_generate` | translate one block or all blocks | `index`, optional `text_block_index`, `language` |
85
 
 
71
  | `ocr` | run OCR on detected blocks | `index` |
72
  | `inpaint` | remove text using the current mask | `index` |
73
  | `render` | draw translated text back onto the page | `index`, optional `text_block_index`, `shader_effect`, `font_family` |
74
+ | `process` | start detect -> OCR -> inpaint -> translate -> render | optional `document_id`, `llm_target`, `language`, `shader_effect`, `font_family` |
75
 
76
  `process` is the coarse-grained convenience tool. If you need more control or easier debugging, use the stage tools separately.
77
 
 
79
 
80
  | Tool | What it does | Key parameters |
81
  | --- | --- | --- |
82
+ | `llm_load` | load a translation model target | `target`, optional `options.temperature`, `options.max_tokens`, `options.custom_system_prompt` |
83
  | `llm_offload` | unload the current model | none |
84
  | `llm_generate` | translate one block or all blocks | `index`, optional `text_block_index`, `language` |
85
 
docs/ja-JP/reference/http-api.md CHANGED
@@ -120,24 +120,24 @@ import エンドポイントは、`files` フィールドを繰り返し持つ m
120
 
121
  | Method | Path | 目的 |
122
  | --- | --- | --- |
123
- | `GET` | `/llm/models` | ローカルおよび API ベースの翻訳モデル一覧を取得する |
124
- | `GET` | `/llm/state` | 現在の LLM 状態を取得する |
125
- | `POST` | `/llm/load` | ローカルまたは API ベースのモデルを読み込む |
126
- | `POST` | `/llm/offload` | 現在のモデルをアンロードする |
127
- | `POST` | `/llm/ping` | OpenAI 互換 base URL を接続確認する |
128
 
129
  実用上のリクエスト詳細:
130
 
131
- - `/llm/models` は `language` と `openaiCompatibleBaseUrl` の query parameter 任意で受け付けます
132
- - `/llm/load` は `id``apiKey`、`baseUrl`、`temperature`、`maxTokens`、`customSystemPrompt` を受け付けます
133
- - `/llm/ping` は `baseUrl` と任意の `apiKey` を受け付けま
134
 
135
- ## プロバイダ API キー
136
 
137
- | Method | Path | 目的 |
138
- | --- | --- | --- |
139
- | `GET` | `/providers/{provider}/api-key` | 保存済み API キーを読み出 |
140
- | `PUT` | `/providers/{provider}/api-key` | プロバイダ API キーを保存また上書きする |
 
141
 
142
  現在の組み込み provider id は次です。
143
 
@@ -157,7 +157,7 @@ import エンドポイントは、`files` フィールドを繰り返し持つ m
157
  pipeline job リクエストには次を含められます。
158
 
159
  - `documentId` を指定すると 1 ページ対象、省略すると読み込み済み全ページ対象
160
- - `llmModelId`、`llmApiKey`、`llmBaseUrl`、`llmTemperature`、`llmMaxTokens`、`llmCustomSystemPrompt` などの LLM 設定
161
  - `shaderEffect`、`shaderStroke`、`fontFamily` などの render 設定
162
  - `language`
163
 
 
120
 
121
  | Method | Path | 目的 |
122
  | --- | --- | --- |
123
+ | `GET` | `/llm/catalog` | ローカル/プロバイダ別に整理された LLM カタログを取得する |
124
+ | `GET` | `/llm` | 現在の LLM 状態を取得する |
125
+ | `PUT` | `/llm` | ローカルまたはプロバイダ target を読み込む |
126
+ | `DELETE` | `/llm` | 現在のモデルをアンロードする |
 
127
 
128
  実用上のリクエスト詳細:
129
 
130
+ - `/llm/catalog` は任意で `language` を受け付けます
131
+ - `PUT /llm` は `target` と任意の `options { temperature, maxTokens, customSystemPrompt }` を受け付けます
132
+ - provider target は `{ kind: "provider", providerId, modelId }`、local target は `{ kind: "local", modelId }`
133
 
134
+ ## プロバイダ設定
135
 
136
+ プロバイダ設定は `GET /config` `PUT /config` に統合されました。
137
+
138
+ - `llm.providers` `baseUrl` など非シクレット設定保存しま
139
+ - 読み出しでは生の API キーは返さず、`hasApiKey` のみ返します
140
+ - API キーの設定/削除も `PUT /config` で行います
141
 
142
  現在の組み込み provider id は次です。
143
 
 
157
  pipeline job リクエストには次を含められます。
158
 
159
  - `documentId` を指定すると 1 ページ対象、省略すると読み込み済み全ページ対象
160
+ - `llm { target, options }` による LLM 選択と任意の生成オプション
161
  - `shaderEffect`、`shaderStroke`、`fontFamily` などの render 設定
162
  - `language`
163
 
docs/ja-JP/reference/mcp-tools.md CHANGED
@@ -71,7 +71,7 @@ http://127.0.0.1:<PORT>/mcp
71
  | `ocr` | 検出済みブロックに OCR をかける | `index` |
72
  | `inpaint` | 現在の mask を使って文字を除去する | `index` |
73
  | `render` | 翻訳済みテキストをページに描き戻す | `index`, 任意で `text_block_index`, `shader_effect`, `font_family` |
74
- | `process` | detect -> OCR -> inpaint -> translate -> render をまとめて開始する | 任意で `index`, `llm_model_id`, `language`, `shader_effect`, `font_family` |
75
 
76
  `process` は粗粒度の convenience tool です。より細かな制御や切り分けが必要なら、各段階ツールを個別に使ってください。
77
 
@@ -79,7 +79,7 @@ http://127.0.0.1:<PORT>/mcp
79
 
80
  | Tool | 役割 | 主な引数 |
81
  | --- | --- | --- |
82
- | `llm_load` | 翻訳モデルを読み込む | `id`, 任意で `temperature`, `max_tokens`, `custom_system_prompt` |
83
  | `llm_offload` | 現在のモデルをアンロードする | なし |
84
  | `llm_generate` | 1 ブロックまたは全ブロックを翻訳する | `index`, 任意で `text_block_index`, `language` |
85
 
 
71
  | `ocr` | 検出済みブロックに OCR をかける | `index` |
72
  | `inpaint` | 現在の mask を使って文字を除去する | `index` |
73
  | `render` | 翻訳済みテキストをページに描き戻す | `index`, 任意で `text_block_index`, `shader_effect`, `font_family` |
74
+ | `process` | detect -> OCR -> inpaint -> translate -> render をまとめて開始する | 任意で `document_id`, `llm_target`, `language`, `shader_effect`, `font_family` |
75
 
76
  `process` は粗粒度の convenience tool です。より細かな制御や切り分けが必要なら、各段階ツールを個別に使ってください。
77
 
 
79
 
80
  | Tool | 役割 | 主な引数 |
81
  | --- | --- | --- |
82
+ | `llm_load` | 翻訳モデル target を読み込む | `target`, 任意で `options.temperature`, `options.max_tokens`, `options.custom_system_prompt` |
83
  | `llm_offload` | 現在のモデルをアンロードする | なし |
84
  | `llm_generate` | 1 ブロックまたは全ブロックを翻訳する | `index`, 任意で `text_block_index`, `language` |
85
 
docs/zh-CN/reference/http-api.md CHANGED
@@ -120,24 +120,24 @@ http://127.0.0.1:<PORT>/api/v1
120
 
121
  | 方法 | 路径 | 用途 |
122
  | --- | --- | --- |
123
- | `GET` | `/llm/models` | 列出本地 API 支持的翻译模型 |
124
- | `GET` | `/llm/state` | 获取当前 LLM 状态 |
125
- | `POST` | `/llm/load` | 加载本地或 API 模型 |
126
- | `POST` | `/llm/offload` | 卸载当前模型 |
127
- | `POST` | `/llm/ping` | 测试 OpenAI 兼容 base URL |
128
 
129
  常用请求细节:
130
 
131
- - `/llm/models` 支持可选查询参数 `language` 和 `openaiCompatibleBaseUrl`
132
- - `/llm/load` 接受 `id``apiKey`、`baseUrl`、`temperature`、`maxTokens` `customSystemPrompt`
133
- - `/llm/ping` 接受 `baseUrl` 以及可选 `apiKey`
134
 
135
- ### 提供商 API Key
136
 
137
- | 方法 | 路径 | 用途 |
138
- | --- | --- | --- |
139
- | `GET` | `/providers/{provider}/api-key` | 读取已保存的提供商 API key |
140
- | `PUT` | `/providers/{provider}/api-key` | 存储或覆盖 API key |
 
141
 
142
  当前内置 provider id 包括:
143
 
@@ -157,7 +157,7 @@ http://127.0.0.1:<PORT>/api/v1
157
  管线任务请求可以包含:
158
 
159
  - `documentId`:只处理某一页;留空时处理所有已加载页面
160
- - LLM 设置,例如 `llmModelId`、`llmApiKey`、`llmBaseUrl`、`llmTemperature`、`llmMaxTokens`、`llmCustomSystemPrompt`
161
  - 渲染设置,例如 `shaderEffect`、`shaderStroke`、`fontFamily`
162
  - `language`
163
 
 
120
 
121
  | 方法 | 路径 | 用途 |
122
  | --- | --- | --- |
123
+ | `GET` | `/llm/catalog` | 获取按本地/提供商分组的 LLM 目录 |
124
+ | `GET` | `/llm` | 获取当前 LLM 状态 |
125
+ | `PUT` | `/llm` | 加载本地或提供商 target |
126
+ | `DELETE` | `/llm` | 卸载当前模型 |
 
127
 
128
  常用请求细节:
129
 
130
+ - `/llm/catalog` 支持可选查询参数 `language`
131
+ - `PUT /llm` 接受 `target` 与可选 `options { temperature, maxTokens, customSystemPrompt }`
132
+ - provider target 使用 `{ kind: "provider", providerId, modelId }`,local target 使用 `{ kind: "local", modelId }`
133
 
134
+ ### 提供商配置
135
 
136
+ 提供商设置现在统一放在 `GET /config` `PUT /config` 中。
137
+
138
+ - `llm.providers` 保存 `baseUrl` 等非秘密配置
139
+ - 读取配置时只返回 `hasApiKey`,不会返回原始 API key
140
+ - 设置或清除 API key 也通过 `PUT /config` 完成
141
 
142
  当前内置 provider id 包括:
143
 
 
157
  管线任务请求可以包含:
158
 
159
  - `documentId`:只处理某一页;留空时处理所有已加载页面
160
+ - `llm { target, options }`:选择 LLM 目标以及可选生成参数
161
  - 渲染设置,例如 `shaderEffect`、`shaderStroke`、`fontFamily`
162
  - `language`
163
 
docs/zh-CN/reference/mcp-tools.md CHANGED
@@ -71,7 +71,7 @@ http://127.0.0.1:<PORT>/mcp
71
  | `ocr` | 对检测块执行 OCR | `index` |
72
  | `inpaint` | 使用当前掩码去除文字 | `index` |
73
  | `render` | 把译文绘制回页面 | `index`、可选 `text_block_index`、`shader_effect`、`font_family` |
74
- | `process` | 依次执行 detect -> OCR -> inpaint -> translate -> render | 可选 `index`、`llm_model_id`、`language`、`shader_effect`、`font_family` |
75
 
76
  `process` 是粗粒度的便捷工具。如果你想要更细的控制或更好排查问题,建议拆开使用各阶段工具。
77
 
@@ -79,7 +79,7 @@ http://127.0.0.1:<PORT>/mcp
79
 
80
  | 工具 | 作用 | 关键参数 |
81
  | --- | --- | --- |
82
- | `llm_load` | 加载一个翻译模型 | `id`、可选 `temperature`、`max_tokens`、`custom_system_prompt` |
83
  | `llm_offload` | 卸载当前模型 | 无 |
84
  | `llm_generate` | 翻译单个文本块或全部文本块 | `index`、可选 `text_block_index`、`language` |
85
 
 
71
  | `ocr` | 对检测块执行 OCR | `index` |
72
  | `inpaint` | 使用当前掩码去除文字 | `index` |
73
  | `render` | 把译文绘制回页面 | `index`、可选 `text_block_index`、`shader_effect`、`font_family` |
74
+ | `process` | 依次执行 detect -> OCR -> inpaint -> translate -> render | 可选 `document_id`、`llm_target`、`language`、`shader_effect`、`font_family` |
75
 
76
  `process` 是粗粒度的便捷工具。如果你想要更细的控制或更好排查问题,建议拆开使用各阶段工具。
77
 
 
79
 
80
  | 工具 | 作用 | 关键参数 |
81
  | --- | --- | --- |
82
+ | `llm_load` | 加载一个翻译模型 target | `target`、可选 `options.temperature`、`options.max_tokens`、`options.custom_system_prompt` |
83
  | `llm_offload` | 卸载当前模型 | 无 |
84
  | `llm_generate` | 翻译单个文本块或全部文本块 | `index`、可选 `text_block_index`、`language` |
85
 
koharu-app/Cargo.toml CHANGED
@@ -18,16 +18,15 @@ koharu-renderer = { workspace = true }
18
  koharu-llm = { workspace = true }
19
  koharu-runtime = { workspace = true }
20
  anyhow = { workspace = true }
 
21
  dirs = { workspace = true }
22
  image = { workspace = true }
23
  imageproc = { workspace = true }
24
- once_cell = { workspace = true }
25
  open = { workspace = true }
26
  rayon = { workspace = true }
27
  rfd = { workspace = true }
28
  reqwest-middleware = { workspace = true }
29
  serde = { workspace = true }
30
- serde_bytes = { workspace = true }
31
  strum = { workspace = true }
32
  tempfile = { workspace = true }
33
  tokio = { workspace = true }
@@ -35,3 +34,6 @@ toml = { workspace = true }
35
  tracing = { workspace = true }
36
  url = { workspace = true }
37
  uuid = { workspace = true }
 
 
 
 
18
  koharu-llm = { workspace = true }
19
  koharu-runtime = { workspace = true }
20
  anyhow = { workspace = true }
21
+ camino = { workspace = true }
22
  dirs = { workspace = true }
23
  image = { workspace = true }
24
  imageproc = { workspace = true }
 
25
  open = { workspace = true }
26
  rayon = { workspace = true }
27
  rfd = { workspace = true }
28
  reqwest-middleware = { workspace = true }
29
  serde = { workspace = true }
 
30
  strum = { workspace = true }
31
  tempfile = { workspace = true }
32
  tokio = { workspace = true }
 
34
  tracing = { workspace = true }
35
  url = { workspace = true }
36
  uuid = { workspace = true }
37
+ blake3 = { workspace = true }
38
+ lru = { workspace = true }
39
+ serde_json = { workspace = true }
koharu-app/src/blob_store.rs ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+
3
+ use anyhow::{Context, Result};
4
+
5
+ #[derive(Clone)]
6
+ pub struct BlobStore {
7
+ root: PathBuf,
8
+ }
9
+
10
+ impl BlobStore {
11
+ pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
12
+ let root = root.into();
13
+ std::fs::create_dir_all(&root)?;
14
+ Ok(Self { root })
15
+ }
16
+
17
+ /// Write bytes to the store, return the blake3 hash hex string.
18
+ pub fn put(&self, data: &[u8]) -> Result<String> {
19
+ let hash = blake3::hash(data).to_hex().to_string();
20
+ let path = self.blob_path(&hash);
21
+ if path.exists() {
22
+ return Ok(hash); // already stored (content-addressable)
23
+ }
24
+ if let Some(parent) = path.parent() {
25
+ std::fs::create_dir_all(parent)?;
26
+ }
27
+ std::fs::write(&path, data).with_context(|| format!("Failed to write blob {hash}"))?;
28
+ Ok(hash)
29
+ }
30
+
31
+ /// Read bytes from the store by hash.
32
+ pub fn get(&self, hash: &str) -> Result<Vec<u8>> {
33
+ let path = self.blob_path(hash);
34
+ std::fs::read(&path).with_context(|| format!("Blob not found: {hash}"))
35
+ }
36
+
37
+ /// Check if a blob exists.
38
+ pub fn exists(&self, hash: &str) -> bool {
39
+ self.blob_path(hash).exists()
40
+ }
41
+
42
+ fn blob_path(&self, hash: &str) -> PathBuf {
43
+ let (prefix, rest) = hash.split_at(2.min(hash.len()));
44
+ self.root.join(prefix).join(rest)
45
+ }
46
+ }
koharu-app/src/config.rs CHANGED
@@ -1,65 +1,97 @@
 
1
  use std::fs;
2
  use std::io::Write;
3
- use std::path::PathBuf;
4
 
5
  use anyhow::{Context, Result};
6
- use koharu_core::{BootstrapConfig, BootstrapHttpConfig, BootstrapPathConfig};
7
- use koharu_runtime::Settings;
 
 
 
 
 
 
 
 
8
  use tempfile::NamedTempFile;
9
- use url::Url;
10
 
11
- const CONFIG_DIR: &str = ".koharu";
12
  const CONFIG_FILE: &str = "config.toml";
 
13
 
14
- pub type AppConfig = Settings;
 
 
 
 
 
 
 
 
 
 
 
15
 
16
- pub fn config_dir() -> Result<PathBuf> {
17
- let home =
18
- dirs::home_dir().ok_or_else(|| anyhow::anyhow!("failed to resolve home directory"))?;
19
- Ok(home.join(CONFIG_DIR))
20
  }
21
 
22
- pub fn config_path() -> Result<PathBuf> {
23
- Ok(config_dir()?.join(CONFIG_FILE))
 
 
24
  }
25
 
26
- pub fn load() -> Result<AppConfig> {
 
 
 
 
 
 
 
 
 
 
 
 
27
  let path = config_path()?;
28
  if !path.exists() {
29
- return Ok(AppConfig::default());
 
 
30
  }
31
 
32
- let content = fs::read_to_string(&path)
33
- .with_context(|| format!("failed to read `{}`", path.display()))?;
34
- toml::from_str(&content).with_context(|| format!("failed to parse `{}`", path.display()))
35
  }
36
 
37
- pub fn save(config: &AppConfig) -> Result<()> {
38
- let dir = config_dir()?;
39
- fs::create_dir_all(&dir)
40
- .with_context(|| format!("failed to create config dir `{}`", dir.display()))?;
 
 
41
 
42
- let path = dir.join(CONFIG_FILE);
43
  let content = toml::to_string_pretty(config).context("failed to serialize app config")?;
44
- let mut temp = NamedTempFile::new_in(&dir)
45
- .with_context(|| format!("failed to stage `{}`", path.display()))?;
46
  temp.write_all(content.as_bytes())
47
- .with_context(|| format!("failed to write temp config for `{}`", path.display()))?;
48
  temp.flush()
49
- .with_context(|| format!("failed to flush temp config for `{}`", path.display()))?;
50
 
51
  match temp.persist(&path) {
52
  Ok(_) => Ok(()),
53
  Err(err) => {
54
  if path.exists() {
55
- fs::remove_file(&path).with_context(|| {
56
- format!("failed to replace existing config `{}`", path.display())
57
- })?;
58
  }
59
  err.file.persist(&path).map(|_| ()).map_err(|persist_err| {
60
  anyhow::anyhow!(
61
- "failed to persist config to `{}`: {}",
62
- path.display(),
63
  persist_err.error
64
  )
65
  })
@@ -67,141 +99,298 @@ pub fn save(config: &AppConfig) -> Result<()> {
67
  }
68
  }
69
 
70
- pub fn to_bootstrap_config(config: &AppConfig) -> BootstrapConfig {
71
- BootstrapConfig {
72
- runtime: BootstrapPathConfig {
73
- path: config.runtime.path.to_string_lossy().to_string(),
74
- },
75
- models: BootstrapPathConfig {
76
- path: config.models.path.to_string_lossy().to_string(),
77
- },
78
- http: BootstrapHttpConfig {
79
- proxy: config.http_proxy().map(Url::to_string),
 
 
 
 
 
 
 
80
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  }
 
 
 
 
 
82
  }
83
 
84
- pub fn from_bootstrap_config(config: BootstrapConfig) -> Result<AppConfig> {
85
- let runtime_path = config.runtime.path.trim();
86
- let models_path = config.models.path.trim();
87
- anyhow::ensure!(!runtime_path.is_empty(), "runtime path is required");
88
- anyhow::ensure!(!models_path.is_empty(), "models path is required");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
- let proxy = config
91
- .http
92
- .proxy
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  .as_deref()
94
  .map(str::trim)
95
  .filter(|value| !value.is_empty())
96
- .map(Url::parse)
97
- .transpose()
98
- .context("invalid HTTP proxy URL")?;
99
 
100
- Ok(
101
- AppConfig::from_paths(PathBuf::from(runtime_path), PathBuf::from(models_path))
102
- .with_proxy(proxy),
103
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  }
105
 
106
  #[cfg(test)]
107
  mod tests {
108
- use std::env;
109
- use std::path::Path;
 
 
 
 
110
 
111
  use super::{
112
- AppConfig, BootstrapConfig, BootstrapHttpConfig, BootstrapPathConfig,
113
- from_bootstrap_config, to_bootstrap_config,
114
  };
115
 
116
  #[test]
117
- fn bootstrap_round_trip_preserves_paths_and_proxy() {
118
- let config = BootstrapConfig {
119
- runtime: BootstrapPathConfig {
120
- path: "/tmp/runtime".to_string(),
121
- },
122
- models: BootstrapPathConfig {
123
- path: "/tmp/models".to_string(),
124
- },
125
- http: BootstrapHttpConfig {
126
- proxy: Some("http://127.0.0.1:7890".to_string()),
127
- },
128
- };
129
 
130
- let app = from_bootstrap_config(config.clone()).unwrap();
131
- let serialized = to_bootstrap_config(&app);
132
 
133
- assert_eq!(serialized.runtime.path, config.runtime.path);
134
- assert_eq!(serialized.models.path, config.models.path);
135
- assert_eq!(
136
- serialized
137
- .http
138
- .proxy
139
- .as_deref()
140
- .map(url::Url::parse)
141
- .transpose()
142
- .unwrap(),
143
- config
144
- .http
145
- .proxy
146
- .as_deref()
147
- .map(url::Url::parse)
148
- .transpose()
149
- .unwrap()
150
- );
151
  }
152
 
153
  #[test]
154
- fn blank_proxy_becomes_none() {
155
- let app = from_bootstrap_config(BootstrapConfig {
156
- runtime: BootstrapPathConfig {
157
- path: ".".to_string(),
158
  },
159
- models: BootstrapPathConfig {
160
- path: ".".to_string(),
161
- },
162
- http: BootstrapHttpConfig {
163
- proxy: Some(" ".to_string()),
 
 
164
  },
165
  })
166
  .unwrap();
167
 
168
- assert!(app.http_proxy().is_none());
 
 
 
 
 
 
 
 
 
 
 
169
  }
170
 
171
  #[test]
172
- fn empty_paths_are_rejected() {
173
- let error = from_bootstrap_config(BootstrapConfig {
174
- runtime: BootstrapPathConfig {
175
- path: " ".to_string(),
176
- },
177
- models: BootstrapPathConfig {
178
- path: ".".to_string(),
179
  },
180
- http: BootstrapHttpConfig::default(),
181
  })
182
- .expect_err("empty runtime path must be rejected");
183
 
184
- assert!(error.to_string().contains("runtime path is required"));
185
  }
186
 
187
  #[test]
188
- fn defaults_use_runtime_owned_paths() {
189
- let config = AppConfig::default();
190
- assert!(
191
- config.runtime.path.is_absolute() || config.runtime.path == Path::new("").to_path_buf()
192
- );
193
- assert!(
194
- config.models.path.is_absolute() || config.models.path == Path::new("").to_path_buf()
195
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  }
 
197
  #[test]
198
- fn config_path_uses_home_dir_layout() {
199
- let path = super::config_path().unwrap();
200
- assert_eq!(
201
- path.file_name().and_then(|name| name.to_str()),
202
- Some("config.toml")
203
- );
204
- assert!(path.to_string_lossy().contains(".koharu"));
205
- let _ = env::var_os("HOME");
206
  }
207
  }
 
1
+ use std::collections::{BTreeMap, HashSet};
2
  use std::fs;
3
  use std::io::Write;
 
4
 
5
  use anyhow::{Context, Result};
6
+ use camino::{Utf8Path, Utf8PathBuf};
7
+ use koharu_core::{
8
+ AppConfig as PublicAppConfig, AppConfigUpdate, AppDataConfig, AppLlmConfig,
9
+ AppLlmProviderConfig, AppLlmProviderConfigUpdate,
10
+ };
11
+ use koharu_llm::providers::{
12
+ all_provider_descriptors, find_provider_descriptor, get_saved_api_key, set_saved_api_key,
13
+ };
14
+ use koharu_runtime::default_app_data_root;
15
+ use serde::{Deserialize, Serialize};
16
  use tempfile::NamedTempFile;
 
17
 
 
18
  const CONFIG_FILE: &str = "config.toml";
19
+ const MANAGED_DATA_DIRS: &[&str] = &["runtime", "models", "blobs", "pages"];
20
 
21
+ #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
22
+ #[serde(default)]
23
+ pub struct StoredConfig {
24
+ pub data: StoredDataConfig,
25
+ pub llm: StoredLlmConfig,
26
+ }
27
+
28
+ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29
+ #[serde(default)]
30
+ pub struct StoredDataConfig {
31
+ pub path: Utf8PathBuf,
32
+ }
33
 
34
+ #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
35
+ #[serde(default)]
36
+ pub struct StoredLlmConfig {
37
+ pub providers: BTreeMap<String, StoredProviderConfig>,
38
  }
39
 
40
+ #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
41
+ #[serde(default)]
42
+ pub struct StoredProviderConfig {
43
+ pub base_url: Option<String>,
44
  }
45
 
46
+ impl Default for StoredDataConfig {
47
+ fn default() -> Self {
48
+ Self {
49
+ path: default_app_data_root(),
50
+ }
51
+ }
52
+ }
53
+
54
+ pub fn config_path() -> Result<Utf8PathBuf> {
55
+ Ok(default_app_data_root().join(CONFIG_FILE))
56
+ }
57
+
58
+ pub fn load() -> Result<StoredConfig> {
59
  let path = config_path()?;
60
  if !path.exists() {
61
+ let config = StoredConfig::default();
62
+ save(&config)?;
63
+ return Ok(config);
64
  }
65
 
66
+ let content = fs::read_to_string(&path).with_context(|| format!("failed to read `{path}`"))?;
67
+ toml::from_str(&content).with_context(|| format!("failed to parse `{path}`"))
 
68
  }
69
 
70
+ pub fn save(config: &StoredConfig) -> Result<()> {
71
+ let path = config_path()?;
72
+ let dir = path
73
+ .parent()
74
+ .ok_or_else(|| anyhow::anyhow!("config path `{path}` does not have a parent directory"))?;
75
+ fs::create_dir_all(dir).with_context(|| format!("failed to create config dir `{dir}`"))?;
76
 
 
77
  let content = toml::to_string_pretty(config).context("failed to serialize app config")?;
78
+ let mut temp =
79
+ NamedTempFile::new_in(dir).with_context(|| format!("failed to stage `{path}`"))?;
80
  temp.write_all(content.as_bytes())
81
+ .with_context(|| format!("failed to write temp config for `{path}`"))?;
82
  temp.flush()
83
+ .with_context(|| format!("failed to flush temp config for `{path}`"))?;
84
 
85
  match temp.persist(&path) {
86
  Ok(_) => Ok(()),
87
  Err(err) => {
88
  if path.exists() {
89
+ fs::remove_file(&path)
90
+ .with_context(|| format!("failed to replace existing config `{path}`"))?;
 
91
  }
92
  err.file.persist(&path).map(|_| ()).map_err(|persist_err| {
93
  anyhow::anyhow!(
94
+ "failed to persist config to `{path}`: {}",
 
95
  persist_err.error
96
  )
97
  })
 
99
  }
100
  }
101
 
102
+ pub fn to_public_config(config: &StoredConfig) -> Result<PublicAppConfig> {
103
+ let mut providers = Vec::new();
104
+ for descriptor in all_provider_descriptors() {
105
+ let stored = config.llm.providers.get(descriptor.id);
106
+ let has_api_key = get_saved_api_key(descriptor.id)?
107
+ .map(|value| !value.trim().is_empty())
108
+ .unwrap_or(false);
109
+ providers.push(AppLlmProviderConfig {
110
+ id: descriptor.id.to_string(),
111
+ base_url: stored.and_then(|provider| provider.base_url.clone()),
112
+ has_api_key,
113
+ });
114
+ }
115
+
116
+ Ok(PublicAppConfig {
117
+ data: AppDataConfig {
118
+ path: config.data.path.to_string(),
119
  },
120
+ llm: AppLlmConfig { providers },
121
+ })
122
+ }
123
+
124
+ pub fn from_public_update(config: AppConfigUpdate) -> Result<StoredConfig> {
125
+ validate_provider_updates(&config)?;
126
+
127
+ let data_path = config.data.path.trim();
128
+ anyhow::ensure!(!data_path.is_empty(), "data path is required");
129
+ let data_path = Utf8PathBuf::from(data_path);
130
+ anyhow::ensure!(data_path.is_absolute(), "data path must be absolute");
131
+
132
+ let mut providers = BTreeMap::new();
133
+ for provider in config.llm.providers {
134
+ let id = provider.id.trim();
135
+ let descriptor = find_provider_descriptor(id)
136
+ .ok_or_else(|| anyhow::anyhow!("unknown provider id: {id}"))?;
137
+ let base_url = normalized_base_url(&provider);
138
+
139
+ if base_url.is_some()
140
+ || provider.api_key.is_some()
141
+ || provider.clear_api_key
142
+ || descriptor.requires_base_url
143
+ {
144
+ providers.insert(id.to_string(), StoredProviderConfig { base_url });
145
+ }
146
  }
147
+
148
+ Ok(StoredConfig {
149
+ data: StoredDataConfig { path: data_path },
150
+ llm: StoredLlmConfig { providers },
151
+ })
152
  }
153
 
154
+ pub fn move_app_data_if_needed(current: &StoredConfig, next: &StoredConfig) -> Result<bool> {
155
+ if same_path(&current.data.path, &next.data.path) {
156
+ return Ok(false);
157
+ }
158
+
159
+ fs::create_dir_all(&next.data.path)
160
+ .with_context(|| format!("failed to create `{}`", next.data.path))?;
161
+
162
+ for name in MANAGED_DATA_DIRS {
163
+ move_path(
164
+ current.data.path.join(name).as_std_path(),
165
+ next.data.path.join(name).as_std_path(),
166
+ )?;
167
+ }
168
+
169
+ Ok(true)
170
+ }
171
+
172
+ pub fn apply_secret_updates(config: &AppConfigUpdate) -> Result<()> {
173
+ validate_provider_updates(config)?;
174
+
175
+ for provider in &config.llm.providers {
176
+ let id = provider.id.trim();
177
+ let api_key = provider.api_key.as_deref().map(str::trim);
178
+ let should_clear = provider.clear_api_key || matches!(api_key, Some(""));
179
+ if should_clear {
180
+ set_saved_api_key(id, "")?;
181
+ continue;
182
+ }
183
+ if let Some(api_key) = api_key
184
+ && !api_key.is_empty()
185
+ {
186
+ set_saved_api_key(id, api_key)?;
187
+ }
188
+ }
189
+
190
+ Ok(())
191
+ }
192
+
193
+ fn same_path(left: &Utf8Path, right: &Utf8Path) -> bool {
194
+ match (
195
+ fs::canonicalize(left.as_std_path()),
196
+ fs::canonicalize(right.as_std_path()),
197
+ ) {
198
+ (Ok(left), Ok(right)) => left == right,
199
+ _ => left == right,
200
+ }
201
+ }
202
+
203
+ fn move_path(source: &std::path::Path, destination: &std::path::Path) -> Result<()> {
204
+ if !source.exists() {
205
+ return Ok(());
206
+ }
207
+
208
+ if let Some(parent) = destination.parent() {
209
+ fs::create_dir_all(parent)
210
+ .with_context(|| format!("failed to create `{}`", parent.display()))?;
211
+ }
212
+
213
+ if !destination.exists() && fs::rename(source, destination).is_ok() {
214
+ return Ok(());
215
+ }
216
+
217
+ if source.is_dir() {
218
+ fs::create_dir_all(destination)
219
+ .with_context(|| format!("failed to create `{}`", destination.display()))?;
220
 
221
+ for entry in fs::read_dir(source)
222
+ .with_context(|| format!("failed to read `{}`", source.display()))?
223
+ {
224
+ let entry = entry.with_context(|| format!("failed to read `{}`", source.display()))?;
225
+ move_path(&entry.path(), &destination.join(entry.file_name()))?;
226
+ }
227
+
228
+ fs::remove_dir_all(source)
229
+ .with_context(|| format!("failed to remove `{}`", source.display()))?;
230
+ } else {
231
+ if destination.exists() {
232
+ fs::remove_file(destination)
233
+ .with_context(|| format!("failed to replace `{}`", destination.display()))?;
234
+ }
235
+ fs::copy(source, destination).with_context(|| {
236
+ format!(
237
+ "failed to copy `{}` to `{}`",
238
+ source.display(),
239
+ destination.display()
240
+ )
241
+ })?;
242
+ fs::remove_file(source)
243
+ .with_context(|| format!("failed to remove `{}`", source.display()))?;
244
+ }
245
+
246
+ Ok(())
247
+ }
248
+ fn normalized_base_url(provider: &AppLlmProviderConfigUpdate) -> Option<String> {
249
+ provider
250
+ .base_url
251
  .as_deref()
252
  .map(str::trim)
253
  .filter(|value| !value.is_empty())
254
+ .map(ToOwned::to_owned)
255
+ }
 
256
 
257
+ fn validate_provider_updates(config: &AppConfigUpdate) -> Result<()> {
258
+ let mut seen = HashSet::new();
259
+ for provider in &config.llm.providers {
260
+ let id = provider.id.trim();
261
+ anyhow::ensure!(!id.is_empty(), "provider id is required");
262
+ anyhow::ensure!(
263
+ find_provider_descriptor(id).is_some(),
264
+ "unknown provider id: {id}"
265
+ );
266
+ anyhow::ensure!(seen.insert(id.to_string()), "duplicate provider id: {id}");
267
+ anyhow::ensure!(
268
+ !(provider.clear_api_key
269
+ && provider
270
+ .api_key
271
+ .as_deref()
272
+ .map(str::trim)
273
+ .is_some_and(|value| !value.is_empty())),
274
+ "provider {id} cannot set and clear api_key in the same update"
275
+ );
276
+ }
277
+
278
+ Ok(())
279
  }
280
 
281
  #[cfg(test)]
282
  mod tests {
283
+ use std::fs;
284
+
285
+ use camino::Utf8PathBuf;
286
+ use koharu_core::{
287
+ AppConfigUpdate, AppDataConfigUpdate, AppLlmConfigUpdate, AppLlmProviderConfigUpdate,
288
+ };
289
 
290
  use super::{
291
+ StoredConfig, config_path, from_public_update, move_app_data_if_needed, to_public_config,
 
292
  };
293
 
294
  #[test]
295
+ fn old_config_without_data_section_still_loads() {
296
+ let config: StoredConfig = toml::from_str(
297
+ r#"
298
+ [runtime]
299
+ path = "/tmp/runtime"
 
 
 
 
 
 
 
300
 
301
+ [models]
302
+ path = "/tmp/models"
303
 
304
+ [http]
305
+ proxy = "http://127.0.0.1:7890"
306
+ "#,
307
+ )
308
+ .unwrap();
309
+
310
+ assert_eq!(config.data.path, koharu_runtime::default_app_data_root());
311
+ assert!(config.llm.providers.is_empty());
 
 
 
 
 
 
 
 
 
 
312
  }
313
 
314
  #[test]
315
+ fn public_update_round_trip_preserves_fields() {
316
+ let config = from_public_update(AppConfigUpdate {
317
+ data: AppDataConfigUpdate {
318
+ path: "/tmp/koharu-data".to_string(),
319
  },
320
+ llm: AppLlmConfigUpdate {
321
+ providers: vec![AppLlmProviderConfigUpdate {
322
+ id: "openai-compatible".to_string(),
323
+ base_url: Some(" http://127.0.0.1:1234/v1/ ".to_string()),
324
+ api_key: None,
325
+ clear_api_key: false,
326
+ }],
327
  },
328
  })
329
  .unwrap();
330
 
331
+ let public = to_public_config(&config).unwrap();
332
+
333
+ assert_eq!(public.data.path, "/tmp/koharu-data");
334
+ assert_eq!(
335
+ public
336
+ .llm
337
+ .providers
338
+ .iter()
339
+ .find(|provider| provider.id == "openai-compatible")
340
+ .and_then(|provider| provider.base_url.as_deref()),
341
+ Some("http://127.0.0.1:1234/v1/")
342
+ );
343
  }
344
 
345
  #[test]
346
+ fn public_update_requires_absolute_data_path() {
347
+ let error = from_public_update(AppConfigUpdate {
348
+ data: AppDataConfigUpdate {
349
+ path: "relative\\koharu-data".to_string(),
 
 
 
350
  },
351
+ llm: AppLlmConfigUpdate::default(),
352
  })
353
+ .unwrap_err();
354
 
355
+ assert!(error.to_string().contains("absolute"));
356
  }
357
 
358
  #[test]
359
+ fn move_app_data_moves_managed_directories() {
360
+ let tempdir = tempfile::tempdir().unwrap();
361
+ let current = tempdir.path().join("old");
362
+ let next = tempdir.path().join("new");
363
+ fs::create_dir_all(current.join("pages")).unwrap();
364
+ fs::create_dir_all(current.join("runtime")).unwrap();
365
+ fs::write(current.join("pages").join("doc.json"), b"{}").unwrap();
366
+ fs::write(current.join("runtime").join("pkg.bin"), b"runtime").unwrap();
367
+
368
+ let current_config = StoredConfig {
369
+ data: super::StoredDataConfig {
370
+ path: Utf8PathBuf::from_path_buf(current.clone()).unwrap(),
371
+ },
372
+ ..StoredConfig::default()
373
+ };
374
+ let next_config = StoredConfig {
375
+ data: super::StoredDataConfig {
376
+ path: Utf8PathBuf::from_path_buf(next.clone()).unwrap(),
377
+ },
378
+ ..StoredConfig::default()
379
+ };
380
+
381
+ let moved = move_app_data_if_needed(&current_config, &next_config).unwrap();
382
+
383
+ assert!(moved);
384
+ assert!(next.join("pages").join("doc.json").exists());
385
+ assert!(next.join("runtime").join("pkg.bin").exists());
386
+ assert!(!current.join("pages").exists());
387
+ assert!(!current.join("runtime").exists());
388
  }
389
+
390
  #[test]
391
+ fn config_path_uses_appdata_layout() {
392
+ let path = config_path().unwrap();
393
+ assert_eq!(path.file_name(), Some("config.toml"));
394
+ assert!(path.as_str().contains("Koharu"));
 
 
 
 
395
  }
396
  }
koharu-app/src/{ops/edit.rs → edit.rs} RENAMED
@@ -1,144 +1,14 @@
1
  use image::DynamicImage;
2
  use image::GenericImageView;
3
  use imageproc::distance_transform::Norm;
4
- use koharu_core::commands::{
5
- AddTextBlockPayload, InpaintPartialPayload, MaskMorphPayload, RemoveTextBlockPayload,
6
- UpdateBrushLayerPayload, UpdateInpaintMaskPayload, UpdateTextBlockPayload,
7
- UpdateTextBlocksPayload,
8
- };
9
  use koharu_core::parse::parse_hex_color;
10
  use koharu_core::views::{TextBlockInfo, to_block_info};
11
  use koharu_core::{SerializableDynamicImage, TextBlock, TextStyle};
12
  use tracing::instrument;
13
 
14
- use crate::{
15
- AppResources,
16
- state_tx::{self, ChangedField},
17
- };
18
-
19
- use super::utils::{InpaintRegionExt, blank_rgba};
20
-
21
- const MATCH_GEOMETRY_EPS: f32 = 0.01;
22
- const MATCH_NEAR_GEOMETRY_DELTA: f32 = 4.0;
23
- const MATCH_TEXT_GEOMETRY_DELTA: f32 = 64.0;
24
-
25
- fn geometry_delta(a: &TextBlock, b: &TextBlock) -> f32 {
26
- (a.x - b.x).abs() + (a.y - b.y).abs() + (a.width - b.width).abs() + (a.height - b.height).abs()
27
- }
28
-
29
- fn geometry_changed(a: &TextBlock, b: &TextBlock) -> bool {
30
- geometry_delta(a, b) > MATCH_GEOMETRY_EPS
31
- }
32
-
33
- fn size_changed(a: &TextBlock, b: &TextBlock) -> bool {
34
- (a.width - b.width).abs() > MATCH_GEOMETRY_EPS
35
- || (a.height - b.height).abs() > MATCH_GEOMETRY_EPS
36
- }
37
-
38
- fn geometry_overlaps(a: &TextBlock, b: &TextBlock) -> bool {
39
- let ax0 = a.x;
40
- let ay0 = a.y;
41
- let ax1 = a.x + a.width;
42
- let ay1 = a.y + a.height;
43
- let bx0 = b.x;
44
- let by0 = b.y;
45
- let bx1 = b.x + b.width;
46
- let by1 = b.y + b.height;
47
-
48
- ax0 < bx1 && ay0 < by1 && ax1 > bx0 && ay1 > by0
49
- }
50
-
51
- fn has_stable_content_identity(a: &TextBlock, b: &TextBlock) -> bool {
52
- let has_content =
53
- a.text.is_some() || a.translation.is_some() || b.text.is_some() || b.translation.is_some();
54
- has_content && a.text == b.text && a.translation == b.translation
55
- }
56
-
57
- fn seed_from_block(block: &TextBlock) -> Option<(f32, f32, f32, f32)> {
58
- match (
59
- block.layout_seed_x,
60
- block.layout_seed_y,
61
- block.layout_seed_width,
62
- block.layout_seed_height,
63
- ) {
64
- (Some(x), Some(y), Some(width), Some(height))
65
- if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 =>
66
- {
67
- Some((x, y, width, height))
68
- }
69
- _ => None,
70
- }
71
- }
72
-
73
- fn find_matching_previous(
74
- current: &TextBlock,
75
- current_index: usize,
76
- previous: &[TextBlock],
77
- used_previous: &[bool],
78
- ) -> Option<usize> {
79
- if current_index < previous.len() && !used_previous[current_index] {
80
- let indexed = &previous[current_index];
81
- let delta = geometry_delta(current, indexed);
82
- if delta <= MATCH_NEAR_GEOMETRY_DELTA
83
- || geometry_overlaps(current, indexed)
84
- || has_stable_content_identity(current, indexed)
85
- {
86
- return Some(current_index);
87
- }
88
- }
89
-
90
- let mut best_idx = None;
91
- let mut best_delta = f32::INFINITY;
92
-
93
- for (idx, prev) in previous.iter().enumerate() {
94
- if used_previous[idx] {
95
- continue;
96
- }
97
- let delta = geometry_delta(current, prev);
98
- if delta < best_delta {
99
- best_idx = Some(idx);
100
- best_delta = delta;
101
- }
102
- }
103
-
104
- let candidate_idx = best_idx?;
105
- let candidate = &previous[candidate_idx];
106
- if best_delta <= MATCH_NEAR_GEOMETRY_DELTA {
107
- return Some(candidate_idx);
108
- }
109
-
110
- if has_stable_content_identity(current, candidate) && best_delta <= MATCH_TEXT_GEOMETRY_DELTA {
111
- return Some(candidate_idx);
112
- }
113
-
114
- None
115
- }
116
-
117
- fn rehydrate_runtime_text_block_state(current: &mut TextBlock, previous: Option<&TextBlock>) {
118
- let Some(prev) = previous else {
119
- current.lock_layout_box = false;
120
- current.set_layout_seed(current.x, current.y, current.width, current.height);
121
- return;
122
- };
123
-
124
- if current.id.trim().is_empty() {
125
- current.id = prev.id.clone();
126
- }
127
-
128
- current.lock_layout_box = if size_changed(current, prev) {
129
- true
130
- } else {
131
- prev.lock_layout_box
132
- };
133
-
134
- if geometry_changed(current, prev) {
135
- current.set_layout_seed(current.x, current.y, current.width, current.height);
136
- } else if let Some((x, y, width, height)) = seed_from_block(prev) {
137
- current.set_layout_seed(x, y, width, height);
138
- } else {
139
- current.set_layout_seed(current.x, current.y, current.width, current.height);
140
- }
141
- }
142
 
143
  fn block_bounds(block: &TextBlock) -> Option<(f32, f32, f32, f32)> {
144
  let bx0 = block.x.max(0.0);
@@ -210,219 +80,198 @@ fn paste_crop(stitched: &mut image::RgbaImage, patch: &image::RgbaImage, x0: u32
210
 
211
  pub async fn update_text_blocks(
212
  state: AppResources,
213
- payload: UpdateTextBlocksPayload,
 
214
  ) -> anyhow::Result<()> {
215
- state_tx::mutate_doc(
216
- &state.state,
217
- payload.index,
218
- &[ChangedField::TextBlocks],
219
- |document| {
220
- let previous = std::mem::take(&mut document.text_blocks);
221
- document.text_blocks = payload.text_blocks;
222
-
223
- let mut used_previous = vec![false; previous.len()];
224
- for (block_index, block) in document.text_blocks.iter_mut().enumerate() {
225
- let matched_idx =
226
- find_matching_previous(block, block_index, &previous, &used_previous);
227
- if let Some(idx) = matched_idx {
228
- used_previous[idx] = true;
229
- rehydrate_runtime_text_block_state(block, Some(&previous[idx]));
230
- } else {
231
- rehydrate_runtime_text_block_state(block, None);
232
- }
233
- }
234
- Ok(())
235
- },
236
- )
237
- .await
238
  }
239
 
240
  pub async fn update_text_block(
241
  state: AppResources,
242
- payload: UpdateTextBlockPayload,
 
243
  ) -> anyhow::Result<TextBlockInfo> {
244
- state_tx::mutate_doc(
245
- &state.state,
246
- payload.index,
247
- &[ChangedField::TextBlocks],
248
- |document| {
249
- let block = document
250
- .text_blocks
251
- .get_mut(payload.text_block_index)
252
- .ok_or_else(|| {
253
- anyhow::anyhow!("Text block {} not found", payload.text_block_index)
254
- })?;
255
- let mut geometry_changed = false;
256
-
257
- if let Some(translation) = payload.translation {
258
- block.translation = Some(translation);
259
- }
260
- if let Some(x) = payload.x {
261
- block.x = x;
262
- geometry_changed = true;
263
- }
264
- if let Some(y) = payload.y {
265
- block.y = y;
266
- geometry_changed = true;
267
- }
268
- if let Some(width) = payload.width {
269
- block.width = width;
270
- geometry_changed = true;
271
- block.lock_layout_box = true;
272
- }
273
- if let Some(height) = payload.height {
274
- block.height = height;
275
- geometry_changed = true;
276
- block.lock_layout_box = true;
277
- }
278
- if geometry_changed {
279
- block.set_layout_seed(block.x, block.y, block.width, block.height);
280
- }
281
 
282
- if payload.font_families.is_some()
283
- || payload.font_size.is_some()
284
- || payload.color.is_some()
285
- || payload.shader_effect.is_some()
286
- {
287
- let style = block.style.get_or_insert_with(|| TextStyle {
288
- font_families: Vec::new(),
289
- font_size: None,
290
- color: [0, 0, 0, 255],
291
- effect: None,
292
- stroke: None,
293
- text_align: None,
294
- });
295
-
296
- if let Some(families) = payload.font_families {
297
- style.font_families = families;
298
- }
299
- if let Some(font_size) = payload.font_size {
300
- style.font_size = Some(font_size);
301
- }
302
- if let Some(hex) = payload.color {
303
- style.color = parse_hex_color(&hex)?;
304
- }
305
- if let Some(effect) = payload.shader_effect {
306
- style.effect = Some(effect.parse()?);
307
- }
308
- }
309
 
310
- block.rendered = None;
311
- block.rendered_direction = None;
312
- Ok(to_block_info(payload.text_block_index, block))
313
- },
314
- )
315
- .await
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  }
317
 
318
  pub async fn add_text_block(
319
  state: AppResources,
320
- payload: AddTextBlockPayload,
 
 
 
 
321
  ) -> anyhow::Result<usize> {
322
- state_tx::mutate_doc(
323
- &state.state,
324
- payload.index,
325
- &[ChangedField::TextBlocks],
326
- |document| {
327
- let mut block = TextBlock {
328
- x: payload.x,
329
- y: payload.y,
330
- width: payload.width,
331
- height: payload.height,
332
- confidence: 1.0,
333
- ..Default::default()
334
- };
335
- block.set_layout_seed(block.x, block.y, block.width, block.height);
336
- document.text_blocks.push(block);
337
- Ok(document.text_blocks.len() - 1)
338
- },
339
- )
340
- .await
341
  }
342
 
343
  pub async fn remove_text_block(
344
  state: AppResources,
345
- payload: RemoveTextBlockPayload,
 
346
  ) -> anyhow::Result<usize> {
347
- state_tx::mutate_doc(
348
- &state.state,
349
- payload.index,
350
- &[ChangedField::TextBlocks],
351
- |document| {
352
- if payload.text_block_index >= document.text_blocks.len() {
353
- anyhow::bail!("Text block {} not found", payload.text_block_index);
354
- }
355
- document.text_blocks.remove(payload.text_block_index);
356
- Ok(document.text_blocks.len())
357
- },
358
- )
359
- .await
360
  }
361
 
362
- pub async fn dilate_mask(state: AppResources, payload: MaskMorphPayload) -> anyhow::Result<()> {
363
- if payload.radius == 0 || payload.radius > 50 {
364
  anyhow::bail!("Radius must be 1-50");
365
  }
366
 
367
- state_tx::mutate_doc(
368
- &state.state,
369
- payload.index,
370
- &[ChangedField::Segment],
371
- |document| {
372
- let segment = document
373
- .segment
374
- .as_ref()
375
- .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?;
376
-
377
- let gray = segment.to_luma8();
378
- let dilated = imageproc::morphology::dilate(&gray, Norm::LInf, payload.radius);
379
- document.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(dilated)));
380
- Ok(())
381
- },
382
- )
383
- .await
384
  }
385
 
386
- pub async fn erode_mask(state: AppResources, payload: MaskMorphPayload) -> anyhow::Result<()> {
387
- if payload.radius == 0 || payload.radius > 50 {
388
  anyhow::bail!("Radius must be 1-50");
389
  }
390
 
391
- state_tx::mutate_doc(
392
- &state.state,
393
- payload.index,
394
- &[ChangedField::Segment],
395
- |document| {
396
- let segment = document
397
- .segment
398
- .as_ref()
399
- .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?;
400
-
401
- let gray = segment.to_luma8();
402
- let eroded = imageproc::morphology::erode(&gray, Norm::LInf, payload.radius);
403
- document.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(eroded)));
404
- Ok(())
405
- },
406
- )
407
- .await
408
  }
409
 
410
  pub async fn update_inpaint_mask(
411
  state: AppResources,
412
- payload: UpdateInpaintMaskPayload,
 
 
413
  ) -> anyhow::Result<()> {
414
- let snapshot = state_tx::read_doc(&state.state, payload.index).await?;
415
 
416
- let update_image = image::load_from_memory(&payload.mask)?;
417
- let (doc_width, doc_height) = (snapshot.width, snapshot.height);
418
 
419
- let mut base_mask = snapshot
420
  .segment
421
  .clone()
422
  .unwrap_or_else(|| blank_rgba(doc_width, doc_height, image::Rgba([0, 0, 0, 255])))
423
  .to_rgba8();
424
 
425
- match payload.region {
426
  Some(region) => {
427
  let (patch_width, patch_height) = update_image.dimensions();
428
  if patch_width != region.width || patch_height != region.height {
@@ -466,43 +315,39 @@ pub async fn update_inpaint_mask(
466
  }
467
  }
468
 
469
- let mut updated = snapshot;
470
- updated.segment = Some(image::DynamicImage::ImageRgba8(base_mask).into());
471
- state_tx::update_doc(
472
- &state.state,
473
- payload.index,
474
- updated,
475
- &[ChangedField::Segment],
476
- )
477
- .await
478
  }
479
 
480
  pub async fn update_brush_layer(
481
  state: AppResources,
482
- payload: UpdateBrushLayerPayload,
 
 
483
  ) -> anyhow::Result<()> {
484
- let snapshot = state_tx::read_doc(&state.state, payload.index).await?;
485
 
486
- let (img_width, img_height) = (snapshot.width, snapshot.height);
487
- let Some((x0, y0, width, height)) = payload.region.clamp(img_width, img_height) else {
488
  return Ok(());
489
  };
490
 
491
- let patch_image = image::load_from_memory(&payload.patch)?;
492
  let (patch_width, patch_height) = patch_image.dimensions();
493
 
494
- if patch_width != payload.region.width || patch_height != payload.region.height {
495
  anyhow::bail!(
496
  "Brush patch size mismatch: expected {}x{}, got {}x{}",
497
- payload.region.width,
498
- payload.region.height,
499
  patch_width,
500
  patch_height
501
  );
502
  }
503
 
504
  let brush_rgba = patch_image.to_rgba8();
505
- let mut brush_layer = snapshot
506
  .brush_layer
507
  .clone()
508
  .unwrap_or_else(|| blank_rgba(img_width, img_height, image::Rgba([0, 0, 0, 0])))
@@ -514,46 +359,38 @@ pub async fn update_brush_layer(
514
  }
515
  }
516
 
517
- let mut updated = snapshot;
518
- updated.brush_layer = Some(image::DynamicImage::ImageRgba8(brush_layer).into());
519
-
520
- state_tx::update_doc(
521
- &state.state,
522
- payload.index,
523
- updated,
524
- &[ChangedField::BrushLayer],
525
- )
526
- .await
527
  }
528
 
529
  #[instrument(level = "info", skip_all)]
530
  pub async fn inpaint_partial(
531
  state: AppResources,
532
- payload: InpaintPartialPayload,
 
533
  ) -> anyhow::Result<()> {
534
- let snapshot = state_tx::read_doc(&state.state, payload.index).await?;
535
 
536
- let mask_image = snapshot
537
  .segment
538
  .as_ref()
539
  .ok_or_else(|| anyhow::anyhow!("Segment image not found"))?;
540
 
541
- if payload.region.width == 0 || payload.region.height == 0 {
542
  return Ok(());
543
  }
544
 
545
- let (img_width, img_height) = (snapshot.width, snapshot.height);
546
- let x0 = payload.region.x.min(img_width.saturating_sub(1));
547
- let y0 = payload.region.y.min(img_height.saturating_sub(1));
548
- let x1 = payload
549
- .region
550
  .x
551
- .saturating_add(payload.region.width)
552
  .min(img_width);
553
- let y1 = payload
554
- .region
555
  .y
556
- .saturating_add(payload.region.height)
557
  .min(img_height);
558
  let crop_width = x1.saturating_sub(x0);
559
  let crop_height = y1.saturating_sub(y0);
@@ -563,13 +400,12 @@ pub async fn inpaint_partial(
563
  }
564
 
565
  let localized_blocks =
566
- localize_inpaint_text_blocks(&snapshot.text_blocks, x0, y0, crop_width, crop_height);
567
  if localized_blocks.is_empty() {
568
  return Ok(());
569
  }
570
 
571
- let image_crop =
572
- SerializableDynamicImage(snapshot.image.crop_imm(x0, y0, crop_width, crop_height));
573
  let mask_crop = SerializableDynamicImage(mask_image.crop_imm(x0, y0, crop_width, crop_height));
574
 
575
  let inpainted_crop = state
@@ -577,85 +413,22 @@ pub async fn inpaint_partial(
577
  .inpaint_raw(&image_crop, &mask_crop, Some(&localized_blocks))
578
  .await?;
579
 
580
- let mut stitched = snapshot
581
- .inpainted
582
- .as_ref()
583
- .unwrap_or(&snapshot.image)
584
- .to_rgba8();
585
 
586
  let patch = inpainted_crop.to_rgba8();
587
  paste_crop(&mut stitched, &patch, x0, y0);
588
 
589
- let mut updated = snapshot;
590
- updated.inpainted = Some(image::DynamicImage::ImageRgba8(stitched).into());
591
-
592
- state_tx::update_doc(
593
- &state.state,
594
- payload.index,
595
- updated,
596
- &[ChangedField::Inpainted],
597
- )
598
- .await
599
  }
600
 
601
  #[cfg(test)]
602
  mod tests {
603
- use super::{
604
- find_matching_previous, localize_inpaint_text_blocks, paste_crop,
605
- rehydrate_runtime_text_block_state,
606
- };
607
  use image::{Rgba, RgbaImage};
608
  use koharu_core::TextBlock;
609
 
610
- #[test]
611
- fn resized_block_locks_layout_box() {
612
- let previous = TextBlock {
613
- x: 10.0,
614
- y: 20.0,
615
- width: 100.0,
616
- height: 80.0,
617
- ..Default::default()
618
- };
619
- let mut current = TextBlock {
620
- x: 10.0,
621
- y: 20.0,
622
- width: 72.0,
623
- height: 80.0,
624
- ..Default::default()
625
- };
626
-
627
- rehydrate_runtime_text_block_state(&mut current, Some(&previous));
628
-
629
- assert!(current.lock_layout_box);
630
- assert_eq!(current.seed_layout_box(), (10.0, 20.0, 72.0, 80.0));
631
- }
632
-
633
- #[test]
634
- fn unchanged_block_preserves_layout_box_lock_and_seed() {
635
- let mut previous = TextBlock {
636
- x: 10.0,
637
- y: 20.0,
638
- width: 100.0,
639
- height: 80.0,
640
- lock_layout_box: true,
641
- ..Default::default()
642
- };
643
- previous.set_layout_seed(5.0, 6.0, 70.0, 60.0);
644
-
645
- let mut current = TextBlock {
646
- x: 10.0,
647
- y: 20.0,
648
- width: 100.0,
649
- height: 80.0,
650
- ..Default::default()
651
- };
652
-
653
- rehydrate_runtime_text_block_state(&mut current, Some(&previous));
654
-
655
- assert!(current.lock_layout_box);
656
- assert_eq!(current.seed_layout_box(), (5.0, 6.0, 70.0, 60.0));
657
- }
658
-
659
  #[test]
660
  fn partial_inpaint_blocks_are_localized_to_crop() {
661
  let block = TextBlock {
@@ -709,69 +482,4 @@ mod tests {
709
  assert_eq!(stitched.get_pixel(4, 4).0, [255, 0, 0, 255]);
710
  assert_eq!(stitched.get_pixel(1, 1).0, [0, 0, 0, 255]);
711
  }
712
-
713
- #[test]
714
- fn matching_previous_prefers_same_index_for_large_manual_resize() {
715
- let previous = vec![
716
- TextBlock {
717
- x: 10.0,
718
- y: 10.0,
719
- width: 40.0,
720
- height: 20.0,
721
- translation: Some("HELLO".to_string()),
722
- ..Default::default()
723
- },
724
- TextBlock {
725
- x: 100.0,
726
- y: 10.0,
727
- width: 40.0,
728
- height: 20.0,
729
- translation: Some("WORLD".to_string()),
730
- ..Default::default()
731
- },
732
- ];
733
-
734
- let current = TextBlock {
735
- x: 10.0,
736
- y: 10.0,
737
- width: 140.0,
738
- height: 80.0,
739
- translation: Some("HELLO".to_string()),
740
- ..Default::default()
741
- };
742
-
743
- let matched = find_matching_previous(&current, 0, &previous, &[false, false]);
744
- assert_eq!(matched, Some(0));
745
- }
746
-
747
- #[test]
748
- fn non_overlapping_same_index_without_identity_does_not_force_match() {
749
- let previous = vec![
750
- TextBlock {
751
- x: 10.0,
752
- y: 10.0,
753
- width: 20.0,
754
- height: 20.0,
755
- ..Default::default()
756
- },
757
- TextBlock {
758
- x: 80.0,
759
- y: 10.0,
760
- width: 20.0,
761
- height: 20.0,
762
- ..Default::default()
763
- },
764
- ];
765
-
766
- let current = TextBlock {
767
- x: 82.0,
768
- y: 12.0,
769
- width: 20.0,
770
- height: 20.0,
771
- ..Default::default()
772
- };
773
-
774
- let matched = find_matching_previous(&current, 0, &previous, &[false, false]);
775
- assert_eq!(matched, Some(1));
776
- }
777
  }
 
1
  use image::DynamicImage;
2
  use image::GenericImageView;
3
  use imageproc::distance_transform::Norm;
4
+ use koharu_core::Region;
 
 
 
 
5
  use koharu_core::parse::parse_hex_color;
6
  use koharu_core::views::{TextBlockInfo, to_block_info};
7
  use koharu_core::{SerializableDynamicImage, TextBlock, TextStyle};
8
  use tracing::instrument;
9
 
10
+ use crate::AppResources;
11
+ use crate::utils::{InpaintRegionExt, blank_rgba};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  fn block_bounds(block: &TextBlock) -> Option<(f32, f32, f32, f32)> {
14
  let bx0 = block.x.max(0.0);
 
80
 
81
  pub async fn update_text_blocks(
82
  state: AppResources,
83
+ document_id: &str,
84
+ text_blocks: Vec<TextBlock>,
85
  ) -> anyhow::Result<()> {
86
+ let mut doc = state.cache.get(document_id).await?;
87
+ doc.text_blocks = text_blocks;
88
+ state.cache.put(&doc).await?;
89
+ Ok(())
90
+ }
91
+
92
+ pub struct UpdateTextBlockArgs {
93
+ pub text_block_index: usize,
94
+ pub translation: Option<String>,
95
+ pub x: Option<f32>,
96
+ pub y: Option<f32>,
97
+ pub width: Option<f32>,
98
+ pub height: Option<f32>,
99
+ pub font_families: Option<Vec<String>>,
100
+ pub font_size: Option<f32>,
101
+ pub color: Option<String>,
102
+ pub shader_effect: Option<String>,
 
 
 
 
 
 
103
  }
104
 
105
  pub async fn update_text_block(
106
  state: AppResources,
107
+ document_id: &str,
108
+ args: UpdateTextBlockArgs,
109
  ) -> anyhow::Result<TextBlockInfo> {
110
+ let mut doc = state.cache.get(document_id).await?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
+ let block = doc
113
+ .text_blocks
114
+ .get_mut(args.text_block_index)
115
+ .ok_or_else(|| anyhow::anyhow!("Text block {} not found", args.text_block_index))?;
116
+ let mut geometry_changed = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
+ if let Some(translation) = args.translation {
119
+ block.translation = Some(translation);
120
+ }
121
+ if let Some(x) = args.x {
122
+ block.x = x;
123
+ geometry_changed = true;
124
+ }
125
+ if let Some(y) = args.y {
126
+ block.y = y;
127
+ geometry_changed = true;
128
+ }
129
+ if let Some(width) = args.width {
130
+ block.width = width;
131
+ geometry_changed = true;
132
+ block.lock_layout_box = true;
133
+ }
134
+ if let Some(height) = args.height {
135
+ block.height = height;
136
+ geometry_changed = true;
137
+ block.lock_layout_box = true;
138
+ }
139
+ if geometry_changed {
140
+ block.set_layout_seed(block.x, block.y, block.width, block.height);
141
+ }
142
+
143
+ if args.font_families.is_some()
144
+ || args.font_size.is_some()
145
+ || args.color.is_some()
146
+ || args.shader_effect.is_some()
147
+ {
148
+ let style = block.style.get_or_insert_with(|| TextStyle {
149
+ font_families: Vec::new(),
150
+ font_size: None,
151
+ color: [0, 0, 0, 255],
152
+ effect: None,
153
+ stroke: None,
154
+ text_align: None,
155
+ });
156
+
157
+ if let Some(families) = args.font_families {
158
+ style.font_families = families;
159
+ }
160
+ if let Some(font_size) = args.font_size {
161
+ style.font_size = Some(font_size);
162
+ }
163
+ if let Some(hex) = args.color {
164
+ style.color = parse_hex_color(&hex)?;
165
+ }
166
+ if let Some(effect) = args.shader_effect {
167
+ style.effect = Some(effect.parse()?);
168
+ }
169
+ }
170
+
171
+ block.rendered = None;
172
+ block.rendered_direction = None;
173
+ let info = to_block_info(args.text_block_index, block);
174
+ state.cache.put(&doc).await?;
175
+ Ok(info)
176
  }
177
 
178
  pub async fn add_text_block(
179
  state: AppResources,
180
+ document_id: &str,
181
+ x: f32,
182
+ y: f32,
183
+ width: f32,
184
+ height: f32,
185
  ) -> anyhow::Result<usize> {
186
+ let mut doc = state.cache.get(document_id).await?;
187
+
188
+ let mut block = TextBlock {
189
+ x,
190
+ y,
191
+ width,
192
+ height,
193
+ confidence: 1.0,
194
+ ..Default::default()
195
+ };
196
+ block.set_layout_seed(block.x, block.y, block.width, block.height);
197
+ doc.text_blocks.push(block);
198
+ let count = doc.text_blocks.len() - 1;
199
+ state.cache.put(&doc).await?;
200
+ Ok(count)
 
 
 
 
201
  }
202
 
203
  pub async fn remove_text_block(
204
  state: AppResources,
205
+ document_id: &str,
206
+ text_block_index: usize,
207
  ) -> anyhow::Result<usize> {
208
+ let mut doc = state.cache.get(document_id).await?;
209
+
210
+ if text_block_index >= doc.text_blocks.len() {
211
+ anyhow::bail!("Text block {} not found", text_block_index);
212
+ }
213
+ doc.text_blocks.remove(text_block_index);
214
+ let count = doc.text_blocks.len();
215
+ state.cache.put(&doc).await?;
216
+ Ok(count)
 
 
 
 
217
  }
218
 
219
+ pub async fn dilate_mask(state: AppResources, document_id: &str, radius: u8) -> anyhow::Result<()> {
220
+ if radius == 0 || radius > 50 {
221
  anyhow::bail!("Radius must be 1-50");
222
  }
223
 
224
+ let mut doc = state.cache.get(document_id).await?;
225
+
226
+ let segment = doc
227
+ .segment
228
+ .as_ref()
229
+ .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?;
230
+
231
+ let gray = segment.to_luma8();
232
+ let dilated = imageproc::morphology::dilate(&gray, Norm::LInf, radius);
233
+ doc.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(dilated)));
234
+ state.cache.put(&doc).await?;
235
+ Ok(())
 
 
 
 
 
236
  }
237
 
238
+ pub async fn erode_mask(state: AppResources, document_id: &str, radius: u8) -> anyhow::Result<()> {
239
+ if radius == 0 || radius > 50 {
240
  anyhow::bail!("Radius must be 1-50");
241
  }
242
 
243
+ let mut doc = state.cache.get(document_id).await?;
244
+
245
+ let segment = doc
246
+ .segment
247
+ .as_ref()
248
+ .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?;
249
+
250
+ let gray = segment.to_luma8();
251
+ let eroded = imageproc::morphology::erode(&gray, Norm::LInf, radius);
252
+ doc.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(eroded)));
253
+ state.cache.put(&doc).await?;
254
+ Ok(())
 
 
 
 
 
255
  }
256
 
257
  pub async fn update_inpaint_mask(
258
  state: AppResources,
259
+ document_id: &str,
260
+ mask: &[u8],
261
+ region: Option<Region>,
262
  ) -> anyhow::Result<()> {
263
+ let mut doc = state.cache.get(document_id).await?;
264
 
265
+ let update_image = image::load_from_memory(mask)?;
266
+ let (doc_width, doc_height) = (doc.width, doc.height);
267
 
268
+ let mut base_mask = doc
269
  .segment
270
  .clone()
271
  .unwrap_or_else(|| blank_rgba(doc_width, doc_height, image::Rgba([0, 0, 0, 255])))
272
  .to_rgba8();
273
 
274
+ match region {
275
  Some(region) => {
276
  let (patch_width, patch_height) = update_image.dimensions();
277
  if patch_width != region.width || patch_height != region.height {
 
315
  }
316
  }
317
 
318
+ doc.segment = Some(image::DynamicImage::ImageRgba8(base_mask).into());
319
+ state.cache.put(&doc).await?;
320
+ Ok(())
 
 
 
 
 
 
321
  }
322
 
323
  pub async fn update_brush_layer(
324
  state: AppResources,
325
+ document_id: &str,
326
+ patch: &[u8],
327
+ brush_region: Region,
328
  ) -> anyhow::Result<()> {
329
+ let mut doc = state.cache.get(document_id).await?;
330
 
331
+ let (img_width, img_height) = (doc.width, doc.height);
332
+ let Some((x0, y0, width, height)) = brush_region.clamp(img_width, img_height) else {
333
  return Ok(());
334
  };
335
 
336
+ let patch_image = image::load_from_memory(patch)?;
337
  let (patch_width, patch_height) = patch_image.dimensions();
338
 
339
+ if patch_width != brush_region.width || patch_height != brush_region.height {
340
  anyhow::bail!(
341
  "Brush patch size mismatch: expected {}x{}, got {}x{}",
342
+ brush_region.width,
343
+ brush_region.height,
344
  patch_width,
345
  patch_height
346
  );
347
  }
348
 
349
  let brush_rgba = patch_image.to_rgba8();
350
+ let mut brush_layer = doc
351
  .brush_layer
352
  .clone()
353
  .unwrap_or_else(|| blank_rgba(img_width, img_height, image::Rgba([0, 0, 0, 0])))
 
359
  }
360
  }
361
 
362
+ doc.brush_layer = Some(image::DynamicImage::ImageRgba8(brush_layer).into());
363
+ state.cache.put(&doc).await?;
364
+ Ok(())
 
 
 
 
 
 
 
365
  }
366
 
367
  #[instrument(level = "info", skip_all)]
368
  pub async fn inpaint_partial(
369
  state: AppResources,
370
+ document_id: &str,
371
+ inpaint_region: Region,
372
  ) -> anyhow::Result<()> {
373
+ let mut doc = state.cache.get(document_id).await?;
374
 
375
+ let mask_image = doc
376
  .segment
377
  .as_ref()
378
  .ok_or_else(|| anyhow::anyhow!("Segment image not found"))?;
379
 
380
+ if inpaint_region.width == 0 || inpaint_region.height == 0 {
381
  return Ok(());
382
  }
383
 
384
+ let (img_width, img_height) = (doc.width, doc.height);
385
+ let x0 = inpaint_region.x.min(img_width.saturating_sub(1));
386
+ let y0 = inpaint_region.y.min(img_height.saturating_sub(1));
387
+ let x1 = inpaint_region
 
388
  .x
389
+ .saturating_add(inpaint_region.width)
390
  .min(img_width);
391
+ let y1 = inpaint_region
 
392
  .y
393
+ .saturating_add(inpaint_region.height)
394
  .min(img_height);
395
  let crop_width = x1.saturating_sub(x0);
396
  let crop_height = y1.saturating_sub(y0);
 
400
  }
401
 
402
  let localized_blocks =
403
+ localize_inpaint_text_blocks(&doc.text_blocks, x0, y0, crop_width, crop_height);
404
  if localized_blocks.is_empty() {
405
  return Ok(());
406
  }
407
 
408
+ let image_crop = SerializableDynamicImage(doc.image.crop_imm(x0, y0, crop_width, crop_height));
 
409
  let mask_crop = SerializableDynamicImage(mask_image.crop_imm(x0, y0, crop_width, crop_height));
410
 
411
  let inpainted_crop = state
 
413
  .inpaint_raw(&image_crop, &mask_crop, Some(&localized_blocks))
414
  .await?;
415
 
416
+ let mut stitched = doc.inpainted.as_ref().unwrap_or(&doc.image).to_rgba8();
 
 
 
 
417
 
418
  let patch = inpainted_crop.to_rgba8();
419
  paste_crop(&mut stitched, &patch, x0, y0);
420
 
421
+ doc.inpainted = Some(image::DynamicImage::ImageRgba8(stitched).into());
422
+ state.cache.put(&doc).await?;
423
+ Ok(())
 
 
 
 
 
 
 
424
  }
425
 
426
  #[cfg(test)]
427
  mod tests {
428
+ use super::{localize_inpaint_text_blocks, paste_crop};
 
 
 
429
  use image::{Rgba, RgbaImage};
430
  use koharu_core::TextBlock;
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  #[test]
433
  fn partial_inpaint_blocks_are_localized_to_crop() {
434
  let block = TextBlock {
 
482
  assert_eq!(stitched.get_pixel(4, 4).0, [255, 0, 0, 255]);
483
  assert_eq!(stitched.get_pixel(1, 1).0, [0, 0, 0, 255]);
484
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  }
koharu-app/src/{ops/core.rs → io.rs} RENAMED
@@ -2,14 +2,12 @@ use std::path::{Path, PathBuf};
2
 
3
  use image::ImageFormat;
4
  use koharu_core::commands::{
5
- DeviceInfo, FileResult, IndexPayload, OpenDocumentsPayload, OpenExternalPayload,
6
- ThumbnailResult,
7
  };
8
  use rfd::FileDialog;
9
 
10
- use crate::{AppResources, state_tx};
11
-
12
- use super::utils::{encode_image, load_documents, mime_from_ext};
13
 
14
  fn next_available_path(output_dir: &Path, stem: &str, ext: &str) -> PathBuf {
15
  let mut candidate = output_dir.join(format!("{stem}.{ext}"));
@@ -25,15 +23,6 @@ async fn pick_output_dir() -> anyhow::Result<Option<PathBuf>> {
25
  Ok(tokio::task::spawn_blocking(|| FileDialog::new().pick_folder()).await?)
26
  }
27
 
28
- fn document_ext(document: &koharu_core::Document) -> String {
29
- document
30
- .path
31
- .extension()
32
- .and_then(|value| value.to_str())
33
- .unwrap_or("jpg")
34
- .to_string()
35
- }
36
-
37
  fn export_documents_matching(
38
  documents: &[koharu_core::Document],
39
  output_dir: &Path,
@@ -48,7 +37,12 @@ fn export_documents_matching(
48
  continue;
49
  };
50
 
51
- let ext = document_ext(document);
 
 
 
 
 
52
  let output_path =
53
  next_available_path(output_dir, &format!("{}_{}", document.name, suffix), &ext);
54
  let bytes = encode_image(image, &ext)?;
@@ -83,22 +77,21 @@ pub async fn open_external(
83
  }
84
 
85
  pub async fn get_documents(state: AppResources) -> anyhow::Result<usize> {
86
- let guard = state.state.read().await;
87
- Ok(guard.documents.len())
88
  }
89
 
90
  pub async fn get_document(
91
  state: AppResources,
92
- payload: IndexPayload,
93
  ) -> anyhow::Result<koharu_core::Document> {
94
- state_tx::read_doc(&state.state, payload.index).await
95
  }
96
 
97
  pub async fn get_thumbnail(
98
  state: AppResources,
99
- payload: IndexPayload,
100
  ) -> anyhow::Result<ThumbnailResult> {
101
- let doc = state_tx::read_doc(&state.state, payload.index).await?;
102
 
103
  let source = doc.rendered.as_ref().unwrap_or(&doc.image);
104
  let thumbnail = source.thumbnail(200, 200);
@@ -116,43 +109,33 @@ pub async fn open_documents(
116
  state: AppResources,
117
  payload: OpenDocumentsPayload,
118
  ) -> anyhow::Result<usize> {
119
- let inputs: Vec<(PathBuf, Vec<u8>)> = payload
120
- .files
121
- .into_iter()
122
- .map(|f| (PathBuf::from(f.name), f.data))
123
- .collect();
124
-
125
- if inputs.is_empty() {
126
  anyhow::bail!("No files uploaded");
127
  }
128
 
129
- let docs = load_documents(inputs)?;
130
- state_tx::replace_docs(&state.state, docs).await
 
 
131
  }
132
 
133
  pub async fn add_documents(
134
  state: AppResources,
135
  payload: OpenDocumentsPayload,
136
  ) -> anyhow::Result<usize> {
137
- let inputs: Vec<(PathBuf, Vec<u8>)> = payload
138
- .files
139
- .into_iter()
140
- .map(|f| (PathBuf::from(f.name), f.data))
141
- .collect();
142
-
143
- if inputs.is_empty() {
144
  anyhow::bail!("No files uploaded");
145
  }
146
 
147
- let docs = load_documents(inputs)?;
148
- state_tx::append_docs(&state.state, docs).await
 
 
 
149
  }
150
 
151
- pub async fn export_document(
152
- state: AppResources,
153
- payload: IndexPayload,
154
- ) -> anyhow::Result<FileResult> {
155
- let document = state_tx::read_doc(&state.state, payload.index).await?;
156
 
157
  let ext = document
158
  .path
@@ -182,9 +165,18 @@ pub async fn export_all_inpainted(state: AppResources) -> anyhow::Result<usize>
182
  return Ok(0);
183
  };
184
 
185
- let guard = state.state.read().await;
 
 
 
 
 
 
 
 
 
186
  export_documents_matching(
187
- &guard.documents,
188
  &output_dir,
189
  "inpainted",
190
  "No inpainted images found to export",
@@ -197,9 +189,18 @@ pub async fn export_all_rendered(state: AppResources) -> anyhow::Result<usize> {
197
  return Ok(0);
198
  };
199
 
200
- let guard = state.state.read().await;
 
 
 
 
 
 
 
 
 
201
  export_documents_matching(
202
- &guard.documents,
203
  &output_dir,
204
  "rendered",
205
  "No rendered images found to export",
 
2
 
3
  use image::ImageFormat;
4
  use koharu_core::commands::{
5
+ DeviceInfo, FileResult, OpenDocumentsPayload, OpenExternalPayload, ThumbnailResult,
 
6
  };
7
  use rfd::FileDialog;
8
 
9
+ use crate::AppResources;
10
+ use crate::utils::{encode_image, mime_from_ext};
 
11
 
12
  fn next_available_path(output_dir: &Path, stem: &str, ext: &str) -> PathBuf {
13
  let mut candidate = output_dir.join(format!("{stem}.{ext}"));
 
23
  Ok(tokio::task::spawn_blocking(|| FileDialog::new().pick_folder()).await?)
24
  }
25
 
 
 
 
 
 
 
 
 
 
26
  fn export_documents_matching(
27
  documents: &[koharu_core::Document],
28
  output_dir: &Path,
 
37
  continue;
38
  };
39
 
40
+ let ext = document
41
+ .path
42
+ .extension()
43
+ .and_then(|value| value.to_str())
44
+ .unwrap_or("jpg")
45
+ .to_string();
46
  let output_path =
47
  next_available_path(output_dir, &format!("{}_{}", document.name, suffix), &ext);
48
  let bytes = encode_image(image, &ext)?;
 
77
  }
78
 
79
  pub async fn get_documents(state: AppResources) -> anyhow::Result<usize> {
80
+ Ok(state.cache.list_documents()?.len())
 
81
  }
82
 
83
  pub async fn get_document(
84
  state: AppResources,
85
+ document_id: &str,
86
  ) -> anyhow::Result<koharu_core::Document> {
87
+ state.cache.get(document_id).await
88
  }
89
 
90
  pub async fn get_thumbnail(
91
  state: AppResources,
92
+ document_id: &str,
93
  ) -> anyhow::Result<ThumbnailResult> {
94
+ let doc = state.cache.get(document_id).await?;
95
 
96
  let source = doc.rendered.as_ref().unwrap_or(&doc.image);
97
  let thumbnail = source.thumbnail(200, 200);
 
109
  state: AppResources,
110
  payload: OpenDocumentsPayload,
111
  ) -> anyhow::Result<usize> {
112
+ if payload.files.is_empty() {
 
 
 
 
 
 
113
  anyhow::bail!("No files uploaded");
114
  }
115
 
116
+ let manifests = state.cache.import_files(payload.files)?;
117
+ let count = manifests.len();
118
+ state.cache.replace_manifests(&manifests).await?;
119
+ Ok(count)
120
  }
121
 
122
  pub async fn add_documents(
123
  state: AppResources,
124
  payload: OpenDocumentsPayload,
125
  ) -> anyhow::Result<usize> {
126
+ if payload.files.is_empty() {
 
 
 
 
 
 
127
  anyhow::bail!("No files uploaded");
128
  }
129
 
130
+ let manifests = state.cache.import_files(payload.files)?;
131
+ for manifest in &manifests {
132
+ state.cache.save_manifest(manifest)?;
133
+ }
134
+ Ok(state.cache.list_documents()?.len())
135
  }
136
 
137
+ pub async fn export_document(state: AppResources, document_id: &str) -> anyhow::Result<FileResult> {
138
+ let document = state.cache.get(document_id).await?;
 
 
 
139
 
140
  let ext = document
141
  .path
 
165
  return Ok(0);
166
  };
167
 
168
+ let page_ids: Vec<String> = state
169
+ .cache
170
+ .list_documents()?
171
+ .into_iter()
172
+ .map(|e| e.id)
173
+ .collect();
174
+ let mut documents = Vec::new();
175
+ for id in &page_ids {
176
+ documents.push(state.cache.get(id).await?);
177
+ }
178
  export_documents_matching(
179
+ &documents,
180
  &output_dir,
181
  "inpainted",
182
  "No inpainted images found to export",
 
189
  return Ok(0);
190
  };
191
 
192
+ let page_ids: Vec<String> = state
193
+ .cache
194
+ .list_documents()?
195
+ .into_iter()
196
+ .map(|e| e.id)
197
+ .collect();
198
+ let mut documents = Vec::new();
199
+ for id in &page_ids {
200
+ documents.push(state.cache.get(id).await?);
201
+ }
202
  export_documents_matching(
203
+ &documents,
204
  &output_dir,
205
  "rendered",
206
  "No rendered images found to export",
koharu-app/src/lib.rs CHANGED
@@ -1,23 +1,27 @@
 
1
  pub mod config;
 
 
2
  pub mod llm;
 
3
  pub mod ml;
4
- pub mod operations;
5
- pub mod ops;
6
  pub mod pipeline;
7
  pub mod renderer;
8
- pub mod state_tx;
9
 
10
  use std::sync::Arc;
11
 
12
- use koharu_core::AppState;
13
  use koharu_ml::Device;
14
  use koharu_runtime::RuntimeManager;
15
  use tokio::sync::RwLock;
16
 
 
 
17
  #[derive(Clone)]
18
  pub struct AppResources {
19
  pub runtime: RuntimeManager,
20
- pub state: AppState,
21
  pub ml: Arc<ml::Model>,
22
  pub llm: Arc<llm::Model>,
23
  pub renderer: Arc<renderer::Renderer>,
 
1
+ pub mod blob_store;
2
  pub mod config;
3
+ pub mod edit;
4
+ pub mod io;
5
  pub mod llm;
6
+ pub mod manifest;
7
  pub mod ml;
8
+ pub mod page_cache;
 
9
  pub mod pipeline;
10
  pub mod renderer;
11
+ pub mod utils;
12
 
13
  use std::sync::Arc;
14
 
 
15
  use koharu_ml::Device;
16
  use koharu_runtime::RuntimeManager;
17
  use tokio::sync::RwLock;
18
 
19
+ use crate::page_cache::PageCache;
20
+
21
  #[derive(Clone)]
22
  pub struct AppResources {
23
  pub runtime: RuntimeManager,
24
+ pub cache: PageCache,
25
  pub ml: Arc<ml::Model>,
26
  pub llm: Arc<llm::Model>,
27
  pub renderer: Arc<renderer::Renderer>,
koharu-app/src/llm.rs CHANGED
@@ -1,15 +1,28 @@
 
1
  use std::sync::Arc;
2
 
3
- use serde::Serialize;
 
 
 
4
  use tokio::sync::{RwLock, broadcast};
 
5
 
6
- use koharu_core::{Document, LlmState, LlmStateStatus, TextBlock};
 
 
 
 
7
  use koharu_runtime::RuntimeManager;
8
 
9
  use koharu_llm::{
10
  GenerateOptions, Language, Llm, ModelId, language::tags as language_tags,
11
  safe::llama_backend::LlamaBackend, supported_locales,
12
  };
 
 
 
 
13
 
14
  pub use koharu_llm::prefetch;
15
 
@@ -26,50 +39,25 @@ struct BlockEndTag {
26
  len: usize,
27
  }
28
 
29
- #[derive(Debug, Clone, Serialize)]
30
- #[serde(rename_all = "camelCase")]
31
- pub struct ModelInfo {
32
- pub id: String,
33
- pub languages: Vec<String>,
34
- pub source: &'static str,
35
- }
36
-
37
- impl ModelInfo {
38
- pub fn new(id: ModelId) -> Self {
39
- let languages = id.languages();
40
- Self {
41
- id: id.to_string(),
42
- languages: language_tags(&languages),
43
- source: "local",
44
- }
45
- }
46
-
47
- pub fn api(provider_id: &'static str, model_id: &str) -> Self {
48
- Self {
49
- id: format!("{provider_id}:{model_id}"),
50
- languages: supported_locales(),
51
- source: provider_id,
52
- }
53
- }
54
- }
55
-
56
  #[allow(clippy::large_enum_variant)]
57
  #[derive(strum::Display)]
58
  pub enum State {
59
  #[strum(serialize = "empty")]
60
  Empty,
61
  #[strum(serialize = "loading")]
62
- Loading { model_id: String, source: String },
63
  #[strum(serialize = "ready")]
64
- Ready(Llm),
65
  #[strum(serialize = "ready")]
66
- ApiReady {
67
- provider: Box<dyn koharu_llm::providers::AnyProvider>,
68
- provider_id: String,
69
- model: String,
70
  },
71
  #[strum(serialize = "failed")]
72
- Failed(String),
 
 
 
73
  }
74
 
75
  pub struct Model {
@@ -85,6 +73,58 @@ pub trait Translatable {
85
  fn set_translation(&mut self, translation: String) -> anyhow::Result<()>;
86
  }
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  fn escape_block_text(text: &str) -> String {
89
  text.replace('&', "&amp;")
90
  .replace('<', "&lt;")
@@ -453,28 +493,22 @@ impl Model {
453
  self.cpu
454
  }
455
 
456
- pub async fn load_api(
457
  &self,
458
- provider_id: &str,
459
- model_id: &str,
460
- config: koharu_llm::providers::ProviderConfig,
461
  ) -> anyhow::Result<()> {
462
- let provider = koharu_llm::providers::build_provider(provider_id, config)?;
463
- *self.state.write().await = State::ApiReady {
464
- provider,
465
- provider_id: provider_id.to_string(),
466
- model: model_id.to_string(),
467
- };
468
  self.emit_state().await;
469
  Ok(())
470
  }
471
 
472
- pub async fn load(&self, id: ModelId) {
 
473
  {
474
  let mut guard = self.state.write().await;
475
  *guard = State::Loading {
476
- model_id: id.to_string(),
477
- source: "local".to_string(),
478
  };
479
  }
480
  self.emit_state().await;
@@ -489,12 +523,15 @@ impl Model {
489
  match res {
490
  Ok(llm) => {
491
  let mut guard = state_cloned.write().await;
492
- *guard = State::Ready(llm);
493
  }
494
  Err(e) => {
495
  tracing::error!("LLM load join error: {e}");
496
  let mut guard = state_cloned.write().await;
497
- *guard = State::Failed(format!("join error: {e}"));
 
 
 
498
  }
499
  }
500
  let snapshot = {
@@ -521,10 +558,15 @@ impl Model {
521
  pub async fn ready(&self) -> bool {
522
  matches!(
523
  *self.state.read().await,
524
- State::Ready(_) | State::ApiReady { .. }
525
  )
526
  }
527
 
 
 
 
 
 
528
  pub fn subscribe(&self) -> broadcast::Receiver<LlmState> {
529
  self.state_tx.subscribe()
530
  }
@@ -553,17 +595,16 @@ impl Model {
553
  }
554
  let mut guard = self.state.write().await;
555
  let translation = match &mut *guard {
556
- State::Ready(llm) => {
557
  llm.generate(&source, &GenerateOptions::default(), target_language)
558
  }
559
- State::ApiReady {
560
- provider, model, ..
561
- } => {
562
- let model = model.clone();
563
- provider.translate(&source, target_language, &model).await
564
  }
565
  State::Loading { .. } => Err(anyhow::anyhow!("Model is still loading")),
566
- State::Failed(e) => Err(anyhow::anyhow!("Model failed to load: {e}")),
567
  State::Empty => Err(anyhow::anyhow!("No model is loaded")),
568
  }?;
569
  doc.set_translation(translation.trim().to_string())
@@ -574,39 +615,308 @@ fn snapshot_from_state(state: &State) -> LlmState {
574
  match state {
575
  State::Empty => LlmState {
576
  status: LlmStateStatus::Empty,
577
- model_id: None,
578
- source: None,
579
  error: None,
580
  },
581
- State::Loading { model_id, source } => LlmState {
582
  status: LlmStateStatus::Loading,
583
- model_id: Some(model_id.clone()),
584
- source: Some(source.clone()),
585
  error: None,
586
  },
587
- State::Ready(llm) => LlmState {
588
  status: LlmStateStatus::Ready,
589
- model_id: Some(llm.id().to_string()),
590
- source: Some("local".to_string()),
591
  error: None,
592
  },
593
- State::ApiReady {
594
- provider_id, model, ..
595
- } => LlmState {
596
  status: LlmStateStatus::Ready,
597
- model_id: Some(format!("{provider_id}:{model}")),
598
- source: Some(provider_id.clone()),
599
  error: None,
600
  },
601
- State::Failed(error) => LlmState {
602
  status: LlmStateStatus::Failed,
603
- model_id: None,
604
- source: None,
605
  error: Some(error.clone()),
606
  },
607
  }
608
  }
609
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  #[cfg(test)]
611
  mod tests {
612
  use koharu_core::Document;
 
1
+ use std::str::FromStr;
2
  use std::sync::Arc;
3
 
4
+ use koharu_llm::providers::{
5
+ AnyProvider, ProviderCatalogModels, ProviderConfig, all_provider_descriptors, build_provider,
6
+ discover_models, get_saved_api_key,
7
+ };
8
  use tokio::sync::{RwLock, broadcast};
9
+ use tracing::instrument;
10
 
11
+ use koharu_core::{
12
+ Document, LlmCatalog, LlmCatalogModel, LlmGenerationOptions, LlmLoadRequest,
13
+ LlmProviderCatalog, LlmProviderCatalogStatus, LlmState, LlmStateStatus, LlmTarget,
14
+ LlmTargetKind, TextBlock,
15
+ };
16
  use koharu_runtime::RuntimeManager;
17
 
18
  use koharu_llm::{
19
  GenerateOptions, Language, Llm, ModelId, language::tags as language_tags,
20
  safe::llama_backend::LlamaBackend, supported_locales,
21
  };
22
+ use strum::IntoEnumIterator;
23
+
24
+ use crate::AppResources;
25
+ use crate::config as app_config;
26
 
27
  pub use koharu_llm::prefetch;
28
 
 
39
  len: usize,
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  #[allow(clippy::large_enum_variant)]
43
  #[derive(strum::Display)]
44
  pub enum State {
45
  #[strum(serialize = "empty")]
46
  Empty,
47
  #[strum(serialize = "loading")]
48
+ Loading { target: LlmTarget },
49
  #[strum(serialize = "ready")]
50
+ ReadyLocal(Llm),
51
  #[strum(serialize = "ready")]
52
+ ReadyProvider {
53
+ target: LlmTarget,
54
+ provider: Box<dyn AnyProvider>,
 
55
  },
56
  #[strum(serialize = "failed")]
57
+ Failed {
58
+ target: Option<LlmTarget>,
59
+ error: String,
60
+ },
61
  }
62
 
63
  pub struct Model {
 
73
  fn set_translation(&mut self, translation: String) -> anyhow::Result<()>;
74
  }
75
 
76
+ fn local_target(id: ModelId) -> LlmTarget {
77
+ LlmTarget {
78
+ kind: LlmTargetKind::Local,
79
+ model_id: id.to_string(),
80
+ provider_id: None,
81
+ }
82
+ }
83
+
84
+ fn provider_target(provider_id: &str, model_id: &str) -> LlmTarget {
85
+ LlmTarget {
86
+ kind: LlmTargetKind::Provider,
87
+ model_id: model_id.to_string(),
88
+ provider_id: Some(provider_id.to_string()),
89
+ }
90
+ }
91
+
92
+ fn validate_target(target: &LlmTarget) -> anyhow::Result<()> {
93
+ match target.kind {
94
+ LlmTargetKind::Local => {
95
+ anyhow::ensure!(
96
+ target.provider_id.is_none(),
97
+ "local targets must not include provider_id"
98
+ );
99
+ }
100
+ LlmTargetKind::Provider => {
101
+ anyhow::ensure!(
102
+ target
103
+ .provider_id
104
+ .as_deref()
105
+ .is_some_and(|value| !value.trim().is_empty()),
106
+ "provider targets require provider_id"
107
+ );
108
+ }
109
+ }
110
+
111
+ anyhow::ensure!(
112
+ !target.model_id.trim().is_empty(),
113
+ "target model_id is required"
114
+ );
115
+ Ok(())
116
+ }
117
+
118
+ fn state_target(state: &State) -> Option<LlmTarget> {
119
+ match state {
120
+ State::Empty => None,
121
+ State::Loading { target } => Some(target.clone()),
122
+ State::ReadyLocal(llm) => Some(local_target(llm.id())),
123
+ State::ReadyProvider { target, .. } => Some(target.clone()),
124
+ State::Failed { target, .. } => target.clone(),
125
+ }
126
+ }
127
+
128
  fn escape_block_text(text: &str) -> String {
129
  text.replace('&', "&amp;")
130
  .replace('<', "&lt;")
 
493
  self.cpu
494
  }
495
 
496
+ pub async fn load_provider(
497
  &self,
498
+ target: LlmTarget,
499
+ provider: Box<dyn AnyProvider>,
 
500
  ) -> anyhow::Result<()> {
501
+ *self.state.write().await = State::ReadyProvider { target, provider };
 
 
 
 
 
502
  self.emit_state().await;
503
  Ok(())
504
  }
505
 
506
+ pub async fn load_local(&self, id: ModelId) {
507
+ let target = local_target(id);
508
  {
509
  let mut guard = self.state.write().await;
510
  *guard = State::Loading {
511
+ target: target.clone(),
 
512
  };
513
  }
514
  self.emit_state().await;
 
523
  match res {
524
  Ok(llm) => {
525
  let mut guard = state_cloned.write().await;
526
+ *guard = State::ReadyLocal(llm);
527
  }
528
  Err(e) => {
529
  tracing::error!("LLM load join error: {e}");
530
  let mut guard = state_cloned.write().await;
531
+ *guard = State::Failed {
532
+ target: Some(target),
533
+ error: format!("join error: {e}"),
534
+ };
535
  }
536
  }
537
  let snapshot = {
 
558
  pub async fn ready(&self) -> bool {
559
  matches!(
560
  *self.state.read().await,
561
+ State::ReadyLocal(_) | State::ReadyProvider { .. }
562
  )
563
  }
564
 
565
+ pub async fn current_target(&self) -> Option<LlmTarget> {
566
+ let guard = self.state.read().await;
567
+ state_target(&guard)
568
+ }
569
+
570
  pub fn subscribe(&self) -> broadcast::Receiver<LlmState> {
571
  self.state_tx.subscribe()
572
  }
 
595
  }
596
  let mut guard = self.state.write().await;
597
  let translation = match &mut *guard {
598
+ State::ReadyLocal(llm) => {
599
  llm.generate(&source, &GenerateOptions::default(), target_language)
600
  }
601
+ State::ReadyProvider { target, provider } => {
602
+ provider
603
+ .translate(&source, target_language, &target.model_id)
604
+ .await
 
605
  }
606
  State::Loading { .. } => Err(anyhow::anyhow!("Model is still loading")),
607
+ State::Failed { error, .. } => Err(anyhow::anyhow!("Model failed to load: {error}")),
608
  State::Empty => Err(anyhow::anyhow!("No model is loaded")),
609
  }?;
610
  doc.set_translation(translation.trim().to_string())
 
615
  match state {
616
  State::Empty => LlmState {
617
  status: LlmStateStatus::Empty,
618
+ target: None,
 
619
  error: None,
620
  },
621
+ State::Loading { target } => LlmState {
622
  status: LlmStateStatus::Loading,
623
+ target: Some(target.clone()),
 
624
  error: None,
625
  },
626
+ State::ReadyLocal(llm) => LlmState {
627
  status: LlmStateStatus::Ready,
628
+ target: Some(local_target(llm.id())),
 
629
  error: None,
630
  },
631
+ State::ReadyProvider { target, .. } => LlmState {
 
 
632
  status: LlmStateStatus::Ready,
633
+ target: Some(target.clone()),
 
634
  error: None,
635
  },
636
+ State::Failed { target, error } => LlmState {
637
  status: LlmStateStatus::Failed,
638
+ target: target.clone(),
 
639
  error: Some(error.clone()),
640
  },
641
  }
642
  }
643
 
644
+ // ---------------------------------------------------------------------------
645
+ // Operations (merged from ops/llm.rs)
646
+ // ---------------------------------------------------------------------------
647
+
648
+ fn sorted_local_models(is_cpu: bool, language: Option<&str>) -> Vec<ModelId> {
649
+ let mut models: Vec<ModelId> = ModelId::iter().collect();
650
+ let cpu_factor = if is_cpu { 10 } else { 1 };
651
+ let lang = language.unwrap_or("en");
652
+ let zh_locale_factor = if lang.starts_with("zh") { 10 } else { 1 };
653
+ let non_zh_en_locale_factor = if lang.starts_with("zh") || lang.starts_with("en") {
654
+ 1
655
+ } else {
656
+ 100
657
+ };
658
+
659
+ models.sort_by_key(|m| match m {
660
+ ModelId::VntlLlama3_8Bv2 => 100,
661
+ ModelId::Lfm2_350mEnjpMt => 200 / cpu_factor,
662
+ ModelId::SakuraGalTransl7Bv3_7 => 300 / zh_locale_factor,
663
+ ModelId::Sakura1_5bQwen2_5v1_0 => 400 / zh_locale_factor / cpu_factor,
664
+ ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor,
665
+ });
666
+ models
667
+ }
668
+
669
+ fn local_catalog_models(is_cpu: bool, language: Option<&str>) -> Vec<LlmCatalogModel> {
670
+ sorted_local_models(is_cpu, language)
671
+ .into_iter()
672
+ .map(|model| LlmCatalogModel {
673
+ target: local_target(model),
674
+ name: model.to_string(),
675
+ languages: language_tags(&model.languages()),
676
+ })
677
+ .collect()
678
+ }
679
+
680
+ async fn provider_catalog(state: &AppResources) -> anyhow::Result<Vec<LlmProviderCatalog>> {
681
+ let config = app_config::load()?;
682
+ let mut providers = Vec::new();
683
+
684
+ for descriptor in all_provider_descriptors() {
685
+ let stored = config.llm.providers.get(descriptor.id);
686
+ let base_url = stored.and_then(|provider| provider.base_url.clone());
687
+ let api_key = get_saved_api_key(descriptor.id)?;
688
+ let has_api_key = api_key
689
+ .as_deref()
690
+ .is_some_and(|value| !value.trim().is_empty());
691
+ let missing_required_config = (descriptor.requires_api_key && !has_api_key)
692
+ || (descriptor.requires_base_url
693
+ && base_url
694
+ .as_deref()
695
+ .is_none_or(|value| value.trim().is_empty()));
696
+
697
+ let (status, error, models) = if missing_required_config {
698
+ (
699
+ LlmProviderCatalogStatus::MissingConfiguration,
700
+ None,
701
+ static_provider_models(descriptor),
702
+ )
703
+ } else {
704
+ match &descriptor.models {
705
+ ProviderCatalogModels::Static(_) => (
706
+ LlmProviderCatalogStatus::Ready,
707
+ None,
708
+ static_provider_models(descriptor),
709
+ ),
710
+ ProviderCatalogModels::Dynamic(_) => {
711
+ let models = discover_models(
712
+ descriptor.id,
713
+ ProviderConfig {
714
+ http_client: state.runtime.http_client(),
715
+ api_key,
716
+ base_url: base_url.clone(),
717
+ temperature: None,
718
+ max_tokens: None,
719
+ custom_system_prompt: None,
720
+ },
721
+ )?
722
+ .await;
723
+
724
+ match models {
725
+ Ok(models) => (
726
+ LlmProviderCatalogStatus::Ready,
727
+ None,
728
+ models
729
+ .into_iter()
730
+ .map(|model| LlmCatalogModel {
731
+ target: provider_target(descriptor.id, &model.id),
732
+ name: model.name,
733
+ languages: supported_locales(),
734
+ })
735
+ .collect(),
736
+ ),
737
+ Err(error) => (
738
+ LlmProviderCatalogStatus::DiscoveryFailed,
739
+ Some(error.to_string()),
740
+ Vec::new(),
741
+ ),
742
+ }
743
+ }
744
+ }
745
+ };
746
+
747
+ providers.push(LlmProviderCatalog {
748
+ id: descriptor.id.to_string(),
749
+ name: descriptor.name.to_string(),
750
+ requires_api_key: descriptor.requires_api_key,
751
+ requires_base_url: descriptor.requires_base_url,
752
+ has_api_key,
753
+ base_url,
754
+ status,
755
+ error,
756
+ models,
757
+ });
758
+ }
759
+
760
+ Ok(providers)
761
+ }
762
+
763
+ fn static_provider_models(
764
+ descriptor: &koharu_llm::providers::ProviderDescriptor,
765
+ ) -> Vec<LlmCatalogModel> {
766
+ match &descriptor.models {
767
+ ProviderCatalogModels::Static(models) => models
768
+ .iter()
769
+ .map(|model| LlmCatalogModel {
770
+ target: provider_target(descriptor.id, model.id),
771
+ name: model.name.to_string(),
772
+ languages: supported_locales(),
773
+ })
774
+ .collect(),
775
+ ProviderCatalogModels::Dynamic(_) => Vec::new(),
776
+ }
777
+ }
778
+
779
+ fn provider_config_from_settings(
780
+ state: &AppResources,
781
+ target: &LlmTarget,
782
+ options: Option<&LlmGenerationOptions>,
783
+ ) -> anyhow::Result<ProviderConfig> {
784
+ validate_target(target)?;
785
+ let provider_id = target
786
+ .provider_id
787
+ .as_deref()
788
+ .ok_or_else(|| anyhow::anyhow!("provider targets require provider_id"))?;
789
+ let config = app_config::load()?;
790
+ let stored = config.llm.providers.get(provider_id);
791
+
792
+ Ok(ProviderConfig {
793
+ http_client: state.runtime.http_client(),
794
+ api_key: get_saved_api_key(provider_id)?,
795
+ base_url: stored.and_then(|provider| provider.base_url.clone()),
796
+ temperature: options.and_then(|options| options.temperature),
797
+ max_tokens: options.and_then(|options| options.max_tokens),
798
+ custom_system_prompt: options.and_then(|options| options.custom_system_prompt.clone()),
799
+ })
800
+ }
801
+
802
+ async fn load_target(
803
+ state: &AppResources,
804
+ target: &LlmTarget,
805
+ options: Option<&LlmGenerationOptions>,
806
+ ) -> anyhow::Result<()> {
807
+ validate_target(target)?;
808
+ if state.llm.current_target().await.as_ref() == Some(target) && state.llm.ready().await {
809
+ return Ok(());
810
+ }
811
+
812
+ match target.kind {
813
+ LlmTargetKind::Local => {
814
+ let model = ModelId::from_str(&target.model_id)?;
815
+ state.llm.load_local(model).await;
816
+ }
817
+ LlmTargetKind::Provider => {
818
+ let provider_id = target
819
+ .provider_id
820
+ .as_deref()
821
+ .ok_or_else(|| anyhow::anyhow!("provider targets require provider_id"))?;
822
+ let provider = build_provider(
823
+ provider_id,
824
+ provider_config_from_settings(state, target, options)?,
825
+ )?;
826
+ state.llm.load_provider(target.clone(), provider).await?;
827
+ }
828
+ }
829
+
830
+ Ok(())
831
+ }
832
+
833
+ pub async fn ensure_target_ready(
834
+ state: &AppResources,
835
+ target: &LlmTarget,
836
+ options: Option<&LlmGenerationOptions>,
837
+ ) -> anyhow::Result<()> {
838
+ load_target(state, target, options).await?;
839
+ if target.kind != LlmTargetKind::Local {
840
+ return Ok(());
841
+ }
842
+
843
+ for _ in 0..300 {
844
+ let snapshot = state.llm.snapshot().await;
845
+ match snapshot.status {
846
+ LlmStateStatus::Ready if snapshot.target.as_ref() == Some(target) => return Ok(()),
847
+ LlmStateStatus::Failed if snapshot.target.as_ref() == Some(target) => {
848
+ anyhow::bail!(
849
+ "{}",
850
+ snapshot
851
+ .error
852
+ .unwrap_or_else(|| "LLM failed to load".to_string())
853
+ );
854
+ }
855
+ _ => {}
856
+ }
857
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
858
+ }
859
+
860
+ anyhow::bail!("LLM failed to load within timeout");
861
+ }
862
+
863
+ pub async fn llm_catalog(
864
+ state: AppResources,
865
+ language: Option<&str>,
866
+ ) -> anyhow::Result<LlmCatalog> {
867
+ Ok(LlmCatalog {
868
+ local_models: local_catalog_models(state.llm.is_cpu(), language),
869
+ providers: provider_catalog(&state).await?,
870
+ })
871
+ }
872
+
873
+ #[instrument(level = "info", skip_all)]
874
+ pub async fn llm_load(state: AppResources, payload: LlmLoadRequest) -> anyhow::Result<()> {
875
+ load_target(&state, &payload.target, payload.options.as_ref()).await
876
+ }
877
+
878
+ pub async fn llm_offload(state: AppResources) -> anyhow::Result<()> {
879
+ state.llm.offload().await;
880
+ Ok(())
881
+ }
882
+
883
+ pub async fn llm_ready(state: AppResources) -> anyhow::Result<bool> {
884
+ Ok(state.llm.ready().await)
885
+ }
886
+
887
+ #[instrument(level = "info", skip_all)]
888
+ pub async fn llm_generate(
889
+ state: AppResources,
890
+ document_id: &str,
891
+ text_block_index: Option<usize>,
892
+ language: Option<&str>,
893
+ ) -> anyhow::Result<()> {
894
+ let mut doc = state.cache.get(document_id).await?;
895
+
896
+ match text_block_index {
897
+ Some(block_index) => {
898
+ let text_block = doc
899
+ .text_blocks
900
+ .get_mut(block_index)
901
+ .ok_or_else(|| anyhow::anyhow!("Text block not found"))?;
902
+ state.llm.translate(text_block, language).await?;
903
+ }
904
+ None => {
905
+ state.llm.translate(&mut doc, language).await?;
906
+ }
907
+ }
908
+
909
+ state.cache.put(&doc).await?;
910
+ Ok(())
911
+ }
912
+
913
+ pub async fn get_document_for_llm(
914
+ state: AppResources,
915
+ document_id: &str,
916
+ ) -> anyhow::Result<koharu_core::Document> {
917
+ state.cache.get(document_id).await
918
+ }
919
+
920
  #[cfg(test)]
921
  mod tests {
922
  use koharu_core::Document;
koharu-app/src/manifest.rs ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::path::PathBuf;
2
+
3
+ use anyhow::{Context, Result};
4
+ use koharu_core::TextBlock;
5
+ use serde::{Deserialize, Serialize};
6
+
7
+ /// Per-page manifest stored as JSON at `~/.koharu/pages/{id}.json`.
8
+ ///
9
+ /// JSON is used instead of TOML because `TextBlock` contains types that TOML
10
+ /// cannot represent (tuples in `FontPrediction::top_fonts`, fixed-size arrays
11
+ /// in `line_polygons`, binary image data in `rendered`).
12
+ #[derive(Debug, Clone, Serialize, Deserialize)]
13
+ pub struct PageManifest {
14
+ pub id: String,
15
+ /// Blob hash of the original image.
16
+ pub source: String,
17
+ pub name: String,
18
+ pub width: u32,
19
+ pub height: u32,
20
+ #[serde(default)]
21
+ pub layers: LayerRefs,
22
+ #[serde(default)]
23
+ pub text_blocks: Vec<TextBlock>,
24
+ }
25
+
26
+ #[derive(Debug, Clone, Default, Serialize, Deserialize)]
27
+ pub struct LayerRefs {
28
+ #[serde(default, skip_serializing_if = "Option::is_none")]
29
+ pub segment: Option<String>,
30
+ #[serde(default, skip_serializing_if = "Option::is_none")]
31
+ pub inpainted: Option<String>,
32
+ #[serde(default, skip_serializing_if = "Option::is_none")]
33
+ pub rendered: Option<String>,
34
+ #[serde(default, skip_serializing_if = "Option::is_none")]
35
+ pub brush_layer: Option<String>,
36
+ }
37
+
38
+ /// Manages manifest files at `~/.koharu/pages/`.
39
+ #[derive(Clone)]
40
+ pub struct ManifestStore {
41
+ root: PathBuf,
42
+ }
43
+
44
+ impl ManifestStore {
45
+ pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
46
+ let root = root.into();
47
+ std::fs::create_dir_all(&root)?;
48
+ Ok(Self { root })
49
+ }
50
+
51
+ pub fn save(&self, manifest: &PageManifest) -> Result<()> {
52
+ let path = self.manifest_path(&manifest.id);
53
+ let content =
54
+ serde_json::to_string_pretty(manifest).context("Failed to serialize manifest")?;
55
+ std::fs::write(&path, content)
56
+ .with_context(|| format!("Failed to write manifest {}", manifest.id))?;
57
+ Ok(())
58
+ }
59
+
60
+ pub fn load(&self, id: &str) -> Result<PageManifest> {
61
+ let path = self.manifest_path(id);
62
+ let content =
63
+ std::fs::read_to_string(&path).with_context(|| format!("Manifest not found: {id}"))?;
64
+ serde_json::from_str(&content).with_context(|| format!("Failed to parse manifest {id}"))
65
+ }
66
+
67
+ pub fn load_all(&self) -> Result<Vec<PageManifest>> {
68
+ let mut manifests = Vec::new();
69
+ if let Ok(entries) = std::fs::read_dir(&self.root) {
70
+ for entry in entries.flatten() {
71
+ let path = entry.path();
72
+ if path.extension().is_some_and(|e| e == "json")
73
+ && let Ok(content) = std::fs::read_to_string(&path)
74
+ && let Ok(manifest) = serde_json::from_str::<PageManifest>(&content)
75
+ {
76
+ manifests.push(manifest);
77
+ }
78
+ }
79
+ }
80
+ Ok(manifests)
81
+ }
82
+
83
+ pub fn replace_all(&self, manifests: &[PageManifest]) -> Result<()> {
84
+ if let Ok(entries) = std::fs::read_dir(&self.root) {
85
+ for entry in entries.flatten() {
86
+ let path = entry.path();
87
+ if path.extension().is_some_and(|e| e == "json") {
88
+ std::fs::remove_file(&path)
89
+ .with_context(|| format!("Failed to remove manifest {}", path.display()))?;
90
+ }
91
+ }
92
+ }
93
+
94
+ for manifest in manifests {
95
+ self.save(manifest)?;
96
+ }
97
+
98
+ Ok(())
99
+ }
100
+
101
+ fn manifest_path(&self, id: &str) -> PathBuf {
102
+ self.root.join(format!("{id}.json"))
103
+ }
104
+ }
koharu-app/src/ml.rs CHANGED
@@ -5,16 +5,21 @@ use std::{
5
 
6
  use anyhow::Result;
7
  use image::DynamicImage;
8
- use koharu_core::{Document, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection};
 
 
 
9
  use koharu_llm::paddleocr_vl::{self as paddleocr_vl_llm, PaddleOcrVl, PaddleOcrVlTask};
10
  use koharu_llm::safe::llama_backend::LlamaBackend;
11
  use koharu_runtime::RuntimeManager;
 
12
 
13
  use koharu_ml::comic_text_detector::{self, ComicTextDetector, crop_text_block_bbox};
14
  use koharu_ml::font_detector::{self, FontDetector};
15
  use koharu_ml::lama::{self, Lama};
16
  use koharu_ml::pp_doclayout_v3::{self, LayoutRegion, PPDocLayoutV3};
17
 
 
18
  const NEAR_BLACK_THRESHOLD: u8 = 12;
19
  const GRAY_NEAR_BLACK_THRESHOLD: u8 = 60;
20
  const NEAR_WHITE_THRESHOLD: u8 = 12;
@@ -358,6 +363,61 @@ fn overlap_area(a: [f32; 4], b: [f32; 4]) -> f32 {
358
  }
359
  }
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  #[cfg(test)]
362
  mod tests {
363
  use super::*;
 
5
 
6
  use anyhow::Result;
7
  use image::DynamicImage;
8
+ use koharu_core::{
9
+ Document, FontFaceInfo, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection,
10
+ TextShaderEffect, TextStrokeStyle,
11
+ };
12
  use koharu_llm::paddleocr_vl::{self as paddleocr_vl_llm, PaddleOcrVl, PaddleOcrVlTask};
13
  use koharu_llm::safe::llama_backend::LlamaBackend;
14
  use koharu_runtime::RuntimeManager;
15
+ use tracing::instrument;
16
 
17
  use koharu_ml::comic_text_detector::{self, ComicTextDetector, crop_text_block_bbox};
18
  use koharu_ml::font_detector::{self, FontDetector};
19
  use koharu_ml::lama::{self, Lama};
20
  use koharu_ml::pp_doclayout_v3::{self, LayoutRegion, PPDocLayoutV3};
21
 
22
+ use crate::AppResources;
23
  const NEAR_BLACK_THRESHOLD: u8 = 12;
24
  const GRAY_NEAR_BLACK_THRESHOLD: u8 = 60;
25
  const NEAR_WHITE_THRESHOLD: u8 = 12;
 
363
  }
364
  }
365
 
366
+ // ---------------------------------------------------------------------------
367
+ // Operations (merged from ops/vision.rs)
368
+ // ---------------------------------------------------------------------------
369
+
370
+ #[instrument(level = "info", skip_all)]
371
+ pub async fn detect(state: AppResources, document_id: &str) -> Result<()> {
372
+ let mut doc = state.cache.get(document_id).await?;
373
+ state.ml.detect(&mut doc).await?;
374
+ state.cache.put(&doc).await?;
375
+ Ok(())
376
+ }
377
+
378
+ #[instrument(level = "info", skip_all)]
379
+ pub async fn ocr(state: AppResources, document_id: &str) -> Result<()> {
380
+ let mut doc = state.cache.get(document_id).await?;
381
+ state.ml.ocr(&mut doc).await?;
382
+ state.cache.put(&doc).await?;
383
+ Ok(())
384
+ }
385
+
386
+ #[instrument(level = "info", skip_all)]
387
+ pub async fn inpaint(state: AppResources, document_id: &str) -> Result<()> {
388
+ let mut doc = state.cache.get(document_id).await?;
389
+ state.ml.inpaint(&mut doc).await?;
390
+ state.cache.put(&doc).await?;
391
+ Ok(())
392
+ }
393
+
394
+ #[instrument(level = "info", skip_all)]
395
+ pub async fn render(
396
+ state: AppResources,
397
+ document_id: &str,
398
+ text_block_index: Option<usize>,
399
+ shader_effect: Option<TextShaderEffect>,
400
+ shader_stroke: Option<TextStrokeStyle>,
401
+ font_family: Option<&str>,
402
+ ) -> Result<()> {
403
+ let mut doc = state.cache.get(document_id).await?;
404
+
405
+ state.renderer.render(
406
+ &mut doc,
407
+ text_block_index,
408
+ shader_effect.unwrap_or_default(),
409
+ shader_stroke,
410
+ font_family,
411
+ )?;
412
+
413
+ state.cache.put(&doc).await?;
414
+ Ok(())
415
+ }
416
+
417
+ pub async fn list_font_families(state: AppResources) -> Result<Vec<FontFaceInfo>> {
418
+ state.renderer.available_fonts()
419
+ }
420
+
421
  #[cfg(test)]
422
  mod tests {
423
  use super::*;
koharu-app/src/operations.rs DELETED
@@ -1 +0,0 @@
1
- pub use crate::ops::*;
 
 
koharu-app/src/ops/llm.rs DELETED
@@ -1,191 +0,0 @@
1
- use std::str::FromStr;
2
- use std::sync::Arc;
3
-
4
- use koharu_core::commands::{
5
- ApiKeyGetPayload, ApiKeyResult, ApiKeySetPayload, IndexPayload, LlmGeneratePayload,
6
- LlmListPayload, LlmLoadPayload,
7
- };
8
- use koharu_llm::ModelId;
9
- use koharu_llm::api::{ALL_API_PROVIDERS, OPENAI_COMPATIBLE_ID};
10
- use koharu_llm::providers::{get_saved_api_key, openai_compatible, set_saved_api_key};
11
- pub use openai_compatible::PingResult;
12
- use reqwest_middleware::ClientWithMiddleware;
13
- use strum::IntoEnumIterator;
14
- use tracing::instrument;
15
-
16
- use crate::{
17
- AppResources, llm,
18
- state_tx::{self, ChangedField},
19
- };
20
-
21
- #[instrument(level = "debug", skip_all, fields(provider = %payload.provider))]
22
- pub async fn get_api_key(
23
- _state: AppResources,
24
- payload: ApiKeyGetPayload,
25
- ) -> anyhow::Result<ApiKeyResult> {
26
- match get_saved_api_key(&payload.provider) {
27
- Ok(Some(key)) => Ok(ApiKeyResult { api_key: Some(key) }),
28
- Ok(None) => Ok(ApiKeyResult { api_key: None }),
29
- Err(err) => {
30
- tracing::error!(%err, "keyring read failed");
31
- Err(err)
32
- }
33
- }
34
- }
35
-
36
- #[instrument(level = "debug", skip_all, fields(provider = %payload.provider))]
37
- pub async fn set_api_key(_state: AppResources, payload: ApiKeySetPayload) -> anyhow::Result<()> {
38
- match set_saved_api_key(&payload.provider, &payload.api_key) {
39
- Ok(()) => Ok(()),
40
- Err(err) => {
41
- tracing::error!(%err, "keyring write failed");
42
- Err(err)
43
- }
44
- }
45
- }
46
-
47
- pub async fn llm_list(
48
- state: AppResources,
49
- payload: LlmListPayload,
50
- ) -> anyhow::Result<Vec<llm::ModelInfo>> {
51
- let mut models: Vec<ModelId> = ModelId::iter().collect();
52
- let cpu_factor = if state.llm.is_cpu() { 10 } else { 1 };
53
- let lang = payload.language.as_deref().unwrap_or("en");
54
- let zh_locale_factor = if lang.starts_with("zh") { 10 } else { 1 };
55
- let non_zh_en_locale_factor = if lang.starts_with("zh") || lang.starts_with("en") {
56
- 1
57
- } else {
58
- 100
59
- };
60
-
61
- models.sort_by_key(|m| match m {
62
- ModelId::VntlLlama3_8Bv2 => 100,
63
- ModelId::Lfm2_350mEnjpMt => 200 / cpu_factor,
64
- ModelId::SakuraGalTransl7Bv3_7 => 300 / zh_locale_factor,
65
- ModelId::Sakura1_5bQwen2_5v1_0 => 400 / zh_locale_factor / cpu_factor,
66
- ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor,
67
- });
68
-
69
- let mut result: Vec<llm::ModelInfo> = models.into_iter().map(llm::ModelInfo::new).collect();
70
-
71
- for provider in ALL_API_PROVIDERS {
72
- for model in provider.models {
73
- result.push(llm::ModelInfo::api(provider.id, model.id));
74
- }
75
- }
76
-
77
- if let Some(base_url) = payload
78
- .openai_compatible_base_url
79
- .as_deref()
80
- .map(str::trim)
81
- .filter(|value| !value.is_empty())
82
- {
83
- let api_key = match get_saved_api_key(OPENAI_COMPATIBLE_ID) {
84
- Ok(value) => value,
85
- Err(err) => {
86
- tracing::warn!(%err, "failed to read openai-compatible API key");
87
- None
88
- }
89
- };
90
-
91
- match openai_compatible::list_models(
92
- state.runtime.http_client(),
93
- base_url,
94
- api_key.as_deref(),
95
- )
96
- .await
97
- {
98
- Ok(models) => {
99
- for model in models {
100
- result.push(llm::ModelInfo::api(OPENAI_COMPATIBLE_ID, &model));
101
- }
102
- }
103
- Err(err) => {
104
- tracing::warn!(%err, "failed to list openai-compatible models");
105
- }
106
- }
107
- }
108
-
109
- Ok(result)
110
- }
111
-
112
- #[instrument(level = "info", skip_all)]
113
- pub async fn llm_load(state: AppResources, payload: LlmLoadPayload) -> anyhow::Result<()> {
114
- if payload.id.contains(':') {
115
- let (provider_id, model_id) = payload.id.split_once(':').unwrap();
116
- let api_key = match payload.api_key {
117
- Some(key) if !key.trim().is_empty() => Some(key),
118
- _ => get_saved_api_key(provider_id)?,
119
- };
120
- state
121
- .llm
122
- .load_api(
123
- provider_id,
124
- model_id,
125
- koharu_llm::providers::ProviderConfig {
126
- http_client: state.runtime.http_client(),
127
- api_key,
128
- base_url: payload.base_url,
129
- temperature: payload.temperature,
130
- max_tokens: payload.max_tokens,
131
- custom_system_prompt: payload.custom_system_prompt,
132
- },
133
- )
134
- .await?;
135
- } else {
136
- let id = ModelId::from_str(&payload.id)?;
137
- state.llm.load(id).await;
138
- }
139
- Ok(())
140
- }
141
-
142
- pub async fn llm_offload(state: AppResources) -> anyhow::Result<()> {
143
- state.llm.offload().await;
144
- Ok(())
145
- }
146
-
147
- pub async fn llm_ready(state: AppResources) -> anyhow::Result<bool> {
148
- Ok(state.llm.ready().await)
149
- }
150
-
151
- #[instrument(level = "info", skip_all)]
152
- pub async fn llm_generate(state: AppResources, payload: LlmGeneratePayload) -> anyhow::Result<()> {
153
- let mut updated = state_tx::read_doc(&state.state, payload.index).await?;
154
- let target_language = payload.language.as_deref();
155
-
156
- match payload.text_block_index {
157
- Some(block_index) => {
158
- let text_block = updated
159
- .text_blocks
160
- .get_mut(block_index)
161
- .ok_or_else(|| anyhow::anyhow!("Text block not found"))?;
162
- state.llm.translate(text_block, target_language).await?;
163
- }
164
- None => {
165
- state.llm.translate(&mut updated, target_language).await?;
166
- }
167
- }
168
-
169
- state_tx::update_doc(
170
- &state.state,
171
- payload.index,
172
- updated,
173
- &[ChangedField::TextBlocks],
174
- )
175
- .await
176
- }
177
-
178
- pub async fn llm_ping(
179
- http_client: Arc<ClientWithMiddleware>,
180
- base_url: &str,
181
- api_key: Option<&str>,
182
- ) -> anyhow::Result<openai_compatible::PingResult> {
183
- openai_compatible::ping(http_client, base_url, api_key).await
184
- }
185
-
186
- pub async fn get_document_for_llm(
187
- state: AppResources,
188
- payload: IndexPayload,
189
- ) -> anyhow::Result<koharu_core::Document> {
190
- state_tx::read_doc(&state.state, payload.index).await
191
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu-app/src/ops/mod.rs DELETED
@@ -1,13 +0,0 @@
1
- mod core;
2
- mod edit;
3
- mod llm;
4
- mod process;
5
- mod utils;
6
- mod vision;
7
-
8
- pub use core::*;
9
- pub use edit::*;
10
- pub use llm::*;
11
- pub use process::*;
12
- pub use utils::{InpaintRegionExt, load_documents};
13
- pub use vision::*;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu-app/src/ops/process.rs DELETED
@@ -1,56 +0,0 @@
1
- use std::sync::{
2
- Arc,
3
- atomic::{AtomicBool, Ordering},
4
- };
5
-
6
- use koharu_core::commands::ProcessRequest;
7
- use koharu_llm::providers::get_saved_api_key;
8
- use uuid::Uuid;
9
-
10
- use crate::AppResources;
11
-
12
- pub async fn process(state: AppResources, payload: ProcessRequest) -> anyhow::Result<String> {
13
- let mut payload = payload;
14
- if let Some(model_id) = payload.llm_model_id.as_deref()
15
- && payload.llm_api_key.is_none()
16
- && model_id.contains(':')
17
- {
18
- let (provider_id, _) = model_id
19
- .split_once(':')
20
- .ok_or_else(|| anyhow::anyhow!("invalid api model id"))?;
21
- payload.llm_api_key = get_saved_api_key(provider_id)?;
22
- }
23
-
24
- {
25
- let guard = state.pipeline.read().await;
26
- if guard.is_some() {
27
- anyhow::bail!("A processing pipeline is already running");
28
- }
29
- }
30
-
31
- let job_id = Uuid::new_v4().to_string();
32
- let cancel = Arc::new(AtomicBool::new(false));
33
- {
34
- let mut guard = state.pipeline.write().await;
35
- *guard = Some(crate::pipeline::PipelineHandle {
36
- id: job_id.clone(),
37
- cancel: cancel.clone(),
38
- });
39
- }
40
-
41
- let resources = state.clone();
42
- let job_id_for_task = job_id.clone();
43
- tokio::spawn(async move {
44
- crate::pipeline::run_pipeline(resources, payload, cancel, job_id_for_task).await;
45
- });
46
-
47
- Ok(job_id)
48
- }
49
-
50
- pub async fn process_cancel(state: AppResources) -> anyhow::Result<()> {
51
- let guard = state.pipeline.read().await;
52
- if let Some(handle) = guard.as_ref() {
53
- handle.cancel.store(true, Ordering::Relaxed);
54
- }
55
- Ok(())
56
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu-app/src/ops/vision.rs DELETED
@@ -1,72 +0,0 @@
1
- use koharu_core::FontFaceInfo;
2
- use koharu_core::commands::{IndexPayload, RenderPayload};
3
- use tracing::instrument;
4
-
5
- use crate::{
6
- AppResources,
7
- state_tx::{self, ChangedField},
8
- };
9
-
10
- #[instrument(level = "info", skip_all)]
11
- pub async fn detect(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
12
- let mut snapshot = state_tx::read_doc(&state.state, payload.index).await?;
13
- state.ml.detect(&mut snapshot).await?;
14
- state_tx::update_doc(
15
- &state.state,
16
- payload.index,
17
- snapshot,
18
- &[ChangedField::TextBlocks, ChangedField::Segment],
19
- )
20
- .await
21
- }
22
-
23
- #[instrument(level = "info", skip_all)]
24
- pub async fn ocr(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
25
- let mut snapshot = state_tx::read_doc(&state.state, payload.index).await?;
26
- state.ml.ocr(&mut snapshot).await?;
27
- state_tx::update_doc(
28
- &state.state,
29
- payload.index,
30
- snapshot,
31
- &[ChangedField::TextBlocks],
32
- )
33
- .await
34
- }
35
-
36
- #[instrument(level = "info", skip_all)]
37
- pub async fn inpaint(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> {
38
- let mut snapshot = state_tx::read_doc(&state.state, payload.index).await?;
39
- state.ml.inpaint(&mut snapshot).await?;
40
- state_tx::update_doc(
41
- &state.state,
42
- payload.index,
43
- snapshot,
44
- &[ChangedField::Inpainted],
45
- )
46
- .await
47
- }
48
-
49
- #[instrument(level = "info", skip_all)]
50
- pub async fn render(state: AppResources, payload: RenderPayload) -> anyhow::Result<()> {
51
- let mut updated = state_tx::read_doc(&state.state, payload.index).await?;
52
-
53
- state.renderer.render(
54
- &mut updated,
55
- payload.text_block_index,
56
- payload.shader_effect.unwrap_or_default(),
57
- payload.shader_stroke,
58
- payload.font_family.as_deref(),
59
- )?;
60
-
61
- state_tx::update_doc(
62
- &state.state,
63
- payload.index,
64
- updated,
65
- &[ChangedField::TextBlocks, ChangedField::Rendered],
66
- )
67
- .await
68
- }
69
-
70
- pub async fn list_font_families(state: AppResources) -> anyhow::Result<Vec<FontFaceInfo>> {
71
- state.renderer.available_fonts()
72
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu-app/src/page_cache.rs ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::num::NonZeroUsize;
2
+ use std::path::{Path, PathBuf};
3
+ use std::sync::Arc;
4
+
5
+ use anyhow::{Context, Result};
6
+ use image::DynamicImage;
7
+ use lru::LruCache;
8
+ use tokio::sync::RwLock;
9
+
10
+ use koharu_core::{Document, DocumentSummary, FileEntry};
11
+
12
+ use crate::blob_store::BlobStore;
13
+ use crate::manifest::{ManifestStore, PageManifest};
14
+
15
+ const DEFAULT_CAPACITY: usize = 5;
16
+
17
+ #[derive(Clone)]
18
+ pub struct PageCache {
19
+ cache: Arc<RwLock<LruCache<String, Document>>>,
20
+ pub(crate) blobs: BlobStore,
21
+ manifests: ManifestStore,
22
+ }
23
+
24
+ impl PageCache {
25
+ pub fn new(blobs: BlobStore, manifests: ManifestStore) -> Self {
26
+ Self {
27
+ cache: Arc::new(RwLock::new(LruCache::new(
28
+ NonZeroUsize::new(DEFAULT_CAPACITY).unwrap(),
29
+ ))),
30
+ blobs,
31
+ manifests,
32
+ }
33
+ }
34
+
35
+ /// Get a page, loading from disk if not cached.
36
+ pub async fn get(&self, id: &str) -> Result<Document> {
37
+ // Check cache first
38
+ {
39
+ let mut cache = self.cache.write().await;
40
+ if let Some(doc) = cache.get(id) {
41
+ return Ok(doc.clone());
42
+ }
43
+ }
44
+ // Cache miss -- load from manifest + blobs
45
+ let doc = self.load_from_disk(id)?;
46
+ let mut cache = self.cache.write().await;
47
+ cache.put(id.to_string(), doc.clone());
48
+ Ok(doc)
49
+ }
50
+
51
+ /// Update a page in cache and save manifest + blobs to disk.
52
+ pub async fn put(&self, doc: &Document) -> Result<()> {
53
+ // Encode layers to blobs and save manifest
54
+ self.save_to_disk(doc)?;
55
+ // Update cache
56
+ let mut cache = self.cache.write().await;
57
+ cache.put(doc.id.clone(), doc.clone());
58
+ Ok(())
59
+ }
60
+
61
+ /// Evict a page from cache (does not delete from disk).
62
+ pub async fn evict(&self, id: &str) {
63
+ let mut cache = self.cache.write().await;
64
+ cache.pop(id);
65
+ }
66
+
67
+ /// Save a manifest without loading/putting the full document.
68
+ pub fn save_manifest(&self, manifest: &PageManifest) -> Result<()> {
69
+ self.manifests.save(manifest)
70
+ }
71
+
72
+ pub fn list_documents(&self) -> Result<Vec<DocumentSummary>> {
73
+ let mut entries: Vec<DocumentSummary> = self
74
+ .manifests
75
+ .load_all()?
76
+ .into_iter()
77
+ .map(|manifest| DocumentSummary {
78
+ id: manifest.id,
79
+ name: manifest.name,
80
+ width: manifest.width,
81
+ height: manifest.height,
82
+ has_segment: manifest.layers.segment.is_some(),
83
+ has_inpainted: manifest.layers.inpainted.is_some(),
84
+ has_rendered: manifest.layers.rendered.is_some(),
85
+ has_brush_layer: manifest.layers.brush_layer.is_some(),
86
+ text_block_count: manifest.text_blocks.len(),
87
+ })
88
+ .collect();
89
+ entries.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));
90
+ Ok(entries)
91
+ }
92
+
93
+ pub async fn replace_manifests(&self, manifests: &[PageManifest]) -> Result<()> {
94
+ self.manifests.replace_all(manifests)?;
95
+ let mut cache = self.cache.write().await;
96
+ cache.clear();
97
+ Ok(())
98
+ }
99
+
100
+ pub fn import_files(&self, inputs: Vec<FileEntry>) -> Result<Vec<PageManifest>> {
101
+ use image::GenericImageView;
102
+ use rayon::iter::{IntoParallelIterator, ParallelIterator};
103
+
104
+ let manifests: Result<Vec<PageManifest>> = inputs
105
+ .into_par_iter()
106
+ .map(|file| {
107
+ let image = image::load_from_memory(&file.data)
108
+ .map_err(|error| anyhow::anyhow!("failed to decode {}: {error}", file.name))?;
109
+ let (width, height) = image.dimensions();
110
+ let id = blake3::hash(&file.data).to_hex().to_string();
111
+ let source = self.blobs.put(&file.data)?;
112
+ let name = Path::new(&file.name)
113
+ .file_stem()
114
+ .unwrap_or_default()
115
+ .to_string_lossy()
116
+ .to_string();
117
+
118
+ Ok(PageManifest {
119
+ id,
120
+ source,
121
+ name,
122
+ width,
123
+ height,
124
+ layers: crate::manifest::LayerRefs::default(),
125
+ text_blocks: Vec::new(),
126
+ })
127
+ })
128
+ .collect();
129
+
130
+ let mut manifests = manifests?;
131
+ manifests.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id)));
132
+ Ok(manifests)
133
+ }
134
+
135
+ fn load_from_disk(&self, id: &str) -> Result<Document> {
136
+ let manifest = self.manifests.load(id)?;
137
+
138
+ let image_bytes = self.blobs
139
+ .get(&manifest.source)
140
+ .with_context(|| format!("Source blob not found: {}", manifest.source))?;
141
+ let image =
142
+ image::load_from_memory(&image_bytes).context("Failed to decode source image")?;
143
+
144
+ // Load layers from blob store
145
+ let segment = self.load_layer(&manifest.layers.segment)?;
146
+ let inpainted = self.load_layer(&manifest.layers.inpainted)?;
147
+ let rendered = self.load_layer(&manifest.layers.rendered)?;
148
+ let brush_layer = self.load_layer(&manifest.layers.brush_layer)?;
149
+
150
+ Ok(Document {
151
+ id: manifest.id,
152
+ path: PathBuf::from(&manifest.name),
153
+ name: manifest.name,
154
+ image: image.into(),
155
+ width: manifest.width,
156
+ height: manifest.height,
157
+ text_blocks: manifest.text_blocks,
158
+ segment: segment.map(Into::into),
159
+ inpainted: inpainted.map(Into::into),
160
+ rendered: rendered.map(Into::into),
161
+ brush_layer: brush_layer.map(Into::into),
162
+ })
163
+ }
164
+
165
+ fn save_to_disk(&self, doc: &Document) -> Result<PageManifest> {
166
+ let encode = |img: &koharu_core::SerializableDynamicImage| -> Result<String> {
167
+ let mut buf = std::io::Cursor::new(Vec::new());
168
+ img.0
169
+ .write_to(&mut buf, image::ImageFormat::WebP)
170
+ .context("Failed to encode layer")?;
171
+ self.blobs.put(&buf.into_inner())
172
+ };
173
+
174
+ let segment = doc.segment.as_ref().map(encode).transpose()?;
175
+ let inpainted = doc.inpainted.as_ref().map(encode).transpose()?;
176
+ let rendered = doc.rendered.as_ref().map(encode).transpose()?;
177
+ let brush_layer = doc.brush_layer.as_ref().map(encode).transpose()?;
178
+ let source = match self.manifests.load(&doc.id) {
179
+ Ok(existing) if !existing.source.is_empty() => existing.source,
180
+ _ => {
181
+ let mut buf = std::io::Cursor::new(Vec::new());
182
+ let format =
183
+ image::ImageFormat::from_path(&doc.path).unwrap_or(image::ImageFormat::Png);
184
+ doc.image
185
+ .0
186
+ .write_to(&mut buf, format)
187
+ .context("Failed to encode source image")?;
188
+ self.blobs.put(&buf.into_inner())?
189
+ }
190
+ };
191
+
192
+ let manifest = PageManifest {
193
+ id: doc.id.clone(),
194
+ source,
195
+ name: doc.name.clone(),
196
+ width: doc.width,
197
+ height: doc.height,
198
+ layers: crate::manifest::LayerRefs {
199
+ segment,
200
+ inpainted,
201
+ rendered,
202
+ brush_layer,
203
+ },
204
+ text_blocks: doc.text_blocks.clone(),
205
+ };
206
+
207
+ self.manifests.save(&manifest)?;
208
+ Ok(manifest)
209
+ }
210
+
211
+ fn load_layer(&self, hash: &Option<String>) -> Result<Option<DynamicImage>> {
212
+ match hash {
213
+ Some(h) if !h.is_empty() => {
214
+ let bytes = self.blobs.get(h)?;
215
+ let img = image::load_from_memory(&bytes)
216
+ .with_context(|| format!("Failed to decode layer blob {h}"))?;
217
+ Ok(Some(img))
218
+ }
219
+ _ => Ok(None),
220
+ }
221
+ }
222
+ }
223
+
224
+ #[cfg(test)]
225
+ mod tests {
226
+ use std::io::Cursor;
227
+ use std::path::PathBuf;
228
+
229
+ use image::{DynamicImage, ImageFormat};
230
+ use koharu_core::FileEntry;
231
+
232
+ use super::PageCache;
233
+ use crate::{blob_store::BlobStore, manifest::ManifestStore};
234
+
235
+ fn cache() -> (tempfile::TempDir, PageCache) {
236
+ let tempdir = tempfile::tempdir().unwrap();
237
+ let blobs = BlobStore::new(tempdir.path().join("blobs")).unwrap();
238
+ let manifests = ManifestStore::new(tempdir.path().join("pages")).unwrap();
239
+ (tempdir, PageCache::new(blobs, manifests))
240
+ }
241
+
242
+ #[test]
243
+ fn import_files_stores_source_as_blob_hash() {
244
+ let (_tempdir, cache) = cache();
245
+
246
+ let mut encoded = Cursor::new(Vec::new());
247
+ DynamicImage::new_rgba8(4, 5)
248
+ .write_to(&mut encoded, ImageFormat::Png)
249
+ .unwrap();
250
+
251
+ let manifests = cache
252
+ .import_files(vec![FileEntry {
253
+ name: "page.png".to_string(),
254
+ data: encoded.into_inner(),
255
+ }])
256
+ .unwrap();
257
+
258
+ assert_eq!(manifests.len(), 1);
259
+ assert_eq!(manifests[0].name, "page");
260
+ assert!(!manifests[0].source.is_empty());
261
+ assert!(cache.blobs.exists(&manifests[0].source));
262
+ }
263
+
264
+ #[test]
265
+ fn load_from_disk_reads_source_blob() {
266
+ let (_tempdir, cache) = cache();
267
+
268
+ let mut encoded = Cursor::new(Vec::new());
269
+ DynamicImage::new_rgba8(4, 5)
270
+ .write_to(&mut encoded, ImageFormat::Png)
271
+ .unwrap();
272
+
273
+ let manifest = cache
274
+ .import_files(vec![FileEntry {
275
+ name: "page.png".to_string(),
276
+ data: encoded.into_inner(),
277
+ }])
278
+ .unwrap()
279
+ .pop()
280
+ .unwrap();
281
+
282
+ cache.save_manifest(&manifest).unwrap();
283
+ let document = cache.load_from_disk(&manifest.id).unwrap();
284
+
285
+ assert_eq!(document.name, "page");
286
+ assert_eq!(document.width, 4);
287
+ assert_eq!(document.height, 5);
288
+ }
289
+ }
koharu-app/src/pipeline.rs CHANGED
@@ -1,4 +1,4 @@
1
- use std::str::FromStr;
2
  use std::sync::{
3
  Arc,
4
  atomic::{AtomicBool, Ordering},
@@ -6,30 +6,28 @@ use std::sync::{
6
  use std::time::Duration;
7
 
8
  use koharu_core::commands::ProcessRequest;
9
- use koharu_core::events::{PipelineProgress, PipelineStatus, PipelineStep};
10
- use koharu_llm::ModelId;
11
- use once_cell::sync::Lazy;
12
- use tokio::sync::broadcast;
13
-
14
- use crate::{
15
- AppResources,
16
- state_tx::{self, ChangedField},
17
- };
18
 
19
  pub struct PipelineHandle {
20
  pub id: String,
21
  pub cancel: Arc<AtomicBool>,
22
  }
23
 
24
- static PIPELINE_TX: Lazy<broadcast::Sender<PipelineProgress>> =
25
- Lazy::new(|| broadcast::channel(256).0);
26
-
27
- pub fn subscribe() -> broadcast::Receiver<PipelineProgress> {
28
- PIPELINE_TX.subscribe()
29
- }
30
-
31
- fn emit(progress: PipelineProgress) {
32
- let _ = PIPELINE_TX.send(progress);
33
  }
34
 
35
  fn compute_percent(doc: usize, step: usize, total_docs: usize, total_steps: usize) -> u8 {
@@ -41,58 +39,142 @@ fn compute_percent(doc: usize, step: usize, total_docs: usize, total_steps: usiz
41
  ((done_units as f64 / total_units as f64) * 100.0).round() as u8
42
  }
43
 
44
- pub async fn run_pipeline(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  resources: AppResources,
46
  request: ProcessRequest,
47
  cancel: Arc<AtomicBool>,
48
  job_id: String,
 
49
  ) {
50
- let result = run_pipeline_inner(&resources, &request, &cancel, &job_id).await;
51
 
52
- let total_docs = match request.index {
53
  Some(_) => 1,
54
- None => resources.state.read().await.documents.len(),
 
 
 
 
55
  };
56
 
57
- match result {
58
- Ok(()) if cancel.load(Ordering::Relaxed) => {
59
- emit(PipelineProgress {
60
- job_id: job_id.clone(),
61
- status: PipelineStatus::Cancelled,
62
- step: None,
63
  current_document: total_docs,
64
  total_documents: total_docs,
65
  current_step_index: 0,
66
  total_steps: PipelineStep::ALL.len(),
67
  overall_percent: 0,
68
- });
69
- }
70
- Ok(()) => {
71
- emit(PipelineProgress {
72
- job_id: job_id.clone(),
73
- status: PipelineStatus::Completed,
74
- step: None,
75
  current_document: total_docs,
76
  total_documents: total_docs,
77
  current_step_index: PipelineStep::ALL.len(),
78
  total_steps: PipelineStep::ALL.len(),
79
  overall_percent: 100,
80
- });
81
- }
82
  Err(err) => {
83
  tracing::error!("Pipeline failed: {err:#}");
84
- emit(PipelineProgress {
85
- job_id: job_id.clone(),
86
- status: PipelineStatus::Failed(err.to_string()),
87
- step: None,
88
- current_document: 0,
89
- total_documents: total_docs,
90
- current_step_index: 0,
91
- total_steps: PipelineStep::ALL.len(),
92
- overall_percent: 0,
93
- });
 
 
94
  }
95
- }
 
96
 
97
  let mut guard = resources.pipeline.write().await;
98
  *guard = None;
@@ -103,97 +185,69 @@ async fn run_pipeline_inner(
103
  req: &ProcessRequest,
104
  cancel: &Arc<AtomicBool>,
105
  job_id: &str,
 
106
  ) -> anyhow::Result<()> {
107
- let total_docs = {
108
- let guard = res.state.read().await;
109
- let len = guard.documents.len();
110
- match req.index {
111
- Some(i) if i >= len => anyhow::bail!("Document index {i} out of range (have {len})"),
112
- Some(_) => 1,
113
- None => len,
114
  }
 
115
  };
116
 
 
117
  if total_docs == 0 {
118
  return Ok(());
119
  }
120
 
121
- if let Some(model_id) = &req.llm_model_id
122
- && !res.llm.ready().await
123
- {
124
- if model_id.contains(':') {
125
- let (provider_id, model_part) = model_id.split_once(':').unwrap();
126
- res.llm
127
- .load_api(
128
- provider_id,
129
- model_part,
130
- koharu_llm::providers::ProviderConfig {
131
- http_client: res.runtime.http_client(),
132
- api_key: req.llm_api_key.clone(),
133
- base_url: req.llm_base_url.clone(),
134
- temperature: req.llm_temperature,
135
- max_tokens: req.llm_max_tokens,
136
- custom_system_prompt: req.llm_custom_system_prompt.clone(),
137
- },
138
- )
139
- .await?;
140
- } else {
141
- let id = ModelId::from_str(model_id)?;
142
- res.llm.load(id).await;
143
- for _ in 0..300 {
144
- if res.llm.ready().await {
145
- break;
146
- }
147
- tokio::time::sleep(Duration::from_millis(100)).await;
148
- if cancel.load(Ordering::Relaxed) {
149
- return Ok(());
150
- }
151
- }
152
- if !res.llm.ready().await {
153
- anyhow::bail!("LLM failed to load within timeout");
154
- }
155
- }
156
  }
157
 
158
- let start_index = req.index.unwrap_or(0);
159
- let end_index = req.index.map(|i| i + 1).unwrap_or(total_docs);
160
  let total_steps = PipelineStep::ALL.len();
161
 
162
- for (doc_ordinal, doc_index) in (start_index..end_index).enumerate() {
 
 
163
  for (step_ordinal, step) in PipelineStep::ALL.iter().enumerate() {
164
  if cancel.load(Ordering::Relaxed) {
165
  return Ok(());
166
  }
167
 
168
  let overall = compute_percent(doc_ordinal, step_ordinal, total_docs, total_steps);
169
- emit(PipelineProgress {
170
- job_id: job_id.to_string(),
171
- status: PipelineStatus::Running,
172
- step: Some(*step),
173
- current_document: doc_ordinal,
174
- total_documents: total_docs,
175
- current_step_index: step_ordinal,
176
- total_steps,
177
- overall_percent: overall,
178
- });
 
 
 
 
 
 
179
 
180
  tokio::task::yield_now().await;
181
  tokio::time::sleep(Duration::from_millis(1)).await;
182
 
183
- let mut snapshot = state_tx::read_doc(&res.state, doc_index).await?;
184
-
185
  match step {
186
- PipelineStep::Detect => res.ml.detect(&mut snapshot).await?,
187
- PipelineStep::Ocr => res.ml.ocr(&mut snapshot).await?,
188
- PipelineStep::Inpaint => res.ml.inpaint(&mut snapshot).await?,
189
  PipelineStep::LlmGenerate => {
190
- res.llm
191
- .translate(&mut snapshot, req.language.as_deref())
192
- .await?;
193
  }
194
  PipelineStep::Render => {
195
  res.renderer.render(
196
- &mut snapshot,
197
  None,
198
  req.shader_effect.unwrap_or_default(),
199
  req.shader_stroke.clone(),
@@ -202,15 +256,11 @@ async fn run_pipeline_inner(
202
  }
203
  }
204
 
205
- let changed = match step {
206
- PipelineStep::Detect => &[ChangedField::TextBlocks, ChangedField::Segment][..],
207
- PipelineStep::Ocr => &[ChangedField::TextBlocks][..],
208
- PipelineStep::Inpaint => &[ChangedField::Inpainted][..],
209
- PipelineStep::LlmGenerate => &[ChangedField::TextBlocks][..],
210
- PipelineStep::Render => &[ChangedField::TextBlocks, ChangedField::Rendered][..],
211
- };
212
- state_tx::update_doc(&res.state, doc_index, snapshot, changed).await?;
213
  }
 
 
214
  }
215
 
216
  Ok(())
 
1
+ use std::collections::HashMap;
2
  use std::sync::{
3
  Arc,
4
  atomic::{AtomicBool, Ordering},
 
6
  use std::time::Duration;
7
 
8
  use koharu_core::commands::ProcessRequest;
9
+ use koharu_core::events::{PipelineStatus, PipelineStep};
10
+ use koharu_core::{JobState, JobStatus};
11
+ use tokio::sync::RwLock;
12
+ use uuid::Uuid;
13
+
14
+ use crate::AppResources;
15
+ use crate::llm::ensure_target_ready;
16
+
17
+ pub type Jobs = Arc<RwLock<HashMap<String, JobState>>>;
18
 
19
  pub struct PipelineHandle {
20
  pub id: String,
21
  pub cancel: Arc<AtomicBool>,
22
  }
23
 
24
+ #[derive(Clone, Copy)]
25
+ struct JobProgress {
26
+ current_document: usize,
27
+ total_documents: usize,
28
+ current_step_index: usize,
29
+ total_steps: usize,
30
+ overall_percent: u8,
 
 
31
  }
32
 
33
  fn compute_percent(doc: usize, step: usize, total_docs: usize, total_steps: usize) -> u8 {
 
39
  ((done_units as f64 / total_units as f64) * 100.0).round() as u8
40
  }
41
 
42
+ async fn update_job(jobs: &Jobs, job: JobState) {
43
+ let terminal = !matches!(job.status, JobStatus::Running);
44
+ let mut guard = jobs.write().await;
45
+ if terminal {
46
+ guard.remove(&job.id);
47
+ } else {
48
+ guard.insert(job.id.clone(), job);
49
+ }
50
+ }
51
+
52
+ fn make_job_state(
53
+ job_id: &str,
54
+ status: PipelineStatus,
55
+ step: Option<PipelineStep>,
56
+ progress: JobProgress,
57
+ ) -> JobState {
58
+ let (job_status, error) = match status {
59
+ PipelineStatus::Running => (JobStatus::Running, None),
60
+ PipelineStatus::Completed => (JobStatus::Completed, None),
61
+ PipelineStatus::Cancelled => (JobStatus::Cancelled, None),
62
+ PipelineStatus::Failed(message) => (JobStatus::Failed, Some(message)),
63
+ };
64
+
65
+ JobState {
66
+ id: job_id.to_string(),
67
+ kind: "pipeline".to_string(),
68
+ status: job_status,
69
+ step: step.map(|step| step.to_string()),
70
+ current_document: progress.current_document,
71
+ total_documents: progress.total_documents,
72
+ current_step_index: progress.current_step_index,
73
+ total_steps: progress.total_steps,
74
+ overall_percent: progress.overall_percent,
75
+ error,
76
+ }
77
+ }
78
+
79
+ pub async fn process(
80
+ state: AppResources,
81
+ payload: ProcessRequest,
82
+ jobs: Jobs,
83
+ ) -> anyhow::Result<String> {
84
+ {
85
+ let guard = state.pipeline.read().await;
86
+ if guard.is_some() {
87
+ anyhow::bail!("A processing pipeline is already running");
88
+ }
89
+ }
90
+
91
+ let job_id = Uuid::new_v4().to_string();
92
+ let cancel = Arc::new(AtomicBool::new(false));
93
+ {
94
+ let mut guard = state.pipeline.write().await;
95
+ *guard = Some(PipelineHandle {
96
+ id: job_id.clone(),
97
+ cancel: cancel.clone(),
98
+ });
99
+ }
100
+
101
+ let resources = state.clone();
102
+ let job_id_for_task = job_id.clone();
103
+ tokio::spawn(async move {
104
+ run_pipeline(resources, payload, cancel, job_id_for_task, jobs).await;
105
+ });
106
+
107
+ Ok(job_id)
108
+ }
109
+
110
+ pub async fn process_cancel(state: AppResources) -> anyhow::Result<()> {
111
+ let guard = state.pipeline.read().await;
112
+ if let Some(handle) = guard.as_ref() {
113
+ handle.cancel.store(true, Ordering::Relaxed);
114
+ }
115
+ Ok(())
116
+ }
117
+
118
+ async fn run_pipeline(
119
  resources: AppResources,
120
  request: ProcessRequest,
121
  cancel: Arc<AtomicBool>,
122
  job_id: String,
123
+ jobs: Jobs,
124
  ) {
125
+ let result = run_pipeline_inner(&resources, &request, &cancel, &job_id, &jobs).await;
126
 
127
+ let total_docs = match request.document_id {
128
  Some(_) => 1,
129
+ None => resources
130
+ .cache
131
+ .list_documents()
132
+ .map(|entries| entries.len())
133
+ .unwrap_or(0),
134
  };
135
 
136
+ let final_state = match result {
137
+ Ok(()) if cancel.load(Ordering::Relaxed) => make_job_state(
138
+ &job_id,
139
+ PipelineStatus::Cancelled,
140
+ None,
141
+ JobProgress {
142
  current_document: total_docs,
143
  total_documents: total_docs,
144
  current_step_index: 0,
145
  total_steps: PipelineStep::ALL.len(),
146
  overall_percent: 0,
147
+ },
148
+ ),
149
+ Ok(()) => make_job_state(
150
+ &job_id,
151
+ PipelineStatus::Completed,
152
+ None,
153
+ JobProgress {
154
  current_document: total_docs,
155
  total_documents: total_docs,
156
  current_step_index: PipelineStep::ALL.len(),
157
  total_steps: PipelineStep::ALL.len(),
158
  overall_percent: 100,
159
+ },
160
+ ),
161
  Err(err) => {
162
  tracing::error!("Pipeline failed: {err:#}");
163
+ make_job_state(
164
+ &job_id,
165
+ PipelineStatus::Failed(err.to_string()),
166
+ None,
167
+ JobProgress {
168
+ current_document: 0,
169
+ total_documents: total_docs,
170
+ current_step_index: 0,
171
+ total_steps: PipelineStep::ALL.len(),
172
+ overall_percent: 0,
173
+ },
174
+ )
175
  }
176
+ };
177
+ update_job(&jobs, final_state).await;
178
 
179
  let mut guard = resources.pipeline.write().await;
180
  *guard = None;
 
185
  req: &ProcessRequest,
186
  cancel: &Arc<AtomicBool>,
187
  job_id: &str,
188
+ jobs: &Jobs,
189
  ) -> anyhow::Result<()> {
190
+ let entries = res.cache.list_documents()?;
191
+ let page_ids: Vec<String> = match req.document_id.as_deref() {
192
+ Some(id) => {
193
+ if !entries.iter().any(|p| p.id == id) {
194
+ anyhow::bail!("Document not found: {id}");
195
+ }
196
+ vec![id.to_string()]
197
  }
198
+ None => entries.into_iter().map(|p| p.id).collect(),
199
  };
200
 
201
+ let total_docs = page_ids.len();
202
  if total_docs == 0 {
203
  return Ok(());
204
  }
205
 
206
+ if let Some(llm) = &req.llm {
207
+ ensure_target_ready(res, &llm.target, llm.options.as_ref()).await?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  }
209
 
 
 
210
  let total_steps = PipelineStep::ALL.len();
211
 
212
+ for (doc_ordinal, page_id) in page_ids.iter().enumerate() {
213
+ let mut doc = res.cache.get(page_id).await?;
214
+
215
  for (step_ordinal, step) in PipelineStep::ALL.iter().enumerate() {
216
  if cancel.load(Ordering::Relaxed) {
217
  return Ok(());
218
  }
219
 
220
  let overall = compute_percent(doc_ordinal, step_ordinal, total_docs, total_steps);
221
+ update_job(
222
+ jobs,
223
+ make_job_state(
224
+ job_id,
225
+ PipelineStatus::Running,
226
+ Some(*step),
227
+ JobProgress {
228
+ current_document: doc_ordinal,
229
+ total_documents: total_docs,
230
+ current_step_index: step_ordinal,
231
+ total_steps,
232
+ overall_percent: overall,
233
+ },
234
+ ),
235
+ )
236
+ .await;
237
 
238
  tokio::task::yield_now().await;
239
  tokio::time::sleep(Duration::from_millis(1)).await;
240
 
 
 
241
  match step {
242
+ PipelineStep::Detect => res.ml.detect(&mut doc).await?,
243
+ PipelineStep::Ocr => res.ml.ocr(&mut doc).await?,
244
+ PipelineStep::Inpaint => res.ml.inpaint(&mut doc).await?,
245
  PipelineStep::LlmGenerate => {
246
+ res.llm.translate(&mut doc, req.language.as_deref()).await?;
 
 
247
  }
248
  PipelineStep::Render => {
249
  res.renderer.render(
250
+ &mut doc,
251
  None,
252
  req.shader_effect.unwrap_or_default(),
253
  req.shader_stroke.clone(),
 
256
  }
257
  }
258
 
259
+ // Save after each step (crash-safe)
260
+ res.cache.put(&doc).await?;
 
 
 
 
 
 
261
  }
262
+ // Evict from cache to free memory
263
+ res.cache.evict(page_id).await;
264
  }
265
 
266
  Ok(())
koharu-app/src/state_tx.rs DELETED
@@ -1,224 +0,0 @@
1
- use anyhow::Result;
2
- use koharu_core::{AppState, Document};
3
- use once_cell::sync::Lazy;
4
- use tokio::sync::broadcast;
5
-
6
- #[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)]
7
- pub enum ChangedField {
8
- #[strum(serialize = "name")]
9
- Name,
10
- #[strum(serialize = "textBlocks")]
11
- TextBlocks,
12
- #[strum(serialize = "segment")]
13
- Segment,
14
- #[strum(serialize = "brushLayer")]
15
- BrushLayer,
16
- #[strum(serialize = "inpainted")]
17
- Inpainted,
18
- #[strum(serialize = "rendered")]
19
- Rendered,
20
- }
21
-
22
- #[derive(Debug, Clone)]
23
- pub enum StateEvent {
24
- DocumentsChanged,
25
- DocumentChanged {
26
- document_id: String,
27
- revision: u64,
28
- changed: Vec<String>,
29
- },
30
- }
31
-
32
- static STATE_TX: Lazy<broadcast::Sender<StateEvent>> = Lazy::new(|| broadcast::channel(256).0);
33
-
34
- pub fn subscribe() -> broadcast::Receiver<StateEvent> {
35
- STATE_TX.subscribe()
36
- }
37
-
38
- fn emit(event: StateEvent) {
39
- let _ = STATE_TX.send(event);
40
- }
41
-
42
- fn serialize_changed_fields(changed: &[ChangedField]) -> Vec<String> {
43
- changed.iter().map(ToString::to_string).collect()
44
- }
45
-
46
- pub async fn read_doc(state: &AppState, index: usize) -> Result<Document> {
47
- let guard = state.read().await;
48
- guard
49
- .documents
50
- .get(index)
51
- .cloned()
52
- .ok_or_else(|| anyhow::anyhow!("Document not found at index {index}"))
53
- }
54
-
55
- pub async fn list_docs(state: &AppState) -> Vec<Document> {
56
- state.read().await.documents.clone()
57
- }
58
-
59
- pub async fn doc_count(state: &AppState) -> usize {
60
- state.read().await.documents.len()
61
- }
62
-
63
- pub async fn find_doc_index(state: &AppState, document_id: &str) -> Result<usize> {
64
- let guard = state.read().await;
65
- guard
66
- .documents
67
- .iter()
68
- .position(|document| document.id == document_id)
69
- .ok_or_else(|| anyhow::anyhow!("Document not found: {document_id}"))
70
- }
71
-
72
- pub async fn replace_docs(state: &AppState, mut documents: Vec<Document>) -> Result<usize> {
73
- for document in &mut documents {
74
- document.prepare_for_store();
75
- }
76
-
77
- let count = documents.len();
78
- let mut guard = state.write().await;
79
- guard.documents = documents;
80
- drop(guard);
81
- emit(StateEvent::DocumentsChanged);
82
- Ok(count)
83
- }
84
-
85
- pub async fn append_docs(state: &AppState, mut documents: Vec<Document>) -> Result<usize> {
86
- for document in &mut documents {
87
- document.prepare_for_store();
88
- }
89
-
90
- let mut guard = state.write().await;
91
- guard.documents.extend(documents);
92
- let count = guard.documents.len();
93
- drop(guard);
94
- emit(StateEvent::DocumentsChanged);
95
- Ok(count)
96
- }
97
-
98
- pub async fn update_doc(
99
- state: &AppState,
100
- index: usize,
101
- mut document: Document,
102
- changed: &[ChangedField],
103
- ) -> Result<()> {
104
- document.prepare_for_store();
105
- document.bump_revision();
106
- let document_id = document.id.clone();
107
- let revision = document.revision;
108
-
109
- let mut guard = state.write().await;
110
- let target = guard
111
- .documents
112
- .get_mut(index)
113
- .ok_or_else(|| anyhow::anyhow!("Document not found at index {index}"))?;
114
- *target = document;
115
- drop(guard);
116
- emit(StateEvent::DocumentChanged {
117
- document_id,
118
- revision,
119
- changed: serialize_changed_fields(changed),
120
- });
121
- Ok(())
122
- }
123
-
124
- pub async fn mutate_doc<T, F>(
125
- state: &AppState,
126
- index: usize,
127
- changed: &[ChangedField],
128
- mutator: F,
129
- ) -> Result<T>
130
- where
131
- F: FnOnce(&mut Document) -> Result<T>,
132
- {
133
- let mut guard = state.write().await;
134
- let target = guard
135
- .documents
136
- .get_mut(index)
137
- .ok_or_else(|| anyhow::anyhow!("Document not found at index {index}"))?;
138
- let result = mutator(target)?;
139
- target.prepare_for_store();
140
- target.bump_revision();
141
- let document_id = target.id.clone();
142
- let revision = target.revision;
143
- drop(guard);
144
- emit(StateEvent::DocumentChanged {
145
- document_id,
146
- revision,
147
- changed: serialize_changed_fields(changed),
148
- });
149
- Ok(result)
150
- }
151
-
152
- #[cfg(test)]
153
- mod tests {
154
- use super::{
155
- ChangedField, append_docs, find_doc_index, list_docs, mutate_doc, read_doc, replace_docs,
156
- update_doc,
157
- };
158
- use koharu_core::{AppState, Document, State};
159
- use std::sync::Arc;
160
- use tokio::sync::RwLock;
161
-
162
- fn test_state() -> AppState {
163
- Arc::new(RwLock::new(State {
164
- documents: vec![Default::default()],
165
- }))
166
- }
167
-
168
- #[tokio::test]
169
- async fn read_update_mutate_doc_round_trip() {
170
- let state = test_state();
171
-
172
- let mut doc = read_doc(&state, 0).await.expect("doc should exist");
173
- doc.name = "before".to_string();
174
- update_doc(&state, 0, doc, &[ChangedField::Name])
175
- .await
176
- .expect("update should work");
177
-
178
- mutate_doc(&state, 0, &[ChangedField::Name], |doc| {
179
- doc.name = "after".to_string();
180
- Ok(())
181
- })
182
- .await
183
- .expect("mutation should work");
184
-
185
- let doc = read_doc(&state, 0).await.expect("doc should exist");
186
- assert_eq!(doc.name, "after");
187
- }
188
-
189
- #[tokio::test]
190
- async fn index_not_found_errors_are_stable() {
191
- let state = test_state();
192
- let err = read_doc(&state, 1)
193
- .await
194
- .expect_err("missing document should fail");
195
- assert_eq!(err.to_string(), "Document not found at index 1");
196
-
197
- let err = mutate_doc(&state, 1, &[ChangedField::Name], |_| Ok(()))
198
- .await
199
- .expect_err("missing document should fail");
200
- assert_eq!(err.to_string(), "Document not found at index 1");
201
- }
202
-
203
- #[tokio::test]
204
- async fn replace_append_and_find_doc_work() {
205
- let state = test_state();
206
- let first = Document {
207
- id: "first".to_string(),
208
- ..Default::default()
209
- };
210
- replace_docs(&state, vec![first])
211
- .await
212
- .expect("replace should work");
213
- assert_eq!(list_docs(&state).await.len(), 1);
214
-
215
- let second = Document {
216
- id: "second".to_string(),
217
- ..Default::default()
218
- };
219
- append_docs(&state, vec![second])
220
- .await
221
- .expect("append should work");
222
- assert_eq!(find_doc_index(&state, "second").await.expect("find"), 1);
223
- }
224
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu-app/src/{ops/utils.rs → utils.rs} RENAMED
@@ -1,15 +1,14 @@
1
  use std::{io::Cursor, path::PathBuf};
2
 
3
  use image::{ImageFormat, RgbaImage};
4
- use koharu_core::commands::InpaintRegion;
5
- use koharu_core::{Document, SerializableDynamicImage};
6
  use rayon::iter::{IntoParallelIterator, ParallelIterator};
7
 
8
  pub trait InpaintRegionExt {
9
  fn clamp(&self, width: u32, height: u32) -> Option<(u32, u32, u32, u32)>;
10
  }
11
 
12
- impl InpaintRegionExt for InpaintRegion {
13
  fn clamp(&self, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
14
  if width == 0 || height == 0 {
15
  return None;
@@ -27,7 +26,7 @@ impl InpaintRegionExt for InpaintRegion {
27
  }
28
  }
29
 
30
- pub(crate) fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyhow::Result<Vec<u8>> {
31
  let mut buf = Vec::new();
32
  let mut cursor = Cursor::new(&mut buf);
33
  let format = ImageFormat::from_extension(ext).unwrap_or(ImageFormat::Jpeg);
@@ -35,7 +34,7 @@ pub(crate) fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyho
35
  Ok(buf)
36
  }
37
 
38
- pub(crate) fn mime_from_ext(ext: &str) -> &'static str {
39
  match ext {
40
  "png" => "image/png",
41
  "jpg" | "jpeg" => "image/jpeg",
@@ -44,11 +43,7 @@ pub(crate) fn mime_from_ext(ext: &str) -> &'static str {
44
  }
45
  }
46
 
47
- pub(crate) fn blank_rgba(
48
- width: u32,
49
- height: u32,
50
- color: image::Rgba<u8>,
51
- ) -> SerializableDynamicImage {
52
  let blank = RgbaImage::from_pixel(width, height, color);
53
  image::DynamicImage::ImageRgba8(blank).into()
54
  }
 
1
  use std::{io::Cursor, path::PathBuf};
2
 
3
  use image::{ImageFormat, RgbaImage};
4
+ use koharu_core::{Document, Region, SerializableDynamicImage};
 
5
  use rayon::iter::{IntoParallelIterator, ParallelIterator};
6
 
7
  pub trait InpaintRegionExt {
8
  fn clamp(&self, width: u32, height: u32) -> Option<(u32, u32, u32, u32)>;
9
  }
10
 
11
+ impl InpaintRegionExt for Region {
12
  fn clamp(&self, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
13
  if width == 0 || height == 0 {
14
  return None;
 
26
  }
27
  }
28
 
29
+ pub fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyhow::Result<Vec<u8>> {
30
  let mut buf = Vec::new();
31
  let mut cursor = Cursor::new(&mut buf);
32
  let format = ImageFormat::from_extension(ext).unwrap_or(ImageFormat::Jpeg);
 
34
  Ok(buf)
35
  }
36
 
37
+ pub fn mime_from_ext(ext: &str) -> &'static str {
38
  match ext {
39
  "png" => "image/png",
40
  "jpg" | "jpeg" => "image/jpeg",
 
43
  }
44
  }
45
 
46
+ pub fn blank_rgba(width: u32, height: u32, color: image::Rgba<u8>) -> SerializableDynamicImage {
 
 
 
 
47
  let blank = RgbaImage::from_pixel(width, height, color);
48
  image::DynamicImage::ImageRgba8(blank).into()
49
  }
koharu-core/src/commands.rs CHANGED
@@ -1,4 +1,4 @@
1
- use crate::{TextBlock, TextShaderEffect, TextStrokeStyle};
2
  use schemars::JsonSchema;
3
  use serde::{Deserialize, Serialize};
4
 
@@ -14,12 +14,6 @@ pub struct OpenExternalPayload {
14
  pub url: String,
15
  }
16
 
17
- #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)]
18
- #[serde(rename_all = "camelCase")]
19
- pub struct IndexPayload {
20
- pub index: usize,
21
- }
22
-
23
  #[derive(Debug, Clone, Serialize, Deserialize)]
24
  #[serde(rename_all = "camelCase")]
25
  pub struct ThumbnailResult {
@@ -53,81 +47,21 @@ pub struct FileResult {
53
 
54
  #[derive(Debug, Clone, Serialize, Deserialize)]
55
  #[serde(rename_all = "camelCase")]
56
- pub struct RenderPayload {
57
- pub index: usize,
58
- pub text_block_index: Option<usize>,
59
- pub shader_effect: Option<TextShaderEffect>,
60
- pub shader_stroke: Option<TextStrokeStyle>,
61
- pub font_family: Option<String>,
62
- }
63
-
64
- #[derive(Debug, Clone, Serialize, Deserialize)]
65
- #[serde(rename_all = "camelCase")]
66
- pub struct UpdateTextBlocksPayload {
67
- pub index: usize,
68
- pub text_blocks: Vec<TextBlock>,
69
- }
70
-
71
- #[derive(Debug, Clone, Serialize, Deserialize)]
72
- #[serde(rename_all = "camelCase")]
73
- pub struct LlmListPayload {
74
- pub language: Option<String>,
75
- pub openai_compatible_base_url: Option<String>,
76
- }
77
-
78
- #[derive(Debug, Clone, Serialize, Deserialize)]
79
- #[serde(rename_all = "camelCase")]
80
- pub struct ApiKeyGetPayload {
81
- pub provider: String,
82
- }
83
-
84
- #[derive(Debug, Clone, Serialize, Deserialize)]
85
- #[serde(rename_all = "camelCase")]
86
- pub struct ApiKeySetPayload {
87
- pub provider: String,
88
- pub api_key: String,
89
- }
90
-
91
- #[derive(Debug, Clone, Serialize, Deserialize)]
92
- #[serde(rename_all = "camelCase")]
93
- pub struct ApiKeyResult {
94
- pub api_key: Option<String>,
95
- }
96
-
97
- #[derive(Debug, Clone, Serialize, Deserialize)]
98
- #[serde(rename_all = "camelCase")]
99
- pub struct LlmLoadPayload {
100
- pub id: String,
101
- pub api_key: Option<String>,
102
- pub base_url: Option<String>,
103
- pub temperature: Option<f64>,
104
- pub max_tokens: Option<u32>,
105
- pub custom_system_prompt: Option<String>,
106
- }
107
-
108
- #[derive(Debug, Clone, Serialize, Deserialize)]
109
- #[serde(rename_all = "camelCase")]
110
- pub struct LlmGeneratePayload {
111
- pub index: usize,
112
- pub text_block_index: Option<usize>,
113
  pub language: Option<String>,
114
  }
115
 
116
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
117
  #[serde(rename_all = "camelCase")]
118
  pub struct LlmLoadParams {
119
- pub id: String,
120
- pub api_key: Option<String>,
121
- pub base_url: Option<String>,
122
- pub temperature: Option<f64>,
123
- pub max_tokens: Option<u32>,
124
- pub custom_system_prompt: Option<String>,
125
  }
126
 
127
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
128
  #[serde(rename_all = "camelCase")]
129
  pub struct LlmGenerateParams {
130
- pub index: usize,
131
  pub text_block_index: Option<usize>,
132
  pub language: Option<String>,
133
  }
@@ -135,57 +69,18 @@ pub struct LlmGenerateParams {
135
  #[derive(Debug, Clone, Serialize, Deserialize)]
136
  #[serde(rename_all = "camelCase")]
137
  pub struct ProcessRequest {
138
- pub index: Option<usize>,
139
- pub llm_model_id: Option<String>,
140
- pub llm_api_key: Option<String>,
141
- pub llm_base_url: Option<String>,
142
- pub llm_temperature: Option<f64>,
143
- pub llm_max_tokens: Option<u32>,
144
- pub llm_custom_system_prompt: Option<String>,
145
  pub language: Option<String>,
146
  pub shader_effect: Option<TextShaderEffect>,
147
  pub shader_stroke: Option<TextStrokeStyle>,
148
  pub font_family: Option<String>,
149
  }
150
 
151
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
152
- #[serde(rename_all = "camelCase")]
153
- pub struct InpaintRegion {
154
- pub x: u32,
155
- pub y: u32,
156
- pub width: u32,
157
- pub height: u32,
158
- }
159
-
160
- #[derive(Debug, Clone, Serialize, Deserialize)]
161
- #[serde(rename_all = "camelCase")]
162
- pub struct UpdateInpaintMaskPayload {
163
- pub index: usize,
164
- #[serde(with = "serde_bytes")]
165
- pub mask: Vec<u8>,
166
- pub region: Option<InpaintRegion>,
167
- }
168
-
169
- #[derive(Debug, Clone, Serialize, Deserialize)]
170
- #[serde(rename_all = "camelCase")]
171
- pub struct UpdateBrushLayerPayload {
172
- pub index: usize,
173
- #[serde(with = "serde_bytes")]
174
- pub patch: Vec<u8>,
175
- pub region: InpaintRegion,
176
- }
177
-
178
- #[derive(Debug, Clone, Serialize, Deserialize)]
179
- #[serde(rename_all = "camelCase")]
180
- pub struct InpaintPartialPayload {
181
- pub index: usize,
182
- pub region: InpaintRegion,
183
- }
184
-
185
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
186
  #[serde(rename_all = "camelCase")]
187
  pub struct ViewImageParams {
188
- pub index: usize,
189
  pub layer: String,
190
  pub max_size: Option<u32>,
191
  }
@@ -193,7 +88,7 @@ pub struct ViewImageParams {
193
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
194
  #[serde(rename_all = "camelCase")]
195
  pub struct ViewTextBlockParams {
196
- pub index: usize,
197
  pub text_block_index: usize,
198
  pub layer: Option<String>,
199
  }
@@ -207,24 +102,30 @@ pub struct OpenDocumentsParams {
207
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
208
  #[serde(rename_all = "camelCase")]
209
  pub struct ExportDocumentParams {
210
- pub index: usize,
211
  pub output_path: String,
212
  }
213
 
214
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
215
  #[serde(rename_all = "camelCase")]
216
  pub struct RenderParams {
217
- pub index: usize,
218
  pub text_block_index: Option<usize>,
219
  pub shader_effect: Option<String>,
220
  pub font_family: Option<String>,
221
  }
222
 
 
 
 
 
 
 
223
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
224
  #[serde(rename_all = "camelCase")]
225
  pub struct ProcessParams {
226
- pub index: Option<usize>,
227
- pub llm_model_id: Option<String>,
228
  pub language: Option<String>,
229
  pub shader_effect: Option<String>,
230
  pub font_family: Option<String>,
@@ -273,7 +174,7 @@ pub struct MaskMorphPayload {
273
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
274
  #[serde(rename_all = "camelCase")]
275
  pub struct InpaintRegionParams {
276
- pub index: usize,
277
  pub x: u32,
278
  pub y: u32,
279
  pub width: u32,
@@ -282,7 +183,6 @@ pub struct InpaintRegionParams {
282
 
283
  #[cfg(test)]
284
  mod tests {
285
- use crate::{TextAlign, TextStyle};
286
  use serde::Serialize;
287
  use serde::de::DeserializeOwned;
288
 
@@ -301,39 +201,13 @@ mod tests {
301
 
302
  #[test]
303
  fn command_dtos_round_trip() {
304
- let text_block = TextBlock {
305
- x: 10.0,
306
- y: 11.0,
307
- width: 120.0,
308
- height: 40.0,
309
- confidence: 0.95,
310
- text: Some("source".to_string()),
311
- translation: Some("translated".to_string()),
312
- style: Some(TextStyle {
313
- font_families: vec!["Noto Sans".to_string()],
314
- font_size: Some(18.0),
315
- color: [255, 255, 255, 255],
316
- effect: Some(TextShaderEffect {
317
- italic: true,
318
- bold: false,
319
- }),
320
- stroke: Some(TextStrokeStyle {
321
- enabled: true,
322
- color: [255, 255, 255, 255],
323
- width_px: Some(2.0),
324
- }),
325
- text_align: Some(TextAlign::Right),
326
- }),
327
- ..Default::default()
328
- };
329
-
330
  round_trip(&DeviceInfo {
331
  ml_device: "CPU".to_string(),
332
  });
333
  round_trip(&OpenExternalPayload {
334
  url: "https://example.com".to_string(),
335
  });
336
- round_trip(&IndexPayload { index: 2 });
337
  round_trip(&ThumbnailResult {
338
  data: vec![1, 2, 3],
339
  content_type: "image/webp".to_string(),
@@ -353,62 +227,52 @@ mod tests {
353
  data: vec![1, 2, 3, 4],
354
  content_type: "image/png".to_string(),
355
  });
356
- round_trip(&RenderPayload {
357
- index: 1,
358
- text_block_index: Some(3),
359
- shader_effect: Some(TextShaderEffect {
360
- italic: false,
361
- bold: true,
362
- }),
363
- shader_stroke: Some(TextStrokeStyle {
364
- enabled: true,
365
- color: [255, 255, 255, 255],
366
- width_px: Some(1.6),
367
- }),
368
- font_family: Some("Noto Sans".to_string()),
369
- });
370
- round_trip(&UpdateTextBlocksPayload {
371
- index: 1,
372
- text_blocks: vec![text_block.clone()],
373
- });
374
- round_trip(&LlmListPayload {
375
  language: Some("zh-CN".to_string()),
376
- openai_compatible_base_url: Some("http://127.0.0.1:1234/v1".to_string()),
377
- });
378
- round_trip(&LlmLoadPayload {
379
- id: "sakura".to_string(),
380
- api_key: None,
381
- base_url: Some("http://127.0.0.1:1234/v1".to_string()),
382
- temperature: Some(0.1),
383
- max_tokens: Some(1000),
384
- custom_system_prompt: None,
385
  });
386
- round_trip(&LlmGeneratePayload {
387
- index: 1,
388
- text_block_index: Some(0),
389
- language: Some("zh-CN".to_string()),
 
 
 
 
 
 
 
390
  });
391
  round_trip(&LlmLoadParams {
392
- id: "sakura".to_string(),
393
- api_key: None,
394
- base_url: Some("http://127.0.0.1:1234/v1".to_string()),
395
- temperature: None,
396
- max_tokens: None,
397
- custom_system_prompt: None,
 
 
 
 
398
  });
399
  round_trip(&LlmGenerateParams {
400
- index: 1,
401
  text_block_index: Some(0),
402
  language: Some("zh-CN".to_string()),
403
  });
404
  round_trip(&ProcessRequest {
405
- index: Some(1),
406
- llm_model_id: Some("sakura".to_string()),
407
- llm_api_key: None,
408
- llm_base_url: Some("http://127.0.0.1:1234/v1".to_string()),
409
- llm_temperature: Some(0.1),
410
- llm_max_tokens: Some(1000),
411
- llm_custom_system_prompt: Some("Translate manga".to_string()),
 
 
 
 
 
 
412
  language: Some("zh-CN".to_string()),
413
  shader_effect: Some(TextShaderEffect {
414
  italic: true,
@@ -421,48 +285,13 @@ mod tests {
421
  }),
422
  font_family: Some("Noto Sans".to_string()),
423
  });
424
- round_trip(&InpaintRegion {
425
- x: 10,
426
- y: 20,
427
- width: 30,
428
- height: 40,
429
- });
430
- round_trip(&UpdateInpaintMaskPayload {
431
- index: 1,
432
- mask: vec![0, 255],
433
- region: Some(InpaintRegion {
434
- x: 1,
435
- y: 2,
436
- width: 3,
437
- height: 4,
438
- }),
439
- });
440
- round_trip(&UpdateBrushLayerPayload {
441
- index: 1,
442
- patch: vec![1, 2, 3],
443
- region: InpaintRegion {
444
- x: 4,
445
- y: 5,
446
- width: 6,
447
- height: 7,
448
- },
449
- });
450
- round_trip(&InpaintPartialPayload {
451
- index: 1,
452
- region: InpaintRegion {
453
- x: 8,
454
- y: 9,
455
- width: 10,
456
- height: 11,
457
- },
458
- });
459
  round_trip(&ViewImageParams {
460
- index: 1,
461
  layer: "original".to_string(),
462
  max_size: Some(512),
463
  });
464
  round_trip(&ViewTextBlockParams {
465
- index: 1,
466
  text_block_index: 0,
467
  layer: Some("rendered".to_string()),
468
  });
@@ -470,18 +299,22 @@ mod tests {
470
  paths: vec!["a.png".to_string(), "b.png".to_string()],
471
  });
472
  round_trip(&ExportDocumentParams {
473
- index: 1,
474
  output_path: "out.png".to_string(),
475
  });
476
  round_trip(&RenderParams {
477
- index: 1,
478
  text_block_index: Some(0),
479
  shader_effect: Some("bold".to_string()),
480
  font_family: Some("Noto Sans".to_string()),
481
  });
482
  round_trip(&ProcessParams {
483
- index: Some(1),
484
- llm_model_id: Some("sakura".to_string()),
 
 
 
 
485
  language: Some("zh-CN".to_string()),
486
  shader_effect: Some("italic,bold".to_string()),
487
  font_family: Some("Noto Sans".to_string()),
@@ -515,7 +348,7 @@ mod tests {
515
  radius: 2,
516
  });
517
  round_trip(&InpaintRegionParams {
518
- index: 1,
519
  x: 2,
520
  y: 3,
521
  width: 4,
 
1
+ use crate::{TextShaderEffect, TextStrokeStyle};
2
  use schemars::JsonSchema;
3
  use serde::{Deserialize, Serialize};
4
 
 
14
  pub url: String,
15
  }
16
 
 
 
 
 
 
 
17
  #[derive(Debug, Clone, Serialize, Deserialize)]
18
  #[serde(rename_all = "camelCase")]
19
  pub struct ThumbnailResult {
 
47
 
48
  #[derive(Debug, Clone, Serialize, Deserialize)]
49
  #[serde(rename_all = "camelCase")]
50
+ pub struct LlmCatalogPayload {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  pub language: Option<String>,
52
  }
53
 
54
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
55
  #[serde(rename_all = "camelCase")]
56
  pub struct LlmLoadParams {
57
+ pub target: crate::LlmTarget,
58
+ pub options: Option<crate::LlmGenerationOptions>,
 
 
 
 
59
  }
60
 
61
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62
  #[serde(rename_all = "camelCase")]
63
  pub struct LlmGenerateParams {
64
+ pub document_id: String,
65
  pub text_block_index: Option<usize>,
66
  pub language: Option<String>,
67
  }
 
69
  #[derive(Debug, Clone, Serialize, Deserialize)]
70
  #[serde(rename_all = "camelCase")]
71
  pub struct ProcessRequest {
72
+ pub document_id: Option<String>,
73
+ pub llm: Option<crate::PipelineLlmRequest>,
 
 
 
 
 
74
  pub language: Option<String>,
75
  pub shader_effect: Option<TextShaderEffect>,
76
  pub shader_stroke: Option<TextStrokeStyle>,
77
  pub font_family: Option<String>,
78
  }
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
81
  #[serde(rename_all = "camelCase")]
82
  pub struct ViewImageParams {
83
+ pub document_id: String,
84
  pub layer: String,
85
  pub max_size: Option<u32>,
86
  }
 
88
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
89
  #[serde(rename_all = "camelCase")]
90
  pub struct ViewTextBlockParams {
91
+ pub document_id: String,
92
  pub text_block_index: usize,
93
  pub layer: Option<String>,
94
  }
 
102
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
103
  #[serde(rename_all = "camelCase")]
104
  pub struct ExportDocumentParams {
105
+ pub document_id: String,
106
  pub output_path: String,
107
  }
108
 
109
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
110
  #[serde(rename_all = "camelCase")]
111
  pub struct RenderParams {
112
+ pub document_id: String,
113
  pub text_block_index: Option<usize>,
114
  pub shader_effect: Option<String>,
115
  pub font_family: Option<String>,
116
  }
117
 
118
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
119
+ #[serde(rename_all = "camelCase")]
120
+ pub struct DocumentIdParam {
121
+ pub document_id: String,
122
+ }
123
+
124
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
125
  #[serde(rename_all = "camelCase")]
126
  pub struct ProcessParams {
127
+ pub document_id: Option<String>,
128
+ pub llm_target: Option<crate::LlmTarget>,
129
  pub language: Option<String>,
130
  pub shader_effect: Option<String>,
131
  pub font_family: Option<String>,
 
174
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
175
  #[serde(rename_all = "camelCase")]
176
  pub struct InpaintRegionParams {
177
+ pub document_id: String,
178
  pub x: u32,
179
  pub y: u32,
180
  pub width: u32,
 
183
 
184
  #[cfg(test)]
185
  mod tests {
 
186
  use serde::Serialize;
187
  use serde::de::DeserializeOwned;
188
 
 
201
 
202
  #[test]
203
  fn command_dtos_round_trip() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  round_trip(&DeviceInfo {
205
  ml_device: "CPU".to_string(),
206
  });
207
  round_trip(&OpenExternalPayload {
208
  url: "https://example.com".to_string(),
209
  });
210
+
211
  round_trip(&ThumbnailResult {
212
  data: vec![1, 2, 3],
213
  content_type: "image/webp".to_string(),
 
227
  data: vec![1, 2, 3, 4],
228
  content_type: "image/png".to_string(),
229
  });
230
+ round_trip(&LlmCatalogPayload {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  language: Some("zh-CN".to_string()),
 
 
 
 
 
 
 
 
 
232
  });
233
+ round_trip(&crate::LlmLoadRequest {
234
+ target: crate::LlmTarget {
235
+ kind: crate::LlmTargetKind::Local,
236
+ model_id: "sakura".to_string(),
237
+ provider_id: None,
238
+ },
239
+ options: Some(crate::LlmGenerationOptions {
240
+ temperature: Some(0.1),
241
+ max_tokens: Some(1000),
242
+ custom_system_prompt: None,
243
+ }),
244
  });
245
  round_trip(&LlmLoadParams {
246
+ target: crate::LlmTarget {
247
+ kind: crate::LlmTargetKind::Provider,
248
+ model_id: "gpt-5-mini".to_string(),
249
+ provider_id: Some("openai".to_string()),
250
+ },
251
+ options: Some(crate::LlmGenerationOptions {
252
+ temperature: None,
253
+ max_tokens: None,
254
+ custom_system_prompt: None,
255
+ }),
256
  });
257
  round_trip(&LlmGenerateParams {
258
+ document_id: "abc123".to_string(),
259
  text_block_index: Some(0),
260
  language: Some("zh-CN".to_string()),
261
  });
262
  round_trip(&ProcessRequest {
263
+ document_id: Some("abc123".to_string()),
264
+ llm: Some(crate::PipelineLlmRequest {
265
+ target: crate::LlmTarget {
266
+ kind: crate::LlmTargetKind::Provider,
267
+ model_id: "gpt-5-mini".to_string(),
268
+ provider_id: Some("openai".to_string()),
269
+ },
270
+ options: Some(crate::LlmGenerationOptions {
271
+ temperature: Some(0.1),
272
+ max_tokens: Some(1000),
273
+ custom_system_prompt: Some("Translate manga".to_string()),
274
+ }),
275
+ }),
276
  language: Some("zh-CN".to_string()),
277
  shader_effect: Some(TextShaderEffect {
278
  italic: true,
 
285
  }),
286
  font_family: Some("Noto Sans".to_string()),
287
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  round_trip(&ViewImageParams {
289
+ document_id: "abc123".to_string(),
290
  layer: "original".to_string(),
291
  max_size: Some(512),
292
  });
293
  round_trip(&ViewTextBlockParams {
294
+ document_id: "abc123".to_string(),
295
  text_block_index: 0,
296
  layer: Some("rendered".to_string()),
297
  });
 
299
  paths: vec!["a.png".to_string(), "b.png".to_string()],
300
  });
301
  round_trip(&ExportDocumentParams {
302
+ document_id: "abc123".to_string(),
303
  output_path: "out.png".to_string(),
304
  });
305
  round_trip(&RenderParams {
306
+ document_id: "abc123".to_string(),
307
  text_block_index: Some(0),
308
  shader_effect: Some("bold".to_string()),
309
  font_family: Some("Noto Sans".to_string()),
310
  });
311
  round_trip(&ProcessParams {
312
+ document_id: Some("abc123".to_string()),
313
+ llm_target: Some(crate::LlmTarget {
314
+ kind: crate::LlmTargetKind::Local,
315
+ model_id: "sakura".to_string(),
316
+ provider_id: None,
317
+ }),
318
  language: Some("zh-CN".to_string()),
319
  shader_effect: Some("italic,bold".to_string()),
320
  font_family: Some("Noto Sans".to_string()),
 
348
  radius: 2,
349
  });
350
  round_trip(&InpaintRegionParams {
351
+ document_id: "abc123".to_string(),
352
  x: 2,
353
  y: 3,
354
  width: 4,
koharu-core/src/lib.rs CHANGED
@@ -15,12 +15,11 @@ pub use font::{FontPrediction, NamedFontPrediction, TextDirection};
15
  pub use image::SerializableDynamicImage;
16
  pub use protocol::*;
17
 
18
- use std::{path::PathBuf, sync::Arc};
19
 
20
  use ::image::GenericImageView;
21
  use schemars::JsonSchema;
22
  use serde::{Deserialize, Serialize};
23
- use tokio::sync::RwLock;
24
  use utoipa::ToSchema;
25
  use uuid::Uuid;
26
 
@@ -63,12 +62,6 @@ pub struct TextBlock {
63
  }
64
 
65
  impl TextBlock {
66
- pub fn ensure_id(&mut self) {
67
- if self.id.trim().is_empty() {
68
- self.id = new_text_block_id();
69
- }
70
- }
71
-
72
  pub fn set_layout_seed(&mut self, x: f32, y: f32, width: f32, height: f32) {
73
  self.layout_seed_x = Some(x);
74
  self.layout_seed_y = Some(y);
@@ -125,7 +118,9 @@ const fn default_stroke_color() -> [u8; 4] {
125
  [255, 255, 255, 255]
126
  }
127
 
128
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, ToSchema, JsonSchema)]
 
 
129
  #[serde(rename_all = "camelCase")]
130
  pub enum TextAlign {
131
  #[default]
@@ -155,8 +150,6 @@ pub struct Document {
155
  pub image: SerializableDynamicImage,
156
  pub width: u32,
157
  pub height: u32,
158
- #[serde(default)]
159
- pub revision: u64,
160
  pub text_blocks: Vec<TextBlock>,
161
  pub segment: Option<SerializableDynamicImage>,
162
  pub inpainted: Option<SerializableDynamicImage>,
@@ -199,29 +192,10 @@ impl Document {
199
  ..Default::default()
200
  })
201
  }
202
-
203
- pub fn ensure_text_block_ids(&mut self) {
204
- for block in &mut self.text_blocks {
205
- block.ensure_id();
206
- }
207
- }
208
-
209
- pub fn bump_revision(&mut self) {
210
- self.revision = self.revision.saturating_add(1);
211
- }
212
-
213
- pub fn prepare_for_store(&mut self) {
214
- self.ensure_text_block_ids();
215
- }
216
  }
217
 
218
- #[derive(Default, Debug, Clone, Serialize, Deserialize)]
219
- pub struct State {
220
- pub documents: Vec<Document>,
221
- }
222
-
223
- pub type AppState = Arc<RwLock<State>>;
224
-
225
  #[cfg(test)]
226
  mod tests {
227
  use super::TextBlock;
 
15
  pub use image::SerializableDynamicImage;
16
  pub use protocol::*;
17
 
18
+ use std::path::PathBuf;
19
 
20
  use ::image::GenericImageView;
21
  use schemars::JsonSchema;
22
  use serde::{Deserialize, Serialize};
 
23
  use utoipa::ToSchema;
24
  use uuid::Uuid;
25
 
 
62
  }
63
 
64
  impl TextBlock {
 
 
 
 
 
 
65
  pub fn set_layout_seed(&mut self, x: f32, y: f32, width: f32, height: f32) {
66
  self.layout_seed_x = Some(x);
67
  self.layout_seed_y = Some(y);
 
118
  [255, 255, 255, 255]
119
  }
120
 
121
+ #[derive(
122
+ Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, ToSchema, JsonSchema,
123
+ )]
124
  #[serde(rename_all = "camelCase")]
125
  pub enum TextAlign {
126
  #[default]
 
150
  pub image: SerializableDynamicImage,
151
  pub width: u32,
152
  pub height: u32,
 
 
153
  pub text_blocks: Vec<TextBlock>,
154
  pub segment: Option<SerializableDynamicImage>,
155
  pub inpainted: Option<SerializableDynamicImage>,
 
192
  ..Default::default()
193
  })
194
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  }
196
 
197
+ /// Lightweight entry for a page — no decoded image data.
198
+ /// Stored in global state; the full `Document` lives in the page cache.
 
 
 
 
 
199
  #[cfg(test)]
200
  mod tests {
201
  use super::TextBlock;
koharu-core/src/protocol.rs CHANGED
@@ -2,9 +2,11 @@ use schemars::JsonSchema;
2
  use serde::{Deserialize, Serialize};
3
  use utoipa::ToSchema;
4
 
5
- use crate::{Document, FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle};
6
 
7
- #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, ToSchema)]
 
 
8
  #[serde(rename_all = "camelCase")]
9
  pub struct FontFaceInfo {
10
  pub family_name: String,
@@ -20,22 +22,62 @@ pub struct MetaInfo {
20
 
21
  #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
22
  #[serde(rename_all = "camelCase")]
23
- pub struct BootstrapConfig {
24
- pub runtime: BootstrapPathConfig,
25
- pub models: BootstrapPathConfig,
26
- pub http: BootstrapHttpConfig,
27
  }
28
 
29
  #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
30
  #[serde(rename_all = "camelCase")]
31
- pub struct BootstrapPathConfig {
 
 
 
 
 
 
 
 
32
  pub path: String,
33
  }
34
 
35
  #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
36
  #[serde(rename_all = "camelCase")]
37
- pub struct BootstrapHttpConfig {
38
- pub proxy: Option<String>,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  }
40
 
41
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -45,7 +87,6 @@ pub struct DocumentSummary {
45
  pub name: String,
46
  pub width: u32,
47
  pub height: u32,
48
- pub revision: u64,
49
  pub has_segment: bool,
50
  pub has_inpainted: bool,
51
  pub has_brush_layer: bool,
@@ -53,23 +94,6 @@ pub struct DocumentSummary {
53
  pub text_block_count: usize,
54
  }
55
 
56
- impl From<&Document> for DocumentSummary {
57
- fn from(document: &Document) -> Self {
58
- Self {
59
- id: document.id.clone(),
60
- name: document.name.clone(),
61
- width: document.width,
62
- height: document.height,
63
- revision: document.revision,
64
- has_segment: document.segment.is_some(),
65
- has_inpainted: document.inpainted.is_some(),
66
- has_brush_layer: document.brush_layer.is_some(),
67
- has_rendered: document.rendered.is_some(),
68
- text_block_count: document.text_blocks.len(),
69
- }
70
- }
71
- }
72
-
73
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
74
  #[serde(rename_all = "camelCase")]
75
  pub struct TextBlockDetail {
@@ -124,7 +148,6 @@ pub struct DocumentDetail {
124
  pub name: String,
125
  pub width: u32,
126
  pub height: u32,
127
- pub revision: u64,
128
  pub text_blocks: Vec<TextBlockDetail>,
129
  #[schema(value_type = Vec<u8>)]
130
  pub image: serde_bytes::ByteBuf,
@@ -142,7 +165,6 @@ pub struct DocumentDetail {
142
  pub rendered: Option<serde_bytes::ByteBuf>,
143
  }
144
 
145
-
146
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
147
  #[serde(rename_all = "camelCase")]
148
  pub struct TextBlockInput {
@@ -204,14 +226,6 @@ pub struct ExportResult {
204
  pub count: usize,
205
  }
206
 
207
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
208
- #[serde(rename_all = "camelCase")]
209
- pub struct LlmModelInfo {
210
- pub id: String,
211
- pub languages: Vec<String>,
212
- pub source: String,
213
- }
214
-
215
  #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
216
  #[serde(rename_all = "snake_case")]
217
  pub enum LlmStateStatus {
@@ -225,36 +239,76 @@ pub enum LlmStateStatus {
225
  #[serde(rename_all = "camelCase")]
226
  pub struct LlmState {
227
  pub status: LlmStateStatus,
228
- pub model_id: Option<String>,
229
- pub source: Option<String>,
230
  pub error: Option<String>,
231
  }
232
 
233
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
234
  #[serde(rename_all = "camelCase")]
235
- pub struct LlmLoadRequest {
236
- pub id: String,
237
- pub api_key: Option<String>,
238
- pub base_url: Option<String>,
239
  pub temperature: Option<f64>,
240
  pub max_tokens: Option<u32>,
241
  pub custom_system_prompt: Option<String>,
242
  }
243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
245
  #[serde(rename_all = "camelCase")]
246
- pub struct LlmPingRequest {
247
- pub base_url: String,
248
- pub api_key: Option<String>,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  }
250
 
251
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
252
  #[serde(rename_all = "camelCase")]
253
- pub struct LlmPingResponse {
254
- pub ok: bool,
255
- pub models: Vec<String>,
256
- pub latency_ms: Option<u64>,
 
 
 
 
257
  pub error: Option<String>,
 
 
 
 
 
 
 
 
258
  }
259
 
260
  #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
@@ -320,22 +374,9 @@ pub struct DocumentsChangedEvent {
320
  #[serde(rename_all = "camelCase")]
321
  pub struct DocumentChangedEvent {
322
  pub document_id: String,
323
- pub revision: u64,
324
  pub changed: Vec<String>,
325
  }
326
 
327
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
328
- #[serde(rename_all = "camelCase")]
329
- pub struct ApiKeyValue {
330
- pub api_key: String,
331
- }
332
-
333
- #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
334
- #[serde(rename_all = "camelCase")]
335
- pub struct ApiKeyResponse {
336
- pub api_key: Option<String>,
337
- }
338
-
339
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
340
  #[serde(rename_all = "camelCase")]
341
  pub struct RenderRequest {
@@ -352,16 +393,19 @@ pub struct TranslateRequest {
352
  pub language: Option<String>,
353
  }
354
 
 
 
 
 
 
 
 
 
355
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
356
  #[serde(rename_all = "camelCase")]
357
  pub struct PipelineJobRequest {
358
  pub document_id: Option<String>,
359
- pub llm_model_id: Option<String>,
360
- pub llm_api_key: Option<String>,
361
- pub llm_base_url: Option<String>,
362
- pub llm_temperature: Option<f64>,
363
- pub llm_max_tokens: Option<u32>,
364
- pub llm_custom_system_prompt: Option<String>,
365
  pub language: Option<String>,
366
  pub shader_effect: Option<TextShaderEffect>,
367
  pub shader_stroke: Option<TextStrokeStyle>,
 
2
  use serde::{Deserialize, Serialize};
3
  use utoipa::ToSchema;
4
 
5
+ use crate::{FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle};
6
 
7
+ #[derive(
8
+ Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, ToSchema,
9
+ )]
10
  #[serde(rename_all = "camelCase")]
11
  pub struct FontFaceInfo {
12
  pub family_name: String,
 
22
 
23
  #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
24
  #[serde(rename_all = "camelCase")]
25
+ pub struct AppConfig {
26
+ pub data: AppDataConfig,
27
+ #[serde(default)]
28
+ pub llm: AppLlmConfig,
29
  }
30
 
31
  #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
32
  #[serde(rename_all = "camelCase")]
33
+ pub struct AppConfigUpdate {
34
+ pub data: AppDataConfigUpdate,
35
+ #[serde(default)]
36
+ pub llm: AppLlmConfigUpdate,
37
+ }
38
+
39
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
40
+ #[serde(rename_all = "camelCase")]
41
+ pub struct AppDataConfig {
42
  pub path: String,
43
  }
44
 
45
  #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
46
  #[serde(rename_all = "camelCase")]
47
+ pub struct AppDataConfigUpdate {
48
+ pub path: String,
49
+ }
50
+
51
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
52
+ #[serde(rename_all = "camelCase")]
53
+ pub struct AppLlmConfig {
54
+ #[serde(default)]
55
+ pub providers: Vec<AppLlmProviderConfig>,
56
+ }
57
+
58
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
59
+ #[serde(rename_all = "camelCase")]
60
+ pub struct AppLlmConfigUpdate {
61
+ #[serde(default)]
62
+ pub providers: Vec<AppLlmProviderConfigUpdate>,
63
+ }
64
+
65
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
66
+ #[serde(rename_all = "camelCase")]
67
+ pub struct AppLlmProviderConfig {
68
+ pub id: String,
69
+ pub base_url: Option<String>,
70
+ pub has_api_key: bool,
71
+ }
72
+
73
+ #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
74
+ #[serde(rename_all = "camelCase")]
75
+ pub struct AppLlmProviderConfigUpdate {
76
+ pub id: String,
77
+ pub base_url: Option<String>,
78
+ pub api_key: Option<String>,
79
+ #[serde(default)]
80
+ pub clear_api_key: bool,
81
  }
82
 
83
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
 
87
  pub name: String,
88
  pub width: u32,
89
  pub height: u32,
 
90
  pub has_segment: bool,
91
  pub has_inpainted: bool,
92
  pub has_brush_layer: bool,
 
94
  pub text_block_count: usize,
95
  }
96
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
98
  #[serde(rename_all = "camelCase")]
99
  pub struct TextBlockDetail {
 
148
  pub name: String,
149
  pub width: u32,
150
  pub height: u32,
 
151
  pub text_blocks: Vec<TextBlockDetail>,
152
  #[schema(value_type = Vec<u8>)]
153
  pub image: serde_bytes::ByteBuf,
 
165
  pub rendered: Option<serde_bytes::ByteBuf>,
166
  }
167
 
 
168
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
169
  #[serde(rename_all = "camelCase")]
170
  pub struct TextBlockInput {
 
226
  pub count: usize,
227
  }
228
 
 
 
 
 
 
 
 
 
229
  #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
230
  #[serde(rename_all = "snake_case")]
231
  pub enum LlmStateStatus {
 
239
  #[serde(rename_all = "camelCase")]
240
  pub struct LlmState {
241
  pub status: LlmStateStatus,
242
+ pub target: Option<LlmTarget>,
 
243
  pub error: Option<String>,
244
  }
245
 
246
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
247
  #[serde(rename_all = "camelCase")]
248
+ pub struct LlmGenerationOptions {
 
 
 
249
  pub temperature: Option<f64>,
250
  pub max_tokens: Option<u32>,
251
  pub custom_system_prompt: Option<String>,
252
  }
253
 
254
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)]
255
+ #[serde(rename_all = "snake_case")]
256
+ pub enum LlmTargetKind {
257
+ Local,
258
+ Provider,
259
+ }
260
+
261
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)]
262
+ #[serde(rename_all = "camelCase")]
263
+ pub struct LlmTarget {
264
+ pub kind: LlmTargetKind,
265
+ pub model_id: String,
266
+ pub provider_id: Option<String>,
267
+ }
268
+
269
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
270
  #[serde(rename_all = "camelCase")]
271
+ pub struct LlmLoadRequest {
272
+ pub target: LlmTarget,
273
+ #[serde(default, skip_serializing_if = "Option::is_none")]
274
+ pub options: Option<LlmGenerationOptions>,
275
+ }
276
+
277
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
278
+ #[serde(rename_all = "camelCase")]
279
+ pub struct LlmCatalogModel {
280
+ pub target: LlmTarget,
281
+ pub name: String,
282
+ pub languages: Vec<String>,
283
+ }
284
+
285
+ #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
286
+ #[serde(rename_all = "snake_case")]
287
+ pub enum LlmProviderCatalogStatus {
288
+ Ready,
289
+ MissingConfiguration,
290
+ DiscoveryFailed,
291
  }
292
 
293
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
294
  #[serde(rename_all = "camelCase")]
295
+ pub struct LlmProviderCatalog {
296
+ pub id: String,
297
+ pub name: String,
298
+ pub requires_api_key: bool,
299
+ pub requires_base_url: bool,
300
+ pub has_api_key: bool,
301
+ pub base_url: Option<String>,
302
+ pub status: LlmProviderCatalogStatus,
303
  pub error: Option<String>,
304
+ pub models: Vec<LlmCatalogModel>,
305
+ }
306
+
307
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
308
+ #[serde(rename_all = "camelCase")]
309
+ pub struct LlmCatalog {
310
+ pub local_models: Vec<LlmCatalogModel>,
311
+ pub providers: Vec<LlmProviderCatalog>,
312
  }
313
 
314
  #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)]
 
374
  #[serde(rename_all = "camelCase")]
375
  pub struct DocumentChangedEvent {
376
  pub document_id: String,
 
377
  pub changed: Vec<String>,
378
  }
379
 
 
 
 
 
 
 
 
 
 
 
 
 
380
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
381
  #[serde(rename_all = "camelCase")]
382
  pub struct RenderRequest {
 
393
  pub language: Option<String>,
394
  }
395
 
396
+ #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
397
+ #[serde(rename_all = "camelCase")]
398
+ pub struct PipelineLlmRequest {
399
+ pub target: LlmTarget,
400
+ #[serde(default, skip_serializing_if = "Option::is_none")]
401
+ pub options: Option<LlmGenerationOptions>,
402
+ }
403
+
404
  #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
405
  #[serde(rename_all = "camelCase")]
406
  pub struct PipelineJobRequest {
407
  pub document_id: Option<String>,
408
+ pub llm: Option<PipelineLlmRequest>,
 
 
 
 
 
409
  pub language: Option<String>,
410
  pub shader_effect: Option<TextShaderEffect>,
411
  pub shader_stroke: Option<TextStrokeStyle>,
koharu-llm/src/api.rs DELETED
@@ -1,60 +0,0 @@
1
- pub struct ApiModelInfo {
2
- pub id: &'static str,
3
- pub name: &'static str,
4
- }
5
-
6
- pub struct ApiProviderInfo {
7
- pub id: &'static str,
8
- pub name: &'static str,
9
- pub models: &'static [ApiModelInfo],
10
- }
11
-
12
- pub const OPENAI_COMPATIBLE_ID: &str = "openai-compatible";
13
-
14
- pub const OPENAI: ApiProviderInfo = ApiProviderInfo {
15
- id: "openai",
16
- name: "OpenAI",
17
- models: &[ApiModelInfo {
18
- id: "gpt-5-mini",
19
- name: "GPT-5 mini",
20
- }],
21
- };
22
-
23
- pub const GEMINI: ApiProviderInfo = ApiProviderInfo {
24
- id: "gemini",
25
- name: "Gemini",
26
- models: &[ApiModelInfo {
27
- id: "gemini-3.1-flash-lite-preview",
28
- name: "Gemini 3.1 Flash-Lite Preview",
29
- }],
30
- };
31
-
32
- pub const CLAUDE: ApiProviderInfo = ApiProviderInfo {
33
- id: "claude",
34
- name: "Claude",
35
- models: &[ApiModelInfo {
36
- id: "claude-haiku-4-5",
37
- name: "Claude Haiku 4.5",
38
- }],
39
- };
40
-
41
- pub const DEEPSEEK: ApiProviderInfo = ApiProviderInfo {
42
- id: "deepseek",
43
- name: "DeepSeek",
44
- models: &[ApiModelInfo {
45
- id: "deepseek-chat",
46
- name: "DeepSeek-V3.2-Chat",
47
- }],
48
- };
49
-
50
- pub const ALL_API_PROVIDERS: &[&ApiProviderInfo] = &[&OPENAI, &GEMINI, &CLAUDE, &DEEPSEEK];
51
-
52
- pub fn find_api_model(id: &str) -> Option<(&'static ApiProviderInfo, &'static str)> {
53
- let (provider_id, model_id) = id.split_once(':')?;
54
- let provider = ALL_API_PROVIDERS
55
- .iter()
56
- .copied()
57
- .find(|p| p.id == provider_id)?;
58
- let model = provider.models.iter().find(|m| m.id == model_id)?;
59
- Some((provider, model.id))
60
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
koharu-llm/src/lib.rs CHANGED
@@ -1,4 +1,3 @@
1
- pub mod api;
2
  pub mod language;
3
  mod model;
4
  pub mod paddleocr_vl;
 
 
1
  pub mod language;
2
  mod model;
3
  pub mod paddleocr_vl;
koharu-llm/src/providers/chat_completions.rs ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use std::sync::Arc;
2
+
3
+ use reqwest_middleware::ClientWithMiddleware;
4
+ use serde::Serialize;
5
+
6
+ use super::ensure_provider_success;
7
+
8
+ pub enum ChatCompletionsAuth {
9
+ None,
10
+ Bearer(String),
11
+ }
12
+
13
+ pub struct ChatCompletionsRequest {
14
+ pub provider: &'static str,
15
+ pub endpoint: String,
16
+ pub auth: ChatCompletionsAuth,
17
+ pub model: String,
18
+ pub system_prompt: String,
19
+ pub user_prompt: String,
20
+ pub temperature: Option<f64>,
21
+ pub max_tokens: Option<u32>,
22
+ }
23
+
24
+ #[derive(Serialize)]
25
+ struct ChatMessage {
26
+ role: &'static str,
27
+ content: String,
28
+ }
29
+
30
+ #[derive(Serialize)]
31
+ struct ChatRequest<'a> {
32
+ model: &'a str,
33
+ messages: Vec<ChatMessage>,
34
+ #[serde(skip_serializing_if = "Option::is_none")]
35
+ temperature: Option<f64>,
36
+ #[serde(skip_serializing_if = "Option::is_none")]
37
+ max_tokens: Option<u32>,
38
+ }
39
+
40
+ pub async fn send_chat_completion(
41
+ http_client: Arc<ClientWithMiddleware>,
42
+ request: ChatCompletionsRequest,
43
+ ) -> anyhow::Result<String> {
44
+ let body = ChatRequest {
45
+ model: &request.model,
46
+ messages: vec![
47
+ ChatMessage {
48
+ role: "system",
49
+ content: request.system_prompt,
50
+ },
51
+ ChatMessage {
52
+ role: "user",
53
+ content: request.user_prompt,
54
+ },
55
+ ],
56
+ temperature: request.temperature,
57
+ max_tokens: request.max_tokens,
58
+ };
59
+
60
+ let mut http_request = http_client.post(&request.endpoint);
61
+ if let ChatCompletionsAuth::Bearer(api_key) = request.auth {
62
+ http_request = http_request.bearer_auth(api_key);
63
+ }
64
+
65
+ let response = http_request
66
+ .header("content-type", "application/json")
67
+ .body(serde_json::to_vec(&body)?)
68
+ .send()
69
+ .await?;
70
+
71
+ let resp: serde_json::Value = ensure_provider_success(request.provider, response)
72
+ .await?
73
+ .json()
74
+ .await?;
75
+
76
+ resp["choices"][0]["message"]["content"]
77
+ .as_str()
78
+ .map(ToOwned::to_owned)
79
+ .ok_or_else(|| anyhow::anyhow!("{} returned no content", request.provider))
80
+ }
koharu-llm/src/providers/deepseek.rs CHANGED
@@ -3,30 +3,17 @@ use std::pin::Pin;
3
  use std::sync::Arc;
4
 
5
  use reqwest_middleware::ClientWithMiddleware;
6
- use serde::Serialize;
7
 
8
  use crate::{Language, prompt::system_prompt};
9
 
10
- use super::{AnyProvider, ensure_provider_success};
 
11
 
12
  pub struct DeepSeekProvider {
13
  pub http_client: Arc<ClientWithMiddleware>,
14
  pub api_key: String,
15
  }
16
 
17
- #[derive(Serialize)]
18
- struct ChatMessage {
19
- role: &'static str,
20
- content: String,
21
- }
22
-
23
- #[derive(Serialize)]
24
- struct ChatRequest<'a> {
25
- model: &'a str,
26
- messages: Vec<ChatMessage>,
27
- temperature: f32,
28
- }
29
-
30
  impl AnyProvider for DeepSeekProvider {
31
  fn translate<'a>(
32
  &'a self,
@@ -35,41 +22,20 @@ impl AnyProvider for DeepSeekProvider {
35
  model: &'a str,
36
  ) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send + 'a>> {
37
  Box::pin(async move {
38
- let body = ChatRequest {
39
- model,
40
- messages: vec![
41
- ChatMessage {
42
- role: "system",
43
- content: system_prompt(target_language),
44
- },
45
- ChatMessage {
46
- role: "user",
47
- content: source.to_string(),
48
- },
49
- ],
50
- temperature: 1.3,
51
- };
52
-
53
- let response = self
54
- .http_client
55
- .post("https://api.deepseek.com/chat/completions")
56
- .bearer_auth(&self.api_key)
57
- .header("content-type", "application/json")
58
- .body(serde_json::to_vec(&body)?)
59
- .send()
60
- .await?;
61
-
62
- let resp: serde_json::Value = ensure_provider_success("deepseek", response)
63
- .await?
64
- .json()
65
- .await?;
66
-
67
- let text = resp["choices"][0]["message"]["content"]
68
- .as_str()
69
- .ok_or_else(|| anyhow::anyhow!("DeepSeek returned no content"))?
70
- .to_string();
71
-
72
- Ok(text)
73
  })
74
  }
75
  }
 
3
  use std::sync::Arc;
4
 
5
  use reqwest_middleware::ClientWithMiddleware;
 
6
 
7
  use crate::{Language, prompt::system_prompt};
8
 
9
+ use super::AnyProvider;
10
+ use super::chat_completions::{ChatCompletionsAuth, ChatCompletionsRequest, send_chat_completion};
11
 
12
  pub struct DeepSeekProvider {
13
  pub http_client: Arc<ClientWithMiddleware>,
14
  pub api_key: String,
15
  }
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  impl AnyProvider for DeepSeekProvider {
18
  fn translate<'a>(
19
  &'a self,
 
22
  model: &'a str,
23
  ) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send + 'a>> {
24
  Box::pin(async move {
25
+ send_chat_completion(
26
+ Arc::clone(&self.http_client),
27
+ ChatCompletionsRequest {
28
+ provider: "deepseek",
29
+ endpoint: "https://api.deepseek.com/chat/completions".to_string(),
30
+ auth: ChatCompletionsAuth::Bearer(self.api_key.clone()),
31
+ model: model.to_string(),
32
+ system_prompt: system_prompt(target_language),
33
+ user_prompt: source.to_string(),
34
+ temperature: Some(1.3),
35
+ max_tokens: None,
36
+ },
37
+ )
38
+ .await
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  })
40
  }
41
  }
koharu-llm/src/providers/mod.rs CHANGED
@@ -9,6 +9,7 @@ use reqwest_middleware::ClientWithMiddleware;
9
 
10
  use crate::Language;
11
 
 
12
  pub mod claude;
13
  pub mod deepseek;
14
  pub mod gemini;
@@ -16,9 +17,39 @@ pub mod openai;
16
  pub mod openai_compatible;
17
 
18
  const API_KEY_SERVICE: &str = "koharu";
 
19
 
20
  static NO_KEYRING: AtomicBool = AtomicBool::new(false);
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  pub fn disable_keyring() {
23
  NO_KEYRING.store(true, Ordering::Relaxed);
24
  }
@@ -112,6 +143,7 @@ pub trait AnyProvider: Send + Sync {
112
  ) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send + 'a>>;
113
  }
114
 
 
115
  pub struct ProviderConfig {
116
  pub http_client: Arc<ClientWithMiddleware>,
117
  pub api_key: Option<String>,
@@ -121,50 +153,204 @@ pub struct ProviderConfig {
121
  pub custom_system_prompt: Option<String>,
122
  }
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  pub fn build_provider(
125
  provider_id: &str,
126
  config: ProviderConfig,
127
  ) -> anyhow::Result<Box<dyn AnyProvider>> {
128
- let required_api_key = |name: &str| {
129
- config
 
 
 
130
  .api_key
131
- .clone()
132
- .filter(|value| !value.trim().is_empty())
133
- .ok_or_else(|| anyhow::anyhow!("api_key is required for {name}"))
134
- };
135
-
136
- let provider: Box<dyn AnyProvider> = match provider_id {
137
- "openai" => Box::new(openai::OpenAiProvider {
138
- http_client: Arc::clone(&config.http_client),
139
- api_key: required_api_key("openai")?,
140
- }),
141
- "gemini" => Box::new(gemini::GeminiProvider {
142
- http_client: Arc::clone(&config.http_client),
143
- api_key: required_api_key("gemini")?,
144
- }),
145
- "claude" => Box::new(claude::ClaudeProvider {
146
- http_client: Arc::clone(&config.http_client),
147
- api_key: required_api_key("claude")?,
148
- }),
149
- "deepseek" => Box::new(deepseek::DeepSeekProvider {
150
- http_client: Arc::clone(&config.http_client),
151
- api_key: required_api_key("deepseek")?,
152
- }),
153
- "openai-compatible" => Box::new(openai_compatible::OpenAiCompatibleProvider {
154
- http_client: Arc::clone(&config.http_client),
155
- base_url: config
156
- .base_url
157
- .filter(|value| !value.trim().is_empty())
158
- .ok_or_else(|| {
159
- anyhow::anyhow!("base_url is required for the openai-compatible provider")
160
- })?,
161
- api_key: config.api_key,
162
- temperature: config.temperature,
163
- max_tokens: config.max_tokens,
164
- custom_system_prompt: config.custom_system_prompt,
165
- }),
166
- other => anyhow::bail!("Unknown API provider: {other}"),
167
- };
168
-
169
- Ok(provider)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  }
 
9
 
10
  use crate::Language;
11
 
12
+ mod chat_completions;
13
  pub mod claude;
14
  pub mod deepseek;
15
  pub mod gemini;
 
17
  pub mod openai_compatible;
18
 
19
  const API_KEY_SERVICE: &str = "koharu";
20
+ pub const OPENAI_COMPATIBLE_ID: &str = "openai-compatible";
21
 
22
  static NO_KEYRING: AtomicBool = AtomicBool::new(false);
23
 
24
+ #[derive(Debug, Clone, Copy)]
25
+ pub struct ProviderModelDescriptor {
26
+ pub id: &'static str,
27
+ pub name: &'static str,
28
+ }
29
+
30
+ #[derive(Debug, Clone)]
31
+ pub struct DiscoveredProviderModel {
32
+ pub id: String,
33
+ pub name: String,
34
+ }
35
+
36
+ pub type ProviderDiscoveryFuture =
37
+ Pin<Box<dyn Future<Output = anyhow::Result<Vec<DiscoveredProviderModel>>> + Send>>;
38
+
39
+ pub enum ProviderCatalogModels {
40
+ Static(&'static [ProviderModelDescriptor]),
41
+ Dynamic(fn(ProviderConfig) -> ProviderDiscoveryFuture),
42
+ }
43
+
44
+ pub struct ProviderDescriptor {
45
+ pub id: &'static str,
46
+ pub name: &'static str,
47
+ pub requires_api_key: bool,
48
+ pub requires_base_url: bool,
49
+ pub models: ProviderCatalogModels,
50
+ pub build: fn(ProviderConfig) -> anyhow::Result<Box<dyn AnyProvider>>,
51
+ }
52
+
53
  pub fn disable_keyring() {
54
  NO_KEYRING.store(true, Ordering::Relaxed);
55
  }
 
143
  ) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send + 'a>>;
144
  }
145
 
146
+ #[derive(Clone)]
147
  pub struct ProviderConfig {
148
  pub http_client: Arc<ClientWithMiddleware>,
149
  pub api_key: Option<String>,
 
153
  pub custom_system_prompt: Option<String>,
154
  }
155
 
156
+ const OPENAI_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor {
157
+ id: "gpt-5-mini",
158
+ name: "GPT-5 mini",
159
+ }];
160
+
161
+ const GEMINI_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor {
162
+ id: "gemini-3.1-flash-lite-preview",
163
+ name: "Gemini 3.1 Flash-Lite Preview",
164
+ }];
165
+
166
+ const CLAUDE_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor {
167
+ id: "claude-haiku-4-5",
168
+ name: "Claude Haiku 4.5",
169
+ }];
170
+
171
+ const DEEPSEEK_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor {
172
+ id: "deepseek-chat",
173
+ name: "DeepSeek-V3.2-Chat",
174
+ }];
175
+
176
+ const PROVIDERS: &[ProviderDescriptor] = &[
177
+ ProviderDescriptor {
178
+ id: "openai",
179
+ name: "OpenAI",
180
+ requires_api_key: true,
181
+ requires_base_url: false,
182
+ models: ProviderCatalogModels::Static(OPENAI_MODELS),
183
+ build: build_openai_provider,
184
+ },
185
+ ProviderDescriptor {
186
+ id: "gemini",
187
+ name: "Gemini",
188
+ requires_api_key: true,
189
+ requires_base_url: false,
190
+ models: ProviderCatalogModels::Static(GEMINI_MODELS),
191
+ build: build_gemini_provider,
192
+ },
193
+ ProviderDescriptor {
194
+ id: "claude",
195
+ name: "Claude",
196
+ requires_api_key: true,
197
+ requires_base_url: false,
198
+ models: ProviderCatalogModels::Static(CLAUDE_MODELS),
199
+ build: build_claude_provider,
200
+ },
201
+ ProviderDescriptor {
202
+ id: "deepseek",
203
+ name: "DeepSeek",
204
+ requires_api_key: true,
205
+ requires_base_url: false,
206
+ models: ProviderCatalogModels::Static(DEEPSEEK_MODELS),
207
+ build: build_deepseek_provider,
208
+ },
209
+ ProviderDescriptor {
210
+ id: OPENAI_COMPATIBLE_ID,
211
+ name: "OpenAI-compatible",
212
+ requires_api_key: false,
213
+ requires_base_url: true,
214
+ models: ProviderCatalogModels::Dynamic(discover_openai_compatible_models),
215
+ build: build_openai_compatible_provider,
216
+ },
217
+ ];
218
+
219
+ pub fn all_provider_descriptors() -> &'static [ProviderDescriptor] {
220
+ PROVIDERS
221
+ }
222
+
223
+ pub fn find_provider_descriptor(provider_id: &str) -> Option<&'static ProviderDescriptor> {
224
+ PROVIDERS
225
+ .iter()
226
+ .find(|descriptor| descriptor.id == provider_id)
227
+ }
228
+
229
+ pub fn discover_models(
230
+ provider_id: &str,
231
+ config: ProviderConfig,
232
+ ) -> anyhow::Result<ProviderDiscoveryFuture> {
233
+ let descriptor = find_provider_descriptor(provider_id)
234
+ .ok_or_else(|| anyhow::anyhow!("Unknown API provider: {provider_id}"))?;
235
+ Ok(match descriptor.models {
236
+ ProviderCatalogModels::Static(models) => {
237
+ let models = models
238
+ .iter()
239
+ .map(|model| DiscoveredProviderModel {
240
+ id: model.id.to_string(),
241
+ name: model.name.to_string(),
242
+ })
243
+ .collect::<Vec<_>>();
244
+ Box::pin(async move { Ok(models) })
245
+ }
246
+ ProviderCatalogModels::Dynamic(discover) => discover(config),
247
+ })
248
+ }
249
+
250
  pub fn build_provider(
251
  provider_id: &str,
252
  config: ProviderConfig,
253
  ) -> anyhow::Result<Box<dyn AnyProvider>> {
254
+ let descriptor = find_provider_descriptor(provider_id)
255
+ .ok_or_else(|| anyhow::anyhow!("Unknown API provider: {provider_id}"))?;
256
+
257
+ if descriptor.requires_api_key
258
+ && config
259
  .api_key
260
+ .as_deref()
261
+ .is_none_or(|value| value.trim().is_empty())
262
+ {
263
+ anyhow::bail!("api_key is required for {}", descriptor.id);
264
+ }
265
+
266
+ if descriptor.requires_base_url
267
+ && config
268
+ .base_url
269
+ .as_deref()
270
+ .is_none_or(|value| value.trim().is_empty())
271
+ {
272
+ anyhow::bail!("base_url is required for {}", descriptor.id);
273
+ }
274
+
275
+ (descriptor.build)(config)
276
+ }
277
+
278
+ fn required_api_key(config: &ProviderConfig, provider_id: &str) -> anyhow::Result<String> {
279
+ config
280
+ .api_key
281
+ .as_deref()
282
+ .map(str::trim)
283
+ .filter(|value| !value.is_empty())
284
+ .map(ToOwned::to_owned)
285
+ .ok_or_else(|| anyhow::anyhow!("api_key is required for {provider_id}"))
286
+ }
287
+
288
+ fn required_base_url(config: &ProviderConfig, provider_id: &str) -> anyhow::Result<String> {
289
+ config
290
+ .base_url
291
+ .as_deref()
292
+ .map(str::trim)
293
+ .filter(|value| !value.is_empty())
294
+ .map(ToOwned::to_owned)
295
+ .ok_or_else(|| anyhow::anyhow!("base_url is required for {provider_id}"))
296
+ }
297
+
298
+ fn build_openai_provider(config: ProviderConfig) -> anyhow::Result<Box<dyn AnyProvider>> {
299
+ Ok(Box::new(openai::OpenAiProvider {
300
+ http_client: Arc::clone(&config.http_client),
301
+ api_key: required_api_key(&config, "openai")?,
302
+ }))
303
+ }
304
+
305
+ fn build_gemini_provider(config: ProviderConfig) -> anyhow::Result<Box<dyn AnyProvider>> {
306
+ Ok(Box::new(gemini::GeminiProvider {
307
+ http_client: Arc::clone(&config.http_client),
308
+ api_key: required_api_key(&config, "gemini")?,
309
+ }))
310
+ }
311
+
312
+ fn build_claude_provider(config: ProviderConfig) -> anyhow::Result<Box<dyn AnyProvider>> {
313
+ Ok(Box::new(claude::ClaudeProvider {
314
+ http_client: Arc::clone(&config.http_client),
315
+ api_key: required_api_key(&config, "claude")?,
316
+ }))
317
+ }
318
+
319
+ fn build_deepseek_provider(config: ProviderConfig) -> anyhow::Result<Box<dyn AnyProvider>> {
320
+ Ok(Box::new(deepseek::DeepSeekProvider {
321
+ http_client: Arc::clone(&config.http_client),
322
+ api_key: required_api_key(&config, "deepseek")?,
323
+ }))
324
+ }
325
+
326
+ fn build_openai_compatible_provider(
327
+ config: ProviderConfig,
328
+ ) -> anyhow::Result<Box<dyn AnyProvider>> {
329
+ Ok(Box::new(openai_compatible::OpenAiCompatibleProvider {
330
+ http_client: Arc::clone(&config.http_client),
331
+ base_url: required_base_url(&config, OPENAI_COMPATIBLE_ID)?,
332
+ api_key: config.api_key,
333
+ temperature: config.temperature,
334
+ max_tokens: config.max_tokens,
335
+ custom_system_prompt: config.custom_system_prompt,
336
+ }))
337
+ }
338
+
339
+ fn discover_openai_compatible_models(config: ProviderConfig) -> ProviderDiscoveryFuture {
340
+ Box::pin(async move {
341
+ let base_url = required_base_url(&config, OPENAI_COMPATIBLE_ID)?;
342
+ let models = openai_compatible::list_models(
343
+ config.http_client,
344
+ &base_url,
345
+ config.api_key.as_deref(),
346
+ )
347
+ .await?;
348
+ Ok(models
349
+ .into_iter()
350
+ .map(|id| DiscoveredProviderModel {
351
+ name: id.clone(),
352
+ id,
353
+ })
354
+ .collect())
355
+ })
356
  }
koharu-llm/src/providers/openai.rs CHANGED
@@ -3,29 +3,17 @@ use std::pin::Pin;
3
  use std::sync::Arc;
4
 
5
  use reqwest_middleware::ClientWithMiddleware;
6
- use serde::Serialize;
7
 
8
  use crate::{Language, prompt::system_prompt};
9
 
10
- use super::{AnyProvider, ensure_provider_success};
 
11
 
12
  pub struct OpenAiProvider {
13
  pub http_client: Arc<ClientWithMiddleware>,
14
  pub api_key: String,
15
  }
16
 
17
- #[derive(Serialize)]
18
- struct ChatMessage {
19
- role: &'static str,
20
- content: String,
21
- }
22
-
23
- #[derive(Serialize)]
24
- struct ChatRequest<'a> {
25
- model: &'a str,
26
- messages: Vec<ChatMessage>,
27
- }
28
-
29
  impl AnyProvider for OpenAiProvider {
30
  fn translate<'a>(
31
  &'a self,
@@ -34,40 +22,20 @@ impl AnyProvider for OpenAiProvider {
34
  model: &'a str,
35
  ) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send + 'a>> {
36
  Box::pin(async move {
37
- let body = ChatRequest {
38
- model,
39
- messages: vec![
40
- ChatMessage {
41
- role: "system",
42
- content: system_prompt(target_language),
43
- },
44
- ChatMessage {
45
- role: "user",
46
- content: source.to_string(),
47
- },
48
- ],
49
- };
50
-
51
- let response = self
52
- .http_client
53
- .post("https://api.openai.com/v1/chat/completions")
54
- .bearer_auth(&self.api_key)
55
- .header("content-type", "application/json")
56
- .body(serde_json::to_vec(&body)?)
57
- .send()
58
- .await?;
59
-
60
- let resp: serde_json::Value = ensure_provider_success("openai", response)
61
- .await?
62
- .json()
63
- .await?;
64
-
65
- let text = resp["choices"][0]["message"]["content"]
66
- .as_str()
67
- .ok_or_else(|| anyhow::anyhow!("OpenAI returned no content"))?
68
- .to_string();
69
-
70
- Ok(text)
71
  })
72
  }
73
  }
 
3
  use std::sync::Arc;
4
 
5
  use reqwest_middleware::ClientWithMiddleware;
 
6
 
7
  use crate::{Language, prompt::system_prompt};
8
 
9
+ use super::AnyProvider;
10
+ use super::chat_completions::{ChatCompletionsAuth, ChatCompletionsRequest, send_chat_completion};
11
 
12
  pub struct OpenAiProvider {
13
  pub http_client: Arc<ClientWithMiddleware>,
14
  pub api_key: String,
15
  }
16
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  impl AnyProvider for OpenAiProvider {
18
  fn translate<'a>(
19
  &'a self,
 
22
  model: &'a str,
23
  ) -> Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send + 'a>> {
24
  Box::pin(async move {
25
+ send_chat_completion(
26
+ Arc::clone(&self.http_client),
27
+ ChatCompletionsRequest {
28
+ provider: "openai",
29
+ endpoint: "https://api.openai.com/v1/chat/completions".to_string(),
30
+ auth: ChatCompletionsAuth::Bearer(self.api_key.clone()),
31
+ model: model.to_string(),
32
+ system_prompt: system_prompt(target_language),
33
+ user_prompt: source.to_string(),
34
+ temperature: None,
35
+ max_tokens: None,
36
+ },
37
+ )
38
+ .await
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  })
40
  }
41
  }
koharu-llm/src/providers/openai_compatible.rs CHANGED
@@ -1,13 +1,12 @@
 
 
1
  use std::future::Future;
2
  use std::pin::Pin;
3
  use std::sync::Arc;
4
- use std::time::Instant;
5
-
6
- use reqwest_middleware::ClientWithMiddleware;
7
- use serde::{Deserialize, Serialize};
8
 
9
  use crate::{Language, prompt::system_prompt};
10
 
 
11
  use super::{AnyProvider, ensure_provider_success};
12
 
13
  #[derive(Debug, Clone)]
@@ -30,22 +29,6 @@ struct ModelEntry {
30
  id: String,
31
  }
32
 
33
- #[derive(Serialize)]
34
- struct ChatMessage {
35
- role: &'static str,
36
- content: String,
37
- }
38
-
39
- #[derive(Serialize)]
40
- struct ChatRequest<'a> {
41
- model: &'a str,
42
- messages: Vec<ChatMessage>,
43
- #[serde(skip_serializing_if = "Option::is_none")]
44
- temperature: Option<f64>,
45
- #[serde(skip_serializing_if = "Option::is_none")]
46
- max_tokens: Option<u32>,
47
- }
48
-
49
  fn normalized_base_url(base_url: &str) -> anyhow::Result<String> {
50
  let normalized = base_url.trim().trim_end_matches('/').to_string();
51
  if normalized.is_empty() {
@@ -83,27 +66,6 @@ pub async fn list_models(
83
  Ok(ids)
84
  }
85
 
86
- pub struct PingResult {
87
- pub models: Vec<String>,
88
- pub latency_ms: u64,
89
- }
90
-
91
- pub async fn ping(
92
- http_client: Arc<ClientWithMiddleware>,
93
- base_url: &str,
94
- api_key: Option<&str>,
95
- ) -> anyhow::Result<PingResult> {
96
- let start = Instant::now();
97
- let models = tokio::time::timeout(
98
- std::time::Duration::from_secs(5),
99
- list_models(http_client, base_url, api_key),
100
- )
101
- .await
102
- .map_err(|_| anyhow::anyhow!("Connection timed out after 5 seconds"))??;
103
- let latency_ms = start.elapsed().as_millis() as u64;
104
- Ok(PingResult { models, latency_ms })
105
- }
106
-
107
  impl AnyProvider for OpenAiCompatibleProvider {
108
  fn translate<'a>(
109
  &'a self,
@@ -116,49 +78,25 @@ impl AnyProvider for OpenAiCompatibleProvider {
116
  Some(p) if !p.trim().is_empty() => p.clone(),
117
  _ => system_prompt(target_language),
118
  };
119
- let body = ChatRequest {
120
- model,
121
- messages: vec![
122
- ChatMessage {
123
- role: "system",
124
- content: prompt,
125
- },
126
- ChatMessage {
127
- role: "user",
128
- content: source.to_string(),
129
- },
130
- ],
131
- temperature: self.temperature,
132
- max_tokens: self.max_tokens,
133
- };
134
-
135
- let endpoint = format!("{}/chat/completions", normalized_base_url(&self.base_url)?);
136
- let mut request = self.http_client.post(endpoint);
137
- if let Some(api_key) = self
138
- .api_key
139
- .as_deref()
140
- .filter(|value| !value.trim().is_empty())
141
- {
142
- request = request.bearer_auth(api_key);
143
- }
144
-
145
- let response = request
146
- .header("content-type", "application/json")
147
- .body(serde_json::to_vec(&body)?)
148
- .send()
149
- .await?;
150
-
151
- let resp: serde_json::Value = ensure_provider_success("openai-compatible", response)
152
- .await?
153
- .json()
154
- .await?;
155
-
156
- let text = resp["choices"][0]["message"]["content"]
157
- .as_str()
158
- .ok_or_else(|| anyhow::anyhow!("OpenAI-compatible provider returned no content"))?
159
- .to_string();
160
-
161
- Ok(text)
162
  })
163
  }
164
  }
 
1
+ use reqwest_middleware::ClientWithMiddleware;
2
+ use serde::Deserialize;
3
  use std::future::Future;
4
  use std::pin::Pin;
5
  use std::sync::Arc;
 
 
 
 
6
 
7
  use crate::{Language, prompt::system_prompt};
8
 
9
+ use super::chat_completions::{ChatCompletionsAuth, ChatCompletionsRequest, send_chat_completion};
10
  use super::{AnyProvider, ensure_provider_success};
11
 
12
  #[derive(Debug, Clone)]
 
29
  id: String,
30
  }
31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  fn normalized_base_url(base_url: &str) -> anyhow::Result<String> {
33
  let normalized = base_url.trim().trim_end_matches('/').to_string();
34
  if normalized.is_empty() {
 
66
  Ok(ids)
67
  }
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  impl AnyProvider for OpenAiCompatibleProvider {
70
  fn translate<'a>(
71
  &'a self,
 
78
  Some(p) if !p.trim().is_empty() => p.clone(),
79
  _ => system_prompt(target_language),
80
  };
81
+ send_chat_completion(
82
+ Arc::clone(&self.http_client),
83
+ ChatCompletionsRequest {
84
+ provider: "openai-compatible",
85
+ endpoint: format!("{}/chat/completions", normalized_base_url(&self.base_url)?),
86
+ auth: self
87
+ .api_key
88
+ .as_deref()
89
+ .filter(|value| !value.trim().is_empty())
90
+ .map(|key| ChatCompletionsAuth::Bearer(key.to_string()))
91
+ .unwrap_or(ChatCompletionsAuth::None),
92
+ model: model.to_string(),
93
+ system_prompt: prompt,
94
+ user_prompt: source.to_string(),
95
+ temperature: self.temperature,
96
+ max_tokens: self.max_tokens,
97
+ },
98
+ )
99
+ .await
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  })
101
  }
102
  }
koharu-llm/tests/llm.rs CHANGED
@@ -1,11 +1,12 @@
1
- use std::path::PathBuf;
2
  use std::sync::Arc;
3
 
4
  use strum::IntoEnumIterator;
5
 
6
  use koharu_llm::safe::llama_backend::LlamaBackend;
7
  use koharu_llm::{GenerateOptions, Language, Llm, ModelId};
8
- use koharu_runtime::{ComputePolicy, RuntimeManager, Settings};
 
 
9
 
10
  #[tokio::test]
11
  #[ignore] // Ignored because it requires downloading multiple large models.
@@ -14,13 +15,17 @@ async fn llm_generates_text_for_all_models() -> anyhow::Result<()> {
14
  テストです。
15
  さよなら。"#;
16
 
17
- let model_dir = dirs::data_local_dir()
18
- .map(|path| path.join("Koharu"))
19
- .unwrap_or(PathBuf::from("."))
20
- .join("models");
21
 
22
  let runtime = RuntimeManager::new(
23
- Settings::from_paths(koharu_runtime::default_runtime_root(), model_dir),
 
 
 
 
 
 
 
24
  ComputePolicy::PreferGpu,
25
  )?;
26
  runtime.prepare().await?;
 
 
1
  use std::sync::Arc;
2
 
3
  use strum::IntoEnumIterator;
4
 
5
  use koharu_llm::safe::llama_backend::LlamaBackend;
6
  use koharu_llm::{GenerateOptions, Language, Llm, ModelId};
7
+ use koharu_runtime::{
8
+ ComputePolicy, DirectorySetting, RuntimeManager, Settings, default_app_data_root,
9
+ };
10
 
11
  #[tokio::test]
12
  #[ignore] // Ignored because it requires downloading multiple large models.
 
15
  テストです。
16
  さよなら。"#;
17
 
18
+ let app_data_root = default_app_data_root();
 
 
 
19
 
20
  let runtime = RuntimeManager::new(
21
+ Settings {
22
+ runtime: DirectorySetting {
23
+ path: app_data_root.join("runtime"),
24
+ },
25
+ models: DirectorySetting {
26
+ path: app_data_root.join("models"),
27
+ },
28
+ },
29
  ComputePolicy::PreferGpu,
30
  )?;
31
  runtime.prepare().await?;
koharu-psd/examples/full_fixture.rs CHANGED
@@ -80,7 +80,6 @@ fn build_document() -> Document {
80
  image: to_serializable_rgba(original),
81
  width,
82
  height,
83
- revision: 0,
84
  text_blocks: vec![
85
  TextBlock {
86
  id: "hero-title".to_string(),
 
80
  image: to_serializable_rgba(original),
81
  width,
82
  height,
 
83
  text_blocks: vec![
84
  TextBlock {
85
  id: "hero-title".to_string(),
koharu-psd/tests/export.rs CHANGED
@@ -31,7 +31,6 @@ fn sample_document() -> Document {
31
  image: rgba_image(16, 12, [240, 240, 240, 255]),
32
  width: 16,
33
  height: 12,
34
- revision: 0,
35
  text_blocks: vec![
36
  TextBlock {
37
  id: "block-h".to_string(),
 
31
  image: rgba_image(16, 12, [240, 240, 240, 255]),
32
  width: 16,
33
  height: 12,
 
34
  text_blocks: vec![
35
  TextBlock {
36
  id: "block-h".to_string(),
koharu-rpc/src/api.rs CHANGED
@@ -1,6 +1,3 @@
1
- use std::io::Cursor;
2
-
3
- use anyhow::Context;
4
  use axum::{
5
  Json,
6
  body::Body,
@@ -11,27 +8,19 @@ use axum::{
11
  },
12
  response::{IntoResponse, Response},
13
  };
14
- use image::ImageFormat;
15
- use koharu_app::{
16
- AppResources, operations,
17
- state_tx::{self, ChangedField},
18
- };
19
  use koharu_core::{
20
- ApiKeyResponse, ApiKeyValue, BootstrapConfig, CreateTextBlock, Document, DocumentDetail,
21
- DocumentSummary, DownloadState, ExportLayer, ExportResult, FontFaceInfo, InpaintRegion,
22
- JobState, JobStatus, LlmLoadRequest, LlmModelInfo, LlmPingRequest, LlmPingResponse,
23
- MaskRegionRequest, MetaInfo, PipelineJobRequest, Region, RenderRequest, TextBlock,
24
- TextBlockDetail, TextBlockInput, TextBlockPatch, TranslateRequest,
25
  };
26
  use koharu_psd::{PsdExportOptions, TextLayerMode};
27
  use serde::{Deserialize, Serialize};
28
  use utoipa::IntoParams;
29
  use utoipa_axum::{router::OpenApiRouter, routes};
30
 
31
- use crate::{
32
- shared::{SharedState, get_resources},
33
- tracker::Tracker,
34
- };
35
 
36
  const MAX_BODY_SIZE: usize = 1024 * 1024 * 1024;
37
 
@@ -43,7 +32,9 @@ pub struct ApiState {
43
 
44
  impl ApiState {
45
  fn resources(&self) -> ApiResult<AppResources> {
46
- get_resources(&self.resources).map_err(ApiError::service_unavailable)
 
 
47
  }
48
  }
49
 
@@ -65,9 +56,7 @@ pub fn api() -> (axum::Router<ApiState>, utoipa::openapi::OpenApi) {
65
  .routes(routes!(export_document))
66
  .routes(routes!(batch_export))
67
  .routes(routes!(get_llm, load_llm, unload_llm))
68
- .routes(routes!(list_llm_models))
69
- .routes(routes!(check_llm_health))
70
- .routes(routes!(get_api_key, set_api_key))
71
  .routes(routes!(start_pipeline))
72
  .routes(routes!(list_jobs))
73
  .routes(routes!(get_job, cancel_job))
@@ -75,7 +64,6 @@ pub fn api() -> (axum::Router<ApiState>, utoipa::openapi::OpenApi) {
75
  .routes(routes!(get_meta))
76
  .routes(routes!(list_fonts))
77
  .routes(routes!(get_config, update_config))
78
- .routes(routes!(initialize))
79
  .split_for_parts()
80
  }
81
 
@@ -111,10 +99,6 @@ impl ApiError {
111
  Self::new(StatusCode::NOT_FOUND, message)
112
  }
113
 
114
- fn conflict(message: impl Into<String>) -> Self {
115
- Self::new(StatusCode::CONFLICT, message)
116
- }
117
-
118
  fn service_unavailable(error: anyhow::Error) -> Self {
119
  Self::new(StatusCode::SERVICE_UNAVAILABLE, error.to_string())
120
  }
@@ -137,8 +121,7 @@ impl From<anyhow::Error> for ApiError {
137
 
138
  impl IntoResponse for ApiError {
139
  fn into_response(self) -> Response {
140
- let status =
141
- StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
142
  (status, Json(self)).into_response()
143
  }
144
  }
@@ -158,9 +141,8 @@ struct ImportQuery {
158
 
159
  #[derive(Debug, Deserialize, IntoParams)]
160
  #[serde(rename_all = "camelCase")]
161
- struct LlmModelsQuery {
162
  language: Option<String>,
163
- openai_compatible_base_url: Option<String>,
164
  }
165
 
166
  #[derive(Debug, Deserialize, IntoParams)]
@@ -185,12 +167,13 @@ struct ExportBatchRequest {
185
  operation_id = "getConfig",
186
  tag = "system",
187
  responses(
188
- (status = 200, body = BootstrapConfig),
189
  (status = 503, body = ApiError),
190
  ),
191
  )]
192
- async fn get_config(State(state): State<ApiState>) -> ApiResult<Json<BootstrapConfig>> {
193
- let config = state.resources.get_config().map_err(ApiError::internal)?;
 
194
  Ok(Json(config))
195
  }
196
 
@@ -199,45 +182,22 @@ async fn get_config(State(state): State<ApiState>) -> ApiResult<Json<BootstrapCo
199
  path = "/config",
200
  operation_id = "updateConfig",
201
  tag = "system",
202
- request_body = BootstrapConfig,
203
  responses(
204
- (status = 200, body = BootstrapConfig),
205
  (status = 400, body = ApiError),
206
  ),
207
  )]
208
- async fn update_config(
209
- State(state): State<ApiState>,
210
- Json(config): Json<BootstrapConfig>,
211
- ) -> ApiResult<Json<BootstrapConfig>> {
212
- let saved = state.resources.put_config(config).map_err(ApiError::from)?;
 
 
213
  Ok(Json(saved))
214
  }
215
 
216
- #[utoipa::path(
217
- post,
218
- path = "/initialize",
219
- operation_id = "initialize",
220
- tag = "system",
221
- responses(
222
- (status = 204),
223
- (status = 409, body = ApiError),
224
- (status = 500, body = ApiError),
225
- ),
226
- )]
227
- async fn initialize(State(state): State<ApiState>) -> ApiResult<StatusCode> {
228
- match state.resources.initialize().await {
229
- Ok(()) => Ok(StatusCode::NO_CONTENT),
230
- Err(error) => {
231
- let message = error.to_string();
232
- if message.contains("already in progress") {
233
- Err(ApiError::conflict(message))
234
- } else {
235
- Err(ApiError::internal(error))
236
- }
237
- }
238
- }
239
- }
240
-
241
  #[utoipa::path(
242
  get,
243
  path = "/meta",
@@ -250,7 +210,7 @@ async fn initialize(State(state): State<ApiState>) -> ApiResult<StatusCode> {
250
  )]
251
  async fn get_meta(State(state): State<ApiState>) -> ApiResult<Json<MetaInfo>> {
252
  let resources = state.resources()?;
253
- let device = operations::device(resources.clone()).await?;
254
  Ok(Json(MetaInfo {
255
  version: resources.version.to_string(),
256
  ml_device: device.ml_device,
@@ -269,7 +229,7 @@ async fn get_meta(State(state): State<ApiState>) -> ApiResult<Json<MetaInfo>> {
269
  )]
270
  async fn list_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFaceInfo>>> {
271
  let resources = state.resources()?;
272
- let fonts = operations::list_font_families(resources)
273
  .await
274
  .map_err(ApiError::from)?;
275
  Ok(Json(fonts))
@@ -291,8 +251,10 @@ async fn list_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFac
291
  )]
292
  async fn list_documents(State(state): State<ApiState>) -> ApiResult<Json<Vec<DocumentSummary>>> {
293
  let resources = state.resources()?;
294
- let guard = resources.state.read().await;
295
- let documents = guard.documents.iter().map(DocumentSummary::from).collect();
 
 
296
  Ok(Json(documents))
297
  }
298
 
@@ -313,20 +275,45 @@ async fn get_document(
313
  Path(document_id): Path<String>,
314
  ) -> ApiResult<Json<DocumentDetail>> {
315
  let resources = state.resources()?;
316
- let guard = resources.state.read().await;
317
- let doc = guard
318
- .documents
319
- .iter()
320
- .find(|d| d.id == document_id)
321
- .ok_or_else(|| ApiError::not_found("Document not found"))?;
322
 
323
- let image = serde_bytes::ByteBuf::from(encode_webp(&doc.image)?);
324
- let segment = doc.segment.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
325
- let inpainted = doc.inpainted.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
326
- let brush_layer = doc.brush_layer.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
327
- let rendered = doc.rendered.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
 
329
- let text_blocks = doc.text_blocks.iter().map(koharu_core::TextBlockDetail::from).collect();
 
 
 
 
330
 
331
  let detail = DocumentDetail {
332
  id: doc.id.clone(),
@@ -334,7 +321,6 @@ async fn get_document(
334
  name: doc.name.clone(),
335
  width: doc.width,
336
  height: doc.height,
337
- revision: doc.revision,
338
  text_blocks,
339
  image,
340
  segment,
@@ -343,7 +329,6 @@ async fn get_document(
343
  rendered,
344
  };
345
 
346
- drop(guard);
347
  Ok(Json(detail))
348
  }
349
 
@@ -372,16 +357,15 @@ async fn get_document_thumbnail(
372
  ) -> ApiResult<Response> {
373
  let size = query.size.unwrap_or(200).min(800);
374
  let resources = state.resources()?;
375
- let guard = resources.state.read().await;
376
- let doc = guard
377
- .documents
378
- .iter()
379
- .find(|d| d.id == document_id)
380
- .ok_or_else(|| ApiError::not_found("Document not found"))?;
381
  let source = doc.rendered.as_ref().unwrap_or(&doc.image);
382
  let thumbnail = source.thumbnail(size, size);
383
- let bytes = encode_webp(&thumbnail.into())?;
384
- drop(guard);
385
  Ok(binary_response(bytes, "image/webp", None))
386
  }
387
 
@@ -404,7 +388,7 @@ async fn import_documents(
404
  mut multipart: Multipart,
405
  ) -> ApiResult<Json<koharu_core::ImportResult>> {
406
  let resources = state.resources()?;
407
- let mut files = Vec::new();
408
 
409
  while let Some(field) = multipart
410
  .next_field()
@@ -419,31 +403,32 @@ async fn import_documents(
419
  .bytes()
420
  .await
421
  .map_err(|error| ApiError::bad_request(error.to_string()))?;
422
- files.push(koharu_core::FileEntry {
423
- name: filename,
424
- data: data.to_vec(),
425
- });
426
  }
427
 
428
- if files.is_empty() {
429
  return Err(ApiError::bad_request("No files uploaded"));
430
  }
431
 
 
 
 
 
 
432
  let payload = koharu_core::OpenDocumentsPayload { files };
433
  match query.mode.unwrap_or(koharu_core::ImportMode::Replace) {
434
  koharu_core::ImportMode::Replace => {
435
- operations::open_documents(resources.clone(), payload).await?;
436
  }
437
  koharu_core::ImportMode::Append => {
438
- operations::add_documents(resources.clone(), payload).await?;
439
  }
440
  }
441
 
442
- let documents = state_tx::list_docs(&resources.state)
443
- .await
444
- .iter()
445
- .map(DocumentSummary::from)
446
- .collect::<Vec<_>>();
447
 
448
  Ok(Json(koharu_core::ImportResult {
449
  total_count: documents.len(),
@@ -472,8 +457,7 @@ async fn detect_document(
472
  Path(document_id): Path<String>,
473
  ) -> ApiResult<StatusCode> {
474
  let resources = state.resources()?;
475
- let (index, _) = find_document(&resources, &document_id).await?;
476
- operations::detect(resources, koharu_core::IndexPayload { index }).await?;
477
  Ok(StatusCode::NO_CONTENT)
478
  }
479
 
@@ -494,8 +478,7 @@ async fn recognize_document(
494
  Path(document_id): Path<String>,
495
  ) -> ApiResult<StatusCode> {
496
  let resources = state.resources()?;
497
- let (index, _) = find_document(&resources, &document_id).await?;
498
- operations::ocr(resources, koharu_core::IndexPayload { index }).await?;
499
  Ok(StatusCode::NO_CONTENT)
500
  }
501
 
@@ -516,8 +499,7 @@ async fn inpaint_document(
516
  Path(document_id): Path<String>,
517
  ) -> ApiResult<StatusCode> {
518
  let resources = state.resources()?;
519
- let (index, _) = find_document(&resources, &document_id).await?;
520
- operations::inpaint(resources, koharu_core::IndexPayload { index }).await?;
521
  Ok(StatusCode::NO_CONTENT)
522
  }
523
 
@@ -540,22 +522,20 @@ async fn render_document(
540
  Json(request): Json<RenderRequest>,
541
  ) -> ApiResult<StatusCode> {
542
  let resources = state.resources()?;
543
- let (index, document) = find_document(&resources, &document_id).await?;
544
  let text_block_index = request
545
  .text_block_id
546
  .as_deref()
547
  .map(|id| find_text_block_index(&document, id))
548
  .transpose()?;
549
 
550
- operations::render(
551
  resources,
552
- koharu_core::RenderPayload {
553
- index,
554
- text_block_index,
555
- shader_effect: request.shader_effect,
556
- shader_stroke: request.shader_stroke,
557
- font_family: request.font_family,
558
- },
559
  )
560
  .await?;
561
 
@@ -581,20 +561,18 @@ async fn translate_document(
581
  Json(request): Json<TranslateRequest>,
582
  ) -> ApiResult<StatusCode> {
583
  let resources = state.resources()?;
584
- let (index, document) = find_document(&resources, &document_id).await?;
585
  let text_block_index = request
586
  .text_block_id
587
  .as_deref()
588
  .map(|id| find_text_block_index(&document, id))
589
  .transpose()?;
590
 
591
- operations::llm_generate(
592
  resources,
593
- koharu_core::LlmGeneratePayload {
594
- index,
595
- text_block_index,
596
- language: request.language,
597
- },
598
  )
599
  .await?;
600
 
@@ -624,16 +602,7 @@ async fn update_mask(
624
  Json(request): Json<MaskRegionRequest>,
625
  ) -> ApiResult<StatusCode> {
626
  let resources = state.resources()?;
627
- let (index, _) = find_document(&resources, &document_id).await?;
628
- operations::update_inpaint_mask(
629
- resources,
630
- koharu_core::UpdateInpaintMaskPayload {
631
- index,
632
- mask: request.data,
633
- region: request.region.map(to_inpaint_region),
634
- },
635
- )
636
- .await?;
637
  Ok(StatusCode::NO_CONTENT)
638
  }
639
 
@@ -655,16 +624,7 @@ async fn update_brush_layer(
655
  Json(request): Json<koharu_core::BrushRegionRequest>,
656
  ) -> ApiResult<StatusCode> {
657
  let resources = state.resources()?;
658
- let (index, _) = find_document(&resources, &document_id).await?;
659
- operations::update_brush_layer(
660
- resources,
661
- koharu_core::UpdateBrushLayerPayload {
662
- index,
663
- patch: request.data,
664
- region: to_inpaint_region(request.region),
665
- },
666
- )
667
- .await?;
668
  Ok(StatusCode::NO_CONTENT)
669
  }
670
 
@@ -686,15 +646,7 @@ async fn inpaint_region(
686
  Json(request): Json<koharu_core::InpaintRegionRequest>,
687
  ) -> ApiResult<StatusCode> {
688
  let resources = state.resources()?;
689
- let (index, _) = find_document(&resources, &document_id).await?;
690
- operations::inpaint_partial(
691
- resources,
692
- koharu_core::InpaintPartialPayload {
693
- index,
694
- region: to_inpaint_region(request.region),
695
- },
696
- )
697
- .await?;
698
  Ok(StatusCode::NO_CONTENT)
699
  }
700
 
@@ -721,31 +673,32 @@ async fn create_text_block(
721
  Json(request): Json<CreateTextBlock>,
722
  ) -> ApiResult<Json<TextBlockDetail>> {
723
  let resources = state.resources()?;
724
- let (index, _) = find_document(&resources, &document_id).await?;
725
-
726
- let detail = state_tx::mutate_doc(
727
- &resources.state,
728
- index,
729
- &[ChangedField::TextBlocks],
730
- |document| {
731
- let mut block = TextBlock {
732
- x: request.x,
733
- y: request.y,
734
- width: request.width,
735
- height: request.height,
736
- confidence: 1.0,
737
- ..Default::default()
738
- };
739
- block.set_layout_seed(block.x, block.y, block.width, block.height);
740
- document.text_blocks.push(block);
741
- let block = document
742
- .text_blocks
743
- .last()
744
- .ok_or_else(|| anyhow::anyhow!("Failed to append text block"))?;
745
- Ok(TextBlockDetail::from(block))
746
- },
747
- )
748
- .await?;
 
749
 
750
  Ok(Json(detail))
751
  }
@@ -769,91 +722,77 @@ async fn put_text_blocks(
769
  Json(inputs): Json<Vec<TextBlockInput>>,
770
  ) -> ApiResult<StatusCode> {
771
  let resources = state.resources()?;
772
- let (index, _) = find_document(&resources, &document_id).await?;
773
-
774
- let any_content_changed = state_tx::mutate_doc(
775
- &resources.state,
776
- index,
777
- &[ChangedField::TextBlocks],
778
- |document| {
779
- let mut any_changed = false;
780
-
781
- // Build a set of incoming IDs for deletion detection
782
- let incoming_ids: std::collections::HashSet<&str> = inputs
783
- .iter()
784
- .filter_map(|input| input.id.as_deref())
785
- .collect();
786
-
787
- // Delete blocks not present in the incoming array
788
- let before_len = document.text_blocks.len();
789
- document
790
- .text_blocks
791
- .retain(|block| incoming_ids.contains(block.id.as_str()));
792
- if document.text_blocks.len() != before_len {
793
- any_changed = true;
794
- }
795
 
796
- for input in &inputs {
797
- if let Some(ref id) = input.id {
798
- // Update existing block
799
- if let Some(block) = document
800
- .text_blocks
801
- .iter_mut()
802
- .find(|b| &b.id == id)
803
- {
804
- let patch = TextBlockPatch {
805
- text: input.text.clone(),
806
- translation: input.translation.clone(),
807
- x: Some(input.x),
808
- y: Some(input.y),
809
- width: Some(input.width),
810
- height: Some(input.height),
811
- style: input.style.clone(),
812
- };
813
- let had_render = block.rendered.is_some();
814
- apply_text_block_patch(block, patch);
815
- // Content changed if the render was invalidated
816
- if had_render && block.rendered.is_none() {
817
- any_changed = true;
818
- }
819
- }
820
- } else {
821
- // Create new block
822
- let mut block = TextBlock {
823
- x: input.x,
824
- y: input.y,
825
- width: input.width,
826
- height: input.height,
827
- text: input.text.clone(),
828
- translation: input.translation.clone(),
829
- style: input.style.clone(),
830
- confidence: 1.0,
831
- ..Default::default()
832
- };
833
- block.set_layout_seed(block.x, block.y, block.width, block.height);
834
- document.text_blocks.push(block);
835
  any_changed = true;
836
  }
837
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
 
839
- Ok(any_changed)
840
- },
841
- )
842
- .await?;
 
843
 
844
  // Auto-render if any block content/geometry changed
845
- if any_content_changed {
846
- let _ = operations::render(
847
- resources,
848
- koharu_core::RenderPayload {
849
- index,
850
- text_block_index: None,
851
- shader_effect: None,
852
- shader_stroke: None,
853
- font_family: None,
854
- },
855
- )
856
- .await;
857
  }
858
 
859
  Ok(StatusCode::NO_CONTENT)
@@ -881,23 +820,23 @@ async fn patch_text_block(
881
  Json(request): Json<TextBlockPatch>,
882
  ) -> ApiResult<Json<TextBlockDetail>> {
883
  let resources = state.resources()?;
884
- let (index, _) = find_document(&resources, &document_id).await?;
885
-
886
- let detail = state_tx::mutate_doc(
887
- &resources.state,
888
- index,
889
- &[ChangedField::TextBlocks],
890
- |document| {
891
- let block = document
892
- .text_blocks
893
- .iter_mut()
894
- .find(|block| block.id == text_block_id)
895
- .ok_or_else(|| anyhow::anyhow!("Text block not found: {text_block_id}"))?;
896
- apply_text_block_patch(block, request.clone());
897
- Ok(TextBlockDetail::from(&*block))
898
- },
899
- )
900
- .await?;
901
 
902
  Ok(Json(detail))
903
  }
@@ -922,23 +861,22 @@ async fn delete_text_block(
922
  Path((document_id, text_block_id)): Path<(String, String)>,
923
  ) -> ApiResult<StatusCode> {
924
  let resources = state.resources()?;
925
- let (index, _) = find_document(&resources, &document_id).await?;
926
-
927
- state_tx::mutate_doc(
928
- &resources.state,
929
- index,
930
- &[ChangedField::TextBlocks],
931
- |document| {
932
- let block_index = document
933
- .text_blocks
934
- .iter()
935
- .position(|block| block.id == text_block_id)
936
- .ok_or_else(|| anyhow::anyhow!("Text block not found: {text_block_id}"))?;
937
- document.text_blocks.remove(block_index);
938
- Ok(())
939
- },
940
- )
941
- .await?;
942
 
943
  Ok(StatusCode::NO_CONTENT)
944
  }
@@ -949,36 +887,23 @@ async fn delete_text_block(
949
 
950
  #[utoipa::path(
951
  get,
952
- path = "/llm/models",
953
- operation_id = "listLlmModels",
954
  tag = "llm",
955
- params(LlmModelsQuery),
956
  responses(
957
- (status = 200, body = Vec<LlmModelInfo>),
958
  (status = 503, body = ApiError),
959
  ),
960
  )]
961
- async fn list_llm_models(
962
  State(state): State<ApiState>,
963
- Query(query): Query<LlmModelsQuery>,
964
- ) -> ApiResult<Json<Vec<LlmModelInfo>>> {
965
  let resources = state.resources()?;
966
- let models = operations::llm_list(
967
- resources,
968
- koharu_core::LlmListPayload {
969
- language: query.language,
970
- openai_compatible_base_url: query.openai_compatible_base_url,
971
- },
972
- )
973
- .await?
974
- .into_iter()
975
- .map(|model| LlmModelInfo {
976
- id: model.id,
977
- languages: model.languages,
978
- source: model.source.to_string(),
979
- })
980
- .collect();
981
- Ok(Json(models))
982
  }
983
 
984
  #[utoipa::path(
@@ -987,11 +912,11 @@ async fn list_llm_models(
987
  operation_id = "getLlm",
988
  tag = "llm",
989
  responses(
990
- (status = 200, body = koharu_core::LlmState),
991
  (status = 503, body = ApiError),
992
  ),
993
  )]
994
- async fn get_llm(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::LlmState>> {
995
  let resources = state.resources()?;
996
  Ok(Json(resources.llm.snapshot().await))
997
  }
@@ -1003,7 +928,7 @@ async fn get_llm(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::L
1003
  tag = "llm",
1004
  request_body = LlmLoadRequest,
1005
  responses(
1006
- (status = 200, body = koharu_core::LlmState),
1007
  (status = 400, body = ApiError),
1008
  (status = 503, body = ApiError),
1009
  ),
@@ -1011,20 +936,9 @@ async fn get_llm(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::L
1011
  async fn load_llm(
1012
  State(state): State<ApiState>,
1013
  Json(request): Json<LlmLoadRequest>,
1014
- ) -> ApiResult<Json<koharu_core::LlmState>> {
1015
  let resources = state.resources()?;
1016
- operations::llm_load(
1017
- resources.clone(),
1018
- koharu_core::LlmLoadPayload {
1019
- id: request.id,
1020
- api_key: request.api_key,
1021
- base_url: request.base_url,
1022
- temperature: request.temperature,
1023
- max_tokens: request.max_tokens,
1024
- custom_system_prompt: request.custom_system_prompt,
1025
- },
1026
- )
1027
- .await?;
1028
  Ok(Json(resources.llm.snapshot().await))
1029
  }
1030
 
@@ -1034,108 +948,16 @@ async fn load_llm(
1034
  operation_id = "unloadLlm",
1035
  tag = "llm",
1036
  responses(
1037
- (status = 200, body = koharu_core::LlmState),
1038
  (status = 503, body = ApiError),
1039
  ),
1040
  )]
1041
- async fn unload_llm(State(state): State<ApiState>) -> ApiResult<Json<koharu_core::LlmState>> {
1042
  let resources = state.resources()?;
1043
- operations::llm_offload(resources.clone()).await?;
1044
  Ok(Json(resources.llm.snapshot().await))
1045
  }
1046
 
1047
- #[utoipa::path(
1048
- post,
1049
- path = "/llm/health",
1050
- operation_id = "checkLlmHealth",
1051
- tag = "llm",
1052
- request_body = LlmPingRequest,
1053
- responses(
1054
- (status = 200, body = LlmPingResponse),
1055
- ),
1056
- )]
1057
- async fn check_llm_health(
1058
- State(state): State<ApiState>,
1059
- Json(request): Json<LlmPingRequest>,
1060
- ) -> ApiResult<Json<LlmPingResponse>> {
1061
- match operations::llm_ping(
1062
- state.resources.runtime().http_client(),
1063
- &request.base_url,
1064
- request.api_key.as_deref(),
1065
- )
1066
- .await
1067
- {
1068
- Ok(result) => Ok(Json(LlmPingResponse {
1069
- ok: true,
1070
- models: result.models,
1071
- latency_ms: Some(result.latency_ms),
1072
- error: None,
1073
- })),
1074
- Err(err) => Ok(Json(LlmPingResponse {
1075
- ok: false,
1076
- models: vec![],
1077
- latency_ms: None,
1078
- error: Some(err.to_string()),
1079
- })),
1080
- }
1081
- }
1082
-
1083
- // ---------------------------------------------------------------------------
1084
- // Providers
1085
- // ---------------------------------------------------------------------------
1086
-
1087
- #[utoipa::path(
1088
- get,
1089
- path = "/providers/{provider}/api-key",
1090
- operation_id = "getApiKey",
1091
- tag = "providers",
1092
- params(("provider" = String, Path,)),
1093
- responses(
1094
- (status = 200, body = ApiKeyResponse),
1095
- (status = 503, body = ApiError),
1096
- ),
1097
- )]
1098
- async fn get_api_key(
1099
- State(state): State<ApiState>,
1100
- Path(provider): Path<String>,
1101
- ) -> ApiResult<Json<ApiKeyResponse>> {
1102
- let resources = state.resources()?;
1103
- let result =
1104
- operations::get_api_key(resources, koharu_core::ApiKeyGetPayload { provider }).await?;
1105
- Ok(Json(ApiKeyResponse {
1106
- api_key: result.api_key,
1107
- }))
1108
- }
1109
-
1110
- #[utoipa::path(
1111
- put,
1112
- path = "/providers/{provider}/api-key",
1113
- operation_id = "setApiKey",
1114
- tag = "providers",
1115
- params(("provider" = String, Path,)),
1116
- request_body = ApiKeyValue,
1117
- responses(
1118
- (status = 204),
1119
- (status = 503, body = ApiError),
1120
- ),
1121
- )]
1122
- async fn set_api_key(
1123
- State(state): State<ApiState>,
1124
- Path(provider): Path<String>,
1125
- Json(request): Json<ApiKeyValue>,
1126
- ) -> ApiResult<StatusCode> {
1127
- let resources = state.resources()?;
1128
- operations::set_api_key(
1129
- resources,
1130
- koharu_core::ApiKeySetPayload {
1131
- provider,
1132
- api_key: request.api_key,
1133
- },
1134
- )
1135
- .await?;
1136
- Ok(StatusCode::NO_CONTENT)
1137
- }
1138
-
1139
  // ---------------------------------------------------------------------------
1140
  // Jobs
1141
  // ---------------------------------------------------------------------------
@@ -1156,31 +978,38 @@ async fn start_pipeline(
1156
  Json(request): Json<PipelineJobRequest>,
1157
  ) -> ApiResult<Json<JobState>> {
1158
  let resources = state.resources()?;
1159
- let index = if let Some(document_id) = request.document_id.as_deref() {
1160
- Some(state_tx::find_doc_index(&resources.state, document_id).await?)
1161
- } else {
1162
- None
1163
- };
1164
- let total_documents = match index {
 
 
 
 
 
 
 
1165
  Some(_) => 1,
1166
- None => state_tx::doc_count(&resources.state).await,
 
 
 
 
1167
  };
1168
 
1169
- let job_id = operations::process(
1170
  resources.clone(),
1171
  koharu_core::ProcessRequest {
1172
- index,
1173
- llm_model_id: request.llm_model_id,
1174
- llm_api_key: request.llm_api_key,
1175
- llm_base_url: request.llm_base_url,
1176
- llm_temperature: request.llm_temperature,
1177
- llm_max_tokens: request.llm_max_tokens,
1178
- llm_custom_system_prompt: request.llm_custom_system_prompt,
1179
  language: request.language,
1180
  shader_effect: request.shader_effect,
1181
  shader_stroke: request.shader_stroke,
1182
  font_family: request.font_family,
1183
  },
 
1184
  )
1185
  .await?;
1186
 
@@ -1312,15 +1141,21 @@ async fn export_document(
1312
  Query(query): Query<ExportQuery>,
1313
  ) -> ApiResult<Response> {
1314
  let resources = state.resources()?;
1315
- let (_, document) = find_document(&resources, &document_id).await?;
1316
 
1317
  if format == "psd" {
1318
- let data = koharu_psd::export_document(&document, &app_psd_export_options())
1319
- .map_err(|error| ApiError::bad_request(error.to_string()))?;
 
 
 
 
 
 
1320
  return Ok(binary_response(
1321
  data,
1322
  "image/vnd.adobe.photoshop",
1323
- Some(psd_export_filename(&document)),
1324
  ));
1325
  }
1326
 
@@ -1332,8 +1167,8 @@ async fn export_document(
1332
  .and_then(|value| value.to_str())
1333
  .unwrap_or("jpg")
1334
  .to_ascii_lowercase();
1335
- let data = encode_image(image, &ext)?;
1336
- let content_type = mime_from_ext(&ext);
1337
  Ok(binary_response(data, content_type, Some(filename)))
1338
  }
1339
 
@@ -1354,23 +1189,18 @@ async fn batch_export(
1354
  ) -> ApiResult<Json<ExportResult>> {
1355
  let resources = state.resources()?;
1356
  let count = match request.layer.unwrap_or(ExportLayer::Rendered) {
1357
- ExportLayer::Rendered => operations::export_all_rendered(resources).await?,
1358
- ExportLayer::Inpainted => operations::export_all_inpainted(resources).await?,
1359
  };
1360
  Ok(Json(ExportResult { count }))
1361
  }
1362
 
1363
- async fn find_document(
1364
- resources: &AppResources,
1365
- document_id: &str,
1366
- ) -> ApiResult<(usize, Document)> {
1367
- let index = state_tx::find_doc_index(&resources.state, document_id)
1368
- .await
1369
- .map_err(ApiError::from)?;
1370
- let document = state_tx::read_doc(&resources.state, index)
1371
  .await
1372
- .map_err(ApiError::from)?;
1373
- Ok((index, document))
1374
  }
1375
 
1376
  fn find_text_block_index(document: &Document, text_block_id: &str) -> ApiResult<usize> {
@@ -1381,21 +1211,6 @@ fn find_text_block_index(document: &Document, text_block_id: &str) -> ApiResult<
1381
  .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))
1382
  }
1383
 
1384
- fn encode_webp(image: &koharu_core::SerializableDynamicImage) -> ApiResult<Vec<u8>> {
1385
- encode_image(image, "webp")
1386
- }
1387
-
1388
- fn encode_image(image: &koharu_core::SerializableDynamicImage, ext: &str) -> ApiResult<Vec<u8>> {
1389
- let format = ImageFormat::from_extension(ext).unwrap_or(ImageFormat::Jpeg);
1390
- let mut cursor = Cursor::new(Vec::new());
1391
- image
1392
- .0
1393
- .write_to(&mut cursor, format)
1394
- .with_context(|| format!("failed to encode image as {ext}"))
1395
- .map_err(ApiError::internal)?;
1396
- Ok(cursor.into_inner())
1397
- }
1398
-
1399
  fn binary_response(data: Vec<u8>, content_type: &str, filename: Option<String>) -> Response {
1400
  let mut response = Response::new(Body::from(data));
1401
  response
@@ -1409,15 +1224,6 @@ fn binary_response(data: Vec<u8>, content_type: &str, filename: Option<String>)
1409
  response
1410
  }
1411
 
1412
- fn mime_from_ext(ext: &str) -> &'static str {
1413
- match ext {
1414
- "png" => "image/png",
1415
- "jpg" | "jpeg" => "image/jpeg",
1416
- "webp" => "image/webp",
1417
- _ => "application/octet-stream",
1418
- }
1419
- }
1420
-
1421
  fn export_target(
1422
  document: &Document,
1423
  layer: ExportLayer,
@@ -1447,26 +1253,6 @@ fn export_target(
1447
  }
1448
  }
1449
 
1450
- fn psd_export_filename(document: &Document) -> String {
1451
- format!("{}_koharu.psd", document.name)
1452
- }
1453
-
1454
- fn app_psd_export_options() -> PsdExportOptions {
1455
- PsdExportOptions {
1456
- text_layer_mode: TextLayerMode::Editable,
1457
- ..PsdExportOptions::default()
1458
- }
1459
- }
1460
-
1461
- fn to_inpaint_region(region: Region) -> InpaintRegion {
1462
- InpaintRegion {
1463
- x: region.x,
1464
- y: region.y,
1465
- width: region.width,
1466
- height: region.height,
1467
- }
1468
- }
1469
-
1470
  fn apply_text_block_patch(block: &mut TextBlock, patch: TextBlockPatch) {
1471
  let previous_width = block.width;
1472
  let previous_height = block.height;
@@ -1530,9 +1316,8 @@ fn apply_text_block_patch(block: &mut TextBlock, patch: TextBlockPatch) {
1530
 
1531
  #[cfg(test)]
1532
  mod tests {
1533
- use super::{app_psd_export_options, apply_text_block_patch, psd_export_filename};
1534
- use koharu_core::{Document, TextAlign, TextBlock, TextBlockPatch, TextDirection, TextStyle};
1535
- use koharu_psd::TextLayerMode;
1536
 
1537
  #[test]
1538
  fn text_block_patch_updates_geometry_and_clears_rendered() {
@@ -1573,22 +1358,4 @@ mod tests {
1573
  assert!(block.rendered.is_none());
1574
  assert!(block.rendered_direction.is_none());
1575
  }
1576
-
1577
- #[test]
1578
- fn psd_export_filename_uses_koharu_suffix() {
1579
- let document = Document {
1580
- name: "chapter-01".to_string(),
1581
- ..Default::default()
1582
- };
1583
-
1584
- assert_eq!(psd_export_filename(&document), "chapter-01_koharu.psd");
1585
- }
1586
-
1587
- #[test]
1588
- fn app_psd_export_uses_editable_text_layers() {
1589
- assert_eq!(
1590
- app_psd_export_options().text_layer_mode,
1591
- TextLayerMode::Editable
1592
- );
1593
- }
1594
  }
 
 
 
 
1
  use axum::{
2
  Json,
3
  body::Body,
 
8
  },
9
  response::{IntoResponse, Response},
10
  };
11
+ use koharu_app::{AppResources, config as app_config, edit, io, llm, ml, pipeline};
 
 
 
 
12
  use koharu_core::{
13
+ AppConfig, AppConfigUpdate, CreateTextBlock, Document, DocumentDetail, DocumentSummary,
14
+ DownloadState, ExportLayer, ExportResult, FontFaceInfo, JobState, JobStatus, LlmCatalog,
15
+ LlmLoadRequest, LlmState, MaskRegionRequest, MetaInfo, PipelineJobRequest, RenderRequest,
16
+ TextBlock, TextBlockDetail, TextBlockInput, TextBlockPatch, TranslateRequest,
 
17
  };
18
  use koharu_psd::{PsdExportOptions, TextLayerMode};
19
  use serde::{Deserialize, Serialize};
20
  use utoipa::IntoParams;
21
  use utoipa_axum::{router::OpenApiRouter, routes};
22
 
23
+ use crate::{shared::SharedState, tracker::Tracker};
 
 
 
24
 
25
  const MAX_BODY_SIZE: usize = 1024 * 1024 * 1024;
26
 
 
32
 
33
  impl ApiState {
34
  fn resources(&self) -> ApiResult<AppResources> {
35
+ self.resources.get().ok_or_else(|| {
36
+ ApiError::service_unavailable(anyhow::anyhow!("Resources not initialized"))
37
+ })
38
  }
39
  }
40
 
 
56
  .routes(routes!(export_document))
57
  .routes(routes!(batch_export))
58
  .routes(routes!(get_llm, load_llm, unload_llm))
59
+ .routes(routes!(get_llm_catalog))
 
 
60
  .routes(routes!(start_pipeline))
61
  .routes(routes!(list_jobs))
62
  .routes(routes!(get_job, cancel_job))
 
64
  .routes(routes!(get_meta))
65
  .routes(routes!(list_fonts))
66
  .routes(routes!(get_config, update_config))
 
67
  .split_for_parts()
68
  }
69
 
 
99
  Self::new(StatusCode::NOT_FOUND, message)
100
  }
101
 
 
 
 
 
102
  fn service_unavailable(error: anyhow::Error) -> Self {
103
  Self::new(StatusCode::SERVICE_UNAVAILABLE, error.to_string())
104
  }
 
121
 
122
  impl IntoResponse for ApiError {
123
  fn into_response(self) -> Response {
124
+ let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
 
125
  (status, Json(self)).into_response()
126
  }
127
  }
 
141
 
142
  #[derive(Debug, Deserialize, IntoParams)]
143
  #[serde(rename_all = "camelCase")]
144
+ struct LlmCatalogQuery {
145
  language: Option<String>,
 
146
  }
147
 
148
  #[derive(Debug, Deserialize, IntoParams)]
 
167
  operation_id = "getConfig",
168
  tag = "system",
169
  responses(
170
+ (status = 200, body = AppConfig),
171
  (status = 503, body = ApiError),
172
  ),
173
  )]
174
+ async fn get_config() -> ApiResult<Json<AppConfig>> {
175
+ let stored = app_config::load().map_err(ApiError::internal)?;
176
+ let config = app_config::to_public_config(&stored).map_err(ApiError::internal)?;
177
  Ok(Json(config))
178
  }
179
 
 
182
  path = "/config",
183
  operation_id = "updateConfig",
184
  tag = "system",
185
+ request_body = AppConfigUpdate,
186
  responses(
187
+ (status = 200, body = AppConfig),
188
  (status = 400, body = ApiError),
189
  ),
190
  )]
191
+ async fn update_config(Json(config): Json<AppConfigUpdate>) -> ApiResult<Json<AppConfig>> {
192
+ let current = app_config::load().map_err(ApiError::internal)?;
193
+ let stored = app_config::from_public_update(config.clone()).map_err(ApiError::from)?;
194
+ app_config::move_app_data_if_needed(&current, &stored).map_err(ApiError::internal)?;
195
+ app_config::save(&stored).map_err(ApiError::internal)?;
196
+ app_config::apply_secret_updates(&config).map_err(ApiError::from)?;
197
+ let saved = app_config::to_public_config(&stored).map_err(ApiError::internal)?;
198
  Ok(Json(saved))
199
  }
200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
  #[utoipa::path(
202
  get,
203
  path = "/meta",
 
210
  )]
211
  async fn get_meta(State(state): State<ApiState>) -> ApiResult<Json<MetaInfo>> {
212
  let resources = state.resources()?;
213
+ let device = io::device(resources.clone()).await?;
214
  Ok(Json(MetaInfo {
215
  version: resources.version.to_string(),
216
  ml_device: device.ml_device,
 
229
  )]
230
  async fn list_fonts(State(state): State<ApiState>) -> ApiResult<Json<Vec<FontFaceInfo>>> {
231
  let resources = state.resources()?;
232
+ let fonts = ml::list_font_families(resources)
233
  .await
234
  .map_err(ApiError::from)?;
235
  Ok(Json(fonts))
 
251
  )]
252
  async fn list_documents(State(state): State<ApiState>) -> ApiResult<Json<Vec<DocumentSummary>>> {
253
  let resources = state.resources()?;
254
+ let documents = resources
255
+ .cache
256
+ .list_documents()
257
+ .map_err(ApiError::internal)?;
258
  Ok(Json(documents))
259
  }
260
 
 
275
  Path(document_id): Path<String>,
276
  ) -> ApiResult<Json<DocumentDetail>> {
277
  let resources = state.resources()?;
278
+ let doc = resources
279
+ .cache
280
+ .get(&document_id)
281
+ .await
282
+ .map_err(|_| ApiError::not_found("Document not found"))?;
 
283
 
284
+ let image = serde_bytes::ByteBuf::from(
285
+ koharu_app::utils::encode_image(&doc.image, "webp").map_err(ApiError::internal)?,
286
+ );
287
+ let segment = doc
288
+ .segment
289
+ .as_ref()
290
+ .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from))
291
+ .transpose()
292
+ .map_err(ApiError::internal)?;
293
+ let inpainted = doc
294
+ .inpainted
295
+ .as_ref()
296
+ .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from))
297
+ .transpose()
298
+ .map_err(ApiError::internal)?;
299
+ let brush_layer = doc
300
+ .brush_layer
301
+ .as_ref()
302
+ .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from))
303
+ .transpose()
304
+ .map_err(ApiError::internal)?;
305
+ let rendered = doc
306
+ .rendered
307
+ .as_ref()
308
+ .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from))
309
+ .transpose()
310
+ .map_err(ApiError::internal)?;
311
 
312
+ let text_blocks = doc
313
+ .text_blocks
314
+ .iter()
315
+ .map(koharu_core::TextBlockDetail::from)
316
+ .collect();
317
 
318
  let detail = DocumentDetail {
319
  id: doc.id.clone(),
 
321
  name: doc.name.clone(),
322
  width: doc.width,
323
  height: doc.height,
 
324
  text_blocks,
325
  image,
326
  segment,
 
329
  rendered,
330
  };
331
 
 
332
  Ok(Json(detail))
333
  }
334
 
 
357
  ) -> ApiResult<Response> {
358
  let size = query.size.unwrap_or(200).min(800);
359
  let resources = state.resources()?;
360
+ let doc = resources
361
+ .cache
362
+ .get(&document_id)
363
+ .await
364
+ .map_err(|_| ApiError::not_found("Document not found"))?;
 
365
  let source = doc.rendered.as_ref().unwrap_or(&doc.image);
366
  let thumbnail = source.thumbnail(size, size);
367
+ let bytes =
368
+ koharu_app::utils::encode_image(&thumbnail.into(), "webp").map_err(ApiError::internal)?;
369
  Ok(binary_response(bytes, "image/webp", None))
370
  }
371
 
 
388
  mut multipart: Multipart,
389
  ) -> ApiResult<Json<koharu_core::ImportResult>> {
390
  let resources = state.resources()?;
391
+ let mut uploaded_files = Vec::new();
392
 
393
  while let Some(field) = multipart
394
  .next_field()
 
403
  .bytes()
404
  .await
405
  .map_err(|error| ApiError::bad_request(error.to_string()))?;
406
+ uploaded_files.push((filename, data.to_vec()));
 
 
 
407
  }
408
 
409
+ if uploaded_files.is_empty() {
410
  return Err(ApiError::bad_request("No files uploaded"));
411
  }
412
 
413
+ let files = uploaded_files
414
+ .into_iter()
415
+ .map(|(name, data)| koharu_core::FileEntry { name, data })
416
+ .collect();
417
+
418
  let payload = koharu_core::OpenDocumentsPayload { files };
419
  match query.mode.unwrap_or(koharu_core::ImportMode::Replace) {
420
  koharu_core::ImportMode::Replace => {
421
+ io::open_documents(resources.clone(), payload).await?;
422
  }
423
  koharu_core::ImportMode::Append => {
424
+ io::add_documents(resources.clone(), payload).await?;
425
  }
426
  }
427
 
428
+ let documents = resources
429
+ .cache
430
+ .list_documents()
431
+ .map_err(ApiError::internal)?;
 
432
 
433
  Ok(Json(koharu_core::ImportResult {
434
  total_count: documents.len(),
 
457
  Path(document_id): Path<String>,
458
  ) -> ApiResult<StatusCode> {
459
  let resources = state.resources()?;
460
+ ml::detect(resources, &document_id).await?;
 
461
  Ok(StatusCode::NO_CONTENT)
462
  }
463
 
 
478
  Path(document_id): Path<String>,
479
  ) -> ApiResult<StatusCode> {
480
  let resources = state.resources()?;
481
+ ml::ocr(resources, &document_id).await?;
 
482
  Ok(StatusCode::NO_CONTENT)
483
  }
484
 
 
499
  Path(document_id): Path<String>,
500
  ) -> ApiResult<StatusCode> {
501
  let resources = state.resources()?;
502
+ ml::inpaint(resources, &document_id).await?;
 
503
  Ok(StatusCode::NO_CONTENT)
504
  }
505
 
 
522
  Json(request): Json<RenderRequest>,
523
  ) -> ApiResult<StatusCode> {
524
  let resources = state.resources()?;
525
+ let document = find_document(&resources, &document_id).await?;
526
  let text_block_index = request
527
  .text_block_id
528
  .as_deref()
529
  .map(|id| find_text_block_index(&document, id))
530
  .transpose()?;
531
 
532
+ ml::render(
533
  resources,
534
+ &document_id,
535
+ text_block_index,
536
+ request.shader_effect,
537
+ request.shader_stroke,
538
+ request.font_family.as_deref(),
 
 
539
  )
540
  .await?;
541
 
 
561
  Json(request): Json<TranslateRequest>,
562
  ) -> ApiResult<StatusCode> {
563
  let resources = state.resources()?;
564
+ let document = find_document(&resources, &document_id).await?;
565
  let text_block_index = request
566
  .text_block_id
567
  .as_deref()
568
  .map(|id| find_text_block_index(&document, id))
569
  .transpose()?;
570
 
571
+ llm::llm_generate(
572
  resources,
573
+ &document_id,
574
+ text_block_index,
575
+ request.language.as_deref(),
 
 
576
  )
577
  .await?;
578
 
 
602
  Json(request): Json<MaskRegionRequest>,
603
  ) -> ApiResult<StatusCode> {
604
  let resources = state.resources()?;
605
+ edit::update_inpaint_mask(resources, &document_id, &request.data, request.region).await?;
 
 
 
 
 
 
 
 
 
606
  Ok(StatusCode::NO_CONTENT)
607
  }
608
 
 
624
  Json(request): Json<koharu_core::BrushRegionRequest>,
625
  ) -> ApiResult<StatusCode> {
626
  let resources = state.resources()?;
627
+ edit::update_brush_layer(resources, &document_id, &request.data, request.region).await?;
 
 
 
 
 
 
 
 
 
628
  Ok(StatusCode::NO_CONTENT)
629
  }
630
 
 
646
  Json(request): Json<koharu_core::InpaintRegionRequest>,
647
  ) -> ApiResult<StatusCode> {
648
  let resources = state.resources()?;
649
+ edit::inpaint_partial(resources, &document_id, request.region).await?;
 
 
 
 
 
 
 
 
650
  Ok(StatusCode::NO_CONTENT)
651
  }
652
 
 
673
  Json(request): Json<CreateTextBlock>,
674
  ) -> ApiResult<Json<TextBlockDetail>> {
675
  let resources = state.resources()?;
676
+ let mut doc = resources
677
+ .cache
678
+ .get(&document_id)
679
+ .await
680
+ .map_err(|_| ApiError::not_found("Document not found"))?;
681
+
682
+ let mut block = TextBlock {
683
+ x: request.x,
684
+ y: request.y,
685
+ width: request.width,
686
+ height: request.height,
687
+ confidence: 1.0,
688
+ ..Default::default()
689
+ };
690
+ block.set_layout_seed(block.x, block.y, block.width, block.height);
691
+ doc.text_blocks.push(block);
692
+ let detail = doc
693
+ .text_blocks
694
+ .last()
695
+ .map(TextBlockDetail::from)
696
+ .ok_or_else(|| ApiError::internal(anyhow::anyhow!("Failed to append text block")))?;
697
+ resources
698
+ .cache
699
+ .put(&doc)
700
+ .await
701
+ .map_err(ApiError::internal)?;
702
 
703
  Ok(Json(detail))
704
  }
 
722
  Json(inputs): Json<Vec<TextBlockInput>>,
723
  ) -> ApiResult<StatusCode> {
724
  let resources = state.resources()?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
725
 
726
+ let mut doc = resources
727
+ .cache
728
+ .get(&document_id)
729
+ .await
730
+ .map_err(|_| ApiError::not_found("Document not found"))?;
731
+
732
+ let mut any_changed = false;
733
+
734
+ // Build a set of incoming IDs for deletion detection
735
+ let incoming_ids: std::collections::HashSet<&str> = inputs
736
+ .iter()
737
+ .filter_map(|input| input.id.as_deref())
738
+ .collect();
739
+
740
+ // Delete blocks not present in the incoming array
741
+ let before_len = doc.text_blocks.len();
742
+ doc.text_blocks
743
+ .retain(|block| incoming_ids.contains(block.id.as_str()));
744
+ if doc.text_blocks.len() != before_len {
745
+ any_changed = true;
746
+ }
747
+
748
+ for input in &inputs {
749
+ if let Some(ref id) = input.id {
750
+ // Update existing block
751
+ if let Some(block) = doc.text_blocks.iter_mut().find(|b| &b.id == id) {
752
+ let patch = TextBlockPatch {
753
+ text: input.text.clone(),
754
+ translation: input.translation.clone(),
755
+ x: Some(input.x),
756
+ y: Some(input.y),
757
+ width: Some(input.width),
758
+ height: Some(input.height),
759
+ style: input.style.clone(),
760
+ };
761
+ let had_render = block.rendered.is_some();
762
+ apply_text_block_patch(block, patch);
763
+ // Content changed if the render was invalidated
764
+ if had_render && block.rendered.is_none() {
765
  any_changed = true;
766
  }
767
  }
768
+ } else {
769
+ // Create new block
770
+ let mut block = TextBlock {
771
+ x: input.x,
772
+ y: input.y,
773
+ width: input.width,
774
+ height: input.height,
775
+ text: input.text.clone(),
776
+ translation: input.translation.clone(),
777
+ style: input.style.clone(),
778
+ confidence: 1.0,
779
+ ..Default::default()
780
+ };
781
+ block.set_layout_seed(block.x, block.y, block.width, block.height);
782
+ doc.text_blocks.push(block);
783
+ any_changed = true;
784
+ }
785
+ }
786
 
787
+ resources
788
+ .cache
789
+ .put(&doc)
790
+ .await
791
+ .map_err(ApiError::internal)?;
792
 
793
  // Auto-render if any block content/geometry changed
794
+ if any_changed {
795
+ let _ = ml::render(resources, &document_id, None, None, None, None).await;
 
 
 
 
 
 
 
 
 
 
796
  }
797
 
798
  Ok(StatusCode::NO_CONTENT)
 
820
  Json(request): Json<TextBlockPatch>,
821
  ) -> ApiResult<Json<TextBlockDetail>> {
822
  let resources = state.resources()?;
823
+ let mut doc = resources
824
+ .cache
825
+ .get(&document_id)
826
+ .await
827
+ .map_err(|_| ApiError::not_found("Document not found"))?;
828
+ let block = doc
829
+ .text_blocks
830
+ .iter_mut()
831
+ .find(|block| block.id == text_block_id)
832
+ .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))?;
833
+ apply_text_block_patch(block, request);
834
+ let detail = TextBlockDetail::from(&*block);
835
+ resources
836
+ .cache
837
+ .put(&doc)
838
+ .await
839
+ .map_err(ApiError::internal)?;
840
 
841
  Ok(Json(detail))
842
  }
 
861
  Path((document_id, text_block_id)): Path<(String, String)>,
862
  ) -> ApiResult<StatusCode> {
863
  let resources = state.resources()?;
864
+ let mut doc = resources
865
+ .cache
866
+ .get(&document_id)
867
+ .await
868
+ .map_err(|_| ApiError::not_found("Document not found"))?;
869
+ let block_index = doc
870
+ .text_blocks
871
+ .iter()
872
+ .position(|block| block.id == text_block_id)
873
+ .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))?;
874
+ doc.text_blocks.remove(block_index);
875
+ resources
876
+ .cache
877
+ .put(&doc)
878
+ .await
879
+ .map_err(ApiError::internal)?;
 
880
 
881
  Ok(StatusCode::NO_CONTENT)
882
  }
 
887
 
888
  #[utoipa::path(
889
  get,
890
+ path = "/llm/catalog",
891
+ operation_id = "getLlmCatalog",
892
  tag = "llm",
893
+ params(LlmCatalogQuery),
894
  responses(
895
+ (status = 200, body = LlmCatalog),
896
  (status = 503, body = ApiError),
897
  ),
898
  )]
899
+ async fn get_llm_catalog(
900
  State(state): State<ApiState>,
901
+ Query(query): Query<LlmCatalogQuery>,
902
+ ) -> ApiResult<Json<LlmCatalog>> {
903
  let resources = state.resources()?;
904
+ Ok(Json(
905
+ llm::llm_catalog(resources, query.language.as_deref()).await?,
906
+ ))
 
 
 
 
 
 
 
 
 
 
 
 
 
907
  }
908
 
909
  #[utoipa::path(
 
912
  operation_id = "getLlm",
913
  tag = "llm",
914
  responses(
915
+ (status = 200, body = LlmState),
916
  (status = 503, body = ApiError),
917
  ),
918
  )]
919
+ async fn get_llm(State(state): State<ApiState>) -> ApiResult<Json<LlmState>> {
920
  let resources = state.resources()?;
921
  Ok(Json(resources.llm.snapshot().await))
922
  }
 
928
  tag = "llm",
929
  request_body = LlmLoadRequest,
930
  responses(
931
+ (status = 200, body = LlmState),
932
  (status = 400, body = ApiError),
933
  (status = 503, body = ApiError),
934
  ),
 
936
  async fn load_llm(
937
  State(state): State<ApiState>,
938
  Json(request): Json<LlmLoadRequest>,
939
+ ) -> ApiResult<Json<LlmState>> {
940
  let resources = state.resources()?;
941
+ llm::llm_load(resources.clone(), request).await?;
 
 
 
 
 
 
 
 
 
 
 
942
  Ok(Json(resources.llm.snapshot().await))
943
  }
944
 
 
948
  operation_id = "unloadLlm",
949
  tag = "llm",
950
  responses(
951
+ (status = 200, body = LlmState),
952
  (status = 503, body = ApiError),
953
  ),
954
  )]
955
+ async fn unload_llm(State(state): State<ApiState>) -> ApiResult<Json<LlmState>> {
956
  let resources = state.resources()?;
957
+ llm::llm_offload(resources.clone()).await?;
958
  Ok(Json(resources.llm.snapshot().await))
959
  }
960
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
961
  // ---------------------------------------------------------------------------
962
  // Jobs
963
  // ---------------------------------------------------------------------------
 
978
  Json(request): Json<PipelineJobRequest>,
979
  ) -> ApiResult<Json<JobState>> {
980
  let resources = state.resources()?;
981
+ // Validate document_id exists if provided
982
+ if let Some(document_id) = request.document_id.as_deref() {
983
+ let entries = resources
984
+ .cache
985
+ .list_documents()
986
+ .map_err(ApiError::internal)?;
987
+ if !entries.iter().any(|d| d.id == document_id) {
988
+ return Err(ApiError::not_found(format!(
989
+ "Document not found: {document_id}"
990
+ )));
991
+ }
992
+ }
993
+ let total_documents = match &request.document_id {
994
  Some(_) => 1,
995
+ None => resources
996
+ .cache
997
+ .list_documents()
998
+ .map_err(ApiError::internal)?
999
+ .len(),
1000
  };
1001
 
1002
+ let job_id = pipeline::process(
1003
  resources.clone(),
1004
  koharu_core::ProcessRequest {
1005
+ document_id: request.document_id.clone(),
1006
+ llm: request.llm.clone(),
 
 
 
 
 
1007
  language: request.language,
1008
  shader_effect: request.shader_effect,
1009
  shader_stroke: request.shader_stroke,
1010
  font_family: request.font_family,
1011
  },
1012
+ state.tracker.jobs(),
1013
  )
1014
  .await?;
1015
 
 
1141
  Query(query): Query<ExportQuery>,
1142
  ) -> ApiResult<Response> {
1143
  let resources = state.resources()?;
1144
+ let document = find_document(&resources, &document_id).await?;
1145
 
1146
  if format == "psd" {
1147
+ let data = koharu_psd::export_document(
1148
+ &document,
1149
+ &PsdExportOptions {
1150
+ text_layer_mode: TextLayerMode::Editable,
1151
+ ..PsdExportOptions::default()
1152
+ },
1153
+ )
1154
+ .map_err(|error| ApiError::bad_request(error.to_string()))?;
1155
  return Ok(binary_response(
1156
  data,
1157
  "image/vnd.adobe.photoshop",
1158
+ Some(format!("{}_koharu.psd", document.name)),
1159
  ));
1160
  }
1161
 
 
1167
  .and_then(|value| value.to_str())
1168
  .unwrap_or("jpg")
1169
  .to_ascii_lowercase();
1170
+ let data = koharu_app::utils::encode_image(image, &ext).map_err(ApiError::internal)?;
1171
+ let content_type = koharu_app::utils::mime_from_ext(&ext);
1172
  Ok(binary_response(data, content_type, Some(filename)))
1173
  }
1174
 
 
1189
  ) -> ApiResult<Json<ExportResult>> {
1190
  let resources = state.resources()?;
1191
  let count = match request.layer.unwrap_or(ExportLayer::Rendered) {
1192
+ ExportLayer::Rendered => io::export_all_rendered(resources).await?,
1193
+ ExportLayer::Inpainted => io::export_all_inpainted(resources).await?,
1194
  };
1195
  Ok(Json(ExportResult { count }))
1196
  }
1197
 
1198
+ async fn find_document(resources: &AppResources, document_id: &str) -> ApiResult<Document> {
1199
+ resources
1200
+ .cache
1201
+ .get(document_id)
 
 
 
 
1202
  .await
1203
+ .map_err(|_| ApiError::not_found(format!("Document not found: {document_id}")))
 
1204
  }
1205
 
1206
  fn find_text_block_index(document: &Document, text_block_id: &str) -> ApiResult<usize> {
 
1211
  .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))
1212
  }
1213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1214
  fn binary_response(data: Vec<u8>, content_type: &str, filename: Option<String>) -> Response {
1215
  let mut response = Response::new(Body::from(data));
1216
  response
 
1224
  response
1225
  }
1226
 
 
 
 
 
 
 
 
 
 
1227
  fn export_target(
1228
  document: &Document,
1229
  layer: ExportLayer,
 
1253
  }
1254
  }
1255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1256
  fn apply_text_block_patch(block: &mut TextBlock, patch: TextBlockPatch) {
1257
  let previous_width = block.width;
1258
  let previous_height = block.height;
 
1316
 
1317
  #[cfg(test)]
1318
  mod tests {
1319
+ use super::apply_text_block_patch;
1320
+ use koharu_core::{TextAlign, TextBlock, TextBlockPatch, TextDirection, TextStyle};
 
1321
 
1322
  #[test]
1323
  fn text_block_patch_updates_geometry_and_clears_rendered() {
 
1358
  assert!(block.rendered.is_none());
1359
  assert!(block.rendered_direction.is_none());
1360
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1361
  }
koharu-rpc/src/lib.rs CHANGED
@@ -4,4 +4,4 @@ pub mod server;
4
  pub mod shared;
5
  pub mod tracker;
6
 
7
- pub use shared::{BootstrapHooks, SharedState};
 
4
  pub mod shared;
5
  pub mod tracker;
6
 
7
+ pub use shared::SharedState;
koharu-rpc/src/mcp/mod.rs CHANGED
@@ -1,6 +1,8 @@
1
  mod helpers;
2
 
 
3
  use std::path::PathBuf;
 
4
 
5
  use image::DynamicImage;
6
  use rmcp::handler::server::router::tool::ToolRouter;
@@ -10,17 +12,17 @@ use rmcp::model::{
10
  ToolsCapability,
11
  };
12
  use rmcp::{ServerHandler, tool, tool_handler, tool_router};
 
13
 
14
- use koharu_app::AppResources;
15
- use koharu_app::operations;
16
  use koharu_core::commands::{
17
- AddTextBlockPayload, ExportDocumentParams, FileEntry, IndexPayload, InpaintPartialPayload,
18
- InpaintRegion, InpaintRegionParams, LlmGenerateParams, LlmGeneratePayload, LlmListPayload,
19
- LlmLoadParams, LlmLoadPayload, MaskMorphPayload, OpenDocumentsParams, OpenDocumentsPayload,
20
- ProcessParams, ProcessRequest, RemoveTextBlockPayload, RenderParams, RenderPayload,
21
- UpdateTextBlockPayload, ViewImageParams, ViewTextBlockParams,
22
  };
23
  use koharu_core::views::to_doc_info;
 
24
 
25
  use crate::shared::SharedState;
26
 
@@ -45,6 +47,21 @@ impl KoharuMcp {
45
  .get()
46
  .ok_or_else(|| "Resources not initialized yet".to_string())
47
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  }
49
 
50
  #[tool_router]
@@ -52,24 +69,20 @@ impl KoharuMcp {
52
  #[tool(description = "Get the application version")]
53
  async fn app_version(&self) -> Result<String, String> {
54
  let res = self.resources()?;
55
- operations::app_version(res)
56
- .await
57
- .map_err(|e| e.to_string())
58
  }
59
 
60
  #[tool(description = "Get device information (ML device, GPU info)")]
61
  async fn device(&self) -> Result<String, String> {
62
  let res = self.resources()?;
63
- let info = operations::device(res).await.map_err(|e| e.to_string())?;
64
  serde_json::to_string_pretty(&info).map_err(|e| e.to_string())
65
  }
66
 
67
  #[tool(description = "Get the number of loaded documents")]
68
  async fn get_documents(&self) -> Result<String, String> {
69
  let res = self.resources()?;
70
- let count = operations::get_documents(res)
71
- .await
72
- .map_err(|e| e.to_string())?;
73
  Ok(format!("{count} document(s) loaded"))
74
  }
75
 
@@ -78,10 +91,10 @@ impl KoharuMcp {
78
  )]
79
  async fn get_document(
80
  &self,
81
- Parameters(p): Parameters<IndexPayload>,
82
  ) -> Result<String, String> {
83
  let res = self.resources()?;
84
- let doc = operations::get_document(res, p)
85
  .await
86
  .map_err(|e| e.to_string())?;
87
  let info = to_doc_info(&doc);
@@ -91,7 +104,7 @@ impl KoharuMcp {
91
  #[tool(description = "List available font families for text rendering")]
92
  async fn list_font_families(&self) -> Result<String, String> {
93
  let res = self.resources()?;
94
- let fonts = operations::list_font_families(res)
95
  .await
96
  .map_err(|e| e.to_string())?;
97
  Ok(fonts
@@ -101,27 +114,21 @@ impl KoharuMcp {
101
  .join(", "))
102
  }
103
 
104
- #[tool(description = "List available LLM translation models with supported languages")]
 
 
105
  async fn llm_list(&self) -> Result<String, String> {
106
  let res = self.resources()?;
107
- let models = operations::llm_list(
108
- res,
109
- LlmListPayload {
110
- language: None,
111
- openai_compatible_base_url: None,
112
- },
113
- )
114
- .await
115
- .map_err(|e| e.to_string())?;
116
- serde_json::to_string_pretty(&models).map_err(|e| e.to_string())
117
  }
118
 
119
  #[tool(description = "Check if an LLM model is loaded and ready")]
120
  async fn llm_ready(&self) -> Result<String, String> {
121
  let res = self.resources()?;
122
- let ready = operations::llm_ready(res)
123
- .await
124
- .map_err(|e| e.to_string())?;
125
  Ok(if ready {
126
  "LLM is ready".to_string()
127
  } else {
@@ -139,7 +146,7 @@ impl KoharuMcp {
139
  let res = self
140
  .resources()
141
  .map_err(|e| ErrorData::internal_error(e, None))?;
142
- let doc = operations::get_document(res, IndexPayload { index: p.index })
143
  .await
144
  .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
145
 
@@ -186,7 +193,7 @@ impl KoharuMcp {
186
  let res = self
187
  .resources()
188
  .map_err(|e| ErrorData::internal_error(e, None))?;
189
- let doc = operations::get_document(res, IndexPayload { index: p.index })
190
  .await
191
  .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
192
 
@@ -264,12 +271,12 @@ impl KoharuMcp {
264
  })
265
  .collect();
266
 
267
- let count = operations::open_documents(res.clone(), OpenDocumentsPayload { files: files? })
268
  .await
269
  .map_err(|e| e.to_string())?;
270
 
271
- let guard = res.state.read().await;
272
- let names: Vec<&str> = guard.documents.iter().map(|d| d.name.as_str()).collect();
273
  Ok(format!("Loaded {count} document(s): {}", names.join(", ")))
274
  }
275
 
@@ -279,7 +286,7 @@ impl KoharuMcp {
279
  Parameters(p): Parameters<ExportDocumentParams>,
280
  ) -> Result<String, String> {
281
  let res = self.resources()?;
282
- let result = operations::export_document(res, IndexPayload { index: p.index })
283
  .await
284
  .map_err(|e| e.to_string())?;
285
 
@@ -292,13 +299,13 @@ impl KoharuMcp {
292
  #[tool(
293
  description = "Detect text blocks and fonts in a manga page. Finds speech bubbles, text regions, and predicts font properties."
294
  )]
295
- async fn detect(&self, Parameters(p): Parameters<IndexPayload>) -> Result<String, String> {
296
  let res = self.resources()?;
297
- operations::detect(res.clone(), p)
298
  .await
299
  .map_err(|e| e.to_string())?;
300
 
301
- let doc = operations::get_document(res, p)
302
  .await
303
  .map_err(|e| e.to_string())?;
304
 
@@ -315,13 +322,13 @@ impl KoharuMcp {
315
  #[tool(
316
  description = "Run OCR (optical character recognition) on detected text blocks to extract the original text."
317
  )]
318
- async fn ocr(&self, Parameters(p): Parameters<IndexPayload>) -> Result<String, String> {
319
  let res = self.resources()?;
320
- operations::ocr(res.clone(), p)
321
  .await
322
  .map_err(|e| e.to_string())?;
323
 
324
- let doc = operations::get_document(res, p)
325
  .await
326
  .map_err(|e| e.to_string())?;
327
 
@@ -336,9 +343,9 @@ impl KoharuMcp {
336
  #[tool(
337
  description = "Inpaint (remove) text from the image using the detection mask. Fills text regions with surrounding background."
338
  )]
339
- async fn inpaint(&self, Parameters(p): Parameters<IndexPayload>) -> Result<String, String> {
340
  let res = self.resources()?;
341
- operations::inpaint(res, p)
342
  .await
343
  .map_err(|e| e.to_string())?;
344
  Ok("Inpainting complete".to_string())
@@ -356,15 +363,13 @@ impl KoharuMcp {
356
  .transpose()
357
  .map_err(|e: anyhow::Error| e.to_string())?;
358
 
359
- operations::render(
360
  res,
361
- RenderPayload {
362
- index: p.index,
363
- text_block_index: p.text_block_index,
364
- shader_effect: effect,
365
- shader_stroke: None,
366
- font_family: p.font_family,
367
- },
368
  )
369
  .await
370
  .map_err(|e| e.to_string())?;
@@ -377,28 +382,34 @@ impl KoharuMcp {
377
  )]
378
  async fn llm_load(&self, Parameters(p): Parameters<LlmLoadParams>) -> Result<String, String> {
379
  let res = self.resources()?;
380
- operations::llm_load(
381
  res,
382
- LlmLoadPayload {
383
- id: p.id.clone(),
384
- api_key: None,
385
- base_url: None,
386
- temperature: p.temperature,
387
- max_tokens: p.max_tokens,
388
- custom_system_prompt: p.custom_system_prompt,
389
  },
390
  )
391
  .await
392
  .map_err(|e| e.to_string())?;
393
- Ok(format!("Loading model '{}'...", p.id))
 
 
 
 
 
 
 
 
 
 
 
 
394
  }
395
 
396
  #[tool(description = "Unload the current LLM model from memory")]
397
  async fn llm_offload(&self) -> Result<String, String> {
398
  let res = self.resources()?;
399
- operations::llm_offload(res)
400
- .await
401
- .map_err(|e| e.to_string())?;
402
  Ok("LLM offloaded".to_string())
403
  }
404
 
@@ -410,18 +421,16 @@ impl KoharuMcp {
410
  Parameters(p): Parameters<LlmGenerateParams>,
411
  ) -> Result<String, String> {
412
  let res = self.resources()?;
413
- operations::llm_generate(
414
  res.clone(),
415
- LlmGeneratePayload {
416
- index: p.index,
417
- text_block_index: p.text_block_index,
418
- language: p.language,
419
- },
420
  )
421
  .await
422
  .map_err(|e| e.to_string())?;
423
 
424
- let doc = operations::get_document(res, IndexPayload { index: p.index })
425
  .await
426
  .map_err(|e| e.to_string())?;
427
 
@@ -446,21 +455,21 @@ impl KoharuMcp {
446
  .transpose()
447
  .map_err(|e: anyhow::Error| e.to_string())?;
448
 
449
- operations::process(
 
450
  res,
451
  ProcessRequest {
452
- index: p.index,
453
- llm_model_id: p.llm_model_id,
454
- llm_api_key: None,
455
- llm_base_url: None,
456
- llm_temperature: None,
457
- llm_max_tokens: None,
458
- llm_custom_system_prompt: None,
459
  language: p.language,
460
  shader_effect: effect,
461
  shader_stroke: None,
462
  font_family: p.font_family,
463
  },
 
464
  )
465
  .await
466
  .map_err(|e| e.to_string())?;
@@ -476,9 +485,25 @@ impl KoharuMcp {
476
  Parameters(p): Parameters<UpdateTextBlockPayload>,
477
  ) -> Result<String, String> {
478
  let res = self.resources()?;
479
- let info = operations::update_text_block(res, p)
480
- .await
481
- .map_err(|e| e.to_string())?;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  serde_json::to_string_pretty(&info).map_err(|e| e.to_string())
483
  }
484
 
@@ -488,7 +513,8 @@ impl KoharuMcp {
488
  Parameters(p): Parameters<AddTextBlockPayload>,
489
  ) -> Result<String, String> {
490
  let res = self.resources()?;
491
- let index = operations::add_text_block(res, p)
 
492
  .await
493
  .map_err(|e| e.to_string())?;
494
  Ok(format!("Added text block at index {index}"))
@@ -500,7 +526,8 @@ impl KoharuMcp {
500
  Parameters(p): Parameters<RemoveTextBlockPayload>,
501
  ) -> Result<String, String> {
502
  let res = self.resources()?;
503
- let remaining = operations::remove_text_block(res, p)
 
504
  .await
505
  .map_err(|e| e.to_string())?;
506
  Ok(format!("Removed text block. {remaining} remaining."))
@@ -512,7 +539,8 @@ impl KoharuMcp {
512
  Parameters(p): Parameters<MaskMorphPayload>,
513
  ) -> Result<String, String> {
514
  let res = self.resources()?;
515
- operations::dilate_mask(res, p)
 
516
  .await
517
  .map_err(|e| e.to_string())?;
518
  Ok("Dilated mask".to_string())
@@ -524,7 +552,8 @@ impl KoharuMcp {
524
  Parameters(p): Parameters<MaskMorphPayload>,
525
  ) -> Result<String, String> {
526
  let res = self.resources()?;
527
- operations::erode_mask(res, p)
 
528
  .await
529
  .map_err(|e| e.to_string())?;
530
  Ok("Eroded mask".to_string())
@@ -536,16 +565,14 @@ impl KoharuMcp {
536
  Parameters(p): Parameters<InpaintRegionParams>,
537
  ) -> Result<String, String> {
538
  let res = self.resources()?;
539
- operations::inpaint_partial(
540
  res,
541
- InpaintPartialPayload {
542
- index: p.index,
543
- region: InpaintRegion {
544
- x: p.x,
545
- y: p.y,
546
- width: p.width,
547
- height: p.height,
548
- },
549
  },
550
  )
551
  .await
 
1
  mod helpers;
2
 
3
+ use std::collections::HashMap;
4
  use std::path::PathBuf;
5
+ use std::sync::Arc;
6
 
7
  use image::DynamicImage;
8
  use rmcp::handler::server::router::tool::ToolRouter;
 
12
  ToolsCapability,
13
  };
14
  use rmcp::{ServerHandler, tool, tool_handler, tool_router};
15
+ use tokio::sync::RwLock;
16
 
17
+ use koharu_app::{AppResources, edit, io, llm, ml, pipeline};
 
18
  use koharu_core::commands::{
19
+ AddTextBlockPayload, DocumentIdParam, ExportDocumentParams, FileEntry, InpaintRegionParams,
20
+ LlmGenerateParams, LlmLoadParams, MaskMorphPayload, OpenDocumentsParams, OpenDocumentsPayload,
21
+ ProcessParams, ProcessRequest, RemoveTextBlockPayload, RenderParams, UpdateTextBlockPayload,
22
+ ViewImageParams, ViewTextBlockParams,
 
23
  };
24
  use koharu_core::views::to_doc_info;
25
+ use koharu_core::{LlmLoadRequest, PipelineLlmRequest, Region};
26
 
27
  use crate::shared::SharedState;
28
 
 
47
  .get()
48
  .ok_or_else(|| "Resources not initialized yet".to_string())
49
  }
50
+
51
+ async fn document_id_for_index(
52
+ &self,
53
+ resources: &AppResources,
54
+ index: usize,
55
+ ) -> Result<String, String> {
56
+ let entries = resources
57
+ .cache
58
+ .list_documents()
59
+ .map_err(|e| e.to_string())?;
60
+ entries
61
+ .get(index)
62
+ .map(|page| page.id.clone())
63
+ .ok_or_else(|| format!("Document index {index} not found"))
64
+ }
65
  }
66
 
67
  #[tool_router]
 
69
  #[tool(description = "Get the application version")]
70
  async fn app_version(&self) -> Result<String, String> {
71
  let res = self.resources()?;
72
+ io::app_version(res).await.map_err(|e| e.to_string())
 
 
73
  }
74
 
75
  #[tool(description = "Get device information (ML device, GPU info)")]
76
  async fn device(&self) -> Result<String, String> {
77
  let res = self.resources()?;
78
+ let info = io::device(res).await.map_err(|e| e.to_string())?;
79
  serde_json::to_string_pretty(&info).map_err(|e| e.to_string())
80
  }
81
 
82
  #[tool(description = "Get the number of loaded documents")]
83
  async fn get_documents(&self) -> Result<String, String> {
84
  let res = self.resources()?;
85
+ let count = io::get_documents(res).await.map_err(|e| e.to_string())?;
 
 
86
  Ok(format!("{count} document(s) loaded"))
87
  }
88
 
 
91
  )]
92
  async fn get_document(
93
  &self,
94
+ Parameters(p): Parameters<DocumentIdParam>,
95
  ) -> Result<String, String> {
96
  let res = self.resources()?;
97
+ let doc = io::get_document(res, &p.document_id)
98
  .await
99
  .map_err(|e| e.to_string())?;
100
  let info = to_doc_info(&doc);
 
104
  #[tool(description = "List available font families for text rendering")]
105
  async fn list_font_families(&self) -> Result<String, String> {
106
  let res = self.resources()?;
107
+ let fonts = ml::list_font_families(res)
108
  .await
109
  .map_err(|e| e.to_string())?;
110
  Ok(fonts
 
114
  .join(", "))
115
  }
116
 
117
+ #[tool(
118
+ description = "List the grouped LLM catalog for local models and configured remote providers"
119
+ )]
120
  async fn llm_list(&self) -> Result<String, String> {
121
  let res = self.resources()?;
122
+ let catalog = llm::llm_catalog(res, None)
123
+ .await
124
+ .map_err(|e| e.to_string())?;
125
+ serde_json::to_string_pretty(&catalog).map_err(|e| e.to_string())
 
 
 
 
 
 
126
  }
127
 
128
  #[tool(description = "Check if an LLM model is loaded and ready")]
129
  async fn llm_ready(&self) -> Result<String, String> {
130
  let res = self.resources()?;
131
+ let ready = llm::llm_ready(res).await.map_err(|e| e.to_string())?;
 
 
132
  Ok(if ready {
133
  "LLM is ready".to_string()
134
  } else {
 
146
  let res = self
147
  .resources()
148
  .map_err(|e| ErrorData::internal_error(e, None))?;
149
+ let doc = io::get_document(res, &p.document_id)
150
  .await
151
  .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
152
 
 
193
  let res = self
194
  .resources()
195
  .map_err(|e| ErrorData::internal_error(e, None))?;
196
+ let doc = io::get_document(res, &p.document_id)
197
  .await
198
  .map_err(|e| ErrorData::internal_error(e.to_string(), None))?;
199
 
 
271
  })
272
  .collect();
273
 
274
+ let count = io::open_documents(res.clone(), OpenDocumentsPayload { files: files? })
275
  .await
276
  .map_err(|e| e.to_string())?;
277
 
278
+ let entries = res.cache.list_documents().map_err(|e| e.to_string())?;
279
+ let names: Vec<&str> = entries.iter().map(|d| d.name.as_str()).collect();
280
  Ok(format!("Loaded {count} document(s): {}", names.join(", ")))
281
  }
282
 
 
286
  Parameters(p): Parameters<ExportDocumentParams>,
287
  ) -> Result<String, String> {
288
  let res = self.resources()?;
289
+ let result = io::export_document(res, &p.document_id)
290
  .await
291
  .map_err(|e| e.to_string())?;
292
 
 
299
  #[tool(
300
  description = "Detect text blocks and fonts in a manga page. Finds speech bubbles, text regions, and predicts font properties."
301
  )]
302
+ async fn detect(&self, Parameters(p): Parameters<DocumentIdParam>) -> Result<String, String> {
303
  let res = self.resources()?;
304
+ ml::detect(res.clone(), &p.document_id)
305
  .await
306
  .map_err(|e| e.to_string())?;
307
 
308
+ let doc = io::get_document(res, &p.document_id)
309
  .await
310
  .map_err(|e| e.to_string())?;
311
 
 
322
  #[tool(
323
  description = "Run OCR (optical character recognition) on detected text blocks to extract the original text."
324
  )]
325
+ async fn ocr(&self, Parameters(p): Parameters<DocumentIdParam>) -> Result<String, String> {
326
  let res = self.resources()?;
327
+ ml::ocr(res.clone(), &p.document_id)
328
  .await
329
  .map_err(|e| e.to_string())?;
330
 
331
+ let doc = io::get_document(res, &p.document_id)
332
  .await
333
  .map_err(|e| e.to_string())?;
334
 
 
343
  #[tool(
344
  description = "Inpaint (remove) text from the image using the detection mask. Fills text regions with surrounding background."
345
  )]
346
+ async fn inpaint(&self, Parameters(p): Parameters<DocumentIdParam>) -> Result<String, String> {
347
  let res = self.resources()?;
348
+ ml::inpaint(res, &p.document_id)
349
  .await
350
  .map_err(|e| e.to_string())?;
351
  Ok("Inpainting complete".to_string())
 
363
  .transpose()
364
  .map_err(|e: anyhow::Error| e.to_string())?;
365
 
366
+ ml::render(
367
  res,
368
+ &p.document_id,
369
+ p.text_block_index,
370
+ effect,
371
+ None,
372
+ p.font_family.as_deref(),
 
 
373
  )
374
  .await
375
  .map_err(|e| e.to_string())?;
 
382
  )]
383
  async fn llm_load(&self, Parameters(p): Parameters<LlmLoadParams>) -> Result<String, String> {
384
  let res = self.resources()?;
385
+ llm::llm_load(
386
  res,
387
+ LlmLoadRequest {
388
+ target: p.target.clone(),
389
+ options: p.options.clone(),
 
 
 
 
390
  },
391
  )
392
  .await
393
  .map_err(|e| e.to_string())?;
394
+ Ok(format!(
395
+ "Loading {} target '{}'{}",
396
+ match p.target.kind {
397
+ koharu_core::LlmTargetKind::Local => "local",
398
+ koharu_core::LlmTargetKind::Provider => "provider",
399
+ },
400
+ p.target.model_id,
401
+ p.target
402
+ .provider_id
403
+ .as_deref()
404
+ .map(|provider| format!(" from {provider}"))
405
+ .unwrap_or_default()
406
+ ))
407
  }
408
 
409
  #[tool(description = "Unload the current LLM model from memory")]
410
  async fn llm_offload(&self) -> Result<String, String> {
411
  let res = self.resources()?;
412
+ llm::llm_offload(res).await.map_err(|e| e.to_string())?;
 
 
413
  Ok("LLM offloaded".to_string())
414
  }
415
 
 
421
  Parameters(p): Parameters<LlmGenerateParams>,
422
  ) -> Result<String, String> {
423
  let res = self.resources()?;
424
+ llm::llm_generate(
425
  res.clone(),
426
+ &p.document_id,
427
+ p.text_block_index,
428
+ p.language.as_deref(),
 
 
429
  )
430
  .await
431
  .map_err(|e| e.to_string())?;
432
 
433
+ let doc = io::get_document(res, &p.document_id)
434
  .await
435
  .map_err(|e| e.to_string())?;
436
 
 
455
  .transpose()
456
  .map_err(|e: anyhow::Error| e.to_string())?;
457
 
458
+ let jobs = Arc::new(RwLock::new(HashMap::new()));
459
+ pipeline::process(
460
  res,
461
  ProcessRequest {
462
+ document_id: p.document_id,
463
+ llm: p.llm_target.map(|target| PipelineLlmRequest {
464
+ target,
465
+ options: None,
466
+ }),
 
 
467
  language: p.language,
468
  shader_effect: effect,
469
  shader_stroke: None,
470
  font_family: p.font_family,
471
  },
472
+ jobs,
473
  )
474
  .await
475
  .map_err(|e| e.to_string())?;
 
485
  Parameters(p): Parameters<UpdateTextBlockPayload>,
486
  ) -> Result<String, String> {
487
  let res = self.resources()?;
488
+ let document_id = self.document_id_for_index(&res, p.index).await?;
489
+ let info = edit::update_text_block(
490
+ res,
491
+ &document_id,
492
+ edit::UpdateTextBlockArgs {
493
+ text_block_index: p.text_block_index,
494
+ translation: p.translation,
495
+ x: p.x,
496
+ y: p.y,
497
+ width: p.width,
498
+ height: p.height,
499
+ font_families: p.font_families,
500
+ font_size: p.font_size,
501
+ color: p.color,
502
+ shader_effect: p.shader_effect,
503
+ },
504
+ )
505
+ .await
506
+ .map_err(|e| e.to_string())?;
507
  serde_json::to_string_pretty(&info).map_err(|e| e.to_string())
508
  }
509
 
 
513
  Parameters(p): Parameters<AddTextBlockPayload>,
514
  ) -> Result<String, String> {
515
  let res = self.resources()?;
516
+ let document_id = self.document_id_for_index(&res, p.index).await?;
517
+ let index = edit::add_text_block(res, &document_id, p.x, p.y, p.width, p.height)
518
  .await
519
  .map_err(|e| e.to_string())?;
520
  Ok(format!("Added text block at index {index}"))
 
526
  Parameters(p): Parameters<RemoveTextBlockPayload>,
527
  ) -> Result<String, String> {
528
  let res = self.resources()?;
529
+ let document_id = self.document_id_for_index(&res, p.index).await?;
530
+ let remaining = edit::remove_text_block(res, &document_id, p.text_block_index)
531
  .await
532
  .map_err(|e| e.to_string())?;
533
  Ok(format!("Removed text block. {remaining} remaining."))
 
539
  Parameters(p): Parameters<MaskMorphPayload>,
540
  ) -> Result<String, String> {
541
  let res = self.resources()?;
542
+ let document_id = self.document_id_for_index(&res, p.index).await?;
543
+ edit::dilate_mask(res, &document_id, p.radius)
544
  .await
545
  .map_err(|e| e.to_string())?;
546
  Ok("Dilated mask".to_string())
 
552
  Parameters(p): Parameters<MaskMorphPayload>,
553
  ) -> Result<String, String> {
554
  let res = self.resources()?;
555
+ let document_id = self.document_id_for_index(&res, p.index).await?;
556
+ edit::erode_mask(res, &document_id, p.radius)
557
  .await
558
  .map_err(|e| e.to_string())?;
559
  Ok("Eroded mask".to_string())
 
565
  Parameters(p): Parameters<InpaintRegionParams>,
566
  ) -> Result<String, String> {
567
  let res = self.resources()?;
568
+ edit::inpaint_partial(
569
  res,
570
+ &p.document_id,
571
+ Region {
572
+ x: p.x,
573
+ y: p.y,
574
+ width: p.width,
575
+ height: p.height,
 
 
576
  },
577
  )
578
  .await
koharu-rpc/src/shared.rs CHANGED
@@ -1,20 +1,8 @@
1
- use std::future::Future;
2
- use std::pin::Pin;
3
  use std::sync::Arc;
4
 
5
  use koharu_app::AppResources;
6
- use koharu_core::BootstrapConfig;
7
  use koharu_runtime::RuntimeManager;
8
- use tokio::sync::{OnceCell, watch};
9
-
10
- pub type InitializeFuture = Pin<Box<dyn Future<Output = anyhow::Result<()>> + Send + 'static>>;
11
-
12
- #[derive(Clone)]
13
- pub struct BootstrapHooks {
14
- pub get_config: Arc<dyn Fn() -> anyhow::Result<BootstrapConfig> + Send + Sync>,
15
- pub put_config: Arc<dyn Fn(BootstrapConfig) -> anyhow::Result<BootstrapConfig> + Send + Sync>,
16
- pub initialize: Arc<dyn Fn() -> InitializeFuture + Send + Sync>,
17
- }
18
 
19
  #[derive(Clone)]
20
  pub struct SharedState {
@@ -23,22 +11,13 @@ pub struct SharedState {
23
 
24
  struct Inner {
25
  resources: Arc<OnceCell<AppResources>>,
26
- runtime: watch::Receiver<RuntimeManager>,
27
- bootstrap: BootstrapHooks,
28
  }
29
 
30
  impl SharedState {
31
- pub fn new(
32
- resources: Arc<OnceCell<AppResources>>,
33
- runtime: watch::Receiver<RuntimeManager>,
34
- bootstrap: BootstrapHooks,
35
- ) -> Self {
36
  Self {
37
- inner: Arc::new(Inner {
38
- resources,
39
- runtime,
40
- bootstrap,
41
- }),
42
  }
43
  }
44
 
@@ -46,41 +25,7 @@ impl SharedState {
46
  self.inner.resources.get().cloned()
47
  }
48
 
49
- pub fn resources_cell(&self) -> Arc<OnceCell<AppResources>> {
50
- Arc::clone(&self.inner.resources)
51
- }
52
-
53
  pub fn runtime(&self) -> RuntimeManager {
54
- self.inner.runtime.borrow().clone()
55
- }
56
-
57
- pub fn subscribe_runtime(&self) -> watch::Receiver<RuntimeManager> {
58
  self.inner.runtime.clone()
59
  }
60
-
61
- pub async fn get_or_try_init<F, Fut>(&self, init: F) -> anyhow::Result<AppResources>
62
- where
63
- F: FnOnce() -> Fut,
64
- Fut: Future<Output = anyhow::Result<AppResources>>,
65
- {
66
- Ok(self.inner.resources.get_or_try_init(init).await?.clone())
67
- }
68
-
69
- pub fn get_config(&self) -> anyhow::Result<BootstrapConfig> {
70
- (self.inner.bootstrap.get_config)()
71
- }
72
-
73
- pub fn put_config(&self, config: BootstrapConfig) -> anyhow::Result<BootstrapConfig> {
74
- (self.inner.bootstrap.put_config)(config)
75
- }
76
-
77
- pub async fn initialize(&self) -> anyhow::Result<()> {
78
- (self.inner.bootstrap.initialize)().await
79
- }
80
- }
81
-
82
- pub fn get_resources(shared: &SharedState) -> anyhow::Result<AppResources> {
83
- shared
84
- .get()
85
- .ok_or_else(|| anyhow::anyhow!("Resources not initialized"))
86
  }
 
 
 
1
  use std::sync::Arc;
2
 
3
  use koharu_app::AppResources;
 
4
  use koharu_runtime::RuntimeManager;
5
+ use tokio::sync::OnceCell;
 
 
 
 
 
 
 
 
 
6
 
7
  #[derive(Clone)]
8
  pub struct SharedState {
 
11
 
12
  struct Inner {
13
  resources: Arc<OnceCell<AppResources>>,
14
+ runtime: RuntimeManager,
 
15
  }
16
 
17
  impl SharedState {
18
+ pub fn new(resources: Arc<OnceCell<AppResources>>, runtime: RuntimeManager) -> Self {
 
 
 
 
19
  Self {
20
+ inner: Arc::new(Inner { resources, runtime }),
 
 
 
 
21
  }
22
  }
23
 
 
25
  self.inner.resources.get().cloned()
26
  }
27
 
 
 
 
 
28
  pub fn runtime(&self) -> RuntimeManager {
 
 
 
 
29
  self.inner.runtime.clone()
30
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  }
koharu-rpc/src/tracker.rs CHANGED
@@ -1,10 +1,7 @@
1
  use std::{collections::HashMap, sync::Arc};
2
 
3
  use koharu_app::pipeline;
4
- use koharu_core::{
5
- DownloadProgress, DownloadState, DownloadStatus, JobState, JobStatus, PipelineProgress,
6
- PipelineStatus, PipelineStep, TransferStatus,
7
- };
8
  use tokio::sync::RwLock;
9
 
10
  use crate::shared::SharedState;
@@ -16,23 +13,27 @@ pub struct Tracker {
16
  }
17
 
18
  struct Inner {
19
- jobs: RwLock<HashMap<String, JobState>>,
20
  downloads: RwLock<HashMap<String, DownloadState>>,
21
  }
22
 
23
  impl Tracker {
24
  pub fn new(shared: &SharedState) -> Self {
 
25
  let inner = Arc::new(Inner {
26
- jobs: RwLock::new(HashMap::new()),
27
  downloads: RwLock::new(HashMap::new()),
28
  });
29
 
30
- spawn_pipeline_listener(inner.clone());
31
  spawn_download_listener(inner.clone(), shared.clone());
32
 
33
  Self { inner }
34
  }
35
 
 
 
 
 
36
  pub async fn list_jobs(&self) -> Vec<JobState> {
37
  let mut jobs: Vec<_> = self.inner.jobs.read().await.values().cloned().collect();
38
  jobs.sort_by(|a, b| a.id.cmp(&b.id));
@@ -48,19 +49,27 @@ impl Tracker {
48
  }
49
 
50
  pub async fn list_downloads(&self) -> Vec<DownloadState> {
51
- let mut downloads: Vec<_> = self.inner.downloads.read().await.values().cloned().collect();
 
 
 
 
 
 
 
52
  downloads.sort_by(|a, b| a.filename.cmp(&b.filename));
53
  downloads
54
  }
55
  }
56
 
57
- fn spawn_pipeline_listener(inner: Arc<Inner>) {
58
- let mut rx = pipeline::subscribe();
59
  tokio::spawn(async move {
 
 
60
  loop {
61
- match rx.recv().await {
62
  Ok(progress) => {
63
- update_job_state(&inner, pipeline_job_state(progress)).await;
64
  }
65
  Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
66
  Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
@@ -69,36 +78,8 @@ fn spawn_pipeline_listener(inner: Arc<Inner>) {
69
  });
70
  }
71
 
72
- fn spawn_download_listener(inner: Arc<Inner>, shared: SharedState) {
73
- tokio::spawn(async move {
74
- let mut runtime_rx = shared.subscribe_runtime();
75
- let mut download_rx = runtime_rx.borrow().clone().subscribe_downloads();
76
-
77
- loop {
78
- tokio::select! {
79
- changed = runtime_rx.changed() => {
80
- if changed.is_err() {
81
- break;
82
- }
83
- inner.downloads.write().await.clear();
84
- download_rx = runtime_rx.borrow().clone().subscribe_downloads();
85
- }
86
- received = download_rx.recv() => match received {
87
- Ok(progress) => {
88
- update_download_state(&inner, download_state(progress)).await;
89
- }
90
- Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
91
- Err(tokio::sync::broadcast::error::RecvError::Closed) => {
92
- download_rx = runtime_rx.borrow().clone().subscribe_downloads();
93
- }
94
- }
95
- }
96
- }
97
- });
98
- }
99
-
100
  async fn update_job_state(inner: &Arc<Inner>, job: JobState) {
101
- let terminal = !matches!(job.status, JobStatus::Running);
102
  let mut jobs = inner.jobs.write().await;
103
  if terminal {
104
  jobs.remove(&job.id);
@@ -120,32 +101,6 @@ async fn update_download_state(inner: &Arc<Inner>, download: DownloadState) {
120
  }
121
  }
122
 
123
- fn pipeline_job_state(progress: PipelineProgress) -> JobState {
124
- let (status, error) = match progress.status {
125
- PipelineStatus::Running => (JobStatus::Running, None),
126
- PipelineStatus::Completed => (JobStatus::Completed, None),
127
- PipelineStatus::Cancelled => (JobStatus::Cancelled, None),
128
- PipelineStatus::Failed(message) => (JobStatus::Failed, Some(message)),
129
- };
130
-
131
- JobState {
132
- id: progress.job_id,
133
- kind: "pipeline".to_string(),
134
- status,
135
- step: progress.step.map(pipeline_step_name),
136
- current_document: progress.current_document,
137
- total_documents: progress.total_documents,
138
- current_step_index: progress.current_step_index,
139
- total_steps: progress.total_steps,
140
- overall_percent: progress.overall_percent,
141
- error,
142
- }
143
- }
144
-
145
- fn pipeline_step_name(step: PipelineStep) -> String {
146
- step.to_string()
147
- }
148
-
149
  fn download_state(progress: DownloadProgress) -> DownloadState {
150
  let (status, error) = match progress.status {
151
  DownloadStatus::Started => (TransferStatus::Started, None),
@@ -166,28 +121,7 @@ fn download_state(progress: DownloadProgress) -> DownloadState {
166
 
167
  #[cfg(test)]
168
  mod tests {
169
- use koharu_core::{PipelineStatus, PipelineStep};
170
-
171
- use super::{TransferStatus, download_state, pipeline_job_state};
172
-
173
- #[test]
174
- fn pipeline_progress_maps_to_job_state() {
175
- let state = pipeline_job_state(koharu_core::PipelineProgress {
176
- job_id: "job-1".to_string(),
177
- status: PipelineStatus::Failed("boom".to_string()),
178
- step: Some(PipelineStep::Render),
179
- current_document: 1,
180
- total_documents: 2,
181
- current_step_index: 4,
182
- total_steps: 5,
183
- overall_percent: 80,
184
- });
185
-
186
- assert_eq!(state.id, "job-1");
187
- assert_eq!(state.kind, "pipeline");
188
- assert_eq!(state.step.as_deref(), Some("render"));
189
- assert_eq!(state.error.as_deref(), Some("boom"));
190
- }
191
 
192
  #[test]
193
  fn download_progress_maps_to_download_state() {
 
1
  use std::{collections::HashMap, sync::Arc};
2
 
3
  use koharu_app::pipeline;
4
+ use koharu_core::{DownloadProgress, DownloadState, DownloadStatus, JobState, TransferStatus};
 
 
 
5
  use tokio::sync::RwLock;
6
 
7
  use crate::shared::SharedState;
 
13
  }
14
 
15
  struct Inner {
16
+ jobs: pipeline::Jobs,
17
  downloads: RwLock<HashMap<String, DownloadState>>,
18
  }
19
 
20
  impl Tracker {
21
  pub fn new(shared: &SharedState) -> Self {
22
+ let jobs = Arc::new(RwLock::new(HashMap::new()));
23
  let inner = Arc::new(Inner {
24
+ jobs: jobs.clone(),
25
  downloads: RwLock::new(HashMap::new()),
26
  });
27
 
 
28
  spawn_download_listener(inner.clone(), shared.clone());
29
 
30
  Self { inner }
31
  }
32
 
33
+ pub fn jobs(&self) -> pipeline::Jobs {
34
+ self.inner.jobs.clone()
35
+ }
36
+
37
  pub async fn list_jobs(&self) -> Vec<JobState> {
38
  let mut jobs: Vec<_> = self.inner.jobs.read().await.values().cloned().collect();
39
  jobs.sort_by(|a, b| a.id.cmp(&b.id));
 
49
  }
50
 
51
  pub async fn list_downloads(&self) -> Vec<DownloadState> {
52
+ let mut downloads: Vec<_> = self
53
+ .inner
54
+ .downloads
55
+ .read()
56
+ .await
57
+ .values()
58
+ .cloned()
59
+ .collect();
60
  downloads.sort_by(|a, b| a.filename.cmp(&b.filename));
61
  downloads
62
  }
63
  }
64
 
65
+ fn spawn_download_listener(inner: Arc<Inner>, shared: SharedState) {
 
66
  tokio::spawn(async move {
67
+ let mut download_rx = shared.runtime().subscribe_downloads();
68
+
69
  loop {
70
+ match download_rx.recv().await {
71
  Ok(progress) => {
72
+ update_download_state(&inner, download_state(progress)).await;
73
  }
74
  Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
75
  Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
 
78
  });
79
  }
80
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  async fn update_job_state(inner: &Arc<Inner>, job: JobState) {
82
+ let terminal = !matches!(job.status, koharu_core::JobStatus::Running);
83
  let mut jobs = inner.jobs.write().await;
84
  if terminal {
85
  jobs.remove(&job.id);
 
101
  }
102
  }
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  fn download_state(progress: DownloadProgress) -> DownloadState {
105
  let (status, error) = match progress.status {
106
  DownloadStatus::Started => (TransferStatus::Started, None),
 
121
 
122
  #[cfg(test)]
123
  mod tests {
124
+ use super::{TransferStatus, download_state};
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  #[test]
127
  fn download_progress_maps_to_download_state() {
koharu-runtime/Cargo.toml CHANGED
@@ -14,6 +14,7 @@ publish.workspace = true
14
  [dependencies]
15
  koharu-core = { workspace = true }
16
  anyhow = { workspace = true }
 
17
  dirs = { workspace = true }
18
  futures = { workspace = true }
19
  hf-hub = { workspace = true }
 
14
  [dependencies]
15
  koharu-core = { workspace = true }
16
  anyhow = { workspace = true }
17
+ camino = { workspace = true }
18
  dirs = { workspace = true }
19
  futures = { workspace = true }
20
  hf-hub = { workspace = true }
koharu-runtime/src/archive.rs CHANGED
@@ -24,7 +24,7 @@ pub(crate) enum ExtractPolicy<'a> {
24
  pub(crate) async fn fetch(runtime: &Runtime, url: &str, file_name: &str) -> Result<PathBuf> {
25
  runtime
26
  .artifacts()
27
- .cached_download(url, file_name, runtime.layout().downloads_root())
28
  .await
29
  .with_context(|| format!("failed to download `{url}`"))
30
  }
 
24
  pub(crate) async fn fetch(runtime: &Runtime, url: &str, file_name: &str) -> Result<PathBuf> {
25
  runtime
26
  .artifacts()
27
+ .cached_download(url, file_name, &runtime.layout().downloads_root)
28
  .await
29
  .with_context(|| format!("failed to download `{url}`"))
30
  }
koharu-runtime/src/artifacts.rs CHANGED
@@ -57,7 +57,7 @@ impl ArtifactStore {
57
  }
58
 
59
  pub fn huggingface_path(&self, repo: &str, filename: &str) -> Result<PathBuf> {
60
- huggingface_path(self.layout.huggingface_root(), repo, filename)
61
  }
62
 
63
  pub(crate) async fn cached_download(
 
57
  }
58
 
59
  pub fn huggingface_path(&self, repo: &str, filename: &str) -> Result<PathBuf> {
60
+ huggingface_path(&self.layout.huggingface_root, repo, filename)
61
  }
62
 
63
  pub(crate) async fn cached_download(
koharu-runtime/src/cuda.rs CHANGED
@@ -207,7 +207,7 @@ fn driver_library_available() -> bool {
207
  }
208
 
209
  fn install_dir(runtime: &Runtime) -> std::path::PathBuf {
210
- runtime.layout().runtime_package_dir("cuda")
211
  }
212
 
213
  fn platform_tags() -> Result<&'static [&'static str]> {
 
207
  }
208
 
209
  fn install_dir(runtime: &Runtime) -> std::path::PathBuf {
210
+ runtime.layout().runtime_root.join("cuda")
211
  }
212
 
213
  fn platform_tags() -> Result<&'static [&'static str]> {
koharu-runtime/src/http.rs CHANGED
@@ -5,8 +5,6 @@ use anyhow::Result;
5
  use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
6
  use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
7
 
8
- use crate::Settings;
9
-
10
  const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
11
 
12
  #[derive(Clone)]
@@ -15,10 +13,10 @@ pub(crate) struct HttpStack {
15
  }
16
 
17
  impl HttpStack {
18
- pub(crate) fn new(settings: &Settings) -> Result<Self> {
19
  Ok(Self {
20
  client: Arc::new(
21
- ClientBuilder::new(build_base_client(settings)?)
22
  .with(RetryTransientMiddleware::new_with_policy(retry_policy()))
23
  .build(),
24
  ),
@@ -30,16 +28,12 @@ impl HttpStack {
30
  }
31
  }
32
 
33
- fn build_base_client(settings: &Settings) -> Result<reqwest::Client> {
34
- let mut builder = reqwest::Client::builder()
35
  .user_agent(USER_AGENT)
36
  .connect_timeout(Duration::from_secs(20))
37
  .read_timeout(Duration::from_secs(60));
38
 
39
- if let Some(proxy) = settings.http_proxy() {
40
- builder = builder.proxy(reqwest::Proxy::all(proxy.as_str())?);
41
- }
42
-
43
  Ok(builder.build()?)
44
  }
45
 
 
5
  use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
6
  use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
7
 
 
 
8
  const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
9
 
10
  #[derive(Clone)]
 
13
  }
14
 
15
  impl HttpStack {
16
+ pub(crate) fn new() -> Result<Self> {
17
  Ok(Self {
18
  client: Arc::new(
19
+ ClientBuilder::new(build_base_client()?)
20
  .with(RetryTransientMiddleware::new_with_policy(retry_policy()))
21
  .build(),
22
  ),
 
28
  }
29
  }
30
 
31
+ fn build_base_client() -> Result<reqwest::Client> {
32
+ let builder = reqwest::Client::builder()
33
  .user_agent(USER_AGENT)
34
  .connect_timeout(Duration::from_secs(20))
35
  .read_timeout(Duration::from_secs(60));
36
 
 
 
 
 
37
  Ok(builder.build()?)
38
  }
39