diff --git a/CHANGELOG.md b/CHANGELOG.md index 843f64e67..00d7b21f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,64 @@ All notable changes to CC Switch will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.9.0-3] - 2025-12-29 + +### Beta Release + +Third beta release with important bug fixes for Windows compatibility, UI improvements, and new features. + +### Added + +- **Universal Provider** - Support for universal provider configurations (#348) +- **Provider Search Filter** - Quick filter to find providers by name (#435) +- **Keyboard Shortcut** - Open settings with Command+comma / Ctrl+comma (#436) +- **Xiaomi MiMo Icon** - Added MiMo icon and Claude provider configuration (#470) +- **Usage Model Extraction** - Extract model info from usage statistics (#455) +- **Skip First-Run Confirmation** - Option to skip Claude Code first-run confirmation dialog +- **Exit Animations** - Added exit animation to FullScreenPanel dialogs +- **Fade Transitions** - Smooth fade transitions for app/view/panel switching + +### Fixed + +#### Windows +- Wrap npx/npm commands with `cmd /c` for MCP export +- Prevent terminal windows from appearing during version check + +#### macOS +- Use .app bundle path for autostart to prevent terminal window popup + +#### UI +- Resolve Dialog/Modal not opening on first click (#492) +- Improve dark mode text contrast for form labels +- Reduce header spacing and fix layout shift on view switch +- Prevent header layout shift when switching views + +#### Database & Schema +- Add missing base columns migration for proxy_config +- Add backward compatibility check for proxy_config seed insert + +#### Other +- Use local timezone and robust DST handling in usage stats (#500) +- Remove deprecated `sync_enabled_to_codex` call +- Gracefully handle invalid Codex config.toml during MCP sync +- Add missing translations for reasoning model and OpenRouter compat mode + +### Improved + +- **macOS Tray** - Use macOS tray template icon +- **Header Alignment** - Remove macOS titlebar tint, align custom header +- **Shadow Removal** - Cleaner UI by removing shadow styles +- **Code Inspector** - Added code-inspector-plugin for development +- **i18n** - Complete internationalization for usage panel and settings +- **Sponsor Logos** - Made sponsor logos clickable + +### Stats + +- 35 commits since v3.9.0-2 +- 5 files changed in test/lint fixes + +--- + ## [3.9.0-1] - 2025-12-18 ### Beta Release diff --git a/package.json b/package.json index a8777b894..1c7f3ed26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-switch", - "version": "3.9.0-2", + "version": "3.9.0-3", "description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI", "type": "module", "scripts": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index afa032432..a363fde87 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -701,7 +701,7 @@ dependencies = [ [[package]] name = "cc-switch" -version = "3.9.0-2" +version = "3.9.0-3" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 471996b19..81d1400a1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cc-switch" -version = "3.9.0-2" +version = "3.9.0-3" description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI" authors = ["Jason Young"] license = "MIT" diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 2c596771a..513ae5606 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -55,6 +55,110 @@ impl McpApps { } } +/// Skill 应用启用状态(标记 Skill 应用到哪些客户端) +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SkillApps { + #[serde(default)] + pub claude: bool, + #[serde(default)] + pub codex: bool, + #[serde(default)] + pub gemini: bool, +} + +impl SkillApps { + /// 检查指定应用是否启用 + pub fn is_enabled_for(&self, app: &AppType) -> bool { + match app { + AppType::Claude => self.claude, + AppType::Codex => self.codex, + AppType::Gemini => self.gemini, + } + } + + /// 设置指定应用的启用状态 + pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) { + match app { + AppType::Claude => self.claude = enabled, + AppType::Codex => self.codex = enabled, + AppType::Gemini => self.gemini = enabled, + } + } + + /// 获取所有启用的应用列表 + pub fn enabled_apps(&self) -> Vec { + let mut apps = Vec::new(); + if self.claude { + apps.push(AppType::Claude); + } + if self.codex { + apps.push(AppType::Codex); + } + if self.gemini { + apps.push(AppType::Gemini); + } + apps + } + + /// 检查是否所有应用都未启用 + pub fn is_empty(&self) -> bool { + !self.claude && !self.codex && !self.gemini + } + + /// 仅启用指定应用(其他应用设为禁用) + pub fn only(app: &AppType) -> Self { + let mut apps = Self::default(); + apps.set_enabled_for(app, true); + apps + } +} + +/// 已安装的 Skill(v3.10.0+ 统一结构) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledSkill { + /// 唯一标识符(格式:"owner/repo:directory" 或 "local:directory") + pub id: String, + /// 显示名称 + pub name: String, + /// 描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 安装目录名(在 SSOT 目录中的子目录名) + pub directory: String, + /// 仓库所有者(GitHub 用户/组织) + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_owner: Option, + /// 仓库名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_name: Option, + /// 仓库分支 + #[serde(skip_serializing_if = "Option::is_none")] + pub repo_branch: Option, + /// README URL + #[serde(skip_serializing_if = "Option::is_none")] + pub readme_url: Option, + /// 应用启用状态 + pub apps: SkillApps, + /// 安装时间(Unix 时间戳) + pub installed_at: i64, +} + +/// 未管理的 Skill(在应用目录中发现但未被 CC Switch 管理) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UnmanagedSkill { + /// 目录名 + pub directory: String, + /// 显示名称(从 SKILL.md 解析) + pub name: String, + /// 描述 + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// 在哪些应用目录中发现(如 ["claude", "codex"]) + pub found_in: Vec, +} + /// MCP 服务器定义(v3.7.0 统一结构) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct McpServer { diff --git a/src-tauri/src/claude_mcp.rs b/src-tauri/src/claude_mcp.rs index d17f75b01..253ba0053 100644 --- a/src-tauri/src/claude_mcp.rs +++ b/src-tauri/src/claude_mcp.rs @@ -7,6 +7,64 @@ use std::path::{Path, PathBuf}; use crate::config::{atomic_write, get_claude_mcp_path, get_default_claude_mcp_path}; use crate::error::AppError; +/// 需要在 Windows 上用 cmd /c 包装的命令 +/// 这些命令在 Windows 上实际是 .cmd 批处理文件,需要通过 cmd /c 来执行 +#[cfg(windows)] +const WINDOWS_WRAP_COMMANDS: &[&str] = &["npx", "npm", "yarn", "pnpm", "node", "bun", "deno"]; + +/// Windows 平台:将 `npx args...` 转换为 `cmd /c npx args...` +/// 解决 Claude Code /doctor 报告的 "Windows requires 'cmd /c' wrapper to execute npx" 警告 +#[cfg(windows)] +fn wrap_command_for_windows(obj: &mut Map) { + // 只处理 stdio 类型(默认或显式) + let server_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); + if server_type != "stdio" { + return; + } + + let Some(cmd) = obj.get("command").and_then(|v| v.as_str()) else { + return; + }; + + // 已经是 cmd 的不重复包装 + if cmd.eq_ignore_ascii_case("cmd") || cmd.eq_ignore_ascii_case("cmd.exe") { + return; + } + + // 提取命令名(去掉 .cmd 后缀和路径) + let cmd_name = Path::new(cmd) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(cmd); + + let needs_wrap = WINDOWS_WRAP_COMMANDS + .iter() + .any(|&c| cmd_name.eq_ignore_ascii_case(c)); + + if !needs_wrap { + return; + } + + // 构建新的 args: ["/c", "原命令", ...原args] + let original_args = obj + .get("args") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let mut new_args = vec![Value::String("/c".into()), Value::String(cmd.into())]; + new_args.extend(original_args); + + obj.insert("command".into(), Value::String("cmd".into())); + obj.insert("args".into(), Value::Array(new_args)); +} + +/// 非 Windows 平台无需处理 +#[cfg(not(windows))] +fn wrap_command_for_windows(_obj: &mut Map) { + // 非 Windows 平台不做任何处理 +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct McpStatus { @@ -339,6 +397,9 @@ pub fn set_mcp_servers_map( obj.remove("homepage"); obj.remove("docs"); + // Windows 平台自动包装 npx/npm 等命令为 cmd /c 格式 + wrap_command_for_windows(&mut obj); + out.insert(id.clone(), Value::Object(obj)); } @@ -352,3 +413,136 @@ pub fn set_mcp_servers_map( write_json_value(&path, &root)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 测试 Windows 命令包装功能 + /// 由于使用条件编译,在非 Windows 平台上测试的是空函数 + #[test] + fn test_wrap_command_for_windows_npx() { + let mut obj = json!({"command": "npx", "args": ["-y", "@upstash/context7-mcp"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!( + obj["args"], + json!(["/c", "npx", "-y", "@upstash/context7-mcp"]) + ); + } + + #[cfg(not(windows))] + { + // 非 Windows 平台不做任何处理 + assert_eq!(obj["command"], "npx"); + } + } + + #[test] + fn test_wrap_command_for_windows_npm() { + let mut obj = json!({"command": "npm", "args": ["run", "start"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "npm", "run", "start"])); + } + } + + #[test] + fn test_wrap_command_for_windows_already_cmd() { + // 已经是 cmd 的不应该重复包装 + let mut obj = json!({"command": "cmd", "args": ["/c", "npx", "-y", "foo"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + assert_eq!(obj["command"], "cmd"); + // args 应该保持不变,不会变成 ["/c", "cmd", "/c", "npx", ...] + assert_eq!(obj["args"], json!(["/c", "npx", "-y", "foo"])); + } + + #[test] + fn test_wrap_command_for_windows_http_type_skipped() { + // http 类型不应该被处理 + let mut obj = json!({"type": "http", "url": "https://example.com/mcp"}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + assert!(!obj.contains_key("command")); + assert_eq!(obj["url"], "https://example.com/mcp"); + } + + #[test] + fn test_wrap_command_for_windows_other_command_skipped() { + // 非目标命令(如 python)不应该被包装 + let mut obj = json!({"command": "python", "args": ["server.py"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + // python 不在 WINDOWS_WRAP_COMMANDS 列表中,不应该被包装 + assert_eq!(obj["command"], "python"); + assert_eq!(obj["args"], json!(["server.py"])); + } + + #[test] + fn test_wrap_command_for_windows_no_args() { + // 没有 args 的情况 + let mut obj = json!({"command": "npx"}).as_object().unwrap().clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "npx"])); + } + } + + #[test] + fn test_wrap_command_for_windows_with_cmd_suffix() { + // 处理 npx.cmd 格式 + let mut obj = json!({"command": "npx.cmd", "args": ["-y", "foo"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "npx.cmd", "-y", "foo"])); + } + } + + #[test] + fn test_wrap_command_for_windows_case_insensitive() { + // 大小写不敏感 + let mut obj = json!({"command": "NPX", "args": ["-y", "foo"]}) + .as_object() + .unwrap() + .clone(); + wrap_command_for_windows(&mut obj); + + #[cfg(windows)] + { + assert_eq!(obj["command"], "cmd"); + assert_eq!(obj["args"], json!(["/c", "NPX", "-y", "foo"])); + } + } +} diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 7abea8e0f..299fed6bf 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -192,3 +192,13 @@ pub async fn toggle_mcp_app( let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?; McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string()) } + +/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑) +#[tauri::command] +pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result { + let mut total = 0; + total += McpService::import_from_claude(&state).unwrap_or(0); + total += McpService::import_from_codex(&state).unwrap_or(0); + total += McpService::import_from_gemini(&state).unwrap_or(0); + Ok(total) +} diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 9be6b7476..5c3dcfdab 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1,7 +1,7 @@ #![allow(non_snake_case)] use crate::app_config::AppType; -use crate::init_status::InitErrorPayload; +use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; use crate::services::ProviderService; use std::str::FromStr; use tauri::AppHandle; @@ -69,6 +69,13 @@ pub async fn get_migration_result() -> Result { Ok(crate::init_status::take_migration_success()) } +/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。 +/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。 +#[tauri::command] +pub async fn get_skills_migration_result() -> Result, String> { + Ok(crate::init_status::take_skills_migration_result()) +} + #[derive(serde::Serialize)] pub struct ToolVersion { name: String, @@ -252,12 +259,18 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { if tool_path.exists() { // 构建 PATH 环境变量,确保 node 可被找到 let current_path = std::env::var("PATH").unwrap_or_default(); + + #[cfg(target_os = "windows")] + let new_path = format!("{};{}", path.display(), current_path); + + #[cfg(not(target_os = "windows"))] let new_path = format!("{}:{}", path.display(), current_path); #[cfg(target_os = "windows")] let output = { - Command::new(&tool_path) - .arg("--version") + // 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行 + Command::new("cmd") + .args(["/C", &format!("\"{}\" --version", tool_path.display())]) .env("PATH", &new_path) .creation_flags(CREATE_NO_WINDOW) .output() diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 37be402cf..56f587842 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -184,17 +184,16 @@ pub async fn reset_circuit_breaker( .await?; // 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取) - let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await { - Ok(config) => config.auto_failover_enabled, + // 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行 + let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await { + Ok(config) => (config.enabled, config.auto_failover_enabled), Err(e) => { - log::error!( - "[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" - ); - false + log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled"); + (false, false) } }; - if auto_failover_enabled && state.proxy_service.is_running().await { + if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await { // 获取当前供应商 ID let current_id = db .get_current_provider(&app_type) diff --git a/src-tauri/src/commands/skill.rs b/src-tauri/src/commands/skill.rs index e7f51f80f..9e3eb6076 100644 --- a/src-tauri/src/commands/skill.rs +++ b/src-tauri/src/commands/skill.rs @@ -1,12 +1,17 @@ -use crate::app_config::AppType; +//! Skills 命令层 +//! +//! v3.10.0+ 统一管理架构: +//! - 支持三应用开关(Claude/Codex/Gemini) +//! - SSOT 存储在 ~/.cc-switch/skills/ + +use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill}; use crate::error::format_skill_error; -use crate::services::skill::SkillState; -use crate::services::{Skill, SkillRepo, SkillService}; +use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService}; use crate::store::AppState; -use chrono::Utc; use std::sync::Arc; use tauri::State; +/// SkillService 状态包装 pub struct SkillServiceState(pub Arc); /// 解析 app 参数为 AppType @@ -19,65 +24,117 @@ fn parse_app_type(app: &str) -> Result { } } -/// 根据 app_type 生成带前缀的 skill key -fn get_skill_key(app_type: &AppType, directory: &str) -> String { - let prefix = match app_type { - AppType::Claude => "claude", - AppType::Codex => "codex", - AppType::Gemini => "gemini", - }; - format!("{prefix}:{directory}") +// ========== 统一管理命令 ========== + +/// 获取所有已安装的 Skills +#[tauri::command] +pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result, String> { + SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string()) } +/// 安装 Skill(新版统一安装) +/// +/// 参数: +/// - skill: 从发现列表获取的技能信息 +/// - current_app: 当前选中的应用,安装后默认启用该应用 +#[tauri::command] +pub async fn install_skill_unified( + skill: DiscoverableSkill, + current_app: String, + service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result { + let app_type = parse_app_type(¤t_app)?; + + service + .0 + .install(&app_state.db, &skill, &app_type) + .await + .map_err(|e| e.to_string()) +} + +/// 卸载 Skill(新版统一卸载) +#[tauri::command] +pub fn uninstall_skill_unified(id: String, app_state: State<'_, AppState>) -> Result { + SkillService::uninstall(&app_state.db, &id).map_err(|e| e.to_string())?; + Ok(true) +} + +/// 切换 Skill 的应用启用状态 +#[tauri::command] +pub fn toggle_skill_app( + id: String, + app: String, + enabled: bool, + app_state: State<'_, AppState>, +) -> Result { + let app_type = parse_app_type(&app)?; + SkillService::toggle_app(&app_state.db, &id, &app_type, enabled).map_err(|e| e.to_string())?; + Ok(true) +} + +/// 扫描未管理的 Skills +#[tauri::command] +pub fn scan_unmanaged_skills( + app_state: State<'_, AppState>, +) -> Result, String> { + SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string()) +} + +/// 从应用目录导入 Skills +#[tauri::command] +pub fn import_skills_from_apps( + directories: Vec, + app_state: State<'_, AppState>, +) -> Result, String> { + SkillService::import_from_apps(&app_state.db, directories).map_err(|e| e.to_string()) +} + +// ========== 发现功能命令 ========== + +/// 发现可安装的 Skills(从仓库获取) +#[tauri::command] +pub async fn discover_available_skills( + service: State<'_, SkillServiceState>, + app_state: State<'_, AppState>, +) -> Result, String> { + let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; + service + .0 + .discover_available(repos) + .await + .map_err(|e| e.to_string()) +} + +// ========== 兼容旧 API 的命令 ========== + +/// 获取技能列表(兼容旧 API) #[tauri::command] pub async fn get_skills( service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { - get_skills_for_app("claude".to_string(), service, app_state).await + let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; + service + .0 + .list_skills(repos, &app_state.db) + .await + .map_err(|e| e.to_string()) } +/// 获取指定应用的技能列表(兼容旧 API) #[tauri::command] pub async fn get_skills_for_app( app: String, - _service: State<'_, SkillServiceState>, + service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result, String> { - let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; - - let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; - - let skills = service - .list_skills(repos) - .await - .map_err(|e| e.to_string())?; - - // 自动同步本地已安装的 skills 到数据库 - // 这样用户在首次运行时,已有的 skills 会被自动记录 - let existing_states = app_state.db.get_skills().unwrap_or_default(); - - for skill in &skills { - if skill.installed { - let key = get_skill_key(&app_type, &skill.directory); - if !existing_states.contains_key(&key) { - // 本地有该 skill,但数据库中没有记录,自动添加 - if let Err(e) = app_state.db.update_skill_state( - &key, - &SkillState { - installed: true, - installed_at: Utc::now(), - }, - ) { - log::warn!("同步本地 skill {key} 状态到数据库失败: {e}"); - } - } - } - } - - Ok(skills) + // 新版本不再区分应用,统一返回所有技能 + let _ = parse_app_type(&app)?; // 验证 app 参数有效 + get_skills(service, app_state).await } +/// 安装技能(兼容旧 API) #[tauri::command] pub async fn install_skill( directory: String, @@ -87,27 +144,34 @@ pub async fn install_skill( install_skill_for_app("claude".to_string(), directory, service, app_state).await } +/// 安装指定应用的技能(兼容旧 API) #[tauri::command] pub async fn install_skill_for_app( app: String, directory: String, - _service: State<'_, SkillServiceState>, + service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; - // 先在不持有写锁的情况下收集仓库与技能信息 + // 先获取技能信息 let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; - let skills = service - .list_skills(repos) + .0 + .discover_available(repos) .await .map_err(|e| e.to_string())?; let skill = skills - .iter() - .find(|s| s.directory.eq_ignore_ascii_case(&directory)) + .into_iter() + .find(|s| { + let install_name = std::path::Path::new(&s.directory) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.directory.clone()); + install_name.eq_ignore_ascii_case(&directory) + || s.directory.eq_ignore_ascii_case(&directory) + }) .ok_or_else(|| { format_skill_error( "SKILL_NOT_FOUND", @@ -116,103 +180,54 @@ pub async fn install_skill_for_app( ) })?; - if !skill.installed { - let repo = SkillRepo { - owner: skill.repo_owner.clone().ok_or_else(|| { - format_skill_error( - "MISSING_REPO_INFO", - &[("directory", &directory), ("field", "owner")], - None, - ) - })?, - name: skill.repo_name.clone().ok_or_else(|| { - format_skill_error( - "MISSING_REPO_INFO", - &[("directory", &directory), ("field", "name")], - None, - ) - })?, - branch: skill - .repo_branch - .clone() - .unwrap_or_else(|| "main".to_string()), - enabled: true, - }; - - service - .install_skill(directory.clone(), repo) - .await - .map_err(|e| e.to_string())?; - } - - let key = get_skill_key(&app_type, &directory); - app_state - .db - .update_skill_state( - &key, - &SkillState { - installed: true, - installed_at: Utc::now(), - }, - ) + service + .0 + .install(&app_state.db, &skill, &app_type) + .await .map_err(|e| e.to_string())?; Ok(true) } +/// 卸载技能(兼容旧 API) #[tauri::command] -pub fn uninstall_skill( - directory: String, - service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result { - uninstall_skill_for_app("claude".to_string(), directory, service, app_state) +pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result { + uninstall_skill_for_app("claude".to_string(), directory, app_state) } +/// 卸载指定应用的技能(兼容旧 API) #[tauri::command] pub fn uninstall_skill_for_app( app: String, directory: String, - _service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { - let app_type = parse_app_type(&app)?; - let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; + let _ = parse_app_type(&app)?; // 验证参数 - service - .uninstall_skill(directory.clone()) - .map_err(|e| e.to_string())?; + // 通过 directory 找到对应的 skill id + let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?; - // Remove from database by setting installed = false - let key = get_skill_key(&app_type, &directory); - app_state - .db - .update_skill_state( - &key, - &SkillState { - installed: false, - installed_at: Utc::now(), - }, - ) - .map_err(|e| e.to_string())?; + let skill = skills + .into_iter() + .find(|s| s.directory.eq_ignore_ascii_case(&directory)) + .ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?; + + SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?; Ok(true) } +// ========== 仓库管理命令 ========== + +/// 获取技能仓库列表 #[tauri::command] -pub fn get_skill_repos( - _service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result, String> { +pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result, String> { app_state.db.get_skill_repos().map_err(|e| e.to_string()) } +/// 添加技能仓库 #[tauri::command] -pub fn add_skill_repo( - repo: SkillRepo, - _service: State<'_, SkillServiceState>, - app_state: State<'_, AppState>, -) -> Result { +pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result { app_state .db .save_skill_repo(&repo) @@ -220,11 +235,11 @@ pub fn add_skill_repo( Ok(true) } +/// 删除技能仓库 #[tauri::command] pub fn remove_skill_repo( owner: String, name: String, - _service: State<'_, SkillServiceState>, app_state: State<'_, AppState>, ) -> Result { app_state diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index e872feb5d..4e527ebbe 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -19,9 +19,10 @@ pub fn get_usage_summary( #[tauri::command] pub fn get_usage_trends( state: State<'_, AppState>, - days: u32, + start_date: Option, + end_date: Option, ) -> Result, AppError> { - state.db.get_daily_trends(days) + state.db.get_daily_trends(start_date, end_date) } /// 获取 Provider 统计 diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 6727059e9..269d11753 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -1,73 +1,156 @@ //! Skills 数据访问对象 //! //! 提供 Skills 和 Skill Repos 的 CRUD 操作。 +//! +//! v3.10.0+ 统一管理架构: +//! - Skills 使用统一的 id 主键,支持三应用启用标志 +//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录 +use crate::app_config::{InstalledSkill, SkillApps}; use crate::database::{lock_conn, Database}; use crate::error::AppError; -use crate::services::skill::{SkillRepo, SkillState}; +use crate::services::skill::SkillRepo; use indexmap::IndexMap; use rusqlite::params; impl Database { - /// 获取所有 Skills 状态 - pub fn get_skills(&self) -> Result, AppError> { + // ========== InstalledSkill CRUD ========== + + /// 获取所有已安装的 Skills + pub fn get_all_installed_skills(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn - .prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC") + .prepare( + "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, + readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + FROM skills ORDER BY name ASC", + ) .map_err(|e| AppError::Database(e.to_string()))?; let skill_iter = stmt .query_map([], |row| { - let directory: String = row.get(0)?; - let app_type: String = row.get(1)?; - let installed: bool = row.get(2)?; - let installed_at_ts: i64 = row.get(3)?; - - let installed_at = - chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); - - // 构建复合 key:"app_type:directory" - let key = format!("{app_type}:{directory}"); - - Ok(( - key, - SkillState { - installed, - installed_at, + Ok(InstalledSkill { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + directory: row.get(3)?, + repo_owner: row.get(4)?, + repo_name: row.get(5)?, + repo_branch: row.get(6)?, + readme_url: row.get(7)?, + apps: SkillApps { + claude: row.get(8)?, + codex: row.get(9)?, + gemini: row.get(10)?, }, - )) + installed_at: row.get(11)?, + }) }) .map_err(|e| AppError::Database(e.to_string()))?; let mut skills = IndexMap::new(); for skill_res in skill_iter { - let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?; - skills.insert(key, skill); + let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?; + skills.insert(skill.id.clone(), skill); } Ok(skills) } - /// 更新 Skill 状态 - /// key 格式为 "app_type:directory" - pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { - // 解析 key - let (app_type, directory) = if let Some(idx) = key.find(':') { - let (app, dir) = key.split_at(idx); - (app, &dir[1..]) // 跳过冒号 - } else { - // 向后兼容:如果没有前缀,默认为 claude - ("claude", key) - }; + /// 获取单个已安装的 Skill + pub fn get_installed_skill(&self, id: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let mut stmt = conn + .prepare( + "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, + readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at + FROM skills WHERE id = ?1", + ) + .map_err(|e| AppError::Database(e.to_string()))?; + let result = stmt.query_row([id], |row| { + Ok(InstalledSkill { + id: row.get(0)?, + name: row.get(1)?, + description: row.get(2)?, + directory: row.get(3)?, + repo_owner: row.get(4)?, + repo_name: row.get(5)?, + repo_branch: row.get(6)?, + readme_url: row.get(7)?, + apps: SkillApps { + claude: row.get(8)?, + codex: row.get(9)?, + gemini: row.get(10)?, + }, + installed_at: row.get(11)?, + }) + }); + + match result { + Ok(skill) => Ok(Some(skill)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + /// 保存 Skill(添加或更新) + pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> { let conn = lock_conn!(self.conn); conn.execute( - "INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", - params![directory, app_type, state.installed, state.installed_at.timestamp()], + "INSERT OR REPLACE INTO skills + (id, name, description, directory, repo_owner, repo_name, repo_branch, + readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + params![ + skill.id, + skill.name, + skill.description, + skill.directory, + skill.repo_owner, + skill.repo_name, + skill.repo_branch, + skill.readme_url, + skill.apps.claude, + skill.apps.codex, + skill.apps.gemini, + skill.installed_at, + ], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } + /// 删除 Skill + pub fn delete_skill(&self, id: &str) -> Result { + let conn = lock_conn!(self.conn); + let affected = conn + .execute("DELETE FROM skills WHERE id = ?1", params![id]) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(affected > 0) + } + + /// 清空所有 Skills(用于迁移) + pub fn clear_skills(&self) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute("DELETE FROM skills", []) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + /// 更新 Skill 的应用启用状态 + pub fn update_skill_apps(&self, id: &str, apps: &SkillApps) -> Result { + let conn = lock_conn!(self.conn); + let affected = conn + .execute( + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3 WHERE id = ?4", + params![apps.claude, apps.codex, apps.gemini, id], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(affected > 0) + } + + // ========== SkillRepo CRUD(保持原有) ========== + /// 获取所有 Skill 仓库 pub fn get_skill_repos(&self) -> Result, AppError> { let conn = lock_conn!(self.conn); @@ -101,7 +184,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)", params![repo.owner, repo.name, repo.branch, repo.enabled], - ).map_err(|e| AppError::Database(e.to_string()))?; + ) + .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } diff --git a/src-tauri/src/database/migration.rs b/src-tauri/src/database/migration.rs index 44c9dc149..3c52e7127 100644 --- a/src-tauri/src/database/migration.rs +++ b/src-tauri/src/database/migration.rs @@ -192,13 +192,16 @@ impl Database { tx: &rusqlite::Transaction<'_>, config: &MultiAppConfig, ) -> Result<(), AppError> { - for (key, state) in &config.skills.skills { - tx.execute( - "INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", - params![key, state.installed, state.installed_at.timestamp()], - ) - .map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?; - } + // v3.10.0+:Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/)+ 数据库统一结构。 + // + // 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据, + // 且无法保证 SSOT 目录中一定存在对应的 skill 文件。 + // + // 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。 + // 迁移后可通过: + // - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT) + // - 或后续启动时的自动扫描逻辑 + // 来重建已安装技能记录。 for repo in &config.skills.repos { tx.execute( diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 73a4877a4..bf59dcd61 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10; /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 2; +pub(crate) const SCHEMA_VERSION: i32 = 3; /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index 0a1429110..563c96716 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -71,11 +71,21 @@ impl Database { PRIMARY KEY (id, app_type) )", []).map_err(|e| AppError::Database(e.to_string()))?; - // 5. Skills 表 + // 5. Skills 表(v3.10.0+ 统一结构) 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) + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + directory TEXT NOT NULL, + repo_owner TEXT, + repo_name TEXT, + repo_branch TEXT DEFAULT 'main', + readme_url TEXT, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0 )", [], ) @@ -233,6 +243,24 @@ impl Database { [], ); + // 尝试添加基础配置列到 proxy_config 表(兼容 v3.9.0-2 升级) + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN proxy_enabled INTEGER NOT NULL DEFAULT 0", + [], + ); + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN listen_address TEXT NOT NULL DEFAULT '127.0.0.1'", + [], + ); + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN listen_port INTEGER NOT NULL DEFAULT 5000", + [], + ); + let _ = conn.execute( + "ALTER TABLE proxy_config ADD COLUMN enable_logging INTEGER NOT NULL DEFAULT 1", + [], + ); + // 尝试添加超时配置列到 proxy_config 表 let _ = conn.execute( "ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30", @@ -247,6 +275,14 @@ impl Database { [], ); + // 兼容:若旧版 proxy_config 仍为单例结构(无 app_type),则在启动时直接转换为三行结构 + // 说明:user_version=2 时不会再触发 v1->v2 迁移,但新代码查询依赖 app_type 列。 + if Self::table_exists(conn, "proxy_config")? + && !Self::has_column(conn, "proxy_config", "app_type")? + { + Self::migrate_proxy_config_to_per_app(conn)?; + } + // 确保 in_failover_queue 列存在(对于已存在的 v2 数据库) Self::add_column_if_missing( conn, @@ -305,6 +341,11 @@ impl Database { Self::migrate_v1_to_v2(conn)?; Self::set_user_version(conn, 2)?; } + 2 => { + log::info!("迁移数据库从 v2 到 v3(Skills 统一管理架构)"); + Self::migrate_v2_to_v3(conn)?; + Self::set_user_version(conn, 3)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -411,6 +452,32 @@ impl Database { // 添加代理超时配置字段 if Self::table_exists(conn, "proxy_config")? { + // 兼容旧版本缺失的基础字段 + Self::add_column_if_missing( + conn, + "proxy_config", + "proxy_enabled", + "INTEGER NOT NULL DEFAULT 0", + )?; + Self::add_column_if_missing( + conn, + "proxy_config", + "listen_address", + "TEXT NOT NULL DEFAULT '127.0.0.1'", + )?; + Self::add_column_if_missing( + conn, + "proxy_config", + "listen_port", + "INTEGER NOT NULL DEFAULT 5000", + )?; + Self::add_column_if_missing( + conn, + "proxy_config", + "enable_logging", + "INTEGER NOT NULL DEFAULT 1", + )?; + Self::add_column_if_missing( conn, "proxy_config", @@ -637,6 +704,17 @@ impl Database { /// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键 fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> { + // v3 结构(统一管理架构)已经是更高版本的 skills 表: + // - 主键为 id + // - 包含 enabled_claude / enabled_codex / enabled_gemini 等列 + // 在这种情况下,不应再执行 v1 -> v2 的迁移逻辑,否则会因列不匹配而失败。 + if Self::has_column(conn, "skills", "enabled_claude")? + || Self::has_column(conn, "skills", "id")? + { + log::info!("skills 表已经是 v3 结构,跳过 v1 -> v2 迁移"); + return Ok(()); + } + // 检查是否已经是新表结构 if Self::has_column(conn, "skills", "app_type")? { log::info!("skills 表已经包含 app_type 字段,跳过迁移"); @@ -708,14 +786,77 @@ impl Database { Ok(()) } + /// v2 -> v3 迁移:Skills 统一管理架构 + /// + /// 将 skills 表从 (directory, app_type) 复合主键结构迁移到统一的 id 主键结构, + /// 支持三应用启用标志(enabled_claude, enabled_codex, enabled_gemini)。 + /// + /// 迁移策略: + /// 1. 旧数据库只存储安装记录,真正的 skill 文件在文件系统 + /// 2. 直接重建新表结构,后续由 SkillService 在首次启动时扫描文件系统重建数据 + fn migrate_v2_to_v3(conn: &Connection) -> Result<(), AppError> { + // 检查是否已经是新结构(通过检查是否有 enabled_claude 列) + if Self::has_column(conn, "skills", "enabled_claude")? { + log::info!("skills 表已经是 v3 结构,跳过迁移"); + return Ok(()); + } + + log::info!("开始迁移 skills 表到 v3 结构(统一管理架构)..."); + + // 1. 备份旧数据(用于日志) + let old_count: i64 = conn + .query_row("SELECT COUNT(*) FROM skills", [], |row| row.get(0)) + .unwrap_or(0); + log::info!("旧 skills 表有 {old_count} 条记录"); + + // 标记:需要在启动后从文件系统扫描并重建 Skills 数据 + // 说明:v3 结构将 Skills 的 SSOT 迁移到 ~/.cc-switch/skills/, + // 旧表只存“安装记录”,无法直接无损迁移到新结构,因此改为启动后扫描 app 目录导入。 + let _ = conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('skills_ssot_migration_pending', 'true')", + [], + ); + + // 2. 删除旧表 + conn.execute("DROP TABLE IF EXISTS skills", []) + .map_err(|e| AppError::Database(format!("删除旧 skills 表失败: {e}")))?; + + // 3. 创建新表 + conn.execute( + "CREATE TABLE skills ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + directory TEXT NOT NULL, + repo_owner TEXT, + repo_name TEXT, + repo_branch TEXT DEFAULT 'main', + readme_url TEXT, + enabled_claude BOOLEAN NOT NULL DEFAULT 0, + enabled_codex BOOLEAN NOT NULL DEFAULT 0, + enabled_gemini BOOLEAN NOT NULL DEFAULT 0, + installed_at INTEGER NOT NULL DEFAULT 0 + )", + [], + ) + .map_err(|e| AppError::Database(format!("创建新 skills 表失败: {e}")))?; + + log::info!( + "skills 表已迁移到 v3 结构。\n\ + 注意:旧的安装记录已清除,首次启动时将自动扫描文件系统重建数据。" + ); + + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { let pricing_data = [ - // Claude 4.5 系列 + // Claude 4.5 系列 (Latest Models) ( - "claude-opus-4-5", + "claude-opus-4-5-20251101", "Claude Opus 4.5", "5", "25", @@ -723,7 +864,7 @@ impl Database { "6.25", ), ( - "claude-sonnet-4-5", + "claude-sonnet-4-5-20250929", "Claude Sonnet 4.5", "3", "15", @@ -731,16 +872,24 @@ impl Database { "3.75", ), ( - "claude-haiku-4-5", + "claude-haiku-4-5-20251001", "Claude Haiku 4.5", "1", "5", "0.10", "1.25", ), - // Claude 4.1 系列 + // Claude 4 系列 (Legacy Models) ( - "claude-opus-4-1", + "claude-opus-4-20250514", + "Claude Opus 4", + "15", + "75", + "1.50", + "18.75", + ), + ( + "claude-opus-4-1-20250805", "Claude Opus 4.1", "15", "75", @@ -748,17 +897,8 @@ impl Database { "18.75", ), ( - "claude-sonnet-4-1", - "Claude Sonnet 4.1", - "3", - "15", - "0.30", - "3.75", - ), - // Claude 3.7 系列 - ( - "claude-sonnet-3-7", - "Claude Sonnet 3.7", + "claude-sonnet-4-20250514", + "Claude Sonnet 4", "3", "15", "0.30", @@ -766,38 +906,167 @@ impl Database { ), // Claude 3.5 系列 ( - "claude-sonnet-3-5", - "Claude Sonnet 3.5", - "3", - "15", - "0.30", - "3.75", - ), - ( - "claude-haiku-3-5", - "Claude Haiku 3.5", + "claude-3-5-haiku-20241022", + "Claude 3.5 Haiku", "0.80", "4", "0.08", "1", ), - // GPT-5 系列(model_id 使用短横线格式) + ( + "claude-3-5-sonnet-20241022", + "Claude 3.5 Sonnet", + "3", + "15", + "0.30", + "3.75", + ), + // GPT-5.2 系列 + ("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"), + ("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"), + ("gpt-5.2-medium", "GPT-5.2", "1.75", "14", "0.175", "0"), + ("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"), + ("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"), + ("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"), + ( + "gpt-5.2-codex-low", + "GPT-5.2 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.2-codex-medium", + "GPT-5.2 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.2-codex-high", + "GPT-5.2 Codex", + "1.75", + "14", + "0.175", + "0", + ), + ( + "gpt-5.2-codex-xhigh", + "GPT-5.2 Codex", + "1.75", + "14", + "0.175", + "0", + ), + // GPT-5.1 系列 + ("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"), + ("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"), + ("gpt-5.1-medium", "GPT-5.1", "1.25", "10", "0.125", "0"), + ("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"), + ("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"), + ("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"), + ( + "gpt-5.1-codex-mini", + "GPT-5.1 Codex", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gpt-5.1-codex-max", + "GPT-5.1 Codex", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gpt-5.1-codex-max-high", + "GPT-5.1 Codex", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gpt-5.1-codex-max-xhigh", + "GPT-5.1 Codex", + "1.25", + "10", + "0.125", + "0", + ), + // GPT-5 系列 ("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"), - ("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"), + ("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"), + ("gpt-5-medium", "GPT-5", "1.25", "10", "0.125", "0"), + ("gpt-5-high", "GPT-5", "1.25", "10", "0.125", "0"), + ("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"), ("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"), - ("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"), + ("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"), + ( + "gpt-5-codex-medium", + "GPT-5 Codex", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gpt-5-codex-high", + "GPT-5 Codex", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gpt-5-codex-mini", + "GPT-5 Codex", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gpt-5-codex-mini-medium", + "GPT-5 Codex", + "1.25", + "10", + "0.125", + "0", + ), + ( + "gpt-5-codex-mini-high", + "GPT-5 Codex", + "1.25", + "10", + "0.125", + "0", + ), // Gemini 3 系列 ( "gemini-3-pro-preview", "Gemini 3 Pro Preview", "2", "12", - "0", + "0.2", "0", ), - // Gemini 2.5 系列(model_id 使用短横线格式) ( - "gemini-2-5-pro", + "gemini-3-flash-preview", + "Gemini 3 Flash Preview", + "0.5", + "3", + "0.05", + "0", + ), + // Gemini 2.5 系列 + ( + "gemini-2.5-pro", "Gemini 2.5 Pro", "1.25", "10", @@ -805,13 +1074,75 @@ impl Database { "0", ), ( - "gemini-2-5-flash", + "gemini-2.5-flash", "Gemini 2.5 Flash", "0.3", "2.5", "0.03", "0", ), + // ====== 国产模型 (CNY/1M tokens) ====== + // Doubao (字节跳动) + ( + "doubao-seed-code", + "Doubao Seed Code", + "1.20", + "8.00", + "0.24", + "0", + ), + // DeepSeek 系列 + ( + "deepseek-v3.2", + "DeepSeek V3.2", + "2.00", + "3.00", + "0.40", + "0", + ), + ( + "deepseek-v3.1", + "DeepSeek V3.1", + "4.00", + "12.00", + "0.80", + "0", + ), + ("deepseek-v3", "DeepSeek V3", "2.00", "8.00", "0.40", "0"), + // Kimi (月之暗面) + ( + "kimi-k2-thinking", + "Kimi K2 Thinking", + "4.00", + "16.00", + "1.00", + "0", + ), + ("kimi-k2-0905", "Kimi K2", "4.00", "16.00", "1.00", "0"), + ( + "kimi-k2-turbo", + "Kimi K2 Turbo", + "8.00", + "58.00", + "1.00", + "0", + ), + // MiniMax 系列 + ("minimax-m2.1", "MiniMax M2.1", "2.10", "8.40", "0.21", "0"), + ( + "minimax-m2.1-lightning", + "MiniMax M2.1 Lightning", + "2.10", + "16.80", + "0.21", + "0", + ), + ("minimax-m2", "MiniMax M2", "2.10", "8.40", "0.21", "0"), + // GLM (智谱) + ("glm-4.7", "GLM-4.7", "2.00", "8.00", "0.40", "0"), + ("glm-4.6", "GLM-4.6", "2.00", "8.00", "0.40", "0"), + // Mimo (小米) + ("mimo-v2-flash", "Mimo V2 Flash", "0", "0", "0", "0"), ]; for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data { diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 74172fe58..8efc0ddba 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -53,7 +53,6 @@ const LEGACY_SCHEMA_SQL: &str = r#" #[derive(Debug)] struct ColumnInfo { - name: String, r#type: String, notnull: i64, default: Option, @@ -65,10 +64,9 @@ fn get_column_info(conn: &Connection, table: &str, column: &str) -> ColumnInfo { .expect("prepare pragma"); let mut rows = stmt.query([]).expect("query pragma"); while let Some(row) = rows.next().expect("read row") { - let name: String = row.get(1).expect("name"); - if name.eq_ignore_ascii_case(column) { + let column_name: String = row.get(1).expect("name"); + if column_name.eq_ignore_ascii_case(column) { return ColumnInfo { - name, r#type: row.get::<_, String>(2).expect("type"), notnull: row.get::<_, i64>(3).expect("notnull"), default: row.get::<_, Option>(4).ok().flatten(), @@ -201,6 +199,53 @@ fn migration_aligns_column_defaults_and_types() { ); } +#[test] +fn create_tables_repairs_legacy_proxy_config_singleton_to_per_app() { + let conn = Connection::open_in_memory().expect("open memory db"); + + // 模拟测试版 v2:user_version=2,但 proxy_config 仍是单例结构(无 app_type) + Database::set_user_version(&conn, 2).expect("set user_version"); + conn.execute_batch( + r#" + CREATE TABLE proxy_config ( + id INTEGER PRIMARY KEY, + 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 INTO proxy_config (id, enabled) VALUES (1, 1); + "#, + ) + .expect("seed legacy proxy_config"); + + Database::create_tables_on_conn(&conn).expect("create tables should repair proxy_config"); + + assert!( + Database::has_column(&conn, "proxy_config", "app_type").expect("check app_type"), + "proxy_config should be migrated to per-app structure" + ); + + let count: i32 = conn + .query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0)) + .expect("count rows"); + assert_eq!(count, 3, "per-app proxy_config should have 3 rows"); + + // 新结构下应能按 app_type 查询 + let _: i32 = conn + .query_row( + "SELECT COUNT(*) FROM proxy_config WHERE app_type = 'claude'", + [], + |r| r.get(0), + ) + .expect("query by app_type"); +} + #[test] fn dry_run_does_not_write_to_disk() { // Create minimal valid config for migration @@ -249,9 +294,10 @@ fn dry_run_validates_schema_compatibility() { }, ); - let mut manager = ProviderManager::default(); - manager.providers = providers; - manager.current = "test-provider".to_string(); + let manager = ProviderManager { + providers, + current: "test-provider".to_string(), + }; let mut apps = HashMap::new(); apps.insert("claude".to_string(), manager); diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 0f7fb5e45..5775fc58d 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -375,7 +375,7 @@ fn test_parse_prompt_deeplink() { assert_eq!(request.name.unwrap(), "test"); assert_eq!(request.content.unwrap(), content_b64); assert_eq!(request.description.unwrap(), "desc"); - assert_eq!(request.enabled.unwrap(), true); + assert!(request.enabled.unwrap()); } #[test] @@ -391,13 +391,13 @@ fn test_parse_mcp_deeplink() { assert_eq!(request.resource, "mcp"); assert_eq!(request.apps.unwrap(), "claude,codex"); assert_eq!(request.config.unwrap(), config_b64); - assert_eq!(request.enabled.unwrap(), true); + assert!(request.enabled.unwrap()); } #[test] fn test_parse_skill_deeplink() { let url = "ccswitch://v1/import?resource=skill&repo=owner/repo&directory=skills&branch=dev"; - let request = parse_deeplink_url(&url).unwrap(); + let request = parse_deeplink_url(url).unwrap(); assert_eq!(request.resource, "skill"); assert_eq!(request.repo.unwrap(), "owner/repo"); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index e9eafd35b..9d8c622f1 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -52,6 +52,10 @@ pub enum AppError { }, #[error("数据库错误: {0}")] Database(String), + #[error("所有供应商已熔断,无可用渠道")] + AllProvidersCircuitOpen, + #[error("未配置供应商")] + NoProvidersConfigured, } impl AppError { diff --git a/src-tauri/src/init_status.rs b/src-tauri/src/init_status.rs index 86e64a4a9..042dd620a 100644 --- a/src-tauri/src/init_status.rs +++ b/src-tauri/src/init_status.rs @@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool { } } +// ============================================================ +// Skills SSOT 迁移结果状态 +// ============================================================ + +#[derive(Debug, Clone, Serialize)] +pub struct SkillsMigrationPayload { + pub count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +static SKILLS_MIGRATION_RESULT: OnceLock>> = OnceLock::new(); + +fn skills_migration_cell() -> &'static RwLock> { + SKILLS_MIGRATION_RESULT.get_or_init(|| RwLock::new(None)) +} + +pub fn set_skills_migration_result(count: usize) { + if let Ok(mut guard) = skills_migration_cell().write() { + *guard = Some(SkillsMigrationPayload { count, error: None }); + } +} + +pub fn set_skills_migration_error(error: String) { + if let Ok(mut guard) = skills_migration_cell().write() { + *guard = Some(SkillsMigrationPayload { + count: 0, + error: Some(error), + }); + } +} + +/// 获取并消费 Skills 迁移结果(只返回一次 Some,之后返回 None) +pub fn take_skills_migration_result() -> Option { + if let Ok(mut guard) = skills_migration_cell().write() { + guard.take() + } else { + None + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ceeef2016..9d336ac81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -325,6 +325,47 @@ pub fn run() { Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"), } + // 1.1. Skills 统一管理迁移:当数据库迁移到 v3 结构后,自动从各应用目录导入到 SSOT + // 触发条件由 schema 迁移设置 settings.skills_ssot_migration_pending = true 控制。 + match app_state.db.get_setting("skills_ssot_migration_pending") { + Ok(Some(flag)) if flag == "true" || flag == "1" => { + // 安全保护:如果用户已经有 v3 结构的 Skills 数据,就不要自动清空重建。 + let has_existing = app_state + .db + .get_all_installed_skills() + .map(|skills| !skills.is_empty()) + .unwrap_or(false); + + if has_existing { + log::info!( + "Detected skills_ssot_migration_pending but skills table not empty; skipping auto import." + ); + let _ = app_state + .db + .set_setting("skills_ssot_migration_pending", "false"); + } else { + match crate::services::skill::migrate_skills_to_ssot(&app_state.db) { + Ok(count) => { + log::info!("✓ Auto imported {count} skill(s) into SSOT"); + if count > 0 { + crate::init_status::set_skills_migration_result(count); + } + let _ = app_state + .db + .set_setting("skills_ssot_migration_pending", "false"); + } + Err(e) => { + log::warn!("✗ Failed to auto import legacy skills to SSOT: {e}"); + crate::init_status::set_skills_migration_error(e.to_string()); + // 保留 pending 标志,方便下次启动重试 + } + } + } + } + Ok(_) => {} // 未开启迁移标志,静默跳过 + Err(e) => log::warn!("✗ Failed to read skills migration flag: {e}"), + } + // 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过) for app in [ crate::app_config::AppType::Claude, @@ -508,14 +549,8 @@ pub fn run() { app.manage(app_state); // 初始化 SkillService - match SkillService::new() { - Ok(skill_service) => { - app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); - } - Err(e) => { - log::warn!("初始化 SkillService 失败: {e}"); - } - } + let skill_service = SkillService::new(); + app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); // 异常退出恢复 + 代理状态自动恢复 let app_handle = app.handle().clone(); @@ -565,6 +600,7 @@ pub fn run() { commands::open_external, commands::get_init_error, commands::get_migration_result, + commands::get_skills_migration_result, commands::get_app_config_path, commands::open_app_config_folder, commands::get_claude_common_config_snippet, @@ -602,6 +638,7 @@ pub fn run() { commands::upsert_mcp_server, commands::delete_mcp_server, commands::toggle_mcp_app, + commands::import_mcp_from_apps, // Prompt management commands::get_prompts, commands::upsert_prompt, @@ -636,7 +673,15 @@ pub fn run() { commands::check_env_conflicts, commands::delete_env_vars, commands::restore_env_backup, - // Skill management + // Skill management (v3.10.0+ unified) + commands::get_installed_skills, + commands::install_skill_unified, + commands::uninstall_skill_unified, + commands::toggle_skill_app, + commands::scan_unmanaged_skills, + commands::import_skills_from_apps, + commands::discover_available_skills, + // Skill management (legacy API compatibility) commands::get_skills, commands::get_skills_for_app, commands::install_skill, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 3a4d1fa1f..13eebce51 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -386,16 +386,15 @@ impl UniversalProvider { // 生成 Codex 的 config.toml 内容 let config_toml = format!( r#"model_provider = "newapi" -model = "{}" -model_reasoning_effort = "{}" +model = "{model}" +model_reasoning_effort = "{reasoning_effort}" disable_response_storage = true [model_providers.newapi] name = "NewAPI" -base_url = "{}" +base_url = "{codex_base_url}" wire_api = "responses" -requires_openai_auth = true"#, - model, reasoning_effort, codex_base_url +requires_openai_auth = true"# ); let settings_config = serde_json::json!({ diff --git a/src-tauri/src/proxy/body_filter.rs b/src-tauri/src/proxy/body_filter.rs new file mode 100644 index 000000000..fc12ef64c --- /dev/null +++ b/src-tauri/src/proxy/body_filter.rs @@ -0,0 +1,303 @@ +//! 请求体过滤模块 +//! +//! 过滤不应透传到上游的私有参数,防止内部信息泄露。 +//! +//! ## 过滤规则 +//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤 +//! - 支持白名单机制,允许透传特定的 `_` 前缀字段 +//! - 支持嵌套对象和数组的深度过滤 +//! +//! ## 使用场景 +//! - `_internal_id`: 内部追踪 ID +//! - `_debug_mode`: 调试标记 +//! - `_session_token`: 会话令牌 +//! - `_client_version`: 客户端版本 + +use serde_json::Value; +use std::collections::HashSet; + +/// 过滤私有参数(以 `_` 开头的字段) +/// +/// 递归遍历 JSON 结构,移除所有以下划线开头的字段。 +/// +/// # Arguments +/// * `body` - 原始请求体 +/// +/// # Returns +/// 过滤后的请求体 +/// +/// # Example +/// ```ignore +/// let input = json!({ +/// "model": "claude-3", +/// "_internal_id": "abc123", +/// "messages": [{"role": "user", "content": "hello", "_token": "secret"}] +/// }); +/// let output = filter_private_params(input); +/// // output 中不包含 _internal_id 和 _token +/// ``` +#[cfg(test)] +pub fn filter_private_params(body: Value) -> Value { + filter_private_params_with_whitelist(body, &[]) +} + +/// 过滤私有参数(支持白名单) +/// +/// 递归遍历 JSON 结构,移除所有以下划线开头的字段, +/// 但保留白名单中指定的字段。 +/// +/// # Arguments +/// * `body` - 原始请求体 +/// * `whitelist` - 白名单字段列表(不过滤这些字段) +/// +/// # Returns +/// 过滤后的请求体 +/// +/// # Example +/// ```ignore +/// let input = json!({ +/// "model": "claude-3", +/// "_metadata": {"key": "value"}, // 白名单中,保留 +/// "_internal_id": "abc123" // 不在白名单中,过滤 +/// }); +/// let output = filter_private_params_with_whitelist(input, &["_metadata"]); +/// // output 包含 _metadata,不包含 _internal_id +/// ``` +pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value { + let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect(); + filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set) +} + +/// 递归过滤实现 +#[cfg(test)] +fn filter_recursive(value: Value, removed_keys: &mut Vec) -> Value { + filter_recursive_with_whitelist(value, removed_keys, &HashSet::new()) +} + +/// 递归过滤实现(支持白名单) +fn filter_recursive_with_whitelist( + value: Value, + removed_keys: &mut Vec, + whitelist: &HashSet<&str>, +) -> Value { + match value { + Value::Object(map) => { + let filtered: serde_json::Map = map + .into_iter() + .filter_map(|(key, val)| { + // 以 _ 开头且不在白名单中的字段被过滤 + if key.starts_with('_') && !whitelist.contains(key.as_str()) { + removed_keys.push(key); + None + } else { + Some(( + key, + filter_recursive_with_whitelist(val, removed_keys, whitelist), + )) + } + }) + .collect(); + + // 仅在有过滤时记录日志(避免每次请求都打印) + if !removed_keys.is_empty() { + log::debug!("[BodyFilter] 过滤私有参数: {removed_keys:?}"); + removed_keys.clear(); + } + + Value::Object(filtered) + } + Value::Array(arr) => Value::Array( + arr.into_iter() + .map(|v| filter_recursive_with_whitelist(v, removed_keys, whitelist)) + .collect(), + ), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_filter_top_level_private_params() { + let input = json!({ + "model": "claude-3", + "_internal_id": "abc123", + "_debug": true, + "max_tokens": 1024 + }); + + let output = filter_private_params(input); + + assert!(output.get("model").is_some()); + assert!(output.get("max_tokens").is_some()); + assert!(output.get("_internal_id").is_none()); + assert!(output.get("_debug").is_none()); + } + + #[test] + fn test_filter_nested_private_params() { + let input = json!({ + "model": "claude-3", + "messages": [ + { + "role": "user", + "content": "hello", + "_session_token": "secret" + } + ], + "metadata": { + "user_id": "user-1", + "_tracking_id": "track-1" + } + }); + + let output = filter_private_params(input); + + // 顶级字段保留 + assert!(output.get("model").is_some()); + assert!(output.get("messages").is_some()); + assert!(output.get("metadata").is_some()); + + // messages 数组中的私有参数被过滤 + let messages = output.get("messages").unwrap().as_array().unwrap(); + assert!(messages[0].get("role").is_some()); + assert!(messages[0].get("content").is_some()); + assert!(messages[0].get("_session_token").is_none()); + + // metadata 对象中的私有参数被过滤 + let metadata = output.get("metadata").unwrap(); + assert!(metadata.get("user_id").is_some()); + assert!(metadata.get("_tracking_id").is_none()); + } + + #[test] + fn test_filter_deeply_nested() { + let input = json!({ + "level1": { + "level2": { + "level3": { + "keep": "value", + "_remove": "secret" + } + } + } + }); + + let output = filter_private_params(input); + + let level3 = output + .get("level1") + .unwrap() + .get("level2") + .unwrap() + .get("level3") + .unwrap(); + + assert!(level3.get("keep").is_some()); + assert!(level3.get("_remove").is_none()); + } + + #[test] + fn test_filter_array_of_objects() { + let input = json!({ + "items": [ + {"id": 1, "_secret": "a"}, + {"id": 2, "_secret": "b"}, + {"id": 3, "_secret": "c"} + ] + }); + + let output = filter_private_params(input); + let items = output.get("items").unwrap().as_array().unwrap(); + + for item in items { + assert!(item.get("id").is_some()); + assert!(item.get("_secret").is_none()); + } + } + + #[test] + fn test_no_private_params() { + let input = json!({ + "model": "claude-3", + "messages": [{"role": "user", "content": "hello"}] + }); + + let output = filter_private_params(input.clone()); + + // 无私有参数时,输出应与输入相同 + assert_eq!(input, output); + } + + #[test] + fn test_empty_object() { + let input = json!({}); + let output = filter_private_params(input); + assert_eq!(output, json!({})); + } + + #[test] + fn test_primitive_values() { + // 原始值不应被修改 + assert_eq!(filter_private_params(json!(42)), json!(42)); + assert_eq!(filter_private_params(json!("string")), json!("string")); + assert_eq!(filter_private_params(json!(true)), json!(true)); + assert_eq!(filter_private_params(json!(null)), json!(null)); + } + + #[test] + fn test_whitelist_preserves_private_params() { + let input = json!({ + "model": "claude-3", + "_metadata": {"key": "value"}, + "_internal_id": "abc123", + "_stream_options": {"include_usage": true} + }); + + let whitelist = vec!["_metadata".to_string(), "_stream_options".to_string()]; + let output = filter_private_params_with_whitelist(input, &whitelist); + + // 白名单中的字段保留 + assert!(output.get("_metadata").is_some()); + assert!(output.get("_stream_options").is_some()); + // 不在白名单中的私有字段被过滤 + assert!(output.get("_internal_id").is_none()); + // 普通字段保留 + assert!(output.get("model").is_some()); + } + + #[test] + fn test_whitelist_nested() { + let input = json!({ + "data": { + "_allowed": "keep", + "_forbidden": "remove", + "normal": "value" + } + }); + + let whitelist = vec!["_allowed".to_string()]; + let output = filter_private_params_with_whitelist(input, &whitelist); + + let data = output.get("data").unwrap(); + assert!(data.get("_allowed").is_some()); + assert!(data.get("_forbidden").is_none()); + assert!(data.get("normal").is_some()); + } + + #[test] + fn test_empty_whitelist_same_as_default() { + let input = json!({ + "model": "claude-3", + "_internal_id": "abc123" + }); + + let output1 = filter_private_params(input.clone()); + let output2 = filter_private_params_with_whitelist(input, &[]); + + assert_eq!(output1, output2); + } +} diff --git a/src-tauri/src/proxy/error.rs b/src-tauri/src/proxy/error.rs index 316ffdc3f..1b0531a3a 100644 --- a/src-tauri/src/proxy/error.rs +++ b/src-tauri/src/proxy/error.rs @@ -23,6 +23,12 @@ pub enum ProxyError { #[error("无可用的Provider")] NoAvailableProvider, + #[error("所有供应商已熔断,无可用渠道")] + AllProvidersCircuitOpen, + + #[error("未配置供应商")] + NoProvidersConfigured, + #[allow(dead_code)] #[error("Provider不健康: {0}")] ProviderUnhealthy(String), @@ -111,6 +117,12 @@ impl IntoResponse for ProxyError { ProxyError::NoAvailableProvider => { (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) } + ProxyError::AllProvidersCircuitOpen => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } + ProxyError::NoProvidersConfigured => { + (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) + } ProxyError::ProviderUnhealthy(_) => { (StatusCode::SERVICE_UNAVAILABLE, self.to_string()) } diff --git a/src-tauri/src/proxy/error_mapper.rs b/src-tauri/src/proxy/error_mapper.rs index b8b44e9a3..f98c9608a 100644 --- a/src-tauri/src/proxy/error_mapper.rs +++ b/src-tauri/src/proxy/error_mapper.rs @@ -27,6 +27,12 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 { // 无可用 Provider:503 Service Unavailable ProxyError::NoAvailableProvider => 503, + // 所有供应商已熔断:503 Service Unavailable + ProxyError::AllProvidersCircuitOpen => 503, + + // 未配置供应商:503 Service Unavailable + ProxyError::NoProvidersConfigured => 503, + // 重试耗尽:503 Service Unavailable ProxyError::MaxRetriesExceeded => 503, @@ -57,6 +63,8 @@ pub fn get_error_message(error: &ProxyError) -> String { ProxyError::Timeout(msg) => format!("请求超时: {msg}"), ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"), ProxyError::NoAvailableProvider => "无可用 Provider".to_string(), + ProxyError::AllProvidersCircuitOpen => "所有供应商已熔断,无可用渠道".to_string(), + ProxyError::NoProvidersConfigured => "未配置供应商".to_string(), ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(), ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"), ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"), diff --git a/src-tauri/src/proxy/failover_switch.rs b/src-tauri/src/proxy/failover_switch.rs index b1ac5b746..e0bb8a00d 100644 --- a/src-tauri/src/proxy/failover_switch.rs +++ b/src-tauri/src/proxy/failover_switch.rs @@ -81,6 +81,21 @@ impl FailoverSwitchManager { provider_id: &str, provider_name: &str, ) -> Result { + // 检查该应用是否已被代理接管(enabled=true) + // 只有被接管的应用才允许执行故障转移切换 + let app_enabled = match self.db.get_proxy_config_for_app(app_type).await { + Ok(config) => config.enabled, + Err(e) => { + log::warn!("[Failover] 无法读取 {app_type} 配置: {e},跳过切换"); + return Ok(false); + } + }; + + if !app_enabled { + log::info!("[Failover] {app_type} 未被代理接管(enabled=false),跳过切换"); + return Ok(false); + } + log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})"); // 1. 更新数据库 is_current diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 334182f92..742ebdb42 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -1,8 +1,9 @@ //! 请求转发器 //! -//! 负责将请求转发到上游Provider,支持重试和故障转移 +//! 负责将请求转发到上游Provider,支持故障转移 use super::{ + body_filter::filter_private_params_with_whitelist, error::*, failover_switch::FailoverSwitchManager, provider_router::ProviderRouter, @@ -17,6 +18,71 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::sync::RwLock; +/// Headers 黑名单 - 不透传到上游的 Headers +/// +/// 参考 Claude Code Hub 设计,过滤以下类别: +/// 1. 认证类(会被覆盖) +/// 2. 连接类(由 HTTP 客户端管理) +/// 3. 代理转发类 +/// 4. CDN/云服务商特定头 +/// 5. 请求追踪类 +/// 6. 浏览器特定头(可能被上游检测) +/// +/// 注意:客户端 IP 类(x-forwarded-for, x-real-ip)默认透传 +const HEADER_BLACKLIST: &[&str] = &[ + // 认证类(会被覆盖) + "authorization", + "x-api-key", + // 连接类 + "host", + "content-length", + "connection", + "transfer-encoding", + // 编码类(会被覆盖为 identity) + "accept-encoding", + // 代理转发类(保留 x-forwarded-for 和 x-real-ip) + "x-forwarded-host", + "x-forwarded-port", + "x-forwarded-proto", + "forwarded", + // CDN/云服务商特定头 + "cf-connecting-ip", + "cf-ipcountry", + "cf-ray", + "cf-visitor", + "true-client-ip", + "fastly-client-ip", + "x-azure-clientip", + "x-azure-fdid", + "x-azure-ref", + "akamai-origin-hop", + "x-akamai-config-log-detail", + // 请求追踪类 + "x-request-id", + "x-correlation-id", + "x-trace-id", + "x-amzn-trace-id", + "x-b3-traceid", + "x-b3-spanid", + "x-b3-parentspanid", + "x-b3-sampled", + "traceparent", + "tracestate", + // 浏览器特定头(可能被上游检测为非 CLI 请求) + "sec-fetch-mode", + "sec-fetch-site", + "sec-fetch-dest", + "sec-ch-ua", + "sec-ch-ua-mobile", + "sec-ch-ua-platform", + "accept-language", + // anthropic-beta 单独处理,避免重复 + "anthropic-beta", + // 客户端 IP 单独处理(默认透传) + "x-forwarded-for", + "x-real-ip", +]; + pub struct ForwardResult { pub response: Response, pub provider: Provider, @@ -31,8 +97,6 @@ pub struct RequestForwarder { client: Client, /// 共享的 ProviderRouter(持有熔断器状态) router: Arc, - /// 单个 Provider 内的最大重试次数 - max_retries: u8, status: Arc>, current_providers: Arc>>, /// 故障转移切换管理器 @@ -48,7 +112,6 @@ impl RequestForwarder { pub fn new( router: Arc, non_streaming_timeout: u64, - max_retries: u8, status: Arc>, current_providers: Arc>>, failover_manager: Arc, @@ -77,7 +140,6 @@ impl RequestForwarder { Self { client, router, - max_retries, status, current_providers, failover_manager, @@ -86,59 +148,6 @@ impl RequestForwarder { } } - /// 对单个 Provider 执行请求(带重试) - /// - /// 在同一个 Provider 上最多重试 max_retries 次,使用指数退避 - async fn forward_with_provider_retry( - &self, - provider: &Provider, - endpoint: &str, - body: &Value, - headers: &axum::http::HeaderMap, - adapter: &dyn ProviderAdapter, - ) -> Result { - let mut last_error = None; - - for attempt in 0..=self.max_retries { - if attempt > 0 { - // 指数退避:100ms, 200ms, 400ms, ... - let delay_ms = 100 * 2u64.pow(attempt as u32 - 1); - log::info!( - "[{}] 重试第 {}/{} 次(等待 {}ms)", - adapter.name(), - attempt, - self.max_retries, - delay_ms - ); - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - } - - match self - .forward(provider, endpoint, body, headers, adapter) - .await - { - Ok(response) => return Ok(response), - Err(e) => { - // 只有“同一 Provider 内可重试”的错误才继续重试 - if !self.should_retry_same_provider(&e) { - return Err(e); - } - - log::debug!( - "[{}] Provider {} 第 {} 次请求失败: {}", - adapter.name(), - provider.name, - attempt + 1, - e - ); - last_error = Some(e); - } - } - } - - Err(last_error.unwrap_or(ProxyError::MaxRetriesExceeded)) - } - /// 转发请求(带故障转移) /// /// # Arguments @@ -224,9 +233,9 @@ impl RequestForwarder { let start = Instant::now(); - // 转发请求(带单 Provider 内重试) + // 转发请求(每个 Provider 只尝试一次,重试由客户端控制) match self - .forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref()) + .forward(provider, endpoint, &body, &headers, adapter.as_ref()) .await { Ok(response) => { @@ -477,6 +486,28 @@ impl RequestForwarder { mapped_body }; + // 过滤私有参数(以 `_` 开头的字段),防止内部信息泄露到上游 + // 默认使用空白名单,过滤所有 _ 前缀字段 + let filtered_body = filter_private_params_with_whitelist(request_body, &[]); + + // ========== 请求体日志(截断显示) ========== + let body_str = serde_json::to_string_pretty(&filtered_body) + .unwrap_or_else(|_| filtered_body.to_string()); + let body_preview = if body_str.len() > 2000 { + format!( + "{}...\n[截断,总长度: {} 字符]", + &body_str[..2000], + body_str.len() + ) + } else { + body_str + }; + log::info!( + "[{}] ====== 最终请求体 ======\n{}", + adapter.name(), + body_preview + ); + log::info!( "[{}] 转发请求: {} -> {}", adapter.name(), @@ -487,28 +518,73 @@ impl RequestForwarder { // 构建请求 let mut request = self.client.post(&url); - // 只透传必要的 Headers(白名单模式) - let allowed_headers = [ - "accept", - "user-agent", - "x-request-id", - "x-stainless-arch", - "x-stainless-lang", - "x-stainless-os", - "x-stainless-package-version", - "x-stainless-runtime", - "x-stainless-runtime-version", - ]; + // ========== 详细 Headers 日志 ========== + log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name()); + for (key, value) in headers { + log::info!( + "[{}] {}: {:?}", + adapter.name(), + key.as_str(), + value.to_str().unwrap_or("") + ); + } + + // 过滤黑名单 Headers,保护隐私并避免冲突 + let mut filtered_headers: Vec = Vec::new(); + let mut passed_headers: Vec<(String, String)> = Vec::new(); for (key, value) in headers { let key_str = key.as_str().to_lowercase(); - if allowed_headers.contains(&key_str.as_str()) { - request = request.header(key, value); + if HEADER_BLACKLIST.contains(&key_str.as_str()) { + filtered_headers.push(key_str); + continue; + } + let value_str = value.to_str().unwrap_or("").to_string(); + passed_headers.push((key.as_str().to_string(), value_str.clone())); + request = request.header(key, value); + } + + if !filtered_headers.is_empty() { + log::info!( + "[{}] ====== 被过滤的 Headers ({}) ======", + adapter.name(), + filtered_headers.len() + ); + for h in &filtered_headers { + log::info!("[{}] - {}", adapter.name(), h); } } - // 确保 Content-Type 是 json - request = request.header("Content-Type", "application/json"); + // 处理 anthropic-beta Header(透传) + // 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记 + if let Some(beta) = headers.get("anthropic-beta") { + if let Ok(beta_str) = beta.to_str() { + request = request.header("anthropic-beta", beta_str); + passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string())); + log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str); + } + } + + // 客户端 IP 透传(默认开启) + if let Some(xff) = headers.get("x-forwarded-for") { + if let Ok(xff_str) = xff.to_str() { + request = request.header("x-forwarded-for", xff_str); + passed_headers.push(("x-forwarded-for".to_string(), xff_str.to_string())); + log::debug!("[{}] 透传 x-forwarded-for: {}", adapter.name(), xff_str); + } + } + if let Some(real_ip) = headers.get("x-real-ip") { + if let Ok(real_ip_str) = real_ip.to_str() { + request = request.header("x-real-ip", real_ip_str); + passed_headers.push(("x-real-ip".to_string(), real_ip_str.to_string())); + log::debug!("[{}] 透传 x-real-ip: {}", adapter.name(), real_ip_str); + } + } + + // 禁用压缩,避免 gzip 流式响应解析错误 + // 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误 + request = request.header("accept-encoding", "identity"); + passed_headers.push(("accept-encoding".to_string(), "identity".to_string())); // 使用适配器添加认证头 if let Some(auth) = adapter.extract_auth(provider) { @@ -519,6 +595,15 @@ impl RequestForwarder { auth.masked_key() ); request = adapter.add_auth_headers(request, &auth); + // 记录认证头(脱敏) + passed_headers.push(( + "authorization".to_string(), + format!("Bearer {}...", &auth.api_key[..8.min(auth.api_key.len())]), + )); + passed_headers.push(( + "x-api-key".to_string(), + format!("{}...", &auth.api_key[..8.min(auth.api_key.len())]), + )); } else { log::error!( "[{}] 未找到 API Key!Provider: {}", @@ -527,9 +612,34 @@ impl RequestForwarder { ); } + // anthropic-version 透传:优先使用客户端的版本号 + // 参考 Claude Code Hub:透传客户端值而非固定版本 + if let Some(version) = headers.get("anthropic-version") { + if let Ok(version_str) = version.to_str() { + // 覆盖适配器设置的默认版本 + request = request.header("anthropic-version", version_str); + passed_headers.push(("anthropic-version".to_string(), version_str.to_string())); + log::info!( + "[{}] 透传 anthropic-version: {}", + adapter.name(), + version_str + ); + } + } + + // ========== 最终发送的 Headers 日志 ========== + log::info!( + "[{}] ====== 最终发送的 Headers ({}) ======", + adapter.name(), + passed_headers.len() + ); + for (k, v) in &passed_headers { + log::info!("[{}] {}: {}", adapter.name(), k, v); + } + // 发送请求 log::info!("[{}] 发送请求到: {}", adapter.name(), url); - let response = request.json(&request_body).send().await.map_err(|e| { + let response = request.json(&filtered_body).send().await.map_err(|e| { log::error!("[{}] 请求失败: {}", adapter.name(), e); if e.is_timeout() { ProxyError::Timeout(format!("请求超时: {e}")) @@ -563,25 +673,6 @@ impl RequestForwarder { } } - /// 分类ProxyError - /// - /// 决定哪些错误应该触发故障转移到下一个 Provider - /// - /// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。 - /// 只有明确是客户端中断的情况才不重试。 - fn should_retry_same_provider(&self, error: &ProxyError) -> bool { - match error { - // 网络类错误:短暂抖动时同一 Provider 内重试有意义 - ProxyError::Timeout(_) => true, - ProxyError::ForwardFailed(_) => true, - // 上游 HTTP 错误:只对“可能瞬态”的状态码做同 Provider 重试(其余交给 failover) - ProxyError::UpstreamError { status, .. } => { - *status == 408 || *status == 429 || *status >= 500 - } - _ => false, - } - } - fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { match error { // 网络和上游错误:都应该尝试下一个供应商 @@ -597,7 +688,6 @@ impl RequestForwarder { ProxyError::TransformError(_) => ErrorCategory::Retryable, ProxyError::AuthError(_) => ErrorCategory::Retryable, ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable, - ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable, // 无可用供应商:所有供应商都试过了,无法重试 ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, // 其他错误(数据库/内部错误等):不是换供应商能解决的问题 diff --git a/src-tauri/src/proxy/handler_config.rs b/src-tauri/src/proxy/handler_config.rs index ca2df0787..fcbeb1fa2 100644 --- a/src-tauri/src/proxy/handler_config.rs +++ b/src-tauri/src/proxy/handler_config.rs @@ -58,10 +58,10 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String { .to_string() } -/// Codex Responses API 流式响应模型提取(优先使用 usage.model) -fn codex_model_extractor(events: &[Value], request_model: &str) -> String { +/// Codex 智能流式响应模型提取(自动检测格式) +fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String { // 首先尝试从解析的 usage 中获取模型 - if let Some(usage) = TokenUsage::from_codex_stream_events(events) { + if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) { if let Some(model) = usage.model { return model; } @@ -76,6 +76,10 @@ fn codex_model_extractor(events: &[Value], request_model: &str) -> String { None } }) + .or_else(|| { + // 再回退:从 OpenAI 格式事件中提取 + events.iter().find_map(|e| e.get("model")?.as_str()) + }) .unwrap_or(request_model) .to_string() } @@ -111,11 +115,11 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { app_type_str: "codex", }; -/// Codex Responses API 解析配置(用于 /v1/responses) +/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式) pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { - stream_parser: TokenUsage::from_codex_stream_events, - response_parser: TokenUsage::from_codex_response, - model_extractor: codex_model_extractor, + stream_parser: TokenUsage::from_codex_stream_events_auto, + response_parser: TokenUsage::from_codex_response_auto, + model_extractor: codex_auto_model_extractor, app_type_str: "codex", }; diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 86bce211d..773515f3d 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -5,8 +5,10 @@ use crate::app_config::AppType; use crate::provider::Provider; use crate::proxy::{ - forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError, + extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, + ProxyError, }; +use axum::http::HeaderMap; use std::time::Instant; /// 流式超时配置 @@ -26,6 +28,7 @@ pub struct StreamingTimeoutConfig { /// - 选中的 Provider 列表(用于故障转移) /// - 请求模型名称 /// - 日志标签 +/// - Session ID(用于日志关联) pub struct RequestContext { /// 请求开始时间 pub start_time: Instant, @@ -35,7 +38,7 @@ pub struct RequestContext { pub provider: Provider, /// 完整的 Provider 列表(用于故障转移) providers: Vec, - /// 请求开始时的“当前供应商”(用于判断是否需要同步 UI/托盘) + /// 请求开始时的"当前供应商"(用于判断是否需要同步 UI/托盘) /// /// 这里使用本地 settings 的设备级 current provider。 /// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。 @@ -49,6 +52,8 @@ pub struct RequestContext { /// 应用类型(预留,目前通过 app_type_str 使用) #[allow(dead_code)] pub app_type: AppType, + /// Session ID(从客户端请求提取或新生成) + pub session_id: String, } impl RequestContext { @@ -57,6 +62,7 @@ impl RequestContext { /// # Arguments /// * `state` - 代理服务器状态 /// * `body` - 请求体 JSON + /// * `headers` - 请求头(用于提取 Session ID) /// * `app_type` - 应用类型 /// * `tag` - 日志标签 /// * `app_type_str` - 应用类型字符串 @@ -66,6 +72,7 @@ impl RequestContext { pub async fn new( state: &ProxyState, body: &serde_json::Value, + headers: &HeaderMap, app_type: AppType, tag: &'static str, app_type_str: &'static str, @@ -89,13 +96,31 @@ impl RequestContext { .unwrap_or("unknown") .to_string(); + // 提取 Session ID + let session_result = extract_session_id(headers, body, app_type_str); + let session_id = session_result.session_id.clone(); + + log::debug!( + "[{}] Session ID: {} (from {:?}, client_provided: {})", + tag, + session_id, + session_result.source, + session_result.client_provided + ); + // 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持) // 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额 let providers = state .provider_router .select_providers(app_type_str) .await - .map_err(|e| ProxyError::DatabaseError(e.to_string()))?; + .map_err(|e| match e { + crate::error::AppError::AllProvidersCircuitOpen => { + ProxyError::AllProvidersCircuitOpen + } + crate::error::AppError::NoProvidersConfigured => ProxyError::NoProvidersConfigured, + _ => ProxyError::DatabaseError(e.to_string()), + })?; let provider = providers .first() @@ -103,11 +128,12 @@ impl RequestContext { .ok_or(ProxyError::NoAvailableProvider)?; log::info!( - "[{}] Provider: {}, model: {}, failover chain: {} providers", + "[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}", tag, provider.name, request_model, - providers.len() + providers.len(), + session_id ); Ok(Self { @@ -120,6 +146,7 @@ impl RequestContext { tag, app_type_str, app_type, + session_id, }) } @@ -148,18 +175,38 @@ impl RequestContext { /// 创建 RequestForwarder /// /// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持 + /// + /// 配置生效规则: + /// - 故障转移开启:超时配置正常生效(0 表示禁用超时) + /// - 故障转移关闭:超时配置不生效(全部传入 0) pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { + let (non_streaming_timeout, first_byte_timeout, idle_timeout) = + if self.app_config.auto_failover_enabled { + // 故障转移开启:使用配置的值(0 = 禁用超时) + ( + self.app_config.non_streaming_timeout as u64, + self.app_config.streaming_first_byte_timeout as u64, + self.app_config.streaming_idle_timeout as u64, + ) + } else { + // 故障转移关闭:不启用超时配置 + log::info!( + "[{}] Failover disabled, timeout configs are bypassed", + self.tag + ); + (0, 0, 0) + }; + RequestForwarder::new( state.provider_router.clone(), - self.app_config.non_streaming_timeout as u64, - self.app_config.max_retries as u8, + non_streaming_timeout, 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, + first_byte_timeout, + idle_timeout, ) } @@ -177,11 +224,24 @@ impl RequestContext { } /// 获取流式超时配置 + /// + /// 配置生效规则: + /// - 故障转移开启:返回配置的值(0 表示禁用超时检查) + /// - 故障转移关闭:返回 0(禁用超时检查) #[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, + if self.app_config.auto_failover_enabled { + // 故障转移开启:使用配置的值(0 = 禁用超时) + StreamingTimeoutConfig { + first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64, + idle_timeout: self.app_config.streaming_idle_timeout as u64, + } + } else { + // 故障转移关闭:禁用流式超时检查 + StreamingTimeoutConfig { + first_byte_timeout: 0, + idle_timeout: 0, + } } } } diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index 20512dc70..0e29aff74 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -61,7 +61,8 @@ pub async fn handle_messages( headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { - let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?; + let mut ctx = + RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?; let is_stream = body .get("stream") @@ -305,7 +306,8 @@ pub async fn handle_chat_completions( ) -> Result { log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); - let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; + let mut ctx = + RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; let is_stream = body .get("stream") @@ -353,7 +355,8 @@ pub async fn handle_responses( headers: axum::http::HeaderMap, Json(body): Json, ) -> Result { - let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; + let mut ctx = + RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?; let is_stream = body .get("stream") @@ -401,7 +404,7 @@ pub async fn handle_gemini( Json(body): Json, ) -> Result { // Gemini 的模型名称在 URI 中 - let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini") + let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini") .await? .with_model_from_uri(&uri); @@ -465,7 +468,7 @@ fn log_forward_error( let request_id = uuid::Uuid::new_v4().to_string(); if let Err(e) = logger.log_error_with_context( - request_id.clone(), + request_id, ctx.provider.id.clone(), ctx.app_type_str.to_string(), ctx.request_model.clone(), @@ -473,7 +476,7 @@ fn log_forward_error( error_message, ctx.latency_ms(), is_streaming, - Some(request_id), + Some(ctx.session_id.clone()), None, ) { log::warn!("记录失败请求日志失败: {e}"); diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 69063dc08..5b0ac0216 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -2,6 +2,7 @@ //! //! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 +pub mod body_filter; pub mod circuit_breaker; pub mod error; pub mod error_mapper; @@ -33,7 +34,9 @@ pub use provider_router::ProviderRouter; #[allow(unused_imports)] pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler}; #[allow(unused_imports)] -pub use session::{ClientFormat, ProxySession}; +pub use session::{ + extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource, +}; #[allow(unused_imports)] pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus}; diff --git a/src-tauri/src/proxy/provider_router.rs b/src-tauri/src/proxy/provider_router.rs index 1cbae1a65..12373587e 100644 --- a/src-tauri/src/proxy/provider_router.rs +++ b/src-tauri/src/proxy/provider_router.rs @@ -34,6 +34,8 @@ impl ProviderRouter { /// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置 pub async fn select_providers(&self, app_type: &str) -> Result, AppError> { let mut result = Vec::new(); + let mut total_providers = 0usize; + let mut circuit_open_count = 0usize; // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { @@ -53,18 +55,26 @@ impl ProviderRouter { if auto_failover_enabled { // 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序 let failover_providers = self.db.get_failover_providers(app_type)?; + total_providers = failover_providers.len(); + log::debug!("[{app_type}] Found {total_providers} failover queue provider(s)"); log::info!( - "[{}] Failover enabled, using queue order ({} items)", - app_type, - failover_providers.len() + "[{app_type}] Failover enabled, using queue order ({total_providers} items)" ); for provider in failover_providers { // 检查熔断器状态 let circuit_key = format!("{}:{}", app_type, provider.id); let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; + let state = breaker.get_state().await; if breaker.is_available().await { + log::debug!( + "[{}] Queue provider available: {} ({}) (state: {:?})", + app_type, + provider.name, + provider.id, + state + ); log::info!( "[{}] Queue provider available: {} ({}) at sort_index {:?}", app_type, @@ -74,10 +84,12 @@ impl ProviderRouter { ); result.push(provider); } else { + circuit_open_count += 1; log::debug!( - "[{}] Queue provider {} circuit breaker open, skipping", + "[{}] Queue provider {} circuit breaker open (state: {:?}), skipping", app_type, - provider.name + provider.name, + state ); } } @@ -94,15 +106,27 @@ impl ProviderRouter { current.name, current.id ); + total_providers = 1; result.push(current); + } else { + log::debug!( + "[{app_type}] Current provider id {current_id} not found in database" + ); } + } else { + log::debug!("[{app_type}] No current provider configured"); } } if result.is_empty() { - return Err(AppError::Config(format!( - "No available provider for {app_type} (all circuit breakers open or no providers configured)" - ))); + // 区分两种情况:全部熔断 vs 未配置供应商 + if total_providers > 0 && circuit_open_count == total_providers { + log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道"); + return Err(AppError::AllProvidersCircuitOpen); + } else { + log::warn!("[{app_type}] 未配置供应商或故障转移队列为空"); + return Err(AppError::NoProvidersConfigured); + } } log::info!( diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 714302b3c..0df50e039 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -112,6 +112,19 @@ pub async fn handle_non_streaming( spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false); } else { + let model = json_value + .get("model") + .and_then(|m| m.as_str()) + .unwrap_or(&ctx.request_model) + .to_string(); + spawn_log_usage( + state, + ctx, + TokenUsage::default(), + &model, + status.as_u16(), + false, + ); log::debug!( "[{}] 未能解析 usage 信息,跳过记录", parser_config.app_type_str @@ -123,6 +136,14 @@ pub async fn handle_non_streaming( ctx.tag, body_bytes.len() ); + spawn_log_usage( + state, + ctx, + TokenUsage::default(), + &ctx.request_model, + status.as_u16(), + false, + ); } log::info!("[{}] ====== 请求结束 ======", ctx.tag); @@ -243,6 +264,7 @@ fn create_usage_collector( let start_time = ctx.start_time; let stream_parser = parser_config.stream_parser; let model_extractor = parser_config.model_extractor; + let session_id = ctx.session_id.clone(); SseUsageCollector::new(start_time, move |events, first_token_ms| { if let Some(usage) = stream_parser(&events) { @@ -251,6 +273,7 @@ fn create_usage_collector( let state = state.clone(); let provider_id = provider_id.clone(); + let session_id = session_id.clone(); tokio::spawn(async move { log_usage_internal( @@ -263,10 +286,32 @@ fn create_usage_collector( first_token_ms, true, // is_streaming status_code, + Some(session_id), ) .await; }); } else { + let model = model_extractor(&events, &request_model); + let latency_ms = start_time.elapsed().as_millis() as u64; + let state = state.clone(); + let provider_id = provider_id.clone(); + let session_id = session_id.clone(); + + tokio::spawn(async move { + log_usage_internal( + &state, + &provider_id, + app_type_str, + &model, + TokenUsage::default(), + latency_ms, + first_token_ms, + true, // is_streaming + status_code, + Some(session_id), + ) + .await; + }); log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录"); } }) @@ -286,6 +331,7 @@ fn spawn_log_usage( let app_type_str = ctx.app_type_str.to_string(); let model = model.to_string(); let latency_ms = ctx.latency_ms(); + let session_id = ctx.session_id.clone(); tokio::spawn(async move { log_usage_internal( @@ -298,6 +344,7 @@ fn spawn_log_usage( None, is_streaming, status_code, + Some(session_id), ) .await; }); @@ -315,6 +362,7 @@ async fn log_usage_internal( first_token_ms: Option, is_streaming: bool, status_code: u16, + session_id: Option, ) { use super::usage::logger::UsageLogger; @@ -338,6 +386,15 @@ async fn log_usage_internal( let request_id = uuid::Uuid::new_v4().to_string(); + log::debug!( + "[{app_type}] 记录请求日志: id={request_id}, provider={provider_id}, model={model}, streaming={is_streaming}, status={status_code}, latency_ms={latency_ms}, first_token_ms={first_token_ms:?}, session={}, input={}, output={}, cache_read={}, cache_creation={}", + session_id.as_deref().unwrap_or("none"), + usage.input_tokens, + usage.output_tokens, + usage.cache_read_tokens, + usage.cache_creation_tokens + ); + if let Err(e) = logger.log_with_calculation( request_id, provider_id.to_string(), @@ -348,7 +405,7 @@ async fn log_usage_internal( latency_ms, first_token_ms, status_code, - None, + session_id, None, // provider_type is_streaming, ) { diff --git a/src-tauri/src/proxy/session.rs b/src-tauri/src/proxy/session.rs index 3e1d14e56..cf3f964fe 100644 --- a/src-tauri/src/proxy/session.rs +++ b/src-tauri/src/proxy/session.rs @@ -1,7 +1,15 @@ //! Proxy Session - 请求会话管理 //! //! 为每个代理请求创建会话上下文,在整个请求生命周期中跟踪状态和元数据。 +//! +//! ## Session ID 提取 +//! +//! 支持从客户端请求中提取 Session ID,用于关联同一对话的多个请求: +//! - Claude: 从 `metadata.user_id` (格式: `user_xxx_session_yyy`) 或 `metadata.session_id` 提取 +//! - Codex: 从 `previous_response_id` 或 headers 中的 `session_id` 提取 +//! - 其他: 生成新的 UUID +use axum::http::HeaderMap; use std::time::Instant; use uuid::Uuid; @@ -176,6 +184,179 @@ impl ProxySession { } } +// ============================================================================ +// Session ID 提取器 +// ============================================================================ + +/// Session ID 来源 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionIdSource { + /// 从 metadata.user_id 提取 (Claude) + MetadataUserId, + /// 从 metadata.session_id 提取 + MetadataSessionId, + /// 从 headers 提取 (Codex) + Header, + /// 从 previous_response_id 提取 (Codex) + PreviousResponseId, + /// 新生成 + Generated, +} + +/// Session ID 提取结果 +#[derive(Debug, Clone)] +pub struct SessionIdResult { + /// 提取或生成的 Session ID + pub session_id: String, + /// Session ID 来源 + pub source: SessionIdSource, + /// 是否为客户端提供的 ID(非新生成) + pub client_provided: bool, +} + +/// 从请求中提取或生成 Session ID +/// +/// 轻量化实现,仅提取 session_id 用于日志记录,不做复杂的 Session 管理。 +/// +/// ## 提取优先级 +/// +/// ### Claude 请求 +/// 1. `metadata.user_id` (格式: `user_xxx_session_yyy`) → 提取 `yyy` 部分 +/// 2. `metadata.session_id` → 直接使用 +/// 3. 生成新 UUID +/// +/// ### Codex 请求 +/// 1. Headers: `session_id` 或 `x-session-id` +/// 2. `metadata.session_id` +/// 3. `previous_response_id` (对话延续) +/// 4. 生成新 UUID +/// +/// ## 示例 +/// +/// ```ignore +/// let result = extract_session_id(&headers, &body, "claude"); +/// println!("Session ID: {} (from {:?})", result.session_id, result.source); +/// ``` +pub fn extract_session_id( + headers: &HeaderMap, + body: &serde_json::Value, + client_format: &str, +) -> SessionIdResult { + // Codex 请求特殊处理 + if client_format == "codex" || client_format == "openai" { + if let Some(result) = extract_codex_session(headers, body) { + return result; + } + } + + // Claude 请求:从 metadata 提取 + if let Some(result) = extract_from_metadata(body) { + return result; + } + + // 兜底:生成新 Session ID + generate_new_session_id() +} + +/// 提取 Codex Session ID +fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option { + // 1. 从 headers 提取 + for header_name in &["session_id", "x-session-id"] { + if let Some(value) = headers.get(*header_name) { + if let Ok(session_id) = value.to_str() { + // Codex Session ID 通常较长(UUID 格式) + if session_id.len() > 20 { + return Some(SessionIdResult { + session_id: format!("codex_{session_id}"), + source: SessionIdSource::Header, + client_provided: true, + }); + } + } + } + } + + // 2. 从 body.metadata.session_id 提取 + if let Some(session_id) = body + .get("metadata") + .and_then(|m| m.get("session_id")) + .and_then(|v| v.as_str()) + { + if session_id.len() > 10 { + return Some(SessionIdResult { + session_id: format!("codex_{session_id}"), + source: SessionIdSource::MetadataSessionId, + client_provided: true, + }); + } + } + + // 3. 从 previous_response_id 提取(对话延续) + if let Some(prev_id) = body.get("previous_response_id").and_then(|v| v.as_str()) { + if prev_id.len() > 10 { + return Some(SessionIdResult { + session_id: format!("codex_{prev_id}"), + source: SessionIdSource::PreviousResponseId, + client_provided: true, + }); + } + } + + None +} + +/// 从 metadata 提取 Session ID (Claude) +fn extract_from_metadata(body: &serde_json::Value) -> Option { + let metadata = body.get("metadata")?; + + // 1. 从 metadata.user_id 提取(格式: user_xxx_session_yyy) + if let Some(user_id) = metadata.get("user_id").and_then(|v| v.as_str()) { + if let Some(session_id) = parse_session_from_user_id(user_id) { + return Some(SessionIdResult { + session_id, + source: SessionIdSource::MetadataUserId, + client_provided: true, + }); + } + } + + // 2. 直接从 metadata.session_id 提取 + if let Some(session_id) = metadata.get("session_id").and_then(|v| v.as_str()) { + if !session_id.is_empty() { + return Some(SessionIdResult { + session_id: session_id.to_string(), + source: SessionIdSource::MetadataSessionId, + client_provided: true, + }); + } + } + + None +} + +/// 从 user_id 解析 session_id +/// +/// 格式: `user_identifier_session_actual_session_id` +fn parse_session_from_user_id(user_id: &str) -> Option { + // 查找 "_session_" 分隔符 + if let Some(pos) = user_id.find("_session_") { + let session_id = &user_id[pos + 9..]; // "_session_" 长度为 9 + if !session_id.is_empty() { + return Some(session_id.to_string()); + } + } + None +} + +/// 生成新的 Session ID +fn generate_new_session_id() -> SessionIdResult { + SessionIdResult { + session_id: Uuid::new_v4().to_string(), + source: SessionIdSource::Generated, + client_provided: false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -295,4 +476,92 @@ mod tests { assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli"); assert_eq!(ClientFormat::Unknown.as_str(), "unknown"); } + + // ========== Session ID 提取测试 ========== + + #[test] + fn test_extract_session_from_claude_metadata_user_id() { + let headers = HeaderMap::new(); + let body = json!({ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": { + "user_id": "user_john_doe_session_abc123def456" + } + }); + + let result = extract_session_id(&headers, &body, "claude"); + + assert_eq!(result.session_id, "abc123def456"); + assert_eq!(result.source, SessionIdSource::MetadataUserId); + assert!(result.client_provided); + } + + #[test] + fn test_extract_session_from_claude_metadata_session_id() { + let headers = HeaderMap::new(); + let body = json!({ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Hello"}], + "metadata": { + "session_id": "my-session-123" + } + }); + + let result = extract_session_id(&headers, &body, "claude"); + + assert_eq!(result.session_id, "my-session-123"); + assert_eq!(result.source, SessionIdSource::MetadataSessionId); + assert!(result.client_provided); + } + + #[test] + fn test_extract_session_from_codex_previous_response_id() { + let headers = HeaderMap::new(); + let body = json!({ + "input": "Write a function", + "previous_response_id": "resp_abc123def456789" + }); + + let result = extract_session_id(&headers, &body, "codex"); + + assert_eq!(result.session_id, "codex_resp_abc123def456789"); + assert_eq!(result.source, SessionIdSource::PreviousResponseId); + assert!(result.client_provided); + } + + #[test] + fn test_extract_session_generates_new_when_not_found() { + let headers = HeaderMap::new(); + let body = json!({ + "model": "claude-3-5-sonnet", + "messages": [{"role": "user", "content": "Hello"}] + }); + + let result = extract_session_id(&headers, &body, "claude"); + + assert!(!result.session_id.is_empty()); + assert_eq!(result.source, SessionIdSource::Generated); + assert!(!result.client_provided); + } + + #[test] + fn test_parse_session_from_user_id() { + assert_eq!( + parse_session_from_user_id("user_john_session_abc123"), + Some("abc123".to_string()) + ); + assert_eq!( + parse_session_from_user_id("my_app_session_xyz789"), + Some("xyz789".to_string()) + ); + // 注意: "_session_" 是分隔符,所以下面的字符串会匹配 + assert_eq!( + parse_session_from_user_id("no_session_marker"), + Some("marker".to_string()) + ); + // 没有 "_session_" 分隔符的情况 + assert_eq!(parse_session_from_user_id("user_john_abc123"), None); + assert_eq!(parse_session_from_user_id("_session_"), None); + } } diff --git a/src-tauri/src/proxy/usage/calculator.rs b/src-tauri/src/proxy/usage/calculator.rs index 7295e13ae..80fd2c673 100644 --- a/src-tauri/src/proxy/usage/calculator.rs +++ b/src-tauri/src/proxy/usage/calculator.rs @@ -35,6 +35,11 @@ impl CostCalculator { /// - `usage`: Token 使用量 /// - `pricing`: 模型定价 /// - `cost_multiplier`: 成本倍数 (provider 自定义) + /// + /// # 计算逻辑 + /// - input_cost: (input_tokens - cache_read_tokens) × 输入价格 + /// - cache_read_cost: cache_read_tokens × 缓存读取价格 + /// - 这样避免缓存部分被重复计费 pub fn calculate( usage: &TokenUsage, pricing: &ModelPricing, @@ -42,7 +47,10 @@ impl CostCalculator { ) -> CostBreakdown { let million = Decimal::from(1_000_000); - let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million + // 计算实际需要按输入价格计费的 token 数(减去缓存命中部分) + let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens); + + let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million / million * cost_multiplier; let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million @@ -113,8 +121,8 @@ mod tests { let cost = CostCalculator::calculate(&usage, &pricing, multiplier); - // input: 1000 * 3.0 / 1M = 0.003 - assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap()); + // input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分) + assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap()); // output: 500 * 15.0 / 1M = 0.0075 assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap()); // cache_read: 200 * 0.3 / 1M = 0.00006 @@ -124,8 +132,8 @@ mod tests { cost.cache_creation_cost, Decimal::from_str("0.000375").unwrap() ); - // total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935 - assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap()); + // total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335 + assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap()); } #[test] diff --git a/src-tauri/src/proxy/usage/parser.rs b/src-tauri/src/proxy/usage/parser.rs index 33c1aaf16..0a33d0187 100644 --- a/src-tauri/src/proxy/usage/parser.rs +++ b/src-tauri/src/proxy/usage/parser.rs @@ -163,13 +163,21 @@ impl TokenUsage { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let cached_tokens = usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_u64()) + .or_else(|| { + usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_u64()) + }) + .unwrap_or(0) as u32; + Some(Self { input_tokens: input_tokens? as u32, output_tokens: output_tokens? as u32, - cache_read_tokens: usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_u64()) - .unwrap_or(0) as u32, + cache_read_tokens: cached_tokens, cache_creation_tokens: usage .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) @@ -188,16 +196,27 @@ impl TokenUsage { let input_tokens = usage.get("input_tokens")?.as_u64()? as u32; let output_tokens = usage.get("output_tokens")?.as_u64()? as u32; - // 获取 cached_tokens (可能在 input_tokens_details 中) + // 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中) let cached_tokens = usage - .get("input_tokens_details") - .and_then(|d| d.get("cached_tokens")) + .get("cache_read_input_tokens") .and_then(|v| v.as_u64()) + .or_else(|| { + usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_u64()) + }) .unwrap_or(0) as u32; // 调整 input_tokens: 减去 cached_tokens let adjusted_input = input_tokens.saturating_sub(cached_tokens); + // 提取响应中的模型名称 + let model = body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + Some(Self { input_tokens: adjusted_input, output_tokens, @@ -206,7 +225,7 @@ impl TokenUsage { .get("cache_creation_input_tokens") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32, - model: None, + model, }) } @@ -220,7 +239,7 @@ impl TokenUsage { if event_type == "response.completed" { if let Some(response) = event.get("response") { log::debug!("[Codex] 找到 response.completed 事件,解析 usage"); - return Self::from_codex_response(response); + return Self::from_codex_response_adjusted(response); } } } @@ -229,6 +248,51 @@ impl TokenUsage { None } + /// 智能 Codex 响应解析 - 自动检测 OpenAI 或 Codex 格式 + /// + /// Codex 支持两种 API 格式: + /// - `/v1/responses`: 使用 input_tokens/output_tokens + /// - `/v1/chat/completions`: 使用 prompt_tokens/completion_tokens (OpenAI 格式) + /// + /// 注意:记录原始 input_tokens,费用计算时再减去 cached_tokens + pub fn from_codex_response_auto(body: &Value) -> Option { + let usage = body.get("usage")?; + + // 检测格式:OpenAI 使用 prompt_tokens,Codex 使用 input_tokens + if usage.get("prompt_tokens").is_some() { + log::debug!("[Codex] 检测到 OpenAI 格式 (prompt_tokens)"); + Self::from_openai_response(body) + } else if usage.get("input_tokens").is_some() { + log::debug!("[Codex] 检测到 Codex 格式 (input_tokens)"); + // 使用非调整版本,记录原始 input_tokens + Self::from_codex_response(body) + } else { + log::debug!("[Codex] 无法识别响应格式,usage: {usage:?}"); + None + } + } + + /// 智能 Codex 流式响应解析 - 自动检测 OpenAI 或 Codex 格式 + pub fn from_codex_stream_events_auto(events: &[Value]) -> Option { + log::debug!("[Codex] 智能解析流式事件,共 {} 个事件", events.len()); + + // 先尝试 Codex Responses API 格式 (response.completed 事件) + for event in events { + if let Some(event_type) = event.get("type").and_then(|v| v.as_str()) { + if event_type == "response.completed" { + if let Some(response) = event.get("response") { + log::debug!("[Codex] 找到 response.completed 事件"); + return Self::from_codex_response_auto(response); + } + } + } + } + + // 回退到 OpenAI Chat Completions 格式 (最后一个 chunk 包含 usage) + log::debug!("[Codex] 尝试 OpenAI 流式格式"); + Self::from_openai_stream_events(events) + } + /// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens) pub fn from_openai_response(body: &Value) -> Option { let usage = body.get("usage")?; @@ -284,9 +348,16 @@ impl TokenUsage { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let prompt_tokens = usage.get("promptTokenCount")?.as_u64()? as u32; + let total_tokens = usage.get("totalTokenCount")?.as_u64()? as u32; + + // 输出 tokens = 总 tokens - 输入 tokens + // 这包含了 candidatesTokenCount + thoughtsTokenCount + let output_tokens = total_tokens.saturating_sub(prompt_tokens); + Some(Self { - input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32, - output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32, + input_tokens: prompt_tokens, + output_tokens, cache_read_tokens: usage .get("cachedContentTokenCount") .and_then(|v| v.as_u64()) @@ -300,20 +371,25 @@ impl TokenUsage { #[allow(dead_code)] pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option { let mut total_input = 0u32; - let mut total_output = 0u32; + let mut total_tokens = 0u32; let mut total_cache_read = 0u32; let mut model: Option = None; for chunk in chunks { if let Some(usage) = chunk.get("usageMetadata") { + // 输入 tokens (通常在所有 chunk 中保持不变) total_input = usage .get("promptTokenCount") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; - total_output += usage - .get("candidatesTokenCount") + + // 总 tokens (包含输入 + 输出 + 思考) + total_tokens = usage + .get("totalTokenCount") .and_then(|v| v.as_u64()) .unwrap_or(0) as u32; + + // 缓存读取 tokens total_cache_read = usage .get("cachedContentTokenCount") .and_then(|v| v.as_u64()) @@ -328,6 +404,9 @@ impl TokenUsage { } } + // 输出 tokens = 总 tokens - 输入 tokens + let total_output = total_tokens.saturating_sub(total_input); + if total_input > 0 || total_output > 0 { Some(Self { input_tokens: total_input, @@ -466,15 +545,18 @@ mod tests { let response = json!({ "modelVersion": "gemini-3-pro-high", "usageMetadata": { - "promptTokenCount": 100, + "promptTokenCount": 8383, "candidatesTokenCount": 50, + "thoughtsTokenCount": 114, + "totalTokenCount": 8547, "cachedContentTokenCount": 20 } }); let usage = TokenUsage::from_gemini_response(&response).unwrap(); - assert_eq!(usage.input_tokens, 100); - assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.input_tokens, 8383); + // output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164 + assert_eq!(usage.output_tokens, 164); assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_creation_tokens, 0); assert_eq!(usage.model, Some("gemini-3-pro-high".to_string())); @@ -486,19 +568,78 @@ mod tests { let response = json!({ "usageMetadata": { "promptTokenCount": 100, - "candidatesTokenCount": 50, + "totalTokenCount": 150, "cachedContentTokenCount": 20 } }); let usage = TokenUsage::from_gemini_response(&response).unwrap(); assert_eq!(usage.input_tokens, 100); + // output_tokens = totalTokenCount - promptTokenCount = 150 - 100 = 50 assert_eq!(usage.output_tokens, 50); assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_creation_tokens, 0); assert_eq!(usage.model, None); } + #[test] + fn test_gemini_response_with_thoughts() { + // 测试包含 thoughtsTokenCount 的实际响应 + // 这是用户报告的真实场景 + let response = json!({ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "", + "thoughtSignature": "EvcECvQE..." + } + ], + "role": "model" + }, + "finishReason": "STOP" + } + ], + "modelVersion": "gemini-3-pro-high", + "responseId": "yupTafqLDu-PjMcPhrOx4QQ", + "usageMetadata": { + "candidatesTokenCount": 50, + "promptTokenCount": 8383, + "thoughtsTokenCount": 114, + "totalTokenCount": 8547 + } + }); + + let usage = TokenUsage::from_gemini_response(&response).unwrap(); + assert_eq!(usage.input_tokens, 8383); + // output_tokens = totalTokenCount - promptTokenCount + // = 8547 - 8383 = 164 (包含 candidatesTokenCount 50 + thoughtsTokenCount 114) + assert_eq!(usage.output_tokens, 164); + assert_eq!(usage.cache_read_tokens, 0); + assert_eq!(usage.cache_creation_tokens, 0); + assert_eq!(usage.model, Some("gemini-3-pro-high".to_string())); + } + + #[test] + fn test_codex_response_parsing_cached_tokens_in_details() { + let response = json!({ + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": { + "cached_tokens": 300 + } + } + }); + + let usage = TokenUsage::from_codex_response(&response).unwrap(); + // 非调整模式:input_tokens 保持原值,但应记录缓存命中 + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 300); + } + #[test] fn test_codex_response_adjusted() { let response = json!({ @@ -534,6 +675,22 @@ mod tests { assert_eq!(usage.cache_read_tokens, 0); } + #[test] + fn test_codex_response_adjusted_cache_read_input_tokens() { + let response = json!({ + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "cache_read_input_tokens": 200 + } + }); + + let usage = TokenUsage::from_codex_response_adjusted(&response).unwrap(); + assert_eq!(usage.input_tokens, 800); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 200); + } + #[test] fn test_codex_response_adjusted_saturating_sub() { // 测试 cached_tokens > input_tokens 的边界情况 @@ -615,4 +772,110 @@ mod tests { assert_eq!(usage.cache_read_tokens, 50); assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); } + + // ============================================================================ + // 智能 Codex 解析测试 + // ============================================================================ + + #[test] + fn test_codex_response_auto_openai_format() { + // OpenAI 格式 (prompt_tokens/completion_tokens) + let response = json!({ + "model": "gpt-4o", + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 500, + "prompt_tokens_details": { + "cached_tokens": 200 + } + } + }); + + let usage = TokenUsage::from_codex_response_auto(&response).unwrap(); + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 200); + assert_eq!(usage.model, Some("gpt-4o".to_string())); + } + + #[test] + fn test_codex_response_auto_codex_format() { + // Codex 格式 (input_tokens/output_tokens) + let response = json!({ + "model": "o3", + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": { + "cached_tokens": 300 + } + } + }); + + let usage = TokenUsage::from_codex_response_auto(&response).unwrap(); + // 记录原始 input_tokens,不调整 + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 300); + assert_eq!(usage.model, Some("o3".to_string())); + } + + #[test] + fn test_codex_stream_events_auto_codex_format() { + // Codex Responses API 流式格式 (response.completed 事件) + let events = vec![ + json!({ + "type": "response.created", + "response": { + "id": "resp_123" + } + }), + json!({ + "type": "response.completed", + "response": { + "model": "o3", + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": { + "cached_tokens": 200 + } + } + } + }), + ]; + + let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap(); + // 记录原始 input_tokens,不调整 + assert_eq!(usage.input_tokens, 1000); + assert_eq!(usage.output_tokens, 500); + assert_eq!(usage.cache_read_tokens, 200); + assert_eq!(usage.model, Some("o3".to_string())); + } + + #[test] + fn test_codex_stream_events_auto_openai_format() { + // OpenAI Chat Completions 流式格式 (最后一个 chunk 包含 usage) + let events = vec![ + json!({ + "id": "chatcmpl-123", + "model": "gpt-4o", + "choices": [{"delta": {"content": "Hello"}}] + }), + json!({ + "id": "chatcmpl-123", + "model": "gpt-4o", + "choices": [{"delta": {}}], + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50 + } + }), + ]; + + let usage = TokenUsage::from_codex_stream_events_auto(&events).unwrap(); + assert_eq!(usage.input_tokens, 100); + assert_eq!(usage.output_tokens, 50); + assert_eq!(usage.model, Some("gpt-4o".to_string())); + } } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index 29f821d57..ea472af31 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -206,6 +206,8 @@ impl McpService { // 调用原有的导入逻辑(从 mcp.rs) let count = crate::mcp::import_from_claude(&mut temp_config)?; + let mut new_count = 0; + // 如果有导入的服务器,保存到数据库 if count > 0 { if let Some(servers) = &temp_config.mcp.servers { @@ -217,6 +219,8 @@ impl McpService { merged.apps.claude = true; merged } else { + // 真正的新服务器 + new_count += 1; server.clone() }; @@ -229,7 +233,7 @@ impl McpService { } } - Ok(count) + Ok(new_count) } /// 从 Codex 导入 MCP(v3.7.0 已更新为统一结构) @@ -240,6 +244,8 @@ impl McpService { // 调用原有的导入逻辑(从 mcp.rs) let count = crate::mcp::import_from_codex(&mut temp_config)?; + let mut new_count = 0; + // 如果有导入的服务器,保存到数据库 if count > 0 { if let Some(servers) = &temp_config.mcp.servers { @@ -251,6 +257,8 @@ impl McpService { merged.apps.codex = true; merged } else { + // 真正的新服务器 + new_count += 1; server.clone() }; @@ -263,7 +271,7 @@ impl McpService { } } - Ok(count) + Ok(new_count) } /// 从 Gemini 导入 MCP(v3.7.0 已更新为统一结构) @@ -274,6 +282,8 @@ impl McpService { // 调用原有的导入逻辑(从 mcp.rs) let count = crate::mcp::import_from_gemini(&mut temp_config)?; + let mut new_count = 0; + // 如果有导入的服务器,保存到数据库 if count > 0 { if let Some(servers) = &temp_config.mcp.servers { @@ -285,6 +295,8 @@ impl McpService { merged.apps.gemini = true; merged } else { + // 真正的新服务器 + new_count += 1; server.clone() }; @@ -297,6 +309,6 @@ impl McpService { } } - Ok(count) + Ok(new_count) } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index c314c70ef..07abe16e4 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -15,7 +15,8 @@ pub use mcp::McpService; pub use prompt::PromptService; pub use provider::{ProviderService, ProviderSortUpdate}; pub use proxy::ProxyService; -pub use skill::{Skill, SkillRepo, SkillService}; +#[allow(unused_imports)] +pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService}; pub use speedtest::{EndpointLatency, SpeedtestService}; #[allow(unused_imports)] pub use usage_stats::{ diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 599574414..91c3ab8e1 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -217,9 +217,12 @@ impl ProviderService { .flatten() .is_some(); let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); + let live_taken_over = state + .proxy_service + .detect_takeover_in_live_config_for_app(&app_type); // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running - let should_hot_switch = is_app_taken_over && is_proxy_running; + let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running; if should_hot_switch { // Proxy takeover mode: hot-switch only, don't write Live config @@ -736,15 +739,15 @@ impl ProviderService { // 删除生成的子供应商 if let Some(p) = provider { if p.apps.claude { - let claude_id = format!("universal-claude-{}", id); + let claude_id = format!("universal-claude-{id}"); let _ = state.db.delete_provider("claude", &claude_id); } if p.apps.codex { - let codex_id = format!("universal-codex-{}", id); + let codex_id = format!("universal-codex-{id}"); let _ = state.db.delete_provider("codex", &codex_id); } if p.apps.gemini { - let gemini_id = format!("universal-gemini-{}", id); + let gemini_id = format!("universal-gemini-{id}"); let _ = state.db.delete_provider("gemini", &gemini_id); } } @@ -757,7 +760,7 @@ impl ProviderService { let provider = state .db .get_universal_provider(id)? - .ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?; + .ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?; // 同步到 Claude if let Some(mut claude_provider) = provider.to_claude_provider() { @@ -770,7 +773,7 @@ impl ProviderService { state.db.save_provider("claude", &claude_provider)?; } else { // 如果禁用了 Claude,删除对应的子供应商 - let claude_id = format!("universal-claude-{}", id); + let claude_id = format!("universal-claude-{id}"); let _ = state.db.delete_provider("claude", &claude_id); } @@ -784,7 +787,7 @@ impl ProviderService { } state.db.save_provider("codex", &codex_provider)?; } else { - let codex_id = format!("universal-codex-{}", id); + let codex_id = format!("universal-codex-{id}"); let _ = state.db.delete_provider("codex", &codex_id); } @@ -798,7 +801,7 @@ impl ProviderService { } state.db.save_provider("gemini", &gemini_provider)?; } else { - let gemini_id = format!("universal-gemini-{}", id); + let gemini_id = format!("universal-gemini-{id}"); let _ = state.db.delete_provider("gemini", &gemini_id); } diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 29b5b3157..4865f7ede 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -193,7 +193,7 @@ impl ProxyService { self.start().await?; } - // 2) 已接管则直接返回(幂等) + // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管 let current_config = self .db .get_proxy_config_for_app(app_type_str) @@ -201,7 +201,22 @@ impl ProxyService { .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; if current_config.enabled { - return Ok(()); + let has_backup = match self.db.get_live_backup(app_type_str).await { + Ok(v) => v.is_some(), + Err(e) => { + log::warn!("读取 {app_type_str} 备份失败(将继续重建接管): {e}"); + false + } + }; + let live_taken_over = self.detect_takeover_in_live_config_for_app(&app); + + if has_backup || live_taken_over { + return Ok(()); + } + + log::warn!( + "{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份" + ); } // 3) 备份 Live 配置(严格:目标 app 不存在则报错) @@ -1063,7 +1078,7 @@ impl ProxyService { } } - fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool { + pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool { match app_type { AppType::Claude => match self.read_claude_live() { Ok(config) => Self::is_claude_live_taken_over(&config), @@ -1257,10 +1272,8 @@ impl ProxyService { /// 检查是否处于 Live 接管模式 pub async fn is_takeover_active(&self) -> Result { - self.db - .is_live_takeover_active() - .await - .map_err(|e| format!("检查接管状态失败: {e}")) + let status = self.get_takeover_status().await?; + Ok(status.claude || status.codex || status.gemini) } /// 从异常退出中恢复(启动时调用) diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 4285652e4..e681efbce 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -1,16 +1,53 @@ +//! Skills 服务层 +//! +//! v3.10.0+ 统一管理架构: +//! - SSOT(单一事实源):`~/.cc-switch/skills/` +//! - 安装时下载到 SSOT,按需同步到各应用目录 +//! - 数据库存储安装记录和启用状态 + use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; use reqwest::Client; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use tokio::time::timeout; -use crate::app_config::AppType; +use crate::app_config::{AppType, InstalledSkill, SkillApps, UnmanagedSkill}; +use crate::config::get_app_config_dir; +use crate::database::Database; use crate::error::format_skill_error; -/// 技能对象 +// ========== 数据结构 ========== + +/// 可发现的技能(来自仓库) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoverableSkill { + /// 唯一标识: "owner/name:directory" + pub key: String, + /// 显示名称 (从 SKILL.md 解析) + pub name: String, + /// 技能描述 + pub description: String, + /// 目录名称 (安装路径的最后一段) + pub directory: String, + /// GitHub README URL + #[serde(rename = "readmeUrl")] + pub readme_url: Option, + /// 仓库所有者 + #[serde(rename = "repoOwner")] + pub repo_owner: String, + /// 仓库名称 + #[serde(rename = "repoName")] + pub repo_name: String, + /// 分支名称 + #[serde(rename = "repoBranch")] + pub repo_branch: String, +} + +/// 技能对象(兼容旧 API,内部使用 DiscoverableSkill) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Skill { /// 唯一标识: "owner/name:directory" 或 "local:directory" @@ -50,7 +87,7 @@ pub struct SkillRepo { pub enabled: bool, } -/// 技能安装状态 +/// 技能安装状态(旧版兼容) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillState { /// 是否已安装 @@ -60,10 +97,10 @@ pub struct SkillState { pub installed_at: DateTime, } -/// 持久化存储结构 +/// 持久化存储结构(仓库配置) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillStore { - /// directory -> 安装状态 + /// directory -> 安装状态(旧版兼容,新版不使用) pub skills: HashMap, /// 仓库列表 pub repos: Vec, @@ -75,15 +112,15 @@ impl Default for SkillStore { skills: HashMap::new(), repos: vec![ SkillRepo { - owner: "ComposioHQ".to_string(), - name: "awesome-claude-skills".to_string(), + owner: "anthropics".to_string(), + name: "skills".to_string(), branch: "main".to_string(), enabled: true, }, SkillRepo { - owner: "anthropics".to_string(), - name: "skills".to_string(), - branch: "main".to_string(), + owner: "ComposioHQ".to_string(), + name: "awesome-claude-skills".to_string(), + branch: "master".to_string(), enabled: true, }, SkillRepo { @@ -104,79 +141,480 @@ pub struct SkillMetadata { pub description: Option, } +// ========== SkillService ========== + pub struct SkillService { http_client: Client, - install_dir: PathBuf, - app_type: AppType, +} + +impl Default for SkillService { + fn default() -> Self { + Self::new() + } } impl SkillService { - pub fn new() -> Result { - Self::new_for_app(AppType::Claude) - } - - pub fn new_for_app(app_type: AppType) -> Result { - let install_dir = Self::get_install_dir_for_app(&app_type)?; - - // 确保目录存在 - fs::create_dir_all(&install_dir)?; - - Ok(Self { + pub fn new() -> Self { + Self { http_client: Client::builder() .user_agent("cc-switch") - // 将单次请求超时时间控制在 10 秒以内,避免无效链接导致长时间卡住 .timeout(std::time::Duration::from_secs(10)) - .build()?, - install_dir, - app_type, - }) + .build() + .expect("Failed to create HTTP client"), + } } - fn get_install_dir_for_app(app_type: &AppType) -> Result { + // ========== 路径管理 ========== + + /// 获取 SSOT 目录(~/.cc-switch/skills/) + pub fn get_ssot_dir() -> Result { + let dir = get_app_config_dir().join("skills"); + fs::create_dir_all(&dir)?; + Ok(dir) + } + + /// 获取应用的 skills 目录 + pub fn get_app_skills_dir(app: &AppType) -> Result { + // 目录覆盖:优先使用用户在 settings.json 中配置的 override 目录 + match app { + AppType::Claude => { + if let Some(custom) = crate::settings::get_claude_override_dir() { + return Ok(custom.join("skills")); + } + } + AppType::Codex => { + if let Some(custom) = crate::settings::get_codex_override_dir() { + return Ok(custom.join("skills")); + } + } + AppType::Gemini => { + if let Some(custom) = crate::settings::get_gemini_override_dir() { + return Ok(custom.join("skills")); + } + } + } + + // 默认路径:回退到用户主目录下的标准位置 let home = dirs::home_dir().context(format_skill_error( "GET_HOME_DIR_FAILED", &[], Some("checkPermission"), ))?; - let dir = match app_type { + Ok(match app { AppType::Claude => home.join(".claude").join("skills"), - AppType::Codex => { - // 检查是否有自定义 Codex 配置目录 - if let Some(custom) = crate::settings::get_codex_override_dir() { - custom.join("skills") - } else { - home.join(".codex").join("skills") - } - } - AppType::Gemini => { - // 为 Gemini 预留,暂时使用默认路径 - home.join(".gemini").join("skills") + AppType::Codex => home.join(".codex").join("skills"), + AppType::Gemini => home.join(".gemini").join("skills"), + }) + } + + // ========== 统一管理方法 ========== + + /// 获取所有已安装的 Skills + pub fn get_all_installed(db: &Arc) -> Result> { + let skills = db.get_all_installed_skills()?; + Ok(skills.into_values().collect()) + } + + /// 安装 Skill + /// + /// 流程: + /// 1. 下载到 SSOT 目录 + /// 2. 保存到数据库 + /// 3. 同步到启用的应用目录 + pub async fn install( + &self, + db: &Arc, + skill: &DiscoverableSkill, + current_app: &AppType, + ) -> Result { + let ssot_dir = Self::get_ssot_dir()?; + + // 使用目录最后一段作为安装名 + let install_name = Path::new(&skill.directory) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| skill.directory.clone()); + + let dest = ssot_dir.join(&install_name); + + // 如果已存在则跳过下载 + if !dest.exists() { + let repo = SkillRepo { + owner: skill.repo_owner.clone(), + name: skill.repo_name.clone(), + branch: skill.repo_branch.clone(), + enabled: true, + }; + + // 下载仓库 + let temp_dir = timeout( + std::time::Duration::from_secs(60), + self.download_repo(&repo), + ) + .await + .map_err(|_| { + anyhow!(format_skill_error( + "DOWNLOAD_TIMEOUT", + &[ + ("owner", &repo.owner), + ("name", &repo.name), + ("timeout", "60") + ], + Some("checkNetwork"), + )) + })??; + + // 复制到 SSOT + let source = temp_dir.join(&skill.directory); + if !source.exists() { + let _ = fs::remove_dir_all(&temp_dir); + return Err(anyhow!(format_skill_error( + "SKILL_DIR_NOT_FOUND", + &[("path", &source.display().to_string())], + Some("checkRepoUrl"), + ))); } + + Self::copy_dir_recursive(&source, &dest)?; + let _ = fs::remove_dir_all(&temp_dir); + } + + // 创建 InstalledSkill 记录 + let installed_skill = InstalledSkill { + id: skill.key.clone(), + name: skill.name.clone(), + description: if skill.description.is_empty() { + None + } else { + Some(skill.description.clone()) + }, + directory: install_name.clone(), + repo_owner: Some(skill.repo_owner.clone()), + repo_name: Some(skill.repo_name.clone()), + repo_branch: Some(skill.repo_branch.clone()), + readme_url: skill.readme_url.clone(), + apps: SkillApps::only(current_app), + installed_at: chrono::Utc::now().timestamp(), }; - Ok(dir) + // 保存到数据库 + db.save_skill(&installed_skill)?; + + // 同步到当前应用目录 + Self::copy_to_app(&install_name, current_app)?; + + log::info!( + "Skill {} 安装成功,已启用 {:?}", + installed_skill.name, + current_app + ); + + Ok(installed_skill) } - pub fn app_type(&self) -> &AppType { - &self.app_type - } -} + /// 卸载 Skill + /// + /// 流程: + /// 1. 从所有应用目录删除 + /// 2. 从 SSOT 删除 + /// 3. 从数据库删除 + pub fn uninstall(db: &Arc, id: &str) -> Result<()> { + // 获取 skill 信息 + let skill = db + .get_installed_skill(id)? + .ok_or_else(|| anyhow!("Skill not found: {}", id))?; -// 核心方法实现 -impl SkillService { - /// 列出所有技能 - pub async fn list_skills(&self, repos: Vec) -> Result> { + // 从所有应用目录删除 + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + let _ = Self::remove_from_app(&skill.directory, &app); + } + + // 从 SSOT 删除 + let ssot_dir = Self::get_ssot_dir()?; + let skill_path = ssot_dir.join(&skill.directory); + if skill_path.exists() { + fs::remove_dir_all(&skill_path)?; + } + + // 从数据库删除 + db.delete_skill(id)?; + + log::info!("Skill {} 卸载成功", skill.name); + + Ok(()) + } + + /// 切换应用启用状态 + /// + /// 启用:复制到应用目录 + /// 禁用:从应用目录删除 + pub fn toggle_app(db: &Arc, id: &str, app: &AppType, enabled: bool) -> Result<()> { + // 获取当前 skill + let mut skill = db + .get_installed_skill(id)? + .ok_or_else(|| anyhow!("Skill not found: {}", id))?; + + // 更新状态 + skill.apps.set_enabled_for(app, enabled); + + // 同步文件 + if enabled { + Self::copy_to_app(&skill.directory, app)?; + } else { + Self::remove_from_app(&skill.directory, app)?; + } + + // 更新数据库 + db.update_skill_apps(id, &skill.apps)?; + + log::info!("Skill {} 的 {:?} 状态已更新为 {}", skill.name, app, enabled); + + Ok(()) + } + + /// 扫描未管理的 Skills + /// + /// 扫描各应用目录,找出未被 CC Switch 管理的 Skills + pub fn scan_unmanaged(db: &Arc) -> Result> { + let managed_skills = db.get_all_installed_skills()?; + let managed_dirs: HashSet = managed_skills + .values() + .map(|s| s.directory.clone()) + .collect(); + + let mut unmanaged: HashMap = HashMap::new(); + + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + let app_dir = match Self::get_app_skills_dir(&app) { + Ok(d) => d, + Err(_) => continue, + }; + + if !app_dir.exists() { + continue; + } + + for entry in fs::read_dir(&app_dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let dir_name = entry.file_name().to_string_lossy().to_string(); + + // 跳过隐藏目录(以 . 开头,如 .system) + if dir_name.starts_with('.') { + continue; + } + + // 跳过已管理的 + if managed_dirs.contains(&dir_name) { + continue; + } + + // 检查是否有 SKILL.md + let skill_md = path.join("SKILL.md"); + let (name, description) = if skill_md.exists() { + match Self::parse_skill_metadata_static(&skill_md) { + Ok(meta) => ( + meta.name.unwrap_or_else(|| dir_name.clone()), + meta.description, + ), + Err(_) => (dir_name.clone(), None), + } + } else { + (dir_name.clone(), None) + }; + + // 添加或更新 + let app_str = match app { + AppType::Claude => "claude", + AppType::Codex => "codex", + AppType::Gemini => "gemini", + }; + + unmanaged + .entry(dir_name.clone()) + .and_modify(|s| s.found_in.push(app_str.to_string())) + .or_insert(UnmanagedSkill { + directory: dir_name, + name, + description, + found_in: vec![app_str.to_string()], + }); + } + } + + Ok(unmanaged.into_values().collect()) + } + + /// 从应用目录导入 Skills + /// + /// 将未管理的 Skills 导入到 CC Switch 统一管理 + pub fn import_from_apps( + db: &Arc, + directories: Vec, + ) -> Result> { + let ssot_dir = Self::get_ssot_dir()?; + let mut imported = Vec::new(); + + for dir_name in directories { + // 找到源目录(从任一应用目录复制) + let mut source_path: Option = None; + let mut found_in: Vec = Vec::new(); + + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + if let Ok(app_dir) = Self::get_app_skills_dir(&app) { + let skill_path = app_dir.join(&dir_name); + if skill_path.exists() { + if source_path.is_none() { + source_path = Some(skill_path); + } + let app_str = match app { + AppType::Claude => "claude", + AppType::Codex => "codex", + AppType::Gemini => "gemini", + }; + found_in.push(app_str.to_string()); + } + } + } + + let source = match source_path { + Some(p) => p, + None => continue, + }; + + // 复制到 SSOT + let dest = ssot_dir.join(&dir_name); + if !dest.exists() { + Self::copy_dir_recursive(&source, &dest)?; + } + + // 解析元数据 + let skill_md = dest.join("SKILL.md"); + let (name, description) = if skill_md.exists() { + match Self::parse_skill_metadata_static(&skill_md) { + Ok(meta) => ( + meta.name.unwrap_or_else(|| dir_name.clone()), + meta.description, + ), + Err(_) => (dir_name.clone(), None), + } + } else { + (dir_name.clone(), None) + }; + + // 构建启用状态 + let mut apps = SkillApps::default(); + for app_str in &found_in { + match app_str.as_str() { + "claude" => apps.claude = true, + "codex" => apps.codex = true, + "gemini" => apps.gemini = true, + _ => {} + } + } + + // 创建记录 + let skill = InstalledSkill { + id: format!("local:{}", dir_name), + name, + description, + directory: dir_name, + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps, + installed_at: chrono::Utc::now().timestamp(), + }; + + // 保存到数据库 + db.save_skill(&skill)?; + imported.push(skill); + } + + log::info!("成功导入 {} 个 Skills", imported.len()); + + Ok(imported) + } + + // ========== 文件同步方法 ========== + + /// 复制 Skill 到应用目录 + pub fn copy_to_app(directory: &str, app: &AppType) -> Result<()> { + let ssot_dir = Self::get_ssot_dir()?; + let source = ssot_dir.join(directory); + + if !source.exists() { + return Err(anyhow!("Skill 不存在于 SSOT: {}", directory)); + } + + let app_dir = Self::get_app_skills_dir(app)?; + fs::create_dir_all(&app_dir)?; + + let dest = app_dir.join(directory); + + // 如果已存在则先删除 + if dest.exists() { + fs::remove_dir_all(&dest)?; + } + + Self::copy_dir_recursive(&source, &dest)?; + + log::debug!("Skill {} 已复制到 {:?}", directory, app); + + Ok(()) + } + + /// 从应用目录删除 Skill + pub fn remove_from_app(directory: &str, app: &AppType) -> Result<()> { + let app_dir = Self::get_app_skills_dir(app)?; + let skill_path = app_dir.join(directory); + + if skill_path.exists() { + fs::remove_dir_all(&skill_path)?; + log::debug!("Skill {} 已从 {:?} 删除", directory, app); + } + + Ok(()) + } + + /// 同步所有已启用的 Skills 到指定应用 + pub fn sync_to_app(db: &Arc, app: &AppType) -> Result<()> { + let skills = db.get_all_installed_skills()?; + + for skill in skills.values() { + if skill.apps.is_enabled_for(app) { + Self::copy_to_app(&skill.directory, app)?; + } + } + + Ok(()) + } + + // ========== 发现功能(保留原有逻辑)========== + + /// 列出所有可发现的技能(从仓库获取) + pub async fn discover_available( + &self, + repos: Vec, + ) -> Result> { let mut skills = Vec::new(); - // 仅使用启用的仓库,并行获取技能列表,避免单个无效仓库拖慢整体刷新 + // 仅使用启用的仓库 let enabled_repos: Vec = repos.into_iter().filter(|repo| repo.enabled).collect(); let fetch_tasks = enabled_repos .iter() .map(|repo| self.fetch_repo_skills(repo)); - let results: Vec>> = futures::future::join_all(fetch_tasks).await; + let results: Vec>> = + futures::future::join_all(fetch_tasks).await; for (repo, result) in enabled_repos.into_iter().zip(results.into_iter()) { match result { @@ -185,19 +623,82 @@ impl SkillService { } } - // 合并本地技能 - self.merge_local_skills(&mut skills)?; - // 去重并排序 - Self::deduplicate_skills(&mut skills); + Self::deduplicate_discoverable_skills(&mut skills); + skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + Ok(skills) + } + + /// 列出所有技能(兼容旧 API) + pub async fn list_skills( + &self, + repos: Vec, + db: &Arc, + ) -> Result> { + // 获取可发现的技能 + let discoverable = self.discover_available(repos).await?; + + // 获取已安装的技能 + let installed = db.get_all_installed_skills()?; + let installed_dirs: HashSet = + installed.values().map(|s| s.directory.clone()).collect(); + + // 转换为 Skill 格式 + let mut skills: Vec = discoverable + .into_iter() + .map(|d| { + let install_name = Path::new(&d.directory) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| d.directory.clone()); + + Skill { + key: d.key, + name: d.name, + description: d.description, + directory: d.directory, + readme_url: d.readme_url, + installed: installed_dirs.contains(&install_name), + repo_owner: Some(d.repo_owner), + repo_name: Some(d.repo_name), + repo_branch: Some(d.repo_branch), + } + }) + .collect(); + + // 添加本地已安装但不在仓库中的技能 + for skill in installed.values() { + let already_in_list = skills.iter().any(|s| { + let s_install_name = Path::new(&s.directory) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| s.directory.clone()); + s_install_name == skill.directory + }); + + if !already_in_list { + skills.push(Skill { + key: skill.id.clone(), + name: skill.name.clone(), + description: skill.description.clone().unwrap_or_default(), + directory: skill.directory.clone(), + readme_url: skill.readme_url.clone(), + installed: true, + repo_owner: skill.repo_owner.clone(), + repo_name: skill.repo_name.clone(), + repo_branch: skill.repo_branch.clone(), + }); + } + } + skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); Ok(skills) } /// 从仓库获取技能列表 - async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result> { - // 为单个仓库加载增加整体超时,避免无效链接长时间阻塞 + async fn fetch_repo_skills(&self, repo: &SkillRepo) -> Result> { let temp_dir = timeout(std::time::Duration::from_secs(60), self.download_repo(repo)) .await .map_err(|_| { @@ -211,42 +712,31 @@ impl SkillService { Some("checkNetwork"), )) })??; - let mut skills = Vec::new(); - // 扫描仓库根目录(支持全仓库递归扫描) + let mut skills = Vec::new(); let scan_dir = temp_dir.clone(); - // 递归扫描目录查找所有技能 self.scan_dir_recursive(&scan_dir, &scan_dir, repo, &mut skills)?; - // 清理临时目录 let _ = fs::remove_dir_all(&temp_dir); Ok(skills) } /// 递归扫描目录查找 SKILL.md - /// - /// 规则: - /// 1. 如果当前目录存在 SKILL.md,则识别为技能,停止扫描其子目录(子目录视为功能文件夹) - /// 2. 如果当前目录不存在 SKILL.md,则递归扫描所有子目录 fn scan_dir_recursive( &self, current_dir: &Path, base_dir: &Path, repo: &SkillRepo, - skills: &mut Vec, + skills: &mut Vec, ) -> Result<()> { - // 检查当前目录是否包含 SKILL.md let skill_md = current_dir.join("SKILL.md"); if skill_md.exists() { - // 发现技能!获取相对路径作为目录名 let directory = if current_dir == base_dir { - // 根目录的 SKILL.md,使用仓库名 repo.name.clone() } else { - // 子目录的 SKILL.md,使用相对路径 current_dir .strip_prefix(base_dir) .unwrap_or(current_dir) @@ -258,16 +748,13 @@ impl SkillService { skills.push(skill); } - // 停止扫描此目录的子目录(同级目录都是功能文件夹) return Ok(()); } - // 未发现 SKILL.md,继续递归扫描所有子目录 for entry in fs::read_dir(current_dir)? { let entry = entry?; let path = entry.path(); - // 只处理目录 if path.is_dir() { self.scan_dir_recursive(&path, base_dir, repo, skills)?; } @@ -282,36 +769,34 @@ impl SkillService { skill_md: &Path, directory: &str, repo: &SkillRepo, - ) -> Result { + ) -> Result { let meta = self.parse_skill_metadata(skill_md)?; - // 构建 README URL - let readme_path = directory.to_string(); - - Ok(Skill { + Ok(DiscoverableSkill { key: format!("{}/{}:{}", repo.owner, repo.name, directory), name: meta.name.unwrap_or_else(|| directory.to_string()), description: meta.description.unwrap_or_default(), directory: directory.to_string(), readme_url: Some(format!( "https://github.com/{}/{}/tree/{}/{}", - repo.owner, repo.name, repo.branch, readme_path + repo.owner, repo.name, repo.branch, directory )), - installed: false, - repo_owner: Some(repo.owner.clone()), - repo_name: Some(repo.name.clone()), - repo_branch: Some(repo.branch.clone()), + repo_owner: repo.owner.clone(), + repo_name: repo.name.clone(), + repo_branch: repo.branch.clone(), }) } /// 解析技能元数据 fn parse_skill_metadata(&self, path: &Path) -> Result { - let content = fs::read_to_string(path)?; + Self::parse_skill_metadata_static(path) + } - // 移除 BOM + /// 静态方法:解析技能元数据 + fn parse_skill_metadata_static(path: &Path) -> Result { + let content = fs::read_to_string(path)?; let content = content.trim_start_matches('\u{feff}'); - // 提取 YAML front matter let parts: Vec<&str> = content.splitn(3, "---").collect(); if parts.len() < 3 { return Ok(SkillMetadata { @@ -329,117 +814,10 @@ impl SkillService { Ok(meta) } - /// 合并本地技能 - fn merge_local_skills(&self, skills: &mut Vec) -> Result<()> { - if !self.install_dir.exists() { - return Ok(()); - } - - // 收集所有本地技能 - let mut local_skills = Vec::new(); - self.scan_local_dir_recursive(&self.install_dir, &self.install_dir, &mut local_skills)?; - - // 处理找到的本地技能 - for local_skill in local_skills { - let directory = &local_skill.directory; - - // 更新已安装状态(匹配远程技能) - // 使用目录最后一段进行比较,因为安装时只使用最后一段作为目录名 - let mut found = false; - let local_install_name = Path::new(directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| directory.clone()); - - for skill in skills.iter_mut() { - let remote_install_name = Path::new(&skill.directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| skill.directory.clone()); - - if remote_install_name.eq_ignore_ascii_case(&local_install_name) { - skill.installed = true; - found = true; - break; - } - } - - // 添加本地独有的技能(仅当在仓库中未找到时) - if !found { - skills.push(local_skill); - } - } - - Ok(()) - } - - /// 递归扫描本地目录查找 SKILL.md - fn scan_local_dir_recursive( - &self, - current_dir: &Path, - base_dir: &Path, - skills: &mut Vec, - ) -> Result<()> { - // 检查当前目录是否包含 SKILL.md - let skill_md = current_dir.join("SKILL.md"); - - if skill_md.exists() { - // 发现技能!获取相对路径作为目录名 - let directory = if current_dir == base_dir { - // 如果是 install_dir 本身,使用最后一段路径名 - current_dir - .file_name() - .unwrap_or_default() - .to_string_lossy() - .to_string() - } else { - // 使用相对于 install_dir 的路径 - current_dir - .strip_prefix(base_dir) - .unwrap_or(current_dir) - .to_string_lossy() - .to_string() - }; - - // 解析元数据并创建本地技能对象 - if let Ok(meta) = self.parse_skill_metadata(&skill_md) { - skills.push(Skill { - key: format!("local:{directory}"), - name: meta.name.unwrap_or_else(|| directory.clone()), - description: meta.description.unwrap_or_default(), - directory: directory.clone(), - readme_url: None, - installed: true, - repo_owner: None, - repo_name: None, - repo_branch: None, - }); - } - - // 停止扫描此目录的子目录(同级目录都是功能文件夹) - return Ok(()); - } - - // 未发现 SKILL.md,继续递归扫描所有子目录 - for entry in fs::read_dir(current_dir)? { - let entry = entry?; - let path = entry.path(); - - // 只处理目录 - if path.is_dir() { - self.scan_local_dir_recursive(&path, base_dir, skills)?; - } - } - - Ok(()) - } - /// 去重技能列表 - /// 使用完整的 key (owner/name:directory) 来区分不同仓库的同名技能 - fn deduplicate_skills(skills: &mut Vec) { + fn deduplicate_discoverable_skills(skills: &mut Vec) { let mut seen = HashMap::new(); skills.retain(|skill| { - // 使用完整 key 而非仅 directory,允许不同仓库的同名技能共存 let unique_key = skill.key.to_lowercase(); if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(unique_key) { e.insert(true); @@ -454,9 +832,8 @@ impl SkillService { async fn download_repo(&self, repo: &SkillRepo) -> Result { let temp_dir = tempfile::tempdir()?; let temp_path = temp_dir.path().to_path_buf(); - let _ = temp_dir.keep(); // 保持临时目录,稍后手动清理 + let _ = temp_dir.keep(); - // 尝试多个分支 let branches = if repo.branch.is_empty() { vec!["main", "master"] } else { @@ -486,7 +863,6 @@ impl SkillService { /// 下载并解压 ZIP async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> { - // 下载 ZIP let response = self.http_client.get(url).send().await?; if !response.status().is_success() { let status = response.status().as_u16().to_string(); @@ -503,12 +879,9 @@ impl SkillService { } let bytes = response.bytes().await?; - - // 解压 let cursor = std::io::Cursor::new(bytes); let mut archive = zip::ZipArchive::new(cursor)?; - // 获取根目录名称 (GitHub 的 zip 会有一个根目录) let root_name = if !archive.is_empty() { let first_file = archive.by_index(0)?; let name = first_file.name(); @@ -521,12 +894,10 @@ impl SkillService { ))); }; - // 解压所有文件 for i in 0..archive.len() { let mut file = archive.by_index(i)?; let file_path = file.name(); - // 跳过根目录,直接提取内容 let relative_path = if let Some(stripped) = file_path.strip_prefix(&format!("{root_name}/")) { stripped @@ -554,66 +925,6 @@ impl SkillService { Ok(()) } - /// 安装技能(仅负责下载和文件操作,状态更新由上层负责) - pub async fn install_skill(&self, directory: String, repo: SkillRepo) -> Result<()> { - // 使用技能目录的最后一段作为安装目录名,避免嵌套路径问题 - // 例如: "skills/codex" -> "codex" - let install_name = Path::new(&directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| directory.clone()); - - let dest = self.install_dir.join(&install_name); - - // 若目标目录已存在,则视为已安装,避免重复下载 - if dest.exists() { - return Ok(()); - } - - // 下载仓库时增加总超时,防止无效链接导致长时间卡住安装过程 - let temp_dir = timeout( - std::time::Duration::from_secs(60), - self.download_repo(&repo), - ) - .await - .map_err(|_| { - anyhow!(format_skill_error( - "DOWNLOAD_TIMEOUT", - &[ - ("owner", &repo.owner), - ("name", &repo.name), - ("timeout", "60") - ], - Some("checkNetwork"), - )) - })??; - - // 确定源目录路径(技能相对于仓库根目录的路径) - let source = temp_dir.join(&directory); - - if !source.exists() { - let _ = fs::remove_dir_all(&temp_dir); - return Err(anyhow::anyhow!(format_skill_error( - "SKILL_DIR_NOT_FOUND", - &[("path", &source.display().to_string())], - Some("checkRepoUrl"), - ))); - } - - // 删除旧版本 - if dest.exists() { - fs::remove_dir_all(&dest)?; - } - - // 递归复制 - Self::copy_dir_recursive(&source, &dest)?; - - // 清理临时目录 - let _ = fs::remove_dir_all(&temp_dir); - - Ok(()) - } - /// 递归复制目录 fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { fs::create_dir_all(dest)?; @@ -633,22 +944,7 @@ impl SkillService { Ok(()) } - /// 卸载技能(仅负责文件操作,状态更新由上层负责) - pub fn uninstall_skill(&self, directory: String) -> Result<()> { - // 使用技能目录的最后一段作为安装目录名,与 install_skill 保持一致 - let install_name = Path::new(&directory) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| directory.clone()); - - let dest = self.install_dir.join(&install_name); - - if dest.exists() { - fs::remove_dir_all(&dest)?; - } - - Ok(()) - } + // ========== 仓库管理(保留原有逻辑)========== /// 列出仓库 pub fn list_repos(&self, store: &SkillStore) -> Vec { @@ -657,7 +953,6 @@ impl SkillService { /// 添加仓库 pub fn add_repo(&self, store: &mut SkillStore, repo: SkillRepo) -> Result<()> { - // 检查重复 if let Some(pos) = store .repos .iter() @@ -680,3 +975,92 @@ impl SkillService { Ok(()) } } + +// ========== 迁移支持 ========== + +/// 首次启动迁移:扫描应用目录,重建数据库 +pub fn migrate_skills_to_ssot(db: &Arc) -> Result { + let ssot_dir = SkillService::get_ssot_dir()?; + let mut discovered: HashMap = HashMap::new(); + + // 扫描各应用目录 + for app in [AppType::Claude, AppType::Codex, AppType::Gemini] { + let app_dir = match SkillService::get_app_skills_dir(&app) { + Ok(d) => d, + Err(_) => continue, + }; + + if !app_dir.exists() { + continue; + } + + for entry in fs::read_dir(&app_dir)? { + let entry = entry?; + let path = entry.path(); + + if !path.is_dir() { + continue; + } + + let dir_name = entry.file_name().to_string_lossy().to_string(); + + // 跳过隐藏目录(以 . 开头,如 .system) + if dir_name.starts_with('.') { + continue; + } + + // 复制到 SSOT(如果不存在) + let ssot_path = ssot_dir.join(&dir_name); + if !ssot_path.exists() { + SkillService::copy_dir_recursive(&path, &ssot_path)?; + } + + // 记录启用状态 + discovered + .entry(dir_name) + .or_default() + .set_enabled_for(&app, true); + } + } + + // 重建数据库 + db.clear_skills()?; + + let mut count = 0; + for (directory, apps) in discovered { + let ssot_path = ssot_dir.join(&directory); + let skill_md = ssot_path.join("SKILL.md"); + + let (name, description) = if skill_md.exists() { + match SkillService::parse_skill_metadata_static(&skill_md) { + Ok(meta) => ( + meta.name.unwrap_or_else(|| directory.clone()), + meta.description, + ), + Err(_) => (directory.clone(), None), + } + } else { + (directory.clone(), None) + }; + + let skill = InstalledSkill { + id: format!("local:{}", directory), + name, + description, + directory, + repo_owner: None, + repo_name: None, + repo_branch: None, + readme_url: None, + apps, + installed_at: chrono::Utc::now().timestamp(), + }; + + db.save_skill(&skill)?; + count += 1; + } + + log::info!("Skills 迁移完成,共 {} 个", count); + + Ok(count) +} diff --git a/src-tauri/src/services/usage_stats.rs b/src-tauri/src/services/usage_stats.rs index 9cda7ca6a..741648751 100644 --- a/src-tauri/src/services/usage_stats.rs +++ b/src-tauri/src/services/usage_stats.rs @@ -4,7 +4,7 @@ use crate::database::{lock_conn, Database}; use crate::error::AppError; -use chrono::{Duration, Utc}; +use chrono::{Local, TimeZone}; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -181,129 +181,114 @@ impl Database { Ok(result) } - /// 获取每日趋势 - pub fn get_daily_trends(&self, days: u32) -> Result, AppError> { + /// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致) + pub fn get_daily_trends( + &self, + start_date: Option, + end_date: Option, + ) -> Result, AppError> { let conn = lock_conn!(self.conn); - if days <= 1 { - let sql = "SELECT - strftime('%Y-%m-%dT%H:00:00Z', datetime(created_at, 'unixepoch')) as bucket, - COUNT(*) as request_count, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs - WHERE created_at >= strftime('%s', 'now', '-1 day') - GROUP BY bucket - ORDER BY bucket ASC"; + let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp()); + let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60); - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([], |row| { - Ok(DailyStats { - date: row.get(0)?, - request_count: row.get::<_, i64>(1)? as u64, - total_cost: format!("{:.6}", row.get::<_, f64>(2)?), - total_tokens: row.get::<_, i64>(3)? as u64, - total_input_tokens: row.get::<_, i64>(4)? as u64, - total_output_tokens: row.get::<_, i64>(5)? as u64, - total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, - total_cache_read_tokens: row.get::<_, i64>(7)? as u64, - }) - })?; - - let mut buckets: HashMap = HashMap::new(); - for row in rows { - let stat = row?; - buckets.insert(stat.date.clone(), stat); - } - - let mut stats = Vec::new(); - let today = Utc::now().date_naive(); - for hour in 0..24 { - let bucket = today - .and_hms_opt(hour, 0, 0) - .unwrap() - .format("%Y-%m-%dT%H:00:00Z") - .to_string(); - - if let Some(stat) = buckets.remove(&bucket) { - stats.push(stat); - } else { - stats.push(DailyStats { - date: bucket, - request_count: 0, - total_cost: "0.000000".to_string(), - total_tokens: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_read_tokens: 0, - }); - } - } - Ok(stats) - } else { - let sql = "SELECT - date(created_at, 'unixepoch') as bucket, - COUNT(*) as request_count, - COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, - COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, - COALESCE(SUM(input_tokens), 0) as total_input_tokens, - COALESCE(SUM(output_tokens), 0) as total_output_tokens, - COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, - COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens - FROM proxy_request_logs - WHERE created_at >= strftime('%s', 'now', ?) - GROUP BY bucket - ORDER BY bucket ASC"; - - let mut stmt = conn.prepare(sql)?; - let rows = stmt.query_map([format!("-{days} days")], |row| { - Ok(DailyStats { - date: row.get(0)?, - request_count: row.get::<_, i64>(1)? as u64, - total_cost: format!("{:.6}", row.get::<_, f64>(2)?), - total_tokens: row.get::<_, i64>(3)? as u64, - total_input_tokens: row.get::<_, i64>(4)? as u64, - total_output_tokens: row.get::<_, i64>(5)? as u64, - total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, - total_cache_read_tokens: row.get::<_, i64>(7)? as u64, - }) - })?; - - let mut map = HashMap::new(); - for row in rows { - let stat = row?; - map.insert(stat.date.clone(), stat); - } - - let mut stats = Vec::new(); - let start_day = - Utc::now().date_naive() - Duration::days((days.saturating_sub(1)) as i64); - - for i in 0..days { - let day = start_day + Duration::days(i as i64); - let key = day.format("%Y-%m-%d").to_string(); - if let Some(stat) = map.remove(&key) { - stats.push(stat); - } else { - stats.push(DailyStats { - date: key, - request_count: 0, - total_cost: "0.000000".to_string(), - total_tokens: 0, - total_input_tokens: 0, - total_output_tokens: 0, - total_cache_creation_tokens: 0, - total_cache_read_tokens: 0, - }); - } - } - Ok(stats) + if start_ts >= end_ts { + start_ts = end_ts - 24 * 60 * 60; } + + let duration = end_ts - start_ts; + let bucket_seconds: i64 = if duration <= 24 * 60 * 60 { + 60 * 60 + } else { + 24 * 60 * 60 + }; + let mut bucket_count: i64 = if duration <= 0 { + 1 + } else { + ((duration as f64) / bucket_seconds as f64).ceil() as i64 + }; + + // 固定 24 小时窗口为 24 个小时桶,避免浮点误差 + if bucket_seconds == 60 * 60 { + bucket_count = 24; + } + + if bucket_count < 1 { + bucket_count = 1; + } + + let sql = " + SELECT + CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx, + COUNT(*) as request_count, + COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost, + COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens, + COALESCE(SUM(input_tokens), 0) as total_input_tokens, + COALESCE(SUM(output_tokens), 0) as total_output_tokens, + COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens, + COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens + FROM proxy_request_logs + WHERE created_at >= ?1 AND created_at <= ?2 + GROUP BY bucket_idx + ORDER BY bucket_idx ASC"; + + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| { + Ok(( + row.get::<_, i64>(0)?, + DailyStats { + date: String::new(), + request_count: row.get::<_, i64>(1)? as u64, + total_cost: format!("{:.6}", row.get::<_, f64>(2)?), + total_tokens: row.get::<_, i64>(3)? as u64, + total_input_tokens: row.get::<_, i64>(4)? as u64, + total_output_tokens: row.get::<_, i64>(5)? as u64, + total_cache_creation_tokens: row.get::<_, i64>(6)? as u64, + total_cache_read_tokens: row.get::<_, i64>(7)? as u64, + }, + )) + })?; + + let mut map: HashMap = HashMap::new(); + for row in rows { + let (mut bucket_idx, stat) = row?; + if bucket_idx < 0 { + continue; + } + if bucket_idx >= bucket_count { + bucket_idx = bucket_count - 1; + } + map.insert(bucket_idx, stat); + } + + let mut stats = Vec::with_capacity(bucket_count as usize); + for i in 0..bucket_count { + let bucket_start_ts = start_ts + i * bucket_seconds; + let bucket_start = Local + .timestamp_opt(bucket_start_ts, 0) + .single() + .unwrap_or_else(Local::now); + + let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string(); + + if let Some(mut stat) = map.remove(&i) { + stat.date = date; + stats.push(stat); + } else { + stats.push(DailyStats { + date, + request_count: 0, + total_cost: "0.000000".to_string(), + total_tokens: 0, + total_input_tokens: 0, + total_output_tokens: 0, + total_cache_creation_tokens: 0, + total_cache_read_tokens: 0, + }); + } + } + + Ok(stats) } /// 获取 Provider 统计 @@ -617,7 +602,7 @@ impl Database { "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) FROM proxy_request_logs WHERE provider_id = ? AND app_type = ? - AND date(created_at, 'unixepoch') = date('now')", + AND date(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')", params![provider_id, app_type], |row| row.get(0), ) @@ -629,7 +614,7 @@ impl Database { "SELECT COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) FROM proxy_request_logs WHERE provider_id = ? AND app_type = ? - AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')", + AND strftime('%Y-%m', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')", params![provider_id, app_type], |row| row.get(0), ) @@ -813,89 +798,46 @@ impl Database { } } -/// 标准化模型名称:去除供应商前缀并将点号替换为短横线 -/// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5 -fn normalize_model_id(model_id: &str) -> String { - // 1. 去除供应商前缀(如 anthropic/、openai/) - let stripped = if let Some(pos) = model_id.find('/') { - &model_id[pos + 1..] - } else { - model_id - }; - // 2. 将点号替换为短横线(如 claude-haiku-4.5 → claude-haiku-4-5) - stripped.replace('.', "-") -} - pub(crate) fn find_model_pricing_row( conn: &Connection, model_id: &str, ) -> Result, AppError> { - // 0. 标准化模型名称(去除前缀 + 点号转短横线) - // 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5 - let normalized = normalize_model_id(model_id); + // 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905 + let without_prefix = model_id + .rsplit_once('/') + .map(|(_, rest)| rest) + .unwrap_or(model_id); + let cleaned = without_prefix + .split(':') + .next() + .map(str::trim) + .unwrap_or(without_prefix); - // 1. 精确匹配(先尝试原始名称,再尝试标准化后的名称) - for id in [model_id, normalized.as_str()] { - let exact = conn - .query_row( - "SELECT input_cost_per_million, output_cost_per_million, - cache_read_cost_per_million, cache_creation_cost_per_million - FROM model_pricing - WHERE model_id = ?1", - [id], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .optional() - .map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?; + // 2) 精确匹配清洗后的名称 + let exact = conn + .query_row( + "SELECT input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + FROM model_pricing + WHERE model_id = ?1", + [cleaned], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional() + .map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?; - if exact.is_some() { - if id != model_id { - log::info!("模型 {model_id} 标准化后精确匹配到: {id}"); - } - return Ok(exact); - } + if exact.is_none() { + log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0"); } - // 2. 逐步删除后缀匹配(claude-haiku-4-5-20250929 → claude-haiku-4-5 → claude-haiku-4 → claude-haiku) - // 使用标准化后的名称进行后缀匹配 - let mut current = normalized; - while let Some(pos) = current.rfind('-') { - current = current[..pos].to_string(); - - let result = conn - .query_row( - "SELECT input_cost_per_million, output_cost_per_million, - cache_read_cost_per_million, cache_creation_cost_per_million - FROM model_pricing - WHERE model_id = ?1", - [¤t], - |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - )) - }, - ) - .optional() - .map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?; - - if result.is_some() { - log::info!("模型 {model_id} 通过删除后缀匹配到: {current}"); - return Ok(result); - } - } - - log::warn!("模型 {model_id} 未找到定价信息,成本将记录为 0"); - Ok(None) + Ok(exact) } #[cfg(test)] @@ -975,54 +917,39 @@ mod tests { let db = Database::memory()?; let conn = lock_conn!(db.conn); - // 测试精确匹配 - let result = find_model_pricing_row(&conn, "claude-sonnet-4-5")?; - assert!(result.is_some(), "应该能精确匹配 claude-sonnet-4-5"); + // 准备额外定价数据,覆盖前缀/后缀清洗场景 + conn.execute( + "INSERT OR REPLACE INTO model_pricing ( + model_id, display_name, input_cost_per_million, output_cost_per_million, + cache_read_cost_per_million, cache_creation_cost_per_million + ) VALUES (?, ?, ?, ?, ?, ?)", + params![ + "claude-haiku-4.5", + "Claude Haiku 4.5", + "1.0", + "2.0", + "0.0", + "0.0" + ], + )?; - // 测试带供应商前缀的模型名称(anthropic/claude-haiku-4.5 → claude-haiku-4-5) - let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?; - assert!( - result.is_some(), - "应该能匹配带前缀的模型 anthropic/claude-haiku-4.5" - ); - - // 测试带供应商前缀 + 点号的模型名称 - let result = find_model_pricing_row(&conn, "anthropic/claude-sonnet-4.5")?; - assert!( - result.is_some(), - "应该能匹配带前缀的模型 anthropic/claude-sonnet-4.5" - ); - - // 测试逐步删除后缀匹配 - 日期后缀 - let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20241022")?; - assert!( - result.is_some(), - "应该能通过删除后缀匹配 claude-sonnet-4-5-20241022" - ); - - // 测试逐步删除后缀匹配 - 多个后缀 - let result = find_model_pricing_row(&conn, "claude-haiku-4-5-20240229-preview")?; - assert!( - result.is_some(), - "应该能通过删除后缀匹配 claude-haiku-4-5-20240229-preview" - ); - - // 测试 GPT 模型 - let result = find_model_pricing_row(&conn, "gpt-5-2024-11-20")?; - assert!(result.is_some(), "应该能通过删除后缀匹配 gpt-5-2024-11-20"); - - // 测试 Gemini 模型 - let result = find_model_pricing_row(&conn, "gemini-2.5-flash-exp")?; - assert!( - result.is_some(), - "应该能通过删除后缀匹配 gemini-2.5-flash-exp" - ); - - // 测试 claude-sonnet-4-5 命名格式 + // 测试精确匹配(seed_model_pricing 已预置 claude-sonnet-4-5-20250929) let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?; assert!( result.is_some(), - "应该能通过删除后缀匹配 claude-sonnet-4-5-20250929" + "应该能精确匹配 claude-sonnet-4-5-20250929" + ); + + // 清洗:去除前缀和冒号后缀 + let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?; + assert!( + result.is_some(), + "带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5" + ); + let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?; + assert!( + result.is_some(), + "带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905" ); // 测试不存在的模型 diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index adfe7b661..8173d9052 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "CC Switch", - "version": "3.9.0-2", + "version": "3.9.0-3", "identifier": "com.ccswitch.desktop", "build": { "frontendDist": "../dist", diff --git a/src-tauri/tests/support.rs b/src-tauri/tests/support.rs index bc5c8b716..78f18759f 100644 --- a/src-tauri/tests/support.rs +++ b/src-tauri/tests/support.rs @@ -49,6 +49,7 @@ pub fn test_mutex() -> &'static Mutex<()> { } /// 创建测试用的 AppState,包含一个空的数据库 +#[allow(dead_code)] pub fn create_test_state() -> Result> { let db = Arc::new(Database::init()?); let proxy_service = ProxyService::new(db.clone()); @@ -56,6 +57,7 @@ pub fn create_test_state() -> Result> { } /// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据 +#[allow(dead_code)] pub fn create_test_state_with_config( config: &MultiAppConfig, ) -> Result> { diff --git a/src/App.tsx b/src/App.tsx index abc40138f..7eaf43fdb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,8 @@ import { Wrench, Server, RefreshCw, + Search, + Download, } from "lucide-react"; import type { Provider } from "@/types"; import type { EnvConflict } from "@/types/env"; @@ -26,6 +28,7 @@ import { import { checkAllEnvConflicts, checkEnvConflicts } from "@/lib/api/env"; import { useProviderActions } from "@/hooks/useProviderActions"; import { useProxyStatus } from "@/hooks/useProxyStatus"; +import { useLastValidValue } from "@/hooks/useLastValidValue"; import { extractErrorMessage } from "@/utils/errorUtils"; import { cn } from "@/lib/utils"; import { AppSwitcher } from "@/components/AppSwitcher"; @@ -41,6 +44,7 @@ import UsageScriptModal from "@/components/UsageScriptModal"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; +import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { UniversalProviderPanel } from "@/components/universal"; @@ -51,6 +55,7 @@ type View = | "settings" | "prompts" | "skills" + | "skillsDiscovery" | "mcp" | "agents" | "universal"; @@ -73,25 +78,14 @@ function App() { const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); - // 保存最后一个有效的 provider,用于动画退出期间显示内容 - const lastUsageProviderRef = useRef(null); - const lastEditingProviderRef = useRef(null); - - useEffect(() => { - if (usageProvider) { - lastUsageProviderRef.current = usageProvider; - } - }, [usageProvider]); - - useEffect(() => { - if (editingProvider) { - lastEditingProviderRef.current = editingProvider; - } - }, [editingProvider]); + // 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示 + const effectiveEditingProvider = useLastValidValue(editingProvider); + const effectiveUsageProvider = useLastValidValue(usageProvider); const promptPanelRef = useRef(null); const mcpPanelRef = useRef(null); const skillsPageRef = useRef(null); + const unifiedSkillsPanelRef = useRef(null); const addActionButtonClass = "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; @@ -117,8 +111,7 @@ function App() { }); const providers = useMemo(() => data?.providers ?? {}, [data]); const currentProviderId = data?.currentProviderId ?? ""; - // Skills 功能仅支持 Claude 和 Codex - const hasSkillsSupport = activeApp === "claude" || activeApp === "codex"; + const hasSkillsSupport = true; // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 const { @@ -229,6 +222,35 @@ function App() { checkMigration(); }, [t]); + // 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT) + useEffect(() => { + const checkSkillsMigration = async () => { + try { + const result = await invoke<{ count: number; error?: string } | null>( + "get_skills_migration_result", + ); + if (result?.error) { + toast.error(t("migration.skillsFailed"), { + description: t("migration.skillsFailedDescription"), + closeButton: true, + }); + console.error("[App] Skills SSOT migration failed:", result.error); + return; + } + if (result && result.count > 0) { + toast.success(t("migration.skillsSuccess", { count: result.count }), { + closeButton: true, + }); + await queryClient.invalidateQueries({ queryKey: ["skills"] }); + } + } catch (error) { + console.error("[App] Failed to check skills migration result:", error); + } + }; + + checkSkillsMigration(); + }, [t, queryClient]); + // 切换应用时检测当前应用的环境变量冲突 useEffect(() => { const checkEnvOnSwitch = async () => { @@ -421,10 +443,16 @@ function App() { /> ); case "skills": + return ( + setCurrentView("skillsDiscovery")} + /> + ); + case "skillsDiscovery": return ( setCurrentView("providers")} initialApp={activeApp} /> ); @@ -564,7 +592,11 @@ function App() { )} {currentView === "mcp" && ( - + <> + + + )} {currentView === "skills" && ( + <> + + + + )} + {currentView === "skillsDiscovery" && ( <> diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index f004b1c7d..1379b8c1c 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -3,12 +3,11 @@ import { useTranslation } from "react-i18next"; import { Server } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; -import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp"; +import { useAllMcpServers, useToggleMcpApp, useDeleteMcpServer, useImportMcpFromApps } from "@/hooks/useMcp"; import type { McpServer } from "@/types"; import type { AppId } from "@/lib/api/types"; import McpFormModal from "./McpFormModal"; import { ConfirmDialog } from "../ConfirmDialog"; -import { useDeleteMcpServer } from "@/hooks/useMcp"; import { Edit3, Trash2 } from "lucide-react"; import { settingsApi } from "@/lib/api"; import { mcpPresets } from "@/config/mcpPresets"; @@ -24,6 +23,7 @@ interface UnifiedMcpPanelProps { */ export interface UnifiedMcpPanelHandle { openAdd: () => void; + openImport: () => void; } const UnifiedMcpPanel = React.forwardRef< @@ -44,6 +44,7 @@ const UnifiedMcpPanel = React.forwardRef< const { data: serversMap, isLoading } = useAllMcpServers(); const toggleAppMutation = useToggleMcpApp(); const deleteServerMutation = useDeleteMcpServer(); + const importMutation = useImportMcpFromApps(); // Convert serversMap to array for easier rendering const serverEntries = useMemo((): Array<[string, McpServer]> => { @@ -86,8 +87,24 @@ const UnifiedMcpPanel = React.forwardRef< setIsFormOpen(true); }; + const handleImport = async () => { + try { + const count = await importMutation.mutateAsync(); + if (count === 0) { + toast.success(t("mcp.unifiedPanel.noImportFound"), { closeButton: true }); + } else { + toast.success(t("mcp.unifiedPanel.importSuccess", { count }), { closeButton: true }); + } + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + React.useImperativeHandle(ref, () => ({ openAdd: handleAdd, + openImport: handleImport, })); const handleDelete = (id: string) => { diff --git a/src/components/prompts/PromptToggle.tsx b/src/components/prompts/PromptToggle.tsx index aae96a7c7..e2834c62c 100644 --- a/src/components/prompts/PromptToggle.tsx +++ b/src/components/prompts/PromptToggle.tsx @@ -8,7 +8,7 @@ interface PromptToggleProps { /** * Toggle 开关组件(提示词专用) - * 启用时为蓝色,禁用时为灰色 + * 启用时为绿色,禁用时为灰色 */ const PromptToggle: React.FC = ({ enabled, @@ -23,8 +23,8 @@ const PromptToggle: React.FC = ({ disabled={disabled} onClick={() => onChange(!enabled)} className={` - relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20 - ${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"} + relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20 + ${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} `} > diff --git a/src/components/providers/forms/ClaudeFormFields.tsx b/src/components/providers/forms/ClaudeFormFields.tsx index 351f11b41..e3e1d5311 100644 --- a/src/components/providers/forms/ClaudeFormFields.tsx +++ b/src/components/providers/forms/ClaudeFormFields.tsx @@ -222,9 +222,7 @@ export function ClaudeFormFields({ {/* 推理模型 */}
- {t("providerForm.anthropicReasoningModel", { - defaultValue: "推理模型 (Thinking)", - })} + {t("providerForm.anthropicReasoningModel")} onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value) } - placeholder={t("providerForm.reasoningModelPlaceholder", { - defaultValue: "", - })} autoComplete="off" />
diff --git a/src/components/proxy/AutoFailoverConfigPanel.tsx b/src/components/proxy/AutoFailoverConfigPanel.tsx index 9daeaed0d..30f55e136 100644 --- a/src/components/proxy/AutoFailoverConfigPanel.tsx +++ b/src/components/proxy/AutoFailoverConfigPanel.tsx @@ -142,12 +142,13 @@ export function AutoFailoverConfigPanel({ min="0" max="10" value={formData.maxRetries} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - maxRetries: parseInt(e.target.value) || 3, - }) - } + maxRetries: isNaN(val) ? 0 : val, + }); + }} disabled={isDisabled} />

