fix: use keyring credential deletion API
Browse filesfeat: fix keyring, and add language support for api provider and language translation for api keys settings.
feat: add provider quota exceeded error handling and localization support
- Cargo.lock +77 -3
- koharu-ml/Cargo.toml +1 -0
- koharu-ml/src/llm/facade.rs +4 -1
- koharu-ml/src/llm/provider/claude.rs +5 -3
- koharu-ml/src/llm/provider/gemini.rs +5 -3
- koharu-ml/src/llm/provider/mod.rs +30 -0
- koharu-ml/src/llm/provider/openai.rs +5 -3
- koharu-pipeline/Cargo.toml +9 -0
- koharu-pipeline/src/ops/llm.rs +57 -8
- ui/app/(app)/settings/page.tsx +61 -8
- ui/app/providers.tsx +18 -0
- ui/components/canvas/CanvasToolbar.tsx +4 -3
- ui/lib/errors.ts +13 -6
- ui/lib/query/mutations.ts +28 -3
- ui/public/locales/en-US/translation.json +6 -2
- ui/public/locales/es-ES/translation.json +6 -2
- ui/public/locales/ja-JP/translation.json +6 -2
- ui/public/locales/ru-RU/translation.json +6 -2
- ui/public/locales/zh-CN/translation.json +6 -2
- ui/public/locales/zh-TW/translation.json +6 -2
Cargo.lock
CHANGED
|
@@ -1393,6 +1393,27 @@ version = "2.10.0"
|
|
| 1393 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1394 |
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
| 1395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1396 |
[[package]]
|
| 1397 |
name = "der"
|
| 1398 |
version = "0.7.10"
|
|
@@ -3773,6 +3794,21 @@ dependencies = [
|
|
| 3773 |
"unicode-segmentation",
|
| 3774 |
]
|
| 3775 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3776 |
[[package]]
|
| 3777 |
name = "koharu"
|
| 3778 |
version = "0.37.0"
|
|
@@ -3857,6 +3893,7 @@ dependencies = [
|
|
| 3857 |
"objc2-metal",
|
| 3858 |
"objc2-metal-performance-shaders",
|
| 3859 |
"objc2-metal-performance-shaders-graph",
|
|
|
|
| 3860 |
"rustfft",
|
| 3861 |
"serde",
|
| 3862 |
"serde_json",
|
|
@@ -3874,6 +3911,7 @@ dependencies = [
|
|
| 3874 |
"anyhow",
|
| 3875 |
"image",
|
| 3876 |
"imageproc",
|
|
|
|
| 3877 |
"koharu-api",
|
| 3878 |
"koharu-ml",
|
| 3879 |
"koharu-renderer",
|
|
@@ -4028,6 +4066,15 @@ version = "0.2.182"
|
|
| 4028 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4029 |
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
| 4030 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4031 |
[[package]]
|
| 4032 |
name = "libfuzzer-sys"
|
| 4033 |
version = "0.4.12"
|
|
@@ -4434,7 +4481,7 @@ dependencies = [
|
|
| 4434 |
"openssl-probe",
|
| 4435 |
"openssl-sys",
|
| 4436 |
"schannel",
|
| 4437 |
-
"security-framework",
|
| 4438 |
"security-framework-sys",
|
| 4439 |
"tempfile",
|
| 4440 |
]
|
|
@@ -6268,7 +6315,7 @@ dependencies = [
|
|
| 6268 |
"openssl-probe",
|
| 6269 |
"rustls-pki-types",
|
| 6270 |
"schannel",
|
| 6271 |
-
"security-framework",
|
| 6272 |
]
|
| 6273 |
|
| 6274 |
[[package]]
|
|
@@ -6296,7 +6343,7 @@ dependencies = [
|
|
| 6296 |
"rustls-native-certs",
|
| 6297 |
"rustls-platform-verifier-android",
|
| 6298 |
"rustls-webpki",
|
| 6299 |
-
"security-framework",
|
| 6300 |
"security-framework-sys",
|
| 6301 |
"webpki-root-certs",
|
| 6302 |
"windows-sys 0.61.2",
|
|
@@ -6457,6 +6504,19 @@ version = "1.2.0"
|
|
| 6457 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6458 |
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
| 6459 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6460 |
[[package]]
|
| 6461 |
name = "security-framework"
|
| 6462 |
version = "3.7.0"
|
|
@@ -9568,6 +9628,20 @@ name = "zeroize"
|
|
| 9568 |
version = "1.8.2"
|
| 9569 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9570 |
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9571 |
|
| 9572 |
[[package]]
|
| 9573 |
name = "zerotrie"
|
|
|
|
| 1393 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1394 |
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
| 1395 |
|
| 1396 |
+
[[package]]
|
| 1397 |
+
name = "dbus"
|
| 1398 |
+
version = "0.9.10"
|
| 1399 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1400 |
+
checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4"
|
| 1401 |
+
dependencies = [
|
| 1402 |
+
"libc",
|
| 1403 |
+
"libdbus-sys",
|
| 1404 |
+
"windows-sys 0.59.0",
|
| 1405 |
+
]
|
| 1406 |
+
|
| 1407 |
+
[[package]]
|
| 1408 |
+
name = "dbus-secret-service"
|
| 1409 |
+
version = "4.1.0"
|
| 1410 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 1411 |
+
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
| 1412 |
+
dependencies = [
|
| 1413 |
+
"dbus",
|
| 1414 |
+
"zeroize",
|
| 1415 |
+
]
|
| 1416 |
+
|
| 1417 |
[[package]]
|
| 1418 |
name = "der"
|
| 1419 |
version = "0.7.10"
|
|
|
|
| 3794 |
"unicode-segmentation",
|
| 3795 |
]
|
| 3796 |
|
| 3797 |
+
[[package]]
|
| 3798 |
+
name = "keyring"
|
| 3799 |
+
version = "3.6.3"
|
| 3800 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 3801 |
+
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
| 3802 |
+
dependencies = [
|
| 3803 |
+
"byteorder",
|
| 3804 |
+
"dbus-secret-service",
|
| 3805 |
+
"log",
|
| 3806 |
+
"security-framework 2.11.1",
|
| 3807 |
+
"security-framework 3.7.0",
|
| 3808 |
+
"windows-sys 0.60.2",
|
| 3809 |
+
"zeroize",
|
| 3810 |
+
]
|
| 3811 |
+
|
| 3812 |
[[package]]
|
| 3813 |
name = "koharu"
|
| 3814 |
version = "0.37.0"
|
|
|
|
| 3893 |
"objc2-metal",
|
| 3894 |
"objc2-metal-performance-shaders",
|
| 3895 |
"objc2-metal-performance-shaders-graph",
|
| 3896 |
+
"reqwest 0.13.2",
|
| 3897 |
"rustfft",
|
| 3898 |
"serde",
|
| 3899 |
"serde_json",
|
|
|
|
| 3911 |
"anyhow",
|
| 3912 |
"image",
|
| 3913 |
"imageproc",
|
| 3914 |
+
"keyring",
|
| 3915 |
"koharu-api",
|
| 3916 |
"koharu-ml",
|
| 3917 |
"koharu-renderer",
|
|
|
|
| 4066 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4067 |
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
| 4068 |
|
| 4069 |
+
[[package]]
|
| 4070 |
+
name = "libdbus-sys"
|
| 4071 |
+
version = "0.2.7"
|
| 4072 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 4073 |
+
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
| 4074 |
+
dependencies = [
|
| 4075 |
+
"pkg-config",
|
| 4076 |
+
]
|
| 4077 |
+
|
| 4078 |
[[package]]
|
| 4079 |
name = "libfuzzer-sys"
|
| 4080 |
version = "0.4.12"
|
|
|
|
| 4481 |
"openssl-probe",
|
| 4482 |
"openssl-sys",
|
| 4483 |
"schannel",
|
| 4484 |
+
"security-framework 3.7.0",
|
| 4485 |
"security-framework-sys",
|
| 4486 |
"tempfile",
|
| 4487 |
]
|
|
|
|
| 6315 |
"openssl-probe",
|
| 6316 |
"rustls-pki-types",
|
| 6317 |
"schannel",
|
| 6318 |
+
"security-framework 3.7.0",
|
| 6319 |
]
|
| 6320 |
|
| 6321 |
[[package]]
|
|
|
|
| 6343 |
"rustls-native-certs",
|
| 6344 |
"rustls-platform-verifier-android",
|
| 6345 |
"rustls-webpki",
|
| 6346 |
+
"security-framework 3.7.0",
|
| 6347 |
"security-framework-sys",
|
| 6348 |
"webpki-root-certs",
|
| 6349 |
"windows-sys 0.61.2",
|
|
|
|
| 6504 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6505 |
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
| 6506 |
|
| 6507 |
+
[[package]]
|
| 6508 |
+
name = "security-framework"
|
| 6509 |
+
version = "2.11.1"
|
| 6510 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 6511 |
+
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
| 6512 |
+
dependencies = [
|
| 6513 |
+
"bitflags 2.11.0",
|
| 6514 |
+
"core-foundation 0.9.4",
|
| 6515 |
+
"core-foundation-sys",
|
| 6516 |
+
"libc",
|
| 6517 |
+
"security-framework-sys",
|
| 6518 |
+
]
|
| 6519 |
+
|
| 6520 |
[[package]]
|
| 6521 |
name = "security-framework"
|
| 6522 |
version = "3.7.0"
|
|
|
|
| 9628 |
version = "1.8.2"
|
| 9629 |
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9630 |
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
|
| 9631 |
+
dependencies = [
|
| 9632 |
+
"zeroize_derive",
|
| 9633 |
+
]
|
| 9634 |
+
|
| 9635 |
+
[[package]]
|
| 9636 |
+
name = "zeroize_derive"
|
| 9637 |
+
version = "1.4.3"
|
| 9638 |
+
source = "registry+https://github.com/rust-lang/crates.io-index"
|
| 9639 |
+
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
|
| 9640 |
+
dependencies = [
|
| 9641 |
+
"proc-macro2",
|
| 9642 |
+
"quote",
|
| 9643 |
+
"syn 2.0.117",
|
| 9644 |
+
]
|
| 9645 |
|
| 9646 |
[[package]]
|
| 9647 |
name = "zerotrie"
|
koharu-ml/Cargo.toml
CHANGED
|
@@ -24,6 +24,7 @@ candle-nn = { workspace = true }
|
|
| 24 |
tokenizers = { workspace = true }
|
| 25 |
serde = { workspace = true }
|
| 26 |
serde_json = { workspace = true }
|
|
|
|
| 27 |
strum = { workspace = true }
|
| 28 |
tracing = { workspace = true }
|
| 29 |
tracing-subscriber = { workspace = true }
|
|
|
|
| 24 |
tokenizers = { workspace = true }
|
| 25 |
serde = { workspace = true }
|
| 26 |
serde_json = { workspace = true }
|
| 27 |
+
reqwest = { workspace = true }
|
| 28 |
strum = { workspace = true }
|
| 29 |
tracing = { workspace = true }
|
| 30 |
tracing-subscriber = { workspace = true }
|
koharu-ml/src/llm/facade.rs
CHANGED
|
@@ -42,7 +42,10 @@ impl ModelInfo {
|
|
| 42 |
pub fn api(provider_id: &'static str, model_id: &str) -> Self {
|
| 43 |
Self {
|
| 44 |
id: format!("{provider_id}:{model_id}"),
|
| 45 |
-
languages:
|
|
|
|
|
|
|
|
|
|
| 46 |
source: provider_id,
|
| 47 |
}
|
| 48 |
}
|
|
|
|
| 42 |
pub fn api(provider_id: &'static str, model_id: &str) -> Self {
|
| 43 |
Self {
|
| 44 |
id: format!("{provider_id}:{model_id}"),
|
| 45 |
+
languages: ["en-US", "zh-CN", "zh-TW", "ja-JP", "ru-RU", "es-ES"]
|
| 46 |
+
.into_iter()
|
| 47 |
+
.map(str::to_string)
|
| 48 |
+
.collect(),
|
| 49 |
source: provider_id,
|
| 50 |
}
|
| 51 |
}
|
koharu-ml/src/llm/provider/claude.rs
CHANGED
|
@@ -5,7 +5,7 @@ use serde::Serialize;
|
|
| 5 |
|
| 6 |
use koharu_http::http::http_client;
|
| 7 |
|
| 8 |
-
use super::{AnyProvider, system_prompt};
|
| 9 |
|
| 10 |
pub struct ClaudeProvider {
|
| 11 |
pub api_key: String,
|
|
@@ -43,15 +43,17 @@ impl AnyProvider for ClaudeProvider {
|
|
| 43 |
}],
|
| 44 |
};
|
| 45 |
|
| 46 |
-
let
|
| 47 |
.post("https://api.anthropic.com/v1/messages")
|
| 48 |
.header("x-api-key", &self.api_key)
|
| 49 |
.header("anthropic-version", "2023-06-01")
|
| 50 |
.header("content-type", "application/json")
|
| 51 |
.body(serde_json::to_vec(&body)?)
|
| 52 |
.send()
|
|
|
|
|
|
|
|
|
|
| 53 |
.await?
|
| 54 |
-
.error_for_status()?
|
| 55 |
.json()
|
| 56 |
.await?;
|
| 57 |
|
|
|
|
| 5 |
|
| 6 |
use koharu_http::http::http_client;
|
| 7 |
|
| 8 |
+
use super::{AnyProvider, ensure_provider_success, system_prompt};
|
| 9 |
|
| 10 |
pub struct ClaudeProvider {
|
| 11 |
pub api_key: String,
|
|
|
|
| 43 |
}],
|
| 44 |
};
|
| 45 |
|
| 46 |
+
let response = http_client()
|
| 47 |
.post("https://api.anthropic.com/v1/messages")
|
| 48 |
.header("x-api-key", &self.api_key)
|
| 49 |
.header("anthropic-version", "2023-06-01")
|
| 50 |
.header("content-type", "application/json")
|
| 51 |
.body(serde_json::to_vec(&body)?)
|
| 52 |
.send()
|
| 53 |
+
.await?;
|
| 54 |
+
|
| 55 |
+
let resp: serde_json::Value = ensure_provider_success("claude", response)
|
| 56 |
.await?
|
|
|
|
| 57 |
.json()
|
| 58 |
.await?;
|
| 59 |
|
koharu-ml/src/llm/provider/gemini.rs
CHANGED
|
@@ -5,7 +5,7 @@ use serde::Serialize;
|
|
| 5 |
|
| 6 |
use koharu_http::http::http_client;
|
| 7 |
|
| 8 |
-
use super::{AnyProvider, system_prompt};
|
| 9 |
|
| 10 |
pub struct GeminiProvider {
|
| 11 |
pub api_key: String,
|
|
@@ -58,13 +58,15 @@ impl AnyProvider for GeminiProvider {
|
|
| 58 |
}],
|
| 59 |
};
|
| 60 |
|
| 61 |
-
let
|
| 62 |
.post(&url)
|
| 63 |
.header("content-type", "application/json")
|
| 64 |
.body(serde_json::to_vec(&body)?)
|
| 65 |
.send()
|
|
|
|
|
|
|
|
|
|
| 66 |
.await?
|
| 67 |
-
.error_for_status()?
|
| 68 |
.json()
|
| 69 |
.await?;
|
| 70 |
|
|
|
|
| 5 |
|
| 6 |
use koharu_http::http::http_client;
|
| 7 |
|
| 8 |
+
use super::{AnyProvider, ensure_provider_success, system_prompt};
|
| 9 |
|
| 10 |
pub struct GeminiProvider {
|
| 11 |
pub api_key: String,
|
|
|
|
| 58 |
}],
|
| 59 |
};
|
| 60 |
|
| 61 |
+
let response = http_client()
|
| 62 |
.post(&url)
|
| 63 |
.header("content-type", "application/json")
|
| 64 |
.body(serde_json::to_vec(&body)?)
|
| 65 |
.send()
|
| 66 |
+
.await?;
|
| 67 |
+
|
| 68 |
+
let resp: serde_json::Value = ensure_provider_success("gemini", response)
|
| 69 |
.await?
|
|
|
|
| 70 |
.json()
|
| 71 |
.await?;
|
| 72 |
|
koharu-ml/src/llm/provider/mod.rs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
use std::future::Future;
|
| 2 |
use std::pin::Pin;
|
| 3 |
|
|
|
|
|
|
|
| 4 |
pub mod claude;
|
| 5 |
pub mod gemini;
|
| 6 |
pub mod openai;
|
|
@@ -14,6 +16,34 @@ pub fn system_prompt(target_language: &str) -> String {
|
|
| 14 |
SYSTEM_PROMPT_TEMPLATE.replace("{target_language}", target_language)
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
pub trait AnyProvider: Send + Sync {
|
| 18 |
fn translate<'a>(
|
| 19 |
&'a self,
|
|
|
|
| 1 |
use std::future::Future;
|
| 2 |
use std::pin::Pin;
|
| 3 |
|
| 4 |
+
use anyhow::Context;
|
| 5 |
+
|
| 6 |
pub mod claude;
|
| 7 |
pub mod gemini;
|
| 8 |
pub mod openai;
|
|
|
|
| 16 |
SYSTEM_PROMPT_TEMPLATE.replace("{target_language}", target_language)
|
| 17 |
}
|
| 18 |
|
| 19 |
+
pub async fn ensure_provider_success(
|
| 20 |
+
provider: &str,
|
| 21 |
+
response: reqwest::Response,
|
| 22 |
+
) -> anyhow::Result<reqwest::Response> {
|
| 23 |
+
let status = response.status();
|
| 24 |
+
if status.is_success() {
|
| 25 |
+
return Ok(response);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
let body = response
|
| 29 |
+
.text()
|
| 30 |
+
.await
|
| 31 |
+
.with_context(|| format!("Failed to read {provider} error response body"))?;
|
| 32 |
+
let body_lower = body.to_ascii_lowercase();
|
| 33 |
+
let quota_exceeded = status.as_u16() == 429
|
| 34 |
+
|| body_lower.contains("insufficient_quota")
|
| 35 |
+
|| body_lower.contains("quota")
|
| 36 |
+
|| body_lower.contains("resource_exhausted")
|
| 37 |
+
|| body_lower.contains("rate limit exceeded")
|
| 38 |
+
|| body_lower.contains("credit balance is too low");
|
| 39 |
+
|
| 40 |
+
if quota_exceeded {
|
| 41 |
+
anyhow::bail!("provider_quota_exceeded:{provider}");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
anyhow::bail!("{provider} API request failed ({status}): {body}");
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
pub trait AnyProvider: Send + Sync {
|
| 48 |
fn translate<'a>(
|
| 49 |
&'a self,
|
koharu-ml/src/llm/provider/openai.rs
CHANGED
|
@@ -5,7 +5,7 @@ use serde::Serialize;
|
|
| 5 |
|
| 6 |
use koharu_http::http::http_client;
|
| 7 |
|
| 8 |
-
use super::{AnyProvider, system_prompt};
|
| 9 |
|
| 10 |
pub struct OpenAiProvider {
|
| 11 |
pub api_key: String,
|
|
@@ -45,14 +45,16 @@ impl AnyProvider for OpenAiProvider {
|
|
| 45 |
],
|
| 46 |
};
|
| 47 |
|
| 48 |
-
let
|
| 49 |
.post("https://api.openai.com/v1/chat/completions")
|
| 50 |
.bearer_auth(&self.api_key)
|
| 51 |
.header("content-type", "application/json")
|
| 52 |
.body(serde_json::to_vec(&body)?)
|
| 53 |
.send()
|
|
|
|
|
|
|
|
|
|
| 54 |
.await?
|
| 55 |
-
.error_for_status()?
|
| 56 |
.json()
|
| 57 |
.await?;
|
| 58 |
|
|
|
|
| 5 |
|
| 6 |
use koharu_http::http::http_client;
|
| 7 |
|
| 8 |
+
use super::{AnyProvider, ensure_provider_success, system_prompt};
|
| 9 |
|
| 10 |
pub struct OpenAiProvider {
|
| 11 |
pub api_key: String,
|
|
|
|
| 45 |
],
|
| 46 |
};
|
| 47 |
|
| 48 |
+
let response = http_client()
|
| 49 |
.post("https://api.openai.com/v1/chat/completions")
|
| 50 |
.bearer_auth(&self.api_key)
|
| 51 |
.header("content-type", "application/json")
|
| 52 |
.body(serde_json::to_vec(&body)?)
|
| 53 |
.send()
|
| 54 |
+
.await?;
|
| 55 |
+
|
| 56 |
+
let resp: serde_json::Value = ensure_provider_success("openai", response)
|
| 57 |
.await?
|
|
|
|
| 58 |
.json()
|
| 59 |
.await?;
|
| 60 |
|
koharu-pipeline/Cargo.toml
CHANGED
|
@@ -29,3 +29,12 @@ strum = { workspace = true }
|
|
| 29 |
tokio = { workspace = true }
|
| 30 |
tracing = { workspace = true }
|
| 31 |
keyring = { workspace = true }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
tokio = { workspace = true }
|
| 30 |
tracing = { workspace = true }
|
| 31 |
keyring = { workspace = true }
|
| 32 |
+
|
| 33 |
+
[target.'cfg(windows)'.dependencies]
|
| 34 |
+
keyring = { workspace = true, features = ["windows-native"] }
|
| 35 |
+
|
| 36 |
+
[target.'cfg(target_os = "macos")'.dependencies]
|
| 37 |
+
keyring = { workspace = true, features = ["apple-native"] }
|
| 38 |
+
|
| 39 |
+
[target.'cfg(target_os = "linux")'.dependencies]
|
| 40 |
+
keyring = { workspace = true, features = ["sync-secret-service"] }
|
koharu-pipeline/src/ops/llm.rs
CHANGED
|
@@ -16,6 +16,11 @@ use crate::{AppResources, state_tx};
|
|
| 16 |
const API_KEY_SERVICE: &str = "koharu";
|
| 17 |
|
| 18 |
fn provider_key_entry(provider: &str) -> anyhow::Result<Entry> {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
let username = format!("llm-provider-api-key:{provider}");
|
| 20 |
Ok(Entry::new(API_KEY_SERVICE, &username)?)
|
| 21 |
}
|
|
@@ -24,32 +29,76 @@ pub(crate) fn get_saved_api_key(provider: &str) -> anyhow::Result<Option<String>
|
|
| 24 |
let entry = provider_key_entry(provider)?;
|
| 25 |
match entry.get_password() {
|
| 26 |
Ok(value) => Ok(Some(value)),
|
| 27 |
-
Err(keyring::Error::NoEntry) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
Err(err) => Err(err.into()),
|
| 29 |
}
|
| 30 |
}
|
| 31 |
|
| 32 |
fn set_saved_api_key(provider: &str, api_key: &str) -> anyhow::Result<()> {
|
| 33 |
let entry = provider_key_entry(provider)?;
|
|
|
|
| 34 |
if api_key.trim().is_empty() {
|
| 35 |
-
match entry.
|
| 36 |
-
Ok(()) | Err(keyring::Error::NoEntry) =>
|
| 37 |
-
Err(err) => Err(
|
| 38 |
}
|
|
|
|
|
|
|
| 39 |
} else {
|
| 40 |
entry.set_password(api_key)?;
|
|
|
|
| 41 |
Ok(())
|
| 42 |
}
|
| 43 |
}
|
| 44 |
|
|
|
|
| 45 |
pub async fn get_api_key(_state: AppResources, payload: ApiKeyGetPayload) -> anyhow::Result<ApiKeyResult> {
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
|
|
|
|
| 51 |
pub async fn set_api_key(_state: AppResources, payload: ApiKeySetPayload) -> anyhow::Result<()> {
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
pub async fn llm_list(
|
|
|
|
| 16 |
const API_KEY_SERVICE: &str = "koharu";
|
| 17 |
|
| 18 |
fn provider_key_entry(provider: &str) -> anyhow::Result<Entry> {
|
| 19 |
+
let username = format!("llm_provider_api_key_{provider}");
|
| 20 |
+
Ok(Entry::new(API_KEY_SERVICE, &username)?)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
fn legacy_provider_key_entry(provider: &str) -> anyhow::Result<Entry> {
|
| 24 |
let username = format!("llm-provider-api-key:{provider}");
|
| 25 |
Ok(Entry::new(API_KEY_SERVICE, &username)?)
|
| 26 |
}
|
|
|
|
| 29 |
let entry = provider_key_entry(provider)?;
|
| 30 |
match entry.get_password() {
|
| 31 |
Ok(value) => Ok(Some(value)),
|
| 32 |
+
Err(keyring::Error::NoEntry) => {
|
| 33 |
+
let legacy_entry = legacy_provider_key_entry(provider)?;
|
| 34 |
+
match legacy_entry.get_password() {
|
| 35 |
+
Ok(value) => {
|
| 36 |
+
let _ = entry.set_password(&value);
|
| 37 |
+
let _ = legacy_entry.delete_credential();
|
| 38 |
+
Ok(Some(value))
|
| 39 |
+
}
|
| 40 |
+
Err(keyring::Error::NoEntry) => Ok(None),
|
| 41 |
+
Err(err) => Err(err.into()),
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
Err(err) => Err(err.into()),
|
| 45 |
}
|
| 46 |
}
|
| 47 |
|
| 48 |
fn set_saved_api_key(provider: &str, api_key: &str) -> anyhow::Result<()> {
|
| 49 |
let entry = provider_key_entry(provider)?;
|
| 50 |
+
let legacy_entry = legacy_provider_key_entry(provider)?;
|
| 51 |
if api_key.trim().is_empty() {
|
| 52 |
+
match entry.delete_credential() {
|
| 53 |
+
Ok(()) | Err(keyring::Error::NoEntry) => {}
|
| 54 |
+
Err(err) => return Err(anyhow::Error::from(err)),
|
| 55 |
}
|
| 56 |
+
let _ = legacy_entry.delete_credential();
|
| 57 |
+
Ok(())
|
| 58 |
} else {
|
| 59 |
entry.set_password(api_key)?;
|
| 60 |
+
let _ = legacy_entry.delete_credential();
|
| 61 |
Ok(())
|
| 62 |
}
|
| 63 |
}
|
| 64 |
|
| 65 |
+
#[instrument(level = "info", skip_all, fields(provider = %payload.provider))]
|
| 66 |
pub async fn get_api_key(_state: AppResources, payload: ApiKeyGetPayload) -> anyhow::Result<ApiKeyResult> {
|
| 67 |
+
tracing::info!("loading API key from keyring");
|
| 68 |
+
match get_saved_api_key(&payload.provider) {
|
| 69 |
+
Ok(Some(key)) => {
|
| 70 |
+
tracing::info!(len = key.len(), "API key found in keyring");
|
| 71 |
+
Ok(ApiKeyResult { api_key: Some(key) })
|
| 72 |
+
}
|
| 73 |
+
Ok(None) => {
|
| 74 |
+
tracing::info!("no API key found in keyring");
|
| 75 |
+
Ok(ApiKeyResult { api_key: None })
|
| 76 |
+
}
|
| 77 |
+
Err(err) => {
|
| 78 |
+
tracing::error!(%err, "keyring read failed");
|
| 79 |
+
Err(err)
|
| 80 |
+
}
|
| 81 |
+
}
|
| 82 |
}
|
| 83 |
|
| 84 |
+
#[instrument(level = "info", skip_all, fields(provider = %payload.provider))]
|
| 85 |
pub async fn set_api_key(_state: AppResources, payload: ApiKeySetPayload) -> anyhow::Result<()> {
|
| 86 |
+
let is_empty = payload.api_key.trim().is_empty();
|
| 87 |
+
tracing::info!(is_empty, "saving API key to keyring");
|
| 88 |
+
match set_saved_api_key(&payload.provider, &payload.api_key) {
|
| 89 |
+
Ok(()) => {
|
| 90 |
+
if is_empty {
|
| 91 |
+
tracing::info!("API key deleted from keyring");
|
| 92 |
+
} else {
|
| 93 |
+
tracing::info!("API key saved to keyring");
|
| 94 |
+
}
|
| 95 |
+
Ok(())
|
| 96 |
+
}
|
| 97 |
+
Err(err) => {
|
| 98 |
+
tracing::error!(%err, "keyring write failed");
|
| 99 |
+
Err(err)
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
}
|
| 103 |
|
| 104 |
pub async fn llm_list(
|
ui/app/(app)/settings/page.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
-
import { useEffect, useMemo, useState } from 'react'
|
| 4 |
import { useTheme } from 'next-themes'
|
| 5 |
import { useTranslation } from 'react-i18next'
|
| 6 |
import Link from 'next/link'
|
|
@@ -33,9 +33,9 @@ const THEME_OPTIONS = [
|
|
| 33 |
] as const
|
| 34 |
|
| 35 |
const API_PROVIDERS = [
|
| 36 |
-
{ id: 'openai', name: 'OpenAI' },
|
| 37 |
-
{ id: 'gemini', name: 'Gemini' },
|
| 38 |
-
{ id: 'claude', name: 'Claude' },
|
| 39 |
] as const
|
| 40 |
|
| 41 |
export default function SettingsPage() {
|
|
@@ -49,6 +49,8 @@ export default function SettingsPage() {
|
|
| 49 |
const apiKeys = usePreferencesStore((state) => state.apiKeys)
|
| 50 |
const setApiKey = usePreferencesStore((state) => state.setApiKey)
|
| 51 |
const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({})
|
|
|
|
|
|
|
| 52 |
|
| 53 |
useEffect(() => {
|
| 54 |
if (!isTauri()) return
|
|
@@ -72,7 +74,9 @@ export default function SettingsPage() {
|
|
| 72 |
for (const { id } of API_PROVIDERS) {
|
| 73 |
try {
|
| 74 |
const key = await api.getApiKey(id)
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
} catch (error) {
|
| 77 |
console.error(`Failed to load API key for ${id}`, error)
|
| 78 |
}
|
|
@@ -82,9 +86,51 @@ export default function SettingsPage() {
|
|
| 82 |
void loadApiKeys()
|
| 83 |
}, [setApiKey])
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
const handleApiKeyChange = (provider: string, value: string) => {
|
| 86 |
setApiKey(provider, value)
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
return (
|
|
@@ -190,10 +236,10 @@ export default function SettingsPage() {
|
|
| 190 |
{/* API Keys Section */}
|
| 191 |
<section className='mb-8'>
|
| 192 |
<h2 className='text-foreground mb-1 text-sm font-bold'>
|
| 193 |
-
|
| 194 |
</h2>
|
| 195 |
<p className='text-muted-foreground mb-4 text-sm'>
|
| 196 |
-
|
| 197 |
</p>
|
| 198 |
<div className='space-y-3'>
|
| 199 |
{API_PROVIDERS.map(({ id, name }) => (
|
|
@@ -204,6 +250,7 @@ export default function SettingsPage() {
|
|
| 204 |
type={visibleKeys[id] ? 'text' : 'password'}
|
| 205 |
value={apiKeys[id] ?? ''}
|
| 206 |
onChange={(e) => handleApiKeyChange(id, e.target.value)}
|
|
|
|
| 207 |
placeholder='Enter API key'
|
| 208 |
className='border-border bg-card text-foreground placeholder:text-muted-foreground focus:ring-primary w-full rounded-md border px-3 py-1.5 pr-9 text-sm focus:ring-1 focus:outline-none'
|
| 209 |
/>
|
|
@@ -220,6 +267,12 @@ export default function SettingsPage() {
|
|
| 220 |
<EyeIcon className='size-4' />
|
| 221 |
)}
|
| 222 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
</div>
|
| 224 |
</div>
|
| 225 |
))}
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
| 4 |
import { useTheme } from 'next-themes'
|
| 5 |
import { useTranslation } from 'react-i18next'
|
| 6 |
import Link from 'next/link'
|
|
|
|
| 33 |
] as const
|
| 34 |
|
| 35 |
const API_PROVIDERS = [
|
| 36 |
+
{ id: 'openai', name: 'OpenAI', free_tier: false},
|
| 37 |
+
{ id: 'gemini', name: 'Gemini', free_tier: true},
|
| 38 |
+
{ id: 'claude', name: 'Claude', free_tier: false},
|
| 39 |
] as const
|
| 40 |
|
| 41 |
export default function SettingsPage() {
|
|
|
|
| 49 |
const apiKeys = usePreferencesStore((state) => state.apiKeys)
|
| 50 |
const setApiKey = usePreferencesStore((state) => state.setApiKey)
|
| 51 |
const [visibleKeys, setVisibleKeys] = useState<Record<string, boolean>>({})
|
| 52 |
+
const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
| 53 |
+
const pendingApiKeysRef = useRef<Record<string, string>>({})
|
| 54 |
|
| 55 |
useEffect(() => {
|
| 56 |
if (!isTauri()) return
|
|
|
|
| 74 |
for (const { id } of API_PROVIDERS) {
|
| 75 |
try {
|
| 76 |
const key = await api.getApiKey(id)
|
| 77 |
+
if (pendingApiKeysRef.current[id] === undefined) {
|
| 78 |
+
setApiKey(id, key ?? '')
|
| 79 |
+
}
|
| 80 |
} catch (error) {
|
| 81 |
console.error(`Failed to load API key for ${id}`, error)
|
| 82 |
}
|
|
|
|
| 86 |
void loadApiKeys()
|
| 87 |
}, [setApiKey])
|
| 88 |
|
| 89 |
+
const persistApiKey = async (provider: string, value: string) => {
|
| 90 |
+
try {
|
| 91 |
+
await api.setApiKey(provider, value)
|
| 92 |
+
} catch (error) {
|
| 93 |
+
console.error(`Failed to save API key for ${provider}`, error)
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
const flushApiKeySave = (provider: string) => {
|
| 98 |
+
const existingTimer = saveTimersRef.current[provider]
|
| 99 |
+
if (existingTimer) {
|
| 100 |
+
clearTimeout(existingTimer)
|
| 101 |
+
delete saveTimersRef.current[provider]
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const pendingValue = pendingApiKeysRef.current[provider]
|
| 105 |
+
if (pendingValue === undefined) {
|
| 106 |
+
return
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
delete pendingApiKeysRef.current[provider]
|
| 110 |
+
void persistApiKey(provider, pendingValue)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
return () => {
|
| 115 |
+
Object.keys(saveTimersRef.current).forEach((provider) => {
|
| 116 |
+
flushApiKeySave(provider)
|
| 117 |
+
})
|
| 118 |
+
}
|
| 119 |
+
}, [])
|
| 120 |
+
|
| 121 |
const handleApiKeyChange = (provider: string, value: string) => {
|
| 122 |
setApiKey(provider, value)
|
| 123 |
+
pendingApiKeysRef.current[provider] = value
|
| 124 |
+
|
| 125 |
+
const existingTimer = saveTimersRef.current[provider]
|
| 126 |
+
if (existingTimer) {
|
| 127 |
+
clearTimeout(existingTimer)
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
saveTimersRef.current[provider] = setTimeout(() => {
|
| 131 |
+
delete saveTimersRef.current[provider]
|
| 132 |
+
flushApiKeySave(provider)
|
| 133 |
+
}, 300)
|
| 134 |
}
|
| 135 |
|
| 136 |
return (
|
|
|
|
| 236 |
{/* API Keys Section */}
|
| 237 |
<section className='mb-8'>
|
| 238 |
<h2 className='text-foreground mb-1 text-sm font-bold'>
|
| 239 |
+
{t('settings.apiKeys')}
|
| 240 |
</h2>
|
| 241 |
<p className='text-muted-foreground mb-4 text-sm'>
|
| 242 |
+
{t('settings.apiKeysDescription')}
|
| 243 |
</p>
|
| 244 |
<div className='space-y-3'>
|
| 245 |
{API_PROVIDERS.map(({ id, name }) => (
|
|
|
|
| 250 |
type={visibleKeys[id] ? 'text' : 'password'}
|
| 251 |
value={apiKeys[id] ?? ''}
|
| 252 |
onChange={(e) => handleApiKeyChange(id, e.target.value)}
|
| 253 |
+
onBlur={() => flushApiKeySave(id)}
|
| 254 |
placeholder='Enter API key'
|
| 255 |
className='border-border bg-card text-foreground placeholder:text-muted-foreground focus:ring-primary w-full rounded-md border px-3 py-1.5 pr-9 text-sm focus:ring-1 focus:outline-none'
|
| 256 |
/>
|
|
|
|
| 267 |
<EyeIcon className='size-4' />
|
| 268 |
)}
|
| 269 |
</button>
|
| 270 |
+
|
| 271 |
+
{API_PROVIDERS.find((provider) => provider.id === id)?.free_tier && (
|
| 272 |
+
<span className='text-green-500 text-xs ml-2'>
|
| 273 |
+
{t('settings.freeTier')}
|
| 274 |
+
</span>
|
| 275 |
+
)}
|
| 276 |
</div>
|
| 277 |
</div>
|
| 278 |
))}
|
ui/app/providers.tsx
CHANGED
|
@@ -18,6 +18,8 @@ import { api, parseProcessProgress } from '@/lib/api'
|
|
| 18 |
import { useDownloadStore } from '@/lib/downloads'
|
| 19 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 20 |
import { useOperationStore } from '@/lib/stores/operationStore'
|
|
|
|
|
|
|
| 21 |
|
| 22 |
export function Providers({ children }: { children: ReactNode }) {
|
| 23 |
const [mounted, setMounted] = useState(false)
|
|
@@ -31,6 +33,22 @@ export function Providers({ children }: { children: ReactNode }) {
|
|
| 31 |
ensureDownloadSubscribed()
|
| 32 |
}, [ensureDownloadSubscribed])
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
useEffect(() => {
|
| 35 |
let unlisten: (() => void) | undefined
|
| 36 |
;(async () => {
|
|
|
|
| 18 |
import { useDownloadStore } from '@/lib/downloads'
|
| 19 |
import { useEditorUiStore } from '@/lib/stores/editorUiStore'
|
| 20 |
import { useOperationStore } from '@/lib/stores/operationStore'
|
| 21 |
+
import { usePreferencesStore } from '@/lib/stores/preferencesStore'
|
| 22 |
+
import { isTauri } from '@/lib/backend'
|
| 23 |
|
| 24 |
export function Providers({ children }: { children: ReactNode }) {
|
| 25 |
const [mounted, setMounted] = useState(false)
|
|
|
|
| 33 |
ensureDownloadSubscribed()
|
| 34 |
}, [ensureDownloadSubscribed])
|
| 35 |
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (!isTauri()) return
|
| 38 |
+
const setApiKey = usePreferencesStore.getState().setApiKey
|
| 39 |
+
void (async () => {
|
| 40 |
+
const providers = ['openai', 'gemini', 'claude']
|
| 41 |
+
for (const provider of providers) {
|
| 42 |
+
try {
|
| 43 |
+
const key = await api.getApiKey(provider)
|
| 44 |
+
setApiKey(provider, key ?? '')
|
| 45 |
+
} catch (error) {
|
| 46 |
+
console.error(`[providers] Failed to load API key for ${provider}`, error)
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
})()
|
| 50 |
+
}, [])
|
| 51 |
+
|
| 52 |
useEffect(() => {
|
| 53 |
let unlisten: (() => void) | undefined
|
| 54 |
;(async () => {
|
ui/components/canvas/CanvasToolbar.tsx
CHANGED
|
@@ -269,8 +269,7 @@ function LlmStatusPopover() {
|
|
| 269 |
</p>
|
| 270 |
)}
|
| 271 |
|
| 272 |
-
{
|
| 273 |
-
{!isApiModel && activeLanguages.length > 0 && (
|
| 274 |
<Select
|
| 275 |
value={llmSelectedLanguage ?? activeLanguages[0]}
|
| 276 |
onValueChange={llmSetSelectedLanguage}
|
|
@@ -288,7 +287,9 @@ function LlmStatusPopover() {
|
|
| 288 |
value={language}
|
| 289 |
data-testid={`llm-language-option-${index}`}
|
| 290 |
>
|
| 291 |
-
{language
|
|
|
|
|
|
|
| 292 |
</SelectItem>
|
| 293 |
))}
|
| 294 |
</SelectContent>
|
|
|
|
| 269 |
</p>
|
| 270 |
)}
|
| 271 |
|
| 272 |
+
{activeLanguages.length > 0 && (
|
|
|
|
| 273 |
<Select
|
| 274 |
value={llmSelectedLanguage ?? activeLanguages[0]}
|
| 275 |
onValueChange={llmSetSelectedLanguage}
|
|
|
|
| 287 |
value={language}
|
| 288 |
data-testid={`llm-language-option-${index}`}
|
| 289 |
>
|
| 290 |
+
{language.includes('-')
|
| 291 |
+
? t(`menu.languages.${language}`)
|
| 292 |
+
: language}
|
| 293 |
</SelectItem>
|
| 294 |
))}
|
| 295 |
</SelectContent>
|
ui/lib/errors.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useUiErrorStore } from '@/lib/stores/uiErrorStore'
|
|
|
|
| 4 |
|
| 5 |
const SURFACED_RPC_METHODS = new Set([
|
| 6 |
'open_documents',
|
|
@@ -24,13 +25,19 @@ const SURFACED_RPC_METHODS = new Set([
|
|
| 24 |
])
|
| 25 |
|
| 26 |
export const normalizeErrorMessage = (error: unknown) => {
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
-
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
export const reportRpcError = (method: string, error: unknown) => {
|
|
|
|
| 1 |
'use client'
|
| 2 |
|
| 3 |
import { useUiErrorStore } from '@/lib/stores/uiErrorStore'
|
| 4 |
+
import i18n from '@/lib/i18n'
|
| 5 |
|
| 6 |
const SURFACED_RPC_METHODS = new Set([
|
| 7 |
'open_documents',
|
|
|
|
| 25 |
])
|
| 26 |
|
| 27 |
export const normalizeErrorMessage = (error: unknown) => {
|
| 28 |
+
const rawMessage =
|
| 29 |
+
error instanceof Error
|
| 30 |
+
? error.message
|
| 31 |
+
: typeof error === 'string'
|
| 32 |
+
? error
|
| 33 |
+
: 'Unexpected error'
|
| 34 |
+
|
| 35 |
+
if (rawMessage.startsWith('provider_quota_exceeded:')) {
|
| 36 |
+
const provider = rawMessage.split(':', 2)[1] ?? 'provider'
|
| 37 |
+
return i18n.t('errors.providerQuotaExceeded', { provider })
|
| 38 |
}
|
| 39 |
+
|
| 40 |
+
return rawMessage
|
| 41 |
}
|
| 42 |
|
| 43 |
export const reportRpcError = (method: string, error: unknown) => {
|
ui/lib/query/mutations.ts
CHANGED
|
@@ -49,6 +49,25 @@ const findModelLanguages = (
|
|
| 49 |
modelId?: string,
|
| 50 |
) => models.find((model) => model.id === modelId)?.languages ?? []
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
const pickLanguage = (
|
| 53 |
models: { id: string; languages: string[] }[],
|
| 54 |
modelId?: string,
|
|
@@ -437,6 +456,7 @@ export const useDocumentMutations = () => {
|
|
| 437 |
try {
|
| 438 |
const models = getCachedLlmModels(queryClient)
|
| 439 |
const modelInfo = models.find((m) => m.id === selectedModel)
|
|
|
|
| 440 |
const llmApiKey =
|
| 441 |
modelInfo && modelInfo.source !== 'local'
|
| 442 |
? usePreferencesStore.getState().apiKeys[modelInfo.source]
|
|
@@ -445,7 +465,7 @@ export const useDocumentMutations = () => {
|
|
| 445 |
index: resolvedIndex,
|
| 446 |
llmModelId: selectedModel,
|
| 447 |
llmApiKey,
|
| 448 |
-
language
|
| 449 |
shaderEffect: renderEffect,
|
| 450 |
shaderStroke: renderStroke,
|
| 451 |
fontFamily,
|
|
@@ -475,6 +495,7 @@ export const useDocumentMutations = () => {
|
|
| 475 |
try {
|
| 476 |
const models = getCachedLlmModels(queryClient)
|
| 477 |
const modelInfo = models.find((m) => m.id === selectedModel)
|
|
|
|
| 478 |
const llmApiKey =
|
| 479 |
modelInfo && modelInfo.source !== 'local'
|
| 480 |
? usePreferencesStore.getState().apiKeys[modelInfo.source]
|
|
@@ -482,7 +503,7 @@ export const useDocumentMutations = () => {
|
|
| 482 |
await api.process({
|
| 483 |
llmModelId: selectedModel,
|
| 484 |
llmApiKey,
|
| 485 |
-
language
|
| 486 |
shaderEffect: renderEffect,
|
| 487 |
shaderStroke: renderStroke,
|
| 488 |
fontFamily,
|
|
@@ -639,7 +660,11 @@ export const useLlmMutations = () => {
|
|
| 639 |
: languages[0]
|
| 640 |
: undefined
|
| 641 |
|
| 642 |
-
await api.llmGenerate(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
await invalidateCurrentDocument(queryClient, resolvedIndex)
|
| 644 |
useEditorUiStore.getState().setShowTextBlocksOverlay(true)
|
| 645 |
if (typeof textBlockIndex === 'number') {
|
|
|
|
| 49 |
modelId?: string,
|
| 50 |
) => models.find((model) => model.id === modelId)?.languages ?? []
|
| 51 |
|
| 52 |
+
const apiLanguageToBackendName = (language?: string) => {
|
| 53 |
+
switch (language) {
|
| 54 |
+
case 'en-US':
|
| 55 |
+
return 'English'
|
| 56 |
+
case 'zh-CN':
|
| 57 |
+
return '简体中文'
|
| 58 |
+
case 'zh-TW':
|
| 59 |
+
return '繁體中文'
|
| 60 |
+
case 'ja-JP':
|
| 61 |
+
return '日本語'
|
| 62 |
+
case 'ru-RU':
|
| 63 |
+
return 'Русский'
|
| 64 |
+
case 'es-ES':
|
| 65 |
+
return 'Español'
|
| 66 |
+
default:
|
| 67 |
+
return language
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
const pickLanguage = (
|
| 72 |
models: { id: string; languages: string[] }[],
|
| 73 |
modelId?: string,
|
|
|
|
| 456 |
try {
|
| 457 |
const models = getCachedLlmModels(queryClient)
|
| 458 |
const modelInfo = models.find((m) => m.id === selectedModel)
|
| 459 |
+
const language = apiLanguageToBackendName(selectedLanguage)
|
| 460 |
const llmApiKey =
|
| 461 |
modelInfo && modelInfo.source !== 'local'
|
| 462 |
? usePreferencesStore.getState().apiKeys[modelInfo.source]
|
|
|
|
| 465 |
index: resolvedIndex,
|
| 466 |
llmModelId: selectedModel,
|
| 467 |
llmApiKey,
|
| 468 |
+
language,
|
| 469 |
shaderEffect: renderEffect,
|
| 470 |
shaderStroke: renderStroke,
|
| 471 |
fontFamily,
|
|
|
|
| 495 |
try {
|
| 496 |
const models = getCachedLlmModels(queryClient)
|
| 497 |
const modelInfo = models.find((m) => m.id === selectedModel)
|
| 498 |
+
const language = apiLanguageToBackendName(selectedLanguage)
|
| 499 |
const llmApiKey =
|
| 500 |
modelInfo && modelInfo.source !== 'local'
|
| 501 |
? usePreferencesStore.getState().apiKeys[modelInfo.source]
|
|
|
|
| 503 |
await api.process({
|
| 504 |
llmModelId: selectedModel,
|
| 505 |
llmApiKey,
|
| 506 |
+
language,
|
| 507 |
shaderEffect: renderEffect,
|
| 508 |
shaderStroke: renderStroke,
|
| 509 |
fontFamily,
|
|
|
|
| 660 |
: languages[0]
|
| 661 |
: undefined
|
| 662 |
|
| 663 |
+
await api.llmGenerate(
|
| 664 |
+
resolvedIndex,
|
| 665 |
+
textBlockIndex,
|
| 666 |
+
apiLanguageToBackendName(language),
|
| 667 |
+
)
|
| 668 |
await invalidateCurrentDocument(queryClient, resolvedIndex)
|
| 669 |
useEditorUiStore.getState().setShowTextBlocksOverlay(true)
|
| 670 |
if (typeof textBlockIndex === 'number') {
|
ui/public/locales/en-US/translation.json
CHANGED
|
@@ -159,7 +159,8 @@
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "Error",
|
| 162 |
-
"dismiss": "Dismiss"
|
|
|
|
| 163 |
},
|
| 164 |
"settings": {
|
| 165 |
"title": "Settings",
|
|
@@ -182,6 +183,9 @@
|
|
| 182 |
"aboutRepository": "Repository",
|
| 183 |
"device": "Device",
|
| 184 |
"deviceDescription": "Hardware acceleration information.",
|
| 185 |
-
"deviceMl": "ML Compute"
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "Error",
|
| 162 |
+
"dismiss": "Dismiss",
|
| 163 |
+
"providerQuotaExceeded": "{{provider}} API quota exceeded. Check your billing or wait for your quota to reset."
|
| 164 |
},
|
| 165 |
"settings": {
|
| 166 |
"title": "Settings",
|
|
|
|
| 183 |
"aboutRepository": "Repository",
|
| 184 |
"device": "Device",
|
| 185 |
"deviceDescription": "Hardware acceleration information.",
|
| 186 |
+
"deviceMl": "ML Compute",
|
| 187 |
+
"apiKeys": "API Keys",
|
| 188 |
+
"apiKeysDescription": "Manage your API keys for different providers. Your keys are stored securely on your device keychain and are never shared with anyone.",
|
| 189 |
+
"freeTier": "Free Tier"
|
| 190 |
}
|
| 191 |
}
|
ui/public/locales/es-ES/translation.json
CHANGED
|
@@ -159,7 +159,8 @@
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "Error",
|
| 162 |
-
"dismiss": "Cerrar"
|
|
|
|
| 163 |
},
|
| 164 |
"settings": {
|
| 165 |
"title": "Configuración",
|
|
@@ -182,6 +183,9 @@
|
|
| 182 |
"aboutRepository": "Repositorio",
|
| 183 |
"device": "Dispositivo",
|
| 184 |
"deviceDescription": "Información sobre aceleración por hardware.",
|
| 185 |
-
"deviceMl": "Cómputo ML"
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "Error",
|
| 162 |
+
"dismiss": "Cerrar",
|
| 163 |
+
"providerQuotaExceeded": "Se agotó la cuota de API de {{provider}}. Revisa la facturación o espera a que se restablezca la cuota."
|
| 164 |
},
|
| 165 |
"settings": {
|
| 166 |
"title": "Configuración",
|
|
|
|
| 183 |
"aboutRepository": "Repositorio",
|
| 184 |
"device": "Dispositivo",
|
| 185 |
"deviceDescription": "Información sobre aceleración por hardware.",
|
| 186 |
+
"deviceMl": "Cómputo ML",
|
| 187 |
+
"apiKeys": "Claves API",
|
| 188 |
+
"apiKeysDescription": "Gestiona tus claves API para diferentes proveedores. Tus claves se almacenan de forma segura en el llavero de tu dispositivo y nunca se comparten con nadie.",
|
| 189 |
+
"freeTier": "Nivel gratuito"
|
| 190 |
}
|
| 191 |
}
|
ui/public/locales/ja-JP/translation.json
CHANGED
|
@@ -159,7 +159,8 @@
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "エラー",
|
| 162 |
-
"dismiss": "閉じる"
|
|
|
|
| 163 |
},
|
| 164 |
"settings": {
|
| 165 |
"title": "設定",
|
|
@@ -182,6 +183,9 @@
|
|
| 182 |
"aboutRepository": "リポジトリ",
|
| 183 |
"device": "デバイス",
|
| 184 |
"deviceDescription": "ハードウェアアクセラレーション情報",
|
| 185 |
-
"deviceMl": "ML 演算"
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "エラー",
|
| 162 |
+
"dismiss": "閉じる",
|
| 163 |
+
"providerQuotaExceeded": "{{provider}} の API クォータを超過しました。課金状況を確認するか、クォータのリセットを待ってください。"
|
| 164 |
},
|
| 165 |
"settings": {
|
| 166 |
"title": "設定",
|
|
|
|
| 183 |
"aboutRepository": "リポジトリ",
|
| 184 |
"device": "デバイス",
|
| 185 |
"deviceDescription": "ハードウェアアクセラレーション情報",
|
| 186 |
+
"deviceMl": "ML 演算",
|
| 187 |
+
"apiKeys": "API キー",
|
| 188 |
+
"apiKeysDescription": "異なるプロバイダーの API キーを管理します。キーはデバイスのキーチェーンに安全に保存され、他人と共有されることはありません。",
|
| 189 |
+
"freeTier": "無料プラン"
|
| 190 |
}
|
| 191 |
}
|
ui/public/locales/ru-RU/translation.json
CHANGED
|
@@ -159,7 +159,8 @@
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "Ошибка",
|
| 162 |
-
"dismiss": "Закрыть"
|
|
|
|
| 163 |
},
|
| 164 |
"settings": {
|
| 165 |
"title": "Настройки",
|
|
@@ -182,6 +183,9 @@
|
|
| 182 |
"aboutRepository": "Репозиторий",
|
| 183 |
"device": "Устройство",
|
| 184 |
"deviceDescription": "Информация об аппаратном ускорении.",
|
| 185 |
-
"deviceMl": "ML Compute"
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "Ошибка",
|
| 162 |
+
"dismiss": "Закрыть",
|
| 163 |
+
"providerQuotaExceeded": "Квота API для {{provider}} исчерпана. Проверьте биллинг или дождитесь сброса квоты."
|
| 164 |
},
|
| 165 |
"settings": {
|
| 166 |
"title": "Настройки",
|
|
|
|
| 183 |
"aboutRepository": "Репозиторий",
|
| 184 |
"device": "Устройство",
|
| 185 |
"deviceDescription": "Информация об аппаратном ускорении.",
|
| 186 |
+
"deviceMl": "ML Compute",
|
| 187 |
+
"apiKeys": "API ключи",
|
| 188 |
+
"apiKeysDescription": "Управляйте своими API ключами для различных провайдеров. Ваши ключи надежно хранятся в связке ключей вашего устройства и никогда не передаются другим лицам.",
|
| 189 |
+
"freeTier": "Бесплатный уровень"
|
| 190 |
}
|
| 191 |
}
|
ui/public/locales/zh-CN/translation.json
CHANGED
|
@@ -159,7 +159,8 @@
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "错误",
|
| 162 |
-
"dismiss": "关闭"
|
|
|
|
| 163 |
},
|
| 164 |
"settings": {
|
| 165 |
"title": "设置",
|
|
@@ -182,6 +183,9 @@
|
|
| 182 |
"aboutRepository": "代码仓库",
|
| 183 |
"device": "设备",
|
| 184 |
"deviceDescription": "硬件加速信息",
|
| 185 |
-
"deviceMl": "ML 计算"
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "错误",
|
| 162 |
+
"dismiss": "关闭",
|
| 163 |
+
"providerQuotaExceeded": "{{provider}} API 配额已用尽。请检查计费状态或等待配额重置。"
|
| 164 |
},
|
| 165 |
"settings": {
|
| 166 |
"title": "设置",
|
|
|
|
| 183 |
"aboutRepository": "代码仓库",
|
| 184 |
"device": "设备",
|
| 185 |
"deviceDescription": "硬件加速信息",
|
| 186 |
+
"deviceMl": "ML 计算",
|
| 187 |
+
"apiKeys": "API 密钥",
|
| 188 |
+
"apiKeysDescription": "管理您在不同提供商的 API 密钥。您的密钥会安全地存储在设备的密钥链中,绝不会与他人共享。",
|
| 189 |
+
"freeTier": "免费等级"
|
| 190 |
}
|
| 191 |
}
|
ui/public/locales/zh-TW/translation.json
CHANGED
|
@@ -159,7 +159,8 @@
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "錯誤",
|
| 162 |
-
"dismiss": "關閉"
|
|
|
|
| 163 |
},
|
| 164 |
"settings": {
|
| 165 |
"title": "設定",
|
|
@@ -182,6 +183,9 @@
|
|
| 182 |
"aboutRepository": "原始碼",
|
| 183 |
"device": "裝置",
|
| 184 |
"deviceDescription": "硬體加速資訊",
|
| 185 |
-
"deviceMl": "ML 運算"
|
|
|
|
|
|
|
|
|
|
| 186 |
}
|
| 187 |
}
|
|
|
|
| 159 |
},
|
| 160 |
"errors": {
|
| 161 |
"title": "錯誤",
|
| 162 |
+
"dismiss": "關閉",
|
| 163 |
+
"providerQuotaExceeded": "{{provider}} API 配額已用盡。請檢查計費狀態或等待配額重置。"
|
| 164 |
},
|
| 165 |
"settings": {
|
| 166 |
"title": "設定",
|
|
|
|
| 183 |
"aboutRepository": "原始碼",
|
| 184 |
"device": "裝置",
|
| 185 |
"deviceDescription": "硬體加速資訊",
|
| 186 |
+
"deviceMl": "ML 運算",
|
| 187 |
+
"apiKeys": "API 密鑰",
|
| 188 |
+
"apiKeysDescription": "管理您在不同提供商的 API 密鑰。您的密鑰會安全地存儲在設備的密鑰鏈中,絕不會與他人共享。",
|
| 189 |
+
"freeTier": "免費等級"
|
| 190 |
}
|
| 191 |
}
|