diff --git a/CLAUDE.md b/CLAUDE.md index 42d836c..a7e069d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -155,6 +155,14 @@ last-updated: 2025-12-07 - 旧的 `transparent_proxy_*` 字段会在读取配置时自动迁移到新结构 - 新命令:`start_tool_proxy`、`stop_tool_proxy`、`get_all_proxy_status` - 旧命令保持兼容,内部使用新架构实现 + - **配置管理机制(2025-12-12)**: + - 代理启动时自动创建内置 Profile(`dc_proxy_*`),通过 `ProfileManager` 切换配置 + - 内置 Profile 在 UI 中不可见(列表查询时过滤 `dc_proxy_` 前缀) + - `dc_proxy_` 为系统保留前缀,用户无法创建同名 Profile + - 代理关闭时自动还原到启动前激活的 Profile + - 运行时禁止修改 `ToolProxyConfig`,确保配置一致性 + - `original_active_profile` 字段记录启动前的 Profile 用于还原 + - Gemini CLI 的 model 字段为可选,允许不填(内置代理 Profile 不设置 model,保留用户原有配置) - `ToolProxyConfig` 额外存储 `real_profile_name`、`auto_start`、工具级 `session_endpoint_config_enabled`,全局配置新增 `hide_transparent_proxy_tip` 控制设置页横幅显示 - `GlobalConfig.hide_session_config_hint` 持久化会话级端点提示的隐藏状态,`ProxyControlBar`/`ProxySettingsDialog`/`ClaudeContent` 通过 `open-proxy-settings` 与 `proxy-config-updated` 事件联动刷新视图 - 日志系统支持完整配置管理:`GlobalConfig.log_config` 存储级别/格式/输出目标;`log_commands.rs` 提供查询与更新命令,`LogSettingsTab` 可热重载级别、保存文件输出设置;`core/logger.rs` 通过 `update_log_level` reload 机制动态调整 @@ -169,7 +177,23 @@ last-updated: 2025-12-07 - `ToolStatusCache` 标记为已废弃,保留仅用于向后兼容 - 所有工具状态查询统一走 `ToolRegistry::get_local_tool_status()` 和 `refresh_and_get_local_status()` - UI 相关的托盘/窗口操作集中在 `src-tauri/src/ui/*`,其它模块如需最小化到托盘请调用 `ui::hide_window_to_tray` 等封装方法。 -- 新增 `TransparentProxyPage` 与会话数据库:`SESSION_MANAGER` 使用 SQLite 记录每个代理会话的 endpoint/API Key,前端可按工具启停代理、查看历史并启用「会话级 Endpoint 配置」开关。页面内的 `ProxyControlBar`、`ProxySettingsDialog`、`ProxyConfigDialog` 负责代理启停、配置切换、工具级设置并内建缺失配置提示。 +- **会话管理系统(2025-12-12 重构)**: + - **架构迁移**:`SessionDatabase` 已删除,`SessionManager` 直接使用 `DataManager::sqlite()` 管理会话数据 + - **核心模块**(位于 `services/session/`): + - `manager.rs`:`SESSION_MANAGER` 单例,内部持有 `Arc` 和 `db_path` + - `db_utils.rs`:私有工具模块,提供 `QueryRow ↔ ProxySession` 转换、SQL 常量定义 + - `models.rs`:数据模型(`ProxySession`、`SessionEvent`、`SessionListResponse`) + - **查询缓存**:所有数据库查询自动利用 `SqliteManager` 的查询缓存(容量 100,TTL 5分钟) + - **转换工具**: + - `parse_proxy_session(row)` - 将 QueryRow 转换为 ProxySession(处理 13 个字段 + NULL 值) + - `parse_count(row)` - 提取计数查询结果 + - `parse_session_config(row)` - 提取三元组配置 (config_name, url, api_key) + - **后台任务**: + - 批量写入任务:每 100ms 或缓冲区满 10 条时批量 upsert 会话 + - 定期清理任务:每小时清理 30 天未活跃会话和超过 1000 条的旧会话 + - **测试覆盖**:10 个单元测试(5 个 db_utils 转换测试 + 2 个 models 测试 + 3 个 manager 集成测试) + - **代码减少**:从 366 行(db.rs)减少到 ~320 行(manager.rs 250 行 + db_utils.rs 70 行工具函数) +- 前端透明代理页面:`TransparentProxyPage` 通过 `SESSION_MANAGER` 记录每个代理会话的 endpoint/API Key,支持按工具启停代理、查看历史并启用「会话级 Endpoint 配置」开关。页面内的 `ProxyControlBar`、`ProxySettingsDialog`、`ProxyConfigDialog` 负责代理启停、配置切换、工具级设置并内建缺失配置提示。 - **余额监控页面(BalancePage)**: - 后端提供通用 `fetch_api` 命令(位于 `commands/api_commands.rs`),支持 GET/POST、自定义 headers、超时控制 - 前端使用 JavaScript `Function` 构造器执行用户自定义的 extractor 脚本(位于 `utils/extractor.ts`) diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index eb790f6..d53e42b 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -11,7 +11,8 @@ pub enum ProfileInput { Gemini { api_key: String, base_url: String, - model: String, + #[serde(default)] + model: Option, }, Codex { api_key: String, @@ -119,7 +120,7 @@ pub async fn pm_save_profile( model, } = input { - manager.save_gemini_profile(&name, api_key, base_url, Some(model)) + manager.save_gemini_profile(&name, api_key, base_url, model) } else { Err(anyhow::anyhow!("Gemini CLI 需要 Gemini Profile 数据")) } diff --git a/src-tauri/src/commands/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index f17a0c9..e227525 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -349,32 +349,79 @@ pub async fn start_tool_proxy( tool_id: String, manager_state: State<'_, ProxyManagerState>, ) -> Result { - // 从 ProxyConfigManager 读取配置 + use ::duckcoding::services::profile_manager::ProfileManager; + + let profile_mgr = ProfileManager::new().map_err(|e| e.to_string())?; let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; - let tool_config = proxy_config_mgr + + // 读取当前配置 + let mut tool_config = proxy_config_mgr .get_config(&tool_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("工具 {} 的代理配置不存在", tool_id))?; - // 检查是否启用 - if !tool_config.enabled { - return Err(format!("{} 的透明代理未启用", tool_id)); + // 检查是否已在运行 + if manager_state.manager.is_running(&tool_id).await { + return Err(format!("{} 代理已在运行", tool_id)); } // 检查必要字段 + if !tool_config.enabled { + return Err(format!("{} 的透明代理未启用", tool_id)); + } if tool_config.local_api_key.is_none() { return Err("透明代理保护密钥未设置".to_string()); } - if tool_config.real_api_key.is_none() { - return Err("真实 API Key 未设置".to_string()); + if tool_config.real_api_key.is_none() || tool_config.real_base_url.is_none() { + return Err("真实 API Key 或 Base URL 未设置".to_string()); } - if tool_config.real_base_url.is_none() { - return Err("真实 Base URL 未设置".to_string()); + + // ========== Profile 切换逻辑 ========== + + // 1. 读取当前激活的 Profile 名称 + let original_profile = profile_mgr + .get_active_profile_name(&tool_id) + .map_err(|e| e.to_string())?; + + // 2. 保存到 ToolProxyConfig + tool_config.original_active_profile = original_profile.clone(); + proxy_config_mgr + .update_config(&tool_id, tool_config.clone()) + .map_err(|e| e.to_string())?; + + // 3. 验证内置 Profile 是否存在 + let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); + + let profile_exists = match tool_id.as_str() { + "claude-code" => profile_mgr.get_claude_profile(&proxy_profile_name).is_ok(), + "codex" => profile_mgr.get_codex_profile(&proxy_profile_name).is_ok(), + "gemini-cli" => profile_mgr.get_gemini_profile(&proxy_profile_name).is_ok(), + _ => false, + }; + + if !profile_exists { + return Err(format!( + "内置 Profile 不存在,请先保存代理配置: {}", + proxy_profile_name + )); } + // 4. 激活内置 Profile(这会自动同步到原生配置文件) + profile_mgr + .activate_profile(&tool_id, &proxy_profile_name) + .map_err(|e| format!("激活内置 Profile 失败: {}", e))?; + + tracing::info!( + tool_id = %tool_id, + original_profile = ?original_profile, + proxy_profile = %proxy_profile_name, + "已切换到代理 Profile" + ); + + // ========== 启动代理 ========== + let proxy_port = tool_config.port; - // 启动代理 manager_state .manager .start_proxy(&tool_id, tool_config) @@ -382,7 +429,7 @@ pub async fn start_tool_proxy( .map_err(|e| format!("启动代理失败: {}", e))?; Ok(format!( - "✅ {} 透明代理已启动\n监听端口: {}\n请求将自动转发", + "✅ {} 透明代理已启动\n监听端口: {}\n已切换到代理配置", tool_id, proxy_port )) } @@ -393,30 +440,59 @@ pub async fn stop_tool_proxy( tool_id: String, manager_state: State<'_, ProxyManagerState>, ) -> Result { - // 读取全局配置 - let config = get_global_config() - .await - .map_err(|e| format!("读取配置失败: {e}"))? - .ok_or_else(|| "全局配置不存在".to_string())?; + use ::duckcoding::services::profile_manager::ProfileManager; + + let profile_mgr = ProfileManager::new().map_err(|e| e.to_string())?; + let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; + + // 读取代理配置 + let mut tool_config = proxy_config_mgr + .get_config(&tool_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("工具 {} 的代理配置不存在", tool_id))?; + + // ========== 停止代理 ========== - // 停止代理 manager_state .manager .stop_proxy(&tool_id) .await .map_err(|e| format!("停止代理失败: {e}"))?; - // 恢复工具配置 - if let Some(tool_config) = config.get_proxy_config(&tool_id) { - if tool_config.real_api_key.is_some() { - let tool = Tool::by_id(&tool_id).ok_or_else(|| format!("未知工具: {tool_id}"))?; + // ========== Profile 还原逻辑 ========== - TransparentProxyConfigService::disable_transparent_proxy(&tool, &config) - .map_err(|e| format!("恢复配置失败: {e}"))?; - } - } + let original_profile = tool_config.original_active_profile.take(); + + if let Some(profile_name) = original_profile { + // 有原始 Profile,切回去 + profile_mgr + .activate_profile(&tool_id, &profile_name) + .map_err(|e| format!("还原 Profile 失败: {}", e))?; - Ok(format!("✅ {tool_id} 透明代理已停止\n配置已恢复")) + tracing::info!( + tool_id = %tool_id, + restored_profile = %profile_name, + "已还原到原始 Profile" + ); + + // 清空 original_active_profile 字段 + proxy_config_mgr + .update_config(&tool_id, tool_config) + .map_err(|e| e.to_string())?; + + Ok(format!( + "✅ {tool_id} 透明代理已停止\n已还原到 Profile: {profile_name}" + )) + } else { + // 没有原始 Profile(启动代理前用户就没激活任何 Profile) + // 按需求:不做任何操作,保持当前状态 + tracing::info!( + tool_id = %tool_id, + "启动代理前无激活 Profile,保持当前状态" + ); + + Ok(format!("✅ {tool_id} 透明代理已停止")) + } } /// 获取所有工具的透明代理状态 @@ -531,11 +607,72 @@ pub async fn get_proxy_config( pub async fn update_proxy_config( tool_id: String, config: ::duckcoding::models::proxy_config::ToolProxyConfig, + manager_state: State<'_, ProxyManagerState>, ) -> Result<(), String> { + use ::duckcoding::services::profile_manager::ProfileManager; + + // ========== 运行时保护检查 ========== + if manager_state.manager.is_running(&tool_id).await { + return Err(format!("{} 代理正在运行,请先停止代理再修改配置", tool_id)); + } + + // ========== 更新配置到全局配置文件 ========== let proxy_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; proxy_mgr - .update_config(&tool_id, config) - .map_err(|e| e.to_string()) + .update_config(&tool_id, config.clone()) + .map_err(|e| e.to_string())?; + + // ========== 同步创建/更新内置 Profile ========== + + // 只有在配置完整时才创建内置 Profile + if config.enabled + && config.local_api_key.is_some() + && config.real_api_key.is_some() + && config.real_base_url.is_some() + { + let profile_mgr = ProfileManager::new().map_err(|e| e.to_string())?; + let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); + let proxy_endpoint = format!("http://127.0.0.1:{}", config.port); + let proxy_key = config.local_api_key.unwrap(); + + match tool_id.as_str() { + "claude-code" => { + profile_mgr + .save_claude_profile_internal(&proxy_profile_name, proxy_key, proxy_endpoint) + .map_err(|e| format!("同步内置 Profile 失败: {}", e))?; + } + "codex" => { + profile_mgr + .save_codex_profile_internal( + &proxy_profile_name, + proxy_key, + proxy_endpoint, + Some("responses".to_string()), + ) + .map_err(|e| format!("同步内置 Profile 失败: {}", e))?; + } + "gemini-cli" => { + profile_mgr + .save_gemini_profile_internal( + &proxy_profile_name, + proxy_key, + proxy_endpoint, + None, // 不设置 model,保留用户原有配置 + ) + .map_err(|e| format!("同步内置 Profile 失败: {}", e))?; + } + _ => return Err(format!("不支持的工具: {}", tool_id)), + } + + tracing::info!( + tool_id = %tool_id, + proxy_profile = %proxy_profile_name, + port = config.port, + "已同步更新内置 Profile" + ); + } + + Ok(()) } /// 获取所有工具的代理配置 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 485eeae..2d12f3d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -136,6 +136,67 @@ fn hide_window_to_tray(window: &WebviewWindow) { } } +/// 初始化内置 Profile(用于透明代理配置切换) +fn initialize_proxy_profiles() -> Result<(), Box> { + use duckcoding::services::profile_manager::ProfileManager; + use duckcoding::services::proxy_config_manager::ProxyConfigManager; + + let proxy_mgr = ProxyConfigManager::new()?; + let profile_mgr = ProfileManager::new()?; + + for tool_id in &["claude-code", "codex", "gemini-cli"] { + if let Ok(Some(config)) = proxy_mgr.get_config(tool_id) { + // 只有在配置完整时才创建内置 Profile + if config.enabled + && config.local_api_key.is_some() + && config.real_api_key.is_some() + && config.real_base_url.is_some() + { + let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); + let proxy_endpoint = format!("http://127.0.0.1:{}", config.port); + let proxy_key = config.local_api_key.unwrap(); + + let result = match *tool_id { + "claude-code" => profile_mgr.save_claude_profile_internal( + &proxy_profile_name, + proxy_key, + proxy_endpoint, + ), + "codex" => profile_mgr.save_codex_profile_internal( + &proxy_profile_name, + proxy_key, + proxy_endpoint, + Some("responses".to_string()), + ), + "gemini-cli" => profile_mgr.save_gemini_profile_internal( + &proxy_profile_name, + proxy_key, + proxy_endpoint, + None, // 不设置 model,保留用户原有配置 + ), + _ => continue, + }; + + if let Err(e) = result { + tracing::warn!( + tool_id = tool_id, + error = ?e, + "初始化内置 Profile 失败" + ); + } else { + tracing::debug!( + tool_id = tool_id, + profile = %proxy_profile_name, + "已初始化内置 Profile" + ); + } + } + } + } + + Ok(()) +} + fn main() { // 🆕 初始化日志系统(必须在最前面) use duckcoding::core::init_logger; @@ -156,6 +217,11 @@ fn main() { tracing::info!("DuckCoding 应用启动"); + // 🆕 初始化内置 Profile(用于透明代理) + if let Err(e) = initialize_proxy_profiles() { + tracing::warn!(error = ?e, "初始化内置 Profile 失败"); + } + // 创建透明代理服务实例(旧架构,保持兼容) let transparent_proxy_port = 8787; // 默认端口,实际会从配置读取 let transparent_proxy_service = TransparentProxyService::new(transparent_proxy_port); diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 86cd869..d49bc6a 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -107,6 +107,9 @@ pub struct ToolProxyConfig { pub session_endpoint_config_enabled: bool, // 工具级:是否允许会话自定义端点 #[serde(default)] pub auto_start: bool, // 应用启动时自动运行代理(默认关闭) + /// 启动代理前激活的 Profile 名称(用于关闭时还原) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_active_profile: Option, } impl Default for ToolProxyConfig { @@ -122,6 +125,7 @@ impl Default for ToolProxyConfig { allow_public: false, session_endpoint_config_enabled: false, auto_start: false, + original_active_profile: None, } } } @@ -212,6 +216,7 @@ fn default_proxy_configs() -> HashMap { allow_public: false, session_endpoint_config_enabled: false, auto_start: false, + original_active_profile: None, }, ); @@ -228,6 +233,7 @@ fn default_proxy_configs() -> HashMap { allow_public: false, session_endpoint_config_enabled: false, auto_start: false, + original_active_profile: None, }, ); @@ -244,6 +250,7 @@ fn default_proxy_configs() -> HashMap { allow_public: false, session_endpoint_config_enabled: false, auto_start: false, + original_active_profile: None, }, ); @@ -276,6 +283,7 @@ impl GlobalConfig { allow_public: false, session_endpoint_config_enabled: false, auto_start: false, + original_active_profile: None, }); } diff --git a/src-tauri/src/models/proxy_config.rs b/src-tauri/src/models/proxy_config.rs index 52b226e..802ce54 100644 --- a/src-tauri/src/models/proxy_config.rs +++ b/src-tauri/src/models/proxy_config.rs @@ -22,6 +22,9 @@ pub struct ToolProxyConfig { pub session_endpoint_config_enabled: bool, #[serde(default)] pub auto_start: bool, + /// 启动代理前激活的 Profile 名称(用于关闭时还原) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_active_profile: Option, } impl ToolProxyConfig { @@ -37,6 +40,7 @@ impl ToolProxyConfig { allow_public: false, session_endpoint_config_enabled: false, auto_start: false, + original_active_profile: None, } } diff --git a/src-tauri/src/services/migration_manager/migrations/profile_v2.rs b/src-tauri/src/services/migration_manager/migrations/profile_v2.rs index 001da6f..42b3fe8 100644 --- a/src-tauri/src/services/migration_manager/migrations/profile_v2.rs +++ b/src-tauri/src/services/migration_manager/migrations/profile_v2.rs @@ -361,7 +361,7 @@ impl ProfileV2Migration { let mut api_key = String::new(); let mut base_url = String::new(); - let mut model = "gemini-2.0-flash-exp".to_string(); + let mut model: Option = None; // 默认为空,只有文件中有值才设置 let raw_env = fs::read_to_string(&path).ok(); if let Some(ref content) = raw_env { @@ -374,7 +374,7 @@ impl ProfileV2Migration { match key.trim() { "GEMINI_API_KEY" => api_key = value.trim().to_string(), "GOOGLE_GEMINI_BASE_URL" => base_url = value.trim().to_string(), - "GEMINI_MODEL" => model = value.trim().to_string(), + "GEMINI_MODEL" => model = Some(value.trim().to_string()), _ => {} } } @@ -389,7 +389,7 @@ impl ProfileV2Migration { let profile = GeminiProfile { api_key, base_url, - model, + model, // 保留从文件读取的值(可能是 None) created_at: Utc::now(), updated_at: Utc::now(), raw_settings: None, @@ -543,8 +543,7 @@ impl ProfileV2Migration { let model = value .get("model") .and_then(|v| v.as_str()) - .unwrap_or("gemini-2.0-flash-exp") - .to_string(); + .map(|s| s.to_string()); // 只有存在时才设置,否则为 None let raw_settings = value.get("raw_settings").cloned(); let raw_env = value .get("raw_env") @@ -557,7 +556,7 @@ impl ProfileV2Migration { GeminiProfile { api_key, base_url, - model, + model, // 直接使用 Option created_at: descriptor.created_at.unwrap_or_else(Utc::now), updated_at: descriptor.updated_at.unwrap_or_else(Utc::now), raw_settings, @@ -880,7 +879,7 @@ impl GeminiProfile { Self { api_key: String::new(), base_url: String::new(), - model: "gemini-2.0-flash-exp".to_string(), + model: None, // 默认不设置 model created_at: Utc::now(), updated_at: Utc::now(), raw_settings: None, diff --git a/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs b/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs index 44b0599..c164117 100644 --- a/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs +++ b/src-tauri/src/services/migration_manager/migrations/proxy_config_split.rs @@ -278,5 +278,9 @@ fn parse_old_config(value: &Value) -> Result { .get("auto_start") .and_then(|v| v.as_bool()) .unwrap_or(false), + original_active_profile: obj + .get("original_active_profile") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), }) } diff --git a/src-tauri/src/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index 8ddcb64..9f98636 100644 --- a/src-tauri/src/services/profile_manager/manager.rs +++ b/src-tauri/src/services/profile_manager/manager.rs @@ -6,6 +6,20 @@ use anyhow::{anyhow, Context, Result}; use chrono::Utc; use std::path::PathBuf; +/// 系统保留的 Profile 名称前缀 +const RESERVED_PREFIX: &str = "dc_proxy_"; + +/// 校验 Profile 名称是否使用保留前缀 +fn validate_profile_name(name: &str) -> Result<()> { + if name.starts_with(RESERVED_PREFIX) { + return Err(anyhow!( + "Profile 名称不能以 '{}' 开头(系统保留前缀)", + RESERVED_PREFIX + )); + } + Ok(()) +} + pub struct ProfileManager { data_manager: DataManager, profiles_path: PathBuf, @@ -60,6 +74,9 @@ impl ProfileManager { // ==================== Claude Code ==================== pub fn save_claude_profile(&self, name: &str, api_key: String, base_url: String) -> Result<()> { + // 保留字校验 + validate_profile_name(name)?; + let mut store = self.load_profiles_store()?; let profile = if let Some(existing) = store.claude_code.get_mut(name) { @@ -121,7 +138,12 @@ impl ProfileManager { pub fn list_claude_profiles(&self) -> Result> { let store = self.load_profiles_store()?; - Ok(store.claude_code.keys().cloned().collect()) + Ok(store + .claude_code + .keys() + .filter(|name| !name.starts_with(RESERVED_PREFIX)) + .cloned() + .collect()) } // ==================== Codex ==================== @@ -133,6 +155,9 @@ impl ProfileManager { base_url: String, wire_api: Option, ) -> Result<()> { + // 保留字校验 + validate_profile_name(name)?; + let mut store = self.load_profiles_store()?; let profile = if let Some(existing) = store.codex.get_mut(name) { @@ -198,7 +223,12 @@ impl ProfileManager { pub fn list_codex_profiles(&self) -> Result> { let store = self.load_profiles_store()?; - Ok(store.codex.keys().cloned().collect()) + Ok(store + .codex + .keys() + .filter(|name| !name.starts_with(RESERVED_PREFIX)) + .cloned() + .collect()) } // ==================== Gemini CLI ==================== @@ -210,6 +240,9 @@ impl ProfileManager { base_url: String, model: Option, ) -> Result<()> { + // 保留字校验 + validate_profile_name(name)?; + let mut store = self.load_profiles_store()?; let profile = if let Some(existing) = store.gemini_cli.get_mut(name) { @@ -221,7 +254,9 @@ impl ProfileManager { existing.base_url = base_url; } if let Some(m) = model { - existing.model = m; + if !m.is_empty() { + existing.model = Some(m); + } } existing.updated_at = Utc::now(); existing.clone() @@ -233,7 +268,7 @@ impl ProfileManager { GeminiProfile { api_key, base_url, - model: model.unwrap_or_else(|| "gemini-2.0-flash-exp".to_string()), + model: model.filter(|m| !m.is_empty()), created_at: Utc::now(), updated_at: Utc::now(), raw_settings: None, @@ -275,7 +310,12 @@ impl ProfileManager { pub fn list_gemini_profiles(&self) -> Result> { let store = self.load_profiles_store()?; - Ok(store.gemini_cli.keys().cloned().collect()) + Ok(store + .gemini_cli + .keys() + .filter(|name| !name.starts_with(RESERVED_PREFIX)) + .cloned() + .collect()) } // ==================== 通用列表 ==================== @@ -288,18 +328,27 @@ impl ProfileManager { // Claude Code let active_claude = active_store.get_active("claude-code"); for (name, profile) in &profiles_store.claude_code { + if name.starts_with(RESERVED_PREFIX) { + continue; // 跳过内置 Profile + } descriptors.push(ProfileDescriptor::from_claude(name, profile, active_claude)); } // Codex let active_codex = active_store.get_active("codex"); for (name, profile) in &profiles_store.codex { + if name.starts_with(RESERVED_PREFIX) { + continue; // 跳过内置 Profile + } descriptors.push(ProfileDescriptor::from_codex(name, profile, active_codex)); } // Gemini CLI let active_gemini = active_store.get_active("gemini-cli"); for (name, profile) in &profiles_store.gemini_cli { + if name.starts_with(RESERVED_PREFIX) { + continue; // 跳过内置 Profile + } descriptors.push(ProfileDescriptor::from_gemini(name, profile, active_gemini)); } @@ -384,6 +433,146 @@ impl ProfileManager { self.capture_profile_from_native(tool_id, profile_name) } + // ==================== 内部方法(跳过保留字校验) ==================== + + /// 内部方法:保存 Claude Profile(跳过保留字校验,用于系统内置 Profile) + pub fn save_claude_profile_internal( + &self, + name: &str, + api_key: String, + base_url: String, + ) -> Result<()> { + let mut store = self.load_profiles_store()?; + + let profile = if let Some(existing) = store.claude_code.get_mut(name) { + // 更新模式:只更新非空字段 + if !api_key.is_empty() { + existing.api_key = api_key; + } + if !base_url.is_empty() { + existing.base_url = base_url; + } + existing.updated_at = Utc::now(); + existing.clone() + } else { + // 创建模式:必须有完整数据 + if api_key.is_empty() || base_url.is_empty() { + return Err(anyhow!("创建 Profile 时 API Key 和 Base URL 不能为空")); + } + ClaudeProfile { + api_key, + base_url, + created_at: Utc::now(), + updated_at: Utc::now(), + raw_settings: None, + raw_config_json: None, + } + }; + + store.claude_code.insert(name.to_string(), profile); + store.metadata.last_updated = Utc::now(); + self.save_profiles_store(&store)?; + + tracing::debug!("已创建/更新内置 Profile: {}", name); + Ok(()) + } + + /// 内部方法:保存 Codex Profile(跳过保留字校验,用于系统内置 Profile) + pub fn save_codex_profile_internal( + &self, + name: &str, + api_key: String, + base_url: String, + wire_api: Option, + ) -> Result<()> { + let mut store = self.load_profiles_store()?; + + let profile = if let Some(existing) = store.codex.get_mut(name) { + // 更新模式:只更新非空字段 + if !api_key.is_empty() { + existing.api_key = api_key; + } + if !base_url.is_empty() { + existing.base_url = base_url; + } + if let Some(w) = wire_api { + existing.wire_api = w; + } + existing.updated_at = Utc::now(); + existing.clone() + } else { + // 创建模式:必须有完整数据 + if api_key.is_empty() || base_url.is_empty() { + return Err(anyhow!("创建 Profile 时 API Key 和 Base URL 不能为空")); + } + CodexProfile { + api_key, + base_url, + wire_api: wire_api.unwrap_or_else(|| "responses".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + raw_config_toml: None, + raw_auth_json: None, + } + }; + + store.codex.insert(name.to_string(), profile); + store.metadata.last_updated = Utc::now(); + self.save_profiles_store(&store)?; + + tracing::debug!("已创建/更新内置 Profile: {}", name); + Ok(()) + } + + /// 内部方法:保存 Gemini Profile(跳过保留字校验,用于系统内置 Profile) + pub fn save_gemini_profile_internal( + &self, + name: &str, + api_key: String, + base_url: String, + model: Option, + ) -> Result<()> { + let mut store = self.load_profiles_store()?; + + let profile = if let Some(existing) = store.gemini_cli.get_mut(name) { + // 更新模式:只更新非空字段 + if !api_key.is_empty() { + existing.api_key = api_key; + } + if !base_url.is_empty() { + existing.base_url = base_url; + } + if let Some(m) = model { + if !m.is_empty() { + existing.model = Some(m); + } + } + existing.updated_at = Utc::now(); + existing.clone() + } else { + // 创建模式:必须有完整数据 + if api_key.is_empty() || base_url.is_empty() { + return Err(anyhow!("创建 Profile 时 API Key 和 Base URL 不能为空")); + } + GeminiProfile { + api_key, + base_url, + model: model.filter(|m| !m.is_empty()), + created_at: Utc::now(), + updated_at: Utc::now(), + raw_settings: None, + raw_env: None, + } + }; + + store.gemini_cli.insert(name.to_string(), profile); + store.metadata.last_updated = Utc::now(); + self.save_profiles_store(&store)?; + + tracing::debug!("已创建/更新内置 Profile: {}", name); + Ok(()) + } + // ==================== 删除 ==================== pub fn delete_profile(&self, tool_id: &str, name: &str) -> Result<()> { diff --git a/src-tauri/src/services/profile_manager/native_config.rs b/src-tauri/src/services/profile_manager/native_config.rs index 5ad7383..e1523e5 100644 --- a/src-tauri/src/services/profile_manager/native_config.rs +++ b/src-tauri/src/services/profile_manager/native_config.rs @@ -268,9 +268,11 @@ fn apply_gemini_native(tool: &Tool, profile: &GeminiProfile) -> Result<()> { manager .env() .set(&env_path, "GOOGLE_GEMINI_BASE_URL", &profile.base_url)?; - manager - .env() - .set(&env_path, "GEMINI_MODEL", &profile.model)?; + + // 只在 model 有值时才写入 + if let Some(ref model) = profile.model { + manager.env().set(&env_path, "GEMINI_MODEL", model)?; + } Ok(()) } diff --git a/src-tauri/src/services/profile_manager/types.rs b/src-tauri/src/services/profile_manager/types.rs index e7e2143..7d413eb 100644 --- a/src-tauri/src/services/profile_manager/types.rs +++ b/src-tauri/src/services/profile_manager/types.rs @@ -45,8 +45,8 @@ fn default_codex_wire_api() -> String { pub struct GeminiProfile { pub api_key: String, pub base_url: String, - #[serde(default = "default_gemini_model")] - pub model: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, pub created_at: DateTime, pub updated_at: DateTime, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -55,10 +55,6 @@ pub struct GeminiProfile { pub raw_env: Option, } -fn default_gemini_model() -> String { - "gemini-2.0-flash-exp".to_string() -} - // ==================== profiles.json 结构 ==================== /// profiles.json 顶层结构 @@ -314,7 +310,7 @@ impl ProfileDescriptor { is_active, switched_at, provider: None, - model: Some(profile.model.clone()), + model: profile.model.clone(), } } } diff --git a/src-tauri/src/services/session/db.rs b/src-tauri/src/services/session/db.rs deleted file mode 100644 index ae8f65e..0000000 --- a/src-tauri/src/services/session/db.rs +++ /dev/null @@ -1,366 +0,0 @@ -// SQLite 数据库管理 - -use crate::services::session::models::{ProxySession, SessionListResponse}; -use anyhow::Result; -use rusqlite::{params, Connection, OptionalExtension}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -/// 数据库连接管理器 -pub struct SessionDatabase { - conn: Arc>, -} - -impl SessionDatabase { - /// 创建或打开数据库 - pub fn new(db_path: PathBuf) -> Result { - // 确保父目录存在 - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent)?; - } - - let conn = Connection::open(&db_path)?; - let db = Self { - conn: Arc::new(Mutex::new(conn)), - }; - - // 初始化表结构 - db.init_schema()?; - - Ok(db) - } - - /// 初始化数据库表结构 - fn init_schema(&self) -> Result<()> { - let conn = self.conn.lock().unwrap(); - - conn.execute( - "CREATE TABLE IF NOT EXISTS claude_proxy_sessions ( - session_id TEXT PRIMARY KEY, - display_id TEXT NOT NULL, - tool_id TEXT NOT NULL, - config_name TEXT NOT NULL DEFAULT 'global', - custom_profile_name TEXT, - url TEXT NOT NULL, - api_key TEXT NOT NULL, - note TEXT, - first_seen_at INTEGER NOT NULL, - last_seen_at INTEGER NOT NULL, - request_count INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL - )", - [], - )?; - - // 为已存在的表添加新字段(兼容旧数据库) - let _ = conn.execute( - "ALTER TABLE claude_proxy_sessions ADD COLUMN custom_profile_name TEXT", - [], - ); - let _ = conn.execute("ALTER TABLE claude_proxy_sessions ADD COLUMN note TEXT", []); - - // 创建索引 - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_tool_id ON claude_proxy_sessions(tool_id)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_display_id ON claude_proxy_sessions(display_id)", - [], - )?; - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_last_seen_at ON claude_proxy_sessions(last_seen_at)", - [], - )?; - - Ok(()) - } - - /// 插入或更新会话(Upsert) - pub fn upsert_session( - &self, - session_id: &str, - display_id: &str, - tool_id: &str, - timestamp: i64, - ) -> Result<()> { - let conn = self.conn.lock().unwrap(); - - conn.execute( - "INSERT INTO claude_proxy_sessions ( - session_id, display_id, tool_id, config_name, url, api_key, - first_seen_at, last_seen_at, request_count, - created_at, updated_at - ) VALUES (?1, ?2, ?3, 'global', '', '', ?4, ?4, 1, ?4, ?4) - ON CONFLICT(session_id) DO UPDATE SET - last_seen_at = ?4, - request_count = request_count + 1, - updated_at = ?4", - params![session_id, display_id, tool_id, timestamp], - )?; - - Ok(()) - } - - /// 查询会话列表(分页) - pub fn get_sessions( - &self, - tool_id: &str, - page: usize, - page_size: usize, - ) -> Result { - let conn = self.conn.lock().unwrap(); - - // 查询总数 - let total: usize = conn.query_row( - "SELECT COUNT(*) FROM claude_proxy_sessions WHERE tool_id = ?1", - params![tool_id], - |row| row.get(0), - )?; - - // 查询分页数据(按最后活跃时间降序) - let offset = (page.saturating_sub(1)) * page_size; - let mut stmt = conn.prepare( - "SELECT session_id, display_id, tool_id, config_name, custom_profile_name, - url, api_key, note, - first_seen_at, last_seen_at, request_count, - created_at, updated_at - FROM claude_proxy_sessions - WHERE tool_id = ?1 - ORDER BY last_seen_at DESC - LIMIT ?2 OFFSET ?3", - )?; - - let sessions = stmt - .query_map(params![tool_id, page_size, offset], |row| { - Ok(ProxySession { - session_id: row.get(0)?, - display_id: row.get(1)?, - tool_id: row.get(2)?, - config_name: row.get(3)?, - custom_profile_name: row.get(4)?, - url: row.get(5)?, - api_key: row.get(6)?, - note: row.get(7)?, - first_seen_at: row.get(8)?, - last_seen_at: row.get(9)?, - request_count: row.get(10)?, - created_at: row.get(11)?, - updated_at: row.get(12)?, - }) - })? - .collect::, _>>()?; - - Ok(SessionListResponse { - sessions, - total, - page, - page_size, - }) - } - - /// 删除单个会话 - pub fn delete_session(&self, session_id: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "DELETE FROM claude_proxy_sessions WHERE session_id = ?1", - params![session_id], - )?; - Ok(()) - } - - /// 清空指定工具的所有会话 - pub fn clear_sessions(&self, tool_id: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - conn.execute( - "DELETE FROM claude_proxy_sessions WHERE tool_id = ?1", - params![tool_id], - )?; - Ok(()) - } - - /// 清理过期会话(30 天未活跃 + 超过 1000 条限制) - pub fn cleanup_old_sessions( - &self, - tool_id: &str, - max_count: usize, - max_age_days: i64, - ) -> Result { - let conn = self.conn.lock().unwrap(); - let now = chrono::Utc::now().timestamp(); - let cutoff_time = now - (max_age_days * 24 * 3600); - - // 1. 删除超过 30 天的会话 - let deleted_by_age = conn.execute( - "DELETE FROM claude_proxy_sessions WHERE tool_id = ?1 AND last_seen_at < ?2", - params![tool_id, cutoff_time], - )?; - - // 2. 如果超过 1000 条,删除最旧的会话 - let current_count: usize = conn.query_row( - "SELECT COUNT(*) FROM claude_proxy_sessions WHERE tool_id = ?1", - params![tool_id], - |row| row.get(0), - )?; - - let deleted_by_count = if current_count > max_count { - let to_delete = current_count - max_count; - conn.execute( - "DELETE FROM claude_proxy_sessions WHERE session_id IN ( - SELECT session_id FROM claude_proxy_sessions - WHERE tool_id = ?1 - ORDER BY last_seen_at ASC - LIMIT ?2 - )", - params![tool_id, to_delete], - )? - } else { - 0 - }; - - Ok(deleted_by_age + deleted_by_count) - } - - /// 获取会话详情 - pub fn get_session(&self, session_id: &str) -> Result> { - let conn = self.conn.lock().unwrap(); - let session = conn - .query_row( - "SELECT session_id, display_id, tool_id, config_name, custom_profile_name, - url, api_key, note, - first_seen_at, last_seen_at, request_count, - created_at, updated_at - FROM claude_proxy_sessions - WHERE session_id = ?1", - params![session_id], - |row| { - Ok(ProxySession { - session_id: row.get(0)?, - display_id: row.get(1)?, - tool_id: row.get(2)?, - config_name: row.get(3)?, - custom_profile_name: row.get(4)?, - url: row.get(5)?, - api_key: row.get(6)?, - note: row.get(7)?, - first_seen_at: row.get(8)?, - last_seen_at: row.get(9)?, - request_count: row.get(10)?, - created_at: row.get(11)?, - updated_at: row.get(12)?, - }) - }, - ) - .optional()?; - - Ok(session) - } - - /// 获取会话配置信息(用于请求处理) - pub fn get_session_config(&self, session_id: &str) -> Result> { - let conn = self.conn.lock().unwrap(); - let config = conn - .query_row( - "SELECT config_name, url, api_key - FROM claude_proxy_sessions - WHERE session_id = ?1", - params![session_id], - |row| { - let config_name: String = row.get(0)?; - let url: String = row.get(1)?; - let api_key: String = row.get(2)?; - Ok((config_name, url, api_key)) - }, - ) - .optional()?; - - Ok(config) - } - - /// 更新会话配置 - pub fn update_session_config( - &self, - session_id: &str, - config_name: &str, - custom_profile_name: Option<&str>, - url: &str, - api_key: &str, - ) -> Result<()> { - let conn = self.conn.lock().unwrap(); - let now = chrono::Utc::now().timestamp(); - - conn.execute( - "UPDATE claude_proxy_sessions - SET config_name = ?1, custom_profile_name = ?2, url = ?3, api_key = ?4, updated_at = ?5 - WHERE session_id = ?6", - params![ - config_name, - custom_profile_name, - url, - api_key, - now, - session_id - ], - )?; - - Ok(()) - } - - /// 更新会话备注 - pub fn update_session_note(&self, session_id: &str, note: Option<&str>) -> Result<()> { - let conn = self.conn.lock().unwrap(); - let now = chrono::Utc::now().timestamp(); - - conn.execute( - "UPDATE claude_proxy_sessions SET note = ?1, updated_at = ?2 WHERE session_id = ?3", - params![note, now, session_id], - )?; - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - fn test_database_creation() { - let dir = tempdir().unwrap(); - let db_path = dir.path().join("test.db"); - let db = SessionDatabase::new(db_path).unwrap(); - - // 测试插入 - let timestamp = chrono::Utc::now().timestamp(); - db.upsert_session("test_session_1", "uuid-1", "claude-code", timestamp) - .unwrap(); - - // 测试查询 - let result = db.get_sessions("claude-code", 1, 10).unwrap(); - assert_eq!(result.sessions.len(), 1); - assert_eq!(result.total, 1); - } - - #[test] - fn test_upsert_increments_count() { - let dir = tempdir().unwrap(); - let db_path = dir.path().join("test.db"); - let db = SessionDatabase::new(db_path).unwrap(); - - let timestamp = chrono::Utc::now().timestamp(); - - // 第一次插入 - db.upsert_session("test_session_1", "uuid-1", "claude-code", timestamp) - .unwrap(); - - // 第二次插入(应该更新) - db.upsert_session("test_session_1", "uuid-1", "claude-code", timestamp + 10) - .unwrap(); - - // 验证请求次数 - let session = db.get_session("test_session_1").unwrap().unwrap(); - assert_eq!(session.request_count, 2); - } -} diff --git a/src-tauri/src/services/session/db_utils.rs b/src-tauri/src/services/session/db_utils.rs new file mode 100644 index 0000000..91d8102 --- /dev/null +++ b/src-tauri/src/services/session/db_utils.rs @@ -0,0 +1,324 @@ +//! 数据库查询工具模块 +//! +//! 提供 QueryRow ↔ ProxySession 转换逻辑,用于 SessionManager 与 DataManager 的适配层。 + +use crate::data::managers::sqlite::QueryRow; +use crate::services::session::models::ProxySession; +use anyhow::{anyhow, Context, Result}; + +/// 标准会话查询的 SQL 语句 +/// +/// **字段顺序(共 13 个):** +/// 1. session_id +/// 2. display_id +/// 3. tool_id +/// 4. config_name +/// 5. custom_profile_name +/// 6. url +/// 7. api_key +/// 8. note +/// 9. first_seen_at +/// 10. last_seen_at +/// 11. request_count +/// 12. created_at +/// 13. updated_at +pub const SELECT_SESSION_FIELDS: &str = "session_id, display_id, tool_id, config_name, \ + custom_profile_name, url, api_key, note, \ + first_seen_at, last_seen_at, request_count, \ + created_at, updated_at"; + +/// 创建表的 SQL 语句 +pub const CREATE_TABLE_SQL: &str = " +CREATE TABLE IF NOT EXISTS claude_proxy_sessions ( + session_id TEXT PRIMARY KEY, + display_id TEXT NOT NULL, + tool_id TEXT NOT NULL, + config_name TEXT NOT NULL DEFAULT 'global', + custom_profile_name TEXT, + url TEXT NOT NULL, + api_key TEXT NOT NULL, + note TEXT, + first_seen_at INTEGER NOT NULL, + last_seen_at INTEGER NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tool_id ON claude_proxy_sessions(tool_id); +CREATE INDEX IF NOT EXISTS idx_display_id ON claude_proxy_sessions(display_id); +CREATE INDEX IF NOT EXISTS idx_last_seen_at ON claude_proxy_sessions(last_seen_at); +"; + +/// 兼容旧数据库的字段添加语句 +pub const ALTER_TABLE_SQL: &str = " +ALTER TABLE claude_proxy_sessions ADD COLUMN custom_profile_name TEXT; +ALTER TABLE claude_proxy_sessions ADD COLUMN note TEXT; +"; + +/// 从 QueryRow 解析为 ProxySession +/// +/// # 参数 +/// +/// - `row`: SqliteManager 返回的查询行 +/// +/// # 返回 +/// +/// 成功返回 ProxySession 实例,失败返回错误信息 +/// +/// # 字段映射 +/// +/// 依赖 `SELECT_SESSION_FIELDS` 定义的顺序: +/// - values[0..7]: 字符串字段 +/// - values[7]: note (可为 NULL) +/// - values[8..12]: 整数字段 +pub fn parse_proxy_session(row: &QueryRow) -> Result { + if row.values.len() != 13 { + return Err(anyhow!( + "Invalid row: expected 13 columns, got {}", + row.values.len() + )); + } + + // 字段提取辅助函数 + let get_string = |idx: usize| -> Result { + row.values[idx] + .as_str() + .ok_or_else(|| anyhow!("Column {} is not a string", idx)) + .map(|s| s.to_string()) + }; + + let get_optional_string = + |idx: usize| -> Option { row.values[idx].as_str().map(|s| s.to_string()) }; + + let get_i64 = |idx: usize| -> Result { + row.values[idx] + .as_i64() + .ok_or_else(|| anyhow!("Column {} is not an integer", idx)) + }; + + let get_i32 = |idx: usize| -> Result { + row.values[idx] + .as_i64() + .ok_or_else(|| anyhow!("Column {} is not an integer", idx)) + .map(|v| v as i32) + }; + + Ok(ProxySession { + session_id: get_string(0).context("session_id")?, + display_id: get_string(1).context("display_id")?, + tool_id: get_string(2).context("tool_id")?, + config_name: get_string(3).context("config_name")?, + custom_profile_name: get_optional_string(4), + url: get_string(5).context("url")?, + api_key: get_string(6).context("api_key")?, + note: get_optional_string(7), + first_seen_at: get_i64(8).context("first_seen_at")?, + last_seen_at: get_i64(9).context("last_seen_at")?, + request_count: get_i32(10).context("request_count")?, + created_at: get_i64(11).context("created_at")?, + updated_at: get_i64(12).context("updated_at")?, + }) +} + +/// 从 QueryRow 提取计数值 +/// +/// # 参数 +/// +/// - `row`: 包含单个整数列的查询结果 +/// +/// # 示例 +/// +/// ```rust +/// let rows = db.query("SELECT COUNT(*) FROM sessions WHERE tool_id = ?", &["claude-code"])?; +/// let count = parse_count(&rows[0])?; +/// ``` +pub fn parse_count(row: &QueryRow) -> Result { + if row.values.is_empty() { + return Err(anyhow!("Count query returned empty row")); + } + + row.values[0] + .as_i64() + .ok_or_else(|| anyhow!("Count value is not an integer")) + .map(|v| v as usize) +} + +/// 从 QueryRow 提取三元组配置 (config_name, url, api_key) +/// +/// 用于 `get_session_config()` 方法的结果解析 +pub fn parse_session_config(row: &QueryRow) -> Result<(String, String, String)> { + if row.values.len() != 3 { + return Err(anyhow!( + "Invalid config row: expected 3 columns, got {}", + row.values.len() + )); + } + + let config_name = row.values[0] + .as_str() + .ok_or_else(|| anyhow!("config_name is not a string"))? + .to_string(); + + let url = row.values[1] + .as_str() + .ok_or_else(|| anyhow!("url is not a string"))? + .to_string(); + + let api_key = row.values[2] + .as_str() + .ok_or_else(|| anyhow!("api_key is not a string"))? + .to_string(); + + Ok((config_name, url, api_key)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_parse_proxy_session_full() { + let row = QueryRow { + columns: vec![ + "session_id".to_string(), + "display_id".to_string(), + "tool_id".to_string(), + "config_name".to_string(), + "custom_profile_name".to_string(), + "url".to_string(), + "api_key".to_string(), + "note".to_string(), + "first_seen_at".to_string(), + "last_seen_at".to_string(), + "request_count".to_string(), + "created_at".to_string(), + "updated_at".to_string(), + ], + values: vec![ + json!("test_session_1"), + json!("uuid-1"), + json!("claude-code"), + json!("custom"), + json!("my-profile"), + json!("https://api.example.com"), + json!("sk-test"), + json!("测试备注"), + json!(1000), + json!(2000), + json!(5), + json!(1000), + json!(2000), + ], + }; + + let session = parse_proxy_session(&row).unwrap(); + + assert_eq!(session.session_id, "test_session_1"); + assert_eq!(session.display_id, "uuid-1"); + assert_eq!(session.tool_id, "claude-code"); + assert_eq!(session.config_name, "custom"); + assert_eq!(session.custom_profile_name, Some("my-profile".to_string())); + assert_eq!(session.url, "https://api.example.com"); + assert_eq!(session.api_key, "sk-test"); + assert_eq!(session.note, Some("测试备注".to_string())); + assert_eq!(session.first_seen_at, 1000); + assert_eq!(session.last_seen_at, 2000); + assert_eq!(session.request_count, 5); + assert_eq!(session.created_at, 1000); + assert_eq!(session.updated_at, 2000); + } + + #[test] + fn test_parse_proxy_session_with_nulls() { + let row = QueryRow { + columns: vec![ + "session_id".to_string(), + "display_id".to_string(), + "tool_id".to_string(), + "config_name".to_string(), + "custom_profile_name".to_string(), + "url".to_string(), + "api_key".to_string(), + "note".to_string(), + "first_seen_at".to_string(), + "last_seen_at".to_string(), + "request_count".to_string(), + "created_at".to_string(), + "updated_at".to_string(), + ], + values: vec![ + json!("test_session_2"), + json!("uuid-2"), + json!("codex"), + json!("global"), + json!(null), // custom_profile_name + json!(""), + json!(""), + json!(null), // note + json!(3000), + json!(4000), + json!(10), + json!(3000), + json!(4000), + ], + }; + + let session = parse_proxy_session(&row).unwrap(); + + assert_eq!(session.session_id, "test_session_2"); + assert_eq!(session.config_name, "global"); + assert_eq!(session.custom_profile_name, None); + assert_eq!(session.note, None); + assert_eq!(session.request_count, 10); + } + + #[test] + fn test_parse_count() { + let row = QueryRow { + columns: vec!["COUNT(*)".to_string()], + values: vec![json!(42)], + }; + + let count = parse_count(&row).unwrap(); + assert_eq!(count, 42); + } + + #[test] + fn test_parse_session_config() { + let row = QueryRow { + columns: vec![ + "config_name".to_string(), + "url".to_string(), + "api_key".to_string(), + ], + values: vec![ + json!("custom"), + json!("https://api.test.com"), + json!("sk-xxx"), + ], + }; + + let (config_name, url, api_key) = parse_session_config(&row).unwrap(); + + assert_eq!(config_name, "custom"); + assert_eq!(url, "https://api.test.com"); + assert_eq!(api_key, "sk-xxx"); + } + + #[test] + fn test_parse_proxy_session_invalid_column_count() { + let row = QueryRow { + columns: vec!["session_id".to_string()], + values: vec![json!("test")], + }; + + let result = parse_proxy_session(&row); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("expected 13 columns")); + } +} diff --git a/src-tauri/src/services/session/manager.rs b/src-tauri/src/services/session/manager.rs index d4ed837..74186fa 100644 --- a/src-tauri/src/services/session/manager.rs +++ b/src-tauri/src/services/session/manager.rs @@ -1,17 +1,22 @@ // SessionManager 单例 - 会话管理核心模块 -use crate::services::session::db::SessionDatabase; +use crate::data::DataManager; +use crate::services::session::db_utils::{ + parse_count, parse_proxy_session, parse_session_config, ALTER_TABLE_SQL, CREATE_TABLE_SQL, + SELECT_SESSION_FIELDS, +}; use crate::services::session::models::{ProxySession, SessionEvent, SessionListResponse}; use anyhow::Result; use lazy_static::lazy_static; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::mpsc; use tokio::time::{interval, Duration}; /// 会话管理器单例 pub struct SessionManager { - db: Arc, + manager: Arc, + db_path: PathBuf, event_sender: mpsc::UnboundedSender, } @@ -27,12 +32,23 @@ impl SessionManager { fn new() -> Result { // 数据库路径:~/.duckcoding/sessions.db let db_path = Self::get_db_path()?; - let db = Arc::new(SessionDatabase::new(db_path)?); + let manager_instance = Arc::new(DataManager::new()); + + // 初始化数据库表结构 + let db = manager_instance.sqlite(&db_path)?; + db.execute_raw(CREATE_TABLE_SQL)?; + + // 兼容旧数据库(忽略错误) + let _ = db.execute_raw(ALTER_TABLE_SQL); // 创建事件队列 let (event_sender, event_receiver) = mpsc::unbounded_channel(); - let manager = Self { db, event_sender }; + let manager = Self { + manager: manager_instance, + db_path, + event_sender, + }; // 启动后台任务 manager.start_background_tasks(event_receiver); @@ -49,7 +65,8 @@ impl SessionManager { /// 启动后台任务 fn start_background_tasks(&self, mut event_receiver: mpsc::UnboundedReceiver) { - let db = Arc::clone(&self.db); + let manager = self.manager.clone(); + let db_path = self.db_path.clone(); // 批量写入任务 tokio::spawn(async move { @@ -64,13 +81,13 @@ impl SessionManager { // 如果缓冲区达到 10 条,立即写入 if buffer.len() >= 10 { - Self::flush_events(&db, &mut buffer); + Self::flush_events(&manager, &db_path, &mut buffer); } } // 每 100ms 刷新一次 _ = tick_interval.tick() => { if !buffer.is_empty() { - Self::flush_events(&db, &mut buffer); + Self::flush_events(&manager, &db_path, &mut buffer); } } } @@ -78,7 +95,8 @@ impl SessionManager { }); // 定期清理任务(每 1 小时) - let db_clone = Arc::clone(&self.db); + let manager_clone = self.manager.clone(); + let db_path_clone = self.db_path.clone(); tokio::spawn(async move { let mut cleanup_interval = interval(Duration::from_secs(3600)); @@ -87,14 +105,20 @@ impl SessionManager { // 清理三个工具的过期会话 for tool_id in &["claude-code", "codex", "gemini-cli"] { - let _ = db_clone.cleanup_old_sessions(tool_id, 1000, 30); + let _ = Self::cleanup_old_sessions_internal( + &manager_clone, + &db_path_clone, + tool_id, + 1000, + 30, + ); } } }); } /// 批量写入事件到数据库 - fn flush_events(db: &SessionDatabase, buffer: &mut Vec) { + fn flush_events(manager: &Arc, db_path: &Path, buffer: &mut Vec) { for event in buffer.drain(..) { match event { SessionEvent::NewRequest { @@ -104,13 +128,70 @@ impl SessionManager { } => { // 提取 display_id if let Some(display_id) = ProxySession::extract_display_id(&session_id) { - let _ = db.upsert_session(&session_id, &display_id, &tool_id, timestamp); + // Upsert 会话 + if let Ok(db) = manager.sqlite(db_path) { + let _ = db.execute( + "INSERT INTO claude_proxy_sessions ( + session_id, display_id, tool_id, config_name, url, api_key, + first_seen_at, last_seen_at, request_count, + created_at, updated_at + ) VALUES (?1, ?2, ?3, 'global', '', '', ?4, ?4, 1, ?4, ?4) + ON CONFLICT(session_id) DO UPDATE SET + last_seen_at = ?4, + request_count = request_count + 1, + updated_at = ?4", + &[&session_id, &display_id, &tool_id, ×tamp.to_string()], + ); + } } } } } } + /// 内部清理方法(用于后台任务) + fn cleanup_old_sessions_internal( + manager: &Arc, + db_path: &Path, + tool_id: &str, + max_count: usize, + max_age_days: i64, + ) -> Result { + let db = manager.sqlite(db_path)?; + let now = chrono::Utc::now().timestamp(); + let cutoff_time = now - (max_age_days * 24 * 3600); + + // 1. 删除超过 30 天的会话 + let deleted_by_age = db.execute( + "DELETE FROM claude_proxy_sessions WHERE tool_id = ? AND last_seen_at < ?", + &[tool_id, &cutoff_time.to_string()], + )?; + + // 2. 如果超过 1000 条,删除最旧的会话 + let count_rows = db.query( + "SELECT COUNT(*) FROM claude_proxy_sessions WHERE tool_id = ?", + &[tool_id], + )?; + let current_count = parse_count(&count_rows[0])?; + + let deleted_by_count = if current_count > max_count { + let to_delete = current_count - max_count; + db.execute( + "DELETE FROM claude_proxy_sessions WHERE session_id IN ( + SELECT session_id FROM claude_proxy_sessions + WHERE tool_id = ? + ORDER BY last_seen_at ASC + LIMIT ? + )", + &[tool_id, &to_delete.to_string()], + )? + } else { + 0 + }; + + Ok(deleted_by_age + deleted_by_count) + } + /// 发送会话事件(公共 API) pub fn send_event(&self, event: SessionEvent) -> Result<()> { self.event_sender @@ -126,28 +207,90 @@ impl SessionManager { page: usize, page_size: usize, ) -> Result { - self.db.get_sessions(tool_id, page, page_size) + let db = self.manager.sqlite(&self.db_path)?; + + // 查询总数 + let total_rows = db.query( + "SELECT COUNT(*) FROM claude_proxy_sessions WHERE tool_id = ?", + &[tool_id], + )?; + let total = parse_count(&total_rows[0])?; + + // 查询分页数据(按最后活跃时间降序) + let offset = (page.saturating_sub(1)) * page_size; + let sql = format!( + "SELECT {} FROM claude_proxy_sessions WHERE tool_id = ? ORDER BY last_seen_at DESC LIMIT ? OFFSET ?", + SELECT_SESSION_FIELDS + ); + let rows = db.query( + &sql, + &[tool_id, &page_size.to_string(), &offset.to_string()], + )?; + + // 转换为 ProxySession + let sessions = rows + .iter() + .map(parse_proxy_session) + .collect::>>()?; + + Ok(SessionListResponse { + sessions, + total, + page, + page_size, + }) } /// 删除单个会话(公共 API) pub fn delete_session(&self, session_id: &str) -> Result<()> { - self.db.delete_session(session_id) + let db = self.manager.sqlite(&self.db_path)?; + db.execute( + "DELETE FROM claude_proxy_sessions WHERE session_id = ?", + &[session_id], + )?; + Ok(()) } /// 清空工具所有会话(公共 API) pub fn clear_sessions(&self, tool_id: &str) -> Result<()> { - self.db.clear_sessions(tool_id) + let db = self.manager.sqlite(&self.db_path)?; + db.execute( + "DELETE FROM claude_proxy_sessions WHERE tool_id = ?", + &[tool_id], + )?; + Ok(()) } /// 获取会话详情(公共 API) pub fn get_session(&self, session_id: &str) -> Result> { - self.db.get_session(session_id) + let db = self.manager.sqlite(&self.db_path)?; + let sql = format!( + "SELECT {} FROM claude_proxy_sessions WHERE session_id = ?", + SELECT_SESSION_FIELDS + ); + let rows = db.query(&sql, &[session_id])?; + + if rows.is_empty() { + Ok(None) + } else { + Ok(Some(parse_proxy_session(&rows[0])?)) + } } /// 获取会话配置(公共 API,用于请求处理) /// 返回 (config_name, url, api_key) pub fn get_session_config(&self, session_id: &str) -> Result> { - self.db.get_session_config(session_id) + let db = self.manager.sqlite(&self.db_path)?; + let rows = db.query( + "SELECT config_name, url, api_key FROM claude_proxy_sessions WHERE session_id = ?", + &[session_id], + )?; + + if rows.is_empty() { + Ok(None) + } else { + Ok(Some(parse_session_config(&rows[0])?)) + } } /// 更新会话配置(公共 API) @@ -159,13 +302,37 @@ impl SessionManager { url: &str, api_key: &str, ) -> Result<()> { - self.db - .update_session_config(session_id, config_name, custom_profile_name, url, api_key) + let db = self.manager.sqlite(&self.db_path)?; + let now = chrono::Utc::now().timestamp(); + + db.execute( + "UPDATE claude_proxy_sessions + SET config_name = ?, custom_profile_name = ?, url = ?, api_key = ?, updated_at = ? + WHERE session_id = ?", + &[ + config_name, + custom_profile_name.unwrap_or(""), + url, + api_key, + &now.to_string(), + session_id, + ], + )?; + + Ok(()) } /// 更新会话备注(公共 API) pub fn update_session_note(&self, session_id: &str, note: Option<&str>) -> Result<()> { - self.db.update_session_note(session_id, note) + let db = self.manager.sqlite(&self.db_path)?; + let now = chrono::Utc::now().timestamp(); + + db.execute( + "UPDATE claude_proxy_sessions SET note = ?, updated_at = ? WHERE session_id = ?", + &[note.unwrap_or(""), &now.to_string(), session_id], + )?; + + Ok(()) } } @@ -173,14 +340,35 @@ impl SessionManager { mod tests { use super::*; use serial_test::serial; - use std::env; use tempfile::TempDir; + /// 创建测试用的 SessionManager 实例 + fn create_test_manager(temp_dir: &TempDir) -> SessionManager { + let db_path = temp_dir.path().join("sessions.db"); + let manager_instance = Arc::new(DataManager::new()); + + // 初始化数据库 + let db = manager_instance.sqlite(&db_path).unwrap(); + db.execute_raw(CREATE_TABLE_SQL).unwrap(); + let _ = db.execute_raw(ALTER_TABLE_SQL); + + let (event_sender, event_receiver) = mpsc::unbounded_channel(); + + let manager = SessionManager { + manager: manager_instance, + db_path, + event_sender, + }; + + manager.start_background_tasks(event_receiver); + manager + } + #[tokio::test] #[serial] async fn test_session_manager_send_event() { let temp = TempDir::new().expect("create temp dir"); - env::set_var("DUCKCODING_CONFIG_DIR", temp.path()); + let manager = create_test_manager(&temp); let timestamp = chrono::Utc::now().timestamp(); let event = SessionEvent::NewRequest { @@ -190,16 +378,93 @@ mod tests { }; // 发送事件 - SESSION_MANAGER.send_event(event).unwrap(); + manager.send_event(event).unwrap(); // 等待批量写入 tokio::time::sleep(Duration::from_millis(200)).await; // 查询验证 - let result = SESSION_MANAGER - .get_session_list("claude-code", 1, 10) - .unwrap(); + let result = manager.get_session_list("claude-code", 1, 10).unwrap(); assert!(result.sessions.iter().any(|s| s.display_id == "abc-123")); } + + #[tokio::test] + #[serial] + async fn test_datamanager_query_caching() { + let temp = TempDir::new().expect("create temp dir"); + let manager = create_test_manager(&temp); + + // 插入测试数据 + let timestamp = chrono::Utc::now().timestamp(); + manager + .send_event(SessionEvent::NewRequest { + session_id: "test_session_cache_xyz".to_string(), + tool_id: "claude-code".to_string(), + timestamp, + }) + .unwrap(); + + tokio::time::sleep(Duration::from_millis(150)).await; + + // 第一次查询(缓存未命中) + let start1 = std::time::Instant::now(); + let result1 = manager.get_session_list("claude-code", 1, 10).unwrap(); + let duration1 = start1.elapsed(); + + // 第二次查询(缓存命中,应该更快) + let start2 = std::time::Instant::now(); + let result2 = manager.get_session_list("claude-code", 1, 10).unwrap(); + let duration2 = start2.elapsed(); + + assert_eq!(result1.total, result2.total); + assert!(result1.total >= 1, "Should have at least one session"); + // 缓存命中的查询应该更快(允许一定误差) + println!("Query 1: {:?}, Query 2: {:?}", duration1, duration2); + } + + #[tokio::test] + async fn test_update_session_config() { + let temp = TempDir::new().expect("create temp dir"); + let manager = create_test_manager(&temp); + + // 手动插入测试会话 + let db = manager.manager.sqlite(&manager.db_path).unwrap(); + let now = chrono::Utc::now().timestamp(); + db.execute( + "INSERT INTO claude_proxy_sessions ( + session_id, display_id, tool_id, config_name, url, api_key, + first_seen_at, last_seen_at, request_count, + created_at, updated_at + ) VALUES (?, ?, ?, 'global', '', '', ?, ?, 1, ?, ?)", + &[ + "test_session_update", + "uuid-update", + "claude-code", + &now.to_string(), + &now.to_string(), + &now.to_string(), + &now.to_string(), + ], + ) + .unwrap(); + + // 更新配置 + manager + .update_session_config( + "test_session_update", + "custom", + Some("my-profile"), + "https://api.test.com", + "sk-test", + ) + .unwrap(); + + // 验证更新 + let session = manager.get_session("test_session_update").unwrap().unwrap(); + assert_eq!(session.config_name, "custom"); + assert_eq!(session.custom_profile_name, Some("my-profile".to_string())); + assert_eq!(session.url, "https://api.test.com"); + assert_eq!(session.api_key, "sk-test"); + } } diff --git a/src-tauri/src/services/session/mod.rs b/src-tauri/src/services/session/mod.rs index ca0bbde..2dfdee8 100644 --- a/src-tauri/src/services/session/mod.rs +++ b/src-tauri/src/services/session/mod.rs @@ -1,6 +1,6 @@ // 会话管理服务模块 -pub mod db; +mod db_utils; pub mod manager; pub mod models; diff --git a/src/pages/ProfileManagementPage/components/ProfileEditor.tsx b/src/pages/ProfileManagementPage/components/ProfileEditor.tsx index 41e03ee..e387756 100644 --- a/src/pages/ProfileManagementPage/components/ProfileEditor.tsx +++ b/src/pages/ProfileManagementPage/components/ProfileEditor.tsx @@ -53,7 +53,7 @@ export function ProfileEditor({ api_key: '', base_url: getDefaultBaseUrl(toolId), wire_api: toolId === 'codex' ? 'responses' : undefined, - model: toolId === 'gemini-cli' ? 'gemini-2.0-flash-exp' : undefined, + model: undefined, // Gemini 默认不设置 model }); const [loading, setLoading] = useState(false); const [generatingKey, setGeneratingKey] = useState(false); @@ -73,7 +73,7 @@ export function ProfileEditor({ api_key: '', base_url: getDefaultBaseUrl(toolId, apiProvider), wire_api: defaultWireApi, - model: toolId === 'gemini-cli' ? 'gemini-2.0-flash-exp' : undefined, + model: undefined, // Gemini 默认不设置 model }); } }, [initialData, toolId, open, apiProvider]); @@ -310,20 +310,24 @@ export function ProfileEditor({ {/* Gemini 特定:Model */} {toolId === 'gemini-cli' && (
- + +

+ 不设置时,将保留工具原生配置文件中的模型设置 +

)} diff --git a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts index 9a991aa..14519b6 100644 --- a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts +++ b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts @@ -253,7 +253,7 @@ function buildProfilePayload(toolId: ToolId, data: ProfileFormData): ProfilePayl return { api_key: data.api_key, base_url: data.base_url, - model: data.model || 'gemini-2.0-flash-exp', // 必须有 model + model: data.model && data.model !== '' ? data.model : undefined, // 空值不设置 model }; default: diff --git a/src/types/profile.ts b/src/types/profile.ts index ac9a867..8659f78 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -27,7 +27,7 @@ export interface CodexProfilePayload { export interface GeminiProfilePayload { api_key: string; base_url: string; - model: string; // 必须 + model?: string; // 可选,不填则不修改原生配置 } /**