@@ -168,12 +169,13 @@ export function AutoFailoverConfigPanel({ min="1" max="20" value={formData.circuitFailureThreshold} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - circuitFailureThreshold: parseInt(e.target.value) || 5, - }) - } + circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val), + }); + }} disabled={isDisabled} />

@@ -206,12 +208,13 @@ export function AutoFailoverConfigPanel({ min="0" max="180" value={formData.streamingFirstByteTimeout} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - streamingFirstByteTimeout: parseInt(e.target.value) || 30, - }) - } + streamingFirstByteTimeout: isNaN(val) ? 0 : val, + }); + }} disabled={isDisabled} />

@@ -232,12 +235,13 @@ export function AutoFailoverConfigPanel({ min="0" max="600" value={formData.streamingIdleTimeout} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - streamingIdleTimeout: parseInt(e.target.value) || 60, - }) - } + streamingIdleTimeout: isNaN(val) ? 0 : val, + }); + }} disabled={isDisabled} />

@@ -258,12 +262,13 @@ export function AutoFailoverConfigPanel({ min="0" max="1800" value={formData.nonStreamingTimeout} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - nonStreamingTimeout: parseInt(e.target.value) || 300, - }) - } + nonStreamingTimeout: isNaN(val) ? 0 : val, + }); + }} disabled={isDisabled} />

