Mayo commited on
Commit ·
c1298ea
1
Parent(s): fe48401
refactor: rewrite backend storage, config, and runtime
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Cargo.lock +26 -2
- Cargo.toml +3 -0
- bun.lock +3 -0
- docs/en-US/reference/http-api.md +15 -15
- docs/en-US/reference/mcp-tools.md +2 -2
- docs/ja-JP/reference/http-api.md +14 -14
- docs/ja-JP/reference/mcp-tools.md +2 -2
- docs/zh-CN/reference/http-api.md +14 -14
- docs/zh-CN/reference/mcp-tools.md +2 -2
- koharu-app/Cargo.toml +4 -2
- koharu-app/src/blob_store.rs +46 -0
- koharu-app/src/config.rs +317 -128
- koharu-app/src/{ops/edit.rs → edit.rs} +191 -483
- koharu-app/src/{ops/core.rs → io.rs} +49 -48
- koharu-app/src/lib.rs +9 -5
- koharu-app/src/llm.rs +385 -75
- koharu-app/src/manifest.rs +104 -0
- koharu-app/src/ml.rs +61 -1
- koharu-app/src/operations.rs +0 -1
- koharu-app/src/ops/llm.rs +0 -191
- koharu-app/src/ops/mod.rs +0 -13
- koharu-app/src/ops/process.rs +0 -56
- koharu-app/src/ops/vision.rs +0 -72
- koharu-app/src/page_cache.rs +289 -0
- koharu-app/src/pipeline.rs +171 -121
- koharu-app/src/state_tx.rs +0 -224
- koharu-app/src/{ops/utils.rs → utils.rs} +5 -10
- koharu-core/src/commands.rs +68 -235
- koharu-core/src/lib.rs +6 -32
- koharu-core/src/protocol.rs +113 -69
- koharu-llm/src/api.rs +0 -60
- koharu-llm/src/lib.rs +0 -1
- koharu-llm/src/providers/chat_completions.rs +80 -0
- koharu-llm/src/providers/deepseek.rs +16 -50
- koharu-llm/src/providers/mod.rs +227 -41
- koharu-llm/src/providers/openai.rs +16 -48
- koharu-llm/src/providers/openai_compatible.rs +22 -84
- koharu-llm/tests/llm.rs +12 -7
- koharu-psd/examples/full_fixture.rs +0 -1
- koharu-psd/tests/export.rs +0 -1
- koharu-rpc/src/api.rs +291 -524
- koharu-rpc/src/lib.rs +1 -1
- koharu-rpc/src/mcp/mod.rs +123 -96
- koharu-rpc/src/shared.rs +4 -59
- koharu-rpc/src/tracker.rs +23 -89
- koharu-runtime/Cargo.toml +1 -0
- koharu-runtime/src/archive.rs +1 -1
- koharu-runtime/src/artifacts.rs +1 -1
- koharu-runtime/src/cuda.rs +1 -1
- 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 |
-
"
|
| 3949 |
"open",
|
| 3950 |
"rayon",
|
| 3951 |
"reqwest-middleware",
|
| 3952 |
"rfd",
|
| 3953 |
"serde",
|
| 3954 |
-
"
|
| 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/
|
| 124 |
-
| `GET` | `/llm
|
| 125 |
-
| `
|
| 126 |
-
| `
|
| 127 |
-
| `POST` | `/llm/ping` | test an OpenAI-compatible base URL |
|
| 128 |
|
| 129 |
Useful request details:
|
| 130 |
|
| 131 |
-
- `/llm/
|
| 132 |
-
- `/llm
|
| 133 |
-
- `
|
| 134 |
|
| 135 |
-
## Provider
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 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 |
-
-
|
| 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. `
|
| 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 `
|
| 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 | `
|
| 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/
|
| 124 |
-
| `GET` | `/llm
|
| 125 |
-
| `
|
| 126 |
-
| `
|
| 127 |
-
| `POST` | `/llm/ping` | OpenAI 互換 base URL を接続確認する |
|
| 128 |
|
| 129 |
実用上のリクエスト詳細:
|
| 130 |
|
| 131 |
-
- `/llm/
|
| 132 |
-
- `/llm
|
| 133 |
-
- `
|
| 134 |
|
| 135 |
-
## プロバイダ
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 141 |
|
| 142 |
現在の組み込み provider id は次です。
|
| 143 |
|
|
@@ -157,7 +157,7 @@ import エンドポイントは、`files` フィールドを繰り返し持つ m
|
|
| 157 |
pipeline job リクエストには次を含められます。
|
| 158 |
|
| 159 |
- `documentId` を指定すると 1 ページ対象、省略すると読み込み済み全ページ対象
|
| 160 |
-
- `
|
| 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 をまとめて開始する | 任意で `
|
| 75 |
|
| 76 |
`process` は粗粒度の convenience tool です。より細かな制御や切り分けが必要なら、各段階ツールを個別に使ってください。
|
| 77 |
|
|
@@ -79,7 +79,7 @@ http://127.0.0.1:<PORT>/mcp
|
|
| 79 |
|
| 80 |
| Tool | 役割 | 主な引数 |
|
| 81 |
| --- | --- | --- |
|
| 82 |
-
| `llm_load` | 翻訳モデルを読み込む | `
|
| 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/
|
| 124 |
-
| `GET` | `/llm
|
| 125 |
-
| `
|
| 126 |
-
| `
|
| 127 |
-
| `POST` | `/llm/ping` | 测试 OpenAI 兼容 base URL |
|
| 128 |
|
| 129 |
常用请求细节:
|
| 130 |
|
| 131 |
-
- `/llm/
|
| 132 |
-
- `/llm
|
| 133 |
-
- `
|
| 134 |
|
| 135 |
-
### 提供商
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
| 141 |
|
| 142 |
当前内置 provider id 包括:
|
| 143 |
|
|
@@ -157,7 +157,7 @@ http://127.0.0.1:<PORT>/api/v1
|
|
| 157 |
管线任务请求可以包含:
|
| 158 |
|
| 159 |
- `documentId`:只处理某一页;留空时处理所有已加载页面
|
| 160 |
-
-
|
| 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 | 可选 `
|
| 75 |
|
| 76 |
`process` 是粗粒度的便捷工具。如果你想要更细的控制或更好排查问题,建议拆开使用各阶段工具。
|
| 77 |
|
|
@@ -79,7 +79,7 @@ http://127.0.0.1:<PORT>/mcp
|
|
| 79 |
|
| 80 |
| 工具 | 作用 | 关键参数 |
|
| 81 |
| --- | --- | --- |
|
| 82 |
-
| `llm_load` | 加载一个翻译模型 | `
|
| 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
|
| 7 |
-
use
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
use tempfile::NamedTempFile;
|
| 9 |
-
use url::Url;
|
| 10 |
|
| 11 |
-
const CONFIG_DIR: &str = ".koharu";
|
| 12 |
const CONFIG_FILE: &str = "config.toml";
|
|
|
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
}
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
let path = config_path()?;
|
| 28 |
if !path.exists() {
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
-
let content = fs::read_to_string(&path)
|
| 33 |
-
|
| 34 |
-
toml::from_str(&content).with_context(|| format!("failed to parse `{}`", path.display()))
|
| 35 |
}
|
| 36 |
|
| 37 |
-
pub fn save(config: &
|
| 38 |
-
let
|
| 39 |
-
|
| 40 |
-
.
|
|
|
|
|
|
|
| 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 =
|
| 45 |
-
.with_context(|| format!("failed to stage `{}`"
|
| 46 |
temp.write_all(content.as_bytes())
|
| 47 |
-
.with_context(|| format!("failed to write temp config for `{}`"
|
| 48 |
temp.flush()
|
| 49 |
-
.with_context(|| format!("failed to flush temp config for `{}`"
|
| 50 |
|
| 51 |
match temp.persist(&path) {
|
| 52 |
Ok(_) => Ok(()),
|
| 53 |
Err(err) => {
|
| 54 |
if path.exists() {
|
| 55 |
-
fs::remove_file(&path)
|
| 56 |
-
format!("failed to replace existing config `{}`"
|
| 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
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
-
pub fn
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
.as_deref()
|
| 94 |
.map(str::trim)
|
| 95 |
.filter(|value| !value.is_empty())
|
| 96 |
-
.map(
|
| 97 |
-
|
| 98 |
-
.context("invalid HTTP proxy URL")?;
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
}
|
| 105 |
|
| 106 |
#[cfg(test)]
|
| 107 |
mod tests {
|
| 108 |
-
use std::
|
| 109 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
use super::{
|
| 112 |
-
|
| 113 |
-
from_bootstrap_config, to_bootstrap_config,
|
| 114 |
};
|
| 115 |
|
| 116 |
#[test]
|
| 117 |
-
fn
|
| 118 |
-
let config =
|
| 119 |
-
|
| 120 |
-
|
| 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 |
-
|
| 131 |
-
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 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
|
| 155 |
-
let
|
| 156 |
-
|
| 157 |
-
path: "
|
| 158 |
},
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
},
|
| 165 |
})
|
| 166 |
.unwrap();
|
| 167 |
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
}
|
| 170 |
|
| 171 |
#[test]
|
| 172 |
-
fn
|
| 173 |
-
let error =
|
| 174 |
-
|
| 175 |
-
path: "
|
| 176 |
-
},
|
| 177 |
-
models: BootstrapPathConfig {
|
| 178 |
-
path: ".".to_string(),
|
| 179 |
},
|
| 180 |
-
|
| 181 |
})
|
| 182 |
-
.
|
| 183 |
|
| 184 |
-
assert!(error.to_string().contains("
|
| 185 |
}
|
| 186 |
|
| 187 |
#[test]
|
| 188 |
-
fn
|
| 189 |
-
let
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
);
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
}
|
|
|
|
| 197 |
#[test]
|
| 198 |
-
fn
|
| 199 |
-
let path =
|
| 200 |
-
assert_eq!(
|
| 201 |
-
|
| 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(¤t.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(¤t_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::
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 214 |
) -> anyhow::Result<()> {
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
}
|
| 233 |
-
}
|
| 234 |
-
Ok(())
|
| 235 |
-
},
|
| 236 |
-
)
|
| 237 |
-
.await
|
| 238 |
}
|
| 239 |
|
| 240 |
pub async fn update_text_block(
|
| 241 |
state: AppResources,
|
| 242 |
-
|
|
|
|
| 243 |
) -> anyhow::Result<TextBlockInfo> {
|
| 244 |
-
|
| 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 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 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 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
}
|
| 317 |
|
| 318 |
pub async fn add_text_block(
|
| 319 |
state: AppResources,
|
| 320 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
) -> anyhow::Result<usize> {
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
Ok(document.text_blocks.len() - 1)
|
| 338 |
-
},
|
| 339 |
-
)
|
| 340 |
-
.await
|
| 341 |
}
|
| 342 |
|
| 343 |
pub async fn remove_text_block(
|
| 344 |
state: AppResources,
|
| 345 |
-
|
|
|
|
| 346 |
) -> anyhow::Result<usize> {
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
Ok(document.text_blocks.len())
|
| 357 |
-
},
|
| 358 |
-
)
|
| 359 |
-
.await
|
| 360 |
}
|
| 361 |
|
| 362 |
-
pub async fn dilate_mask(state: AppResources,
|
| 363 |
-
if
|
| 364 |
anyhow::bail!("Radius must be 1-50");
|
| 365 |
}
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
document.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(dilated)));
|
| 380 |
-
Ok(())
|
| 381 |
-
},
|
| 382 |
-
)
|
| 383 |
-
.await
|
| 384 |
}
|
| 385 |
|
| 386 |
-
pub async fn erode_mask(state: AppResources,
|
| 387 |
-
if
|
| 388 |
anyhow::bail!("Radius must be 1-50");
|
| 389 |
}
|
| 390 |
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 413 |
) -> anyhow::Result<()> {
|
| 414 |
-
let
|
| 415 |
|
| 416 |
-
let update_image = image::load_from_memory(
|
| 417 |
-
let (doc_width, doc_height) = (
|
| 418 |
|
| 419 |
-
let mut base_mask =
|
| 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
|
| 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 |
-
|
| 470 |
-
|
| 471 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 483 |
) -> anyhow::Result<()> {
|
| 484 |
-
let
|
| 485 |
|
| 486 |
-
let (img_width, img_height) = (
|
| 487 |
-
let Some((x0, y0, width, height)) =
|
| 488 |
return Ok(());
|
| 489 |
};
|
| 490 |
|
| 491 |
-
let patch_image = image::load_from_memory(
|
| 492 |
let (patch_width, patch_height) = patch_image.dimensions();
|
| 493 |
|
| 494 |
-
if patch_width !=
|
| 495 |
anyhow::bail!(
|
| 496 |
"Brush patch size mismatch: expected {}x{}, got {}x{}",
|
| 497 |
-
|
| 498 |
-
|
| 499 |
patch_width,
|
| 500 |
patch_height
|
| 501 |
);
|
| 502 |
}
|
| 503 |
|
| 504 |
let brush_rgba = patch_image.to_rgba8();
|
| 505 |
-
let mut brush_layer =
|
| 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 |
-
|
| 518 |
-
|
| 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 |
-
|
|
|
|
| 533 |
) -> anyhow::Result<()> {
|
| 534 |
-
let
|
| 535 |
|
| 536 |
-
let mask_image =
|
| 537 |
.segment
|
| 538 |
.as_ref()
|
| 539 |
.ok_or_else(|| anyhow::anyhow!("Segment image not found"))?;
|
| 540 |
|
| 541 |
-
if
|
| 542 |
return Ok(());
|
| 543 |
}
|
| 544 |
|
| 545 |
-
let (img_width, img_height) = (
|
| 546 |
-
let x0 =
|
| 547 |
-
let y0 =
|
| 548 |
-
let x1 =
|
| 549 |
-
.region
|
| 550 |
.x
|
| 551 |
-
.saturating_add(
|
| 552 |
.min(img_width);
|
| 553 |
-
let y1 =
|
| 554 |
-
.region
|
| 555 |
.y
|
| 556 |
-
.saturating_add(
|
| 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(&
|
| 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 =
|
| 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 |
-
|
| 590 |
-
|
| 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(¤t, 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(¤t, 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,
|
| 6 |
-
ThumbnailResult,
|
| 7 |
};
|
| 8 |
use rfd::FileDialog;
|
| 9 |
|
| 10 |
-
use crate::
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 87 |
-
Ok(guard.documents.len())
|
| 88 |
}
|
| 89 |
|
| 90 |
pub async fn get_document(
|
| 91 |
state: AppResources,
|
| 92 |
-
|
| 93 |
) -> anyhow::Result<koharu_core::Document> {
|
| 94 |
-
|
| 95 |
}
|
| 96 |
|
| 97 |
pub async fn get_thumbnail(
|
| 98 |
state: AppResources,
|
| 99 |
-
|
| 100 |
) -> anyhow::Result<ThumbnailResult> {
|
| 101 |
-
let doc =
|
| 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 |
-
|
| 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
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
pub async fn add_documents(
|
| 134 |
state: AppResources,
|
| 135 |
payload: OpenDocumentsPayload,
|
| 136 |
) -> anyhow::Result<usize> {
|
| 137 |
-
|
| 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
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
| 149 |
}
|
| 150 |
|
| 151 |
-
pub async fn export_document(
|
| 152 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
export_documents_matching(
|
| 187 |
-
&
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
export_documents_matching(
|
| 202 |
-
&
|
| 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
|
| 5 |
-
pub mod ops;
|
| 6 |
pub mod pipeline;
|
| 7 |
pub mod renderer;
|
| 8 |
-
pub mod
|
| 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
|
| 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
|
|
|
|
|
|
|
|
|
|
| 4 |
use tokio::sync::{RwLock, broadcast};
|
|
|
|
| 5 |
|
| 6 |
-
use koharu_core::{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 63 |
#[strum(serialize = "ready")]
|
| 64 |
-
|
| 65 |
#[strum(serialize = "ready")]
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
model: String,
|
| 70 |
},
|
| 71 |
#[strum(serialize = "failed")]
|
| 72 |
-
Failed
|
|
|
|
|
|
|
|
|
|
| 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('&', "&")
|
| 90 |
.replace('<', "<")
|
|
@@ -453,28 +493,22 @@ impl Model {
|
|
| 453 |
self.cpu
|
| 454 |
}
|
| 455 |
|
| 456 |
-
pub async fn
|
| 457 |
&self,
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
config: koharu_llm::providers::ProviderConfig,
|
| 461 |
) -> anyhow::Result<()> {
|
| 462 |
-
|
| 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
|
|
|
|
| 473 |
{
|
| 474 |
let mut guard = self.state.write().await;
|
| 475 |
*guard = State::Loading {
|
| 476 |
-
|
| 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::
|
| 493 |
}
|
| 494 |
Err(e) => {
|
| 495 |
tracing::error!("LLM load join error: {e}");
|
| 496 |
let mut guard = state_cloned.write().await;
|
| 497 |
-
*guard = State::Failed
|
|
|
|
|
|
|
|
|
|
| 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::
|
| 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::
|
| 557 |
llm.generate(&source, &GenerateOptions::default(), target_language)
|
| 558 |
}
|
| 559 |
-
State::
|
| 560 |
-
provider
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
provider.translate(&source, target_language, &model).await
|
| 564 |
}
|
| 565 |
State::Loading { .. } => Err(anyhow::anyhow!("Model is still loading")),
|
| 566 |
-
State::Failed
|
| 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 |
-
|
| 578 |
-
source: None,
|
| 579 |
error: None,
|
| 580 |
},
|
| 581 |
-
State::Loading {
|
| 582 |
status: LlmStateStatus::Loading,
|
| 583 |
-
|
| 584 |
-
source: Some(source.clone()),
|
| 585 |
error: None,
|
| 586 |
},
|
| 587 |
-
State::
|
| 588 |
status: LlmStateStatus::Ready,
|
| 589 |
-
|
| 590 |
-
source: Some("local".to_string()),
|
| 591 |
error: None,
|
| 592 |
},
|
| 593 |
-
State::
|
| 594 |
-
provider_id, model, ..
|
| 595 |
-
} => LlmState {
|
| 596 |
status: LlmStateStatus::Ready,
|
| 597 |
-
|
| 598 |
-
source: Some(provider_id.clone()),
|
| 599 |
error: None,
|
| 600 |
},
|
| 601 |
-
State::Failed
|
| 602 |
status: LlmStateStatus::Failed,
|
| 603 |
-
|
| 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('&', "&")
|
| 130 |
.replace('<', "<")
|
|
|
|
| 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::{
|
|
|
|
|
|
|
|
|
|
| 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::
|
| 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::{
|
| 10 |
-
use
|
| 11 |
-
use
|
| 12 |
-
use
|
| 13 |
-
|
| 14 |
-
use crate::
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
|
| 19 |
pub struct PipelineHandle {
|
| 20 |
pub id: String,
|
| 21 |
pub cancel: Arc<AtomicBool>,
|
| 22 |
}
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 53 |
Some(_) => 1,
|
| 54 |
-
None => resources
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
};
|
| 56 |
|
| 57 |
-
match result {
|
| 58 |
-
Ok(()) if cancel.load(Ordering::Relaxed) =>
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 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 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
|
| 85 |
-
job_id
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 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
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
}
|
|
|
|
| 115 |
};
|
| 116 |
|
|
|
|
| 117 |
if total_docs == 0 {
|
| 118 |
return Ok(());
|
| 119 |
}
|
| 120 |
|
| 121 |
-
if let Some(
|
| 122 |
-
|
| 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,
|
|
|
|
|
|
|
| 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 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 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
|
| 187 |
-
PipelineStep::Ocr => res.ml.ocr(&mut
|
| 188 |
-
PipelineStep::Inpaint => res.ml.inpaint(&mut
|
| 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
|
| 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 |
-
|
| 206 |
-
|
| 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::
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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::{
|
| 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
|
| 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
|
| 120 |
-
pub
|
| 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
|
| 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
|
| 139 |
-
pub
|
| 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
|
| 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
|
| 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
|
| 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
|
| 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
|
| 227 |
-
pub
|
| 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
|
| 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 |
-
|
| 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(&
|
| 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(&
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
});
|
| 391 |
round_trip(&LlmLoadParams {
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
});
|
| 399 |
round_trip(&LlmGenerateParams {
|
| 400 |
-
|
| 401 |
text_block_index: Some(0),
|
| 402 |
language: Some("zh-CN".to_string()),
|
| 403 |
});
|
| 404 |
round_trip(&ProcessRequest {
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 461 |
layer: "original".to_string(),
|
| 462 |
max_size: Some(512),
|
| 463 |
});
|
| 464 |
round_trip(&ViewTextBlockParams {
|
| 465 |
-
|
| 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 |
-
|
| 474 |
output_path: "out.png".to_string(),
|
| 475 |
});
|
| 476 |
round_trip(&RenderParams {
|
| 477 |
-
|
| 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 |
-
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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::
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
| 219 |
-
|
| 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::{
|
| 6 |
|
| 7 |
-
#[derive(
|
|
|
|
|
|
|
| 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
|
| 24 |
-
pub
|
| 25 |
-
|
| 26 |
-
pub
|
| 27 |
}
|
| 28 |
|
| 29 |
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
|
| 30 |
#[serde(rename_all = "camelCase")]
|
| 31 |
-
pub struct
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
pub path: String,
|
| 33 |
}
|
| 34 |
|
| 35 |
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)]
|
| 36 |
#[serde(rename_all = "camelCase")]
|
| 37 |
-
pub struct
|
| 38 |
-
pub
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 247 |
-
pub
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
}
|
| 250 |
|
| 251 |
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
|
| 252 |
#[serde(rename_all = "camelCase")]
|
| 253 |
-
pub struct
|
| 254 |
-
pub
|
| 255 |
-
pub
|
| 256 |
-
pub
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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::
|
|
|
|
| 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 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
.api_key
|
| 131 |
-
.
|
| 132 |
-
.
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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::
|
|
|
|
| 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 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 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 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 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::{
|
|
|
|
|
|
|
| 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
|
| 18 |
-
.map(|path| path.join("Koharu"))
|
| 19 |
-
.unwrap_or(PathBuf::from("."))
|
| 20 |
-
.join("models");
|
| 21 |
|
| 22 |
let runtime = RuntimeManager::new(
|
| 23 |
-
Settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 15 |
-
use koharu_app::{
|
| 16 |
-
AppResources, operations,
|
| 17 |
-
state_tx::{self, ChangedField},
|
| 18 |
-
};
|
| 19 |
use koharu_core::{
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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!(
|
| 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
|
| 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 =
|
| 189 |
(status = 503, body = ApiError),
|
| 190 |
),
|
| 191 |
)]
|
| 192 |
-
async fn get_config(
|
| 193 |
-
let
|
|
|
|
| 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 =
|
| 203 |
responses(
|
| 204 |
-
(status = 200, body =
|
| 205 |
(status = 400, body = ApiError),
|
| 206 |
),
|
| 207 |
)]
|
| 208 |
-
async fn update_config(
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
| 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 =
|
| 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 =
|
| 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
|
| 295 |
-
|
|
|
|
|
|
|
| 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
|
| 317 |
-
|
| 318 |
-
.
|
| 319 |
-
.
|
| 320 |
-
.
|
| 321 |
-
.ok_or_else(|| ApiError::not_found("Document not found"))?;
|
| 322 |
|
| 323 |
-
let image = serde_bytes::ByteBuf::from(
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
let
|
| 327 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
-
let text_blocks = doc
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 376 |
-
|
| 377 |
-
.
|
| 378 |
-
.
|
| 379 |
-
.
|
| 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 =
|
| 384 |
-
|
| 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
|
| 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 |
-
|
| 423 |
-
name: filename,
|
| 424 |
-
data: data.to_vec(),
|
| 425 |
-
});
|
| 426 |
}
|
| 427 |
|
| 428 |
-
if
|
| 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 |
-
|
| 436 |
}
|
| 437 |
koharu_core::ImportMode::Append => {
|
| 438 |
-
|
| 439 |
}
|
| 440 |
}
|
| 441 |
|
| 442 |
-
let documents =
|
| 443 |
-
.
|
| 444 |
-
.
|
| 445 |
-
.
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 551 |
resources,
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 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
|
| 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 |
-
|
| 592 |
resources,
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
|
|
|
| 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 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
any_changed = true;
|
| 836 |
}
|
| 837 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 838 |
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
|
|
|
| 843 |
|
| 844 |
// Auto-render if any block content/geometry changed
|
| 845 |
-
if
|
| 846 |
-
let _ =
|
| 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
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 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
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 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/
|
| 953 |
-
operation_id = "
|
| 954 |
tag = "llm",
|
| 955 |
-
params(
|
| 956 |
responses(
|
| 957 |
-
(status = 200, body =
|
| 958 |
(status = 503, body = ApiError),
|
| 959 |
),
|
| 960 |
)]
|
| 961 |
-
async fn
|
| 962 |
State(state): State<ApiState>,
|
| 963 |
-
Query(query): Query<
|
| 964 |
-
) -> ApiResult<Json<
|
| 965 |
let resources = state.resources()?;
|
| 966 |
-
|
| 967 |
-
resources,
|
| 968 |
-
|
| 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 =
|
| 991 |
(status = 503, body = ApiError),
|
| 992 |
),
|
| 993 |
)]
|
| 994 |
-
async fn get_llm(State(state): State<ApiState>) -> ApiResult<Json<
|
| 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 =
|
| 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<
|
| 1015 |
let resources = state.resources()?;
|
| 1016 |
-
|
| 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 =
|
| 1038 |
(status = 503, body = ApiError),
|
| 1039 |
),
|
| 1040 |
)]
|
| 1041 |
-
async fn unload_llm(State(state): State<ApiState>) -> ApiResult<Json<
|
| 1042 |
let resources = state.resources()?;
|
| 1043 |
-
|
| 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 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
Some(_) => 1,
|
| 1166 |
-
None =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1167 |
};
|
| 1168 |
|
| 1169 |
-
let job_id =
|
| 1170 |
resources.clone(),
|
| 1171 |
koharu_core::ProcessRequest {
|
| 1172 |
-
|
| 1173 |
-
|
| 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
|
| 1316 |
|
| 1317 |
if format == "psd" {
|
| 1318 |
-
let data = koharu_psd::export_document(
|
| 1319 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1320 |
return Ok(binary_response(
|
| 1321 |
data,
|
| 1322 |
"image/vnd.adobe.photoshop",
|
| 1323 |
-
Some(
|
| 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 =>
|
| 1358 |
-
ExportLayer::Inpainted =>
|
| 1359 |
};
|
| 1360 |
Ok(Json(ExportResult { count }))
|
| 1361 |
}
|
| 1362 |
|
| 1363 |
-
async fn find_document(
|
| 1364 |
-
resources
|
| 1365 |
-
|
| 1366 |
-
|
| 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::
|
| 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::
|
| 1534 |
-
use koharu_core::{
|
| 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(¤t, &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::
|
|
|
|
| 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,
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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 |
-
|
| 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 =
|
| 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 =
|
| 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<
|
| 82 |
) -> Result<String, String> {
|
| 83 |
let res = self.resources()?;
|
| 84 |
-
let doc =
|
| 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 =
|
| 95 |
.await
|
| 96 |
.map_err(|e| e.to_string())?;
|
| 97 |
Ok(fonts
|
|
@@ -101,27 +114,21 @@ impl KoharuMcp {
|
|
| 101 |
.join(", "))
|
| 102 |
}
|
| 103 |
|
| 104 |
-
#[tool(
|
|
|
|
|
|
|
| 105 |
async fn llm_list(&self) -> Result<String, String> {
|
| 106 |
let res = self.resources()?;
|
| 107 |
-
let
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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 =
|
| 268 |
.await
|
| 269 |
.map_err(|e| e.to_string())?;
|
| 270 |
|
| 271 |
-
let
|
| 272 |
-
let names: Vec<&str> =
|
| 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 =
|
| 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<
|
| 296 |
let res = self.resources()?;
|
| 297 |
-
|
| 298 |
.await
|
| 299 |
.map_err(|e| e.to_string())?;
|
| 300 |
|
| 301 |
-
let doc =
|
| 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<
|
| 319 |
let res = self.resources()?;
|
| 320 |
-
|
| 321 |
.await
|
| 322 |
.map_err(|e| e.to_string())?;
|
| 323 |
|
| 324 |
-
let doc =
|
| 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<
|
| 340 |
let res = self.resources()?;
|
| 341 |
-
|
| 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 |
-
|
| 360 |
res,
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 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 |
-
|
| 381 |
res,
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 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!(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 414 |
res.clone(),
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
language: p.language,
|
| 419 |
-
},
|
| 420 |
)
|
| 421 |
.await
|
| 422 |
.map_err(|e| e.to_string())?;
|
| 423 |
|
| 424 |
-
let doc =
|
| 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 |
-
|
|
|
|
| 450 |
res,
|
| 451 |
ProcessRequest {
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 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
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 540 |
res,
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 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::
|
| 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:
|
| 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:
|
| 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:
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
downloads.sort_by(|a, b| a.filename.cmp(&b.filename));
|
| 53 |
downloads
|
| 54 |
}
|
| 55 |
}
|
| 56 |
|
| 57 |
-
fn
|
| 58 |
-
let mut rx = pipeline::subscribe();
|
| 59 |
tokio::spawn(async move {
|
|
|
|
|
|
|
| 60 |
loop {
|
| 61 |
-
match
|
| 62 |
Ok(progress) => {
|
| 63 |
-
|
| 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
|
| 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
|
| 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().
|
| 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(
|
| 19 |
Ok(Self {
|
| 20 |
client: Arc::new(
|
| 21 |
-
ClientBuilder::new(build_base_client(
|
| 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(
|
| 34 |
-
let
|
| 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 |
|