diff --git a/src-tauri/src/commands/failover.rs b/src-tauri/src/commands/failover.rs index 83558514c..4c1e71d56 100644 --- a/src-tauri/src/commands/failover.rs +++ b/src-tauri/src/commands/failover.rs @@ -56,21 +56,21 @@ pub async fn remove_from_failover_queue( .map_err(|e| e.to_string()) } -/// 获取指定应用的自动故障转移开关状态 +/// 获取指定应用的自动故障转移开关状态(从 proxy_config 表读取) #[tauri::command] pub async fn get_auto_failover_enabled( state: tauri::State<'_, AppState>, app_type: String, ) -> Result { - let key = format!("auto_failover_enabled_{app_type}"); state .db - .get_setting(&key) - .map(|v| v.map(|s| s == "true").unwrap_or(false)) // 默认关闭 + .get_proxy_config_for_app(&app_type) + .await + .map(|config| config.auto_failover_enabled) .map_err(|e| e.to_string()) } -/// 设置指定应用的自动故障转移开关状态 +/// 设置指定应用的自动故障转移开关状态(写入 proxy_config 表) /// /// 注意:关闭故障转移时不会清除队列,队列内容会保留供下次开启时使用 #[tauri::command] @@ -79,12 +79,24 @@ pub async fn set_auto_failover_enabled( app_type: String, enabled: bool, ) -> Result<(), String> { - let key = format!("auto_failover_enabled_{app_type}"); - let value = if enabled { "true" } else { "false" }; - log::info!( - "[Failover] Setting auto_failover_enabled: key='{key}', value='{value}', app_type='{app_type}'" + "[Failover] Setting auto_failover_enabled: app_type='{app_type}', enabled={enabled}" ); - state.db.set_setting(&key, value).map_err(|e| e.to_string()) + // 读取当前配置 + 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 + .db + .update_proxy_config_for_app(config) + .await + .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 27ce31c00..37be402cf 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -62,6 +62,63 @@ pub async fn update_proxy_config( 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 { + 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 { + 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] pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result { @@ -126,19 +183,12 @@ pub async fn reset_circuit_breaker( .reset_provider_circuit_breaker(&provider_id, &app_type) .await?; - // 3. 检查是否应该切回优先级更高的供应商 - let failover_key = format!("auto_failover_enabled_{app_type}"); - let auto_failover_enabled = match db.get_setting(&failover_key) { - Ok(Some(value)) => value == "true", - Ok(None) => { - log::debug!( - "[{app_type}] Failover setting '{failover_key}' not found, defaulting to disabled" - ); - false - } + // 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 failover setting '{failover_key}': {e}, defaulting to disabled" + "[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" ); false } diff --git a/src-tauri/src/database/dao/proxy.rs b/src-tauri/src/database/dao/proxy.rs index e2573df7d..886fbc528 100644 --- a/src-tauri/src/database/dao/proxy.rs +++ b/src-tauri/src/database/dao/proxy.rs @@ -8,62 +8,66 @@ use crate::proxy::types::*; use super::super::{lock_conn, Database}; impl Database { - // ==================== Proxy Config ==================== + // ==================== Global Proxy Config ==================== - /// 获取代理配置 - pub async fn get_proxy_config(&self) -> Result { - // 在一个作用域内获取锁并查询,确保锁在await之前释放 + /// 获取全局代理配置(统一字段) + /// + /// 从 claude 行读取(三行镜像一致) + pub async fn get_global_proxy_config(&self) -> Result { + // 使用 block 限制 conn 的作用域,避免跨 await 持有锁 let result = { let conn = lock_conn!(self.conn); conn.query_row( - "SELECT listen_address, listen_port, max_retries, - request_timeout, enable_logging, live_takeover_active - FROM proxy_config WHERE id = 1", + "SELECT proxy_enabled, listen_address, listen_port, enable_logging + 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: row.get::<_, i32>(3)? as u64, - enable_logging: row.get::<_, i32>(4)? != 0, - live_takeover_active: row.get::<_, i32>(5).unwrap_or(0) != 0, + Ok(GlobalProxyConfig { + proxy_enabled: row.get::<_, i32>(0)? != 0, + listen_address: row.get(1)?, + listen_port: row.get::<_, i32>(2)? as u16, + enable_logging: row.get::<_, i32>(3)? != 0, }) }, ) - }; // conn锁在这里释放 + }; + // conn 已在 block 结束时释放 match result { Ok(config) => Ok(config), Err(rusqlite::Error::QueryReturnedNoRows) => { - // 如果不存在,插入默认配置 - let default_config = ProxyConfig::default(); - self.update_proxy_config(default_config.clone()).await?; - Ok(default_config) + // 如果不存在,创建默认配置 + self.init_proxy_config_rows().await?; + Ok(GlobalProxyConfig { + 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())), } } - /// 更新代理配置 - 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); conn.execute( - "INSERT OR REPLACE INTO proxy_config - (id, enabled, listen_address, listen_port, max_retries, request_timeout, enable_logging, live_takeover_active, target_app, created_at, updated_at) - VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, - COALESCE((SELECT created_at FROM proxy_config WHERE id = 1), datetime('now')), - datetime('now'))", + "UPDATE proxy_config SET + proxy_enabled = ?1, + listen_address = ?2, + listen_port = ?3, + enable_logging = ?4, + updated_at = datetime('now')", rusqlite::params![ - 0, // 已移除自动启用逻辑,保留列但固定为 0 + if config.proxy_enabled { 1 } else { 0 }, config.listen_address, config.listen_port as i32, - config.max_retries as i32, - config.request_timeout as i32, if config.enable_logging { 1 } else { 0 }, - if config.live_takeover_active { 1 } else { 0 }, - "claude", // 兼容旧字段,写入默认值 ], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -71,27 +75,214 @@ impl Database { Ok(()) } - /// 设置 Live 接管状态(仅更新 proxy_config 表,兼容旧逻辑) - /// - /// 注意:此方法不会清除 settings 表中的 proxy_takeover_* 状态。 - /// settings 表的状态由 set_proxy_takeover_enabled 单独管理,用于跨重启保持状态。 - pub async fn set_live_takeover_active(&self, active: bool) -> Result<(), AppError> { - // 仅更新 proxy_config 表(兼容旧版本) + /// 获取应用级代理配置 + pub async fn get_proxy_config_for_app( + &self, + app_type: &str, + ) -> Result { + // 使用 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); + conn.execute( - "UPDATE proxy_config SET live_takeover_active = ?1, updated_at = datetime('now') WHERE id = 1", - rusqlite::params![if active { 1 } else { 0 }], + "UPDATE proxy_config SET + 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()))?; 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 { + // 使用 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(()) + } + /// 检查是否处于 Live 接管模式 /// - /// v3.8.0+:以 settings 表中的 `proxy_takeover_{app_type}` 为真实来源 + /// 检查是否有任一 app 的 enabled = true pub async fn is_live_takeover_active(&self) -> Result { - self.has_any_proxy_takeover() + let conn = lock_conn!(self.conn); + 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 ==================== @@ -270,19 +461,22 @@ impl Database { 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( &self, ) -> Result { - let conn = lock_conn!(self.conn); - - let config = conn - .query_row( - "SELECT failure_threshold, success_threshold, timeout_seconds, - error_rate_threshold, min_requests - FROM circuit_breaker_config WHERE id = 1", + // 使用 block 限制 conn 的作用域,避免跨 await 持有锁 + let result = { + let conn = lock_conn!(self.conn); + conn.query_row( + "SELECT circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, + circuit_error_rate_threshold, circuit_min_requests + FROM proxy_config WHERE app_type = 'claude'", [], |row| { Ok(crate::proxy::circuit_breaker::CircuitBreakerConfig { @@ -294,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( &self, config: &crate::proxy::circuit_breaker::CircuitBreakerConfig, ) -> Result<(), AppError> { let conn = lock_conn!(self.conn); + // 更新所有三行的熔断器配置 conn.execute( - "UPDATE circuit_breaker_config - SET failure_threshold = ?1, - success_threshold = ?2, - timeout_seconds = ?3, - error_rate_threshold = ?4, - min_requests = ?5, - updated_at = CURRENT_TIMESTAMP - WHERE id = 1", + "UPDATE proxy_config SET + circuit_failure_threshold = ?1, + circuit_success_threshold = ?2, + circuit_timeout_seconds = ?3, + circuit_error_rate_threshold = ?4, + circuit_min_requests = ?5, + updated_at = datetime('now')", rusqlite::params![ config.failure_threshold as i32, config.success_threshold as i32, diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 68a5c68b8..65d2328f9 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -63,11 +63,13 @@ impl Database { } } - // --- 代理接管状态管理 --- + // --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)--- /// 获取指定应用的代理接管状态 /// - /// 使用 settings 表存储各应用的接管状态,key 格式: `proxy_takeover_{app_type}` + /// **已废弃**: 请使用 `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 { let key = format!("proxy_takeover_{app_type}"); match self.get_setting(&key)? { @@ -78,8 +80,11 @@ impl Database { /// 设置指定应用的代理接管状态 /// - /// - `true` = 开启代理接管 - /// - `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, @@ -91,6 +96,9 @@ impl Database { } /// 检查是否有任一应用开启了代理接管 + /// + /// **已废弃**: 请使用 `is_live_takeover_active()` 替代 + #[deprecated(since = "3.9.0", note = "使用 is_live_takeover_active() 替代")] pub fn has_any_proxy_takeover(&self) -> Result { let conn = lock_conn!(self.conn); let count: i64 = conn @@ -104,6 +112,12 @@ impl Database { } /// 清除所有代理接管状态(将所有 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( diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 4c1602781..70ab96d68 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -55,47 +55,28 @@ impl Database { // 3. MCP Servers 表 conn.execute( "CREATE TABLE IF NOT EXISTS mcp_servers ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - server_config TEXT NOT NULL, - description TEXT, - 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 - )", + id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL, + description TEXT, 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()))?; // 4. Prompts 表 - conn.execute( - "CREATE TABLE IF NOT EXISTS prompts ( - id TEXT NOT NULL, - app_type TEXT NOT NULL, - name TEXT NOT NULL, - 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()))?; + conn.execute("CREATE TABLE IF NOT EXISTS prompts ( + id TEXT NOT NULL, app_type TEXT NOT NULL, name TEXT NOT NULL, 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 表 conn.execute( "CREATE TABLE IF NOT EXISTS skills ( - directory TEXT NOT NULL, - app_type TEXT NOT NULL, - installed BOOLEAN NOT NULL DEFAULT 0, - installed_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (directory, app_type) - )", + directory TEXT NOT NULL, app_type TEXT NOT NULL, 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()))?; @@ -103,169 +84,124 @@ impl Database { // 6. Skill Repos 表 conn.execute( "CREATE TABLE IF NOT EXISTS skill_repos ( - owner TEXT NOT NULL, - name TEXT NOT NULL, - branch TEXT NOT NULL DEFAULT 'main', - enabled BOOLEAN NOT NULL DEFAULT 1, - PRIMARY KEY (owner, name) - )", + owner TEXT NOT NULL, name TEXT NOT NULL, branch TEXT NOT NULL DEFAULT 'main', + enabled BOOLEAN NOT NULL DEFAULT 1, PRIMARY KEY (owner, name) + )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - // 7. Settings 表 (通用配置) + // 7. Settings 表 conn.execute( - "CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT - )", + "CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)", [], ) .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( - "CREATE TABLE IF NOT EXISTS proxy_config ( - id INTEGER PRIMARY KEY CHECK (id = 1), - enabled INTEGER NOT NULL DEFAULT 0, - listen_address TEXT NOT NULL DEFAULT '127.0.0.1', - listen_port INTEGER NOT NULL DEFAULT 5000, - 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')) - )", + "INSERT OR IGNORE INTO proxy_config (app_type, 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 ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)", [], ) .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( - "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 - )", + "INSERT OR IGNORE INTO proxy_config (app_type, 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 ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)", [], ) .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 - )", + "INSERT OR IGNORE INTO proxy_config (app_type, 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 ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)", [], ) .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( - "CREATE INDEX IF NOT EXISTS idx_request_logs_provider - ON proxy_request_logs(provider_id, app_type)", + "CREATE INDEX IF NOT EXISTS idx_request_logs_model ON proxy_request_logs(model)", [], ) .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)", + "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_model - ON proxy_request_logs(model)", + "CREATE INDEX IF NOT EXISTS idx_request_logs_status ON proxy_request_logs(status_code)", [], ) .map_err(|e| AppError::Database(e.to_string()))?; - conn.execute( - "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 表 (模型定价) + // 11. Model Pricing 表 conn.execute( "CREATE TABLE IF NOT EXISTS model_pricing ( - model_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - input_cost_per_million TEXT NOT NULL, - output_cost_per_million TEXT NOT NULL, - cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', - cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' - )", + model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL, + 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()))?; - // 12. Stream Check Logs 表 (流式健康检查日志) - conn.execute( - "CREATE TABLE IF NOT EXISTS stream_check_logs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - provider_id TEXT NOT NULL, - provider_name TEXT NOT NULL, - app_type TEXT NOT NULL, - 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()))?; + // 12. Stream Check Logs 表 + conn.execute("CREATE TABLE IF NOT EXISTS stream_check_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT NOT NULL, provider_name TEXT NOT NULL, + app_type TEXT NOT NULL, 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( "CREATE INDEX IF NOT EXISTS idx_stream_check_logs_provider @@ -274,35 +210,13 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; - // 13. Circuit Breaker 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()))?; + // 注意:circuit_breaker_config 已合并到 proxy_config 表中 // 16. Proxy Live Backup 表 (Live 配置备份) conn.execute( "CREATE TABLE IF NOT EXISTS proxy_live_backup ( - app_type TEXT PRIMARY KEY, - original_config TEXT NOT NULL, - backed_up_at TEXT NOT NULL - )", + app_type TEXT PRIMARY KEY, original_config TEXT NOT NULL, backed_up_at TEXT NOT NULL + )", [], ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -313,6 +227,20 @@ impl Database { [], ); + // 尝试添加超时配置列到 proxy_config 表 + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30", + [], + ); + 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 数据库) Self::add_column_if_missing( conn, @@ -475,6 +403,28 @@ impl Database { "BOOLEAN NOT NULL DEFAULT 0", )?; + // 添加代理超时配置字段 + 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}")))?; @@ -489,35 +439,18 @@ impl Database { ) .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 - )", - [], - )?; + // 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")?; @@ -539,13 +472,11 @@ impl Database { // model_pricing 表 conn.execute( "CREATE TABLE IF NOT EXISTS model_pricing ( - model_id TEXT PRIMARY KEY, - display_name TEXT NOT NULL, - input_cost_per_million TEXT NOT NULL, - output_cost_per_million TEXT NOT NULL, - cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', - cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' - )", + model_id TEXT PRIMARY KEY, display_name TEXT NOT NULL, + input_cost_per_million TEXT NOT NULL, output_cost_per_million TEXT NOT NULL, + cache_read_cost_per_million TEXT NOT NULL DEFAULT '0', + cache_creation_cost_per_million TEXT NOT NULL DEFAULT '0' + )", [], )?; @@ -557,6 +488,144 @@ impl Database { // 重构 skills 表(添加 app_type 字段) 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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27110f225..556c326b6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -596,7 +596,7 @@ pub fn run() { commands::upsert_mcp_server_in_config, commands::delete_mcp_server_in_config, commands::set_mcp_enabled, - // v3.7.0: Unified MCP management + // Unified MCP management commands::get_mcp_servers, commands::upsert_mcp_server, commands::delete_mcp_server, @@ -656,6 +656,11 @@ pub fn run() { commands::get_proxy_status, commands::get_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_live_takeover_active, commands::switch_proxy_provider, @@ -845,22 +850,20 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { // 启动时恢复代理状态 // ============================================================ -/// 启动时根据 settings 表中的代理状态自动恢复代理服务 +/// 启动时根据 proxy_config 表中的代理状态自动恢复代理服务 /// -/// 检查 `proxy_takeover_claude`、`proxy_takeover_codex`、`proxy_takeover_gemini` 的值, -/// 如果有任一应用的状态为 `true`,则自动启动代理服务并接管对应应用的 Live 配置。 +/// 检查 `proxy_config.enabled` 字段,如果有任一应用的状态为 `true`, +/// 则自动启动代理服务并接管对应应用的 Live 配置。 async fn restore_proxy_state_on_startup(state: &store::AppState) { - // 收集需要恢复接管的应用列表 - let apps_to_restore: Vec<&str> = ["claude", "codex", "gemini"] - .iter() - .filter(|app_type| { - state - .db - .get_proxy_takeover_enabled(app_type) - .unwrap_or(false) - }) - .copied() - .collect(); + // 收集需要恢复接管的应用列表(从 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!("启动时无需恢复代理状态"); @@ -882,7 +885,11 @@ async fn restore_proxy_state_on_startup(state: &store::AppState) { Err(e) => { log::error!("✗ 恢复 {app_type} 的代理接管状态失败: {e}"); // 失败时清除该应用的状态,避免下次启动再次尝试 - if let Err(clear_err) = state.db.set_proxy_takeover_enabled(app_type, false) { + if let Err(clear_err) = state + .proxy_service + .set_takeover_for_app(app_type, false) + .await + { log::error!("清除 {app_type} 代理状态失败: {clear_err}"); } } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index f5b27d482..334182f92 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -39,7 +39,7 @@ pub struct RequestForwarder { failover_manager: Arc, /// AppHandle,用于发射事件和更新托盘 app_handle: Option, - /// 请求开始时的“当前供应商 ID”(用于判断是否需要同步 UI/托盘) + /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) current_provider_id_at_start: String, } @@ -47,17 +47,27 @@ impl RequestForwarder { #[allow(clippy::too_many_arguments)] pub fn new( router: Arc, - timeout_secs: u64, + non_streaming_timeout: u64, max_retries: u8, status: Arc>, current_providers: Arc>>, failover_manager: Arc, app_handle: Option, current_provider_id_at_start: String, + _streaming_first_byte_timeout: u64, + _streaming_idle_timeout: u64, ) -> Self { + // 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作 + // 参考 Claude Code Hub 的 undici 全局超时设计 + const GLOBAL_TIMEOUT_SECS: u64 = 1800; + let mut client_builder = Client::builder(); - if timeout_secs > 0 { - client_builder = client_builder.timeout(Duration::from_secs(timeout_secs)); + if non_streaming_timeout > 0 { + // 使用配置的非流式超时 + 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 @@ -166,14 +176,24 @@ impl RequestForwarder { let mut last_provider = None; let mut attempted_providers = 0usize; + // 单 Provider 场景下跳过熔断器检查(故障转移关闭时) + let bypass_circuit_breaker = providers.len() == 1; + // 依次尝试每个供应商 for provider in providers.iter() { // 发起请求前先获取熔断器放行许可(HalfOpen 会占用探测名额) - let permit = self - .router - .allow_provider_request(&provider.id, app_type_str) - .await; - if !permit.allowed { + // 单 Provider 场景下跳过此检查,避免熔断器阻塞所有请求 + let (allowed, used_half_open_permit) = if bypass_circuit_breaker { + (true, false) + } else { + let permit = self + .router + .allow_provider_request(&provider.id, app_type_str) + .await; + (permit.allowed, permit.used_half_open_permit) + }; + + if !allowed { log::debug!( "[{}] Provider {} 熔断器拒绝本次请求,跳过", app_type_str, @@ -182,8 +202,6 @@ impl RequestForwarder { continue; } - let used_half_open_permit = permit.used_half_open_permit; - attempted_providers += 1; log::info!( @@ -412,12 +430,19 @@ impl RequestForwarder { let base_url = adapter.extract_base_url(provider)?; 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 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 log::info!( "[{}] ====== 请求开始 ======\n>>> 原始请求 JSON:\n{}", @@ -425,10 +450,23 @@ impl RequestForwarder { 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 { log::info!("[{}] 转换请求格式 (Anthropic → OpenAI)", adapter.name()); - let transformed = adapter.transform_request(body.clone(), provider)?; + let transformed = adapter.transform_request(mapped_body, provider)?; log::info!( "[{}] >>> 转换后的请求 JSON:\n{}", adapter.name(), @@ -436,7 +474,7 @@ impl RequestForwarder { ); transformed } else { - body.clone() + mapped_body }; log::info!( diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs index 5710f0b48..ca2df0787 100644 --- a/src-tauri/src/proxy/handler_config.rs +++ b/src-tauri/src/proxy/handler_config.rs @@ -31,13 +31,26 @@ pub struct UsageParserConfig { // 模型提取器实现 // ============================================================================ -/// Claude 流式响应模型提取(直接使用请求模型) -fn claude_model_extractor(_events: &[Value], request_model: &str) -> String { +/// Claude 流式响应模型提取(优先使用 usage.model) +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() } -/// OpenAI Chat Completions 流式响应模型提取 +/// OpenAI Chat Completions 流式响应模型提取(优先使用 usage.model) 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 .iter() .find_map(|e| e.get("model")?.as_str()) @@ -45,8 +58,15 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String { .to_string() } -/// Codex Responses API 流式响应模型提取 +/// Codex Responses API 流式响应模型提取(优先使用 usage.model) 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 .iter() .find_map(|e| { diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index a8eae231b..86bce211d 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -5,23 +5,32 @@ use crate::app_config::AppType; use crate::provider::Provider; use crate::proxy::{ - forwarder::RequestForwarder, server::ProxyState, types::ProxyConfig, ProxyError, + forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError, }; 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 列表(用于故障转移) /// - 请求模型名称 /// - 日志标签 pub struct RequestContext { /// 请求开始时间 pub start_time: Instant, - /// 代理配置快照 - pub config: ProxyConfig, + /// 应用级代理配置(per-app,包含重试次数和超时配置) + pub app_config: AppProxyConfig, /// 选中的 Provider(故障转移链的第一个) pub provider: Provider, /// 完整的 Provider 列表(用于故障转移) @@ -62,7 +71,14 @@ impl RequestContext { app_type_str: &'static str, ) -> Result { 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 = crate::settings::get_current_provider(&app_type).unwrap_or_default(); @@ -96,7 +112,7 @@ impl RequestContext { Ok(Self { start_time, - config, + app_config, provider, providers, current_provider_id, @@ -135,13 +151,15 @@ impl RequestContext { pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { RequestForwarder::new( state.provider_router.clone(), - self.config.request_timeout, - self.config.max_retries, + self.app_config.non_streaming_timeout as u64, + self.app_config.max_retries as u8, state.status.clone(), state.current_providers.clone(), state.failover_manager.clone(), state.app_handle.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 { 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, + } + } } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 4df94ea15..20512dc70 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -170,10 +170,14 @@ async fn handle_claude_transform( }) }; + // 获取流式超时配置 + let timeout_config = ctx.streaming_timeout_config(); + let logged_stream = create_logged_passthrough_stream( sse_stream, "Claude/OpenRouter", Some(usage_collector), + timeout_config, ); let mut headers = axum::http::HeaderMap::new(); diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 4f65b853d..69063dc08 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -11,6 +11,7 @@ pub mod handler_config; pub mod handler_context; mod handlers; mod health; +pub mod model_mapper; pub mod provider_router; pub mod providers; pub mod response_handler; diff --git a/src-tauri/src/proxy/model_mapper.rs b/src-tauri/src/proxy/model_mapper.rs new file mode 100644 index 000000000..debe155ab --- /dev/null +++ b/src-tauri/src/proxy/model_mapper.rs @@ -0,0 +1,264 @@ +//! 模型映射模块 +//! +//! 在请求转发前,根据 Provider 配置替换请求中的模型名称 + +use crate::provider::Provider; +use serde_json::Value; + +/// 模型映射配置 +pub struct ModelMapping { + pub haiku_model: Option, + pub sonnet_model: Option, + pub opus_model: Option, + pub default_model: Option, + pub reasoning_model: Option, +} + +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, Option) { + 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())); + } +} diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 04eead5f3..1cbae1a65 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -35,25 +35,16 @@ impl ProviderRouter { pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { let mut result = Vec::new(); - // 检查该应用的自动故障转移开关是否开启 - let failover_key = format!("auto_failover_enabled_{app_type}"); - let auto_failover_enabled = match self.db.get_setting(&failover_key) { - Ok(Some(value)) => { - let enabled = value == "true"; - log::info!( - "[{app_type}] Failover setting '{failover_key}' = '{value}', enabled: {enabled}" - ); + // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) + let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { + Ok(config) => { + let enabled = config.auto_failover_enabled; + log::info!("[{app_type}] Failover enabled from proxy_config: {enabled}"); enabled } - Ok(None) => { - log::warn!( - "[{app_type}] Failover setting '{failover_key}' not found in database, defaulting to disabled" - ); - false - } Err(e) => { log::error!( - "[{app_type}] Failed to read failover setting '{failover_key}': {e}, defaulting to disabled" + "[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" ); false } @@ -91,29 +82,19 @@ impl ProviderRouter { } } } else { - // 故障转移关闭:仅使用当前供应商 - log::info!("[{app_type}] Failover disabled, using current provider only"); + // 故障转移关闭:仅使用当前供应商,跳过熔断器检查 + // 原因:单 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)? { - let circuit_key = format!("{}:{}", app_type, current.id); - let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; - - if breaker.is_available().await { - log::info!( - "[{}] Current provider available: {} ({})", - app_type, - current.name, - current.id - ); - result.push(current); - } else { - log::warn!( - "[{}] Current provider {} circuit breaker open", - app_type, - current.name - ); - } + log::info!( + "[{}] Current provider: {} ({})", + app_type, + current.name, + current.id + ); + result.push(current); } } } @@ -156,9 +137,16 @@ impl ProviderRouter { success: bool, error_msg: Option, ) -> Result<(), AppError> { - // 1. 获取熔断器配置(用于更新健康状态和判断是否禁用) - let config = self.db.get_circuit_breaker_config().await.ok(); - let failure_threshold = config.map(|c| c.failure_threshold).unwrap_or(5); + // 1. 按应用独立获取熔断器配置(用于更新健康状态和判断是否禁用) + let failure_threshold = match self.db.get_proxy_config_for_app(app_type).await { + 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. 更新熔断器状态 let circuit_key = format!("{app_type}:{provider_id}"); @@ -255,12 +243,34 @@ impl ProviderRouter { return breaker.clone(); } - // 从数据库加载配置 - let config = self - .db - .get_circuit_breaker_config() - .await - .unwrap_or_default(); + // 从 key 中提取 app_type (格式: "app_type:provider_id") + let app_type = key.split(':').next().unwrap_or("claude"); + + // 按应用独立读取熔断器配置 + let config = match self.db.get_proxy_config_for_app(app_type).await { + 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:?}"); @@ -325,8 +335,11 @@ mod tests { db.add_to_failover_queue("claude", "b").unwrap(); db.add_to_failover_queue("claude", "a").unwrap(); - db.set_setting("auto_failover_enabled_claude", "true") - .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(); @@ -359,8 +372,11 @@ mod tests { db.add_to_failover_queue("claude", "a").unwrap(); db.add_to_failover_queue("claude", "b").unwrap(); - db.set_setting("auto_failover_enabled_claude", "true") - .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()); diff --git a/src-tauri/src/proxy/providers/claude.rs b/src-tauri/src/proxy/providers/claude.rs index ba6254252..a0dfd87e7 100644 --- a/src-tauri/src/proxy/providers/claude.rs +++ b/src-tauri/src/proxy/providers/claude.rs @@ -48,6 +48,24 @@ impl ClaudeAdapter { 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 认证模式 fn is_bearer_only_mode(&self, provider: &Provider) -> bool { // 检查 settings_config 中的 auth_mode @@ -197,11 +215,7 @@ impl ProviderAdapter for ClaudeAdapter { // 映射到 `/v1/chat/completions`,并做 Anthropic ↔ OpenAI 的格式转换。 // // 现在 OpenRouter 已推出 Claude Code 兼容接口,因此默认直接透传 endpoint。 - // 如需回退旧逻辑,可恢复下面这段分支: - // - // if base_url.contains("openrouter.ai") { - // return format!("{}/v1/chat/completions", base_url.trim_end_matches('/')); - // } + // 如需回退旧逻辑,可在 forwarder 中根据 needs_transform 改写 endpoint。 format!( "{}/{}", @@ -235,8 +249,7 @@ impl ProviderAdapter for ClaudeAdapter { // Anthropic ↔ OpenAI 的格式转换。 // // 如果未来需要回退到旧的 OpenAI Chat Completions 方案,可恢复下面这行: - // self.is_openrouter(_provider) - false + self.is_openrouter_compat_enabled(_provider) } fn transform_request( @@ -430,6 +443,14 @@ mod tests { "ANTHROPIC_BASE_URL": "https://openrouter.ai/api" } })); - 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)); } } diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 38b59faeb..714302b3c 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -3,8 +3,11 @@ //! 统一处理流式和非流式 API 响应 use super::{ - handler_config::UsageParserConfig, handler_context::RequestContext, server::ProxyState, - usage::parser::TokenUsage, ProxyError, + handler_config::UsageParserConfig, + handler_context::{RequestContext, StreamingTimeoutConfig}, + server::ProxyState, + usage::parser::TokenUsage, + ProxyError, }; use axum::response::Response; use bytes::Bytes; @@ -17,6 +20,7 @@ use std::{ atomic::{AtomicBool, Ordering}, Arc, }, + time::Duration, }; 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 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); builder.body(body).unwrap() @@ -93,12 +101,16 @@ pub async fn handle_non_streaming( // 解析使用量 if let Some(usage) = (parser_config.response_parser)(&json_value) { - let model = json_value - .get("model") - .and_then(|m| m.as_str()) - .unwrap_or(&ctx.request_model); + // 优先使用 usage 中解析出的模型名称,其次使用响应中的 model 字段,最后回退到请求模型 + let model = if let Some(ref m) = usage.model { + m.clone() + } 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 { log::debug!( "[{}] 未能解析 usage 信息,跳过记录", @@ -344,21 +356,60 @@ async fn log_usage_internal( } } -/// 创建带日志记录的透传流 +/// 创建带日志记录和超时控制的透传流 pub fn create_logged_passthrough_stream( stream: impl Stream> + Send + 'static, tag: &'static str, usage_collector: Option, + timeout_config: StreamingTimeoutConfig, ) -> impl Stream> + Send { async_stream::stream! { let mut buffer = String::new(); 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); - while let Some(chunk) = stream.next().await { - match chunk { - Ok(bytes) => { + loop { + // 选择超时时间:首字节超时或静默期超时 + 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); buffer.push_str(&text); @@ -394,11 +445,15 @@ pub fn create_logged_passthrough_stream( yield Ok(bytes); } - Err(e) => { + Some(Err(e)) => { log::error!("[{tag}] 流错误: {e}"); yield Err(std::io::Error::other(e.to_string())); break; } + None => { + // 流正常结束 + break; + } } } diff --git a/src-tauri/src/proxy/types.rs b/src-tauri/src/proxy/types.rs index f00af8de5..9ce103473 100644 --- a/src-tauri/src/proxy/types.rs +++ b/src-tauri/src/proxy/types.rs @@ -9,13 +9,34 @@ pub struct ProxyConfig { pub listen_port: u16, /// 最大重试次数 pub max_retries: u8, - /// 请求超时时间(秒) + /// 请求超时时间(秒)- 已废弃,保留兼容 pub request_timeout: u64, /// 是否启用日志 pub enable_logging: bool, /// 是否正在接管 Live 配置 #[serde(default)] 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 { @@ -27,6 +48,9 @@ impl Default for ProxyConfig { request_timeout: 300, enable_logging: true, live_takeover_active: false, + streaming_first_byte_timeout: 30, + streaming_idle_timeout: 60, + non_streaming_timeout: 600, } } } @@ -123,3 +147,47 @@ pub struct LiveBackup { /// 备份时间 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, +} diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 950d6f669..33c1aaf16 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -34,6 +34,12 @@ impl TokenUsage { /// 从 Claude API 非流式响应解析 pub fn from_claude_response(body: &Value) -> Option { let usage = body.get("usage")?; + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: usage.get("input_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") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, - model: None, + model, }) } @@ -53,11 +59,20 @@ impl TokenUsage { #[allow(dead_code)] pub fn from_claude_stream_events(events: &[Value]) -> Option { let mut usage = Self::default(); + let mut model: Option = None; for event in events { if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { match event_type { "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")) { // 从 message_start 获取 input_tokens(原生 Claude API) if let Some(input) = @@ -102,6 +117,7 @@ impl TokenUsage { } if usage.input_tokens > 0 || usage.output_tokens > 0 { + usage.model = model; Some(usage) } else { None @@ -141,6 +157,12 @@ impl TokenUsage { return None; } + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: input_tokens? as u32, output_tokens: output_tokens? as u32, @@ -152,7 +174,7 @@ impl TokenUsage { .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, - model: None, + model, }) } @@ -222,12 +244,18 @@ impl TokenUsage { .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: prompt_tokens as u32, output_tokens: completion_tokens as u32, cache_read_tokens: cached_tokens, cache_creation_tokens: 0, - model: None, + model, }) } @@ -322,6 +350,7 @@ mod tests { #[test] fn test_claude_response_parsing() { let response = json!({ + "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 100, "output_tokens": 50, @@ -335,10 +364,60 @@ mod tests { 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_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] 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![ json!({ "type": "message_start", @@ -363,6 +442,7 @@ mod tests { 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] @@ -481,6 +561,7 @@ mod tests { json!({ "type": "message_start", "message": { + "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 0, "output_tokens": 0 @@ -502,6 +583,7 @@ mod tests { let usage = TokenUsage::from_claude_stream_events(&events).unwrap(); assert_eq!(usage.input_tokens, 150); assert_eq!(usage.output_tokens, 75); + assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); } #[test] @@ -512,6 +594,7 @@ mod tests { json!({ "type": "message_start", "message": { + "model": "claude-sonnet-4-20250514", "usage": { "input_tokens": 200, "cache_read_input_tokens": 50 @@ -530,5 +613,6 @@ mod tests { assert_eq!(usage.input_tokens, 200); assert_eq!(usage.output_tokens, 100); assert_eq!(usage.cache_read_tokens, 50); + assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); } } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 03570eb05..29b5b3157 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -43,7 +43,22 @@ impl ProxyService { /// 启动代理服务器 pub async fn start(&self) -> Result { - // 1. 获取配置 + // 1. 启动时自动设置 proxy_enabled = true + let mut global_config = self + .db + .get_global_proxy_config() + .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 .get_proxy_config() @@ -115,14 +130,7 @@ impl ProxyService { return Err(e); } - // 5. 设置 settings 表中所有应用的接管状态(用于重启后自动恢复) - for app in ["claude", "codex", "gemini"] { - if let Err(e) = self.db.set_proxy_takeover_enabled(app, true) { - log::warn!("设置 {app} 接管状态失败: {e}"); - } - } - - // 6. 启动代理服务器 + // 5. 启动代理服务器 match self.start().await { Ok(info) => Ok(info), Err(e) => { @@ -132,8 +140,6 @@ impl ProxyService { Ok(()) => { let _ = self.db.set_live_takeover_active(false).await; let _ = self.db.delete_all_live_backups().await; - // 清除 settings 状态 - let _ = self.db.clear_all_proxy_takeover(); } Err(restore_err) => { log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); @@ -146,29 +152,30 @@ impl ProxyService { /// 获取各应用的接管状态(是否改写该应用的 Live 配置指向本地代理) pub async fn get_takeover_status(&self) -> Result { - let claude = self + // 从 proxy_config.enabled 读取(优先),兼容旧的 live_backup 备份检测 + let claude_enabled = self .db - .get_live_backup("claude") + .get_proxy_config_for_app("claude") .await - .map_err(|e| format!("获取 Claude 接管状态失败: {e}"))? - .is_some(); - let codex = self + .map(|c| c.enabled) + .unwrap_or(false); + let codex_enabled = self .db - .get_live_backup("codex") + .get_proxy_config_for_app("codex") .await - .map_err(|e| format!("获取 Codex 接管状态失败: {e}"))? - .is_some(); - let gemini = self + .map(|c| c.enabled) + .unwrap_or(false); + let gemini_enabled = self .db - .get_live_backup("gemini") + .get_proxy_config_for_app("gemini") .await - .map_err(|e| format!("获取 Gemini 接管状态失败: {e}"))? - .is_some(); + .map(|c| c.enabled) + .unwrap_or(false); Ok(ProxyTakeoverStatus { - claude, - codex, - gemini, + claude: claude_enabled, + codex: codex_enabled, + gemini: gemini_enabled, }) } @@ -187,13 +194,13 @@ impl ProxyService { } // 2) 已接管则直接返回(幂等) - if self + let current_config = self .db - .get_live_backup(app_type_str) + .get_proxy_config_for_app(app_type_str) .await - .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))? - .is_some() - { + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + + if current_config.enabled { return Ok(()); } @@ -223,25 +230,32 @@ impl ProxyService { return Err(e); } - // 6) 设置 settings 表中的接管状态 + // 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 - .set_proxy_takeover_enabled(app_type_str, true) - .map_err(|e| format!("设置 {app_type_str} 接管状态失败: {e}"))?; + .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; return Ok(()); } - // 关闭接管:无备份则视为未接管(幂等) - let has_backup = self + // 关闭接管:检查 enabled 状态 + let current_config = self .db - .get_live_backup(app_type_str) + .get_proxy_config_for_app(app_type_str) .await - .map_err(|e| format!("检查 {app_type_str} Live 备份失败: {e}"))? - .is_some(); - if !has_backup { - return Ok(()); + .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; + + if !current_config.enabled { + return Ok(()); // 未接管,幂等返回 } // 1) 恢复 Live 配置 @@ -253,10 +267,17 @@ impl ProxyService { .await .map_err(|e| format!("删除 {app_type_str} Live 备份失败: {e}"))?; - // 3) 清除 settings 表中该应用的接管状态 + // 3) 设置 proxy_config.enabled = false + 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 = false; self.db - .set_proxy_takeover_enabled(app_type_str, false) - .map_err(|e| format!("清除 {app_type_str} 接管状态失败: {e}"))?; + .update_proxy_config_for_app(updated_config) + .await + .map_err(|e| format!("清除 {app_type_str} enabled 状态失败: {e}"))?; // 4) 清除该应用的健康状态(关闭代理时重置队列状态) self.db @@ -265,12 +286,14 @@ impl ProxyService { .map_err(|e| format!("清除 {app_type_str} 健康状态失败: {e}"))?; // 5) 若无其它接管,更新旧标志,并停止代理服务 - let has_any_backup = self + // 检查是否还有其它 app 的 enabled = true + let any_enabled = self .db - .has_any_live_backup() + .is_live_takeover_active() .await - .map_err(|e| format!("检查 Live 备份失败: {e}"))?; - if !has_any_backup { + .map_err(|e| format!("检查接管状态失败: {e}"))?; + + if !any_enabled { let _ = self.db.set_live_takeover_active(false).await; if self.is_running().await { @@ -502,6 +525,20 @@ impl ProxyService { .await .map_err(|e| format!("停止代理服务器失败: {e}"))?; + // 停止时设置 proxy_enabled = false + let mut global_config = self + .db + .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!("代理服务器已停止"); Ok(()) } else { @@ -527,10 +564,17 @@ impl ProxyService { .await .map_err(|e| format!("清除接管状态失败: {e}"))?; - // 4. 清除 settings 表中的代理状态(用户手动关闭,不需要下次自动恢复) - self.db - .clear_all_proxy_takeover() - .map_err(|e| format!("清除代理状态失败: {e}"))?; + // 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 @@ -562,7 +606,7 @@ impl ProxyService { self.restore_live_configs().await?; // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版) - // 注意:仅更新 proxy_config 表,不清除 settings 表中的 proxy_takeover_* 状态 + // 注意:保留 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; diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 36004344f..d8a5db6f7 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -3,6 +3,7 @@ //! 使用流式 API 进行快速健康检查,只需接收首个 chunk 即判定成功。 use futures::StreamExt; +use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -141,15 +142,17 @@ impl StreamCheckService { .build() .map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?; + let model_to_test = Self::resolve_test_model(app_type, provider, config); + let result = match app_type { 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 => { - 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 => { - 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()) } } + + 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 { + 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 { + 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)] diff --git a/src/App.tsx b/src/App.tsx index ca32be6e9..6dd3e37ad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -641,9 +641,7 @@ function App() {
-
- {renderContent()} -
+
{renderContent()}
= ({ >
-

{title}

diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index d0df92bb7..351f11b41 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -1,5 +1,6 @@ import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; +import { Switch } from "@/components/ui/switch"; import { Input } from "@/components/ui/input"; import EndpointSpeedTest from "./EndpointSpeedTest"; import { ApiKeySection, EndpointField } from "./shared"; @@ -39,12 +40,14 @@ interface ClaudeFormFieldsProps { // Model Selector shouldShowModelSelector: boolean; claudeModel: string; + reasoningModel: string; defaultHaikuModel: string; defaultSonnetModel: string; defaultOpusModel: string; onModelChange: ( field: | "ANTHROPIC_MODEL" + | "ANTHROPIC_REASONING_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_OPUS_MODEL", @@ -53,6 +56,11 @@ interface ClaudeFormFieldsProps { // Speed Test Endpoints speedTestEndpoints: EndpointCandidate[]; + + // OpenRouter Compat + showOpenRouterCompatToggle: boolean; + openRouterCompatEnabled: boolean; + onOpenRouterCompatChange: (enabled: boolean) => void; } export function ClaudeFormFields({ @@ -77,11 +85,15 @@ export function ClaudeFormFields({ onCustomEndpointsChange, shouldShowModelSelector, claudeModel, + reasoningModel, defaultHaikuModel, defaultSonnetModel, defaultOpusModel, onModelChange, speedTestEndpoints, + showOpenRouterCompatToggle, + openRouterCompatEnabled, + onOpenRouterCompatChange, }: ClaudeFormFieldsProps) { const { t } = useTranslation(); @@ -162,6 +174,28 @@ export function ClaudeFormFields({ /> )} + {showOpenRouterCompatToggle && ( +
+
+ + {t("providerForm.openrouterCompatMode", { + defaultValue: "OpenRouter 兼容模式", + })} + +

+ {t("providerForm.openrouterCompatModeHint", { + defaultValue: + "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。", + })} +

+
+ +
+ )} + {/* 模型选择器 */} {shouldShowModelSelector && (
@@ -185,6 +219,27 @@ export function ClaudeFormFields({ />
+ {/* 推理模型 */} +
+ + {t("providerForm.anthropicReasoningModel", { + defaultValue: "推理模型 (Thinking)", + })} + + + onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value) + } + placeholder={t("providerForm.reasoningModelPlaceholder", { + defaultValue: "", + })} + autoComplete="off" + /> +
+ {/* 默认 Haiku */}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 81b530e29..fadec46a7 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -162,6 +162,8 @@ export function ProviderForm({ mode: "onSubmit", }); + const settingsConfigValue = form.watch("settingsConfig"); + // 使用 API Key hook const { apiKey, @@ -187,9 +189,10 @@ export function ProviderForm({ }, }); - // 使用 Model hook(新:主模型 + Haiku/Sonnet/Opus 默认模型) + // 使用 Model hook(新:主模型 + 推理模型 + Haiku/Sonnet/Opus 默认模型) const { claudeModel, + reasoningModel, defaultHaikuModel, defaultSonnetModel, defaultOpusModel, @@ -199,6 +202,53 @@ export function ProviderForm({ 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 模式) const { codexAuth, @@ -789,11 +839,15 @@ export function ProviderForm({ } shouldShowModelSelector={category !== "official"} claudeModel={claudeModel} + reasoningModel={reasoningModel} defaultHaikuModel={defaultHaikuModel} defaultSonnetModel={defaultSonnetModel} defaultOpusModel={defaultOpusModel} onModelChange={handleModelChange} speedTestEndpoints={speedTestEndpoints} + showOpenRouterCompatToggle={isOpenRouterProvider} + openRouterCompatEnabled={openRouterCompatEnabled} + onOpenRouterCompatChange={handleOpenRouterCompatChange} /> )} diff --git a/src/components/providers/forms/hooks/useModelState.ts b/src/components/providers/forms/hooks/useModelState.ts index 6e2fe4b60..07d7122dc 100644 --- a/src/components/providers/forms/hooks/useModelState.ts +++ b/src/components/providers/forms/hooks/useModelState.ts @@ -7,13 +7,14 @@ interface UseModelStateProps { /** * 管理模型选择状态 - * 支持 ANTHROPIC_MODEL 和 ANTHROPIC_SMALL_FAST_MODEL + * 支持 ANTHROPIC_MODEL, ANTHROPIC_REASONING_MODEL 和各类型默认模型 */ export function useModelState({ settingsConfig, onConfigChange, }: UseModelStateProps) { const [claudeModel, setClaudeModel] = useState(""); + const [reasoningModel, setReasoningModel] = useState(""); const [defaultHaikuModel, setDefaultHaikuModel] = useState(""); const [defaultSonnetModel, setDefaultSonnetModel] = useState(""); const [defaultOpusModel, setDefaultOpusModel] = useState(""); @@ -29,6 +30,10 @@ export function useModelState({ const env = cfg?.env || {}; const model = typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : ""; + const reasoning = + typeof env.ANTHROPIC_REASONING_MODEL === "string" + ? env.ANTHROPIC_REASONING_MODEL + : ""; const small = typeof env.ANTHROPIC_SMALL_FAST_MODEL === "string" ? env.ANTHROPIC_SMALL_FAST_MODEL @@ -47,6 +52,7 @@ export function useModelState({ : model || small; setClaudeModel(model || ""); + setReasoningModel(reasoning || ""); setDefaultHaikuModel(haiku || ""); setDefaultSonnetModel(sonnet || ""); setDefaultOpusModel(opus || ""); @@ -59,12 +65,14 @@ export function useModelState({ ( field: | "ANTHROPIC_MODEL" + | "ANTHROPIC_REASONING_MODEL" | "ANTHROPIC_DEFAULT_HAIKU_MODEL" | "ANTHROPIC_DEFAULT_SONNET_MODEL" | "ANTHROPIC_DEFAULT_OPUS_MODEL", value: string, ) => { if (field === "ANTHROPIC_MODEL") setClaudeModel(value); + if (field === "ANTHROPIC_REASONING_MODEL") setReasoningModel(value); if (field === "ANTHROPIC_DEFAULT_HAIKU_MODEL") setDefaultHaikuModel(value); if (field === "ANTHROPIC_DEFAULT_SONNET_MODEL") @@ -98,6 +106,8 @@ export function useModelState({ return { claudeModel, setClaudeModel, + reasoningModel, + setReasoningModel, defaultHaikuModel, setDefaultHaikuModel, defaultSonnetModel, diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 182dbce5c..9daeaed0d 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -6,51 +6,67 @@ import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Save, Loader2, Info } from "lucide-react"; import { toast } from "sonner"; -import { - useCircuitBreakerConfig, - useUpdateCircuitBreakerConfig, -} from "@/lib/query/failover"; +import { useAppProxyConfig, useUpdateAppProxyConfig } from "@/lib/query/proxy"; export interface AutoFailoverConfigPanelProps { - enabled?: boolean; - onEnabledChange?: (enabled: boolean) => void; + appType: string; + disabled?: boolean; } export function AutoFailoverConfigPanel({ - enabled = true, - onEnabledChange: _onEnabledChange, -}: AutoFailoverConfigPanelProps = {}) { - // Note: onEnabledChange is currently unused but kept in the interface - // for potential future use by parent components - void _onEnabledChange; + appType, + disabled = false, +}: AutoFailoverConfigPanelProps) { const { t } = useTranslation(); - const { data: config, isLoading, error } = useCircuitBreakerConfig(); - const updateConfig = useUpdateCircuitBreakerConfig(); + const { data: config, isLoading, error } = useAppProxyConfig(appType); + const updateConfig = useUpdateAppProxyConfig(); const [formData, setFormData] = useState({ - failureThreshold: 5, - successThreshold: 2, - timeoutSeconds: 60, - errorRateThreshold: 0.5, - minRequests: 10, + autoFailoverEnabled: false, + maxRetries: 3, + streamingFirstByteTimeout: 30, + streamingIdleTimeout: 60, + nonStreamingTimeout: 300, + circuitFailureThreshold: 5, + circuitSuccessThreshold: 2, + circuitTimeoutSeconds: 60, + circuitErrorRateThreshold: 0.5, + circuitMinRequests: 10, }); useEffect(() => { if (config) { 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]); const handleSave = async () => { + if (!config) return; try { await updateConfig.mutateAsync({ - failureThreshold: formData.failureThreshold, - successThreshold: formData.successThreshold, - timeoutSeconds: formData.timeoutSeconds, - errorRateThreshold: formData.errorRateThreshold, - minRequests: formData.minRequests, + appType, + enabled: config.enabled, + autoFailoverEnabled: formData.autoFailoverEnabled, + maxRetries: formData.maxRetries, + 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( t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"), @@ -66,7 +82,16 @@ export function AutoFailoverConfigPanel({ const handleReset = () => { if (config) { 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 (
- {/* 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). - */} -
{error && ( @@ -114,22 +133,48 @@ export function AutoFailoverConfigPanel({
-
+ +
+ + + setFormData({ + ...formData, + circuitFailureThreshold: parseInt(e.target.value) || 5, + }) + } + disabled={isDisabled} />

{t( @@ -138,59 +183,123 @@ export function AutoFailoverConfigPanel({ )}

+
+
+ {/* 超时配置 */} +
+

+ {t("proxy.autoFailover.timeoutSettings", "超时配置")} +

+ +
-
+ +
+ + + setFormData({ + ...formData, + streamingIdleTimeout: parseInt(e.target.value) || 60, + }) + } + disabled={isDisabled} + /> +

+ {t( + "proxy.autoFailover.streamingIdleHint", + "数据块之间的最大间隔", + )} +

+
+ +
+ + + setFormData({ + ...formData, + nonStreamingTimeout: parseInt(e.target.value) || 300, + }) + } + disabled={isDisabled} + /> +

+ {t( + "proxy.autoFailover.nonStreamingHint", + "非流式请求的总超时时间", )}

- {/* 熔断器高级配置 */} + {/* 熔断器配置 */}

- {t("proxy.autoFailover.circuitBreakerSettings", "熔断器高级设置")} + {t("proxy.autoFailover.circuitBreakerSettings", "熔断器配置")}

-
+
-
-
+ +
+ setFormData({ ...formData, - errorRateThreshold: (parseInt(e.target.value) || 50) / 100, + circuitErrorRateThreshold: + (parseInt(e.target.value) || 50) / 100, }) } - disabled={!enabled} + disabled={isDisabled} />

{t( @@ -228,22 +364,22 @@ export function AutoFailoverConfigPanel({

-
); diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 66a1c829e..537b8ff68 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -1,26 +1,54 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Activity, Clock, TrendingUp, Server, ListOrdered, - Settings, + Save, + Loader2, } from "lucide-react"; 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 { ProxySettingsDialog } from "./ProxySettingsDialog"; import { toast } from "sonner"; import { useFailoverQueue } from "@/lib/query/failover"; import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge"; import { useProviderHealth } from "@/lib/query/failover"; +import { + useProxyTakeoverStatus, + useSetProxyTakeoverForApp, + useGlobalProxyConfig, + useUpdateGlobalProxyConfig, +} from "@/lib/query/proxy"; import type { ProxyStatus } from "@/types/proxy"; import { useTranslation } from "react-i18next"; export function ProxyPanel() { const { t } = useTranslation(); 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]); // 获取所有三个应用类型的故障转移队列(不包含当前供应商) // 当前供应商始终优先,队列仅用于失败后的备用顺序 @@ -28,6 +56,69 @@ export function ProxyPanel() { const { data: codexQueue = [] } = useFailoverQueue("codex"); 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 hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); @@ -49,22 +140,11 @@ export function ProxyPanel() {
-
-

- {t("proxy.panel.serviceAddress", { - defaultValue: "服务地址", - })} -

- -
+

+ {t("proxy.panel.serviceAddress", { + defaultValue: "服务地址", + })} +

http://{status.address}:{status.port} @@ -87,6 +167,11 @@ export function ProxyPanel() { {t("common.copy")}
+

+ {t("proxy.settings.restartRequired", { + defaultValue: "修改监听地址/端口需要先停止代理服务", + })} +

@@ -130,6 +215,63 @@ export function ProxyPanel() { )}
+ {/* 应用接管开关 */} +
+

+ {t("proxyConfig.appTakeover", { + defaultValue: "应用接管", + })} +

+
+ {(["claude", "codex", "gemini"] as const).map((appType) => { + const isEnabled = + takeoverStatus?.[ + appType as keyof typeof takeoverStatus + ] ?? false; + return ( +
+ + {appType} + + + handleTakeoverChange(appType, checked) + } + disabled={setTakeoverForApp.isPending} + /> +
+ ); + })} +
+
+ + {/* 日志记录开关 */} +
+
+
+ +

+ {t("proxy.settings.fields.enableLogging.description", { + defaultValue: "记录所有代理请求,便于排查问题", + })} +

+
+ +
+
+ {/* 供应商队列 - 按应用类型分组展示 */} {(claudeQueue.length > 0 || codexQueue.length > 0 || @@ -217,36 +359,109 @@ export function ProxyPanel() {
) : ( -
-
- +
+ {/* 空白区域避免冲突 */} +
+ + {/* 基础设置 - 监听地址/端口 */} +
+
+

+ {t("proxy.settings.basic.title", { + defaultValue: "基础设置", + })} +

+

+ {t("proxy.settings.basic.description", { + defaultValue: "配置代理服务监听的地址与端口。", + })} +

+
+ +
+
+ + setListenAddress(e.target.value)} + placeholder="127.0.0.1" + /> +

+ {t("proxy.settings.fields.listenAddress.description", { + defaultValue: + "代理服务器监听的 IP 地址(推荐 127.0.0.1)", + })} +

+
+ +
+ + + setListenPort(parseInt(e.target.value) || 5000) + } + placeholder="5000" + /> +

+ {t("proxy.settings.fields.listenPort.description", { + defaultValue: "代理服务器监听的端口号(1024 ~ 65535)", + })} +

+
+
+ +
+ +
+
+ + {/* 代理服务已停止提示 */} +
+
+ +
+

+ {t("proxy.panel.stoppedTitle", { + defaultValue: "代理服务已停止", + })} +

+

+ {t("proxy.panel.stoppedDescription", { + defaultValue: "使用右上角开关即可启动服务", + })} +

-

- {t("proxy.panel.stoppedTitle", { - defaultValue: "代理服务已停止", - })} -

-

- {t("proxy.panel.stoppedDescription", { - defaultValue: "使用右上角开关即可启动服务", - })} -

-
)} - - ); } diff --git a/src/components/proxy/ProxySettingsDialog.tsx b/src/components/proxy/ProxySettingsDialog.tsx deleted file mode 100644 index 3ae452f89..000000000 --- a/src/components/proxy/ProxySettingsDialog.tsx +++ /dev/null @@ -1,403 +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"; - -// 表单数据类型(仅包含可编辑字段) -type ProxyConfigForm = Pick< - ProxyConfig, - | "listen_address" - | "listen_port" - | "max_retries" - | "request_timeout" - | "enable_logging" ->; - -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({ - 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({ - listen_address: config.listen_address, - listen_port: config.listen_port, - max_retries: config.max_retries, - request_timeout: config.request_timeout, - enable_logging: config.enable_logging, - }); - } - }, [config, form]); - - const onSubmit = async (data: ProxyConfigForm) => { - try { - await updateConfig(data); - closePanel(); - } catch (error) { - console.error("Save config failed:", error); - } - }; - - const formId = "proxy-settings-form"; - - return ( - - - - - } - > -
-

- {t("proxy.settings.description", { - defaultValue: - "配置本地代理服务器的监听地址、端口和运行参数,保存后立即生效。", - })} -

- - - - {t("proxy.settings.alert.autoApply", { - defaultValue: - "保存后将自动同步到正在运行的代理服务,无需手动重启。", - })} - - - -
- -
-
-

- {t("proxy.settings.basic.title", { - defaultValue: "基础设置", - })} -

-

- {t("proxy.settings.basic.description", { - defaultValue: "配置代理服务监听的地址与端口。", - })} -

-
- -
- ( - - - {t("proxy.settings.fields.listenAddress.label", { - defaultValue: "监听地址", - })} - - - - - - {t("proxy.settings.fields.listenAddress.description", { - defaultValue: - "代理服务器监听的 IP 地址(推荐 127.0.0.1)", - })} - - - - )} - /> - - ( - - - {t("proxy.settings.fields.listenPort.label", { - defaultValue: "监听端口", - })} - - - - field.onChange(parseInt(e.target.value, 10) || 0) - } - placeholder={t( - "proxy.settings.fields.listenPort.placeholder", - { defaultValue: "5000" }, - )} - disabled={isLoading} - /> - - - {t("proxy.settings.fields.listenPort.description", { - defaultValue: - "代理服务器监听的端口号(1024 ~ 65535)", - })} - - - - )} - /> -
-
- -
-
-