@@ -293,12 +298,13 @@ export function AutoFailoverConfigPanel({ min="1" max="10" value={formData.circuitSuccessThreshold} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - circuitSuccessThreshold: parseInt(e.target.value) || 2, - }) - } + circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val), + }); + }} disabled={isDisabled} />

@@ -319,12 +325,13 @@ export function AutoFailoverConfigPanel({ min="10" max="300" value={formData.circuitTimeoutSeconds} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - circuitTimeoutSeconds: parseInt(e.target.value) || 60, - }) - } + circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val), + }); + }} disabled={isDisabled} />

@@ -346,13 +353,13 @@ export function AutoFailoverConfigPanel({ max="100" step="5" value={Math.round(formData.circuitErrorRateThreshold * 100)} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - circuitErrorRateThreshold: - (parseInt(e.target.value) || 50) / 100, - }) - } + circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100, + }); + }} disabled={isDisabled} />

@@ -373,12 +380,13 @@ export function AutoFailoverConfigPanel({ min="5" max="100" value={formData.circuitMinRequests} - onChange={(e) => + onChange={(e) => { + const val = parseInt(e.target.value); setFormData({ ...formData, - circuitMinRequests: parseInt(e.target.value) || 10, - }) - } + circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val), + }); + }} disabled={isDisabled} />

diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index d40233f97..6db2fd83d 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -312,7 +312,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) { {isLoadingTools ? t("common.refreshing") : t("common.refresh")} -

+
{["claude", "codex", "gemini"].map((toolName, index) => { const tool = toolVersions.find((item) => item.name === toolName); const displayName = tool?.name ?? toolName; diff --git a/src/components/skills/RepoManager.tsx b/src/components/skills/RepoManager.tsx index 7a4839c4e..69fee8d9f 100644 --- a/src/components/skills/RepoManager.tsx +++ b/src/components/skills/RepoManager.tsx @@ -12,13 +12,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Trash2, ExternalLink, Plus } from "lucide-react"; import { settingsApi } from "@/lib/api"; -import type { Skill, SkillRepo } from "@/lib/api/skills"; +import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills"; interface RepoManagerProps { open: boolean; onOpenChange: (open: boolean) => void; repos: SkillRepo[]; - skills: Skill[]; + skills: DiscoverableSkill[]; onAdd: (repo: SkillRepo) => Promise; onRemove: (owner: string, name: string) => Promise; } diff --git a/src/components/skills/RepoManagerPanel.tsx b/src/components/skills/RepoManagerPanel.tsx index c58ec570b..a264d6d2f 100644 --- a/src/components/skills/RepoManagerPanel.tsx +++ b/src/components/skills/RepoManagerPanel.tsx @@ -6,11 +6,11 @@ import { Label } from "@/components/ui/label"; import { Trash2, ExternalLink, Plus } from "lucide-react"; import { settingsApi } from "@/lib/api"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; -import type { Skill, SkillRepo } from "@/lib/api/skills"; +import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills"; interface RepoManagerPanelProps { repos: SkillRepo[]; - skills: Skill[]; + skills: DiscoverableSkill[]; onAdd: (repo: SkillRepo) => Promise; onRemove: (owner: string, name: string) => Promise; onClose: () => void; @@ -92,7 +92,7 @@ export function RepoManagerPanel({ {/* 添加仓库表单 */}

- 添加技能仓库 + {t("skills.addRepo")}

diff --git a/src/components/skills/SkillCard.tsx b/src/components/skills/SkillCard.tsx index 4a22566d7..5787f72ec 100644 --- a/src/components/skills/SkillCard.tsx +++ b/src/components/skills/SkillCard.tsx @@ -12,10 +12,12 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react"; import { settingsApi } from "@/lib/api"; -import type { Skill } from "@/lib/api/skills"; +import type { DiscoverableSkill } from "@/lib/api/skills"; + +type SkillCardSkill = DiscoverableSkill & { installed: boolean }; interface SkillCardProps { - skill: Skill; + skill: SkillCardSkill; onInstall: (directory: string) => Promise; onUninstall: (directory: string) => Promise; } @@ -57,7 +59,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) { skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase(); return ( - +
diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index 2b199bc10..721ac9665 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -1,10 +1,4 @@ -import { - useState, - useEffect, - useMemo, - forwardRef, - useImperativeHandle, -} from "react"; +import { useState, useMemo, forwardRef, useImperativeHandle } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -20,15 +14,18 @@ import { toast } from "sonner"; import { SkillCard } from "./SkillCard"; import { RepoManagerPanel } from "./RepoManagerPanel"; import { - skillsApi, - type Skill, - type SkillRepo, + useDiscoverableSkills, + useInstalledSkills, + useInstallSkill, + useSkillRepos, + useAddSkillRepo, + useRemoveSkillRepo, type AppType, -} from "@/lib/api/skills"; +} from "@/hooks/useSkills"; +import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills"; import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { - onClose?: () => void; initialApp?: AppType; } @@ -37,163 +34,138 @@ export interface SkillsPageHandle { openRepoManager: () => void; } +/** + * Skills 发现面板 + * 用于浏览和安装来自仓库的 Skills + */ export const SkillsPage = forwardRef( - ({ onClose: _onClose, initialApp = "claude" }, ref) => { + ({ initialApp = "claude" }, ref) => { const { t } = useTranslation(); - const [skills, setSkills] = useState([]); - const [repos, setRepos] = useState([]); - const [loading, setLoading] = useState(true); const [repoManagerOpen, setRepoManagerOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const [filterStatus, setFilterStatus] = useState< "all" | "installed" | "uninstalled" >("all"); - // 使用 initialApp,不允许切换 - const selectedApp = initialApp; - const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { - try { - setLoading(true); - const data = await skillsApi.getAll(selectedApp); - setSkills(data); - if (afterLoad) { - afterLoad(data); - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + // currentApp 用于安装时的默认应用 + const currentApp = initialApp; - // 传入 "skills.loadFailed" 作为标题 - const { title, description } = formatSkillError( - errorMessage, - t, - "skills.loadFailed", - ); + // Queries + const { + data: discoverableSkills, + isLoading: loadingDiscoverable, + isFetching: fetchingDiscoverable, + refetch: refetchDiscoverable, + } = useDiscoverableSkills(); + const { data: installedSkills } = useInstalledSkills(); + const { data: repos = [], refetch: refetchRepos } = useSkillRepos(); - toast.error(title, { - description, - duration: 8000, - }); + // Mutations + const installMutation = useInstallSkill(); + const addRepoMutation = useAddSkillRepo(); + const removeRepoMutation = useRemoveSkillRepo(); - console.error("Load skills failed:", error); - } finally { - setLoading(false); - } - }; + // 已安装的 directory 集合 + const installedDirs = useMemo(() => { + if (!installedSkills) return new Set(); + return new Set(installedSkills.map((s) => s.directory.toLowerCase())); + }, [installedSkills]); - const loadRepos = async () => { - try { - const data = await skillsApi.getRepos(); - setRepos(data); - } catch (error) { - console.error("Failed to load repos:", error); - } - }; + type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean }; - useEffect(() => { - Promise.all([loadSkills(), loadRepos()]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + // 为发现列表补齐 installed 状态,供 SkillCard 使用 + const skills: DiscoverableSkillItem[] = useMemo(() => { + if (!discoverableSkills) return []; + return discoverableSkills.map((d) => { + const installName = + d.directory.split("/").pop()?.toLowerCase() || + d.directory.toLowerCase(); + return { + ...d, + installed: installedDirs.has(installName), + }; + }); + }, [discoverableSkills, installedDirs]); + + const loading = loadingDiscoverable || fetchingDiscoverable; useImperativeHandle(ref, () => ({ - refresh: () => loadSkills(), + refresh: () => { + refetchDiscoverable(); + refetchRepos(); + }, openRepoManager: () => setRepoManagerOpen(true), })); const handleInstall = async (directory: string) => { + // 找到对应的 DiscoverableSkill + const skill = discoverableSkills?.find( + (s) => + s.directory === directory || + s.directory.split("/").pop() === directory, + ); + if (!skill) { + toast.error(t("skills.notFound")); + return; + } + try { - await skillsApi.install(directory, selectedApp); - toast.success(t("skills.installSuccess", { name: directory }), { + await installMutation.mutateAsync({ + skill, + currentApp, + }); + toast.success(t("skills.installSuccess", { name: skill.name }), { closeButton: true, }); - await loadSkills(); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); - - // 使用错误解析器格式化错误,传入 "skills.installFailed" const { title, description } = formatSkillError( errorMessage, t, "skills.installFailed", ); - - toast.error(title, { - description, - duration: 10000, // 延长显示时间让用户看清 - }); - - console.error("Install skill failed:", { - directory, - error, - message: errorMessage, - }); - } - }; - - const handleUninstall = async (directory: string) => { - try { - await skillsApi.uninstall(directory, selectedApp); - toast.success(t("skills.uninstallSuccess", { name: directory }), { - closeButton: true, - }); - await loadSkills(); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - // 使用错误解析器格式化错误,传入 "skills.uninstallFailed" - const { title, description } = formatSkillError( - errorMessage, - t, - "skills.uninstallFailed", - ); - toast.error(title, { description, duration: 10000, }); + console.error("Install skill failed:", error); + } + }; - console.error("Uninstall skill failed:", { - directory, - error, - message: errorMessage, + const handleUninstall = async (_directory: string) => { + // 在发现面板中,不支持卸载,需要在主面板中操作 + toast.info(t("skills.uninstallInMainPanel")); + }; + + const handleAddRepo = async (repo: SkillRepo) => { + try { + await addRepoMutation.mutateAsync(repo); + toast.success( + t("skills.repo.addSuccess", { + owner: repo.owner, + name: repo.name, + }), + { closeButton: true }, + ); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), }); } }; - const handleAddRepo = async (repo: SkillRepo) => { - await skillsApi.addRepo(repo); - - let repoSkillCount = 0; - await Promise.all([ - loadRepos(), - loadSkills((data) => { - repoSkillCount = data.filter( - (skill) => - skill.repoOwner === repo.owner && - skill.repoName === repo.name && - (skill.repoBranch || "main") === (repo.branch || "main"), - ).length; - }), - ]); - - toast.success( - t("skills.repo.addSuccess", { - owner: repo.owner, - name: repo.name, - count: repoSkillCount, - }), - { closeButton: true }, - ); - }; - const handleRemoveRepo = async (owner: string, name: string) => { - await skillsApi.removeRepo(owner, name); - toast.success(t("skills.repo.removeSuccess", { owner, name }), { - closeButton: true, - }); - await Promise.all([loadRepos(), loadSkills()]); + try { + await removeRepoMutation.mutateAsync({ owner, name }); + toast.success(t("skills.repo.removeSuccess", { owner, name }), { + closeButton: true, + }); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } }; // 过滤技能列表 diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx new file mode 100644 index 000000000..052ce2033 --- /dev/null +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -0,0 +1,417 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Sparkles, Trash2, ExternalLink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { + useInstalledSkills, + useToggleSkillApp, + useUninstallSkill, + useScanUnmanagedSkills, + useImportSkillsFromApps, + type InstalledSkill, + type AppType, +} from "@/hooks/useSkills"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { settingsApi } from "@/lib/api"; +import { toast } from "sonner"; + +interface UnifiedSkillsPanelProps { + onOpenDiscovery: () => void; +} + +/** + * 统一 Skills 管理面板 + * v3.10.0 新架构:所有 Skills 统一管理,每个 Skill 通过开关控制应用到哪些客户端 + */ +export interface UnifiedSkillsPanelHandle { + openDiscovery: () => void; + openImport: () => void; +} + +const UnifiedSkillsPanel = React.forwardRef< + UnifiedSkillsPanelHandle, + UnifiedSkillsPanelProps +>(({ onOpenDiscovery }, ref) => { + const { t } = useTranslation(); + const [confirmDialog, setConfirmDialog] = useState<{ + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + } | null>(null); + const [importDialogOpen, setImportDialogOpen] = useState(false); + + // Queries and Mutations + const { data: skills, isLoading } = useInstalledSkills(); + const toggleAppMutation = useToggleSkillApp(); + const uninstallMutation = useUninstallSkill(); + const { data: unmanagedSkills, refetch: scanUnmanaged } = + useScanUnmanagedSkills(); + const importMutation = useImportSkillsFromApps(); + + // Count enabled skills per app + const enabledCounts = useMemo(() => { + const counts = { claude: 0, codex: 0, gemini: 0 }; + if (!skills) return counts; + skills.forEach((skill) => { + if (skill.apps.claude) counts.claude++; + if (skill.apps.codex) counts.codex++; + if (skill.apps.gemini) counts.gemini++; + }); + return counts; + }, [skills]); + + const handleToggleApp = async ( + id: string, + app: AppType, + enabled: boolean, + ) => { + try { + await toggleAppMutation.mutateAsync({ id, app, enabled }); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + const handleUninstall = (skill: InstalledSkill) => { + setConfirmDialog({ + isOpen: true, + title: t("skills.uninstall"), + message: t("skills.uninstallConfirm", { name: skill.name }), + onConfirm: async () => { + try { + await uninstallMutation.mutateAsync(skill.id); + setConfirmDialog(null); + toast.success(t("skills.uninstallSuccess", { name: skill.name }), { + closeButton: true, + }); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }, + }); + }; + + const handleOpenImport = async () => { + try { + const result = await scanUnmanaged(); + if (!result.data || result.data.length === 0) { + toast.success(t("skills.noUnmanagedFound"), { closeButton: true }); + return; + } + setImportDialogOpen(true); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + const handleImport = async (directories: string[]) => { + try { + const imported = await importMutation.mutateAsync(directories); + setImportDialogOpen(false); + toast.success( + t("skills.importSuccess", { count: imported.length }), + { closeButton: true }, + ); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + React.useImperativeHandle(ref, () => ({ + openDiscovery: onOpenDiscovery, + openImport: handleOpenImport, + })); + + return ( +
+ {/* Info Section */} +
+
+ {t("skills.installed", { count: skills?.length || 0 })} ·{" "} + {t("skills.apps.claude")}: {enabledCounts.claude} ·{" "} + {t("skills.apps.codex")}: {enabledCounts.codex} ·{" "} + {t("skills.apps.gemini")}: {enabledCounts.gemini} +
+
+ + {/* Content - Scrollable */} +
+ {isLoading ? ( +
+ {t("skills.loading")} +
+ ) : !skills || skills.length === 0 ? ( +
+
+ +
+

+ {t("skills.noInstalled")} +

+

+ {t("skills.noInstalledDescription")} +

+
+ ) : ( +
+ {skills.map((skill) => ( + handleUninstall(skill)} + /> + ))} +
+ )} +
+ + {/* Confirm Dialog */} + {confirmDialog && ( + setConfirmDialog(null)} + /> + )} + + {/* Import Dialog */} + {importDialogOpen && unmanagedSkills && ( + setImportDialogOpen(false)} + /> + )} +
+ ); +}); + +UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel"; + +/** + * 已安装 Skill 列表项组件 + */ +interface InstalledSkillListItemProps { + skill: InstalledSkill; + onToggleApp: (id: string, app: AppType, enabled: boolean) => void; + onUninstall: () => void; +} + +const InstalledSkillListItem: React.FC = ({ + skill, + onToggleApp, + onUninstall, +}) => { + const { t } = useTranslation(); + + const openDocs = async () => { + if (!skill.readmeUrl) return; + try { + await settingsApi.openExternal(skill.readmeUrl); + } catch { + // ignore + } + }; + + // 生成来源标签 + const sourceLabel = useMemo(() => { + if (skill.repoOwner && skill.repoName) { + return `${skill.repoOwner}/${skill.repoName}`; + } + return t("skills.local"); + }, [skill.repoOwner, skill.repoName, t]); + + return ( +
+ {/* 左侧:Skill 信息 */} +
+
+

