mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 06:24:32 +08:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 621be9a466 | |||
| 937978d68a | |||
| 298d19af89 | |||
| 5fcddbd096 | |||
| 00a94e118f | |||
| c09e0378b9 | |||
| 30004037e5 | |||
| d6ed95078c | |||
| e27b8ee31f | |||
| 3d7b056df1 | |||
| 69f3c78bbf | |||
| 9b1c659f07 | |||
| e1720a0dd1 | |||
| 85998600a1 | |||
| 2b1fd0582e | |||
| dbdaf35770 | |||
| c6f4a54c98 | |||
| 7d495aa772 | |||
| db8180aa31 | |||
| 1586451862 | |||
| bf570b6d2a | |||
| 26c3f05daf | |||
| d303706d51 | |||
| 97495d1550 | |||
| a1537807eb | |||
| f047960a33 | |||
| ace9b38cee | |||
| b67cdbb18c | |||
| c4f1e90893 | |||
| ddbff070d5 | |||
| ca7cb398c2 | |||
| 2fb3b5405a | |||
| ec649e7718 | |||
| 3da5525c79 | |||
| 44ca688253 | |||
| 5fe5ed98be | |||
| b2a9e91d70 | |||
| 4a1a997935 | |||
| c4535c894a | |||
| 64e0cabaa7 | |||
| 8ecb41d25e | |||
| 3e8f84481d | |||
| ba59483b33 | |||
| b6ff721d67 | |||
| 1706c9a26f | |||
| 5bce6d6020 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cc-switch",
|
"name": "cc-switch",
|
||||||
"version": "3.9.0-1",
|
"version": "3.9.0-2",
|
||||||
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm tauri dev",
|
"dev": "pnpm tauri dev",
|
||||||
|
|||||||
Generated
+57
-5
@@ -586,6 +586,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -695,7 +701,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-1"
|
version = "3.9.0-2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -2214,7 +2220,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2330,6 +2336,19 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png 0.18.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -2759,6 +2778,16 @@ dependencies = [
|
|||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.7.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -2774,7 +2803,7 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation 0.3.1",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -3563,6 +3592,19 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.4",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -3694,6 +3736,15 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.37.5"
|
version = "0.37.5"
|
||||||
@@ -4987,6 +5038,7 @@ dependencies = [
|
|||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
"http-range",
|
"http-range",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -5055,7 +5107,7 @@ dependencies = [
|
|||||||
"ico",
|
"ico",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"plist",
|
"plist",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -5810,7 +5862,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation 0.3.1",
|
"objc2-foundation 0.3.1",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"windows-sys 0.59.0",
|
"windows-sys 0.59.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "cc-switch"
|
name = "cc-switch"
|
||||||
version = "3.9.0-1"
|
version = "3.9.0-2"
|
||||||
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
|
||||||
authors = ["Jason Young"]
|
authors = ["Jason Young"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -26,7 +26,7 @@ serde_json = "1.0"
|
|||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset"] }
|
tauri = { version = "2.8.2", features = ["tray-icon", "protocol-asset", "image-png"] }
|
||||||
tauri-plugin-log = "2"
|
tauri-plugin-log = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-process = "2"
|
tauri-plugin-process = "2"
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
@@ -105,6 +105,55 @@ pub fn read_mcp_json() -> Result<Option<String>, AppError> {
|
|||||||
Ok(Some(content))
|
Ok(Some(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 在 ~/.claude.json 根对象写入 hasCompletedOnboarding=true(用于跳过 Claude Code 初次安装确认)
|
||||||
|
/// 仅增量写入该字段,其他字段保持不变
|
||||||
|
pub fn set_has_completed_onboarding() -> Result<bool, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
let mut root = if path.exists() {
|
||||||
|
read_json_value(&path)?
|
||||||
|
} else {
|
||||||
|
serde_json::json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||||
|
|
||||||
|
let already = obj
|
||||||
|
.get("hasCompletedOnboarding")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if already {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.insert("hasCompletedOnboarding".into(), Value::Bool(true));
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除 ~/.claude.json 根对象的 hasCompletedOnboarding 字段(恢复 Claude Code 初次安装确认)
|
||||||
|
/// 仅增量删除该字段,其他字段保持不变
|
||||||
|
pub fn clear_has_completed_onboarding() -> Result<bool, AppError> {
|
||||||
|
let path = user_config_path();
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut root = read_json_value(&path)?;
|
||||||
|
let obj = root
|
||||||
|
.as_object_mut()
|
||||||
|
.ok_or_else(|| AppError::Config("~/.claude.json 根必须是对象".into()))?;
|
||||||
|
|
||||||
|
let existed = obj.remove("hasCompletedOnboarding").is_some();
|
||||||
|
if !existed {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
write_json_value(&path, &root)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
pub fn upsert_mcp_server(id: &str, spec: Value) -> Result<bool, AppError> {
|
||||||
if id.trim().is_empty() {
|
if id.trim().is_empty() {
|
||||||
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
return Err(AppError::InvalidInput("MCP 服务器 ID 不能为空".into()));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! 故障转移队列命令
|
//! 故障转移队列命令
|
||||||
//!
|
//!
|
||||||
//! 管理代理模式下的故障转移队列
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
use crate::database::FailoverQueueItem;
|
use crate::database::FailoverQueueItem;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -56,29 +56,47 @@ pub async fn remove_from_failover_queue(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 重新排序故障转移队列
|
/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reorder_failover_queue(
|
pub async fn get_auto_failover_enabled(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
provider_ids: Vec<String>,
|
) -> Result<bool, String> {
|
||||||
) -> Result<(), String> {
|
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.reorder_failover_queue(&app_type, &provider_ids)
|
.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map(|config| config.auto_failover_enabled)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置故障转移队列项的启用状态
|
/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表)
|
||||||
|
///
|
||||||
|
/// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn set_failover_item_enabled(
|
pub async fn set_auto_failover_enabled(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
provider_id: String,
|
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
log::info!(
|
||||||
|
"[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 读取当前配置
|
||||||
|
let mut config = state
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 更新 auto_failover_enabled 字段
|
||||||
|
config.auto_failover_enabled = enabled;
|
||||||
|
|
||||||
|
// 写回数据库
|
||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_failover_item_enabled(&app_type, &provider_id, enabled)
|
.update_proxy_config_for_app(config)
|
||||||
|
.await
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ use crate::init_status::InitErrorPayload;
|
|||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
|
||||||
/// 打开外部链接
|
/// 打开外部链接
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
pub async fn open_external(app: AppHandle, url: String) -> Result<bool, String> {
|
||||||
@@ -142,11 +148,16 @@ fn extract_version(raw: &str) -> String {
|
|||||||
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
let output = if cfg!(target_os = "windows") {
|
#[cfg(target_os = "windows")]
|
||||||
|
let output = {
|
||||||
Command::new("cmd")
|
Command::new("cmd")
|
||||||
.args(["/C", &format!("{tool} --version")])
|
.args(["/C", &format!("{tool} --version")])
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
.output()
|
.output()
|
||||||
} else {
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
Command::new("sh")
|
Command::new("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
.arg(format!("{tool} --version"))
|
.arg(format!("{tool} --version"))
|
||||||
@@ -155,11 +166,17 @@ fn try_get_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
|
|
||||||
match output {
|
match output {
|
||||||
Ok(out) => {
|
Ok(out) => {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
(Some(extract_version(&raw)), None)
|
if raw.is_empty() {
|
||||||
|
(None, Some("未安装或无法执行".to_string()))
|
||||||
|
} else {
|
||||||
|
(Some(extract_version(raw)), None)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let err = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
let err = if stderr.is_empty() { stdout } else { stderr };
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
Some(if err.is_empty() {
|
Some(if err.is_empty() {
|
||||||
@@ -233,15 +250,31 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
|
|||||||
let current_path = std::env::var("PATH").unwrap_or_default();
|
let current_path = std::env::var("PATH").unwrap_or_default();
|
||||||
let new_path = format!("{}:{}", path.display(), current_path);
|
let new_path = format!("{}:{}", path.display(), current_path);
|
||||||
|
|
||||||
let output = Command::new(&tool_path)
|
#[cfg(target_os = "windows")]
|
||||||
.arg("--version")
|
let output = {
|
||||||
.env("PATH", &new_path)
|
Command::new(&tool_path)
|
||||||
.output();
|
.arg("--version")
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let output = {
|
||||||
|
Command::new(&tool_path)
|
||||||
|
.arg("--version")
|
||||||
|
.env("PATH", &new_path)
|
||||||
|
.output()
|
||||||
|
};
|
||||||
|
|
||||||
if let Ok(out) = output {
|
if let Ok(out) = output {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
|
||||||
if out.status.success() {
|
if out.status.success() {
|
||||||
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
let raw = if stdout.is_empty() { &stderr } else { &stdout };
|
||||||
return (Some(extract_version(&raw)), None);
|
if !raw.is_empty() {
|
||||||
|
return (Some(extract_version(raw)), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,3 +34,15 @@ pub async fn apply_claude_plugin_config(official: bool) -> Result<bool, String>
|
|||||||
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
pub async fn is_claude_plugin_applied() -> Result<bool, String> {
|
||||||
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
crate::claude_plugin::is_claude_config_applied().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Claude Code:跳过初次安装确认(写入 ~/.claude.json 的 hasCompletedOnboarding=true)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn apply_claude_onboarding_skip() -> Result<bool, String> {
|
||||||
|
crate::claude_mcp::set_has_completed_onboarding().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude Code:恢复初次安装确认(删除 ~/.claude.json 的 hasCompletedOnboarding 字段)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_claude_onboarding_skip() -> Result<bool, String> {
|
||||||
|
crate::claude_mcp::clear_has_completed_onboarding().map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,15 +11,7 @@ use crate::store::AppState;
|
|||||||
pub async fn start_proxy_server(
|
pub async fn start_proxy_server(
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
) -> Result<ProxyServerInfo, String> {
|
) -> Result<ProxyServerInfo, String> {
|
||||||
state.proxy_service.start(true).await
|
state.proxy_service.start().await
|
||||||
}
|
|
||||||
|
|
||||||
/// 启动代理服务器(带 Live 配置接管)
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn start_proxy_with_takeover(
|
|
||||||
state: tauri::State<'_, AppState>,
|
|
||||||
) -> Result<ProxyServerInfo, String> {
|
|
||||||
state.proxy_service.start_with_takeover().await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止代理服务器(恢复 Live 配置)
|
/// 停止代理服务器(恢复 Live 配置)
|
||||||
@@ -70,6 +62,63 @@ pub async fn update_proxy_config(
|
|||||||
state.proxy_service.update_config(&config).await
|
state.proxy_service.update_config(&config).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Global & Per-App Config ====================
|
||||||
|
|
||||||
|
/// 获取全局代理配置
|
||||||
|
///
|
||||||
|
/// 返回统一的全局配置字段(代理开关、监听地址、端口、日志开关)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_global_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<GlobalProxyConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_global_proxy_config()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新全局代理配置
|
||||||
|
///
|
||||||
|
/// 更新统一的全局配置字段,会同时更新三行(claude/codex/gemini)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_global_proxy_config(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: GlobalProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_global_proxy_config(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取指定应用的代理配置
|
||||||
|
///
|
||||||
|
/// 返回应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_proxy_config_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
app_type: String,
|
||||||
|
) -> Result<AppProxyConfig, String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.get_proxy_config_for_app(&app_type)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新指定应用的代理配置
|
||||||
|
///
|
||||||
|
/// 更新应用级配置(enabled、auto_failover、超时、熔断器等)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_proxy_config_for_app(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
config: AppProxyConfig,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let db = &state.db;
|
||||||
|
db.update_proxy_config_for_app(config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// 检查代理服务器是否正在运行
|
/// 检查代理服务器是否正在运行
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result<bool, String> {
|
||||||
@@ -111,8 +160,13 @@ pub async fn get_provider_health(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 重置熔断器
|
/// 重置熔断器
|
||||||
|
///
|
||||||
|
/// 重置后会检查是否应该切回队列中优先级更高的供应商:
|
||||||
|
/// 1. 检查自动故障转移是否开启
|
||||||
|
/// 2. 如果恢复的供应商在队列中优先级更高(queue_order 更小),则自动切换
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reset_circuit_breaker(
|
pub async fn reset_circuit_breaker(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
state: tauri::State<'_, AppState>,
|
state: tauri::State<'_, AppState>,
|
||||||
provider_id: String,
|
provider_id: String,
|
||||||
app_type: String,
|
app_type: String,
|
||||||
@@ -129,6 +183,68 @@ pub async fn reset_circuit_breaker(
|
|||||||
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
.reset_provider_circuit_breaker(&provider_id, &app_type)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
|
||||||
|
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
|
||||||
|
Ok(config) => config.auto_failover_enabled,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if auto_failover_enabled && state.proxy_service.is_running().await {
|
||||||
|
// 获取当前供应商 ID
|
||||||
|
let current_id = db
|
||||||
|
.get_current_provider(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
if let Some(current_id) = current_id {
|
||||||
|
// 获取故障转移队列
|
||||||
|
let queue = db
|
||||||
|
.get_failover_queue(&app_type)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// 找到恢复的供应商和当前供应商在队列中的位置(使用 sort_index)
|
||||||
|
let restored_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == provider_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
let current_order = queue
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.provider_id == current_id)
|
||||||
|
.and_then(|item| item.sort_index);
|
||||||
|
|
||||||
|
// 如果恢复的供应商优先级更高(sort_index 更小),则切换
|
||||||
|
if let (Some(restored), Some(current)) = (restored_order, current_order) {
|
||||||
|
if restored < current {
|
||||||
|
log::info!(
|
||||||
|
"[Recovery] 供应商 {provider_id} 已恢复且优先级更高 (P{restored} vs P{current}),自动切换"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取供应商名称用于日志和事件
|
||||||
|
let provider_name = db
|
||||||
|
.get_all_providers(&app_type)
|
||||||
|
.ok()
|
||||||
|
.and_then(|providers| providers.get(&provider_id).map(|p| p.name.clone()))
|
||||||
|
.unwrap_or_else(|| provider_id.clone());
|
||||||
|
|
||||||
|
// 创建故障转移切换管理器并执行切换
|
||||||
|
let switch_manager =
|
||||||
|
crate::proxy::failover_switch::FailoverSwitchManager::new(db.clone());
|
||||||
|
if let Err(e) = switch_manager
|
||||||
|
.try_switch(Some(&app_handle), &app_type, &provider_id, &provider_name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("[Recovery] 自动切换失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,9 +52,7 @@ pub async fn stream_check_all_providers(
|
|||||||
}
|
}
|
||||||
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
if let Ok(queue) = state.db.get_failover_queue(app_type.as_str()) {
|
||||||
for item in queue {
|
for item in queue {
|
||||||
if item.enabled {
|
ids.insert(item.provider_id);
|
||||||
ids.insert(item.provider_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(ids)
|
Some(ids)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ use std::fs;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
const CC_SWITCH_SQL_EXPORT_HEADER: &str = "-- CC Switch SQLite 导出";
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 导出为 SQLite 兼容的 SQL 文本
|
/// 导出为 SQLite 兼容的 SQL 文本
|
||||||
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
pub fn export_sql(&self, target_path: &Path) -> Result<(), AppError> {
|
||||||
@@ -36,7 +38,8 @@ impl Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
let sql_raw = fs::read_to_string(source_path).map_err(|e| AppError::io(source_path, e))?;
|
||||||
let sql_content = Self::sanitize_import_sql(&sql_raw);
|
let sql_content = sql_raw.trim_start_matches('\u{feff}');
|
||||||
|
Self::validate_cc_switch_sql_export(sql_content)?;
|
||||||
|
|
||||||
// 导入前备份现有数据库
|
// 导入前备份现有数据库
|
||||||
let backup_path = self.backup_database_file()?;
|
let backup_path = self.backup_database_file()?;
|
||||||
@@ -51,7 +54,7 @@ impl Database {
|
|||||||
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
|
Connection::open(&temp_path).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
temp_conn
|
temp_conn
|
||||||
.execute_batch(&sql_content)
|
.execute_batch(sql_content)
|
||||||
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
.map_err(|e| AppError::Database(format!("执行 SQL 导入失败: {e}")))?;
|
||||||
|
|
||||||
// 补齐缺失表/索引并进行基础校验
|
// 补齐缺失表/索引并进行基础校验
|
||||||
@@ -93,26 +96,17 @@ impl Database {
|
|||||||
Ok(snapshot)
|
Ok(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 移除 SQLite 保留对象相关语句(如 sqlite_sequence),避免导入报错
|
fn validate_cc_switch_sql_export(sql: &str) -> Result<(), AppError> {
|
||||||
fn sanitize_import_sql(sql: &str) -> String {
|
let trimmed = sql.trim_start();
|
||||||
let mut cleaned = String::new();
|
if trimmed.starts_with(CC_SWITCH_SQL_EXPORT_HEADER) {
|
||||||
let lower_keyword = "sqlite_sequence";
|
return Ok(());
|
||||||
|
|
||||||
for stmt in sql.split(';') {
|
|
||||||
let trimmed = stmt.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if trimmed.to_ascii_lowercase().contains(lower_keyword) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleaned.push_str(trimmed);
|
|
||||||
cleaned.push_str(";\n");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cleaned
|
Err(AppError::localized(
|
||||||
|
"backup.sql.invalid_format",
|
||||||
|
"仅支持导入由 CC Switch 导出的 SQL 备份文件。",
|
||||||
|
"Only SQL backups exported by CC Switch are supported.",
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
/// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None)
|
||||||
@@ -129,8 +123,15 @@ impl Database {
|
|||||||
|
|
||||||
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?;
|
||||||
|
|
||||||
let backup_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S"));
|
||||||
let backup_path = backup_dir.join(format!("{backup_id}.db"));
|
let mut backup_id = base_id.clone();
|
||||||
|
let mut backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||||
|
let mut counter = 1;
|
||||||
|
while backup_path.exists() {
|
||||||
|
backup_id = format!("{base_id}_{counter}");
|
||||||
|
backup_path = backup_dir.join(format!("{backup_id}.db"));
|
||||||
|
counter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|||||||
@@ -1,36 +1,32 @@
|
|||||||
//! 故障转移队列 DAO
|
//! 故障转移队列 DAO
|
||||||
//!
|
//!
|
||||||
//! 管理代理模式下的故障转移队列
|
//! 管理代理模式下的故障转移队列(基于 providers 表的 in_failover_queue 字段)
|
||||||
|
|
||||||
use crate::database::{lock_conn, Database};
|
use crate::database::{lock_conn, Database};
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// 故障转移队列条目
|
/// 故障转移队列条目(简化版,用于前端展示)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct FailoverQueueItem {
|
pub struct FailoverQueueItem {
|
||||||
pub provider_id: String,
|
pub provider_id: String,
|
||||||
pub provider_name: String,
|
pub provider_name: String,
|
||||||
pub queue_order: i32,
|
pub sort_index: Option<usize>,
|
||||||
pub enabled: bool,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
/// 获取故障转移队列(按 queue_order 排序)
|
/// 获取故障转移队列(按 sort_index 排序)
|
||||||
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
pub fn get_failover_queue(&self, app_type: &str) -> Result<Vec<FailoverQueueItem>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let mut stmt = conn
|
let mut stmt = conn
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT fq.provider_id, p.name, fq.queue_order, fq.enabled, fq.created_at
|
"SELECT id, name, sort_index
|
||||||
FROM failover_queue fq
|
FROM providers
|
||||||
JOIN providers p ON fq.provider_id = p.id AND fq.app_type = p.app_type
|
WHERE app_type = ?1 AND in_failover_queue = 1
|
||||||
WHERE fq.app_type = ?1
|
ORDER BY COALESCE(sort_index, 999999), id ASC",
|
||||||
ORDER BY fq.queue_order ASC",
|
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
@@ -39,9 +35,7 @@ impl Database {
|
|||||||
Ok(FailoverQueueItem {
|
Ok(FailoverQueueItem {
|
||||||
provider_id: row.get(0)?,
|
provider_id: row.get(0)?,
|
||||||
provider_name: row.get(1)?,
|
provider_name: row.get(1)?,
|
||||||
queue_order: row.get(2)?,
|
sort_index: row.get(2)?,
|
||||||
enabled: row.get(3)?,
|
|
||||||
created_at: row.get(4)?,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?
|
.map_err(|e| AppError::Database(e.to_string()))?
|
||||||
@@ -53,43 +47,23 @@ impl Database {
|
|||||||
|
|
||||||
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
/// 获取故障转移队列中的供应商(完整 Provider 信息,按顺序)
|
||||||
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
pub fn get_failover_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
let queue = self.get_failover_queue(app_type)?;
|
|
||||||
let all_providers = self.get_all_providers(app_type)?;
|
let all_providers = self.get_all_providers(app_type)?;
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let result: Vec<Provider> = all_providers
|
||||||
for item in queue {
|
.into_values()
|
||||||
if item.enabled {
|
.filter(|p| p.in_failover_queue)
|
||||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
.collect();
|
||||||
result.push(provider.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 添加供应商到故障转移队列末尾
|
/// 添加供应商到故障转移队列
|
||||||
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
pub fn add_to_failover_queue(&self, app_type: &str, provider_id: &str) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
// 获取当前最大 queue_order
|
|
||||||
let max_order: i32 = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT COALESCE(MAX(queue_order), 0) FROM failover_queue WHERE app_type = ?1",
|
|
||||||
[app_type],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs() as i64;
|
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO failover_queue (app_type, provider_id, queue_order, enabled, created_at)
|
"UPDATE providers SET in_failover_queue = 1 WHERE id = ?1 AND app_type = ?2",
|
||||||
VALUES (?1, ?2, ?3, 1, ?4)",
|
rusqlite::params![provider_id, app_type],
|
||||||
rusqlite::params![app_type, provider_id, max_order + 1, now],
|
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
@@ -104,90 +78,22 @@ impl Database {
|
|||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
// 获取被删除项的 queue_order
|
// 1. 从队列中移除
|
||||||
let removed_order: Option<i32> = conn
|
|
||||||
.query_row(
|
|
||||||
"SELECT queue_order FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
[app_type, provider_id],
|
|
||||||
|row| row.get(0),
|
|
||||||
)
|
|
||||||
.ok();
|
|
||||||
|
|
||||||
// 删除该项
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"DELETE FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
"UPDATE providers SET in_failover_queue = 0 WHERE id = ?1 AND app_type = ?2",
|
||||||
[app_type, provider_id],
|
rusqlite::params![provider_id, app_type],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 重新排序后面的项(填补空隙)
|
// 2. 清除该供应商的健康状态(退出队列后不再需要健康监控)
|
||||||
if let Some(order) = removed_order {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE failover_queue
|
|
||||||
SET queue_order = queue_order - 1
|
|
||||||
WHERE app_type = ?1 AND queue_order > ?2",
|
|
||||||
rusqlite::params![app_type, order],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重新排序故障转移队列
|
|
||||||
/// provider_ids: 按新顺序排列的 provider_id 列表
|
|
||||||
pub fn reorder_failover_queue(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_ids: &[String],
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
// 使用事务确保原子性
|
|
||||||
conn.execute("BEGIN TRANSACTION", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
let result = (|| {
|
|
||||||
for (index, provider_id) in provider_ids.iter().enumerate() {
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE failover_queue
|
|
||||||
SET queue_order = ?3
|
|
||||||
WHERE app_type = ?1 AND provider_id = ?2",
|
|
||||||
rusqlite::params![app_type, provider_id, (index + 1) as i32],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
})();
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(_) => {
|
|
||||||
conn.execute("COMMIT", [])
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
conn.execute("ROLLBACK", []).ok();
|
|
||||||
Err(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置故障转移队列中供应商的启用状态
|
|
||||||
pub fn set_failover_item_enabled(
|
|
||||||
&self,
|
|
||||||
app_type: &str,
|
|
||||||
provider_id: &str,
|
|
||||||
enabled: bool,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let conn = lock_conn!(self.conn);
|
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE failover_queue SET enabled = ?3 WHERE app_type = ?1 AND provider_id = ?2",
|
"DELETE FROM provider_health WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
rusqlite::params![app_type, provider_id, enabled],
|
rusqlite::params![provider_id, app_type],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::info!("已从故障转移队列移除供应商 {provider_id} ({app_type}), 并清除其健康状态");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,8 +101,11 @@ impl Database {
|
|||||||
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
pub fn clear_failover_queue(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.execute("DELETE FROM failover_queue WHERE app_type = ?1", [app_type])
|
conn.execute(
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
"UPDATE providers SET in_failover_queue = 0 WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -209,15 +118,15 @@ impl Database {
|
|||||||
) -> Result<bool, AppError> {
|
) -> Result<bool, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
let count: i32 = conn
|
let in_queue: bool = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT COUNT(*) FROM failover_queue WHERE app_type = ?1 AND provider_id = ?2",
|
"SELECT in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
[app_type, provider_id],
|
rusqlite::params![provider_id, app_type],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.unwrap_or(false);
|
||||||
|
|
||||||
Ok(count > 0)
|
Ok(in_queue)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
/// 获取可添加到故障转移队列的供应商(不在队列中的)
|
||||||
@@ -226,14 +135,10 @@ impl Database {
|
|||||||
app_type: &str,
|
app_type: &str,
|
||||||
) -> Result<Vec<Provider>, AppError> {
|
) -> Result<Vec<Provider>, AppError> {
|
||||||
let all_providers = self.get_all_providers(app_type)?;
|
let all_providers = self.get_all_providers(app_type)?;
|
||||||
let queue = self.get_failover_queue(app_type)?;
|
|
||||||
|
|
||||||
let queue_ids: std::collections::HashSet<_> =
|
|
||||||
queue.iter().map(|item| &item.provider_id).collect();
|
|
||||||
|
|
||||||
let available: Vec<Provider> = all_providers
|
let available: Vec<Provider> = all_providers
|
||||||
.into_values()
|
.into_values()
|
||||||
.filter(|p| !queue_ids.contains(&p.id))
|
.filter(|p| !p.in_failover_queue)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(available)
|
Ok(available)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ impl Database {
|
|||||||
) -> Result<IndexMap<String, Provider>, AppError> {
|
) -> Result<IndexMap<String, Provider>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
"SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
FROM providers WHERE app_type = ?1
|
FROM providers WHERE app_type = ?1
|
||||||
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
ORDER BY COALESCE(sort_index, 999999), created_at ASC, id ASC"
|
||||||
).map_err(|e| AppError::Database(e.to_string()))?;
|
).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -35,6 +35,7 @@ impl Database {
|
|||||||
let icon: Option<String> = row.get(8)?;
|
let icon: Option<String> = row.get(8)?;
|
||||||
let icon_color: Option<String> = row.get(9)?;
|
let icon_color: Option<String> = row.get(9)?;
|
||||||
let meta_str: String = row.get(10)?;
|
let meta_str: String = row.get(10)?;
|
||||||
|
let in_failover_queue: bool = row.get(11)?;
|
||||||
|
|
||||||
let settings_config =
|
let settings_config =
|
||||||
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
@@ -54,6 +55,7 @@ impl Database {
|
|||||||
meta: Some(meta),
|
meta: Some(meta),
|
||||||
icon,
|
icon,
|
||||||
icon_color,
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
@@ -129,7 +131,7 @@ impl Database {
|
|||||||
) -> Result<Option<Provider>, AppError> {
|
) -> Result<Option<Provider>, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
let result = conn.query_row(
|
let result = conn.query_row(
|
||||||
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta
|
"SELECT name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue
|
||||||
FROM providers WHERE id = ?1 AND app_type = ?2",
|
FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
params![id, app_type],
|
params![id, app_type],
|
||||||
|row| {
|
|row| {
|
||||||
@@ -143,6 +145,7 @@ impl Database {
|
|||||||
let icon: Option<String> = row.get(7)?;
|
let icon: Option<String> = row.get(7)?;
|
||||||
let icon_color: Option<String> = row.get(8)?;
|
let icon_color: Option<String> = row.get(8)?;
|
||||||
let meta_str: String = row.get(9)?;
|
let meta_str: String = row.get(9)?;
|
||||||
|
let in_failover_queue: bool = row.get(10)?;
|
||||||
|
|
||||||
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
let settings_config = serde_json::from_str(&settings_config_str).unwrap_or(serde_json::Value::Null);
|
||||||
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
let meta: ProviderMeta = serde_json::from_str(&meta_str).unwrap_or_default();
|
||||||
@@ -159,6 +162,7 @@ impl Database {
|
|||||||
meta: Some(meta),
|
meta: Some(meta),
|
||||||
icon,
|
icon,
|
||||||
icon_color,
|
icon_color,
|
||||||
|
in_failover_queue,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -184,17 +188,18 @@ impl Database {
|
|||||||
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
let mut meta_clone = provider.meta.clone().unwrap_or_default();
|
||||||
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
let endpoints = std::mem::take(&mut meta_clone.custom_endpoints);
|
||||||
|
|
||||||
// 检查是否存在(用于判断新增/更新,以及保留 is_current)
|
// 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue)
|
||||||
let existing: Option<bool> = tx
|
let existing: Option<(bool, bool)> = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT is_current FROM providers WHERE id = ?1 AND app_type = ?2",
|
"SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2",
|
||||||
params![provider.id, app_type],
|
params![provider.id, app_type],
|
||||||
|row| row.get(0),
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
)
|
)
|
||||||
.ok();
|
.ok();
|
||||||
|
|
||||||
let is_update = existing.is_some();
|
let is_update = existing.is_some();
|
||||||
let is_current = existing.unwrap_or(false);
|
let (is_current, in_failover_queue) =
|
||||||
|
existing.unwrap_or((false, provider.in_failover_queue));
|
||||||
|
|
||||||
if is_update {
|
if is_update {
|
||||||
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
// 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE
|
||||||
@@ -210,8 +215,9 @@ impl Database {
|
|||||||
icon = ?8,
|
icon = ?8,
|
||||||
icon_color = ?9,
|
icon_color = ?9,
|
||||||
meta = ?10,
|
meta = ?10,
|
||||||
is_current = ?11
|
is_current = ?11,
|
||||||
WHERE id = ?12 AND app_type = ?13",
|
in_failover_queue = ?12
|
||||||
|
WHERE id = ?13 AND app_type = ?14",
|
||||||
params![
|
params![
|
||||||
provider.name,
|
provider.name,
|
||||||
serde_json::to_string(&provider.settings_config).unwrap(),
|
serde_json::to_string(&provider.settings_config).unwrap(),
|
||||||
@@ -224,6 +230,7 @@ impl Database {
|
|||||||
provider.icon_color,
|
provider.icon_color,
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
is_current,
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
],
|
],
|
||||||
@@ -234,8 +241,8 @@ impl Database {
|
|||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO providers (
|
"INSERT INTO providers (
|
||||||
id, app_type, name, settings_config, website_url, category,
|
id, app_type, name, settings_config, website_url, category,
|
||||||
created_at, sort_index, notes, icon, icon_color, meta, is_current
|
created_at, sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||||
params![
|
params![
|
||||||
provider.id,
|
provider.id,
|
||||||
app_type,
|
app_type,
|
||||||
@@ -250,6 +257,7 @@ impl Database {
|
|||||||
provider.icon_color,
|
provider.icon_color,
|
||||||
serde_json::to_string(&meta_clone).unwrap(),
|
serde_json::to_string(&meta_clone).unwrap(),
|
||||||
is_current,
|
is_current,
|
||||||
|
in_failover_queue,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|||||||
@@ -8,63 +8,66 @@ use crate::proxy::types::*;
|
|||||||
use super::super::{lock_conn, Database};
|
use super::super::{lock_conn, Database};
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
// ==================== Proxy Config ====================
|
// ==================== Global Proxy Config ====================
|
||||||
|
|
||||||
/// 获取代理配置
|
/// 获取全局代理配置(统一字段)
|
||||||
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
///
|
||||||
// 在一个作用域内获取锁并查询,确保锁在await之前释放
|
/// 从 claude 行读取(三行镜像一致)
|
||||||
|
pub async fn get_global_proxy_config(&self) -> Result<GlobalProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
let result = {
|
let result = {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT enabled, listen_address, listen_port, max_retries,
|
"SELECT proxy_enabled, listen_address, listen_port, enable_logging
|
||||||
request_timeout, enable_logging, live_takeover_active
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
FROM proxy_config WHERE id = 1",
|
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(ProxyConfig {
|
Ok(GlobalProxyConfig {
|
||||||
enabled: row.get::<_, i32>(0)? != 0,
|
proxy_enabled: row.get::<_, i32>(0)? != 0,
|
||||||
listen_address: row.get(1)?,
|
listen_address: row.get(1)?,
|
||||||
listen_port: row.get::<_, i32>(2)? as u16,
|
listen_port: row.get::<_, i32>(2)? as u16,
|
||||||
max_retries: row.get::<_, i32>(3)? as u8,
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
request_timeout: row.get::<_, i32>(4)? as u64,
|
|
||||||
enable_logging: row.get::<_, i32>(5)? != 0,
|
|
||||||
live_takeover_active: row.get::<_, i32>(6).unwrap_or(0) != 0,
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}; // conn锁在这里释放
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(config) => Ok(config),
|
Ok(config) => Ok(config),
|
||||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
// 如果不存在,插入默认配置
|
// 如果不存在,创建默认配置
|
||||||
let default_config = ProxyConfig::default();
|
self.init_proxy_config_rows().await?;
|
||||||
self.update_proxy_config(default_config.clone()).await?;
|
Ok(GlobalProxyConfig {
|
||||||
Ok(default_config)
|
proxy_enabled: false,
|
||||||
|
listen_address: "127.0.0.1".to_string(),
|
||||||
|
listen_port: 5000,
|
||||||
|
enable_logging: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Err(e) => Err(AppError::Database(e.to_string())),
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新代理配置
|
/// 更新全局代理配置(镜像写三行)
|
||||||
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
pub async fn update_global_proxy_config(
|
||||||
|
&self,
|
||||||
|
config: GlobalProxyConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO proxy_config
|
"UPDATE proxy_config SET
|
||||||
(id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at)
|
proxy_enabled = ?1,
|
||||||
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
listen_address = ?2,
|
||||||
COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')),
|
listen_port = ?3,
|
||||||
datetime('now'))",
|
enable_logging = ?4,
|
||||||
|
updated_at = datetime('now')",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
if config.enabled { 1 } else { 0 },
|
if config.proxy_enabled { 1 } else { 0 },
|
||||||
config.listen_address,
|
config.listen_address,
|
||||||
config.listen_port as i32,
|
config.listen_port as i32,
|
||||||
config.max_retries as i32,
|
|
||||||
config.request_timeout as i32,
|
|
||||||
if config.enable_logging { 1 } else { 0 },
|
if config.enable_logging { 1 } else { 0 },
|
||||||
if config.live_takeover_active { 1 } else { 0 },
|
|
||||||
"claude", // 兼容旧字段,写入默认值
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -72,21 +75,214 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 设置 Live 接管状态
|
/// 获取应用级代理配置
|
||||||
pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> {
|
pub async fn get_proxy_config_for_app(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
) -> Result<AppProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let app_type_owned = app_type.to_string();
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT app_type, enabled, auto_failover_enabled,
|
||||||
|
max_retries, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
|
FROM proxy_config WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
|row| {
|
||||||
|
Ok(AppProxyConfig {
|
||||||
|
app_type: row.get(0)?,
|
||||||
|
enabled: row.get::<_, i32>(1)? != 0,
|
||||||
|
auto_failover_enabled: row.get::<_, i32>(2)? != 0,
|
||||||
|
max_retries: row.get::<_, i32>(3)? as u32,
|
||||||
|
streaming_first_byte_timeout: row.get::<_, i32>(4)? as u32,
|
||||||
|
streaming_idle_timeout: row.get::<_, i32>(5)? as u32,
|
||||||
|
non_streaming_timeout: row.get::<_, i32>(6)? as u32,
|
||||||
|
circuit_failure_threshold: row.get::<_, i32>(7)? as u32,
|
||||||
|
circuit_success_threshold: row.get::<_, i32>(8)? as u32,
|
||||||
|
circuit_timeout_seconds: row.get::<_, i32>(9)? as u32,
|
||||||
|
circuit_error_rate_threshold: row.get(10)?,
|
||||||
|
circuit_min_requests: row.get::<_, i32>(11)? as u32,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,创建默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(AppProxyConfig {
|
||||||
|
app_type: app_type_owned,
|
||||||
|
enabled: false,
|
||||||
|
auto_failover_enabled: false,
|
||||||
|
max_retries: 3,
|
||||||
|
streaming_first_byte_timeout: 30,
|
||||||
|
streaming_idle_timeout: 60,
|
||||||
|
non_streaming_timeout: 300,
|
||||||
|
circuit_failure_threshold: 5,
|
||||||
|
circuit_success_threshold: 2,
|
||||||
|
circuit_timeout_seconds: 60,
|
||||||
|
circuit_error_rate_threshold: 0.5,
|
||||||
|
circuit_min_requests: 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新应用级代理配置
|
||||||
|
pub async fn update_proxy_config_for_app(
|
||||||
|
&self,
|
||||||
|
config: AppProxyConfig,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1",
|
"UPDATE proxy_config SET
|
||||||
rusqlite::params![if active { 1 } else { 0 }],
|
enabled = ?2,
|
||||||
|
auto_failover_enabled = ?3,
|
||||||
|
max_retries = ?4,
|
||||||
|
streaming_first_byte_timeout = ?5,
|
||||||
|
streaming_idle_timeout = ?6,
|
||||||
|
non_streaming_timeout = ?7,
|
||||||
|
circuit_failure_threshold = ?8,
|
||||||
|
circuit_success_threshold = ?9,
|
||||||
|
circuit_timeout_seconds = ?10,
|
||||||
|
circuit_error_rate_threshold = ?11,
|
||||||
|
circuit_min_requests = ?12,
|
||||||
|
updated_at = datetime('now')
|
||||||
|
WHERE app_type = ?1",
|
||||||
|
rusqlite::params![
|
||||||
|
config.app_type,
|
||||||
|
if config.enabled { 1 } else { 0 },
|
||||||
|
if config.auto_failover_enabled { 1 } else { 0 },
|
||||||
|
config.max_retries as i32,
|
||||||
|
config.streaming_first_byte_timeout as i32,
|
||||||
|
config.streaming_idle_timeout as i32,
|
||||||
|
config.non_streaming_timeout as i32,
|
||||||
|
config.circuit_failure_threshold as i32,
|
||||||
|
config.circuit_success_threshold as i32,
|
||||||
|
config.circuit_timeout_seconds as i32,
|
||||||
|
config.circuit_error_rate_threshold,
|
||||||
|
config.circuit_min_requests as i32,
|
||||||
|
],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化 proxy_config 表的三行数据
|
||||||
|
async fn init_proxy_config_rows(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
for app_type in &["claude", "codex", "gemini"] {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES (?1)",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Legacy Proxy Config (兼容旧代码) ====================
|
||||||
|
|
||||||
|
/// 获取代理配置(兼容旧接口,返回 claude 行的配置)
|
||||||
|
pub async fn get_proxy_config(&self) -> Result<ProxyConfig, AppError> {
|
||||||
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.query_row(
|
||||||
|
"SELECT listen_address, listen_port, max_retries,
|
||||||
|
enable_logging,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||||
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok(ProxyConfig {
|
||||||
|
listen_address: row.get(0)?,
|
||||||
|
listen_port: row.get::<_, i32>(1)? as u16,
|
||||||
|
max_retries: row.get::<_, i32>(2)? as u8,
|
||||||
|
request_timeout: 300, // 废弃字段,返回默认值
|
||||||
|
enable_logging: row.get::<_, i32>(3)? != 0,
|
||||||
|
live_takeover_active: false, // 废弃字段
|
||||||
|
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
|
||||||
|
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
|
||||||
|
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,初始化默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(ProxyConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新代理配置(兼容旧接口,更新所有三行的公共字段)
|
||||||
|
pub async fn update_proxy_config(&self, config: ProxyConfig) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 更新所有三行的公共字段
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE proxy_config SET
|
||||||
|
listen_address = ?1,
|
||||||
|
listen_port = ?2,
|
||||||
|
max_retries = ?3,
|
||||||
|
enable_logging = ?4,
|
||||||
|
streaming_first_byte_timeout = ?5,
|
||||||
|
streaming_idle_timeout = ?6,
|
||||||
|
non_streaming_timeout = ?7,
|
||||||
|
updated_at = datetime('now')",
|
||||||
|
rusqlite::params![
|
||||||
|
config.listen_address,
|
||||||
|
config.listen_port as i32,
|
||||||
|
config.max_retries as i32,
|
||||||
|
if config.enable_logging { 1 } else { 0 },
|
||||||
|
config.streaming_first_byte_timeout as i32,
|
||||||
|
config.streaming_idle_timeout as i32,
|
||||||
|
config.non_streaming_timeout as i32,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置 Live 接管状态(兼容旧版本,更新 enabled 字段)
|
||||||
|
pub async fn set_live_takeover_active(&self, _active: bool) -> Result<(), AppError> {
|
||||||
|
// 不再使用此字段,由 enabled 字段替代
|
||||||
|
// 保留空实现以兼容旧代码
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查是否处于 Live 接管模式
|
/// 检查是否处于 Live 接管模式
|
||||||
|
///
|
||||||
|
/// 检查是否有任一 app 的 enabled = true
|
||||||
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
pub async fn is_live_takeover_active(&self) -> Result<bool, AppError> {
|
||||||
// v3.7.0+:以 proxy_live_backup 是否存在作为“接管状态”的真实来源(更贴近 per-app 接管)
|
let conn = lock_conn!(self.conn);
|
||||||
self.has_any_live_backup().await
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM proxy_config WHERE enabled = 1",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Provider Health ====================
|
// ==================== Provider Health ====================
|
||||||
@@ -97,28 +293,45 @@ impl Database {
|
|||||||
provider_id: &str,
|
provider_id: &str,
|
||||||
app_type: &str,
|
app_type: &str,
|
||||||
) -> Result<ProviderHealth, AppError> {
|
) -> Result<ProviderHealth, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let result = {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
"SELECT provider_id, app_type, is_healthy, consecutive_failures,
|
||||||
last_success_at, last_failure_at, last_error, updated_at
|
last_success_at, last_failure_at, last_error, updated_at
|
||||||
FROM provider_health
|
FROM provider_health
|
||||||
WHERE provider_id = ?1 AND app_type = ?2",
|
WHERE provider_id = ?1 AND app_type = ?2",
|
||||||
rusqlite::params![provider_id, app_type],
|
rusqlite::params![provider_id, app_type],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(ProviderHealth {
|
Ok(ProviderHealth {
|
||||||
provider_id: row.get(0)?,
|
provider_id: row.get(0)?,
|
||||||
app_type: row.get(1)?,
|
app_type: row.get(1)?,
|
||||||
is_healthy: row.get::<_, i64>(2)? != 0,
|
is_healthy: row.get::<_, i64>(2)? != 0,
|
||||||
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
consecutive_failures: row.get::<_, i64>(3)? as u32,
|
||||||
last_success_at: row.get(4)?,
|
last_success_at: row.get(4)?,
|
||||||
last_failure_at: row.get(5)?,
|
last_failure_at: row.get(5)?,
|
||||||
last_error: row.get(6)?,
|
last_error: row.get(6)?,
|
||||||
updated_at: row.get(7)?,
|
updated_at: row.get(7)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(health) => Ok(health),
|
||||||
|
// 缺少记录时视为健康(关闭后清空状态,再次打开时默认正常)
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(ProviderHealth {
|
||||||
|
provider_id: provider_id.to_string(),
|
||||||
|
app_type: app_type.to_string(),
|
||||||
|
is_healthy: true,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
last_success_at: None,
|
||||||
|
last_failure_at: None,
|
||||||
|
last_error: None,
|
||||||
|
updated_at: chrono::Utc::now().to_rfc3339(),
|
||||||
|
}),
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新Provider健康状态
|
/// 更新Provider健康状态
|
||||||
@@ -223,6 +436,20 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清空指定应用的健康状态(关闭单个代理时使用)
|
||||||
|
pub async fn clear_provider_health_for_app(&self, app_type: &str) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM provider_health WHERE app_type = ?1",
|
||||||
|
[app_type],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
log::debug!("Cleared provider health records for app {app_type}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 清空所有Provider健康状态(代理停止时调用)
|
/// 清空所有Provider健康状态(代理停止时调用)
|
||||||
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
pub async fn clear_all_provider_health(&self) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
@@ -234,19 +461,22 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Circuit Breaker Config ====================
|
// ==================== Circuit Breaker Config (Legacy Compatibility) ====================
|
||||||
|
|
||||||
/// 获取熔断器配置
|
/// 获取熔断器配置(兼容旧接口,从 claude 行读取)
|
||||||
|
///
|
||||||
|
/// 熔断器配置已合并到 proxy_config 表,每 app 独立
|
||||||
|
/// 此方法保留用于兼容旧代码,建议使用 get_proxy_config_for_app
|
||||||
pub async fn get_circuit_breaker_config(
|
pub async fn get_circuit_breaker_config(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
) -> Result<crate::proxy::circuit_breaker::CircuitBreakerConfig, AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
// 使用 block 限制 conn 的作用域,避免跨 await 持有锁
|
||||||
|
let result = {
|
||||||
let config = conn
|
let conn = lock_conn!(self.conn);
|
||||||
.query_row(
|
conn.query_row(
|
||||||
"SELECT failure_threshold, success_threshold, timeout_seconds,
|
"SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
error_rate_threshold, min_requests
|
circuit_error_rate_threshold, circuit_min_requests
|
||||||
FROM circuit_breaker_config WHERE id = 1",
|
FROM proxy_config WHERE app_type = 'claude'",
|
||||||
[],
|
[],
|
||||||
|row| {
|
|row| {
|
||||||
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||||
@@ -258,27 +488,39 @@ impl Database {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
};
|
||||||
|
// conn 已在 block 结束时释放
|
||||||
|
|
||||||
Ok(config)
|
match result {
|
||||||
|
Ok(config) => Ok(config),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||||
|
// 如果不存在,初始化默认配置
|
||||||
|
self.init_proxy_config_rows().await?;
|
||||||
|
Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig::default())
|
||||||
|
}
|
||||||
|
Err(e) => Err(AppError::Database(e.to_string())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新熔断器配置
|
/// 更新熔断器配置(兼容旧接口,更新所有三行)
|
||||||
|
///
|
||||||
|
/// 熔断器配置已合并到 proxy_config 表
|
||||||
|
/// 此方法保留用于兼容旧代码,建议使用 update_proxy_config_for_app
|
||||||
pub async fn update_circuit_breaker_config(
|
pub async fn update_circuit_breaker_config(
|
||||||
&self,
|
&self,
|
||||||
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
config: &crate::proxy::circuit_breaker::CircuitBreakerConfig,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let conn = lock_conn!(self.conn);
|
let conn = lock_conn!(self.conn);
|
||||||
|
|
||||||
|
// 更新所有三行的熔断器配置
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE circuit_breaker_config
|
"UPDATE proxy_config SET
|
||||||
SET failure_threshold = ?1,
|
circuit_failure_threshold = ?1,
|
||||||
success_threshold = ?2,
|
circuit_success_threshold = ?2,
|
||||||
timeout_seconds = ?3,
|
circuit_timeout_seconds = ?3,
|
||||||
error_rate_threshold = ?4,
|
circuit_error_rate_threshold = ?4,
|
||||||
min_requests = ?5,
|
circuit_min_requests = ?5,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = datetime('now')",
|
||||||
WHERE id = 1",
|
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
config.failure_threshold as i32,
|
config.failure_threshold as i32,
|
||||||
config.success_threshold as i32,
|
config.success_threshold as i32,
|
||||||
|
|||||||
@@ -62,4 +62,70 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)---
|
||||||
|
|
||||||
|
/// 获取指定应用的代理接管状态
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||||
|
/// 此方法仅用于数据库迁移时读取旧数据
|
||||||
|
#[deprecated(since = "3.9.0", note = "使用 get_proxy_config_for_app().enabled 替代")]
|
||||||
|
pub fn get_proxy_takeover_enabled(&self, app_type: &str) -> Result<bool, AppError> {
|
||||||
|
let key = format!("proxy_takeover_{app_type}");
|
||||||
|
match self.get_setting(&key)? {
|
||||||
|
Some(value) => Ok(value == "true"),
|
||||||
|
None => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置指定应用的代理接管状态
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `proxy_config.enabled` 字段替代
|
||||||
|
#[deprecated(
|
||||||
|
since = "3.9.0",
|
||||||
|
note = "使用 update_proxy_config_for_app() 修改 enabled 字段"
|
||||||
|
)]
|
||||||
|
pub fn set_proxy_takeover_enabled(
|
||||||
|
&self,
|
||||||
|
app_type: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let key = format!("proxy_takeover_{app_type}");
|
||||||
|
let value = if enabled { "true" } else { "false" };
|
||||||
|
self.set_setting(&key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有任一应用开启了代理接管
|
||||||
|
///
|
||||||
|
/// **已废弃**: 请使用 `is_live_takeover_active()` 替代
|
||||||
|
#[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")]
|
||||||
|
pub fn has_any_proxy_takeover(&self) -> Result<bool, AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM settings WHERE key LIKE 'proxy_takeover_%' AND value = 'true'",
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除所有代理接管状态(将所有 proxy_takeover_* 设置为 false)
|
||||||
|
///
|
||||||
|
/// **已废弃**: settings 表不再用于存储代理状态
|
||||||
|
#[deprecated(
|
||||||
|
since = "3.9.0",
|
||||||
|
note = "使用 update_proxy_config_for_app() 清除各应用的 enabled 字段"
|
||||||
|
)]
|
||||||
|
pub fn clear_all_proxy_takeover(&self) -> Result<(), AppError> {
|
||||||
|
let conn = lock_conn!(self.conn);
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE settings SET value = 'false' WHERE key LIKE 'proxy_takeover_%'",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
log::info!("已清除所有代理接管状态");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+320
-235
@@ -31,6 +31,7 @@ impl Database {
|
|||||||
icon_color TEXT,
|
icon_color TEXT,
|
||||||
meta TEXT NOT NULL DEFAULT '{}',
|
meta TEXT NOT NULL DEFAULT '{}',
|
||||||
is_current BOOLEAN NOT NULL DEFAULT 0,
|
is_current BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
in_failover_queue BOOLEAN NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (id, app_type)
|
PRIMARY KEY (id, app_type)
|
||||||
)",
|
)",
|
||||||
[],
|
[],
|
||||||
@@ -54,47 +55,28 @@ impl Database {
|
|||||||
// 3. MCP Servers 表
|
// 3. MCP Servers 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
"CREATE TABLE IF NOT EXISTS mcp_servers (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
|
||||||
server_config TEXT NOT NULL,
|
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
||||||
description TEXT,
|
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
||||||
homepage TEXT,
|
)",
|
||||||
docs TEXT,
|
|
||||||
tags TEXT NOT NULL DEFAULT '[]',
|
|
||||||
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
|
|
||||||
enabled_gemini BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 4. Prompts 表
|
// 4. Prompts 表
|
||||||
conn.execute(
|
conn.execute("CREATE TABLE IF NOT EXISTS prompts (
|
||||||
"CREATE TABLE IF NOT EXISTS prompts (
|
id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, content TEXT NOT NULL,
|
||||||
id TEXT NOT NULL,
|
description TEXT, enabled BOOLEAN NOT NULL DEFAULT 1, created_at INTEGER, updated_at INTEGER,
|
||||||
app_type TEXT NOT NULL,
|
PRIMARY KEY (id, app_type)
|
||||||
name TEXT NOT NULL,
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
content TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
created_at INTEGER,
|
|
||||||
updated_at INTEGER,
|
|
||||||
PRIMARY KEY (id, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 5. Skills 表
|
// 5. Skills 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skills (
|
"CREATE TABLE IF NOT EXISTS skills (
|
||||||
directory TEXT NOT NULL,
|
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0,
|
||||||
app_type TEXT NOT NULL,
|
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type)
|
||||||
installed BOOLEAN NOT NULL DEFAULT 0,
|
)",
|
||||||
installed_at INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (directory, app_type)
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -102,169 +84,124 @@ impl Database {
|
|||||||
// 6. Skill Repos 表
|
// 6. Skill Repos 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS skill_repos (
|
"CREATE TABLE IF NOT EXISTS skill_repos (
|
||||||
owner TEXT NOT NULL,
|
owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main',
|
||||||
name TEXT NOT NULL,
|
enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name)
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
)",
|
||||||
enabled BOOLEAN NOT NULL DEFAULT 1,
|
|
||||||
PRIMARY KEY (owner, name)
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 7. Settings 表 (通用配置)
|
// 7. Settings 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS settings (
|
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)",
|
||||||
key TEXT PRIMARY KEY,
|
|
||||||
value TEXT
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 8. Proxy Config 表 (代理服务器配置)
|
// 8. Proxy Config 表(三行结构,app_type 主键)
|
||||||
// 代理配置表(单例)
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
|
||||||
|
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||||
|
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||||
|
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||||
|
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||||
|
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||||
|
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 初始化三行数据(每应用不同默认值)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_config (
|
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
enabled INTEGER NOT NULL DEFAULT 0,
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
listen_port INTEGER NOT NULL DEFAULT 5000,
|
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
|
||||||
max_retries INTEGER NOT NULL DEFAULT 3,
|
|
||||||
request_timeout INTEGER NOT NULL DEFAULT 300,
|
|
||||||
enable_logging INTEGER NOT NULL DEFAULT 1,
|
|
||||||
target_app TEXT NOT NULL DEFAULT 'claude',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 尝试添加 target_app 列(如果表已存在但缺少该列)
|
|
||||||
// 忽略 "duplicate column name" 错误
|
|
||||||
let _ = conn.execute(
|
|
||||||
"ALTER TABLE proxy_config ADD COLUMN target_app TEXT NOT NULL DEFAULT 'claude'",
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 9. Provider Health 表 (Provider健康状态)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS provider_health (
|
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||||
provider_id TEXT NOT NULL,
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
app_type TEXT NOT NULL,
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
is_healthy INTEGER NOT NULL DEFAULT 1,
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
consecutive_failures INTEGER NOT NULL DEFAULT 0,
|
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||||
last_success_at TEXT,
|
|
||||||
last_failure_at TEXT,
|
|
||||||
last_error TEXT,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (provider_id, app_type),
|
|
||||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 10. Proxy Request Logs 表 (详细请求日志)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
|
||||||
request_id TEXT PRIMARY KEY,
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
|
||||||
provider_id TEXT NOT NULL,
|
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
app_type TEXT NOT NULL,
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
model TEXT NOT NULL,
|
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
latency_ms INTEGER NOT NULL,
|
|
||||||
first_token_ms INTEGER,
|
|
||||||
duration_ms INTEGER,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
session_id TEXT,
|
|
||||||
provider_type TEXT,
|
|
||||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 9. Provider Health 表
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS provider_health (
|
||||||
|
provider_id TEXT NOT NULL, app_type TEXT NOT NULL, is_healthy INTEGER NOT NULL DEFAULT 1,
|
||||||
|
consecutive_failures INTEGER NOT NULL DEFAULT 0, last_success_at TEXT, last_failure_at TEXT,
|
||||||
|
last_error TEXT, updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (provider_id, app_type),
|
||||||
|
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
||||||
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
// 10. Proxy Request Logs 表
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
|
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||||||
|
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||||||
|
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||||||
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_provider ON proxy_request_logs(provider_id, app_type)", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON proxy_request_logs(created_at)", [])
|
||||||
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_provider
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)",
|
||||||
ON proxy_request_logs(provider_id, app_type)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_created_at
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_session ON proxy_request_logs(session_id)",
|
||||||
ON proxy_request_logs(created_at)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_model
|
"CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)",
|
||||||
ON proxy_request_logs(model)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
conn.execute(
|
// 11. Model Pricing 表
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_session
|
|
||||||
ON proxy_request_logs(session_id)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_request_logs_status
|
|
||||||
ON proxy_request_logs(status_code)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 11. Model Pricing 表 (模型定价)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||||
model_id TEXT PRIMARY KEY,
|
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||||
input_cost_per_million TEXT NOT NULL,
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||||
output_cost_per_million TEXT NOT NULL,
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
)",
|
||||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 12. Stream Check Logs 表 (流式健康检查日志)
|
// 12. Stream Check Logs 表
|
||||||
conn.execute(
|
conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs (
|
||||||
"CREATE TABLE IF NOT EXISTS stream_check_logs (
|
id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
app_type TEXT NOT NULL, status TEXT NOT NULL, success INTEGER NOT NULL, message TEXT NOT NULL,
|
||||||
provider_id TEXT NOT NULL,
|
response_time_ms INTEGER, http_status INTEGER, model_used TEXT,
|
||||||
provider_name TEXT NOT NULL,
|
retry_count INTEGER DEFAULT 0, tested_at INTEGER NOT NULL
|
||||||
app_type TEXT NOT NULL,
|
)", []).map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
status TEXT NOT NULL,
|
|
||||||
success INTEGER NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
response_time_ms INTEGER,
|
|
||||||
http_status INTEGER,
|
|
||||||
model_used TEXT,
|
|
||||||
retry_count INTEGER DEFAULT 0,
|
|
||||||
tested_at INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
|
"CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider
|
||||||
@@ -273,35 +210,13 @@ impl Database {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
// 13. Circuit Breaker Config 表 (熔断器配置)
|
// 注意:circuit_breaker_config 已合并到 proxy_config 表中
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE IF NOT EXISTS circuit_breaker_config (
|
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
||||||
failure_threshold INTEGER NOT NULL DEFAULT 5,
|
|
||||||
success_threshold INTEGER NOT NULL DEFAULT 2,
|
|
||||||
timeout_seconds INTEGER NOT NULL DEFAULT 60,
|
|
||||||
error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
|
||||||
min_requests INTEGER NOT NULL DEFAULT 10,
|
|
||||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 插入默认熔断器配置
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO circuit_breaker_config (id) VALUES (1)",
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
// 16. Proxy Live Backup 表 (Live 配置备份)
|
// 16. Proxy Live Backup 表 (Live 配置备份)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
|
"CREATE TABLE IF NOT EXISTS proxy_live_backup (
|
||||||
app_type TEXT PRIMARY KEY,
|
app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL
|
||||||
original_config TEXT NOT NULL,
|
)",
|
||||||
backed_up_at TEXT NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
@@ -312,29 +227,38 @@ impl Database {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 14. Failover Queue 表 (故障转移队列)
|
// 尝试添加超时配置列到 proxy_config 表
|
||||||
conn.execute(
|
let _ = conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS failover_queue (
|
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
queue_order INTEGER NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
UNIQUE (app_type, provider_id),
|
|
||||||
FOREIGN KEY (provider_id, app_type) REFERENCES providers(id, app_type) ON DELETE CASCADE
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)
|
);
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// 为故障转移队列创建索引
|
// 确保 in_failover_queue 列存在(对于已存在的 v2 数据库)
|
||||||
conn.execute(
|
Self::add_column_if_missing(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_failover_queue_order
|
conn,
|
||||||
ON failover_queue(app_type, queue_order)",
|
"providers",
|
||||||
|
"in_failover_queue",
|
||||||
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// 删除旧的 failover_queue 表(如果存在)
|
||||||
|
let _ = conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", []);
|
||||||
|
let _ = conn.execute("DROP TABLE IF EXISTS failover_queue", []);
|
||||||
|
|
||||||
|
// 为故障转移队列创建索引(基于 providers 表)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||||
|
ON providers(app_type, in_failover_queue, sort_index)",
|
||||||
[],
|
[],
|
||||||
)
|
);
|
||||||
.map_err(|e| AppError::Database(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -472,37 +396,62 @@ impl Database {
|
|||||||
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
Self::add_column_if_missing(conn, "providers", "limit_daily_usd", "TEXT")?;
|
||||||
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
Self::add_column_if_missing(conn, "providers", "limit_monthly_usd", "TEXT")?;
|
||||||
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
Self::add_column_if_missing(conn, "providers", "provider_type", "TEXT")?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
// proxy_request_logs 表(包含所有字段)
|
conn,
|
||||||
conn.execute(
|
"providers",
|
||||||
"CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
"in_failover_queue",
|
||||||
request_id TEXT PRIMARY KEY,
|
"BOOLEAN NOT NULL DEFAULT 0",
|
||||||
provider_id TEXT NOT NULL,
|
|
||||||
app_type TEXT NOT NULL,
|
|
||||||
model TEXT NOT NULL,
|
|
||||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
|
||||||
input_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
output_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_read_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
total_cost_usd TEXT NOT NULL DEFAULT '0',
|
|
||||||
latency_ms INTEGER NOT NULL,
|
|
||||||
first_token_ms INTEGER,
|
|
||||||
duration_ms INTEGER,
|
|
||||||
status_code INTEGER NOT NULL,
|
|
||||||
error_message TEXT,
|
|
||||||
session_id TEXT,
|
|
||||||
provider_type TEXT,
|
|
||||||
is_streaming INTEGER NOT NULL DEFAULT 0,
|
|
||||||
cost_multiplier TEXT NOT NULL DEFAULT '1.0',
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
)",
|
|
||||||
[],
|
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
// 添加代理超时配置字段
|
||||||
|
if Self::table_exists(conn, "proxy_config")? {
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"streaming_first_byte_timeout",
|
||||||
|
"INTEGER NOT NULL DEFAULT 30",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"streaming_idle_timeout",
|
||||||
|
"INTEGER NOT NULL DEFAULT 60",
|
||||||
|
)?;
|
||||||
|
Self::add_column_if_missing(
|
||||||
|
conn,
|
||||||
|
"proxy_config",
|
||||||
|
"non_streaming_timeout",
|
||||||
|
"INTEGER NOT NULL DEFAULT 300",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除旧的 failover_queue 表(如果存在)
|
||||||
|
conn.execute("DROP INDEX IF EXISTS idx_failover_queue_order", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("删除 failover_queue 索引失败: {e}")))?;
|
||||||
|
conn.execute("DROP TABLE IF EXISTS failover_queue", [])
|
||||||
|
.map_err(|e| AppError::Database(format!("删除 failover_queue 表失败: {e}")))?;
|
||||||
|
|
||||||
|
// 创建 failover 索引
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_providers_failover
|
||||||
|
ON providers(app_type, in_failover_queue, sort_index)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::Database(format!("创建 failover 索引失败: {e}")))?;
|
||||||
|
|
||||||
|
// proxy_request_logs 表
|
||||||
|
conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs (
|
||||||
|
request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL,
|
||||||
|
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
cache_read_cost_usd TEXT NOT NULL DEFAULT '0', cache_creation_cost_usd TEXT NOT NULL DEFAULT '0',
|
||||||
|
total_cost_usd TEXT NOT NULL DEFAULT '0', latency_ms INTEGER NOT NULL, first_token_ms INTEGER,
|
||||||
|
duration_ms INTEGER, status_code INTEGER NOT NULL, error_message TEXT, session_id TEXT,
|
||||||
|
provider_type TEXT, is_streaming INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cost_multiplier TEXT NOT NULL DEFAULT '1.0', created_at INTEGER NOT NULL
|
||||||
|
)", [])?;
|
||||||
|
|
||||||
// 为已存在的表添加新字段
|
// 为已存在的表添加新字段
|
||||||
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
Self::add_column_if_missing(conn, "proxy_request_logs", "provider_type", "TEXT")?;
|
||||||
Self::add_column_if_missing(
|
Self::add_column_if_missing(
|
||||||
@@ -523,13 +472,11 @@ impl Database {
|
|||||||
// model_pricing 表
|
// model_pricing 表
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE TABLE IF NOT EXISTS model_pricing (
|
"CREATE TABLE IF NOT EXISTS model_pricing (
|
||||||
model_id TEXT PRIMARY KEY,
|
model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL,
|
input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL,
|
||||||
input_cost_per_million TEXT NOT NULL,
|
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
||||||
output_cost_per_million TEXT NOT NULL,
|
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
||||||
cache_read_cost_per_million TEXT NOT NULL DEFAULT '0',
|
)",
|
||||||
cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0'
|
|
||||||
)",
|
|
||||||
[],
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -541,6 +488,144 @@ impl Database {
|
|||||||
// 重构 skills 表(添加 app_type 字段)
|
// 重构 skills 表(添加 app_type 字段)
|
||||||
Self::migrate_skills_table(conn)?;
|
Self::migrate_skills_table(conn)?;
|
||||||
|
|
||||||
|
// 重构 proxy_config 为三行结构(每应用独立配置)
|
||||||
|
Self::migrate_proxy_config_to_per_app(conn)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 proxy_config 迁移为三行结构(每应用独立配置)
|
||||||
|
fn migrate_proxy_config_to_per_app(conn: &Connection) -> Result<(), AppError> {
|
||||||
|
// 检查是否已经是新表结构(幂等性)
|
||||||
|
if !Self::table_exists(conn, "proxy_config")? {
|
||||||
|
// 表不存在,跳过迁移(新安装)
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if Self::has_column(conn, "proxy_config", "app_type")? {
|
||||||
|
// 已经是三行结构,跳过迁移
|
||||||
|
log::info!("proxy_config 已经是三行结构,跳过迁移");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取旧配置
|
||||||
|
let old_config = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT listen_address, listen_port, max_retries, enable_logging,
|
||||||
|
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout
|
||||||
|
FROM proxy_config WHERE id = 1",
|
||||||
|
[],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, i32>(1)?,
|
||||||
|
row.get::<_, i32>(2)?,
|
||||||
|
row.get::<_, i32>(3)?,
|
||||||
|
row.get::<_, i32>(4).unwrap_or(30),
|
||||||
|
row.get::<_, i32>(5).unwrap_or(60),
|
||||||
|
row.get::<_, i32>(6).unwrap_or(300),
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|_| ("127.0.0.1".to_string(), 5000, 3, 1, 30, 60, 300));
|
||||||
|
|
||||||
|
let old_cb = conn.query_row(
|
||||||
|
"SELECT failure_threshold, success_threshold, timeout_seconds, error_rate_threshold, min_requests
|
||||||
|
FROM circuit_breaker_config WHERE id = 1", [],
|
||||||
|
|row| Ok((row.get::<_, i32>(0)?, row.get::<_, i32>(1)?, row.get::<_, i64>(2)?,
|
||||||
|
row.get::<_, f64>(3)?, row.get::<_, i32>(4)?))
|
||||||
|
).unwrap_or((5, 2, 60, 0.5, 10));
|
||||||
|
|
||||||
|
let get_bool = |key: &str| -> bool {
|
||||||
|
conn.query_row("SELECT value FROM settings WHERE key = ?", [key], |r| {
|
||||||
|
r.get::<_, String>(0)
|
||||||
|
})
|
||||||
|
.map(|v| v == "true" || v == "1")
|
||||||
|
.unwrap_or(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
let apps = [
|
||||||
|
(
|
||||||
|
"claude",
|
||||||
|
get_bool("proxy_takeover_claude"),
|
||||||
|
get_bool("auto_failover_enabled_claude"),
|
||||||
|
6,
|
||||||
|
45,
|
||||||
|
90,
|
||||||
|
8,
|
||||||
|
3,
|
||||||
|
90,
|
||||||
|
0.6,
|
||||||
|
15,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"codex",
|
||||||
|
get_bool("proxy_takeover_codex"),
|
||||||
|
get_bool("auto_failover_enabled_codex"),
|
||||||
|
3,
|
||||||
|
old_config.4,
|
||||||
|
old_config.5,
|
||||||
|
old_cb.0,
|
||||||
|
old_cb.1,
|
||||||
|
old_cb.2,
|
||||||
|
old_cb.3,
|
||||||
|
old_cb.4,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"gemini",
|
||||||
|
get_bool("proxy_takeover_gemini"),
|
||||||
|
get_bool("auto_failover_enabled_gemini"),
|
||||||
|
5,
|
||||||
|
old_config.4,
|
||||||
|
old_config.5,
|
||||||
|
old_cb.0,
|
||||||
|
old_cb.1,
|
||||||
|
old_cb.2,
|
||||||
|
old_cb.3,
|
||||||
|
old_cb.4,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 创建新表
|
||||||
|
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
|
||||||
|
conn.execute("CREATE TABLE proxy_config_new (
|
||||||
|
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
|
||||||
|
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
|
||||||
|
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
|
||||||
|
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
|
||||||
|
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
|
||||||
|
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)", [])?;
|
||||||
|
|
||||||
|
// 插入三行配置
|
||||||
|
for (app, takeover, failover, retries, fb, idle, cb_f, cb_s, cb_t, cb_r, cb_m) in apps {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO proxy_config_new (app_type, proxy_enabled, listen_address, listen_port, enable_logging,
|
||||||
|
enabled, auto_failover_enabled, max_retries, streaming_first_byte_timeout, streaming_idle_timeout,
|
||||||
|
non_streaming_timeout, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
|
||||||
|
circuit_error_rate_threshold, circuit_min_requests)
|
||||||
|
VALUES (?1, 0, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||||
|
rusqlite::params![app, old_config.0, old_config.1, old_config.3,
|
||||||
|
if takeover { 1 } else { 0 }, if failover { 1 } else { 0 },
|
||||||
|
retries, fb, idle, old_config.6, cb_f, cb_s, cb_t, cb_r, cb_m]
|
||||||
|
).map_err(|e| AppError::Database(format!("插入 {app} 配置失败: {e}")))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换表并清理
|
||||||
|
conn.execute("DROP TABLE IF EXISTS proxy_config", [])?;
|
||||||
|
conn.execute("ALTER TABLE proxy_config_new RENAME TO proxy_config", [])?;
|
||||||
|
conn.execute("DROP TABLE IF EXISTS circuit_breaker_config", [])?;
|
||||||
|
conn.execute("DELETE FROM settings WHERE key LIKE 'proxy_takeover_%'", [])?;
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM settings WHERE key LIKE 'auto_failover_enabled_%'",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
log::info!("proxy_config 已迁移为三行结构");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ pub(crate) fn build_provider_from_request(
|
|||||||
meta,
|
meta,
|
||||||
icon: request.icon.clone(),
|
icon: request.icon.clone(),
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(provider)
|
Ok(provider)
|
||||||
|
|||||||
+142
-119
@@ -48,6 +48,8 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
|||||||
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
use tauri::image::Image;
|
||||||
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
|
||||||
use tauri::RunEvent;
|
use tauri::RunEvent;
|
||||||
use tauri::{Emitter, Manager};
|
use tauri::{Emitter, Manager};
|
||||||
@@ -133,6 +135,19 @@ async fn update_tray_menu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn macos_tray_icon() -> Option<Image<'static>> {
|
||||||
|
const ICON_BYTES: &[u8] = include_bytes!("../icons/tray/macos/statusbar_template_3x.png");
|
||||||
|
|
||||||
|
match Image::from_bytes(ICON_BYTES) {
|
||||||
|
Ok(icon) => Some(icon),
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!("Failed to load macOS tray icon: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let mut builder = tauri::Builder::default();
|
let mut builder = tauri::Builder::default();
|
||||||
@@ -208,44 +223,6 @@ pub fn run() {
|
|||||||
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
log::warn!("初始化 Updater 插件失败,已跳过:{e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
{
|
|
||||||
// 设置 macOS 标题栏背景色为主界面蓝色
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
|
||||||
use objc2::rc::Retained;
|
|
||||||
use objc2::runtime::AnyObject;
|
|
||||||
use objc2_app_kit::NSColor;
|
|
||||||
|
|
||||||
match window.ns_window() {
|
|
||||||
Ok(ns_window_ptr) => {
|
|
||||||
if let Some(ns_window) =
|
|
||||||
unsafe { Retained::retain(ns_window_ptr as *mut AnyObject) }
|
|
||||||
{
|
|
||||||
// 使用与主界面 banner 相同的蓝色 #3498db
|
|
||||||
// #3498db = RGB(52, 152, 219)
|
|
||||||
let bg_color = unsafe {
|
|
||||||
NSColor::colorWithRed_green_blue_alpha(
|
|
||||||
52.0 / 255.0, // R: 52
|
|
||||||
152.0 / 255.0, // G: 152
|
|
||||||
219.0 / 255.0, // B: 219
|
|
||||||
1.0, // Alpha: 1.0
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
use objc2::msg_send;
|
|
||||||
let _: () =
|
|
||||||
msg_send![&*ns_window, setBackgroundColor: &*bg_color];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
log::warn!("Failed to retain NSWindow reference");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => log::warn!("Failed to get NSWindow pointer: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化日志
|
// 初始化日志
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
@@ -503,11 +480,26 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.show_menu_on_left_click(true);
|
.show_menu_on_left_click(true);
|
||||||
|
|
||||||
// 统一使用应用默认图标;待托盘模板图标就绪后再启用
|
// 使用平台对应的托盘图标(macOS 使用模板图标适配深浅色)
|
||||||
if let Some(icon) = app.default_window_icon() {
|
#[cfg(target_os = "macos")]
|
||||||
tray_builder = tray_builder.icon(icon.clone());
|
{
|
||||||
} else {
|
if let Some(icon) = macos_tray_icon() {
|
||||||
log::warn!("Failed to get default window icon for tray");
|
tray_builder = tray_builder.icon(icon).icon_as_template(true);
|
||||||
|
} else if let Some(icon) = app.default_window_icon() {
|
||||||
|
log::warn!("Falling back to default window icon for tray");
|
||||||
|
tray_builder = tray_builder.icon(icon.clone());
|
||||||
|
} else {
|
||||||
|
log::warn!("Failed to load macOS tray icon for tray");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if let Some(icon) = app.default_window_icon() {
|
||||||
|
tray_builder = tray_builder.icon(icon.clone());
|
||||||
|
} else {
|
||||||
|
log::warn!("Failed to get default window icon for tray");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let _tray = tray_builder.build(app)?;
|
let _tray = tray_builder.build(app)?;
|
||||||
@@ -524,67 +516,33 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 异常退出恢复 + 自动启动代理服务器
|
// 异常退出恢复 + 代理状态自动恢复
|
||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let state = app_handle.state::<AppState>();
|
let state = app_handle.state::<AppState>();
|
||||||
|
|
||||||
// 1. 检测异常退出并恢复 Live 配置
|
// 检查是否有 Live 备份(表示上次异常退出时可能处于接管状态)
|
||||||
let is_proxy_running = state.proxy_service.is_running().await;
|
let has_backups = match state.db.has_any_live_backup().await {
|
||||||
if !is_proxy_running {
|
Ok(v) => v,
|
||||||
let takeover_flag = match state.db.is_live_takeover_active().await {
|
Err(e) => {
|
||||||
Ok(active) => active,
|
log::error!("检查 Live 备份失败: {e}");
|
||||||
Err(e) => {
|
false
|
||||||
log::error!("检查接管状态失败: {e}");
|
}
|
||||||
false
|
};
|
||||||
}
|
// 检查 Live 配置是否仍处于被接管状态(包含占位符)
|
||||||
};
|
let live_taken_over = state.proxy_service.detect_takeover_in_live_configs();
|
||||||
|
|
||||||
let has_backups = match state.db.has_any_live_backup().await {
|
if has_backups || live_taken_over {
|
||||||
Ok(v) => v,
|
log::warn!("检测到上次异常退出(存在接管残留),正在恢复 Live 配置...");
|
||||||
Err(e) => {
|
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
||||||
log::error!("检查 Live 备份失败: {e}");
|
log::error!("恢复 Live 配置失败: {e}");
|
||||||
false
|
} else {
|
||||||
}
|
log::info!("Live 配置已恢复");
|
||||||
};
|
|
||||||
|
|
||||||
// 兜底检测:旧版本/极端窗口期可能出现“标志未写入,但 Live 已被写成占位符”的残留状态。
|
|
||||||
// 只有在存在备份时才检查占位符,避免误判覆盖用户正常配置。
|
|
||||||
let live_taken_over =
|
|
||||||
has_backups && state.proxy_service.detect_takeover_in_live_configs();
|
|
||||||
|
|
||||||
if takeover_flag || live_taken_over {
|
|
||||||
log::warn!("检测到上次异常退出或残留接管状态,正在恢复 Live 配置...");
|
|
||||||
if let Err(e) = state.proxy_service.recover_from_crash().await {
|
|
||||||
log::error!("恢复 Live 配置失败: {e}");
|
|
||||||
} else {
|
|
||||||
log::info!("Live 配置已从异常退出中恢复");
|
|
||||||
}
|
|
||||||
} else if has_backups {
|
|
||||||
// 备份残留但 Live 未处于接管状态:清理敏感备份,避免长期存储 Token
|
|
||||||
if let Err(e) = state.db.delete_all_live_backups().await {
|
|
||||||
log::warn!("清理残留 Live 备份失败: {e}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 自动启动代理服务器(如果配置为启用)
|
// 检查 settings 表中的代理状态,自动恢复代理服务
|
||||||
match state.db.get_proxy_config().await {
|
restore_proxy_state_on_startup(&state).await;
|
||||||
Ok(config) => {
|
|
||||||
if config.enabled {
|
|
||||||
log::info!("代理服务配置为启用,正在启动...");
|
|
||||||
match state.proxy_service.start(true).await {
|
|
||||||
Ok(info) => log::info!(
|
|
||||||
"代理服务器自动启动成功: {}:{}",
|
|
||||||
info.address,
|
|
||||||
info.port
|
|
||||||
),
|
|
||||||
Err(e) => log::error!("代理服务器自动启动失败: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => log::error!("启动时获取代理配置失败: {e}"),
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -622,6 +580,8 @@ pub fn run() {
|
|||||||
commands::read_claude_plugin_config,
|
commands::read_claude_plugin_config,
|
||||||
commands::apply_claude_plugin_config,
|
commands::apply_claude_plugin_config,
|
||||||
commands::is_claude_plugin_applied,
|
commands::is_claude_plugin_applied,
|
||||||
|
commands::apply_claude_onboarding_skip,
|
||||||
|
commands::clear_claude_onboarding_skip,
|
||||||
// Claude MCP management
|
// Claude MCP management
|
||||||
commands::get_claude_mcp_status,
|
commands::get_claude_mcp_status,
|
||||||
commands::read_claude_mcp_config,
|
commands::read_claude_mcp_config,
|
||||||
@@ -636,7 +596,7 @@ pub fn run() {
|
|||||||
commands::upsert_mcp_server_in_config,
|
commands::upsert_mcp_server_in_config,
|
||||||
commands::delete_mcp_server_in_config,
|
commands::delete_mcp_server_in_config,
|
||||||
commands::set_mcp_enabled,
|
commands::set_mcp_enabled,
|
||||||
// v3.7.0: Unified MCP management
|
// Unified MCP management
|
||||||
commands::get_mcp_servers,
|
commands::get_mcp_servers,
|
||||||
commands::upsert_mcp_server,
|
commands::upsert_mcp_server,
|
||||||
commands::delete_mcp_server,
|
commands::delete_mcp_server,
|
||||||
@@ -690,13 +650,17 @@ pub fn run() {
|
|||||||
commands::get_auto_launch_status,
|
commands::get_auto_launch_status,
|
||||||
// Proxy server management
|
// Proxy server management
|
||||||
commands::start_proxy_server,
|
commands::start_proxy_server,
|
||||||
commands::start_proxy_with_takeover,
|
|
||||||
commands::stop_proxy_with_restore,
|
commands::stop_proxy_with_restore,
|
||||||
commands::get_proxy_takeover_status,
|
commands::get_proxy_takeover_status,
|
||||||
commands::set_proxy_takeover_for_app,
|
commands::set_proxy_takeover_for_app,
|
||||||
commands::get_proxy_status,
|
commands::get_proxy_status,
|
||||||
commands::get_proxy_config,
|
commands::get_proxy_config,
|
||||||
commands::update_proxy_config,
|
commands::update_proxy_config,
|
||||||
|
// Global & Per-App Config
|
||||||
|
commands::get_global_proxy_config,
|
||||||
|
commands::update_global_proxy_config,
|
||||||
|
commands::get_proxy_config_for_app,
|
||||||
|
commands::update_proxy_config_for_app,
|
||||||
commands::is_proxy_running,
|
commands::is_proxy_running,
|
||||||
commands::is_live_takeover_active,
|
commands::is_live_takeover_active,
|
||||||
commands::switch_proxy_provider,
|
commands::switch_proxy_provider,
|
||||||
@@ -711,8 +675,8 @@ pub fn run() {
|
|||||||
commands::get_available_providers_for_failover,
|
commands::get_available_providers_for_failover,
|
||||||
commands::add_to_failover_queue,
|
commands::add_to_failover_queue,
|
||||||
commands::remove_from_failover_queue,
|
commands::remove_from_failover_queue,
|
||||||
commands::reorder_failover_queue,
|
commands::get_auto_failover_enabled,
|
||||||
commands::set_failover_item_enabled,
|
commands::set_auto_failover_enabled,
|
||||||
// Usage statistics
|
// Usage statistics
|
||||||
commands::get_usage_summary,
|
commands::get_usage_summary,
|
||||||
commands::get_usage_trends,
|
commands::get_usage_trends,
|
||||||
@@ -844,32 +808,91 @@ pub fn run() {
|
|||||||
///
|
///
|
||||||
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
|
/// 在应用退出前检查代理服务器状态,如果正在运行则停止代理并恢复 Live 配置。
|
||||||
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
|
/// 确保 Claude Code/Codex/Gemini 的配置不会处于损坏状态。
|
||||||
|
/// 使用 stop_with_restore_keep_state 保留 settings 表中的代理状态,下次启动时自动恢复。
|
||||||
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) {
|
||||||
if let Some(state) = app_handle.try_state::<store::AppState>() {
|
if let Some(state) = app_handle.try_state::<store::AppState>() {
|
||||||
let proxy_service = &state.proxy_service;
|
let proxy_service = &state.proxy_service;
|
||||||
|
|
||||||
// 检查代理是否在运行
|
// 退出时也需要兜底:代理可能已崩溃/未运行,但 Live 接管残留仍在(占位符/备份)。
|
||||||
if proxy_service.is_running().await {
|
let has_backups = match state.db.has_any_live_backup().await {
|
||||||
log::info!("检测到代理服务器正在运行,开始清理...");
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("退出时检查 Live 备份失败: {e}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let live_taken_over = proxy_service.detect_takeover_in_live_configs();
|
||||||
|
let needs_restore = has_backups || live_taken_over;
|
||||||
|
|
||||||
// 检查是否处于 Live 接管模式
|
if needs_restore {
|
||||||
if let Ok(is_takeover) = state.db.is_live_takeover_active().await {
|
log::info!("检测到接管残留,开始恢复 Live 配置(保留代理状态)...");
|
||||||
if is_takeover {
|
// 使用 keep_state 版本,保留 settings 表中的代理状态
|
||||||
// 接管模式:停止并恢复配置
|
if let Err(e) = proxy_service.stop_with_restore_keep_state().await {
|
||||||
if let Err(e) = proxy_service.stop_with_restore().await {
|
log::error!("退出时恢复 Live 配置失败: {e}");
|
||||||
log::error!("退出时恢复 Live 配置失败: {e}");
|
} else {
|
||||||
} else {
|
log::info!("已恢复 Live 配置(代理状态已保留,下次启动将自动恢复)");
|
||||||
log::info!("已恢复 Live 配置");
|
}
|
||||||
}
|
return;
|
||||||
} else {
|
}
|
||||||
// 非接管模式:仅停止代理
|
|
||||||
if let Err(e) = proxy_service.stop().await {
|
// 非接管模式:代理在运行则仅停止代理
|
||||||
log::error!("退出时停止代理失败: {e}");
|
if proxy_service.is_running().await {
|
||||||
}
|
log::info!("检测到代理服务器正在运行,开始停止...");
|
||||||
|
if let Err(e) = proxy_service.stop().await {
|
||||||
|
log::error!("退出时停止代理失败: {e}");
|
||||||
|
}
|
||||||
|
log::info!("代理服务器清理完成");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 启动时恢复代理状态
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务
|
||||||
|
///
|
||||||
|
/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`,
|
||||||
|
/// 则自动启动代理服务并接管对应应用的 Live 配置。
|
||||||
|
async fn restore_proxy_state_on_startup(state: &store::AppState) {
|
||||||
|
// 收集需要恢复接管的应用列表(从 proxy_config.enabled 读取)
|
||||||
|
let mut apps_to_restore = Vec::new();
|
||||||
|
for app_type in ["claude", "codex", "gemini"] {
|
||||||
|
if let Ok(config) = state.db.get_proxy_config_for_app(app_type).await {
|
||||||
|
if config.enabled {
|
||||||
|
apps_to_restore.push(app_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if apps_to_restore.is_empty() {
|
||||||
|
log::debug!("启动时无需恢复代理状态");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("检测到上次代理状态需要恢复,应用列表: {apps_to_restore:?}");
|
||||||
|
|
||||||
|
// 逐个恢复接管状态
|
||||||
|
for app_type in apps_to_restore {
|
||||||
|
match state
|
||||||
|
.proxy_service
|
||||||
|
.set_takeover_for_app(app_type, true)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
log::info!("✓ 已恢复 {app_type} 的代理接管状态");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}");
|
||||||
|
// 失败时清除该应用的状态,避免下次启动再次尝试
|
||||||
|
if let Err(clear_err) = state
|
||||||
|
.proxy_service
|
||||||
|
.set_takeover_for_app(app_type, false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("清除 {app_type} 代理状态失败: {clear_err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("代理服务器清理完成");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ pub struct Provider {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[serde(rename = "iconColor")]
|
#[serde(rename = "iconColor")]
|
||||||
pub icon_color: Option<String>,
|
pub icon_color: Option<String>,
|
||||||
|
/// 是否加入故障转移队列
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde(rename = "inFailoverQueue")]
|
||||||
|
pub in_failover_queue: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Provider {
|
impl Provider {
|
||||||
@@ -58,6 +62,7 @@ impl Provider {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ use std::sync::Arc;
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
pub struct ForwardResult {
|
||||||
|
pub response: Response,
|
||||||
|
pub provider: Provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ForwardError {
|
||||||
|
pub error: ProxyError,
|
||||||
|
pub provider: Option<Provider>,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct RequestForwarder {
|
pub struct RequestForwarder {
|
||||||
client: Client,
|
client: Client,
|
||||||
/// 共享的 ProviderRouter(持有熔断器状态)
|
/// 共享的 ProviderRouter(持有熔断器状态)
|
||||||
@@ -29,24 +39,35 @@ pub struct RequestForwarder {
|
|||||||
failover_manager: Arc<FailoverSwitchManager>,
|
failover_manager: Arc<FailoverSwitchManager>,
|
||||||
/// AppHandle,用于发射事件和更新托盘
|
/// AppHandle,用于发射事件和更新托盘
|
||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
/// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘)
|
/// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘)
|
||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RequestForwarder {
|
impl RequestForwarder {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
router: Arc<ProviderRouter>,
|
router: Arc<ProviderRouter>,
|
||||||
timeout_secs: u64,
|
non_streaming_timeout: u64,
|
||||||
max_retries: u8,
|
max_retries: u8,
|
||||||
status: Arc<RwLock<ProxyStatus>>,
|
status: Arc<RwLock<ProxyStatus>>,
|
||||||
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
|
||||||
failover_manager: Arc<FailoverSwitchManager>,
|
failover_manager: Arc<FailoverSwitchManager>,
|
||||||
app_handle: Option<tauri::AppHandle>,
|
app_handle: Option<tauri::AppHandle>,
|
||||||
current_provider_id_at_start: String,
|
current_provider_id_at_start: String,
|
||||||
|
_streaming_first_byte_timeout: u64,
|
||||||
|
_streaming_idle_timeout: u64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
// 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作
|
||||||
|
// 参考 Claude Code Hub 的 undici 全局超时设计
|
||||||
|
const GLOBAL_TIMEOUT_SECS: u64 = 1800;
|
||||||
|
|
||||||
let mut client_builder = Client::builder();
|
let mut client_builder = Client::builder();
|
||||||
if timeout_secs > 0 {
|
if non_streaming_timeout > 0 {
|
||||||
client_builder = client_builder.timeout(Duration::from_secs(timeout_secs));
|
// 使用配置的非流式超时
|
||||||
|
client_builder = client_builder.timeout(Duration::from_secs(non_streaming_timeout));
|
||||||
|
} else {
|
||||||
|
// 禁用超时时使用全局超时作为保底
|
||||||
|
client_builder = client_builder.timeout(Duration::from_secs(GLOBAL_TIMEOUT_SECS));
|
||||||
}
|
}
|
||||||
|
|
||||||
let client = client_builder
|
let client = client_builder
|
||||||
@@ -133,13 +154,16 @@ impl RequestForwarder {
|
|||||||
body: Value,
|
body: Value,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
providers: Vec<Provider>,
|
providers: Vec<Provider>,
|
||||||
) -> Result<Response, ProxyError> {
|
) -> Result<ForwardResult, ForwardError> {
|
||||||
// 获取适配器
|
// 获取适配器
|
||||||
let adapter = get_adapter(app_type);
|
let adapter = get_adapter(app_type);
|
||||||
let app_type_str = app_type.as_str();
|
let app_type_str = app_type.as_str();
|
||||||
|
|
||||||
if providers.is_empty() {
|
if providers.is_empty() {
|
||||||
return Err(ProxyError::NoAvailableProvider);
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -149,16 +173,27 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let mut last_error = None;
|
let mut last_error = None;
|
||||||
|
let mut last_provider = None;
|
||||||
let mut attempted_providers = 0usize;
|
let mut attempted_providers = 0usize;
|
||||||
|
|
||||||
|
// 单 Provider 场景下跳过熔断器检查(故障转移关闭时)
|
||||||
|
let bypass_circuit_breaker = providers.len() == 1;
|
||||||
|
|
||||||
// 依次尝试每个供应商
|
// 依次尝试每个供应商
|
||||||
for provider in providers.iter() {
|
for provider in providers.iter() {
|
||||||
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
|
// 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额)
|
||||||
let permit = self
|
// 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求
|
||||||
.router
|
let (allowed, used_half_open_permit) = if bypass_circuit_breaker {
|
||||||
.allow_provider_request(&provider.id, app_type_str)
|
(true, false)
|
||||||
.await;
|
} else {
|
||||||
if !permit.allowed {
|
let permit = self
|
||||||
|
.router
|
||||||
|
.allow_provider_request(&provider.id, app_type_str)
|
||||||
|
.await;
|
||||||
|
(permit.allowed, permit.used_half_open_permit)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
|
"[{}] Provider {} 熔断器拒绝本次请求,跳过",
|
||||||
app_type_str,
|
app_type_str,
|
||||||
@@ -167,8 +202,6 @@ impl RequestForwarder {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let used_half_open_permit = permit.used_half_open_permit;
|
|
||||||
|
|
||||||
attempted_providers += 1;
|
attempted_providers += 1;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -268,7 +301,10 @@ impl RequestForwarder {
|
|||||||
latency
|
latency
|
||||||
);
|
);
|
||||||
|
|
||||||
return Ok(response);
|
return Ok(ForwardResult {
|
||||||
|
response,
|
||||||
|
provider: provider.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let latency = start.elapsed().as_millis() as u64;
|
let latency = start.elapsed().as_millis() as u64;
|
||||||
@@ -309,6 +345,7 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
|
|
||||||
last_error = Some(e);
|
last_error = Some(e);
|
||||||
|
last_provider = Some(provider.clone());
|
||||||
// 继续尝试下一个供应商
|
// 继续尝试下一个供应商
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -330,7 +367,10 @@ impl RequestForwarder {
|
|||||||
provider.name,
|
provider.name,
|
||||||
e
|
e
|
||||||
);
|
);
|
||||||
return Err(e);
|
return Err(ForwardError {
|
||||||
|
error: e,
|
||||||
|
provider: Some(provider.clone()),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -348,7 +388,10 @@ impl RequestForwarder {
|
|||||||
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
(status.success_requests as f32 / status.total_requests as f32) * 100.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Err(ProxyError::NoAvailableProvider);
|
return Err(ForwardError {
|
||||||
|
error: ProxyError::NoAvailableProvider,
|
||||||
|
provider: None,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 所有供应商都失败了
|
// 所有供应商都失败了
|
||||||
@@ -368,7 +411,10 @@ impl RequestForwarder {
|
|||||||
providers.len()
|
providers.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded))
|
Err(ForwardError {
|
||||||
|
error: last_error.unwrap_or(ProxyError::MaxRetriesExceeded),
|
||||||
|
provider: last_provider,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 转发单个请求(使用适配器)
|
/// 转发单个请求(使用适配器)
|
||||||
@@ -384,12 +430,19 @@ impl RequestForwarder {
|
|||||||
let base_url = adapter.extract_base_url(provider)?;
|
let base_url = adapter.extract_base_url(provider)?;
|
||||||
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
log::info!("[{}] base_url: {}", adapter.name(), base_url);
|
||||||
|
|
||||||
// 使用适配器构建 URL
|
|
||||||
let url = adapter.build_url(&base_url, endpoint);
|
|
||||||
|
|
||||||
// 检查是否需要格式转换
|
// 检查是否需要格式转换
|
||||||
let needs_transform = adapter.needs_transform(provider);
|
let needs_transform = adapter.needs_transform(provider);
|
||||||
|
|
||||||
|
let effective_endpoint =
|
||||||
|
if needs_transform && adapter.name() == "Claude" && endpoint == "/v1/messages" {
|
||||||
|
"/v1/chat/completions"
|
||||||
|
} else {
|
||||||
|
endpoint
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用适配器构建 URL
|
||||||
|
let url = adapter.build_url(&base_url, effective_endpoint);
|
||||||
|
|
||||||
// 记录原始请求 JSON
|
// 记录原始请求 JSON
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
|
"[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}",
|
||||||
@@ -397,10 +450,23 @@ impl RequestForwarder {
|
|||||||
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
|
serde_json::to_string_pretty(body).unwrap_or_else(|_| body.to_string())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 应用模型映射(独立于格式转换)
|
||||||
|
let (mapped_body, _original_model, mapped_model) =
|
||||||
|
super::model_mapper::apply_model_mapping(body.clone(), provider);
|
||||||
|
|
||||||
|
if let Some(ref mapped) = mapped_model {
|
||||||
|
log::info!(
|
||||||
|
"[{}] >>> 模型映射后的请求 JSON:\n{}",
|
||||||
|
adapter.name(),
|
||||||
|
serde_json::to_string_pretty(&mapped_body).unwrap_or_default()
|
||||||
|
);
|
||||||
|
log::info!("[{}] 模型已映射到: {}", adapter.name(), mapped);
|
||||||
|
}
|
||||||
|
|
||||||
// 转换请求体(如果需要)
|
// 转换请求体(如果需要)
|
||||||
let request_body = if needs_transform {
|
let request_body = if needs_transform {
|
||||||
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name());
|
||||||
let transformed = adapter.transform_request(body.clone(), provider)?;
|
let transformed = adapter.transform_request(mapped_body, provider)?;
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] >>> 转换后的请求 JSON:\n{}",
|
"[{}] >>> 转换后的请求 JSON:\n{}",
|
||||||
adapter.name(),
|
adapter.name(),
|
||||||
@@ -408,7 +474,7 @@ impl RequestForwarder {
|
|||||||
);
|
);
|
||||||
transformed
|
transformed
|
||||||
} else {
|
} else {
|
||||||
body.clone()
|
mapped_body
|
||||||
};
|
};
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
|
|||||||
@@ -31,13 +31,26 @@ pub struct UsageParserConfig {
|
|||||||
// 模型提取器实现
|
// 模型提取器实现
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Claude 流式响应模型提取(直接使用请求模型)
|
/// Claude 流式响应模型提取(优先使用 usage.model)
|
||||||
fn claude_model_extractor(_events: &[Value], request_model: &str) -> String {
|
fn claude_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||||
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
|
if let Some(usage) = TokenUsage::from_claude_stream_events(events) {
|
||||||
|
if let Some(model) = usage.model {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
request_model.to_string()
|
request_model.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// OpenAI Chat Completions 流式响应模型提取
|
/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model)
|
||||||
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||||
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
|
if let Some(usage) = TokenUsage::from_openai_stream_events(events) {
|
||||||
|
if let Some(model) = usage.model {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退:从事件中直接提取
|
||||||
events
|
events
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| e.get("model")?.as_str())
|
.find_map(|e| e.get("model")?.as_str())
|
||||||
@@ -45,8 +58,15 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codex Responses API 流式响应模型提取
|
/// Codex Responses API 流式响应模型提取(优先使用 usage.model)
|
||||||
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
|
||||||
|
// 首先尝试从解析的 usage 中获取模型
|
||||||
|
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
|
||||||
|
if let Some(model) = usage.model {
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 回退:从 response.completed 事件中提取
|
||||||
events
|
events
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|e| {
|
.find_map(|e| {
|
||||||
|
|||||||
@@ -5,23 +5,32 @@
|
|||||||
use crate::app_config::AppType;
|
use crate::app_config::AppType;
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::{
|
use crate::proxy::{
|
||||||
forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError,
|
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError,
|
||||||
};
|
};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// 流式超时配置
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct StreamingTimeoutConfig {
|
||||||
|
/// 首字节超时(秒),0 表示禁用
|
||||||
|
pub first_byte_timeout: u64,
|
||||||
|
/// 静默期超时(秒),0 表示禁用
|
||||||
|
pub idle_timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// 请求上下文
|
/// 请求上下文
|
||||||
///
|
///
|
||||||
/// 贯穿整个请求生命周期,包含:
|
/// 贯穿整个请求生命周期,包含:
|
||||||
/// - 计时信息
|
/// - 计时信息
|
||||||
/// - 代理配置
|
/// - 应用级代理配置(per-app)
|
||||||
/// - 选中的 Provider 列表(用于故障转移)
|
/// - 选中的 Provider 列表(用于故障转移)
|
||||||
/// - 请求模型名称
|
/// - 请求模型名称
|
||||||
/// - 日志标签
|
/// - 日志标签
|
||||||
pub struct RequestContext {
|
pub struct RequestContext {
|
||||||
/// 请求开始时间
|
/// 请求开始时间
|
||||||
pub start_time: Instant,
|
pub start_time: Instant,
|
||||||
/// 代理配置快照
|
/// 应用级代理配置(per-app,包含重试次数和超时配置)
|
||||||
pub config: ProxyConfig,
|
pub app_config: AppProxyConfig,
|
||||||
/// 选中的 Provider(故障转移链的第一个)
|
/// 选中的 Provider(故障转移链的第一个)
|
||||||
pub provider: Provider,
|
pub provider: Provider,
|
||||||
/// 完整的 Provider 列表(用于故障转移)
|
/// 完整的 Provider 列表(用于故障转移)
|
||||||
@@ -62,7 +71,14 @@ impl RequestContext {
|
|||||||
app_type_str: &'static str,
|
app_type_str: &'static str,
|
||||||
) -> Result<Self, ProxyError> {
|
) -> Result<Self, ProxyError> {
|
||||||
let start_time = Instant::now();
|
let start_time = Instant::now();
|
||||||
let config = state.config.read().await.clone();
|
|
||||||
|
// 从数据库读取应用级代理配置(per-app)
|
||||||
|
let app_config = state
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(app_type_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
|
||||||
|
|
||||||
let current_provider_id =
|
let current_provider_id =
|
||||||
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
crate::settings::get_current_provider(&app_type).unwrap_or_default();
|
||||||
|
|
||||||
@@ -96,7 +112,7 @@ impl RequestContext {
|
|||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
start_time,
|
start_time,
|
||||||
config,
|
app_config,
|
||||||
provider,
|
provider,
|
||||||
providers,
|
providers,
|
||||||
current_provider_id,
|
current_provider_id,
|
||||||
@@ -135,13 +151,15 @@ impl RequestContext {
|
|||||||
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
|
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder {
|
||||||
RequestForwarder::new(
|
RequestForwarder::new(
|
||||||
state.provider_router.clone(),
|
state.provider_router.clone(),
|
||||||
self.config.request_timeout,
|
self.app_config.non_streaming_timeout as u64,
|
||||||
self.config.max_retries,
|
self.app_config.max_retries as u8,
|
||||||
state.status.clone(),
|
state.status.clone(),
|
||||||
state.current_providers.clone(),
|
state.current_providers.clone(),
|
||||||
state.failover_manager.clone(),
|
state.failover_manager.clone(),
|
||||||
state.app_handle.clone(),
|
state.app_handle.clone(),
|
||||||
self.current_provider_id.clone(),
|
self.current_provider_id.clone(),
|
||||||
|
self.app_config.streaming_first_byte_timeout as u64,
|
||||||
|
self.app_config.streaming_idle_timeout as u64,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,4 +175,13 @@ impl RequestContext {
|
|||||||
pub fn latency_ms(&self) -> u64 {
|
pub fn latency_ms(&self) -> u64 {
|
||||||
self.start_time.elapsed().as_millis() as u64
|
self.start_time.elapsed().as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 获取流式超时配置
|
||||||
|
#[inline]
|
||||||
|
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
|
||||||
|
StreamingTimeoutConfig {
|
||||||
|
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
|
||||||
|
idle_timeout: self.app_config.streaming_idle_timeout as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! 重构后的结构:
|
//! 重构后的结构:
|
||||||
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
|
//! - 通用逻辑提取到 `handler_context` 和 `response_processor` 模块
|
||||||
//! - 各 handler 只保留独特的业务逻辑
|
//! - 各 handler 只保留独特的业务逻辑
|
||||||
//! - Claude 的格式转换逻辑保留在此文件(独有功能)
|
//! - Claude 的格式转换逻辑保留在此文件(用于 OpenRouter 旧接口回退)
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
error_mapper::{get_error_message, map_proxy_error_to_status},
|
error_mapper::{get_error_message, map_proxy_error_to_status},
|
||||||
@@ -54,34 +54,23 @@ pub async fn get_status(State(state): State<ProxyState>) -> Result<Json<ProxySta
|
|||||||
/// 处理 /v1/messages 请求(Claude API)
|
/// 处理 /v1/messages 请求(Claude API)
|
||||||
///
|
///
|
||||||
/// Claude 处理器包含独特的格式转换逻辑:
|
/// Claude 处理器包含独特的格式转换逻辑:
|
||||||
/// - 当使用 OpenRouter 等中转服务时,需要将 Anthropic 格式转换为 OpenAI 格式
|
/// - 过去用于 OpenRouter 的 OpenAI Chat Completions 兼容接口(Anthropic ↔ OpenAI 转换)
|
||||||
/// - 响应需要从 OpenAI 格式转回 Anthropic 格式
|
/// - 现在 OpenRouter 已推出 Claude Code 兼容接口,默认不再启用该转换(逻辑保留以备回退)
|
||||||
pub async fn handle_messages(
|
pub async fn handle_messages(
|
||||||
State(state): State<ProxyState>,
|
State(state): State<ProxyState>,
|
||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
|
||||||
|
|
||||||
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
|
||||||
let adapter = get_adapter(&AppType::Claude);
|
|
||||||
let needs_transform = adapter.needs_transform(&ctx.provider);
|
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
.and_then(|s| s.as_bool())
|
.and_then(|s| s.as_bool())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
log::info!(
|
|
||||||
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
|
||||||
ctx.provider.name,
|
|
||||||
needs_transform,
|
|
||||||
is_stream
|
|
||||||
);
|
|
||||||
|
|
||||||
// 转发请求
|
// 转发请求
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Claude,
|
&AppType::Claude,
|
||||||
"/v1/messages",
|
"/v1/messages",
|
||||||
@@ -91,13 +80,30 @@ pub async fn handle_messages(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
|
// 检查是否需要格式转换(OpenRouter 等中转服务)
|
||||||
|
let adapter = get_adapter(&AppType::Claude);
|
||||||
|
let needs_transform = adapter.needs_transform(&ctx.provider);
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[Claude] Provider: {}, needs_transform: {}, is_stream: {}",
|
||||||
|
ctx.provider.name,
|
||||||
|
needs_transform,
|
||||||
|
is_stream
|
||||||
|
);
|
||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
log::info!("[Claude] 上游响应状态: {status}");
|
log::info!("[Claude] 上游响应状态: {status}");
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ pub async fn handle_messages(
|
|||||||
|
|
||||||
/// Claude 格式转换处理(独有逻辑)
|
/// Claude 格式转换处理(独有逻辑)
|
||||||
///
|
///
|
||||||
/// 处理 OpenRouter 等需要格式转换的中转服务
|
/// 处理 OpenRouter 旧 OpenAI 兼容接口的回退方案(当前默认不启用)
|
||||||
async fn handle_claude_transform(
|
async fn handle_claude_transform(
|
||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
ctx: &RequestContext,
|
ctx: &RequestContext,
|
||||||
@@ -164,10 +170,14 @@ async fn handle_claude_transform(
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取流式超时配置
|
||||||
|
let timeout_config = ctx.streaming_timeout_config();
|
||||||
|
|
||||||
let logged_stream = create_logged_passthrough_stream(
|
let logged_stream = create_logged_passthrough_stream(
|
||||||
sse_stream,
|
sse_stream,
|
||||||
"Claude/OpenRouter",
|
"Claude/OpenRouter",
|
||||||
Some(usage_collector),
|
Some(usage_collector),
|
||||||
|
timeout_config,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut headers = axum::http::HeaderMap::new();
|
let mut headers = axum::http::HeaderMap::new();
|
||||||
@@ -295,7 +305,7 @@ pub async fn handle_chat_completions(
|
|||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
|
||||||
|
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
@@ -309,7 +319,7 @@ pub async fn handle_chat_completions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/chat/completions",
|
"/v1/chat/completions",
|
||||||
@@ -319,13 +329,19 @@ pub async fn handle_chat_completions(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &OPENAI_PARSER_CONFIG).await
|
||||||
@@ -337,7 +353,7 @@ pub async fn handle_responses(
|
|||||||
headers: axum::http::HeaderMap,
|
headers: axum::http::HeaderMap,
|
||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
|
||||||
|
|
||||||
let is_stream = body
|
let is_stream = body
|
||||||
.get("stream")
|
.get("stream")
|
||||||
@@ -345,7 +361,7 @@ pub async fn handle_responses(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Codex,
|
&AppType::Codex,
|
||||||
"/v1/responses",
|
"/v1/responses",
|
||||||
@@ -355,13 +371,19 @@ pub async fn handle_responses(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Codex] 上游响应状态: {}", response.status());
|
log::info!("[Codex] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &CODEX_PARSER_CONFIG).await
|
||||||
@@ -379,7 +401,7 @@ pub async fn handle_gemini(
|
|||||||
Json(body): Json<Value>,
|
Json(body): Json<Value>,
|
||||||
) -> Result<axum::response::Response, ProxyError> {
|
) -> Result<axum::response::Response, ProxyError> {
|
||||||
// Gemini 的模型名称在 URI 中
|
// Gemini 的模型名称在 URI 中
|
||||||
let ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
|
||||||
.await?
|
.await?
|
||||||
.with_model_from_uri(&uri);
|
.with_model_from_uri(&uri);
|
||||||
|
|
||||||
@@ -397,7 +419,7 @@ pub async fn handle_gemini(
|
|||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let forwarder = ctx.create_forwarder(&state);
|
let forwarder = ctx.create_forwarder(&state);
|
||||||
let response = match forwarder
|
let result = match forwarder
|
||||||
.forward_with_retry(
|
.forward_with_retry(
|
||||||
&AppType::Gemini,
|
&AppType::Gemini,
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -407,13 +429,19 @@ pub async fn handle_gemini(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(resp) => resp,
|
Ok(result) => result,
|
||||||
Err(e) => {
|
Err(mut err) => {
|
||||||
log_forward_error(&state, &ctx, is_stream, &e);
|
if let Some(provider) = err.provider.take() {
|
||||||
return Err(e);
|
ctx.provider = provider;
|
||||||
|
}
|
||||||
|
log_forward_error(&state, &ctx, is_stream, &err.error);
|
||||||
|
return Err(err.error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ctx.provider = result.provider;
|
||||||
|
let response = result.response;
|
||||||
|
|
||||||
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
log::info!("[Gemini] 上游响应状态: {}", response.status());
|
||||||
|
|
||||||
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
process_response(response, &ctx, &state, &GEMINI_PARSER_CONFIG).await
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod handler_config;
|
|||||||
pub mod handler_context;
|
pub mod handler_context;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
mod health;
|
mod health;
|
||||||
|
pub mod model_mapper;
|
||||||
pub mod provider_router;
|
pub mod provider_router;
|
||||||
pub mod providers;
|
pub mod providers;
|
||||||
pub mod response_handler;
|
pub mod response_handler;
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! 模型映射模块
|
||||||
|
//!
|
||||||
|
//! 在请求转发前,根据 Provider 配置替换请求中的模型名称
|
||||||
|
|
||||||
|
use crate::provider::Provider;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// 模型映射配置
|
||||||
|
pub struct ModelMapping {
|
||||||
|
pub haiku_model: Option<String>,
|
||||||
|
pub sonnet_model: Option<String>,
|
||||||
|
pub opus_model: Option<String>,
|
||||||
|
pub default_model: Option<String>,
|
||||||
|
pub reasoning_model: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelMapping {
|
||||||
|
/// 从 Provider 配置中提取模型映射
|
||||||
|
pub fn from_provider(provider: &Provider) -> Self {
|
||||||
|
let env = provider.settings_config.get("env");
|
||||||
|
|
||||||
|
Self {
|
||||||
|
haiku_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_HAIKU_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
sonnet_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_SONNET_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
opus_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
default_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
reasoning_model: env
|
||||||
|
.and_then(|e| e.get("ANTHROPIC_REASONING_MODEL"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(String::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否配置了任何模型映射
|
||||||
|
pub fn has_mapping(&self) -> bool {
|
||||||
|
self.haiku_model.is_some()
|
||||||
|
|| self.sonnet_model.is_some()
|
||||||
|
|| self.opus_model.is_some()
|
||||||
|
|| self.default_model.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据原始模型名称获取映射后的模型
|
||||||
|
pub fn map_model(&self, original_model: &str, has_thinking: bool) -> String {
|
||||||
|
let model_lower = original_model.to_lowercase();
|
||||||
|
|
||||||
|
// 1. thinking 模式优先使用推理模型
|
||||||
|
if has_thinking {
|
||||||
|
if let Some(ref m) = self.reasoning_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 按模型类型匹配
|
||||||
|
if model_lower.contains("haiku") {
|
||||||
|
if let Some(ref m) = self.haiku_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model_lower.contains("opus") {
|
||||||
|
if let Some(ref m) = self.opus_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model_lower.contains("sonnet") {
|
||||||
|
if let Some(ref m) = self.sonnet_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 默认模型
|
||||||
|
if let Some(ref m) = self.default_model {
|
||||||
|
return m.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 无映射,保持原样
|
||||||
|
original_model.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检测请求是否启用了 thinking 模式
|
||||||
|
pub fn has_thinking_enabled(body: &Value) -> bool {
|
||||||
|
body.get("thinking")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.and_then(|o| o.get("type"))
|
||||||
|
.and_then(|t| t.as_str())
|
||||||
|
== Some("enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对请求体应用模型映射
|
||||||
|
///
|
||||||
|
/// 返回 (映射后的请求体, 原始模型名, 映射后模型名)
|
||||||
|
pub fn apply_model_mapping(
|
||||||
|
mut body: Value,
|
||||||
|
provider: &Provider,
|
||||||
|
) -> (Value, Option<String>, Option<String>) {
|
||||||
|
let mapping = ModelMapping::from_provider(provider);
|
||||||
|
|
||||||
|
// 如果没有配置映射,直接返回
|
||||||
|
if !mapping.has_mapping() {
|
||||||
|
let original = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
||||||
|
return (body, original, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取原始模型名
|
||||||
|
let original_model = body.get("model").and_then(|m| m.as_str()).map(String::from);
|
||||||
|
|
||||||
|
if let Some(ref original) = original_model {
|
||||||
|
let has_thinking = has_thinking_enabled(&body);
|
||||||
|
let mapped = mapping.map_model(original, has_thinking);
|
||||||
|
|
||||||
|
if mapped != *original {
|
||||||
|
log::info!("[ModelMapper] 模型映射: {original} → {mapped}");
|
||||||
|
body["model"] = serde_json::json!(mapped);
|
||||||
|
return (body, Some(original.clone()), Some(mapped));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(body, original_model, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn create_provider_with_mapping() -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test".to_string(),
|
||||||
|
settings_config: json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_MODEL": "default-model",
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-mapped",
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "sonnet-mapped",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "opus-mapped",
|
||||||
|
"ANTHROPIC_REASONING_MODEL": "reasoning-model"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_provider_without_mapping() -> Provider {
|
||||||
|
Provider {
|
||||||
|
id: "test".to_string(),
|
||||||
|
name: "Test".to_string(),
|
||||||
|
settings_config: json!({}),
|
||||||
|
website_url: None,
|
||||||
|
category: None,
|
||||||
|
created_at: None,
|
||||||
|
sort_index: None,
|
||||||
|
notes: None,
|
||||||
|
meta: None,
|
||||||
|
icon: None,
|
||||||
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sonnet_mapping() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-sonnet-4-5-20250929"});
|
||||||
|
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "sonnet-mapped");
|
||||||
|
assert_eq!(original, Some("claude-sonnet-4-5-20250929".to_string()));
|
||||||
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_haiku_mapping() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-haiku-4-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "haiku-mapped");
|
||||||
|
assert_eq!(mapped, Some("haiku-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_opus_mapping() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "claude-opus-4-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "opus-mapped");
|
||||||
|
assert_eq!(mapped, Some("opus-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_mode() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"thinking": {"type": "enabled"}
|
||||||
|
});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "reasoning-model");
|
||||||
|
assert_eq!(mapped, Some("reasoning-model".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_thinking_disabled() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet-4-5",
|
||||||
|
"thinking": {"type": "disabled"}
|
||||||
|
});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "sonnet-mapped");
|
||||||
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_unknown_model_uses_default() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "some-unknown-model"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "default-model");
|
||||||
|
assert_eq!(mapped, Some("default-model".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_mapping_configured() {
|
||||||
|
let provider = create_provider_without_mapping();
|
||||||
|
let body = json!({"model": "claude-sonnet-4-5"});
|
||||||
|
let (result, original, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "claude-sonnet-4-5");
|
||||||
|
assert_eq!(original, Some("claude-sonnet-4-5".to_string()));
|
||||||
|
assert!(mapped.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_case_insensitive() {
|
||||||
|
let provider = create_provider_with_mapping();
|
||||||
|
let body = json!({"model": "Claude-SONNET-4-5"});
|
||||||
|
let (result, _, mapped) = apply_model_mapping(body, &provider);
|
||||||
|
assert_eq!(result["model"], "sonnet-mapped");
|
||||||
|
assert_eq!(mapped, Some("sonnet-mapped".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,74 +30,73 @@ impl ProviderRouter {
|
|||||||
/// 选择可用的供应商(支持故障转移)
|
/// 选择可用的供应商(支持故障转移)
|
||||||
///
|
///
|
||||||
/// 返回按优先级排序的可用供应商列表:
|
/// 返回按优先级排序的可用供应商列表:
|
||||||
/// 1. 当前供应商(is_current=true)始终第一位
|
/// - 故障转移关闭时:仅返回当前供应商
|
||||||
/// 2. 故障转移队列中的其他供应商(按 queue_order 排序)
|
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
|
||||||
/// 3. 只返回熔断器未打开的供应商
|
|
||||||
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let all_providers = self.db.get_all_providers(app_type)?;
|
|
||||||
|
|
||||||
// 1. 当前供应商始终第一位
|
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
|
||||||
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
if let Some(current) = all_providers.get(¤t_id) {
|
Ok(config) => {
|
||||||
let circuit_key = format!("{}:{}", app_type, current.id);
|
let enabled = config.auto_failover_enabled;
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}");
|
||||||
|
enabled
|
||||||
if breaker.is_available().await {
|
|
||||||
log::info!(
|
|
||||||
"[{}] Current provider available: {} ({})",
|
|
||||||
app_type,
|
|
||||||
current.name,
|
|
||||||
current.id
|
|
||||||
);
|
|
||||||
result.push(current.clone());
|
|
||||||
} else {
|
|
||||||
log::warn!(
|
|
||||||
"[{}] Current provider {} circuit breaker open, checking failover queue",
|
|
||||||
app_type,
|
|
||||||
current.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
// 2. 获取故障转移队列中的供应商
|
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
|
||||||
let queue = self.db.get_failover_queue(app_type)?;
|
);
|
||||||
|
false
|
||||||
for item in queue {
|
|
||||||
// 跳过已添加的当前供应商
|
|
||||||
if result.iter().any(|p| p.id == item.provider_id) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 跳过禁用的队列项
|
if auto_failover_enabled {
|
||||||
if !item.enabled {
|
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
|
||||||
continue;
|
let failover_providers = self.db.get_failover_providers(app_type)?;
|
||||||
}
|
log::info!(
|
||||||
|
"[{}] Failover enabled, using queue order ({} items)",
|
||||||
|
app_type,
|
||||||
|
failover_providers.len()
|
||||||
|
);
|
||||||
|
|
||||||
// 获取供应商信息
|
for provider in failover_providers {
|
||||||
if let Some(provider) = all_providers.get(&item.provider_id) {
|
|
||||||
// 检查熔断器状态
|
// 检查熔断器状态
|
||||||
let circuit_key = format!("{}:{}", app_type, provider.id);
|
let circuit_key = format!("{}:{}", app_type, provider.id);
|
||||||
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
|
||||||
|
|
||||||
if breaker.is_available().await {
|
if breaker.is_available().await {
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] Failover provider available: {} ({}) at queue position {}",
|
"[{}] Queue provider available: {} ({}) at sort_index {:?}",
|
||||||
app_type,
|
app_type,
|
||||||
provider.name,
|
provider.name,
|
||||||
provider.id,
|
provider.id,
|
||||||
item.queue_order
|
provider.sort_index
|
||||||
);
|
);
|
||||||
result.push(provider.clone());
|
result.push(provider);
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[{}] Failover provider {} circuit breaker open, skipping",
|
"[{}] Queue provider {} circuit breaker open, skipping",
|
||||||
app_type,
|
app_type,
|
||||||
provider.name
|
provider.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 故障转移关闭:仅使用当前供应商,跳过熔断器检查
|
||||||
|
// 原因:单 Provider 场景下,熔断器打开会导致所有请求失败,用户体验差
|
||||||
|
log::info!("[{app_type}] Failover disabled, using current provider only (circuit breaker bypassed)");
|
||||||
|
|
||||||
|
if let Some(current_id) = self.db.get_current_provider(app_type)? {
|
||||||
|
if let Some(current) = self.db.get_provider_by_id(¤t_id, app_type)? {
|
||||||
|
log::info!(
|
||||||
|
"[{}] Current provider: {} ({})",
|
||||||
|
app_type,
|
||||||
|
current.name,
|
||||||
|
current.id
|
||||||
|
);
|
||||||
|
result.push(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
@@ -107,7 +106,7 @@ impl ProviderRouter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"[{}] Failover chain: {} provider(s) available",
|
"[{}] Provider chain: {} provider(s) available",
|
||||||
app_type,
|
app_type,
|
||||||
result.len()
|
result.len()
|
||||||
);
|
);
|
||||||
@@ -138,9 +137,16 @@ impl ProviderRouter {
|
|||||||
success: bool,
|
success: bool,
|
||||||
error_msg: Option<String>,
|
error_msg: Option<String>,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
// 1. 获取熔断器配置(用于更新健康状态和判断是否禁用)
|
// 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用)
|
||||||
let config = self.db.get_circuit_breaker_config().await.ok();
|
let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5);
|
Ok(app_config) => app_config.circuit_failure_threshold,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to load circuit config for {app_type}, using default threshold: {e}"
|
||||||
|
);
|
||||||
|
5 // 默认值
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 2. 更新熔断器状态
|
// 2. 更新熔断器状态
|
||||||
let circuit_key = format!("{app_type}:{provider_id}");
|
let circuit_key = format!("{app_type}:{provider_id}");
|
||||||
@@ -237,12 +243,34 @@ impl ProviderRouter {
|
|||||||
return breaker.clone();
|
return breaker.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从数据库加载配置
|
// 从 key 中提取 app_type (格式: "app_type:provider_id")
|
||||||
let config = self
|
let app_type = key.split(':').next().unwrap_or("claude");
|
||||||
.db
|
|
||||||
.get_circuit_breaker_config()
|
// 按应用独立读取熔断器配置
|
||||||
.await
|
let config = match self.db.get_proxy_config_for_app(app_type).await {
|
||||||
.unwrap_or_default();
|
Ok(app_config) => {
|
||||||
|
log::debug!(
|
||||||
|
"Loading circuit breaker config for {key} (app={app_type}): \
|
||||||
|
failure_threshold={}, success_threshold={}, timeout={}s",
|
||||||
|
app_config.circuit_failure_threshold,
|
||||||
|
app_config.circuit_success_threshold,
|
||||||
|
app_config.circuit_timeout_seconds
|
||||||
|
);
|
||||||
|
crate::proxy::circuit_breaker::CircuitBreakerConfig {
|
||||||
|
failure_threshold: app_config.circuit_failure_threshold,
|
||||||
|
success_threshold: app_config.circuit_success_threshold,
|
||||||
|
timeout_seconds: app_config.circuit_timeout_seconds as u64,
|
||||||
|
error_rate_threshold: app_config.circuit_error_rate_threshold,
|
||||||
|
min_requests: app_config.circuit_min_requests,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"Failed to load circuit breaker config for {key} (app={app_type}): {e}, using default"
|
||||||
|
);
|
||||||
|
crate::proxy::circuit_breaker::CircuitBreakerConfig::default()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
log::debug!("Creating new circuit breaker for {key} with config: {config:?}");
|
||||||
|
|
||||||
@@ -264,25 +292,14 @@ mod tests {
|
|||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
let router = ProviderRouter::new(db);
|
let router = ProviderRouter::new(db);
|
||||||
|
|
||||||
// 测试创建熔断器
|
|
||||||
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
let breaker = router.get_or_create_circuit_breaker("claude:test").await;
|
||||||
assert!(breaker.allow_request().await.allowed);
|
assert!(breaker.allow_request().await.allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn select_providers_does_not_consume_half_open_permit() {
|
async fn test_failover_disabled_uses_current_provider() {
|
||||||
let db = Arc::new(Database::memory().unwrap());
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
// 配置:让熔断器 Open 后立刻进入 HalfOpen(timeout_seconds=0),并用 1 次失败就打开熔断器
|
|
||||||
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
|
||||||
failure_threshold: 1,
|
|
||||||
timeout_seconds: 0,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// 准备 2 个 Provider:A(当前)+ B(队列)
|
|
||||||
let provider_a =
|
let provider_a =
|
||||||
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
let provider_b =
|
let provider_b =
|
||||||
@@ -293,19 +310,84 @@ mod tests {
|
|||||||
db.set_current_provider("claude", "a").unwrap();
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
db.add_to_failover_queue("claude", "b").unwrap();
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 1);
|
||||||
|
assert_eq!(providers[0].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_failover_enabled_uses_queue_order() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
// 设置 sort_index 来控制顺序:b=1, a=2
|
||||||
|
let mut provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
provider_a.sort_index = Some(2);
|
||||||
|
let mut provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
provider_b.sort_index = Some(1);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
db.set_current_provider("claude", "a").unwrap();
|
||||||
|
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
|
||||||
|
// 启用自动故障转移(使用新的 proxy_config API)
|
||||||
|
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||||
|
config.auto_failover_enabled = true;
|
||||||
|
db.update_proxy_config_for_app(config).await.unwrap();
|
||||||
|
|
||||||
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(providers.len(), 2);
|
||||||
|
// 按 sort_index 排序:b(1) 在前,a(2) 在后
|
||||||
|
assert_eq!(providers[0].id, "b");
|
||||||
|
assert_eq!(providers[1].id, "a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_select_providers_does_not_consume_half_open_permit() {
|
||||||
|
let db = Arc::new(Database::memory().unwrap());
|
||||||
|
|
||||||
|
db.update_circuit_breaker_config(&CircuitBreakerConfig {
|
||||||
|
failure_threshold: 1,
|
||||||
|
timeout_seconds: 0,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let provider_a =
|
||||||
|
Provider::with_id("a".to_string(), "Provider A".to_string(), json!({}), None);
|
||||||
|
let provider_b =
|
||||||
|
Provider::with_id("b".to_string(), "Provider B".to_string(), json!({}), None);
|
||||||
|
|
||||||
|
db.save_provider("claude", &provider_a).unwrap();
|
||||||
|
db.save_provider("claude", &provider_b).unwrap();
|
||||||
|
|
||||||
|
db.add_to_failover_queue("claude", "a").unwrap();
|
||||||
|
db.add_to_failover_queue("claude", "b").unwrap();
|
||||||
|
|
||||||
|
// 启用自动故障转移(使用新的 proxy_config API)
|
||||||
|
let mut config = db.get_proxy_config_for_app("claude").await.unwrap();
|
||||||
|
config.auto_failover_enabled = true;
|
||||||
|
db.update_proxy_config_for_app(config).await.unwrap();
|
||||||
|
|
||||||
let router = ProviderRouter::new(db.clone());
|
let router = ProviderRouter::new(db.clone());
|
||||||
|
|
||||||
// 让 B 进入 Open 状态(failure_threshold=1)
|
|
||||||
router
|
router
|
||||||
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
.record_result("b", "claude", false, false, Some("fail".to_string()))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// select_providers 只做“可用性判断”,不应占用 HalfOpen 探测名额
|
|
||||||
let providers = router.select_providers("claude").await.unwrap();
|
let providers = router.select_providers("claude").await.unwrap();
|
||||||
assert_eq!(providers.len(), 2);
|
assert_eq!(providers.len(), 2);
|
||||||
|
|
||||||
// 如果 select_providers 错误地消耗了 HalfOpen 名额,这里会返回 false(被限流拒绝)
|
|
||||||
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
assert!(router.allow_provider_request("b", "claude").await.allowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ pub trait ProviderAdapter: Send + Sync {
|
|||||||
/// 是否需要格式转换
|
/// 是否需要格式转换
|
||||||
///
|
///
|
||||||
/// 默认返回 `false`(透传模式)。
|
/// 默认返回 `false`(透传模式)。
|
||||||
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter)才返回 `true`。
|
/// 仅当供应商需要格式转换时(如 Claude + OpenRouter 旧 OpenAI 兼容接口)才返回 `true`。
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
/// * `provider` - Provider 配置
|
/// * `provider` - Provider 配置
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! ## 认证模式
|
//! ## 认证模式
|
||||||
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
//! - **Claude**: Anthropic 官方 API (x-api-key + anthropic-version)
|
||||||
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
//! - **ClaudeAuth**: 中转服务 (仅 Bearer 认证,无 x-api-key)
|
||||||
//! - **OpenRouter**: 需要 Anthropic ↔ OpenAI 格式转换
|
//! - **OpenRouter**: 已支持 Claude Code 兼容接口,默认透传(保留旧转换逻辑备用)
|
||||||
|
|
||||||
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
use super::{AuthInfo, AuthStrategy, ProviderAdapter, ProviderType};
|
||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
@@ -28,10 +28,8 @@ impl ClaudeAdapter {
|
|||||||
/// - Claude: 默认 Anthropic 官方
|
/// - Claude: 默认 Anthropic 官方
|
||||||
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
pub fn provider_type(&self, provider: &Provider) -> ProviderType {
|
||||||
// 检测 OpenRouter
|
// 检测 OpenRouter
|
||||||
if let Ok(base_url) = self.extract_base_url(provider) {
|
if self.is_openrouter(provider) {
|
||||||
if base_url.contains("openrouter.ai") {
|
return ProviderType::OpenRouter;
|
||||||
return ProviderType::OpenRouter;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检测 ClaudeAuth (仅 Bearer 认证)
|
// 检测 ClaudeAuth (仅 Bearer 认证)
|
||||||
@@ -50,6 +48,24 @@ impl ClaudeAdapter {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 检测 OpenRouter 是否启用兼容模式
|
||||||
|
fn is_openrouter_compat_enabled(&self, provider: &Provider) -> bool {
|
||||||
|
if !self.is_openrouter(provider) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw = provider.settings_config.get("openrouter_compat_mode");
|
||||||
|
match raw {
|
||||||
|
Some(serde_json::Value::Bool(enabled)) => *enabled,
|
||||||
|
Some(serde_json::Value::Number(num)) => num.as_i64().unwrap_or(0) != 0,
|
||||||
|
Some(serde_json::Value::String(value)) => {
|
||||||
|
let normalized = value.trim().to_lowercase();
|
||||||
|
normalized == "true" || normalized == "1"
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 检测是否为仅 Bearer 认证模式
|
/// 检测是否为仅 Bearer 认证模式
|
||||||
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
fn is_bearer_only_mode(&self, provider: &Provider) -> bool {
|
||||||
// 检查 settings_config 中的 auth_mode
|
// 检查 settings_config 中的 auth_mode
|
||||||
@@ -87,6 +103,14 @@ impl ClaudeAdapter {
|
|||||||
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
log::debug!("[Claude] 使用 ANTHROPIC_AUTH_TOKEN");
|
||||||
return Some(key.to_string());
|
return Some(key.to_string());
|
||||||
}
|
}
|
||||||
|
if let Some(key) = env
|
||||||
|
.get("ANTHROPIC_API_KEY")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
{
|
||||||
|
log::debug!("[Claude] 使用 ANTHROPIC_API_KEY");
|
||||||
|
return Some(key.to_string());
|
||||||
|
}
|
||||||
// OpenRouter key
|
// OpenRouter key
|
||||||
if let Some(key) = env
|
if let Some(key) = env
|
||||||
.get("OPENROUTER_API_KEY")
|
.get("OPENROUTER_API_KEY")
|
||||||
@@ -186,12 +210,13 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
fn build_url(&self, base_url: &str, endpoint: &str) -> String {
|
||||||
// OpenRouter 使用 /v1/chat/completions
|
// NOTE:
|
||||||
if base_url.contains("openrouter.ai") {
|
// 过去 OpenRouter 只有 OpenAI Chat Completions 兼容接口,需要把 Claude 的 `/v1/messages`
|
||||||
return format!("{}/v1/chat/completions", base_url.trim_end_matches('/'));
|
// 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。
|
||||||
}
|
//
|
||||||
|
// 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。
|
||||||
|
// 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。
|
||||||
|
|
||||||
// Anthropic 直连
|
|
||||||
format!(
|
format!(
|
||||||
"{}/{}",
|
"{}/{}",
|
||||||
base_url.trim_end_matches('/'),
|
base_url.trim_end_matches('/'),
|
||||||
@@ -207,19 +232,24 @@ impl ProviderAdapter for ClaudeAdapter {
|
|||||||
.header("x-api-key", &auth.api_key)
|
.header("x-api-key", &auth.api_key)
|
||||||
.header("anthropic-version", "2023-06-01"),
|
.header("anthropic-version", "2023-06-01"),
|
||||||
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
// ClaudeAuth 中转服务: 仅 Bearer,无 x-api-key
|
||||||
AuthStrategy::ClaudeAuth => {
|
AuthStrategy::ClaudeAuth => request
|
||||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
}
|
.header("anthropic-version", "2023-06-01"),
|
||||||
// OpenRouter: Bearer
|
// OpenRouter: Bearer
|
||||||
AuthStrategy::Bearer => {
|
AuthStrategy::Bearer => request
|
||||||
request.header("Authorization", format!("Bearer {}", auth.api_key))
|
.header("Authorization", format!("Bearer {}", auth.api_key))
|
||||||
}
|
.header("anthropic-version", "2023-06-01"),
|
||||||
_ => request,
|
_ => request,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_transform(&self, provider: &Provider) -> bool {
|
fn needs_transform(&self, _provider: &Provider) -> bool {
|
||||||
self.is_openrouter(provider)
|
// NOTE:
|
||||||
|
// OpenRouter 已推出 Claude Code 兼容接口(可直接处理 `/v1/messages`),默认不再启用
|
||||||
|
// Anthropic ↔ OpenAI 的格式转换。
|
||||||
|
//
|
||||||
|
// 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行:
|
||||||
|
self.is_openrouter_compat_enabled(_provider)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transform_request(
|
fn transform_request(
|
||||||
@@ -253,6 +283,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,6 +315,21 @@ mod tests {
|
|||||||
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_auth_anthropic_api_key() {
|
||||||
|
let adapter = ClaudeAdapter::new();
|
||||||
|
let provider = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||||
|
"ANTHROPIC_API_KEY": "sk-ant-test-key"
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
let auth = adapter.extract_auth(&provider).unwrap();
|
||||||
|
assert_eq!(auth.api_key, "sk-ant-test-key");
|
||||||
|
assert_eq!(auth.strategy, AuthStrategy::Anthropic);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_auth_openrouter() {
|
fn test_extract_auth_openrouter() {
|
||||||
let adapter = ClaudeAdapter::new();
|
let adapter = ClaudeAdapter::new();
|
||||||
@@ -378,7 +424,7 @@ mod tests {
|
|||||||
fn test_build_url_openrouter() {
|
fn test_build_url_openrouter() {
|
||||||
let adapter = ClaudeAdapter::new();
|
let adapter = ClaudeAdapter::new();
|
||||||
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
let url = adapter.build_url("https://openrouter.ai/api", "/v1/messages");
|
||||||
assert_eq!(url, "https://openrouter.ai/api/v1/chat/completions");
|
assert_eq!(url, "https://openrouter.ai/api/v1/messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -398,5 +444,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
assert!(adapter.needs_transform(&openrouter_provider));
|
assert!(adapter.needs_transform(&openrouter_provider));
|
||||||
|
|
||||||
|
let openrouter_disabled = create_provider(json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://openrouter.ai/api"
|
||||||
|
},
|
||||||
|
"openrouter_compat_mode": false
|
||||||
|
}));
|
||||||
|
assert!(!adapter.needs_transform(&openrouter_disabled));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,17 +48,21 @@ pub enum ProviderType {
|
|||||||
Gemini,
|
Gemini,
|
||||||
/// Google Gemini CLI (OAuth Bearer)
|
/// Google Gemini CLI (OAuth Bearer)
|
||||||
GeminiCli,
|
GeminiCli,
|
||||||
/// OpenRouter (需要 Anthropic ↔ OpenAI 格式转换)
|
/// OpenRouter(已支持 Claude Code 兼容接口,默认透传;保留旧转换逻辑备用)
|
||||||
OpenRouter,
|
OpenRouter,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderType {
|
impl ProviderType {
|
||||||
/// 是否需要格式转换
|
/// 是否需要格式转换
|
||||||
///
|
///
|
||||||
/// OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式
|
/// 过去 OpenRouter 需要将 Anthropic 格式转换为 OpenAI 格式;
|
||||||
|
/// 现在默认关闭转换(因为 OpenRouter 已支持 Claude Code 兼容接口)。
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn needs_transform(&self) -> bool {
|
pub fn needs_transform(&self) -> bool {
|
||||||
matches!(self, ProviderType::OpenRouter)
|
match self {
|
||||||
|
ProviderType::OpenRouter => false,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取默认端点
|
/// 获取默认端点
|
||||||
@@ -205,6 +209,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,7 +220,7 @@ mod tests {
|
|||||||
assert!(!ProviderType::Codex.needs_transform());
|
assert!(!ProviderType::Codex.needs_transform());
|
||||||
assert!(!ProviderType::Gemini.needs_transform());
|
assert!(!ProviderType::Gemini.needs_transform());
|
||||||
assert!(!ProviderType::GeminiCli.needs_transform());
|
assert!(!ProviderType::GeminiCli.needs_transform());
|
||||||
assert!(ProviderType::OpenRouter.needs_transform());
|
assert!(!ProviderType::OpenRouter.needs_transform());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -394,6 +394,7 @@ mod tests {
|
|||||||
meta: None,
|
meta: None,
|
||||||
icon: None,
|
icon: None,
|
||||||
icon_color: None,
|
icon_color: None,
|
||||||
|
in_failover_queue: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
//! 统一处理流式和非流式 API 响应
|
//! 统一处理流式和非流式 API 响应
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState,
|
handler_config::UsageParserConfig,
|
||||||
usage::parser::TokenUsage, ProxyError,
|
handler_context::{RequestContext, StreamingTimeoutConfig},
|
||||||
|
server::ProxyState,
|
||||||
|
usage::parser::TokenUsage,
|
||||||
|
ProxyError,
|
||||||
};
|
};
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -17,6 +20,7 @@ use std::{
|
|||||||
atomic::{AtomicBool, Ordering},
|
atomic::{AtomicBool, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
},
|
},
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -60,8 +64,12 @@ pub async fn handle_streaming(
|
|||||||
// 创建使用量收集器
|
// 创建使用量收集器
|
||||||
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
let usage_collector = create_usage_collector(ctx, state, status.as_u16(), parser_config);
|
||||||
|
|
||||||
// 创建带日志的透传流
|
// 获取流式超时配置
|
||||||
let logged_stream = create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector));
|
let timeout_config = ctx.streaming_timeout_config();
|
||||||
|
|
||||||
|
// 创建带日志和超时的透传流
|
||||||
|
let logged_stream =
|
||||||
|
create_logged_passthrough_stream(stream, ctx.tag, Some(usage_collector), timeout_config);
|
||||||
|
|
||||||
let body = axum::body::Body::from_stream(logged_stream);
|
let body = axum::body::Body::from_stream(logged_stream);
|
||||||
builder.body(body).unwrap()
|
builder.body(body).unwrap()
|
||||||
@@ -93,12 +101,16 @@ pub async fn handle_non_streaming(
|
|||||||
|
|
||||||
// 解析使用量
|
// 解析使用量
|
||||||
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
if let Some(usage) = (parser_config.response_parser)(&json_value) {
|
||||||
let model = json_value
|
// 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型
|
||||||
.get("model")
|
let model = if let Some(ref m) = usage.model {
|
||||||
.and_then(|m| m.as_str())
|
m.clone()
|
||||||
.unwrap_or(&ctx.request_model);
|
} else if let Some(m) = json_value.get("model").and_then(|m| m.as_str()) {
|
||||||
|
m.to_string()
|
||||||
|
} else {
|
||||||
|
ctx.request_model.clone()
|
||||||
|
};
|
||||||
|
|
||||||
spawn_log_usage(state, ctx, usage, model, status.as_u16(), false);
|
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
|
||||||
} else {
|
} else {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[{}] 未能解析 usage 信息,跳过记录",
|
"[{}] 未能解析 usage 信息,跳过记录",
|
||||||
@@ -344,21 +356,60 @@ async fn log_usage_internal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建带日志记录的透传流
|
/// 创建带日志记录和超时控制的透传流
|
||||||
pub fn create_logged_passthrough_stream(
|
pub fn create_logged_passthrough_stream(
|
||||||
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
stream: impl Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static,
|
||||||
tag: &'static str,
|
tag: &'static str,
|
||||||
usage_collector: Option<SseUsageCollector>,
|
usage_collector: Option<SseUsageCollector>,
|
||||||
|
timeout_config: StreamingTimeoutConfig,
|
||||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
) -> impl Stream<Item = Result<Bytes, std::io::Error>> + Send {
|
||||||
async_stream::stream! {
|
async_stream::stream! {
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
let mut collector = usage_collector;
|
let mut collector = usage_collector;
|
||||||
|
let mut is_first_chunk = true;
|
||||||
|
|
||||||
|
// 超时配置
|
||||||
|
let first_byte_timeout = if timeout_config.first_byte_timeout > 0 {
|
||||||
|
Some(Duration::from_secs(timeout_config.first_byte_timeout))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let idle_timeout = if timeout_config.idle_timeout > 0 {
|
||||||
|
Some(Duration::from_secs(timeout_config.idle_timeout))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
tokio::pin!(stream);
|
tokio::pin!(stream);
|
||||||
|
|
||||||
while let Some(chunk) = stream.next().await {
|
loop {
|
||||||
match chunk {
|
// 选择超时时间:首字节超时或静默期超时
|
||||||
Ok(bytes) => {
|
let timeout_duration = if is_first_chunk {
|
||||||
|
first_byte_timeout
|
||||||
|
} else {
|
||||||
|
idle_timeout
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunk_result = match timeout_duration {
|
||||||
|
Some(duration) => {
|
||||||
|
match tokio::time::timeout(duration, stream.next()).await {
|
||||||
|
Ok(Some(chunk)) => Some(chunk),
|
||||||
|
Ok(None) => None, // 流结束
|
||||||
|
Err(_) => {
|
||||||
|
// 超时
|
||||||
|
let timeout_type = if is_first_chunk { "首字节" } else { "静默期" };
|
||||||
|
log::error!("[{tag}] 流式响应{}超时 ({}秒)", timeout_type, duration.as_secs());
|
||||||
|
yield Err(std::io::Error::other(format!("流式响应{timeout_type}超时")));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => stream.next().await, // 无超时限制
|
||||||
|
};
|
||||||
|
|
||||||
|
match chunk_result {
|
||||||
|
Some(Ok(bytes)) => {
|
||||||
|
is_first_chunk = false;
|
||||||
let text = String::from_utf8_lossy(&bytes);
|
let text = String::from_utf8_lossy(&bytes);
|
||||||
buffer.push_str(&text);
|
buffer.push_str(&text);
|
||||||
|
|
||||||
@@ -394,11 +445,15 @@ pub fn create_logged_passthrough_stream(
|
|||||||
|
|
||||||
yield Ok(bytes);
|
yield Ok(bytes);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Some(Err(e)) => {
|
||||||
log::error!("[{tag}] 流错误: {e}");
|
log::error!("[{tag}] 流错误: {e}");
|
||||||
yield Err(std::io::Error::other(e.to_string()));
|
yield Err(std::io::Error::other(e.to_string()));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
None => {
|
||||||
|
// 流正常结束
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,33 +3,54 @@ use serde::{Deserialize, Serialize};
|
|||||||
/// 代理服务器配置
|
/// 代理服务器配置
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ProxyConfig {
|
pub struct ProxyConfig {
|
||||||
/// 是否启用代理服务
|
|
||||||
pub enabled: bool,
|
|
||||||
/// 监听地址
|
/// 监听地址
|
||||||
pub listen_address: String,
|
pub listen_address: String,
|
||||||
/// 监听端口
|
/// 监听端口
|
||||||
pub listen_port: u16,
|
pub listen_port: u16,
|
||||||
/// 最大重试次数
|
/// 最大重试次数
|
||||||
pub max_retries: u8,
|
pub max_retries: u8,
|
||||||
/// 请求超时时间(秒)
|
/// 请求超时时间(秒)- 已废弃,保留兼容
|
||||||
pub request_timeout: u64,
|
pub request_timeout: u64,
|
||||||
/// 是否启用日志
|
/// 是否启用日志
|
||||||
pub enable_logging: bool,
|
pub enable_logging: bool,
|
||||||
/// 是否正在接管 Live 配置
|
/// 是否正在接管 Live 配置
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub live_takeover_active: bool,
|
pub live_takeover_active: bool,
|
||||||
|
/// 流式首字超时(秒)- 等待首个数据块的最大时间
|
||||||
|
#[serde(default = "default_streaming_first_byte_timeout")]
|
||||||
|
pub streaming_first_byte_timeout: u64,
|
||||||
|
/// 流式静默超时(秒)- 两个数据块之间的最大间隔
|
||||||
|
#[serde(default = "default_streaming_idle_timeout")]
|
||||||
|
pub streaming_idle_timeout: u64,
|
||||||
|
/// 非流式总超时(秒)- 非流式请求的总超时时间
|
||||||
|
#[serde(default = "default_non_streaming_timeout")]
|
||||||
|
pub non_streaming_timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_streaming_first_byte_timeout() -> u64 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_streaming_idle_timeout() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_non_streaming_timeout() -> u64 {
|
||||||
|
600
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ProxyConfig {
|
impl Default for ProxyConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
|
||||||
listen_address: "127.0.0.1".to_string(),
|
listen_address: "127.0.0.1".to_string(),
|
||||||
listen_port: 15721, // 使用较少占用的高位端口
|
listen_port: 15721, // 使用较少占用的高位端口
|
||||||
max_retries: 3,
|
max_retries: 3,
|
||||||
request_timeout: 300,
|
request_timeout: 300,
|
||||||
enable_logging: true,
|
enable_logging: true,
|
||||||
live_takeover_active: false,
|
live_takeover_active: false,
|
||||||
|
streaming_first_byte_timeout: 30,
|
||||||
|
streaming_idle_timeout: 60,
|
||||||
|
non_streaming_timeout: 600,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,3 +147,47 @@ pub struct LiveBackup {
|
|||||||
/// 备份时间
|
/// 备份时间
|
||||||
pub backed_up_at: String,
|
pub backed_up_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全局代理配置(统一字段,三行镜像)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GlobalProxyConfig {
|
||||||
|
/// 代理总开关
|
||||||
|
pub proxy_enabled: bool,
|
||||||
|
/// 监听地址
|
||||||
|
pub listen_address: String,
|
||||||
|
/// 监听端口
|
||||||
|
pub listen_port: u16,
|
||||||
|
/// 是否启用日志
|
||||||
|
pub enable_logging: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用级代理配置(每个 app 独立)
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppProxyConfig {
|
||||||
|
/// 应用类型 (claude/codex/gemini)
|
||||||
|
pub app_type: String,
|
||||||
|
/// 该 app 代理启用开关
|
||||||
|
pub enabled: bool,
|
||||||
|
/// 该 app 自动故障转移开关
|
||||||
|
pub auto_failover_enabled: bool,
|
||||||
|
/// 最大重试次数
|
||||||
|
pub max_retries: u32,
|
||||||
|
/// 流式首字超时(秒)
|
||||||
|
pub streaming_first_byte_timeout: u32,
|
||||||
|
/// 流式静默超时(秒)
|
||||||
|
pub streaming_idle_timeout: u32,
|
||||||
|
/// 非流式总超时(秒)
|
||||||
|
pub non_streaming_timeout: u32,
|
||||||
|
/// 熔断失败阈值
|
||||||
|
pub circuit_failure_threshold: u32,
|
||||||
|
/// 熔断恢复阈值
|
||||||
|
pub circuit_success_threshold: u32,
|
||||||
|
/// 熔断恢复等待时间(秒)
|
||||||
|
pub circuit_timeout_seconds: u32,
|
||||||
|
/// 错误率阈值
|
||||||
|
pub circuit_error_rate_threshold: f64,
|
||||||
|
/// 计算错误率的最小请求数
|
||||||
|
pub circuit_min_requests: u32,
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ impl TokenUsage {
|
|||||||
/// 从 Claude API 非流式响应解析
|
/// 从 Claude API 非流式响应解析
|
||||||
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
pub fn from_claude_response(body: &Value) -> Option<Self> {
|
||||||
let usage = body.get("usage")?;
|
let usage = body.get("usage")?;
|
||||||
|
// 提取响应中的模型名称
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
input_tokens: usage.get("input_tokens")?.as_u64()? as u32,
|
||||||
output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
|
output_tokens: usage.get("output_tokens")?.as_u64()? as u32,
|
||||||
@@ -45,7 +51,7 @@ impl TokenUsage {
|
|||||||
.get("cache_creation_input_tokens")
|
.get("cache_creation_input_tokens")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(0) as u32,
|
.unwrap_or(0) as u32,
|
||||||
model: None,
|
model,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +59,20 @@ impl TokenUsage {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
|
pub fn from_claude_stream_events(events: &[Value]) -> Option<Self> {
|
||||||
let mut usage = Self::default();
|
let mut usage = Self::default();
|
||||||
|
let mut model: Option<String> = None;
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) {
|
||||||
match event_type {
|
match event_type {
|
||||||
"message_start" => {
|
"message_start" => {
|
||||||
|
// 从 message_start 提取模型名称
|
||||||
|
if model.is_none() {
|
||||||
|
if let Some(message) = event.get("message") {
|
||||||
|
if let Some(m) = message.get("model").and_then(|v| v.as_str()) {
|
||||||
|
model = Some(m.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
|
if let Some(msg_usage) = event.get("message").and_then(|m| m.get("usage")) {
|
||||||
// 从 message_start 获取 input_tokens(原生 Claude API)
|
// 从 message_start 获取 input_tokens(原生 Claude API)
|
||||||
if let Some(input) =
|
if let Some(input) =
|
||||||
@@ -102,6 +117,7 @@ impl TokenUsage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
if usage.input_tokens > 0 || usage.output_tokens > 0 {
|
||||||
|
usage.model = model;
|
||||||
Some(usage)
|
Some(usage)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -141,6 +157,12 @@ impl TokenUsage {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 提取响应中的模型名称
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
input_tokens: input_tokens? as u32,
|
input_tokens: input_tokens? as u32,
|
||||||
output_tokens: output_tokens? as u32,
|
output_tokens: output_tokens? as u32,
|
||||||
@@ -152,7 +174,7 @@ impl TokenUsage {
|
|||||||
.get("cache_creation_input_tokens")
|
.get("cache_creation_input_tokens")
|
||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(0) as u32,
|
.unwrap_or(0) as u32,
|
||||||
model: None,
|
model,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,12 +244,18 @@ impl TokenUsage {
|
|||||||
.and_then(|v| v.as_u64())
|
.and_then(|v| v.as_u64())
|
||||||
.unwrap_or(0) as u32;
|
.unwrap_or(0) as u32;
|
||||||
|
|
||||||
|
// 提取响应中的模型名称
|
||||||
|
let model = body
|
||||||
|
.get("model")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
input_tokens: prompt_tokens as u32,
|
input_tokens: prompt_tokens as u32,
|
||||||
output_tokens: completion_tokens as u32,
|
output_tokens: completion_tokens as u32,
|
||||||
cache_read_tokens: cached_tokens,
|
cache_read_tokens: cached_tokens,
|
||||||
cache_creation_tokens: 0,
|
cache_creation_tokens: 0,
|
||||||
model: None,
|
model,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,6 +350,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_claude_response_parsing() {
|
fn test_claude_response_parsing() {
|
||||||
let response = json!({
|
let response = json!({
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
"usage": {
|
"usage": {
|
||||||
"input_tokens": 100,
|
"input_tokens": 100,
|
||||||
"output_tokens": 50,
|
"output_tokens": 50,
|
||||||
@@ -335,10 +364,60 @@ mod tests {
|
|||||||
assert_eq!(usage.output_tokens, 50);
|
assert_eq!(usage.output_tokens, 50);
|
||||||
assert_eq!(usage.cache_read_tokens, 20);
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
assert_eq!(usage.cache_creation_tokens, 10);
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_response_parsing_no_model() {
|
||||||
|
let response = json!({
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"output_tokens": 50,
|
||||||
|
"cache_read_input_tokens": 20,
|
||||||
|
"cache_creation_input_tokens": 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_response(&response).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_claude_stream_parsing() {
|
fn test_claude_stream_parsing() {
|
||||||
|
let events = vec![
|
||||||
|
json!({
|
||||||
|
"type": "message_start",
|
||||||
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": 100,
|
||||||
|
"cache_read_input_tokens": 20,
|
||||||
|
"cache_creation_input_tokens": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
json!({
|
||||||
|
"type": "message_delta",
|
||||||
|
"usage": {
|
||||||
|
"output_tokens": 50
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||||
|
assert_eq!(usage.input_tokens, 100);
|
||||||
|
assert_eq!(usage.output_tokens, 50);
|
||||||
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_claude_stream_parsing_no_model() {
|
||||||
let events = vec![
|
let events = vec![
|
||||||
json!({
|
json!({
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
@@ -363,6 +442,7 @@ mod tests {
|
|||||||
assert_eq!(usage.output_tokens, 50);
|
assert_eq!(usage.output_tokens, 50);
|
||||||
assert_eq!(usage.cache_read_tokens, 20);
|
assert_eq!(usage.cache_read_tokens, 20);
|
||||||
assert_eq!(usage.cache_creation_tokens, 10);
|
assert_eq!(usage.cache_creation_tokens, 10);
|
||||||
|
assert_eq!(usage.model, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -481,6 +561,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
"message": {
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
"usage": {
|
"usage": {
|
||||||
"input_tokens": 0,
|
"input_tokens": 0,
|
||||||
"output_tokens": 0
|
"output_tokens": 0
|
||||||
@@ -502,6 +583,7 @@ mod tests {
|
|||||||
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
let usage = TokenUsage::from_claude_stream_events(&events).unwrap();
|
||||||
assert_eq!(usage.input_tokens, 150);
|
assert_eq!(usage.input_tokens, 150);
|
||||||
assert_eq!(usage.output_tokens, 75);
|
assert_eq!(usage.output_tokens, 75);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -512,6 +594,7 @@ mod tests {
|
|||||||
json!({
|
json!({
|
||||||
"type": "message_start",
|
"type": "message_start",
|
||||||
"message": {
|
"message": {
|
||||||
|
"model": "claude-sonnet-4-20250514",
|
||||||
"usage": {
|
"usage": {
|
||||||
"input_tokens": 200,
|
"input_tokens": 200,
|
||||||
"cache_read_input_tokens": 50
|
"cache_read_input_tokens": 50
|
||||||
@@ -530,5 +613,6 @@ mod tests {
|
|||||||
assert_eq!(usage.input_tokens, 200);
|
assert_eq!(usage.input_tokens, 200);
|
||||||
assert_eq!(usage.output_tokens, 100);
|
assert_eq!(usage.output_tokens, 100);
|
||||||
assert_eq!(usage.cache_read_tokens, 50);
|
assert_eq!(usage.cache_read_tokens, 50);
|
||||||
|
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+603
-132
@@ -8,6 +8,7 @@ use crate::database::Database;
|
|||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
|
use crate::services::provider::write_live_snapshot;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -41,31 +42,31 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 启动代理服务器
|
/// 启动代理服务器
|
||||||
///
|
pub async fn start(&self) -> Result<ProxyServerInfo, String> {
|
||||||
/// - `persist_enabled = true`:将 `proxy_config.enabled` 持久化为启用(用于“总开关”)
|
// 1. 启动时自动设置 proxy_enabled = true
|
||||||
/// - `persist_enabled = false`:仅在当前进程启动代理服务(用于“按 App 接管”自动启动)
|
let mut global_config = self
|
||||||
pub async fn start(&self, persist_enabled: bool) -> Result<ProxyServerInfo, String> {
|
.db
|
||||||
// 1. 获取配置
|
.get_global_proxy_config()
|
||||||
let mut config = self
|
.await
|
||||||
|
.map_err(|e| format!("获取全局代理配置失败: {e}"))?;
|
||||||
|
|
||||||
|
if !global_config.proxy_enabled {
|
||||||
|
global_config.proxy_enabled = true;
|
||||||
|
self.db
|
||||||
|
.update_global_proxy_config(global_config.clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("更新代理总开关失败: {e}"))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 获取配置
|
||||||
|
let config = self
|
||||||
.db
|
.db
|
||||||
.get_proxy_config()
|
.get_proxy_config()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
||||||
|
|
||||||
// 2. 仅在需要时持久化 enabled(避免“按 App 接管”自动启动时误打开总开关)
|
|
||||||
if persist_enabled {
|
|
||||||
config.enabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 若已在运行:确保持久化状态(如需要)并返回当前信息
|
// 3. 若已在运行:确保持久化状态(如需要)并返回当前信息
|
||||||
if let Some(server) = self.server.read().await.as_ref() {
|
if let Some(server) = self.server.read().await.as_ref() {
|
||||||
if persist_enabled {
|
|
||||||
self.db
|
|
||||||
.update_proxy_config(config)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("保存代理配置失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = server.get_status().await;
|
let status = server.get_status().await;
|
||||||
return Ok(ProxyServerInfo {
|
return Ok(ProxyServerInfo {
|
||||||
address: status.address,
|
address: status.address,
|
||||||
@@ -86,14 +87,6 @@ impl ProxyService {
|
|||||||
// 5. 保存服务器实例
|
// 5. 保存服务器实例
|
||||||
*self.server.write().await = Some(server);
|
*self.server.write().await = Some(server);
|
||||||
|
|
||||||
// 6. 持久化 enabled 状态(仅总开关)
|
|
||||||
if persist_enabled {
|
|
||||||
self.db
|
|
||||||
.update_proxy_config(config)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("保存代理配置失败: {e}"))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("代理服务器已启动: {}:{}", info.address, info.port);
|
log::info!("代理服务器已启动: {}:{}", info.address, info.port);
|
||||||
Ok(info)
|
Ok(info)
|
||||||
}
|
}
|
||||||
@@ -138,7 +131,7 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. 启动代理服务器
|
// 5. 启动代理服务器
|
||||||
match self.start(true).await {
|
match self.start().await {
|
||||||
Ok(info) => Ok(info),
|
Ok(info) => Ok(info),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 启动失败,恢复原始配置
|
// 启动失败,恢复原始配置
|
||||||
@@ -159,54 +152,55 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
|
/// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理)
|
||||||
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
|
pub async fn get_takeover_status(&self) -> Result<ProxyTakeoverStatus, String> {
|
||||||
let claude = self
|
// 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测
|
||||||
|
let claude_enabled = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup("claude")
|
.get_proxy_config_for_app("claude")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取 Claude 接管状态失败: {e}"))?
|
.map(|c| c.enabled)
|
||||||
.is_some();
|
.unwrap_or(false);
|
||||||
let codex = self
|
let codex_enabled = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup("codex")
|
.get_proxy_config_for_app("codex")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取 Codex 接管状态失败: {e}"))?
|
.map(|c| c.enabled)
|
||||||
.is_some();
|
.unwrap_or(false);
|
||||||
let gemini = self
|
let gemini_enabled = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup("gemini")
|
.get_proxy_config_for_app("gemini")
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))?
|
.map(|c| c.enabled)
|
||||||
.is_some();
|
.unwrap_or(false);
|
||||||
|
|
||||||
Ok(ProxyTakeoverStatus {
|
Ok(ProxyTakeoverStatus {
|
||||||
claude,
|
claude: claude_enabled,
|
||||||
codex,
|
codex: codex_enabled,
|
||||||
gemini,
|
gemini: gemini_enabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 为指定应用开启/关闭 Live 接管
|
/// 为指定应用开启/关闭 Live 接管
|
||||||
///
|
///
|
||||||
/// - 开启:自动启动代理服务(不影响总开关持久化),仅接管当前 app 的 Live 配置
|
/// - 开启:自动启动代理服务,仅接管当前 app 的 Live 配置
|
||||||
/// - 关闭:仅恢复当前 app 的 Live 配置;若总开关未开启且无其它接管,则自动停止代理服务
|
/// - 关闭:仅恢复当前 app 的 Live 配置;若无其它接管,则自动停止代理服务
|
||||||
pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {
|
pub async fn set_takeover_for_app(&self, app_type: &str, enabled: bool) -> Result<(), String> {
|
||||||
let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
let app = AppType::from_str(app_type).map_err(|e| format!("无效的应用类型: {e}"))?;
|
||||||
let app_type_str = app.as_str();
|
let app_type_str = app.as_str();
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
// 1) 代理服务未运行则自动启动(不持久化总开关)
|
// 1) 代理服务未运行则自动启动
|
||||||
if !self.is_running().await {
|
if !self.is_running().await {
|
||||||
self.start(false).await?;
|
self.start().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) 已接管则直接返回(幂等)
|
// 2) 已接管则直接返回(幂等)
|
||||||
if self
|
let current_config = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup(app_type_str)
|
.get_proxy_config_for_app(app_type_str)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
|
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||||
.is_some()
|
|
||||||
{
|
if current_config.enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,25 +216,46 @@ impl ProxyService {
|
|||||||
// 5) 写入接管配置(仅当前 app)
|
// 5) 写入接管配置(仅当前 app)
|
||||||
if let Err(e) = self.takeover_live_config_strict(&app).await {
|
if let Err(e) = self.takeover_live_config_strict(&app).await {
|
||||||
log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}");
|
log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}");
|
||||||
let _ = self.restore_live_config_for_app(&app).await;
|
match self.restore_live_config_for_app(&app).await {
|
||||||
let _ = self.db.delete_live_backup(app_type_str).await;
|
Ok(()) => {
|
||||||
|
// 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源
|
||||||
|
let _ = self.db.delete_live_backup(app_type_str).await;
|
||||||
|
}
|
||||||
|
Err(restore_err) => {
|
||||||
|
log::error!(
|
||||||
|
"{app_type_str} 恢复 Live 配置失败,将保留备份以便下次启动恢复: {restore_err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
return Err(e);
|
return Err(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
// 6) 设置 proxy_config.enabled = true
|
||||||
|
let mut updated_config = self
|
||||||
|
.db
|
||||||
|
.get_proxy_config_for_app(app_type_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||||
|
updated_config.enabled = true;
|
||||||
|
self.db
|
||||||
|
.update_proxy_config_for_app(updated_config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}"))?;
|
||||||
|
|
||||||
|
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
||||||
let _ = self.db.set_live_takeover_active(true).await;
|
let _ = self.db.set_live_takeover_active(true).await;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭接管:无备份则视为未接管(幂等)
|
// 关闭接管:检查 enabled 状态
|
||||||
let has_backup = self
|
let current_config = self
|
||||||
.db
|
.db
|
||||||
.get_live_backup(app_type_str)
|
.get_proxy_config_for_app(app_type_str)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))?
|
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||||
.is_some();
|
|
||||||
if !has_backup {
|
if !current_config.enabled {
|
||||||
return Ok(());
|
return Ok(()); // 未接管,幂等返回
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 恢复 Live 配置
|
// 1) 恢复 Live 配置
|
||||||
@@ -252,22 +267,36 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
|
.map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?;
|
||||||
|
|
||||||
// 3) 若无其它接管,更新旧标志,并在总开关未开启时停止代理服务
|
// 3) 设置 proxy_config.enabled = false
|
||||||
let has_any_backup = self
|
let mut updated_config = self
|
||||||
.db
|
.db
|
||||||
.has_any_live_backup()
|
.get_proxy_config_for_app(app_type_str)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("检查 Live 备份失败: {e}"))?;
|
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
|
||||||
if !has_any_backup {
|
updated_config.enabled = false;
|
||||||
|
self.db
|
||||||
|
.update_proxy_config_for_app(updated_config)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?;
|
||||||
|
|
||||||
|
// 4) 清除该应用的健康状态(关闭代理时重置队列状态)
|
||||||
|
self.db
|
||||||
|
.clear_provider_health_for_app(app_type_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?;
|
||||||
|
|
||||||
|
// 5) 若无其它接管,更新旧标志,并停止代理服务
|
||||||
|
// 检查是否还有其它 app 的 enabled = true
|
||||||
|
let any_enabled = self
|
||||||
|
.db
|
||||||
|
.is_live_takeover_active()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("检查接管状态失败: {e}"))?;
|
||||||
|
|
||||||
|
if !any_enabled {
|
||||||
let _ = self.db.set_live_takeover_active(false).await;
|
let _ = self.db.set_live_takeover_active(false).await;
|
||||||
|
|
||||||
let master_enabled = self
|
if self.is_running().await {
|
||||||
.db
|
|
||||||
.get_proxy_config()
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("获取代理配置失败: {e}"))?
|
|
||||||
.enabled;
|
|
||||||
if !master_enabled && self.is_running().await {
|
|
||||||
// 此时没有任何 app 处于接管状态,停止服务即可
|
// 此时没有任何 app 处于接管状态,停止服务即可
|
||||||
let _ = self.stop().await;
|
let _ = self.stop().await;
|
||||||
}
|
}
|
||||||
@@ -331,34 +360,35 @@ impl ProxyService {
|
|||||||
|
|
||||||
match env_obj {
|
match env_obj {
|
||||||
Some(obj) => {
|
Some(obj) => {
|
||||||
obj.insert(token_key.to_string(), json!(token));
|
|
||||||
// ANTHROPIC_AUTH_TOKEN 与 ANTHROPIC_API_KEY 视为同义字段,保持一致
|
|
||||||
if token_key == "ANTHROPIC_AUTH_TOKEN"
|
if token_key == "ANTHROPIC_AUTH_TOKEN"
|
||||||
|| token_key == "ANTHROPIC_API_KEY"
|
|| token_key == "ANTHROPIC_API_KEY"
|
||||||
{
|
{
|
||||||
obj.insert(
|
let mut updated = false;
|
||||||
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
if obj.contains_key("ANTHROPIC_AUTH_TOKEN") {
|
||||||
json!(token),
|
obj.insert(
|
||||||
);
|
"ANTHROPIC_AUTH_TOKEN".to_string(),
|
||||||
obj.insert(
|
json!(token),
|
||||||
"ANTHROPIC_API_KEY".to_string(),
|
);
|
||||||
json!(token),
|
updated = true;
|
||||||
);
|
}
|
||||||
|
if obj.contains_key("ANTHROPIC_API_KEY") {
|
||||||
|
obj.insert(
|
||||||
|
"ANTHROPIC_API_KEY".to_string(),
|
||||||
|
json!(token),
|
||||||
|
);
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
obj.insert(token_key.to_string(), json!(token));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
obj.insert(token_key.to_string(), json!(token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
// 至少写入一份可用的 Token
|
// 至少写入一份可用的 Token
|
||||||
provider.settings_config["env"] = json!({
|
provider.settings_config["env"] =
|
||||||
token_key: token
|
json!({ token_key: token });
|
||||||
});
|
|
||||||
if token_key == "ANTHROPIC_AUTH_TOKEN"
|
|
||||||
|| token_key == "ANTHROPIC_API_KEY"
|
|
||||||
{
|
|
||||||
provider.settings_config["env"]
|
|
||||||
["ANTHROPIC_AUTH_TOKEN"] = json!(token);
|
|
||||||
provider.settings_config["env"]["ANTHROPIC_API_KEY"] =
|
|
||||||
json!(token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,10 +525,18 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
|
.map_err(|e| format!("停止代理服务器失败: {e}"))?;
|
||||||
|
|
||||||
// 将 enabled 设为 false,避免下次启动时自动开启
|
// 停止时设置 proxy_enabled = false
|
||||||
if let Ok(mut config) = self.db.get_proxy_config().await {
|
let mut global_config = self
|
||||||
config.enabled = false;
|
.db
|
||||||
let _ = self.db.update_proxy_config(config).await;
|
.get_global_proxy_config()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("获取全局代理配置失败: {e}"))?;
|
||||||
|
|
||||||
|
if global_config.proxy_enabled {
|
||||||
|
global_config.proxy_enabled = false;
|
||||||
|
if let Err(e) = self.db.update_global_proxy_config(global_config).await {
|
||||||
|
log::warn!("更新代理总开关失败: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("代理服务器已停止");
|
log::info!("代理服务器已停止");
|
||||||
@@ -508,47 +546,88 @@ impl ProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止代理服务器(恢复 Live 配置)
|
/// 停止代理服务器(恢复 Live 配置,用户手动关闭时使用)
|
||||||
|
///
|
||||||
|
/// 会清除 settings 表中的代理状态,下次启动不会自动恢复。
|
||||||
pub async fn stop_with_restore(&self) -> Result<(), String> {
|
pub async fn stop_with_restore(&self) -> Result<(), String> {
|
||||||
// 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)
|
// 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)
|
||||||
if let Err(e) = self.stop().await {
|
if let Err(e) = self.stop().await {
|
||||||
log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}");
|
log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}");
|
||||||
|
|
||||||
// stop() 只有在 server 实例存在时才会把 enabled 设为 false;
|
|
||||||
// 这里兜底确保“总开关关闭”能落盘关闭状态。
|
|
||||||
if let Ok(mut config) = self.db.get_proxy_config().await {
|
|
||||||
if config.enabled {
|
|
||||||
config.enabled = false;
|
|
||||||
let _ = self.db.update_proxy_config(config).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 恢复原始 Live 配置
|
// 2. 恢复原始 Live 配置
|
||||||
self.restore_live_configs().await?;
|
self.restore_live_configs().await?;
|
||||||
|
|
||||||
// 3. 清除接管状态
|
// 3. 清除 proxy_config 表中的接管状态(兼容旧版)
|
||||||
self.db
|
self.db
|
||||||
.set_live_takeover_active(false)
|
.set_live_takeover_active(false)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("清除接管状态失败: {e}"))?;
|
.map_err(|e| format!("清除接管状态失败: {e}"))?;
|
||||||
|
|
||||||
// 4. 删除备份
|
// 4. 清除所有应用的 enabled 状态(用户手动关闭,不需要下次自动恢复)
|
||||||
|
for app_type in ["claude", "codex", "gemini"] {
|
||||||
|
if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type).await {
|
||||||
|
if config.enabled {
|
||||||
|
config.enabled = false;
|
||||||
|
if let Err(e) = self.db.update_proxy_config_for_app(config).await {
|
||||||
|
log::warn!("清除 {app_type} enabled 状态失败: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 删除备份
|
||||||
self.db
|
self.db
|
||||||
.delete_all_live_backups()
|
.delete_all_live_backups()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("删除备份失败: {e}"))?;
|
.map_err(|e| format!("删除备份失败: {e}"))?;
|
||||||
|
|
||||||
// 5. 重置健康状态(让健康徽章恢复为正常)
|
// 6. 重置健康状态(让健康徽章恢复为正常)
|
||||||
self.db
|
self.db
|
||||||
.clear_all_provider_health()
|
.clear_all_provider_health()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
||||||
|
|
||||||
|
// 注意:不清除故障转移队列和开关状态,保留供下次开启代理时使用
|
||||||
log::info!("代理已停止,Live 配置已恢复");
|
log::info!("代理已停止,Live 配置已恢复");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 停止代理服务器(恢复 Live 配置,但保留 settings 表中的代理状态)
|
||||||
|
///
|
||||||
|
/// 用于程序正常退出时,保留代理状态以便下次启动时自动恢复
|
||||||
|
pub async fn stop_with_restore_keep_state(&self) -> Result<(), String> {
|
||||||
|
// 1. 停止代理服务器(即使未运行也继续执行恢复逻辑)
|
||||||
|
if let Err(e) = self.stop().await {
|
||||||
|
log::warn!("停止代理服务器失败(将继续恢复 Live 配置): {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 恢复原始 Live 配置
|
||||||
|
self.restore_live_configs().await?;
|
||||||
|
|
||||||
|
// 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版)
|
||||||
|
// 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复
|
||||||
|
if let Ok(mut config) = self.db.get_proxy_config().await {
|
||||||
|
config.live_takeover_active = false;
|
||||||
|
let _ = self.db.update_proxy_config(config).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 删除备份(Live 配置已恢复,备份不再需要)
|
||||||
|
self.db
|
||||||
|
.delete_all_live_backups()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("删除备份失败: {e}"))?;
|
||||||
|
|
||||||
|
// 5. 重置健康状态
|
||||||
|
self.db
|
||||||
|
.clear_all_provider_health()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("重置健康状态失败: {e}"))?;
|
||||||
|
|
||||||
|
log::info!("代理已停止,Live 配置已恢复(保留代理状态,下次启动将自动恢复)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 备份各应用的 Live 配置
|
/// 备份各应用的 Live 配置
|
||||||
async fn backup_live_configs(&self) -> Result<(), String> {
|
async fn backup_live_configs(&self) -> Result<(), String> {
|
||||||
// Claude
|
// Claude
|
||||||
@@ -909,30 +988,270 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 恢复原始 Live 配置
|
/// 恢复原始 Live 配置
|
||||||
async fn restore_live_configs(&self) -> Result<(), String> {
|
async fn restore_live_configs(&self) -> Result<(), String> {
|
||||||
// Claude
|
let mut errors = Vec::new();
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("claude").await {
|
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
|
||||||
.map_err(|e| format!("解析 Claude 备份失败: {e}"))?;
|
if let Err(e) = self
|
||||||
self.write_claude_live(&config)?;
|
.restore_live_config_for_app_with_fallback(&app_type)
|
||||||
log::info!("Claude Live 配置已恢复");
|
.await
|
||||||
|
{
|
||||||
|
errors.push(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Codex
|
if errors.is_empty() {
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("codex").await {
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(errors.join(";"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore_live_config_for_app_with_fallback(
|
||||||
|
&self,
|
||||||
|
app_type: &AppType,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let app_type_str = app_type.as_str();
|
||||||
|
|
||||||
|
// 1) 优先从 Live 备份恢复(这是“原始 Live”的唯一可靠来源)
|
||||||
|
let backup = self
|
||||||
|
.db
|
||||||
|
.get_live_backup(app_type_str)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("获取 {app_type_str} Live 备份失败: {e}"))?;
|
||||||
|
if let Some(backup) = backup {
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
let config: Value = serde_json::from_str(&backup.original_config)
|
||||||
.map_err(|e| format!("解析 Codex 备份失败: {e}"))?;
|
.map_err(|e| format!("解析 {app_type_str} 备份失败: {e}"))?;
|
||||||
self.write_codex_live(&config)?;
|
self.write_live_config_for_app(app_type, &config)?;
|
||||||
log::info!("Codex Live 配置已恢复");
|
log::info!("{app_type_str} Live 配置已从备份恢复");
|
||||||
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gemini
|
// 2) 兜底:备份缺失,但 Live 仍包含接管占位符(异常退出/历史 bug 场景)
|
||||||
if let Ok(Some(backup)) = self.db.get_live_backup("gemini").await {
|
if !self.detect_takeover_in_live_config_for_app(app_type) {
|
||||||
let config: Value = serde_json::from_str(&backup.original_config)
|
return Ok(());
|
||||||
.map_err(|e| format!("解析 Gemini 备份失败: {e}"))?;
|
|
||||||
self.write_gemini_live(&config)?;
|
|
||||||
log::info!("Gemini Live 配置已恢复");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.1) 优先从 SSOT(当前供应商)重建 Live(比“清理字段”更可用)
|
||||||
|
match self.restore_live_from_ssot_for_app(app_type) {
|
||||||
|
Ok(true) => {
|
||||||
|
log::info!("{app_type_str} Live 配置已从 SSOT 恢复(无备份兜底)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
log::warn!(
|
||||||
|
"{app_type_str} Live 备份缺失,且无法从 SSOT 恢复,将尝试清理接管占位符"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"{app_type_str} Live 备份缺失,SSOT 恢复失败,将尝试清理接管占位符: {e}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.2) 最后兜底:尽力清理占位符与本地代理地址,避免长期卡在代理占位符状态
|
||||||
|
self.cleanup_takeover_placeholders_in_live_for_app(app_type)?;
|
||||||
|
log::info!("{app_type_str} Live 接管占位符已清理(无备份兜底)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_live_config_for_app(&self, app_type: &AppType, config: &Value) -> Result<(), String> {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => self.write_claude_live(config),
|
||||||
|
AppType::Codex => self.write_codex_live(config),
|
||||||
|
AppType::Gemini => self.write_gemini_live(config),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => match self.read_claude_live() {
|
||||||
|
Ok(config) => Self::is_claude_live_taken_over(&config),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
AppType::Codex => match self.read_codex_live() {
|
||||||
|
Ok(config) => Self::is_codex_live_taken_over(&config),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
AppType::Gemini => match self.read_gemini_live() {
|
||||||
|
Ok(config) => Self::is_gemini_live_taken_over(&config),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当 Live 备份缺失时,尝试用 SSOT(当前供应商)写回 Live,以解除占位符接管。
|
||||||
|
///
|
||||||
|
/// 返回值:
|
||||||
|
/// - Ok(true):已成功写回
|
||||||
|
/// - Ok(false):缺少当前供应商/供应商不存在,无法写回
|
||||||
|
fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {
|
||||||
|
let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)
|
||||||
|
.map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?;
|
||||||
|
|
||||||
|
let Some(current_id) = current_id else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
let providers = self
|
||||||
|
.db
|
||||||
|
.get_all_providers(app_type.as_str())
|
||||||
|
.map_err(|e| format!("读取 {app_type:?} 供应商列表失败: {e}"))?;
|
||||||
|
|
||||||
|
let Some(provider) = providers.get(¤t_id) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
write_live_snapshot(app_type, provider)
|
||||||
|
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_takeover_placeholders_in_live_for_app(
|
||||||
|
&self,
|
||||||
|
app_type: &AppType,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
|
||||||
|
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
|
||||||
|
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_local_proxy_url(url: &str) -> bool {
|
||||||
|
let url = url.trim();
|
||||||
|
if !url.starts_with("http://") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let rest = &url["http://".len()..];
|
||||||
|
rest.starts_with("127.0.0.1")
|
||||||
|
|| rest.starts_with("localhost")
|
||||||
|
|| rest.starts_with("0.0.0.0")
|
||||||
|
|| rest.starts_with("[::1]")
|
||||||
|
|| rest.starts_with("[::]")
|
||||||
|
|| rest.starts_with("::1")
|
||||||
|
|| rest.starts_with("::")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_claude_takeover_placeholders_in_live(&self) -> Result<(), String> {
|
||||||
|
let mut config = self.read_claude_live()?;
|
||||||
|
|
||||||
|
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
for key in [
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
"OPENROUTER_API_KEY",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
] {
|
||||||
|
if env.get(key).and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {
|
||||||
|
env.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if env
|
||||||
|
.get("ANTHROPIC_BASE_URL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(Self::is_local_proxy_url)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
env.remove("ANTHROPIC_BASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_claude_live(&config)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_codex_takeover_placeholders_in_live(&self) -> Result<(), String> {
|
||||||
|
let mut config = self.read_codex_live()?;
|
||||||
|
|
||||||
|
if let Some(auth) = config.get_mut("auth").and_then(|v| v.as_object_mut()) {
|
||||||
|
if auth.get("OPENAI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER)
|
||||||
|
{
|
||||||
|
auth.remove("OPENAI_API_KEY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(cfg_str) = config.get("config").and_then(|v| v.as_str()) {
|
||||||
|
let updated = Self::remove_local_toml_base_url(cfg_str);
|
||||||
|
config["config"] = json!(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_codex_live(&config)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_local_toml_base_url(toml_str: &str) -> String {
|
||||||
|
use toml_edit::DocumentMut;
|
||||||
|
|
||||||
|
let mut doc = match toml_str.parse::<DocumentMut>() {
|
||||||
|
Ok(doc) => doc,
|
||||||
|
Err(_) => return toml_str.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let model_provider = doc
|
||||||
|
.get("model_provider")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
|
||||||
|
if let Some(provider_key) = model_provider {
|
||||||
|
if let Some(model_providers) = doc
|
||||||
|
.get_mut("model_providers")
|
||||||
|
.and_then(|v| v.as_table_mut())
|
||||||
|
{
|
||||||
|
if let Some(provider_table) = model_providers
|
||||||
|
.get_mut(provider_key.as_str())
|
||||||
|
.and_then(|v| v.as_table_mut())
|
||||||
|
{
|
||||||
|
let should_remove = provider_table
|
||||||
|
.get("base_url")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.map(Self::is_local_proxy_url)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if should_remove {
|
||||||
|
provider_table.remove("base_url");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:清理顶层 base_url(仅当它看起来像本地代理地址)
|
||||||
|
let should_remove_root = doc
|
||||||
|
.get("base_url")
|
||||||
|
.and_then(|item| item.as_str())
|
||||||
|
.map(Self::is_local_proxy_url)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if should_remove_root {
|
||||||
|
doc.as_table_mut().remove("base_url");
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_gemini_takeover_placeholders_in_live(&self) -> Result<(), String> {
|
||||||
|
let mut config = self.read_gemini_live()?;
|
||||||
|
|
||||||
|
let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if env.get("GEMINI_API_KEY").and_then(|v| v.as_str()) == Some(PROXY_TOKEN_PLACEHOLDER) {
|
||||||
|
env.remove("GEMINI_API_KEY");
|
||||||
|
}
|
||||||
|
|
||||||
|
if env
|
||||||
|
.get("GOOGLE_GEMINI_BASE_URL")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(Self::is_local_proxy_url)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
env.remove("GOOGLE_GEMINI_BASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.write_gemini_live(&config)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -946,7 +1265,7 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 从异常退出中恢复(启动时调用)
|
/// 从异常退出中恢复(启动时调用)
|
||||||
///
|
///
|
||||||
/// 检测到 live_takeover_active=true 但代理未运行时调用此方法。
|
/// 检测到 Live 备份残留时调用此方法。
|
||||||
/// 会恢复 Live 配置、清除接管标志、删除备份。
|
/// 会恢复 Live 配置、清除接管标志、删除备份。
|
||||||
pub async fn recover_from_crash(&self) -> Result<(), String> {
|
pub async fn recover_from_crash(&self) -> Result<(), String> {
|
||||||
// 1. 恢复 Live 配置
|
// 1. 恢复 Live 配置
|
||||||
@@ -970,7 +1289,7 @@ impl ProxyService {
|
|||||||
|
|
||||||
/// 检测 Live 配置是否处于“被接管”的残留状态
|
/// 检测 Live 配置是否处于“被接管”的残留状态
|
||||||
///
|
///
|
||||||
/// 用于兜底处理:当数据库标志未写入成功(或旧版本遗留)但 Live 文件已经写成代理占位符时,
|
/// 用于兜底处理:当数据库备份缺失但 Live 文件已经写成代理占位符时,
|
||||||
/// 启动流程可以据此触发恢复逻辑。
|
/// 启动流程可以据此触发恢复逻辑。
|
||||||
pub fn detect_takeover_in_live_configs(&self) -> bool {
|
pub fn detect_takeover_in_live_configs(&self) -> bool {
|
||||||
if let Ok(config) = self.read_claude_live() {
|
if let Ok(config) = self.read_claude_live() {
|
||||||
@@ -1255,9 +1574,8 @@ impl ProxyService {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
.map_err(|e| format!("获取代理配置失败: {e}"))?;
|
||||||
|
|
||||||
// 保存到数据库(保持 enabled 和 live_takeover_active 状态不变)
|
// 保存到数据库(保持 live_takeover_active 状态不变)
|
||||||
let mut new_config = config.clone();
|
let mut new_config = config.clone();
|
||||||
new_config.enabled = previous.enabled;
|
|
||||||
new_config.live_takeover_active = previous.live_takeover_active;
|
new_config.live_takeover_active = previous.live_takeover_active;
|
||||||
|
|
||||||
self.db
|
self.db
|
||||||
@@ -1370,6 +1688,47 @@ impl ProxyService {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use serial_test::serial;
|
||||||
|
use std::env;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
struct TempHome {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
dir: TempDir,
|
||||||
|
original_home: Option<String>,
|
||||||
|
original_userprofile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TempHome {
|
||||||
|
fn new() -> Self {
|
||||||
|
let dir = TempDir::new().expect("failed to create temp home");
|
||||||
|
let original_home = env::var("HOME").ok();
|
||||||
|
let original_userprofile = env::var("USERPROFILE").ok();
|
||||||
|
|
||||||
|
env::set_var("HOME", dir.path());
|
||||||
|
env::set_var("USERPROFILE", dir.path());
|
||||||
|
|
||||||
|
Self {
|
||||||
|
dir,
|
||||||
|
original_home,
|
||||||
|
original_userprofile,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TempHome {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
match &self.original_home {
|
||||||
|
Some(value) => env::set_var("HOME", value),
|
||||||
|
None => env::remove_var("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match &self.original_userprofile {
|
||||||
|
Some(value) => env::set_var("USERPROFILE", value),
|
||||||
|
None => env::remove_var("USERPROFILE"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn update_toml_base_url_updates_active_model_provider_base_url() {
|
fn update_toml_base_url_updates_active_model_provider_base_url() {
|
||||||
@@ -1432,4 +1791,116 @@ model = "gpt-5.1-codex"
|
|||||||
|
|
||||||
assert_eq!(base_url, new_url);
|
assert_eq!(base_url, new_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn sync_claude_token_does_not_add_anthropic_api_key() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let service = ProxyService::new(db.clone());
|
||||||
|
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"p1".to_string(),
|
||||||
|
"P1".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "stale"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("claude", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
db.set_current_provider("claude", "p1")
|
||||||
|
.expect("set current provider");
|
||||||
|
|
||||||
|
let live_config = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "fresh"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service
|
||||||
|
.sync_live_config_to_provider(&AppType::Claude, &live_config)
|
||||||
|
.await
|
||||||
|
.expect("sync");
|
||||||
|
|
||||||
|
let updated = db
|
||||||
|
.get_provider_by_id("p1", "claude")
|
||||||
|
.expect("get provider")
|
||||||
|
.expect("provider exists");
|
||||||
|
let env = updated
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("env object");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
|
||||||
|
Some("fresh")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!env.contains_key("ANTHROPIC_API_KEY"),
|
||||||
|
"should not add ANTHROPIC_API_KEY when absent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn sync_claude_token_respects_existing_api_key_field() {
|
||||||
|
let _home = TempHome::new();
|
||||||
|
crate::settings::reload_settings().expect("reload settings");
|
||||||
|
|
||||||
|
let db = Arc::new(Database::memory().expect("init db"));
|
||||||
|
let service = ProxyService::new(db.clone());
|
||||||
|
|
||||||
|
let provider = Provider::with_id(
|
||||||
|
"p1".to_string(),
|
||||||
|
"P1".to_string(),
|
||||||
|
json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_BASE_URL": "https://api.anthropic.com",
|
||||||
|
"ANTHROPIC_API_KEY": "stale"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
db.save_provider("claude", &provider)
|
||||||
|
.expect("save provider");
|
||||||
|
db.set_current_provider("claude", "p1")
|
||||||
|
.expect("set current provider");
|
||||||
|
|
||||||
|
let live_config = json!({
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "fresh"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
service
|
||||||
|
.sync_live_config_to_provider(&AppType::Claude, &live_config)
|
||||||
|
.await
|
||||||
|
.expect("sync");
|
||||||
|
|
||||||
|
let updated = db
|
||||||
|
.get_provider_by_id("p1", "claude")
|
||||||
|
.expect("get provider")
|
||||||
|
.expect("provider exists");
|
||||||
|
let env = updated
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.expect("env object");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
env.get("ANTHROPIC_API_KEY").and_then(|v| v.as_str()),
|
||||||
|
Some("fresh")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!env.contains_key("ANTHROPIC_AUTH_TOKEN"),
|
||||||
|
"should not add ANTHROPIC_AUTH_TOKEN when absent"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
|
//! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
|
use regex::Regex;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -141,15 +142,17 @@ impl StreamCheckService {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?;
|
||||||
|
|
||||||
|
let model_to_test = Self::resolve_test_model(app_type, provider, config);
|
||||||
|
|
||||||
let result = match app_type {
|
let result = match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
Self::check_claude_stream(&client, &base_url, &auth, &config.claude_model).await
|
Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
}
|
}
|
||||||
AppType::Codex => {
|
AppType::Codex => {
|
||||||
Self::check_codex_stream(&client, &base_url, &auth, &config.codex_model).await
|
Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
}
|
}
|
||||||
AppType::Gemini => {
|
AppType::Gemini => {
|
||||||
Self::check_gemini_stream(&client, &base_url, &auth, &config.gemini_model).await
|
Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -379,6 +382,48 @@ impl StreamCheckService {
|
|||||||
AppError::Message(e.to_string())
|
AppError::Message(e.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_test_model(
|
||||||
|
app_type: &AppType,
|
||||||
|
provider: &Provider,
|
||||||
|
config: &StreamCheckConfig,
|
||||||
|
) -> String {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => Self::extract_env_model(provider, "ANTHROPIC_MODEL")
|
||||||
|
.unwrap_or_else(|| config.claude_model.clone()),
|
||||||
|
AppType::Codex => {
|
||||||
|
Self::extract_codex_model(provider).unwrap_or_else(|| config.codex_model.clone())
|
||||||
|
}
|
||||||
|
AppType::Gemini => Self::extract_env_model(provider, "GEMINI_MODEL")
|
||||||
|
.unwrap_or_else(|| config.gemini_model.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_env_model(provider: &Provider, key: &str) -> Option<String> {
|
||||||
|
provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get(key))
|
||||||
|
.and_then(|value| value.as_str())
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_codex_model(provider: &Provider) -> Option<String> {
|
||||||
|
let config_text = provider
|
||||||
|
.settings_config
|
||||||
|
.get("config")
|
||||||
|
.and_then(|value| value.as_str())?;
|
||||||
|
if config_text.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let re = Regex::new(r#"^model\s*=\s*["']([^"']+)["']"#).ok()?;
|
||||||
|
re.captures(config_text)
|
||||||
|
.and_then(|caps| caps.get(1))
|
||||||
|
.map(|m| m.as_str().trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ pub struct AppSettings {
|
|||||||
/// 是否启用 Claude 插件联动
|
/// 是否启用 Claude 插件联动
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub enable_claude_plugin_integration: bool,
|
pub enable_claude_plugin_integration: bool,
|
||||||
|
/// 是否跳过 Claude Code 初次安装确认
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub skip_claude_onboarding: bool,
|
||||||
/// 是否开机自启
|
/// 是否开机自启
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub launch_on_startup: bool,
|
pub launch_on_startup: bool,
|
||||||
@@ -65,12 +68,17 @@ fn default_minimize_to_tray_on_close() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_true() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for AppSettings {
|
impl Default for AppSettings {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
show_in_tray: true,
|
show_in_tray: true,
|
||||||
minimize_to_tray_on_close: true,
|
minimize_to_tray_on_close: true,
|
||||||
enable_claude_plugin_integration: false,
|
enable_claude_plugin_integration: false,
|
||||||
|
skip_claude_onboarding: true,
|
||||||
launch_on_startup: false,
|
launch_on_startup: false,
|
||||||
language: None,
|
language: None,
|
||||||
claude_config_dir: None,
|
claude_config_dir: None,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "CC Switch",
|
"productName": "CC Switch",
|
||||||
"version": "3.9.0-1",
|
"version": "3.9.0-2",
|
||||||
"identifier": "com.ccswitch.desktop",
|
"identifier": "com.ccswitch.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../dist",
|
"frontendDist": "../dist",
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"label": "main",
|
"label": "main",
|
||||||
"titleBarStyle": "Visible"
|
"titleBarStyle": "Visible",
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1003,3 +1003,76 @@ fn export_sql_returns_error_for_invalid_path() {
|
|||||||
other => panic!("expected IoContext or Io error, got {other:?}"),
|
other => panic!("expected IoContext or Io error, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_sql_rejects_non_cc_switch_backup() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
|
||||||
|
let import_path = home.join("not-cc-switch.sql");
|
||||||
|
fs::write(&import_path, "CREATE TABLE x (id INTEGER);").expect("write import sql");
|
||||||
|
|
||||||
|
let err = state
|
||||||
|
.db
|
||||||
|
.import_sql(&import_path)
|
||||||
|
.expect_err("non-cc-switch sql should be rejected");
|
||||||
|
|
||||||
|
match err {
|
||||||
|
AppError::Localized { key, .. } => {
|
||||||
|
assert_eq!(key, "backup.sql.invalid_format");
|
||||||
|
}
|
||||||
|
other => panic!("expected Localized error, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_sql_accepts_cc_switch_exported_backup() {
|
||||||
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
reset_test_fs();
|
||||||
|
let home = ensure_test_home();
|
||||||
|
|
||||||
|
// Create a database with some data and export it.
|
||||||
|
let mut config = MultiAppConfig::default();
|
||||||
|
{
|
||||||
|
let manager = config
|
||||||
|
.get_manager_mut(&AppType::Claude)
|
||||||
|
.expect("claude manager");
|
||||||
|
manager.current = "test-provider".to_string();
|
||||||
|
manager.providers.insert(
|
||||||
|
"test-provider".to_string(),
|
||||||
|
Provider::with_id(
|
||||||
|
"test-provider".to_string(),
|
||||||
|
"Test Provider".to_string(),
|
||||||
|
json!({"env": {"ANTHROPIC_API_KEY": "test-key"}}),
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = create_test_state_with_config(&config).expect("create test state");
|
||||||
|
let export_path = home.join("cc-switch-export.sql");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.export_sql(&export_path)
|
||||||
|
.expect("export should succeed");
|
||||||
|
|
||||||
|
// Reset database, then import into a fresh one.
|
||||||
|
reset_test_fs();
|
||||||
|
let state = create_test_state().expect("create test state");
|
||||||
|
state
|
||||||
|
.db
|
||||||
|
.import_sql(&export_path)
|
||||||
|
.expect("import should succeed");
|
||||||
|
|
||||||
|
let providers = state
|
||||||
|
.db
|
||||||
|
.get_all_providers(AppType::Claude.as_str())
|
||||||
|
.expect("load providers");
|
||||||
|
assert!(
|
||||||
|
providers.contains_key("test-provider"),
|
||||||
|
"imported providers should contain test-provider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+188
-107
@@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo, useState, useRef } from "react";
|
import { useEffect, useMemo, useState, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -45,8 +47,13 @@ import { Button } from "@/components/ui/button";
|
|||||||
|
|
||||||
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents";
|
||||||
|
|
||||||
|
const DRAG_BAR_HEIGHT = 28; // px
|
||||||
|
const HEADER_HEIGHT = 64; // px
|
||||||
|
const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
const [activeApp, setActiveApp] = useState<AppId>("claude");
|
||||||
const [currentView, setCurrentView] = useState<View>("providers");
|
const [currentView, setCurrentView] = useState<View>("providers");
|
||||||
@@ -58,6 +65,22 @@ function App() {
|
|||||||
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
|
||||||
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
const [showEnvBanner, setShowEnvBanner] = useState(false);
|
||||||
|
|
||||||
|
// 保存最后一个有效的 provider,用于动画退出期间显示内容
|
||||||
|
const lastUsageProviderRef = useRef<Provider | null>(null);
|
||||||
|
const lastEditingProviderRef = useRef<Provider | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (usageProvider) {
|
||||||
|
lastUsageProviderRef.current = usageProvider;
|
||||||
|
}
|
||||||
|
}, [usageProvider]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingProvider) {
|
||||||
|
lastEditingProviderRef.current = editingProvider;
|
||||||
|
}
|
||||||
|
}, [editingProvider]);
|
||||||
|
|
||||||
const promptPanelRef = useRef<any>(null);
|
const promptPanelRef = useRef<any>(null);
|
||||||
const mcpPanelRef = useRef<any>(null);
|
const mcpPanelRef = useRef<any>(null);
|
||||||
const skillsPageRef = useRef<any>(null);
|
const skillsPageRef = useRef<any>(null);
|
||||||
@@ -65,7 +88,20 @@ function App() {
|
|||||||
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
"bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8";
|
||||||
|
|
||||||
// 获取代理服务状态
|
// 获取代理服务状态
|
||||||
const { isRunning: isProxyRunning, isTakeoverActive } = useProxyStatus();
|
const {
|
||||||
|
isRunning: isProxyRunning,
|
||||||
|
takeoverStatus,
|
||||||
|
status: proxyStatus,
|
||||||
|
} = useProxyStatus();
|
||||||
|
// 当前应用的代理是否开启
|
||||||
|
const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false;
|
||||||
|
// 当前应用代理实际使用的供应商 ID(从 active_targets 中获取)
|
||||||
|
const activeProviderId = useMemo(() => {
|
||||||
|
const target = proxyStatus?.active_targets?.find(
|
||||||
|
(t) => t.app_type === activeApp,
|
||||||
|
);
|
||||||
|
return target?.provider_id;
|
||||||
|
}, [proxyStatus?.active_targets, activeApp]);
|
||||||
|
|
||||||
// 获取供应商列表,当代理服务运行时自动刷新
|
// 获取供应商列表,当代理服务运行时自动刷新
|
||||||
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
const { data, isLoading, refetch } = useProvidersQuery(activeApp, {
|
||||||
@@ -268,7 +304,20 @@ function App() {
|
|||||||
|
|
||||||
// 导入配置成功后刷新
|
// 导入配置成功后刷新
|
||||||
const handleImportSuccess = async () => {
|
const handleImportSuccess = async () => {
|
||||||
await refetch();
|
try {
|
||||||
|
// 导入会影响所有应用的供应商数据:刷新所有 providers 缓存
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["providers"],
|
||||||
|
refetchType: "all",
|
||||||
|
});
|
||||||
|
await queryClient.refetchQueries({
|
||||||
|
queryKey: ["providers"],
|
||||||
|
type: "all",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[App] Failed to refresh providers after import", error);
|
||||||
|
await refetch();
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await providersApi.updateTrayMenu();
|
await providersApi.updateTrayMenu();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -277,79 +326,109 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
switch (currentView) {
|
const content = (() => {
|
||||||
case "settings":
|
switch (currentView) {
|
||||||
return (
|
case "settings":
|
||||||
<SettingsPage
|
return (
|
||||||
open={true}
|
<SettingsPage
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
open={true}
|
||||||
onImportSuccess={handleImportSuccess}
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
/>
|
onImportSuccess={handleImportSuccess}
|
||||||
);
|
/>
|
||||||
case "prompts":
|
);
|
||||||
return (
|
case "prompts":
|
||||||
<PromptPanel
|
return (
|
||||||
ref={promptPanelRef}
|
<PromptPanel
|
||||||
open={true}
|
ref={promptPanelRef}
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
open={true}
|
||||||
appId={activeApp}
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
/>
|
appId={activeApp}
|
||||||
);
|
/>
|
||||||
case "skills":
|
);
|
||||||
return (
|
case "skills":
|
||||||
<SkillsPage
|
return (
|
||||||
ref={skillsPageRef}
|
<SkillsPage
|
||||||
onClose={() => setCurrentView("providers")}
|
ref={skillsPageRef}
|
||||||
initialApp={activeApp}
|
onClose={() => setCurrentView("providers")}
|
||||||
/>
|
initialApp={activeApp}
|
||||||
);
|
/>
|
||||||
case "mcp":
|
);
|
||||||
return (
|
case "mcp":
|
||||||
<UnifiedMcpPanel
|
return (
|
||||||
ref={mcpPanelRef}
|
<UnifiedMcpPanel
|
||||||
onOpenChange={() => setCurrentView("providers")}
|
ref={mcpPanelRef}
|
||||||
/>
|
onOpenChange={() => setCurrentView("providers")}
|
||||||
);
|
/>
|
||||||
case "agents":
|
);
|
||||||
return <AgentsPanel onOpenChange={() => setCurrentView("providers")} />;
|
case "agents":
|
||||||
default:
|
return (
|
||||||
return (
|
<AgentsPanel onOpenChange={() => setCurrentView("providers")} />
|
||||||
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
);
|
||||||
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
default:
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
return (
|
||||||
<div className="space-y-4">
|
<div className="mx-auto max-w-[56rem] px-5 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
|
||||||
<ProviderList
|
{/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
|
||||||
providers={providers}
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-12 px-1">
|
||||||
currentProviderId={currentProviderId}
|
<AnimatePresence mode="wait">
|
||||||
appId={activeApp}
|
<motion.div
|
||||||
isLoading={isLoading}
|
key={activeApp}
|
||||||
isProxyRunning={isProxyRunning}
|
initial={{ opacity: 0 }}
|
||||||
isProxyTakeover={isProxyRunning && isTakeoverActive}
|
animate={{ opacity: 1 }}
|
||||||
onSwitch={switchProvider}
|
exit={{ opacity: 0 }}
|
||||||
onEdit={setEditingProvider}
|
transition={{ duration: 0.15 }}
|
||||||
onDelete={setConfirmDelete}
|
className="space-y-4"
|
||||||
onDuplicate={handleDuplicateProvider}
|
>
|
||||||
onConfigureUsage={setUsageProvider}
|
<ProviderList
|
||||||
onOpenWebsite={handleOpenWebsite}
|
providers={providers}
|
||||||
onCreate={() => setIsAddOpen(true)}
|
currentProviderId={currentProviderId}
|
||||||
/>
|
appId={activeApp}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isProxyRunning={isProxyRunning}
|
||||||
|
isProxyTakeover={
|
||||||
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
|
}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
|
onSwitch={switchProvider}
|
||||||
|
onEdit={setEditingProvider}
|
||||||
|
onDelete={setConfirmDelete}
|
||||||
|
onDuplicate={handleDuplicateProvider}
|
||||||
|
onConfigureUsage={setUsageProvider}
|
||||||
|
onOpenWebsite={handleOpenWebsite}
|
||||||
|
onCreate={() => setIsAddOpen(true)}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
}
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentView}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-screen flex-col bg-background text-foreground selection:bg-primary/30"
|
className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30"
|
||||||
style={{ overflowX: "hidden" }}
|
style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }}
|
||||||
>
|
>
|
||||||
{/* 全局拖拽区域(顶部 4px),避免上边框无法拖动 */}
|
{/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
|
||||||
<div
|
<div
|
||||||
className="fixed top-0 left-0 right-0 h-4 z-[60]"
|
className="fixed top-0 left-0 right-0 z-[60]"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag", height: DRAG_BAR_HEIGHT } as any}
|
||||||
/>
|
/>
|
||||||
{/* 环境变量警告横幅 */}
|
{/* 环境变量警告横幅 */}
|
||||||
{showEnvBanner && envConflicts.length > 0 && (
|
{showEnvBanner && envConflicts.length > 0 && (
|
||||||
@@ -379,13 +458,18 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<header
|
<header
|
||||||
className="fixed top-0 z-50 w-full py-3 bg-background/80 backdrop-blur-md transition-all duration-300"
|
className="fixed z-50 w-full transition-all duration-300 bg-background/80 backdrop-blur-md"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={
|
||||||
|
{
|
||||||
|
WebkitAppRegion: "drag",
|
||||||
|
top: DRAG_BAR_HEIGHT,
|
||||||
|
height: HEADER_HEIGHT,
|
||||||
|
} as any
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="h-4 w-full" aria-hidden data-tauri-drag-region />
|
|
||||||
<div
|
<div
|
||||||
className="mx-auto max-w-[56rem] px-6 flex flex-wrap items-center justify-between gap-2"
|
className="mx-auto flex h-full max-w-[56rem] flex-wrap items-center justify-between gap-2 px-6"
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
style={{ WebkitAppRegion: "drag" } as any}
|
style={{ WebkitAppRegion: "drag" } as any}
|
||||||
>
|
>
|
||||||
@@ -401,7 +485,7 @@ function App() {
|
|||||||
onClick={() => setCurrentView("providers")}
|
onClick={() => setCurrentView("providers")}
|
||||||
className="mr-2 rounded-lg"
|
className="mr-2 rounded-lg"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-lg font-semibold">
|
<h1 className="text-lg font-semibold">
|
||||||
{currentView === "settings" && t("settings.title")}
|
{currentView === "settings" && t("settings.title")}
|
||||||
@@ -421,7 +505,7 @@ function App() {
|
|||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-xl font-semibold transition-colors",
|
"text-xl font-semibold transition-colors",
|
||||||
isProxyRunning && isTakeoverActive
|
isProxyRunning && isCurrentAppTakeoverActive
|
||||||
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
? "text-emerald-500 hover:text-emerald-600 dark:text-emerald-400 dark:hover:text-emerald-300"
|
||||||
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
: "text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300",
|
||||||
)}
|
)}
|
||||||
@@ -435,7 +519,7 @@ function App() {
|
|||||||
title={t("common.settings")}
|
title={t("common.settings")}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4" />
|
<Settings className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
<UpdateBadge onClick={() => setCurrentView("settings")} />
|
||||||
@@ -444,27 +528,27 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2 h-[32px]"
|
||||||
style={{ WebkitAppRegion: "no-drag" } as any}
|
style={{ WebkitAppRegion: "no-drag" } as any}
|
||||||
>
|
>
|
||||||
{currentView === "prompts" && (
|
{currentView === "prompts" && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => promptPanelRef.current?.openAdd()}
|
onClick={() => promptPanelRef.current?.openAdd()}
|
||||||
className={addActionButtonClass}
|
className={`ml-auto ${addActionButtonClass}`}
|
||||||
title={t("prompts.add")}
|
title={t("prompts.add")}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentView === "mcp" && (
|
{currentView === "mcp" && (
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => mcpPanelRef.current?.openAdd()}
|
onClick={() => mcpPanelRef.current?.openAdd()}
|
||||||
className={addActionButtonClass}
|
className={`ml-auto ${addActionButtonClass}`}
|
||||||
title={t("mcp.unifiedPanel.addServer")}
|
title={t("mcp.unifiedPanel.addServer")}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentView === "skills" && (
|
{currentView === "skills" && (
|
||||||
@@ -475,7 +559,7 @@ function App() {
|
|||||||
onClick={() => skillsPageRef.current?.refresh()}
|
onClick={() => skillsPageRef.current?.refresh()}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
{t("skills.refresh")}
|
{t("skills.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -484,7 +568,7 @@ function App() {
|
|||||||
onClick={() => skillsPageRef.current?.openRepoManager()}
|
onClick={() => skillsPageRef.current?.openRepoManager()}
|
||||||
className="hover:bg-black/5 dark:hover:bg-white/5"
|
className="hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
{t("skills.repoManager")}
|
{t("skills.repoManager")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -495,7 +579,7 @@ function App() {
|
|||||||
|
|
||||||
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
<AppSwitcher activeApp={activeApp} onSwitch={setActiveApp} />
|
||||||
|
|
||||||
<div className="bg-muted p-1 rounded-xl flex items-center gap-1">
|
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -509,20 +593,20 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
title={t("skills.manage")}
|
title={t("skills.manage")}
|
||||||
>
|
>
|
||||||
<Wrench className="h-4 w-4 flex-shrink-0" />
|
<Wrench className="flex-shrink-0 w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
{/* TODO: Agents 功能开发中,暂时隐藏入口 */}
|
||||||
{/* {isClaudeApp && (
|
{/* {isClaudeApp && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setCurrentView("agents")}
|
onClick={() => setCurrentView("agents")}
|
||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title="Agents"
|
title="Agents"
|
||||||
>
|
>
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)} */}
|
)} */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -530,7 +614,7 @@ function App() {
|
|||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("prompts.manage")}
|
title={t("prompts.manage")}
|
||||||
>
|
>
|
||||||
<Book className="h-4 w-4" />
|
<Book className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -539,7 +623,7 @@ function App() {
|
|||||||
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
className="text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/5"
|
||||||
title={t("mcp.title")}
|
title={t("mcp.title")}
|
||||||
>
|
>
|
||||||
<Server className="h-4 w-4" />
|
<Server className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -548,7 +632,7 @@ function App() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
className={`ml-2 ${addActionButtonClass}`}
|
className={`ml-2 ${addActionButtonClass}`}
|
||||||
>
|
>
|
||||||
<Plus className="h-5 w-5" />
|
<Plus className="w-5 h-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -556,13 +640,8 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main
|
<main className="flex-1 pb-12 animate-fade-in ">
|
||||||
className={`flex-1 overflow-y-auto pb-12 animate-fade-in scroll-overlay ${
|
<div className="pb-12">{renderContent()}</div>
|
||||||
currentView === "providers" ? "pt-24" : "pt-20"
|
|
||||||
}`}
|
|
||||||
style={{ overflowX: "hidden" }}
|
|
||||||
>
|
|
||||||
{renderContent()}
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<AddProviderDialog
|
<AddProviderDialog
|
||||||
@@ -574,7 +653,7 @@ function App() {
|
|||||||
|
|
||||||
<EditProviderDialog
|
<EditProviderDialog
|
||||||
open={Boolean(editingProvider)}
|
open={Boolean(editingProvider)}
|
||||||
provider={editingProvider}
|
provider={lastEditingProviderRef.current}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setEditingProvider(null);
|
setEditingProvider(null);
|
||||||
@@ -582,17 +661,19 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleEditProvider}
|
onSubmit={handleEditProvider}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isProxyTakeover={isProxyRunning && isTakeoverActive}
|
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{usageProvider && (
|
{lastUsageProviderRef.current && (
|
||||||
<UsageScriptModal
|
<UsageScriptModal
|
||||||
provider={usageProvider}
|
provider={lastUsageProviderRef.current}
|
||||||
appId={activeApp}
|
appId={activeApp}
|
||||||
isOpen={Boolean(usageProvider)}
|
isOpen={Boolean(usageProvider)}
|
||||||
onClose={() => setUsageProvider(null)}
|
onClose={() => setUsageProvider(null)}
|
||||||
onSave={(script) => {
|
onSave={(script) => {
|
||||||
void saveUsageScript(usageProvider, script);
|
if (usageProvider) {
|
||||||
|
void saveUsageScript(usageProvider, script);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex bg-muted rounded-lg p-1 gap-1">
|
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("claude")}
|
onClick={() => handleSwitch("claude")}
|
||||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "claude"
|
activeApp === "claude"
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||||
@@ -50,7 +50,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("codex")}
|
onClick={() => handleSwitch("codex")}
|
||||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "codex"
|
activeApp === "codex"
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||||
@@ -72,7 +72,7 @@ export function AppSwitcher({ activeApp, onSwitch }: AppSwitcherProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSwitch("gemini")}
|
onClick={() => handleSwitch("gemini")}
|
||||||
className={`group inline-flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all duration-200 ${
|
className={`group inline-flex items-center gap-2 px-3 h-8 rounded-md text-sm font-medium transition-all duration-200 ${
|
||||||
activeApp === "gemini"
|
activeApp === "gemini"
|
||||||
? "bg-background text-foreground shadow-sm"
|
? "bg-background text-foreground shadow-sm"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
: "text-muted-foreground hover:text-foreground hover:bg-background/50"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function UpdateBadge({ className = "", onClick }: UpdateBadgeProps) {
|
|||||||
"
|
"
|
||||||
aria-label={t("common.close")}
|
aria-label={t("common.close")}
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
{/* 第一行:更新时间和刷新按钮 */}
|
{/* 第一行:更新时间和刷新按钮 */}
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
{/* 上次查询时间 */}
|
{/* 上次查询时间 */}
|
||||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
{lastQueriedAt
|
{lastQueriedAt
|
||||||
? formatRelativeTime(lastQueriedAt, now, t)
|
? formatRelativeTime(lastQueriedAt, now, t)
|
||||||
@@ -128,7 +128,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50 flex-shrink-0 text-gray-400 dark:text-gray-500"
|
className="p-1 rounded hover:bg-muted transition-colors disabled:opacity-50 flex-shrink-0 text-muted-foreground"
|
||||||
title={t("usage.refreshUsage")}
|
title={t("usage.refreshUsage")}
|
||||||
>
|
>
|
||||||
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
<RefreshCw size={12} className={loading ? "animate-spin" : ""} />
|
||||||
@@ -191,7 +191,7 @@ const UsageFooter: React.FC<UsageFooterProps> = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* 自动查询时间提示 */}
|
{/* 自动查询时间提示 */}
|
||||||
{lastQueriedAt && (
|
{lastQueriedAt && (
|
||||||
<span className="text-[10px] text-gray-400 dark:text-gray-500 flex items-center gap-1">
|
<span className="text-[10px] text-muted-foreground/70 flex items-center gap-1">
|
||||||
<Clock size={10} />
|
<Clock size={10} />
|
||||||
{formatRelativeTime(lastQueriedAt, now, t)}
|
{formatRelativeTime(lastQueriedAt, now, t)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
@@ -32,46 +33,57 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<AnimatePresence>
|
||||||
className="fixed inset-0 z-[60] flex flex-col"
|
{isOpen && (
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
<motion.div
|
||||||
>
|
initial={{ opacity: 0 }}
|
||||||
{/* Header */}
|
animate={{ opacity: 1 }}
|
||||||
<div
|
exit={{ opacity: 0 }}
|
||||||
className="flex-shrink-0 py-3 border-b border-border-default"
|
transition={{ duration: 0.2 }}
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
className="fixed inset-0 z-[60] flex flex-col"
|
||||||
>
|
|
||||||
<div className="h-4 w-full" data-tauri-drag-region />
|
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
|
||||||
<Button type="button" variant="outline" size="icon" onClick={onClose}>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto scroll-overlay">
|
|
||||||
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
{footer && (
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 py-4 border-t border-border-default"
|
|
||||||
style={{ backgroundColor: "hsl(var(--background))" }}
|
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||||
>
|
>
|
||||||
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
|
{/* Header */}
|
||||||
{footer}
|
<div
|
||||||
|
className="flex-shrink-0 py-3 border-b border-border-default"
|
||||||
|
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||||
|
>
|
||||||
|
<div className="h-4 w-full" data-tauri-drag-region />
|
||||||
|
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">{title}</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto scroll-overlay">
|
||||||
|
<div className="mx-auto max-w-[56rem] px-6 py-6 space-y-6 w-full">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{footer && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 py-4 border-t border-border-default"
|
||||||
|
style={{ backgroundColor: "hsl(var(--background))" }}
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-[56rem] px-6 flex items-center justify-end gap-3">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</AnimatePresence>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -198,7 +198,7 @@ export function EnvWarningBanner({
|
|||||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
|
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 break-all">
|
||||||
{t("env.field.value")}: {conflict.varValue}
|
{t("env.field.value")}: {conflict.varValue}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
{t("env.field.source")}:{" "}
|
{t("env.field.source")}:{" "}
|
||||||
{getSourceDescription(conflict)}
|
{getSourceDescription(conflict)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -129,18 +129,18 @@ const UnifiedMcpPanel = React.forwardRef<
|
|||||||
{/* Content - Scrollable */}
|
{/* Content - Scrollable */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
{t("mcp.loading")}
|
{t("mcp.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : serverEntries.length === 0 ? (
|
) : serverEntries.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||||
<Server size={24} className="text-gray-400 dark:text-gray-500" />
|
<Server size={24} className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
{t("mcp.unifiedPanel.noServers")}
|
{t("mcp.unifiedPanel.noServers")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("mcp.emptyDescription")}
|
{t("mcp.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,9 +237,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
{/* 左侧:服务器信息 */}
|
{/* 左侧:服务器信息 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100">
|
<h3 className="font-medium text-foreground">{name}</h3>
|
||||||
{name}
|
|
||||||
</h3>
|
|
||||||
{docsUrl && (
|
{docsUrl && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -253,12 +251,12 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!description && tags && tags.length > 0 && (
|
{!description && tags && tags.length > 0 && (
|
||||||
<p className="text-xs text-gray-400 dark:text-gray-500 truncate">
|
<p className="text-xs text-muted-foreground/70 truncate">
|
||||||
{tags.join(", ")}
|
{tags.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -269,7 +267,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<label
|
<label
|
||||||
htmlFor={`${id}-claude`}
|
htmlFor={`${id}-claude`}
|
||||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
className="text-sm text-foreground/80 cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("mcp.unifiedPanel.apps.claude")}
|
{t("mcp.unifiedPanel.apps.claude")}
|
||||||
</label>
|
</label>
|
||||||
@@ -285,7 +283,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<label
|
<label
|
||||||
htmlFor={`${id}-codex`}
|
htmlFor={`${id}-codex`}
|
||||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
className="text-sm text-foreground/80 cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("mcp.unifiedPanel.apps.codex")}
|
{t("mcp.unifiedPanel.apps.codex")}
|
||||||
</label>
|
</label>
|
||||||
@@ -301,7 +299,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
|
|||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<label
|
<label
|
||||||
htmlFor={`${id}-gemini`}
|
htmlFor={`${id}-gemini`}
|
||||||
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer"
|
className="text-sm text-foreground/80 cursor-pointer"
|
||||||
>
|
>
|
||||||
{t("mcp.unifiedPanel.apps.gemini")}
|
{t("mcp.unifiedPanel.apps.gemini")}
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -36,11 +36,9 @@ const PromptListItem: React.FC<PromptListItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium text-gray-900 dark:text-gray-100 mb-1">
|
<h3 className="font-medium text-foreground mb-1">{prompt.name}</h3>
|
||||||
{prompt.name}
|
|
||||||
</h3>
|
|
||||||
{prompt.description && (
|
{prompt.description && (
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">
|
<p className="text-sm text-muted-foreground truncate">
|
||||||
{prompt.description}
|
{prompt.description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -108,21 +108,18 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
|
|||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pb-16">
|
<div className="flex-1 overflow-y-auto pb-16">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
|
<div className="text-center py-12 text-muted-foreground">
|
||||||
{t("prompts.loading")}
|
{t("prompts.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : promptEntries.length === 0 ? (
|
) : promptEntries.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
|
||||||
<FileText
|
<FileText size={24} className="text-muted-foreground" />
|
||||||
size={24}
|
|
||||||
className="text-gray-400 dark:text-gray-500"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||||
{t("prompts.empty")}
|
{t("prompts.empty")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{t("prompts.emptyDescription")}
|
{t("prompts.emptyDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface FailoverPriorityBadgeProps {
|
||||||
|
priority: number; // 1, 2, 3, ...
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 故障转移优先级徽章
|
||||||
|
* 显示供应商在故障转移队列中的优先级顺序
|
||||||
|
*/
|
||||||
|
export function FailoverPriorityBadge({
|
||||||
|
priority,
|
||||||
|
className,
|
||||||
|
}: FailoverPriorityBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center px-1.5 py-0.5 rounded text-xs font-semibold",
|
||||||
|
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
title={t("failover.priority.tooltip", {
|
||||||
|
priority,
|
||||||
|
defaultValue: `故障转移优先级 ${priority}`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
P{priority}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { HealthStatus } from "@/lib/api/model-test";
|
import type { HealthStatus } from "@/lib/api/model-test";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface HealthStatusIndicatorProps {
|
interface HealthStatusIndicatorProps {
|
||||||
status: HealthStatus;
|
status: HealthStatus;
|
||||||
@@ -11,17 +12,20 @@ interface HealthStatusIndicatorProps {
|
|||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
operational: {
|
operational: {
|
||||||
color: "bg-emerald-500",
|
color: "bg-emerald-500",
|
||||||
label: "正常",
|
labelKey: "health.operational",
|
||||||
|
labelFallback: "正常",
|
||||||
textColor: "text-emerald-600 dark:text-emerald-400",
|
textColor: "text-emerald-600 dark:text-emerald-400",
|
||||||
},
|
},
|
||||||
degraded: {
|
degraded: {
|
||||||
color: "bg-yellow-500",
|
color: "bg-yellow-500",
|
||||||
label: "降级",
|
labelKey: "health.degraded",
|
||||||
|
labelFallback: "降级",
|
||||||
textColor: "text-yellow-600 dark:text-yellow-400",
|
textColor: "text-yellow-600 dark:text-yellow-400",
|
||||||
},
|
},
|
||||||
failed: {
|
failed: {
|
||||||
color: "bg-red-500",
|
color: "bg-red-500",
|
||||||
label: "失败",
|
labelKey: "health.failed",
|
||||||
|
labelFallback: "失败",
|
||||||
textColor: "text-red-600 dark:text-red-400",
|
textColor: "text-red-600 dark:text-red-400",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -31,13 +35,15 @@ export const HealthStatusIndicator: React.FC<HealthStatusIndicatorProps> = ({
|
|||||||
responseTimeMs,
|
responseTimeMs,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const config = statusConfig[status];
|
const config = statusConfig[status];
|
||||||
|
const label = t(config.labelKey, { defaultValue: config.labelFallback });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-2", className)}>
|
<div className={cn("flex items-center gap-2", className)}>
|
||||||
<div className={cn("w-2 h-2 rounded-full", config.color)} />
|
<div className={cn("w-2 h-2 rounded-full", config.color)} />
|
||||||
<span className={cn("text-xs font-medium", config.textColor)}>
|
<span className={cn("text-xs font-medium", config.textColor)}>
|
||||||
{config.label}
|
{label}
|
||||||
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
{responseTimeMs !== undefined && ` (${responseTimeMs}ms)`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Loader2,
|
Loader2,
|
||||||
Play,
|
Play,
|
||||||
|
Plus,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -22,6 +23,10 @@ interface ProviderActionsProps {
|
|||||||
onTest?: () => void;
|
onTest?: () => void;
|
||||||
onConfigureUsage: () => void;
|
onConfigureUsage: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled?: boolean;
|
||||||
|
isInFailoverQueue?: boolean;
|
||||||
|
onToggleFailover?: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderActions({
|
export function ProviderActions({
|
||||||
@@ -34,38 +39,88 @@ export function ProviderActions({
|
|||||||
onTest,
|
onTest,
|
||||||
onConfigureUsage,
|
onConfigureUsage,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled = false,
|
||||||
|
isInFailoverQueue = false,
|
||||||
|
onToggleFailover,
|
||||||
}: ProviderActionsProps) {
|
}: ProviderActionsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const iconButtonClass = "h-8 w-8 p-1";
|
const iconButtonClass = "h-8 w-8 p-1";
|
||||||
|
|
||||||
|
// 故障转移模式下的按钮逻辑
|
||||||
|
const isFailoverMode = isAutoFailoverEnabled && onToggleFailover;
|
||||||
|
|
||||||
|
// 处理主按钮点击
|
||||||
|
const handleMainButtonClick = () => {
|
||||||
|
if (isFailoverMode) {
|
||||||
|
// 故障转移模式:切换队列状态
|
||||||
|
onToggleFailover(!isInFailoverQueue);
|
||||||
|
} else {
|
||||||
|
// 普通模式:切换供应商
|
||||||
|
onSwitch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主按钮的状态和样式
|
||||||
|
const getMainButtonState = () => {
|
||||||
|
if (isFailoverMode) {
|
||||||
|
// 故障转移模式
|
||||||
|
if (isInFailoverQueue) {
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-blue-100 text-blue-600 hover:bg-blue-200 dark:bg-blue-900/50 dark:text-blue-400 dark:hover:bg-blue-900/70",
|
||||||
|
icon: <Check className="h-4 w-4" />,
|
||||||
|
text: t("failover.inQueue", { defaultValue: "已加入" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className:
|
||||||
|
"bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700",
|
||||||
|
icon: <Plus className="h-4 w-4" />,
|
||||||
|
text: t("failover.addQueue", { defaultValue: "加入" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通模式
|
||||||
|
if (isCurrent) {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
variant: "secondary" as const,
|
||||||
|
className:
|
||||||
|
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
||||||
|
icon: <Check className="h-4 w-4" />,
|
||||||
|
text: t("provider.inUse"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
disabled: false,
|
||||||
|
variant: "default" as const,
|
||||||
|
className: isProxyTakeover
|
||||||
|
? "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
||||||
|
: "",
|
||||||
|
icon: <Play className="h-4 w-4" />,
|
||||||
|
text: t("provider.enable"),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonState = getMainButtonState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={isCurrent ? "secondary" : "default"}
|
variant={buttonState.variant}
|
||||||
onClick={onSwitch}
|
onClick={handleMainButtonClick}
|
||||||
disabled={isCurrent}
|
disabled={buttonState.disabled}
|
||||||
className={cn(
|
className={cn("w-[4.5rem] px-2.5", buttonState.className)}
|
||||||
"w-[4.5rem] px-2.5",
|
|
||||||
isCurrent &&
|
|
||||||
"bg-gray-200 text-muted-foreground hover:bg-gray-200 hover:text-muted-foreground dark:bg-gray-700 dark:hover:bg-gray-700",
|
|
||||||
// 代理接管模式下启用按钮使用绿色
|
|
||||||
!isCurrent &&
|
|
||||||
isProxyTakeover &&
|
|
||||||
"bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isCurrent ? (
|
{buttonState.icon}
|
||||||
<>
|
{buttonState.text}
|
||||||
<Check className="h-4 w-4" />
|
|
||||||
{t("provider.inUse")}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Play className="h-4 w-4" />
|
|
||||||
{t("provider.enable")}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ProviderActions } from "@/components/providers/ProviderActions";
|
|||||||
import { ProviderIcon } from "@/components/ProviderIcon";
|
import { ProviderIcon } from "@/components/ProviderIcon";
|
||||||
import UsageFooter from "@/components/UsageFooter";
|
import UsageFooter from "@/components/UsageFooter";
|
||||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||||
|
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
|
||||||
import { useProviderHealth } from "@/lib/query/failover";
|
import { useProviderHealth } from "@/lib/query/failover";
|
||||||
import { useUsageQuery } from "@/lib/query/queries";
|
import { useUsageQuery } from "@/lib/query/queries";
|
||||||
|
|
||||||
@@ -36,6 +37,12 @@ interface ProviderCardProps {
|
|||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
|
||||||
dragHandleProps?: DragHandleProps;
|
dragHandleProps?: DragHandleProps;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled?: boolean; // 是否开启自动故障转移
|
||||||
|
failoverPriority?: number; // 故障转移优先级(1 = P1, 2 = P2, ...)
|
||||||
|
isInFailoverQueue?: boolean; // 是否在故障转移队列中
|
||||||
|
onToggleFailover?: (enabled: boolean) => void; // 切换故障转移队列
|
||||||
|
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||||
}
|
}
|
||||||
|
|
||||||
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
const extractApiUrl = (provider: Provider, fallbackText: string) => {
|
||||||
@@ -88,6 +95,12 @@ export function ProviderCard({
|
|||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover = false,
|
isProxyTakeover = false,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled = false,
|
||||||
|
failoverPriority,
|
||||||
|
isInFailoverQueue = false,
|
||||||
|
onToggleFailover,
|
||||||
|
activeProviderId,
|
||||||
}: ProviderCardProps) {
|
}: ProviderCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -148,21 +161,32 @@ export function ProviderCard({
|
|||||||
onOpenWebsite(displayUrl);
|
onOpenWebsite(displayUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 判断是否是"当前使用中"的供应商
|
||||||
|
// - 故障转移模式:代理实际使用的供应商(activeProviderId)
|
||||||
|
// - 代理接管模式(非故障转移):isCurrent
|
||||||
|
// - 普通模式:isCurrent
|
||||||
|
const isActiveProvider = isAutoFailoverEnabled
|
||||||
|
? activeProviderId === provider.id
|
||||||
|
: isCurrent;
|
||||||
|
|
||||||
|
// 判断是否使用绿色(代理接管模式)还是蓝色(普通模式)
|
||||||
|
const shouldUseGreen = isProxyTakeover && isActiveProvider;
|
||||||
|
const shouldUseBlue = !isProxyTakeover && isActiveProvider;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
"relative overflow-hidden rounded-xl border border-border p-4 transition-all duration-300",
|
||||||
"bg-card text-card-foreground group",
|
"bg-card text-card-foreground group",
|
||||||
// 代理接管模式下 hover 使用绿色边框,否则使用蓝色
|
// hover 时的边框效果
|
||||||
isProxyTakeover
|
isAutoFailoverEnabled || isProxyTakeover
|
||||||
? "hover:border-emerald-500/50"
|
? "hover:border-emerald-500/50"
|
||||||
: "hover:border-border-active",
|
: "hover:border-border-active",
|
||||||
// 代理接管模式下当前供应商使用绿色边框
|
// 当前激活的供应商边框样式
|
||||||
isProxyTakeover && isCurrent
|
shouldUseGreen &&
|
||||||
? "border-emerald-500/60 shadow-sm shadow-emerald-500/10"
|
"border-emerald-500/60 shadow-sm shadow-emerald-500/10",
|
||||||
: isCurrent
|
shouldUseBlue && "border-blue-500/60 shadow-sm shadow-blue-500/10",
|
||||||
? "border-primary/50 shadow-sm"
|
!isActiveProvider && "hover:shadow-sm",
|
||||||
: "hover:shadow-sm",
|
|
||||||
dragHandleProps?.isDragging &&
|
dragHandleProps?.isDragging &&
|
||||||
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
"cursor-grabbing border-primary shadow-lg scale-105 z-10",
|
||||||
)}
|
)}
|
||||||
@@ -170,11 +194,11 @@ export function ProviderCard({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
"absolute inset-0 bg-gradient-to-r to-transparent transition-opacity duration-500 pointer-events-none",
|
||||||
// 代理接管模式下使用绿色渐变,否则使用蓝色主色调
|
// 代理接管模式使用绿色渐变,普通模式使用蓝色渐变
|
||||||
isProxyTakeover && isCurrent
|
shouldUseGreen && "from-emerald-500/10",
|
||||||
? "from-emerald-500/10"
|
shouldUseBlue && "from-blue-500/10",
|
||||||
: "from-primary/10",
|
!isActiveProvider && "from-primary/10",
|
||||||
isCurrent ? "opacity-100" : "opacity-0",
|
isActiveProvider ? "opacity-100" : "opacity-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="relative flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
@@ -209,13 +233,20 @@ export function ProviderCard({
|
|||||||
{provider.name}
|
{provider.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* 健康状态徽章和优先级 */}
|
{/* 健康状态徽章 */}
|
||||||
{isProxyRunning && health && (
|
{isProxyRunning && isInFailoverQueue && health && (
|
||||||
<ProviderHealthBadge
|
<ProviderHealthBadge
|
||||||
consecutiveFailures={health.consecutive_failures}
|
consecutiveFailures={health.consecutive_failures}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 故障转移优先级徽章 */}
|
||||||
|
{isAutoFailoverEnabled &&
|
||||||
|
isInFailoverQueue &&
|
||||||
|
failoverPriority && (
|
||||||
|
<FailoverPriorityBadge priority={failoverPriority} />
|
||||||
|
)}
|
||||||
|
|
||||||
{provider.category === "third_party" &&
|
{provider.category === "third_party" &&
|
||||||
provider.meta?.isPartner && (
|
provider.meta?.isPartner && (
|
||||||
<span
|
<span
|
||||||
@@ -308,6 +339,10 @@ export function ProviderCard({
|
|||||||
onTest={onTest ? () => onTest(provider) : undefined}
|
onTest={onTest ? () => onTest(provider) : undefined}
|
||||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||||
onDelete={() => onDelete(provider)}
|
onDelete={() => onDelete(provider)}
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
onToggleFailover={onToggleFailover}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ProviderHealthStatus } from "@/types/proxy";
|
import { ProviderHealthStatus } from "@/types/proxy";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ProviderHealthBadgeProps {
|
interface ProviderHealthBadgeProps {
|
||||||
consecutiveFailures: number;
|
consecutiveFailures: number;
|
||||||
@@ -14,11 +15,14 @@ export function ProviderHealthBadge({
|
|||||||
consecutiveFailures,
|
consecutiveFailures,
|
||||||
className,
|
className,
|
||||||
}: ProviderHealthBadgeProps) {
|
}: ProviderHealthBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 根据失败次数计算状态
|
// 根据失败次数计算状态
|
||||||
const getStatus = () => {
|
const getStatus = () => {
|
||||||
if (consecutiveFailures === 0) {
|
if (consecutiveFailures === 0) {
|
||||||
return {
|
return {
|
||||||
label: "正常",
|
labelKey: "health.operational",
|
||||||
|
labelFallback: "正常",
|
||||||
status: ProviderHealthStatus.Healthy,
|
status: ProviderHealthStatus.Healthy,
|
||||||
color: "bg-green-500",
|
color: "bg-green-500",
|
||||||
// 使用更深/柔和的背景色,去除可能的白色内容感
|
// 使用更深/柔和的背景色,去除可能的白色内容感
|
||||||
@@ -27,7 +31,8 @@ export function ProviderHealthBadge({
|
|||||||
};
|
};
|
||||||
} else if (consecutiveFailures < 5) {
|
} else if (consecutiveFailures < 5) {
|
||||||
return {
|
return {
|
||||||
label: "降级",
|
labelKey: "health.degraded",
|
||||||
|
labelFallback: "降级",
|
||||||
status: ProviderHealthStatus.Degraded,
|
status: ProviderHealthStatus.Degraded,
|
||||||
color: "bg-yellow-500",
|
color: "bg-yellow-500",
|
||||||
bgColor: "bg-yellow-500/10",
|
bgColor: "bg-yellow-500/10",
|
||||||
@@ -35,7 +40,8 @@ export function ProviderHealthBadge({
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
label: "熔断",
|
labelKey: "health.circuitOpen",
|
||||||
|
labelFallback: "熔断",
|
||||||
status: ProviderHealthStatus.Failed,
|
status: ProviderHealthStatus.Failed,
|
||||||
color: "bg-red-500",
|
color: "bg-red-500",
|
||||||
bgColor: "bg-red-500/10",
|
bgColor: "bg-red-500/10",
|
||||||
@@ -45,6 +51,9 @@ export function ProviderHealthBadge({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = getStatus();
|
const statusConfig = getStatus();
|
||||||
|
const label = t(statusConfig.labelKey, {
|
||||||
|
defaultValue: statusConfig.labelFallback,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -54,10 +63,13 @@ export function ProviderHealthBadge({
|
|||||||
statusConfig.textColor,
|
statusConfig.textColor,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
title={`连续失败 ${consecutiveFailures} 次`}
|
title={t("health.consecutiveFailures", {
|
||||||
|
count: consecutiveFailures,
|
||||||
|
defaultValue: `连续失败 ${consecutiveFailures} 次`,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
|
<div className={cn("w-2 h-2 rounded-full", statusConfig.color)} />
|
||||||
<span>{statusConfig.label}</span>
|
<span>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,31 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import type { CSSProperties } from "react";
|
import {
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type CSSProperties,
|
||||||
|
} from "react";
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Provider } from "@/types";
|
import type { Provider } from "@/types";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
import { useDragSort } from "@/hooks/useDragSort";
|
import { useDragSort } from "@/hooks/useDragSort";
|
||||||
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
import { useStreamCheck } from "@/hooks/useStreamCheck";
|
||||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||||
|
import {
|
||||||
|
useAutoFailoverEnabled,
|
||||||
|
useFailoverQueue,
|
||||||
|
useAddToFailoverQueue,
|
||||||
|
useRemoveFromFailoverQueue,
|
||||||
|
} from "@/lib/query/failover";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
interface ProviderListProps {
|
interface ProviderListProps {
|
||||||
providers: Record<string, Provider>;
|
providers: Record<string, Provider>;
|
||||||
@@ -27,6 +45,7 @@ interface ProviderListProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
isProxyRunning?: boolean; // 代理服务运行状态
|
isProxyRunning?: boolean; // 代理服务运行状态
|
||||||
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管)
|
||||||
|
activeProviderId?: string; // 代理当前实际使用的供应商 ID(用于故障转移模式下标注绿色边框)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProviderList({
|
export function ProviderList({
|
||||||
@@ -41,9 +60,11 @@ export function ProviderList({
|
|||||||
onOpenWebsite,
|
onOpenWebsite,
|
||||||
onCreate,
|
onCreate,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isProxyRunning = false, // 默认值为 false
|
isProxyRunning = false,
|
||||||
isProxyTakeover = false, // 默认值为 false
|
isProxyTakeover = false,
|
||||||
|
activeProviderId,
|
||||||
}: ProviderListProps) {
|
}: ProviderListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
const { sortedProviders, sensors, handleDragEnd } = useDragSort(
|
||||||
providers,
|
providers,
|
||||||
appId,
|
appId,
|
||||||
@@ -52,17 +73,103 @@ export function ProviderList({
|
|||||||
// 流式健康检查
|
// 流式健康检查
|
||||||
const { checkProvider, isChecking } = useStreamCheck(appId);
|
const { checkProvider, isChecking } = useStreamCheck(appId);
|
||||||
|
|
||||||
|
// 故障转移相关
|
||||||
|
const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId);
|
||||||
|
const { data: failoverQueue } = useFailoverQueue(appId);
|
||||||
|
const addToQueue = useAddToFailoverQueue();
|
||||||
|
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||||
|
|
||||||
|
// 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式
|
||||||
|
const isFailoverModeActive =
|
||||||
|
isProxyTakeover === true && isAutoFailoverEnabled === true;
|
||||||
|
|
||||||
|
// 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序)
|
||||||
|
const getFailoverPriority = useCallback(
|
||||||
|
(providerId: string): number | undefined => {
|
||||||
|
if (!isFailoverModeActive || !failoverQueue) return undefined;
|
||||||
|
const index = failoverQueue.findIndex(
|
||||||
|
(item) => item.providerId === providerId,
|
||||||
|
);
|
||||||
|
return index >= 0 ? index + 1 : undefined;
|
||||||
|
},
|
||||||
|
[isFailoverModeActive, failoverQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 判断供应商是否在故障转移队列中
|
||||||
|
const isInFailoverQueue = useCallback(
|
||||||
|
(providerId: string): boolean => {
|
||||||
|
if (!isFailoverModeActive || !failoverQueue) return false;
|
||||||
|
return failoverQueue.some((item) => item.providerId === providerId);
|
||||||
|
},
|
||||||
|
[isFailoverModeActive, failoverQueue],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 切换供应商的故障转移队列状态
|
||||||
|
const handleToggleFailover = useCallback(
|
||||||
|
(providerId: string, enabled: boolean) => {
|
||||||
|
if (enabled) {
|
||||||
|
addToQueue.mutate({ appType: appId, providerId });
|
||||||
|
} else {
|
||||||
|
removeFromQueue.mutate({ appType: appId, providerId });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[appId, addToQueue, removeFromQueue],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTest = (provider: Provider) => {
|
const handleTest = (provider: Provider) => {
|
||||||
checkProvider(provider.id, provider.name);
|
checkProvider(provider.id, provider.name);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if ((event.metaKey || event.ctrlKey) && key === "f") {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsSearchOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === "escape") {
|
||||||
|
setIsSearchOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSearchOpen) {
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
searchInputRef.current?.select();
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}
|
||||||
|
}, [isSearchOpen]);
|
||||||
|
|
||||||
|
const filteredProviders = useMemo(() => {
|
||||||
|
const keyword = searchTerm.trim().toLowerCase();
|
||||||
|
if (!keyword) return sortedProviders;
|
||||||
|
return sortedProviders.filter((provider) => {
|
||||||
|
const fields = [provider.name, provider.notes, provider.websiteUrl];
|
||||||
|
return fields.some((field) =>
|
||||||
|
field?.toString().toLowerCase().includes(keyword),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [searchTerm, sortedProviders]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[0, 1, 2].map((index) => (
|
{[0, 1, 2].map((index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="h-28 w-full rounded-lg border border-dashed border-muted-foreground/40 bg-muted/40"
|
className="w-full border border-dashed rounded-lg h-28 border-muted-foreground/40 bg-muted/40"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,21 +180,18 @@ export function ProviderList({
|
|||||||
return <ProviderEmptyState onCreate={onCreate} />;
|
return <ProviderEmptyState onCreate={onCreate} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderProviderList = () => (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={sortedProviders.map((provider) => provider.id)}
|
items={filteredProviders.map((provider) => provider.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div
|
<div className="space-y-3">
|
||||||
className="space-y-3 animate-slide-up"
|
{filteredProviders.map((provider) => (
|
||||||
style={{ animationDelay: "0.1s" }}
|
|
||||||
>
|
|
||||||
{sortedProviders.map((provider) => (
|
|
||||||
<SortableProviderCard
|
<SortableProviderCard
|
||||||
key={provider.id}
|
key={provider.id}
|
||||||
provider={provider}
|
provider={provider}
|
||||||
@@ -103,12 +207,98 @@ export function ProviderList({
|
|||||||
isTesting={isChecking(provider.id)}
|
isTesting={isChecking(provider.id)}
|
||||||
isProxyRunning={isProxyRunning}
|
isProxyRunning={isProxyRunning}
|
||||||
isProxyTakeover={isProxyTakeover}
|
isProxyTakeover={isProxyTakeover}
|
||||||
|
// 故障转移相关:联动状态
|
||||||
|
isAutoFailoverEnabled={isFailoverModeActive}
|
||||||
|
failoverPriority={getFailoverPriority(provider.id)}
|
||||||
|
isInFailoverQueue={isInFailoverQueue(provider.id)}
|
||||||
|
onToggleFailover={(enabled) =>
|
||||||
|
handleToggleFailover(provider.id, enabled)
|
||||||
|
}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<AnimatePresence>
|
||||||
|
{isSearchOpen && (
|
||||||
|
<motion.div
|
||||||
|
key="provider-search"
|
||||||
|
initial={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||||
|
transition={{ duration: 0.18, ease: "easeOut" }}
|
||||||
|
className="fixed left-1/2 top-[6.5rem] z-40 w-[min(90vw,26rem)] -translate-x-1/2 sm:right-6 sm:left-auto sm:translate-x-0"
|
||||||
|
>
|
||||||
|
<div className="p-4 space-y-3 border shadow-md rounded-2xl border-white/10 bg-background/95 shadow-black/20 backdrop-blur-md">
|
||||||
|
<div className="relative flex items-center gap-2">
|
||||||
|
<Search className="absolute w-4 h-4 -translate-y-1/2 pointer-events-none left-3 top-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
ref={searchInputRef}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(event) => setSearchTerm(event.target.value)}
|
||||||
|
placeholder={t("provider.searchPlaceholder", {
|
||||||
|
defaultValue: "Search name, notes, or URL...",
|
||||||
|
})}
|
||||||
|
aria-label={t("provider.searchAriaLabel", {
|
||||||
|
defaultValue: "Search providers",
|
||||||
|
})}
|
||||||
|
className="pr-16 pl-9"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="absolute text-xs -translate-y-1/2 right-11 top-1/2"
|
||||||
|
onClick={() => setSearchTerm("")}
|
||||||
|
>
|
||||||
|
{t("common.clear", { defaultValue: "Clear" })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => setIsSearchOpen(false)}
|
||||||
|
aria-label={t("provider.searchCloseAriaLabel", {
|
||||||
|
defaultValue: "Close provider search",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||||
|
<span>
|
||||||
|
{t("provider.searchScopeHint", {
|
||||||
|
defaultValue: "Matches provider name, notes, and URL.",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{t("provider.searchCloseHint", {
|
||||||
|
defaultValue: "Press Esc to close",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{filteredProviders.length === 0 ? (
|
||||||
|
<div className="px-6 py-8 text-sm text-center border border-dashed rounded-lg border-border text-muted-foreground">
|
||||||
|
{t("provider.noSearchResults", {
|
||||||
|
defaultValue: "No providers match your search.",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderProviderList()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableProviderCardProps {
|
interface SortableProviderCardProps {
|
||||||
@@ -125,6 +315,12 @@ interface SortableProviderCardProps {
|
|||||||
isTesting: boolean;
|
isTesting: boolean;
|
||||||
isProxyRunning: boolean;
|
isProxyRunning: boolean;
|
||||||
isProxyTakeover: boolean;
|
isProxyTakeover: boolean;
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled: boolean;
|
||||||
|
failoverPriority?: number;
|
||||||
|
isInFailoverQueue: boolean;
|
||||||
|
onToggleFailover: (enabled: boolean) => void;
|
||||||
|
activeProviderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableProviderCard({
|
function SortableProviderCard({
|
||||||
@@ -141,6 +337,11 @@ function SortableProviderCard({
|
|||||||
isTesting,
|
isTesting,
|
||||||
isProxyRunning,
|
isProxyRunning,
|
||||||
isProxyTakeover,
|
isProxyTakeover,
|
||||||
|
isAutoFailoverEnabled,
|
||||||
|
failoverPriority,
|
||||||
|
isInFailoverQueue,
|
||||||
|
onToggleFailover,
|
||||||
|
activeProviderId,
|
||||||
}: SortableProviderCardProps) {
|
}: SortableProviderCardProps) {
|
||||||
const {
|
const {
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
@@ -179,6 +380,12 @@ function SortableProviderCard({
|
|||||||
listeners,
|
listeners,
|
||||||
isDragging,
|
isDragging,
|
||||||
}}
|
}}
|
||||||
|
// 故障转移相关
|
||||||
|
isAutoFailoverEnabled={isAutoFailoverEnabled}
|
||||||
|
failoverPriority={failoverPriority}
|
||||||
|
isInFailoverQueue={isInFailoverQueue}
|
||||||
|
onToggleFailover={onToggleFailover}
|
||||||
|
activeProviderId={activeProviderId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const ApiKeyInput: React.FC<ApiKeyInputProps> = ({
|
|||||||
|
|
||||||
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
const inputClass = `w-full px-3 py-2 pr-10 border rounded-lg text-sm transition-colors ${
|
||||||
disabled
|
disabled
|
||||||
? "bg-gray-100 dark:bg-gray-800 border-border-default text-gray-400 dark:text-gray-500 cursor-not-allowed"
|
? "bg-muted border-border-default text-muted-foreground cursor-not-allowed"
|
||||||
: "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
|
: "border-border-default dark:bg-gray-800 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20"
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,15 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center"
|
className="w-20 h-20 p-3 rounded-xl border-2 border-muted hover:border-primary transition-colors cursor-pointer bg-muted/30 hover:bg-muted/50 flex items-center justify-center"
|
||||||
title={currentIcon ? "点击更换图标" : "点击选择图标"}
|
title={
|
||||||
|
currentIcon
|
||||||
|
? t("providerIcon.clickToChange", {
|
||||||
|
defaultValue: "点击更换图标",
|
||||||
|
})
|
||||||
|
: t("providerIcon.clickToSelect", {
|
||||||
|
defaultValue: "点击选择图标",
|
||||||
|
})
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
icon={currentIcon}
|
icon={currentIcon}
|
||||||
@@ -145,7 +153,10 @@ export function BasicFormFields({ form }: BasicFormFieldsProps) {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
|
<FormLabel>{t("provider.websiteUrl")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://" />
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder={t("providerForm.websiteUrlPlaceholder")}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { FormLabel } from "@/components/ui/form";
|
import { FormLabel } from "@/components/ui/form";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
import { ApiKeySection, EndpointField } from "./shared";
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
@@ -39,12 +40,14 @@ interface ClaudeFormFieldsProps {
|
|||||||
// Model Selector
|
// Model Selector
|
||||||
shouldShowModelSelector: boolean;
|
shouldShowModelSelector: boolean;
|
||||||
claudeModel: string;
|
claudeModel: string;
|
||||||
|
reasoningModel: string;
|
||||||
defaultHaikuModel: string;
|
defaultHaikuModel: string;
|
||||||
defaultSonnetModel: string;
|
defaultSonnetModel: string;
|
||||||
defaultOpusModel: string;
|
defaultOpusModel: string;
|
||||||
onModelChange: (
|
onModelChange: (
|
||||||
field:
|
field:
|
||||||
| "ANTHROPIC_MODEL"
|
| "ANTHROPIC_MODEL"
|
||||||
|
| "ANTHROPIC_REASONING_MODEL"
|
||||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||||
@@ -53,6 +56,11 @@ interface ClaudeFormFieldsProps {
|
|||||||
|
|
||||||
// Speed Test Endpoints
|
// Speed Test Endpoints
|
||||||
speedTestEndpoints: EndpointCandidate[];
|
speedTestEndpoints: EndpointCandidate[];
|
||||||
|
|
||||||
|
// OpenRouter Compat
|
||||||
|
showOpenRouterCompatToggle: boolean;
|
||||||
|
openRouterCompatEnabled: boolean;
|
||||||
|
onOpenRouterCompatChange: (enabled: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeFormFields({
|
export function ClaudeFormFields({
|
||||||
@@ -77,11 +85,15 @@ export function ClaudeFormFields({
|
|||||||
onCustomEndpointsChange,
|
onCustomEndpointsChange,
|
||||||
shouldShowModelSelector,
|
shouldShowModelSelector,
|
||||||
claudeModel,
|
claudeModel,
|
||||||
|
reasoningModel,
|
||||||
defaultHaikuModel,
|
defaultHaikuModel,
|
||||||
defaultSonnetModel,
|
defaultSonnetModel,
|
||||||
defaultOpusModel,
|
defaultOpusModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
|
showOpenRouterCompatToggle,
|
||||||
|
openRouterCompatEnabled,
|
||||||
|
onOpenRouterCompatChange,
|
||||||
}: ClaudeFormFieldsProps) {
|
}: ClaudeFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -162,6 +174,28 @@ export function ClaudeFormFields({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showOpenRouterCompatToggle && (
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FormLabel>
|
||||||
|
{t("providerForm.openrouterCompatMode", {
|
||||||
|
defaultValue: "OpenRouter 兼容模式",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.openrouterCompatModeHint", {
|
||||||
|
defaultValue:
|
||||||
|
"使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={openRouterCompatEnabled}
|
||||||
|
onCheckedChange={onOpenRouterCompatChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 模型选择器 */}
|
{/* 模型选择器 */}
|
||||||
{shouldShowModelSelector && (
|
{shouldShowModelSelector && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -185,6 +219,27 @@ export function ClaudeFormFields({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 推理模型 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="reasoningModel">
|
||||||
|
{t("providerForm.anthropicReasoningModel", {
|
||||||
|
defaultValue: "推理模型 (Thinking)",
|
||||||
|
})}
|
||||||
|
</FormLabel>
|
||||||
|
<Input
|
||||||
|
id="reasoningModel"
|
||||||
|
type="text"
|
||||||
|
value={reasoningModel}
|
||||||
|
onChange={(e) =>
|
||||||
|
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={t("providerForm.reasoningModelPlaceholder", {
|
||||||
|
defaultValue: "",
|
||||||
|
})}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 默认 Haiku */}
|
{/* 默认 Haiku */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
<FormLabel htmlFor="claudeDefaultHaikuModel">
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ export function ProviderForm({
|
|||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const settingsConfigValue = form.watch("settingsConfig");
|
||||||
|
|
||||||
// 使用 API Key hook
|
// 使用 API Key hook
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -187,9 +189,10 @@ export function ProviderForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型)
|
// 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型)
|
||||||
const {
|
const {
|
||||||
claudeModel,
|
claudeModel,
|
||||||
|
reasoningModel,
|
||||||
defaultHaikuModel,
|
defaultHaikuModel,
|
||||||
defaultSonnetModel,
|
defaultSonnetModel,
|
||||||
defaultOpusModel,
|
defaultOpusModel,
|
||||||
@@ -199,6 +202,53 @@ export function ProviderForm({
|
|||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isOpenRouterProvider = useMemo(() => {
|
||||||
|
if (appId !== "claude") return false;
|
||||||
|
const normalized = baseUrl.trim().toLowerCase();
|
||||||
|
if (normalized.includes("openrouter.ai")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(settingsConfigValue || "{}");
|
||||||
|
const envUrl = config?.env?.ANTHROPIC_BASE_URL;
|
||||||
|
return typeof envUrl === "string" && envUrl.includes("openrouter.ai");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [appId, baseUrl, settingsConfigValue]);
|
||||||
|
|
||||||
|
const openRouterCompatEnabled = useMemo(() => {
|
||||||
|
if (!isOpenRouterProvider) return false;
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(settingsConfigValue || "{}");
|
||||||
|
const raw = config?.openrouter_compat_mode;
|
||||||
|
if (typeof raw === "boolean") return raw;
|
||||||
|
if (typeof raw === "number") return raw !== 0;
|
||||||
|
if (typeof raw === "string") {
|
||||||
|
const normalized = raw.trim().toLowerCase();
|
||||||
|
return normalized === "true" || normalized === "1";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [isOpenRouterProvider, settingsConfigValue]);
|
||||||
|
|
||||||
|
const handleOpenRouterCompatChange = useCallback(
|
||||||
|
(enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const currentConfig = JSON.parse(
|
||||||
|
form.getValues("settingsConfig") || "{}",
|
||||||
|
);
|
||||||
|
currentConfig.openrouter_compat_mode = enabled;
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(currentConfig, null, 2));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
// 使用 Codex 配置 hook (仅 Codex 模式)
|
// 使用 Codex 配置 hook (仅 Codex 模式)
|
||||||
const {
|
const {
|
||||||
codexAuth,
|
codexAuth,
|
||||||
@@ -789,11 +839,15 @@ export function ProviderForm({
|
|||||||
}
|
}
|
||||||
shouldShowModelSelector={category !== "official"}
|
shouldShowModelSelector={category !== "official"}
|
||||||
claudeModel={claudeModel}
|
claudeModel={claudeModel}
|
||||||
|
reasoningModel={reasoningModel}
|
||||||
defaultHaikuModel={defaultHaikuModel}
|
defaultHaikuModel={defaultHaikuModel}
|
||||||
defaultSonnetModel={defaultSonnetModel}
|
defaultSonnetModel={defaultSonnetModel}
|
||||||
defaultOpusModel={defaultOpusModel}
|
defaultOpusModel={defaultOpusModel}
|
||||||
onModelChange={handleModelChange}
|
onModelChange={handleModelChange}
|
||||||
speedTestEndpoints={speedTestEndpoints}
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
|
showOpenRouterCompatToggle={isOpenRouterProvider}
|
||||||
|
openRouterCompatEnabled={openRouterCompatEnabled}
|
||||||
|
onOpenRouterCompatChange={handleOpenRouterCompatChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -149,10 +149,7 @@ export function ProviderPresetSelector({
|
|||||||
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
|
className={`${getPresetButtonClass(isSelected, entry.preset)} relative`}
|
||||||
style={getPresetButtonStyle(isSelected, entry.preset)}
|
style={getPresetButtonStyle(isSelected, entry.preset)}
|
||||||
title={
|
title={
|
||||||
presetCategoryLabels[category] ??
|
presetCategoryLabels[category] ?? t("providerPreset.other")
|
||||||
t("providerPreset.categoryOther", {
|
|
||||||
defaultValue: "其他",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{renderPresetIcon(entry.preset)}
|
{renderPresetIcon(entry.preset)}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ interface UseModelStateProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 管理模型选择状态
|
* 管理模型选择状态
|
||||||
* 支持 ANTHROPIC_MODEL 和 ANTHROPIC_SMALL_FAST_MODEL
|
* 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型
|
||||||
*/
|
*/
|
||||||
export function useModelState({
|
export function useModelState({
|
||||||
settingsConfig,
|
settingsConfig,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
}: UseModelStateProps) {
|
}: UseModelStateProps) {
|
||||||
const [claudeModel, setClaudeModel] = useState("");
|
const [claudeModel, setClaudeModel] = useState("");
|
||||||
|
const [reasoningModel, setReasoningModel] = useState("");
|
||||||
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
|
const [defaultHaikuModel, setDefaultHaikuModel] = useState("");
|
||||||
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
|
const [defaultSonnetModel, setDefaultSonnetModel] = useState("");
|
||||||
const [defaultOpusModel, setDefaultOpusModel] = useState("");
|
const [defaultOpusModel, setDefaultOpusModel] = useState("");
|
||||||
@@ -29,6 +30,10 @@ export function useModelState({
|
|||||||
const env = cfg?.env || {};
|
const env = cfg?.env || {};
|
||||||
const model =
|
const model =
|
||||||
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
|
typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : "";
|
||||||
|
const reasoning =
|
||||||
|
typeof env.ANTHROPIC_REASONING_MODEL === "string"
|
||||||
|
? env.ANTHROPIC_REASONING_MODEL
|
||||||
|
: "";
|
||||||
const small =
|
const small =
|
||||||
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string"
|
||||||
? env.ANTHROPIC_SMALL_FAST_MODEL
|
? env.ANTHROPIC_SMALL_FAST_MODEL
|
||||||
@@ -47,6 +52,7 @@ export function useModelState({
|
|||||||
: model || small;
|
: model || small;
|
||||||
|
|
||||||
setClaudeModel(model || "");
|
setClaudeModel(model || "");
|
||||||
|
setReasoningModel(reasoning || "");
|
||||||
setDefaultHaikuModel(haiku || "");
|
setDefaultHaikuModel(haiku || "");
|
||||||
setDefaultSonnetModel(sonnet || "");
|
setDefaultSonnetModel(sonnet || "");
|
||||||
setDefaultOpusModel(opus || "");
|
setDefaultOpusModel(opus || "");
|
||||||
@@ -59,12 +65,14 @@ export function useModelState({
|
|||||||
(
|
(
|
||||||
field:
|
field:
|
||||||
| "ANTHROPIC_MODEL"
|
| "ANTHROPIC_MODEL"
|
||||||
|
| "ANTHROPIC_REASONING_MODEL"
|
||||||
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
| "ANTHROPIC_DEFAULT_HAIKU_MODEL"
|
||||||
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
| "ANTHROPIC_DEFAULT_SONNET_MODEL"
|
||||||
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
| "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||||
value: string,
|
value: string,
|
||||||
) => {
|
) => {
|
||||||
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
|
if (field === "ANTHROPIC_MODEL") setClaudeModel(value);
|
||||||
|
if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value);
|
||||||
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL")
|
||||||
setDefaultHaikuModel(value);
|
setDefaultHaikuModel(value);
|
||||||
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
|
if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL")
|
||||||
@@ -98,6 +106,8 @@ export function useModelState({
|
|||||||
return {
|
return {
|
||||||
claudeModel,
|
claudeModel,
|
||||||
setClaudeModel,
|
setClaudeModel,
|
||||||
|
reasoningModel,
|
||||||
|
setReasoningModel,
|
||||||
defaultHaikuModel,
|
defaultHaikuModel,
|
||||||
setDefaultHaikuModel,
|
setDefaultHaikuModel,
|
||||||
defaultSonnetModel,
|
defaultSonnetModel,
|
||||||
|
|||||||
@@ -6,51 +6,67 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Save, Loader2, Info } from "lucide-react";
|
import { Save, Loader2, Info } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy";
|
||||||
useCircuitBreakerConfig,
|
|
||||||
useUpdateCircuitBreakerConfig,
|
|
||||||
} from "@/lib/query/failover";
|
|
||||||
|
|
||||||
export interface AutoFailoverConfigPanelProps {
|
export interface AutoFailoverConfigPanelProps {
|
||||||
enabled: boolean;
|
appType: string;
|
||||||
onEnabledChange: (enabled: boolean) => void;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutoFailoverConfigPanel({
|
export function AutoFailoverConfigPanel({
|
||||||
enabled,
|
appType,
|
||||||
onEnabledChange: _onEnabledChange,
|
disabled = false,
|
||||||
}: AutoFailoverConfigPanelProps) {
|
}: AutoFailoverConfigPanelProps) {
|
||||||
// Note: onEnabledChange is currently unused but kept in the interface
|
|
||||||
// for potential future use by parent components
|
|
||||||
void _onEnabledChange;
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { data: config, isLoading, error } = useCircuitBreakerConfig();
|
const { data: config, isLoading, error } = useAppProxyConfig(appType);
|
||||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
const updateConfig = useUpdateAppProxyConfig();
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
failureThreshold: 5,
|
autoFailoverEnabled: false,
|
||||||
successThreshold: 2,
|
maxRetries: 3,
|
||||||
timeoutSeconds: 60,
|
streamingFirstByteTimeout: 30,
|
||||||
errorRateThreshold: 0.5,
|
streamingIdleTimeout: 60,
|
||||||
minRequests: 10,
|
nonStreamingTimeout: 300,
|
||||||
|
circuitFailureThreshold: 5,
|
||||||
|
circuitSuccessThreshold: 2,
|
||||||
|
circuitTimeoutSeconds: 60,
|
||||||
|
circuitErrorRateThreshold: 0.5,
|
||||||
|
circuitMinRequests: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...config,
|
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||||
|
maxRetries: config.maxRetries,
|
||||||
|
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
||||||
|
streamingIdleTimeout: config.streamingIdleTimeout,
|
||||||
|
nonStreamingTimeout: config.nonStreamingTimeout,
|
||||||
|
circuitFailureThreshold: config.circuitFailureThreshold,
|
||||||
|
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
||||||
|
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
||||||
|
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
||||||
|
circuitMinRequests: config.circuitMinRequests,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (!config) return;
|
||||||
try {
|
try {
|
||||||
await updateConfig.mutateAsync({
|
await updateConfig.mutateAsync({
|
||||||
failureThreshold: formData.failureThreshold,
|
appType,
|
||||||
successThreshold: formData.successThreshold,
|
enabled: config.enabled,
|
||||||
timeoutSeconds: formData.timeoutSeconds,
|
autoFailoverEnabled: formData.autoFailoverEnabled,
|
||||||
errorRateThreshold: formData.errorRateThreshold,
|
maxRetries: formData.maxRetries,
|
||||||
minRequests: formData.minRequests,
|
streamingFirstByteTimeout: formData.streamingFirstByteTimeout,
|
||||||
|
streamingIdleTimeout: formData.streamingIdleTimeout,
|
||||||
|
nonStreamingTimeout: formData.nonStreamingTimeout,
|
||||||
|
circuitFailureThreshold: formData.circuitFailureThreshold,
|
||||||
|
circuitSuccessThreshold: formData.circuitSuccessThreshold,
|
||||||
|
circuitTimeoutSeconds: formData.circuitTimeoutSeconds,
|
||||||
|
circuitErrorRateThreshold: formData.circuitErrorRateThreshold,
|
||||||
|
circuitMinRequests: formData.circuitMinRequests,
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
||||||
@@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (config) {
|
if (config) {
|
||||||
setFormData({
|
setFormData({
|
||||||
...config,
|
autoFailoverEnabled: config.autoFailoverEnabled,
|
||||||
|
maxRetries: config.maxRetries,
|
||||||
|
streamingFirstByteTimeout: config.streamingFirstByteTimeout,
|
||||||
|
streamingIdleTimeout: config.streamingIdleTimeout,
|
||||||
|
nonStreamingTimeout: config.nonStreamingTimeout,
|
||||||
|
circuitFailureThreshold: config.circuitFailureThreshold,
|
||||||
|
circuitSuccessThreshold: config.circuitSuccessThreshold,
|
||||||
|
circuitTimeoutSeconds: config.circuitTimeoutSeconds,
|
||||||
|
circuitErrorRateThreshold: config.circuitErrorRateThreshold,
|
||||||
|
circuitMinRequests: config.circuitMinRequests,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -79,16 +104,10 @@ export function AutoFailoverConfigPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDisabled = disabled || updateConfig.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-0 rounded-none shadow-none bg-transparent">
|
<div className="border-0 rounded-none shadow-none bg-transparent">
|
||||||
{/* Header Switch moved to parent accordion logic or kept here absolutely positioned if styling permits.
|
|
||||||
Since we need it in the accordion header, and this component is inside the content, we can use a portal or
|
|
||||||
absolute positioning trick similar to ProxyPanel, OR cleaner, just duplicate the switch logic in SettingsPage
|
|
||||||
and pass it down. But for now, let's use the absolute positioning trick to "lift" it visually.
|
|
||||||
Better yet, let's just render the content directly without the wrapping Card header/collapse logic
|
|
||||||
since the user requested "click to expand is detailed info, no need to fold again" (implying the accordion handles folding).
|
|
||||||
*/}
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
@@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="failureThreshold">
|
<Label htmlFor={`maxRetries-${appType}`}>
|
||||||
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
|
{t("proxy.autoFailover.maxRetries", "最大重试次数")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="failureThreshold"
|
id={`maxRetries-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="0"
|
||||||
max="20"
|
max="10"
|
||||||
value={formData.failureThreshold}
|
value={formData.maxRetries}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
failureThreshold: parseInt(e.target.value) || 5,
|
maxRetries: parseInt(e.target.value) || 3,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.maxRetriesHint",
|
||||||
|
"请求失败时的重试次数(0-10)",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`failureThreshold-${appType}`}>
|
||||||
|
{t("proxy.autoFailover.failureThreshold", "失败阈值")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`failureThreshold-${appType}`}
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value={formData.circuitFailureThreshold}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
circuitFailureThreshold: parseInt(e.target.value) || 5,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 超时配置 */}
|
||||||
|
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.autoFailover.timeoutSettings", "超时配置")}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="timeoutSeconds">
|
<Label htmlFor={`streamingFirstByte-${appType}`}>
|
||||||
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
|
{t(
|
||||||
|
"proxy.autoFailover.streamingFirstByte",
|
||||||
|
"流式首字节超时(秒)",
|
||||||
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="timeoutSeconds"
|
id={`streamingFirstByte-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="10"
|
min="0"
|
||||||
max="300"
|
max="180"
|
||||||
value={formData.timeoutSeconds}
|
value={formData.streamingFirstByteTimeout}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
timeoutSeconds: parseInt(e.target.value) || 60,
|
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.autoFailover.timeoutHint",
|
"proxy.autoFailover.streamingFirstByteHint",
|
||||||
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
|
"等待首个数据块的最大时间",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`streamingIdle-${appType}`}>
|
||||||
|
{t("proxy.autoFailover.streamingIdle", "流式静默超时(秒)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`streamingIdle-${appType}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="600"
|
||||||
|
value={formData.streamingIdleTimeout}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
streamingIdleTimeout: parseInt(e.target.value) || 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.streamingIdleHint",
|
||||||
|
"数据块之间的最大间隔",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`nonStreaming-${appType}`}>
|
||||||
|
{t("proxy.autoFailover.nonStreaming", "非流式超时(秒)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`nonStreaming-${appType}`}
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="1800"
|
||||||
|
value={formData.nonStreamingTimeout}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
nonStreamingTimeout: parseInt(e.target.value) || 300,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.nonStreamingHint",
|
||||||
|
"非流式请求的总超时时间",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 熔断器高级配置 */}
|
{/* 熔断器配置 */}
|
||||||
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
||||||
<h4 className="text-sm font-semibold">
|
<h4 className="text-sm font-semibold">
|
||||||
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")}
|
{t("proxy.autoFailover.circuitBreakerSettings", "熔断器配置")}
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="successThreshold">
|
<Label htmlFor={`successThreshold-${appType}`}>
|
||||||
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
|
{t("proxy.autoFailover.successThreshold", "恢复成功阈值")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="successThreshold"
|
id={`successThreshold-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
value={formData.successThreshold}
|
value={formData.circuitSuccessThreshold}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
successThreshold: parseInt(e.target.value) || 2,
|
circuitSuccessThreshold: parseInt(e.target.value) || 2,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -201,23 +310,50 @@ export function AutoFailoverConfigPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="errorRateThreshold">
|
<Label htmlFor={`timeoutSeconds-${appType}`}>
|
||||||
|
{t("proxy.autoFailover.timeout", "恢复等待时间(秒)")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={`timeoutSeconds-${appType}`}
|
||||||
|
type="number"
|
||||||
|
min="10"
|
||||||
|
max="300"
|
||||||
|
value={formData.circuitTimeoutSeconds}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isDisabled}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"proxy.autoFailover.timeoutHint",
|
||||||
|
"熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor={`errorRateThreshold-${appType}`}>
|
||||||
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
|
{t("proxy.autoFailover.errorRate", "错误率阈值 (%)")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="errorRateThreshold"
|
id={`errorRateThreshold-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
max="100"
|
max="100"
|
||||||
step="5"
|
step="5"
|
||||||
value={Math.round(formData.errorRateThreshold * 100)}
|
value={Math.round(formData.circuitErrorRateThreshold * 100)}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
errorRateThreshold: (parseInt(e.target.value) || 50) / 100,
|
circuitErrorRateThreshold:
|
||||||
|
(parseInt(e.target.value) || 50) / 100,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="minRequests">
|
<Label htmlFor={`minRequests-${appType}`}>
|
||||||
{t("proxy.autoFailover.minRequests", "最小请求数")}
|
{t("proxy.autoFailover.minRequests", "最小请求数")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="minRequests"
|
id={`minRequests-${appType}`}
|
||||||
type="number"
|
type="number"
|
||||||
min="5"
|
min="5"
|
||||||
max="100"
|
max="100"
|
||||||
value={formData.minRequests}
|
value={formData.circuitMinRequests}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({
|
setFormData({
|
||||||
...formData,
|
...formData,
|
||||||
minRequests: parseInt(e.target.value) || 10,
|
circuitMinRequests: parseInt(e.target.value) || 10,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
disabled={!enabled}
|
disabled={isDisabled}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -257,17 +393,10 @@ export function AutoFailoverConfigPanel({
|
|||||||
|
|
||||||
{/* 操作按钮 */}
|
{/* 操作按钮 */}
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
<Button
|
<Button variant="outline" onClick={handleReset} disabled={isDisabled}>
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
disabled={updateConfig.isPending || !enabled}
|
|
||||||
>
|
|
||||||
{t("common.reset", "重置")}
|
{t("common.reset", "重置")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSave} disabled={isDisabled}>
|
||||||
onClick={handleSave}
|
|
||||||
disabled={updateConfig.isPending || !enabled}
|
|
||||||
>
|
|
||||||
{updateConfig.isPending ? (
|
{updateConfig.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
@@ -281,59 +410,6 @@ export function AutoFailoverConfigPanel({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 说明信息 */}
|
|
||||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2 text-sm">
|
|
||||||
<h4 className="font-medium">
|
|
||||||
{t("proxy.autoFailover.explanationTitle", "工作原理")}
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1 text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
•{" "}
|
|
||||||
<strong>
|
|
||||||
{t("proxy.autoFailover.failureThresholdLabel", "失败阈值")}
|
|
||||||
</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.failureThresholdExplain",
|
|
||||||
"连续失败达到此次数时,熔断器打开,该供应商暂时不可用",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
•{" "}
|
|
||||||
<strong>
|
|
||||||
{t("proxy.autoFailover.timeoutLabel", "恢复等待时间")}
|
|
||||||
</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.timeoutExplain",
|
|
||||||
"熔断器打开后,等待此时间后尝试半开状态",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
•{" "}
|
|
||||||
<strong>
|
|
||||||
{t("proxy.autoFailover.successThresholdLabel", "恢复成功阈值")}
|
|
||||||
</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.successThresholdExplain",
|
|
||||||
"半开状态下,成功达到此次数时关闭熔断器,供应商恢复可用",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
•{" "}
|
|
||||||
<strong>
|
|
||||||
{t("proxy.autoFailover.errorRateLabel", "错误率阈值")}
|
|
||||||
</strong>
|
|
||||||
:
|
|
||||||
{t(
|
|
||||||
"proxy.autoFailover.errorRateExplain",
|
|
||||||
"错误率超过此值时,即使未达到失败阈值也会打开熔断器",
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,37 +2,14 @@
|
|||||||
* 故障转移队列管理组件
|
* 故障转移队列管理组件
|
||||||
*
|
*
|
||||||
* 允许用户管理代理模式下的故障转移队列,支持:
|
* 允许用户管理代理模式下的故障转移队列,支持:
|
||||||
* - 拖拽排序
|
|
||||||
* - 添加/移除供应商
|
* - 添加/移除供应商
|
||||||
* - 启用/禁用队列项
|
* - 队列顺序基于首页供应商列表的 sort_index
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import { DndContext, closestCenter } from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import {
|
|
||||||
KeyboardSensor,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
type DragEndEvent,
|
|
||||||
} from "@dnd-kit/core";
|
|
||||||
import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import { Plus, Trash2, Loader2, Info, AlertTriangle } from "lucide-react";
|
||||||
GripVertical,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Loader2,
|
|
||||||
Info,
|
|
||||||
AlertTriangle,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
@@ -51,8 +28,8 @@ import {
|
|||||||
useAvailableProvidersForFailover,
|
useAvailableProvidersForFailover,
|
||||||
useAddToFailoverQueue,
|
useAddToFailoverQueue,
|
||||||
useRemoveFromFailoverQueue,
|
useRemoveFromFailoverQueue,
|
||||||
useReorderFailoverQueue,
|
useAutoFailoverEnabled,
|
||||||
useSetFailoverItemEnabled,
|
useSetAutoFailoverEnabled,
|
||||||
} from "@/lib/query/failover";
|
} from "@/lib/query/failover";
|
||||||
|
|
||||||
interface FailoverQueueManagerProps {
|
interface FailoverQueueManagerProps {
|
||||||
@@ -67,6 +44,10 @@ export function FailoverQueueManager({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
const [selectedProviderId, setSelectedProviderId] = useState<string>("");
|
||||||
|
|
||||||
|
// 故障转移开关状态(每个应用独立)
|
||||||
|
const { data: isFailoverEnabled = false } = useAutoFailoverEnabled(appType);
|
||||||
|
const setFailoverEnabled = useSetAutoFailoverEnabled();
|
||||||
|
|
||||||
// 查询数据
|
// 查询数据
|
||||||
const {
|
const {
|
||||||
data: queue,
|
data: queue,
|
||||||
@@ -79,59 +60,11 @@ export function FailoverQueueManager({
|
|||||||
// Mutations
|
// Mutations
|
||||||
const addToQueue = useAddToFailoverQueue();
|
const addToQueue = useAddToFailoverQueue();
|
||||||
const removeFromQueue = useRemoveFromFailoverQueue();
|
const removeFromQueue = useRemoveFromFailoverQueue();
|
||||||
const reorderQueue = useReorderFailoverQueue();
|
|
||||||
const setItemEnabled = useSetFailoverItemEnabled();
|
|
||||||
|
|
||||||
// 拖拽配置
|
// 切换故障转移开关
|
||||||
const sensors = useSensors(
|
const handleToggleFailover = (enabled: boolean) => {
|
||||||
useSensor(PointerSensor, {
|
setFailoverEnabled.mutate({ appType, enabled });
|
||||||
activationConstraint: { distance: 8 },
|
};
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 排序后的队列
|
|
||||||
const sortedQueue = useMemo(() => {
|
|
||||||
if (!queue) return [];
|
|
||||||
return [...queue].sort((a, b) => a.queueOrder - b.queueOrder);
|
|
||||||
}, [queue]);
|
|
||||||
|
|
||||||
// 处理拖拽结束
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
async (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id || !sortedQueue) return;
|
|
||||||
|
|
||||||
const oldIndex = sortedQueue.findIndex(
|
|
||||||
(item) => item.providerId === active.id,
|
|
||||||
);
|
|
||||||
const newIndex = sortedQueue.findIndex(
|
|
||||||
(item) => item.providerId === over.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
|
||||||
|
|
||||||
const reordered = arrayMove(sortedQueue, oldIndex, newIndex);
|
|
||||||
const providerIds = reordered.map((item) => item.providerId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await reorderQueue.mutateAsync({ appType, providerIds });
|
|
||||||
toast.success(
|
|
||||||
t("proxy.failoverQueue.reorderSuccess", "队列顺序已更新"),
|
|
||||||
{ closeButton: true },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.failoverQueue.reorderFailed", "更新顺序失败") +
|
|
||||||
": " +
|
|
||||||
String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sortedQueue, appType, reorderQueue, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 添加供应商到队列
|
// 添加供应商到队列
|
||||||
const handleAddProvider = async () => {
|
const handleAddProvider = async () => {
|
||||||
@@ -171,19 +104,6 @@ export function FailoverQueueManager({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换启用状态
|
|
||||||
const handleToggleEnabled = async (providerId: string, enabled: boolean) => {
|
|
||||||
try {
|
|
||||||
await setItemEnabled.mutateAsync({ appType, providerId, enabled });
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(
|
|
||||||
t("proxy.failoverQueue.toggleFailed", "状态更新失败") +
|
|
||||||
": " +
|
|
||||||
String(error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isQueueLoading) {
|
if (isQueueLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
@@ -203,13 +123,41 @@ export function FailoverQueueManager({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* 自动故障转移开关 */}
|
||||||
|
<div className="flex items-center justify-between p-4 rounded-lg bg-muted/50 border border-border/50">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("proxy.failover.autoSwitch", {
|
||||||
|
defaultValue: "自动故障转移",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{isFailoverEnabled && (
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full bg-emerald-500/20 text-emerald-600 dark:text-emerald-400">
|
||||||
|
{t("common.enabled", { defaultValue: "已开启" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failover.autoSwitchDescription", {
|
||||||
|
defaultValue: "开启后,请求失败时自动切换到队列中的下一个供应商",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={isFailoverEnabled}
|
||||||
|
onCheckedChange={handleToggleFailover}
|
||||||
|
disabled={disabled || setFailoverEnabled.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 说明信息 */}
|
{/* 说明信息 */}
|
||||||
<Alert className="border-blue-500/40 bg-blue-500/10">
|
<Alert className="border-blue-500/40 bg-blue-500/10">
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
<AlertDescription className="text-sm">
|
<AlertDescription className="text-sm">
|
||||||
{t(
|
{t(
|
||||||
"proxy.failoverQueue.info",
|
"proxy.failoverQueue.info",
|
||||||
"当前激活的供应商始终优先。当请求失败时,系统会按队列顺序依次尝试其他供应商。",
|
"队列顺序与首页供应商列表顺序一致。当请求失败时,系统会按顺序依次尝试队列中的供应商。",
|
||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -260,7 +208,7 @@ export function FailoverQueueManager({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 队列列表 */}
|
{/* 队列列表 */}
|
||||||
{sortedQueue.length === 0 ? (
|
{!queue || queue.length === 0 ? (
|
||||||
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
<div className="rounded-lg border border-dashed border-muted-foreground/40 p-8 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -270,39 +218,26 @@ export function FailoverQueueManager({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
<div className="space-y-2">
|
||||||
sensors={sensors}
|
{queue.map((item, index) => (
|
||||||
collisionDetection={closestCenter}
|
<QueueItem
|
||||||
onDragEnd={handleDragEnd}
|
key={item.providerId}
|
||||||
>
|
item={item}
|
||||||
<SortableContext
|
index={index}
|
||||||
items={sortedQueue.map((item) => item.providerId)}
|
disabled={disabled}
|
||||||
strategy={verticalListSortingStrategy}
|
onRemove={handleRemoveProvider}
|
||||||
>
|
isRemoving={removeFromQueue.isPending}
|
||||||
<div className="space-y-2">
|
/>
|
||||||
{sortedQueue.map((item, index) => (
|
))}
|
||||||
<SortableQueueItem
|
</div>
|
||||||
key={item.providerId}
|
|
||||||
item={item}
|
|
||||||
index={index}
|
|
||||||
disabled={disabled}
|
|
||||||
onToggleEnabled={handleToggleEnabled}
|
|
||||||
onRemove={handleRemoveProvider}
|
|
||||||
isRemoving={removeFromQueue.isPending}
|
|
||||||
isToggling={setItemEnabled.isPending}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 队列说明 */}
|
{/* 队列说明 */}
|
||||||
{sortedQueue.length > 0 && (
|
{queue && queue.length > 0 && (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
"proxy.failoverQueue.dragHint",
|
"proxy.failoverQueue.orderHint",
|
||||||
"拖拽供应商可调整故障转移顺序,序号越小优先级越高。",
|
"队列顺序与首页供应商列表顺序一致,可在首页拖拽调整顺序。",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -310,65 +245,29 @@ export function FailoverQueueManager({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableQueueItemProps {
|
interface QueueItemProps {
|
||||||
item: FailoverQueueItem;
|
item: FailoverQueueItem;
|
||||||
index: number;
|
index: number;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
|
||||||
onRemove: (providerId: string) => void;
|
onRemove: (providerId: string) => void;
|
||||||
isRemoving: boolean;
|
isRemoving: boolean;
|
||||||
isToggling: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableQueueItem({
|
function QueueItem({
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
disabled,
|
disabled,
|
||||||
onToggleEnabled,
|
|
||||||
onRemove,
|
onRemove,
|
||||||
isRemoving,
|
isRemoving,
|
||||||
isToggling,
|
}: QueueItemProps) {
|
||||||
}: SortableQueueItemProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const {
|
|
||||||
setNodeRef,
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id: item.providerId, disabled });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
|
"flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors",
|
||||||
isDragging && "opacity-50 shadow-lg",
|
|
||||||
!item.enabled && "opacity-60",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 拖拽手柄 */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"cursor-grab touch-none text-muted-foreground hover:text-foreground",
|
|
||||||
disabled && "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-label={t("provider.dragHandle", "拖拽排序")}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* 序号 */}
|
{/* 序号 */}
|
||||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-muted text-xs font-medium">
|
||||||
{index + 1}
|
{index + 1}
|
||||||
@@ -376,24 +275,11 @@ function SortableQueueItem({
|
|||||||
|
|
||||||
{/* 供应商名称 */}
|
{/* 供应商名称 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<span
|
<span className="text-sm font-medium truncate block">
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium truncate block",
|
|
||||||
!item.enabled && "text-muted-foreground line-through",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.providerName}
|
{item.providerName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 启用开关 */}
|
|
||||||
<Switch
|
|
||||||
checked={item.enabled}
|
|
||||||
onCheckedChange={(checked) => onToggleEnabled(item.providerId, checked)}
|
|
||||||
disabled={disabled || isToggling}
|
|
||||||
aria-label={t("proxy.failoverQueue.toggleEnabled", "启用/禁用")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 删除按钮 */}
|
{/* 删除按钮 */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Clock,
|
Clock,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Server,
|
Server,
|
||||||
ListOrdered,
|
ListOrdered,
|
||||||
Settings,
|
Save,
|
||||||
|
Loader2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||||
import { ProxySettingsDialog } from "./ProxySettingsDialog";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useFailoverQueue } from "@/lib/query/failover";
|
import { useFailoverQueue } from "@/lib/query/failover";
|
||||||
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
|
||||||
import { useProviderHealth } from "@/lib/query/failover";
|
import { useProviderHealth } from "@/lib/query/failover";
|
||||||
|
import {
|
||||||
|
useProxyTakeoverStatus,
|
||||||
|
useSetProxyTakeoverForApp,
|
||||||
|
useGlobalProxyConfig,
|
||||||
|
useUpdateGlobalProxyConfig,
|
||||||
|
} from "@/lib/query/proxy";
|
||||||
import type { ProxyStatus } from "@/types/proxy";
|
import type { ProxyStatus } from "@/types/proxy";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export function ProxyPanel() {
|
export function ProxyPanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { status, isRunning } = useProxyStatus();
|
const { status, isRunning } = useProxyStatus();
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
|
// 获取应用接管状态
|
||||||
|
const { data: takeoverStatus } = useProxyTakeoverStatus();
|
||||||
|
const setTakeoverForApp = useSetProxyTakeoverForApp();
|
||||||
|
|
||||||
|
// 获取全局代理配置
|
||||||
|
const { data: globalConfig } = useGlobalProxyConfig();
|
||||||
|
const updateGlobalConfig = useUpdateGlobalProxyConfig();
|
||||||
|
|
||||||
|
// 监听地址/端口的本地状态
|
||||||
|
const [listenAddress, setListenAddress] = useState("127.0.0.1");
|
||||||
|
const [listenPort, setListenPort] = useState(5000);
|
||||||
|
|
||||||
|
// 同步全局配置到本地状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (globalConfig) {
|
||||||
|
setListenAddress(globalConfig.listenAddress);
|
||||||
|
setListenPort(globalConfig.listenPort);
|
||||||
|
}
|
||||||
|
}, [globalConfig]);
|
||||||
|
|
||||||
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
|
// 获取所有三个应用类型的故障转移队列(不包含当前供应商)
|
||||||
// 当前供应商始终优先,队列仅用于失败后的备用顺序
|
// 当前供应商始终优先,队列仅用于失败后的备用顺序
|
||||||
@@ -26,6 +56,69 @@ export function ProxyPanel() {
|
|||||||
const { data: codexQueue = [] } = useFailoverQueue("codex");
|
const { data: codexQueue = [] } = useFailoverQueue("codex");
|
||||||
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
|
const { data: geminiQueue = [] } = useFailoverQueue("gemini");
|
||||||
|
|
||||||
|
const handleTakeoverChange = async (appType: string, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
await setTakeoverForApp.mutateAsync({ appType, enabled });
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? t("proxy.takeover.enabled", {
|
||||||
|
app: appType,
|
||||||
|
defaultValue: `${appType} 接管已启用`,
|
||||||
|
})
|
||||||
|
: t("proxy.takeover.disabled", {
|
||||||
|
app: appType,
|
||||||
|
defaultValue: `${appType} 接管已关闭`,
|
||||||
|
}),
|
||||||
|
{ closeButton: true },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.takeover.failed", {
|
||||||
|
defaultValue: "切换接管状态失败",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoggingChange = async (enabled: boolean) => {
|
||||||
|
if (!globalConfig) return;
|
||||||
|
try {
|
||||||
|
await updateGlobalConfig.mutateAsync({
|
||||||
|
...globalConfig,
|
||||||
|
enableLogging: enabled,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
enabled
|
||||||
|
? t("proxy.logging.enabled", { defaultValue: "日志记录已启用" })
|
||||||
|
: t("proxy.logging.disabled", { defaultValue: "日志记录已关闭" }),
|
||||||
|
{ closeButton: true },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.logging.failed", { defaultValue: "切换日志状态失败" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveBasicConfig = async () => {
|
||||||
|
if (!globalConfig) return;
|
||||||
|
try {
|
||||||
|
await updateGlobalConfig.mutateAsync({
|
||||||
|
...globalConfig,
|
||||||
|
listenAddress,
|
||||||
|
listenPort,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }),
|
||||||
|
{ closeButton: true },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(
|
||||||
|
t("proxy.settings.configSaveFailed", { defaultValue: "保存配置失败" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatUptime = (seconds: number): string => {
|
const formatUptime = (seconds: number): string => {
|
||||||
const hours = Math.floor(seconds / 3600);
|
const hours = Math.floor(seconds / 3600);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
@@ -47,18 +140,11 @@ export function ProxyPanel() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
<p className="text-xs text-muted-foreground">服务地址</p>
|
{t("proxy.panel.serviceAddress", {
|
||||||
<Button
|
defaultValue: "服务地址",
|
||||||
size="sm"
|
})}
|
||||||
variant="ghost"
|
</p>
|
||||||
onClick={() => setShowSettings(true)}
|
|
||||||
className="h-7 gap-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<Settings className="h-3.5 w-3.5" />
|
|
||||||
配置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
|
<code className="flex-1 text-sm bg-background px-3 py-2 rounded border border-border/60">
|
||||||
http://{status.address}:{status.port}
|
http://{status.address}:{status.port}
|
||||||
@@ -70,16 +156,28 @@ export function ProxyPanel() {
|
|||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
`http://${status.address}:${status.port}`,
|
`http://${status.address}:${status.port}`,
|
||||||
);
|
);
|
||||||
toast.success("地址已复制", { closeButton: true });
|
toast.success(
|
||||||
|
t("proxy.panel.addressCopied", {
|
||||||
|
defaultValue: "地址已复制",
|
||||||
|
}),
|
||||||
|
{ closeButton: true },
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
复制
|
{t("common.copy")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
{t("proxy.settings.restartRequired", {
|
||||||
|
defaultValue: "修改监听地址/端口需要先停止代理服务",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-border space-y-2">
|
<div className="pt-3 border-t border-border space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">使用中</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("provider.inUse")}
|
||||||
|
</p>
|
||||||
{status.active_targets && status.active_targets.length > 0 ? (
|
{status.active_targets && status.active_targets.length > 0 ? (
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{status.active_targets.map((target) => (
|
{status.active_targets.map((target) => (
|
||||||
@@ -101,18 +199,79 @@ export function ProxyPanel() {
|
|||||||
</div>
|
</div>
|
||||||
) : status.current_provider ? (
|
) : status.current_provider ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
当前 Provider:{" "}
|
{t("proxy.panel.currentProvider", {
|
||||||
|
defaultValue: "当前 Provider:",
|
||||||
|
})}{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
{status.current_provider}
|
{status.current_provider}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
当前 Provider:等待首次请求…
|
{t("proxy.panel.waitingFirstRequest", {
|
||||||
|
defaultValue: "当前 Provider:等待首次请求…",
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 应用接管开关 */}
|
||||||
|
<div className="pt-3 border-t border-border space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxyConfig.appTakeover", {
|
||||||
|
defaultValue: "应用接管",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{(["claude", "codex", "gemini"] as const).map((appType) => {
|
||||||
|
const isEnabled =
|
||||||
|
takeoverStatus?.[
|
||||||
|
appType as keyof typeof takeoverStatus
|
||||||
|
] ?? false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={appType}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium capitalize">
|
||||||
|
{appType}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleTakeoverChange(appType, checked)
|
||||||
|
}
|
||||||
|
disabled={setTakeoverForApp.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日志记录开关 */}
|
||||||
|
<div className="pt-3 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between rounded-md border border-border bg-background/60 px-3 py-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label className="text-sm font-medium">
|
||||||
|
{t("proxy.settings.fields.enableLogging.label", {
|
||||||
|
defaultValue: "启用日志记录",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.settings.fields.enableLogging.description", {
|
||||||
|
defaultValue: "记录所有代理请求,便于排查问题",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={globalConfig?.enableLogging ?? true}
|
||||||
|
onCheckedChange={handleLoggingChange}
|
||||||
|
disabled={updateGlobalConfig.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 供应商队列 - 按应用类型分组展示 */}
|
{/* 供应商队列 - 按应用类型分组展示 */}
|
||||||
{(claudeQueue.length > 0 ||
|
{(claudeQueue.length > 0 ||
|
||||||
codexQueue.length > 0 ||
|
codexQueue.length > 0 ||
|
||||||
@@ -121,7 +280,7 @@ export function ProxyPanel() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
|
<ListOrdered className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
故障转移队列
|
{t("proxy.failoverQueue.title")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -130,13 +289,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="claude"
|
appType="claude"
|
||||||
appLabel="Claude"
|
appLabel="Claude"
|
||||||
targets={claudeQueue
|
targets={claudeQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -146,13 +302,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="codex"
|
appType="codex"
|
||||||
appLabel="Codex"
|
appLabel="Codex"
|
||||||
targets={codexQueue
|
targets={codexQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -162,13 +315,10 @@ export function ProxyPanel() {
|
|||||||
<ProviderQueueGroup
|
<ProviderQueueGroup
|
||||||
appType="gemini"
|
appType="gemini"
|
||||||
appLabel="Gemini"
|
appLabel="Gemini"
|
||||||
targets={geminiQueue
|
targets={geminiQueue.map((item) => ({
|
||||||
.filter((item) => item.enabled)
|
id: item.providerId,
|
||||||
.sort((a, b) => a.queueOrder - b.queueOrder)
|
name: item.providerName,
|
||||||
.map((item) => ({
|
}))}
|
||||||
id: item.providerId,
|
|
||||||
name: item.providerName,
|
|
||||||
}))}
|
|
||||||
status={status}
|
status={status}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -179,52 +329,139 @@ export function ProxyPanel() {
|
|||||||
<div className="grid gap-3 md:grid-cols-4">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Activity className="h-4 w-4" />}
|
icon={<Activity className="h-4 w-4" />}
|
||||||
label="活跃连接"
|
label={t("proxy.panel.stats.activeConnections", {
|
||||||
|
defaultValue: "活跃连接",
|
||||||
|
})}
|
||||||
value={status.active_connections}
|
value={status.active_connections}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<TrendingUp className="h-4 w-4" />}
|
icon={<TrendingUp className="h-4 w-4" />}
|
||||||
label="总请求数"
|
label={t("proxy.panel.stats.totalRequests", {
|
||||||
|
defaultValue: "总请求数",
|
||||||
|
})}
|
||||||
value={status.total_requests}
|
value={status.total_requests}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Clock className="h-4 w-4" />}
|
icon={<Clock className="h-4 w-4" />}
|
||||||
label="成功率"
|
label={t("proxy.panel.stats.successRate", {
|
||||||
|
defaultValue: "成功率",
|
||||||
|
})}
|
||||||
value={`${status.success_rate.toFixed(1)}%`}
|
value={`${status.success_rate.toFixed(1)}%`}
|
||||||
variant={status.success_rate > 90 ? "success" : "warning"}
|
variant={status.success_rate > 90 ? "success" : "warning"}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Clock className="h-4 w-4" />}
|
icon={<Clock className="h-4 w-4" />}
|
||||||
label="运行时间"
|
label={t("proxy.panel.stats.uptime", {
|
||||||
|
defaultValue: "运行时间",
|
||||||
|
})}
|
||||||
value={formatUptime(status.uptime_seconds)}
|
value={formatUptime(status.uptime_seconds)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-10 text-muted-foreground">
|
<div className="space-y-6">
|
||||||
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
{/* 空白区域避免冲突 */}
|
||||||
<Server className="h-8 w-8" />
|
<div className="h-4"></div>
|
||||||
|
|
||||||
|
{/* 基础设置 - 监听地址/端口 */}
|
||||||
|
<div className="rounded-lg border border-border bg-muted/40 p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.settings.basic.title", {
|
||||||
|
defaultValue: "基础设置",
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.settings.basic.description", {
|
||||||
|
defaultValue: "配置代理服务监听的地址与端口。",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listen-address">
|
||||||
|
{t("proxy.settings.fields.listenAddress.label", {
|
||||||
|
defaultValue: "监听地址",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="listen-address"
|
||||||
|
value={listenAddress}
|
||||||
|
onChange={(e) => setListenAddress(e.target.value)}
|
||||||
|
placeholder="127.0.0.1"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.settings.fields.listenAddress.description", {
|
||||||
|
defaultValue:
|
||||||
|
"代理服务器监听的 IP 地址(推荐 127.0.0.1)",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="listen-port">
|
||||||
|
{t("proxy.settings.fields.listenPort.label", {
|
||||||
|
defaultValue: "监听端口",
|
||||||
|
})}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="listen-port"
|
||||||
|
type="number"
|
||||||
|
value={listenPort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setListenPort(parseInt(e.target.value) || 5000)
|
||||||
|
}
|
||||||
|
placeholder="5000"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.settings.fields.listenPort.description", {
|
||||||
|
defaultValue: "代理服务器监听的端口号(1024 ~ 65535)",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveBasicConfig}
|
||||||
|
disabled={updateGlobalConfig.isPending}
|
||||||
|
>
|
||||||
|
{updateGlobalConfig.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("common.saving", { defaultValue: "保存中..." })}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.save", { defaultValue: "保存" })}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 代理服务已停止提示 */}
|
||||||
|
<div className="text-center py-6 text-muted-foreground">
|
||||||
|
<div className="mx-auto w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||||
|
<Server className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<p className="text-base font-medium text-foreground mb-1">
|
||||||
|
{t("proxy.panel.stoppedTitle", {
|
||||||
|
defaultValue: "代理服务已停止",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("proxy.panel.stoppedDescription", {
|
||||||
|
defaultValue: "使用右上角开关即可启动服务",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-base font-medium text-foreground mb-1">
|
|
||||||
代理服务已停止
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
使用右上角开关即可启动服务
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowSettings(true)}
|
|
||||||
className="gap-1.5"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
配置代理服务
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<ProxySettingsDialog open={showSettings} onOpenChange={setShowSettings} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -319,6 +556,7 @@ function ProviderQueueItem({
|
|||||||
appType,
|
appType,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
}: ProviderQueueItemProps) {
|
}: ProviderQueueItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: health } = useProviderHealth(provider.id, appType);
|
const { data: health } = useProviderHealth(provider.id, appType);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -344,7 +582,7 @@ function ProviderQueueItem({
|
|||||||
</span>
|
</span>
|
||||||
{isCurrent && (
|
{isCurrent && (
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
|
<span className="text-xs px-1.5 py-0.5 rounded bg-primary/20 text-primary">
|
||||||
使用中
|
{t("provider.inUse")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,397 +0,0 @@
|
|||||||
/**
|
|
||||||
* 代理服务设置对话框
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormDescription,
|
|
||||||
FormMessage,
|
|
||||||
} from "@/components/ui/form";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { useProxyConfig } from "@/hooks/useProxyConfig";
|
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { AlertCircle } from "lucide-react";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { TFunction } from "i18next";
|
|
||||||
import type { ProxyConfig } from "@/types/proxy";
|
|
||||||
|
|
||||||
// 表单数据类型(不包含 enabled 字段,该字段由后端自动管理)
|
|
||||||
type ProxyConfigForm = Omit<ProxyConfig, "enabled">;
|
|
||||||
|
|
||||||
const createProxyConfigSchema = (t: TFunction) => {
|
|
||||||
const requestTimeoutSchema = z
|
|
||||||
.number()
|
|
||||||
.min(
|
|
||||||
0,
|
|
||||||
t("proxy.settings.validation.timeoutNonNegative", {
|
|
||||||
defaultValue: "超时时间不能为负数",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.max(
|
|
||||||
600,
|
|
||||||
t("proxy.settings.validation.timeoutMax", {
|
|
||||||
defaultValue: "超时时间最多600秒",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.refine((value) => value === 0 || value >= 10, {
|
|
||||||
message: t("proxy.settings.validation.timeoutRange", {
|
|
||||||
defaultValue: "请输入 0 或 10-600 之间的数值",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return z.object({
|
|
||||||
listen_address: z.string().regex(
|
|
||||||
/^(\d{1,3}\.){3}\d{1,3}$/,
|
|
||||||
t("proxy.settings.validation.addressInvalid", {
|
|
||||||
defaultValue: "请输入有效的IP地址",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
listen_port: z
|
|
||||||
.number()
|
|
||||||
.min(
|
|
||||||
1024,
|
|
||||||
t("proxy.settings.validation.portMin", {
|
|
||||||
defaultValue: "端口必须大于1024",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.max(
|
|
||||||
65535,
|
|
||||||
t("proxy.settings.validation.portMax", {
|
|
||||||
defaultValue: "端口必须小于65535",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
max_retries: z
|
|
||||||
.number()
|
|
||||||
.min(
|
|
||||||
0,
|
|
||||||
t("proxy.settings.validation.retryMin", {
|
|
||||||
defaultValue: "重试次数不能为负",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.max(
|
|
||||||
10,
|
|
||||||
t("proxy.settings.validation.retryMax", {
|
|
||||||
defaultValue: "重试次数不能超过10",
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
request_timeout: requestTimeoutSchema,
|
|
||||||
enable_logging: z.boolean(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProxySettingsDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ProxySettingsDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
}: ProxySettingsDialogProps) {
|
|
||||||
const { config, isLoading, updateConfig, isUpdating } = useProxyConfig();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const schema = useMemo(() => createProxyConfigSchema(t), [t]);
|
|
||||||
|
|
||||||
const closePanel = () => onOpenChange(false);
|
|
||||||
|
|
||||||
const form = useForm<ProxyConfigForm>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: {
|
|
||||||
listen_address: "127.0.0.1",
|
|
||||||
listen_port: 5000,
|
|
||||||
max_retries: 3,
|
|
||||||
request_timeout: 300,
|
|
||||||
enable_logging: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 当配置加载完成后更新表单
|
|
||||||
useEffect(() => {
|
|
||||||
if (config) {
|
|
||||||
form.reset({
|
|
||||||
...config,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [config, form]);
|
|
||||||
|
|
||||||
const onSubmit = async (data: ProxyConfigForm) => {
|
|
||||||
try {
|
|
||||||
// 添加 enabled 字段(从当前配置中获取,保持不变)
|
|
||||||
const configToSave: ProxyConfig = {
|
|
||||||
...data,
|
|
||||||
enabled: config?.enabled ?? true,
|
|
||||||
};
|
|
||||||
await updateConfig(configToSave);
|
|
||||||
closePanel();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Save config failed:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formId = "proxy-settings-form";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={open}
|
|
||||||
title={t("proxy.settings.title", { defaultValue: "代理服务设置" })}
|
|
||||||
onClose={closePanel}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={closePanel}
|
|
||||||
disabled={isUpdating}
|
|
||||||
>
|
|
||||||
{t("common.cancel", { defaultValue: "取消" })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form={formId}
|
|
||||||
disabled={isUpdating || isLoading}
|
|
||||||
>
|
|
||||||
{isUpdating
|
|
||||||
? t("common.saving", { defaultValue: "保存中..." })
|
|
||||||
: t("proxy.settings.actions.save", { defaultValue: "保存配置" })}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("proxy.settings.description", {
|
|
||||||
defaultValue:
|
|
||||||
"配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<Alert className="border-emerald-500/40 bg-emerald-500/10">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription className="text-sm">
|
|
||||||
{t("proxy.settings.alert.autoApply", {
|
|
||||||
defaultValue:
|
|
||||||
"保存后将自动同步到正在运行的代理服务,无需手动重启。",
|
|
||||||
})}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id={formId}
|
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
|
||||||
className="space-y-6"
|
|
||||||
>
|
|
||||||
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{t("proxy.settings.basic.title", {
|
|
||||||
defaultValue: "基础设置",
|
|
||||||
})}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("proxy.settings.basic.description", {
|
|
||||||
defaultValue: "配置代理服务监听的地址与端口。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="listen_address"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxy.settings.fields.listenAddress.label", {
|
|
||||||
defaultValue: "监听地址",
|
|
||||||
})}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder={t(
|
|
||||||
"proxy.settings.fields.listenAddress.placeholder",
|
|
||||||
{ defaultValue: "127.0.0.1" },
|
|
||||||
)}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("proxy.settings.fields.listenAddress.description", {
|
|
||||||
defaultValue:
|
|
||||||
"代理服务器监听的 IP 地址(推荐 127.0.0.1)",
|
|
||||||
})}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="listen_port"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxy.settings.fields.listenPort.label", {
|
|
||||||
defaultValue: "监听端口",
|
|
||||||
})}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"proxy.settings.fields.listenPort.placeholder",
|
|
||||||
{ defaultValue: "5000" },
|
|
||||||
)}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("proxy.settings.fields.listenPort.description", {
|
|
||||||
defaultValue:
|
|
||||||
"代理服务器监听的端口号(1024 ~ 65535)",
|
|
||||||
})}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-semibold text-foreground">
|
|
||||||
{t("proxy.settings.advanced.title", {
|
|
||||||
defaultValue: "高级参数",
|
|
||||||
})}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("proxy.settings.advanced.description", {
|
|
||||||
defaultValue: "控制请求的稳定性和日志记录。",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="max_retries"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxy.settings.fields.maxRetries.label", {
|
|
||||||
defaultValue: "最大重试次数",
|
|
||||||
})}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"proxy.settings.fields.maxRetries.placeholder",
|
|
||||||
{ defaultValue: "3" },
|
|
||||||
)}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("proxy.settings.fields.maxRetries.description", {
|
|
||||||
defaultValue: "请求失败时的重试次数(0 ~ 10)",
|
|
||||||
})}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="request_timeout"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxy.settings.fields.requestTimeout.label", {
|
|
||||||
defaultValue: "请求超时(秒)",
|
|
||||||
})}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
inputMode="numeric"
|
|
||||||
{...field}
|
|
||||||
onChange={(e) =>
|
|
||||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
|
||||||
}
|
|
||||||
placeholder={t(
|
|
||||||
"proxy.settings.fields.requestTimeout.placeholder",
|
|
||||||
{ defaultValue: "0(不限)或 300" },
|
|
||||||
)}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
{t("proxy.settings.fields.requestTimeout.description", {
|
|
||||||
defaultValue:
|
|
||||||
"单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)",
|
|
||||||
})}
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="enable_logging"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border border-white/10 bg-background/60 p-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<FormLabel>
|
|
||||||
{t("proxy.settings.fields.enableLogging.label", {
|
|
||||||
defaultValue: "启用日志记录",
|
|
||||||
})}
|
|
||||||
</FormLabel>
|
|
||||||
<FormDescription>
|
|
||||||
{t("proxy.settings.fields.enableLogging.description", {
|
|
||||||
defaultValue: "记录所有代理请求,便于排查问题",
|
|
||||||
})}
|
|
||||||
</FormDescription>
|
|
||||||
</div>
|
|
||||||
<FormControl>
|
|
||||||
<Switch
|
|
||||||
checked={field.value}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -49,43 +49,39 @@ export function ProxyToggle({ className, activeApp }: ProxyToggleProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn("p-1 rounded-xl transition-all", className)}
|
||||||
"flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all cursor-default",
|
|
||||||
takeoverEnabled
|
|
||||||
? "bg-emerald-500/10 border border-emerald-500/30"
|
|
||||||
: "bg-muted/50 hover:bg-muted",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
title={tooltipText}
|
title={tooltipText}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
<div className="flex items-center gap-2 px-2 h-8 rounded-md cursor-default">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
{isPending ? (
|
||||||
) : (
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
<Radio
|
) : (
|
||||||
|
<Radio
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 transition-colors",
|
||||||
|
takeoverEnabled
|
||||||
|
? "text-emerald-500 animate-pulse"
|
||||||
|
: "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-4 w-4 transition-colors",
|
"text-sm font-medium transition-colors select-none",
|
||||||
takeoverEnabled
|
takeoverEnabled
|
||||||
? "text-emerald-500 animate-pulse"
|
? "text-emerald-600 dark:text-emerald-400"
|
||||||
: "text-muted-foreground",
|
: "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
Proxy
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={takeoverEnabled}
|
||||||
|
onCheckedChange={handleToggle}
|
||||||
|
disabled={isPending}
|
||||||
|
className="ml-1"
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium transition-colors select-none",
|
|
||||||
takeoverEnabled
|
|
||||||
? "text-emerald-600 dark:text-emerald-400"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Proxy
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
checked={takeoverEnabled}
|
|
||||||
onCheckedChange={handleToggle}
|
|
||||||
disabled={isPending}
|
|
||||||
className="ml-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,3 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { ProxyPanel } from "./ProxyPanel";
|
export { ProxyPanel } from "./ProxyPanel";
|
||||||
export { ProxySettingsDialog } from "./ProxySettingsDialog";
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
|
Copy,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -17,6 +19,7 @@ import { settingsApi } from "@/lib/api";
|
|||||||
import { useUpdate } from "@/contexts/UpdateContext";
|
import { useUpdate } from "@/contexts/UpdateContext";
|
||||||
import { relaunchApp } from "@/lib/updater";
|
import { relaunchApp } from "@/lib/updater";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
interface AboutSectionProps {
|
interface AboutSectionProps {
|
||||||
isPortable: boolean;
|
isPortable: boolean;
|
||||||
@@ -29,6 +32,10 @@ interface ToolVersion {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ONE_CLICK_INSTALL_COMMANDS = `npm i -g @anthropic-ai/claude-code@latest
|
||||||
|
npm i -g @openai/codex@latest
|
||||||
|
npm i -g @google/gemini-cli@latest`;
|
||||||
|
|
||||||
export function AboutSection({ isPortable }: AboutSectionProps) {
|
export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||||
// ... (use hooks as before) ...
|
// ... (use hooks as before) ...
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -47,6 +54,18 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
isChecking,
|
isChecking,
|
||||||
} = useUpdate();
|
} = useUpdate();
|
||||||
|
|
||||||
|
const loadToolVersions = useCallback(async () => {
|
||||||
|
setIsLoadingTools(true);
|
||||||
|
try {
|
||||||
|
const tools = await settingsApi.getToolVersions();
|
||||||
|
setToolVersions(tools);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AboutSection] Failed to load tool versions", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingTools(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
@@ -150,10 +169,25 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
}
|
}
|
||||||
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
|
}, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]);
|
||||||
|
|
||||||
|
const handleCopyInstallCommands = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(ONE_CLICK_INSTALL_COMMANDS);
|
||||||
|
toast.success(t("settings.installCommandsCopied"), { closeButton: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[AboutSection] Failed to copy install commands", error);
|
||||||
|
toast.error(t("settings.installCommandsCopyFailed"));
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
const displayVersion = version ?? t("common.unknown");
|
const displayVersion = version ?? t("common.unknown");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-6">
|
<motion.section
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h3 className="text-sm font-medium">{t("common.about")}</h3>
|
<h3 className="text-sm font-medium">{t("common.about")}</h3>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -161,12 +195,22 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="rounded-xl border border-border bg-card/50 p-6 space-y-6">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.1 }}
|
||||||
|
className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-6 space-y-5 shadow-sm"
|
||||||
|
>
|
||||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h4 className="text-lg font-semibold text-foreground">CC Switch</h4>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="outline" className="gap-1.5 bg-background">
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
<h4 className="text-lg font-semibold text-foreground">
|
||||||
|
CC Switch
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="gap-1.5 bg-background/80">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("common.version")}
|
{t("common.version")}
|
||||||
</span>
|
</span>
|
||||||
@@ -185,15 +229,15 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleOpenReleaseNotes}
|
onClick={handleOpenReleaseNotes}
|
||||||
className="h-9"
|
className="h-8 gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
{t("settings.releaseNotes")}
|
{t("settings.releaseNotes")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -201,34 +245,41 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCheckUpdate}
|
onClick={handleCheckUpdate}
|
||||||
disabled={isChecking || isDownloading}
|
disabled={isChecking || isDownloading}
|
||||||
className="min-w-[140px] h-9"
|
className="h-8 gap-1.5 text-xs"
|
||||||
>
|
>
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
{t("settings.updating")}
|
{t("settings.updating")}
|
||||||
</span>
|
</>
|
||||||
) : hasUpdate ? (
|
) : hasUpdate ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-3.5 w-3.5" />
|
||||||
{t("settings.updateTo", {
|
{t("settings.updateTo", {
|
||||||
version: updateInfo?.availableVersion ?? "",
|
version: updateInfo?.availableVersion ?? "",
|
||||||
})}
|
})}
|
||||||
</span>
|
</>
|
||||||
) : isChecking ? (
|
) : isChecking ? (
|
||||||
<span className="inline-flex items-center gap-2">
|
<>
|
||||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
|
||||||
{t("settings.checking")}
|
{t("settings.checking")}
|
||||||
</span>
|
</>
|
||||||
) : (
|
) : (
|
||||||
t("settings.checkForUpdates")
|
<>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
{t("settings.checkForUpdates")}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasUpdate && updateInfo && (
|
{hasUpdate && updateInfo && (
|
||||||
<div className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: "auto" }}
|
||||||
|
className="rounded-lg bg-primary/10 border border-primary/20 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
<p className="font-medium text-primary mb-1">
|
<p className="font-medium text-primary mb-1">
|
||||||
{t("settings.updateAvailable", {
|
{t("settings.updateAvailable", {
|
||||||
version: updateInfo.availableVersion,
|
version: updateInfo.availableVersion,
|
||||||
@@ -239,60 +290,111 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
|||||||
{updateInfo.notes}
|
{updateInfo.notes}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground px-1">
|
<div className="flex items-center justify-between px-1">
|
||||||
本地环境检查
|
<h3 className="text-sm font-medium">{t("settings.localEnvCheck")}</h3>
|
||||||
</h4>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
onClick={loadToolVersions}
|
||||||
|
disabled={isLoadingTools}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
className={
|
||||||
|
isLoadingTools ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
{isLoadingTools
|
{["claude", "codex", "gemini"].map((toolName, index) => {
|
||||||
? Array.from({ length: 3 }).map((_, i) => (
|
const tool = toolVersions.find((item) => item.name === toolName);
|
||||||
<div
|
const displayName = tool?.name ?? toolName;
|
||||||
key={i}
|
const title = tool?.version || tool?.error || t("common.unknown");
|
||||||
className="h-20 rounded-xl border border-border bg-card/50 animate-pulse"
|
|
||||||
/>
|
return (
|
||||||
))
|
<motion.div
|
||||||
: toolVersions.map((tool) => (
|
key={toolName}
|
||||||
<div
|
initial={{ opacity: 0, y: 10 }}
|
||||||
key={tool.name}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="flex flex-col gap-2 rounded-xl border border-border bg-card/50 p-4 transition-colors hover:bg-muted/50"
|
transition={{ duration: 0.3, delay: 0.15 + index * 0.05 }}
|
||||||
>
|
whileHover={{ scale: 1.02 }}
|
||||||
<div className="flex items-center justify-between">
|
className="flex flex-col gap-2 rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 shadow-sm transition-colors hover:border-primary/30"
|
||||||
<div className="flex items-center gap-2">
|
>
|
||||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium capitalize">
|
<div className="flex items-center gap-2">
|
||||||
{tool.name}
|
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||||
</span>
|
<span className="text-sm font-medium capitalize">
|
||||||
</div>
|
{displayName}
|
||||||
{tool.version ? (
|
</span>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{tool.latest_version &&
|
|
||||||
tool.version !== tool.latest_version && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
|
|
||||||
Update: {tool.latest_version}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
{isLoadingTools ? (
|
||||||
<div
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
className="text-xs font-mono truncate"
|
) : tool?.version ? (
|
||||||
title={tool.version || tool.error || "Unknown"}
|
<div className="flex items-center gap-1.5">
|
||||||
>
|
{tool.latest_version &&
|
||||||
{tool.version ? tool.version : tool.error || "未安装"}
|
tool.version !== tool.latest_version && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border border-yellow-500/20">
|
||||||
|
{tool.latest_version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div
|
||||||
|
className="text-xs font-mono text-muted-foreground truncate"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{isLoadingTools
|
||||||
|
? t("common.loading")
|
||||||
|
: tool?.version
|
||||||
|
? tool.version
|
||||||
|
: tool?.error || t("common.notInstalled")}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3, delay: 0.3 }}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-medium px-1">
|
||||||
|
{t("settings.oneClickInstall")}
|
||||||
|
</h3>
|
||||||
|
<div className="rounded-xl border border-border bg-gradient-to-br from-card/80 to-card/40 p-4 space-y-3 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("settings.oneClickInstallHint")}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCopyInstallCommands}
|
||||||
|
className="h-7 gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
{t("common.copy")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="text-xs font-mono bg-background/80 px-3 py-2.5 rounded-lg border border-border/60 overflow-x-auto">
|
||||||
|
{ONE_CLICK_INSTALL_COMMANDS}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function ImportExportSection({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClear}
|
onClick={onClear}
|
||||||
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
|
className="absolute -top-2 -right-2 h-6 w-6 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center shadow-lg transition-colors z-10"
|
||||||
aria-label="Clear selection"
|
aria-label={t("common.clear")}
|
||||||
>
|
>
|
||||||
<XCircle className="h-4 w-4" />
|
<XCircle className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Save,
|
Save,
|
||||||
@@ -181,7 +182,6 @@ export function SettingsPage({
|
|||||||
stopWithRestore,
|
stopWithRestore,
|
||||||
isPending: isProxyPending,
|
isPending: isProxyPending,
|
||||||
} = useProxyStatus();
|
} = useProxyStatus();
|
||||||
const [failoverEnabled, setFailoverEnabled] = useState(true);
|
|
||||||
|
|
||||||
const handleToggleProxy = async (checked: boolean) => {
|
const handleToggleProxy = async (checked: boolean) => {
|
||||||
try {
|
try {
|
||||||
@@ -214,16 +214,19 @@ export function SettingsPage({
|
|||||||
<TabsTrigger value="advanced">
|
<TabsTrigger value="advanced">
|
||||||
{t("settings.tabAdvanced")}
|
{t("settings.tabAdvanced")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="usage">
|
<TabsTrigger value="usage">{t("usage.title")}</TabsTrigger>
|
||||||
{t("usage.title", "使用统计")}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
<TabsTrigger value="about">{t("common.about")}</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-2">
|
||||||
<TabsContent value="general" className="space-y-6 mt-0">
|
<TabsContent value="general" className="space-y-6 mt-0">
|
||||||
{settings ? (
|
{settings ? (
|
||||||
<>
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
<LanguageSettings
|
<LanguageSettings
|
||||||
value={settings.language}
|
value={settings.language}
|
||||||
onChange={(lang) => handleAutoSave({ language: lang })}
|
onChange={(lang) => handleAutoSave({ language: lang })}
|
||||||
@@ -233,13 +236,18 @@ export function SettingsPage({
|
|||||||
settings={settings}
|
settings={settings}
|
||||||
onChange={handleAutoSave}
|
onChange={handleAutoSave}
|
||||||
/>
|
/>
|
||||||
</>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
|
<TabsContent value="advanced" className="space-y-6 mt-0 pb-6">
|
||||||
{settings ? (
|
{settings ? (
|
||||||
<div className="space-y-4">
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
<Accordion
|
<Accordion
|
||||||
type="multiple"
|
type="multiple"
|
||||||
defaultValue={[]}
|
defaultValue={[]}
|
||||||
@@ -254,10 +262,10 @@ export function SettingsPage({
|
|||||||
<FolderSearch className="h-5 w-5 text-primary" />
|
<FolderSearch className="h-5 w-5 text-primary" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
配置文件目录
|
{t("settings.advanced.configDir.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
管理 Claude、Codex 和 Gemini 的配置存储路径
|
{t("settings.advanced.configDir.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,10 +297,10 @@ export function SettingsPage({
|
|||||||
<Server className="h-5 w-5 text-green-500" />
|
<Server className="h-5 w-5 text-green-500" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
本地代理
|
{t("settings.advanced.proxy.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
控制代理服务开关、查看状态与端口信息
|
{t("settings.advanced.proxy.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -307,7 +315,9 @@ export function SettingsPage({
|
|||||||
<Activity
|
<Activity
|
||||||
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
className={`h-3 w-3 ${isRunning ? "animate-pulse" : ""}`}
|
||||||
/>
|
/>
|
||||||
{isRunning ? "运行中" : "已停止"}
|
{isRunning
|
||||||
|
? t("settings.advanced.proxy.running")
|
||||||
|
: t("settings.advanced.proxy.stopped")}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Switch
|
<Switch
|
||||||
checked={isRunning}
|
checked={isRunning}
|
||||||
@@ -330,10 +340,10 @@ export function SettingsPage({
|
|||||||
<Activity className="h-5 w-5 text-indigo-500" />
|
<Activity className="h-5 w-5 text-indigo-500" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
模型测试配置
|
{t("settings.advanced.modelTest.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
配置模型测试使用的默认模型和提示词
|
{t("settings.advanced.modelTest.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -345,80 +355,118 @@ export function SettingsPage({
|
|||||||
|
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
value="failover"
|
value="failover"
|
||||||
className="rounded-xl glass-card overflow-hidden [&[data-state=open]>.accordion-header]:bg-muted/50"
|
className="rounded-xl glass-card overflow-hidden"
|
||||||
>
|
>
|
||||||
<AccordionPrimitive.Header className="accordion-header flex items-center justify-between px-6 py-4 hover:bg-muted/50">
|
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
|
||||||
<AccordionPrimitive.Trigger className="flex flex-1 items-center justify-between hover:no-underline [&[data-state=open]>svg]:rotate-180">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Activity className="h-5 w-5 text-orange-500" />
|
||||||
<Activity className="h-5 w-5 text-orange-500" />
|
<div className="text-left">
|
||||||
<div className="text-left">
|
<h3 className="text-base font-semibold">
|
||||||
<h3 className="text-base font-semibold">
|
{t("settings.advanced.failover.title")}
|
||||||
自动故障转移
|
</h3>
|
||||||
</h3>
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
{t("settings.advanced.failover.description")}
|
||||||
配置故障转移队列和熔断策略
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
|
|
||||||
</AccordionPrimitive.Trigger>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 pl-4">
|
|
||||||
<Switch
|
|
||||||
checked={failoverEnabled}
|
|
||||||
onCheckedChange={setFailoverEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionPrimitive.Header>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 故障转移队列管理 */}
|
{/* 代理未运行时的提示 */}
|
||||||
<div className="space-y-4">
|
{!isRunning && (
|
||||||
<div>
|
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
||||||
<h4 className="text-sm font-semibold">
|
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||||
{t("proxy.failoverQueue.title", "故障转移队列")}
|
{t("proxy.failover.proxyRequired", {
|
||||||
</h4>
|
defaultValue:
|
||||||
<p className="text-xs text-muted-foreground">
|
"需要先启动代理服务才能配置故障转移",
|
||||||
{t(
|
})}
|
||||||
"proxy.failoverQueue.description",
|
|
||||||
"管理各应用的供应商故障转移顺序",
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Tabs defaultValue="claude" className="w-full">
|
)}
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
|
||||||
<TabsTrigger value="claude">Claude</TabsTrigger>
|
{/* 故障转移设置 - 按应用分组 */}
|
||||||
<TabsTrigger value="codex">Codex</TabsTrigger>
|
<Tabs defaultValue="claude" className="w-full">
|
||||||
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
</TabsList>
|
<TabsTrigger value="claude">Claude</TabsTrigger>
|
||||||
<TabsContent value="claude" className="mt-4">
|
<TabsTrigger value="codex">Codex</TabsTrigger>
|
||||||
|
<TabsTrigger value="gemini">Gemini</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent
|
||||||
|
value="claude"
|
||||||
|
className="mt-4 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="claude"
|
appType="claude"
|
||||||
disabled={!failoverEnabled}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="codex" className="mt-4">
|
<div className="border-t border-border/50 pt-6">
|
||||||
|
<AutoFailoverConfigPanel
|
||||||
|
appType="claude"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="codex"
|
||||||
|
className="mt-4 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="codex"
|
appType="codex"
|
||||||
disabled={!failoverEnabled}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</div>
|
||||||
<TabsContent value="gemini" className="mt-4">
|
<div className="border-t border-border/50 pt-6">
|
||||||
|
<AutoFailoverConfigPanel
|
||||||
|
appType="codex"
|
||||||
|
disabled={!isRunning}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="gemini"
|
||||||
|
className="mt-4 space-y-6"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold">
|
||||||
|
{t("proxy.failoverQueue.title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("proxy.failoverQueue.description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<FailoverQueueManager
|
<FailoverQueueManager
|
||||||
appType="gemini"
|
appType="gemini"
|
||||||
disabled={!failoverEnabled}
|
disabled={!isRunning}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</div>
|
||||||
</Tabs>
|
<div className="border-t border-border/50 pt-6">
|
||||||
</div>
|
<AutoFailoverConfigPanel
|
||||||
|
appType="gemini"
|
||||||
{/* 熔断器配置 */}
|
disabled={!isRunning}
|
||||||
<div className="border-t border-border/50 pt-6">
|
/>
|
||||||
<AutoFailoverConfigPanel
|
</div>
|
||||||
enabled={failoverEnabled}
|
</TabsContent>
|
||||||
onEnabledChange={setFailoverEnabled}
|
</Tabs>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
@@ -432,10 +480,10 @@ export function SettingsPage({
|
|||||||
<Coins className="h-5 w-5 text-yellow-500" />
|
<Coins className="h-5 w-5 text-yellow-500" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
成本定价
|
{t("settings.advanced.pricing.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
管理各模型 Token 计费规则
|
{t("settings.advanced.pricing.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -454,10 +502,10 @@ export function SettingsPage({
|
|||||||
<Database className="h-5 w-5 text-blue-500" />
|
<Database className="h-5 w-5 text-blue-500" />
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
数据管理
|
{t("settings.advanced.data.title")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground font-normal">
|
<p className="text-sm text-muted-foreground font-normal">
|
||||||
导入导出配置与备份恢复
|
{t("settings.advanced.data.description")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,7 +545,7 @@ export function SettingsPage({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ export function WindowSettings({ settings, onChange }: WindowSettingsProps) {
|
|||||||
onChange({ enableClaudePluginIntegration: value })
|
onChange({ enableClaudePluginIntegration: value })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ToggleRow
|
||||||
|
icon={<MonitorUp className="h-4 w-4 text-cyan-500" />}
|
||||||
|
title={t("settings.skipClaudeOnboarding")}
|
||||||
|
description={t("settings.skipClaudeOnboardingDescription")}
|
||||||
|
checked={!!settings.skipClaudeOnboarding}
|
||||||
|
onCheckedChange={(value) => onChange({ skipClaudeOnboarding: value })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ const Switch = React.forwardRef<
|
|||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-300 dark:data-[state=unchecked]:bg-gray-700",
|
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-900",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<SwitchPrimitives.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
"pointer-events-none block h-5 w-5 rounded-full bg-white dark:bg-gray-400 shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitives.Root>
|
</SwitchPrimitives.Root>
|
||||||
|
|||||||
@@ -47,13 +47,11 @@ export function ModelTestConfigPanel() {
|
|||||||
try {
|
try {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
await saveStreamCheckConfig(config);
|
await saveStreamCheckConfig(config);
|
||||||
toast.success(t("streamCheck.configSaved", "健康检查配置已保存"), {
|
toast.success(t("streamCheck.configSaved"), {
|
||||||
closeButton: true,
|
closeButton: true,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(
|
toast.error(t("streamCheck.configSaveFailed") + ": " + String(e));
|
||||||
t("streamCheck.configSaveFailed", "保存失败") + ": " + String(e),
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
@@ -78,13 +76,11 @@ export function ModelTestConfigPanel() {
|
|||||||
{/* 测试模型配置 */}
|
{/* 测试模型配置 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
{t("streamCheck.testModels", "测试模型")}
|
{t("streamCheck.testModels")}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="claudeModel">
|
<Label htmlFor="claudeModel">{t("streamCheck.claudeModel")}</Label>
|
||||||
{t("streamCheck.claudeModel", "Claude 模型")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="claudeModel"
|
id="claudeModel"
|
||||||
value={config.claudeModel}
|
value={config.claudeModel}
|
||||||
@@ -96,9 +92,7 @@ export function ModelTestConfigPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="codexModel">
|
<Label htmlFor="codexModel">{t("streamCheck.codexModel")}</Label>
|
||||||
{t("streamCheck.codexModel", "Codex 模型")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="codexModel"
|
id="codexModel"
|
||||||
value={config.codexModel}
|
value={config.codexModel}
|
||||||
@@ -110,9 +104,7 @@ export function ModelTestConfigPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="geminiModel">
|
<Label htmlFor="geminiModel">{t("streamCheck.geminiModel")}</Label>
|
||||||
{t("streamCheck.geminiModel", "Gemini 模型")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="geminiModel"
|
id="geminiModel"
|
||||||
value={config.geminiModel}
|
value={config.geminiModel}
|
||||||
@@ -128,13 +120,11 @@ export function ModelTestConfigPanel() {
|
|||||||
{/* 检查参数配置 */}
|
{/* 检查参数配置 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
{t("streamCheck.checkParams", "检查参数")}
|
{t("streamCheck.checkParams")}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="timeoutSecs">
|
<Label htmlFor="timeoutSecs">{t("streamCheck.timeout")}</Label>
|
||||||
{t("streamCheck.timeout", "超时时间(秒)")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="timeoutSecs"
|
id="timeoutSecs"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -151,9 +141,7 @@ export function ModelTestConfigPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="maxRetries">
|
<Label htmlFor="maxRetries">{t("streamCheck.maxRetries")}</Label>
|
||||||
{t("streamCheck.maxRetries", "最大重试次数")}
|
|
||||||
</Label>
|
|
||||||
<Input
|
<Input
|
||||||
id="maxRetries"
|
id="maxRetries"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -171,7 +159,7 @@ export function ModelTestConfigPanel() {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="degradedThresholdMs">
|
<Label htmlFor="degradedThresholdMs">
|
||||||
{t("streamCheck.degradedThreshold", "降级阈值(毫秒)")}
|
{t("streamCheck.degradedThreshold")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="degradedThresholdMs"
|
id="degradedThresholdMs"
|
||||||
@@ -196,12 +184,12 @@ export function ModelTestConfigPanel() {
|
|||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
{t("common.saving", "保存中...")}
|
{t("common.saving")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Save className="mr-2 h-4 w-4" />
|
<Save className="mr-2 h-4 w-4" />
|
||||||
{t("common.save", "保存")}
|
{t("common.save")}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function PricingConfigPanel() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
{t("usage.modelPricing", "模型定价")}
|
{t("usage.modelPricing")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -85,7 +85,7 @@ export function PricingConfigPanel() {
|
|||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="text-base">
|
||||||
{t("usage.modelPricing", "模型定价")}
|
{t("usage.modelPricing")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -93,8 +93,7 @@ export function PricingConfigPanel() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{t("usage.loadPricingError", "加载定价数据失败")}:{" "}
|
{t("usage.loadPricingError")}: {String(error)}
|
||||||
{String(error)}
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -107,7 +106,7 @@ export function PricingConfigPanel() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h4 className="text-sm font-medium text-muted-foreground">
|
<h4 className="text-sm font-medium text-muted-foreground">
|
||||||
{t("usage.modelPricingDesc", "配置各模型的 Token 成本")} (每百万)
|
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||||
</h4>
|
</h4>
|
||||||
<Button
|
<Button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -117,41 +116,36 @@ export function PricingConfigPanel() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
{t("common.add", "新增")}
|
{t("common.add")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{!pricing || pricing.length === 0 ? (
|
{!pricing || pricing.length === 0 ? (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||||
{t(
|
|
||||||
"usage.noPricingData",
|
|
||||||
'暂无定价数据。点击"新增"添加模型定价配置。',
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md bg-card/60 shadow-sm">
|
<div className="rounded-md bg-card/60 shadow-sm">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("usage.model", "模型")}</TableHead>
|
<TableHead>{t("usage.model")}</TableHead>
|
||||||
<TableHead>{t("usage.displayName", "显示名称")}</TableHead>
|
<TableHead>{t("usage.displayName")}</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("usage.inputCost", "输入成本")}
|
{t("usage.inputCost")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("usage.outputCost", "输出成本")}
|
{t("usage.outputCost")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("usage.cacheReadCost", "缓存读取")}
|
{t("usage.cacheReadCost")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("usage.cacheWriteCost", "缓存写入")}
|
{t("usage.cacheWriteCost")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("common.actions", "操作")}
|
{t("common.actions")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -183,7 +177,7 @@ export function PricingConfigPanel() {
|
|||||||
setIsAddingNew(false);
|
setIsAddingNew(false);
|
||||||
setEditingModel(model);
|
setEditingModel(model);
|
||||||
}}
|
}}
|
||||||
title={t("common.edit", "编辑")}
|
title={t("common.edit")}
|
||||||
>
|
>
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -191,7 +185,7 @@ export function PricingConfigPanel() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setDeleteConfirm(model.modelId)}
|
onClick={() => setDeleteConfirm(model.modelId)}
|
||||||
title={t("common.delete", "删除")}
|
title={t("common.delete")}
|
||||||
className="text-destructive hover:text-destructive"
|
className="text-destructive hover:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
@@ -223,19 +217,14 @@ export function PricingConfigPanel() {
|
|||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>{t("usage.deleteConfirmTitle")}</DialogTitle>
|
||||||
{t("usage.deleteConfirmTitle", "确认删除")}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{t(
|
{t("usage.deleteConfirmDesc")}
|
||||||
"usage.deleteConfirmDesc",
|
|
||||||
"确定要删除此模型定价配置吗?此操作无法撤销。",
|
|
||||||
)}
|
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
{t("common.cancel", "取消")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -243,8 +232,8 @@ export function PricingConfigPanel() {
|
|||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
{deleteMutation.isPending
|
{deleteMutation.isPending
|
||||||
? t("common.deleting", "删除中...")
|
? t("common.deleting")
|
||||||
: t("common.delete", "删除")}
|
: t("common.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ export function PricingEditModal({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, modelId: e.target.value })
|
setFormData({ ...formData, modelId: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="例如: claude-3-5-sonnet-20241022"
|
placeholder={t("usage.modelIdPlaceholder", {
|
||||||
|
defaultValue: "例如: claude-3-5-sonnet-20241022",
|
||||||
|
})}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +124,9 @@ export function PricingEditModal({
|
|||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setFormData({ ...formData, displayName: e.target.value })
|
setFormData({ ...formData, displayName: e.target.value })
|
||||||
}
|
}
|
||||||
placeholder="例如: Claude 3.5 Sonnet"
|
placeholder={t("usage.displayNamePlaceholder", {
|
||||||
|
defaultValue: "例如: Claude 3.5 Sonnet",
|
||||||
|
})}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,8 +16,14 @@ export function RequestDetailPanel({
|
|||||||
requestId,
|
requestId,
|
||||||
onClose,
|
onClose,
|
||||||
}: RequestDetailPanelProps) {
|
}: RequestDetailPanelProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { data: request, isLoading } = useRequestDetail(requestId);
|
const { data: request, isLoading } = useRequestDetail(requestId);
|
||||||
|
const dateLocale =
|
||||||
|
i18n.language === "zh"
|
||||||
|
? "zh-CN"
|
||||||
|
: i18n.language === "ja"
|
||||||
|
? "ja-JP"
|
||||||
|
: "en-US";
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -69,7 +75,9 @@ export function RequestDetailPanel({
|
|||||||
{t("usage.time", "时间")}
|
{t("usage.time", "时间")}
|
||||||
</dt>
|
</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{new Date(request.createdAt * 1000).toLocaleString("zh-CN")}
|
{new Date(request.createdAt * 1000).toLocaleString(
|
||||||
|
dateLocale,
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type { LogFilters } from "@/types/usage";
|
|||||||
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, RefreshCw, Search, X } from "lucide-react";
|
||||||
|
|
||||||
export function RequestLogTable() {
|
export function RequestLogTable() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// 默认时间范围:过去24小时
|
// 默认时间范围:过去24小时
|
||||||
@@ -62,6 +62,13 @@ export function RequestLogTable() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dateLocale =
|
||||||
|
i18n.language === "zh"
|
||||||
|
? "zh-CN"
|
||||||
|
: i18n.language === "ja"
|
||||||
|
? "ja-JP"
|
||||||
|
: "en-US";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* 筛选栏 */}
|
{/* 筛选栏 */}
|
||||||
@@ -77,10 +84,10 @@ export function RequestLogTable() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[130px] bg-background">
|
<SelectTrigger className="w-[130px] bg-background">
|
||||||
<SelectValue placeholder={t("usage.endpoint", "端点")} />
|
<SelectValue placeholder={t("usage.appType")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t("common.all", "全部端点")}</SelectItem>
|
<SelectItem value="all">{t("usage.allApps")}</SelectItem>
|
||||||
<SelectItem value="claude">Claude</SelectItem>
|
<SelectItem value="claude">Claude</SelectItem>
|
||||||
<SelectItem value="codex">Codex</SelectItem>
|
<SelectItem value="codex">Codex</SelectItem>
|
||||||
<SelectItem value="gemini">Gemini</SelectItem>
|
<SelectItem value="gemini">Gemini</SelectItem>
|
||||||
@@ -97,10 +104,10 @@ export function RequestLogTable() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[130px] bg-background">
|
<SelectTrigger className="w-[130px] bg-background">
|
||||||
<SelectValue placeholder={t("usage.status", "状态码")} />
|
<SelectValue placeholder={t("usage.statusCode")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{t("common.all", "全部状态")}</SelectItem>
|
<SelectItem value="all">{t("common.all")}</SelectItem>
|
||||||
<SelectItem value="200">200 OK</SelectItem>
|
<SelectItem value="200">200 OK</SelectItem>
|
||||||
<SelectItem value="400">400 Bad Request</SelectItem>
|
<SelectItem value="400">400 Bad Request</SelectItem>
|
||||||
<SelectItem value="401">401 Unauthorized</SelectItem>
|
<SelectItem value="401">401 Unauthorized</SelectItem>
|
||||||
@@ -113,7 +120,7 @@ export function RequestLogTable() {
|
|||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("usage.provider", "搜索供应商...")}
|
placeholder={t("usage.searchProviderPlaceholder")}
|
||||||
className="pl-9 bg-background"
|
className="pl-9 bg-background"
|
||||||
value={tempFilters.providerName || ""}
|
value={tempFilters.providerName || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -125,7 +132,7 @@ export function RequestLogTable() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("usage.model", "搜索模型...")}
|
placeholder={t("usage.searchModelPlaceholder")}
|
||||||
className="w-[180px] bg-background"
|
className="w-[180px] bg-background"
|
||||||
value={tempFilters.model || ""}
|
value={tempFilters.model || ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -140,7 +147,7 @@ export function RequestLogTable() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<span className="whitespace-nowrap">时间范围:</span>
|
<span className="whitespace-nowrap">{t("usage.timeRange")}:</span>
|
||||||
<Input
|
<Input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
className="h-8 w-[200px] bg-background"
|
className="h-8 w-[200px] bg-background"
|
||||||
@@ -190,7 +197,7 @@ export function RequestLogTable() {
|
|||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
<Search className="mr-2 h-3.5 w-3.5" />
|
<Search className="mr-2 h-3.5 w-3.5" />
|
||||||
{t("common.search", "查询")}
|
{t("common.search")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -199,7 +206,7 @@ export function RequestLogTable() {
|
|||||||
className="h-8"
|
className="h-8"
|
||||||
>
|
>
|
||||||
<X className="mr-2 h-3.5 w-3.5" />
|
<X className="mr-2 h-3.5 w-3.5" />
|
||||||
{t("common.reset", "重置")}
|
{t("common.reset")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -222,34 +229,34 @@ export function RequestLogTable() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("usage.time", "时间")}
|
{t("usage.time")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("usage.provider", "供应商")}
|
{t("usage.provider")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="min-w-[280px] whitespace-nowrap">
|
<TableHead className="min-w-[280px] whitespace-nowrap">
|
||||||
{t("usage.billingModel", "计费模型")}
|
{t("usage.billingModel")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.inputTokens", "输入")}
|
{t("usage.inputTokens")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.outputTokens", "输出")}
|
{t("usage.outputTokens")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||||
{t("usage.cacheReadTokens", "缓存读取")}
|
{t("usage.cacheReadTokens")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||||
{t("usage.cacheCreationTokens", "缓存写入")}
|
{t("usage.cacheCreationTokens")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right whitespace-nowrap">
|
<TableHead className="text-right whitespace-nowrap">
|
||||||
{t("usage.totalCost", "成本")}
|
{t("usage.totalCost")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
<TableHead className="text-center min-w-[140px] whitespace-nowrap">
|
||||||
{t("usage.timingInfo", "用时/首字")}
|
{t("usage.timingInfo")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="whitespace-nowrap">
|
<TableHead className="whitespace-nowrap">
|
||||||
{t("usage.status", "状态")}
|
{t("usage.status")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -260,18 +267,19 @@ export function RequestLogTable() {
|
|||||||
colSpan={10}
|
colSpan={10}
|
||||||
className="text-center text-muted-foreground"
|
className="text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t("usage.noData", "暂无数据")}
|
{t("usage.noData")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<TableRow key={log.requestId}>
|
<TableRow key={log.requestId}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{new Date(log.createdAt * 1000).toLocaleString("zh-CN")}
|
{new Date(log.createdAt * 1000).toLocaleString(
|
||||||
|
dateLocale,
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{log.providerName ||
|
{log.providerName || t("usage.unknownProvider")}
|
||||||
t("usage.unknownProvider", "未知供应商")}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className="font-mono text-sm max-w-[280px] truncate"
|
className="font-mono text-sm max-w-[280px] truncate"
|
||||||
@@ -339,8 +347,8 @@ export function RequestLogTable() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{log.isStreaming
|
{log.isStreaming
|
||||||
? t("usage.stream", "流")
|
? t("usage.stream")
|
||||||
: t("usage.nonStream", "非流")}
|
: t("usage.nonStream")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -366,7 +374,7 @@ export function RequestLogTable() {
|
|||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<div className="flex items-center justify-between px-2">
|
<div className="flex items-center justify-between px-2">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{t("usage.totalRecords", "共 {{total}} 条记录", { total })}
|
{t("usage.totalRecords", { total })}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -25,10 +25,8 @@ export function UsageDashboard() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<h2 className="text-2xl font-bold">{t("usage.title", "使用统计")}</h2>
|
<h2 className="text-2xl font-bold">{t("usage.title")}</h2>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">{t("usage.subtitle")}</p>
|
||||||
{t("usage.subtitle", "查看 AI 模型的使用情况和成本统计")}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -41,19 +39,19 @@ export function UsageDashboard() {
|
|||||||
value="1d"
|
value="1d"
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{t("usage.today", "24小时")}
|
{t("usage.today")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="7d"
|
value="7d"
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{t("usage.last7days", "7天")}
|
{t("usage.last7days")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="30d"
|
value="30d"
|
||||||
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
className="flex-1 sm:flex-none sm:px-6 data-[state=active]:bg-primary/10 data-[state=active]:text-primary hover:text-primary transition-colors"
|
||||||
>
|
>
|
||||||
{t("usage.last30days", "30天")}
|
{t("usage.last30days")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -69,15 +67,15 @@ export function UsageDashboard() {
|
|||||||
<TabsList className="bg-muted/50">
|
<TabsList className="bg-muted/50">
|
||||||
<TabsTrigger value="logs" className="gap-2">
|
<TabsTrigger value="logs" className="gap-2">
|
||||||
<ListFilter className="h-4 w-4" />
|
<ListFilter className="h-4 w-4" />
|
||||||
{t("usage.requestLogs", "请求日志")}
|
{t("usage.requestLogs")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="providers" className="gap-2">
|
<TabsTrigger value="providers" className="gap-2">
|
||||||
<Activity className="h-4 w-4" />
|
<Activity className="h-4 w-4" />
|
||||||
{t("usage.providerStats", "Provider 统计")}
|
{t("usage.providerStats")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="models" className="gap-2">
|
<TabsTrigger value="models" className="gap-2">
|
||||||
<BarChart3 className="h-4 w-4" />
|
<BarChart3 className="h-4 w-4" />
|
||||||
{t("usage.modelStats", "模型统计")}
|
{t("usage.modelStats")}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
title: t("usage.totalRequests", "总请求数"),
|
title: t("usage.totalRequests"),
|
||||||
value: totalRequests.toLocaleString(),
|
value: totalRequests.toLocaleString(),
|
||||||
icon: Activity,
|
icon: Activity,
|
||||||
color: "text-blue-500",
|
color: "text-blue-500",
|
||||||
@@ -42,7 +42,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
subValue: null,
|
subValue: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("usage.totalCost", "总成本"),
|
title: t("usage.totalCost"),
|
||||||
value: `$${totalCost.toFixed(4)}`,
|
value: `$${totalCost.toFixed(4)}`,
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
color: "text-green-500",
|
color: "text-green-500",
|
||||||
@@ -50,7 +50,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
subValue: null,
|
subValue: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("usage.totalTokens", "总 Token 数"),
|
title: t("usage.totalTokens"),
|
||||||
value: totalTokens.toLocaleString(),
|
value: totalTokens.toLocaleString(),
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
color: "text-purple-500",
|
color: "text-purple-500",
|
||||||
@@ -58,13 +58,13 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
subValue: (
|
subValue: (
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span>Input</span>
|
<span>{t("usage.input")}</span>
|
||||||
<span className="text-foreground/80">
|
<span className="text-foreground/80">
|
||||||
{(inputTokens / 1000).toFixed(1)}k
|
{(inputTokens / 1000).toFixed(1)}k
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span>Output</span>
|
<span>{t("usage.output")}</span>
|
||||||
<span className="text-foreground/80">
|
<span className="text-foreground/80">
|
||||||
{(outputTokens / 1000).toFixed(1)}k
|
{(outputTokens / 1000).toFixed(1)}k
|
||||||
</span>
|
</span>
|
||||||
@@ -73,7 +73,7 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("usage.cacheTokens", "缓存 Token"),
|
title: t("usage.cacheTokens"),
|
||||||
value: totalCacheTokens.toLocaleString(),
|
value: totalCacheTokens.toLocaleString(),
|
||||||
icon: Database,
|
icon: Database,
|
||||||
color: "text-orange-500",
|
color: "text-orange-500",
|
||||||
@@ -81,13 +81,13 @@ export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
|
|||||||
subValue: (
|
subValue: (
|
||||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground mt-3 pt-3 border-t border-border/50">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span>Write</span>
|
<span>{t("usage.cacheWrite")}</span>
|
||||||
<span className="text-foreground/80">
|
<span className="text-foreground/80">
|
||||||
{(cacheWriteTokens / 1000).toFixed(1)}k
|
{(cacheWriteTokens / 1000).toFixed(1)}k
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span>Read</span>
|
<span>{t("usage.cacheRead")}</span>
|
||||||
<span className="text-foreground/80">
|
<span className="text-foreground/80">
|
||||||
{(cacheReadTokens / 1000).toFixed(1)}k
|
{(cacheReadTokens / 1000).toFixed(1)}k
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface UsageTrendChartProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { data: trends, isLoading } = useUsageTrends(days);
|
const { data: trends, isLoading } = useUsageTrends(days);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -29,14 +29,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isToday = days === 1;
|
const isToday = days === 1;
|
||||||
|
const dateLocale =
|
||||||
|
i18n.language === "zh"
|
||||||
|
? "zh-CN"
|
||||||
|
: i18n.language === "ja"
|
||||||
|
? "ja-JP"
|
||||||
|
: "en-US";
|
||||||
const chartData =
|
const chartData =
|
||||||
trends?.map((stat) => {
|
trends?.map((stat) => {
|
||||||
const pointDate = new Date(stat.date);
|
const pointDate = new Date(stat.date);
|
||||||
return {
|
return {
|
||||||
rawDate: stat.date,
|
rawDate: stat.date,
|
||||||
label: isToday
|
label: isToday
|
||||||
? pointDate.toLocaleTimeString("zh-CN", { hour: "2-digit" })
|
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
|
||||||
: pointDate.toLocaleDateString("zh-CN", {
|
: pointDate.toLocaleDateString(dateLocale, {
|
||||||
month: "2-digit",
|
month: "2-digit",
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "aggregator",
|
category: "aggregator",
|
||||||
|
icon: "modelscope",
|
||||||
|
iconColor: "#624AFF",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "KAT-Coder",
|
name: "KAT-Coder",
|
||||||
@@ -228,6 +230,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
category: "cn_official",
|
category: "cn_official",
|
||||||
|
icon: "longcat",
|
||||||
|
iconColor: "#29E154",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "MiniMax",
|
name: "MiniMax",
|
||||||
@@ -330,6 +334,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
||||||
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
|
endpointCandidates: ["https://aihubmix.com", "https://api.aihubmix.com"],
|
||||||
category: "aggregator",
|
category: "aggregator",
|
||||||
|
icon: "aihubmix",
|
||||||
|
iconColor: "#006FFB",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "DMXAPI",
|
name: "DMXAPI",
|
||||||
@@ -344,6 +350,8 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
||||||
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
|
endpointCandidates: ["https://www.dmxapi.cn", "https://api.dmxapi.cn"],
|
||||||
category: "aggregator",
|
category: "aggregator",
|
||||||
|
isPartner: true, // 合作伙伴
|
||||||
|
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "PackyCode",
|
name: "PackyCode",
|
||||||
@@ -381,6 +389,6 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
},
|
},
|
||||||
category: "aggregator",
|
category: "aggregator",
|
||||||
icon: "openrouter",
|
icon: "openrouter",
|
||||||
iconColor: "#6366F1",
|
iconColor: "#6566F1",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ requires_openai_auth = true`,
|
|||||||
"gpt-5.1-codex",
|
"gpt-5.1-codex",
|
||||||
),
|
),
|
||||||
endpointCandidates: ["https://www.dmxapi.cn/v1"],
|
endpointCandidates: ["https://www.dmxapi.cn/v1"],
|
||||||
|
isPartner: true, // 合作伙伴
|
||||||
|
partnerPromotionKey: "dmxapi", // 促销信息 i18n key
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "PackyCode",
|
name: "PackyCode",
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ export function useDragSort(providers: Record<string, Provider>, appId: AppId) {
|
|||||||
queryKey: ["providers", appId],
|
queryKey: ["providers", appId],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 刷新故障转移队列(因为队列顺序依赖 sort_index)
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: ["failoverQueue", appId],
|
||||||
|
});
|
||||||
|
|
||||||
// 更新托盘菜单以反映新的排序(失败不影响主操作)
|
// 更新托盘菜单以反映新的排序(失败不影响主操作)
|
||||||
try {
|
try {
|
||||||
await providersApi.updateTrayMenu();
|
await providersApi.updateTrayMenu();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { settingsApi } from "@/lib/api";
|
import { settingsApi } from "@/lib/api";
|
||||||
@@ -39,15 +39,6 @@ export function useImportExport(
|
|||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const [backupId, setBackupId] = useState<string | null>(null);
|
const [backupId, setBackupId] = useState<string | null>(null);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const successTimerRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (successTimerRef.current) {
|
|
||||||
window.clearTimeout(successTimerRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearSelection = useCallback(() => {
|
const clearSelection = useCallback(() => {
|
||||||
setSelectedFile("");
|
setSelectedFile("");
|
||||||
@@ -105,6 +96,10 @@ export function useImportExport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBackupId(result.backupId ?? null);
|
setBackupId(result.backupId ?? null);
|
||||||
|
// 导入成功后立即触发外部刷新(与 live 同步结果解耦)
|
||||||
|
// - 避免 sync 失败时 UI 不刷新
|
||||||
|
// - 避免依赖 setTimeout(组件卸载会取消)
|
||||||
|
void onImportSuccess?.();
|
||||||
|
|
||||||
const syncResult = await syncCurrentProvidersLiveSafe();
|
const syncResult = await syncCurrentProvidersLiveSafe();
|
||||||
if (syncResult.ok) {
|
if (syncResult.ok) {
|
||||||
@@ -115,10 +110,6 @@ export function useImportExport(
|
|||||||
}),
|
}),
|
||||||
{ closeButton: true },
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
successTimerRef.current = window.setTimeout(() => {
|
|
||||||
void onImportSuccess?.();
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
"[useImportExport] Failed to sync live config",
|
"[useImportExport] Failed to sync live config",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import type { ProxyConfig } from "@/types/proxy";
|
import type { ProxyConfig } from "@/types/proxy";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -12,6 +13,7 @@ import type { ProxyConfig } from "@/types/proxy";
|
|||||||
*/
|
*/
|
||||||
export function useProxyConfig() {
|
export function useProxyConfig() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// 查询配置
|
// 查询配置
|
||||||
const { data: config, isLoading } = useQuery({
|
const { data: config, isLoading } = useQuery({
|
||||||
@@ -24,12 +26,16 @@ export function useProxyConfig() {
|
|||||||
mutationFn: (newConfig: ProxyConfig) =>
|
mutationFn: (newConfig: ProxyConfig) =>
|
||||||
invoke("update_proxy_config", { config: newConfig }),
|
invoke("update_proxy_config", { config: newConfig }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("代理配置已保存", { closeButton: true });
|
toast.success(t("proxy.settings.toast.saved"), { closeButton: true });
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyConfig"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(`保存失败: ${error.message}`);
|
toast.error(
|
||||||
|
t("proxy.settings.toast.saveFailed", {
|
||||||
|
error: error.message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,9 @@ export function useProxyStatus() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
const detail = extractErrorMessage(error) || "未知错误";
|
const detail =
|
||||||
|
extractErrorMessage(error) ||
|
||||||
|
t("common.unknown", { defaultValue: "未知错误" });
|
||||||
toast.error(
|
toast.error(
|
||||||
t("proxy.server.startFailed", {
|
t("proxy.server.startFailed", {
|
||||||
defaultValue: `启动代理服务失败: ${detail}`,
|
defaultValue: `启动代理服务失败: ${detail}`,
|
||||||
@@ -71,11 +73,16 @@ export function useProxyStatus() {
|
|||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||||
// 清除所有供应商健康状态缓存(后端已清空数据库记录)
|
// 彻底删除所有供应商健康状态缓存(后端已清空数据库记录)
|
||||||
queryClient.invalidateQueries({ queryKey: ["providerHealth"] });
|
queryClient.removeQueries({ queryKey: ["providerHealth"] });
|
||||||
|
// 彻底删除所有熔断器统计缓存(代理停止后熔断器状态已重置)
|
||||||
|
queryClient.removeQueries({ queryKey: ["circuitBreakerStats"] });
|
||||||
|
// 注意:故障转移队列和开关状态会保留,不需要刷新
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
const detail = extractErrorMessage(error) || "未知错误";
|
const detail =
|
||||||
|
extractErrorMessage(error) ||
|
||||||
|
t("common.unknown", { defaultValue: "未知错误" });
|
||||||
toast.error(
|
toast.error(
|
||||||
t("proxy.stopWithRestoreFailed", {
|
t("proxy.stopWithRestoreFailed", {
|
||||||
defaultValue: `停止失败: ${detail}`,
|
defaultValue: `停止失败: ${detail}`,
|
||||||
@@ -111,7 +118,9 @@ export function useProxyStatus() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] });
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
const detail = extractErrorMessage(error) || "未知错误";
|
const detail =
|
||||||
|
extractErrorMessage(error) ||
|
||||||
|
t("common.unknown", { defaultValue: "未知错误" });
|
||||||
toast.error(
|
toast.error(
|
||||||
t("proxy.takeover.failed", {
|
t("proxy.takeover.failed", {
|
||||||
defaultValue: `操作失败: ${detail}`,
|
defaultValue: `操作失败: ${detail}`,
|
||||||
@@ -133,8 +142,15 @@ export function useProxyStatus() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
queryClient.invalidateQueries({ queryKey: ["proxyStatus"] });
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
const detail = extractErrorMessage(error) || "未知错误";
|
const detail =
|
||||||
toast.error(`切换失败: ${detail}`);
|
extractErrorMessage(error) ||
|
||||||
|
t("common.unknown", { defaultValue: "未知错误" });
|
||||||
|
toast.error(
|
||||||
|
t("proxy.switchFailed", {
|
||||||
|
error: detail,
|
||||||
|
defaultValue: `切换失败: ${detail}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,36 @@ export function useSettings(): UseSettingsResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段
|
||||||
|
// 仅在本次更新包含 skipClaudeOnboarding 时触发,避免其它自动保存误触发
|
||||||
|
const nextSkipClaudeOnboarding = updates.skipClaudeOnboarding;
|
||||||
|
if (
|
||||||
|
nextSkipClaudeOnboarding !== undefined &&
|
||||||
|
nextSkipClaudeOnboarding !== (data?.skipClaudeOnboarding ?? false)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
if (nextSkipClaudeOnboarding) {
|
||||||
|
await settingsApi.applyClaudeOnboardingSkip();
|
||||||
|
} else {
|
||||||
|
await settingsApi.clearClaudeOnboardingSkip();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[useSettings] Failed to sync Claude onboarding skip",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
toast.error(
|
||||||
|
nextSkipClaudeOnboarding
|
||||||
|
? t("notifications.skipClaudeOnboardingFailed", {
|
||||||
|
defaultValue: "跳过 Claude Code 初次安装确认失败",
|
||||||
|
})
|
||||||
|
: t("notifications.clearClaudeOnboardingSkipFailed", {
|
||||||
|
defaultValue: "恢复 Claude Code 初次安装确认失败",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 持久化语言偏好
|
// 持久化语言偏好
|
||||||
try {
|
try {
|
||||||
if (typeof window !== "undefined" && updates.language) {
|
if (typeof window !== "undefined" && updates.language) {
|
||||||
@@ -242,6 +272,33 @@ export function useSettings(): UseSettingsResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Claude Code 初次安装确认:开=写入 hasCompletedOnboarding=true;关=删除该字段
|
||||||
|
const prevSkipClaudeOnboarding = data?.skipClaudeOnboarding ?? false;
|
||||||
|
const nextSkipClaudeOnboarding = payload.skipClaudeOnboarding ?? false;
|
||||||
|
if (nextSkipClaudeOnboarding !== prevSkipClaudeOnboarding) {
|
||||||
|
try {
|
||||||
|
if (nextSkipClaudeOnboarding) {
|
||||||
|
await settingsApi.applyClaudeOnboardingSkip();
|
||||||
|
} else {
|
||||||
|
await settingsApi.clearClaudeOnboardingSkip();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"[useSettings] Failed to sync Claude onboarding skip",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
toast.error(
|
||||||
|
nextSkipClaudeOnboarding
|
||||||
|
? t("notifications.skipClaudeOnboardingFailed", {
|
||||||
|
defaultValue: "跳过 Claude Code 初次安装确认失败",
|
||||||
|
})
|
||||||
|
: t("notifications.clearClaudeOnboardingSkipFailed", {
|
||||||
|
defaultValue: "恢复 Claude Code 初次安装确认失败",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 只在 Claude 插件集成状态真正改变时调用系统 API
|
// 只在 Claude 插件集成状态真正改变时调用系统 API
|
||||||
if (
|
if (
|
||||||
payload.enableClaudePluginIntegration !== undefined &&
|
payload.enableClaudePluginIntegration !== undefined &&
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
|||||||
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
|
minimizeToTrayOnClose: data.minimizeToTrayOnClose ?? true,
|
||||||
enableClaudePluginIntegration:
|
enableClaudePluginIntegration:
|
||||||
data.enableClaudePluginIntegration ?? false,
|
data.enableClaudePluginIntegration ?? false,
|
||||||
|
skipClaudeOnboarding: data.skipClaudeOnboarding ?? true,
|
||||||
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
claudeConfigDir: sanitizeDir(data.claudeConfigDir),
|
||||||
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
codexConfigDir: sanitizeDir(data.codexConfigDir),
|
||||||
language: normalizedLanguage,
|
language: normalizedLanguage,
|
||||||
@@ -102,6 +103,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
|||||||
showInTray: true,
|
showInTray: true,
|
||||||
minimizeToTrayOnClose: true,
|
minimizeToTrayOnClose: true,
|
||||||
enableClaudePluginIntegration: false,
|
enableClaudePluginIntegration: false,
|
||||||
|
skipClaudeOnboarding: true,
|
||||||
language: readPersistedLanguage(),
|
language: readPersistedLanguage(),
|
||||||
} as SettingsFormState);
|
} as SettingsFormState);
|
||||||
|
|
||||||
@@ -136,6 +138,7 @@ export function useSettingsForm(): UseSettingsFormResult {
|
|||||||
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
|
minimizeToTrayOnClose: serverData.minimizeToTrayOnClose ?? true,
|
||||||
enableClaudePluginIntegration:
|
enableClaudePluginIntegration:
|
||||||
serverData.enableClaudePluginIntegration ?? false,
|
serverData.enableClaudePluginIntegration ?? false,
|
||||||
|
skipClaudeOnboarding: serverData.skipClaudeOnboarding ?? true,
|
||||||
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
claudeConfigDir: sanitizeDir(serverData.claudeConfigDir),
|
||||||
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
|
||||||
language: normalizedLanguage,
|
language: normalizedLanguage,
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import {
|
|||||||
type StreamCheckResult,
|
type StreamCheckResult,
|
||||||
} from "@/lib/api/model-test";
|
} from "@/lib/api/model-test";
|
||||||
import type { AppId } from "@/lib/api";
|
import type { AppId } from "@/lib/api";
|
||||||
|
import { useResetCircuitBreaker } from "@/lib/query/failover";
|
||||||
|
|
||||||
export function useStreamCheck(appId: AppId) {
|
export function useStreamCheck(appId: AppId) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
const [checkingIds, setCheckingIds] = useState<Set<string>>(new Set());
|
||||||
|
const resetCircuitBreaker = useResetCircuitBreaker();
|
||||||
|
|
||||||
const checkProvider = useCallback(
|
const checkProvider = useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -30,6 +32,9 @@ export function useStreamCheck(appId: AppId) {
|
|||||||
}),
|
}),
|
||||||
{ closeButton: true },
|
{ closeButton: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 测试通过后重置熔断器状态
|
||||||
|
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||||
} else if (result.status === "degraded") {
|
} else if (result.status === "degraded") {
|
||||||
toast.warning(
|
toast.warning(
|
||||||
t("streamCheck.degraded", {
|
t("streamCheck.degraded", {
|
||||||
@@ -38,6 +43,9 @@ export function useStreamCheck(appId: AppId) {
|
|||||||
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
defaultValue: `${providerName} 响应较慢 (${result.responseTimeMs}ms)`,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 降级状态也重置熔断器,因为至少能通信
|
||||||
|
resetCircuitBreaker.mutate({ providerId, appType: appId });
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
toast.error(
|
||||||
t("streamCheck.failed", {
|
t("streamCheck.failed", {
|
||||||
@@ -66,7 +74,7 @@ export function useStreamCheck(appId: AppId) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appId, t],
|
[appId, t, resetCircuitBreaker],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isChecking = useCallback(
|
const isChecking = useCallback(
|
||||||
|
|||||||
+247
-8
@@ -17,6 +17,7 @@
|
|||||||
"about": "About",
|
"about": "About",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"notInstalled": "Not installed",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
@@ -28,7 +29,14 @@
|
|||||||
"formatError": "Format failed: {{error}}",
|
"formatError": "Format failed: {{error}}",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"back": "Back"
|
"back": "Back",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"refreshing": "Refreshing...",
|
||||||
|
"all": "All",
|
||||||
|
"search": "Search",
|
||||||
|
"reset": "Reset",
|
||||||
|
"actions": "Actions",
|
||||||
|
"deleting": "Deleting..."
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "Enter API Key",
|
"placeholder": "Enter API Key",
|
||||||
@@ -79,6 +87,12 @@
|
|||||||
"removeFromClaudePlugin": "Remove from Claude plugin",
|
"removeFromClaudePlugin": "Remove from Claude plugin",
|
||||||
"dragToReorder": "Drag to reorder",
|
"dragToReorder": "Drag to reorder",
|
||||||
"dragHandle": "Drag to reorder",
|
"dragHandle": "Drag to reorder",
|
||||||
|
"searchPlaceholder": "Search name, notes, or URL...",
|
||||||
|
"searchAriaLabel": "Search providers",
|
||||||
|
"searchScopeHint": "Matches provider name, notes, and URL.",
|
||||||
|
"searchCloseHint": "Press Esc to close",
|
||||||
|
"searchCloseAriaLabel": "Close provider search",
|
||||||
|
"noSearchResults": "No providers match your search.",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"sortUpdateFailed": "Failed to update sort order",
|
"sortUpdateFailed": "Failed to update sort order",
|
||||||
"configureUsage": "Configure usage query",
|
"configureUsage": "Configure usage query",
|
||||||
@@ -122,6 +136,8 @@
|
|||||||
"appliedToClaudePlugin": "Applied to Claude plugin",
|
"appliedToClaudePlugin": "Applied to Claude plugin",
|
||||||
"removedFromClaudePlugin": "Removed from Claude plugin",
|
"removedFromClaudePlugin": "Removed from Claude plugin",
|
||||||
"syncClaudePluginFailed": "Sync Claude plugin failed",
|
"syncClaudePluginFailed": "Sync Claude plugin failed",
|
||||||
|
"skipClaudeOnboardingFailed": "Failed to skip Claude Code first-run confirmation",
|
||||||
|
"clearClaudeOnboardingSkipFailed": "Failed to restore Claude Code first-run confirmation",
|
||||||
"updateSuccess": "Provider updated successfully",
|
"updateSuccess": "Provider updated successfully",
|
||||||
"updateFailed": "Failed to update provider: {{error}}",
|
"updateFailed": "Failed to update provider: {{error}}",
|
||||||
"deleteSuccess": "Provider deleted",
|
"deleteSuccess": "Provider deleted",
|
||||||
@@ -138,6 +154,34 @@
|
|||||||
"general": "General",
|
"general": "General",
|
||||||
"tabGeneral": "General",
|
"tabGeneral": "General",
|
||||||
"tabAdvanced": "Advanced",
|
"tabAdvanced": "Advanced",
|
||||||
|
"advanced": {
|
||||||
|
"configDir": {
|
||||||
|
"title": "Configuration Directory",
|
||||||
|
"description": "Manage storage paths for Claude, Codex and Gemini configurations"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "Local Proxy",
|
||||||
|
"description": "Control proxy service toggle, view status and port info",
|
||||||
|
"running": "Running",
|
||||||
|
"stopped": "Stopped"
|
||||||
|
},
|
||||||
|
"modelTest": {
|
||||||
|
"title": "Model Test Config",
|
||||||
|
"description": "Configure default models and prompts for model testing"
|
||||||
|
},
|
||||||
|
"failover": {
|
||||||
|
"title": "Auto Failover",
|
||||||
|
"description": "Configure failover queue and circuit breaker strategy"
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"title": "Cost Pricing",
|
||||||
|
"description": "Manage token pricing rules for each model"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"title": "Data Management",
|
||||||
|
"description": "Import/export configurations and backup/restore"
|
||||||
|
}
|
||||||
|
},
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageHint": "Preview interface language immediately after switching, takes effect permanently after saving.",
|
"languageHint": "Preview interface language immediately after switching, takes effect permanently after saving.",
|
||||||
"theme": "Theme",
|
"theme": "Theme",
|
||||||
@@ -146,7 +190,7 @@
|
|||||||
"themeDark": "Dark",
|
"themeDark": "Dark",
|
||||||
"themeSystem": "System",
|
"themeSystem": "System",
|
||||||
"importExport": "SQL Import/Export",
|
"importExport": "SQL Import/Export",
|
||||||
"importExportHint": "Import or export database SQL backups for migration or restore.",
|
"importExportHint": "Import or export database SQL backups for migration or restore (import supports only backups exported by CC Switch).",
|
||||||
"exportConfig": "Export SQL Backup",
|
"exportConfig": "Export SQL Backup",
|
||||||
"selectConfigFile": "Select SQL File",
|
"selectConfigFile": "Select SQL File",
|
||||||
"noFileSelected": "No configuration file selected.",
|
"noFileSelected": "No configuration file selected.",
|
||||||
@@ -162,7 +206,7 @@
|
|||||||
"selectFileFailed": "Please choose a valid SQL backup file",
|
"selectFileFailed": "Please choose a valid SQL backup file",
|
||||||
"configCorrupted": "SQL file may be corrupted or invalid",
|
"configCorrupted": "SQL file may be corrupted or invalid",
|
||||||
"backupId": "Backup ID",
|
"backupId": "Backup ID",
|
||||||
"autoReload": "Data will refresh automatically in 2 seconds...",
|
"autoReload": "Data refreshed",
|
||||||
"languageOptionChinese": "中文",
|
"languageOptionChinese": "中文",
|
||||||
"languageOptionEnglish": "English",
|
"languageOptionEnglish": "English",
|
||||||
"languageOptionJapanese": "日本語",
|
"languageOptionJapanese": "日本語",
|
||||||
@@ -175,6 +219,8 @@
|
|||||||
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
"minimizeToTrayDescription": "When checked, clicking the close button will hide to system tray, otherwise the app will exit directly.",
|
||||||
"enableClaudePluginIntegration": "Apply to Claude Code extension",
|
"enableClaudePluginIntegration": "Apply to Claude Code extension",
|
||||||
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
|
"enableClaudePluginIntegrationDescription": "When enabled, the VS Code Claude Code extension provider will switch with this app",
|
||||||
|
"skipClaudeOnboarding": "Skip Claude Code first-run confirmation",
|
||||||
|
"skipClaudeOnboardingDescription": "When enabled, Claude Code will skip the first-run confirmation",
|
||||||
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
"configDirectoryOverride": "Configuration Directory Override (Advanced)",
|
||||||
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
"configDirectoryDescription": "When using Claude Code or Codex in environments like WSL, you can manually specify the configuration directory to the one in WSL to keep provider data consistent with the main environment.",
|
||||||
"appConfigDir": "CC Switch Configuration Directory",
|
"appConfigDir": "CC Switch Configuration Directory",
|
||||||
@@ -205,6 +251,11 @@
|
|||||||
"releaseNotes": "Release Notes",
|
"releaseNotes": "Release Notes",
|
||||||
"viewReleaseNotes": "View release notes for this version",
|
"viewReleaseNotes": "View release notes for this version",
|
||||||
"viewCurrentReleaseNotes": "View current version release notes",
|
"viewCurrentReleaseNotes": "View current version release notes",
|
||||||
|
"oneClickInstall": "One-click Install",
|
||||||
|
"oneClickInstallHint": "Install Claude Code / Codex / Gemini CLI",
|
||||||
|
"localEnvCheck": "Local environment check",
|
||||||
|
"installCommandsCopied": "Install commands copied",
|
||||||
|
"installCommandsCopyFailed": "Copy failed, please copy manually.",
|
||||||
"importFailedError": "Import config failed: {{message}}",
|
"importFailedError": "Import config failed: {{message}}",
|
||||||
"exportFailedError": "Export config failed:",
|
"exportFailedError": "Export config failed:",
|
||||||
"restartRequired": "Restart Required",
|
"restartRequired": "Restart Required",
|
||||||
@@ -264,7 +315,8 @@
|
|||||||
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
|
"zhipu": "Zhipu GLM is an official partner of CC Switch. Use this link to top up and get a 10% discount",
|
||||||
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
"packycode": "PackyCode is an official partner of CC Switch. Register using this link and enter \"cc-switch\" promo code during recharge to get 10% off",
|
||||||
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
"minimax_cn": "MiniMax Coding Plan Special Offer, Starter from ¥9.9",
|
||||||
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)"
|
"minimax_en": "MiniMax Coding Plan Black Friday, Starter is now $2/mo (80% OFF!)",
|
||||||
|
"dmxapi": "Claude Code exclusive model 66% OFF now!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "Parameter Config - {{name}} *",
|
"parameterConfig": "Parameter Config - {{name}} *",
|
||||||
"mainModel": "Main Model (optional)",
|
"mainModel": "Main Model (optional)",
|
||||||
@@ -355,6 +407,50 @@
|
|||||||
"hint": "You can continue to adjust the fields below after selecting a preset."
|
"hint": "You can continue to adjust the fields below after selecting a preset."
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
|
"title": "Usage Statistics",
|
||||||
|
"subtitle": "View AI model usage and cost statistics",
|
||||||
|
"today": "24 Hours",
|
||||||
|
"last7days": "7 Days",
|
||||||
|
"last30days": "30 Days",
|
||||||
|
"totalRequests": "Total Requests",
|
||||||
|
"totalCost": "Total Cost",
|
||||||
|
"cost": "Cost",
|
||||||
|
"perMillion": "(per million)",
|
||||||
|
"trends": "Usage Trends",
|
||||||
|
"rangeToday": "Today (hourly)",
|
||||||
|
"rangeLast7Days": "Last 7 days",
|
||||||
|
"rangeLast30Days": "Last 30 days",
|
||||||
|
"totalTokens": "Total Tokens",
|
||||||
|
"cacheTokens": "Cache Tokens",
|
||||||
|
"requestLogs": "Request Logs",
|
||||||
|
"providerStats": "Provider Stats",
|
||||||
|
"modelStats": "Model Stats",
|
||||||
|
"time": "Time",
|
||||||
|
"provider": "Provider",
|
||||||
|
"billingModel": "Billing Model",
|
||||||
|
"inputTokens": "Input",
|
||||||
|
"outputTokens": "Output",
|
||||||
|
"cacheReadTokens": "Cache Read",
|
||||||
|
"cacheCreationTokens": "Cache Write",
|
||||||
|
"timingInfo": "Duration/TTFT",
|
||||||
|
"status": "Status",
|
||||||
|
"noData": "No data",
|
||||||
|
"unknownProvider": "Unknown Provider",
|
||||||
|
"stream": "Stream",
|
||||||
|
"nonStream": "Non-stream",
|
||||||
|
"totalRecords": "{{total}} records total",
|
||||||
|
"modelPricing": "Model Pricing",
|
||||||
|
"loadPricingError": "Failed to load pricing data",
|
||||||
|
"modelPricingDesc": "Configure token costs for each model",
|
||||||
|
"noPricingData": "No pricing data. Click \"Add\" to add model pricing configuration.",
|
||||||
|
"model": "Model",
|
||||||
|
"displayName": "Display Name",
|
||||||
|
"inputCost": "Input Cost",
|
||||||
|
"outputCost": "Output Cost",
|
||||||
|
"cacheReadCost": "Cache Read",
|
||||||
|
"cacheWriteCost": "Cache Write",
|
||||||
|
"deleteConfirmTitle": "Confirm Delete",
|
||||||
|
"deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.",
|
||||||
"queryFailed": "Query failed",
|
"queryFailed": "Query failed",
|
||||||
"refreshUsage": "Refresh usage",
|
"refreshUsage": "Refresh usage",
|
||||||
"planUsage": "Plan usage",
|
"planUsage": "Plan usage",
|
||||||
@@ -368,7 +464,19 @@
|
|||||||
"daysAgo": "{{count}} day ago",
|
"daysAgo": "{{count}} day ago",
|
||||||
"multiplePlans": "{{count}} plans",
|
"multiplePlans": "{{count}} plans",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse"
|
"collapse": "Collapse",
|
||||||
|
"modelIdPlaceholder": "e.g., claude-3-5-sonnet-20241022",
|
||||||
|
"displayNamePlaceholder": "e.g., Claude 3.5 Sonnet",
|
||||||
|
"appType": "App Type",
|
||||||
|
"allApps": "All Apps",
|
||||||
|
"statusCode": "Status Code",
|
||||||
|
"searchProviderPlaceholder": "Search provider...",
|
||||||
|
"searchModelPlaceholder": "Search model...",
|
||||||
|
"timeRange": "Time Range",
|
||||||
|
"input": "Input",
|
||||||
|
"output": "Output",
|
||||||
|
"cacheWrite": "Write",
|
||||||
|
"cacheRead": "Read"
|
||||||
},
|
},
|
||||||
"usageScript": {
|
"usageScript": {
|
||||||
"title": "Configure Usage Query",
|
"title": "Configure Usage Query",
|
||||||
@@ -438,7 +546,9 @@
|
|||||||
"tip3": "• Entire config must be wrapped in () to form object literal expression"
|
"tip3": "• Entire config must be wrapped in () to form object literal expression"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"usage_query_failed": "Usage query failed"
|
"usage_query_failed": "Usage query failed",
|
||||||
|
"configLoadFailedTitle": "Configuration Load Failed",
|
||||||
|
"configLoadFailedMessage": "Unable to read configuration file:\n{{path}}\n\nError details:\n{{detail}}\n\nPlease check if the JSON is valid, or restore from a backup file (e.g., config.json.bak) in the same directory.\n\nThe app will exit so you can fix this."
|
||||||
},
|
},
|
||||||
"presetSelector": {
|
"presetSelector": {
|
||||||
"title": "Select Configuration Type",
|
"title": "Select Configuration Type",
|
||||||
@@ -837,7 +947,9 @@
|
|||||||
"label": "Icon",
|
"label": "Icon",
|
||||||
"colorLabel": "Icon Color",
|
"colorLabel": "Icon Color",
|
||||||
"selectIcon": "Select Icon",
|
"selectIcon": "Select Icon",
|
||||||
"preview": "Preview"
|
"preview": "Preview",
|
||||||
|
"clickToChange": "Click to change icon",
|
||||||
|
"clickToSelect": "Click to select icon"
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"success": "Configuration migrated successfully"
|
"success": "Configuration migrated successfully"
|
||||||
@@ -845,7 +957,106 @@
|
|||||||
"agents": {
|
"agents": {
|
||||||
"title": "Agents"
|
"title": "Agents"
|
||||||
},
|
},
|
||||||
|
"health": {
|
||||||
|
"operational": "Operational",
|
||||||
|
"degraded": "Degraded",
|
||||||
|
"failed": "Failed",
|
||||||
|
"circuitOpen": "Circuit Open",
|
||||||
|
"consecutiveFailures": "{{count}} consecutive failures"
|
||||||
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
|
"panel": {
|
||||||
|
"serviceAddress": "Service Address",
|
||||||
|
"addressCopied": "Address copied",
|
||||||
|
"currentProvider": "Current Provider:",
|
||||||
|
"waitingFirstRequest": "Current Provider: Waiting for first request...",
|
||||||
|
"stoppedTitle": "Proxy Service Stopped",
|
||||||
|
"stoppedDescription": "Use the toggle in the top right to start the service",
|
||||||
|
"openSettings": "Configure Proxy Service",
|
||||||
|
"stats": {
|
||||||
|
"activeConnections": "Active Connections",
|
||||||
|
"totalRequests": "Total Requests",
|
||||||
|
"successRate": "Success Rate",
|
||||||
|
"uptime": "Uptime"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Proxy Service Settings",
|
||||||
|
"description": "Configure local proxy server listening address, port and runtime parameters. Changes take effect immediately after saving.",
|
||||||
|
"alert": {
|
||||||
|
"autoApply": "Changes will be automatically synced to the running proxy service without manual restart."
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"title": "Basic Settings",
|
||||||
|
"description": "Configure proxy service listening address and port."
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "Advanced Parameters",
|
||||||
|
"description": "Control request stability and logging."
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"title": "Timeout Settings",
|
||||||
|
"description": "Configure timeout for streaming and non-streaming requests."
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"listenAddress": {
|
||||||
|
"label": "Listen Address",
|
||||||
|
"placeholder": "127.0.0.1",
|
||||||
|
"description": "IP address the proxy server listens on (recommended: 127.0.0.1)"
|
||||||
|
},
|
||||||
|
"listenPort": {
|
||||||
|
"label": "Listen Port",
|
||||||
|
"placeholder": "5000",
|
||||||
|
"description": "Port number the proxy server listens on (1024 ~ 65535)"
|
||||||
|
},
|
||||||
|
"maxRetries": {
|
||||||
|
"label": "Max Retries",
|
||||||
|
"placeholder": "3",
|
||||||
|
"description": "Number of retries on request failure (0 ~ 10)"
|
||||||
|
},
|
||||||
|
"requestTimeout": {
|
||||||
|
"label": "Request Timeout (sec)",
|
||||||
|
"placeholder": "0 (unlimited) or 300",
|
||||||
|
"description": "Maximum wait time for a single request (0 = unlimited, or 10 ~ 600 seconds)"
|
||||||
|
},
|
||||||
|
"enableLogging": {
|
||||||
|
"label": "Enable Logging",
|
||||||
|
"description": "Log all proxy requests for troubleshooting"
|
||||||
|
},
|
||||||
|
"streamingFirstByteTimeout": {
|
||||||
|
"label": "Streaming First Byte Timeout (sec)",
|
||||||
|
"description": "Maximum time to wait for the first data chunk"
|
||||||
|
},
|
||||||
|
"streamingIdleTimeout": {
|
||||||
|
"label": "Streaming Idle Timeout (sec)",
|
||||||
|
"description": "Maximum interval between data chunks"
|
||||||
|
},
|
||||||
|
"nonStreamingTimeout": {
|
||||||
|
"label": "Non-Streaming Timeout (sec)",
|
||||||
|
"description": "Total timeout for non-streaming requests"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"addressInvalid": "Please enter a valid IP address",
|
||||||
|
"portMin": "Port must be greater than 1024",
|
||||||
|
"portMax": "Port must be less than 65535",
|
||||||
|
"retryMin": "Retry count cannot be negative",
|
||||||
|
"retryMax": "Retry count cannot exceed 10",
|
||||||
|
"timeoutNonNegative": "Timeout cannot be negative",
|
||||||
|
"timeoutMax": "Timeout cannot exceed 600 seconds",
|
||||||
|
"timeoutRange": "Please enter 0 or a value between 10-600",
|
||||||
|
"streamingTimeoutMin": "Timeout must be at least 5 seconds",
|
||||||
|
"streamingTimeoutMax": "Timeout cannot exceed 300 seconds"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "Save Configuration"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"saved": "Proxy configuration saved",
|
||||||
|
"saveFailed": "Save failed: {{error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switchFailed": "Switch failed: {{error}}",
|
||||||
"failoverQueue": {
|
"failoverQueue": {
|
||||||
"title": "Failover Queue",
|
"title": "Failover Queue",
|
||||||
"description": "Manage failover order for each app's providers",
|
"description": "Manage failover order for each app's providers",
|
||||||
@@ -872,7 +1083,7 @@
|
|||||||
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
|
"failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)",
|
||||||
"timeout": "Recovery Wait Time (seconds)",
|
"timeout": "Recovery Wait Time (seconds)",
|
||||||
"timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)",
|
"timeoutHint": "Wait this long before trying to recover after circuit opens (recommended: 30-120)",
|
||||||
"circuitBreakerSettings": "Circuit Breaker Advanced Settings",
|
"circuitBreakerSettings": "Circuit Breaker Settings",
|
||||||
"successThreshold": "Recovery Success Threshold",
|
"successThreshold": "Recovery Success Threshold",
|
||||||
"successThresholdHint": "Close circuit breaker after this many successes in half-open state",
|
"successThresholdHint": "Close circuit breaker after this many successes in half-open state",
|
||||||
"errorRate": "Error Rate Threshold (%)",
|
"errorRate": "Error Rate Threshold (%)",
|
||||||
@@ -889,5 +1100,33 @@
|
|||||||
"errorRateLabel": "Error Rate Threshold",
|
"errorRateLabel": "Error Rate Threshold",
|
||||||
"errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached"
|
"errorRateExplain": "Open circuit breaker when error rate exceeds this value, even if failure threshold not reached"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"streamCheck": {
|
||||||
|
"configSaved": "Health check config saved",
|
||||||
|
"configSaveFailed": "Save failed",
|
||||||
|
"testModels": "Test Models",
|
||||||
|
"claudeModel": "Claude Model",
|
||||||
|
"codexModel": "Codex Model",
|
||||||
|
"geminiModel": "Gemini Model",
|
||||||
|
"checkParams": "Check Parameters",
|
||||||
|
"timeout": "Timeout (seconds)",
|
||||||
|
"maxRetries": "Max Retries",
|
||||||
|
"degradedThreshold": "Degraded Threshold (ms)"
|
||||||
|
},
|
||||||
|
"proxyConfig": {
|
||||||
|
"proxyEnabled": "Proxy Enabled",
|
||||||
|
"appTakeover": "Proxy Enabled",
|
||||||
|
"perAppConfig": "Per-App Config",
|
||||||
|
"circuitBreaker": "Circuit Breaker",
|
||||||
|
"circuitBreakerSettings": "Circuit Breaker Settings",
|
||||||
|
"failureThreshold": "Failure Threshold",
|
||||||
|
"successThreshold": "Success Threshold",
|
||||||
|
"recoveryTimeout": "Recovery Timeout",
|
||||||
|
"errorRateThreshold": "Error Rate Threshold",
|
||||||
|
"minRequests": "Min Requests",
|
||||||
|
"timeoutConfig": "Timeout Config",
|
||||||
|
"streamingFirstByte": "Streaming First Byte Timeout",
|
||||||
|
"streamingIdle": "Streaming Idle Timeout",
|
||||||
|
"nonStreaming": "Non-Streaming Timeout"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+291
-7
@@ -17,6 +17,7 @@
|
|||||||
"about": "バージョン情報",
|
"about": "バージョン情報",
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
"loading": "読み込み中...",
|
"loading": "読み込み中...",
|
||||||
|
"notInstalled": "未インストール",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"error": "エラー",
|
"error": "エラー",
|
||||||
"unknown": "不明",
|
"unknown": "不明",
|
||||||
@@ -28,7 +29,14 @@
|
|||||||
"formatError": "整形に失敗しました: {{error}}",
|
"formatError": "整形に失敗しました: {{error}}",
|
||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
"view": "表示",
|
"view": "表示",
|
||||||
"back": "戻る"
|
"back": "戻る",
|
||||||
|
"refresh": "更新",
|
||||||
|
"refreshing": "更新中...",
|
||||||
|
"all": "すべて",
|
||||||
|
"search": "検索",
|
||||||
|
"reset": "リセット",
|
||||||
|
"actions": "操作",
|
||||||
|
"deleting": "削除中..."
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "API Key を入力",
|
"placeholder": "API Key を入力",
|
||||||
@@ -79,6 +87,12 @@
|
|||||||
"removeFromClaudePlugin": "Claude プラグインから解除",
|
"removeFromClaudePlugin": "Claude プラグインから解除",
|
||||||
"dragToReorder": "ドラッグで並べ替え",
|
"dragToReorder": "ドラッグで並べ替え",
|
||||||
"dragHandle": "ドラッグで並べ替え",
|
"dragHandle": "ドラッグで並べ替え",
|
||||||
|
"searchPlaceholder": "名前・メモ・URLで検索...",
|
||||||
|
"searchAriaLabel": "プロバイダーを検索",
|
||||||
|
"searchScopeHint": "名前・メモ・URL を対象に検索します。",
|
||||||
|
"searchCloseHint": "Esc で閉じる",
|
||||||
|
"searchCloseAriaLabel": "検索を閉じる",
|
||||||
|
"noSearchResults": "一致するプロバイダーがありません。",
|
||||||
"duplicate": "複製",
|
"duplicate": "複製",
|
||||||
"sortUpdateFailed": "並び順の更新に失敗しました",
|
"sortUpdateFailed": "並び順の更新に失敗しました",
|
||||||
"configureUsage": "利用状況を設定",
|
"configureUsage": "利用状況を設定",
|
||||||
@@ -122,6 +136,8 @@
|
|||||||
"appliedToClaudePlugin": "Claude プラグインに適用しました",
|
"appliedToClaudePlugin": "Claude プラグインに適用しました",
|
||||||
"removedFromClaudePlugin": "Claude プラグインから削除しました",
|
"removedFromClaudePlugin": "Claude プラグインから削除しました",
|
||||||
"syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました",
|
"syncClaudePluginFailed": "Claude プラグインとの同期に失敗しました",
|
||||||
|
"skipClaudeOnboardingFailed": "Claude Code の初回確認スキップに失敗しました",
|
||||||
|
"clearClaudeOnboardingSkipFailed": "Claude Code の初回確認の復元に失敗しました",
|
||||||
"updateSuccess": "プロバイダーを更新しました",
|
"updateSuccess": "プロバイダーを更新しました",
|
||||||
"updateFailed": "プロバイダーの更新に失敗しました: {{error}}",
|
"updateFailed": "プロバイダーの更新に失敗しました: {{error}}",
|
||||||
"deleteSuccess": "プロバイダーを削除しました",
|
"deleteSuccess": "プロバイダーを削除しました",
|
||||||
@@ -138,6 +154,34 @@
|
|||||||
"general": "一般",
|
"general": "一般",
|
||||||
"tabGeneral": "一般",
|
"tabGeneral": "一般",
|
||||||
"tabAdvanced": "詳細",
|
"tabAdvanced": "詳細",
|
||||||
|
"advanced": {
|
||||||
|
"configDir": {
|
||||||
|
"title": "設定ディレクトリ",
|
||||||
|
"description": "Claude、Codex、Gemini の設定保存パスを管理"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "ローカルプロキシ",
|
||||||
|
"description": "プロキシサービスの切り替え、ステータスとポート情報を表示",
|
||||||
|
"running": "実行中",
|
||||||
|
"stopped": "停止中"
|
||||||
|
},
|
||||||
|
"modelTest": {
|
||||||
|
"title": "モデルテスト設定",
|
||||||
|
"description": "モデルテストで使用するデフォルトモデルとプロンプトを設定"
|
||||||
|
},
|
||||||
|
"failover": {
|
||||||
|
"title": "自動フェイルオーバー",
|
||||||
|
"description": "フェイルオーバーキューとサーキットブレーカー戦略を設定"
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"title": "コスト計算",
|
||||||
|
"description": "各モデルのトークン料金ルールを管理"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"title": "データ管理",
|
||||||
|
"description": "設定のインポート/エクスポートとバックアップ/復元"
|
||||||
|
}
|
||||||
|
},
|
||||||
"language": "言語",
|
"language": "言語",
|
||||||
"languageHint": "切り替えるとすぐにプレビューされ、保存後に永続化されます。",
|
"languageHint": "切り替えるとすぐにプレビューされ、保存後に永続化されます。",
|
||||||
"theme": "テーマ",
|
"theme": "テーマ",
|
||||||
@@ -146,7 +190,7 @@
|
|||||||
"themeDark": "ダーク",
|
"themeDark": "ダーク",
|
||||||
"themeSystem": "システム",
|
"themeSystem": "システム",
|
||||||
"importExport": "SQL インポート/エクスポート",
|
"importExport": "SQL インポート/エクスポート",
|
||||||
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします。",
|
"importExportHint": "移行や復元用にデータベースの SQL バックアップをインポート/エクスポートします(インポートは CC Switch がエクスポートしたバックアップのみ対応)。",
|
||||||
"exportConfig": "SQL バックアップをエクスポート",
|
"exportConfig": "SQL バックアップをエクスポート",
|
||||||
"selectConfigFile": "SQL ファイルを選択",
|
"selectConfigFile": "SQL ファイルを選択",
|
||||||
"noFileSelected": "ファイルが選択されていません。",
|
"noFileSelected": "ファイルが選択されていません。",
|
||||||
@@ -162,7 +206,7 @@
|
|||||||
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
|
"selectFileFailed": "有効な SQL バックアップファイルを選択してください",
|
||||||
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
|
"configCorrupted": "SQL ファイルが壊れているか形式が無効な可能性があります",
|
||||||
"backupId": "バックアップ ID",
|
"backupId": "バックアップ ID",
|
||||||
"autoReload": "2 秒後に自動で再読み込みします...",
|
"autoReload": "データを更新しました",
|
||||||
"languageOptionChinese": "中文",
|
"languageOptionChinese": "中文",
|
||||||
"languageOptionEnglish": "English",
|
"languageOptionEnglish": "English",
|
||||||
"languageOptionJapanese": "日本語",
|
"languageOptionJapanese": "日本語",
|
||||||
@@ -175,6 +219,8 @@
|
|||||||
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
|
"minimizeToTrayDescription": "チェックすると閉じるボタンでトレイに隠し、オフならアプリを終了します。",
|
||||||
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
|
"enableClaudePluginIntegration": "Claude Code 拡張に適用",
|
||||||
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
|
"enableClaudePluginIntegrationDescription": "オンにすると VS Code の Claude Code 拡張のプロバイダーも同期します",
|
||||||
|
"skipClaudeOnboarding": "Claude Code の初回確認をスキップ",
|
||||||
|
"skipClaudeOnboardingDescription": "オンにすると Claude Code の初回インストール確認をスキップします",
|
||||||
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
|
"configDirectoryOverride": "設定ディレクトリの上書き(詳細)",
|
||||||
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
|
"configDirectoryDescription": "WSL などで Claude Code や Codex を使う場合、ここで設定ディレクトリを WSL 側に合わせるとデータを揃えられます。",
|
||||||
"appConfigDir": "CC Switch 設定ディレクトリ",
|
"appConfigDir": "CC Switch 設定ディレクトリ",
|
||||||
@@ -205,6 +251,11 @@
|
|||||||
"releaseNotes": "リリースノート",
|
"releaseNotes": "リリースノート",
|
||||||
"viewReleaseNotes": "このバージョンのリリースノートを見る",
|
"viewReleaseNotes": "このバージョンのリリースノートを見る",
|
||||||
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
|
"viewCurrentReleaseNotes": "現在のバージョンのリリースノートを見る",
|
||||||
|
"oneClickInstall": "ワンクリックインストール",
|
||||||
|
"oneClickInstallHint": "Claude Code / Codex / Gemini CLI をインストール",
|
||||||
|
"localEnvCheck": "ローカル環境チェック",
|
||||||
|
"installCommandsCopied": "インストールコマンドをコピーしました",
|
||||||
|
"installCommandsCopyFailed": "コピーに失敗しました。手動でコピーしてください。",
|
||||||
"importFailedError": "設定のインポートに失敗しました: {{message}}",
|
"importFailedError": "設定のインポートに失敗しました: {{message}}",
|
||||||
"exportFailedError": "設定のエクスポートに失敗しました:",
|
"exportFailedError": "設定のエクスポートに失敗しました:",
|
||||||
"restartRequired": "再起動が必要です",
|
"restartRequired": "再起動が必要です",
|
||||||
@@ -264,7 +315,8 @@
|
|||||||
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
|
"zhipu": "Zhipu GLM は CC Switch の公式パートナーです。リンク経由でチャージすると 10% 割引",
|
||||||
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
"packycode": "PackyCode は CC Switch の公式パートナーです。登録後チャージ時に \"cc-switch\" を入力すると 10% オフ",
|
||||||
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
"minimax_cn": "MiniMax Coding Plan 特別価格、Starter ¥9.9 から",
|
||||||
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)"
|
"minimax_en": "MiniMax Coding Plan Black Friday、Starter が月額 $2(80% OFF)",
|
||||||
|
"dmxapi": "Claude Code 専用モデル 66% OFF 実施中!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "パラメーター設定 - {{name}} *",
|
"parameterConfig": "パラメーター設定 - {{name}} *",
|
||||||
"mainModel": "メインモデル(任意)",
|
"mainModel": "メインモデル(任意)",
|
||||||
@@ -355,6 +407,50 @@
|
|||||||
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
|
"hint": "プリセットを選んだ後でも、下のフィールドで調整できます。"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
|
"title": "利用統計",
|
||||||
|
"subtitle": "AI モデルの利用状況とコスト統計を表示",
|
||||||
|
"today": "24時間",
|
||||||
|
"last7days": "7日間",
|
||||||
|
"last30days": "30日間",
|
||||||
|
"totalRequests": "総リクエスト数",
|
||||||
|
"totalCost": "総コスト",
|
||||||
|
"cost": "コスト",
|
||||||
|
"perMillion": "(100万あたり)",
|
||||||
|
"trends": "利用トレンド",
|
||||||
|
"rangeToday": "今日 (時間別)",
|
||||||
|
"rangeLast7Days": "過去7日間",
|
||||||
|
"rangeLast30Days": "過去30日間",
|
||||||
|
"totalTokens": "総トークン数",
|
||||||
|
"cacheTokens": "キャッシュトークン",
|
||||||
|
"requestLogs": "リクエストログ",
|
||||||
|
"providerStats": "プロバイダー統計",
|
||||||
|
"modelStats": "モデル統計",
|
||||||
|
"time": "時間",
|
||||||
|
"provider": "プロバイダー",
|
||||||
|
"billingModel": "課金モデル",
|
||||||
|
"inputTokens": "入力",
|
||||||
|
"outputTokens": "出力",
|
||||||
|
"cacheReadTokens": "キャッシュ読取",
|
||||||
|
"cacheCreationTokens": "キャッシュ書込",
|
||||||
|
"timingInfo": "応答時間/TTFT",
|
||||||
|
"status": "ステータス",
|
||||||
|
"noData": "データなし",
|
||||||
|
"unknownProvider": "不明なプロバイダー",
|
||||||
|
"stream": "ストリーム",
|
||||||
|
"nonStream": "非ストリーム",
|
||||||
|
"totalRecords": "全 {{total}} 件",
|
||||||
|
"modelPricing": "モデル料金",
|
||||||
|
"loadPricingError": "料金データの読み込みに失敗しました",
|
||||||
|
"modelPricingDesc": "各モデルのトークンコストを設定",
|
||||||
|
"noPricingData": "料金データがありません。「追加」をクリックしてモデル料金を設定してください。",
|
||||||
|
"model": "モデル",
|
||||||
|
"displayName": "表示名",
|
||||||
|
"inputCost": "入力コスト",
|
||||||
|
"outputCost": "出力コスト",
|
||||||
|
"cacheReadCost": "キャッシュ読取",
|
||||||
|
"cacheWriteCost": "キャッシュ書込",
|
||||||
|
"deleteConfirmTitle": "削除の確認",
|
||||||
|
"deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。",
|
||||||
"queryFailed": "照会に失敗しました",
|
"queryFailed": "照会に失敗しました",
|
||||||
"refreshUsage": "利用状況を更新",
|
"refreshUsage": "利用状況を更新",
|
||||||
"planUsage": "プラン利用状況",
|
"planUsage": "プラン利用状況",
|
||||||
@@ -368,7 +464,19 @@
|
|||||||
"daysAgo": "{{count}} 日前",
|
"daysAgo": "{{count}} 日前",
|
||||||
"multiplePlans": "{{count}} プラン",
|
"multiplePlans": "{{count}} プラン",
|
||||||
"expand": "展開",
|
"expand": "展開",
|
||||||
"collapse": "折りたたむ"
|
"collapse": "折りたたむ",
|
||||||
|
"modelIdPlaceholder": "例: claude-3-5-sonnet-20241022",
|
||||||
|
"displayNamePlaceholder": "例: Claude 3.5 Sonnet",
|
||||||
|
"appType": "アプリ種別",
|
||||||
|
"allApps": "すべてのアプリ",
|
||||||
|
"statusCode": "ステータスコード",
|
||||||
|
"searchProviderPlaceholder": "プロバイダーを検索...",
|
||||||
|
"searchModelPlaceholder": "モデルを検索...",
|
||||||
|
"timeRange": "期間",
|
||||||
|
"input": "Input",
|
||||||
|
"output": "Output",
|
||||||
|
"cacheWrite": "Write",
|
||||||
|
"cacheRead": "Read"
|
||||||
},
|
},
|
||||||
"usageScript": {
|
"usageScript": {
|
||||||
"title": "利用状況を設定",
|
"title": "利用状況を設定",
|
||||||
@@ -438,7 +546,9 @@
|
|||||||
"tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください"
|
"tip3": "• 全体を () で囲み、オブジェクトリテラル式にしてください"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"usage_query_failed": "利用状況の取得に失敗しました"
|
"usage_query_failed": "利用状況の取得に失敗しました",
|
||||||
|
"configLoadFailedTitle": "設定の読み込みに失敗しました",
|
||||||
|
"configLoadFailedMessage": "設定ファイルを読み込めません:\n{{path}}\n\nエラー詳細:\n{{detail}}\n\nJSON が正しいか確認するか、同じディレクトリのバックアップファイル(config.json.bak など)から復元してください。\n\nアプリを終了して修正してください。"
|
||||||
},
|
},
|
||||||
"presetSelector": {
|
"presetSelector": {
|
||||||
"title": "設定タイプを選択",
|
"title": "設定タイプを選択",
|
||||||
@@ -837,12 +947,186 @@
|
|||||||
"label": "アイコン",
|
"label": "アイコン",
|
||||||
"colorLabel": "アイコンカラー",
|
"colorLabel": "アイコンカラー",
|
||||||
"selectIcon": "アイコンを選択",
|
"selectIcon": "アイコンを選択",
|
||||||
"preview": "プレビュー"
|
"preview": "プレビュー",
|
||||||
|
"clickToChange": "クリックでアイコンを変更",
|
||||||
|
"clickToSelect": "クリックでアイコンを選択"
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"success": "設定の移行が完了しました"
|
"success": "設定の移行が完了しました"
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"title": "エージェント"
|
"title": "エージェント"
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"operational": "正常",
|
||||||
|
"degraded": "低下",
|
||||||
|
"failed": "失敗",
|
||||||
|
"circuitOpen": "サーキットオープン",
|
||||||
|
"consecutiveFailures": "{{count}} 回連続失敗"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"panel": {
|
||||||
|
"serviceAddress": "サービスアドレス",
|
||||||
|
"addressCopied": "アドレスをコピーしました",
|
||||||
|
"currentProvider": "現在のプロバイダー:",
|
||||||
|
"waitingFirstRequest": "現在のプロバイダー: 最初のリクエスト待ち...",
|
||||||
|
"stoppedTitle": "プロキシサービス停止中",
|
||||||
|
"stoppedDescription": "右上のトグルでサービスを開始できます",
|
||||||
|
"openSettings": "プロキシサービスを設定",
|
||||||
|
"stats": {
|
||||||
|
"activeConnections": "アクティブ接続",
|
||||||
|
"totalRequests": "総リクエスト数",
|
||||||
|
"successRate": "成功率",
|
||||||
|
"uptime": "稼働時間"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "プロキシサービス設定",
|
||||||
|
"description": "ローカルプロキシサーバーのリッスンアドレス、ポート、実行パラメータを設定します。保存後すぐに反映されます。",
|
||||||
|
"alert": {
|
||||||
|
"autoApply": "変更は実行中のプロキシサービスに自動的に同期され、手動での再起動は不要です。"
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"title": "基本設定",
|
||||||
|
"description": "プロキシサービスのリッスンアドレスとポートを設定します。"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "詳細パラメータ",
|
||||||
|
"description": "リクエストの安定性とログ記録を制御します。"
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"title": "タイムアウト設定",
|
||||||
|
"description": "ストリーミングと非ストリーミングリクエストのタイムアウトを設定します。"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"listenAddress": {
|
||||||
|
"label": "リッスンアドレス",
|
||||||
|
"placeholder": "127.0.0.1",
|
||||||
|
"description": "プロキシサーバーがリッスンするIPアドレス(推奨: 127.0.0.1)"
|
||||||
|
},
|
||||||
|
"listenPort": {
|
||||||
|
"label": "リッスンポート",
|
||||||
|
"placeholder": "5000",
|
||||||
|
"description": "プロキシサーバーがリッスンするポート番号(1024 ~ 65535)"
|
||||||
|
},
|
||||||
|
"maxRetries": {
|
||||||
|
"label": "最大リトライ回数",
|
||||||
|
"placeholder": "3",
|
||||||
|
"description": "リクエスト失敗時のリトライ回数(0 ~ 10)"
|
||||||
|
},
|
||||||
|
"requestTimeout": {
|
||||||
|
"label": "リクエストタイムアウト(秒)",
|
||||||
|
"placeholder": "0(無制限)または 300",
|
||||||
|
"description": "単一リクエストの最大待機時間(0 = 無制限、または 10 ~ 600 秒)"
|
||||||
|
},
|
||||||
|
"enableLogging": {
|
||||||
|
"label": "ログ記録を有効化",
|
||||||
|
"description": "トラブルシューティングのためにすべてのプロキシリクエストを記録"
|
||||||
|
},
|
||||||
|
"streamingFirstByteTimeout": {
|
||||||
|
"label": "ストリーミング初回バイトタイムアウト(秒)",
|
||||||
|
"description": "最初のデータチャンクを待つ最大時間"
|
||||||
|
},
|
||||||
|
"streamingIdleTimeout": {
|
||||||
|
"label": "ストリーミングアイドルタイムアウト(秒)",
|
||||||
|
"description": "データチャンク間の最大間隔"
|
||||||
|
},
|
||||||
|
"nonStreamingTimeout": {
|
||||||
|
"label": "非ストリーミングタイムアウト(秒)",
|
||||||
|
"description": "非ストリーミングリクエストの総タイムアウト"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"addressInvalid": "有効なIPアドレスを入力してください",
|
||||||
|
"portMin": "ポートは1024より大きい必要があります",
|
||||||
|
"portMax": "ポートは65535より小さい必要があります",
|
||||||
|
"retryMin": "リトライ回数は負の値にできません",
|
||||||
|
"retryMax": "リトライ回数は10を超えることはできません",
|
||||||
|
"timeoutNonNegative": "タイムアウトは負の値にできません",
|
||||||
|
"timeoutMax": "タイムアウトは600秒を超えることはできません",
|
||||||
|
"timeoutRange": "0または10-600の間の値を入力してください",
|
||||||
|
"streamingTimeoutMin": "タイムアウトは少なくとも5秒必要です",
|
||||||
|
"streamingTimeoutMax": "タイムアウトは300秒を超えることはできません"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "設定を保存"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"saved": "プロキシ設定を保存しました",
|
||||||
|
"saveFailed": "保存に失敗しました: {{error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switchFailed": "切り替えに失敗しました: {{error}}",
|
||||||
|
"failoverQueue": {
|
||||||
|
"title": "フェイルオーバーキュー",
|
||||||
|
"description": "各アプリのプロバイダーのフェイルオーバー順序を管理します",
|
||||||
|
"info": "現在アクティブなプロバイダーが常に優先されます。リクエストが失敗すると、システムはキュー順に他のプロバイダーを試行します。",
|
||||||
|
"selectProvider": "キューに追加するプロバイダーを選択",
|
||||||
|
"noAvailableProviders": "追加できるプロバイダーがありません",
|
||||||
|
"empty": "フェイルオーバーキューが空です。自動フェイルオーバーを有効にするにはプロバイダーを追加してください。",
|
||||||
|
"dragHint": "ドラッグでフェイルオーバー順序を調整します。番号が小さいほど優先度が高くなります。",
|
||||||
|
"toggleEnabled": "有効/無効",
|
||||||
|
"addSuccess": "フェイルオーバーキューに追加しました",
|
||||||
|
"addFailed": "追加に失敗しました",
|
||||||
|
"removeSuccess": "フェイルオーバーキューから削除しました",
|
||||||
|
"removeFailed": "削除に失敗しました",
|
||||||
|
"reorderSuccess": "キュー順序を更新しました",
|
||||||
|
"reorderFailed": "順序の更新に失敗しました",
|
||||||
|
"toggleFailed": "状態の更新に失敗しました"
|
||||||
|
},
|
||||||
|
"autoFailover": {
|
||||||
|
"info": "フェイルオーバーキューに複数のプロバイダーが設定されている場合、リクエストが失敗すると優先度順に試行します。プロバイダーが連続失敗のしきい値に達すると、サーキットブレーカーが開き、一時的にスキップされます。",
|
||||||
|
"configSaved": "自動フェイルオーバー設定を保存しました",
|
||||||
|
"configSaveFailed": "保存に失敗しました",
|
||||||
|
"retrySettings": "リトライとタイムアウト設定",
|
||||||
|
"failureThreshold": "失敗しきい値",
|
||||||
|
"failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)",
|
||||||
|
"timeout": "回復待ち時間(秒)",
|
||||||
|
"timeoutHint": "サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)",
|
||||||
|
"circuitBreakerSettings": "サーキットブレーカー設定",
|
||||||
|
"successThreshold": "回復成功しきい値",
|
||||||
|
"successThresholdHint": "半開状態でこの回数成功するとサーキットブレーカーが閉じます",
|
||||||
|
"errorRate": "エラー率しきい値 (%)",
|
||||||
|
"errorRateHint": "この値を超えるとサーキットブレーカーが開きます",
|
||||||
|
"minRequests": "最小リクエスト数",
|
||||||
|
"minRequestsHint": "エラー率を計算する前の最小リクエスト数",
|
||||||
|
"explanationTitle": "仕組み",
|
||||||
|
"failureThresholdLabel": "失敗しきい値",
|
||||||
|
"failureThresholdExplain": "この回数連続で失敗すると、サーキットブレーカーが開き、プロバイダーは一時的に利用不可になります",
|
||||||
|
"timeoutLabel": "回復待ち時間",
|
||||||
|
"timeoutExplain": "サーキットが開いた後、半開状態を試みるまでの待ち時間",
|
||||||
|
"successThresholdLabel": "回復成功しきい値",
|
||||||
|
"successThresholdExplain": "半開状態でこの回数成功するとサーキットブレーカーが閉じ、プロバイダーが再び利用可能になります",
|
||||||
|
"errorRateLabel": "エラー率しきい値",
|
||||||
|
"errorRateExplain": "失敗しきい値に達していなくても、エラー率がこの値を超えるとサーキットブレーカーが開きます"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"streamCheck": {
|
||||||
|
"configSaved": "ヘルスチェック設定を保存しました",
|
||||||
|
"configSaveFailed": "保存に失敗しました",
|
||||||
|
"testModels": "テストモデル",
|
||||||
|
"claudeModel": "Claude モデル",
|
||||||
|
"codexModel": "Codex モデル",
|
||||||
|
"geminiModel": "Gemini モデル",
|
||||||
|
"checkParams": "チェックパラメーター",
|
||||||
|
"timeout": "タイムアウト(秒)",
|
||||||
|
"maxRetries": "最大リトライ回数",
|
||||||
|
"degradedThreshold": "劣化しきい値(ミリ秒)"
|
||||||
|
},
|
||||||
|
"proxyConfig": {
|
||||||
|
"proxyEnabled": "プロキシ有効",
|
||||||
|
"appTakeover": "プロキシ有効",
|
||||||
|
"perAppConfig": "アプリ別設定",
|
||||||
|
"circuitBreaker": "サーキットブレーカー",
|
||||||
|
"circuitBreakerSettings": "サーキットブレーカー設定",
|
||||||
|
"failureThreshold": "失敗閾値",
|
||||||
|
"successThreshold": "回復閾値",
|
||||||
|
"recoveryTimeout": "回復待機時間",
|
||||||
|
"errorRateThreshold": "エラー率閾値",
|
||||||
|
"minRequests": "最小リクエスト数",
|
||||||
|
"timeoutConfig": "タイムアウト設定",
|
||||||
|
"streamingFirstByte": "ストリーミング初回バイトタイムアウト",
|
||||||
|
"streamingIdle": "ストリーミングアイドルタイムアウト",
|
||||||
|
"nonStreaming": "非ストリーミングタイムアウト"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+247
-8
@@ -17,6 +17,7 @@
|
|||||||
"about": "关于",
|
"about": "关于",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
|
"notInstalled": "未安装",
|
||||||
"success": "成功",
|
"success": "成功",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
"unknown": "未知",
|
"unknown": "未知",
|
||||||
@@ -28,7 +29,14 @@
|
|||||||
"formatError": "格式化失败:{{error}}",
|
"formatError": "格式化失败:{{error}}",
|
||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"view": "查看",
|
"view": "查看",
|
||||||
"back": "返回"
|
"back": "返回",
|
||||||
|
"refresh": "刷新",
|
||||||
|
"refreshing": "刷新中...",
|
||||||
|
"all": "全部",
|
||||||
|
"search": "查询",
|
||||||
|
"reset": "重置",
|
||||||
|
"actions": "操作",
|
||||||
|
"deleting": "删除中..."
|
||||||
},
|
},
|
||||||
"apiKeyInput": {
|
"apiKeyInput": {
|
||||||
"placeholder": "请输入API Key",
|
"placeholder": "请输入API Key",
|
||||||
@@ -79,6 +87,12 @@
|
|||||||
"removeFromClaudePlugin": "从 Claude 插件移除",
|
"removeFromClaudePlugin": "从 Claude 插件移除",
|
||||||
"dragToReorder": "拖拽以重新排序",
|
"dragToReorder": "拖拽以重新排序",
|
||||||
"dragHandle": "拖拽排序",
|
"dragHandle": "拖拽排序",
|
||||||
|
"searchPlaceholder": "按名称/备注/网址搜索供应商...",
|
||||||
|
"searchAriaLabel": "搜索供应商",
|
||||||
|
"searchScopeHint": "根据名称、备注和官网链接匹配结果。",
|
||||||
|
"searchCloseHint": "按 Esc 关闭",
|
||||||
|
"searchCloseAriaLabel": "关闭供应商搜索",
|
||||||
|
"noSearchResults": "没有符合搜索条件的供应商。",
|
||||||
"duplicate": "复制",
|
"duplicate": "复制",
|
||||||
"sortUpdateFailed": "排序更新失败",
|
"sortUpdateFailed": "排序更新失败",
|
||||||
"configureUsage": "配置用量查询",
|
"configureUsage": "配置用量查询",
|
||||||
@@ -122,6 +136,8 @@
|
|||||||
"appliedToClaudePlugin": "已应用到 Claude 插件",
|
"appliedToClaudePlugin": "已应用到 Claude 插件",
|
||||||
"removedFromClaudePlugin": "已从 Claude 插件移除",
|
"removedFromClaudePlugin": "已从 Claude 插件移除",
|
||||||
"syncClaudePluginFailed": "同步 Claude 插件失败",
|
"syncClaudePluginFailed": "同步 Claude 插件失败",
|
||||||
|
"skipClaudeOnboardingFailed": "跳过 Claude Code 初次安装确认失败",
|
||||||
|
"clearClaudeOnboardingSkipFailed": "恢复 Claude Code 初次安装确认失败",
|
||||||
"updateSuccess": "供应商更新成功",
|
"updateSuccess": "供应商更新成功",
|
||||||
"updateFailed": "更新供应商失败:{{error}}",
|
"updateFailed": "更新供应商失败:{{error}}",
|
||||||
"deleteSuccess": "供应商已删除",
|
"deleteSuccess": "供应商已删除",
|
||||||
@@ -138,6 +154,34 @@
|
|||||||
"general": "通用",
|
"general": "通用",
|
||||||
"tabGeneral": "通用",
|
"tabGeneral": "通用",
|
||||||
"tabAdvanced": "高级",
|
"tabAdvanced": "高级",
|
||||||
|
"advanced": {
|
||||||
|
"configDir": {
|
||||||
|
"title": "配置文件目录",
|
||||||
|
"description": "管理 Claude、Codex 和 Gemini 的配置存储路径"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"title": "本地代理",
|
||||||
|
"description": "控制代理服务开关、查看状态与端口信息",
|
||||||
|
"running": "运行中",
|
||||||
|
"stopped": "已停止"
|
||||||
|
},
|
||||||
|
"modelTest": {
|
||||||
|
"title": "模型测试配置",
|
||||||
|
"description": "配置模型测试使用的默认模型和提示词"
|
||||||
|
},
|
||||||
|
"failover": {
|
||||||
|
"title": "自动故障转移",
|
||||||
|
"description": "配置故障转移队列和熔断策略"
|
||||||
|
},
|
||||||
|
"pricing": {
|
||||||
|
"title": "成本定价",
|
||||||
|
"description": "管理各模型 Token 计费规则"
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"title": "数据管理",
|
||||||
|
"description": "导入导出配置与备份恢复"
|
||||||
|
}
|
||||||
|
},
|
||||||
"language": "界面语言",
|
"language": "界面语言",
|
||||||
"languageHint": "切换后立即预览界面语言,保存后永久生效。",
|
"languageHint": "切换后立即预览界面语言,保存后永久生效。",
|
||||||
"theme": "外观主题",
|
"theme": "外观主题",
|
||||||
@@ -146,7 +190,7 @@
|
|||||||
"themeDark": "深色",
|
"themeDark": "深色",
|
||||||
"themeSystem": "跟随系统",
|
"themeSystem": "跟随系统",
|
||||||
"importExport": "SQL 导入导出",
|
"importExport": "SQL 导入导出",
|
||||||
"importExportHint": "导入/导出数据库 SQL 备份,便于备份或迁移。",
|
"importExportHint": "导入/导出数据库 SQL 备份(仅支持导入由 CC Switch 导出的备份),便于备份或迁移。",
|
||||||
"exportConfig": "导出 SQL 备份",
|
"exportConfig": "导出 SQL 备份",
|
||||||
"selectConfigFile": "选择 SQL 文件",
|
"selectConfigFile": "选择 SQL 文件",
|
||||||
"noFileSelected": "尚未选择配置文件。",
|
"noFileSelected": "尚未选择配置文件。",
|
||||||
@@ -162,7 +206,7 @@
|
|||||||
"selectFileFailed": "请选择有效的 SQL 备份文件",
|
"selectFileFailed": "请选择有效的 SQL 备份文件",
|
||||||
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
|
"configCorrupted": "SQL 文件可能已损坏或格式不正确",
|
||||||
"backupId": "备份ID",
|
"backupId": "备份ID",
|
||||||
"autoReload": "数据将在2秒后自动刷新...",
|
"autoReload": "数据已刷新",
|
||||||
"languageOptionChinese": "中文",
|
"languageOptionChinese": "中文",
|
||||||
"languageOptionEnglish": "English",
|
"languageOptionEnglish": "English",
|
||||||
"languageOptionJapanese": "日本語",
|
"languageOptionJapanese": "日本語",
|
||||||
@@ -175,6 +219,8 @@
|
|||||||
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
"minimizeToTrayDescription": "勾选后点击关闭按钮会隐藏到系统托盘,取消则直接退出应用。",
|
||||||
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
|
"enableClaudePluginIntegration": "应用到 Claude Code 插件",
|
||||||
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
|
"enableClaudePluginIntegrationDescription": "开启后 Vscode Claude Code 插件的供应商将随本软件切换",
|
||||||
|
"skipClaudeOnboarding": "跳过 Claude Code 初次安装确认",
|
||||||
|
"skipClaudeOnboardingDescription": "开启后跳过 Claude Code 初次安装确认",
|
||||||
"configDirectoryOverride": "配置目录覆盖(高级)",
|
"configDirectoryOverride": "配置目录覆盖(高级)",
|
||||||
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
"configDirectoryDescription": "在 WSL 等环境使用 Claude Code 或 Codex 的时候,可手动指定为 WSL 里的配置目录,供应商数据与主环境保持一致。",
|
||||||
"appConfigDir": "CC Switch 配置目录",
|
"appConfigDir": "CC Switch 配置目录",
|
||||||
@@ -205,6 +251,11 @@
|
|||||||
"releaseNotes": "更新日志",
|
"releaseNotes": "更新日志",
|
||||||
"viewReleaseNotes": "查看该版本更新日志",
|
"viewReleaseNotes": "查看该版本更新日志",
|
||||||
"viewCurrentReleaseNotes": "查看当前版本更新日志",
|
"viewCurrentReleaseNotes": "查看当前版本更新日志",
|
||||||
|
"oneClickInstall": "一键安装",
|
||||||
|
"oneClickInstallHint": "安装 Claude Code / Codex / Gemini CLI",
|
||||||
|
"localEnvCheck": "本地环境检查",
|
||||||
|
"installCommandsCopied": "安装命令已复制",
|
||||||
|
"installCommandsCopyFailed": "复制失败,请手动复制。",
|
||||||
"importFailedError": "导入配置失败:{{message}}",
|
"importFailedError": "导入配置失败:{{message}}",
|
||||||
"exportFailedError": "导出配置失败:",
|
"exportFailedError": "导出配置失败:",
|
||||||
"restartRequired": "需要重启应用",
|
"restartRequired": "需要重启应用",
|
||||||
@@ -264,7 +315,8 @@
|
|||||||
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
|
"zhipu": "智谱 GLM 是 CC Switch 的官方合作伙伴,使用此链接充值可以获得9折优惠",
|
||||||
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
|
"packycode": "PackyCode 是 CC Switch 的官方合作伙伴,使用此链接注册并在充值时填写 \"cc-switch\" 优惠码,可以享受9折优惠",
|
||||||
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
|
"minimax_cn": "MiniMax Coding Plan 特惠,Starter 套餐 9.9 元起",
|
||||||
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)"
|
"minimax_en": "MiniMax Coding Plan 黑五特惠,Starter 套餐现仅 $2/月(2折优惠!)",
|
||||||
|
"dmxapi": "Claude Code 专属模型 3.4 折优惠进行中!"
|
||||||
},
|
},
|
||||||
"parameterConfig": "参数配置 - {{name}} *",
|
"parameterConfig": "参数配置 - {{name}} *",
|
||||||
"mainModel": "主模型 (可选)",
|
"mainModel": "主模型 (可选)",
|
||||||
@@ -355,6 +407,50 @@
|
|||||||
"hint": "选择预设后可继续调整下方字段。"
|
"hint": "选择预设后可继续调整下方字段。"
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
|
"title": "使用统计",
|
||||||
|
"subtitle": "查看 AI 模型的使用情况和成本统计",
|
||||||
|
"today": "24小时",
|
||||||
|
"last7days": "7天",
|
||||||
|
"last30days": "30天",
|
||||||
|
"totalRequests": "总请求数",
|
||||||
|
"totalCost": "总成本",
|
||||||
|
"cost": "成本",
|
||||||
|
"perMillion": "(每百万)",
|
||||||
|
"trends": "使用趋势",
|
||||||
|
"rangeToday": "今天 (按小时)",
|
||||||
|
"rangeLast7Days": "过去 7 天",
|
||||||
|
"rangeLast30Days": "过去 30 天",
|
||||||
|
"totalTokens": "总 Token 数",
|
||||||
|
"cacheTokens": "缓存 Token",
|
||||||
|
"requestLogs": "请求日志",
|
||||||
|
"providerStats": "Provider 统计",
|
||||||
|
"modelStats": "模型统计",
|
||||||
|
"time": "时间",
|
||||||
|
"provider": "供应商",
|
||||||
|
"billingModel": "计费模型",
|
||||||
|
"inputTokens": "输入",
|
||||||
|
"outputTokens": "输出",
|
||||||
|
"cacheReadTokens": "缓存读取",
|
||||||
|
"cacheCreationTokens": "缓存写入",
|
||||||
|
"timingInfo": "用时/首字",
|
||||||
|
"status": "状态",
|
||||||
|
"noData": "暂无数据",
|
||||||
|
"unknownProvider": "未知供应商",
|
||||||
|
"stream": "流",
|
||||||
|
"nonStream": "非流",
|
||||||
|
"totalRecords": "共 {{total}} 条记录",
|
||||||
|
"modelPricing": "模型定价",
|
||||||
|
"loadPricingError": "加载定价数据失败",
|
||||||
|
"modelPricingDesc": "配置各模型的 Token 成本",
|
||||||
|
"noPricingData": "暂无定价数据。点击\"新增\"添加模型定价配置。",
|
||||||
|
"model": "模型",
|
||||||
|
"displayName": "显示名称",
|
||||||
|
"inputCost": "输入成本",
|
||||||
|
"outputCost": "输出成本",
|
||||||
|
"cacheReadCost": "缓存读取",
|
||||||
|
"cacheWriteCost": "缓存写入",
|
||||||
|
"deleteConfirmTitle": "确认删除",
|
||||||
|
"deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。",
|
||||||
"queryFailed": "查询失败",
|
"queryFailed": "查询失败",
|
||||||
"refreshUsage": "刷新用量",
|
"refreshUsage": "刷新用量",
|
||||||
"planUsage": "套餐用量",
|
"planUsage": "套餐用量",
|
||||||
@@ -368,7 +464,19 @@
|
|||||||
"daysAgo": "{{count}} 天前",
|
"daysAgo": "{{count}} 天前",
|
||||||
"multiplePlans": "{{count}} 个套餐",
|
"multiplePlans": "{{count}} 个套餐",
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"collapse": "收起"
|
"collapse": "收起",
|
||||||
|
"modelIdPlaceholder": "例如: claude-3-5-sonnet-20241022",
|
||||||
|
"displayNamePlaceholder": "例如: Claude 3.5 Sonnet",
|
||||||
|
"appType": "应用类型",
|
||||||
|
"allApps": "全部应用",
|
||||||
|
"statusCode": "状态码",
|
||||||
|
"searchProviderPlaceholder": "搜索供应商...",
|
||||||
|
"searchModelPlaceholder": "搜索模型...",
|
||||||
|
"timeRange": "时间范围",
|
||||||
|
"input": "Input",
|
||||||
|
"output": "Output",
|
||||||
|
"cacheWrite": "Write",
|
||||||
|
"cacheRead": "Read"
|
||||||
},
|
},
|
||||||
"usageScript": {
|
"usageScript": {
|
||||||
"title": "配置用量查询",
|
"title": "配置用量查询",
|
||||||
@@ -438,7 +546,9 @@
|
|||||||
"tip3": "• 整个配置必须用 () 包裹,形成对象字面量表达式"
|
"tip3": "• 整个配置必须用 () 包裹,形成对象字面量表达式"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"usage_query_failed": "用量查询失败"
|
"usage_query_failed": "用量查询失败",
|
||||||
|
"configLoadFailedTitle": "配置加载失败",
|
||||||
|
"configLoadFailedMessage": "无法读取配置文件:\n{{path}}\n\n错误详情:\n{{detail}}\n\n请手动检查 JSON 是否有效,或从同目录的备份文件(如 config.json.bak)恢复。\n\n应用将退出以便您进行修复。"
|
||||||
},
|
},
|
||||||
"presetSelector": {
|
"presetSelector": {
|
||||||
"title": "选择配置类型",
|
"title": "选择配置类型",
|
||||||
@@ -837,7 +947,9 @@
|
|||||||
"label": "图标",
|
"label": "图标",
|
||||||
"colorLabel": "图标颜色",
|
"colorLabel": "图标颜色",
|
||||||
"selectIcon": "选择图标",
|
"selectIcon": "选择图标",
|
||||||
"preview": "预览"
|
"preview": "预览",
|
||||||
|
"clickToChange": "点击更换图标",
|
||||||
|
"clickToSelect": "点击选择图标"
|
||||||
},
|
},
|
||||||
"migration": {
|
"migration": {
|
||||||
"success": "配置迁移成功"
|
"success": "配置迁移成功"
|
||||||
@@ -845,7 +957,106 @@
|
|||||||
"agents": {
|
"agents": {
|
||||||
"title": "智能体"
|
"title": "智能体"
|
||||||
},
|
},
|
||||||
|
"health": {
|
||||||
|
"operational": "正常",
|
||||||
|
"degraded": "降级",
|
||||||
|
"failed": "失败",
|
||||||
|
"circuitOpen": "熔断",
|
||||||
|
"consecutiveFailures": "连续失败 {{count}} 次"
|
||||||
|
},
|
||||||
"proxy": {
|
"proxy": {
|
||||||
|
"panel": {
|
||||||
|
"serviceAddress": "服务地址",
|
||||||
|
"addressCopied": "地址已复制",
|
||||||
|
"currentProvider": "当前 Provider:",
|
||||||
|
"waitingFirstRequest": "当前 Provider:等待首次请求…",
|
||||||
|
"stoppedTitle": "代理服务已停止",
|
||||||
|
"stoppedDescription": "使用右上角开关即可启动服务",
|
||||||
|
"openSettings": "配置代理服务",
|
||||||
|
"stats": {
|
||||||
|
"activeConnections": "活跃连接",
|
||||||
|
"totalRequests": "总请求数",
|
||||||
|
"successRate": "成功率",
|
||||||
|
"uptime": "运行时间"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "代理服务设置",
|
||||||
|
"description": "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。",
|
||||||
|
"alert": {
|
||||||
|
"autoApply": "保存后将自动同步到正在运行的代理服务,无需手动重启。"
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"title": "基础设置",
|
||||||
|
"description": "配置代理服务监听的地址与端口。"
|
||||||
|
},
|
||||||
|
"advanced": {
|
||||||
|
"title": "高级参数",
|
||||||
|
"description": "控制请求的稳定性和日志记录。"
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"title": "超时设置",
|
||||||
|
"description": "配置流式和非流式请求的超时时间。"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"listenAddress": {
|
||||||
|
"label": "监听地址",
|
||||||
|
"placeholder": "127.0.0.1",
|
||||||
|
"description": "代理服务器监听的 IP 地址(推荐 127.0.0.1)"
|
||||||
|
},
|
||||||
|
"listenPort": {
|
||||||
|
"label": "监听端口",
|
||||||
|
"placeholder": "5000",
|
||||||
|
"description": "代理服务器监听的端口号(1024 ~ 65535)"
|
||||||
|
},
|
||||||
|
"maxRetries": {
|
||||||
|
"label": "最大重试次数",
|
||||||
|
"placeholder": "3",
|
||||||
|
"description": "请求失败时的重试次数(0 ~ 10)"
|
||||||
|
},
|
||||||
|
"requestTimeout": {
|
||||||
|
"label": "请求超时(秒)",
|
||||||
|
"placeholder": "0(不限)或 300",
|
||||||
|
"description": "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)"
|
||||||
|
},
|
||||||
|
"enableLogging": {
|
||||||
|
"label": "启用日志记录",
|
||||||
|
"description": "记录所有代理请求,便于排查问题"
|
||||||
|
},
|
||||||
|
"streamingFirstByteTimeout": {
|
||||||
|
"label": "流式首字超时(秒)",
|
||||||
|
"description": "等待首个数据块的最大时间"
|
||||||
|
},
|
||||||
|
"streamingIdleTimeout": {
|
||||||
|
"label": "流式静默超时(秒)",
|
||||||
|
"description": "数据块之间的最大间隔"
|
||||||
|
},
|
||||||
|
"nonStreamingTimeout": {
|
||||||
|
"label": "非流式超时(秒)",
|
||||||
|
"description": "非流式请求的总超时时间"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"addressInvalid": "请输入有效的IP地址",
|
||||||
|
"portMin": "端口必须大于1024",
|
||||||
|
"portMax": "端口必须小于65535",
|
||||||
|
"retryMin": "重试次数不能为负",
|
||||||
|
"retryMax": "重试次数不能超过10",
|
||||||
|
"timeoutNonNegative": "超时时间不能为负数",
|
||||||
|
"timeoutMax": "超时时间最多600秒",
|
||||||
|
"timeoutRange": "请输入 0 或 10-600 之间的数值",
|
||||||
|
"streamingTimeoutMin": "超时时间至少5秒",
|
||||||
|
"streamingTimeoutMax": "超时时间最多300秒"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"save": "保存配置"
|
||||||
|
},
|
||||||
|
"toast": {
|
||||||
|
"saved": "代理配置已保存",
|
||||||
|
"saveFailed": "保存失败: {{error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"switchFailed": "切换失败: {{error}}",
|
||||||
"failoverQueue": {
|
"failoverQueue": {
|
||||||
"title": "故障转移队列",
|
"title": "故障转移队列",
|
||||||
"description": "管理各应用的供应商故障转移顺序",
|
"description": "管理各应用的供应商故障转移顺序",
|
||||||
@@ -872,7 +1083,7 @@
|
|||||||
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
|
"failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)",
|
||||||
"timeout": "恢复等待时间(秒)",
|
"timeout": "恢复等待时间(秒)",
|
||||||
"timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
|
"timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)",
|
||||||
"circuitBreakerSettings": "熔断器高级设置",
|
"circuitBreakerSettings": "熔断器设置",
|
||||||
"successThreshold": "恢复成功阈值",
|
"successThreshold": "恢复成功阈值",
|
||||||
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
|
"successThresholdHint": "半开状态下成功多少次后关闭熔断器",
|
||||||
"errorRate": "错误率阈值 (%)",
|
"errorRate": "错误率阈值 (%)",
|
||||||
@@ -889,5 +1100,33 @@
|
|||||||
"errorRateLabel": "错误率阈值",
|
"errorRateLabel": "错误率阈值",
|
||||||
"errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器"
|
"errorRateExplain": "错误率超过此值时,即使未达到失败阈值也会打开熔断器"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"streamCheck": {
|
||||||
|
"configSaved": "健康检查配置已保存",
|
||||||
|
"configSaveFailed": "保存失败",
|
||||||
|
"testModels": "测试模型",
|
||||||
|
"claudeModel": "Claude 模型",
|
||||||
|
"codexModel": "Codex 模型",
|
||||||
|
"geminiModel": "Gemini 模型",
|
||||||
|
"checkParams": "检查参数",
|
||||||
|
"timeout": "超时时间(秒)",
|
||||||
|
"maxRetries": "最大重试次数",
|
||||||
|
"degradedThreshold": "降级阈值(毫秒)"
|
||||||
|
},
|
||||||
|
"proxyConfig": {
|
||||||
|
"proxyEnabled": "代理总开关",
|
||||||
|
"appTakeover": "代理启用",
|
||||||
|
"perAppConfig": "应用配置",
|
||||||
|
"circuitBreaker": "熔断器配置",
|
||||||
|
"circuitBreakerSettings": "熔断器设置",
|
||||||
|
"failureThreshold": "失败阈值",
|
||||||
|
"successThreshold": "恢复阈值",
|
||||||
|
"recoveryTimeout": "恢复等待时间",
|
||||||
|
"errorRateThreshold": "错误率阈值",
|
||||||
|
"minRequests": "最小请求数",
|
||||||
|
"timeoutConfig": "超时配置",
|
||||||
|
"streamingFirstByte": "流式首字节超时",
|
||||||
|
"streamingIdle": "流式静默超时",
|
||||||
|
"nonStreaming": "非流式超时"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -45,6 +45,10 @@ export const icons: Record<string, string> = {
|
|||||||
yi: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>`,
|
yi: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Yi</title><path d="M18.62 13.927c.611 0 1.107.505 1.107 1.128v5.817c0 .623-.496 1.128-1.108 1.128a1.118 1.118 0 01-1.108-1.128v-5.817c0-.623.496-1.128 1.108-1.128zM16.59 3.052a1.094 1.094 0 011.562-.129c.466.404.522 1.116.126 1.59l-5.938 7.111v9.147c0 .624-.496 1.129-1.108 1.129a1.118 1.118 0 01-1.108-1.129v-9.477l.003-.088.01-.087c.015-.232.102-.462.261-.654l6.192-7.413zM2.906 2.256a1.094 1.094 0 011.559.157l4.387 5.45a1.142 1.142 0 01-.155 1.587 1.094 1.094 0 01-1.559-.157l-4.387-5.45a1.144 1.144 0 01.06-1.498l.095-.09z"></path><ellipse cx="20.146" cy="10.692" fill="#00FF25" rx="1.354" ry="1.379"></ellipse></svg>`,
|
||||||
zeroone: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`,
|
zeroone: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>01.AI</title><path d="M5.246 12c0 .837-.086 1.554-.257 2.151-.172.598-.45 1.055-.837 1.373-.386.317-.898.476-1.534.476-.901 0-1.563-.353-1.985-1.059C.211 14.235 0 13.255 0 12c0-.837.086-1.554.257-2.151.172-.598.45-1.055.832-1.373C1.472 8.16 1.981 8 2.618 8c.894 0 1.555.351 1.985 1.053.429.702.643 1.685.643 2.947zm-3.883 0c0 .956.09 1.668.273 2.134.183.467.51.7.982.7.465 0 .792-.23.981-.694.19-.463.285-1.176.285-2.14 0-.956-.095-1.668-.285-2.134-.19-.467-.516-.7-.981-.7-.472 0-.8.233-.982.7-.182.466-.273 1.178-.273 2.134zm8.52 3.771H8.517l.011-6.295-1.823.324V8.571l2.04-.457h1.136v7.657zm2.497-1.6h.543c.3 0 .543.256.543.572v.571a.558.558 0 01-.543.572h-.543a.558.558 0 01-.543-.572v-.571c0-.316.243-.572.543-.572zm10.317-6.057H24v7.772h-1.303V8.114zm-3.692 0l2.606 7.772h-1.303l-.69-2.058h-3.073l-.69 2.058h-1.303l2.606-7.772h1.847zm.191 4.457l-1.115-3.323-1.114 3.323h2.23z"></path></svg>`,
|
||||||
zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`,
|
zhipu: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z" fill="#3859FF" fill-rule="nonzero"></path></svg>`,
|
||||||
|
openrouter: `<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>`,
|
||||||
|
longcat: `<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>`,
|
||||||
|
modelscope: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>`,
|
||||||
|
aihubmix: `<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>AiHubMix</title><path d="M12 24c6.627 0 12-5.373 12-12S18.627 0 12 0 0 5.373 0 12s5.373 12 12 12z" fill="#006FFB"></path><path clip-rule="evenodd" d="M11.24 8.393c.095-.644.302-1.47.624-2.48L12 5.496l.136.417c.322 1.01.53 1.836.624 2.48.071.472.071 1.072 0 1.8-.072.731-.072 1.336 0 1.814.106.7.426 1.281.96 1.744a2.795 2.795 0 001.89.708 2.78 2.78 0 002.034-.84c.56-.559.842-1.234.848-2.024.003-.7.075-1.472.216-2.316.069-.422.14-.775.21-1.06l.095-.384.168.356a7.862 7.862 0 01.76 3.244v.16a7.84 7.84 0 01-.624 3.089 7.952 7.952 0 01-4.228 4.228 7.841 7.841 0 01-3.089.623 7.84 7.84 0 01-3.089-.623 7.952 7.952 0 01-4.228-4.228 7.84 7.84 0 01-.623-3.09v-.159a7.862 7.862 0 01.759-3.244l.169-.356.093.385c.072.284.143.637.211 1.059.141.844.213 1.616.216 2.316.006.79.29 1.465.848 2.024.563.56 1.241.84 2.035.84.715 0 1.345-.236 1.889-.708a2.79 2.79 0 00.96-1.744c.073-.478.073-1.083 0-1.814-.071-.728-.071-1.328 0-1.8zm.76 9.694c1.097 0 2.125-.26 3.085-.778a6.379 6.379 0 001.77-1.399c.063-.07-.01-.178-.101-.153-.37.1-.75.15-1.144.15a4.236 4.236 0 01-2.18-.59 4.253 4.253 0 01-1.35-1.233.099.099 0 00-.16 0 4.253 4.253 0 01-1.35 1.232 4.236 4.236 0 01-2.18.591c-.393 0-.774-.05-1.143-.15-.091-.025-.165.083-.102.153a6.38 6.38 0 001.77 1.399c.96.518 1.988.778 3.085.778z" fill="#fff" fill-rule="evenodd"></path></svg>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const iconList = Object.keys(icons);
|
export const iconList = Object.keys(icons);
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LongCat</title><path clip-rule="evenodd" d="M.507 19.883a.507.507 0 01-.489-.642L4.29 3.745a1.013 1.013 0 011.533-.578l5.622 3.687a1.013 1.013 0 001.11 0L18.2 3.165a1.013 1.013 0 011.532.58l4.25 15.497a.506.506 0 01-.49.64H18.07a6.297 6.297 0 001.53-4.115v-.177a6.09 6.09 0 00-1.513-4.017l-.697-3.495a.438.438 0 00-.694-.266L14.07 9.781a.748.748 0 01-.654.121 5.156 5.156 0 00-2.833 0 .746.746 0 01-.653-.121L7.302 7.81a.435.435 0 00-.688.269l-.675 3.652a5.36 5.36 0 00-1.539 3.76v.333c0 1.474.527 2.9 1.488 4.02l.032.038H.507z" fill="#29E154" fill-rule="evenodd"></path><path d="M9.213 16.843h1.52v-3.546h-1.29l-.23 3.546zm5.573 0h-1.52v-3.546h1.29l.23 3.546z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 819 B |
@@ -303,6 +303,34 @@ export const iconMetadata: Record<string, IconMetadata> = {
|
|||||||
keywords: ["chatglm", "glm"],
|
keywords: ["chatglm", "glm"],
|
||||||
defaultColor: "#0F62FE",
|
defaultColor: "#0F62FE",
|
||||||
},
|
},
|
||||||
|
openrouter: {
|
||||||
|
name: "openrouter",
|
||||||
|
displayName: "OpenRouter",
|
||||||
|
category: "ai-provider",
|
||||||
|
keywords: ["openrouter", "router", "aggregator"],
|
||||||
|
defaultColor: "#6566F1",
|
||||||
|
},
|
||||||
|
longcat: {
|
||||||
|
name: "longcat",
|
||||||
|
displayName: "LongCat",
|
||||||
|
category: "ai-provider",
|
||||||
|
keywords: ["longcat", "long", "cat"],
|
||||||
|
defaultColor: "#29E154",
|
||||||
|
},
|
||||||
|
modelscope: {
|
||||||
|
name: "modelscope",
|
||||||
|
displayName: "ModelScope",
|
||||||
|
category: "ai-provider",
|
||||||
|
keywords: ["modelscope", "alibaba", "scope"],
|
||||||
|
defaultColor: "#624AFF",
|
||||||
|
},
|
||||||
|
aihubmix: {
|
||||||
|
name: "aihubmix",
|
||||||
|
displayName: "AiHubMix",
|
||||||
|
category: "ai-provider",
|
||||||
|
keywords: ["aihubmix", "hub", "mix", "aggregator"],
|
||||||
|
defaultColor: "#006FFB",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getIconMetadata(name: string): IconMetadata | undefined {
|
export function getIconMetadata(name: string): IconMetadata | undefined {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>ModelScope</title><path d="M0 7.967h2.667v2.667H0zM8 10.633h2.667V13.3H8z" fill="#36CED0"></path><path d="M0 10.633h2.667V13.3H0zM2.667 13.3h2.666v2.667H8v2.666H2.667V13.3zM2.667 5.3H8v2.667H5.333v2.666H2.667V5.3zM10.667 13.3h2.667v2.667h-2.667z" fill="#624AFF"></path><path d="M24 7.967h-2.667v2.667H24zM16 10.633h-2.667V13.3H16z" fill="#36CED0"></path><path d="M24 10.633h-2.667V13.3H24zM21.333 13.3h-2.666v2.667H16v2.666h5.333V13.3zM21.333 5.3H16v2.667h2.667v2.666h2.666V5.3z" fill="#624AFF"></path></svg>
|
||||||
|
After Width: | Height: | Size: 632 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenRouter</title><path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 906 B |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user