- {t("proxy.settings.advanced.title", { - defaultValue: "高级参数", - })} -

-

- {t("proxy.settings.advanced.description", { - defaultValue: "控制请求的稳定性和日志记录。", - })} -

-
- -
- ( - - - {t("proxy.settings.fields.maxRetries.label", { - defaultValue: "最大重试次数", - })} - - - - field.onChange(parseInt(e.target.value, 10) || 0) - } - placeholder={t( - "proxy.settings.fields.maxRetries.placeholder", - { defaultValue: "3" }, - )} - disabled={isLoading} - /> - - - {t("proxy.settings.fields.maxRetries.description", { - defaultValue: "请求失败时的重试次数(0 ~ 10)", - })} - - - - )} - /> - - ( - - - {t("proxy.settings.fields.requestTimeout.label", { - defaultValue: "请求超时(秒)", - })} - - - - field.onChange(parseInt(e.target.value, 10) || 0) - } - placeholder={t( - "proxy.settings.fields.requestTimeout.placeholder", - { defaultValue: "0(不限)或 300" }, - )} - disabled={isLoading} - /> - - - {t("proxy.settings.fields.requestTimeout.description", { - defaultValue: - "单个请求的最大等待时间(0 表示不限制,或设置 10 ~ 600 秒)", - })} - - - - )} - /> -
- - ( - -
- - {t("proxy.settings.fields.enableLogging.label", { - defaultValue: "启用日志记录", - })} - - - {t("proxy.settings.fields.enableLogging.description", { - defaultValue: "记录所有代理请求,便于排查问题", - })} - -
- - - -
- )} - /> -
-
- -
-
- ); -} diff --git a/src/components/proxy/index.ts b/src/components/proxy/index.ts index b645228d8..0abc65408 100644 --- a/src/components/proxy/index.ts +++ b/src/components/proxy/index.ts @@ -3,4 +3,3 @@ */ export { ProxyPanel } from "./ProxyPanel"; -export { ProxySettingsDialog } from "./ProxySettingsDialog"; diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 30481bd3d..44c538af5 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -384,47 +384,89 @@ export function SettingsPage({
)} - {/* 故障转移队列管理 - 每个应用独立 */} -
-
-