{skill.name}

+ {skill.readmeUrl && ( + + )} +
+ {skill.description && ( +

+ {skill.description} +

+ )} +

{sourceLabel}

+
+ + {/* 中间:应用开关 */} +
+
+ + + onToggleApp(skill.id, "claude", checked) + } + /> +
+ +
+ + + onToggleApp(skill.id, "codex", checked) + } + /> +
+ +
+ + + onToggleApp(skill.id, "gemini", checked) + } + /> +
+
+ + {/* 右侧:删除按钮 */} +
+ +
+
+ ); +}; + +/** + * 导入 Skills 对话框 + */ +interface ImportSkillsDialogProps { + skills: Array<{ + directory: string; + name: string; + description?: string; + foundIn: string[]; + }>; + onImport: (directories: string[]) => void; + onClose: () => void; +} + +const ImportSkillsDialog: React.FC = ({ + skills, + onImport, + onClose, +}) => { + const { t } = useTranslation(); + const [selected, setSelected] = useState>( + new Set(skills.map((s) => s.directory)), + ); + + const toggleSelect = (directory: string) => { + const newSelected = new Set(selected); + if (newSelected.has(directory)) { + newSelected.delete(directory); + } else { + newSelected.add(directory); + } + setSelected(newSelected); + }; + + const handleImport = () => { + onImport(Array.from(selected)); + }; + + return ( +
+
+

{t("skills.import")}

+

+ {t("skills.importDescription")} +

+ +
+ {skills.map((skill) => ( + + ))} +
+ +
+ + +
+
+
+ ); +}; + +export default UnifiedSkillsPanel; diff --git a/src/components/usage/RequestLogTable.tsx b/src/components/usage/RequestLogTable.tsx index 6f6436833..92b7097c1 100644 --- a/src/components/usage/RequestLogTable.tsx +++ b/src/components/usage/RequestLogTable.tsx @@ -62,6 +62,28 @@ export function RequestLogTable() { }); }; + // 将 Unix 时间戳转换为本地时间的 datetime-local 格式 + const timestampToLocalDatetime = (timestamp: number): string => { + const date = new Date(timestamp * 1000); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }; + + // 将 datetime-local 格式转换为 Unix 时间戳 + const localDatetimeToTimestamp = (datetime: string): number | undefined => { + if (!datetime) return undefined; + // 验证格式是否完整 (YYYY-MM-DDTHH:mm) + if (datetime.length < 16) return undefined; + const timestamp = new Date(datetime).getTime(); + // 验证是否为有效日期 + if (isNaN(timestamp)) return undefined; + return Math.floor(timestamp / 1000); + }; + const dateLocale = i18n.language === "zh" ? "zh-CN" @@ -153,19 +175,16 @@ export function RequestLogTable() { className="h-8 w-[200px] bg-background" value={ tempFilters.startDate - ? new Date(tempFilters.startDate * 1000) - .toISOString() - .slice(0, 16) + ? timestampToLocalDatetime(tempFilters.startDate) : "" } - onChange={(e) => + onChange={(e) => { + const timestamp = localDatetimeToTimestamp(e.target.value); setTempFilters({ ...tempFilters, - startDate: e.target.value - ? Math.floor(new Date(e.target.value).getTime() / 1000) - : undefined, - }) - } + startDate: timestamp, + }); + }} /> - + onChange={(e) => { + const timestamp = localDatetimeToTimestamp(e.target.value); setTempFilters({ ...tempFilters, - endDate: e.target.value - ? Math.floor(new Date(e.target.value).getTime() / 1000) - : undefined, - }) - } + endDate: timestamp, + }); + }} />
diff --git a/src/components/usage/UsageSummaryCards.tsx b/src/components/usage/UsageSummaryCards.tsx index 5ccae8651..f3a6176ba 100644 --- a/src/components/usage/UsageSummaryCards.tsx +++ b/src/components/usage/UsageSummaryCards.tsx @@ -12,13 +12,7 @@ interface UsageSummaryCardsProps { export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { const { t } = useTranslation(); - const { startDate, endDate } = useMemo(() => { - const end = Math.floor(Date.now() / 1000); - const start = end - days * 24 * 60 * 60; - return { startDate: start, endDate: end }; - }, [days]); - - const { data: summary, isLoading } = useUsageSummary(startDate, endDate); + const { data: summary, isLoading } = useUsageSummary(days); const stats = useMemo(() => { const totalRequests = summary?.totalRequests ?? 0; diff --git a/src/components/usage/UsageTrendChart.tsx b/src/components/usage/UsageTrendChart.tsx index 3fe75d9ff..376014e02 100644 --- a/src/components/usage/UsageTrendChart.tsx +++ b/src/components/usage/UsageTrendChart.tsx @@ -41,7 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { return { rawDate: stat.date, label: isToday - ? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" }) + ? pointDate.toLocaleString(dateLocale, { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) : pointDate.toLocaleDateString(dateLocale, { month: "2-digit", day: "2-digit", @@ -49,28 +54,13 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { hour: pointDate.getHours(), inputTokens: stat.totalInputTokens, outputTokens: stat.totalOutputTokens, + cacheCreationTokens: stat.totalCacheCreationTokens, + cacheReadTokens: stat.totalCacheReadTokens, cost: parseFloat(stat.totalCost), }; }) || []; - const hourlyData = (() => { - if (!isToday) return chartData; - const map = new Map(); - chartData.forEach((point) => { - map.set(point.hour ?? 0, point); - }); - return Array.from({ length: 24 }, (_, hour) => { - const bucket = map.get(hour); - return { - label: `${hour.toString().padStart(2, "0")}:00`, - inputTokens: bucket?.inputTokens ?? 0, - outputTokens: bucket?.outputTokens ?? 0, - cost: bucket?.cost ?? 0, - }; - }); - })(); - - const displayData = isToday ? hourlyData : chartData; + const displayData = chartData; const CustomTooltip = ({ active, payload, label }: any) => { if (active && payload && payload.length) { @@ -131,6 +121,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) { + + + + + + + + + + (value: T | null | undefined): T | null { + const ref = useRef(null); + + // 同步更新 ref(在渲染期间,不在 useEffect 中) + if (value != null) { + ref.current = value; + } + + // 返回当前值或最后有效值 + return value ?? ref.current; +} diff --git a/src/hooks/useMcp.ts b/src/hooks/useMcp.ts index 4af74f3ee..ff9f505fd 100644 --- a/src/hooks/useMcp.ts +++ b/src/hooks/useMcp.ts @@ -59,3 +59,16 @@ export function useDeleteMcpServer() { }, }); } + +/** + * 从所有应用导入 MCP 服务器 + */ +export function useImportMcpFromApps() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => mcpApi.importFromApps(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }); + }, + }); +} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts new file mode 100644 index 000000000..280f15125 --- /dev/null +++ b/src/hooks/useSkills.ts @@ -0,0 +1,151 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + skillsApi, + type AppType, + type DiscoverableSkill, + type InstalledSkill, +} from "@/lib/api/skills"; + +/** + * 查询所有已安装的 Skills + */ +export function useInstalledSkills() { + return useQuery({ + queryKey: ["skills", "installed"], + queryFn: () => skillsApi.getInstalled(), + }); +} + +/** + * 发现可安装的 Skills(从仓库获取) + */ +export function useDiscoverableSkills() { + return useQuery({ + queryKey: ["skills", "discoverable"], + queryFn: () => skillsApi.discoverAvailable(), + staleTime: Infinity, // 无限缓存,直到仓库变化时 invalidate + }); +} + +/** + * 安装 Skill + */ +export function useInstallSkill() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + skill, + currentApp, + }: { + skill: DiscoverableSkill; + currentApp: AppType; + }) => skillsApi.installUnified(skill, currentApp), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +/** + * 卸载 Skill + */ +export function useUninstallSkill() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => skillsApi.uninstallUnified(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +/** + * 切换 Skill 在特定应用的启用状态 + */ +export function useToggleSkillApp() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + id, + app, + enabled, + }: { + id: string; + app: AppType; + enabled: boolean; + }) => skillsApi.toggleApp(id, app, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + }, + }); +} + +/** + * 扫描未管理的 Skills + */ +export function useScanUnmanagedSkills() { + return useQuery({ + queryKey: ["skills", "unmanaged"], + queryFn: () => skillsApi.scanUnmanaged(), + enabled: false, // 手动触发 + }); +} + +/** + * 从应用目录导入 Skills + */ +export function useImportSkillsFromApps() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (directories: string[]) => skillsApi.importFromApps(directories), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }); + }, + }); +} + +/** + * 获取仓库列表 + */ +export function useSkillRepos() { + return useQuery({ + queryKey: ["skills", "repos"], + queryFn: () => skillsApi.getRepos(), + }); +} + +/** + * 添加仓库 + */ +export function useAddSkillRepo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: skillsApi.addRepo, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "repos"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +/** + * 删除仓库 + */ +export function useRemoveSkillRepo() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ owner, name }: { owner: string; name: string }) => + skillsApi.removeRepo(owner, name), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "repos"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + +// ========== 辅助类型 ========== + +export type { InstalledSkill, DiscoverableSkill, AppType }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 97f6873a5..c10a86f68 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -343,7 +343,6 @@ "anthropicModel": "Main Model", "anthropicSmallFastModel": "Fast Model", "anthropicReasoningModel": "Reasoning Model (Thinking)", - "reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514", "openrouterCompatMode": "OpenRouter Compatibility Mode", "openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.", "anthropicDefaultHaikuModel": "Default Haiku Model", @@ -423,7 +422,7 @@ "cost": "Cost", "perMillion": "(per million)", "trends": "Usage Trends", - "rangeToday": "Today (hourly)", + "rangeToday": "Last 24 hours (hourly)", "rangeLast7Days": "Last 7 days", "rangeLast30Days": "Last 30 days", "totalTokens": "Total Tokens", @@ -436,8 +435,8 @@ "billingModel": "Billing Model", "inputTokens": "Input", "outputTokens": "Output", - "cacheReadTokens": "Cache Read", - "cacheCreationTokens": "Cache Write", + "cacheReadTokens": "Cache Hit", + "cacheCreationTokens": "Cache Creation", "timingInfo": "Duration/TTFT", "status": "Status", "noData": "No data", @@ -453,8 +452,8 @@ "displayName": "Display Name", "inputCost": "Input Cost", "outputCost": "Output Cost", - "cacheReadCost": "Cache Read", - "cacheWriteCost": "Cache Write", + "cacheReadCost": "Cache Hit", + "cacheWriteCost": "Cache Creation", "deleteConfirmTitle": "Confirm Delete", "deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.", "queryFailed": "Query failed", @@ -481,8 +480,8 @@ "timeRange": "Time Range", "input": "Input", "output": "Output", - "cacheWrite": "Write", - "cacheRead": "Read" + "cacheWrite": "Creation", + "cacheRead": "Hit" }, "usageScript": { "title": "Configure Usage Query", @@ -565,6 +564,9 @@ }, "mcp": { "title": "MCP Management", + "import": "Import", + "importExisting": "Import Existing", + "addMcp": "Add MCP", "claudeTitle": "Claude Code MCP Management", "codexTitle": "Codex MCP Management", "geminiTitle": "Gemini MCP Management", @@ -576,6 +578,8 @@ "deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.", "noServers": "No servers yet", "enabledApps": "Enabled Apps", + "noImportFound": "No MCP servers to import found. All servers are already managed by CC Switch.", + "importSuccess": "Successfully imported {{count}} MCP servers", "apps": { "claude": "Claude", "codex": "Codex", @@ -793,8 +797,8 @@ }, "skills": { "manage": "Skills", - "title": "Claude Skills Management", - "description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities", + "title": "Skills Management", + "description": "Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities", "refresh": "Refresh", "refreshing": "Refreshing...", "repoManager": "Repository Management", @@ -870,7 +874,25 @@ "installed": "Installed", "uninstalled": "Not installed" }, - "noResults": "No matching skills found" + "noResults": "No matching skills found", + "noInstalled": "No skills installed", + "noInstalledDescription": "Discover and install skills from repositories, or import existing skills", + "discover": "Discover Skills", + "import": "Import Existing", + "importDescription": "Select skills to import into CC Switch unified management", + "importSuccess": "Successfully imported {{count}} skills", + "importSelected": "Import Selected ({{count}})", + "noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.", + "foundIn": "Found in", + "local": "Local", + "uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.", + "uninstallInMainPanel": "Please uninstall skills from the main panel", + "notFound": "Skill not found", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } }, "deeplink": { "confirmImport": "Confirm Import Provider", @@ -958,7 +980,10 @@ "clickToSelect": "Click to select icon" }, "migration": { - "success": "Configuration migrated successfully" + "success": "Configuration migrated successfully", + "skillsSuccess": "Automatically imported {{count}} skill(s) into unified management", + "skillsFailed": "Failed to auto import skills", + "skillsFailedDescription": "Open the Skills page and click \"Import Existing\" to import manually (or restart and try again)." }, "agents": { "title": "Agents" diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1a27b4715..73746946d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -343,7 +343,6 @@ "anthropicModel": "メインモデル", "anthropicSmallFastModel": "高速モデル", "anthropicReasoningModel": "推論モデル(Thinking)", - "reasoningModelPlaceholder": "例: claude-sonnet-4-20250514", "openrouterCompatMode": "OpenRouter 互換モード", "openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。", "anthropicDefaultHaikuModel": "既定 Haiku モデル", @@ -423,7 +422,7 @@ "cost": "コスト", "perMillion": "(100万あたり)", "trends": "利用トレンド", - "rangeToday": "今日 (時間別)", + "rangeToday": "直近24時間 (時間別)", "rangeLast7Days": "過去7日間", "rangeLast30Days": "過去30日間", "totalTokens": "総トークン数", @@ -436,8 +435,8 @@ "billingModel": "課金モデル", "inputTokens": "入力", "outputTokens": "出力", - "cacheReadTokens": "キャッシュ読取", - "cacheCreationTokens": "キャッシュ書込", + "cacheReadTokens": "キャッシュヒット", + "cacheCreationTokens": "キャッシュ作成", "timingInfo": "応答時間/TTFT", "status": "ステータス", "noData": "データなし", @@ -453,8 +452,8 @@ "displayName": "表示名", "inputCost": "入力コスト", "outputCost": "出力コスト", - "cacheReadCost": "キャッシュ読取", - "cacheWriteCost": "キャッシュ書込", + "cacheReadCost": "キャッシュヒット", + "cacheWriteCost": "キャッシュ作成", "deleteConfirmTitle": "削除の確認", "deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。", "queryFailed": "照会に失敗しました", @@ -481,8 +480,8 @@ "timeRange": "期間", "input": "Input", "output": "Output", - "cacheWrite": "Write", - "cacheRead": "Read" + "cacheWrite": "作成", + "cacheRead": "ヒット" }, "usageScript": { "title": "利用状況を設定", @@ -565,6 +564,9 @@ }, "mcp": { "title": "MCP 管理", + "import": "インポート", + "importExisting": "既存をインポート", + "addMcp": "MCPを追加", "claudeTitle": "Claude Code MCP 管理", "codexTitle": "Codex MCP 管理", "geminiTitle": "Gemini MCP 管理", @@ -576,6 +578,8 @@ "deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。", "noServers": "まだサーバーがありません", "enabledApps": "有効なアプリ", + "noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。", + "importSuccess": "{{count}} 個の MCP サーバーをインポートしました", "apps": { "claude": "Claude", "codex": "Codex", @@ -793,8 +797,8 @@ }, "skills": { "manage": "Skills", - "title": "Claude スキル管理", - "description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張", + "title": "Skills 管理", + "description": "人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張", "refresh": "更新", "refreshing": "更新中...", "repoManager": "リポジトリ管理", @@ -870,7 +874,25 @@ "installed": "インストール済み", "uninstalled": "未インストール" }, - "noResults": "一致するスキルが見つかりませんでした" + "noResults": "一致するスキルが見つかりませんでした", + "noInstalled": "インストールされたスキルがありません", + "noInstalledDescription": "リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください", + "discover": "スキルを発見", + "import": "既存をインポート", + "importDescription": "CC Switch 統合管理にインポートするスキルを選択してください", + "importSuccess": "{{count}} 件のスキルをインポートしました", + "importSelected": "選択をインポート ({{count}})", + "noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。", + "foundIn": "発見場所", + "local": "ローカル", + "uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。", + "uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください", + "notFound": "スキルが見つかりません", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } }, "deeplink": { "confirmImport": "プロバイダーのインポートを確認", @@ -958,7 +980,10 @@ "clickToSelect": "クリックでアイコンを選択" }, "migration": { - "success": "設定の移行が完了しました" + "success": "設定の移行が完了しました", + "skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました", + "skillsFailed": "スキルの自動インポートに失敗しました", + "skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。" }, "agents": { "title": "エージェント" diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 73bf58558..2f4bec1c6 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -343,7 +343,6 @@ "anthropicModel": "主模型", "anthropicSmallFastModel": "快速模型", "anthropicReasoningModel": "推理模型 (Thinking)", - "reasoningModelPlaceholder": "如 claude-sonnet-4-20250514", "openrouterCompatMode": "OpenRouter 兼容模式", "openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。", "anthropicDefaultHaikuModel": "Haiku 默认模型", @@ -423,7 +422,7 @@ "cost": "成本", "perMillion": "(每百万)", "trends": "使用趋势", - "rangeToday": "今天 (按小时)", + "rangeToday": "过去 24 小时 (按小时)", "rangeLast7Days": "过去 7 天", "rangeLast30Days": "过去 30 天", "totalTokens": "总 Token 数", @@ -436,8 +435,8 @@ "billingModel": "计费模型", "inputTokens": "输入", "outputTokens": "输出", - "cacheReadTokens": "缓存读取", - "cacheCreationTokens": "缓存写入", + "cacheReadTokens": "缓存命中", + "cacheCreationTokens": "缓存创建", "timingInfo": "用时/首字", "status": "状态", "noData": "暂无数据", @@ -453,8 +452,8 @@ "displayName": "显示名称", "inputCost": "输入成本", "outputCost": "输出成本", - "cacheReadCost": "缓存读取", - "cacheWriteCost": "缓存写入", + "cacheReadCost": "缓存命中", + "cacheWriteCost": "缓存创建", "deleteConfirmTitle": "确认删除", "deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。", "queryFailed": "查询失败", @@ -481,8 +480,8 @@ "timeRange": "时间范围", "input": "Input", "output": "Output", - "cacheWrite": "Write", - "cacheRead": "Read" + "cacheWrite": "创建", + "cacheRead": "命中" }, "usageScript": { "title": "配置用量查询", @@ -565,6 +564,9 @@ }, "mcp": { "title": "MCP 管理", + "import": "导入", + "importExisting": "导入已有", + "addMcp": "添加MCP", "claudeTitle": "Claude Code MCP 管理", "codexTitle": "Codex MCP 管理", "geminiTitle": "Gemini MCP 管理", @@ -576,6 +578,8 @@ "deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。", "noServers": "暂无服务器", "enabledApps": "启用的应用", + "noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。", + "importSuccess": "成功导入 {{count}} 个 MCP 服务器", "apps": { "claude": "Claude", "codex": "Codex", @@ -793,8 +797,8 @@ }, "skills": { "manage": "Skills", - "title": "Claude Skills 管理", - "description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力", + "title": "Skills 管理", + "description": "从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力", "refresh": "刷新", "refreshing": "刷新中...", "repoManager": "仓库管理", @@ -870,7 +874,25 @@ "installed": "已安装", "uninstalled": "未安装" }, - "noResults": "未找到匹配的技能" + "noResults": "未找到匹配的技能", + "noInstalled": "暂无已安装的技能", + "noInstalledDescription": "从仓库发现并安装技能,或导入已有的技能", + "discover": "发现技能", + "import": "导入已有", + "importDescription": "选择要导入到 CC Switch 统一管理的技能", + "importSuccess": "成功导入 {{count}} 个技能", + "importSelected": "导入已选 ({{count}})", + "noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。", + "foundIn": "发现于", + "local": "本地", + "uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。", + "uninstallInMainPanel": "请在主面板中卸载技能", + "notFound": "未找到技能", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } }, "deeplink": { "confirmImport": "确认导入供应商配置", @@ -958,7 +980,10 @@ "clickToSelect": "点击选择图标" }, "migration": { - "success": "配置迁移成功" + "success": "配置迁移成功", + "skillsSuccess": "已自动导入 {{count}} 个技能到统一管理", + "skillsFailed": "自动导入技能失败", + "skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。" }, "agents": { "title": "智能体" diff --git a/src/lib/api/mcp.ts b/src/lib/api/mcp.ts index 4f97754ae..983daf70e 100644 --- a/src/lib/api/mcp.ts +++ b/src/lib/api/mcp.ts @@ -119,4 +119,11 @@ export const mcpApi = { ): Promise { return await invoke("toggle_mcp_app", { serverId, app, enabled }); }, + + /** + * 从所有应用导入 MCP 服务器 + */ + async importFromApps(): Promise { + return await invoke("import_mcp_from_apps"); + }, }; diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts index 862813a2a..09a4d6a45 100644 --- a/src/lib/api/skills.ts +++ b/src/lib/api/skills.ts @@ -1,5 +1,51 @@ import { invoke } from "@tauri-apps/api/core"; +// ========== 类型定义 ========== + +export type AppType = "claude" | "codex" | "gemini"; + +/** Skill 应用启用状态 */ +export interface SkillApps { + claude: boolean; + codex: boolean; + gemini: boolean; +} + +/** 已安装的 Skill(v3.10.0+ 统一结构) */ +export interface InstalledSkill { + id: string; + name: string; + description?: string; + directory: string; + repoOwner?: string; + repoName?: string; + repoBranch?: string; + readmeUrl?: string; + apps: SkillApps; + installedAt: number; +} + +/** 可发现的 Skill(来自仓库) */ +export interface DiscoverableSkill { + key: string; + name: string; + description: string; + directory: string; + readmeUrl?: string; + repoOwner: string; + repoName: string; + repoBranch: string; +} + +/** 未管理的 Skill(用于导入) */ +export interface UnmanagedSkill { + directory: string; + name: string; + description?: string; + foundIn: string[]; +} + +/** 技能对象(兼容旧 API) */ export interface Skill { key: string; name: string; @@ -12,6 +58,7 @@ export interface Skill { repoBranch?: string; } +/** 仓库配置 */ export interface SkillRepo { owner: string; name: string; @@ -19,9 +66,56 @@ export interface SkillRepo { enabled: boolean; } -export type AppType = "claude" | "codex" | "gemini"; +// ========== API ========== export const skillsApi = { + // ========== 统一管理 API (v3.10.0+) ========== + + /** 获取所有已安装的 Skills */ + async getInstalled(): Promise { + return await invoke("get_installed_skills"); + }, + + /** 安装 Skill(统一安装) */ + async installUnified( + skill: DiscoverableSkill, + currentApp: AppType, + ): Promise { + return await invoke("install_skill_unified", { skill, currentApp }); + }, + + /** 卸载 Skill(统一卸载) */ + async uninstallUnified(id: string): Promise { + return await invoke("uninstall_skill_unified", { id }); + }, + + /** 切换 Skill 的应用启用状态 */ + async toggleApp( + id: string, + app: AppType, + enabled: boolean, + ): Promise { + return await invoke("toggle_skill_app", { id, app, enabled }); + }, + + /** 扫描未管理的 Skills */ + async scanUnmanaged(): Promise { + return await invoke("scan_unmanaged_skills"); + }, + + /** 从应用目录导入 Skills */ + async importFromApps(directories: string[]): Promise { + return await invoke("import_skills_from_apps", { directories }); + }, + + /** 发现可安装的 Skills(从仓库获取) */ + async discoverAvailable(): Promise { + return await invoke("discover_available_skills"); + }, + + // ========== 兼容旧 API ========== + + /** 获取技能列表(兼容旧 API) */ async getAll(app: AppType = "claude"): Promise { if (app === "claude") { return await invoke("get_skills"); @@ -29,6 +123,7 @@ export const skillsApi = { return await invoke("get_skills_for_app", { app }); }, + /** 安装技能(兼容旧 API) */ async install(directory: string, app: AppType = "claude"): Promise { if (app === "claude") { return await invoke("install_skill", { directory }); @@ -36,6 +131,7 @@ export const skillsApi = { return await invoke("install_skill_for_app", { app, directory }); }, + /** 卸载技能(兼容旧 API) */ async uninstall( directory: string, app: AppType = "claude", @@ -46,14 +142,19 @@ export const skillsApi = { return await invoke("uninstall_skill_for_app", { app, directory }); }, + // ========== 仓库管理 ========== + + /** 获取仓库列表 */ async getRepos(): Promise { return await invoke("get_skill_repos"); }, + /** 添加仓库 */ async addRepo(repo: SkillRepo): Promise { return await invoke("add_skill_repo", { repo }); }, + /** 删除仓库 */ async removeRepo(owner: string, name: string): Promise { return await invoke("remove_skill_repo", { owner, name }); }, diff --git a/src/lib/api/usage.ts b/src/lib/api/usage.ts index 7d6dcdbb6..4224973f3 100644 --- a/src/lib/api/usage.ts +++ b/src/lib/api/usage.ts @@ -49,8 +49,11 @@ export const usageApi = { return invoke("get_usage_summary", { startDate, endDate }); }, - getUsageTrends: async (days: number): Promise => { - return invoke("get_usage_trends", { days }); + getUsageTrends: async ( + startDate?: number, + endDate?: number, + ): Promise => { + return invoke("get_usage_trends", { startDate, endDate }); }, getProviderStats: async (): Promise => { diff --git a/src/lib/query/usage.ts b/src/lib/query/usage.ts index 176fc3769..bf220c549 100644 --- a/src/lib/query/usage.ts +++ b/src/lib/query/usage.ts @@ -5,8 +5,7 @@ import type { LogFilters } from "@/types/usage"; // Query keys export const usageKeys = { all: ["usage"] as const, - summary: (startDate?: number, endDate?: number) => - [...usageKeys.all, "summary", startDate, endDate] as const, + summary: (days: number) => [...usageKeys.all, "summary", days] as const, trends: (days: number) => [...usageKeys.all, "trends", days] as const, providerStats: () => [...usageKeys.all, "provider-stats"] as const, modelStats: () => [...usageKeys.all, "model-stats"] as const, @@ -19,18 +18,34 @@ export const usageKeys = { [...usageKeys.all, "limits", providerId, appType] as const, }; +const getWindow = (days: number) => { + const endDate = Math.floor(Date.now() / 1000); + const startDate = endDate - days * 24 * 60 * 60; + return { startDate, endDate }; +}; + // Hooks -export function useUsageSummary(startDate?: number, endDate?: number) { +export function useUsageSummary(days: number) { return useQuery({ - queryKey: usageKeys.summary(startDate, endDate), - queryFn: () => usageApi.getUsageSummary(startDate, endDate), + queryKey: usageKeys.summary(days), + queryFn: () => { + const { startDate, endDate } = getWindow(days); + return usageApi.getUsageSummary(startDate, endDate); + }, + refetchInterval: 30000, // 每30秒自动刷新 + refetchIntervalInBackground: false, // 后台不刷新 }); } export function useUsageTrends(days: number) { return useQuery({ queryKey: usageKeys.trends(days), - queryFn: () => usageApi.getUsageTrends(days), + queryFn: () => { + const { startDate, endDate } = getWindow(days); + return usageApi.getUsageTrends(startDate, endDate); + }, + refetchInterval: 30000, // 每30秒自动刷新 + refetchIntervalInBackground: false, }); } @@ -38,6 +53,8 @@ export function useProviderStats() { return useQuery({ queryKey: usageKeys.providerStats(), queryFn: usageApi.getProviderStats, + refetchInterval: 30000, // 每30秒自动刷新 + refetchIntervalInBackground: false, }); } @@ -45,6 +62,8 @@ export function useModelStats() { return useQuery({ queryKey: usageKeys.modelStats(), queryFn: usageApi.getModelStats, + refetchInterval: 30000, // 每30秒自动刷新 + refetchIntervalInBackground: false, }); } @@ -56,6 +75,8 @@ export function useRequestLogs( return useQuery({ queryKey: usageKeys.logs(filters, page, pageSize), queryFn: () => usageApi.getRequestLogs(filters, page, pageSize), + refetchInterval: 30000, // 每30秒自动刷新 + refetchIntervalInBackground: false, }); } diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx index 177eb4033..c8f7198ac 100644 --- a/tests/integration/App.test.tsx +++ b/tests/integration/App.test.tsx @@ -1,8 +1,7 @@ -import { Suspense } from "react"; +import { Suspense, type ComponentType } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { describe, it, expect, beforeEach, vi } from "vitest"; -import App from "@/App"; import { resetProviderState } from "../msw/state"; import { emitTauriEvent } from "../msw/tauriMocks"; @@ -109,20 +108,6 @@ vi.mock("@/components/ConfirmDialog", () => ({ ) : null, })); -vi.mock("@/components/settings/SettingsPage", () => ({ - SettingsPage: ({ open, onOpenChange, onImportSuccess }: any) => - open ? ( -
- - -
- ) : ( - - ), -})); - vi.mock("@/components/AppSwitcher", () => ({ AppSwitcher: ({ activeApp, onSwitch }: any) => (
@@ -150,12 +135,12 @@ vi.mock("@/components/mcp/McpPanel", () => ({ ), })); -const renderApp = () => { +const renderApp = (AppComponent: ComponentType) => { const client = new QueryClient(); return render( loading
}> - + , ); @@ -169,7 +154,8 @@ describe("App integration with MSW", () => { }); it("covers basic provider flows via real hooks", async () => { - renderApp(); + const { default: App } = await import("@/App"); + renderApp(App); await waitFor(() => expect(screen.getByTestId("provider-list").textContent).toContain( @@ -177,11 +163,6 @@ describe("App integration with MSW", () => { ), ); - fireEvent.click(screen.getByText("update-badge")); - expect(screen.getByTestId("settings-dialog")).toBeInTheDocument(); - fireEvent.click(screen.getByText("trigger-import-success")); - fireEvent.click(screen.getByText("close-settings")); - fireEvent.click(screen.getByText("switch-codex")); await waitFor(() => expect(screen.getByTestId("provider-list").textContent).toContain( diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index df637d662..ae145c001 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -36,6 +36,8 @@ const withJson = async (request: Request): Promise => { const success = (payload: T) => HttpResponse.json(payload as any); export const handlers = [ + http.post(`${TAURI_ENDPOINT}/get_migration_result`, () => success(false)), + http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () => success(null)), http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => { const { app } = await withJson<{ app: AppId }>(request); return success(getProviders(app));