diff --git a/Cargo.lock b/Cargo.lock index cb22d8c473b53bad3d9fe493c91b36bddac778e1..ebd783855ff6b4cb4c206bf39febb7f4a0fb257e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3908,6 +3908,7 @@ name = "koharu" version = "0.41.4" dependencies = [ "anyhow", + "camino", "clap", "dirs", "git-version", @@ -3923,6 +3924,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-process", "tauri-plugin-updater", "tokio", "tracing", @@ -3937,6 +3939,8 @@ name = "koharu-app" version = "0.41.4" dependencies = [ "anyhow", + "blake3", + "camino", "dirs", "image", "imageproc", @@ -3945,13 +3949,13 @@ dependencies = [ "koharu-ml", "koharu-renderer", "koharu-runtime", - "once_cell", + "lru", "open", "rayon", "reqwest-middleware", "rfd", "serde", - "serde_bytes", + "serde_json", "strum", "tempfile", "tokio", @@ -4119,6 +4123,7 @@ version = "0.41.4" dependencies = [ "anyhow", "axum", + "camino", "dirs", "flate2", "futures", @@ -4324,6 +4329,15 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -7640,6 +7654,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.0" diff --git a/Cargo.toml b/Cargo.toml index 00bc6ff2142cdf7815d91233e793a75c6b51ceed..16cb0dd3ff1279e6a361fdde7ba6c0a97b491e35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ toml = "1.1" inventory = "0.3" url = { version = "2.5", features = ["serde"] } arc-swap = "1.7" +camino = { version = "1.1", features = ["serde1"] } indexmap = "2.11" flate2 = { version = "1.0", default-features = false, features = [ "zlib-ng-compat", @@ -134,6 +135,8 @@ tower-http = { version = "0.6", features = ["cors"] } criterion = { version = "0.8", features = ["html_reports"] } git-version = "0.3" keyring = "3.6" +lru = "0.14" +tauri-plugin-process = "2.3" [patch.crates-io] candle-transformers = { git = "https://github.com/mayocream/candle", branch = "cuda-dynamic-loading" } diff --git a/bun.lock b/bun.lock index f1286e5b3745ca2e27f496b33841a56690b86af2..e1578b44f14b3a243b4c6161684176020c074978 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@tanstack/react-query": "^5.96.1", "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-process": "^2.3.1", "@use-gesture/react": "^10.3.1", "@xstate/react": "^6.1.0", "browser-fs-access": "^0.38.0", @@ -432,6 +433,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg=="], + "@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=="], + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], diff --git a/docs/en-US/reference/http-api.md b/docs/en-US/reference/http-api.md index cee43b566d3ec30bd22817d2a241edff298956ad..f14ea95e1a835b31482fe2dd368d8c6d57cb0da3 100644 --- a/docs/en-US/reference/http-api.md +++ b/docs/en-US/reference/http-api.md @@ -120,24 +120,24 @@ Single-document export endpoints return binary file content. Bulk export returns | Method | Path | Purpose | | --- | --- | --- | -| `GET` | `/llm/models` | list local and API-backed translation models | -| `GET` | `/llm/state` | get the current LLM status | -| `POST` | `/llm/load` | load a local or API-backed model | -| `POST` | `/llm/offload` | unload the current model | -| `POST` | `/llm/ping` | test an OpenAI-compatible base URL | +| `GET` | `/llm/catalog` | list the grouped local/provider LLM catalog | +| `GET` | `/llm` | get the current LLM status | +| `PUT` | `/llm` | load a local or provider-backed model target | +| `DELETE` | `/llm` | unload the current model | Useful request details: -- `/llm/models` accepts optional `language` and `openaiCompatibleBaseUrl` query parameters -- `/llm/load` accepts `id`, `apiKey`, `baseUrl`, `temperature`, `maxTokens`, and `customSystemPrompt` -- `/llm/ping` accepts `baseUrl` and optional `apiKey` +- `/llm/catalog` accepts optional `language` +- `PUT /llm` accepts `target` plus optional `options { temperature, maxTokens, customSystemPrompt }` +- provider targets use `{ kind: "provider", providerId, modelId }`; local targets use `{ kind: "local", modelId }` -## Provider API keys +## Provider configuration -| Method | Path | Purpose | -| --- | --- | --- | -| `GET` | `/providers/{provider}/api-key` | read a saved API key for a provider | -| `PUT` | `/providers/{provider}/api-key` | store or overwrite a provider API key | +Provider settings now live under `GET /config` and `PUT /config`. + +- non-secret provider fields such as `baseUrl` are stored in `llm.providers` +- provider reads expose `hasApiKey`, not the raw saved key +- provider updates can set or clear API keys through `PUT /config` Current built-in provider ids include: @@ -157,7 +157,7 @@ Current built-in provider ids include: The pipeline job request can include: - `documentId` to target one page, or omit it to process all loaded pages -- LLM settings such as `llmModelId`, `llmApiKey`, `llmBaseUrl`, `llmTemperature`, `llmMaxTokens`, and `llmCustomSystemPrompt` +- `llm { target, options }` to choose a local/provider model and optional generation overrides - render settings such as `shaderEffect`, `shaderStroke`, and `fontFamily` - `language` @@ -187,7 +187,7 @@ The normal API order for one page is: 1. `POST /documents/import?mode=replace` 2. `POST /documents/{documentId}/detect` 3. `POST /documents/{documentId}/ocr` -4. `POST /llm/load` +4. `PUT /llm` 5. `POST /documents/{documentId}/translate` 6. `POST /documents/{documentId}/inpaint` 7. `POST /documents/{documentId}/render` diff --git a/docs/en-US/reference/mcp-tools.md b/docs/en-US/reference/mcp-tools.md index 940a835ea748713680d0f792e644125eec60ebd8..5663e29cd7f4973cb5037d0d1d4ca855b2d67774 100644 --- a/docs/en-US/reference/mcp-tools.md +++ b/docs/en-US/reference/mcp-tools.md @@ -71,7 +71,7 @@ Valid `view_text_block` layers: | `ocr` | run OCR on detected blocks | `index` | | `inpaint` | remove text using the current mask | `index` | | `render` | draw translated text back onto the page | `index`, optional `text_block_index`, `shader_effect`, `font_family` | -| `process` | start detect -> OCR -> inpaint -> translate -> render | optional `index`, `llm_model_id`, `language`, `shader_effect`, `font_family` | +| `process` | start detect -> OCR -> inpaint -> translate -> render | optional `document_id`, `llm_target`, `language`, `shader_effect`, `font_family` | `process` is the coarse-grained convenience tool. If you need more control or easier debugging, use the stage tools separately. @@ -79,7 +79,7 @@ Valid `view_text_block` layers: | Tool | What it does | Key parameters | | --- | --- | --- | -| `llm_load` | load a translation model | `id`, optional `temperature`, `max_tokens`, `custom_system_prompt` | +| `llm_load` | load a translation model target | `target`, optional `options.temperature`, `options.max_tokens`, `options.custom_system_prompt` | | `llm_offload` | unload the current model | none | | `llm_generate` | translate one block or all blocks | `index`, optional `text_block_index`, `language` | diff --git a/docs/ja-JP/reference/http-api.md b/docs/ja-JP/reference/http-api.md index 5c2430fc970a5d44c9c5b138a3f06b6d4a2d4baf..0e5f587d918a4ce67385c4d5178fd6f0b9aac185 100644 --- a/docs/ja-JP/reference/http-api.md +++ b/docs/ja-JP/reference/http-api.md @@ -120,24 +120,24 @@ import エンドポイントは、`files` フィールドを繰り返し持つ m | Method | Path | 目的 | | --- | --- | --- | -| `GET` | `/llm/models` | ローカルおよび API ベースの翻訳モデル一覧を取得する | -| `GET` | `/llm/state` | 現在の LLM 状態を取得する | -| `POST` | `/llm/load` | ローカルまたは API ベースのモデルを読み込む | -| `POST` | `/llm/offload` | 現在のモデルをアンロードする | -| `POST` | `/llm/ping` | OpenAI 互換 base URL を接続確認する | +| `GET` | `/llm/catalog` | ローカル/プロバイダ別に整理された LLM カタログを取得する | +| `GET` | `/llm` | 現在の LLM 状態を取得する | +| `PUT` | `/llm` | ローカルまたはプロバイダ target を読み込む | +| `DELETE` | `/llm` | 現在のモデルをアンロードする | 実用上のリクエスト詳細: -- `/llm/models` は `language` と `openaiCompatibleBaseUrl` の query parameter を任意で受け付けます -- `/llm/load` は `id`、`apiKey`、`baseUrl`、`temperature`、`maxTokens`、`customSystemPrompt` を受け付けます -- `/llm/ping` は `baseUrl` と任意の `apiKey` を受け付けます +- `/llm/catalog` は任意で `language` を受け付けます +- `PUT /llm` は `target` と任意の `options { temperature, maxTokens, customSystemPrompt }` を受け付けます +- provider target は `{ kind: "provider", providerId, modelId }`、local target は `{ kind: "local", modelId }` です -## プロバイダ API キー +## プロバイダ設定 -| Method | Path | 目的 | -| --- | --- | --- | -| `GET` | `/providers/{provider}/api-key` | 保存済みの API キーを読み出す | -| `PUT` | `/providers/{provider}/api-key` | プロバイダ API キーを保存または上書きする | +プロバイダ設定は `GET /config` と `PUT /config` に統合されました。 + +- `llm.providers` に `baseUrl` などの非シークレット設定を保存します +- 読み出しでは生の API キーは返さず、`hasApiKey` のみ返します +- API キーの設定/削除も `PUT /config` で行います 現在の組み込み provider id は次です。 @@ -157,7 +157,7 @@ import エンドポイントは、`files` フィールドを繰り返し持つ m pipeline job リクエストには次を含められます。 - `documentId` を指定すると 1 ページ対象、省略すると読み込み済み全ページ対象 -- `llmModelId`、`llmApiKey`、`llmBaseUrl`、`llmTemperature`、`llmMaxTokens`、`llmCustomSystemPrompt` などの LLM 設定 +- `llm { target, options }` による LLM 選択と任意の生成オプション - `shaderEffect`、`shaderStroke`、`fontFamily` などの render 設定 - `language` diff --git a/docs/ja-JP/reference/mcp-tools.md b/docs/ja-JP/reference/mcp-tools.md index 7db9b4159bf021d97f7c2fde4eb861dc12501669..e55a9a378f66c49b849311a0efcf0524ea8a4576 100644 --- a/docs/ja-JP/reference/mcp-tools.md +++ b/docs/ja-JP/reference/mcp-tools.md @@ -71,7 +71,7 @@ http://127.0.0.1:/mcp | `ocr` | 検出済みブロックに OCR をかける | `index` | | `inpaint` | 現在の mask を使って文字を除去する | `index` | | `render` | 翻訳済みテキストをページに描き戻す | `index`, 任意で `text_block_index`, `shader_effect`, `font_family` | -| `process` | detect -> OCR -> inpaint -> translate -> render をまとめて開始する | 任意で `index`, `llm_model_id`, `language`, `shader_effect`, `font_family` | +| `process` | detect -> OCR -> inpaint -> translate -> render をまとめて開始する | 任意で `document_id`, `llm_target`, `language`, `shader_effect`, `font_family` | `process` は粗粒度の convenience tool です。より細かな制御や切り分けが必要なら、各段階ツールを個別に使ってください。 @@ -79,7 +79,7 @@ http://127.0.0.1:/mcp | Tool | 役割 | 主な引数 | | --- | --- | --- | -| `llm_load` | 翻訳モデルを読み込む | `id`, 任意で `temperature`, `max_tokens`, `custom_system_prompt` | +| `llm_load` | 翻訳モデル target を読み込む | `target`, 任意で `options.temperature`, `options.max_tokens`, `options.custom_system_prompt` | | `llm_offload` | 現在のモデルをアンロードする | なし | | `llm_generate` | 1 ブロックまたは全ブロックを翻訳する | `index`, 任意で `text_block_index`, `language` | diff --git a/docs/zh-CN/reference/http-api.md b/docs/zh-CN/reference/http-api.md index 54ef27e6470b5f00aa106f4d7910be51c23810d9..7b0ab332daea40418a8e965ac1d89adf91bd8c61 100644 --- a/docs/zh-CN/reference/http-api.md +++ b/docs/zh-CN/reference/http-api.md @@ -120,24 +120,24 @@ http://127.0.0.1:/api/v1 | 方法 | 路径 | 用途 | | --- | --- | --- | -| `GET` | `/llm/models` | 列出本地与 API 支持的翻译模型 | -| `GET` | `/llm/state` | 获取当前 LLM 状态 | -| `POST` | `/llm/load` | 加载本地或 API 模型 | -| `POST` | `/llm/offload` | 卸载当前模型 | -| `POST` | `/llm/ping` | 测试 OpenAI 兼容 base URL | +| `GET` | `/llm/catalog` | 获取按本地/提供商分组的 LLM 目录 | +| `GET` | `/llm` | 获取当前 LLM 状态 | +| `PUT` | `/llm` | 加载本地或提供商 target | +| `DELETE` | `/llm` | 卸载当前模型 | 常用请求细节: -- `/llm/models` 支持可选查询参数 `language` 和 `openaiCompatibleBaseUrl` -- `/llm/load` 接受 `id`、`apiKey`、`baseUrl`、`temperature`、`maxTokens` 和 `customSystemPrompt` -- `/llm/ping` 接受 `baseUrl` 以及可选 `apiKey` +- `/llm/catalog` 支持可选查询参数 `language` +- `PUT /llm` 接受 `target` 与可选 `options { temperature, maxTokens, customSystemPrompt }` +- provider target 使用 `{ kind: "provider", providerId, modelId }`,local target 使用 `{ kind: "local", modelId }` -### 提供商 API Key +### 提供商配置 -| 方法 | 路径 | 用途 | -| --- | --- | --- | -| `GET` | `/providers/{provider}/api-key` | 读取已保存的提供商 API key | -| `PUT` | `/providers/{provider}/api-key` | 存储或覆盖 API key | +提供商设置现在统一放在 `GET /config` 和 `PUT /config` 中。 + +- `llm.providers` 保存 `baseUrl` 等非秘密配置 +- 读取配置时只返回 `hasApiKey`,不会返回原始 API key +- 设置或清除 API key 也通过 `PUT /config` 完成 当前内置 provider id 包括: @@ -157,7 +157,7 @@ http://127.0.0.1:/api/v1 管线任务请求可以包含: - `documentId`:只处理某一页;留空时处理所有已加载页面 -- LLM 设置,例如 `llmModelId`、`llmApiKey`、`llmBaseUrl`、`llmTemperature`、`llmMaxTokens`、`llmCustomSystemPrompt` +- `llm { target, options }`:选择 LLM 目标以及可选生成参数 - 渲染设置,例如 `shaderEffect`、`shaderStroke`、`fontFamily` - `language` diff --git a/docs/zh-CN/reference/mcp-tools.md b/docs/zh-CN/reference/mcp-tools.md index c28073441eba6aa30c1fc77751e7ac77539d4d25..83fc78ab4bec1a444935c67115a02b51715a0e0a 100644 --- a/docs/zh-CN/reference/mcp-tools.md +++ b/docs/zh-CN/reference/mcp-tools.md @@ -71,7 +71,7 @@ http://127.0.0.1:/mcp | `ocr` | 对检测块执行 OCR | `index` | | `inpaint` | 使用当前掩码去除文字 | `index` | | `render` | 把译文绘制回页面 | `index`、可选 `text_block_index`、`shader_effect`、`font_family` | -| `process` | 依次执行 detect -> OCR -> inpaint -> translate -> render | 可选 `index`、`llm_model_id`、`language`、`shader_effect`、`font_family` | +| `process` | 依次执行 detect -> OCR -> inpaint -> translate -> render | 可选 `document_id`、`llm_target`、`language`、`shader_effect`、`font_family` | `process` 是粗粒度的便捷工具。如果你想要更细的控制或更好排查问题,建议拆开使用各阶段工具。 @@ -79,7 +79,7 @@ http://127.0.0.1:/mcp | 工具 | 作用 | 关键参数 | | --- | --- | --- | -| `llm_load` | 加载一个翻译模型 | `id`、可选 `temperature`、`max_tokens`、`custom_system_prompt` | +| `llm_load` | 加载一个翻译模型 target | `target`、可选 `options.temperature`、`options.max_tokens`、`options.custom_system_prompt` | | `llm_offload` | 卸载当前模型 | 无 | | `llm_generate` | 翻译单个文本块或全部文本块 | `index`、可选 `text_block_index`、`language` | diff --git a/koharu-app/Cargo.toml b/koharu-app/Cargo.toml index ba5affd266d18ee7dee0af385e34f91d02b43b0c..166252c8d58e4e93745c2b1c773697785bb6ace2 100644 --- a/koharu-app/Cargo.toml +++ b/koharu-app/Cargo.toml @@ -18,16 +18,15 @@ koharu-renderer = { workspace = true } koharu-llm = { workspace = true } koharu-runtime = { workspace = true } anyhow = { workspace = true } +camino = { workspace = true } dirs = { workspace = true } image = { workspace = true } imageproc = { workspace = true } -once_cell = { workspace = true } open = { workspace = true } rayon = { workspace = true } rfd = { workspace = true } reqwest-middleware = { workspace = true } serde = { workspace = true } -serde_bytes = { workspace = true } strum = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true } @@ -35,3 +34,6 @@ toml = { workspace = true } tracing = { workspace = true } url = { workspace = true } uuid = { workspace = true } +blake3 = { workspace = true } +lru = { workspace = true } +serde_json = { workspace = true } diff --git a/koharu-app/src/blob_store.rs b/koharu-app/src/blob_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..25295ec1ab54e40448a11b33023e303429161f0a --- /dev/null +++ b/koharu-app/src/blob_store.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +#[derive(Clone)] +pub struct BlobStore { + root: PathBuf, +} + +impl BlobStore { + pub fn new(root: impl Into) -> Result { + let root = root.into(); + std::fs::create_dir_all(&root)?; + Ok(Self { root }) + } + + /// Write bytes to the store, return the blake3 hash hex string. + pub fn put(&self, data: &[u8]) -> Result { + let hash = blake3::hash(data).to_hex().to_string(); + let path = self.blob_path(&hash); + if path.exists() { + return Ok(hash); // already stored (content-addressable) + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, data).with_context(|| format!("Failed to write blob {hash}"))?; + Ok(hash) + } + + /// Read bytes from the store by hash. + pub fn get(&self, hash: &str) -> Result> { + let path = self.blob_path(hash); + std::fs::read(&path).with_context(|| format!("Blob not found: {hash}")) + } + + /// Check if a blob exists. + pub fn exists(&self, hash: &str) -> bool { + self.blob_path(hash).exists() + } + + fn blob_path(&self, hash: &str) -> PathBuf { + let (prefix, rest) = hash.split_at(2.min(hash.len())); + self.root.join(prefix).join(rest) + } +} diff --git a/koharu-app/src/config.rs b/koharu-app/src/config.rs index c16213919baaed813476ef4a8530803fff59a022..6103a8338cafe1dc672f6641ea49d1bc8f4f5eee 100644 --- a/koharu-app/src/config.rs +++ b/koharu-app/src/config.rs @@ -1,65 +1,97 @@ +use std::collections::{BTreeMap, HashSet}; use std::fs; use std::io::Write; -use std::path::PathBuf; use anyhow::{Context, Result}; -use koharu_core::{BootstrapConfig, BootstrapHttpConfig, BootstrapPathConfig}; -use koharu_runtime::Settings; +use camino::{Utf8Path, Utf8PathBuf}; +use koharu_core::{ + AppConfig as PublicAppConfig, AppConfigUpdate, AppDataConfig, AppLlmConfig, + AppLlmProviderConfig, AppLlmProviderConfigUpdate, +}; +use koharu_llm::providers::{ + all_provider_descriptors, find_provider_descriptor, get_saved_api_key, set_saved_api_key, +}; +use koharu_runtime::default_app_data_root; +use serde::{Deserialize, Serialize}; use tempfile::NamedTempFile; -use url::Url; -const CONFIG_DIR: &str = ".koharu"; const CONFIG_FILE: &str = "config.toml"; +const MANAGED_DATA_DIRS: &[&str] = &["runtime", "models", "blobs", "pages"]; -pub type AppConfig = Settings; +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct StoredConfig { + pub data: StoredDataConfig, + pub llm: StoredLlmConfig, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct StoredDataConfig { + pub path: Utf8PathBuf, +} -pub fn config_dir() -> Result { - let home = - dirs::home_dir().ok_or_else(|| anyhow::anyhow!("failed to resolve home directory"))?; - Ok(home.join(CONFIG_DIR)) +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct StoredLlmConfig { + pub providers: BTreeMap, } -pub fn config_path() -> Result { - Ok(config_dir()?.join(CONFIG_FILE)) +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct StoredProviderConfig { + pub base_url: Option, } -pub fn load() -> Result { +impl Default for StoredDataConfig { + fn default() -> Self { + Self { + path: default_app_data_root(), + } + } +} + +pub fn config_path() -> Result { + Ok(default_app_data_root().join(CONFIG_FILE)) +} + +pub fn load() -> Result { let path = config_path()?; if !path.exists() { - return Ok(AppConfig::default()); + let config = StoredConfig::default(); + save(&config)?; + return Ok(config); } - let content = fs::read_to_string(&path) - .with_context(|| format!("failed to read `{}`", path.display()))?; - toml::from_str(&content).with_context(|| format!("failed to parse `{}`", path.display())) + let content = fs::read_to_string(&path).with_context(|| format!("failed to read `{path}`"))?; + toml::from_str(&content).with_context(|| format!("failed to parse `{path}`")) } -pub fn save(config: &AppConfig) -> Result<()> { - let dir = config_dir()?; - fs::create_dir_all(&dir) - .with_context(|| format!("failed to create config dir `{}`", dir.display()))?; +pub fn save(config: &StoredConfig) -> Result<()> { + let path = config_path()?; + let dir = path + .parent() + .ok_or_else(|| anyhow::anyhow!("config path `{path}` does not have a parent directory"))?; + fs::create_dir_all(dir).with_context(|| format!("failed to create config dir `{dir}`"))?; - let path = dir.join(CONFIG_FILE); let content = toml::to_string_pretty(config).context("failed to serialize app config")?; - let mut temp = NamedTempFile::new_in(&dir) - .with_context(|| format!("failed to stage `{}`", path.display()))?; + let mut temp = + NamedTempFile::new_in(dir).with_context(|| format!("failed to stage `{path}`"))?; temp.write_all(content.as_bytes()) - .with_context(|| format!("failed to write temp config for `{}`", path.display()))?; + .with_context(|| format!("failed to write temp config for `{path}`"))?; temp.flush() - .with_context(|| format!("failed to flush temp config for `{}`", path.display()))?; + .with_context(|| format!("failed to flush temp config for `{path}`"))?; match temp.persist(&path) { Ok(_) => Ok(()), Err(err) => { if path.exists() { - fs::remove_file(&path).with_context(|| { - format!("failed to replace existing config `{}`", path.display()) - })?; + fs::remove_file(&path) + .with_context(|| format!("failed to replace existing config `{path}`"))?; } err.file.persist(&path).map(|_| ()).map_err(|persist_err| { anyhow::anyhow!( - "failed to persist config to `{}`: {}", - path.display(), + "failed to persist config to `{path}`: {}", persist_err.error ) }) @@ -67,141 +99,298 @@ pub fn save(config: &AppConfig) -> Result<()> { } } -pub fn to_bootstrap_config(config: &AppConfig) -> BootstrapConfig { - BootstrapConfig { - runtime: BootstrapPathConfig { - path: config.runtime.path.to_string_lossy().to_string(), - }, - models: BootstrapPathConfig { - path: config.models.path.to_string_lossy().to_string(), - }, - http: BootstrapHttpConfig { - proxy: config.http_proxy().map(Url::to_string), +pub fn to_public_config(config: &StoredConfig) -> Result { + let mut providers = Vec::new(); + for descriptor in all_provider_descriptors() { + let stored = config.llm.providers.get(descriptor.id); + let has_api_key = get_saved_api_key(descriptor.id)? + .map(|value| !value.trim().is_empty()) + .unwrap_or(false); + providers.push(AppLlmProviderConfig { + id: descriptor.id.to_string(), + base_url: stored.and_then(|provider| provider.base_url.clone()), + has_api_key, + }); + } + + Ok(PublicAppConfig { + data: AppDataConfig { + path: config.data.path.to_string(), }, + llm: AppLlmConfig { providers }, + }) +} + +pub fn from_public_update(config: AppConfigUpdate) -> Result { + validate_provider_updates(&config)?; + + let data_path = config.data.path.trim(); + anyhow::ensure!(!data_path.is_empty(), "data path is required"); + let data_path = Utf8PathBuf::from(data_path); + anyhow::ensure!(data_path.is_absolute(), "data path must be absolute"); + + let mut providers = BTreeMap::new(); + for provider in config.llm.providers { + let id = provider.id.trim(); + let descriptor = find_provider_descriptor(id) + .ok_or_else(|| anyhow::anyhow!("unknown provider id: {id}"))?; + let base_url = normalized_base_url(&provider); + + if base_url.is_some() + || provider.api_key.is_some() + || provider.clear_api_key + || descriptor.requires_base_url + { + providers.insert(id.to_string(), StoredProviderConfig { base_url }); + } } + + Ok(StoredConfig { + data: StoredDataConfig { path: data_path }, + llm: StoredLlmConfig { providers }, + }) } -pub fn from_bootstrap_config(config: BootstrapConfig) -> Result { - let runtime_path = config.runtime.path.trim(); - let models_path = config.models.path.trim(); - anyhow::ensure!(!runtime_path.is_empty(), "runtime path is required"); - anyhow::ensure!(!models_path.is_empty(), "models path is required"); +pub fn move_app_data_if_needed(current: &StoredConfig, next: &StoredConfig) -> Result { + if same_path(¤t.data.path, &next.data.path) { + return Ok(false); + } + + fs::create_dir_all(&next.data.path) + .with_context(|| format!("failed to create `{}`", next.data.path))?; + + for name in MANAGED_DATA_DIRS { + move_path( + current.data.path.join(name).as_std_path(), + next.data.path.join(name).as_std_path(), + )?; + } + + Ok(true) +} + +pub fn apply_secret_updates(config: &AppConfigUpdate) -> Result<()> { + validate_provider_updates(config)?; + + for provider in &config.llm.providers { + let id = provider.id.trim(); + let api_key = provider.api_key.as_deref().map(str::trim); + let should_clear = provider.clear_api_key || matches!(api_key, Some("")); + if should_clear { + set_saved_api_key(id, "")?; + continue; + } + if let Some(api_key) = api_key + && !api_key.is_empty() + { + set_saved_api_key(id, api_key)?; + } + } + + Ok(()) +} + +fn same_path(left: &Utf8Path, right: &Utf8Path) -> bool { + match ( + fs::canonicalize(left.as_std_path()), + fs::canonicalize(right.as_std_path()), + ) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn move_path(source: &std::path::Path, destination: &std::path::Path) -> Result<()> { + if !source.exists() { + return Ok(()); + } + + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create `{}`", parent.display()))?; + } + + if !destination.exists() && fs::rename(source, destination).is_ok() { + return Ok(()); + } + + if source.is_dir() { + fs::create_dir_all(destination) + .with_context(|| format!("failed to create `{}`", destination.display()))?; - let proxy = config - .http - .proxy + for entry in fs::read_dir(source) + .with_context(|| format!("failed to read `{}`", source.display()))? + { + let entry = entry.with_context(|| format!("failed to read `{}`", source.display()))?; + move_path(&entry.path(), &destination.join(entry.file_name()))?; + } + + fs::remove_dir_all(source) + .with_context(|| format!("failed to remove `{}`", source.display()))?; + } else { + if destination.exists() { + fs::remove_file(destination) + .with_context(|| format!("failed to replace `{}`", destination.display()))?; + } + fs::copy(source, destination).with_context(|| { + format!( + "failed to copy `{}` to `{}`", + source.display(), + destination.display() + ) + })?; + fs::remove_file(source) + .with_context(|| format!("failed to remove `{}`", source.display()))?; + } + + Ok(()) +} +fn normalized_base_url(provider: &AppLlmProviderConfigUpdate) -> Option { + provider + .base_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(Url::parse) - .transpose() - .context("invalid HTTP proxy URL")?; + .map(ToOwned::to_owned) +} - Ok( - AppConfig::from_paths(PathBuf::from(runtime_path), PathBuf::from(models_path)) - .with_proxy(proxy), - ) +fn validate_provider_updates(config: &AppConfigUpdate) -> Result<()> { + let mut seen = HashSet::new(); + for provider in &config.llm.providers { + let id = provider.id.trim(); + anyhow::ensure!(!id.is_empty(), "provider id is required"); + anyhow::ensure!( + find_provider_descriptor(id).is_some(), + "unknown provider id: {id}" + ); + anyhow::ensure!(seen.insert(id.to_string()), "duplicate provider id: {id}"); + anyhow::ensure!( + !(provider.clear_api_key + && provider + .api_key + .as_deref() + .map(str::trim) + .is_some_and(|value| !value.is_empty())), + "provider {id} cannot set and clear api_key in the same update" + ); + } + + Ok(()) } #[cfg(test)] mod tests { - use std::env; - use std::path::Path; + use std::fs; + + use camino::Utf8PathBuf; + use koharu_core::{ + AppConfigUpdate, AppDataConfigUpdate, AppLlmConfigUpdate, AppLlmProviderConfigUpdate, + }; use super::{ - AppConfig, BootstrapConfig, BootstrapHttpConfig, BootstrapPathConfig, - from_bootstrap_config, to_bootstrap_config, + StoredConfig, config_path, from_public_update, move_app_data_if_needed, to_public_config, }; #[test] - fn bootstrap_round_trip_preserves_paths_and_proxy() { - let config = BootstrapConfig { - runtime: BootstrapPathConfig { - path: "/tmp/runtime".to_string(), - }, - models: BootstrapPathConfig { - path: "/tmp/models".to_string(), - }, - http: BootstrapHttpConfig { - proxy: Some("http://127.0.0.1:7890".to_string()), - }, - }; + fn old_config_without_data_section_still_loads() { + let config: StoredConfig = toml::from_str( + r#" + [runtime] + path = "/tmp/runtime" - let app = from_bootstrap_config(config.clone()).unwrap(); - let serialized = to_bootstrap_config(&app); + [models] + path = "/tmp/models" - assert_eq!(serialized.runtime.path, config.runtime.path); - assert_eq!(serialized.models.path, config.models.path); - assert_eq!( - serialized - .http - .proxy - .as_deref() - .map(url::Url::parse) - .transpose() - .unwrap(), - config - .http - .proxy - .as_deref() - .map(url::Url::parse) - .transpose() - .unwrap() - ); + [http] + proxy = "http://127.0.0.1:7890" + "#, + ) + .unwrap(); + + assert_eq!(config.data.path, koharu_runtime::default_app_data_root()); + assert!(config.llm.providers.is_empty()); } #[test] - fn blank_proxy_becomes_none() { - let app = from_bootstrap_config(BootstrapConfig { - runtime: BootstrapPathConfig { - path: ".".to_string(), + fn public_update_round_trip_preserves_fields() { + let config = from_public_update(AppConfigUpdate { + data: AppDataConfigUpdate { + path: "/tmp/koharu-data".to_string(), }, - models: BootstrapPathConfig { - path: ".".to_string(), - }, - http: BootstrapHttpConfig { - proxy: Some(" ".to_string()), + llm: AppLlmConfigUpdate { + providers: vec![AppLlmProviderConfigUpdate { + id: "openai-compatible".to_string(), + base_url: Some(" http://127.0.0.1:1234/v1/ ".to_string()), + api_key: None, + clear_api_key: false, + }], }, }) .unwrap(); - assert!(app.http_proxy().is_none()); + let public = to_public_config(&config).unwrap(); + + assert_eq!(public.data.path, "/tmp/koharu-data"); + assert_eq!( + public + .llm + .providers + .iter() + .find(|provider| provider.id == "openai-compatible") + .and_then(|provider| provider.base_url.as_deref()), + Some("http://127.0.0.1:1234/v1/") + ); } #[test] - fn empty_paths_are_rejected() { - let error = from_bootstrap_config(BootstrapConfig { - runtime: BootstrapPathConfig { - path: " ".to_string(), - }, - models: BootstrapPathConfig { - path: ".".to_string(), + fn public_update_requires_absolute_data_path() { + let error = from_public_update(AppConfigUpdate { + data: AppDataConfigUpdate { + path: "relative\\koharu-data".to_string(), }, - http: BootstrapHttpConfig::default(), + llm: AppLlmConfigUpdate::default(), }) - .expect_err("empty runtime path must be rejected"); + .unwrap_err(); - assert!(error.to_string().contains("runtime path is required")); + assert!(error.to_string().contains("absolute")); } #[test] - fn defaults_use_runtime_owned_paths() { - let config = AppConfig::default(); - assert!( - config.runtime.path.is_absolute() || config.runtime.path == Path::new("").to_path_buf() - ); - assert!( - config.models.path.is_absolute() || config.models.path == Path::new("").to_path_buf() - ); + fn move_app_data_moves_managed_directories() { + let tempdir = tempfile::tempdir().unwrap(); + let current = tempdir.path().join("old"); + let next = tempdir.path().join("new"); + fs::create_dir_all(current.join("pages")).unwrap(); + fs::create_dir_all(current.join("runtime")).unwrap(); + fs::write(current.join("pages").join("doc.json"), b"{}").unwrap(); + fs::write(current.join("runtime").join("pkg.bin"), b"runtime").unwrap(); + + let current_config = StoredConfig { + data: super::StoredDataConfig { + path: Utf8PathBuf::from_path_buf(current.clone()).unwrap(), + }, + ..StoredConfig::default() + }; + let next_config = StoredConfig { + data: super::StoredDataConfig { + path: Utf8PathBuf::from_path_buf(next.clone()).unwrap(), + }, + ..StoredConfig::default() + }; + + let moved = move_app_data_if_needed(¤t_config, &next_config).unwrap(); + + assert!(moved); + assert!(next.join("pages").join("doc.json").exists()); + assert!(next.join("runtime").join("pkg.bin").exists()); + assert!(!current.join("pages").exists()); + assert!(!current.join("runtime").exists()); } + #[test] - fn config_path_uses_home_dir_layout() { - let path = super::config_path().unwrap(); - assert_eq!( - path.file_name().and_then(|name| name.to_str()), - Some("config.toml") - ); - assert!(path.to_string_lossy().contains(".koharu")); - let _ = env::var_os("HOME"); + fn config_path_uses_appdata_layout() { + let path = config_path().unwrap(); + assert_eq!(path.file_name(), Some("config.toml")); + assert!(path.as_str().contains("Koharu")); } } diff --git a/koharu-app/src/edit.rs b/koharu-app/src/edit.rs new file mode 100644 index 0000000000000000000000000000000000000000..552f915911d5d606f2015026ccfc4244aac9f883 --- /dev/null +++ b/koharu-app/src/edit.rs @@ -0,0 +1,485 @@ +use image::DynamicImage; +use image::GenericImageView; +use imageproc::distance_transform::Norm; +use koharu_core::Region; +use koharu_core::parse::parse_hex_color; +use koharu_core::views::{TextBlockInfo, to_block_info}; +use koharu_core::{SerializableDynamicImage, TextBlock, TextStyle}; +use tracing::instrument; + +use crate::AppResources; +use crate::utils::{InpaintRegionExt, blank_rgba}; + +fn block_bounds(block: &TextBlock) -> Option<(f32, f32, f32, f32)> { + let bx0 = block.x.max(0.0); + let by0 = block.y.max(0.0); + let bx1 = (block.x + block.width).max(bx0); + let by1 = (block.y + block.height).max(by0); + (bx1 > bx0 && by1 > by0).then_some((bx0, by0, bx1, by1)) +} + +fn localize_line_polygons( + polygons: &Option>, + x0: u32, + y0: u32, + crop_width: u32, + crop_height: u32, +) -> Option> { + polygons.as_ref().map(|polygons| { + polygons + .iter() + .map(|polygon| { + let mut localized = *polygon; + for point in &mut localized { + point[0] = (point[0] - x0 as f32).clamp(0.0, crop_width as f32); + point[1] = (point[1] - y0 as f32).clamp(0.0, crop_height as f32); + } + localized + }) + .collect() + }) +} + +fn localize_inpaint_text_blocks( + text_blocks: &[TextBlock], + x0: u32, + y0: u32, + crop_width: u32, + crop_height: u32, +) -> Vec { + let crop_x1 = x0 + crop_width; + let crop_y1 = y0 + crop_height; + + text_blocks + .iter() + .filter_map(|block| { + let (bx0, by0, bx1, by1) = block_bounds(block)?; + let ix0 = bx0.max(x0 as f32); + let iy0 = by0.max(y0 as f32); + let ix1 = bx1.min(crop_x1 as f32); + let iy1 = by1.min(crop_y1 as f32); + if ix1 <= ix0 || iy1 <= iy0 { + return None; + } + + let mut localized = block.clone(); + localized.x = ix0 - x0 as f32; + localized.y = iy0 - y0 as f32; + localized.width = ix1 - ix0; + localized.height = iy1 - iy0; + localized.line_polygons = + localize_line_polygons(&block.line_polygons, x0, y0, crop_width, crop_height); + Some(localized) + }) + .collect() +} + +fn paste_crop(stitched: &mut image::RgbaImage, patch: &image::RgbaImage, x0: u32, y0: u32) { + image::imageops::replace(stitched, patch, i64::from(x0), i64::from(y0)); +} + +pub async fn update_text_blocks( + state: AppResources, + document_id: &str, + text_blocks: Vec, +) -> anyhow::Result<()> { + let mut doc = state.cache.get(document_id).await?; + doc.text_blocks = text_blocks; + state.cache.put(&doc).await?; + Ok(()) +} + +pub struct UpdateTextBlockArgs { + pub text_block_index: usize, + pub translation: Option, + pub x: Option, + pub y: Option, + pub width: Option, + pub height: Option, + pub font_families: Option>, + pub font_size: Option, + pub color: Option, + pub shader_effect: Option, +} + +pub async fn update_text_block( + state: AppResources, + document_id: &str, + args: UpdateTextBlockArgs, +) -> anyhow::Result { + let mut doc = state.cache.get(document_id).await?; + + let block = doc + .text_blocks + .get_mut(args.text_block_index) + .ok_or_else(|| anyhow::anyhow!("Text block {} not found", args.text_block_index))?; + let mut geometry_changed = false; + + if let Some(translation) = args.translation { + block.translation = Some(translation); + } + if let Some(x) = args.x { + block.x = x; + geometry_changed = true; + } + if let Some(y) = args.y { + block.y = y; + geometry_changed = true; + } + if let Some(width) = args.width { + block.width = width; + geometry_changed = true; + block.lock_layout_box = true; + } + if let Some(height) = args.height { + block.height = height; + geometry_changed = true; + block.lock_layout_box = true; + } + if geometry_changed { + block.set_layout_seed(block.x, block.y, block.width, block.height); + } + + if args.font_families.is_some() + || args.font_size.is_some() + || args.color.is_some() + || args.shader_effect.is_some() + { + let style = block.style.get_or_insert_with(|| TextStyle { + font_families: Vec::new(), + font_size: None, + color: [0, 0, 0, 255], + effect: None, + stroke: None, + text_align: None, + }); + + if let Some(families) = args.font_families { + style.font_families = families; + } + if let Some(font_size) = args.font_size { + style.font_size = Some(font_size); + } + if let Some(hex) = args.color { + style.color = parse_hex_color(&hex)?; + } + if let Some(effect) = args.shader_effect { + style.effect = Some(effect.parse()?); + } + } + + block.rendered = None; + block.rendered_direction = None; + let info = to_block_info(args.text_block_index, block); + state.cache.put(&doc).await?; + Ok(info) +} + +pub async fn add_text_block( + state: AppResources, + document_id: &str, + x: f32, + y: f32, + width: f32, + height: f32, +) -> anyhow::Result { + let mut doc = state.cache.get(document_id).await?; + + let mut block = TextBlock { + x, + y, + width, + height, + confidence: 1.0, + ..Default::default() + }; + block.set_layout_seed(block.x, block.y, block.width, block.height); + doc.text_blocks.push(block); + let count = doc.text_blocks.len() - 1; + state.cache.put(&doc).await?; + Ok(count) +} + +pub async fn remove_text_block( + state: AppResources, + document_id: &str, + text_block_index: usize, +) -> anyhow::Result { + let mut doc = state.cache.get(document_id).await?; + + if text_block_index >= doc.text_blocks.len() { + anyhow::bail!("Text block {} not found", text_block_index); + } + doc.text_blocks.remove(text_block_index); + let count = doc.text_blocks.len(); + state.cache.put(&doc).await?; + Ok(count) +} + +pub async fn dilate_mask(state: AppResources, document_id: &str, radius: u8) -> anyhow::Result<()> { + if radius == 0 || radius > 50 { + anyhow::bail!("Radius must be 1-50"); + } + + let mut doc = state.cache.get(document_id).await?; + + let segment = doc + .segment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?; + + let gray = segment.to_luma8(); + let dilated = imageproc::morphology::dilate(&gray, Norm::LInf, radius); + doc.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(dilated))); + state.cache.put(&doc).await?; + Ok(()) +} + +pub async fn erode_mask(state: AppResources, document_id: &str, radius: u8) -> anyhow::Result<()> { + if radius == 0 || radius > 50 { + anyhow::bail!("Radius must be 1-50"); + } + + let mut doc = state.cache.get(document_id).await?; + + let segment = doc + .segment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?; + + let gray = segment.to_luma8(); + let eroded = imageproc::morphology::erode(&gray, Norm::LInf, radius); + doc.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(eroded))); + state.cache.put(&doc).await?; + Ok(()) +} + +pub async fn update_inpaint_mask( + state: AppResources, + document_id: &str, + mask: &[u8], + region: Option, +) -> anyhow::Result<()> { + let mut doc = state.cache.get(document_id).await?; + + let update_image = image::load_from_memory(mask)?; + let (doc_width, doc_height) = (doc.width, doc.height); + + let mut base_mask = doc + .segment + .clone() + .unwrap_or_else(|| blank_rgba(doc_width, doc_height, image::Rgba([0, 0, 0, 255]))) + .to_rgba8(); + + match region { + Some(region) => { + let (patch_width, patch_height) = update_image.dimensions(); + if patch_width != region.width || patch_height != region.height { + anyhow::bail!( + "Mask patch size mismatch: expected {}x{}, got {}x{}", + region.width, + region.height, + patch_width, + patch_height + ); + } + + let x0 = region.x.min(doc_width.saturating_sub(1)); + let y0 = region.y.min(doc_height.saturating_sub(1)); + let x1 = region.x.saturating_add(region.width).min(doc_width); + let y1 = region.y.saturating_add(region.height).min(doc_height); + + if x1 <= x0 || y1 <= y0 { + return Ok(()); + } + + let patch_rgba = update_image.to_rgba8(); + for y in 0..(y1 - y0) { + for x in 0..(x1 - x0) { + base_mask.put_pixel(x0 + x, y0 + y, *patch_rgba.get_pixel(x, y)); + } + } + } + None => { + let (mask_width, mask_height) = update_image.dimensions(); + if mask_width != doc_width || mask_height != doc_height { + anyhow::bail!( + "Mask size mismatch: expected {}x{}, got {}x{}", + doc_width, + doc_height, + mask_width, + mask_height + ); + } + base_mask = update_image.to_rgba8(); + } + } + + doc.segment = Some(image::DynamicImage::ImageRgba8(base_mask).into()); + state.cache.put(&doc).await?; + Ok(()) +} + +pub async fn update_brush_layer( + state: AppResources, + document_id: &str, + patch: &[u8], + brush_region: Region, +) -> anyhow::Result<()> { + let mut doc = state.cache.get(document_id).await?; + + let (img_width, img_height) = (doc.width, doc.height); + let Some((x0, y0, width, height)) = brush_region.clamp(img_width, img_height) else { + return Ok(()); + }; + + let patch_image = image::load_from_memory(patch)?; + let (patch_width, patch_height) = patch_image.dimensions(); + + if patch_width != brush_region.width || patch_height != brush_region.height { + anyhow::bail!( + "Brush patch size mismatch: expected {}x{}, got {}x{}", + brush_region.width, + brush_region.height, + patch_width, + patch_height + ); + } + + let brush_rgba = patch_image.to_rgba8(); + let mut brush_layer = doc + .brush_layer + .clone() + .unwrap_or_else(|| blank_rgba(img_width, img_height, image::Rgba([0, 0, 0, 0]))) + .to_rgba8(); + + for y in 0..height { + for x in 0..width { + brush_layer.put_pixel(x0 + x, y0 + y, *brush_rgba.get_pixel(x, y)); + } + } + + doc.brush_layer = Some(image::DynamicImage::ImageRgba8(brush_layer).into()); + state.cache.put(&doc).await?; + Ok(()) +} + +#[instrument(level = "info", skip_all)] +pub async fn inpaint_partial( + state: AppResources, + document_id: &str, + inpaint_region: Region, +) -> anyhow::Result<()> { + let mut doc = state.cache.get(document_id).await?; + + let mask_image = doc + .segment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Segment image not found"))?; + + if inpaint_region.width == 0 || inpaint_region.height == 0 { + return Ok(()); + } + + let (img_width, img_height) = (doc.width, doc.height); + let x0 = inpaint_region.x.min(img_width.saturating_sub(1)); + let y0 = inpaint_region.y.min(img_height.saturating_sub(1)); + let x1 = inpaint_region + .x + .saturating_add(inpaint_region.width) + .min(img_width); + let y1 = inpaint_region + .y + .saturating_add(inpaint_region.height) + .min(img_height); + let crop_width = x1.saturating_sub(x0); + let crop_height = y1.saturating_sub(y0); + + if crop_width == 0 || crop_height == 0 { + return Ok(()); + } + + let localized_blocks = + localize_inpaint_text_blocks(&doc.text_blocks, x0, y0, crop_width, crop_height); + if localized_blocks.is_empty() { + return Ok(()); + } + + let image_crop = SerializableDynamicImage(doc.image.crop_imm(x0, y0, crop_width, crop_height)); + let mask_crop = SerializableDynamicImage(mask_image.crop_imm(x0, y0, crop_width, crop_height)); + + let inpainted_crop = state + .ml + .inpaint_raw(&image_crop, &mask_crop, Some(&localized_blocks)) + .await?; + + let mut stitched = doc.inpainted.as_ref().unwrap_or(&doc.image).to_rgba8(); + + let patch = inpainted_crop.to_rgba8(); + paste_crop(&mut stitched, &patch, x0, y0); + + doc.inpainted = Some(image::DynamicImage::ImageRgba8(stitched).into()); + state.cache.put(&doc).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{localize_inpaint_text_blocks, paste_crop}; + use image::{Rgba, RgbaImage}; + use koharu_core::TextBlock; + + #[test] + fn partial_inpaint_blocks_are_localized_to_crop() { + let block = TextBlock { + x: 40.0, + y: 30.0, + width: 40.0, + height: 30.0, + line_polygons: Some(vec![[ + [42.0, 32.0], + [78.0, 32.0], + [78.0, 40.0], + [42.0, 40.0], + ]]), + ..Default::default() + }; + + let localized = localize_inpaint_text_blocks(&[block], 50, 20, 40, 30); + assert_eq!(localized.len(), 1); + assert_eq!(localized[0].x, 0.0); + assert_eq!(localized[0].y, 10.0); + assert_eq!(localized[0].width, 30.0); + assert_eq!(localized[0].height, 20.0); + assert_eq!( + localized[0].line_polygons, + Some(vec![[[0.0, 12.0], [28.0, 12.0], [28.0, 20.0], [0.0, 20.0]]]) + ); + } + + #[test] + fn partial_inpaint_with_no_overlapping_blocks_returns_empty_list() { + let block = TextBlock { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + ..Default::default() + }; + + let localized = localize_inpaint_text_blocks(&[block], 50, 20, 40, 30); + assert!(localized.is_empty()); + } + + #[test] + fn crop_paste_replaces_entire_returned_patch() { + let mut stitched = RgbaImage::from_pixel(8, 8, Rgba([0, 0, 0, 255])); + let patch = RgbaImage::from_pixel(3, 3, Rgba([255, 0, 0, 255])); + + paste_crop(&mut stitched, &patch, 2, 2); + + assert_eq!(stitched.get_pixel(2, 2).0, [255, 0, 0, 255]); + assert_eq!(stitched.get_pixel(4, 4).0, [255, 0, 0, 255]); + assert_eq!(stitched.get_pixel(1, 1).0, [0, 0, 0, 255]); + } +} diff --git a/koharu-app/src/ops/core.rs b/koharu-app/src/io.rs similarity index 72% rename from koharu-app/src/ops/core.rs rename to koharu-app/src/io.rs index 87c584046700dbf08b43901fa136eaac578c02db..4dfba04a3222645396fc2e0dff493543e30b016c 100644 --- a/koharu-app/src/ops/core.rs +++ b/koharu-app/src/io.rs @@ -2,14 +2,12 @@ use std::path::{Path, PathBuf}; use image::ImageFormat; use koharu_core::commands::{ - DeviceInfo, FileResult, IndexPayload, OpenDocumentsPayload, OpenExternalPayload, - ThumbnailResult, + DeviceInfo, FileResult, OpenDocumentsPayload, OpenExternalPayload, ThumbnailResult, }; use rfd::FileDialog; -use crate::{AppResources, state_tx}; - -use super::utils::{encode_image, load_documents, mime_from_ext}; +use crate::AppResources; +use crate::utils::{encode_image, mime_from_ext}; fn next_available_path(output_dir: &Path, stem: &str, ext: &str) -> PathBuf { let mut candidate = output_dir.join(format!("{stem}.{ext}")); @@ -25,15 +23,6 @@ async fn pick_output_dir() -> anyhow::Result> { Ok(tokio::task::spawn_blocking(|| FileDialog::new().pick_folder()).await?) } -fn document_ext(document: &koharu_core::Document) -> String { - document - .path - .extension() - .and_then(|value| value.to_str()) - .unwrap_or("jpg") - .to_string() -} - fn export_documents_matching( documents: &[koharu_core::Document], output_dir: &Path, @@ -48,7 +37,12 @@ fn export_documents_matching( continue; }; - let ext = document_ext(document); + let ext = document + .path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("jpg") + .to_string(); let output_path = next_available_path(output_dir, &format!("{}_{}", document.name, suffix), &ext); let bytes = encode_image(image, &ext)?; @@ -83,22 +77,21 @@ pub async fn open_external( } pub async fn get_documents(state: AppResources) -> anyhow::Result { - let guard = state.state.read().await; - Ok(guard.documents.len()) + Ok(state.cache.list_documents()?.len()) } pub async fn get_document( state: AppResources, - payload: IndexPayload, + document_id: &str, ) -> anyhow::Result { - state_tx::read_doc(&state.state, payload.index).await + state.cache.get(document_id).await } pub async fn get_thumbnail( state: AppResources, - payload: IndexPayload, + document_id: &str, ) -> anyhow::Result { - let doc = state_tx::read_doc(&state.state, payload.index).await?; + let doc = state.cache.get(document_id).await?; let source = doc.rendered.as_ref().unwrap_or(&doc.image); let thumbnail = source.thumbnail(200, 200); @@ -116,43 +109,33 @@ pub async fn open_documents( state: AppResources, payload: OpenDocumentsPayload, ) -> anyhow::Result { - let inputs: Vec<(PathBuf, Vec)> = payload - .files - .into_iter() - .map(|f| (PathBuf::from(f.name), f.data)) - .collect(); - - if inputs.is_empty() { + if payload.files.is_empty() { anyhow::bail!("No files uploaded"); } - let docs = load_documents(inputs)?; - state_tx::replace_docs(&state.state, docs).await + let manifests = state.cache.import_files(payload.files)?; + let count = manifests.len(); + state.cache.replace_manifests(&manifests).await?; + Ok(count) } pub async fn add_documents( state: AppResources, payload: OpenDocumentsPayload, ) -> anyhow::Result { - let inputs: Vec<(PathBuf, Vec)> = payload - .files - .into_iter() - .map(|f| (PathBuf::from(f.name), f.data)) - .collect(); - - if inputs.is_empty() { + if payload.files.is_empty() { anyhow::bail!("No files uploaded"); } - let docs = load_documents(inputs)?; - state_tx::append_docs(&state.state, docs).await + let manifests = state.cache.import_files(payload.files)?; + for manifest in &manifests { + state.cache.save_manifest(manifest)?; + } + Ok(state.cache.list_documents()?.len()) } -pub async fn export_document( - state: AppResources, - payload: IndexPayload, -) -> anyhow::Result { - let document = state_tx::read_doc(&state.state, payload.index).await?; +pub async fn export_document(state: AppResources, document_id: &str) -> anyhow::Result { + let document = state.cache.get(document_id).await?; let ext = document .path @@ -182,9 +165,18 @@ pub async fn export_all_inpainted(state: AppResources) -> anyhow::Result return Ok(0); }; - let guard = state.state.read().await; + let page_ids: Vec = state + .cache + .list_documents()? + .into_iter() + .map(|e| e.id) + .collect(); + let mut documents = Vec::new(); + for id in &page_ids { + documents.push(state.cache.get(id).await?); + } export_documents_matching( - &guard.documents, + &documents, &output_dir, "inpainted", "No inpainted images found to export", @@ -197,9 +189,18 @@ pub async fn export_all_rendered(state: AppResources) -> anyhow::Result { return Ok(0); }; - let guard = state.state.read().await; + let page_ids: Vec = state + .cache + .list_documents()? + .into_iter() + .map(|e| e.id) + .collect(); + let mut documents = Vec::new(); + for id in &page_ids { + documents.push(state.cache.get(id).await?); + } export_documents_matching( - &guard.documents, + &documents, &output_dir, "rendered", "No rendered images found to export", diff --git a/koharu-app/src/lib.rs b/koharu-app/src/lib.rs index 445242627faf4dc331e04192fa12077b24783279..dee54945be80f05624e7eee0313488db067a4522 100644 --- a/koharu-app/src/lib.rs +++ b/koharu-app/src/lib.rs @@ -1,23 +1,27 @@ +pub mod blob_store; pub mod config; +pub mod edit; +pub mod io; pub mod llm; +pub mod manifest; pub mod ml; -pub mod operations; -pub mod ops; +pub mod page_cache; pub mod pipeline; pub mod renderer; -pub mod state_tx; +pub mod utils; use std::sync::Arc; -use koharu_core::AppState; use koharu_ml::Device; use koharu_runtime::RuntimeManager; use tokio::sync::RwLock; +use crate::page_cache::PageCache; + #[derive(Clone)] pub struct AppResources { pub runtime: RuntimeManager, - pub state: AppState, + pub cache: PageCache, pub ml: Arc, pub llm: Arc, pub renderer: Arc, diff --git a/koharu-app/src/llm.rs b/koharu-app/src/llm.rs index 6c263bdd18c8805e9e6afb4e5dcff26cee0a702c..a0d421985768266fefb1ef9c47b980a09a342e34 100644 --- a/koharu-app/src/llm.rs +++ b/koharu-app/src/llm.rs @@ -1,15 +1,28 @@ +use std::str::FromStr; use std::sync::Arc; -use serde::Serialize; +use koharu_llm::providers::{ + AnyProvider, ProviderCatalogModels, ProviderConfig, all_provider_descriptors, build_provider, + discover_models, get_saved_api_key, +}; use tokio::sync::{RwLock, broadcast}; +use tracing::instrument; -use koharu_core::{Document, LlmState, LlmStateStatus, TextBlock}; +use koharu_core::{ + Document, LlmCatalog, LlmCatalogModel, LlmGenerationOptions, LlmLoadRequest, + LlmProviderCatalog, LlmProviderCatalogStatus, LlmState, LlmStateStatus, LlmTarget, + LlmTargetKind, TextBlock, +}; use koharu_runtime::RuntimeManager; use koharu_llm::{ GenerateOptions, Language, Llm, ModelId, language::tags as language_tags, safe::llama_backend::LlamaBackend, supported_locales, }; +use strum::IntoEnumIterator; + +use crate::AppResources; +use crate::config as app_config; pub use koharu_llm::prefetch; @@ -26,50 +39,25 @@ struct BlockEndTag { len: usize, } -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ModelInfo { - pub id: String, - pub languages: Vec, - pub source: &'static str, -} - -impl ModelInfo { - pub fn new(id: ModelId) -> Self { - let languages = id.languages(); - Self { - id: id.to_string(), - languages: language_tags(&languages), - source: "local", - } - } - - pub fn api(provider_id: &'static str, model_id: &str) -> Self { - Self { - id: format!("{provider_id}:{model_id}"), - languages: supported_locales(), - source: provider_id, - } - } -} - #[allow(clippy::large_enum_variant)] #[derive(strum::Display)] pub enum State { #[strum(serialize = "empty")] Empty, #[strum(serialize = "loading")] - Loading { model_id: String, source: String }, + Loading { target: LlmTarget }, #[strum(serialize = "ready")] - Ready(Llm), + ReadyLocal(Llm), #[strum(serialize = "ready")] - ApiReady { - provider: Box, - provider_id: String, - model: String, + ReadyProvider { + target: LlmTarget, + provider: Box, }, #[strum(serialize = "failed")] - Failed(String), + Failed { + target: Option, + error: String, + }, } pub struct Model { @@ -85,6 +73,58 @@ pub trait Translatable { fn set_translation(&mut self, translation: String) -> anyhow::Result<()>; } +fn local_target(id: ModelId) -> LlmTarget { + LlmTarget { + kind: LlmTargetKind::Local, + model_id: id.to_string(), + provider_id: None, + } +} + +fn provider_target(provider_id: &str, model_id: &str) -> LlmTarget { + LlmTarget { + kind: LlmTargetKind::Provider, + model_id: model_id.to_string(), + provider_id: Some(provider_id.to_string()), + } +} + +fn validate_target(target: &LlmTarget) -> anyhow::Result<()> { + match target.kind { + LlmTargetKind::Local => { + anyhow::ensure!( + target.provider_id.is_none(), + "local targets must not include provider_id" + ); + } + LlmTargetKind::Provider => { + anyhow::ensure!( + target + .provider_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty()), + "provider targets require provider_id" + ); + } + } + + anyhow::ensure!( + !target.model_id.trim().is_empty(), + "target model_id is required" + ); + Ok(()) +} + +fn state_target(state: &State) -> Option { + match state { + State::Empty => None, + State::Loading { target } => Some(target.clone()), + State::ReadyLocal(llm) => Some(local_target(llm.id())), + State::ReadyProvider { target, .. } => Some(target.clone()), + State::Failed { target, .. } => target.clone(), + } +} + fn escape_block_text(text: &str) -> String { text.replace('&', "&") .replace('<', "<") @@ -453,28 +493,22 @@ impl Model { self.cpu } - pub async fn load_api( + pub async fn load_provider( &self, - provider_id: &str, - model_id: &str, - config: koharu_llm::providers::ProviderConfig, + target: LlmTarget, + provider: Box, ) -> anyhow::Result<()> { - let provider = koharu_llm::providers::build_provider(provider_id, config)?; - *self.state.write().await = State::ApiReady { - provider, - provider_id: provider_id.to_string(), - model: model_id.to_string(), - }; + *self.state.write().await = State::ReadyProvider { target, provider }; self.emit_state().await; Ok(()) } - pub async fn load(&self, id: ModelId) { + pub async fn load_local(&self, id: ModelId) { + let target = local_target(id); { let mut guard = self.state.write().await; *guard = State::Loading { - model_id: id.to_string(), - source: "local".to_string(), + target: target.clone(), }; } self.emit_state().await; @@ -489,12 +523,15 @@ impl Model { match res { Ok(llm) => { let mut guard = state_cloned.write().await; - *guard = State::Ready(llm); + *guard = State::ReadyLocal(llm); } Err(e) => { tracing::error!("LLM load join error: {e}"); let mut guard = state_cloned.write().await; - *guard = State::Failed(format!("join error: {e}")); + *guard = State::Failed { + target: Some(target), + error: format!("join error: {e}"), + }; } } let snapshot = { @@ -521,10 +558,15 @@ impl Model { pub async fn ready(&self) -> bool { matches!( *self.state.read().await, - State::Ready(_) | State::ApiReady { .. } + State::ReadyLocal(_) | State::ReadyProvider { .. } ) } + pub async fn current_target(&self) -> Option { + let guard = self.state.read().await; + state_target(&guard) + } + pub fn subscribe(&self) -> broadcast::Receiver { self.state_tx.subscribe() } @@ -553,17 +595,16 @@ impl Model { } let mut guard = self.state.write().await; let translation = match &mut *guard { - State::Ready(llm) => { + State::ReadyLocal(llm) => { llm.generate(&source, &GenerateOptions::default(), target_language) } - State::ApiReady { - provider, model, .. - } => { - let model = model.clone(); - provider.translate(&source, target_language, &model).await + State::ReadyProvider { target, provider } => { + provider + .translate(&source, target_language, &target.model_id) + .await } State::Loading { .. } => Err(anyhow::anyhow!("Model is still loading")), - State::Failed(e) => Err(anyhow::anyhow!("Model failed to load: {e}")), + State::Failed { error, .. } => Err(anyhow::anyhow!("Model failed to load: {error}")), State::Empty => Err(anyhow::anyhow!("No model is loaded")), }?; doc.set_translation(translation.trim().to_string()) @@ -574,39 +615,308 @@ fn snapshot_from_state(state: &State) -> LlmState { match state { State::Empty => LlmState { status: LlmStateStatus::Empty, - model_id: None, - source: None, + target: None, error: None, }, - State::Loading { model_id, source } => LlmState { + State::Loading { target } => LlmState { status: LlmStateStatus::Loading, - model_id: Some(model_id.clone()), - source: Some(source.clone()), + target: Some(target.clone()), error: None, }, - State::Ready(llm) => LlmState { + State::ReadyLocal(llm) => LlmState { status: LlmStateStatus::Ready, - model_id: Some(llm.id().to_string()), - source: Some("local".to_string()), + target: Some(local_target(llm.id())), error: None, }, - State::ApiReady { - provider_id, model, .. - } => LlmState { + State::ReadyProvider { target, .. } => LlmState { status: LlmStateStatus::Ready, - model_id: Some(format!("{provider_id}:{model}")), - source: Some(provider_id.clone()), + target: Some(target.clone()), error: None, }, - State::Failed(error) => LlmState { + State::Failed { target, error } => LlmState { status: LlmStateStatus::Failed, - model_id: None, - source: None, + target: target.clone(), error: Some(error.clone()), }, } } +// --------------------------------------------------------------------------- +// Operations (merged from ops/llm.rs) +// --------------------------------------------------------------------------- + +fn sorted_local_models(is_cpu: bool, language: Option<&str>) -> Vec { + let mut models: Vec = ModelId::iter().collect(); + let cpu_factor = if is_cpu { 10 } else { 1 }; + let lang = language.unwrap_or("en"); + let zh_locale_factor = if lang.starts_with("zh") { 10 } else { 1 }; + let non_zh_en_locale_factor = if lang.starts_with("zh") || lang.starts_with("en") { + 1 + } else { + 100 + }; + + models.sort_by_key(|m| match m { + ModelId::VntlLlama3_8Bv2 => 100, + ModelId::Lfm2_350mEnjpMt => 200 / cpu_factor, + ModelId::SakuraGalTransl7Bv3_7 => 300 / zh_locale_factor, + ModelId::Sakura1_5bQwen2_5v1_0 => 400 / zh_locale_factor / cpu_factor, + ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor, + }); + models +} + +fn local_catalog_models(is_cpu: bool, language: Option<&str>) -> Vec { + sorted_local_models(is_cpu, language) + .into_iter() + .map(|model| LlmCatalogModel { + target: local_target(model), + name: model.to_string(), + languages: language_tags(&model.languages()), + }) + .collect() +} + +async fn provider_catalog(state: &AppResources) -> anyhow::Result> { + let config = app_config::load()?; + let mut providers = Vec::new(); + + for descriptor in all_provider_descriptors() { + let stored = config.llm.providers.get(descriptor.id); + let base_url = stored.and_then(|provider| provider.base_url.clone()); + let api_key = get_saved_api_key(descriptor.id)?; + let has_api_key = api_key + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + let missing_required_config = (descriptor.requires_api_key && !has_api_key) + || (descriptor.requires_base_url + && base_url + .as_deref() + .is_none_or(|value| value.trim().is_empty())); + + let (status, error, models) = if missing_required_config { + ( + LlmProviderCatalogStatus::MissingConfiguration, + None, + static_provider_models(descriptor), + ) + } else { + match &descriptor.models { + ProviderCatalogModels::Static(_) => ( + LlmProviderCatalogStatus::Ready, + None, + static_provider_models(descriptor), + ), + ProviderCatalogModels::Dynamic(_) => { + let models = discover_models( + descriptor.id, + ProviderConfig { + http_client: state.runtime.http_client(), + api_key, + base_url: base_url.clone(), + temperature: None, + max_tokens: None, + custom_system_prompt: None, + }, + )? + .await; + + match models { + Ok(models) => ( + LlmProviderCatalogStatus::Ready, + None, + models + .into_iter() + .map(|model| LlmCatalogModel { + target: provider_target(descriptor.id, &model.id), + name: model.name, + languages: supported_locales(), + }) + .collect(), + ), + Err(error) => ( + LlmProviderCatalogStatus::DiscoveryFailed, + Some(error.to_string()), + Vec::new(), + ), + } + } + } + }; + + providers.push(LlmProviderCatalog { + id: descriptor.id.to_string(), + name: descriptor.name.to_string(), + requires_api_key: descriptor.requires_api_key, + requires_base_url: descriptor.requires_base_url, + has_api_key, + base_url, + status, + error, + models, + }); + } + + Ok(providers) +} + +fn static_provider_models( + descriptor: &koharu_llm::providers::ProviderDescriptor, +) -> Vec { + match &descriptor.models { + ProviderCatalogModels::Static(models) => models + .iter() + .map(|model| LlmCatalogModel { + target: provider_target(descriptor.id, model.id), + name: model.name.to_string(), + languages: supported_locales(), + }) + .collect(), + ProviderCatalogModels::Dynamic(_) => Vec::new(), + } +} + +fn provider_config_from_settings( + state: &AppResources, + target: &LlmTarget, + options: Option<&LlmGenerationOptions>, +) -> anyhow::Result { + validate_target(target)?; + let provider_id = target + .provider_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("provider targets require provider_id"))?; + let config = app_config::load()?; + let stored = config.llm.providers.get(provider_id); + + Ok(ProviderConfig { + http_client: state.runtime.http_client(), + api_key: get_saved_api_key(provider_id)?, + base_url: stored.and_then(|provider| provider.base_url.clone()), + temperature: options.and_then(|options| options.temperature), + max_tokens: options.and_then(|options| options.max_tokens), + custom_system_prompt: options.and_then(|options| options.custom_system_prompt.clone()), + }) +} + +async fn load_target( + state: &AppResources, + target: &LlmTarget, + options: Option<&LlmGenerationOptions>, +) -> anyhow::Result<()> { + validate_target(target)?; + if state.llm.current_target().await.as_ref() == Some(target) && state.llm.ready().await { + return Ok(()); + } + + match target.kind { + LlmTargetKind::Local => { + let model = ModelId::from_str(&target.model_id)?; + state.llm.load_local(model).await; + } + LlmTargetKind::Provider => { + let provider_id = target + .provider_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("provider targets require provider_id"))?; + let provider = build_provider( + provider_id, + provider_config_from_settings(state, target, options)?, + )?; + state.llm.load_provider(target.clone(), provider).await?; + } + } + + Ok(()) +} + +pub async fn ensure_target_ready( + state: &AppResources, + target: &LlmTarget, + options: Option<&LlmGenerationOptions>, +) -> anyhow::Result<()> { + load_target(state, target, options).await?; + if target.kind != LlmTargetKind::Local { + return Ok(()); + } + + for _ in 0..300 { + let snapshot = state.llm.snapshot().await; + match snapshot.status { + LlmStateStatus::Ready if snapshot.target.as_ref() == Some(target) => return Ok(()), + LlmStateStatus::Failed if snapshot.target.as_ref() == Some(target) => { + anyhow::bail!( + "{}", + snapshot + .error + .unwrap_or_else(|| "LLM failed to load".to_string()) + ); + } + _ => {} + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + + anyhow::bail!("LLM failed to load within timeout"); +} + +pub async fn llm_catalog( + state: AppResources, + language: Option<&str>, +) -> anyhow::Result { + Ok(LlmCatalog { + local_models: local_catalog_models(state.llm.is_cpu(), language), + providers: provider_catalog(&state).await?, + }) +} + +#[instrument(level = "info", skip_all)] +pub async fn llm_load(state: AppResources, payload: LlmLoadRequest) -> anyhow::Result<()> { + load_target(&state, &payload.target, payload.options.as_ref()).await +} + +pub async fn llm_offload(state: AppResources) -> anyhow::Result<()> { + state.llm.offload().await; + Ok(()) +} + +pub async fn llm_ready(state: AppResources) -> anyhow::Result { + Ok(state.llm.ready().await) +} + +#[instrument(level = "info", skip_all)] +pub async fn llm_generate( + state: AppResources, + document_id: &str, + text_block_index: Option, + language: Option<&str>, +) -> anyhow::Result<()> { + let mut doc = state.cache.get(document_id).await?; + + match text_block_index { + Some(block_index) => { + let text_block = doc + .text_blocks + .get_mut(block_index) + .ok_or_else(|| anyhow::anyhow!("Text block not found"))?; + state.llm.translate(text_block, language).await?; + } + None => { + state.llm.translate(&mut doc, language).await?; + } + } + + state.cache.put(&doc).await?; + Ok(()) +} + +pub async fn get_document_for_llm( + state: AppResources, + document_id: &str, +) -> anyhow::Result { + state.cache.get(document_id).await +} + #[cfg(test)] mod tests { use koharu_core::Document; diff --git a/koharu-app/src/manifest.rs b/koharu-app/src/manifest.rs new file mode 100644 index 0000000000000000000000000000000000000000..8f97fda28c4d55a550ce3889d57e859d10051785 --- /dev/null +++ b/koharu-app/src/manifest.rs @@ -0,0 +1,104 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use koharu_core::TextBlock; +use serde::{Deserialize, Serialize}; + +/// Per-page manifest stored as JSON at `~/.koharu/pages/{id}.json`. +/// +/// JSON is used instead of TOML because `TextBlock` contains types that TOML +/// cannot represent (tuples in `FontPrediction::top_fonts`, fixed-size arrays +/// in `line_polygons`, binary image data in `rendered`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageManifest { + pub id: String, + /// Blob hash of the original image. + pub source: String, + pub name: String, + pub width: u32, + pub height: u32, + #[serde(default)] + pub layers: LayerRefs, + #[serde(default)] + pub text_blocks: Vec, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LayerRefs { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segment: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inpainted: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rendered: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brush_layer: Option, +} + +/// Manages manifest files at `~/.koharu/pages/`. +#[derive(Clone)] +pub struct ManifestStore { + root: PathBuf, +} + +impl ManifestStore { + pub fn new(root: impl Into) -> Result { + let root = root.into(); + std::fs::create_dir_all(&root)?; + Ok(Self { root }) + } + + pub fn save(&self, manifest: &PageManifest) -> Result<()> { + let path = self.manifest_path(&manifest.id); + let content = + serde_json::to_string_pretty(manifest).context("Failed to serialize manifest")?; + std::fs::write(&path, content) + .with_context(|| format!("Failed to write manifest {}", manifest.id))?; + Ok(()) + } + + pub fn load(&self, id: &str) -> Result { + let path = self.manifest_path(id); + let content = + std::fs::read_to_string(&path).with_context(|| format!("Manifest not found: {id}"))?; + serde_json::from_str(&content).with_context(|| format!("Failed to parse manifest {id}")) + } + + pub fn load_all(&self) -> Result> { + let mut manifests = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&self.root) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") + && let Ok(content) = std::fs::read_to_string(&path) + && let Ok(manifest) = serde_json::from_str::(&content) + { + manifests.push(manifest); + } + } + } + Ok(manifests) + } + + pub fn replace_all(&self, manifests: &[PageManifest]) -> Result<()> { + if let Ok(entries) = std::fs::read_dir(&self.root) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") { + std::fs::remove_file(&path) + .with_context(|| format!("Failed to remove manifest {}", path.display()))?; + } + } + } + + for manifest in manifests { + self.save(manifest)?; + } + + Ok(()) + } + + fn manifest_path(&self, id: &str) -> PathBuf { + self.root.join(format!("{id}.json")) + } +} diff --git a/koharu-app/src/ml.rs b/koharu-app/src/ml.rs index 8a117d3fe89108dc581dc8f61c961efad771fef1..959b8fd7a405bcb37509588c134bf837d5588d03 100644 --- a/koharu-app/src/ml.rs +++ b/koharu-app/src/ml.rs @@ -5,16 +5,21 @@ use std::{ use anyhow::Result; use image::DynamicImage; -use koharu_core::{Document, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection}; +use koharu_core::{ + Document, FontFaceInfo, FontPrediction, SerializableDynamicImage, TextBlock, TextDirection, + TextShaderEffect, TextStrokeStyle, +}; use koharu_llm::paddleocr_vl::{self as paddleocr_vl_llm, PaddleOcrVl, PaddleOcrVlTask}; use koharu_llm::safe::llama_backend::LlamaBackend; use koharu_runtime::RuntimeManager; +use tracing::instrument; use koharu_ml::comic_text_detector::{self, ComicTextDetector, crop_text_block_bbox}; use koharu_ml::font_detector::{self, FontDetector}; use koharu_ml::lama::{self, Lama}; use koharu_ml::pp_doclayout_v3::{self, LayoutRegion, PPDocLayoutV3}; +use crate::AppResources; const NEAR_BLACK_THRESHOLD: u8 = 12; const GRAY_NEAR_BLACK_THRESHOLD: u8 = 60; const NEAR_WHITE_THRESHOLD: u8 = 12; @@ -358,6 +363,61 @@ fn overlap_area(a: [f32; 4], b: [f32; 4]) -> f32 { } } +// --------------------------------------------------------------------------- +// Operations (merged from ops/vision.rs) +// --------------------------------------------------------------------------- + +#[instrument(level = "info", skip_all)] +pub async fn detect(state: AppResources, document_id: &str) -> Result<()> { + let mut doc = state.cache.get(document_id).await?; + state.ml.detect(&mut doc).await?; + state.cache.put(&doc).await?; + Ok(()) +} + +#[instrument(level = "info", skip_all)] +pub async fn ocr(state: AppResources, document_id: &str) -> Result<()> { + let mut doc = state.cache.get(document_id).await?; + state.ml.ocr(&mut doc).await?; + state.cache.put(&doc).await?; + Ok(()) +} + +#[instrument(level = "info", skip_all)] +pub async fn inpaint(state: AppResources, document_id: &str) -> Result<()> { + let mut doc = state.cache.get(document_id).await?; + state.ml.inpaint(&mut doc).await?; + state.cache.put(&doc).await?; + Ok(()) +} + +#[instrument(level = "info", skip_all)] +pub async fn render( + state: AppResources, + document_id: &str, + text_block_index: Option, + shader_effect: Option, + shader_stroke: Option, + font_family: Option<&str>, +) -> Result<()> { + let mut doc = state.cache.get(document_id).await?; + + state.renderer.render( + &mut doc, + text_block_index, + shader_effect.unwrap_or_default(), + shader_stroke, + font_family, + )?; + + state.cache.put(&doc).await?; + Ok(()) +} + +pub async fn list_font_families(state: AppResources) -> Result> { + state.renderer.available_fonts() +} + #[cfg(test)] mod tests { use super::*; diff --git a/koharu-app/src/operations.rs b/koharu-app/src/operations.rs deleted file mode 100644 index f2124414c19e6214af8e43b6125943db535a12a5..0000000000000000000000000000000000000000 --- a/koharu-app/src/operations.rs +++ /dev/null @@ -1 +0,0 @@ -pub use crate::ops::*; diff --git a/koharu-app/src/ops/edit.rs b/koharu-app/src/ops/edit.rs deleted file mode 100644 index 8d989c508c5c48329b64f756802aeaf10a1a6f2e..0000000000000000000000000000000000000000 --- a/koharu-app/src/ops/edit.rs +++ /dev/null @@ -1,777 +0,0 @@ -use image::DynamicImage; -use image::GenericImageView; -use imageproc::distance_transform::Norm; -use koharu_core::commands::{ - AddTextBlockPayload, InpaintPartialPayload, MaskMorphPayload, RemoveTextBlockPayload, - UpdateBrushLayerPayload, UpdateInpaintMaskPayload, UpdateTextBlockPayload, - UpdateTextBlocksPayload, -}; -use koharu_core::parse::parse_hex_color; -use koharu_core::views::{TextBlockInfo, to_block_info}; -use koharu_core::{SerializableDynamicImage, TextBlock, TextStyle}; -use tracing::instrument; - -use crate::{ - AppResources, - state_tx::{self, ChangedField}, -}; - -use super::utils::{InpaintRegionExt, blank_rgba}; - -const MATCH_GEOMETRY_EPS: f32 = 0.01; -const MATCH_NEAR_GEOMETRY_DELTA: f32 = 4.0; -const MATCH_TEXT_GEOMETRY_DELTA: f32 = 64.0; - -fn geometry_delta(a: &TextBlock, b: &TextBlock) -> f32 { - (a.x - b.x).abs() + (a.y - b.y).abs() + (a.width - b.width).abs() + (a.height - b.height).abs() -} - -fn geometry_changed(a: &TextBlock, b: &TextBlock) -> bool { - geometry_delta(a, b) > MATCH_GEOMETRY_EPS -} - -fn size_changed(a: &TextBlock, b: &TextBlock) -> bool { - (a.width - b.width).abs() > MATCH_GEOMETRY_EPS - || (a.height - b.height).abs() > MATCH_GEOMETRY_EPS -} - -fn geometry_overlaps(a: &TextBlock, b: &TextBlock) -> bool { - let ax0 = a.x; - let ay0 = a.y; - let ax1 = a.x + a.width; - let ay1 = a.y + a.height; - let bx0 = b.x; - let by0 = b.y; - let bx1 = b.x + b.width; - let by1 = b.y + b.height; - - ax0 < bx1 && ay0 < by1 && ax1 > bx0 && ay1 > by0 -} - -fn has_stable_content_identity(a: &TextBlock, b: &TextBlock) -> bool { - let has_content = - a.text.is_some() || a.translation.is_some() || b.text.is_some() || b.translation.is_some(); - has_content && a.text == b.text && a.translation == b.translation -} - -fn seed_from_block(block: &TextBlock) -> Option<(f32, f32, f32, f32)> { - match ( - block.layout_seed_x, - block.layout_seed_y, - block.layout_seed_width, - block.layout_seed_height, - ) { - (Some(x), Some(y), Some(width), Some(height)) - if width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0 => - { - Some((x, y, width, height)) - } - _ => None, - } -} - -fn find_matching_previous( - current: &TextBlock, - current_index: usize, - previous: &[TextBlock], - used_previous: &[bool], -) -> Option { - if current_index < previous.len() && !used_previous[current_index] { - let indexed = &previous[current_index]; - let delta = geometry_delta(current, indexed); - if delta <= MATCH_NEAR_GEOMETRY_DELTA - || geometry_overlaps(current, indexed) - || has_stable_content_identity(current, indexed) - { - return Some(current_index); - } - } - - let mut best_idx = None; - let mut best_delta = f32::INFINITY; - - for (idx, prev) in previous.iter().enumerate() { - if used_previous[idx] { - continue; - } - let delta = geometry_delta(current, prev); - if delta < best_delta { - best_idx = Some(idx); - best_delta = delta; - } - } - - let candidate_idx = best_idx?; - let candidate = &previous[candidate_idx]; - if best_delta <= MATCH_NEAR_GEOMETRY_DELTA { - return Some(candidate_idx); - } - - if has_stable_content_identity(current, candidate) && best_delta <= MATCH_TEXT_GEOMETRY_DELTA { - return Some(candidate_idx); - } - - None -} - -fn rehydrate_runtime_text_block_state(current: &mut TextBlock, previous: Option<&TextBlock>) { - let Some(prev) = previous else { - current.lock_layout_box = false; - current.set_layout_seed(current.x, current.y, current.width, current.height); - return; - }; - - if current.id.trim().is_empty() { - current.id = prev.id.clone(); - } - - current.lock_layout_box = if size_changed(current, prev) { - true - } else { - prev.lock_layout_box - }; - - if geometry_changed(current, prev) { - current.set_layout_seed(current.x, current.y, current.width, current.height); - } else if let Some((x, y, width, height)) = seed_from_block(prev) { - current.set_layout_seed(x, y, width, height); - } else { - current.set_layout_seed(current.x, current.y, current.width, current.height); - } -} - -fn block_bounds(block: &TextBlock) -> Option<(f32, f32, f32, f32)> { - let bx0 = block.x.max(0.0); - let by0 = block.y.max(0.0); - let bx1 = (block.x + block.width).max(bx0); - let by1 = (block.y + block.height).max(by0); - (bx1 > bx0 && by1 > by0).then_some((bx0, by0, bx1, by1)) -} - -fn localize_line_polygons( - polygons: &Option>, - x0: u32, - y0: u32, - crop_width: u32, - crop_height: u32, -) -> Option> { - polygons.as_ref().map(|polygons| { - polygons - .iter() - .map(|polygon| { - let mut localized = *polygon; - for point in &mut localized { - point[0] = (point[0] - x0 as f32).clamp(0.0, crop_width as f32); - point[1] = (point[1] - y0 as f32).clamp(0.0, crop_height as f32); - } - localized - }) - .collect() - }) -} - -fn localize_inpaint_text_blocks( - text_blocks: &[TextBlock], - x0: u32, - y0: u32, - crop_width: u32, - crop_height: u32, -) -> Vec { - let crop_x1 = x0 + crop_width; - let crop_y1 = y0 + crop_height; - - text_blocks - .iter() - .filter_map(|block| { - let (bx0, by0, bx1, by1) = block_bounds(block)?; - let ix0 = bx0.max(x0 as f32); - let iy0 = by0.max(y0 as f32); - let ix1 = bx1.min(crop_x1 as f32); - let iy1 = by1.min(crop_y1 as f32); - if ix1 <= ix0 || iy1 <= iy0 { - return None; - } - - let mut localized = block.clone(); - localized.x = ix0 - x0 as f32; - localized.y = iy0 - y0 as f32; - localized.width = ix1 - ix0; - localized.height = iy1 - iy0; - localized.line_polygons = - localize_line_polygons(&block.line_polygons, x0, y0, crop_width, crop_height); - Some(localized) - }) - .collect() -} - -fn paste_crop(stitched: &mut image::RgbaImage, patch: &image::RgbaImage, x0: u32, y0: u32) { - image::imageops::replace(stitched, patch, i64::from(x0), i64::from(y0)); -} - -pub async fn update_text_blocks( - state: AppResources, - payload: UpdateTextBlocksPayload, -) -> anyhow::Result<()> { - state_tx::mutate_doc( - &state.state, - payload.index, - &[ChangedField::TextBlocks], - |document| { - let previous = std::mem::take(&mut document.text_blocks); - document.text_blocks = payload.text_blocks; - - let mut used_previous = vec![false; previous.len()]; - for (block_index, block) in document.text_blocks.iter_mut().enumerate() { - let matched_idx = - find_matching_previous(block, block_index, &previous, &used_previous); - if let Some(idx) = matched_idx { - used_previous[idx] = true; - rehydrate_runtime_text_block_state(block, Some(&previous[idx])); - } else { - rehydrate_runtime_text_block_state(block, None); - } - } - Ok(()) - }, - ) - .await -} - -pub async fn update_text_block( - state: AppResources, - payload: UpdateTextBlockPayload, -) -> anyhow::Result { - state_tx::mutate_doc( - &state.state, - payload.index, - &[ChangedField::TextBlocks], - |document| { - let block = document - .text_blocks - .get_mut(payload.text_block_index) - .ok_or_else(|| { - anyhow::anyhow!("Text block {} not found", payload.text_block_index) - })?; - let mut geometry_changed = false; - - if let Some(translation) = payload.translation { - block.translation = Some(translation); - } - if let Some(x) = payload.x { - block.x = x; - geometry_changed = true; - } - if let Some(y) = payload.y { - block.y = y; - geometry_changed = true; - } - if let Some(width) = payload.width { - block.width = width; - geometry_changed = true; - block.lock_layout_box = true; - } - if let Some(height) = payload.height { - block.height = height; - geometry_changed = true; - block.lock_layout_box = true; - } - if geometry_changed { - block.set_layout_seed(block.x, block.y, block.width, block.height); - } - - if payload.font_families.is_some() - || payload.font_size.is_some() - || payload.color.is_some() - || payload.shader_effect.is_some() - { - let style = block.style.get_or_insert_with(|| TextStyle { - font_families: Vec::new(), - font_size: None, - color: [0, 0, 0, 255], - effect: None, - stroke: None, - text_align: None, - }); - - if let Some(families) = payload.font_families { - style.font_families = families; - } - if let Some(font_size) = payload.font_size { - style.font_size = Some(font_size); - } - if let Some(hex) = payload.color { - style.color = parse_hex_color(&hex)?; - } - if let Some(effect) = payload.shader_effect { - style.effect = Some(effect.parse()?); - } - } - - block.rendered = None; - block.rendered_direction = None; - Ok(to_block_info(payload.text_block_index, block)) - }, - ) - .await -} - -pub async fn add_text_block( - state: AppResources, - payload: AddTextBlockPayload, -) -> anyhow::Result { - state_tx::mutate_doc( - &state.state, - payload.index, - &[ChangedField::TextBlocks], - |document| { - let mut block = TextBlock { - x: payload.x, - y: payload.y, - width: payload.width, - height: payload.height, - confidence: 1.0, - ..Default::default() - }; - block.set_layout_seed(block.x, block.y, block.width, block.height); - document.text_blocks.push(block); - Ok(document.text_blocks.len() - 1) - }, - ) - .await -} - -pub async fn remove_text_block( - state: AppResources, - payload: RemoveTextBlockPayload, -) -> anyhow::Result { - state_tx::mutate_doc( - &state.state, - payload.index, - &[ChangedField::TextBlocks], - |document| { - if payload.text_block_index >= document.text_blocks.len() { - anyhow::bail!("Text block {} not found", payload.text_block_index); - } - document.text_blocks.remove(payload.text_block_index); - Ok(document.text_blocks.len()) - }, - ) - .await -} - -pub async fn dilate_mask(state: AppResources, payload: MaskMorphPayload) -> anyhow::Result<()> { - if payload.radius == 0 || payload.radius > 50 { - anyhow::bail!("Radius must be 1-50"); - } - - state_tx::mutate_doc( - &state.state, - payload.index, - &[ChangedField::Segment], - |document| { - let segment = document - .segment - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?; - - let gray = segment.to_luma8(); - let dilated = imageproc::morphology::dilate(&gray, Norm::LInf, payload.radius); - document.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(dilated))); - Ok(()) - }, - ) - .await -} - -pub async fn erode_mask(state: AppResources, payload: MaskMorphPayload) -> anyhow::Result<()> { - if payload.radius == 0 || payload.radius > 50 { - anyhow::bail!("Radius must be 1-50"); - } - - state_tx::mutate_doc( - &state.state, - payload.index, - &[ChangedField::Segment], - |document| { - let segment = document - .segment - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No segment mask. Run detect first."))?; - - let gray = segment.to_luma8(); - let eroded = imageproc::morphology::erode(&gray, Norm::LInf, payload.radius); - document.segment = Some(SerializableDynamicImage(DynamicImage::ImageLuma8(eroded))); - Ok(()) - }, - ) - .await -} - -pub async fn update_inpaint_mask( - state: AppResources, - payload: UpdateInpaintMaskPayload, -) -> anyhow::Result<()> { - let snapshot = state_tx::read_doc(&state.state, payload.index).await?; - - let update_image = image::load_from_memory(&payload.mask)?; - let (doc_width, doc_height) = (snapshot.width, snapshot.height); - - let mut base_mask = snapshot - .segment - .clone() - .unwrap_or_else(|| blank_rgba(doc_width, doc_height, image::Rgba([0, 0, 0, 255]))) - .to_rgba8(); - - match payload.region { - Some(region) => { - let (patch_width, patch_height) = update_image.dimensions(); - if patch_width != region.width || patch_height != region.height { - anyhow::bail!( - "Mask patch size mismatch: expected {}x{}, got {}x{}", - region.width, - region.height, - patch_width, - patch_height - ); - } - - let x0 = region.x.min(doc_width.saturating_sub(1)); - let y0 = region.y.min(doc_height.saturating_sub(1)); - let x1 = region.x.saturating_add(region.width).min(doc_width); - let y1 = region.y.saturating_add(region.height).min(doc_height); - - if x1 <= x0 || y1 <= y0 { - return Ok(()); - } - - let patch_rgba = update_image.to_rgba8(); - for y in 0..(y1 - y0) { - for x in 0..(x1 - x0) { - base_mask.put_pixel(x0 + x, y0 + y, *patch_rgba.get_pixel(x, y)); - } - } - } - None => { - let (mask_width, mask_height) = update_image.dimensions(); - if mask_width != doc_width || mask_height != doc_height { - anyhow::bail!( - "Mask size mismatch: expected {}x{}, got {}x{}", - doc_width, - doc_height, - mask_width, - mask_height - ); - } - base_mask = update_image.to_rgba8(); - } - } - - let mut updated = snapshot; - updated.segment = Some(image::DynamicImage::ImageRgba8(base_mask).into()); - state_tx::update_doc( - &state.state, - payload.index, - updated, - &[ChangedField::Segment], - ) - .await -} - -pub async fn update_brush_layer( - state: AppResources, - payload: UpdateBrushLayerPayload, -) -> anyhow::Result<()> { - let snapshot = state_tx::read_doc(&state.state, payload.index).await?; - - let (img_width, img_height) = (snapshot.width, snapshot.height); - let Some((x0, y0, width, height)) = payload.region.clamp(img_width, img_height) else { - return Ok(()); - }; - - let patch_image = image::load_from_memory(&payload.patch)?; - let (patch_width, patch_height) = patch_image.dimensions(); - - if patch_width != payload.region.width || patch_height != payload.region.height { - anyhow::bail!( - "Brush patch size mismatch: expected {}x{}, got {}x{}", - payload.region.width, - payload.region.height, - patch_width, - patch_height - ); - } - - let brush_rgba = patch_image.to_rgba8(); - let mut brush_layer = snapshot - .brush_layer - .clone() - .unwrap_or_else(|| blank_rgba(img_width, img_height, image::Rgba([0, 0, 0, 0]))) - .to_rgba8(); - - for y in 0..height { - for x in 0..width { - brush_layer.put_pixel(x0 + x, y0 + y, *brush_rgba.get_pixel(x, y)); - } - } - - let mut updated = snapshot; - updated.brush_layer = Some(image::DynamicImage::ImageRgba8(brush_layer).into()); - - state_tx::update_doc( - &state.state, - payload.index, - updated, - &[ChangedField::BrushLayer], - ) - .await -} - -#[instrument(level = "info", skip_all)] -pub async fn inpaint_partial( - state: AppResources, - payload: InpaintPartialPayload, -) -> anyhow::Result<()> { - let snapshot = state_tx::read_doc(&state.state, payload.index).await?; - - let mask_image = snapshot - .segment - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Segment image not found"))?; - - if payload.region.width == 0 || payload.region.height == 0 { - return Ok(()); - } - - let (img_width, img_height) = (snapshot.width, snapshot.height); - let x0 = payload.region.x.min(img_width.saturating_sub(1)); - let y0 = payload.region.y.min(img_height.saturating_sub(1)); - let x1 = payload - .region - .x - .saturating_add(payload.region.width) - .min(img_width); - let y1 = payload - .region - .y - .saturating_add(payload.region.height) - .min(img_height); - let crop_width = x1.saturating_sub(x0); - let crop_height = y1.saturating_sub(y0); - - if crop_width == 0 || crop_height == 0 { - return Ok(()); - } - - let localized_blocks = - localize_inpaint_text_blocks(&snapshot.text_blocks, x0, y0, crop_width, crop_height); - if localized_blocks.is_empty() { - return Ok(()); - } - - let image_crop = - SerializableDynamicImage(snapshot.image.crop_imm(x0, y0, crop_width, crop_height)); - let mask_crop = SerializableDynamicImage(mask_image.crop_imm(x0, y0, crop_width, crop_height)); - - let inpainted_crop = state - .ml - .inpaint_raw(&image_crop, &mask_crop, Some(&localized_blocks)) - .await?; - - let mut stitched = snapshot - .inpainted - .as_ref() - .unwrap_or(&snapshot.image) - .to_rgba8(); - - let patch = inpainted_crop.to_rgba8(); - paste_crop(&mut stitched, &patch, x0, y0); - - let mut updated = snapshot; - updated.inpainted = Some(image::DynamicImage::ImageRgba8(stitched).into()); - - state_tx::update_doc( - &state.state, - payload.index, - updated, - &[ChangedField::Inpainted], - ) - .await -} - -#[cfg(test)] -mod tests { - use super::{ - find_matching_previous, localize_inpaint_text_blocks, paste_crop, - rehydrate_runtime_text_block_state, - }; - use image::{Rgba, RgbaImage}; - use koharu_core::TextBlock; - - #[test] - fn resized_block_locks_layout_box() { - let previous = TextBlock { - x: 10.0, - y: 20.0, - width: 100.0, - height: 80.0, - ..Default::default() - }; - let mut current = TextBlock { - x: 10.0, - y: 20.0, - width: 72.0, - height: 80.0, - ..Default::default() - }; - - rehydrate_runtime_text_block_state(&mut current, Some(&previous)); - - assert!(current.lock_layout_box); - assert_eq!(current.seed_layout_box(), (10.0, 20.0, 72.0, 80.0)); - } - - #[test] - fn unchanged_block_preserves_layout_box_lock_and_seed() { - let mut previous = TextBlock { - x: 10.0, - y: 20.0, - width: 100.0, - height: 80.0, - lock_layout_box: true, - ..Default::default() - }; - previous.set_layout_seed(5.0, 6.0, 70.0, 60.0); - - let mut current = TextBlock { - x: 10.0, - y: 20.0, - width: 100.0, - height: 80.0, - ..Default::default() - }; - - rehydrate_runtime_text_block_state(&mut current, Some(&previous)); - - assert!(current.lock_layout_box); - assert_eq!(current.seed_layout_box(), (5.0, 6.0, 70.0, 60.0)); - } - - #[test] - fn partial_inpaint_blocks_are_localized_to_crop() { - let block = TextBlock { - x: 40.0, - y: 30.0, - width: 40.0, - height: 30.0, - line_polygons: Some(vec![[ - [42.0, 32.0], - [78.0, 32.0], - [78.0, 40.0], - [42.0, 40.0], - ]]), - ..Default::default() - }; - - let localized = localize_inpaint_text_blocks(&[block], 50, 20, 40, 30); - assert_eq!(localized.len(), 1); - assert_eq!(localized[0].x, 0.0); - assert_eq!(localized[0].y, 10.0); - assert_eq!(localized[0].width, 30.0); - assert_eq!(localized[0].height, 20.0); - assert_eq!( - localized[0].line_polygons, - Some(vec![[[0.0, 12.0], [28.0, 12.0], [28.0, 20.0], [0.0, 20.0]]]) - ); - } - - #[test] - fn partial_inpaint_with_no_overlapping_blocks_returns_empty_list() { - let block = TextBlock { - x: 0.0, - y: 0.0, - width: 10.0, - height: 10.0, - ..Default::default() - }; - - let localized = localize_inpaint_text_blocks(&[block], 50, 20, 40, 30); - assert!(localized.is_empty()); - } - - #[test] - fn crop_paste_replaces_entire_returned_patch() { - let mut stitched = RgbaImage::from_pixel(8, 8, Rgba([0, 0, 0, 255])); - let patch = RgbaImage::from_pixel(3, 3, Rgba([255, 0, 0, 255])); - - paste_crop(&mut stitched, &patch, 2, 2); - - assert_eq!(stitched.get_pixel(2, 2).0, [255, 0, 0, 255]); - assert_eq!(stitched.get_pixel(4, 4).0, [255, 0, 0, 255]); - assert_eq!(stitched.get_pixel(1, 1).0, [0, 0, 0, 255]); - } - - #[test] - fn matching_previous_prefers_same_index_for_large_manual_resize() { - let previous = vec![ - TextBlock { - x: 10.0, - y: 10.0, - width: 40.0, - height: 20.0, - translation: Some("HELLO".to_string()), - ..Default::default() - }, - TextBlock { - x: 100.0, - y: 10.0, - width: 40.0, - height: 20.0, - translation: Some("WORLD".to_string()), - ..Default::default() - }, - ]; - - let current = TextBlock { - x: 10.0, - y: 10.0, - width: 140.0, - height: 80.0, - translation: Some("HELLO".to_string()), - ..Default::default() - }; - - let matched = find_matching_previous(¤t, 0, &previous, &[false, false]); - assert_eq!(matched, Some(0)); - } - - #[test] - fn non_overlapping_same_index_without_identity_does_not_force_match() { - let previous = vec![ - TextBlock { - x: 10.0, - y: 10.0, - width: 20.0, - height: 20.0, - ..Default::default() - }, - TextBlock { - x: 80.0, - y: 10.0, - width: 20.0, - height: 20.0, - ..Default::default() - }, - ]; - - let current = TextBlock { - x: 82.0, - y: 12.0, - width: 20.0, - height: 20.0, - ..Default::default() - }; - - let matched = find_matching_previous(¤t, 0, &previous, &[false, false]); - assert_eq!(matched, Some(1)); - } -} diff --git a/koharu-app/src/ops/llm.rs b/koharu-app/src/ops/llm.rs deleted file mode 100644 index 7ed48de25b8ee35724f8b079f0a858c6c7cae9cb..0000000000000000000000000000000000000000 --- a/koharu-app/src/ops/llm.rs +++ /dev/null @@ -1,191 +0,0 @@ -use std::str::FromStr; -use std::sync::Arc; - -use koharu_core::commands::{ - ApiKeyGetPayload, ApiKeyResult, ApiKeySetPayload, IndexPayload, LlmGeneratePayload, - LlmListPayload, LlmLoadPayload, -}; -use koharu_llm::ModelId; -use koharu_llm::api::{ALL_API_PROVIDERS, OPENAI_COMPATIBLE_ID}; -use koharu_llm::providers::{get_saved_api_key, openai_compatible, set_saved_api_key}; -pub use openai_compatible::PingResult; -use reqwest_middleware::ClientWithMiddleware; -use strum::IntoEnumIterator; -use tracing::instrument; - -use crate::{ - AppResources, llm, - state_tx::{self, ChangedField}, -}; - -#[instrument(level = "debug", skip_all, fields(provider = %payload.provider))] -pub async fn get_api_key( - _state: AppResources, - payload: ApiKeyGetPayload, -) -> anyhow::Result { - match get_saved_api_key(&payload.provider) { - Ok(Some(key)) => Ok(ApiKeyResult { api_key: Some(key) }), - Ok(None) => Ok(ApiKeyResult { api_key: None }), - Err(err) => { - tracing::error!(%err, "keyring read failed"); - Err(err) - } - } -} - -#[instrument(level = "debug", skip_all, fields(provider = %payload.provider))] -pub async fn set_api_key(_state: AppResources, payload: ApiKeySetPayload) -> anyhow::Result<()> { - match set_saved_api_key(&payload.provider, &payload.api_key) { - Ok(()) => Ok(()), - Err(err) => { - tracing::error!(%err, "keyring write failed"); - Err(err) - } - } -} - -pub async fn llm_list( - state: AppResources, - payload: LlmListPayload, -) -> anyhow::Result> { - let mut models: Vec = ModelId::iter().collect(); - let cpu_factor = if state.llm.is_cpu() { 10 } else { 1 }; - let lang = payload.language.as_deref().unwrap_or("en"); - let zh_locale_factor = if lang.starts_with("zh") { 10 } else { 1 }; - let non_zh_en_locale_factor = if lang.starts_with("zh") || lang.starts_with("en") { - 1 - } else { - 100 - }; - - models.sort_by_key(|m| match m { - ModelId::VntlLlama3_8Bv2 => 100, - ModelId::Lfm2_350mEnjpMt => 200 / cpu_factor, - ModelId::SakuraGalTransl7Bv3_7 => 300 / zh_locale_factor, - ModelId::Sakura1_5bQwen2_5v1_0 => 400 / zh_locale_factor / cpu_factor, - ModelId::HunyuanMT7B => 500 / non_zh_en_locale_factor, - }); - - let mut result: Vec = models.into_iter().map(llm::ModelInfo::new).collect(); - - for provider in ALL_API_PROVIDERS { - for model in provider.models { - result.push(llm::ModelInfo::api(provider.id, model.id)); - } - } - - if let Some(base_url) = payload - .openai_compatible_base_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - let api_key = match get_saved_api_key(OPENAI_COMPATIBLE_ID) { - Ok(value) => value, - Err(err) => { - tracing::warn!(%err, "failed to read openai-compatible API key"); - None - } - }; - - match openai_compatible::list_models( - state.runtime.http_client(), - base_url, - api_key.as_deref(), - ) - .await - { - Ok(models) => { - for model in models { - result.push(llm::ModelInfo::api(OPENAI_COMPATIBLE_ID, &model)); - } - } - Err(err) => { - tracing::warn!(%err, "failed to list openai-compatible models"); - } - } - } - - Ok(result) -} - -#[instrument(level = "info", skip_all)] -pub async fn llm_load(state: AppResources, payload: LlmLoadPayload) -> anyhow::Result<()> { - if payload.id.contains(':') { - let (provider_id, model_id) = payload.id.split_once(':').unwrap(); - let api_key = match payload.api_key { - Some(key) if !key.trim().is_empty() => Some(key), - _ => get_saved_api_key(provider_id)?, - }; - state - .llm - .load_api( - provider_id, - model_id, - koharu_llm::providers::ProviderConfig { - http_client: state.runtime.http_client(), - api_key, - base_url: payload.base_url, - temperature: payload.temperature, - max_tokens: payload.max_tokens, - custom_system_prompt: payload.custom_system_prompt, - }, - ) - .await?; - } else { - let id = ModelId::from_str(&payload.id)?; - state.llm.load(id).await; - } - Ok(()) -} - -pub async fn llm_offload(state: AppResources) -> anyhow::Result<()> { - state.llm.offload().await; - Ok(()) -} - -pub async fn llm_ready(state: AppResources) -> anyhow::Result { - Ok(state.llm.ready().await) -} - -#[instrument(level = "info", skip_all)] -pub async fn llm_generate(state: AppResources, payload: LlmGeneratePayload) -> anyhow::Result<()> { - let mut updated = state_tx::read_doc(&state.state, payload.index).await?; - let target_language = payload.language.as_deref(); - - match payload.text_block_index { - Some(block_index) => { - let text_block = updated - .text_blocks - .get_mut(block_index) - .ok_or_else(|| anyhow::anyhow!("Text block not found"))?; - state.llm.translate(text_block, target_language).await?; - } - None => { - state.llm.translate(&mut updated, target_language).await?; - } - } - - state_tx::update_doc( - &state.state, - payload.index, - updated, - &[ChangedField::TextBlocks], - ) - .await -} - -pub async fn llm_ping( - http_client: Arc, - base_url: &str, - api_key: Option<&str>, -) -> anyhow::Result { - openai_compatible::ping(http_client, base_url, api_key).await -} - -pub async fn get_document_for_llm( - state: AppResources, - payload: IndexPayload, -) -> anyhow::Result { - state_tx::read_doc(&state.state, payload.index).await -} diff --git a/koharu-app/src/ops/mod.rs b/koharu-app/src/ops/mod.rs deleted file mode 100644 index 9161a8de291b62802ecc54956eefdcdf87855d92..0000000000000000000000000000000000000000 --- a/koharu-app/src/ops/mod.rs +++ /dev/null @@ -1,13 +0,0 @@ -mod core; -mod edit; -mod llm; -mod process; -mod utils; -mod vision; - -pub use core::*; -pub use edit::*; -pub use llm::*; -pub use process::*; -pub use utils::{InpaintRegionExt, load_documents}; -pub use vision::*; diff --git a/koharu-app/src/ops/process.rs b/koharu-app/src/ops/process.rs deleted file mode 100644 index faef613100a748f41a4b8ff06dba0b582e9715a2..0000000000000000000000000000000000000000 --- a/koharu-app/src/ops/process.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, -}; - -use koharu_core::commands::ProcessRequest; -use koharu_llm::providers::get_saved_api_key; -use uuid::Uuid; - -use crate::AppResources; - -pub async fn process(state: AppResources, payload: ProcessRequest) -> anyhow::Result { - let mut payload = payload; - if let Some(model_id) = payload.llm_model_id.as_deref() - && payload.llm_api_key.is_none() - && model_id.contains(':') - { - let (provider_id, _) = model_id - .split_once(':') - .ok_or_else(|| anyhow::anyhow!("invalid api model id"))?; - payload.llm_api_key = get_saved_api_key(provider_id)?; - } - - { - let guard = state.pipeline.read().await; - if guard.is_some() { - anyhow::bail!("A processing pipeline is already running"); - } - } - - let job_id = Uuid::new_v4().to_string(); - let cancel = Arc::new(AtomicBool::new(false)); - { - let mut guard = state.pipeline.write().await; - *guard = Some(crate::pipeline::PipelineHandle { - id: job_id.clone(), - cancel: cancel.clone(), - }); - } - - let resources = state.clone(); - let job_id_for_task = job_id.clone(); - tokio::spawn(async move { - crate::pipeline::run_pipeline(resources, payload, cancel, job_id_for_task).await; - }); - - Ok(job_id) -} - -pub async fn process_cancel(state: AppResources) -> anyhow::Result<()> { - let guard = state.pipeline.read().await; - if let Some(handle) = guard.as_ref() { - handle.cancel.store(true, Ordering::Relaxed); - } - Ok(()) -} diff --git a/koharu-app/src/ops/vision.rs b/koharu-app/src/ops/vision.rs deleted file mode 100644 index 287969d6164c748f05cc701971628f45c8f0506f..0000000000000000000000000000000000000000 --- a/koharu-app/src/ops/vision.rs +++ /dev/null @@ -1,72 +0,0 @@ -use koharu_core::FontFaceInfo; -use koharu_core::commands::{IndexPayload, RenderPayload}; -use tracing::instrument; - -use crate::{ - AppResources, - state_tx::{self, ChangedField}, -}; - -#[instrument(level = "info", skip_all)] -pub async fn detect(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> { - let mut snapshot = state_tx::read_doc(&state.state, payload.index).await?; - state.ml.detect(&mut snapshot).await?; - state_tx::update_doc( - &state.state, - payload.index, - snapshot, - &[ChangedField::TextBlocks, ChangedField::Segment], - ) - .await -} - -#[instrument(level = "info", skip_all)] -pub async fn ocr(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> { - let mut snapshot = state_tx::read_doc(&state.state, payload.index).await?; - state.ml.ocr(&mut snapshot).await?; - state_tx::update_doc( - &state.state, - payload.index, - snapshot, - &[ChangedField::TextBlocks], - ) - .await -} - -#[instrument(level = "info", skip_all)] -pub async fn inpaint(state: AppResources, payload: IndexPayload) -> anyhow::Result<()> { - let mut snapshot = state_tx::read_doc(&state.state, payload.index).await?; - state.ml.inpaint(&mut snapshot).await?; - state_tx::update_doc( - &state.state, - payload.index, - snapshot, - &[ChangedField::Inpainted], - ) - .await -} - -#[instrument(level = "info", skip_all)] -pub async fn render(state: AppResources, payload: RenderPayload) -> anyhow::Result<()> { - let mut updated = state_tx::read_doc(&state.state, payload.index).await?; - - state.renderer.render( - &mut updated, - payload.text_block_index, - payload.shader_effect.unwrap_or_default(), - payload.shader_stroke, - payload.font_family.as_deref(), - )?; - - state_tx::update_doc( - &state.state, - payload.index, - updated, - &[ChangedField::TextBlocks, ChangedField::Rendered], - ) - .await -} - -pub async fn list_font_families(state: AppResources) -> anyhow::Result> { - state.renderer.available_fonts() -} diff --git a/koharu-app/src/page_cache.rs b/koharu-app/src/page_cache.rs new file mode 100644 index 0000000000000000000000000000000000000000..327b5f9005329c5ff3baedb6788edd31e2a3a7f7 --- /dev/null +++ b/koharu-app/src/page_cache.rs @@ -0,0 +1,289 @@ +use std::num::NonZeroUsize; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use image::DynamicImage; +use lru::LruCache; +use tokio::sync::RwLock; + +use koharu_core::{Document, DocumentSummary, FileEntry}; + +use crate::blob_store::BlobStore; +use crate::manifest::{ManifestStore, PageManifest}; + +const DEFAULT_CAPACITY: usize = 5; + +#[derive(Clone)] +pub struct PageCache { + cache: Arc>>, + pub(crate) blobs: BlobStore, + manifests: ManifestStore, +} + +impl PageCache { + pub fn new(blobs: BlobStore, manifests: ManifestStore) -> Self { + Self { + cache: Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(DEFAULT_CAPACITY).unwrap(), + ))), + blobs, + manifests, + } + } + + /// Get a page, loading from disk if not cached. + pub async fn get(&self, id: &str) -> Result { + // Check cache first + { + let mut cache = self.cache.write().await; + if let Some(doc) = cache.get(id) { + return Ok(doc.clone()); + } + } + // Cache miss -- load from manifest + blobs + let doc = self.load_from_disk(id)?; + let mut cache = self.cache.write().await; + cache.put(id.to_string(), doc.clone()); + Ok(doc) + } + + /// Update a page in cache and save manifest + blobs to disk. + pub async fn put(&self, doc: &Document) -> Result<()> { + // Encode layers to blobs and save manifest + self.save_to_disk(doc)?; + // Update cache + let mut cache = self.cache.write().await; + cache.put(doc.id.clone(), doc.clone()); + Ok(()) + } + + /// Evict a page from cache (does not delete from disk). + pub async fn evict(&self, id: &str) { + let mut cache = self.cache.write().await; + cache.pop(id); + } + + /// Save a manifest without loading/putting the full document. + pub fn save_manifest(&self, manifest: &PageManifest) -> Result<()> { + self.manifests.save(manifest) + } + + pub fn list_documents(&self) -> Result> { + let mut entries: Vec = self + .manifests + .load_all()? + .into_iter() + .map(|manifest| DocumentSummary { + id: manifest.id, + name: manifest.name, + width: manifest.width, + height: manifest.height, + has_segment: manifest.layers.segment.is_some(), + has_inpainted: manifest.layers.inpainted.is_some(), + has_rendered: manifest.layers.rendered.is_some(), + has_brush_layer: manifest.layers.brush_layer.is_some(), + text_block_count: manifest.text_blocks.len(), + }) + .collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + Ok(entries) + } + + pub async fn replace_manifests(&self, manifests: &[PageManifest]) -> Result<()> { + self.manifests.replace_all(manifests)?; + let mut cache = self.cache.write().await; + cache.clear(); + Ok(()) + } + + pub fn import_files(&self, inputs: Vec) -> Result> { + use image::GenericImageView; + use rayon::iter::{IntoParallelIterator, ParallelIterator}; + + let manifests: Result> = inputs + .into_par_iter() + .map(|file| { + let image = image::load_from_memory(&file.data) + .map_err(|error| anyhow::anyhow!("failed to decode {}: {error}", file.name))?; + let (width, height) = image.dimensions(); + let id = blake3::hash(&file.data).to_hex().to_string(); + let source = self.blobs.put(&file.data)?; + let name = Path::new(&file.name) + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + Ok(PageManifest { + id, + source, + name, + width, + height, + layers: crate::manifest::LayerRefs::default(), + text_blocks: Vec::new(), + }) + }) + .collect(); + + let mut manifests = manifests?; + manifests.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.id.cmp(&b.id))); + Ok(manifests) + } + + fn load_from_disk(&self, id: &str) -> Result { + let manifest = self.manifests.load(id)?; + + let image_bytes = self.blobs + .get(&manifest.source) + .with_context(|| format!("Source blob not found: {}", manifest.source))?; + let image = + image::load_from_memory(&image_bytes).context("Failed to decode source image")?; + + // Load layers from blob store + let segment = self.load_layer(&manifest.layers.segment)?; + let inpainted = self.load_layer(&manifest.layers.inpainted)?; + let rendered = self.load_layer(&manifest.layers.rendered)?; + let brush_layer = self.load_layer(&manifest.layers.brush_layer)?; + + Ok(Document { + id: manifest.id, + path: PathBuf::from(&manifest.name), + name: manifest.name, + image: image.into(), + width: manifest.width, + height: manifest.height, + text_blocks: manifest.text_blocks, + segment: segment.map(Into::into), + inpainted: inpainted.map(Into::into), + rendered: rendered.map(Into::into), + brush_layer: brush_layer.map(Into::into), + }) + } + + fn save_to_disk(&self, doc: &Document) -> Result { + let encode = |img: &koharu_core::SerializableDynamicImage| -> Result { + let mut buf = std::io::Cursor::new(Vec::new()); + img.0 + .write_to(&mut buf, image::ImageFormat::WebP) + .context("Failed to encode layer")?; + self.blobs.put(&buf.into_inner()) + }; + + let segment = doc.segment.as_ref().map(encode).transpose()?; + let inpainted = doc.inpainted.as_ref().map(encode).transpose()?; + let rendered = doc.rendered.as_ref().map(encode).transpose()?; + let brush_layer = doc.brush_layer.as_ref().map(encode).transpose()?; + let source = match self.manifests.load(&doc.id) { + Ok(existing) if !existing.source.is_empty() => existing.source, + _ => { + let mut buf = std::io::Cursor::new(Vec::new()); + let format = + image::ImageFormat::from_path(&doc.path).unwrap_or(image::ImageFormat::Png); + doc.image + .0 + .write_to(&mut buf, format) + .context("Failed to encode source image")?; + self.blobs.put(&buf.into_inner())? + } + }; + + let manifest = PageManifest { + id: doc.id.clone(), + source, + name: doc.name.clone(), + width: doc.width, + height: doc.height, + layers: crate::manifest::LayerRefs { + segment, + inpainted, + rendered, + brush_layer, + }, + text_blocks: doc.text_blocks.clone(), + }; + + self.manifests.save(&manifest)?; + Ok(manifest) + } + + fn load_layer(&self, hash: &Option) -> Result> { + match hash { + Some(h) if !h.is_empty() => { + let bytes = self.blobs.get(h)?; + let img = image::load_from_memory(&bytes) + .with_context(|| format!("Failed to decode layer blob {h}"))?; + Ok(Some(img)) + } + _ => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + use std::path::PathBuf; + + use image::{DynamicImage, ImageFormat}; + use koharu_core::FileEntry; + + use super::PageCache; + use crate::{blob_store::BlobStore, manifest::ManifestStore}; + + fn cache() -> (tempfile::TempDir, PageCache) { + let tempdir = tempfile::tempdir().unwrap(); + let blobs = BlobStore::new(tempdir.path().join("blobs")).unwrap(); + let manifests = ManifestStore::new(tempdir.path().join("pages")).unwrap(); + (tempdir, PageCache::new(blobs, manifests)) + } + + #[test] + fn import_files_stores_source_as_blob_hash() { + let (_tempdir, cache) = cache(); + + let mut encoded = Cursor::new(Vec::new()); + DynamicImage::new_rgba8(4, 5) + .write_to(&mut encoded, ImageFormat::Png) + .unwrap(); + + let manifests = cache + .import_files(vec![FileEntry { + name: "page.png".to_string(), + data: encoded.into_inner(), + }]) + .unwrap(); + + assert_eq!(manifests.len(), 1); + assert_eq!(manifests[0].name, "page"); + assert!(!manifests[0].source.is_empty()); + assert!(cache.blobs.exists(&manifests[0].source)); + } + + #[test] + fn load_from_disk_reads_source_blob() { + let (_tempdir, cache) = cache(); + + let mut encoded = Cursor::new(Vec::new()); + DynamicImage::new_rgba8(4, 5) + .write_to(&mut encoded, ImageFormat::Png) + .unwrap(); + + let manifest = cache + .import_files(vec![FileEntry { + name: "page.png".to_string(), + data: encoded.into_inner(), + }]) + .unwrap() + .pop() + .unwrap(); + + cache.save_manifest(&manifest).unwrap(); + let document = cache.load_from_disk(&manifest.id).unwrap(); + + assert_eq!(document.name, "page"); + assert_eq!(document.width, 4); + assert_eq!(document.height, 5); + } +} diff --git a/koharu-app/src/pipeline.rs b/koharu-app/src/pipeline.rs index 0bc0ded4aedf651f68339cb01126af79d569aa7c..2e4f9066d7177d669afa207224bfb06711fa8cc8 100644 --- a/koharu-app/src/pipeline.rs +++ b/koharu-app/src/pipeline.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::collections::HashMap; use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -6,30 +6,28 @@ use std::sync::{ use std::time::Duration; use koharu_core::commands::ProcessRequest; -use koharu_core::events::{PipelineProgress, PipelineStatus, PipelineStep}; -use koharu_llm::ModelId; -use once_cell::sync::Lazy; -use tokio::sync::broadcast; - -use crate::{ - AppResources, - state_tx::{self, ChangedField}, -}; +use koharu_core::events::{PipelineStatus, PipelineStep}; +use koharu_core::{JobState, JobStatus}; +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::AppResources; +use crate::llm::ensure_target_ready; + +pub type Jobs = Arc>>; pub struct PipelineHandle { pub id: String, pub cancel: Arc, } -static PIPELINE_TX: Lazy> = - Lazy::new(|| broadcast::channel(256).0); - -pub fn subscribe() -> broadcast::Receiver { - PIPELINE_TX.subscribe() -} - -fn emit(progress: PipelineProgress) { - let _ = PIPELINE_TX.send(progress); +#[derive(Clone, Copy)] +struct JobProgress { + current_document: usize, + total_documents: usize, + current_step_index: usize, + total_steps: usize, + overall_percent: u8, } 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 ((done_units as f64 / total_units as f64) * 100.0).round() as u8 } -pub async fn run_pipeline( +async fn update_job(jobs: &Jobs, job: JobState) { + let terminal = !matches!(job.status, JobStatus::Running); + let mut guard = jobs.write().await; + if terminal { + guard.remove(&job.id); + } else { + guard.insert(job.id.clone(), job); + } +} + +fn make_job_state( + job_id: &str, + status: PipelineStatus, + step: Option, + progress: JobProgress, +) -> JobState { + let (job_status, error) = match status { + PipelineStatus::Running => (JobStatus::Running, None), + PipelineStatus::Completed => (JobStatus::Completed, None), + PipelineStatus::Cancelled => (JobStatus::Cancelled, None), + PipelineStatus::Failed(message) => (JobStatus::Failed, Some(message)), + }; + + JobState { + id: job_id.to_string(), + kind: "pipeline".to_string(), + status: job_status, + step: step.map(|step| step.to_string()), + current_document: progress.current_document, + total_documents: progress.total_documents, + current_step_index: progress.current_step_index, + total_steps: progress.total_steps, + overall_percent: progress.overall_percent, + error, + } +} + +pub async fn process( + state: AppResources, + payload: ProcessRequest, + jobs: Jobs, +) -> anyhow::Result { + { + let guard = state.pipeline.read().await; + if guard.is_some() { + anyhow::bail!("A processing pipeline is already running"); + } + } + + let job_id = Uuid::new_v4().to_string(); + let cancel = Arc::new(AtomicBool::new(false)); + { + let mut guard = state.pipeline.write().await; + *guard = Some(PipelineHandle { + id: job_id.clone(), + cancel: cancel.clone(), + }); + } + + let resources = state.clone(); + let job_id_for_task = job_id.clone(); + tokio::spawn(async move { + run_pipeline(resources, payload, cancel, job_id_for_task, jobs).await; + }); + + Ok(job_id) +} + +pub async fn process_cancel(state: AppResources) -> anyhow::Result<()> { + let guard = state.pipeline.read().await; + if let Some(handle) = guard.as_ref() { + handle.cancel.store(true, Ordering::Relaxed); + } + Ok(()) +} + +async fn run_pipeline( resources: AppResources, request: ProcessRequest, cancel: Arc, job_id: String, + jobs: Jobs, ) { - let result = run_pipeline_inner(&resources, &request, &cancel, &job_id).await; + let result = run_pipeline_inner(&resources, &request, &cancel, &job_id, &jobs).await; - let total_docs = match request.index { + let total_docs = match request.document_id { Some(_) => 1, - None => resources.state.read().await.documents.len(), + None => resources + .cache + .list_documents() + .map(|entries| entries.len()) + .unwrap_or(0), }; - match result { - Ok(()) if cancel.load(Ordering::Relaxed) => { - emit(PipelineProgress { - job_id: job_id.clone(), - status: PipelineStatus::Cancelled, - step: None, + let final_state = match result { + Ok(()) if cancel.load(Ordering::Relaxed) => make_job_state( + &job_id, + PipelineStatus::Cancelled, + None, + JobProgress { current_document: total_docs, total_documents: total_docs, current_step_index: 0, total_steps: PipelineStep::ALL.len(), overall_percent: 0, - }); - } - Ok(()) => { - emit(PipelineProgress { - job_id: job_id.clone(), - status: PipelineStatus::Completed, - step: None, + }, + ), + Ok(()) => make_job_state( + &job_id, + PipelineStatus::Completed, + None, + JobProgress { current_document: total_docs, total_documents: total_docs, current_step_index: PipelineStep::ALL.len(), total_steps: PipelineStep::ALL.len(), overall_percent: 100, - }); - } + }, + ), Err(err) => { tracing::error!("Pipeline failed: {err:#}"); - emit(PipelineProgress { - job_id: job_id.clone(), - status: PipelineStatus::Failed(err.to_string()), - step: None, - current_document: 0, - total_documents: total_docs, - current_step_index: 0, - total_steps: PipelineStep::ALL.len(), - overall_percent: 0, - }); + make_job_state( + &job_id, + PipelineStatus::Failed(err.to_string()), + None, + JobProgress { + current_document: 0, + total_documents: total_docs, + current_step_index: 0, + total_steps: PipelineStep::ALL.len(), + overall_percent: 0, + }, + ) } - } + }; + update_job(&jobs, final_state).await; let mut guard = resources.pipeline.write().await; *guard = None; @@ -103,97 +185,69 @@ async fn run_pipeline_inner( req: &ProcessRequest, cancel: &Arc, job_id: &str, + jobs: &Jobs, ) -> anyhow::Result<()> { - let total_docs = { - let guard = res.state.read().await; - let len = guard.documents.len(); - match req.index { - Some(i) if i >= len => anyhow::bail!("Document index {i} out of range (have {len})"), - Some(_) => 1, - None => len, + let entries = res.cache.list_documents()?; + let page_ids: Vec = match req.document_id.as_deref() { + Some(id) => { + if !entries.iter().any(|p| p.id == id) { + anyhow::bail!("Document not found: {id}"); + } + vec![id.to_string()] } + None => entries.into_iter().map(|p| p.id).collect(), }; + let total_docs = page_ids.len(); if total_docs == 0 { return Ok(()); } - if let Some(model_id) = &req.llm_model_id - && !res.llm.ready().await - { - if model_id.contains(':') { - let (provider_id, model_part) = model_id.split_once(':').unwrap(); - res.llm - .load_api( - provider_id, - model_part, - koharu_llm::providers::ProviderConfig { - http_client: res.runtime.http_client(), - api_key: req.llm_api_key.clone(), - base_url: req.llm_base_url.clone(), - temperature: req.llm_temperature, - max_tokens: req.llm_max_tokens, - custom_system_prompt: req.llm_custom_system_prompt.clone(), - }, - ) - .await?; - } else { - let id = ModelId::from_str(model_id)?; - res.llm.load(id).await; - for _ in 0..300 { - if res.llm.ready().await { - break; - } - tokio::time::sleep(Duration::from_millis(100)).await; - if cancel.load(Ordering::Relaxed) { - return Ok(()); - } - } - if !res.llm.ready().await { - anyhow::bail!("LLM failed to load within timeout"); - } - } + if let Some(llm) = &req.llm { + ensure_target_ready(res, &llm.target, llm.options.as_ref()).await?; } - let start_index = req.index.unwrap_or(0); - let end_index = req.index.map(|i| i + 1).unwrap_or(total_docs); let total_steps = PipelineStep::ALL.len(); - for (doc_ordinal, doc_index) in (start_index..end_index).enumerate() { + for (doc_ordinal, page_id) in page_ids.iter().enumerate() { + let mut doc = res.cache.get(page_id).await?; + for (step_ordinal, step) in PipelineStep::ALL.iter().enumerate() { if cancel.load(Ordering::Relaxed) { return Ok(()); } let overall = compute_percent(doc_ordinal, step_ordinal, total_docs, total_steps); - emit(PipelineProgress { - job_id: job_id.to_string(), - status: PipelineStatus::Running, - step: Some(*step), - current_document: doc_ordinal, - total_documents: total_docs, - current_step_index: step_ordinal, - total_steps, - overall_percent: overall, - }); + update_job( + jobs, + make_job_state( + job_id, + PipelineStatus::Running, + Some(*step), + JobProgress { + current_document: doc_ordinal, + total_documents: total_docs, + current_step_index: step_ordinal, + total_steps, + overall_percent: overall, + }, + ), + ) + .await; tokio::task::yield_now().await; tokio::time::sleep(Duration::from_millis(1)).await; - let mut snapshot = state_tx::read_doc(&res.state, doc_index).await?; - match step { - PipelineStep::Detect => res.ml.detect(&mut snapshot).await?, - PipelineStep::Ocr => res.ml.ocr(&mut snapshot).await?, - PipelineStep::Inpaint => res.ml.inpaint(&mut snapshot).await?, + PipelineStep::Detect => res.ml.detect(&mut doc).await?, + PipelineStep::Ocr => res.ml.ocr(&mut doc).await?, + PipelineStep::Inpaint => res.ml.inpaint(&mut doc).await?, PipelineStep::LlmGenerate => { - res.llm - .translate(&mut snapshot, req.language.as_deref()) - .await?; + res.llm.translate(&mut doc, req.language.as_deref()).await?; } PipelineStep::Render => { res.renderer.render( - &mut snapshot, + &mut doc, None, req.shader_effect.unwrap_or_default(), req.shader_stroke.clone(), @@ -202,15 +256,11 @@ async fn run_pipeline_inner( } } - let changed = match step { - PipelineStep::Detect => &[ChangedField::TextBlocks, ChangedField::Segment][..], - PipelineStep::Ocr => &[ChangedField::TextBlocks][..], - PipelineStep::Inpaint => &[ChangedField::Inpainted][..], - PipelineStep::LlmGenerate => &[ChangedField::TextBlocks][..], - PipelineStep::Render => &[ChangedField::TextBlocks, ChangedField::Rendered][..], - }; - state_tx::update_doc(&res.state, doc_index, snapshot, changed).await?; + // Save after each step (crash-safe) + res.cache.put(&doc).await?; } + // Evict from cache to free memory + res.cache.evict(page_id).await; } Ok(()) diff --git a/koharu-app/src/state_tx.rs b/koharu-app/src/state_tx.rs deleted file mode 100644 index 1cde4256dfc0b2bed16c9d02ee7c663afbc21bfa..0000000000000000000000000000000000000000 --- a/koharu-app/src/state_tx.rs +++ /dev/null @@ -1,224 +0,0 @@ -use anyhow::Result; -use koharu_core::{AppState, Document}; -use once_cell::sync::Lazy; -use tokio::sync::broadcast; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display)] -pub enum ChangedField { - #[strum(serialize = "name")] - Name, - #[strum(serialize = "textBlocks")] - TextBlocks, - #[strum(serialize = "segment")] - Segment, - #[strum(serialize = "brushLayer")] - BrushLayer, - #[strum(serialize = "inpainted")] - Inpainted, - #[strum(serialize = "rendered")] - Rendered, -} - -#[derive(Debug, Clone)] -pub enum StateEvent { - DocumentsChanged, - DocumentChanged { - document_id: String, - revision: u64, - changed: Vec, - }, -} - -static STATE_TX: Lazy> = Lazy::new(|| broadcast::channel(256).0); - -pub fn subscribe() -> broadcast::Receiver { - STATE_TX.subscribe() -} - -fn emit(event: StateEvent) { - let _ = STATE_TX.send(event); -} - -fn serialize_changed_fields(changed: &[ChangedField]) -> Vec { - changed.iter().map(ToString::to_string).collect() -} - -pub async fn read_doc(state: &AppState, index: usize) -> Result { - let guard = state.read().await; - guard - .documents - .get(index) - .cloned() - .ok_or_else(|| anyhow::anyhow!("Document not found at index {index}")) -} - -pub async fn list_docs(state: &AppState) -> Vec { - state.read().await.documents.clone() -} - -pub async fn doc_count(state: &AppState) -> usize { - state.read().await.documents.len() -} - -pub async fn find_doc_index(state: &AppState, document_id: &str) -> Result { - let guard = state.read().await; - guard - .documents - .iter() - .position(|document| document.id == document_id) - .ok_or_else(|| anyhow::anyhow!("Document not found: {document_id}")) -} - -pub async fn replace_docs(state: &AppState, mut documents: Vec) -> Result { - for document in &mut documents { - document.prepare_for_store(); - } - - let count = documents.len(); - let mut guard = state.write().await; - guard.documents = documents; - drop(guard); - emit(StateEvent::DocumentsChanged); - Ok(count) -} - -pub async fn append_docs(state: &AppState, mut documents: Vec) -> Result { - for document in &mut documents { - document.prepare_for_store(); - } - - let mut guard = state.write().await; - guard.documents.extend(documents); - let count = guard.documents.len(); - drop(guard); - emit(StateEvent::DocumentsChanged); - Ok(count) -} - -pub async fn update_doc( - state: &AppState, - index: usize, - mut document: Document, - changed: &[ChangedField], -) -> Result<()> { - document.prepare_for_store(); - document.bump_revision(); - let document_id = document.id.clone(); - let revision = document.revision; - - let mut guard = state.write().await; - let target = guard - .documents - .get_mut(index) - .ok_or_else(|| anyhow::anyhow!("Document not found at index {index}"))?; - *target = document; - drop(guard); - emit(StateEvent::DocumentChanged { - document_id, - revision, - changed: serialize_changed_fields(changed), - }); - Ok(()) -} - -pub async fn mutate_doc( - state: &AppState, - index: usize, - changed: &[ChangedField], - mutator: F, -) -> Result -where - F: FnOnce(&mut Document) -> Result, -{ - let mut guard = state.write().await; - let target = guard - .documents - .get_mut(index) - .ok_or_else(|| anyhow::anyhow!("Document not found at index {index}"))?; - let result = mutator(target)?; - target.prepare_for_store(); - target.bump_revision(); - let document_id = target.id.clone(); - let revision = target.revision; - drop(guard); - emit(StateEvent::DocumentChanged { - document_id, - revision, - changed: serialize_changed_fields(changed), - }); - Ok(result) -} - -#[cfg(test)] -mod tests { - use super::{ - ChangedField, append_docs, find_doc_index, list_docs, mutate_doc, read_doc, replace_docs, - update_doc, - }; - use koharu_core::{AppState, Document, State}; - use std::sync::Arc; - use tokio::sync::RwLock; - - fn test_state() -> AppState { - Arc::new(RwLock::new(State { - documents: vec![Default::default()], - })) - } - - #[tokio::test] - async fn read_update_mutate_doc_round_trip() { - let state = test_state(); - - let mut doc = read_doc(&state, 0).await.expect("doc should exist"); - doc.name = "before".to_string(); - update_doc(&state, 0, doc, &[ChangedField::Name]) - .await - .expect("update should work"); - - mutate_doc(&state, 0, &[ChangedField::Name], |doc| { - doc.name = "after".to_string(); - Ok(()) - }) - .await - .expect("mutation should work"); - - let doc = read_doc(&state, 0).await.expect("doc should exist"); - assert_eq!(doc.name, "after"); - } - - #[tokio::test] - async fn index_not_found_errors_are_stable() { - let state = test_state(); - let err = read_doc(&state, 1) - .await - .expect_err("missing document should fail"); - assert_eq!(err.to_string(), "Document not found at index 1"); - - let err = mutate_doc(&state, 1, &[ChangedField::Name], |_| Ok(())) - .await - .expect_err("missing document should fail"); - assert_eq!(err.to_string(), "Document not found at index 1"); - } - - #[tokio::test] - async fn replace_append_and_find_doc_work() { - let state = test_state(); - let first = Document { - id: "first".to_string(), - ..Default::default() - }; - replace_docs(&state, vec![first]) - .await - .expect("replace should work"); - assert_eq!(list_docs(&state).await.len(), 1); - - let second = Document { - id: "second".to_string(), - ..Default::default() - }; - append_docs(&state, vec![second]) - .await - .expect("append should work"); - assert_eq!(find_doc_index(&state, "second").await.expect("find"), 1); - } -} diff --git a/koharu-app/src/ops/utils.rs b/koharu-app/src/utils.rs similarity index 82% rename from koharu-app/src/ops/utils.rs rename to koharu-app/src/utils.rs index a1f5153c16a27cbd1381b140aae03fb5c0731c6e..769bc8cd0e65d3d7998fa4b9f5972b88cde41535 100644 --- a/koharu-app/src/ops/utils.rs +++ b/koharu-app/src/utils.rs @@ -1,15 +1,14 @@ use std::{io::Cursor, path::PathBuf}; use image::{ImageFormat, RgbaImage}; -use koharu_core::commands::InpaintRegion; -use koharu_core::{Document, SerializableDynamicImage}; +use koharu_core::{Document, Region, SerializableDynamicImage}; use rayon::iter::{IntoParallelIterator, ParallelIterator}; pub trait InpaintRegionExt { fn clamp(&self, width: u32, height: u32) -> Option<(u32, u32, u32, u32)>; } -impl InpaintRegionExt for InpaintRegion { +impl InpaintRegionExt for Region { fn clamp(&self, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> { if width == 0 || height == 0 { return None; @@ -27,7 +26,7 @@ impl InpaintRegionExt for InpaintRegion { } } -pub(crate) fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyhow::Result> { +pub fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyhow::Result> { let mut buf = Vec::new(); let mut cursor = Cursor::new(&mut buf); let format = ImageFormat::from_extension(ext).unwrap_or(ImageFormat::Jpeg); @@ -35,7 +34,7 @@ pub(crate) fn encode_image(image: &SerializableDynamicImage, ext: &str) -> anyho Ok(buf) } -pub(crate) fn mime_from_ext(ext: &str) -> &'static str { +pub fn mime_from_ext(ext: &str) -> &'static str { match ext { "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", @@ -44,11 +43,7 @@ pub(crate) fn mime_from_ext(ext: &str) -> &'static str { } } -pub(crate) fn blank_rgba( - width: u32, - height: u32, - color: image::Rgba, -) -> SerializableDynamicImage { +pub fn blank_rgba(width: u32, height: u32, color: image::Rgba) -> SerializableDynamicImage { let blank = RgbaImage::from_pixel(width, height, color); image::DynamicImage::ImageRgba8(blank).into() } diff --git a/koharu-core/src/commands.rs b/koharu-core/src/commands.rs index 05697e4d162a2091fc04870a43a6690a0d16f59f..8142b0fe581676ee2b0818a7f95dbde7ba808201 100644 --- a/koharu-core/src/commands.rs +++ b/koharu-core/src/commands.rs @@ -1,4 +1,4 @@ -use crate::{TextBlock, TextShaderEffect, TextStrokeStyle}; +use crate::{TextShaderEffect, TextStrokeStyle}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -14,12 +14,6 @@ pub struct OpenExternalPayload { pub url: String, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct IndexPayload { - pub index: usize, -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ThumbnailResult { @@ -53,81 +47,21 @@ pub struct FileResult { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct RenderPayload { - pub index: usize, - pub text_block_index: Option, - pub shader_effect: Option, - pub shader_stroke: Option, - pub font_family: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateTextBlocksPayload { - pub index: usize, - pub text_blocks: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmListPayload { - pub language: Option, - pub openai_compatible_base_url: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiKeyGetPayload { - pub provider: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiKeySetPayload { - pub provider: String, - pub api_key: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ApiKeyResult { - pub api_key: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmLoadPayload { - pub id: String, - pub api_key: Option, - pub base_url: Option, - pub temperature: Option, - pub max_tokens: Option, - pub custom_system_prompt: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct LlmGeneratePayload { - pub index: usize, - pub text_block_index: Option, +pub struct LlmCatalogPayload { pub language: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct LlmLoadParams { - pub id: String, - pub api_key: Option, - pub base_url: Option, - pub temperature: Option, - pub max_tokens: Option, - pub custom_system_prompt: Option, + pub target: crate::LlmTarget, + pub options: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct LlmGenerateParams { - pub index: usize, + pub document_id: String, pub text_block_index: Option, pub language: Option, } @@ -135,57 +69,18 @@ pub struct LlmGenerateParams { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProcessRequest { - pub index: Option, - pub llm_model_id: Option, - pub llm_api_key: Option, - pub llm_base_url: Option, - pub llm_temperature: Option, - pub llm_max_tokens: Option, - pub llm_custom_system_prompt: Option, + pub document_id: Option, + pub llm: Option, pub language: Option, pub shader_effect: Option, pub shader_stroke: Option, pub font_family: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct InpaintRegion { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateInpaintMaskPayload { - pub index: usize, - #[serde(with = "serde_bytes")] - pub mask: Vec, - pub region: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct UpdateBrushLayerPayload { - pub index: usize, - #[serde(with = "serde_bytes")] - pub patch: Vec, - pub region: InpaintRegion, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct InpaintPartialPayload { - pub index: usize, - pub region: InpaintRegion, -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ViewImageParams { - pub index: usize, + pub document_id: String, pub layer: String, pub max_size: Option, } @@ -193,7 +88,7 @@ pub struct ViewImageParams { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ViewTextBlockParams { - pub index: usize, + pub document_id: String, pub text_block_index: usize, pub layer: Option, } @@ -207,24 +102,30 @@ pub struct OpenDocumentsParams { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ExportDocumentParams { - pub index: usize, + pub document_id: String, pub output_path: String, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct RenderParams { - pub index: usize, + pub document_id: String, pub text_block_index: Option, pub shader_effect: Option, pub font_family: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocumentIdParam { + pub document_id: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ProcessParams { - pub index: Option, - pub llm_model_id: Option, + pub document_id: Option, + pub llm_target: Option, pub language: Option, pub shader_effect: Option, pub font_family: Option, @@ -273,7 +174,7 @@ pub struct MaskMorphPayload { #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct InpaintRegionParams { - pub index: usize, + pub document_id: String, pub x: u32, pub y: u32, pub width: u32, @@ -282,7 +183,6 @@ pub struct InpaintRegionParams { #[cfg(test)] mod tests { - use crate::{TextAlign, TextStyle}; use serde::Serialize; use serde::de::DeserializeOwned; @@ -301,39 +201,13 @@ mod tests { #[test] fn command_dtos_round_trip() { - let text_block = TextBlock { - x: 10.0, - y: 11.0, - width: 120.0, - height: 40.0, - confidence: 0.95, - text: Some("source".to_string()), - translation: Some("translated".to_string()), - style: Some(TextStyle { - font_families: vec!["Noto Sans".to_string()], - font_size: Some(18.0), - color: [255, 255, 255, 255], - effect: Some(TextShaderEffect { - italic: true, - bold: false, - }), - stroke: Some(TextStrokeStyle { - enabled: true, - color: [255, 255, 255, 255], - width_px: Some(2.0), - }), - text_align: Some(TextAlign::Right), - }), - ..Default::default() - }; - round_trip(&DeviceInfo { ml_device: "CPU".to_string(), }); round_trip(&OpenExternalPayload { url: "https://example.com".to_string(), }); - round_trip(&IndexPayload { index: 2 }); + round_trip(&ThumbnailResult { data: vec![1, 2, 3], content_type: "image/webp".to_string(), @@ -353,62 +227,52 @@ mod tests { data: vec![1, 2, 3, 4], content_type: "image/png".to_string(), }); - round_trip(&RenderPayload { - index: 1, - text_block_index: Some(3), - shader_effect: Some(TextShaderEffect { - italic: false, - bold: true, - }), - shader_stroke: Some(TextStrokeStyle { - enabled: true, - color: [255, 255, 255, 255], - width_px: Some(1.6), - }), - font_family: Some("Noto Sans".to_string()), - }); - round_trip(&UpdateTextBlocksPayload { - index: 1, - text_blocks: vec![text_block.clone()], - }); - round_trip(&LlmListPayload { + round_trip(&LlmCatalogPayload { language: Some("zh-CN".to_string()), - openai_compatible_base_url: Some("http://127.0.0.1:1234/v1".to_string()), - }); - round_trip(&LlmLoadPayload { - id: "sakura".to_string(), - api_key: None, - base_url: Some("http://127.0.0.1:1234/v1".to_string()), - temperature: Some(0.1), - max_tokens: Some(1000), - custom_system_prompt: None, }); - round_trip(&LlmGeneratePayload { - index: 1, - text_block_index: Some(0), - language: Some("zh-CN".to_string()), + round_trip(&crate::LlmLoadRequest { + target: crate::LlmTarget { + kind: crate::LlmTargetKind::Local, + model_id: "sakura".to_string(), + provider_id: None, + }, + options: Some(crate::LlmGenerationOptions { + temperature: Some(0.1), + max_tokens: Some(1000), + custom_system_prompt: None, + }), }); round_trip(&LlmLoadParams { - id: "sakura".to_string(), - api_key: None, - base_url: Some("http://127.0.0.1:1234/v1".to_string()), - temperature: None, - max_tokens: None, - custom_system_prompt: None, + target: crate::LlmTarget { + kind: crate::LlmTargetKind::Provider, + model_id: "gpt-5-mini".to_string(), + provider_id: Some("openai".to_string()), + }, + options: Some(crate::LlmGenerationOptions { + temperature: None, + max_tokens: None, + custom_system_prompt: None, + }), }); round_trip(&LlmGenerateParams { - index: 1, + document_id: "abc123".to_string(), text_block_index: Some(0), language: Some("zh-CN".to_string()), }); round_trip(&ProcessRequest { - index: Some(1), - llm_model_id: Some("sakura".to_string()), - llm_api_key: None, - llm_base_url: Some("http://127.0.0.1:1234/v1".to_string()), - llm_temperature: Some(0.1), - llm_max_tokens: Some(1000), - llm_custom_system_prompt: Some("Translate manga".to_string()), + document_id: Some("abc123".to_string()), + llm: Some(crate::PipelineLlmRequest { + target: crate::LlmTarget { + kind: crate::LlmTargetKind::Provider, + model_id: "gpt-5-mini".to_string(), + provider_id: Some("openai".to_string()), + }, + options: Some(crate::LlmGenerationOptions { + temperature: Some(0.1), + max_tokens: Some(1000), + custom_system_prompt: Some("Translate manga".to_string()), + }), + }), language: Some("zh-CN".to_string()), shader_effect: Some(TextShaderEffect { italic: true, @@ -421,48 +285,13 @@ mod tests { }), font_family: Some("Noto Sans".to_string()), }); - round_trip(&InpaintRegion { - x: 10, - y: 20, - width: 30, - height: 40, - }); - round_trip(&UpdateInpaintMaskPayload { - index: 1, - mask: vec![0, 255], - region: Some(InpaintRegion { - x: 1, - y: 2, - width: 3, - height: 4, - }), - }); - round_trip(&UpdateBrushLayerPayload { - index: 1, - patch: vec![1, 2, 3], - region: InpaintRegion { - x: 4, - y: 5, - width: 6, - height: 7, - }, - }); - round_trip(&InpaintPartialPayload { - index: 1, - region: InpaintRegion { - x: 8, - y: 9, - width: 10, - height: 11, - }, - }); round_trip(&ViewImageParams { - index: 1, + document_id: "abc123".to_string(), layer: "original".to_string(), max_size: Some(512), }); round_trip(&ViewTextBlockParams { - index: 1, + document_id: "abc123".to_string(), text_block_index: 0, layer: Some("rendered".to_string()), }); @@ -470,18 +299,22 @@ mod tests { paths: vec!["a.png".to_string(), "b.png".to_string()], }); round_trip(&ExportDocumentParams { - index: 1, + document_id: "abc123".to_string(), output_path: "out.png".to_string(), }); round_trip(&RenderParams { - index: 1, + document_id: "abc123".to_string(), text_block_index: Some(0), shader_effect: Some("bold".to_string()), font_family: Some("Noto Sans".to_string()), }); round_trip(&ProcessParams { - index: Some(1), - llm_model_id: Some("sakura".to_string()), + document_id: Some("abc123".to_string()), + llm_target: Some(crate::LlmTarget { + kind: crate::LlmTargetKind::Local, + model_id: "sakura".to_string(), + provider_id: None, + }), language: Some("zh-CN".to_string()), shader_effect: Some("italic,bold".to_string()), font_family: Some("Noto Sans".to_string()), @@ -515,7 +348,7 @@ mod tests { radius: 2, }); round_trip(&InpaintRegionParams { - index: 1, + document_id: "abc123".to_string(), x: 2, y: 3, width: 4, diff --git a/koharu-core/src/lib.rs b/koharu-core/src/lib.rs index b85e8c9a56a831199c9baa989d983d9deae3aa32..3a0611119f2ecce58de6fe70f81922a7986a039c 100644 --- a/koharu-core/src/lib.rs +++ b/koharu-core/src/lib.rs @@ -15,12 +15,11 @@ pub use font::{FontPrediction, NamedFontPrediction, TextDirection}; pub use image::SerializableDynamicImage; pub use protocol::*; -use std::{path::PathBuf, sync::Arc}; +use std::path::PathBuf; use ::image::GenericImageView; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; use utoipa::ToSchema; use uuid::Uuid; @@ -63,12 +62,6 @@ pub struct TextBlock { } impl TextBlock { - pub fn ensure_id(&mut self) { - if self.id.trim().is_empty() { - self.id = new_text_block_id(); - } - } - pub fn set_layout_seed(&mut self, x: f32, y: f32, width: f32, height: f32) { self.layout_seed_x = Some(x); self.layout_seed_y = Some(y); @@ -125,7 +118,9 @@ const fn default_stroke_color() -> [u8; 4] { [255, 255, 255, 255] } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, ToSchema, JsonSchema)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, ToSchema, JsonSchema, +)] #[serde(rename_all = "camelCase")] pub enum TextAlign { #[default] @@ -155,8 +150,6 @@ pub struct Document { pub image: SerializableDynamicImage, pub width: u32, pub height: u32, - #[serde(default)] - pub revision: u64, pub text_blocks: Vec, pub segment: Option, pub inpainted: Option, @@ -199,29 +192,10 @@ impl Document { ..Default::default() }) } - - pub fn ensure_text_block_ids(&mut self) { - for block in &mut self.text_blocks { - block.ensure_id(); - } - } - - pub fn bump_revision(&mut self) { - self.revision = self.revision.saturating_add(1); - } - - pub fn prepare_for_store(&mut self) { - self.ensure_text_block_ids(); - } } -#[derive(Default, Debug, Clone, Serialize, Deserialize)] -pub struct State { - pub documents: Vec, -} - -pub type AppState = Arc>; - +/// Lightweight entry for a page — no decoded image data. +/// Stored in global state; the full `Document` lives in the page cache. #[cfg(test)] mod tests { use super::TextBlock; diff --git a/koharu-core/src/protocol.rs b/koharu-core/src/protocol.rs index 7d80bf52171d055aa17e9d1a133863b66f131fb8..596c6e342460700562a39c72d8b6df87c7604b8c 100644 --- a/koharu-core/src/protocol.rs +++ b/koharu-core/src/protocol.rs @@ -2,9 +2,11 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::{Document, FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle}; +use crate::{FontPrediction, TextBlock, TextShaderEffect, TextStrokeStyle, TextStyle}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, ToSchema)] +#[derive( + Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, JsonSchema, ToSchema, +)] #[serde(rename_all = "camelCase")] pub struct FontFaceInfo { pub family_name: String, @@ -20,22 +22,62 @@ pub struct MetaInfo { #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct BootstrapConfig { - pub runtime: BootstrapPathConfig, - pub models: BootstrapPathConfig, - pub http: BootstrapHttpConfig, +pub struct AppConfig { + pub data: AppDataConfig, + #[serde(default)] + pub llm: AppLlmConfig, } #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct BootstrapPathConfig { +pub struct AppConfigUpdate { + pub data: AppDataConfigUpdate, + #[serde(default)] + pub llm: AppLlmConfigUpdate, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AppDataConfig { pub path: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct BootstrapHttpConfig { - pub proxy: Option, +pub struct AppDataConfigUpdate { + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AppLlmConfig { + #[serde(default)] + pub providers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AppLlmConfigUpdate { + #[serde(default)] + pub providers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AppLlmProviderConfig { + pub id: String, + pub base_url: Option, + pub has_api_key: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AppLlmProviderConfigUpdate { + pub id: String, + pub base_url: Option, + pub api_key: Option, + #[serde(default)] + pub clear_api_key: bool, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -45,7 +87,6 @@ pub struct DocumentSummary { pub name: String, pub width: u32, pub height: u32, - pub revision: u64, pub has_segment: bool, pub has_inpainted: bool, pub has_brush_layer: bool, @@ -53,23 +94,6 @@ pub struct DocumentSummary { pub text_block_count: usize, } -impl From<&Document> for DocumentSummary { - fn from(document: &Document) -> Self { - Self { - id: document.id.clone(), - name: document.name.clone(), - width: document.width, - height: document.height, - revision: document.revision, - has_segment: document.segment.is_some(), - has_inpainted: document.inpainted.is_some(), - has_brush_layer: document.brush_layer.is_some(), - has_rendered: document.rendered.is_some(), - text_block_count: document.text_blocks.len(), - } - } -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] pub struct TextBlockDetail { @@ -124,7 +148,6 @@ pub struct DocumentDetail { pub name: String, pub width: u32, pub height: u32, - pub revision: u64, pub text_blocks: Vec, #[schema(value_type = Vec)] pub image: serde_bytes::ByteBuf, @@ -142,7 +165,6 @@ pub struct DocumentDetail { pub rendered: Option, } - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] pub struct TextBlockInput { @@ -204,14 +226,6 @@ pub struct ExportResult { pub count: usize, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct LlmModelInfo { - pub id: String, - pub languages: Vec, - pub source: String, -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "snake_case")] pub enum LlmStateStatus { @@ -225,36 +239,76 @@ pub enum LlmStateStatus { #[serde(rename_all = "camelCase")] pub struct LlmState { pub status: LlmStateStatus, - pub model_id: Option, - pub source: Option, + pub target: Option, pub error: Option, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct LlmLoadRequest { - pub id: String, - pub api_key: Option, - pub base_url: Option, +pub struct LlmGenerationOptions { pub temperature: Option, pub max_tokens: Option, pub custom_system_prompt: Option, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LlmTargetKind { + Local, + Provider, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct LlmTarget { + pub kind: LlmTargetKind, + pub model_id: String, + pub provider_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct LlmPingRequest { - pub base_url: String, - pub api_key: Option, +pub struct LlmLoadRequest { + pub target: LlmTarget, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LlmCatalogModel { + pub target: LlmTarget, + pub name: String, + pub languages: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum LlmProviderCatalogStatus { + Ready, + MissingConfiguration, + DiscoveryFailed, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] -pub struct LlmPingResponse { - pub ok: bool, - pub models: Vec, - pub latency_ms: Option, +pub struct LlmProviderCatalog { + pub id: String, + pub name: String, + pub requires_api_key: bool, + pub requires_base_url: bool, + pub has_api_key: bool, + pub base_url: Option, + pub status: LlmProviderCatalogStatus, pub error: Option, + pub models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct LlmCatalog { + pub local_models: Vec, + pub providers: Vec, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, ToSchema)] @@ -320,22 +374,9 @@ pub struct DocumentsChangedEvent { #[serde(rename_all = "camelCase")] pub struct DocumentChangedEvent { pub document_id: String, - pub revision: u64, pub changed: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ApiKeyValue { - pub api_key: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct ApiKeyResponse { - pub api_key: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] pub struct RenderRequest { @@ -352,16 +393,19 @@ pub struct TranslateRequest { pub language: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PipelineLlmRequest { + pub target: LlmTarget, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] pub struct PipelineJobRequest { pub document_id: Option, - pub llm_model_id: Option, - pub llm_api_key: Option, - pub llm_base_url: Option, - pub llm_temperature: Option, - pub llm_max_tokens: Option, - pub llm_custom_system_prompt: Option, + pub llm: Option, pub language: Option, pub shader_effect: Option, pub shader_stroke: Option, diff --git a/koharu-llm/src/api.rs b/koharu-llm/src/api.rs deleted file mode 100644 index 8e4d6df98ee7db47b39cf78b2db2349acdcc617e..0000000000000000000000000000000000000000 --- a/koharu-llm/src/api.rs +++ /dev/null @@ -1,60 +0,0 @@ -pub struct ApiModelInfo { - pub id: &'static str, - pub name: &'static str, -} - -pub struct ApiProviderInfo { - pub id: &'static str, - pub name: &'static str, - pub models: &'static [ApiModelInfo], -} - -pub const OPENAI_COMPATIBLE_ID: &str = "openai-compatible"; - -pub const OPENAI: ApiProviderInfo = ApiProviderInfo { - id: "openai", - name: "OpenAI", - models: &[ApiModelInfo { - id: "gpt-5-mini", - name: "GPT-5 mini", - }], -}; - -pub const GEMINI: ApiProviderInfo = ApiProviderInfo { - id: "gemini", - name: "Gemini", - models: &[ApiModelInfo { - id: "gemini-3.1-flash-lite-preview", - name: "Gemini 3.1 Flash-Lite Preview", - }], -}; - -pub const CLAUDE: ApiProviderInfo = ApiProviderInfo { - id: "claude", - name: "Claude", - models: &[ApiModelInfo { - id: "claude-haiku-4-5", - name: "Claude Haiku 4.5", - }], -}; - -pub const DEEPSEEK: ApiProviderInfo = ApiProviderInfo { - id: "deepseek", - name: "DeepSeek", - models: &[ApiModelInfo { - id: "deepseek-chat", - name: "DeepSeek-V3.2-Chat", - }], -}; - -pub const ALL_API_PROVIDERS: &[&ApiProviderInfo] = &[&OPENAI, &GEMINI, &CLAUDE, &DEEPSEEK]; - -pub fn find_api_model(id: &str) -> Option<(&'static ApiProviderInfo, &'static str)> { - let (provider_id, model_id) = id.split_once(':')?; - let provider = ALL_API_PROVIDERS - .iter() - .copied() - .find(|p| p.id == provider_id)?; - let model = provider.models.iter().find(|m| m.id == model_id)?; - Some((provider, model.id)) -} diff --git a/koharu-llm/src/lib.rs b/koharu-llm/src/lib.rs index a3ef42aae56f3c46cf84e45ff3134a2f107174af..741b45e98fec9eb99cb74307823825ac138a0898 100644 --- a/koharu-llm/src/lib.rs +++ b/koharu-llm/src/lib.rs @@ -1,4 +1,3 @@ -pub mod api; pub mod language; mod model; pub mod paddleocr_vl; diff --git a/koharu-llm/src/providers/chat_completions.rs b/koharu-llm/src/providers/chat_completions.rs new file mode 100644 index 0000000000000000000000000000000000000000..d54814eff0a2e4919fbabcd519c32d4d119adeb3 --- /dev/null +++ b/koharu-llm/src/providers/chat_completions.rs @@ -0,0 +1,80 @@ +use std::sync::Arc; + +use reqwest_middleware::ClientWithMiddleware; +use serde::Serialize; + +use super::ensure_provider_success; + +pub enum ChatCompletionsAuth { + None, + Bearer(String), +} + +pub struct ChatCompletionsRequest { + pub provider: &'static str, + pub endpoint: String, + pub auth: ChatCompletionsAuth, + pub model: String, + pub system_prompt: String, + pub user_prompt: String, + pub temperature: Option, + pub max_tokens: Option, +} + +#[derive(Serialize)] +struct ChatMessage { + role: &'static str, + content: String, +} + +#[derive(Serialize)] +struct ChatRequest<'a> { + model: &'a str, + messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, +} + +pub async fn send_chat_completion( + http_client: Arc, + request: ChatCompletionsRequest, +) -> anyhow::Result { + let body = ChatRequest { + model: &request.model, + messages: vec![ + ChatMessage { + role: "system", + content: request.system_prompt, + }, + ChatMessage { + role: "user", + content: request.user_prompt, + }, + ], + temperature: request.temperature, + max_tokens: request.max_tokens, + }; + + let mut http_request = http_client.post(&request.endpoint); + if let ChatCompletionsAuth::Bearer(api_key) = request.auth { + http_request = http_request.bearer_auth(api_key); + } + + let response = http_request + .header("content-type", "application/json") + .body(serde_json::to_vec(&body)?) + .send() + .await?; + + let resp: serde_json::Value = ensure_provider_success(request.provider, response) + .await? + .json() + .await?; + + resp["choices"][0]["message"]["content"] + .as_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow::anyhow!("{} returned no content", request.provider)) +} diff --git a/koharu-llm/src/providers/deepseek.rs b/koharu-llm/src/providers/deepseek.rs index 788bff42ae332c6588d6e6feaa78dea561bdce47..8f612f90b9d6f27ed23cd13c619f445592ff3006 100644 --- a/koharu-llm/src/providers/deepseek.rs +++ b/koharu-llm/src/providers/deepseek.rs @@ -3,30 +3,17 @@ use std::pin::Pin; use std::sync::Arc; use reqwest_middleware::ClientWithMiddleware; -use serde::Serialize; use crate::{Language, prompt::system_prompt}; -use super::{AnyProvider, ensure_provider_success}; +use super::AnyProvider; +use super::chat_completions::{ChatCompletionsAuth, ChatCompletionsRequest, send_chat_completion}; pub struct DeepSeekProvider { pub http_client: Arc, pub api_key: String, } -#[derive(Serialize)] -struct ChatMessage { - role: &'static str, - content: String, -} - -#[derive(Serialize)] -struct ChatRequest<'a> { - model: &'a str, - messages: Vec, - temperature: f32, -} - impl AnyProvider for DeepSeekProvider { fn translate<'a>( &'a self, @@ -35,41 +22,20 @@ impl AnyProvider for DeepSeekProvider { model: &'a str, ) -> Pin> + Send + 'a>> { Box::pin(async move { - let body = ChatRequest { - model, - messages: vec![ - ChatMessage { - role: "system", - content: system_prompt(target_language), - }, - ChatMessage { - role: "user", - content: source.to_string(), - }, - ], - temperature: 1.3, - }; - - let response = self - .http_client - .post("https://api.deepseek.com/chat/completions") - .bearer_auth(&self.api_key) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body)?) - .send() - .await?; - - let resp: serde_json::Value = ensure_provider_success("deepseek", response) - .await? - .json() - .await?; - - let text = resp["choices"][0]["message"]["content"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("DeepSeek returned no content"))? - .to_string(); - - Ok(text) + send_chat_completion( + Arc::clone(&self.http_client), + ChatCompletionsRequest { + provider: "deepseek", + endpoint: "https://api.deepseek.com/chat/completions".to_string(), + auth: ChatCompletionsAuth::Bearer(self.api_key.clone()), + model: model.to_string(), + system_prompt: system_prompt(target_language), + user_prompt: source.to_string(), + temperature: Some(1.3), + max_tokens: None, + }, + ) + .await }) } } diff --git a/koharu-llm/src/providers/mod.rs b/koharu-llm/src/providers/mod.rs index aaac20d7b2a74326cafb2f36a0d867e8e2e1d739..46a8890753fddb175335769bba9f427dd4cff99e 100644 --- a/koharu-llm/src/providers/mod.rs +++ b/koharu-llm/src/providers/mod.rs @@ -9,6 +9,7 @@ use reqwest_middleware::ClientWithMiddleware; use crate::Language; +mod chat_completions; pub mod claude; pub mod deepseek; pub mod gemini; @@ -16,9 +17,39 @@ pub mod openai; pub mod openai_compatible; const API_KEY_SERVICE: &str = "koharu"; +pub const OPENAI_COMPATIBLE_ID: &str = "openai-compatible"; static NO_KEYRING: AtomicBool = AtomicBool::new(false); +#[derive(Debug, Clone, Copy)] +pub struct ProviderModelDescriptor { + pub id: &'static str, + pub name: &'static str, +} + +#[derive(Debug, Clone)] +pub struct DiscoveredProviderModel { + pub id: String, + pub name: String, +} + +pub type ProviderDiscoveryFuture = + Pin>> + Send>>; + +pub enum ProviderCatalogModels { + Static(&'static [ProviderModelDescriptor]), + Dynamic(fn(ProviderConfig) -> ProviderDiscoveryFuture), +} + +pub struct ProviderDescriptor { + pub id: &'static str, + pub name: &'static str, + pub requires_api_key: bool, + pub requires_base_url: bool, + pub models: ProviderCatalogModels, + pub build: fn(ProviderConfig) -> anyhow::Result>, +} + pub fn disable_keyring() { NO_KEYRING.store(true, Ordering::Relaxed); } @@ -112,6 +143,7 @@ pub trait AnyProvider: Send + Sync { ) -> Pin> + Send + 'a>>; } +#[derive(Clone)] pub struct ProviderConfig { pub http_client: Arc, pub api_key: Option, @@ -121,50 +153,204 @@ pub struct ProviderConfig { pub custom_system_prompt: Option, } +const OPENAI_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor { + id: "gpt-5-mini", + name: "GPT-5 mini", +}]; + +const GEMINI_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor { + id: "gemini-3.1-flash-lite-preview", + name: "Gemini 3.1 Flash-Lite Preview", +}]; + +const CLAUDE_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor { + id: "claude-haiku-4-5", + name: "Claude Haiku 4.5", +}]; + +const DEEPSEEK_MODELS: &[ProviderModelDescriptor] = &[ProviderModelDescriptor { + id: "deepseek-chat", + name: "DeepSeek-V3.2-Chat", +}]; + +const PROVIDERS: &[ProviderDescriptor] = &[ + ProviderDescriptor { + id: "openai", + name: "OpenAI", + requires_api_key: true, + requires_base_url: false, + models: ProviderCatalogModels::Static(OPENAI_MODELS), + build: build_openai_provider, + }, + ProviderDescriptor { + id: "gemini", + name: "Gemini", + requires_api_key: true, + requires_base_url: false, + models: ProviderCatalogModels::Static(GEMINI_MODELS), + build: build_gemini_provider, + }, + ProviderDescriptor { + id: "claude", + name: "Claude", + requires_api_key: true, + requires_base_url: false, + models: ProviderCatalogModels::Static(CLAUDE_MODELS), + build: build_claude_provider, + }, + ProviderDescriptor { + id: "deepseek", + name: "DeepSeek", + requires_api_key: true, + requires_base_url: false, + models: ProviderCatalogModels::Static(DEEPSEEK_MODELS), + build: build_deepseek_provider, + }, + ProviderDescriptor { + id: OPENAI_COMPATIBLE_ID, + name: "OpenAI-compatible", + requires_api_key: false, + requires_base_url: true, + models: ProviderCatalogModels::Dynamic(discover_openai_compatible_models), + build: build_openai_compatible_provider, + }, +]; + +pub fn all_provider_descriptors() -> &'static [ProviderDescriptor] { + PROVIDERS +} + +pub fn find_provider_descriptor(provider_id: &str) -> Option<&'static ProviderDescriptor> { + PROVIDERS + .iter() + .find(|descriptor| descriptor.id == provider_id) +} + +pub fn discover_models( + provider_id: &str, + config: ProviderConfig, +) -> anyhow::Result { + let descriptor = find_provider_descriptor(provider_id) + .ok_or_else(|| anyhow::anyhow!("Unknown API provider: {provider_id}"))?; + Ok(match descriptor.models { + ProviderCatalogModels::Static(models) => { + let models = models + .iter() + .map(|model| DiscoveredProviderModel { + id: model.id.to_string(), + name: model.name.to_string(), + }) + .collect::>(); + Box::pin(async move { Ok(models) }) + } + ProviderCatalogModels::Dynamic(discover) => discover(config), + }) +} + pub fn build_provider( provider_id: &str, config: ProviderConfig, ) -> anyhow::Result> { - let required_api_key = |name: &str| { - config + let descriptor = find_provider_descriptor(provider_id) + .ok_or_else(|| anyhow::anyhow!("Unknown API provider: {provider_id}"))?; + + if descriptor.requires_api_key + && config .api_key - .clone() - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| anyhow::anyhow!("api_key is required for {name}")) - }; - - let provider: Box = match provider_id { - "openai" => Box::new(openai::OpenAiProvider { - http_client: Arc::clone(&config.http_client), - api_key: required_api_key("openai")?, - }), - "gemini" => Box::new(gemini::GeminiProvider { - http_client: Arc::clone(&config.http_client), - api_key: required_api_key("gemini")?, - }), - "claude" => Box::new(claude::ClaudeProvider { - http_client: Arc::clone(&config.http_client), - api_key: required_api_key("claude")?, - }), - "deepseek" => Box::new(deepseek::DeepSeekProvider { - http_client: Arc::clone(&config.http_client), - api_key: required_api_key("deepseek")?, - }), - "openai-compatible" => Box::new(openai_compatible::OpenAiCompatibleProvider { - http_client: Arc::clone(&config.http_client), - base_url: config - .base_url - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| { - anyhow::anyhow!("base_url is required for the openai-compatible provider") - })?, - api_key: config.api_key, - temperature: config.temperature, - max_tokens: config.max_tokens, - custom_system_prompt: config.custom_system_prompt, - }), - other => anyhow::bail!("Unknown API provider: {other}"), - }; - - Ok(provider) + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + anyhow::bail!("api_key is required for {}", descriptor.id); + } + + if descriptor.requires_base_url + && config + .base_url + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + anyhow::bail!("base_url is required for {}", descriptor.id); + } + + (descriptor.build)(config) +} + +fn required_api_key(config: &ProviderConfig, provider_id: &str) -> anyhow::Result { + config + .api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow::anyhow!("api_key is required for {provider_id}")) +} + +fn required_base_url(config: &ProviderConfig, provider_id: &str) -> anyhow::Result { + config + .base_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow::anyhow!("base_url is required for {provider_id}")) +} + +fn build_openai_provider(config: ProviderConfig) -> anyhow::Result> { + Ok(Box::new(openai::OpenAiProvider { + http_client: Arc::clone(&config.http_client), + api_key: required_api_key(&config, "openai")?, + })) +} + +fn build_gemini_provider(config: ProviderConfig) -> anyhow::Result> { + Ok(Box::new(gemini::GeminiProvider { + http_client: Arc::clone(&config.http_client), + api_key: required_api_key(&config, "gemini")?, + })) +} + +fn build_claude_provider(config: ProviderConfig) -> anyhow::Result> { + Ok(Box::new(claude::ClaudeProvider { + http_client: Arc::clone(&config.http_client), + api_key: required_api_key(&config, "claude")?, + })) +} + +fn build_deepseek_provider(config: ProviderConfig) -> anyhow::Result> { + Ok(Box::new(deepseek::DeepSeekProvider { + http_client: Arc::clone(&config.http_client), + api_key: required_api_key(&config, "deepseek")?, + })) +} + +fn build_openai_compatible_provider( + config: ProviderConfig, +) -> anyhow::Result> { + Ok(Box::new(openai_compatible::OpenAiCompatibleProvider { + http_client: Arc::clone(&config.http_client), + base_url: required_base_url(&config, OPENAI_COMPATIBLE_ID)?, + api_key: config.api_key, + temperature: config.temperature, + max_tokens: config.max_tokens, + custom_system_prompt: config.custom_system_prompt, + })) +} + +fn discover_openai_compatible_models(config: ProviderConfig) -> ProviderDiscoveryFuture { + Box::pin(async move { + let base_url = required_base_url(&config, OPENAI_COMPATIBLE_ID)?; + let models = openai_compatible::list_models( + config.http_client, + &base_url, + config.api_key.as_deref(), + ) + .await?; + Ok(models + .into_iter() + .map(|id| DiscoveredProviderModel { + name: id.clone(), + id, + }) + .collect()) + }) } diff --git a/koharu-llm/src/providers/openai.rs b/koharu-llm/src/providers/openai.rs index 5371502c3c8bdd0811add9a3aa1585711da31808..a3fd990f48301457f715bc56a3e2ed3863daef63 100644 --- a/koharu-llm/src/providers/openai.rs +++ b/koharu-llm/src/providers/openai.rs @@ -3,29 +3,17 @@ use std::pin::Pin; use std::sync::Arc; use reqwest_middleware::ClientWithMiddleware; -use serde::Serialize; use crate::{Language, prompt::system_prompt}; -use super::{AnyProvider, ensure_provider_success}; +use super::AnyProvider; +use super::chat_completions::{ChatCompletionsAuth, ChatCompletionsRequest, send_chat_completion}; pub struct OpenAiProvider { pub http_client: Arc, pub api_key: String, } -#[derive(Serialize)] -struct ChatMessage { - role: &'static str, - content: String, -} - -#[derive(Serialize)] -struct ChatRequest<'a> { - model: &'a str, - messages: Vec, -} - impl AnyProvider for OpenAiProvider { fn translate<'a>( &'a self, @@ -34,40 +22,20 @@ impl AnyProvider for OpenAiProvider { model: &'a str, ) -> Pin> + Send + 'a>> { Box::pin(async move { - let body = ChatRequest { - model, - messages: vec![ - ChatMessage { - role: "system", - content: system_prompt(target_language), - }, - ChatMessage { - role: "user", - content: source.to_string(), - }, - ], - }; - - let response = self - .http_client - .post("https://api.openai.com/v1/chat/completions") - .bearer_auth(&self.api_key) - .header("content-type", "application/json") - .body(serde_json::to_vec(&body)?) - .send() - .await?; - - let resp: serde_json::Value = ensure_provider_success("openai", response) - .await? - .json() - .await?; - - let text = resp["choices"][0]["message"]["content"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("OpenAI returned no content"))? - .to_string(); - - Ok(text) + send_chat_completion( + Arc::clone(&self.http_client), + ChatCompletionsRequest { + provider: "openai", + endpoint: "https://api.openai.com/v1/chat/completions".to_string(), + auth: ChatCompletionsAuth::Bearer(self.api_key.clone()), + model: model.to_string(), + system_prompt: system_prompt(target_language), + user_prompt: source.to_string(), + temperature: None, + max_tokens: None, + }, + ) + .await }) } } diff --git a/koharu-llm/src/providers/openai_compatible.rs b/koharu-llm/src/providers/openai_compatible.rs index f853a5c252bb8818c630cc6e4667f319ccb9c507..3eae04ed62b2346645ad75f963235ed548add217 100644 --- a/koharu-llm/src/providers/openai_compatible.rs +++ b/koharu-llm/src/providers/openai_compatible.rs @@ -1,13 +1,12 @@ +use reqwest_middleware::ClientWithMiddleware; +use serde::Deserialize; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::time::Instant; - -use reqwest_middleware::ClientWithMiddleware; -use serde::{Deserialize, Serialize}; use crate::{Language, prompt::system_prompt}; +use super::chat_completions::{ChatCompletionsAuth, ChatCompletionsRequest, send_chat_completion}; use super::{AnyProvider, ensure_provider_success}; #[derive(Debug, Clone)] @@ -30,22 +29,6 @@ struct ModelEntry { id: String, } -#[derive(Serialize)] -struct ChatMessage { - role: &'static str, - content: String, -} - -#[derive(Serialize)] -struct ChatRequest<'a> { - model: &'a str, - messages: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - temperature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - max_tokens: Option, -} - fn normalized_base_url(base_url: &str) -> anyhow::Result { let normalized = base_url.trim().trim_end_matches('/').to_string(); if normalized.is_empty() { @@ -83,27 +66,6 @@ pub async fn list_models( Ok(ids) } -pub struct PingResult { - pub models: Vec, - pub latency_ms: u64, -} - -pub async fn ping( - http_client: Arc, - base_url: &str, - api_key: Option<&str>, -) -> anyhow::Result { - let start = Instant::now(); - let models = tokio::time::timeout( - std::time::Duration::from_secs(5), - list_models(http_client, base_url, api_key), - ) - .await - .map_err(|_| anyhow::anyhow!("Connection timed out after 5 seconds"))??; - let latency_ms = start.elapsed().as_millis() as u64; - Ok(PingResult { models, latency_ms }) -} - impl AnyProvider for OpenAiCompatibleProvider { fn translate<'a>( &'a self, @@ -116,49 +78,25 @@ impl AnyProvider for OpenAiCompatibleProvider { Some(p) if !p.trim().is_empty() => p.clone(), _ => system_prompt(target_language), }; - let body = ChatRequest { - model, - messages: vec![ - ChatMessage { - role: "system", - content: prompt, - }, - ChatMessage { - role: "user", - content: source.to_string(), - }, - ], - temperature: self.temperature, - max_tokens: self.max_tokens, - }; - - let endpoint = format!("{}/chat/completions", normalized_base_url(&self.base_url)?); - let mut request = self.http_client.post(endpoint); - if let Some(api_key) = self - .api_key - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - request = request.bearer_auth(api_key); - } - - let response = request - .header("content-type", "application/json") - .body(serde_json::to_vec(&body)?) - .send() - .await?; - - let resp: serde_json::Value = ensure_provider_success("openai-compatible", response) - .await? - .json() - .await?; - - let text = resp["choices"][0]["message"]["content"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("OpenAI-compatible provider returned no content"))? - .to_string(); - - Ok(text) + send_chat_completion( + Arc::clone(&self.http_client), + ChatCompletionsRequest { + provider: "openai-compatible", + endpoint: format!("{}/chat/completions", normalized_base_url(&self.base_url)?), + auth: self + .api_key + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|key| ChatCompletionsAuth::Bearer(key.to_string())) + .unwrap_or(ChatCompletionsAuth::None), + model: model.to_string(), + system_prompt: prompt, + user_prompt: source.to_string(), + temperature: self.temperature, + max_tokens: self.max_tokens, + }, + ) + .await }) } } diff --git a/koharu-llm/tests/llm.rs b/koharu-llm/tests/llm.rs index 83e8d20e57c4c3871b842e6d4b193fea28f465da..ca15b2aa590e74a9d1b0a6716f10eafc06027f1a 100644 --- a/koharu-llm/tests/llm.rs +++ b/koharu-llm/tests/llm.rs @@ -1,11 +1,12 @@ -use std::path::PathBuf; use std::sync::Arc; use strum::IntoEnumIterator; use koharu_llm::safe::llama_backend::LlamaBackend; use koharu_llm::{GenerateOptions, Language, Llm, ModelId}; -use koharu_runtime::{ComputePolicy, RuntimeManager, Settings}; +use koharu_runtime::{ + ComputePolicy, DirectorySetting, RuntimeManager, Settings, default_app_data_root, +}; #[tokio::test] #[ignore] // Ignored because it requires downloading multiple large models. @@ -14,13 +15,17 @@ async fn llm_generates_text_for_all_models() -> anyhow::Result<()> { テストです。 さよなら。"#; - let model_dir = dirs::data_local_dir() - .map(|path| path.join("Koharu")) - .unwrap_or(PathBuf::from(".")) - .join("models"); + let app_data_root = default_app_data_root(); let runtime = RuntimeManager::new( - Settings::from_paths(koharu_runtime::default_runtime_root(), model_dir), + Settings { + runtime: DirectorySetting { + path: app_data_root.join("runtime"), + }, + models: DirectorySetting { + path: app_data_root.join("models"), + }, + }, ComputePolicy::PreferGpu, )?; runtime.prepare().await?; diff --git a/koharu-psd/examples/full_fixture.rs b/koharu-psd/examples/full_fixture.rs index c5383b9786ec9a8af6cf7a22b27a3be72335edf9..428e2a4640bbfeb314536fdf63acd9fb15e58216 100644 --- a/koharu-psd/examples/full_fixture.rs +++ b/koharu-psd/examples/full_fixture.rs @@ -80,7 +80,6 @@ fn build_document() -> Document { image: to_serializable_rgba(original), width, height, - revision: 0, text_blocks: vec![ TextBlock { id: "hero-title".to_string(), diff --git a/koharu-psd/tests/export.rs b/koharu-psd/tests/export.rs index 8da2cfc20ff91d95d37c8d35c2f4d597258b8bce..cfdbac5eb0e417b95f2e978631bb0b9756c14af3 100644 --- a/koharu-psd/tests/export.rs +++ b/koharu-psd/tests/export.rs @@ -31,7 +31,6 @@ fn sample_document() -> Document { image: rgba_image(16, 12, [240, 240, 240, 255]), width: 16, height: 12, - revision: 0, text_blocks: vec![ TextBlock { id: "block-h".to_string(), diff --git a/koharu-rpc/src/api.rs b/koharu-rpc/src/api.rs index 823d4d881dd868c4d9c9ad23d22efed386000a00..cb9006004ebf0217585f95bad2cb781b761d04cd 100644 --- a/koharu-rpc/src/api.rs +++ b/koharu-rpc/src/api.rs @@ -1,6 +1,3 @@ -use std::io::Cursor; - -use anyhow::Context; use axum::{ Json, body::Body, @@ -11,27 +8,19 @@ use axum::{ }, response::{IntoResponse, Response}, }; -use image::ImageFormat; -use koharu_app::{ - AppResources, operations, - state_tx::{self, ChangedField}, -}; +use koharu_app::{AppResources, config as app_config, edit, io, llm, ml, pipeline}; use koharu_core::{ - ApiKeyResponse, ApiKeyValue, BootstrapConfig, CreateTextBlock, Document, DocumentDetail, - DocumentSummary, DownloadState, ExportLayer, ExportResult, FontFaceInfo, InpaintRegion, - JobState, JobStatus, LlmLoadRequest, LlmModelInfo, LlmPingRequest, LlmPingResponse, - MaskRegionRequest, MetaInfo, PipelineJobRequest, Region, RenderRequest, TextBlock, - TextBlockDetail, TextBlockInput, TextBlockPatch, TranslateRequest, + AppConfig, AppConfigUpdate, CreateTextBlock, Document, DocumentDetail, DocumentSummary, + DownloadState, ExportLayer, ExportResult, FontFaceInfo, JobState, JobStatus, LlmCatalog, + LlmLoadRequest, LlmState, MaskRegionRequest, MetaInfo, PipelineJobRequest, RenderRequest, + TextBlock, TextBlockDetail, TextBlockInput, TextBlockPatch, TranslateRequest, }; use koharu_psd::{PsdExportOptions, TextLayerMode}; use serde::{Deserialize, Serialize}; use utoipa::IntoParams; use utoipa_axum::{router::OpenApiRouter, routes}; -use crate::{ - shared::{SharedState, get_resources}, - tracker::Tracker, -}; +use crate::{shared::SharedState, tracker::Tracker}; const MAX_BODY_SIZE: usize = 1024 * 1024 * 1024; @@ -43,7 +32,9 @@ pub struct ApiState { impl ApiState { fn resources(&self) -> ApiResult { - get_resources(&self.resources).map_err(ApiError::service_unavailable) + self.resources.get().ok_or_else(|| { + ApiError::service_unavailable(anyhow::anyhow!("Resources not initialized")) + }) } } @@ -65,9 +56,7 @@ pub fn api() -> (axum::Router, utoipa::openapi::OpenApi) { .routes(routes!(export_document)) .routes(routes!(batch_export)) .routes(routes!(get_llm, load_llm, unload_llm)) - .routes(routes!(list_llm_models)) - .routes(routes!(check_llm_health)) - .routes(routes!(get_api_key, set_api_key)) + .routes(routes!(get_llm_catalog)) .routes(routes!(start_pipeline)) .routes(routes!(list_jobs)) .routes(routes!(get_job, cancel_job)) @@ -75,7 +64,6 @@ pub fn api() -> (axum::Router, utoipa::openapi::OpenApi) { .routes(routes!(get_meta)) .routes(routes!(list_fonts)) .routes(routes!(get_config, update_config)) - .routes(routes!(initialize)) .split_for_parts() } @@ -111,10 +99,6 @@ impl ApiError { Self::new(StatusCode::NOT_FOUND, message) } - fn conflict(message: impl Into) -> Self { - Self::new(StatusCode::CONFLICT, message) - } - fn service_unavailable(error: anyhow::Error) -> Self { Self::new(StatusCode::SERVICE_UNAVAILABLE, error.to_string()) } @@ -137,8 +121,7 @@ impl From for ApiError { impl IntoResponse for ApiError { fn into_response(self) -> Response { - let status = - StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); (status, Json(self)).into_response() } } @@ -158,9 +141,8 @@ struct ImportQuery { #[derive(Debug, Deserialize, IntoParams)] #[serde(rename_all = "camelCase")] -struct LlmModelsQuery { +struct LlmCatalogQuery { language: Option, - openai_compatible_base_url: Option, } #[derive(Debug, Deserialize, IntoParams)] @@ -185,12 +167,13 @@ struct ExportBatchRequest { operation_id = "getConfig", tag = "system", responses( - (status = 200, body = BootstrapConfig), + (status = 200, body = AppConfig), (status = 503, body = ApiError), ), )] -async fn get_config(State(state): State) -> ApiResult> { - let config = state.resources.get_config().map_err(ApiError::internal)?; +async fn get_config() -> ApiResult> { + let stored = app_config::load().map_err(ApiError::internal)?; + let config = app_config::to_public_config(&stored).map_err(ApiError::internal)?; Ok(Json(config)) } @@ -199,45 +182,22 @@ async fn get_config(State(state): State) -> ApiResult, - Json(config): Json, -) -> ApiResult> { - let saved = state.resources.put_config(config).map_err(ApiError::from)?; +async fn update_config(Json(config): Json) -> ApiResult> { + let current = app_config::load().map_err(ApiError::internal)?; + let stored = app_config::from_public_update(config.clone()).map_err(ApiError::from)?; + app_config::move_app_data_if_needed(¤t, &stored).map_err(ApiError::internal)?; + app_config::save(&stored).map_err(ApiError::internal)?; + app_config::apply_secret_updates(&config).map_err(ApiError::from)?; + let saved = app_config::to_public_config(&stored).map_err(ApiError::internal)?; Ok(Json(saved)) } -#[utoipa::path( - post, - path = "/initialize", - operation_id = "initialize", - tag = "system", - responses( - (status = 204), - (status = 409, body = ApiError), - (status = 500, body = ApiError), - ), -)] -async fn initialize(State(state): State) -> ApiResult { - match state.resources.initialize().await { - Ok(()) => Ok(StatusCode::NO_CONTENT), - Err(error) => { - let message = error.to_string(); - if message.contains("already in progress") { - Err(ApiError::conflict(message)) - } else { - Err(ApiError::internal(error)) - } - } - } -} - #[utoipa::path( get, path = "/meta", @@ -250,7 +210,7 @@ async fn initialize(State(state): State) -> ApiResult { )] async fn get_meta(State(state): State) -> ApiResult> { let resources = state.resources()?; - let device = operations::device(resources.clone()).await?; + let device = io::device(resources.clone()).await?; Ok(Json(MetaInfo { version: resources.version.to_string(), ml_device: device.ml_device, @@ -269,7 +229,7 @@ async fn get_meta(State(state): State) -> ApiResult> { )] async fn list_fonts(State(state): State) -> ApiResult>> { let resources = state.resources()?; - let fonts = operations::list_font_families(resources) + let fonts = ml::list_font_families(resources) .await .map_err(ApiError::from)?; Ok(Json(fonts)) @@ -291,8 +251,10 @@ async fn list_fonts(State(state): State) -> ApiResult) -> ApiResult>> { let resources = state.resources()?; - let guard = resources.state.read().await; - let documents = guard.documents.iter().map(DocumentSummary::from).collect(); + let documents = resources + .cache + .list_documents() + .map_err(ApiError::internal)?; Ok(Json(documents)) } @@ -313,20 +275,45 @@ async fn get_document( Path(document_id): Path, ) -> ApiResult> { let resources = state.resources()?; - let guard = resources.state.read().await; - let doc = guard - .documents - .iter() - .find(|d| d.id == document_id) - .ok_or_else(|| ApiError::not_found("Document not found"))?; + let doc = resources + .cache + .get(&document_id) + .await + .map_err(|_| ApiError::not_found("Document not found"))?; - let image = serde_bytes::ByteBuf::from(encode_webp(&doc.image)?); - let segment = doc.segment.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?; - let inpainted = doc.inpainted.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?; - let brush_layer = doc.brush_layer.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?; - let rendered = doc.rendered.as_ref().map(|s| encode_webp(s).map(serde_bytes::ByteBuf::from)).transpose()?; + let image = serde_bytes::ByteBuf::from( + koharu_app::utils::encode_image(&doc.image, "webp").map_err(ApiError::internal)?, + ); + let segment = doc + .segment + .as_ref() + .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from)) + .transpose() + .map_err(ApiError::internal)?; + let inpainted = doc + .inpainted + .as_ref() + .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from)) + .transpose() + .map_err(ApiError::internal)?; + let brush_layer = doc + .brush_layer + .as_ref() + .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from)) + .transpose() + .map_err(ApiError::internal)?; + let rendered = doc + .rendered + .as_ref() + .map(|s| koharu_app::utils::encode_image(s, "webp").map(serde_bytes::ByteBuf::from)) + .transpose() + .map_err(ApiError::internal)?; - let text_blocks = doc.text_blocks.iter().map(koharu_core::TextBlockDetail::from).collect(); + let text_blocks = doc + .text_blocks + .iter() + .map(koharu_core::TextBlockDetail::from) + .collect(); let detail = DocumentDetail { id: doc.id.clone(), @@ -334,7 +321,6 @@ async fn get_document( name: doc.name.clone(), width: doc.width, height: doc.height, - revision: doc.revision, text_blocks, image, segment, @@ -343,7 +329,6 @@ async fn get_document( rendered, }; - drop(guard); Ok(Json(detail)) } @@ -372,16 +357,15 @@ async fn get_document_thumbnail( ) -> ApiResult { let size = query.size.unwrap_or(200).min(800); let resources = state.resources()?; - let guard = resources.state.read().await; - let doc = guard - .documents - .iter() - .find(|d| d.id == document_id) - .ok_or_else(|| ApiError::not_found("Document not found"))?; + let doc = resources + .cache + .get(&document_id) + .await + .map_err(|_| ApiError::not_found("Document not found"))?; let source = doc.rendered.as_ref().unwrap_or(&doc.image); let thumbnail = source.thumbnail(size, size); - let bytes = encode_webp(&thumbnail.into())?; - drop(guard); + let bytes = + koharu_app::utils::encode_image(&thumbnail.into(), "webp").map_err(ApiError::internal)?; Ok(binary_response(bytes, "image/webp", None)) } @@ -404,7 +388,7 @@ async fn import_documents( mut multipart: Multipart, ) -> ApiResult> { let resources = state.resources()?; - let mut files = Vec::new(); + let mut uploaded_files = Vec::new(); while let Some(field) = multipart .next_field() @@ -419,31 +403,32 @@ async fn import_documents( .bytes() .await .map_err(|error| ApiError::bad_request(error.to_string()))?; - files.push(koharu_core::FileEntry { - name: filename, - data: data.to_vec(), - }); + uploaded_files.push((filename, data.to_vec())); } - if files.is_empty() { + if uploaded_files.is_empty() { return Err(ApiError::bad_request("No files uploaded")); } + let files = uploaded_files + .into_iter() + .map(|(name, data)| koharu_core::FileEntry { name, data }) + .collect(); + let payload = koharu_core::OpenDocumentsPayload { files }; match query.mode.unwrap_or(koharu_core::ImportMode::Replace) { koharu_core::ImportMode::Replace => { - operations::open_documents(resources.clone(), payload).await?; + io::open_documents(resources.clone(), payload).await?; } koharu_core::ImportMode::Append => { - operations::add_documents(resources.clone(), payload).await?; + io::add_documents(resources.clone(), payload).await?; } } - let documents = state_tx::list_docs(&resources.state) - .await - .iter() - .map(DocumentSummary::from) - .collect::>(); + let documents = resources + .cache + .list_documents() + .map_err(ApiError::internal)?; Ok(Json(koharu_core::ImportResult { total_count: documents.len(), @@ -472,8 +457,7 @@ async fn detect_document( Path(document_id): Path, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - operations::detect(resources, koharu_core::IndexPayload { index }).await?; + ml::detect(resources, &document_id).await?; Ok(StatusCode::NO_CONTENT) } @@ -494,8 +478,7 @@ async fn recognize_document( Path(document_id): Path, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - operations::ocr(resources, koharu_core::IndexPayload { index }).await?; + ml::ocr(resources, &document_id).await?; Ok(StatusCode::NO_CONTENT) } @@ -516,8 +499,7 @@ async fn inpaint_document( Path(document_id): Path, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - operations::inpaint(resources, koharu_core::IndexPayload { index }).await?; + ml::inpaint(resources, &document_id).await?; Ok(StatusCode::NO_CONTENT) } @@ -540,22 +522,20 @@ async fn render_document( Json(request): Json, ) -> ApiResult { let resources = state.resources()?; - let (index, document) = find_document(&resources, &document_id).await?; + let document = find_document(&resources, &document_id).await?; let text_block_index = request .text_block_id .as_deref() .map(|id| find_text_block_index(&document, id)) .transpose()?; - operations::render( + ml::render( resources, - koharu_core::RenderPayload { - index, - text_block_index, - shader_effect: request.shader_effect, - shader_stroke: request.shader_stroke, - font_family: request.font_family, - }, + &document_id, + text_block_index, + request.shader_effect, + request.shader_stroke, + request.font_family.as_deref(), ) .await?; @@ -581,20 +561,18 @@ async fn translate_document( Json(request): Json, ) -> ApiResult { let resources = state.resources()?; - let (index, document) = find_document(&resources, &document_id).await?; + let document = find_document(&resources, &document_id).await?; let text_block_index = request .text_block_id .as_deref() .map(|id| find_text_block_index(&document, id)) .transpose()?; - operations::llm_generate( + llm::llm_generate( resources, - koharu_core::LlmGeneratePayload { - index, - text_block_index, - language: request.language, - }, + &document_id, + text_block_index, + request.language.as_deref(), ) .await?; @@ -624,16 +602,7 @@ async fn update_mask( Json(request): Json, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - operations::update_inpaint_mask( - resources, - koharu_core::UpdateInpaintMaskPayload { - index, - mask: request.data, - region: request.region.map(to_inpaint_region), - }, - ) - .await?; + edit::update_inpaint_mask(resources, &document_id, &request.data, request.region).await?; Ok(StatusCode::NO_CONTENT) } @@ -655,16 +624,7 @@ async fn update_brush_layer( Json(request): Json, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - operations::update_brush_layer( - resources, - koharu_core::UpdateBrushLayerPayload { - index, - patch: request.data, - region: to_inpaint_region(request.region), - }, - ) - .await?; + edit::update_brush_layer(resources, &document_id, &request.data, request.region).await?; Ok(StatusCode::NO_CONTENT) } @@ -686,15 +646,7 @@ async fn inpaint_region( Json(request): Json, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - operations::inpaint_partial( - resources, - koharu_core::InpaintPartialPayload { - index, - region: to_inpaint_region(request.region), - }, - ) - .await?; + edit::inpaint_partial(resources, &document_id, request.region).await?; Ok(StatusCode::NO_CONTENT) } @@ -721,31 +673,32 @@ async fn create_text_block( Json(request): Json, ) -> ApiResult> { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - - let detail = state_tx::mutate_doc( - &resources.state, - index, - &[ChangedField::TextBlocks], - |document| { - let mut block = TextBlock { - x: request.x, - y: request.y, - width: request.width, - height: request.height, - confidence: 1.0, - ..Default::default() - }; - block.set_layout_seed(block.x, block.y, block.width, block.height); - document.text_blocks.push(block); - let block = document - .text_blocks - .last() - .ok_or_else(|| anyhow::anyhow!("Failed to append text block"))?; - Ok(TextBlockDetail::from(block)) - }, - ) - .await?; + let mut doc = resources + .cache + .get(&document_id) + .await + .map_err(|_| ApiError::not_found("Document not found"))?; + + let mut block = TextBlock { + x: request.x, + y: request.y, + width: request.width, + height: request.height, + confidence: 1.0, + ..Default::default() + }; + block.set_layout_seed(block.x, block.y, block.width, block.height); + doc.text_blocks.push(block); + let detail = doc + .text_blocks + .last() + .map(TextBlockDetail::from) + .ok_or_else(|| ApiError::internal(anyhow::anyhow!("Failed to append text block")))?; + resources + .cache + .put(&doc) + .await + .map_err(ApiError::internal)?; Ok(Json(detail)) } @@ -769,91 +722,77 @@ async fn put_text_blocks( Json(inputs): Json>, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - - let any_content_changed = state_tx::mutate_doc( - &resources.state, - index, - &[ChangedField::TextBlocks], - |document| { - let mut any_changed = false; - - // Build a set of incoming IDs for deletion detection - let incoming_ids: std::collections::HashSet<&str> = inputs - .iter() - .filter_map(|input| input.id.as_deref()) - .collect(); - - // Delete blocks not present in the incoming array - let before_len = document.text_blocks.len(); - document - .text_blocks - .retain(|block| incoming_ids.contains(block.id.as_str())); - if document.text_blocks.len() != before_len { - any_changed = true; - } - for input in &inputs { - if let Some(ref id) = input.id { - // Update existing block - if let Some(block) = document - .text_blocks - .iter_mut() - .find(|b| &b.id == id) - { - let patch = TextBlockPatch { - text: input.text.clone(), - translation: input.translation.clone(), - x: Some(input.x), - y: Some(input.y), - width: Some(input.width), - height: Some(input.height), - style: input.style.clone(), - }; - let had_render = block.rendered.is_some(); - apply_text_block_patch(block, patch); - // Content changed if the render was invalidated - if had_render && block.rendered.is_none() { - any_changed = true; - } - } - } else { - // Create new block - let mut block = TextBlock { - x: input.x, - y: input.y, - width: input.width, - height: input.height, - text: input.text.clone(), - translation: input.translation.clone(), - style: input.style.clone(), - confidence: 1.0, - ..Default::default() - }; - block.set_layout_seed(block.x, block.y, block.width, block.height); - document.text_blocks.push(block); + let mut doc = resources + .cache + .get(&document_id) + .await + .map_err(|_| ApiError::not_found("Document not found"))?; + + let mut any_changed = false; + + // Build a set of incoming IDs for deletion detection + let incoming_ids: std::collections::HashSet<&str> = inputs + .iter() + .filter_map(|input| input.id.as_deref()) + .collect(); + + // Delete blocks not present in the incoming array + let before_len = doc.text_blocks.len(); + doc.text_blocks + .retain(|block| incoming_ids.contains(block.id.as_str())); + if doc.text_blocks.len() != before_len { + any_changed = true; + } + + for input in &inputs { + if let Some(ref id) = input.id { + // Update existing block + if let Some(block) = doc.text_blocks.iter_mut().find(|b| &b.id == id) { + let patch = TextBlockPatch { + text: input.text.clone(), + translation: input.translation.clone(), + x: Some(input.x), + y: Some(input.y), + width: Some(input.width), + height: Some(input.height), + style: input.style.clone(), + }; + let had_render = block.rendered.is_some(); + apply_text_block_patch(block, patch); + // Content changed if the render was invalidated + if had_render && block.rendered.is_none() { any_changed = true; } } + } else { + // Create new block + let mut block = TextBlock { + x: input.x, + y: input.y, + width: input.width, + height: input.height, + text: input.text.clone(), + translation: input.translation.clone(), + style: input.style.clone(), + confidence: 1.0, + ..Default::default() + }; + block.set_layout_seed(block.x, block.y, block.width, block.height); + doc.text_blocks.push(block); + any_changed = true; + } + } - Ok(any_changed) - }, - ) - .await?; + resources + .cache + .put(&doc) + .await + .map_err(ApiError::internal)?; // Auto-render if any block content/geometry changed - if any_content_changed { - let _ = operations::render( - resources, - koharu_core::RenderPayload { - index, - text_block_index: None, - shader_effect: None, - shader_stroke: None, - font_family: None, - }, - ) - .await; + if any_changed { + let _ = ml::render(resources, &document_id, None, None, None, None).await; } Ok(StatusCode::NO_CONTENT) @@ -881,23 +820,23 @@ async fn patch_text_block( Json(request): Json, ) -> ApiResult> { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - - let detail = state_tx::mutate_doc( - &resources.state, - index, - &[ChangedField::TextBlocks], - |document| { - let block = document - .text_blocks - .iter_mut() - .find(|block| block.id == text_block_id) - .ok_or_else(|| anyhow::anyhow!("Text block not found: {text_block_id}"))?; - apply_text_block_patch(block, request.clone()); - Ok(TextBlockDetail::from(&*block)) - }, - ) - .await?; + let mut doc = resources + .cache + .get(&document_id) + .await + .map_err(|_| ApiError::not_found("Document not found"))?; + let block = doc + .text_blocks + .iter_mut() + .find(|block| block.id == text_block_id) + .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))?; + apply_text_block_patch(block, request); + let detail = TextBlockDetail::from(&*block); + resources + .cache + .put(&doc) + .await + .map_err(ApiError::internal)?; Ok(Json(detail)) } @@ -922,23 +861,22 @@ async fn delete_text_block( Path((document_id, text_block_id)): Path<(String, String)>, ) -> ApiResult { let resources = state.resources()?; - let (index, _) = find_document(&resources, &document_id).await?; - - state_tx::mutate_doc( - &resources.state, - index, - &[ChangedField::TextBlocks], - |document| { - let block_index = document - .text_blocks - .iter() - .position(|block| block.id == text_block_id) - .ok_or_else(|| anyhow::anyhow!("Text block not found: {text_block_id}"))?; - document.text_blocks.remove(block_index); - Ok(()) - }, - ) - .await?; + let mut doc = resources + .cache + .get(&document_id) + .await + .map_err(|_| ApiError::not_found("Document not found"))?; + let block_index = doc + .text_blocks + .iter() + .position(|block| block.id == text_block_id) + .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}")))?; + doc.text_blocks.remove(block_index); + resources + .cache + .put(&doc) + .await + .map_err(ApiError::internal)?; Ok(StatusCode::NO_CONTENT) } @@ -949,36 +887,23 @@ async fn delete_text_block( #[utoipa::path( get, - path = "/llm/models", - operation_id = "listLlmModels", + path = "/llm/catalog", + operation_id = "getLlmCatalog", tag = "llm", - params(LlmModelsQuery), + params(LlmCatalogQuery), responses( - (status = 200, body = Vec), + (status = 200, body = LlmCatalog), (status = 503, body = ApiError), ), )] -async fn list_llm_models( +async fn get_llm_catalog( State(state): State, - Query(query): Query, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult> { let resources = state.resources()?; - let models = operations::llm_list( - resources, - koharu_core::LlmListPayload { - language: query.language, - openai_compatible_base_url: query.openai_compatible_base_url, - }, - ) - .await? - .into_iter() - .map(|model| LlmModelInfo { - id: model.id, - languages: model.languages, - source: model.source.to_string(), - }) - .collect(); - Ok(Json(models)) + Ok(Json( + llm::llm_catalog(resources, query.language.as_deref()).await?, + )) } #[utoipa::path( @@ -987,11 +912,11 @@ async fn list_llm_models( operation_id = "getLlm", tag = "llm", responses( - (status = 200, body = koharu_core::LlmState), + (status = 200, body = LlmState), (status = 503, body = ApiError), ), )] -async fn get_llm(State(state): State) -> ApiResult> { +async fn get_llm(State(state): State) -> ApiResult> { let resources = state.resources()?; Ok(Json(resources.llm.snapshot().await)) } @@ -1003,7 +928,7 @@ async fn get_llm(State(state): State) -> ApiResult) -> ApiResult, Json(request): Json, -) -> ApiResult> { +) -> ApiResult> { let resources = state.resources()?; - operations::llm_load( - resources.clone(), - koharu_core::LlmLoadPayload { - id: request.id, - api_key: request.api_key, - base_url: request.base_url, - temperature: request.temperature, - max_tokens: request.max_tokens, - custom_system_prompt: request.custom_system_prompt, - }, - ) - .await?; + llm::llm_load(resources.clone(), request).await?; Ok(Json(resources.llm.snapshot().await)) } @@ -1034,108 +948,16 @@ async fn load_llm( operation_id = "unloadLlm", tag = "llm", responses( - (status = 200, body = koharu_core::LlmState), + (status = 200, body = LlmState), (status = 503, body = ApiError), ), )] -async fn unload_llm(State(state): State) -> ApiResult> { +async fn unload_llm(State(state): State) -> ApiResult> { let resources = state.resources()?; - operations::llm_offload(resources.clone()).await?; + llm::llm_offload(resources.clone()).await?; Ok(Json(resources.llm.snapshot().await)) } -#[utoipa::path( - post, - path = "/llm/health", - operation_id = "checkLlmHealth", - tag = "llm", - request_body = LlmPingRequest, - responses( - (status = 200, body = LlmPingResponse), - ), -)] -async fn check_llm_health( - State(state): State, - Json(request): Json, -) -> ApiResult> { - match operations::llm_ping( - state.resources.runtime().http_client(), - &request.base_url, - request.api_key.as_deref(), - ) - .await - { - Ok(result) => Ok(Json(LlmPingResponse { - ok: true, - models: result.models, - latency_ms: Some(result.latency_ms), - error: None, - })), - Err(err) => Ok(Json(LlmPingResponse { - ok: false, - models: vec![], - latency_ms: None, - error: Some(err.to_string()), - })), - } -} - -// --------------------------------------------------------------------------- -// Providers -// --------------------------------------------------------------------------- - -#[utoipa::path( - get, - path = "/providers/{provider}/api-key", - operation_id = "getApiKey", - tag = "providers", - params(("provider" = String, Path,)), - responses( - (status = 200, body = ApiKeyResponse), - (status = 503, body = ApiError), - ), -)] -async fn get_api_key( - State(state): State, - Path(provider): Path, -) -> ApiResult> { - let resources = state.resources()?; - let result = - operations::get_api_key(resources, koharu_core::ApiKeyGetPayload { provider }).await?; - Ok(Json(ApiKeyResponse { - api_key: result.api_key, - })) -} - -#[utoipa::path( - put, - path = "/providers/{provider}/api-key", - operation_id = "setApiKey", - tag = "providers", - params(("provider" = String, Path,)), - request_body = ApiKeyValue, - responses( - (status = 204), - (status = 503, body = ApiError), - ), -)] -async fn set_api_key( - State(state): State, - Path(provider): Path, - Json(request): Json, -) -> ApiResult { - let resources = state.resources()?; - operations::set_api_key( - resources, - koharu_core::ApiKeySetPayload { - provider, - api_key: request.api_key, - }, - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - // --------------------------------------------------------------------------- // Jobs // --------------------------------------------------------------------------- @@ -1156,31 +978,38 @@ async fn start_pipeline( Json(request): Json, ) -> ApiResult> { let resources = state.resources()?; - let index = if let Some(document_id) = request.document_id.as_deref() { - Some(state_tx::find_doc_index(&resources.state, document_id).await?) - } else { - None - }; - let total_documents = match index { + // Validate document_id exists if provided + if let Some(document_id) = request.document_id.as_deref() { + let entries = resources + .cache + .list_documents() + .map_err(ApiError::internal)?; + if !entries.iter().any(|d| d.id == document_id) { + return Err(ApiError::not_found(format!( + "Document not found: {document_id}" + ))); + } + } + let total_documents = match &request.document_id { Some(_) => 1, - None => state_tx::doc_count(&resources.state).await, + None => resources + .cache + .list_documents() + .map_err(ApiError::internal)? + .len(), }; - let job_id = operations::process( + let job_id = pipeline::process( resources.clone(), koharu_core::ProcessRequest { - index, - llm_model_id: request.llm_model_id, - llm_api_key: request.llm_api_key, - llm_base_url: request.llm_base_url, - llm_temperature: request.llm_temperature, - llm_max_tokens: request.llm_max_tokens, - llm_custom_system_prompt: request.llm_custom_system_prompt, + document_id: request.document_id.clone(), + llm: request.llm.clone(), language: request.language, shader_effect: request.shader_effect, shader_stroke: request.shader_stroke, font_family: request.font_family, }, + state.tracker.jobs(), ) .await?; @@ -1312,15 +1141,21 @@ async fn export_document( Query(query): Query, ) -> ApiResult { let resources = state.resources()?; - let (_, document) = find_document(&resources, &document_id).await?; + let document = find_document(&resources, &document_id).await?; if format == "psd" { - let data = koharu_psd::export_document(&document, &app_psd_export_options()) - .map_err(|error| ApiError::bad_request(error.to_string()))?; + let data = koharu_psd::export_document( + &document, + &PsdExportOptions { + text_layer_mode: TextLayerMode::Editable, + ..PsdExportOptions::default() + }, + ) + .map_err(|error| ApiError::bad_request(error.to_string()))?; return Ok(binary_response( data, "image/vnd.adobe.photoshop", - Some(psd_export_filename(&document)), + Some(format!("{}_koharu.psd", document.name)), )); } @@ -1332,8 +1167,8 @@ async fn export_document( .and_then(|value| value.to_str()) .unwrap_or("jpg") .to_ascii_lowercase(); - let data = encode_image(image, &ext)?; - let content_type = mime_from_ext(&ext); + let data = koharu_app::utils::encode_image(image, &ext).map_err(ApiError::internal)?; + let content_type = koharu_app::utils::mime_from_ext(&ext); Ok(binary_response(data, content_type, Some(filename))) } @@ -1354,23 +1189,18 @@ async fn batch_export( ) -> ApiResult> { let resources = state.resources()?; let count = match request.layer.unwrap_or(ExportLayer::Rendered) { - ExportLayer::Rendered => operations::export_all_rendered(resources).await?, - ExportLayer::Inpainted => operations::export_all_inpainted(resources).await?, + ExportLayer::Rendered => io::export_all_rendered(resources).await?, + ExportLayer::Inpainted => io::export_all_inpainted(resources).await?, }; Ok(Json(ExportResult { count })) } -async fn find_document( - resources: &AppResources, - document_id: &str, -) -> ApiResult<(usize, Document)> { - let index = state_tx::find_doc_index(&resources.state, document_id) - .await - .map_err(ApiError::from)?; - let document = state_tx::read_doc(&resources.state, index) +async fn find_document(resources: &AppResources, document_id: &str) -> ApiResult { + resources + .cache + .get(document_id) .await - .map_err(ApiError::from)?; - Ok((index, document)) + .map_err(|_| ApiError::not_found(format!("Document not found: {document_id}"))) } fn find_text_block_index(document: &Document, text_block_id: &str) -> ApiResult { @@ -1381,21 +1211,6 @@ fn find_text_block_index(document: &Document, text_block_id: &str) -> ApiResult< .ok_or_else(|| ApiError::not_found(format!("Text block not found: {text_block_id}"))) } -fn encode_webp(image: &koharu_core::SerializableDynamicImage) -> ApiResult> { - encode_image(image, "webp") -} - -fn encode_image(image: &koharu_core::SerializableDynamicImage, ext: &str) -> ApiResult> { - let format = ImageFormat::from_extension(ext).unwrap_or(ImageFormat::Jpeg); - let mut cursor = Cursor::new(Vec::new()); - image - .0 - .write_to(&mut cursor, format) - .with_context(|| format!("failed to encode image as {ext}")) - .map_err(ApiError::internal)?; - Ok(cursor.into_inner()) -} - fn binary_response(data: Vec, content_type: &str, filename: Option) -> Response { let mut response = Response::new(Body::from(data)); response @@ -1409,15 +1224,6 @@ fn binary_response(data: Vec, content_type: &str, filename: Option) response } -fn mime_from_ext(ext: &str) -> &'static str { - match ext { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "webp" => "image/webp", - _ => "application/octet-stream", - } -} - fn export_target( document: &Document, layer: ExportLayer, @@ -1447,26 +1253,6 @@ fn export_target( } } -fn psd_export_filename(document: &Document) -> String { - format!("{}_koharu.psd", document.name) -} - -fn app_psd_export_options() -> PsdExportOptions { - PsdExportOptions { - text_layer_mode: TextLayerMode::Editable, - ..PsdExportOptions::default() - } -} - -fn to_inpaint_region(region: Region) -> InpaintRegion { - InpaintRegion { - x: region.x, - y: region.y, - width: region.width, - height: region.height, - } -} - fn apply_text_block_patch(block: &mut TextBlock, patch: TextBlockPatch) { let previous_width = block.width; let previous_height = block.height; @@ -1530,9 +1316,8 @@ fn apply_text_block_patch(block: &mut TextBlock, patch: TextBlockPatch) { #[cfg(test)] mod tests { - use super::{app_psd_export_options, apply_text_block_patch, psd_export_filename}; - use koharu_core::{Document, TextAlign, TextBlock, TextBlockPatch, TextDirection, TextStyle}; - use koharu_psd::TextLayerMode; + use super::apply_text_block_patch; + use koharu_core::{TextAlign, TextBlock, TextBlockPatch, TextDirection, TextStyle}; #[test] fn text_block_patch_updates_geometry_and_clears_rendered() { @@ -1573,22 +1358,4 @@ mod tests { assert!(block.rendered.is_none()); assert!(block.rendered_direction.is_none()); } - - #[test] - fn psd_export_filename_uses_koharu_suffix() { - let document = Document { - name: "chapter-01".to_string(), - ..Default::default() - }; - - assert_eq!(psd_export_filename(&document), "chapter-01_koharu.psd"); - } - - #[test] - fn app_psd_export_uses_editable_text_layers() { - assert_eq!( - app_psd_export_options().text_layer_mode, - TextLayerMode::Editable - ); - } } diff --git a/koharu-rpc/src/lib.rs b/koharu-rpc/src/lib.rs index 9afb827edc3b5e19c2a855fa4343bf4785d03a76..0a9642b577083cb7e8813cdb3920599ad6d77835 100644 --- a/koharu-rpc/src/lib.rs +++ b/koharu-rpc/src/lib.rs @@ -4,4 +4,4 @@ pub mod server; pub mod shared; pub mod tracker; -pub use shared::{BootstrapHooks, SharedState}; +pub use shared::SharedState; diff --git a/koharu-rpc/src/mcp/mod.rs b/koharu-rpc/src/mcp/mod.rs index 64b8913061a59c51b13ed947d5c0092c96802b6a..eb7965cfb87c0613ce1a001250e598e127536f13 100644 --- a/koharu-rpc/src/mcp/mod.rs +++ b/koharu-rpc/src/mcp/mod.rs @@ -1,6 +1,8 @@ mod helpers; +use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use image::DynamicImage; use rmcp::handler::server::router::tool::ToolRouter; @@ -10,17 +12,17 @@ use rmcp::model::{ ToolsCapability, }; use rmcp::{ServerHandler, tool, tool_handler, tool_router}; +use tokio::sync::RwLock; -use koharu_app::AppResources; -use koharu_app::operations; +use koharu_app::{AppResources, edit, io, llm, ml, pipeline}; use koharu_core::commands::{ - AddTextBlockPayload, ExportDocumentParams, FileEntry, IndexPayload, InpaintPartialPayload, - InpaintRegion, InpaintRegionParams, LlmGenerateParams, LlmGeneratePayload, LlmListPayload, - LlmLoadParams, LlmLoadPayload, MaskMorphPayload, OpenDocumentsParams, OpenDocumentsPayload, - ProcessParams, ProcessRequest, RemoveTextBlockPayload, RenderParams, RenderPayload, - UpdateTextBlockPayload, ViewImageParams, ViewTextBlockParams, + AddTextBlockPayload, DocumentIdParam, ExportDocumentParams, FileEntry, InpaintRegionParams, + LlmGenerateParams, LlmLoadParams, MaskMorphPayload, OpenDocumentsParams, OpenDocumentsPayload, + ProcessParams, ProcessRequest, RemoveTextBlockPayload, RenderParams, UpdateTextBlockPayload, + ViewImageParams, ViewTextBlockParams, }; use koharu_core::views::to_doc_info; +use koharu_core::{LlmLoadRequest, PipelineLlmRequest, Region}; use crate::shared::SharedState; @@ -45,6 +47,21 @@ impl KoharuMcp { .get() .ok_or_else(|| "Resources not initialized yet".to_string()) } + + async fn document_id_for_index( + &self, + resources: &AppResources, + index: usize, + ) -> Result { + let entries = resources + .cache + .list_documents() + .map_err(|e| e.to_string())?; + entries + .get(index) + .map(|page| page.id.clone()) + .ok_or_else(|| format!("Document index {index} not found")) + } } #[tool_router] @@ -52,24 +69,20 @@ impl KoharuMcp { #[tool(description = "Get the application version")] async fn app_version(&self) -> Result { let res = self.resources()?; - operations::app_version(res) - .await - .map_err(|e| e.to_string()) + io::app_version(res).await.map_err(|e| e.to_string()) } #[tool(description = "Get device information (ML device, GPU info)")] async fn device(&self) -> Result { let res = self.resources()?; - let info = operations::device(res).await.map_err(|e| e.to_string())?; + let info = io::device(res).await.map_err(|e| e.to_string())?; serde_json::to_string_pretty(&info).map_err(|e| e.to_string()) } #[tool(description = "Get the number of loaded documents")] async fn get_documents(&self) -> Result { let res = self.resources()?; - let count = operations::get_documents(res) - .await - .map_err(|e| e.to_string())?; + let count = io::get_documents(res).await.map_err(|e| e.to_string())?; Ok(format!("{count} document(s) loaded")) } @@ -78,10 +91,10 @@ impl KoharuMcp { )] async fn get_document( &self, - Parameters(p): Parameters, + Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - let doc = operations::get_document(res, p) + let doc = io::get_document(res, &p.document_id) .await .map_err(|e| e.to_string())?; let info = to_doc_info(&doc); @@ -91,7 +104,7 @@ impl KoharuMcp { #[tool(description = "List available font families for text rendering")] async fn list_font_families(&self) -> Result { let res = self.resources()?; - let fonts = operations::list_font_families(res) + let fonts = ml::list_font_families(res) .await .map_err(|e| e.to_string())?; Ok(fonts @@ -101,27 +114,21 @@ impl KoharuMcp { .join(", ")) } - #[tool(description = "List available LLM translation models with supported languages")] + #[tool( + description = "List the grouped LLM catalog for local models and configured remote providers" + )] async fn llm_list(&self) -> Result { let res = self.resources()?; - let models = operations::llm_list( - res, - LlmListPayload { - language: None, - openai_compatible_base_url: None, - }, - ) - .await - .map_err(|e| e.to_string())?; - serde_json::to_string_pretty(&models).map_err(|e| e.to_string()) + let catalog = llm::llm_catalog(res, None) + .await + .map_err(|e| e.to_string())?; + serde_json::to_string_pretty(&catalog).map_err(|e| e.to_string()) } #[tool(description = "Check if an LLM model is loaded and ready")] async fn llm_ready(&self) -> Result { let res = self.resources()?; - let ready = operations::llm_ready(res) - .await - .map_err(|e| e.to_string())?; + let ready = llm::llm_ready(res).await.map_err(|e| e.to_string())?; Ok(if ready { "LLM is ready".to_string() } else { @@ -139,7 +146,7 @@ impl KoharuMcp { let res = self .resources() .map_err(|e| ErrorData::internal_error(e, None))?; - let doc = operations::get_document(res, IndexPayload { index: p.index }) + let doc = io::get_document(res, &p.document_id) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; @@ -186,7 +193,7 @@ impl KoharuMcp { let res = self .resources() .map_err(|e| ErrorData::internal_error(e, None))?; - let doc = operations::get_document(res, IndexPayload { index: p.index }) + let doc = io::get_document(res, &p.document_id) .await .map_err(|e| ErrorData::internal_error(e.to_string(), None))?; @@ -264,12 +271,12 @@ impl KoharuMcp { }) .collect(); - let count = operations::open_documents(res.clone(), OpenDocumentsPayload { files: files? }) + let count = io::open_documents(res.clone(), OpenDocumentsPayload { files: files? }) .await .map_err(|e| e.to_string())?; - let guard = res.state.read().await; - let names: Vec<&str> = guard.documents.iter().map(|d| d.name.as_str()).collect(); + let entries = res.cache.list_documents().map_err(|e| e.to_string())?; + let names: Vec<&str> = entries.iter().map(|d| d.name.as_str()).collect(); Ok(format!("Loaded {count} document(s): {}", names.join(", "))) } @@ -279,7 +286,7 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - let result = operations::export_document(res, IndexPayload { index: p.index }) + let result = io::export_document(res, &p.document_id) .await .map_err(|e| e.to_string())?; @@ -292,13 +299,13 @@ impl KoharuMcp { #[tool( description = "Detect text blocks and fonts in a manga page. Finds speech bubbles, text regions, and predicts font properties." )] - async fn detect(&self, Parameters(p): Parameters) -> Result { + async fn detect(&self, Parameters(p): Parameters) -> Result { let res = self.resources()?; - operations::detect(res.clone(), p) + ml::detect(res.clone(), &p.document_id) .await .map_err(|e| e.to_string())?; - let doc = operations::get_document(res, p) + let doc = io::get_document(res, &p.document_id) .await .map_err(|e| e.to_string())?; @@ -315,13 +322,13 @@ impl KoharuMcp { #[tool( description = "Run OCR (optical character recognition) on detected text blocks to extract the original text." )] - async fn ocr(&self, Parameters(p): Parameters) -> Result { + async fn ocr(&self, Parameters(p): Parameters) -> Result { let res = self.resources()?; - operations::ocr(res.clone(), p) + ml::ocr(res.clone(), &p.document_id) .await .map_err(|e| e.to_string())?; - let doc = operations::get_document(res, p) + let doc = io::get_document(res, &p.document_id) .await .map_err(|e| e.to_string())?; @@ -336,9 +343,9 @@ impl KoharuMcp { #[tool( description = "Inpaint (remove) text from the image using the detection mask. Fills text regions with surrounding background." )] - async fn inpaint(&self, Parameters(p): Parameters) -> Result { + async fn inpaint(&self, Parameters(p): Parameters) -> Result { let res = self.resources()?; - operations::inpaint(res, p) + ml::inpaint(res, &p.document_id) .await .map_err(|e| e.to_string())?; Ok("Inpainting complete".to_string()) @@ -356,15 +363,13 @@ impl KoharuMcp { .transpose() .map_err(|e: anyhow::Error| e.to_string())?; - operations::render( + ml::render( res, - RenderPayload { - index: p.index, - text_block_index: p.text_block_index, - shader_effect: effect, - shader_stroke: None, - font_family: p.font_family, - }, + &p.document_id, + p.text_block_index, + effect, + None, + p.font_family.as_deref(), ) .await .map_err(|e| e.to_string())?; @@ -377,28 +382,34 @@ impl KoharuMcp { )] async fn llm_load(&self, Parameters(p): Parameters) -> Result { let res = self.resources()?; - operations::llm_load( + llm::llm_load( res, - LlmLoadPayload { - id: p.id.clone(), - api_key: None, - base_url: None, - temperature: p.temperature, - max_tokens: p.max_tokens, - custom_system_prompt: p.custom_system_prompt, + LlmLoadRequest { + target: p.target.clone(), + options: p.options.clone(), }, ) .await .map_err(|e| e.to_string())?; - Ok(format!("Loading model '{}'...", p.id)) + Ok(format!( + "Loading {} target '{}'{}", + match p.target.kind { + koharu_core::LlmTargetKind::Local => "local", + koharu_core::LlmTargetKind::Provider => "provider", + }, + p.target.model_id, + p.target + .provider_id + .as_deref() + .map(|provider| format!(" from {provider}")) + .unwrap_or_default() + )) } #[tool(description = "Unload the current LLM model from memory")] async fn llm_offload(&self) -> Result { let res = self.resources()?; - operations::llm_offload(res) - .await - .map_err(|e| e.to_string())?; + llm::llm_offload(res).await.map_err(|e| e.to_string())?; Ok("LLM offloaded".to_string()) } @@ -410,18 +421,16 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - operations::llm_generate( + llm::llm_generate( res.clone(), - LlmGeneratePayload { - index: p.index, - text_block_index: p.text_block_index, - language: p.language, - }, + &p.document_id, + p.text_block_index, + p.language.as_deref(), ) .await .map_err(|e| e.to_string())?; - let doc = operations::get_document(res, IndexPayload { index: p.index }) + let doc = io::get_document(res, &p.document_id) .await .map_err(|e| e.to_string())?; @@ -446,21 +455,21 @@ impl KoharuMcp { .transpose() .map_err(|e: anyhow::Error| e.to_string())?; - operations::process( + let jobs = Arc::new(RwLock::new(HashMap::new())); + pipeline::process( res, ProcessRequest { - index: p.index, - llm_model_id: p.llm_model_id, - llm_api_key: None, - llm_base_url: None, - llm_temperature: None, - llm_max_tokens: None, - llm_custom_system_prompt: None, + document_id: p.document_id, + llm: p.llm_target.map(|target| PipelineLlmRequest { + target, + options: None, + }), language: p.language, shader_effect: effect, shader_stroke: None, font_family: p.font_family, }, + jobs, ) .await .map_err(|e| e.to_string())?; @@ -476,9 +485,25 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - let info = operations::update_text_block(res, p) - .await - .map_err(|e| e.to_string())?; + let document_id = self.document_id_for_index(&res, p.index).await?; + let info = edit::update_text_block( + res, + &document_id, + edit::UpdateTextBlockArgs { + text_block_index: p.text_block_index, + translation: p.translation, + x: p.x, + y: p.y, + width: p.width, + height: p.height, + font_families: p.font_families, + font_size: p.font_size, + color: p.color, + shader_effect: p.shader_effect, + }, + ) + .await + .map_err(|e| e.to_string())?; serde_json::to_string_pretty(&info).map_err(|e| e.to_string()) } @@ -488,7 +513,8 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - let index = operations::add_text_block(res, p) + let document_id = self.document_id_for_index(&res, p.index).await?; + let index = edit::add_text_block(res, &document_id, p.x, p.y, p.width, p.height) .await .map_err(|e| e.to_string())?; Ok(format!("Added text block at index {index}")) @@ -500,7 +526,8 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - let remaining = operations::remove_text_block(res, p) + let document_id = self.document_id_for_index(&res, p.index).await?; + let remaining = edit::remove_text_block(res, &document_id, p.text_block_index) .await .map_err(|e| e.to_string())?; Ok(format!("Removed text block. {remaining} remaining.")) @@ -512,7 +539,8 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - operations::dilate_mask(res, p) + let document_id = self.document_id_for_index(&res, p.index).await?; + edit::dilate_mask(res, &document_id, p.radius) .await .map_err(|e| e.to_string())?; Ok("Dilated mask".to_string()) @@ -524,7 +552,8 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - operations::erode_mask(res, p) + let document_id = self.document_id_for_index(&res, p.index).await?; + edit::erode_mask(res, &document_id, p.radius) .await .map_err(|e| e.to_string())?; Ok("Eroded mask".to_string()) @@ -536,16 +565,14 @@ impl KoharuMcp { Parameters(p): Parameters, ) -> Result { let res = self.resources()?; - operations::inpaint_partial( + edit::inpaint_partial( res, - InpaintPartialPayload { - index: p.index, - region: InpaintRegion { - x: p.x, - y: p.y, - width: p.width, - height: p.height, - }, + &p.document_id, + Region { + x: p.x, + y: p.y, + width: p.width, + height: p.height, }, ) .await diff --git a/koharu-rpc/src/shared.rs b/koharu-rpc/src/shared.rs index 14cdf6d6045fec30f7d5cc480f1c15d81c3d16fc..1f0baf36b652f1464fc3b00256dd31ba720bc439 100644 --- a/koharu-rpc/src/shared.rs +++ b/koharu-rpc/src/shared.rs @@ -1,20 +1,8 @@ -use std::future::Future; -use std::pin::Pin; use std::sync::Arc; use koharu_app::AppResources; -use koharu_core::BootstrapConfig; use koharu_runtime::RuntimeManager; -use tokio::sync::{OnceCell, watch}; - -pub type InitializeFuture = Pin> + Send + 'static>>; - -#[derive(Clone)] -pub struct BootstrapHooks { - pub get_config: Arc anyhow::Result + Send + Sync>, - pub put_config: Arc anyhow::Result + Send + Sync>, - pub initialize: Arc InitializeFuture + Send + Sync>, -} +use tokio::sync::OnceCell; #[derive(Clone)] pub struct SharedState { @@ -23,22 +11,13 @@ pub struct SharedState { struct Inner { resources: Arc>, - runtime: watch::Receiver, - bootstrap: BootstrapHooks, + runtime: RuntimeManager, } impl SharedState { - pub fn new( - resources: Arc>, - runtime: watch::Receiver, - bootstrap: BootstrapHooks, - ) -> Self { + pub fn new(resources: Arc>, runtime: RuntimeManager) -> Self { Self { - inner: Arc::new(Inner { - resources, - runtime, - bootstrap, - }), + inner: Arc::new(Inner { resources, runtime }), } } @@ -46,41 +25,7 @@ impl SharedState { self.inner.resources.get().cloned() } - pub fn resources_cell(&self) -> Arc> { - Arc::clone(&self.inner.resources) - } - pub fn runtime(&self) -> RuntimeManager { - self.inner.runtime.borrow().clone() - } - - pub fn subscribe_runtime(&self) -> watch::Receiver { self.inner.runtime.clone() } - - pub async fn get_or_try_init(&self, init: F) -> anyhow::Result - where - F: FnOnce() -> Fut, - Fut: Future>, - { - Ok(self.inner.resources.get_or_try_init(init).await?.clone()) - } - - pub fn get_config(&self) -> anyhow::Result { - (self.inner.bootstrap.get_config)() - } - - pub fn put_config(&self, config: BootstrapConfig) -> anyhow::Result { - (self.inner.bootstrap.put_config)(config) - } - - pub async fn initialize(&self) -> anyhow::Result<()> { - (self.inner.bootstrap.initialize)().await - } -} - -pub fn get_resources(shared: &SharedState) -> anyhow::Result { - shared - .get() - .ok_or_else(|| anyhow::anyhow!("Resources not initialized")) } diff --git a/koharu-rpc/src/tracker.rs b/koharu-rpc/src/tracker.rs index 3af24abff793c890d2951f95531ec2b63faee2d6..9f704c3078215525ddd8e1d2ad5eb68226be8dc6 100644 --- a/koharu-rpc/src/tracker.rs +++ b/koharu-rpc/src/tracker.rs @@ -1,10 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use koharu_app::pipeline; -use koharu_core::{ - DownloadProgress, DownloadState, DownloadStatus, JobState, JobStatus, PipelineProgress, - PipelineStatus, PipelineStep, TransferStatus, -}; +use koharu_core::{DownloadProgress, DownloadState, DownloadStatus, JobState, TransferStatus}; use tokio::sync::RwLock; use crate::shared::SharedState; @@ -16,23 +13,27 @@ pub struct Tracker { } struct Inner { - jobs: RwLock>, + jobs: pipeline::Jobs, downloads: RwLock>, } impl Tracker { pub fn new(shared: &SharedState) -> Self { + let jobs = Arc::new(RwLock::new(HashMap::new())); let inner = Arc::new(Inner { - jobs: RwLock::new(HashMap::new()), + jobs: jobs.clone(), downloads: RwLock::new(HashMap::new()), }); - spawn_pipeline_listener(inner.clone()); spawn_download_listener(inner.clone(), shared.clone()); Self { inner } } + pub fn jobs(&self) -> pipeline::Jobs { + self.inner.jobs.clone() + } + pub async fn list_jobs(&self) -> Vec { let mut jobs: Vec<_> = self.inner.jobs.read().await.values().cloned().collect(); jobs.sort_by(|a, b| a.id.cmp(&b.id)); @@ -48,19 +49,27 @@ impl Tracker { } pub async fn list_downloads(&self) -> Vec { - let mut downloads: Vec<_> = self.inner.downloads.read().await.values().cloned().collect(); + let mut downloads: Vec<_> = self + .inner + .downloads + .read() + .await + .values() + .cloned() + .collect(); downloads.sort_by(|a, b| a.filename.cmp(&b.filename)); downloads } } -fn spawn_pipeline_listener(inner: Arc) { - let mut rx = pipeline::subscribe(); +fn spawn_download_listener(inner: Arc, shared: SharedState) { tokio::spawn(async move { + let mut download_rx = shared.runtime().subscribe_downloads(); + loop { - match rx.recv().await { + match download_rx.recv().await { Ok(progress) => { - update_job_state(&inner, pipeline_job_state(progress)).await; + update_download_state(&inner, download_state(progress)).await; } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, Err(tokio::sync::broadcast::error::RecvError::Closed) => break, @@ -69,36 +78,8 @@ fn spawn_pipeline_listener(inner: Arc) { }); } -fn spawn_download_listener(inner: Arc, shared: SharedState) { - tokio::spawn(async move { - let mut runtime_rx = shared.subscribe_runtime(); - let mut download_rx = runtime_rx.borrow().clone().subscribe_downloads(); - - loop { - tokio::select! { - changed = runtime_rx.changed() => { - if changed.is_err() { - break; - } - inner.downloads.write().await.clear(); - download_rx = runtime_rx.borrow().clone().subscribe_downloads(); - } - received = download_rx.recv() => match received { - Ok(progress) => { - update_download_state(&inner, download_state(progress)).await; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - download_rx = runtime_rx.borrow().clone().subscribe_downloads(); - } - } - } - } - }); -} - async fn update_job_state(inner: &Arc, job: JobState) { - let terminal = !matches!(job.status, JobStatus::Running); + let terminal = !matches!(job.status, koharu_core::JobStatus::Running); let mut jobs = inner.jobs.write().await; if terminal { jobs.remove(&job.id); @@ -120,32 +101,6 @@ async fn update_download_state(inner: &Arc, download: DownloadState) { } } -fn pipeline_job_state(progress: PipelineProgress) -> JobState { - let (status, error) = match progress.status { - PipelineStatus::Running => (JobStatus::Running, None), - PipelineStatus::Completed => (JobStatus::Completed, None), - PipelineStatus::Cancelled => (JobStatus::Cancelled, None), - PipelineStatus::Failed(message) => (JobStatus::Failed, Some(message)), - }; - - JobState { - id: progress.job_id, - kind: "pipeline".to_string(), - status, - step: progress.step.map(pipeline_step_name), - current_document: progress.current_document, - total_documents: progress.total_documents, - current_step_index: progress.current_step_index, - total_steps: progress.total_steps, - overall_percent: progress.overall_percent, - error, - } -} - -fn pipeline_step_name(step: PipelineStep) -> String { - step.to_string() -} - fn download_state(progress: DownloadProgress) -> DownloadState { let (status, error) = match progress.status { DownloadStatus::Started => (TransferStatus::Started, None), @@ -166,28 +121,7 @@ fn download_state(progress: DownloadProgress) -> DownloadState { #[cfg(test)] mod tests { - use koharu_core::{PipelineStatus, PipelineStep}; - - use super::{TransferStatus, download_state, pipeline_job_state}; - - #[test] - fn pipeline_progress_maps_to_job_state() { - let state = pipeline_job_state(koharu_core::PipelineProgress { - job_id: "job-1".to_string(), - status: PipelineStatus::Failed("boom".to_string()), - step: Some(PipelineStep::Render), - current_document: 1, - total_documents: 2, - current_step_index: 4, - total_steps: 5, - overall_percent: 80, - }); - - assert_eq!(state.id, "job-1"); - assert_eq!(state.kind, "pipeline"); - assert_eq!(state.step.as_deref(), Some("render")); - assert_eq!(state.error.as_deref(), Some("boom")); - } + use super::{TransferStatus, download_state}; #[test] fn download_progress_maps_to_download_state() { diff --git a/koharu-runtime/Cargo.toml b/koharu-runtime/Cargo.toml index 2156be2a919169d4c663c4eac469affe28fe1247..831a44d30ee5e285946e0a3ca4969711dda9415e 100644 --- a/koharu-runtime/Cargo.toml +++ b/koharu-runtime/Cargo.toml @@ -14,6 +14,7 @@ publish.workspace = true [dependencies] koharu-core = { workspace = true } anyhow = { workspace = true } +camino = { workspace = true } dirs = { workspace = true } futures = { workspace = true } hf-hub = { workspace = true } diff --git a/koharu-runtime/src/archive.rs b/koharu-runtime/src/archive.rs index 2653f59bcd4d5fc8c5b4df6737770b79be4af5d9..065e84e05045e5ff82bc758de0b74979e2878a5e 100644 --- a/koharu-runtime/src/archive.rs +++ b/koharu-runtime/src/archive.rs @@ -24,7 +24,7 @@ pub(crate) enum ExtractPolicy<'a> { pub(crate) async fn fetch(runtime: &Runtime, url: &str, file_name: &str) -> Result { runtime .artifacts() - .cached_download(url, file_name, runtime.layout().downloads_root()) + .cached_download(url, file_name, &runtime.layout().downloads_root) .await .with_context(|| format!("failed to download `{url}`")) } diff --git a/koharu-runtime/src/artifacts.rs b/koharu-runtime/src/artifacts.rs index ab6e11e1bc01fab2d9b95c6cdd81d616c02bd703..c45508fe396bee05016a4c885e391cfa9179b920 100644 --- a/koharu-runtime/src/artifacts.rs +++ b/koharu-runtime/src/artifacts.rs @@ -57,7 +57,7 @@ impl ArtifactStore { } pub fn huggingface_path(&self, repo: &str, filename: &str) -> Result { - huggingface_path(self.layout.huggingface_root(), repo, filename) + huggingface_path(&self.layout.huggingface_root, repo, filename) } pub(crate) async fn cached_download( diff --git a/koharu-runtime/src/cuda.rs b/koharu-runtime/src/cuda.rs index 4970663807a194e9bb2d1d0d8f400a2cae28594a..6d21c3dca0ec4586892e83fda3d2d633cdc37df6 100644 --- a/koharu-runtime/src/cuda.rs +++ b/koharu-runtime/src/cuda.rs @@ -207,7 +207,7 @@ fn driver_library_available() -> bool { } fn install_dir(runtime: &Runtime) -> std::path::PathBuf { - runtime.layout().runtime_package_dir("cuda") + runtime.layout().runtime_root.join("cuda") } fn platform_tags() -> Result<&'static [&'static str]> { diff --git a/koharu-runtime/src/http.rs b/koharu-runtime/src/http.rs index 931f700d069e48f24a4236705d1e8f1b4179dc78..f3dfdb497177d2d7e125f30ec4220d9a2fb28f5a 100644 --- a/koharu-runtime/src/http.rs +++ b/koharu-runtime/src/http.rs @@ -5,8 +5,6 @@ use anyhow::Result; use reqwest_middleware::{ClientBuilder, ClientWithMiddleware}; use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff}; -use crate::Settings; - const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); #[derive(Clone)] @@ -15,10 +13,10 @@ pub(crate) struct HttpStack { } impl HttpStack { - pub(crate) fn new(settings: &Settings) -> Result { + pub(crate) fn new() -> Result { Ok(Self { client: Arc::new( - ClientBuilder::new(build_base_client(settings)?) + ClientBuilder::new(build_base_client()?) .with(RetryTransientMiddleware::new_with_policy(retry_policy())) .build(), ), @@ -30,16 +28,12 @@ impl HttpStack { } } -fn build_base_client(settings: &Settings) -> Result { - let mut builder = reqwest::Client::builder() +fn build_base_client() -> Result { + let builder = reqwest::Client::builder() .user_agent(USER_AGENT) .connect_timeout(Duration::from_secs(20)) .read_timeout(Duration::from_secs(60)); - if let Some(proxy) = settings.http_proxy() { - builder = builder.proxy(reqwest::Proxy::all(proxy.as_str())?); - } - Ok(builder.build()?) } diff --git a/koharu-runtime/src/layout.rs b/koharu-runtime/src/layout.rs index 4b09b9db00dd2efbc56a4416d2b77fd66da25a81..0ee74a3254221c21666a8959da9cece82517b06f 100644 --- a/koharu-runtime/src/layout.rs +++ b/koharu-runtime/src/layout.rs @@ -1,5 +1,5 @@ use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::{Context, Result}; @@ -10,16 +10,16 @@ const HUGGINGFACE_DIR: &str = "huggingface"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Layout { - runtime_root: PathBuf, - models_root: PathBuf, - downloads_root: PathBuf, - huggingface_root: PathBuf, + pub(crate) runtime_root: PathBuf, + pub(crate) models_root: PathBuf, + pub(crate) downloads_root: PathBuf, + pub(crate) huggingface_root: PathBuf, } impl Layout { pub fn from_settings(settings: &Settings) -> Self { - let runtime_root = settings.runtime_root().to_path_buf(); - let models_root = settings.models_root().to_path_buf(); + let runtime_root = settings.runtime.path.clone().into_std_path_buf(); + let models_root = settings.models.path.clone().into_std_path_buf(); let downloads_root = runtime_root.join(DOWNLOADS_DIR); let huggingface_root = models_root.join(HUGGINGFACE_DIR); @@ -31,35 +31,15 @@ impl Layout { } } - pub fn runtime_root(&self) -> &Path { - &self.runtime_root - } - - pub fn models_root(&self) -> &Path { - &self.models_root - } - - pub fn downloads_root(&self) -> &Path { - &self.downloads_root - } - - pub fn huggingface_root(&self) -> &Path { - &self.huggingface_root - } - - pub fn runtime_package_dir(&self, package: &str) -> PathBuf { - self.runtime_root.join(package) - } - pub fn ensure_roots(&self) -> Result<()> { - ensure_dir(self.runtime_root())?; - ensure_dir(self.models_root())?; - ensure_dir(self.downloads_root())?; - ensure_dir(self.huggingface_root())?; + fs::create_dir_all(&self.runtime_root) + .with_context(|| format!("failed to create `{}`", self.runtime_root.display()))?; + fs::create_dir_all(&self.models_root) + .with_context(|| format!("failed to create `{}`", self.models_root.display()))?; + fs::create_dir_all(&self.downloads_root) + .with_context(|| format!("failed to create `{}`", self.downloads_root.display()))?; + fs::create_dir_all(&self.huggingface_root) + .with_context(|| format!("failed to create `{}`", self.huggingface_root.display()))?; Ok(()) } } - -fn ensure_dir(path: &Path) -> Result<()> { - fs::create_dir_all(path).with_context(|| format!("failed to create `{}`", path.display())) -} diff --git a/koharu-runtime/src/lib.rs b/koharu-runtime/src/lib.rs index 7ed7beb96528df5074ff92fe6bae4bcae96b9d5a..433f6e419558b975d6cff01c430665f0c6a653db 100644 --- a/koharu-runtime/src/lib.rs +++ b/koharu-runtime/src/lib.rs @@ -15,8 +15,5 @@ pub use cuda::{CudaDriverVersion, driver_version as nvidia_driver_version}; pub use inventory; pub use loader::{load_library_by_name, load_library_by_path}; pub use packages::{Package, PackageCatalog as Catalog, PackageFuture, PackageKind}; -pub use runtime::{Runtime, RuntimeBuilder, RuntimeManager}; -pub use settings::{ - ComputePolicy, DirectorySetting, HttpSetting, PathSetting, Settings, SettingsBuilder, - default_models_root, default_runtime_root, -}; +pub use runtime::{Runtime, RuntimeManager}; +pub use settings::{ComputePolicy, DirectorySetting, Settings, default_app_data_root}; diff --git a/koharu-runtime/src/llama.rs b/koharu-runtime/src/llama.rs index bb1ce2d4c6190d94d9ad4d931298a3a54a1f1f62..15d4817c9087068d237f362272a7b68593c26884 100644 --- a/koharu-runtime/src/llama.rs +++ b/koharu-runtime/src/llama.rs @@ -157,7 +157,8 @@ impl LlamaDistribution { fn install_dir(self, runtime: &Runtime) -> PathBuf { runtime .layout() - .runtime_package_dir("llama.cpp") + .runtime_root + .join("llama.cpp") .join(LLAMA_CPP_TAG) .join(self.id()) } @@ -266,7 +267,14 @@ mod tests { #[test] fn install_dir_includes_tag_and_id() { let runtime = Runtime::new( - crate::Settings::from_paths("/tmp/rt", "/tmp/models"), + crate::Settings { + runtime: crate::DirectorySetting { + path: camino::Utf8PathBuf::from("/tmp/rt"), + }, + models: crate::DirectorySetting { + path: camino::Utf8PathBuf::from("/tmp/models"), + }, + }, crate::ComputePolicy::CpuOnly, ) .unwrap(); diff --git a/koharu-runtime/src/packages.rs b/koharu-runtime/src/packages.rs index f84024f287b40817cc818dbf3eba242874174553..c965d5302ba5ef0926629f74ca0d92dcbf035548 100644 --- a/koharu-runtime/src/packages.rs +++ b/koharu-runtime/src/packages.rs @@ -71,18 +71,6 @@ impl PackageCatalog { .filter(|package| (package.enabled)(runtime)) } - pub fn requires_bootstrap(&self, runtime: &Runtime) -> Result { - for package in self.bootstrap_packages(runtime) { - if !(package.present)(runtime) - .with_context(|| format!("failed to inspect package `{}`", package.id))? - { - return Ok(true); - } - } - - Ok(false) - } - pub async fn prepare_bootstrap(&self, runtime: &Runtime) -> Result<()> { for package in self.bootstrap_packages(runtime) { (package.ensure)(runtime) diff --git a/koharu-runtime/src/runtime.rs b/koharu-runtime/src/runtime.rs index 31f9f918fbd3608357a360e32c4be1ea7a538907..c0ed24588f40033f5516c6fde722721bfe1e8d5a 100644 --- a/koharu-runtime/src/runtime.rs +++ b/koharu-runtime/src/runtime.rs @@ -1,4 +1,4 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use anyhow::Result; @@ -27,21 +27,24 @@ struct RuntimeInner { packages: PackageCatalog, } -#[derive(Debug, Clone)] -pub struct RuntimeBuilder { - settings: Settings, - compute: ComputePolicy, -} - impl Runtime { - pub fn builder(settings: Settings) -> RuntimeBuilder { - RuntimeBuilder::new(settings) - } - pub fn new(settings: Settings, compute: ComputePolicy) -> Result { - RuntimeBuilder::new(settings) - .compute_policy(compute) - .build() + let layout = Layout::from_settings(&settings); + let http = HttpStack::new()?; + let transfers = TransferHub::new(); + let artifacts = ArtifactStore::new(layout.clone(), http.clone(), transfers.clone()); + + Ok(Self { + inner: Arc::new(RuntimeInner { + settings, + compute, + layout, + http, + transfers, + artifacts, + packages: PackageCatalog::discover(), + }), + }) } pub fn settings(&self) -> &Settings { @@ -52,22 +55,6 @@ impl Runtime { &self.inner.layout } - pub fn runtime_root(&self) -> &Path { - self.layout().runtime_root() - } - - pub fn models_root(&self) -> &Path { - self.layout().models_root() - } - - pub fn downloads_root(&self) -> PathBuf { - self.layout().downloads_root().to_path_buf() - } - - pub fn http_proxy(&self) -> Option<&url::Url> { - self.settings().http_proxy() - } - pub fn wants_gpu(&self) -> bool { self.inner.compute.wants_gpu() } @@ -88,10 +75,6 @@ impl Runtime { &self.inner.packages } - pub fn needs_bootstrap(&self) -> Result { - self.catalog().requires_bootstrap(self) - } - pub async fn prepare(&self) -> Result<()> { self.layout().ensure_roots()?; self.catalog().prepare_bootstrap(self).await @@ -102,44 +85,6 @@ impl Runtime { } } -impl RuntimeBuilder { - pub fn new(settings: Settings) -> Self { - Self { - settings, - compute: ComputePolicy::PreferGpu, - } - } - - pub fn compute_policy(mut self, compute: ComputePolicy) -> Self { - self.compute = compute; - self - } - - pub fn cpu_only(self) -> Self { - self.compute_policy(ComputePolicy::CpuOnly) - } - - pub fn build(self) -> Result { - let settings = self.settings.apply_process_overrides(); - let layout = Layout::from_settings(&settings); - let http = HttpStack::new(&settings)?; - let transfers = TransferHub::new(); - let artifacts = ArtifactStore::new(layout.clone(), http.clone(), transfers.clone()); - - Ok(Runtime { - inner: Arc::new(RuntimeInner { - settings, - compute: self.compute, - layout, - http, - transfers, - artifacts, - packages: PackageCatalog::discover(), - }), - }) - } -} - pub type RuntimeManager = Runtime; #[cfg(test)] @@ -155,10 +100,16 @@ mod tests { async fn prepares_llama_runtime_into_configured_root() -> Result<()> { let tempdir = tempfile::tempdir()?; let runtime = Runtime::new( - Settings::from_paths( - tempdir.path().join("runtime"), - tempdir.path().join("models"), - ), + Settings { + runtime: crate::DirectorySetting { + path: camino::Utf8PathBuf::from_path_buf(tempdir.path().join("runtime")) + .unwrap(), + }, + models: crate::DirectorySetting { + path: camino::Utf8PathBuf::from_path_buf(tempdir.path().join("models")) + .unwrap(), + }, + }, ComputePolicy::CpuOnly, )?; runtime.prepare().await?; @@ -171,10 +122,16 @@ mod tests { async fn repeated_basename_loads_succeed_after_prepare() -> Result<()> { let tempdir = tempfile::tempdir()?; let runtime = Runtime::new( - Settings::from_paths( - tempdir.path().join("runtime"), - tempdir.path().join("models"), - ), + Settings { + runtime: crate::DirectorySetting { + path: camino::Utf8PathBuf::from_path_buf(tempdir.path().join("runtime")) + .unwrap(), + }, + models: crate::DirectorySetting { + path: camino::Utf8PathBuf::from_path_buf(tempdir.path().join("models")) + .unwrap(), + }, + }, ComputePolicy::CpuOnly, )?; runtime.prepare().await?; diff --git a/koharu-runtime/src/settings.rs b/koharu-runtime/src/settings.rs index 4a5f20956e0d86eea7dbf636dbff154e04086e94..3e98b069a34b120b0b3741417c92bb3f4868ecf7 100644 --- a/koharu-runtime/src/settings.rs +++ b/koharu-runtime/src/settings.rs @@ -1,28 +1,18 @@ -use std::env; -use std::path::{Path, PathBuf}; - +use camino::Utf8PathBuf; use serde::{Deserialize, Serialize}; -use url::Url; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] pub struct Settings { - #[serde(default = "DirectorySetting::runtime_default")] + #[serde(default = "default_runtime_setting")] pub runtime: DirectorySetting, - #[serde(default = "DirectorySetting::models_default")] + #[serde(default = "default_models_setting")] pub models: DirectorySetting, - pub http: HttpSetting, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct DirectorySetting { - pub path: PathBuf, -} - -#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] -#[serde(default)] -pub struct HttpSetting { - pub proxy: Option, + pub path: Utf8PathBuf, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -31,69 +21,17 @@ pub enum ComputePolicy { CpuOnly, } -#[derive(Debug, Clone, Default)] -pub struct SettingsBuilder { - runtime_root: Option, - models_root: Option, - proxy: Option, -} - -pub type PathSetting = DirectorySetting; - impl Default for Settings { fn default() -> Self { + let app_data_root = default_app_data_root(); Self { - runtime: DirectorySetting::runtime_default(), - models: DirectorySetting::models_default(), - http: HttpSetting::default(), - } - } -} - -impl Settings { - pub fn builder() -> SettingsBuilder { - SettingsBuilder::default() - } - - pub fn from_paths(runtime_root: impl Into, models_root: impl Into) -> Self { - Self::builder() - .runtime_root(runtime_root) - .models_root(models_root) - .build() - } - - pub fn with_runtime_root(mut self, path: impl Into) -> Self { - self.runtime = DirectorySetting::new(path); - self - } - - pub fn with_models_root(mut self, path: impl Into) -> Self { - self.models = DirectorySetting::new(path); - self - } - - pub fn with_proxy(mut self, proxy: Option) -> Self { - self.http.proxy = proxy; - self - } - - pub fn runtime_root(&self) -> &Path { - &self.runtime.path - } - - pub fn models_root(&self) -> &Path { - &self.models.path - } - - pub fn http_proxy(&self) -> Option<&Url> { - self.http.proxy.as_ref() - } - - pub(crate) fn apply_process_overrides(mut self) -> Self { - if let Some(path) = env::var_os("KOHARU_RUNTIME_ROOT") { - self.runtime.path = PathBuf::from(path); + runtime: DirectorySetting { + path: app_data_root.join("runtime"), + }, + models: DirectorySetting { + path: app_data_root.join("models"), + }, } - self } } @@ -103,55 +41,23 @@ impl ComputePolicy { } } -impl SettingsBuilder { - pub fn runtime_root(mut self, path: impl Into) -> Self { - self.runtime_root = Some(path.into()); - self - } - - pub fn models_root(mut self, path: impl Into) -> Self { - self.models_root = Some(path.into()); - self - } - - pub fn proxy(mut self, proxy: impl Into>) -> Self { - self.proxy = proxy.into(); - self - } - - pub fn build(self) -> Settings { - Settings { - runtime: DirectorySetting::new(self.runtime_root.unwrap_or_else(default_runtime_root)), - models: DirectorySetting::new(self.models_root.unwrap_or_else(default_models_root)), - http: HttpSetting { proxy: self.proxy }, - } - } +pub fn default_app_data_root() -> Utf8PathBuf { + let root = dirs::data_local_dir() + .or_else(dirs::data_dir) + .unwrap_or_else(std::env::temp_dir) + .join("Koharu"); + Utf8PathBuf::from_path_buf(root) + .unwrap_or_else(|path| Utf8PathBuf::from(path.to_string_lossy().into_owned())) } -impl DirectorySetting { - pub fn new(path: impl Into) -> Self { - Self { path: path.into() } - } - - fn runtime_default() -> Self { - Self::new(default_runtime_root()) - } - - fn models_default() -> Self { - Self::new(default_models_root()) +fn default_runtime_setting() -> DirectorySetting { + DirectorySetting { + path: default_app_data_root().join("runtime"), } } -pub fn default_runtime_root() -> PathBuf { - dirs::data_local_dir() - .unwrap_or_else(env::temp_dir) - .join("Koharu") - .join("runtime") -} - -pub fn default_models_root() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(env::temp_dir) - .join("Koharu") - .join("models") +fn default_models_setting() -> DirectorySetting { + DirectorySetting { + path: default_app_data_root().join("models"), + } } diff --git a/koharu/Cargo.toml b/koharu/Cargo.toml index b171c9a733eb14c892cf373f73b4dc8b4702a50c..b73232f77b6d09ef65d55d78788dc942d422aac9 100644 --- a/koharu/Cargo.toml +++ b/koharu/Cargo.toml @@ -29,6 +29,7 @@ koharu-renderer = { workspace = true } koharu-rpc = { workspace = true } koharu-runtime = { workspace = true } anyhow = { workspace = true } +camino = { workspace = true } clap = { workspace = true } dirs = { workspace = true } git-version = { workspace = true } @@ -36,6 +37,7 @@ serde_json = { workspace = true } once_cell = { workspace = true } rfd = { workspace = true } tauri = { workspace = true } +tauri-plugin-process = { workspace = true } tauri-plugin-updater = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/koharu/capabilities/default.json b/koharu/capabilities/default.json index 7413bc6c7f1edb0caee63c91651e659f8b3fe88d..a231cc796dc13040d281ff9bf037a8454889fc1f 100644 --- a/koharu/capabilities/default.json +++ b/koharu/capabilities/default.json @@ -3,9 +3,7 @@ "identifier": "default", "description": "default", "windows": [ - "main", - "splashscreen", - "bootstrap" + "main" ], "permissions": [ "core:default", @@ -17,6 +15,7 @@ "core:window:allow-maximize", "core:window:allow-toggle-maximize", "core:window:allow-internal-toggle-maximize", + "process:default", "updater:default" ] } diff --git a/koharu/src/app.rs b/koharu/src/app.rs index 41017298336acd03bd752bc80f57d012d044a3d6..059becc7c1de2c053a42b24c76ef9dc48ace941f 100644 --- a/koharu/src/app.rs +++ b/koharu/src/app.rs @@ -1,26 +1,27 @@ -use std::sync::{Arc, Mutex as StdMutex}; +use std::sync::Arc; use anyhow::{Context, Result}; +use camino::Utf8PathBuf; use clap::Parser; use rfd::MessageDialog; use tauri::{AppHandle, Manager, WebviewWindowBuilder}; -use tokio::{ - net::TcpListener, - sync::{RwLock, watch}, -}; +use tokio::{net::TcpListener, sync::RwLock}; use tracing_subscriber::fmt::format::FmtSpan; use koharu_app::{ AppResources, - config::{self as app_config, AppConfig}, - llm, ml, + blob_store::BlobStore, + config::{self as app_config}, + llm, + manifest::ManifestStore, + ml, + page_cache::PageCache, renderer::Renderer, }; -use koharu_core::{BootstrapConfig, State}; use koharu_llm::safe::llama_backend::LlamaBackend; use koharu_ml::{cuda_is_available, device}; -use koharu_rpc::{BootstrapHooks, SharedState, server}; -use koharu_runtime::{ComputePolicy, RuntimeManager}; +use koharu_rpc::{SharedState, server}; +use koharu_runtime::{ComputePolicy, DirectorySetting, RuntimeManager, Settings}; #[derive(Parser)] #[command(version = crate::version::APP_VERSION, about)] @@ -65,78 +66,6 @@ struct Cli { debug: bool, } -#[derive(Clone)] -struct BootstrapController { - resources: Arc>, - runtime_tx: watch::Sender, - init_lock: Arc>, - handle: Arc>>, - cpu: bool, - headless: bool, -} - -impl BootstrapController { - fn get_config(&self) -> Result { - Ok(app_config::to_bootstrap_config(&app_config::load()?)) - } - - fn put_config(&self, config: BootstrapConfig) -> Result { - let config = app_config::from_bootstrap_config(config)?; - app_config::save(&config)?; - if self.resources.get().is_none() { - self.runtime_tx - .send_replace(runtime_from_config(config.clone(), self.cpu)?); - } - Ok(app_config::to_bootstrap_config(&config)) - } - - fn set_handle(&self, handle: AppHandle) { - if let Ok(mut slot) = self.handle.lock() { - *slot = Some(handle); - } - } - - async fn initialize(&self) -> Result<()> { - if self.resources.get().is_some() { - if let Some(handle) = self.current_handle() { - show_main_window(&handle)?; - } - return Ok(()); - } - - let _guard = self - .init_lock - .try_lock() - .map_err(|_| anyhow::anyhow!("initialization already in progress"))?; - - let runtime = self.runtime_tx.borrow().clone(); - let settings = runtime.settings(); - tracing::info!( - runtime_root = %settings.runtime.path.display(), - models_root = %settings.models.path.display(), - proxy = settings.http.proxy.as_ref().map(|value| value.as_str()).unwrap_or(""), - "initializing application resources" - ); - - self.resources - .get_or_try_init(|| { - let runtime = runtime.clone(); - async move { build_resources(runtime, self.cpu, self.headless).await } - }) - .await?; - - if let Some(handle) = self.current_handle() { - show_main_window(&handle)?; - } - - Ok(()) - } - - fn current_handle(&self) -> Option { - self.handle.lock().ok().and_then(|slot| slot.clone()) - } -} - fn initialize(headless: bool, _debug: bool) -> Result<()> { #[cfg(target_os = "windows")] { @@ -177,27 +106,27 @@ fn initialize(headless: bool, _debug: bool) -> Result<()> { Ok(()) } -fn compute_policy(cpu: bool) -> ComputePolicy { - if cpu { - ComputePolicy::CpuOnly - } else { - ComputePolicy::PreferGpu - } -} - -fn runtime_from_config(config: AppConfig, cpu: bool) -> Result { - RuntimeManager::new(config, compute_policy(cpu)) -} - -fn load_runtime(cpu: bool) -> Result { - runtime_from_config(app_config::load()?, cpu) -} - async fn prefetch(cpu: bool) -> Result<()> { - load_runtime(cpu)? - .prepare() - .await - .context("Failed to initialize runtime and model packages")?; + let config = app_config::load()?; + let data_root = config.data.path.clone(); + RuntimeManager::new( + Settings { + runtime: DirectorySetting { + path: data_root.join("runtime"), + }, + models: DirectorySetting { + path: data_root.join("models"), + }, + }, + if cpu { + ComputePolicy::CpuOnly + } else { + ComputePolicy::PreferGpu + }, + )? + .prepare() + .await + .context("Failed to initialize runtime and model packages")?; Ok(()) } @@ -224,6 +153,7 @@ fn warning(headless: bool, title: &str, description: &str) { async fn build_resources( runtime: RuntimeManager, + data_root: Utf8PathBuf, cpu: bool, headless: bool, ) -> Result { @@ -281,11 +211,14 @@ async fn build_resources( ); let llm = Arc::new(llm::Model::new(runtime.clone(), cpu, llama_backend)); let renderer = Arc::new(Renderer::new().context("Failed to initialize renderer")?); - let state = Arc::new(RwLock::new(State::default())); + + let blobs = BlobStore::new(data_root.join("blobs"))?; + let manifests = ManifestStore::new(data_root.join("pages"))?; + let cache = PageCache::new(blobs, manifests); Ok(AppResources { runtime, - state, + cache, ml, llm, renderer, @@ -295,6 +228,35 @@ async fn build_resources( }) } +async fn initialize_resources( + resources: Arc>, + runtime: RuntimeManager, + data_root: Utf8PathBuf, + cpu: bool, + headless: bool, +) -> Result<()> { + if resources.get().is_some() { + return Ok(()); + } + + let settings = runtime.settings(); + tracing::info!( + runtime_root = %settings.runtime.path, + models_root = %settings.models.path, + "initializing application resources" + ); + + resources + .get_or_try_init(|| { + let runtime = runtime.clone(); + let data_root = data_root.clone(); + async move { build_resources(runtime, data_root, cpu, headless).await } + }) + .await?; + + Ok(()) +} + fn create_window(handle: &AppHandle, label: &str) -> Result<()> { if handle.get_webview_window(label).is_some() { return Ok(()); @@ -327,35 +289,9 @@ fn show_main_window(handle: &AppHandle) -> Result<()> { main_window.show().ok(); } - for label in ["splashscreen", "bootstrap"] { - if let Some(window) = handle.get_webview_window(label) { - window.close().ok(); - } - } - Ok(()) } -fn make_bootstrap_hooks(controller: Arc) -> BootstrapHooks { - BootstrapHooks { - get_config: Arc::new({ - let controller = Arc::clone(&controller); - move || controller.get_config() - }), - put_config: Arc::new({ - let controller = Arc::clone(&controller); - move |config| controller.put_config(config) - }), - initialize: Arc::new({ - let controller = Arc::clone(&controller); - move || { - let controller = Arc::clone(&controller); - Box::pin(async move { controller.initialize().await }) - } - }), - } -} - pub async fn run() -> Result<()> { let Cli { download, @@ -377,24 +313,27 @@ pub async fn run() -> Result<()> { return Ok(()); } - let initial_runtime = load_runtime(cpu)?; + let config = app_config::load()?; + let data_root = config.data.path.clone(); + let initial_runtime = RuntimeManager::new( + Settings { + runtime: DirectorySetting { + path: data_root.join("runtime"), + }, + models: DirectorySetting { + path: data_root.join("models"), + }, + }, + if cpu { + ComputePolicy::CpuOnly + } else { + ComputePolicy::PreferGpu + }, + )?; let listener = TcpListener::bind(format!("127.0.0.1:{}", port.unwrap_or(0))).await?; let api_port = listener.local_addr()?.port(); let resources = Arc::new(tokio::sync::OnceCell::new()); - let (runtime_tx, runtime_rx) = watch::channel(initial_runtime.clone()); - let controller = Arc::new(BootstrapController { - resources: Arc::clone(&resources), - runtime_tx, - init_lock: Arc::new(tokio::sync::Mutex::new(())), - handle: Arc::new(StdMutex::new(None)), - cpu, - headless, - }); - let shared = SharedState::new( - Arc::clone(&resources), - runtime_rx, - make_bootstrap_hooks(controller.clone()), - ); + let shared = SharedState::new(Arc::clone(&resources), initial_runtime.clone()); let mut context = tauri::generate_context!(); let shared_assets = crate::assets::share_context_assets(&mut context); @@ -409,18 +348,21 @@ pub async fn run() -> Result<()> { } } }); - shared - .get_or_try_init(|| { - let runtime = initial_runtime.clone(); - async move { build_resources(runtime, cpu, headless).await } - }) - .await?; + initialize_resources( + Arc::clone(&resources), + initial_runtime.clone(), + data_root.clone(), + cpu, + headless, + ) + .await?; tokio::signal::ctrl_c().await?; return Ok(()); } let embedded_resolver = crate::assets::embedded_asset_resolver(shared_assets); tauri::Builder::default() + .plugin(tauri_plugin_process::init()) .append_invoke_initialization_script(format!("window.__KOHARU_API_PORT__ = {api_port};")) .setup(move |app| { let resolver = server::asset_resolver([ @@ -438,31 +380,17 @@ pub async fn run() -> Result<()> { }); let handle = app.handle().clone(); - controller.set_handle(handle.clone()); - - let runtime = initial_runtime.clone(); - let needs_bootstrap = runtime.needs_bootstrap().with_context(|| { - format!( - "failed to inspect bootstrap packages for runtime `{}` and models `{}`", - runtime.settings().runtime.path.display(), - runtime.settings().models.path.display() - ) - })?; - - if needs_bootstrap { - create_window(&handle, "bootstrap")?; - } else { - create_window(&handle, "splashscreen")?; - tauri::async_runtime::spawn({ - let controller = controller.clone(); - async move { - controller - .initialize() - .await - .expect("failed to build app resources"); - } - }); - } + show_main_window(&handle)?; + tauri::async_runtime::spawn({ + let resources = Arc::clone(&resources); + let runtime = initial_runtime.clone(); + let data_root = data_root.clone(); + async move { + initialize_resources(resources, runtime, data_root, cpu, headless) + .await + .expect("failed to build app resources"); + } + }); tauri::async_runtime::spawn(async move { handle diff --git a/koharu/tauri.conf.json b/koharu/tauri.conf.json index 7ac9770f623279b99a17b0b7b0d70c916b56c8f5..bb1a72f1013eb22a02a07b060606312fbd86fed7 100644 --- a/koharu/tauri.conf.json +++ b/koharu/tauri.conf.json @@ -23,26 +23,6 @@ "center": true, "title": "Koharu", "decorations": false - }, - { - "label": "splashscreen", - "create": false, - "url": "/splashscreen", - "decorations": false, - "resizable": false, - "height": 200, - "width": 300, - "center": true - }, - { - "label": "bootstrap", - "create": false, - "url": "/bootstrap", - "decorations": false, - "resizable": false, - "height": 200, - "width": 300, - "center": true } ] }, diff --git a/ui/app/(app)/page.tsx b/ui/app/(app)/page.tsx index 1a631f64394c7bda9dbf5f6dfe32e18de266db0e..76ba77c66d44d2967f5aeb2675d28be2c2ff6b91 100644 --- a/ui/app/(app)/page.tsx +++ b/ui/app/(app)/page.tsx @@ -4,6 +4,8 @@ import { Panels } from '@/components/Panels' import { Workspace, StatusBar } from '@/components/Canvas' import { Navigator } from '@/components/Navigator' import { ActivityBubble } from '@/components/ActivityBubble' +import { AppInitializationSkeleton } from '@/components/AppInitializationSkeleton' +import { useGetMeta } from '@/lib/api/system/system' import { Group, Panel, @@ -19,6 +21,17 @@ export default function Page() { id: LAYOUT_ID, panelIds: ['left', 'center', 'right'], }) + const { data: meta } = useGetMeta({ + query: { + retry: false, + refetchInterval: (query) => (query.state.data ? false : 1500), + staleTime: Infinity, + }, + }) + + if (!meta) { + return + } return (
diff --git a/ui/app/(app)/settings/page.tsx b/ui/app/(app)/settings/page.tsx index 133abc6bd95de7648a129c73e6098a6fc75a4811..0b94822e2ed2ea3e475b848069b915f129d1f271 100644 --- a/ui/app/(app)/settings/page.tsx +++ b/ui/app/(app)/settings/page.tsx @@ -1,9 +1,10 @@ 'use client' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' import Link from 'next/link' +import { relaunch } from '@tauri-apps/plugin-process' import { SunIcon, MoonIcon, @@ -22,11 +23,17 @@ import { SelectValue, } from '@/components/ui/select' import { isTauri } from '@/lib/backend' -import { getMeta, getConfig, updateConfig } from '@/lib/api/system/system' -import { setApiKey as setApiKeyApi } from '@/lib/api/providers/providers' +import { getConfig, getMeta, updateConfig } from '@/lib/api/system/system' +import { getLlmCatalog } from '@/lib/api/llm/llm' +import { toAppConfigUpdate } from '@/lib/appConfig' import { usePreferencesStore } from '@/lib/stores/preferencesStore' import { supportedLanguages } from '@/lib/i18n' -import type { BootstrapConfig } from '@/lib/api/schemas' +import type { + AppConfig, + AppLlmProviderConfig, + AppLlmProviderConfigUpdate, + LlmProviderCatalog, +} from '@/lib/api/schemas' const THEME_OPTIONS = [ { value: 'light', icon: SunIcon, labelKey: 'settings.themeLight' }, @@ -34,39 +41,89 @@ const THEME_OPTIONS = [ { value: 'system', icon: MonitorIcon, labelKey: 'settings.themeSystem' }, ] as const -type ApiProvider = { - id: string - name: string - free_tier: boolean -} - -const API_PROVIDERS: ApiProvider[] = [ - { id: 'openai', name: 'OpenAI', free_tier: false }, - { id: 'gemini', name: 'Gemini', free_tier: true }, - { id: 'claude', name: 'Claude', free_tier: false }, - { id: 'deepseek', name: 'DeepSeek', free_tier: false }, -] - const inputClass = 'border-border bg-card text-foreground placeholder:text-muted-foreground focus:ring-primary w-full rounded-md border px-3 py-1.5 text-sm focus:ring-1 focus:outline-none' +const getProviderConfig = ( + config: AppConfig | null, + providerId: string, +): AppLlmProviderConfig | undefined => + config?.llm?.providers?.find((provider) => provider.id === providerId) + +const upsertProviderConfig = ( + config: AppConfig, + providerId: string, + updater: (provider: AppLlmProviderConfig) => AppLlmProviderConfig, +): AppConfig => { + const providers = [...(config.llm?.providers ?? [])] + const index = providers.findIndex((provider) => provider.id === providerId) + const current = + index >= 0 + ? providers[index] + : { id: providerId, baseUrl: null, hasApiKey: false } + const nextProvider = updater(current) + + if (index >= 0) { + providers[index] = nextProvider + } else { + providers.push(nextProvider) + } + + return { + ...config, + llm: { + providers, + }, + } +} + +const providerStatusText = (provider: LlmProviderCatalog) => { + if (provider.status === 'missing_configuration') { + return 'Missing configuration' + } + if (provider.status === 'discovery_failed') { + return provider.error ?? 'Model discovery failed' + } + if (provider.error) { + return provider.error + } + return null +} + export default function SettingsPage() { const { t, i18n } = useTranslation() const { theme, setTheme } = useTheme() const locales = useMemo(() => supportedLanguages, []) + const fontFamily = usePreferencesStore((state) => state.fontFamily) + const setFontFamily = usePreferencesStore((state) => state.setFontFamily) + const [deviceInfo, setDeviceInfo] = useState<{ mlDevice: string }>() - const apiKeys = usePreferencesStore((state) => state.apiKeys) - const setApiKey = usePreferencesStore((state) => state.setApiKey) + const [appConfig, setAppConfig] = useState(null) + const [providerCatalogs, setProviderCatalogs] = useState< + LlmProviderCatalog[] + >([]) const [visibleKeys, setVisibleKeys] = useState>({}) - const saveTimersRef = useRef>>( - {}, - ) - const pendingApiKeysRef = useRef>({}) - const proxySaveTimerRef = useRef | null>(null) - const pendingBootstrapConfigRef = useRef(null) + const [apiKeyDrafts, setApiKeyDrafts] = useState>({}) + const [dataPathDraft, setDataPathDraft] = useState('') + const [dataPathError, setDataPathError] = useState(null) + const [isSavingDataPath, setIsSavingDataPath] = useState(false) + + useEffect(() => { + const loadPageState = async () => { + try { + const [config, catalog] = await Promise.all([ + getConfig(), + getLlmCatalog(), + ]) + setAppConfig(config) + setProviderCatalogs(catalog.providers) + } catch (error) { + console.error('Failed to load settings state', error) + } + } - const [bootstrapConfig, setBootstrapConfig] = - useState(null) + void loadPageState() + }, []) useEffect(() => { if (!isTauri()) return @@ -84,121 +141,148 @@ export default function SettingsPage() { }, []) useEffect(() => { - const loadBootstrapConfig = async () => { - try { - const config = await getConfig() - setBootstrapConfig(config) - } catch (error) { - console.error('Failed to load bootstrap config', error) + if (!appConfig) return + setDataPathDraft(appConfig.data.path) + setDataPathError(null) + }, [appConfig]) + + const persistConfig = async ( + nextConfig: AppConfig, + providerOverrides?: AppLlmProviderConfigUpdate[], + ): Promise => { + try { + const saved = await updateConfig( + toAppConfigUpdate(nextConfig, providerOverrides), + ) + const catalog = await getLlmCatalog() + setAppConfig(saved) + setProviderCatalogs(catalog.providers) + if (providerOverrides?.length) { + setApiKeyDrafts((current) => { + const next = { ...current } + for (const provider of providerOverrides) { + if (provider.apiKey !== undefined || provider.clearApiKey) { + delete next[provider.id] + } + } + return next + }) } + return saved + } catch (error) { + console.error('Failed to save settings', error) + return null } + } - void loadBootstrapConfig() - }, []) + const handleProviderBaseUrlChange = (providerId: string, value: string) => { + setAppConfig((current) => + current + ? upsertProviderConfig(current, providerId, (provider) => ({ + ...provider, + baseUrl: value || null, + })) + : current, + ) + } - const persistApiKey = async (provider: string, value: string) => { - try { - await setApiKeyApi(provider, { apiKey: value }) - } catch (error) { - console.error(`Failed to save API key for ${provider}`, error) - } + const handleProviderApiKeyChange = (providerId: string, value: string) => { + setApiKeyDrafts((current) => ({ + ...current, + [providerId]: value, + })) } - const persistBootstrapConfig = async (nextConfig: BootstrapConfig) => { - try { - const saved = await updateConfig(nextConfig) - setBootstrapConfig(saved) - } catch (error) { - console.error('Failed to save bootstrap config', error) - } + const handlePersistCurrentConfig = () => { + if (!appConfig) return + void persistConfig(appConfig) } - const flushApiKeySave = (provider: string) => { - const existingTimer = saveTimersRef.current[provider] - if (existingTimer) { - clearTimeout(existingTimer) - delete saveTimersRef.current[provider] - } + const handleApplyDataPath = async () => { + if (!appConfig) return - const pendingValue = pendingApiKeysRef.current[provider] - if (pendingValue === undefined) { + const nextPath = dataPathDraft.trim() + if (!nextPath) { + setDataPathError('App data path is required.') return } - delete pendingApiKeysRef.current[provider] - void persistApiKey(provider, pendingValue) - } - - const flushProxySave = () => { - const existingTimer = proxySaveTimerRef.current - if (existingTimer) { - clearTimeout(existingTimer) - proxySaveTimerRef.current = null + if (nextPath === appConfig.data.path) { + setDataPathError(null) + return } - const pendingConfig = pendingBootstrapConfigRef.current - if (!pendingConfig) { + const confirmed = window.confirm( + 'Changing the app data path will move Koharu data to the new location and restart the app. Continue?', + ) + if (!confirmed) { + setDataPathDraft(appConfig.data.path) + setDataPathError(null) return } - pendingBootstrapConfigRef.current = null - void persistBootstrapConfig(pendingConfig) - } + setIsSavingDataPath(true) + setDataPathError(null) - useEffect(() => { - return () => { - Object.keys(saveTimersRef.current).forEach((provider) => { - flushApiKeySave(provider) - }) - flushProxySave() - } - }, []) + const saved = await persistConfig({ + ...appConfig, + data: { + path: nextPath, + }, + }) - const handleApiKeyChange = (provider: string, value: string) => { - setApiKey(provider, value) - pendingApiKeysRef.current[provider] = value + setIsSavingDataPath(false) + if (!saved) { + setDataPathError('Failed to update the app data path.') + return + } - const existingTimer = saveTimersRef.current[provider] - if (existingTimer) { - clearTimeout(existingTimer) + if (!isTauri()) { + window.alert( + 'Koharu saved the new app data path. Restart the app to finish applying it.', + ) + return } - saveTimersRef.current[provider] = setTimeout(() => { - delete saveTimersRef.current[provider] - flushApiKeySave(provider) - }, 300) + try { + await relaunch() + } catch (error) { + console.error('Failed to restart Koharu', error) + setDataPathError( + 'Koharu saved the new path but could not restart automatically. Restart it manually.', + ) + } } - const handleProxyChange = (value: string) => { - if (!bootstrapConfig) return - - const nextConfig: BootstrapConfig = { - ...bootstrapConfig, - http: { - proxy: value.trim() ? value : null, + const handlePersistProviderApiKey = (providerId: string) => { + if (!appConfig) return + const apiKey = apiKeyDrafts[providerId]?.trim() + if (!apiKey) return + void persistConfig(appConfig, [ + { + id: providerId, + apiKey, + clearApiKey: false, }, - } - - setBootstrapConfig(nextConfig) - pendingBootstrapConfigRef.current = nextConfig - - if (proxySaveTimerRef.current) { - clearTimeout(proxySaveTimerRef.current) - } + ]) + } - proxySaveTimerRef.current = setTimeout(() => { - proxySaveTimerRef.current = null - flushProxySave() - }, 300) + const handleClearProviderApiKey = (providerId: string) => { + if (!appConfig) return + void persistConfig(appConfig, [ + { + id: providerId, + apiKey: null, + clearApiKey: true, + }, + ]) } return (
- {/* Content column */}
- {/* Header with back button */}
- {/* Appearance Section */}

{t('settings.appearance')} @@ -241,7 +324,6 @@ export default function SettingsPage() {

- {/* Language Section */}

{t('settings.language')} @@ -269,29 +351,72 @@ export default function SettingsPage() {

- {t('settings.httpProxy')} + {t('llm.render')}

- {t('settings.httpProxyDescription')} + {t('settings.localLlmDescription')}

- + handleProxyChange(e.target.value)} - onBlur={flushProxySave} - placeholder={t('bootstrap.proxyUrlPlaceholder')} - disabled={!bootstrapConfig} + type='text' + value={fontFamily ?? ''} + onChange={(event) => + setFontFamily(event.target.value || undefined) + } + placeholder='e.g. Noto Sans' className={inputClass} />
- {/* Device Section */} +
+

+ App Data +

+

+ Koharu stores models, runtime packages, pages, and blobs under + this folder. Changing it moves managed data and restarts the + app. config.toml always stays in{' '} + LocalAppData\\Koharu. +

+ +
+
+ + { + setDataPathDraft(event.target.value) + setDataPathError(null) + }} + placeholder='C:\\Users\\you\\AppData\\Local\\Koharu' + className={inputClass} + /> +
+ +
+
+ {dataPathError ?? 'Apply to move data and restart Koharu.'} +
+ +
+
+
+ {deviceInfo && (

@@ -316,7 +441,6 @@ export default function SettingsPage() {

)} - {/* API Keys Section */}

{t('settings.apiKeys')} @@ -324,52 +448,126 @@ export default function SettingsPage() {

{t('settings.apiKeysDescription')}

-
- {API_PROVIDERS.map(({ id, name, free_tier }) => ( -
- -
-
- - handleApiKeyChange(id, e.target.value) - } - onBlur={() => flushApiKeySave(id)} - placeholder='Enter API key' - className={`${inputClass} pr-9`} - /> - + +
+ {providerCatalogs.map((provider) => { + const configured = getProviderConfig(appConfig, provider.id) + const apiKeyDraft = apiKeyDrafts[provider.id] ?? '' + const statusText = providerStatusText(provider) + + return ( +
+
+
+
+ {provider.name} +
+ {statusText ? ( +
+ {statusText} +
+ ) : null} +
+
+ {provider.status} +
- {free_tier && ( - - {t('settings.freeTier')} - - )} + {provider.requiresBaseUrl ? ( +
+ + + handleProviderBaseUrlChange( + provider.id, + event.target.value, + ) + } + onBlur={handlePersistCurrentConfig} + placeholder='https://example.com/v1' + className={inputClass} + /> +
+ ) : null} + +
+ +
+ + handleProviderApiKeyChange( + provider.id, + event.target.value, + ) + } + onBlur={() => + handlePersistProviderApiKey(provider.id) + } + placeholder={ + configured?.hasApiKey + ? 'Stored in keychain. Enter a new key to replace it.' + : 'Enter API key' + } + className={`${inputClass} pr-9`} + /> + +
+ +
+ + {configured?.hasApiKey + ? 'API key stored in keychain' + : 'No API key stored'} + + {configured?.hasApiKey ? ( + + ) : null} +
+
-
- ))} + ) + })}

- {/* Divider */}
- {/* About Link */} - download.total && download.total > 0 - ? Math.min(100, Math.round((download.downloaded / download.total) * 100)) - : undefined - -const normalizeConfig = (next: BootstrapConfig): BootstrapConfig => ({ - runtime: { path: next.runtime.path.trim() }, - models: { path: next.models.path.trim() }, - http: { - proxy: next.http.proxy?.trim() ? next.http.proxy.trim() : null, - }, -}) - -export default function BootstrapPage() { - const { t } = useTranslation() - - const [step, setStep] = useState(0) - const [config, setConfig] = useState(DEFAULT_CONFIG) - const [loading, setLoading] = useState(true) - const [initializing, setInitializing] = useState(false) - const [failed, setFailed] = useState(false) - const [error, setError] = useState(null) - const [download, setDownload] = useState(null) - - const startInitialize = useCallback(async () => { - const nextConfig = normalizeConfig(config) - if (!nextConfig.runtime.path || !nextConfig.models.path) { - setError(t('bootstrap.pathsRequired')) - return - } - - setConfig(nextConfig) - setStep(3) - setInitializing(true) - setFailed(false) - setError(null) - - try { - const saved = await updateConfig(nextConfig) - setConfig(saved) - await initialize() - } catch (cause) { - setInitializing(false) - setFailed(true) - setError(cause instanceof Error ? cause.message : t('bootstrap.failed')) - } - }, [config, t]) - - useEffect(() => { - void (async () => { - try { - const saved = await getConfig() - setConfig(saved) - } catch (cause) { - const message = - cause instanceof Error - ? cause.message - : t('bootstrap.failedLoadConfig') - setError(message) - } finally { - setLoading(false) - } - })() - }, []) - - useEffect(() => { - const interval = setInterval(async () => { - try { - const downloads = await listDownloads() - const active = - downloads.find( - (entry) => - entry.status === 'started' || entry.status === 'downloading', - ) ?? - downloads - .slice() - .sort((left, right) => left.filename.localeCompare(right.filename)) - .at(-1) ?? - null - - if (active) { - setDownload({ - filename: active.filename, - percent: computePercent(active), - failed: active.status === 'failed', - }) - } - } catch { - // Backend may not be ready yet during bootstrap - } - }, 1500) - - return () => clearInterval(interval) - }, []) - - const goNext = async () => { - setError(null) - - if (step === 0) { - startTransition(() => setStep(1)) - return - } - - if (step === 1) { - const nextConfig = normalizeConfig(config) - if (!nextConfig.runtime.path || !nextConfig.models.path) { - setError(t('bootstrap.pathsRequired')) - return - } - startTransition(() => setStep(2)) - return - } - - if (step === 2) { - await startInitialize() - } - } - - const goBack = () => { - setInitializing(false) - setFailed(false) - setError(null) - - startTransition(() => { - setStep((current) => - current > 0 ? ((current - 1) as WizardStep) : current, - ) - }) - } - - const stepIndex = Math.min(step + 1, STEP_COUNT) - const progressLabel = failed - ? t('bootstrap.failed') - : t('common.initializing') - - if (loading) { - return ( -
-
-
- {t('bootstrap.loading')} -
-
-
- ) - } - - return ( -
- - -
-
- {step === 0 && ( - -
- -
-
- )} - - {step === 1 && ( - -
-
- - { - setConfig((current) => ({ - ...current, - runtime: { path: event.target.value }, - })) - }} - placeholder={t('bootstrap.runtimePathPlaceholder')} - /> -
- -
- - { - setConfig((current) => ({ - ...current, - models: { path: event.target.value }, - })) - }} - placeholder={t('bootstrap.modelsPathPlaceholder')} - /> -
-
-
- )} - - {step === 2 && ( - -
- - { - setConfig((current) => ({ - ...current, - http: { proxy: event.target.value || null }, - })) - }} - placeholder={t('bootstrap.proxyUrlPlaceholder')} - /> -
-
- )} - - {step === 3 && ( - -
-
- {download && typeof download.percent === 'number' ? ( -
- ) : ( -
- )} -
- -
- {download?.filename ?? t('bootstrap.waitingForDownload')} - {download && typeof download.percent === 'number' - ? ` ${download.percent}%` - : ''} -
-
- - )} -
- -
- {step < 3 ? ( -
- - -
- ) : ( - - )} -
-
-
- ) -} - -function StepPane({ - title, - children, - error, -}: { - title?: string - children: ReactNode - error: string | null -}) { - return ( -
- {title ? ( -
-
- {title} -
-
- ) : null} - {children} - {error && ( -
- {error} -
- )} -
- ) -} - -function StepIndicator({ current, total }: { current: number; total: number }) { - return ( -
- {Array.from({ length: total }).map((_, index) => ( - - ))} -
- ) -} diff --git a/ui/app/providers.tsx b/ui/app/providers.tsx index c5b0d731e10aabca32c06a616cc718300bba4d63..4ec90fa6d5f1753f8c6c27dcf140b1ea7cefa1f5 100644 --- a/ui/app/providers.tsx +++ b/ui/app/providers.tsx @@ -6,55 +6,21 @@ import { ThemeProvider } from 'next-themes' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import ClientOnly from '@/components/ClientOnly' import { TooltipProvider } from '@/components/ui/tooltip' -import { isTauri } from '@/lib/backend' -import { useGetApiKey } from '@/lib/api/providers/providers' import i18n from '@/lib/i18n' -import { usePreferencesStore } from '@/lib/stores/preferencesStore' import { ProcessingProvider } from '@/lib/machines' const queryClient = new QueryClient() -const API_KEY_PROVIDERS = ['openai', 'openai-compatible', 'gemini', 'claude', 'deepseek'] as const - -function ApiKeySyncer() { - const setApiKey = usePreferencesStore((state) => state.setApiKey) - - return ( - <> - {API_KEY_PROVIDERS.map((provider) => ( - - ))} - - ) -} - -function ApiKeySync({ - provider, - setApiKey, -}: { - provider: string - setApiKey: (provider: string, key: string) => void -}) { - const { data, status } = useGetApiKey(provider, { - query: { - enabled: isTauri(), - select: (res: { apiKey?: string | null }) => res?.apiKey ?? '', - }, - }) - - useEffect(() => { - if (status === 'success') setApiKey(provider, data ?? '') - }, [data, status, provider, setApiKey]) - - return null -} - export function Providers({ children }: { children: ReactNode }) { useEffect(() => { - const onLang = (lng: string) => { document.documentElement.lang = lng } + const onLang = (lng: string) => { + document.documentElement.lang = lng + } onLang(i18n.language) i18n.on('languageChanged', onLang) - return () => { i18n.off('languageChanged', onLang) } + return () => { + i18n.off('languageChanged', onLang) + } }, []) return ( @@ -62,7 +28,6 @@ export function Providers({ children }: { children: ReactNode }) { - {children} diff --git a/ui/app/splashscreen/page.tsx b/ui/app/splashscreen/page.tsx deleted file mode 100644 index 19d729ebf6fa28f13ac21e6e38f3e4e748d1e731..0000000000000000000000000000000000000000 --- a/ui/app/splashscreen/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { listDownloads } from '@/lib/api/downloads/downloads' - -type AggregateProgress = { - filename: string - percent?: number -} - -export default function SplashScreen() { - const { t } = useTranslation() - const [progress, setProgress] = useState(null) - - useEffect(() => { - const interval = setInterval(async () => { - try { - const downloads = await listDownloads() - if (downloads.length === 0) return - - let totalBytes = 0 - let downloadedBytes = 0 - let activeFilename: string | null = null - - for (const d of downloads) { - totalBytes += d.total ?? 0 - downloadedBytes += d.downloaded - if (d.status === 'started' || d.status === 'downloading') { - activeFilename = d.filename - } - } - - const percent = - totalBytes > 0 - ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) - : undefined - - if (activeFilename) { - setProgress({ filename: activeFilename, percent }) - } else { - setProgress((prev) => - prev ? { ...prev, percent: percent ?? 100 } : null, - ) - } - } catch { - // Backend may not be ready yet - } - }, 1500) - - return () => clearInterval(interval) - }, []) - - return ( -
- Koharu - - {t('common.initializing')} - -
- - {progress ? progress.filename : '\u00a0'} - -
- {progress && typeof progress.percent === 'number' ? ( -
- ) : ( -
- )} -
- - {progress && typeof progress.percent === 'number' - ? `${progress.percent}%` - : '\u00a0'} - -
-
- ) -} diff --git a/ui/components/ActivityBubble.tsx b/ui/components/ActivityBubble.tsx index fde2a0e3d4d185e6c10d11caecbfa224677fa1f7..ce7eecb16a05c91ecdc43c26684689fc5fe50b88 100644 --- a/ui/components/ActivityBubble.tsx +++ b/ui/components/ActivityBubble.tsx @@ -145,7 +145,10 @@ function OperationCard({ hasProgressNumbers ? (ctx.current / ctx.total) * 100 : undefined, ) const displayCurrent = hasProgressNumbers - ? Math.min(ctx.total, Math.floor(ctx.current) + (ctx.current >= ctx.total ? 0 : 1)) + ? Math.min( + ctx.total, + Math.floor(ctx.current) + (ctx.current >= ctx.total ? 0 : 1), + ) : undefined const total = hasProgressNumbers ? ctx.total : undefined @@ -159,9 +162,7 @@ function OperationCard({ render: t('processing.render'), } - const stepLabel = ctx.step - ? (stepLabels[ctx.step] ?? ctx.step) - : undefined + const stepLabel = ctx.step ? (stepLabels[ctx.step] ?? ctx.step) : undefined const stepText = stepLabel && total && typeof displayCurrent === 'number' ? t('operations.stepProgress', { @@ -189,10 +190,7 @@ function OperationCard({ return ( -
+
@@ -235,7 +233,13 @@ function OperationCard({ export function ActivityBubble() { const { t } = useTranslation() - const { isProcessing, state: machineState, send, canCancel, error: machineError } = useProcessing() + const { + isProcessing, + state: machineState, + send, + canCancel, + error: machineError, + } = useProcessing() const uiError = useEditorUiStore((state) => state.error) const clearUiError = useEditorUiStore((state) => state.clearError) @@ -259,9 +263,14 @@ export function ActivityBubble() { // Show machine errors as well as UI errors const errorMessage = machineError ?? uiError?.message - const clearError = machineError ? () => { /* machine clears on next operation */ } : clearUiError + const clearError = machineError + ? () => { + /* machine clears on next operation */ + } + : clearUiError - if (!errorMessage && !isProcessing && activeDownloads.length === 0) return null + if (!errorMessage && !isProcessing && activeDownloads.length === 0) + return null return (
diff --git a/ui/components/AppInitializationSkeleton.tsx b/ui/components/AppInitializationSkeleton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..44ca6cf51f53fe08cca31daa3bbcea43601cb5d8 --- /dev/null +++ b/ui/components/AppInitializationSkeleton.tsx @@ -0,0 +1,138 @@ +'use client' + +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useListDownloads } from '@/lib/api/downloads/downloads' +import type { DownloadState } from '@/lib/api/schemas' + +const shimmerClass = + 'animate-pulse rounded-xl bg-gradient-to-r from-muted via-muted/70 to-muted' + +const summarizeDownload = (downloads?: DownloadState[] | null) => { + if (!downloads?.length) return null + + let totalBytes = 0 + let downloadedBytes = 0 + let activeFilename: string | null = null + + for (const download of downloads) { + totalBytes += download.total ?? 0 + downloadedBytes += download.downloaded + if (download.status === 'started' || download.status === 'downloading') { + activeFilename = download.filename + } + } + + const percent = + totalBytes > 0 + ? Math.min(100, Math.round((downloadedBytes / totalBytes) * 100)) + : undefined + + return { + filename: activeFilename, + percent, + } +} + +export function AppInitializationSkeleton() { + const { t } = useTranslation() + const { data: downloads } = useListDownloads({ + query: { + refetchInterval: 1500, + }, + }) + + const progress = useMemo(() => summarizeDownload(downloads), [downloads]) + + return ( +
+
+
+
+ Koharu +
+
+ {t('common.initializing')} +
+
+
+
+ {progress?.filename ?? t('bootstrap.waitingForDownload')} +
+
+ {typeof progress?.percent === 'number' ? ( +
+ ) : ( +
+ )} +
+
+
+ +
+
+
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+
+ +
+
+ {Array.from({ length: 5 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+
+ ) +} diff --git a/ui/components/MenuBar.tsx b/ui/components/MenuBar.tsx index 33ef7842f7806c3de335613ee0f313b10e3f80e9..af25ab0c637edd4affaffab1711101e84aa5b569 100644 --- a/ui/components/MenuBar.tsx +++ b/ui/components/MenuBar.tsx @@ -65,13 +65,12 @@ export function MenuBar() { const { send } = useProcessing() const buildPipelineRequest = (documentId?: string): PipelineJobRequest => { - const { selectedModel, selectedLanguage, renderEffect, renderStroke } = + const { selectedTarget, selectedLanguage, renderEffect, renderStroke } = useEditorUiStore.getState() - const { fontFamily, apiKeys } = usePreferencesStore.getState() + const { fontFamily } = usePreferencesStore.getState() return { documentId, - llmModelId: selectedModel, - llmApiKey: selectedModel ? apiKeys[selectedModel.split(':')[0]] : undefined, + llm: selectedTarget ? { target: selectedTarget } : undefined, language: selectedLanguage, shaderEffect: renderEffect, shaderStroke: renderStroke, @@ -88,32 +87,47 @@ export function MenuBar() { const fileMenuItems: MenuItem[] = [ { label: t('menu.openFile'), - onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'files' }), + onSelect: () => + send({ type: 'START_IMPORT', mode: 'replace', source: 'files' }), testId: 'menu-file-open', }, { label: t('menu.addFile'), - onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'files' }), + onSelect: () => + send({ type: 'START_IMPORT', mode: 'append', source: 'files' }), testId: 'menu-file-add', }, { label: t('menu.openFolder'), - onSelect: () => send({ type: 'START_IMPORT', mode: 'replace', source: 'folder' }), + onSelect: () => + send({ type: 'START_IMPORT', mode: 'replace', source: 'folder' }), testId: 'menu-file-open-folder', }, { label: t('menu.addFolder'), - onSelect: () => send({ type: 'START_IMPORT', mode: 'append', source: 'folder' }), + onSelect: () => + send({ type: 'START_IMPORT', mode: 'append', source: 'folder' }), testId: 'menu-file-add-folder', }, { label: t('menu.export'), - onSelect: () => send({ type: 'START_EXPORT', documentId: requireDocumentId(), format: 'webp', params: { layer: 'rendered' } }), + onSelect: () => + send({ + type: 'START_EXPORT', + documentId: requireDocumentId(), + format: 'webp', + params: { layer: 'rendered' }, + }), testId: 'menu-file-export', }, { label: t('menu.exportPsd'), - onSelect: () => send({ type: 'START_EXPORT', documentId: requireDocumentId(), format: 'psd' }), + onSelect: () => + send({ + type: 'START_EXPORT', + documentId: requireDocumentId(), + format: 'psd', + }), testId: 'menu-file-export-psd', }, { @@ -144,7 +158,10 @@ export function MenuBar() { label: t('menu.processCurrent'), onSelect: () => { const documentId = requireDocumentId() - send({ type: 'START_PIPELINE', request: buildPipelineRequest(documentId) }) + send({ + type: 'START_PIPELINE', + request: buildPipelineRequest(documentId), + }) }, testId: 'menu-process-current', }, @@ -158,7 +175,8 @@ export function MenuBar() { }, { label: t('menu.processAll'), - onSelect: () => send({ type: 'START_PIPELINE', request: buildPipelineRequest() }), + onSelect: () => + send({ type: 'START_PIPELINE', request: buildPipelineRequest() }), testId: 'menu-process-all', }, ], diff --git a/ui/components/Navigator.tsx b/ui/components/Navigator.tsx index bc227d94140bda40d77241d0ee15c20fc71523fb..ea6179fcedf24cb5d6470c14673a4d06c6a3f34d 100644 --- a/ui/components/Navigator.tsx +++ b/ui/components/Navigator.tsx @@ -11,9 +11,10 @@ import { useEditorUiStore } from '@/lib/stores/editorUiStore' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' -const THUMBNAIL_DPR = typeof window !== 'undefined' - ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3) - : 2 +const THUMBNAIL_DPR = + typeof window !== 'undefined' + ? Math.min(Math.ceil(window.devicePixelRatio || 1), 3) + : 2 // Fixed row height: thumbnail (aspect 3:4 in ~150px width ≈ 200px) + page number + padding const ROW_HEIGHT = 230 @@ -22,13 +23,13 @@ const OVERSCAN = 5 export function Navigator() { const { data: documents = [] } = useListDocuments() const totalPages = documents.length - const currentDocumentId = useEditorUiStore( - (state) => state.currentDocumentId, - ) + const currentDocumentId = useEditorUiStore((state) => state.currentDocumentId) const setCurrentDocumentId = useEditorUiStore( (state) => state.setCurrentDocumentId, ) - const currentDocumentIndex = documents.findIndex((d) => d.id === currentDocumentId) + const currentDocumentIndex = documents.findIndex( + (d) => d.id === currentDocumentId, + ) const viewportRef = useRef(null) const { t } = useTranslation() @@ -87,9 +88,7 @@ export function Navigator() { index={virtualRow.index} documentId={doc?.id} selected={doc?.id === currentDocumentId} - onSelect={() => - doc && setCurrentDocumentId(doc.id) - } + onSelect={() => doc && setCurrentDocumentId(doc.id)} />
) @@ -139,9 +138,7 @@ function PagePreview({ )}
-
- {index + 1} -
+
{index + 1}
) diff --git a/ui/components/canvas/CanvasToolbar.tsx b/ui/components/canvas/CanvasToolbar.tsx index 1443d9a1d9e2ba243fb2eea03660adcd90452b62..e7957b6464894e966a607ddbaea81e8f9b90709f 100644 --- a/ui/components/canvas/CanvasToolbar.tsx +++ b/ui/components/canvas/CanvasToolbar.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useQuery, useQueryClient } from '@tanstack/react-query' import { motion } from 'motion/react' @@ -29,25 +29,33 @@ import { import { useEditorUiStore } from '@/lib/stores/editorUiStore' import { useGetLlm, - getListLlmModelsQueryKey, - listLlmModels, + getGetLlmCatalogQueryKey, + getLlmCatalog, unloadLlm, } from '@/lib/api/llm/llm' -import { - translateDocument, -} from '@/lib/api/processing/processing' +import { translateDocument } from '@/lib/api/processing/processing' import { getGetDocumentQueryKey, getListDocumentsQueryKey, } from '@/lib/api/documents/documents' -import type { LlmModelInfo } from '@/lib/api/schemas' +import type { + LlmCatalog, + LlmCatalogModel, + LlmProviderCatalog, +} from '@/lib/api/schemas' import { useProcessing } from '@/lib/machines' import { usePreferencesStore } from '@/lib/stores/preferencesStore' -import { getProviderDisplayName } from '@/lib/providers' +import { llmTargetKey, sameLlmTarget } from '@/lib/llmTargets' import i18n from '@/lib/i18n' -function useLlmModelsQuery() { +type SelectableLlmModel = { + model: LlmCatalogModel + provider?: LlmProviderCatalog +} + +function useLlmCatalogQuery() { const [language, setLanguage] = useState(i18n.language) + useEffect(() => { const handleLanguageChange = (nextLanguage: string) => { setLanguage(nextLanguage) @@ -58,15 +66,31 @@ function useLlmModelsQuery() { } }, []) - return useQuery({ - queryKey: getListLlmModelsQueryKey({ language: language ?? 'default' }), - queryFn: async () => { - return listLlmModels({ language }) - }, + return useQuery({ + queryKey: getGetLlmCatalogQueryKey({ language: language ?? 'default' }), + queryFn: async () => getLlmCatalog({ language }), staleTime: 5 * 60 * 1000, }) } +const flattenCatalogModels = (catalog?: LlmCatalog): SelectableLlmModel[] => [ + ...(catalog?.localModels ?? []).map((model) => ({ model })), + ...((catalog?.providers ?? []).flatMap((provider) => + provider.models.map((model) => ({ model, provider })), + ) ?? []), +] + +const providerWarningText = (provider?: LlmProviderCatalog) => { + if (!provider) return null + if (provider.status === 'missing_configuration') { + return `${provider.name} is missing required configuration in Settings.` + } + if (provider.status === 'discovery_failed') { + return provider.error ?? `${provider.name} model discovery failed.` + } + return null +} + export function CanvasToolbar() { return (
@@ -103,8 +127,12 @@ function WorkflowButtons() { setGenerating(true) try { await translateDocument(documentId, { language: selectedLanguage }) - await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) }) - await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() }) + await queryClient.invalidateQueries({ + queryKey: getGetDocumentQueryKey(documentId), + }) + await queryClient.invalidateQueries({ + queryKey: getListDocumentsQueryKey(), + }) useEditorUiStore.getState().setShowTextBlocksOverlay(true) } catch (error) { console.error(error) @@ -118,7 +146,9 @@ function WorkflowButtons() {
diff --git a/ui/components/panels/RenderControlsPanel.tsx b/ui/components/panels/RenderControlsPanel.tsx index b41befd9ed487c45903b32264711fcc22ef83954..c73fefdf31bd3a07f60e61b6e3c2ee7c4aab830e 100644 --- a/ui/components/panels/RenderControlsPanel.tsx +++ b/ui/components/panels/RenderControlsPanel.tsx @@ -185,7 +185,8 @@ export function RenderControlsPanel() { const { data: availableFonts = [] } = useListFonts() const fontFamily = usePreferencesStore((state) => state.fontFamily) const setFontFamily = usePreferencesStore((state) => state.setFontFamily) - const { textBlocks, selectedBlockIndex, replaceBlock, updateTextBlocks } = useTextBlocks() + const { textBlocks, selectedBlockIndex, replaceBlock, updateTextBlocks } = + useTextBlocks() const { t } = useTranslation() const selectedBlock = selectedBlockIndex !== undefined diff --git a/ui/components/panels/TextBlocksPanel.tsx b/ui/components/panels/TextBlocksPanel.tsx index fdd47a464a51cfcda88495156090ea4b1431bd8f..2962533017ef31009871c5f5e37a28e1da325897 100644 --- a/ui/components/panels/TextBlocksPanel.tsx +++ b/ui/components/panels/TextBlocksPanel.tsx @@ -59,8 +59,12 @@ export function TextBlocksPanel() { selectedBlockIndex !== undefined ? selectedBlockIndex.toString() : '' const invalidateDocument = async (documentId: string) => { - await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) }) - await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() }) + await queryClient.invalidateQueries({ + queryKey: getGetDocumentQueryKey(documentId), + }) + await queryClient.invalidateQueries({ + queryKey: getListDocumentsQueryKey(), + }) } const handleGenerate = async (blockIndex: number) => { @@ -70,7 +74,10 @@ export function TextBlocksPanel() { const textBlockId = document.textBlocks[blockIndex]?.id setGeneratingIndex(blockIndex) try { - await translateDocument(documentId, { textBlockId, language: selectedLanguage }) + await translateDocument(documentId, { + textBlockId, + language: selectedLanguage, + }) await invalidateDocument(documentId) useEditorUiStore.getState().setShowTextBlocksOverlay(true) // Re-render the block's sprite diff --git a/ui/hooks/useCanvasDrawing.ts b/ui/hooks/useCanvasDrawing.ts index b165877cb0707eeba62ae54c035e0b1aa0f3c190..ee827609d5e1059893c02f2275406c02e06b1273 100644 --- a/ui/hooks/useCanvasDrawing.ts +++ b/ui/hooks/useCanvasDrawing.ts @@ -37,7 +37,10 @@ export type CanvasDrawingConfig = { /** When true, clear the drawing canvas after each stroke finalize. */ clearAfterStroke?: boolean /** Called to set up the canvas content when the document changes (e.g. draw existing mask). */ - onCanvasInit?: (ctx: CanvasRenderingContext2D, doc: MappedDocument) => void | Promise + onCanvasInit?: ( + ctx: CanvasRenderingContext2D, + doc: MappedDocument, + ) => void | Promise } // --------------------------------------------------------------------------- @@ -71,7 +74,12 @@ const boundsToRegion = (bounds: Bounds, doc: MappedDocument): InpaintRegion => { const y0 = Math.max(0, Math.floor(bounds.minY)) const x1 = Math.min(doc.width, Math.ceil(bounds.maxX)) const y1 = Math.min(doc.height, Math.ceil(bounds.maxY)) - return { x: x0, y: y0, width: Math.max(1, x1 - x0), height: Math.max(1, y1 - y0) } + return { + x: x0, + y: y0, + width: Math.max(1, x1 - x0), + height: Math.max(1, y1 - y0), + } } const exportCanvasRegion = async ( @@ -84,7 +92,17 @@ const exportCanvasRegion = async ( tmp.height = region.height const ctx = tmp.getContext('2d') if (!ctx) return null - ctx.drawImage(canvas, region.x, region.y, region.width, region.height, 0, 0, region.width, region.height) + ctx.drawImage( + canvas, + region.x, + region.y, + region.width, + region.height, + 0, + 0, + region.width, + region.height, + ) const blob = await new Promise((r) => tmp.toBlob(r, 'image/png')) return blob ? blobToUint8Array(blob) : null } @@ -92,7 +110,9 @@ const exportCanvasRegion = async ( const exportFullCanvas = async ( canvas: HTMLCanvasElement, ): Promise => { - const blob = await new Promise((r) => canvas.toBlob(r, 'image/png')) + const blob = await new Promise((r) => + canvas.toBlob(r, 'image/png'), + ) return blob ? blobToUint8Array(blob) : null } diff --git a/ui/hooks/useCanvasZoom.ts b/ui/hooks/useCanvasZoom.ts index d28ad1d6cd8d84829d9f363b3c887b08f79073e3..9888a93583c986974467e9fb357efc325924d9b1 100644 --- a/ui/hooks/useCanvasZoom.ts +++ b/ui/hooks/useCanvasZoom.ts @@ -12,9 +12,7 @@ export function useCanvasZoom() { query: { enabled: !!documentId }, }) - const summary = document - ? `${document.width} x ${document.height}` - : '--' + const summary = document ? `${document.width} x ${document.height}` : '--' const applyScale = (value: number) => { setAutoFitEnabled(false) diff --git a/ui/hooks/useMaskDrawing.ts b/ui/hooks/useMaskDrawing.ts index 8d94f5346613e31e739489a3397663348209b8a6..21e6ac200edd161ac407822f94fc7ef8d2561a17 100644 --- a/ui/hooks/useMaskDrawing.ts +++ b/ui/hooks/useMaskDrawing.ts @@ -40,79 +40,93 @@ export function useMaskDrawing({ const invalidateDocument = useCallback( async (documentId: string) => { - await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) }) - await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() }) + await queryClient.invalidateQueries({ + queryKey: getGetDocumentQueryKey(documentId), + }) + await queryClient.invalidateQueries({ + queryKey: getListDocumentsQueryKey(), + }) }, [queryClient], ) - const { canvasRef, bind } = useCanvasDrawing(currentDocument, pointerToDocument, { - getColor: () => (isEraseMode ? '#000000' : '#ffffff'), - blendMode: 'source-over', - getBrushSize: () => usePreferencesStore.getState().brushConfig.size, - enabled: isActive, - onCanvasInit: (ctx, doc) => { - // Fill black then draw existing segment mask on top - ctx.fillStyle = '#000' - ctx.fillRect(0, 0, doc.width, doc.height) - if (doc.segment) { - void (async () => { - try { - const bitmap = await convertToImageBitmap(doc.segment!) - ctx.save() - ctx.clearRect(0, 0, doc.width, doc.height) - ctx.drawImage(bitmap, 0, 0, doc.width, doc.height) - ctx.restore() - bitmap.close() - } catch (e) { - console.error(e) - } - })() - } + const { canvasRef, bind } = useCanvasDrawing( + currentDocument, + pointerToDocument, + { + getColor: () => (isEraseMode ? '#000000' : '#ffffff'), + blendMode: 'source-over', + getBrushSize: () => usePreferencesStore.getState().brushConfig.size, + enabled: isActive, + onCanvasInit: (ctx, doc) => { + // Fill black then draw existing segment mask on top + ctx.fillStyle = '#000' + ctx.fillRect(0, 0, doc.width, doc.height) + if (doc.segment) { + void (async () => { + try { + const bitmap = await convertToImageBitmap(doc.segment!) + ctx.save() + ctx.clearRect(0, 0, doc.width, doc.height) + ctx.drawImage(bitmap, 0, 0, doc.width, doc.height) + ctx.restore() + bitmap.close() + } catch (e) { + console.error(e) + } + })() + } + }, + onFinalizeFullCanvas: async (fullPng) => { + const documentId = useEditorUiStore.getState().currentDocumentId + if (!documentId) return + try { + await updateMaskApi(documentId, { + data: Array.from(fullPng), + }) + } catch (e) { + console.error(e) + } + }, + onFinalize: async (_patch, region) => { + const documentId = useEditorUiStore.getState().currentDocumentId + if (!documentId) return + // Compute the inpaint region with margin + const brushSize = usePreferencesStore.getState().brushConfig.size + const width = Math.max(brushSize, region.width) + const height = Math.max(brushSize, region.height) + const margin = Math.min(width * 0.2, 32) + const doc = currentDocument! + const x0 = Math.max(0, Math.floor(region.x - margin)) + const y0 = Math.max(0, Math.floor(region.y - margin)) + const x1 = Math.min( + doc.width, + Math.ceil(region.x + region.width + margin), + ) + const y1 = Math.min( + doc.height, + Math.ceil(region.y + region.height + margin), + ) + const inpaintRegion = { + x: x0, + y: y0, + width: Math.max(1, x1 - x0), + height: Math.max(1, y1 - y0), + } + inpaintQueueRef.current = inpaintQueueRef.current + .catch(() => {}) + .then(async () => { + try { + await inpaintRegionApi(documentId, { region: inpaintRegion }) + await invalidateDocument(documentId) + useEditorUiStore.getState().setShowInpaintedImage(true) + } catch (e) { + console.error(e) + } + }) + }, }, - onFinalizeFullCanvas: async (fullPng) => { - const documentId = useEditorUiStore.getState().currentDocumentId - if (!documentId) return - try { - await updateMaskApi(documentId, { - data: Array.from(fullPng), - }) - } catch (e) { - console.error(e) - } - }, - onFinalize: async (_patch, region) => { - const documentId = useEditorUiStore.getState().currentDocumentId - if (!documentId) return - // Compute the inpaint region with margin - const brushSize = usePreferencesStore.getState().brushConfig.size - const width = Math.max(brushSize, region.width) - const height = Math.max(brushSize, region.height) - const margin = Math.min(width * 0.2, 32) - const doc = currentDocument! - const x0 = Math.max(0, Math.floor(region.x - margin)) - const y0 = Math.max(0, Math.floor(region.y - margin)) - const x1 = Math.min(doc.width, Math.ceil(region.x + region.width + margin)) - const y1 = Math.min(doc.height, Math.ceil(region.y + region.height + margin)) - const inpaintRegion = { - x: x0, - y: y0, - width: Math.max(1, x1 - x0), - height: Math.max(1, y1 - y0), - } - inpaintQueueRef.current = inpaintQueueRef.current - .catch(() => {}) - .then(async () => { - try { - await inpaintRegionApi(documentId, { region: inpaintRegion }) - await invalidateDocument(documentId) - useEditorUiStore.getState().setShowInpaintedImage(true) - } catch (e) { - console.error(e) - } - }) - }, - }) + ) return { canvasRef, visible: showMask, bind } } diff --git a/ui/hooks/useRenderBrushDrawing.ts b/ui/hooks/useRenderBrushDrawing.ts index 53a4fb6d735b1d594e0d947df56917e7ddc32f1e..c9c3f29d55b57ddd6a5ee93bf63941da5a9daa69 100644 --- a/ui/hooks/useRenderBrushDrawing.ts +++ b/ui/hooks/useRenderBrushDrawing.ts @@ -48,8 +48,12 @@ export function useRenderBrushDrawing({ data: Array.from(patch), region, }) - await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(documentId) }) - await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() }) + await queryClient.invalidateQueries({ + queryKey: getGetDocumentQueryKey(documentId), + }) + await queryClient.invalidateQueries({ + queryKey: getListDocumentsQueryKey(), + }) useEditorUiStore.getState().setShowBrushLayer(true) }, }) diff --git a/ui/hooks/useTextBlocks.ts b/ui/hooks/useTextBlocks.ts index 2a2d349767c3b7219ff5f47586ba88f64e432dcf..f6b0fc88b577b86fa40cbd0dbfbc767501ee5cb3 100644 --- a/ui/hooks/useTextBlocks.ts +++ b/ui/hooks/useTextBlocks.ts @@ -35,8 +35,9 @@ const hasGeometryChange = (updates: Partial) => Object.prototype.hasOwnProperty.call(updates, 'width') || Object.prototype.hasOwnProperty.call(updates, 'height') -const toUint8Array = (data: number[] | null | undefined): Uint8Array | undefined => - data ? new Uint8Array(data) : undefined +const toUint8Array = ( + data: number[] | null | undefined, +): Uint8Array | undefined => (data ? new Uint8Array(data) : undefined) const mapTextBlock = ( block: DocumentDetail['textBlocks'][number], @@ -67,7 +68,6 @@ export type MappedDocument = { name: string width: number height: number - revision: number textBlocks: TextBlock[] image: Uint8Array segment?: Uint8Array @@ -82,7 +82,6 @@ const mapDocumentDetail = (detail: DocumentDetail): MappedDocument => ({ name: detail.name, width: detail.width, height: detail.height, - revision: detail.revision, textBlocks: detail.textBlocks.map(mapTextBlock), image: new Uint8Array(detail.image), segment: toUint8Array(detail.segment), @@ -128,8 +127,12 @@ export function useTextBlocks() { const invalidateDocument = useCallback( async (docId: string) => { - await queryClient.invalidateQueries({ queryKey: getGetDocumentQueryKey(docId) }) - await queryClient.invalidateQueries({ queryKey: getListDocumentsQueryKey() }) + await queryClient.invalidateQueries({ + queryKey: getGetDocumentQueryKey(docId), + }) + await queryClient.invalidateQueries({ + queryKey: getListDocumentsQueryKey(), + }) }, [queryClient], ) diff --git a/ui/lib/api/documents/documents.ts b/ui/lib/api/documents/documents.ts index f306eddd592245e1938040ad5623de80fe24d04a..497d12ce6f757cda375cb7a8ed83f3027b35b022 100644 --- a/ui/lib/api/documents/documents.ts +++ b/ui/lib/api/documents/documents.ts @@ -3,10 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query' import type { DataTag, DefinedInitialDataOptions, @@ -19,8 +16,8 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from '@tanstack/react-query' import type { ApiError, @@ -29,378 +26,580 @@ import type { GetDocumentThumbnailParams, ImportDocumentsBody, ImportDocumentsParams, - ImportResult -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; + ImportResult, +} from '../schemas' +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] export const getListDocumentsUrl = () => { - - - - return `/api/v1/documents` } -export const listDocuments = async ( options?: RequestInit): Promise => { - - return fetchApi(getListDocumentsUrl(), - { +export const listDocuments = async ( + options?: RequestInit, +): Promise => { + return fetchApi(getListDocumentsUrl(), { ...options, - method: 'GET' - - - } -);} - - - - + method: 'GET', + }) +} export const getListDocumentsQueryKey = () => { - return [ - `/api/v1/documents` - ] as const; - } - - -export const getListDocumentsQueryOptions = >, TError = ApiError>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListDocumentsQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => listDocuments({ signal, ...requestOptions }); - - - - + return [`/api/v1/documents`] as const +} - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getListDocumentsQueryOptions = < + TData = Awaited>, + TError = ApiError, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getListDocumentsQueryKey() + + const queryFn: QueryFunction>> = ({ + signal, + }) => listDocuments({ signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag } } -export type ListDocumentsQueryResult = NonNullable>> +export type ListDocumentsQueryResult = NonNullable< + Awaited> +> export type ListDocumentsQueryError = ApiError - -export function useListDocuments>, TError = ApiError>( - options: { query:Partial>, TError, TData>> & Pick< +export function useListDocuments< + TData = Awaited>, + TError = ApiError, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useListDocuments>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useListDocuments< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useListDocuments>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useListDocuments>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useListDocuments< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useListDocuments< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { const queryOptions = getListDocumentsQueryOptions(options) - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } - return { ...query, queryKey: queryOptions.queryKey }; + return { ...query, queryKey: queryOptions.queryKey } } - - - -export const getImportDocumentsUrl = (params?: ImportDocumentsParams,) => { - const normalizedParams = new URLSearchParams(); +export const getImportDocumentsUrl = (params?: ImportDocumentsParams) => { + const normalizedParams = new URLSearchParams() Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } - }); + }) - const stringifiedParams = normalizedParams.toString(); + const stringifiedParams = normalizedParams.toString() - return stringifiedParams.length > 0 ? `/api/v1/documents?${stringifiedParams}` : `/api/v1/documents` + return stringifiedParams.length > 0 + ? `/api/v1/documents?${stringifiedParams}` + : `/api/v1/documents` } -export const importDocuments = async (importDocumentsBody: ImportDocumentsBody, - params?: ImportDocumentsParams, options?: RequestInit): Promise => { - const formData = new FormData(); -importDocumentsBody.files.forEach(value => formData.append(`files`, value)); +export const importDocuments = async ( + importDocumentsBody: ImportDocumentsBody, + params?: ImportDocumentsParams, + options?: RequestInit, +): Promise => { + const formData = new FormData() + importDocumentsBody.files.forEach((value) => formData.append(`files`, value)) - return fetchApi(getImportDocumentsUrl(params), - { + return fetchApi(getImportDocumentsUrl(params), { ...options, - method: 'POST' - , - body: - formData, - } -);} - - - - -export const getImportDocumentsMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ImportDocumentsBody;params?: ImportDocumentsParams}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: ImportDocumentsBody;params?: ImportDocumentsParams}, TContext> => { - -const mutationKey = ['importDocuments']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {data: ImportDocumentsBody;params?: ImportDocumentsParams}> = (props) => { - const {data,params} = props ?? {}; - - return importDocuments(data,params,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type ImportDocumentsMutationResult = NonNullable>> - export type ImportDocumentsMutationBody = ImportDocumentsBody - export type ImportDocumentsMutationError = ApiError - - export const useImportDocuments = (options?: { mutation?:UseMutationOptions>, TError,{data: ImportDocumentsBody;params?: ImportDocumentsParams}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: ImportDocumentsBody;params?: ImportDocumentsParams}, - TContext - > => { - return useMutation(getImportDocumentsMutationOptions(options), queryClient); - } - export const getGetDocumentUrl = (documentId: string,) => { - + method: 'POST', + body: formData, + }) +} +export const getImportDocumentsMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ImportDocumentsBody; params?: ImportDocumentsParams }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { data: ImportDocumentsBody; params?: ImportDocumentsParams }, + TContext +> => { + const mutationKey = ['importDocuments'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { data: ImportDocumentsBody; params?: ImportDocumentsParams } + > = (props) => { + const { data, params } = props ?? {} + + return importDocuments(data, params, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type ImportDocumentsMutationResult = NonNullable< + Awaited> +> +export type ImportDocumentsMutationBody = ImportDocumentsBody +export type ImportDocumentsMutationError = ApiError + +export const useImportDocuments = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ImportDocumentsBody; params?: ImportDocumentsParams }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ImportDocumentsBody; params?: ImportDocumentsParams }, + TContext +> => { + return useMutation(getImportDocumentsMutationOptions(options), queryClient) +} +export const getGetDocumentUrl = (documentId: string) => { return `/api/v1/documents/${documentId}` } -export const getDocument = async (documentId: string, options?: RequestInit): Promise => { - - return fetchApi(getGetDocumentUrl(documentId), - { +export const getDocument = async ( + documentId: string, + options?: RequestInit, +): Promise => { + return fetchApi(getGetDocumentUrl(documentId), { ...options, - method: 'GET' - - - } -);} - - - - - -export const getGetDocumentQueryKey = (documentId: string,) => { - return [ - `/api/v1/documents/${documentId}` - ] as const; - } + method: 'GET', + }) +} +export const getGetDocumentQueryKey = (documentId: string) => { + return [`/api/v1/documents/${documentId}`] as const +} -export const getGetDocumentQueryOptions = >, TError = ApiError>(documentId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export const getGetDocumentQueryOptions = < + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, ) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetDocumentQueryKey(documentId); - - - - const queryFn: QueryFunction>> = ({ signal }) => getDocument(documentId, { signal, ...requestOptions }); - - - - - - return { queryKey, queryFn, enabled: !!(documentId), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetDocumentQueryKey(documentId) + + const queryFn: QueryFunction>> = ({ + signal, + }) => getDocument(documentId, { signal, ...requestOptions }) + + return { + queryKey, + queryFn, + enabled: !!documentId, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag } } -export type GetDocumentQueryResult = NonNullable>> +export type GetDocumentQueryResult = NonNullable< + Awaited> +> export type GetDocumentQueryError = ApiError - -export function useGetDocument>, TError = ApiError>( - documentId: string, options: { query:Partial>, TError, TData>> & Pick< +export function useGetDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetDocument>, TError = ApiError>( - documentId: string, options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useGetDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetDocument>, TError = ApiError>( - documentId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useGetDocument>, TError = ApiError>( - documentId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetDocumentQueryOptions(documentId,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return { ...query, queryKey: queryOptions.queryKey }; + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag } +export function useGetDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { + const queryOptions = getGetDocumentQueryOptions(documentId, options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } + + return { ...query, queryKey: queryOptions.queryKey } +} - - -export const getGetDocumentThumbnailUrl = (documentId: string, - params?: GetDocumentThumbnailParams,) => { - const normalizedParams = new URLSearchParams(); +export const getGetDocumentThumbnailUrl = ( + documentId: string, + params?: GetDocumentThumbnailParams, +) => { + const normalizedParams = new URLSearchParams() Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } - }); + }) - const stringifiedParams = normalizedParams.toString(); + const stringifiedParams = normalizedParams.toString() - return stringifiedParams.length > 0 ? `/api/v1/documents/${documentId}/thumbnail?${stringifiedParams}` : `/api/v1/documents/${documentId}/thumbnail` + return stringifiedParams.length > 0 + ? `/api/v1/documents/${documentId}/thumbnail?${stringifiedParams}` + : `/api/v1/documents/${documentId}/thumbnail` } -export const getDocumentThumbnail = async (documentId: string, - params?: GetDocumentThumbnailParams, options?: RequestInit): Promise => { - - return fetchApi(getGetDocumentThumbnailUrl(documentId,params), - { +export const getDocumentThumbnail = async ( + documentId: string, + params?: GetDocumentThumbnailParams, + options?: RequestInit, +): Promise => { + return fetchApi(getGetDocumentThumbnailUrl(documentId, params), { ...options, - method: 'GET' - - - } -);} - - - - - -export const getGetDocumentThumbnailQueryKey = (documentId: string, - params?: GetDocumentThumbnailParams,) => { - return [ - `/api/v1/documents/${documentId}/thumbnail`, ...(params ? [params] : []) - ] as const; - } - + method: 'GET', + }) +} -export const getGetDocumentThumbnailQueryOptions = >, TError = ApiError>(documentId: string, - params?: GetDocumentThumbnailParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export const getGetDocumentThumbnailQueryKey = ( + documentId: string, + params?: GetDocumentThumbnailParams, ) => { + return [ + `/api/v1/documents/${documentId}/thumbnail`, + ...(params ? [params] : []), + ] as const +} -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetDocumentThumbnailQueryKey(documentId,params); - - - - const queryFn: QueryFunction>> = ({ signal }) => getDocumentThumbnail(documentId,params, { signal, ...requestOptions }); - - - - - - return { queryKey, queryFn, enabled: !!(documentId), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getGetDocumentThumbnailQueryOptions = < + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + params?: GetDocumentThumbnailParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > + request?: SecondParameter + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = + queryOptions?.queryKey ?? + getGetDocumentThumbnailQueryKey(documentId, params) + + const queryFn: QueryFunction< + Awaited> + > = ({ signal }) => + getDocumentThumbnail(documentId, params, { signal, ...requestOptions }) + + return { + queryKey, + queryFn, + enabled: !!documentId, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag } } -export type GetDocumentThumbnailQueryResult = NonNullable>> +export type GetDocumentThumbnailQueryResult = NonNullable< + Awaited> +> export type GetDocumentThumbnailQueryError = ApiError - -export function useGetDocumentThumbnail>, TError = ApiError>( - documentId: string, - params: undefined | GetDocumentThumbnailParams, options: { query:Partial>, TError, TData>> & Pick< +export function useGetDocumentThumbnail< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + params: undefined | GetDocumentThumbnailParams, + options: { + query: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetDocumentThumbnail>, TError = ApiError>( - documentId: string, - params?: GetDocumentThumbnailParams, options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useGetDocumentThumbnail< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + params?: GetDocumentThumbnailParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetDocumentThumbnail>, TError = ApiError>( - documentId: string, - params?: GetDocumentThumbnailParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useGetDocumentThumbnail>, TError = ApiError>( - documentId: string, - params?: GetDocumentThumbnailParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetDocumentThumbnailQueryOptions(documentId,params,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return { ...query, queryKey: queryOptions.queryKey }; + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetDocumentThumbnail< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + params?: GetDocumentThumbnailParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag } - - - +export function useGetDocumentThumbnail< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + params?: GetDocumentThumbnailParams, + options?: { + query?: Partial< + UseQueryOptions< + Awaited>, + TError, + TData + > + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { + const queryOptions = getGetDocumentThumbnailQueryOptions( + documentId, + params, + options, + ) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } + + return { ...query, queryKey: queryOptions.queryKey } +} diff --git a/ui/lib/api/downloads/downloads.ts b/ui/lib/api/downloads/downloads.ts index b3d9912d1cde2e474be39f6a0407ad729063912a..1a66b63b45b26eb5b8de0b169dd4bf5de6851132 100644 --- a/ui/lib/api/downloads/downloads.ts +++ b/ui/lib/api/downloads/downloads.ts @@ -3,9 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useQuery -} from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query' import type { DataTag, DefinedInitialDataOptions, @@ -15,109 +13,146 @@ import type { QueryKey, UndefinedInitialDataOptions, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; - -import type { - DownloadState -} from '../schemas'; - -import { fetchApi } from '.././fetch'; + UseQueryResult, +} from '@tanstack/react-query' +import type { DownloadState } from '../schemas' -type SecondParameter unknown> = Parameters[1]; - +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] export const getListDownloadsUrl = () => { - - - - return `/api/v1/downloads` } -export const listDownloads = async ( options?: RequestInit): Promise => { - - return fetchApi(getListDownloadsUrl(), - { +export const listDownloads = async ( + options?: RequestInit, +): Promise => { + return fetchApi(getListDownloadsUrl(), { ...options, - method: 'GET' - - - } -);} - - - - + method: 'GET', + }) +} export const getListDownloadsQueryKey = () => { - return [ - `/api/v1/downloads` - ] as const; - } - - -export const getListDownloadsQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListDownloadsQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => listDownloads({ signal, ...requestOptions }); - - - - + return [`/api/v1/downloads`] as const +} - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getListDownloadsQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getListDownloadsQueryKey() + + const queryFn: QueryFunction>> = ({ + signal, + }) => listDownloads({ signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag } } -export type ListDownloadsQueryResult = NonNullable>> +export type ListDownloadsQueryResult = NonNullable< + Awaited> +> export type ListDownloadsQueryError = unknown - -export function useListDownloads>, TError = unknown>( - options: { query:Partial>, TError, TData>> & Pick< +export function useListDownloads< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useListDownloads>, TError = unknown>( - options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useListDownloads< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useListDownloads>, TError = unknown>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useListDownloads>, TError = unknown>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useListDownloads< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useListDownloads< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { const queryOptions = getListDownloadsQueryOptions(options) - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } - return { ...query, queryKey: queryOptions.queryKey }; + return { ...query, queryKey: queryOptions.queryKey } } - - - - diff --git a/ui/lib/api/exports/exports.ts b/ui/lib/api/exports/exports.ts index 48e7cdc70a038dcae55eae42ef22415ad6fd5f02..a0c04137e141af4f13dd02bdef0fb2108f60f41e 100644 --- a/ui/lib/api/exports/exports.ts +++ b/ui/lib/api/exports/exports.ts @@ -3,10 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query' import type { DataTag, DefinedInitialDataOptions, @@ -19,199 +16,283 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from '@tanstack/react-query' import type { ApiError, ExportBatchRequest, ExportDocumentParams, - ExportResult -} from '../schemas'; + ExportResult, +} from '../schemas' -import { fetchApi } from '.././fetch'; +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] -type SecondParameter unknown> = Parameters[1]; - - - -export const getExportDocumentUrl = (documentId: string, - format: string, - params?: ExportDocumentParams,) => { - const normalizedParams = new URLSearchParams(); +export const getExportDocumentUrl = ( + documentId: string, + format: string, + params?: ExportDocumentParams, +) => { + const normalizedParams = new URLSearchParams() Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } - }); + }) - const stringifiedParams = normalizedParams.toString(); + const stringifiedParams = normalizedParams.toString() - return stringifiedParams.length > 0 ? `/api/v1/documents/${documentId}/export/${format}?${stringifiedParams}` : `/api/v1/documents/${documentId}/export/${format}` + return stringifiedParams.length > 0 + ? `/api/v1/documents/${documentId}/export/${format}?${stringifiedParams}` + : `/api/v1/documents/${documentId}/export/${format}` } -export const exportDocument = async (documentId: string, - format: string, - params?: ExportDocumentParams, options?: RequestInit): Promise => { - - return fetchApi(getExportDocumentUrl(documentId,format,params), - { +export const exportDocument = async ( + documentId: string, + format: string, + params?: ExportDocumentParams, + options?: RequestInit, +): Promise => { + return fetchApi(getExportDocumentUrl(documentId, format, params), { ...options, - method: 'GET' - - - } -);} - - - - - -export const getExportDocumentQueryKey = (documentId: string, - format: string, - params?: ExportDocumentParams,) => { - return [ - `/api/v1/documents/${documentId}/export/${format}`, ...(params ? [params] : []) - ] as const; - } - + method: 'GET', + }) +} -export const getExportDocumentQueryOptions = >, TError = ApiError>(documentId: string, - format: string, - params?: ExportDocumentParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export const getExportDocumentQueryKey = ( + documentId: string, + format: string, + params?: ExportDocumentParams, ) => { + return [ + `/api/v1/documents/${documentId}/export/${format}`, + ...(params ? [params] : []), + ] as const +} -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getExportDocumentQueryKey(documentId,format,params); - - - - const queryFn: QueryFunction>> = ({ signal }) => exportDocument(documentId,format,params, { signal, ...requestOptions }); - - - - - - return { queryKey, queryFn, enabled: !!(documentId && format), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getExportDocumentQueryOptions = < + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + format: string, + params?: ExportDocumentParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, +) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = + queryOptions?.queryKey ?? + getExportDocumentQueryKey(documentId, format, params) + + const queryFn: QueryFunction>> = ({ + signal, + }) => + exportDocument(documentId, format, params, { signal, ...requestOptions }) + + return { + queryKey, + queryFn, + enabled: !!(documentId && format), + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag } } -export type ExportDocumentQueryResult = NonNullable>> +export type ExportDocumentQueryResult = NonNullable< + Awaited> +> export type ExportDocumentQueryError = ApiError - -export function useExportDocument>, TError = ApiError>( - documentId: string, - format: string, - params: undefined | ExportDocumentParams, options: { query:Partial>, TError, TData>> & Pick< +export function useExportDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + format: string, + params: undefined | ExportDocumentParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useExportDocument>, TError = ApiError>( - documentId: string, - format: string, - params?: ExportDocumentParams, options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useExportDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + format: string, + params?: ExportDocumentParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useExportDocument>, TError = ApiError>( - documentId: string, - format: string, - params?: ExportDocumentParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useExportDocument>, TError = ApiError>( - documentId: string, - format: string, - params?: ExportDocumentParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getExportDocumentQueryOptions(documentId,format,params,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return { ...query, queryKey: queryOptions.queryKey }; + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useExportDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + format: string, + params?: ExportDocumentParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag } - - +export function useExportDocument< + TData = Awaited>, + TError = ApiError, +>( + documentId: string, + format: string, + params?: ExportDocumentParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { + const queryOptions = getExportDocumentQueryOptions( + documentId, + format, + params, + options, + ) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } + + return { ...query, queryKey: queryOptions.queryKey } +} export const getBatchExportUrl = () => { - - - - return `/api/v1/exports` } -export const batchExport = async (exportBatchRequest: ExportBatchRequest, options?: RequestInit): Promise => { - - return fetchApi(getBatchExportUrl(), - { +export const batchExport = async ( + exportBatchRequest: ExportBatchRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getBatchExportUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - exportBatchRequest,) - } -);} - - - - -export const getBatchExportMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ExportBatchRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: ExportBatchRequest}, TContext> => { - -const mutationKey = ['batchExport']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {data: ExportBatchRequest}> = (props) => { - const {data} = props ?? {}; - - return batchExport(data,requestOptions) - } - - - - - + body: JSON.stringify(exportBatchRequest), + }) +} - return { mutationFn, ...mutationOptions }} +export const getBatchExportMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ExportBatchRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { data: ExportBatchRequest }, + TContext +> => { + const mutationKey = ['batchExport'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { data: ExportBatchRequest } + > = (props) => { + const { data } = props ?? {} + + return batchExport(data, requestOptions) + } - export type BatchExportMutationResult = NonNullable>> - export type BatchExportMutationBody = ExportBatchRequest - export type BatchExportMutationError = ApiError + return { mutationFn, ...mutationOptions } +} - export const useBatchExport = (options?: { mutation?:UseMutationOptions>, TError,{data: ExportBatchRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: ExportBatchRequest}, - TContext - > => { - return useMutation(getBatchExportMutationOptions(options), queryClient); - } +export type BatchExportMutationResult = NonNullable< + Awaited> +> +export type BatchExportMutationBody = ExportBatchRequest +export type BatchExportMutationError = ApiError + +export const useBatchExport = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: ExportBatchRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: ExportBatchRequest }, + TContext +> => { + return useMutation(getBatchExportMutationOptions(options), queryClient) +} diff --git a/ui/lib/api/fetch.ts b/ui/lib/api/fetch.ts index 949184fe011008359b064451a2f38732b9fb0052..c87a4a4c8d7cf1ca67154d6cd3acf118bd87eb28 100644 --- a/ui/lib/api/fetch.ts +++ b/ui/lib/api/fetch.ts @@ -1,7 +1,12 @@ -export const fetchApi = async (url: string, options?: RequestInit): Promise => { +export const fetchApi = async ( + url: string, + options?: RequestInit, +): Promise => { const res = await fetch(url, options) if (!res.ok) { - throw await res.json().catch(() => ({ status: res.status, message: res.statusText })) + throw await res + .json() + .catch(() => ({ status: res.status, message: res.statusText })) } if ([204, 205, 304].includes(res.status)) { return undefined as T diff --git a/ui/lib/api/jobs/jobs.ts b/ui/lib/api/jobs/jobs.ts index e311bee68cc9f279ac221bb46231b42d96ff8048..8a811ba89086967ba33e68e232b47d215d9d9050 100644 --- a/ui/lib/api/jobs/jobs.ts +++ b/ui/lib/api/jobs/jobs.ts @@ -3,10 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query' import type { DataTag, DefinedInitialDataOptions, @@ -19,330 +16,439 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from '@tanstack/react-query' -import type { - ApiError, - JobState, - PipelineJobRequest -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; +import type { ApiError, JobState, PipelineJobRequest } from '../schemas' +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] export const getListJobsUrl = () => { - - - - return `/api/v1/jobs` } -export const listJobs = async ( options?: RequestInit): Promise => { - - return fetchApi(getListJobsUrl(), - { +export const listJobs = async (options?: RequestInit): Promise => { + return fetchApi(getListJobsUrl(), { ...options, - method: 'GET' - - - } -);} - - - - + method: 'GET', + }) +} export const getListJobsQueryKey = () => { - return [ - `/api/v1/jobs` - ] as const; - } - - -export const getListJobsQueryOptions = >, TError = unknown>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListJobsQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => listJobs({ signal, ...requestOptions }); - - - - + return [`/api/v1/jobs`] as const +} - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getListJobsQueryOptions = < + TData = Awaited>, + TError = unknown, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getListJobsQueryKey() + + const queryFn: QueryFunction>> = ({ + signal, + }) => listJobs({ signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag + } } -export type ListJobsQueryResult = NonNullable>> +export type ListJobsQueryResult = NonNullable< + Awaited> +> export type ListJobsQueryError = unknown - -export function useListJobs>, TError = unknown>( - options: { query:Partial>, TError, TData>> & Pick< +export function useListJobs< + TData = Awaited>, + TError = unknown, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useListJobs>, TError = unknown>( - options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useListJobs< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useListJobs>, TError = unknown>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useListJobs>, TError = unknown>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useListJobs< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useListJobs< + TData = Awaited>, + TError = unknown, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { const queryOptions = getListJobsQueryOptions(options) - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } - return { ...query, queryKey: queryOptions.queryKey }; + return { ...query, queryKey: queryOptions.queryKey } } - - - export const getStartPipelineUrl = () => { - - - - return `/api/v1/jobs/pipeline` } -export const startPipeline = async (pipelineJobRequest: PipelineJobRequest, options?: RequestInit): Promise => { - - return fetchApi(getStartPipelineUrl(), - { +export const startPipeline = async ( + pipelineJobRequest: PipelineJobRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getStartPipelineUrl(), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - pipelineJobRequest,) - } -);} - - - - -export const getStartPipelineMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: PipelineJobRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: PipelineJobRequest}, TContext> => { - -const mutationKey = ['startPipeline']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {data: PipelineJobRequest}> = (props) => { - const {data} = props ?? {}; - - return startPipeline(data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type StartPipelineMutationResult = NonNullable>> - export type StartPipelineMutationBody = PipelineJobRequest - export type StartPipelineMutationError = ApiError - - export const useStartPipeline = (options?: { mutation?:UseMutationOptions>, TError,{data: PipelineJobRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: PipelineJobRequest}, - TContext - > => { - return useMutation(getStartPipelineMutationOptions(options), queryClient); - } - export const getGetJobUrl = (jobId: string,) => { - + body: JSON.stringify(pipelineJobRequest), + }) +} +export const getStartPipelineMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: PipelineJobRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { data: PipelineJobRequest }, + TContext +> => { + const mutationKey = ['startPipeline'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { data: PipelineJobRequest } + > = (props) => { + const { data } = props ?? {} + + return startPipeline(data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type StartPipelineMutationResult = NonNullable< + Awaited> +> +export type StartPipelineMutationBody = PipelineJobRequest +export type StartPipelineMutationError = ApiError + +export const useStartPipeline = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: PipelineJobRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: PipelineJobRequest }, + TContext +> => { + return useMutation(getStartPipelineMutationOptions(options), queryClient) +} +export const getGetJobUrl = (jobId: string) => { return `/api/v1/jobs/${jobId}` } -export const getJob = async (jobId: string, options?: RequestInit): Promise => { - - return fetchApi(getGetJobUrl(jobId), - { +export const getJob = async ( + jobId: string, + options?: RequestInit, +): Promise => { + return fetchApi(getGetJobUrl(jobId), { ...options, - method: 'GET' - - - } -);} - - - - - -export const getGetJobQueryKey = (jobId: string,) => { - return [ - `/api/v1/jobs/${jobId}` - ] as const; - } + method: 'GET', + }) +} +export const getGetJobQueryKey = (jobId: string) => { + return [`/api/v1/jobs/${jobId}`] as const +} -export const getGetJobQueryOptions = >, TError = ApiError>(jobId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export const getGetJobQueryOptions = < + TData = Awaited>, + TError = ApiError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, ) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(jobId); - - - - const queryFn: QueryFunction>> = ({ signal }) => getJob(jobId, { signal, ...requestOptions }); - - - - - - return { queryKey, queryFn, enabled: !!(jobId), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetJobQueryKey(jobId) + + const queryFn: QueryFunction>> = ({ + signal, + }) => getJob(jobId, { signal, ...requestOptions }) + + return { + queryKey, + queryFn, + enabled: !!jobId, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag + } } export type GetJobQueryResult = NonNullable>> export type GetJobQueryError = ApiError - -export function useGetJob>, TError = ApiError>( - jobId: string, options: { query:Partial>, TError, TData>> & Pick< +export function useGetJob< + TData = Awaited>, + TError = ApiError, +>( + jobId: string, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetJob>, TError = ApiError>( - jobId: string, options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useGetJob< + TData = Awaited>, + TError = ApiError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetJob>, TError = ApiError>( - jobId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useGetJob>, TError = ApiError>( - jobId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetJobQueryOptions(jobId,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return { ...query, queryKey: queryOptions.queryKey }; + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetJob< + TData = Awaited>, + TError = ApiError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag } +export function useGetJob< + TData = Awaited>, + TError = ApiError, +>( + jobId: string, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { + const queryOptions = getGetJobQueryOptions(jobId, options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } + + return { ...query, queryKey: queryOptions.queryKey } +} - - -export const getCancelJobUrl = (jobId: string,) => { - - - - +export const getCancelJobUrl = (jobId: string) => { return `/api/v1/jobs/${jobId}` } -export const cancelJob = async (jobId: string, options?: RequestInit): Promise => { - - return fetchApi(getCancelJobUrl(jobId), - { +export const cancelJob = async ( + jobId: string, + options?: RequestInit, +): Promise => { + return fetchApi(getCancelJobUrl(jobId), { ...options, - method: 'DELETE' - + method: 'DELETE', + }) +} +export const getCancelJobMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { jobId: string }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { jobId: string }, + TContext +> => { + const mutationKey = ['cancelJob'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { jobId: string } + > = (props) => { + const { jobId } = props ?? {} + + return cancelJob(jobId, requestOptions) } -);} - - - - -export const getCancelJobMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{jobId: string}, TContext> => { -const mutationKey = ['cancelJob']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {jobId: string}> = (props) => { - const {jobId} = props ?? {}; - - return cancelJob(jobId,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type CancelJobMutationResult = NonNullable>> - - export type CancelJobMutationError = ApiError + return { mutationFn, ...mutationOptions } +} - export const useCancelJob = (options?: { mutation?:UseMutationOptions>, TError,{jobId: string}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {jobId: string}, - TContext - > => { - return useMutation(getCancelJobMutationOptions(options), queryClient); - } +export type CancelJobMutationResult = NonNullable< + Awaited> +> + +export type CancelJobMutationError = ApiError + +export const useCancelJob = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { jobId: string }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { jobId: string }, + TContext +> => { + return useMutation(getCancelJobMutationOptions(options), queryClient) +} diff --git a/ui/lib/api/llm/llm.ts b/ui/lib/api/llm/llm.ts index f1791fab276051832253d7a72bc334f11355a6ad..12997abb5985e9d70b425e7a481e685895df6f11 100644 --- a/ui/lib/api/llm/llm.ts +++ b/ui/lib/api/llm/llm.ts @@ -3,10 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query' import type { DataTag, DefinedInitialDataOptions, @@ -19,405 +16,452 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from '@tanstack/react-query' import type { ApiError, - ListLlmModelsParams, + GetLlmCatalogParams, + LlmCatalog, LlmLoadRequest, - LlmModelInfo, - LlmPingRequest, - LlmPingResponse, - LlmState -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; + LlmState, +} from '../schemas' +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] export const getGetLlmUrl = () => { - - - - return `/api/v1/llm` } -export const getLlm = async ( options?: RequestInit): Promise => { - - return fetchApi(getGetLlmUrl(), - { +export const getLlm = async (options?: RequestInit): Promise => { + return fetchApi(getGetLlmUrl(), { ...options, - method: 'GET' - - - } -);} - - - - + method: 'GET', + }) +} export const getGetLlmQueryKey = () => { - return [ - `/api/v1/llm` - ] as const; - } - - -export const getGetLlmQueryOptions = >, TError = ApiError>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetLlmQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => getLlm({ signal, ...requestOptions }); - - - - + return [`/api/v1/llm`] as const +} - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getGetLlmQueryOptions = < + TData = Awaited>, + TError = ApiError, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetLlmQueryKey() + + const queryFn: QueryFunction>> = ({ + signal, + }) => getLlm({ signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag + } } export type GetLlmQueryResult = NonNullable>> export type GetLlmQueryError = ApiError - -export function useGetLlm>, TError = ApiError>( - options: { query:Partial>, TError, TData>> & Pick< +export function useGetLlm< + TData = Awaited>, + TError = ApiError, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetLlm>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useGetLlm< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetLlm>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useGetLlm>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetLlm< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetLlm< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { const queryOptions = getGetLlmQueryOptions(options) - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } - return { ...query, queryKey: queryOptions.queryKey }; + return { ...query, queryKey: queryOptions.queryKey } } - - - export const getLoadLlmUrl = () => { - - - - return `/api/v1/llm` } -export const loadLlm = async (llmLoadRequest: LlmLoadRequest, options?: RequestInit): Promise => { - - return fetchApi(getLoadLlmUrl(), - { +export const loadLlm = async ( + llmLoadRequest: LlmLoadRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getLoadLlmUrl(), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - llmLoadRequest,) - } -);} - - - - -export const getLoadLlmMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: LlmLoadRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: LlmLoadRequest}, TContext> => { - -const mutationKey = ['loadLlm']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {data: LlmLoadRequest}> = (props) => { - const {data} = props ?? {}; - - return loadLlm(data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type LoadLlmMutationResult = NonNullable>> - export type LoadLlmMutationBody = LlmLoadRequest - export type LoadLlmMutationError = ApiError - - export const useLoadLlm = (options?: { mutation?:UseMutationOptions>, TError,{data: LlmLoadRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: LlmLoadRequest}, - TContext - > => { - return useMutation(getLoadLlmMutationOptions(options), queryClient); - } - export const getUnloadLlmUrl = () => { - + body: JSON.stringify(llmLoadRequest), + }) +} +export const getLoadLlmMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: LlmLoadRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { data: LlmLoadRequest }, + TContext +> => { + const mutationKey = ['loadLlm'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { data: LlmLoadRequest } + > = (props) => { + const { data } = props ?? {} + + return loadLlm(data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type LoadLlmMutationResult = NonNullable< + Awaited> +> +export type LoadLlmMutationBody = LlmLoadRequest +export type LoadLlmMutationError = ApiError + +export const useLoadLlm = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: LlmLoadRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: LlmLoadRequest }, + TContext +> => { + return useMutation(getLoadLlmMutationOptions(options), queryClient) +} +export const getUnloadLlmUrl = () => { return `/api/v1/llm` } -export const unloadLlm = async ( options?: RequestInit): Promise => { - - return fetchApi(getUnloadLlmUrl(), - { +export const unloadLlm = async (options?: RequestInit): Promise => { + return fetchApi(getUnloadLlmUrl(), { ...options, - method: 'DELETE' - - - } -);} - - - - -export const getUnloadLlmMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,void, TContext> => { - -const mutationKey = ['unloadLlm']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, void> = () => { - - - return unloadLlm(requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type UnloadLlmMutationResult = NonNullable>> - - export type UnloadLlmMutationError = ApiError - - export const useUnloadLlm = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - void, - TContext - > => { - return useMutation(getUnloadLlmMutationOptions(options), queryClient); - } - export const getCheckLlmHealthUrl = () => { - - - - - return `/api/v1/llm/health` + method: 'DELETE', + }) } -export const checkLlmHealth = async (llmPingRequest: LlmPingRequest, options?: RequestInit): Promise => { - - return fetchApi(getCheckLlmHealthUrl(), - { - ...options, - method: 'POST', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - llmPingRequest,) +export const getUnloadLlmMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + void, + TContext +> => { + const mutationKey = ['unloadLlm'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + void + > = () => { + return unloadLlm(requestOptions) } -);} - - - - -export const getCheckLlmHealthMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: LlmPingRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: LlmPingRequest}, TContext> => { - -const mutationKey = ['checkLlmHealth']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {data: LlmPingRequest}> = (props) => { - const {data} = props ?? {}; - - return checkLlmHealth(data,requestOptions) - } - - - + return { mutationFn, ...mutationOptions } +} - - return { mutationFn, ...mutationOptions }} - - export type CheckLlmHealthMutationResult = NonNullable>> - export type CheckLlmHealthMutationBody = LlmPingRequest - export type CheckLlmHealthMutationError = unknown - - export const useCheckLlmHealth = (options?: { mutation?:UseMutationOptions>, TError,{data: LlmPingRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: LlmPingRequest}, - TContext - > => { - return useMutation(getCheckLlmHealthMutationOptions(options), queryClient); - } - export const getListLlmModelsUrl = (params?: ListLlmModelsParams,) => { - const normalizedParams = new URLSearchParams(); +export type UnloadLlmMutationResult = NonNullable< + Awaited> +> + +export type UnloadLlmMutationError = ApiError + +export const useUnloadLlm = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + void, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + void, + TContext +> => { + return useMutation(getUnloadLlmMutationOptions(options), queryClient) +} +export const getGetLlmCatalogUrl = (params?: GetLlmCatalogParams) => { + const normalizedParams = new URLSearchParams() Object.entries(params || {}).forEach(([key, value]) => { - if (value !== undefined) { normalizedParams.append(key, value === null ? 'null' : value.toString()) } - }); + }) - const stringifiedParams = normalizedParams.toString(); + const stringifiedParams = normalizedParams.toString() - return stringifiedParams.length > 0 ? `/api/v1/llm/models?${stringifiedParams}` : `/api/v1/llm/models` + return stringifiedParams.length > 0 + ? `/api/v1/llm/catalog?${stringifiedParams}` + : `/api/v1/llm/catalog` } -export const listLlmModels = async (params?: ListLlmModelsParams, options?: RequestInit): Promise => { - - return fetchApi(getListLlmModelsUrl(params), - { +export const getLlmCatalog = async ( + params?: GetLlmCatalogParams, + options?: RequestInit, +): Promise => { + return fetchApi(getGetLlmCatalogUrl(params), { ...options, - method: 'GET' - - - } -);} - - - - - -export const getListLlmModelsQueryKey = (params?: ListLlmModelsParams,) => { - return [ - `/api/v1/llm/models`, ...(params ? [params] : []) - ] as const; - } + method: 'GET', + }) +} +export const getGetLlmCatalogQueryKey = (params?: GetLlmCatalogParams) => { + return [`/api/v1/llm/catalog`, ...(params ? [params] : [])] as const +} -export const getListLlmModelsQueryOptions = >, TError = ApiError>(params?: ListLlmModelsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +export const getGetLlmCatalogQueryOptions = < + TData = Awaited>, + TError = ApiError, +>( + params?: GetLlmCatalogParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, ) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListLlmModelsQueryKey(params); - - - - const queryFn: QueryFunction>> = ({ signal }) => listLlmModels(params, { signal, ...requestOptions }); - - - - - - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetLlmCatalogQueryKey(params) + + const queryFn: QueryFunction>> = ({ + signal, + }) => getLlmCatalog(params, { signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions< + Awaited>, + TError, + TData + > & { queryKey: DataTag } } -export type ListLlmModelsQueryResult = NonNullable>> -export type ListLlmModelsQueryError = ApiError - - -export function useListLlmModels>, TError = ApiError>( - params: undefined | ListLlmModelsParams, options: { query:Partial>, TError, TData>> & Pick< +export type GetLlmCatalogQueryResult = NonNullable< + Awaited> +> +export type GetLlmCatalogQueryError = ApiError + +export function useGetLlmCatalog< + TData = Awaited>, + TError = ApiError, +>( + params: undefined | GetLlmCatalogParams, + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< - Awaited>, + Awaited>, TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useListLlmModels>, TError = ApiError>( - params?: ListLlmModelsParams, options?: { query?:Partial>, TError, TData>> & Pick< + Awaited> + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useGetLlmCatalog< + TData = Awaited>, + TError = ApiError, +>( + params?: GetLlmCatalogParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< - Awaited>, + Awaited>, TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useListLlmModels>, TError = ApiError>( - params?: ListLlmModelsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useListLlmModels>, TError = ApiError>( - params?: ListLlmModelsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getListLlmModelsQueryOptions(params,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return { ...query, queryKey: queryOptions.queryKey }; + Awaited> + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetLlmCatalog< + TData = Awaited>, + TError = ApiError, +>( + params?: GetLlmCatalogParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag } - - - +export function useGetLlmCatalog< + TData = Awaited>, + TError = ApiError, +>( + params?: GetLlmCatalogParams, + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { + const queryOptions = getGetLlmCatalogQueryOptions(params, options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } + + return { ...query, queryKey: queryOptions.queryKey } +} diff --git a/ui/lib/api/processing/processing.ts b/ui/lib/api/processing/processing.ts index 3eeefb10b7bc05a91188b931a6455972ac7c6b08..ce581b274e4faf09eb4eee037ffb4fb41b06e819 100644 --- a/ui/lib/api/processing/processing.ts +++ b/ui/lib/api/processing/processing.ts @@ -3,345 +3,408 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation -} from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query' import type { MutationFunction, QueryClient, UseMutationOptions, - UseMutationResult -} from '@tanstack/react-query'; - -import type { - ApiError, - RenderRequest, - TranslateRequest -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; - - - -export const getDetectDocumentUrl = (documentId: string,) => { + UseMutationResult, +} from '@tanstack/react-query' +import type { ApiError, RenderRequest, TranslateRequest } from '../schemas' +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] +export const getDetectDocumentUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/detect` } -export const detectDocument = async (documentId: string, options?: RequestInit): Promise => { - - return fetchApi(getDetectDocumentUrl(documentId), - { +export const detectDocument = async ( + documentId: string, + options?: RequestInit, +): Promise => { + return fetchApi(getDetectDocumentUrl(documentId), { ...options, - method: 'POST' - + method: 'POST', + }) +} +export const getDetectDocumentMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext +> => { + const mutationKey = ['detectDocument'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string } + > = (props) => { + const { documentId } = props ?? {} + + return detectDocument(documentId, requestOptions) } -);} - - - - -export const getDetectDocumentMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string}, TContext> => { - -const mutationKey = ['detectDocument']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string}> = (props) => { - const {documentId} = props ?? {}; - - return detectDocument(documentId,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type DetectDocumentMutationResult = NonNullable>> - - export type DetectDocumentMutationError = ApiError - - export const useDetectDocument = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string}, - TContext - > => { - return useMutation(getDetectDocumentMutationOptions(options), queryClient); - } - export const getInpaintDocumentUrl = (documentId: string,) => { - - + return { mutationFn, ...mutationOptions } +} +export type DetectDocumentMutationResult = NonNullable< + Awaited> +> + +export type DetectDocumentMutationError = ApiError + +export const useDetectDocument = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string }, + TContext +> => { + return useMutation(getDetectDocumentMutationOptions(options), queryClient) +} +export const getInpaintDocumentUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/inpaint` } -export const inpaintDocument = async (documentId: string, options?: RequestInit): Promise => { - - return fetchApi(getInpaintDocumentUrl(documentId), - { +export const inpaintDocument = async ( + documentId: string, + options?: RequestInit, +): Promise => { + return fetchApi(getInpaintDocumentUrl(documentId), { ...options, - method: 'POST' - + method: 'POST', + }) +} +export const getInpaintDocumentMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext +> => { + const mutationKey = ['inpaintDocument'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string } + > = (props) => { + const { documentId } = props ?? {} + + return inpaintDocument(documentId, requestOptions) } -);} - - - - -export const getInpaintDocumentMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string}, TContext> => { - -const mutationKey = ['inpaintDocument']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string}> = (props) => { - const {documentId} = props ?? {}; - - return inpaintDocument(documentId,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type InpaintDocumentMutationResult = NonNullable>> - - export type InpaintDocumentMutationError = ApiError - - export const useInpaintDocument = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string}, - TContext - > => { - return useMutation(getInpaintDocumentMutationOptions(options), queryClient); - } - export const getRecognizeDocumentUrl = (documentId: string,) => { - - + return { mutationFn, ...mutationOptions } +} +export type InpaintDocumentMutationResult = NonNullable< + Awaited> +> + +export type InpaintDocumentMutationError = ApiError + +export const useInpaintDocument = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string }, + TContext +> => { + return useMutation(getInpaintDocumentMutationOptions(options), queryClient) +} +export const getRecognizeDocumentUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/recognize` } -export const recognizeDocument = async (documentId: string, options?: RequestInit): Promise => { - - return fetchApi(getRecognizeDocumentUrl(documentId), - { +export const recognizeDocument = async ( + documentId: string, + options?: RequestInit, +): Promise => { + return fetchApi(getRecognizeDocumentUrl(documentId), { ...options, - method: 'POST' - + method: 'POST', + }) +} +export const getRecognizeDocumentMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext +> => { + const mutationKey = ['recognizeDocument'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string } + > = (props) => { + const { documentId } = props ?? {} + + return recognizeDocument(documentId, requestOptions) } -);} - - - - -export const getRecognizeDocumentMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string}, TContext> => { - -const mutationKey = ['recognizeDocument']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string}> = (props) => { - const {documentId} = props ?? {}; - - return recognizeDocument(documentId,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type RecognizeDocumentMutationResult = NonNullable>> - - export type RecognizeDocumentMutationError = ApiError - - export const useRecognizeDocument = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string}, - TContext - > => { - return useMutation(getRecognizeDocumentMutationOptions(options), queryClient); - } - export const getRenderDocumentUrl = (documentId: string,) => { - - + return { mutationFn, ...mutationOptions } +} +export type RecognizeDocumentMutationResult = NonNullable< + Awaited> +> + +export type RecognizeDocumentMutationError = ApiError + +export const useRecognizeDocument = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string }, + TContext +> => { + return useMutation(getRecognizeDocumentMutationOptions(options), queryClient) +} +export const getRenderDocumentUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/render` } -export const renderDocument = async (documentId: string, - renderRequest: RenderRequest, options?: RequestInit): Promise => { - - return fetchApi(getRenderDocumentUrl(documentId), - { +export const renderDocument = async ( + documentId: string, + renderRequest: RenderRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getRenderDocumentUrl(documentId), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - renderRequest,) - } -);} - - - - -export const getRenderDocumentMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: RenderRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;data: RenderRequest}, TContext> => { - -const mutationKey = ['renderDocument']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;data: RenderRequest}> = (props) => { - const {documentId,data} = props ?? {}; - - return renderDocument(documentId,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type RenderDocumentMutationResult = NonNullable>> - export type RenderDocumentMutationBody = RenderRequest - export type RenderDocumentMutationError = ApiError - - export const useRenderDocument = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: RenderRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;data: RenderRequest}, - TContext - > => { - return useMutation(getRenderDocumentMutationOptions(options), queryClient); - } - export const getTranslateDocumentUrl = (documentId: string,) => { - + body: JSON.stringify(renderRequest), + }) +} +export const getRenderDocumentMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: RenderRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: RenderRequest }, + TContext +> => { + const mutationKey = ['renderDocument'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; data: RenderRequest } + > = (props) => { + const { documentId, data } = props ?? {} + + return renderDocument(documentId, data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type RenderDocumentMutationResult = NonNullable< + Awaited> +> +export type RenderDocumentMutationBody = RenderRequest +export type RenderDocumentMutationError = ApiError + +export const useRenderDocument = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: RenderRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; data: RenderRequest }, + TContext +> => { + return useMutation(getRenderDocumentMutationOptions(options), queryClient) +} +export const getTranslateDocumentUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/translate` } -export const translateDocument = async (documentId: string, - translateRequest: TranslateRequest, options?: RequestInit): Promise => { - - return fetchApi(getTranslateDocumentUrl(documentId), - { +export const translateDocument = async ( + documentId: string, + translateRequest: TranslateRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getTranslateDocumentUrl(documentId), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - translateRequest,) - } -);} - - - - -export const getTranslateDocumentMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: TranslateRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;data: TranslateRequest}, TContext> => { - -const mutationKey = ['translateDocument']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;data: TranslateRequest}> = (props) => { - const {documentId,data} = props ?? {}; - - return translateDocument(documentId,data,requestOptions) - } - - - - - + body: JSON.stringify(translateRequest), + }) +} - return { mutationFn, ...mutationOptions }} +export const getTranslateDocumentMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: TranslateRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: TranslateRequest }, + TContext +> => { + const mutationKey = ['translateDocument'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; data: TranslateRequest } + > = (props) => { + const { documentId, data } = props ?? {} + + return translateDocument(documentId, data, requestOptions) + } - export type TranslateDocumentMutationResult = NonNullable>> - export type TranslateDocumentMutationBody = TranslateRequest - export type TranslateDocumentMutationError = ApiError + return { mutationFn, ...mutationOptions } +} - export const useTranslateDocument = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: TranslateRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;data: TranslateRequest}, - TContext - > => { - return useMutation(getTranslateDocumentMutationOptions(options), queryClient); - } +export type TranslateDocumentMutationResult = NonNullable< + Awaited> +> +export type TranslateDocumentMutationBody = TranslateRequest +export type TranslateDocumentMutationError = ApiError + +export const useTranslateDocument = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: TranslateRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; data: TranslateRequest }, + TContext +> => { + return useMutation(getTranslateDocumentMutationOptions(options), queryClient) +} diff --git a/ui/lib/api/providers/providers.ts b/ui/lib/api/providers/providers.ts deleted file mode 100644 index 9ff43269e8857a5c4acd36a41af7fa0631899fe5..0000000000000000000000000000000000000000 --- a/ui/lib/api/providers/providers.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Generated by orval v8.6.2 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.1 - */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; -import type { - DataTag, - DefinedInitialDataOptions, - DefinedUseQueryResult, - MutationFunction, - QueryClient, - QueryFunction, - QueryKey, - UndefinedInitialDataOptions, - UseMutationOptions, - UseMutationResult, - UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; - -import type { - ApiError, - ApiKeyResponse, - ApiKeyValue -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; - - - -export const getGetApiKeyUrl = (provider: string,) => { - - - - - return `/api/v1/providers/${provider}/api-key` -} - -export const getApiKey = async (provider: string, options?: RequestInit): Promise => { - - return fetchApi(getGetApiKeyUrl(provider), - { - ...options, - method: 'GET' - - - } -);} - - - - - -export const getGetApiKeyQueryKey = (provider: string,) => { - return [ - `/api/v1/providers/${provider}/api-key` - ] as const; - } - - -export const getGetApiKeyQueryOptions = >, TError = ApiError>(provider: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetApiKeyQueryKey(provider); - - - - const queryFn: QueryFunction>> = ({ signal }) => getApiKey(provider, { signal, ...requestOptions }); - - - - - - return { queryKey, queryFn, enabled: !!(provider), gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } -} - -export type GetApiKeyQueryResult = NonNullable>> -export type GetApiKeyQueryError = ApiError - - -export function useGetApiKey>, TError = ApiError>( - provider: string, options: { query:Partial>, TError, TData>> & Pick< - DefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetApiKey>, TError = ApiError>( - provider: string, options?: { query?:Partial>, TError, TData>> & Pick< - UndefinedInitialDataOptions< - Awaited>, - TError, - Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetApiKey>, TError = ApiError>( - provider: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useGetApiKey>, TError = ApiError>( - provider: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getGetApiKeyQueryOptions(provider,options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return { ...query, queryKey: queryOptions.queryKey }; -} - - - - -export const getSetApiKeyUrl = (provider: string,) => { - - - - - return `/api/v1/providers/${provider}/api-key` -} - -export const setApiKey = async (provider: string, - apiKeyValue: ApiKeyValue, options?: RequestInit): Promise => { - - return fetchApi(getSetApiKeyUrl(provider), - { - ...options, - method: 'PUT', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - apiKeyValue,) - } -);} - - - - -export const getSetApiKeyMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{provider: string;data: ApiKeyValue}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{provider: string;data: ApiKeyValue}, TContext> => { - -const mutationKey = ['setApiKey']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {provider: string;data: ApiKeyValue}> = (props) => { - const {provider,data} = props ?? {}; - - return setApiKey(provider,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type SetApiKeyMutationResult = NonNullable>> - export type SetApiKeyMutationBody = ApiKeyValue - export type SetApiKeyMutationError = ApiError - - export const useSetApiKey = (options?: { mutation?:UseMutationOptions>, TError,{provider: string;data: ApiKeyValue}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {provider: string;data: ApiKeyValue}, - TContext - > => { - return useMutation(getSetApiKeyMutationOptions(options), queryClient); - } diff --git a/ui/lib/api/regions/regions.ts b/ui/lib/api/regions/regions.ts index f16690369821d03539aeb62029a6115d82e3d65b..57d1cc7c0b5cd1b085107fe6caa793e6578c2967 100644 --- a/ui/lib/api/regions/regions.ts +++ b/ui/lib/api/regions/regions.ts @@ -3,222 +3,262 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation -} from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query' import type { MutationFunction, QueryClient, UseMutationOptions, - UseMutationResult -} from '@tanstack/react-query'; + UseMutationResult, +} from '@tanstack/react-query' import type { ApiError, BrushRegionRequest, InpaintRegionRequest, - MaskRegionRequest -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; - - - -export const getUpdateBrushLayerUrl = (documentId: string,) => { - + MaskRegionRequest, +} from '../schemas' +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] +export const getUpdateBrushLayerUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/brush-layer` } -export const updateBrushLayer = async (documentId: string, - brushRegionRequest: BrushRegionRequest, options?: RequestInit): Promise => { - - return fetchApi(getUpdateBrushLayerUrl(documentId), - { +export const updateBrushLayer = async ( + documentId: string, + brushRegionRequest: BrushRegionRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getUpdateBrushLayerUrl(documentId), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - brushRegionRequest,) - } -);} - - - - -export const getUpdateBrushLayerMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: BrushRegionRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;data: BrushRegionRequest}, TContext> => { - -const mutationKey = ['updateBrushLayer']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;data: BrushRegionRequest}> = (props) => { - const {documentId,data} = props ?? {}; - - return updateBrushLayer(documentId,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type UpdateBrushLayerMutationResult = NonNullable>> - export type UpdateBrushLayerMutationBody = BrushRegionRequest - export type UpdateBrushLayerMutationError = ApiError - - export const useUpdateBrushLayer = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: BrushRegionRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;data: BrushRegionRequest}, - TContext - > => { - return useMutation(getUpdateBrushLayerMutationOptions(options), queryClient); - } - export const getInpaintRegionUrl = (documentId: string,) => { - + body: JSON.stringify(brushRegionRequest), + }) +} +export const getUpdateBrushLayerMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: BrushRegionRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: BrushRegionRequest }, + TContext +> => { + const mutationKey = ['updateBrushLayer'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; data: BrushRegionRequest } + > = (props) => { + const { documentId, data } = props ?? {} + + return updateBrushLayer(documentId, data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type UpdateBrushLayerMutationResult = NonNullable< + Awaited> +> +export type UpdateBrushLayerMutationBody = BrushRegionRequest +export type UpdateBrushLayerMutationError = ApiError + +export const useUpdateBrushLayer = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: BrushRegionRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; data: BrushRegionRequest }, + TContext +> => { + return useMutation(getUpdateBrushLayerMutationOptions(options), queryClient) +} +export const getInpaintRegionUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/inpaint-region` } -export const inpaintRegion = async (documentId: string, - inpaintRegionRequest: InpaintRegionRequest, options?: RequestInit): Promise => { - - return fetchApi(getInpaintRegionUrl(documentId), - { +export const inpaintRegion = async ( + documentId: string, + inpaintRegionRequest: InpaintRegionRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getInpaintRegionUrl(documentId), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - inpaintRegionRequest,) - } -);} - - - - -export const getInpaintRegionMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: InpaintRegionRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;data: InpaintRegionRequest}, TContext> => { - -const mutationKey = ['inpaintRegion']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;data: InpaintRegionRequest}> = (props) => { - const {documentId,data} = props ?? {}; - - return inpaintRegion(documentId,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type InpaintRegionMutationResult = NonNullable>> - export type InpaintRegionMutationBody = InpaintRegionRequest - export type InpaintRegionMutationError = ApiError - - export const useInpaintRegion = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: InpaintRegionRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;data: InpaintRegionRequest}, - TContext - > => { - return useMutation(getInpaintRegionMutationOptions(options), queryClient); - } - export const getUpdateMaskUrl = (documentId: string,) => { - + body: JSON.stringify(inpaintRegionRequest), + }) +} +export const getInpaintRegionMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: InpaintRegionRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: InpaintRegionRequest }, + TContext +> => { + const mutationKey = ['inpaintRegion'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; data: InpaintRegionRequest } + > = (props) => { + const { documentId, data } = props ?? {} + + return inpaintRegion(documentId, data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type InpaintRegionMutationResult = NonNullable< + Awaited> +> +export type InpaintRegionMutationBody = InpaintRegionRequest +export type InpaintRegionMutationError = ApiError + +export const useInpaintRegion = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: InpaintRegionRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; data: InpaintRegionRequest }, + TContext +> => { + return useMutation(getInpaintRegionMutationOptions(options), queryClient) +} +export const getUpdateMaskUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/mask` } -export const updateMask = async (documentId: string, - maskRegionRequest: MaskRegionRequest, options?: RequestInit): Promise => { - - return fetchApi(getUpdateMaskUrl(documentId), - { +export const updateMask = async ( + documentId: string, + maskRegionRequest: MaskRegionRequest, + options?: RequestInit, +): Promise => { + return fetchApi(getUpdateMaskUrl(documentId), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - maskRegionRequest,) - } -);} - - - - -export const getUpdateMaskMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: MaskRegionRequest}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;data: MaskRegionRequest}, TContext> => { - -const mutationKey = ['updateMask']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;data: MaskRegionRequest}> = (props) => { - const {documentId,data} = props ?? {}; - - return updateMask(documentId,data,requestOptions) - } - - - - - + body: JSON.stringify(maskRegionRequest), + }) +} - return { mutationFn, ...mutationOptions }} +export const getUpdateMaskMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: MaskRegionRequest }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: MaskRegionRequest }, + TContext +> => { + const mutationKey = ['updateMask'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; data: MaskRegionRequest } + > = (props) => { + const { documentId, data } = props ?? {} + + return updateMask(documentId, data, requestOptions) + } - export type UpdateMaskMutationResult = NonNullable>> - export type UpdateMaskMutationBody = MaskRegionRequest - export type UpdateMaskMutationError = ApiError + return { mutationFn, ...mutationOptions } +} - export const useUpdateMask = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: MaskRegionRequest}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;data: MaskRegionRequest}, - TContext - > => { - return useMutation(getUpdateMaskMutationOptions(options), queryClient); - } +export type UpdateMaskMutationResult = NonNullable< + Awaited> +> +export type UpdateMaskMutationBody = MaskRegionRequest +export type UpdateMaskMutationError = ApiError + +export const useUpdateMask = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: MaskRegionRequest }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; data: MaskRegionRequest }, + TContext +> => { + return useMutation(getUpdateMaskMutationOptions(options), queryClient) +} diff --git a/ui/lib/api/schemas/apiError.ts b/ui/lib/api/schemas/apiError.ts index 05b9a2ac7b3c87316e9a39fdc5ba9c6fbfa46b93..95964396b410d78b53f56f1417889462da932709 100644 --- a/ui/lib/api/schemas/apiError.ts +++ b/ui/lib/api/schemas/apiError.ts @@ -5,7 +5,7 @@ */ export interface ApiError { - message: string; + message: string /** @minimum 0 */ - status: number; + status: number } diff --git a/ui/lib/api/schemas/appConfig.ts b/ui/lib/api/schemas/appConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..3bfc57c3fa8adad25892f29c465e1597b32adef8 --- /dev/null +++ b/ui/lib/api/schemas/appConfig.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { AppDataConfig } from './appDataConfig' +import type { AppLlmConfig } from './appLlmConfig' + +export interface AppConfig { + data: AppDataConfig + llm?: AppLlmConfig +} diff --git a/ui/lib/api/schemas/appConfigUpdate.ts b/ui/lib/api/schemas/appConfigUpdate.ts new file mode 100644 index 0000000000000000000000000000000000000000..6689bb0b2ac80297441f5417965e67e98b70969d --- /dev/null +++ b/ui/lib/api/schemas/appConfigUpdate.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { AppDataConfigUpdate } from './appDataConfigUpdate' +import type { AppLlmConfigUpdate } from './appLlmConfigUpdate' + +export interface AppConfigUpdate { + data: AppDataConfigUpdate + llm?: AppLlmConfigUpdate +} diff --git a/ui/lib/api/schemas/apiKeyValue.ts b/ui/lib/api/schemas/appDataConfig.ts similarity index 67% rename from ui/lib/api/schemas/apiKeyValue.ts rename to ui/lib/api/schemas/appDataConfig.ts index 578315fbf781dfb352842fdd3e9563e0e2380fc9..aa7784f5f2d9c3e61302c534509a7d2dbc03440d 100644 --- a/ui/lib/api/schemas/apiKeyValue.ts +++ b/ui/lib/api/schemas/appDataConfig.ts @@ -4,6 +4,6 @@ * OpenAPI spec version: 0.0.1 */ -export interface ApiKeyValue { - apiKey: string; +export interface AppDataConfig { + path: string } diff --git a/ui/lib/api/schemas/bootstrapPathConfig.ts b/ui/lib/api/schemas/appDataConfigUpdate.ts similarity index 64% rename from ui/lib/api/schemas/bootstrapPathConfig.ts rename to ui/lib/api/schemas/appDataConfigUpdate.ts index ab781afa5d8d2717c46b637a9735e6a679db5b1f..52f0bf883eccd0b7355d82e3ac0270b047e6e412 100644 --- a/ui/lib/api/schemas/bootstrapPathConfig.ts +++ b/ui/lib/api/schemas/appDataConfigUpdate.ts @@ -4,6 +4,6 @@ * OpenAPI spec version: 0.0.1 */ -export interface BootstrapPathConfig { - path: string; +export interface AppDataConfigUpdate { + path: string } diff --git a/ui/lib/api/schemas/appLlmConfig.ts b/ui/lib/api/schemas/appLlmConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb73545aff246765bec9f7c99289add27283b673 --- /dev/null +++ b/ui/lib/api/schemas/appLlmConfig.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { AppLlmProviderConfig } from './appLlmProviderConfig' + +export interface AppLlmConfig { + providers?: AppLlmProviderConfig[] +} diff --git a/ui/lib/api/schemas/appLlmConfigUpdate.ts b/ui/lib/api/schemas/appLlmConfigUpdate.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4ad61fb3409fbbe2c890f2a3d885fc4b8605f91 --- /dev/null +++ b/ui/lib/api/schemas/appLlmConfigUpdate.ts @@ -0,0 +1,10 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { AppLlmProviderConfigUpdate } from './appLlmProviderConfigUpdate' + +export interface AppLlmConfigUpdate { + providers?: AppLlmProviderConfigUpdate[] +} diff --git a/ui/lib/api/schemas/bootstrapHttpConfig.ts b/ui/lib/api/schemas/appLlmProviderConfig.ts similarity index 54% rename from ui/lib/api/schemas/bootstrapHttpConfig.ts rename to ui/lib/api/schemas/appLlmProviderConfig.ts index c780d5ede11f4c0ece0cd22ee9588054c0bfd0fb..c981cfeb51ac2d66d069fb6071faa8ed12f303f6 100644 --- a/ui/lib/api/schemas/bootstrapHttpConfig.ts +++ b/ui/lib/api/schemas/appLlmProviderConfig.ts @@ -4,7 +4,9 @@ * OpenAPI spec version: 0.0.1 */ -export interface BootstrapHttpConfig { +export interface AppLlmProviderConfig { /** @nullable */ - proxy?: string | null; + baseUrl?: string | null + hasApiKey: boolean + id: string } diff --git a/ui/lib/api/schemas/appLlmProviderConfigUpdate.ts b/ui/lib/api/schemas/appLlmProviderConfigUpdate.ts new file mode 100644 index 0000000000000000000000000000000000000000..eacd8386c635023d189ba67f5f50b4c9196a4539 --- /dev/null +++ b/ui/lib/api/schemas/appLlmProviderConfigUpdate.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ + +export interface AppLlmProviderConfigUpdate { + /** @nullable */ + apiKey?: string | null + /** @nullable */ + baseUrl?: string | null + clearApiKey?: boolean + id: string +} diff --git a/ui/lib/api/schemas/bootstrapConfig.ts b/ui/lib/api/schemas/bootstrapConfig.ts deleted file mode 100644 index ca5b50afa0c7a86ee6ec8234e46b5e9b595a5f8e..0000000000000000000000000000000000000000 --- a/ui/lib/api/schemas/bootstrapConfig.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Generated by orval v8.6.2 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.1 - */ -import type { BootstrapHttpConfig } from './bootstrapHttpConfig'; -import type { BootstrapPathConfig } from './bootstrapPathConfig'; - -export interface BootstrapConfig { - http: BootstrapHttpConfig; - models: BootstrapPathConfig; - runtime: BootstrapPathConfig; -} diff --git a/ui/lib/api/schemas/brushRegionRequest.ts b/ui/lib/api/schemas/brushRegionRequest.ts index 5155a0b5ec6d340698adef60ebc3bcf3f2eb60b4..f7596174c6722b342150e76ae97abb192d55cc67 100644 --- a/ui/lib/api/schemas/brushRegionRequest.ts +++ b/ui/lib/api/schemas/brushRegionRequest.ts @@ -3,9 +3,9 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { Region } from './region'; +import type { Region } from './region' export interface BrushRegionRequest { - data: number[]; - region: Region; + data: number[] + region: Region } diff --git a/ui/lib/api/schemas/createTextBlock.ts b/ui/lib/api/schemas/createTextBlock.ts index b4216fbaa98966e8777494fd183d16023216c627..26e8aab358f2e6b235dc97ebdd8b963bc0a0dca8 100644 --- a/ui/lib/api/schemas/createTextBlock.ts +++ b/ui/lib/api/schemas/createTextBlock.ts @@ -5,8 +5,8 @@ */ export interface CreateTextBlock { - height: number; - width: number; - x: number; - y: number; + height: number + width: number + x: number + y: number } diff --git a/ui/lib/api/schemas/documentDetail.ts b/ui/lib/api/schemas/documentDetail.ts index 78c1edd9b2aaa92bb7d52949e8d6a13043ab2ff9..4700781e83dd046372940e38cb63b13d2d3a0feb 100644 --- a/ui/lib/api/schemas/documentDetail.ts +++ b/ui/lib/api/schemas/documentDetail.ts @@ -3,26 +3,24 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { TextBlockDetail } from './textBlockDetail'; +import type { TextBlockDetail } from './textBlockDetail' export interface DocumentDetail { /** @nullable */ - brushLayer?: number[] | null; + brushLayer?: number[] | null /** @minimum 0 */ - height: number; - id: string; - image: number[]; + height: number + id: string + image: number[] /** @nullable */ - inpainted?: number[] | null; - name: string; - path: string; + inpainted?: number[] | null + name: string + path: string /** @nullable */ - rendered?: number[] | null; - /** @minimum 0 */ - revision: number; + rendered?: number[] | null /** @nullable */ - segment?: number[] | null; - textBlocks: TextBlockDetail[]; + segment?: number[] | null + textBlocks: TextBlockDetail[] /** @minimum 0 */ - width: number; + width: number } diff --git a/ui/lib/api/schemas/documentSummary.ts b/ui/lib/api/schemas/documentSummary.ts index 5a3ddd7d2dff3e86186744d52b0b4a6e9143547a..c8d05dc3d68850cfc4eb63a94510117df486828b 100644 --- a/ui/lib/api/schemas/documentSummary.ts +++ b/ui/lib/api/schemas/documentSummary.ts @@ -5,18 +5,16 @@ */ export interface DocumentSummary { - hasBrushLayer: boolean; - hasInpainted: boolean; - hasRendered: boolean; - hasSegment: boolean; + hasBrushLayer: boolean + hasInpainted: boolean + hasRendered: boolean + hasSegment: boolean /** @minimum 0 */ - height: number; - id: string; - name: string; + height: number + id: string + name: string /** @minimum 0 */ - revision: number; + textBlockCount: number /** @minimum 0 */ - textBlockCount: number; - /** @minimum 0 */ - width: number; + width: number } diff --git a/ui/lib/api/schemas/downloadState.ts b/ui/lib/api/schemas/downloadState.ts index 5abbef366240a5dd8211a0673ad7dc04863ce353..0056d8f8ba2ea246f589e4abe08e25943c4da189 100644 --- a/ui/lib/api/schemas/downloadState.ts +++ b/ui/lib/api/schemas/downloadState.ts @@ -3,19 +3,19 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { TransferStatus } from './transferStatus'; +import type { TransferStatus } from './transferStatus' export interface DownloadState { /** @minimum 0 */ - downloaded: number; + downloaded: number /** @nullable */ - error?: string | null; - filename: string; - id: string; - status: TransferStatus; + error?: string | null + filename: string + id: string + status: TransferStatus /** - * @minimum 0 - * @nullable - */ - total?: number | null; + * @minimum 0 + * @nullable + */ + total?: number | null } diff --git a/ui/lib/api/schemas/exportBatchRequest.ts b/ui/lib/api/schemas/exportBatchRequest.ts index fa8027f4c00625f2e7873fa6afdc01bbfad2ee69..98a52b07099b1d2a836b9efb9e2ebd690ba08266 100644 --- a/ui/lib/api/schemas/exportBatchRequest.ts +++ b/ui/lib/api/schemas/exportBatchRequest.ts @@ -3,8 +3,8 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { ExportLayer } from './exportLayer'; +import type { ExportLayer } from './exportLayer' export interface ExportBatchRequest { - layer?: null | ExportLayer; + layer?: null | ExportLayer } diff --git a/ui/lib/api/schemas/exportDocumentParams.ts b/ui/lib/api/schemas/exportDocumentParams.ts index 7de17406337eb32b2b9652a160d2e8e41c40ad23..1c8be31e0ddbe1094c50bf03102a56ee21f941d6 100644 --- a/ui/lib/api/schemas/exportDocumentParams.ts +++ b/ui/lib/api/schemas/exportDocumentParams.ts @@ -3,8 +3,8 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { ExportLayer } from './exportLayer'; +import type { ExportLayer } from './exportLayer' export type ExportDocumentParams = { -layer?: null | ExportLayer; -}; + layer?: null | ExportLayer +} diff --git a/ui/lib/api/schemas/exportLayer.ts b/ui/lib/api/schemas/exportLayer.ts index 73ccf76d7b3157348f2bf8ac072cfabde491efe5..6d9abc1c2e5a9482ebedcdadf3d70fdf9e84410c 100644 --- a/ui/lib/api/schemas/exportLayer.ts +++ b/ui/lib/api/schemas/exportLayer.ts @@ -4,10 +4,9 @@ * OpenAPI spec version: 0.0.1 */ -export type ExportLayer = typeof ExportLayer[keyof typeof ExportLayer]; - +export type ExportLayer = (typeof ExportLayer)[keyof typeof ExportLayer] export const ExportLayer = { rendered: 'rendered', inpainted: 'inpainted', -} as const; +} as const diff --git a/ui/lib/api/schemas/exportResult.ts b/ui/lib/api/schemas/exportResult.ts index 20be4e71f7672859c8bd1ee51404496388c900fc..3accec2d248614a6ef85a10a16d65f3c97bde2c6 100644 --- a/ui/lib/api/schemas/exportResult.ts +++ b/ui/lib/api/schemas/exportResult.ts @@ -6,5 +6,5 @@ export interface ExportResult { /** @minimum 0 */ - count: number; + count: number } diff --git a/ui/lib/api/schemas/fontFaceInfo.ts b/ui/lib/api/schemas/fontFaceInfo.ts index 660ad56b9d5fd161d6dc1494f3cd7571abe3429f..1c8741a98db8c4e60def20741b30880e94d5f6c2 100644 --- a/ui/lib/api/schemas/fontFaceInfo.ts +++ b/ui/lib/api/schemas/fontFaceInfo.ts @@ -5,6 +5,6 @@ */ export interface FontFaceInfo { - familyName: string; - postScriptName: string; + familyName: string + postScriptName: string } diff --git a/ui/lib/api/schemas/fontPrediction.ts b/ui/lib/api/schemas/fontPrediction.ts index 0b347a3149c249a3004e929b7df04aa704d10a1a..581e3c0804065336e5b470c13b4b9ff33760ec49 100644 --- a/ui/lib/api/schemas/fontPrediction.ts +++ b/ui/lib/api/schemas/fontPrediction.ts @@ -3,17 +3,17 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { NamedFontPrediction } from './namedFontPrediction'; -import type { TextDirection } from './textDirection'; +import type { NamedFontPrediction } from './namedFontPrediction' +import type { TextDirection } from './textDirection' export interface FontPrediction { - angle_deg: number; - direction: TextDirection; - font_size_px: number; - line_height: number; - named_fonts: NamedFontPrediction[]; - stroke_color: number[]; - stroke_width_px: number; - text_color: number[]; - top_fonts: [number, number][]; + angle_deg: number + direction: TextDirection + font_size_px: number + line_height: number + named_fonts: NamedFontPrediction[] + stroke_color: number[] + stroke_width_px: number + text_color: number[] + top_fonts: [number, number][] } diff --git a/ui/lib/api/schemas/getDocumentThumbnailParams.ts b/ui/lib/api/schemas/getDocumentThumbnailParams.ts index 92f8648f04714728f0ad9852faabc269cecfd72d..416863a796cc3452341380cdfdc1e092abc5e53b 100644 --- a/ui/lib/api/schemas/getDocumentThumbnailParams.ts +++ b/ui/lib/api/schemas/getDocumentThumbnailParams.ts @@ -5,9 +5,9 @@ */ export type GetDocumentThumbnailParams = { -/** - * @minimum 0 - * @nullable - */ -size?: number | null; -}; + /** + * @minimum 0 + * @nullable + */ + size?: number | null +} diff --git a/ui/lib/api/schemas/apiKeyResponse.ts b/ui/lib/api/schemas/getLlmCatalogParams.ts similarity index 52% rename from ui/lib/api/schemas/apiKeyResponse.ts rename to ui/lib/api/schemas/getLlmCatalogParams.ts index ba9b0bfe61d816ce31dc32037fa3466600a7f3d1..4ac76ba5ed2c9c13b6af5e1dd9edcf2f4892c778 100644 --- a/ui/lib/api/schemas/apiKeyResponse.ts +++ b/ui/lib/api/schemas/getLlmCatalogParams.ts @@ -4,7 +4,9 @@ * OpenAPI spec version: 0.0.1 */ -export interface ApiKeyResponse { - /** @nullable */ - apiKey?: string | null; +export type GetLlmCatalogParams = { + /** + * @nullable + */ + language?: string | null } diff --git a/ui/lib/api/schemas/importDocumentsBody.ts b/ui/lib/api/schemas/importDocumentsBody.ts index 481d1800a885df124a172aeea74d51a9f9ef5959..e965615d056d98b7fd694e1afcaf693d92dd1ec3 100644 --- a/ui/lib/api/schemas/importDocumentsBody.ts +++ b/ui/lib/api/schemas/importDocumentsBody.ts @@ -5,5 +5,5 @@ */ export type ImportDocumentsBody = { - files: Blob[]; -}; + files: Blob[] +} diff --git a/ui/lib/api/schemas/importDocumentsParams.ts b/ui/lib/api/schemas/importDocumentsParams.ts index b67b9eb297fc989cf32cd601e8faf11652713a4b..d98ebb0706733eff3131fd5b745f6e52f25f0966 100644 --- a/ui/lib/api/schemas/importDocumentsParams.ts +++ b/ui/lib/api/schemas/importDocumentsParams.ts @@ -3,8 +3,8 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { ImportMode } from './importMode'; +import type { ImportMode } from './importMode' export type ImportDocumentsParams = { -mode?: null | ImportMode; -}; + mode?: null | ImportMode +} diff --git a/ui/lib/api/schemas/importMode.ts b/ui/lib/api/schemas/importMode.ts index d592aa3e7d5f2344e624d9e24a09d19cac70d3be..6d2a1ef106b1cbf26bb6383a3e4882f3cb5f095a 100644 --- a/ui/lib/api/schemas/importMode.ts +++ b/ui/lib/api/schemas/importMode.ts @@ -4,10 +4,9 @@ * OpenAPI spec version: 0.0.1 */ -export type ImportMode = typeof ImportMode[keyof typeof ImportMode]; - +export type ImportMode = (typeof ImportMode)[keyof typeof ImportMode] export const ImportMode = { replace: 'replace', append: 'append', -} as const; +} as const diff --git a/ui/lib/api/schemas/importResult.ts b/ui/lib/api/schemas/importResult.ts index 89a9635eec38ed2fc6a5750760b2b3c99152214b..0b79cadb02b9ac8094f6e3218602227b31e7ef43 100644 --- a/ui/lib/api/schemas/importResult.ts +++ b/ui/lib/api/schemas/importResult.ts @@ -3,10 +3,10 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { DocumentSummary } from './documentSummary'; +import type { DocumentSummary } from './documentSummary' export interface ImportResult { - documents: DocumentSummary[]; + documents: DocumentSummary[] /** @minimum 0 */ - totalCount: number; + totalCount: number } diff --git a/ui/lib/api/schemas/index.ts b/ui/lib/api/schemas/index.ts index 6aeef155f32618ade49771c3b73b4b385ee5a433..b2917ec55f500be7b0e93b9189a584dff4511a52 100644 --- a/ui/lib/api/schemas/index.ts +++ b/ui/lib/api/schemas/index.ts @@ -4,51 +4,59 @@ * OpenAPI spec version: 0.0.1 */ -export * from './apiError'; -export * from './apiKeyResponse'; -export * from './apiKeyValue'; -export * from './bootstrapConfig'; -export * from './bootstrapHttpConfig'; -export * from './bootstrapPathConfig'; -export * from './brushRegionRequest'; -export * from './createTextBlock'; -export * from './documentDetail'; -export * from './documentSummary'; -export * from './downloadState'; -export * from './exportBatchRequest'; -export * from './exportDocumentParams'; -export * from './exportLayer'; -export * from './exportResult'; -export * from './fontFaceInfo'; -export * from './fontPrediction'; -export * from './getDocumentThumbnailParams'; -export * from './importDocumentsBody'; -export * from './importDocumentsParams'; -export * from './importMode'; -export * from './importResult'; -export * from './inpaintRegionRequest'; -export * from './jobState'; -export * from './jobStatus'; -export * from './listLlmModelsParams'; -export * from './llmLoadRequest'; -export * from './llmModelInfo'; -export * from './llmPingRequest'; -export * from './llmPingResponse'; -export * from './llmState'; -export * from './llmStateStatus'; -export * from './maskRegionRequest'; -export * from './metaInfo'; -export * from './namedFontPrediction'; -export * from './pipelineJobRequest'; -export * from './region'; -export * from './renderRequest'; -export * from './textAlign'; -export * from './textBlockDetail'; -export * from './textBlockInput'; -export * from './textBlockPatch'; -export * from './textDirection'; -export * from './textShaderEffect'; -export * from './textStrokeStyle'; -export * from './textStyle'; -export * from './transferStatus'; -export * from './translateRequest'; +export * from './apiError' +export * from './appConfig' +export * from './appConfigUpdate' +export * from './appDataConfig' +export * from './appDataConfigUpdate' +export * from './appLlmConfig' +export * from './appLlmConfigUpdate' +export * from './appLlmProviderConfig' +export * from './appLlmProviderConfigUpdate' +export * from './brushRegionRequest' +export * from './createTextBlock' +export * from './documentDetail' +export * from './documentSummary' +export * from './downloadState' +export * from './exportBatchRequest' +export * from './exportDocumentParams' +export * from './exportLayer' +export * from './exportResult' +export * from './fontFaceInfo' +export * from './fontPrediction' +export * from './getDocumentThumbnailParams' +export * from './getLlmCatalogParams' +export * from './importDocumentsBody' +export * from './importDocumentsParams' +export * from './importMode' +export * from './importResult' +export * from './inpaintRegionRequest' +export * from './jobState' +export * from './jobStatus' +export * from './llmCatalog' +export * from './llmCatalogModel' +export * from './llmGenerationOptions' +export * from './llmLoadRequest' +export * from './llmProviderCatalog' +export * from './llmProviderCatalogStatus' +export * from './llmState' +export * from './llmStateStatus' +export * from './llmTarget' +export * from './llmTargetKind' +export * from './maskRegionRequest' +export * from './metaInfo' +export * from './namedFontPrediction' +export * from './pipelineJobRequest' +export * from './pipelineLlmRequest' +export * from './region' +export * from './renderRequest' +export * from './textAlign' +export * from './textBlockDetail' +export * from './textBlockInput' +export * from './textBlockPatch' +export * from './textDirection' +export * from './textShaderEffect' +export * from './textStrokeStyle' +export * from './textStyle' +export * from './transferStatus' +export * from './translateRequest' diff --git a/ui/lib/api/schemas/inpaintRegionRequest.ts b/ui/lib/api/schemas/inpaintRegionRequest.ts index 0b054b01adf6e26cc8fa61cf97a2bf6fa1d45d10..80fca6877236c3c375103c8cb732ee42c6cb3999 100644 --- a/ui/lib/api/schemas/inpaintRegionRequest.ts +++ b/ui/lib/api/schemas/inpaintRegionRequest.ts @@ -3,8 +3,8 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { Region } from './region'; +import type { Region } from './region' export interface InpaintRegionRequest { - region: Region; + region: Region } diff --git a/ui/lib/api/schemas/jobState.ts b/ui/lib/api/schemas/jobState.ts index b79e2c5aacb83e8e6424808facf49bbd363cd84e..65f6693b8bd8235dea6369b3c554afe9998bad88 100644 --- a/ui/lib/api/schemas/jobState.ts +++ b/ui/lib/api/schemas/jobState.ts @@ -3,24 +3,24 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { JobStatus } from './jobStatus'; +import type { JobStatus } from './jobStatus' export interface JobState { /** @minimum 0 */ - currentDocument: number; + currentDocument: number /** @minimum 0 */ - currentStepIndex: number; + currentStepIndex: number /** @nullable */ - error?: string | null; - id: string; - kind: string; + error?: string | null + id: string + kind: string /** @minimum 0 */ - overallPercent: number; - status: JobStatus; + overallPercent: number + status: JobStatus /** @nullable */ - step?: string | null; + step?: string | null /** @minimum 0 */ - totalDocuments: number; + totalDocuments: number /** @minimum 0 */ - totalSteps: number; + totalSteps: number } diff --git a/ui/lib/api/schemas/jobStatus.ts b/ui/lib/api/schemas/jobStatus.ts index 7b63796711a2193ac611b908dd61327c8214ab67..9d9ca6944975fbd96cf16880dc738720c6462c6b 100644 --- a/ui/lib/api/schemas/jobStatus.ts +++ b/ui/lib/api/schemas/jobStatus.ts @@ -4,12 +4,11 @@ * OpenAPI spec version: 0.0.1 */ -export type JobStatus = typeof JobStatus[keyof typeof JobStatus]; - +export type JobStatus = (typeof JobStatus)[keyof typeof JobStatus] export const JobStatus = { running: 'running', completed: 'completed', cancelled: 'cancelled', failed: 'failed', -} as const; +} as const diff --git a/ui/lib/api/schemas/listLlmModelsParams.ts b/ui/lib/api/schemas/listLlmModelsParams.ts deleted file mode 100644 index 1d73c2c7f02863a32b190026cad05ce1e6ada45c..0000000000000000000000000000000000000000 --- a/ui/lib/api/schemas/listLlmModelsParams.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Generated by orval v8.6.2 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.1 - */ - -export type ListLlmModelsParams = { -/** - * @nullable - */ -language?: string | null; -/** - * @nullable - */ -openaiCompatibleBaseUrl?: string | null; -}; diff --git a/ui/lib/api/schemas/llmCatalog.ts b/ui/lib/api/schemas/llmCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9e31222a2f87fba486690105580342a83955316 --- /dev/null +++ b/ui/lib/api/schemas/llmCatalog.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { LlmCatalogModel } from './llmCatalogModel' +import type { LlmProviderCatalog } from './llmProviderCatalog' + +export interface LlmCatalog { + localModels: LlmCatalogModel[] + providers: LlmProviderCatalog[] +} diff --git a/ui/lib/api/schemas/llmCatalogModel.ts b/ui/lib/api/schemas/llmCatalogModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff0b353420634ce39e3cc37f514eeb08ce8dfc30 --- /dev/null +++ b/ui/lib/api/schemas/llmCatalogModel.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { LlmTarget } from './llmTarget' + +export interface LlmCatalogModel { + languages: string[] + name: string + target: LlmTarget +} diff --git a/ui/lib/api/schemas/llmGenerationOptions.ts b/ui/lib/api/schemas/llmGenerationOptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..f36c14c92c16aeb3eb39af635fd2ace6c4cf5227 --- /dev/null +++ b/ui/lib/api/schemas/llmGenerationOptions.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ + +export interface LlmGenerationOptions { + /** @nullable */ + customSystemPrompt?: string | null + /** + * @minimum 0 + * @nullable + */ + maxTokens?: number | null + /** @nullable */ + temperature?: number | null +} diff --git a/ui/lib/api/schemas/llmLoadRequest.ts b/ui/lib/api/schemas/llmLoadRequest.ts index 58ba0706bf02984bab3047434535af35d4b30dd8..b99ab902da7623856ef7f953baaaf9faa503472e 100644 --- a/ui/lib/api/schemas/llmLoadRequest.ts +++ b/ui/lib/api/schemas/llmLoadRequest.ts @@ -3,20 +3,10 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ +import type { LlmGenerationOptions } from './llmGenerationOptions' +import type { LlmTarget } from './llmTarget' export interface LlmLoadRequest { - /** @nullable */ - apiKey?: string | null; - /** @nullable */ - baseUrl?: string | null; - /** @nullable */ - customSystemPrompt?: string | null; - id: string; - /** - * @minimum 0 - * @nullable - */ - maxTokens?: number | null; - /** @nullable */ - temperature?: number | null; + options?: null | LlmGenerationOptions + target: LlmTarget } diff --git a/ui/lib/api/schemas/llmModelInfo.ts b/ui/lib/api/schemas/llmModelInfo.ts deleted file mode 100644 index a0581322f1b0f91dae810ffe51fcb9506edc75a2..0000000000000000000000000000000000000000 --- a/ui/lib/api/schemas/llmModelInfo.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Generated by orval v8.6.2 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.1 - */ - -export interface LlmModelInfo { - id: string; - languages: string[]; - source: string; -} diff --git a/ui/lib/api/schemas/llmPingRequest.ts b/ui/lib/api/schemas/llmPingRequest.ts deleted file mode 100644 index 12ee7b6b634d63be7d8c4005be24b7b4cf446c67..0000000000000000000000000000000000000000 --- a/ui/lib/api/schemas/llmPingRequest.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Generated by orval v8.6.2 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.1 - */ - -export interface LlmPingRequest { - /** @nullable */ - apiKey?: string | null; - baseUrl: string; -} diff --git a/ui/lib/api/schemas/llmPingResponse.ts b/ui/lib/api/schemas/llmPingResponse.ts deleted file mode 100644 index 90fae57cfd81f52fa3c9b7ac638de8f1e3a7c7cf..0000000000000000000000000000000000000000 --- a/ui/lib/api/schemas/llmPingResponse.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Generated by orval v8.6.2 🍺 - * Do not edit manually. - * OpenAPI spec version: 0.0.1 - */ - -export interface LlmPingResponse { - /** @nullable */ - error?: string | null; - /** - * @minimum 0 - * @nullable - */ - latencyMs?: number | null; - models: string[]; - ok: boolean; -} diff --git a/ui/lib/api/schemas/llmProviderCatalog.ts b/ui/lib/api/schemas/llmProviderCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..6929bc4db56d474ef880b014571377fe9422d3f3 --- /dev/null +++ b/ui/lib/api/schemas/llmProviderCatalog.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { LlmCatalogModel } from './llmCatalogModel' +import type { LlmProviderCatalogStatus } from './llmProviderCatalogStatus' + +export interface LlmProviderCatalog { + /** @nullable */ + baseUrl?: string | null + /** @nullable */ + error?: string | null + hasApiKey: boolean + id: string + models: LlmCatalogModel[] + name: string + requiresApiKey: boolean + requiresBaseUrl: boolean + status: LlmProviderCatalogStatus +} diff --git a/ui/lib/api/schemas/llmProviderCatalogStatus.ts b/ui/lib/api/schemas/llmProviderCatalogStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..036cad0f844ecb67252b041ff1383a4cb98b6bc7 --- /dev/null +++ b/ui/lib/api/schemas/llmProviderCatalogStatus.ts @@ -0,0 +1,14 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ + +export type LlmProviderCatalogStatus = + (typeof LlmProviderCatalogStatus)[keyof typeof LlmProviderCatalogStatus] + +export const LlmProviderCatalogStatus = { + ready: 'ready', + missing_configuration: 'missing_configuration', + discovery_failed: 'discovery_failed', +} as const diff --git a/ui/lib/api/schemas/llmState.ts b/ui/lib/api/schemas/llmState.ts index 1ad53b60f75deeba0bc0e1afbb35576e7ed722ef..9cd379476bd102491fa80461ecde708849b9168f 100644 --- a/ui/lib/api/schemas/llmState.ts +++ b/ui/lib/api/schemas/llmState.ts @@ -3,14 +3,12 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { LlmStateStatus } from './llmStateStatus'; +import type { LlmStateStatus } from './llmStateStatus' +import type { LlmTarget } from './llmTarget' export interface LlmState { /** @nullable */ - error?: string | null; - /** @nullable */ - modelId?: string | null; - /** @nullable */ - source?: string | null; - status: LlmStateStatus; + error?: string | null + status: LlmStateStatus + target?: null | LlmTarget } diff --git a/ui/lib/api/schemas/llmStateStatus.ts b/ui/lib/api/schemas/llmStateStatus.ts index f053027ca61f9e01da599bd7519755db7d84aa0e..ef90fd46fe38757da05b42d0f1e064b233dc2166 100644 --- a/ui/lib/api/schemas/llmStateStatus.ts +++ b/ui/lib/api/schemas/llmStateStatus.ts @@ -4,12 +4,12 @@ * OpenAPI spec version: 0.0.1 */ -export type LlmStateStatus = typeof LlmStateStatus[keyof typeof LlmStateStatus]; - +export type LlmStateStatus = + (typeof LlmStateStatus)[keyof typeof LlmStateStatus] export const LlmStateStatus = { empty: 'empty', loading: 'loading', ready: 'ready', failed: 'failed', -} as const; +} as const diff --git a/ui/lib/api/schemas/llmTarget.ts b/ui/lib/api/schemas/llmTarget.ts new file mode 100644 index 0000000000000000000000000000000000000000..f23eb0503435477334d8ae4efc86a7b75d675090 --- /dev/null +++ b/ui/lib/api/schemas/llmTarget.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { LlmTargetKind } from './llmTargetKind' + +export interface LlmTarget { + kind: LlmTargetKind + modelId: string + /** @nullable */ + providerId?: string | null +} diff --git a/ui/lib/api/schemas/llmTargetKind.ts b/ui/lib/api/schemas/llmTargetKind.ts new file mode 100644 index 0000000000000000000000000000000000000000..b577af7c3e2b5be82facba0c68420ee79985e258 --- /dev/null +++ b/ui/lib/api/schemas/llmTargetKind.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ + +export type LlmTargetKind = (typeof LlmTargetKind)[keyof typeof LlmTargetKind] + +export const LlmTargetKind = { + local: 'local', + provider: 'provider', +} as const diff --git a/ui/lib/api/schemas/maskRegionRequest.ts b/ui/lib/api/schemas/maskRegionRequest.ts index 3911d22d2acef4ce9008b1ebe86fb84280d7c3a3..7f85dd084a51990057ffdda860ccd86985cb241b 100644 --- a/ui/lib/api/schemas/maskRegionRequest.ts +++ b/ui/lib/api/schemas/maskRegionRequest.ts @@ -3,9 +3,9 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { Region } from './region'; +import type { Region } from './region' export interface MaskRegionRequest { - data: number[]; - region?: null | Region; + data: number[] + region?: null | Region } diff --git a/ui/lib/api/schemas/metaInfo.ts b/ui/lib/api/schemas/metaInfo.ts index 3274ade29e1ae89b17addc4835c0ee2991f243bb..96ed7dae991882506e20092b96071319e033b1a4 100644 --- a/ui/lib/api/schemas/metaInfo.ts +++ b/ui/lib/api/schemas/metaInfo.ts @@ -5,6 +5,6 @@ */ export interface MetaInfo { - mlDevice: string; - version: string; + mlDevice: string + version: string } diff --git a/ui/lib/api/schemas/namedFontPrediction.ts b/ui/lib/api/schemas/namedFontPrediction.ts index 28c14437427e4849ab35f1ab3ba5a47bb6a70296..2563b5d6ac33bebdf4af0fa57c2f1a2ebc176a29 100644 --- a/ui/lib/api/schemas/namedFontPrediction.ts +++ b/ui/lib/api/schemas/namedFontPrediction.ts @@ -6,10 +6,10 @@ export interface NamedFontPrediction { /** @minimum 0 */ - index: number; + index: number /** @nullable */ - language?: string | null; - name: string; - probability: number; - serif: boolean; + language?: string | null + name: string + probability: number + serif: boolean } diff --git a/ui/lib/api/schemas/pipelineJobRequest.ts b/ui/lib/api/schemas/pipelineJobRequest.ts index 528275f42d02fbdb47428f6f615f2aa47c88437c..7443e3dfbcfa8da66a67b285cfcfbeeaf245d725 100644 --- a/ui/lib/api/schemas/pipelineJobRequest.ts +++ b/ui/lib/api/schemas/pipelineJobRequest.ts @@ -3,31 +3,18 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { TextShaderEffect } from './textShaderEffect'; -import type { TextStrokeStyle } from './textStrokeStyle'; +import type { PipelineLlmRequest } from './pipelineLlmRequest' +import type { TextShaderEffect } from './textShaderEffect' +import type { TextStrokeStyle } from './textStrokeStyle' export interface PipelineJobRequest { /** @nullable */ - documentId?: string | null; + documentId?: string | null /** @nullable */ - fontFamily?: string | null; + fontFamily?: string | null /** @nullable */ - language?: string | null; - /** @nullable */ - llmApiKey?: string | null; - /** @nullable */ - llmBaseUrl?: string | null; - /** @nullable */ - llmCustomSystemPrompt?: string | null; - /** - * @minimum 0 - * @nullable - */ - llmMaxTokens?: number | null; - /** @nullable */ - llmModelId?: string | null; - /** @nullable */ - llmTemperature?: number | null; - shaderEffect?: null | TextShaderEffect; - shaderStroke?: null | TextStrokeStyle; + language?: string | null + llm?: null | PipelineLlmRequest + shaderEffect?: null | TextShaderEffect + shaderStroke?: null | TextStrokeStyle } diff --git a/ui/lib/api/schemas/pipelineLlmRequest.ts b/ui/lib/api/schemas/pipelineLlmRequest.ts new file mode 100644 index 0000000000000000000000000000000000000000..973d586b0c45bdd232c31575e14e3c882b373a89 --- /dev/null +++ b/ui/lib/api/schemas/pipelineLlmRequest.ts @@ -0,0 +1,12 @@ +/** + * Generated by orval v8.6.2 🍺 + * Do not edit manually. + * OpenAPI spec version: 0.0.1 + */ +import type { LlmGenerationOptions } from './llmGenerationOptions' +import type { LlmTarget } from './llmTarget' + +export interface PipelineLlmRequest { + options?: null | LlmGenerationOptions + target: LlmTarget +} diff --git a/ui/lib/api/schemas/region.ts b/ui/lib/api/schemas/region.ts index d275a500c2b7c2540f4968c3cd56e890c4a57671..86dfd46e7cefb17c120121eb3c4ee7d21e69e75e 100644 --- a/ui/lib/api/schemas/region.ts +++ b/ui/lib/api/schemas/region.ts @@ -6,11 +6,11 @@ export interface Region { /** @minimum 0 */ - height: number; + height: number /** @minimum 0 */ - width: number; + width: number /** @minimum 0 */ - x: number; + x: number /** @minimum 0 */ - y: number; + y: number } diff --git a/ui/lib/api/schemas/renderRequest.ts b/ui/lib/api/schemas/renderRequest.ts index d564469c000456e9f1aff3dfebe9276cf75a6ab5..1ddfccd34b0a031e8877bbfa8ae88f4809258d9f 100644 --- a/ui/lib/api/schemas/renderRequest.ts +++ b/ui/lib/api/schemas/renderRequest.ts @@ -3,14 +3,14 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { TextShaderEffect } from './textShaderEffect'; -import type { TextStrokeStyle } from './textStrokeStyle'; +import type { TextShaderEffect } from './textShaderEffect' +import type { TextStrokeStyle } from './textStrokeStyle' export interface RenderRequest { /** @nullable */ - fontFamily?: string | null; - shaderEffect?: null | TextShaderEffect; - shaderStroke?: null | TextStrokeStyle; + fontFamily?: string | null + shaderEffect?: null | TextShaderEffect + shaderStroke?: null | TextStrokeStyle /** @nullable */ - textBlockId?: string | null; + textBlockId?: string | null } diff --git a/ui/lib/api/schemas/textAlign.ts b/ui/lib/api/schemas/textAlign.ts index a75f58dcc4f7cc36d46aa87e7f09b60d9fc4e49e..cfe90af2827988fab517c965f2b185798c9ff3e2 100644 --- a/ui/lib/api/schemas/textAlign.ts +++ b/ui/lib/api/schemas/textAlign.ts @@ -4,11 +4,10 @@ * OpenAPI spec version: 0.0.1 */ -export type TextAlign = typeof TextAlign[keyof typeof TextAlign]; - +export type TextAlign = (typeof TextAlign)[keyof typeof TextAlign] export const TextAlign = { left: 'left', center: 'center', right: 'right', -} as const; +} as const diff --git a/ui/lib/api/schemas/textBlockDetail.ts b/ui/lib/api/schemas/textBlockDetail.ts index 083f8fdb65c7ceba8b24e7ab1d4207c45dc18c5f..6d63d78a395029f638e42d593ead5cbf7625011b 100644 --- a/ui/lib/api/schemas/textBlockDetail.ts +++ b/ui/lib/api/schemas/textBlockDetail.ts @@ -3,33 +3,33 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { FontPrediction } from './fontPrediction'; -import type { TextDirection } from './textDirection'; -import type { TextStyle } from './textStyle'; +import type { FontPrediction } from './fontPrediction' +import type { TextDirection } from './textDirection' +import type { TextStyle } from './textStyle' export interface TextBlockDetail { - confidence: number; + confidence: number /** @nullable */ - detectedFontSizePx?: number | null; + detectedFontSizePx?: number | null /** @nullable */ - detector?: string | null; - fontPrediction?: null | FontPrediction; - height: number; - id: string; + detector?: string | null + fontPrediction?: null | FontPrediction + height: number + id: string /** @nullable */ - linePolygons?: number[][][] | null; - renderedDirection?: null | TextDirection; + linePolygons?: number[][][] | null + renderedDirection?: null | TextDirection /** @nullable */ - rotationDeg?: number | null; - sourceDirection?: null | TextDirection; + rotationDeg?: number | null + sourceDirection?: null | TextDirection /** @nullable */ - sourceLanguage?: string | null; - style?: null | TextStyle; + sourceLanguage?: string | null + style?: null | TextStyle /** @nullable */ - text?: string | null; + text?: string | null /** @nullable */ - translation?: string | null; - width: number; - x: number; - y: number; + translation?: string | null + width: number + x: number + y: number } diff --git a/ui/lib/api/schemas/textBlockInput.ts b/ui/lib/api/schemas/textBlockInput.ts index cabbac8fa1b3bb958a9f5fc88024e1b5254a2bad..99849d38ee53abb448187ce1653ab8a140b22549 100644 --- a/ui/lib/api/schemas/textBlockInput.ts +++ b/ui/lib/api/schemas/textBlockInput.ts @@ -3,18 +3,18 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { TextStyle } from './textStyle'; +import type { TextStyle } from './textStyle' export interface TextBlockInput { - height: number; + height: number /** @nullable */ - id?: string | null; - style?: null | TextStyle; + id?: string | null + style?: null | TextStyle /** @nullable */ - text?: string | null; + text?: string | null /** @nullable */ - translation?: string | null; - width: number; - x: number; - y: number; + translation?: string | null + width: number + x: number + y: number } diff --git a/ui/lib/api/schemas/textBlockPatch.ts b/ui/lib/api/schemas/textBlockPatch.ts index d0e3dc60cc0ca8d09afc107874ddb22411245e1f..4a46d777ed1677be795a7480300e4c4552e224fa 100644 --- a/ui/lib/api/schemas/textBlockPatch.ts +++ b/ui/lib/api/schemas/textBlockPatch.ts @@ -3,20 +3,20 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { TextStyle } from './textStyle'; +import type { TextStyle } from './textStyle' export interface TextBlockPatch { /** @nullable */ - height?: number | null; - style?: null | TextStyle; + height?: number | null + style?: null | TextStyle /** @nullable */ - text?: string | null; + text?: string | null /** @nullable */ - translation?: string | null; + translation?: string | null /** @nullable */ - width?: number | null; + width?: number | null /** @nullable */ - x?: number | null; + x?: number | null /** @nullable */ - y?: number | null; + y?: number | null } diff --git a/ui/lib/api/schemas/textDirection.ts b/ui/lib/api/schemas/textDirection.ts index 8d5c6b1008249995df852e9ff5fd9eec33df07ab..f8ae3af661ee0763b2744fda17cdb18b0b6af38b 100644 --- a/ui/lib/api/schemas/textDirection.ts +++ b/ui/lib/api/schemas/textDirection.ts @@ -4,10 +4,9 @@ * OpenAPI spec version: 0.0.1 */ -export type TextDirection = typeof TextDirection[keyof typeof TextDirection]; - +export type TextDirection = (typeof TextDirection)[keyof typeof TextDirection] export const TextDirection = { Horizontal: 'Horizontal', Vertical: 'Vertical', -} as const; +} as const diff --git a/ui/lib/api/schemas/textShaderEffect.ts b/ui/lib/api/schemas/textShaderEffect.ts index 49355e9a8d0f90f190f0047d9f2edee6bfda119a..ea141684f1f5095c9caad27fd8adf3d8708242ed 100644 --- a/ui/lib/api/schemas/textShaderEffect.ts +++ b/ui/lib/api/schemas/textShaderEffect.ts @@ -5,6 +5,6 @@ */ export interface TextShaderEffect { - bold?: boolean; - italic?: boolean; + bold?: boolean + italic?: boolean } diff --git a/ui/lib/api/schemas/textStrokeStyle.ts b/ui/lib/api/schemas/textStrokeStyle.ts index b5d6a7331b12ef6073cb3e60e29b9ed3e8b0ed64..c8441282f599affe5956dbb4420a1d40af0eb349 100644 --- a/ui/lib/api/schemas/textStrokeStyle.ts +++ b/ui/lib/api/schemas/textStrokeStyle.ts @@ -5,8 +5,8 @@ */ export interface TextStrokeStyle { - color?: number[]; - enabled?: boolean; + color?: number[] + enabled?: boolean /** @nullable */ - widthPx?: number | null; + widthPx?: number | null } diff --git a/ui/lib/api/schemas/textStyle.ts b/ui/lib/api/schemas/textStyle.ts index 50cb695d64bceb41c91bb5da08c9c7cd734c981b..7dd7617b8b20435069a6f03be7ba8fd45b10b833 100644 --- a/ui/lib/api/schemas/textStyle.ts +++ b/ui/lib/api/schemas/textStyle.ts @@ -3,16 +3,16 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import type { TextAlign } from './textAlign'; -import type { TextShaderEffect } from './textShaderEffect'; -import type { TextStrokeStyle } from './textStrokeStyle'; +import type { TextAlign } from './textAlign' +import type { TextShaderEffect } from './textShaderEffect' +import type { TextStrokeStyle } from './textStrokeStyle' export interface TextStyle { - color: number[]; - effect?: null | TextShaderEffect; - fontFamilies: string[]; + color: number[] + effect?: null | TextShaderEffect + fontFamilies: string[] /** @nullable */ - fontSize?: number | null; - stroke?: null | TextStrokeStyle; - textAlign?: null | TextAlign; + fontSize?: number | null + stroke?: null | TextStrokeStyle + textAlign?: null | TextAlign } diff --git a/ui/lib/api/schemas/transferStatus.ts b/ui/lib/api/schemas/transferStatus.ts index 04dd59f26501b8ce065090ba650c06bb14ed25b4..7dbf6aebcbffee2a8a0c58943e3aae33372c886c 100644 --- a/ui/lib/api/schemas/transferStatus.ts +++ b/ui/lib/api/schemas/transferStatus.ts @@ -4,12 +4,12 @@ * OpenAPI spec version: 0.0.1 */ -export type TransferStatus = typeof TransferStatus[keyof typeof TransferStatus]; - +export type TransferStatus = + (typeof TransferStatus)[keyof typeof TransferStatus] export const TransferStatus = { started: 'started', downloading: 'downloading', completed: 'completed', failed: 'failed', -} as const; +} as const diff --git a/ui/lib/api/schemas/translateRequest.ts b/ui/lib/api/schemas/translateRequest.ts index b968af60eae34cf9258c393ffa8df343188269bb..e3df902a4e7ab9bfb6796f464a062662f7c76ba3 100644 --- a/ui/lib/api/schemas/translateRequest.ts +++ b/ui/lib/api/schemas/translateRequest.ts @@ -6,7 +6,7 @@ export interface TranslateRequest { /** @nullable */ - language?: string | null; + language?: string | null /** @nullable */ - textBlockId?: string | null; + textBlockId?: string | null } diff --git a/ui/lib/api/system/system.ts b/ui/lib/api/system/system.ts index a6905fe9d83ee157e878cb8f98a3534838897b49..19304ef81214722f5c6a5a069321514dc9598d00 100644 --- a/ui/lib/api/system/system.ts +++ b/ui/lib/api/system/system.ts @@ -3,10 +3,7 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation, - useQuery -} from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query' import type { DataTag, DefinedInitialDataOptions, @@ -19,423 +16,491 @@ import type { UseMutationOptions, UseMutationResult, UseQueryOptions, - UseQueryResult -} from '@tanstack/react-query'; + UseQueryResult, +} from '@tanstack/react-query' import type { ApiError, - BootstrapConfig, + AppConfig, + AppConfigUpdate, FontFaceInfo, - MetaInfo -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; + MetaInfo, +} from '../schemas' +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] export const getGetConfigUrl = () => { - - - - return `/api/v1/config` } -export const getConfig = async ( options?: RequestInit): Promise => { - - return fetchApi(getGetConfigUrl(), - { +export const getConfig = async (options?: RequestInit): Promise => { + return fetchApi(getGetConfigUrl(), { ...options, - method: 'GET' - - - } -);} - - - - + method: 'GET', + }) +} export const getGetConfigQueryKey = () => { - return [ - `/api/v1/config` - ] as const; - } - - -export const getGetConfigQueryOptions = >, TError = ApiError>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetConfigQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => getConfig({ signal, ...requestOptions }); - - - - + return [`/api/v1/config`] as const +} - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getGetConfigQueryOptions = < + TData = Awaited>, + TError = ApiError, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetConfigQueryKey() + + const queryFn: QueryFunction>> = ({ + signal, + }) => getConfig({ signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag + } } -export type GetConfigQueryResult = NonNullable>> +export type GetConfigQueryResult = NonNullable< + Awaited> +> export type GetConfigQueryError = ApiError - -export function useGetConfig>, TError = ApiError>( - options: { query:Partial>, TError, TData>> & Pick< +export function useGetConfig< + TData = Awaited>, + TError = ApiError, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetConfig>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useGetConfig< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetConfig>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useGetConfig>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetConfig< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetConfig< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { const queryOptions = getGetConfigQueryOptions(options) - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } - return { ...query, queryKey: queryOptions.queryKey }; + return { ...query, queryKey: queryOptions.queryKey } } - - - export const getUpdateConfigUrl = () => { - - - - return `/api/v1/config` } -export const updateConfig = async (bootstrapConfig: BootstrapConfig, options?: RequestInit): Promise => { - - return fetchApi(getUpdateConfigUrl(), - { +export const updateConfig = async ( + appConfigUpdate: AppConfigUpdate, + options?: RequestInit, +): Promise => { + return fetchApi(getUpdateConfigUrl(), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - bootstrapConfig,) - } -);} - - - - -export const getUpdateConfigMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: BootstrapConfig}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{data: BootstrapConfig}, TContext> => { - -const mutationKey = ['updateConfig']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {data: BootstrapConfig}> = (props) => { - const {data} = props ?? {}; - - return updateConfig(data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type UpdateConfigMutationResult = NonNullable>> - export type UpdateConfigMutationBody = BootstrapConfig - export type UpdateConfigMutationError = ApiError - - export const useUpdateConfig = (options?: { mutation?:UseMutationOptions>, TError,{data: BootstrapConfig}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: BootstrapConfig}, - TContext - > => { - return useMutation(getUpdateConfigMutationOptions(options), queryClient); - } - export const getListFontsUrl = () => { - + body: JSON.stringify(appConfigUpdate), + }) +} +export const getUpdateConfigMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: AppConfigUpdate }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { data: AppConfigUpdate }, + TContext +> => { + const mutationKey = ['updateConfig'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { data: AppConfigUpdate } + > = (props) => { + const { data } = props ?? {} + + return updateConfig(data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type UpdateConfigMutationResult = NonNullable< + Awaited> +> +export type UpdateConfigMutationBody = AppConfigUpdate +export type UpdateConfigMutationError = ApiError + +export const useUpdateConfig = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { data: AppConfigUpdate }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { data: AppConfigUpdate }, + TContext +> => { + return useMutation(getUpdateConfigMutationOptions(options), queryClient) +} +export const getListFontsUrl = () => { return `/api/v1/fonts` } -export const listFonts = async ( options?: RequestInit): Promise => { - - return fetchApi(getListFontsUrl(), - { +export const listFonts = async ( + options?: RequestInit, +): Promise => { + return fetchApi(getListFontsUrl(), { ...options, - method: 'GET' - - - } -);} - - - - + method: 'GET', + }) +} export const getListFontsQueryKey = () => { - return [ - `/api/v1/fonts` - ] as const; - } - - -export const getListFontsQueryOptions = >, TError = ApiError>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getListFontsQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => listFonts({ signal, ...requestOptions }); - - - - + return [`/api/v1/fonts`] as const +} - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getListFontsQueryOptions = < + TData = Awaited>, + TError = ApiError, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getListFontsQueryKey() + + const queryFn: QueryFunction>> = ({ + signal, + }) => listFonts({ signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag + } } -export type ListFontsQueryResult = NonNullable>> +export type ListFontsQueryResult = NonNullable< + Awaited> +> export type ListFontsQueryError = ApiError - -export function useListFonts>, TError = ApiError>( - options: { query:Partial>, TError, TData>> & Pick< +export function useListFonts< + TData = Awaited>, + TError = ApiError, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useListFonts>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useListFonts< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useListFonts>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useListFonts>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { - - const queryOptions = getListFontsQueryOptions(options) - - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; - - return { ...query, queryKey: queryOptions.queryKey }; + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag } - - - - -export const getInitializeUrl = () => { - - - - - return `/api/v1/initialize` +export function useListFonts< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag } -export const initialize = async ( options?: RequestInit): Promise => { - - return fetchApi(getInitializeUrl(), - { - ...options, - method: 'POST' - - - } -);} - - - - -export const getInitializeMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,void, TContext> => { - -const mutationKey = ['initialize']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, void> = () => { - - - return initialize(requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type InitializeMutationResult = NonNullable>> - - export type InitializeMutationError = ApiError - - export const useInitialize = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - void, - TContext - > => { - return useMutation(getInitializeMutationOptions(options), queryClient); - } - export const getGetMetaUrl = () => { - +export function useListFonts< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { + const queryOptions = getListFontsQueryOptions(options) + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } + return { ...query, queryKey: queryOptions.queryKey } +} +export const getGetMetaUrl = () => { return `/api/v1/meta` } -export const getMeta = async ( options?: RequestInit): Promise => { - - return fetchApi(getGetMetaUrl(), - { +export const getMeta = async (options?: RequestInit): Promise => { + return fetchApi(getGetMetaUrl(), { ...options, - method: 'GET' - - - } -);} - - - - + method: 'GET', + }) +} export const getGetMetaQueryKey = () => { - return [ - `/api/v1/meta` - ] as const; - } - - -export const getGetMetaQueryOptions = >, TError = ApiError>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} -) => { - -const {query: queryOptions, request: requestOptions} = options ?? {}; - - const queryKey = queryOptions?.queryKey ?? getGetMetaQueryKey(); - - - - const queryFn: QueryFunction>> = ({ signal }) => getMeta({ signal, ...requestOptions }); - - - - + return [`/api/v1/meta`] as const +} - return { queryKey, queryFn, gcTime: 300000, retry: 1, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +export const getGetMetaQueryOptions = < + TData = Awaited>, + TError = ApiError, +>(options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter +}) => { + const { query: queryOptions, request: requestOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetMetaQueryKey() + + const queryFn: QueryFunction>> = ({ + signal, + }) => getMeta({ signal, ...requestOptions }) + + return { + queryKey, + queryFn, + gcTime: 300000, + retry: 1, + ...queryOptions, + } as UseQueryOptions>, TError, TData> & { + queryKey: DataTag + } } -export type GetMetaQueryResult = NonNullable>> +export type GetMetaQueryResult = NonNullable< + Awaited> +> export type GetMetaQueryError = ApiError - -export function useGetMeta>, TError = ApiError>( - options: { query:Partial>, TError, TData>> & Pick< +export function useGetMeta< + TData = Awaited>, + TError = ApiError, +>( + options: { + query: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< DefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): DefinedUseQueryResult & { queryKey: DataTag } -export function useGetMeta>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>> & Pick< + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): DefinedUseQueryResult & { + queryKey: DataTag +} +export function useGetMeta< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > & + Pick< UndefinedInitialDataOptions< Awaited>, TError, Awaited> - > , 'initialData' - >, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } -export function useGetMeta>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } - -export function useGetMeta>, TError = ApiError>( - options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} - , queryClient?: QueryClient - ): UseQueryResult & { queryKey: DataTag } { + >, + 'initialData' + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetMeta< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} +export function useGetMeta< + TData = Awaited>, + TError = ApiError, +>( + options?: { + query?: Partial< + UseQueryOptions>, TError, TData> + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseQueryResult & { + queryKey: DataTag +} { const queryOptions = getGetMetaQueryOptions(options) - const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + const query = useQuery(queryOptions, queryClient) as UseQueryResult< + TData, + TError + > & { queryKey: DataTag } - return { ...query, queryKey: queryOptions.queryKey }; + return { ...query, queryKey: queryOptions.queryKey } } - - - - diff --git a/ui/lib/api/text-blocks/text-blocks.ts b/ui/lib/api/text-blocks/text-blocks.ts index 3e931e80359db7e36c8b03f2bfb6a6ceefa254ae..8e0c2b9c02f4f84e2cc8ebd104b9beafbf37a4ba 100644 --- a/ui/lib/api/text-blocks/text-blocks.ts +++ b/ui/lib/api/text-blocks/text-blocks.ts @@ -3,290 +3,351 @@ * Do not edit manually. * OpenAPI spec version: 0.0.1 */ -import { - useMutation -} from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query' import type { MutationFunction, QueryClient, UseMutationOptions, - UseMutationResult -} from '@tanstack/react-query'; + UseMutationResult, +} from '@tanstack/react-query' import type { ApiError, CreateTextBlock, TextBlockDetail, TextBlockInput, - TextBlockPatch -} from '../schemas'; - -import { fetchApi } from '.././fetch'; - - -type SecondParameter unknown> = Parameters[1]; - - - -export const getPutTextBlocksUrl = (documentId: string,) => { - + TextBlockPatch, +} from '../schemas' +import { fetchApi } from '.././fetch' +type SecondParameter unknown> = Parameters[1] +export const getPutTextBlocksUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/text-blocks` } -export const putTextBlocks = async (documentId: string, - textBlockInput: TextBlockInput[], options?: RequestInit): Promise => { - - return fetchApi(getPutTextBlocksUrl(documentId), - { +export const putTextBlocks = async ( + documentId: string, + textBlockInput: TextBlockInput[], + options?: RequestInit, +): Promise => { + return fetchApi(getPutTextBlocksUrl(documentId), { ...options, method: 'PUT', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - textBlockInput,) - } -);} - - - - -export const getPutTextBlocksMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: TextBlockInput[]}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;data: TextBlockInput[]}, TContext> => { - -const mutationKey = ['putTextBlocks']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;data: TextBlockInput[]}> = (props) => { - const {documentId,data} = props ?? {}; - - return putTextBlocks(documentId,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type PutTextBlocksMutationResult = NonNullable>> - export type PutTextBlocksMutationBody = TextBlockInput[] - export type PutTextBlocksMutationError = ApiError - - export const usePutTextBlocks = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: TextBlockInput[]}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;data: TextBlockInput[]}, - TContext - > => { - return useMutation(getPutTextBlocksMutationOptions(options), queryClient); - } - export const getCreateTextBlockUrl = (documentId: string,) => { - + body: JSON.stringify(textBlockInput), + }) +} +export const getPutTextBlocksMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: TextBlockInput[] }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: TextBlockInput[] }, + TContext +> => { + const mutationKey = ['putTextBlocks'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; data: TextBlockInput[] } + > = (props) => { + const { documentId, data } = props ?? {} + + return putTextBlocks(documentId, data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type PutTextBlocksMutationResult = NonNullable< + Awaited> +> +export type PutTextBlocksMutationBody = TextBlockInput[] +export type PutTextBlocksMutationError = ApiError + +export const usePutTextBlocks = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: TextBlockInput[] }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; data: TextBlockInput[] }, + TContext +> => { + return useMutation(getPutTextBlocksMutationOptions(options), queryClient) +} +export const getCreateTextBlockUrl = (documentId: string) => { return `/api/v1/documents/${documentId}/text-blocks` } -export const createTextBlock = async (documentId: string, - createTextBlock: CreateTextBlock, options?: RequestInit): Promise => { - - return fetchApi(getCreateTextBlockUrl(documentId), - { +export const createTextBlock = async ( + documentId: string, + createTextBlock: CreateTextBlock, + options?: RequestInit, +): Promise => { + return fetchApi(getCreateTextBlockUrl(documentId), { ...options, method: 'POST', headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - createTextBlock,) - } -);} - - - - -export const getCreateTextBlockMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: CreateTextBlock}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;data: CreateTextBlock}, TContext> => { - -const mutationKey = ['createTextBlock']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;data: CreateTextBlock}> = (props) => { - const {documentId,data} = props ?? {}; - - return createTextBlock(documentId,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type CreateTextBlockMutationResult = NonNullable>> - export type CreateTextBlockMutationBody = CreateTextBlock - export type CreateTextBlockMutationError = ApiError - - export const useCreateTextBlock = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;data: CreateTextBlock}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;data: CreateTextBlock}, - TContext - > => { - return useMutation(getCreateTextBlockMutationOptions(options), queryClient); - } - export const getDeleteTextBlockUrl = (documentId: string, - textBlockId: string,) => { - + body: JSON.stringify(createTextBlock), + }) +} +export const getCreateTextBlockMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: CreateTextBlock }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: CreateTextBlock }, + TContext +> => { + const mutationKey = ['createTextBlock'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; data: CreateTextBlock } + > = (props) => { + const { documentId, data } = props ?? {} + + return createTextBlock(documentId, data, requestOptions) + } + return { mutationFn, ...mutationOptions } +} +export type CreateTextBlockMutationResult = NonNullable< + Awaited> +> +export type CreateTextBlockMutationBody = CreateTextBlock +export type CreateTextBlockMutationError = ApiError + +export const useCreateTextBlock = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; data: CreateTextBlock }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; data: CreateTextBlock }, + TContext +> => { + return useMutation(getCreateTextBlockMutationOptions(options), queryClient) +} +export const getDeleteTextBlockUrl = ( + documentId: string, + textBlockId: string, +) => { return `/api/v1/documents/${documentId}/text-blocks/${textBlockId}` } -export const deleteTextBlock = async (documentId: string, - textBlockId: string, options?: RequestInit): Promise => { - - return fetchApi(getDeleteTextBlockUrl(documentId,textBlockId), - { +export const deleteTextBlock = async ( + documentId: string, + textBlockId: string, + options?: RequestInit, +): Promise => { + return fetchApi(getDeleteTextBlockUrl(documentId, textBlockId), { ...options, - method: 'DELETE' - + method: 'DELETE', + }) +} +export const getDeleteTextBlockMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; textBlockId: string }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; textBlockId: string }, + TContext +> => { + const mutationKey = ['deleteTextBlock'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; textBlockId: string } + > = (props) => { + const { documentId, textBlockId } = props ?? {} + + return deleteTextBlock(documentId, textBlockId, requestOptions) } -);} - - - - -export const getDeleteTextBlockMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;textBlockId: string}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;textBlockId: string}, TContext> => { - -const mutationKey = ['deleteTextBlock']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; - - - - - const mutationFn: MutationFunction>, {documentId: string;textBlockId: string}> = (props) => { - const {documentId,textBlockId} = props ?? {}; - - return deleteTextBlock(documentId,textBlockId,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type DeleteTextBlockMutationResult = NonNullable>> - - export type DeleteTextBlockMutationError = ApiError - - export const useDeleteTextBlock = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;textBlockId: string}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;textBlockId: string}, - TContext - > => { - return useMutation(getDeleteTextBlockMutationOptions(options), queryClient); - } - export const getPatchTextBlockUrl = (documentId: string, - textBlockId: string,) => { - - + return { mutationFn, ...mutationOptions } +} +export type DeleteTextBlockMutationResult = NonNullable< + Awaited> +> + +export type DeleteTextBlockMutationError = ApiError + +export const useDeleteTextBlock = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; textBlockId: string }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; textBlockId: string }, + TContext +> => { + return useMutation(getDeleteTextBlockMutationOptions(options), queryClient) +} +export const getPatchTextBlockUrl = ( + documentId: string, + textBlockId: string, +) => { return `/api/v1/documents/${documentId}/text-blocks/${textBlockId}` } -export const patchTextBlock = async (documentId: string, - textBlockId: string, - textBlockPatch: TextBlockPatch, options?: RequestInit): Promise => { +export const patchTextBlock = async ( + documentId: string, + textBlockId: string, + textBlockPatch: TextBlockPatch, + options?: RequestInit, +): Promise => { + return fetchApi( + getPatchTextBlockUrl(documentId, textBlockId), + { + ...options, + method: 'PATCH', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify(textBlockPatch), + }, + ) +} - return fetchApi(getPatchTextBlockUrl(documentId,textBlockId), - { - ...options, - method: 'PATCH', - headers: { 'Content-Type': 'application/json', ...options?.headers }, - body: JSON.stringify( - textBlockPatch,) +export const getPatchTextBlockMutationOptions = < + TError = ApiError, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; textBlockId: string; data: TextBlockPatch }, + TContext + > + request?: SecondParameter +}): UseMutationOptions< + Awaited>, + TError, + { documentId: string; textBlockId: string; data: TextBlockPatch }, + TContext +> => { + const mutationKey = ['patchTextBlock'] + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + 'mutationKey' in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined } + + const mutationFn: MutationFunction< + Awaited>, + { documentId: string; textBlockId: string; data: TextBlockPatch } + > = (props) => { + const { documentId, textBlockId, data } = props ?? {} + + return patchTextBlock(documentId, textBlockId, data, requestOptions) } -);} - - - - -export const getPatchTextBlockMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;textBlockId: string;data: TextBlockPatch}, TContext>, request?: SecondParameter} -): UseMutationOptions>, TError,{documentId: string;textBlockId: string;data: TextBlockPatch}, TContext> => { - -const mutationKey = ['patchTextBlock']; -const {mutation: mutationOptions, request: requestOptions} = options ? - options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? - options - : {...options, mutation: {...options.mutation, mutationKey}} - : {mutation: { mutationKey, }, request: undefined}; + return { mutationFn, ...mutationOptions } +} - - - const mutationFn: MutationFunction>, {documentId: string;textBlockId: string;data: TextBlockPatch}> = (props) => { - const {documentId,textBlockId,data} = props ?? {}; - - return patchTextBlock(documentId,textBlockId,data,requestOptions) - } - - - - - - - return { mutationFn, ...mutationOptions }} - - export type PatchTextBlockMutationResult = NonNullable>> - export type PatchTextBlockMutationBody = TextBlockPatch - export type PatchTextBlockMutationError = ApiError - - export const usePatchTextBlock = (options?: { mutation?:UseMutationOptions>, TError,{documentId: string;textBlockId: string;data: TextBlockPatch}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {documentId: string;textBlockId: string;data: TextBlockPatch}, - TContext - > => { - return useMutation(getPatchTextBlockMutationOptions(options), queryClient); - } +export type PatchTextBlockMutationResult = NonNullable< + Awaited> +> +export type PatchTextBlockMutationBody = TextBlockPatch +export type PatchTextBlockMutationError = ApiError + +export const usePatchTextBlock = ( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { documentId: string; textBlockId: string; data: TextBlockPatch }, + TContext + > + request?: SecondParameter + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { documentId: string; textBlockId: string; data: TextBlockPatch }, + TContext +> => { + return useMutation(getPatchTextBlockMutationOptions(options), queryClient) +} diff --git a/ui/lib/appConfig.ts b/ui/lib/appConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..598ff2b4eca352fbf744f7cd3b3270dad53bd388 --- /dev/null +++ b/ui/lib/appConfig.ts @@ -0,0 +1,53 @@ +import type { + AppConfig, + AppConfigUpdate, + AppLlmProviderConfigUpdate, +} from '@/lib/api/schemas' + +const providerUpdatesFromConfig = ( + config: AppConfig, +): AppLlmProviderConfigUpdate[] => + (config.llm?.providers ?? []).map((provider) => ({ + id: provider.id, + baseUrl: provider.baseUrl?.trim() ? provider.baseUrl.trim() : null, + apiKey: null, + clearApiKey: false, + })) + +export const toAppConfigUpdate = ( + config: AppConfig, + providerOverrides?: AppLlmProviderConfigUpdate[], +): AppConfigUpdate => { + const providers = new Map( + providerUpdatesFromConfig(config).map((provider) => [ + provider.id, + provider, + ]), + ) + + for (const override of providerOverrides ?? []) { + const current = providers.get(override.id) ?? { + id: override.id, + baseUrl: null, + apiKey: null, + clearApiKey: false, + } + providers.set(override.id, { + ...current, + ...override, + baseUrl: + override.baseUrl !== undefined + ? override.baseUrl?.trim() || null + : current.baseUrl?.trim() || null, + }) + } + + return { + data: { + path: config.data.path.trim(), + }, + llm: { + providers: [...providers.values()], + }, + } +} diff --git a/ui/lib/filePicker.ts b/ui/lib/filePicker.ts new file mode 100644 index 0000000000000000000000000000000000000000..427b1f1f32e3ed39aabcd956037e37572943680a --- /dev/null +++ b/ui/lib/filePicker.ts @@ -0,0 +1,32 @@ +'use client' + +import { fileOpen, directoryOpen } from 'browser-fs-access' + +const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] + +export const pickImageFiles = async (): Promise => { + try { + const files = await fileOpen({ + mimeTypes: ['image/*'], + extensions: IMAGE_EXTENSIONS, + multiple: true, + description: 'Select images', + }) + const result = Array.isArray(files) ? files : [files] + return result.length > 0 ? result : null + } catch { + return null // user cancelled + } +} + +export const pickImageFolderFiles = async (): Promise => { + try { + const files = await directoryOpen({ recursive: true }) + const images = files.filter((f) => + IMAGE_EXTENSIONS.some((ext) => f.name.toLowerCase().endsWith(ext)), + ) + return images.length > 0 ? images : null + } catch { + return null // user cancelled + } +} diff --git a/ui/lib/llmTargets.ts b/ui/lib/llmTargets.ts new file mode 100644 index 0000000000000000000000000000000000000000..f454a8592478fbadbba491ab7dcae45228665044 --- /dev/null +++ b/ui/lib/llmTargets.ts @@ -0,0 +1,16 @@ +import type { LlmTarget } from '@/lib/api/schemas' + +export const llmTargetKey = (target: LlmTarget) => + target.kind === 'local' + ? `local:${target.modelId}` + : `provider:${target.providerId ?? ''}:${target.modelId}` + +export const sameLlmTarget = ( + left?: LlmTarget | null, + right?: LlmTarget | null, +) => + !!left && + !!right && + left.kind === right.kind && + left.modelId === right.modelId && + (left.providerId ?? null) === (right.providerId ?? null) diff --git a/ui/lib/machines/index.tsx b/ui/lib/machines/index.tsx index 687769e4dd978fa903b4d4ed07b9a40e14274c11..f290c5074044040afa161ef2a7c2c7e492410c50 100644 --- a/ui/lib/machines/index.tsx +++ b/ui/lib/machines/index.tsx @@ -25,7 +25,11 @@ const ProcessingContext = createActorContext(processingMachine) * Provider wrapper that initialises the processing machine with the current * React Query client. Mount this once near the app root. */ -export function ProcessingProvider({ children }: { children: React.ReactNode }) { +export function ProcessingProvider({ + children, +}: { + children: React.ReactNode +}) { const queryClient = useQueryClient() return ( => { - try { - return await fileOpen({ - description: 'Documents', - mimeTypes: ['image/*'], - extensions: IMAGE_EXTENSIONS, - multiple: true, - }) - } catch { - return null - } +const importSelectedDocuments = async ( + files: File[], + mode: 'replace' | 'append', +): Promise => { + return importDocuments( + { + files, + }, + { mode }, + ) } -const pickFolder = async (): Promise => { - try { - const files = await directoryOpen({ recursive: true }) - return files.filter((file) => - IMAGE_EXTENSIONS.some((ext) => file.name.toLowerCase().endsWith(ext)), - ) - } catch { - return null - } +const saveBlob = async (blob: Blob, filename: string) => { + if (typeof document === 'undefined') return + + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.style.display = 'none' + document.body.appendChild(link) + link.click() + window.setTimeout(() => { + URL.revokeObjectURL(url) + link.remove() + }, 0) } // --------------------------------------------------------------------------- @@ -76,7 +71,11 @@ export type ProcessingContext = { } export type ProcessingEvent = - | { type: 'START_IMPORT'; mode: 'replace' | 'append'; source: 'files' | 'folder' } + | { + type: 'START_IMPORT' + mode: 'replace' | 'append' + source: 'files' | 'folder' + } | { type: 'START_DETECT'; documentId: string } | { type: 'START_RECOGNIZE'; documentId: string } | { type: 'START_INPAINT'; documentId: string } @@ -84,9 +83,20 @@ export type ProcessingEvent = | { type: 'START_TRANSLATE'; documentId: string; options: TranslateRequest } | { type: 'START_PIPELINE'; request: PipelineJobRequest } | { type: 'START_LLM_LOAD'; request: LlmLoadRequest } - | { type: 'START_EXPORT'; documentId: string; format: string; params?: { layer?: ExportLayer | null } } + | { + type: 'START_EXPORT' + documentId: string + format: string + params?: { layer?: ExportLayer | null } + } | { type: 'START_BATCH_EXPORT'; layer: ExportLayer } - | { type: 'PROGRESS'; step?: string; current: number; total: number; overallPercent: number } + | { + type: 'PROGRESS' + step?: string + current: number + total: number + overallPercent: number + } | { type: 'CANCEL' } | { type: 'DONE' } | { type: 'ERROR'; message: string } @@ -119,9 +129,12 @@ const importActor = fromPromise< number, { mode: 'replace' | 'append'; source: 'files' | 'folder' } >(async ({ input }) => { - const files = input.source === 'folder' ? await pickFolder() : await pickFiles() - if (!files?.length) throw new Error('__CANCELLED__') - const result = await importDocuments({ files }, { mode: input.mode }) + const picked = + input.source === 'folder' + ? await pickImageFolderFiles() + : await pickImageFiles() + if (!picked) throw new Error('__CANCELLED__') + const result = await importSelectedDocuments(picked, input.mode) return result.totalCount }) @@ -157,13 +170,12 @@ const translateActor = fromPromise< await translateDocument(input.documentId, input.options) }) -const pipelineActor = fromPromise< - string, - { request: PipelineJobRequest } ->(async ({ input }) => { - const job = await startPipeline(input.request) - return job.id -}) +const pipelineActor = fromPromise( + async ({ input }) => { + const job = await startPipeline(input.request) + return job.id + }, +) const llmLoadActor = fromPromise( async ({ input }) => { @@ -185,14 +197,12 @@ const exportActor = fromPromise< getListDocumentsQueryKey(), ) ?? [] const summary = documents.find((d) => d.id === input.documentId) - const blob = await exportDocument(input.documentId, input.format, input.params) - try { - await fileSave(blob, { - fileName: `${summary?.name ?? 'export'}_koharu.${input.format}`, - }) - } catch { - // user cancelled save dialog -- not an error - } + const blob = await exportDocument( + input.documentId, + input.format, + input.params, + ) + await saveBlob(blob, `${summary?.name ?? 'export'}_koharu.${input.format}`) }) const batchExportActor = fromPromise( @@ -217,7 +227,10 @@ const jobPollingActor = fromCallback( step: job.step ?? undefined, current: single ? job.currentStepIndex - : job.currentDocument + (job.totalSteps > 0 ? job.currentStepIndex / job.totalSteps : 0), + : job.currentDocument + + (job.totalSteps > 0 + ? job.currentStepIndex / job.totalSteps + : 0), total: single ? job.totalSteps : job.totalDocuments, overallPercent: job.overallPercent, }) @@ -227,7 +240,10 @@ const jobPollingActor = fromCallback( sendBack({ type: 'ERROR', message: job.error ?? 'Job failed' }) } } catch (e) { - sendBack({ type: 'ERROR', message: (e as Error)?.message ?? 'Failed to poll job' }) + sendBack({ + type: 'ERROR', + message: (e as Error)?.message ?? 'Failed to poll job', + }) } }, 1500) return () => clearInterval(interval) @@ -246,7 +262,10 @@ const llmPollingActor = fromCallback>( } // status === 'loading' → keep polling } catch (e) { - sendBack({ type: 'ERROR', message: (e as Error)?.message ?? 'Failed to poll LLM' }) + sendBack({ + type: 'ERROR', + message: (e as Error)?.message ?? 'Failed to poll LLM', + }) } }, 1500) return () => clearInterval(interval) @@ -280,7 +299,8 @@ export const processingMachine = setup({ actions: { // --- progress bar --- setProgressBarNormal: () => setProgressBarValue(0), - updateProgressBar: ({ context }) => setProgressBarValue(context.overallPercent), + updateProgressBar: ({ context }) => + setProgressBarValue(context.overallPercent), clearProgressBar: () => clearProgressBarValue(), // --- context reset --- @@ -312,10 +332,12 @@ export const processingMachine = setup({ // --- progress update --- updateProgress: assign({ - step: ({ event }) => (event.type === 'PROGRESS' ? (event.step ?? null) : null), + step: ({ event }) => + event.type === 'PROGRESS' ? (event.step ?? null) : null, current: ({ event }) => (event.type === 'PROGRESS' ? event.current : 0), total: ({ event }) => (event.type === 'PROGRESS' ? event.total : 0), - overallPercent: ({ event }) => (event.type === 'PROGRESS' ? event.overallPercent : 0), + overallPercent: ({ event }) => + event.type === 'PROGRESS' ? event.overallPercent : 0, }), // --- error --- @@ -546,7 +568,10 @@ export const processingMachine = setup({ invoke: { src: 'translateActor', input: ({ context, event }) => { - const e = event as Extract + const e = event as Extract< + ProcessingEvent, + { type: 'START_TRANSLATE' } + > return { documentId: context.documentId!, options: e.options } }, onDone: { @@ -579,7 +604,10 @@ export const processingMachine = setup({ invoke: { src: 'pipelineActor', input: ({ event }) => { - const e = event as Extract + const e = event as Extract< + ProcessingEvent, + { type: 'START_PIPELINE' } + > return { request: e.request } }, onDone: { @@ -624,7 +652,10 @@ export const processingMachine = setup({ invoke: { src: 'llmLoadActor', input: ({ event }) => { - const e = event as Extract + const e = event as Extract< + ProcessingEvent, + { type: 'START_LLM_LOAD' } + > return { request: e.request } }, onDone: { @@ -689,7 +720,10 @@ export const processingMachine = setup({ invoke: { src: 'batchExportActor', input: ({ event }) => { - const e = event as Extract + const e = event as Extract< + ProcessingEvent, + { type: 'START_BATCH_EXPORT' } + > return { layer: e.layer } }, onDone: { diff --git a/ui/lib/stores/editorUiStore.ts b/ui/lib/stores/editorUiStore.ts index 7126d0e594e66d805d744b8523897d0fff679bae..7d974a14ec5a59cec9348513d91c89aca19071c3 100644 --- a/ui/lib/stores/editorUiStore.ts +++ b/ui/lib/stores/editorUiStore.ts @@ -2,6 +2,7 @@ import { create } from 'zustand' import { RenderEffect, RenderStroke, ToolMode } from '@/types' +import type { LlmTarget } from '@/lib/api/schemas' // --------------------------------------------------------------------------- // Error auto-dismiss timer @@ -52,9 +53,9 @@ type EditorUiState = { setRenderStroke: (stroke: RenderStroke) => void // --- llm ui --- - selectedModel?: string + selectedTarget?: LlmTarget selectedLanguage?: string - setSelectedModel: (selectedModel?: string) => void + setSelectedTarget: (selectedTarget?: LlmTarget) => void setSelectedLanguage: (selectedLanguage?: string) => void // --- ui error --- @@ -91,7 +92,7 @@ const initialState = { } as RenderStroke, // llm ui - selectedModel: undefined as string | undefined, + selectedTarget: undefined as LlmTarget | undefined, selectedLanguage: undefined as string | undefined, // ui error @@ -163,7 +164,7 @@ export const useEditorUiStore = create((set, get) => ({ setRenderStroke: (stroke) => set({ renderStroke: stroke }), // --- llm ui actions --- - setSelectedModel: (selectedModel) => set({ selectedModel }), + setSelectedTarget: (selectedTarget) => set({ selectedTarget }), setSelectedLanguage: (selectedLanguage) => set({ selectedLanguage }), // --- ui error actions --- diff --git a/ui/lib/stores/preferencesStore.ts b/ui/lib/stores/preferencesStore.ts index 587c7331b0817346732dedbc2e101fee61044527..ce1602e7c9d5ca71735fcbb1a570d26b2e406075 100644 --- a/ui/lib/stores/preferencesStore.ts +++ b/ui/lib/stores/preferencesStore.ts @@ -11,12 +11,6 @@ type PreferencesState = { setBrushConfig: (config: Partial) => void fontFamily?: string setFontFamily: (font?: string) => void - apiKeys: Record - setApiKey: (provider: string, key: string) => void - providerBaseUrls: Record - setProviderBaseUrl: (provider: string, url: string) => void - providerModelNames: Record - setProviderModelName: (provider: string, name: string) => void resetPreferences: () => void } @@ -26,9 +20,6 @@ const initialPreferences = { color: '#ffffff', }, fontFamily: undefined as string | undefined, - apiKeys: {} as Record, - providerBaseUrls: {} as Record, - providerModelNames: {} as Record, } export const usePreferencesStore = create()( @@ -43,42 +34,26 @@ export const usePreferencesStore = create()( }, })), setFontFamily: (font) => set({ fontFamily: font }), - setApiKey: (provider, key) => - set((state) => ({ - apiKeys: { ...state.apiKeys, [provider]: key }, - })), - setProviderBaseUrl: (provider, url) => - set((state) => ({ - providerBaseUrls: { - ...state.providerBaseUrls, - [provider]: url, - }, - })), - setProviderModelName: (provider, name) => - set((state) => ({ - providerModelNames: { - ...state.providerModelNames, - [provider]: name, - }, - })), resetPreferences: () => set({ ...initialPreferences }), }), { name: 'koharu-config', - version: 2, + version: 3, migrate: (persisted: any, version: number) => { - // Drop legacy localLlm and openAiCompatibleConfigVersion fields if (version < 2 && persisted) { delete persisted.localLlm delete persisted.openAiCompatibleConfigVersion } + if (version < 3 && persisted) { + delete persisted.apiKeys + delete persisted.providerBaseUrls + delete persisted.providerModelNames + } return persisted }, partialize: (state) => ({ brushConfig: state.brushConfig, fontFamily: state.fontFamily, - providerBaseUrls: state.providerBaseUrls, - providerModelNames: state.providerModelNames, }), }, ), diff --git a/ui/openapi.json b/ui/openapi.json index 90c31d00feb0abbb197889ffe11bfc29c93c71db..2788f42f41bab3adb0e4a9d022957d130d777d53 100644 --- a/ui/openapi.json +++ b/ui/openapi.json @@ -7,9 +7,7 @@ "paths": { "/config": { "get": { - "tags": [ - "system" - ], + "tags": ["system"], "operationId": "getConfig", "responses": { "200": { @@ -17,7 +15,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BootstrapConfig" + "$ref": "#/components/schemas/AppConfig" } } } @@ -35,15 +33,13 @@ } }, "put": { - "tags": [ - "system" - ], + "tags": ["system"], "operationId": "updateConfig", "requestBody": { "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BootstrapConfig" + "$ref": "#/components/schemas/AppConfigUpdate" } } }, @@ -55,7 +51,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BootstrapConfig" + "$ref": "#/components/schemas/AppConfig" } } } @@ -75,9 +71,7 @@ }, "/documents": { "get": { - "tags": [ - "documents" - ], + "tags": ["documents"], "operationId": "listDocuments", "responses": { "200": { @@ -106,9 +100,7 @@ } }, "post": { - "tags": [ - "documents" - ], + "tags": ["documents"], "operationId": "importDocuments", "parameters": [ { @@ -132,9 +124,7 @@ "multipart/form-data": { "schema": { "type": "object", - "required": [ - "files" - ], + "required": ["files"], "properties": { "files": { "type": "array", @@ -185,9 +175,7 @@ }, "/documents/{document_id}": { "get": { - "tags": [ - "documents" - ], + "tags": ["documents"], "operationId": "getDocument", "parameters": [ { @@ -235,9 +223,7 @@ }, "/documents/{document_id}/brush-layer": { "put": { - "tags": [ - "regions" - ], + "tags": ["regions"], "operationId": "updateBrushLayer", "parameters": [ { @@ -288,9 +274,7 @@ }, "/documents/{document_id}/detect": { "post": { - "tags": [ - "processing" - ], + "tags": ["processing"], "operationId": "detectDocument", "parameters": [ { @@ -331,9 +315,7 @@ }, "/documents/{document_id}/export/{format}": { "get": { - "tags": [ - "exports" - ], + "tags": ["exports"], "operationId": "exportDocument", "parameters": [ { @@ -404,9 +386,7 @@ }, "/documents/{document_id}/inpaint": { "post": { - "tags": [ - "processing" - ], + "tags": ["processing"], "operationId": "inpaintDocument", "parameters": [ { @@ -447,9 +427,7 @@ }, "/documents/{document_id}/inpaint-region": { "post": { - "tags": [ - "regions" - ], + "tags": ["regions"], "operationId": "inpaintRegion", "parameters": [ { @@ -500,9 +478,7 @@ }, "/documents/{document_id}/mask": { "put": { - "tags": [ - "regions" - ], + "tags": ["regions"], "operationId": "updateMask", "parameters": [ { @@ -553,9 +529,7 @@ }, "/documents/{document_id}/recognize": { "post": { - "tags": [ - "processing" - ], + "tags": ["processing"], "operationId": "recognizeDocument", "parameters": [ { @@ -596,9 +570,7 @@ }, "/documents/{document_id}/render": { "post": { - "tags": [ - "processing" - ], + "tags": ["processing"], "operationId": "renderDocument", "parameters": [ { @@ -649,9 +621,7 @@ }, "/documents/{document_id}/text-blocks": { "put": { - "tags": [ - "text-blocks" - ], + "tags": ["text-blocks"], "operationId": "putTextBlocks", "parameters": [ { @@ -703,9 +673,7 @@ } }, "post": { - "tags": [ - "text-blocks" - ], + "tags": ["text-blocks"], "operationId": "createTextBlock", "parameters": [ { @@ -763,9 +731,7 @@ }, "/documents/{document_id}/text-blocks/{text_block_id}": { "delete": { - "tags": [ - "text-blocks" - ], + "tags": ["text-blocks"], "operationId": "deleteTextBlock", "parameters": [ { @@ -812,9 +778,7 @@ } }, "patch": { - "tags": [ - "text-blocks" - ], + "tags": ["text-blocks"], "operationId": "patchTextBlock", "parameters": [ { @@ -880,9 +844,7 @@ }, "/documents/{document_id}/thumbnail": { "get": { - "tags": [ - "documents" - ], + "tags": ["documents"], "operationId": "getDocumentThumbnail", "parameters": [ { @@ -898,10 +860,7 @@ "in": "query", "required": false, "schema": { - "type": [ - "integer", - "null" - ], + "type": ["integer", "null"], "format": "int32", "minimum": 0 } @@ -943,9 +902,7 @@ }, "/documents/{document_id}/translate": { "post": { - "tags": [ - "processing" - ], + "tags": ["processing"], "operationId": "translateDocument", "parameters": [ { @@ -996,9 +953,7 @@ }, "/downloads": { "get": { - "tags": [ - "downloads" - ], + "tags": ["downloads"], "operationId": "listDownloads", "responses": { "200": { @@ -1019,9 +974,7 @@ }, "/exports": { "post": { - "tags": [ - "exports" - ], + "tags": ["exports"], "operationId": "batchExport", "requestBody": { "content": { @@ -1059,9 +1012,7 @@ }, "/fonts": { "get": { - "tags": [ - "system" - ], + "tags": ["system"], "operationId": "listFonts", "responses": { "200": { @@ -1090,44 +1041,9 @@ } } }, - "/initialize": { - "post": { - "tags": [ - "system" - ], - "operationId": "initialize", - "responses": { - "204": { - "description": "" - }, - "409": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - }, - "500": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - } - } - } - }, "/jobs": { "get": { - "tags": [ - "jobs" - ], + "tags": ["jobs"], "operationId": "listJobs", "responses": { "200": { @@ -1148,9 +1064,7 @@ }, "/jobs/pipeline": { "post": { - "tags": [ - "jobs" - ], + "tags": ["jobs"], "operationId": "startPipeline", "requestBody": { "content": { @@ -1188,9 +1102,7 @@ }, "/jobs/{job_id}": { "get": { - "tags": [ - "jobs" - ], + "tags": ["jobs"], "operationId": "getJob", "parameters": [ { @@ -1226,9 +1138,7 @@ } }, "delete": { - "tags": [ - "jobs" - ], + "tags": ["jobs"], "operationId": "cancelJob", "parameters": [ { @@ -1269,9 +1179,7 @@ }, "/llm": { "get": { - "tags": [ - "llm" - ], + "tags": ["llm"], "operationId": "getLlm", "responses": { "200": { @@ -1297,9 +1205,7 @@ } }, "put": { - "tags": [ - "llm" - ], + "tags": ["llm"], "operationId": "loadLlm", "requestBody": { "content": { @@ -1345,9 +1251,7 @@ } }, "delete": { - "tags": [ - "llm" - ], + "tags": ["llm"], "operationId": "unloadLlm", "responses": { "200": { @@ -1373,63 +1277,17 @@ } } }, - "/llm/health": { - "post": { - "tags": [ - "llm" - ], - "operationId": "checkLlmHealth", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LlmPingRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LlmPingResponse" - } - } - } - } - } - } - }, - "/llm/models": { + "/llm/catalog": { "get": { - "tags": [ - "llm" - ], - "operationId": "listLlmModels", + "tags": ["llm"], + "operationId": "getLlmCatalog", "parameters": [ { "name": "language", "in": "query", "required": false, "schema": { - "type": [ - "string", - "null" - ] - } - }, - { - "name": "openaiCompatibleBaseUrl", - "in": "query", - "required": false, - "schema": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } } ], @@ -1439,10 +1297,7 @@ "content": { "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LlmModelInfo" - } + "$ref": "#/components/schemas/LlmCatalog" } } } @@ -1462,9 +1317,7 @@ }, "/meta": { "get": { - "tags": [ - "system" - ], + "tags": ["system"], "operationId": "getMeta", "responses": { "200": { @@ -1489,97 +1342,13 @@ } } } - }, - "/providers/{provider}/api-key": { - "get": { - "tags": [ - "providers" - ], - "operationId": "getApiKey", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiKeyResponse" - } - } - } - }, - "503": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - } - } - }, - "put": { - "tags": [ - "providers" - ], - "operationId": "setApiKey", - "parameters": [ - { - "name": "provider", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiKeyValue" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "" - }, - "503": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ApiError" - } - } - } - } - } - } } }, "components": { "schemas": { "ApiError": { "type": "object", - "required": [ - "status", - "message" - ], + "required": ["status", "message"], "properties": { "message": { "type": "string" @@ -1591,75 +1360,106 @@ } } }, - "ApiKeyResponse": { + "AppConfig": { "type": "object", + "required": ["data"], "properties": { - "apiKey": { - "type": [ - "string", - "null" - ] + "data": { + "$ref": "#/components/schemas/AppDataConfig" + }, + "llm": { + "$ref": "#/components/schemas/AppLlmConfig" } } }, - "ApiKeyValue": { + "AppConfigUpdate": { "type": "object", - "required": [ - "apiKey" - ], + "required": ["data"], "properties": { - "apiKey": { + "data": { + "$ref": "#/components/schemas/AppDataConfigUpdate" + }, + "llm": { + "$ref": "#/components/schemas/AppLlmConfigUpdate" + } + } + }, + "AppDataConfig": { + "type": "object", + "required": ["path"], + "properties": { + "path": { "type": "string" } } }, - "BootstrapConfig": { + "AppDataConfigUpdate": { "type": "object", - "required": [ - "runtime", - "models", - "http" - ], + "required": ["path"], "properties": { - "http": { - "$ref": "#/components/schemas/BootstrapHttpConfig" - }, - "models": { - "$ref": "#/components/schemas/BootstrapPathConfig" - }, - "runtime": { - "$ref": "#/components/schemas/BootstrapPathConfig" + "path": { + "type": "string" } } }, - "BootstrapHttpConfig": { + "AppLlmConfig": { "type": "object", "properties": { - "proxy": { - "type": [ - "string", - "null" - ] + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppLlmProviderConfig" + } } } }, - "BootstrapPathConfig": { + "AppLlmConfigUpdate": { "type": "object", - "required": [ - "path" - ], "properties": { - "path": { + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AppLlmProviderConfigUpdate" + } + } + } + }, + "AppLlmProviderConfig": { + "type": "object", + "required": ["id", "hasApiKey"], + "properties": { + "baseUrl": { + "type": ["string", "null"] + }, + "hasApiKey": { + "type": "boolean" + }, + "id": { + "type": "string" + } + } + }, + "AppLlmProviderConfigUpdate": { + "type": "object", + "required": ["id"], + "properties": { + "apiKey": { + "type": ["string", "null"] + }, + "baseUrl": { + "type": ["string", "null"] + }, + "clearApiKey": { + "type": "boolean" + }, + "id": { "type": "string" } } }, "BrushRegionRequest": { "type": "object", - "required": [ - "data", - "region" - ], + "required": ["data", "region"], "properties": { "data": { "type": "array", @@ -1676,12 +1476,7 @@ }, "CreateTextBlock": { "type": "object", - "required": [ - "x", - "y", - "width", - "height" - ], + "required": ["x", "y", "width", "height"], "properties": { "height": { "type": "number", @@ -1709,16 +1504,12 @@ "name", "width", "height", - "revision", "textBlocks", "image" ], "properties": { "brushLayer": { - "type": [ - "array", - "null" - ], + "type": ["array", "null"], "items": { "type": "integer", "format": "int32", @@ -1742,10 +1533,7 @@ } }, "inpainted": { - "type": [ - "array", - "null" - ], + "type": ["array", "null"], "items": { "type": "integer", "format": "int32", @@ -1759,26 +1547,15 @@ "type": "string" }, "rendered": { - "type": [ - "array", - "null" - ], + "type": ["array", "null"], "items": { "type": "integer", "format": "int32", "minimum": 0 } }, - "revision": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, "segment": { - "type": [ - "array", - "null" - ], + "type": ["array", "null"], "items": { "type": "integer", "format": "int32", @@ -1805,7 +1582,6 @@ "name", "width", "height", - "revision", "hasSegment", "hasInpainted", "hasBrushLayer", @@ -1836,11 +1612,6 @@ "name": { "type": "string" }, - "revision": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, "textBlockCount": { "type": "integer", "minimum": 0 @@ -1854,12 +1625,7 @@ }, "DownloadState": { "type": "object", - "required": [ - "id", - "filename", - "downloaded", - "status" - ], + "required": ["id", "filename", "downloaded", "status"], "properties": { "downloaded": { "type": "integer", @@ -1867,10 +1633,7 @@ "minimum": 0 }, "error": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "filename": { "type": "string" @@ -1882,10 +1645,7 @@ "$ref": "#/components/schemas/TransferStatus" }, "total": { - "type": [ - "integer", - "null" - ], + "type": ["integer", "null"], "format": "int64", "minimum": 0 } @@ -1908,16 +1668,11 @@ }, "ExportLayer": { "type": "string", - "enum": [ - "rendered", - "inpainted" - ] + "enum": ["rendered", "inpainted"] }, "ExportResult": { "type": "object", - "required": [ - "count" - ], + "required": ["count"], "properties": { "count": { "type": "integer", @@ -1927,10 +1682,7 @@ }, "FontFaceInfo": { "type": "object", - "required": [ - "familyName", - "postScriptName" - ], + "required": ["familyName", "postScriptName"], "properties": { "familyName": { "type": "string" @@ -2016,17 +1768,11 @@ }, "ImportMode": { "type": "string", - "enum": [ - "replace", - "append" - ] + "enum": ["replace", "append"] }, "ImportResult": { "type": "object", - "required": [ - "totalCount", - "documents" - ], + "required": ["totalCount", "documents"], "properties": { "documents": { "type": "array", @@ -2042,9 +1788,7 @@ }, "InpaintRegionRequest": { "type": "object", - "required": [ - "region" - ], + "required": ["region"], "properties": { "region": { "$ref": "#/components/schemas/Region" @@ -2073,10 +1817,7 @@ "minimum": 0 }, "error": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "id": { "type": "string" @@ -2093,10 +1834,7 @@ "$ref": "#/components/schemas/JobStatus" }, "step": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "totalDocuments": { "type": "integer", @@ -2110,171 +1848,176 @@ }, "JobStatus": { "type": "string", - "enum": [ - "running", - "completed", - "cancelled", - "failed" - ] + "enum": ["running", "completed", "cancelled", "failed"] }, - "LlmLoadRequest": { + "LlmCatalog": { "type": "object", - "required": [ - "id" - ], + "required": ["localModels", "providers"], "properties": { - "apiKey": { - "type": [ - "string", - "null" - ] - }, - "baseUrl": { - "type": [ - "string", - "null" - ] - }, - "customSystemPrompt": { - "type": [ - "string", - "null" - ] - }, - "id": { - "type": "string" - }, - "maxTokens": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "minimum": 0 + "localModels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LlmCatalogModel" + } }, - "temperature": { - "type": [ - "number", - "null" - ], - "format": "double" + "providers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LlmProviderCatalog" + } } } }, - "LlmModelInfo": { + "LlmCatalogModel": { "type": "object", - "required": [ - "id", - "languages", - "source" - ], + "required": ["target", "name", "languages"], "properties": { - "id": { - "type": "string" - }, "languages": { "type": "array", "items": { "type": "string" } }, - "source": { + "name": { "type": "string" + }, + "target": { + "$ref": "#/components/schemas/LlmTarget" } } }, - "LlmPingRequest": { + "LlmGenerationOptions": { "type": "object", - "required": [ - "baseUrl" - ], "properties": { - "apiKey": { - "type": [ - "string", - "null" + "customSystemPrompt": { + "type": ["string", "null"] + }, + "maxTokens": { + "type": ["integer", "null"], + "format": "int32", + "minimum": 0 + }, + "temperature": { + "type": ["number", "null"], + "format": "double" + } + } + }, + "LlmLoadRequest": { + "type": "object", + "required": ["target"], + "properties": { + "options": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LlmGenerationOptions" + } ] }, - "baseUrl": { - "type": "string" + "target": { + "$ref": "#/components/schemas/LlmTarget" } } }, - "LlmPingResponse": { + "LlmProviderCatalog": { "type": "object", "required": [ - "ok", + "id", + "name", + "requiresApiKey", + "requiresBaseUrl", + "hasApiKey", + "status", "models" ], "properties": { + "baseUrl": { + "type": ["string", "null"] + }, "error": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, - "latencyMs": { - "type": [ - "integer", - "null" - ], - "format": "int64", - "minimum": 0 + "hasApiKey": { + "type": "boolean" + }, + "id": { + "type": "string" }, "models": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/LlmCatalogModel" } }, - "ok": { + "name": { + "type": "string" + }, + "requiresApiKey": { + "type": "boolean" + }, + "requiresBaseUrl": { "type": "boolean" + }, + "status": { + "$ref": "#/components/schemas/LlmProviderCatalogStatus" } } }, + "LlmProviderCatalogStatus": { + "type": "string", + "enum": ["ready", "missing_configuration", "discovery_failed"] + }, "LlmState": { "type": "object", - "required": [ - "status" - ], + "required": ["status"], "properties": { "error": { - "type": [ - "string", - "null" - ] - }, - "modelId": { - "type": [ - "string", - "null" - ] - }, - "source": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "status": { "$ref": "#/components/schemas/LlmStateStatus" + }, + "target": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LlmTarget" + } + ] } } }, "LlmStateStatus": { "type": "string", - "enum": [ - "empty", - "loading", - "ready", - "failed" - ] + "enum": ["empty", "loading", "ready", "failed"] + }, + "LlmTarget": { + "type": "object", + "required": ["kind", "modelId"], + "properties": { + "kind": { + "$ref": "#/components/schemas/LlmTargetKind" + }, + "modelId": { + "type": "string" + }, + "providerId": { + "type": ["string", "null"] + } + } + }, + "LlmTargetKind": { + "type": "string", + "enum": ["local", "provider"] }, "MaskRegionRequest": { "type": "object", - "required": [ - "data" - ], + "required": ["data"], "properties": { "data": { "type": "array", @@ -2298,10 +2041,7 @@ }, "MetaInfo": { "type": "object", - "required": [ - "version", - "mlDevice" - ], + "required": ["version", "mlDevice"], "properties": { "mlDevice": { "type": "string" @@ -2313,22 +2053,14 @@ }, "NamedFontPrediction": { "type": "object", - "required": [ - "index", - "name", - "probability", - "serif" - ], + "required": ["index", "name", "probability", "serif"], "properties": { "index": { "type": "integer", "minimum": 0 }, "language": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "name": { "type": "string" @@ -2346,62 +2078,24 @@ "type": "object", "properties": { "documentId": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "fontFamily": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "language": { - "type": [ - "string", - "null" - ] - }, - "llmApiKey": { - "type": [ - "string", - "null" - ] - }, - "llmBaseUrl": { - "type": [ - "string", - "null" - ] - }, - "llmCustomSystemPrompt": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, - "llmMaxTokens": { - "type": [ - "integer", - "null" - ], - "format": "int32", - "minimum": 0 - }, - "llmModelId": { - "type": [ - "string", - "null" + "llm": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/PipelineLlmRequest" + } ] }, - "llmTemperature": { - "type": [ - "number", - "null" - ], - "format": "double" - }, "shaderEffect": { "oneOf": [ { @@ -2424,14 +2118,28 @@ } } }, + "PipelineLlmRequest": { + "type": "object", + "required": ["target"], + "properties": { + "options": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/LlmGenerationOptions" + } + ] + }, + "target": { + "$ref": "#/components/schemas/LlmTarget" + } + } + }, "Region": { "type": "object", - "required": [ - "x", - "y", - "width", - "height" - ], + "required": ["x", "y", "width", "height"], "properties": { "height": { "type": "integer", @@ -2459,10 +2167,7 @@ "type": "object", "properties": { "fontFamily": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "shaderEffect": { "oneOf": [ @@ -2485,48 +2190,28 @@ ] }, "textBlockId": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } } }, "TextAlign": { "type": "string", - "enum": [ - "left", - "center", - "right" - ] + "enum": ["left", "center", "right"] }, "TextBlockDetail": { "type": "object", - "required": [ - "id", - "x", - "y", - "width", - "height", - "confidence" - ], + "required": ["id", "x", "y", "width", "height", "confidence"], "properties": { "confidence": { "type": "number", "format": "float" }, "detectedFontSizePx": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" }, "detector": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "fontPrediction": { "oneOf": [ @@ -2546,10 +2231,7 @@ "type": "string" }, "linePolygons": { - "type": [ - "array", - "null" - ], + "type": ["array", "null"], "items": { "type": "array", "items": { @@ -2572,10 +2254,7 @@ ] }, "rotationDeg": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" }, "sourceDirection": { @@ -2589,10 +2268,7 @@ ] }, "sourceLanguage": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "style": { "oneOf": [ @@ -2605,16 +2281,10 @@ ] }, "text": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "translation": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "width": { "type": "number", @@ -2632,22 +2302,14 @@ }, "TextBlockInput": { "type": "object", - "required": [ - "x", - "y", - "width", - "height" - ], + "required": ["x", "y", "width", "height"], "properties": { "height": { "type": "number", "format": "float" }, "id": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "style": { "oneOf": [ @@ -2660,16 +2322,10 @@ ] }, "text": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "translation": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "width": { "type": "number", @@ -2689,10 +2345,7 @@ "type": "object", "properties": { "height": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" }, "style": { @@ -2706,46 +2359,28 @@ ] }, "text": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "translation": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "width": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" }, "x": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" }, "y": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" } } }, "TextDirection": { "type": "string", - "enum": [ - "Horizontal", - "Vertical" - ] + "enum": ["Horizontal", "Vertical"] }, "TextShaderEffect": { "type": "object", @@ -2773,20 +2408,14 @@ "type": "boolean" }, "widthPx": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" } } }, "TextStyle": { "type": "object", - "required": [ - "fontFamilies", - "color" - ], + "required": ["fontFamilies", "color"], "properties": { "color": { "type": "array", @@ -2813,10 +2442,7 @@ } }, "fontSize": { - "type": [ - "number", - "null" - ], + "type": ["number", "null"], "format": "float" }, "stroke": { @@ -2843,27 +2469,16 @@ }, "TransferStatus": { "type": "string", - "enum": [ - "started", - "downloading", - "completed", - "failed" - ] + "enum": ["started", "downloading", "completed", "failed"] }, "TranslateRequest": { "type": "object", "properties": { "language": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] }, "textBlockId": { - "type": [ - "string", - "null" - ] + "type": ["string", "null"] } } } diff --git a/ui/package.json b/ui/package.json index 96fa2b7e687b526361f4463127c0b442b816b15b..bfa98c3d9096ecb9a7cc338159859c9d18bf8032 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,6 +26,7 @@ "@tanstack/react-query": "^5.96.1", "@tanstack/react-virtual": "^3.13.23", "@tauri-apps/api": "^2.10.1", + "@tauri-apps/plugin-process": "^2.3.1", "@use-gesture/react": "^10.3.1", "@xstate/react": "^6.1.0", "browser-fs-access": "^0.38.0", diff --git a/ui/types.d.ts b/ui/types.d.ts index ec00d7bf37ba96278aa7db9712f989f401c5c4e6..f0688bdc403a2715f17ec8a6f9e02a5cf3aaeb37 100644 --- a/ui/types.d.ts +++ b/ui/types.d.ts @@ -78,4 +78,3 @@ export type InpaintRegion = { width: number height: number } -