- {t("proxy.failoverQueue.title")} -

-

- {t("proxy.failoverQueue.description")} -

-
- - - Claude - Codex - Gemini - - + {/* 故障转移设置 - 按应用分组 */} + + + Claude + Codex + Gemini + + +
+
+

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

+
- - +
+
+ +
+
+ +
+
+

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

+
- - +
+
+ +
+
+ +
+
+

+ {t("proxy.failoverQueue.title")} +

+

+ {t("proxy.failoverQueue.description")} +

+
- - -
- - {/* 熔断器配置 - 全局共享 */} -
- -
+
+
+ +
+ +
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 051d4affc..88cf7336b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -981,6 +981,76 @@ } }, "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}}" @@ -1013,7 +1083,7 @@ "failureThresholdHint": "Open circuit breaker after this many consecutive failures (recommended: 3-10)", "timeout": "Recovery Wait Time (seconds)", "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", "successThresholdHint": "Close circuit breaker after this many successes in half-open state", "errorRate": "Error Rate Threshold (%)", @@ -1042,5 +1112,21 @@ "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" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 6554839b7..93567bf0b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -981,6 +981,76 @@ } }, "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}}" @@ -1013,7 +1083,7 @@ "failureThresholdHint": "この回数連続で失敗するとサーキットブレーカーが開きます(推奨: 3-10)", "timeout": "回復待ち時間(秒)", "timeoutHint": "サーキットが開いた後、回復を試みるまでの待ち時間(推奨: 30-120)", - "circuitBreakerSettings": "サーキットブレーカー詳細設定", + "circuitBreakerSettings": "サーキットブレーカー設定", "successThreshold": "回復成功しきい値", "successThresholdHint": "半開状態でこの回数成功するとサーキットブレーカーが閉じます", "errorRate": "エラー率しきい値 (%)", @@ -1042,5 +1112,21 @@ "timeout": "タイムアウト(秒)", "maxRetries": "最大リトライ回数", "degradedThreshold": "劣化しきい値(ミリ秒)" + }, + "proxyConfig": { + "proxyEnabled": "プロキシ有効", + "appTakeover": "プロキシ有効", + "perAppConfig": "アプリ別設定", + "circuitBreaker": "サーキットブレーカー", + "circuitBreakerSettings": "サーキットブレーカー設定", + "failureThreshold": "失敗閾値", + "successThreshold": "回復閾値", + "recoveryTimeout": "回復待機時間", + "errorRateThreshold": "エラー率閾値", + "minRequests": "最小リクエスト数", + "timeoutConfig": "タイムアウト設定", + "streamingFirstByte": "ストリーミング初回バイトタイムアウト", + "streamingIdle": "ストリーミングアイドルタイムアウト", + "nonStreaming": "非ストリーミングタイムアウト" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2b7d7cf5e..55aeb99bb 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -981,6 +981,76 @@ } }, "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}}" @@ -1013,7 +1083,7 @@ "failureThresholdHint": "连续失败多少次后打开熔断器(建议: 3-10)", "timeout": "恢复等待时间(秒)", "timeoutHint": "熔断器打开后,等待多久后尝试恢复(建议: 30-120)", - "circuitBreakerSettings": "熔断器高级设置", + "circuitBreakerSettings": "熔断器设置", "successThreshold": "恢复成功阈值", "successThresholdHint": "半开状态下成功多少次后关闭熔断器", "errorRate": "错误率阈值 (%)", @@ -1042,5 +1112,21 @@ "timeout": "超时时间(秒)", "maxRetries": "最大重试次数", "degradedThreshold": "降级阈值(毫秒)" + }, + "proxyConfig": { + "proxyEnabled": "代理总开关", + "appTakeover": "代理启用", + "perAppConfig": "应用配置", + "circuitBreaker": "熔断器配置", + "circuitBreakerSettings": "熔断器设置", + "failureThreshold": "失败阈值", + "successThreshold": "恢复阈值", + "recoveryTimeout": "恢复等待时间", + "errorRateThreshold": "错误率阈值", + "minRequests": "最小请求数", + "timeoutConfig": "超时配置", + "streamingFirstByte": "流式首字节超时", + "streamingIdle": "流式静默超时", + "nonStreaming": "非流式超时" } } diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 9053449eb..371faa945 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -5,6 +5,7 @@ export { mcpApi } from "./mcp"; export { promptsApi } from "./prompts"; export { usageApi } from "./usage"; export { vscodeApi } from "./vscode"; +export { proxyApi } from "./proxy"; export * as configApi from "./config"; export type { ProviderSwitchEvent } from "./providers"; export type { Prompt } from "./prompts"; diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts new file mode 100644 index 000000000..6009fe745 --- /dev/null +++ b/src/lib/api/proxy.ts @@ -0,0 +1,95 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + ProxyConfig, + ProxyStatus, + ProxyServerInfo, + ProxyTakeoverStatus, + GlobalProxyConfig, + AppProxyConfig, +} from "@/types/proxy"; + +export const proxyApi = { + // ========== 代理服务器控制 API ========== + + // 启动代理服务器 + async startProxyServer(): Promise { + return invoke("start_proxy_server"); + }, + + // 停止代理服务器并恢复配置 + async stopProxyWithRestore(): Promise { + return invoke("stop_proxy_with_restore"); + }, + + // 获取代理服务器状态 + async getProxyStatus(): Promise { + return invoke("get_proxy_status"); + }, + + // 检查代理服务器是否正在运行 + async isProxyRunning(): Promise { + return invoke("is_proxy_running"); + }, + + // 检查是否处于接管模式 + async isLiveTakeoverActive(): Promise { + return invoke("is_live_takeover_active"); + }, + + // 代理模式下切换供应商 + async switchProxyProvider( + appType: string, + providerId: string, + ): Promise { + return invoke("switch_proxy_provider", { appType, providerId }); + }, + + // ========== 接管状态 API ========== + + // 获取各应用接管状态 + async getProxyTakeoverStatus(): Promise { + return invoke("get_proxy_takeover_status"); + }, + + // 为指定应用开启/关闭接管 + async setProxyTakeoverForApp( + appType: string, + enabled: boolean, + ): Promise { + return invoke("set_proxy_takeover_for_app", { appType, enabled }); + }, + + // ========== Legacy 代理配置 API (兼容) ========== + + // 获取代理配置(旧版 v2 兼容接口) + async getProxyConfig(): Promise { + return invoke("get_proxy_config"); + }, + + // 更新代理配置(旧版 v2 兼容接口) + async updateProxyConfig(config: ProxyConfig): Promise { + return invoke("update_proxy_config", { config }); + }, + + // ========== v3+ 全局/应用级配置 API ========== + + // 获取全局代理配置 + async getGlobalProxyConfig(): Promise { + return invoke("get_global_proxy_config"); + }, + + // 更新全局代理配置 + async updateGlobalProxyConfig(config: GlobalProxyConfig): Promise { + return invoke("update_global_proxy_config", { config }); + }, + + // 获取指定应用的代理配置 + async getProxyConfigForApp(appType: string): Promise { + return invoke("get_proxy_config_for_app", { appType }); + }, + + // 更新指定应用的代理配置 + async updateProxyConfigForApp(config: AppProxyConfig): Promise { + return invoke("update_proxy_config_for_app", { config }); + }, +}; diff --git a/src/lib/query/index.ts b/src/lib/query/index.ts index a3961bf60..223ec0327 100644 --- a/src/lib/query/index.ts +++ b/src/lib/query/index.ts @@ -1,3 +1,4 @@ export * from "./queryClient"; export * from "./queries"; export * from "./mutations"; +export * from "./proxy"; diff --git a/src/lib/query/proxy.ts b/src/lib/query/proxy.ts new file mode 100644 index 000000000..38e162488 --- /dev/null +++ b/src/lib/query/proxy.ts @@ -0,0 +1,239 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { proxyApi } from "@/lib/api/proxy"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import type { GlobalProxyConfig, AppProxyConfig } from "@/types/proxy"; + +// ========== 代理服务器状态 Hooks ========== + +/** + * 获取代理服务器状态 + */ +export function useProxyStatus() { + return useQuery({ + queryKey: ["proxyStatus"], + queryFn: () => proxyApi.getProxyStatus(), + refetchInterval: 5000, // 每 5 秒刷新一次 + }); +} + +/** + * 检查代理服务器是否运行 + */ +export function useIsProxyRunning() { + return useQuery({ + queryKey: ["proxyRunning"], + queryFn: () => proxyApi.isProxyRunning(), + refetchInterval: 2000, + }); +} + +/** + * 检查是否处于接管模式 + */ +export function useIsLiveTakeoverActive() { + return useQuery({ + queryKey: ["liveTakeoverActive"], + queryFn: () => proxyApi.isLiveTakeoverActive(), + refetchInterval: 2000, + }); +} + +/** + * 获取各应用接管状态 + */ +export function useProxyTakeoverStatus() { + return useQuery({ + queryKey: ["proxyTakeoverStatus"], + queryFn: () => proxyApi.getProxyTakeoverStatus(), + refetchInterval: 2000, + }); +} + +// ========== 代理服务器控制 Hooks ========== + +/** + * 启动代理服务器 + */ +export function useStartProxyServer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => proxyApi.startProxyServer(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + queryClient.invalidateQueries({ queryKey: ["proxyRunning"] }); + queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] }); + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + }, + }); +} + +/** + * 停止代理服务器 + */ +export function useStopProxyServer() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: () => proxyApi.stopProxyWithRestore(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + queryClient.invalidateQueries({ queryKey: ["proxyRunning"] }); + queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] }); + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + }, + }); +} + +/** + * 设置应用接管状态 + */ +export function useSetProxyTakeoverForApp() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ appType, enabled }: { appType: string; enabled: boolean }) => + proxyApi.setProxyTakeoverForApp(appType, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["proxyTakeoverStatus"] }); + queryClient.invalidateQueries({ queryKey: ["liveTakeoverActive"] }); + }, + }); +} + +/** + * 代理模式下切换供应商 + */ +export function useSwitchProxyProvider() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: ({ + appType, + providerId, + }: { + appType: string; + providerId: string; + }) => proxyApi.switchProxyProvider(appType, providerId), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + queryClient.invalidateQueries({ + queryKey: ["providers", variables.appType], + }); + }, + onError: (error: Error) => { + toast.error(t("proxy.switchFailed", { error: error.message })); + }, + }); +} + +// ========== Legacy 代理配置 Hooks (兼容) ========== + +/** + * 获取代理配置(旧版) + */ +export function useProxyConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + const { data: config, isLoading } = useQuery({ + queryKey: ["proxyConfig"], + queryFn: () => proxyApi.getProxyConfig(), + }); + + const updateMutation = useMutation({ + mutationFn: proxyApi.updateProxyConfig, + onSuccess: () => { + toast.success(t("proxy.settings.toast.saved"), { closeButton: true }); + queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + toast.error( + t("proxy.settings.toast.saveFailed", { error: error.message }), + ); + }, + }); + + return { + config, + isLoading, + updateConfig: updateMutation.mutateAsync, + isUpdating: updateMutation.isPending, + }; +} + +// ========== v3+ 全局/应用级配置 Hooks ========== + +/** + * 获取全局代理配置 + */ +export function useGlobalProxyConfig() { + return useQuery({ + queryKey: ["globalProxyConfig"], + queryFn: () => proxyApi.getGlobalProxyConfig(), + }); +} + +/** + * 更新全局代理配置 + */ +export function useUpdateGlobalProxyConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (config: GlobalProxyConfig) => + proxyApi.updateGlobalProxyConfig(config), + onSuccess: () => { + toast.success(t("proxy.settings.toast.saved"), { closeButton: true }); + queryClient.invalidateQueries({ queryKey: ["globalProxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["proxyStatus"] }); + }, + onError: (error: Error) => { + toast.error( + t("proxy.settings.toast.saveFailed", { error: error.message }), + ); + }, + }); +} + +/** + * 获取指定应用的代理配置 + */ +export function useAppProxyConfig(appType: string) { + return useQuery({ + queryKey: ["appProxyConfig", appType], + queryFn: () => proxyApi.getProxyConfigForApp(appType), + enabled: !!appType, + }); +} + +/** + * 更新指定应用的代理配置 + */ +export function useUpdateAppProxyConfig() { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + + return useMutation({ + mutationFn: (config: AppProxyConfig) => + proxyApi.updateProxyConfigForApp(config), + onSuccess: (_, variables) => { + toast.success(t("proxy.settings.toast.saved"), { closeButton: true }); + queryClient.invalidateQueries({ + queryKey: ["appProxyConfig", variables.appType], + }); + queryClient.invalidateQueries({ queryKey: ["proxyConfig"] }); + queryClient.invalidateQueries({ queryKey: ["circuitBreakerConfig"] }); + }, + onError: (error: Error) => { + toast.error( + t("proxy.settings.toast.saveFailed", { error: error.message }), + ); + }, + }); +} diff --git a/src/types/proxy.ts b/src/types/proxy.ts index cc809dfeb..4d11370ad 100644 --- a/src/types/proxy.ts +++ b/src/types/proxy.ts @@ -5,6 +5,10 @@ export interface ProxyConfig { request_timeout: number; enable_logging: boolean; live_takeover_active?: boolean; + // 超时配置 + streaming_first_byte_timeout: number; + streaming_idle_timeout: number; + non_streaming_timeout: number; } export interface ProxyStatus { @@ -105,3 +109,27 @@ export interface FailoverQueueItem { providerName: string; sortIndex?: number; } + +// 全局代理配置(统一字段,三行镜像) +export interface GlobalProxyConfig { + proxyEnabled: boolean; + listenAddress: string; + listenPort: number; + enableLogging: boolean; +} + +// 应用级代理配置(每个 app 独立) +export interface AppProxyConfig { + appType: string; + enabled: boolean; + autoFailoverEnabled: boolean; + maxRetries: number; + streamingFirstByteTimeout: number; + streamingIdleTimeout: number; + nonStreamingTimeout: number; + circuitFailureThreshold: number; + circuitSuccessThreshold: number; + circuitTimeoutSeconds: number; + circuitErrorRateThreshold: number; + circuitMinRequests: number; +}