Compare commits

..

4 Commits

Author SHA1 Message Date
Jason 2af8c82977 style: cargo fmt 2025-12-29 17:23:17 +08:00
Jason d4286ab90a fix: migrate proxy_config singleton to per-app on startup for v2 databases
Add startup migration for legacy proxy_config tables that still have
singleton structure (no app_type column) even with user_version=2.

This fixes the issue where v3.9.0-2 databases with v2 schema but legacy
proxy_config structure would fail with "no such column: app_type" error.

- Call migrate_proxy_config_to_per_app in create_tables_on_conn
- Add regression test to verify the fix
2025-12-29 17:21:06 +08:00
YoVinchen f7c6947220 fix: add proxy_config base column patches for v3.9.0-2 upgrade
Add base config column patches in create_tables_on_conn():
- proxy_enabled
- listen_address
- listen_port
- enable_logging

Ensures v3.9.0-2 users (user_version=2 but missing columns)
can properly upgrade with all required fields added.
2025-12-29 11:13:44 +08:00
YoVinchen 8fccc2a286 fix(schema): add missing base columns migration for proxy_config
Add compatibility migration for older databases that may be missing
the basic proxy_config columns (proxy_enabled, listen_address,
listen_port, enable_logging) before adding newer timeout fields.
2025-12-29 10:02:35 +08:00
73 changed files with 1299 additions and 4510 deletions
-58
View File
@@ -5,64 +5,6 @@ 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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cc-switch",
"version": "3.9.0-3",
"version": "3.9.0-2",
"description": "All-in-One Assistant for Claude Code, Codex & Gemini CLI",
"type": "module",
"scripts": {
+1 -1
View File
@@ -701,7 +701,7 @@ dependencies = [
[[package]]
name = "cc-switch"
version = "3.9.0-3"
version = "3.9.0-2"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cc-switch"
version = "3.9.0-3"
version = "3.9.0-2"
description = "All-in-One Assistant for Claude Code, Codex & Gemini CLI"
authors = ["Jason Young"]
license = "MIT"
-104
View File
@@ -55,110 +55,6 @@ 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<AppType> {
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
}
}
/// 已安装的 Skillv3.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<String>,
/// 安装目录名(在 SSOT 目录中的子目录名)
pub directory: String,
/// 仓库所有者(GitHub 用户/组织)
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_owner: Option<String>,
/// 仓库名称
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_name: Option<String>,
/// 仓库分支
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_branch: Option<String>,
/// README URL
#[serde(skip_serializing_if = "Option::is_none")]
pub readme_url: Option<String>,
/// 应用启用状态
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<String>,
/// 在哪些应用目录中发现(如 ["claude", "codex"]
pub found_in: Vec<String>,
}
/// MCP 服务器定义(v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
-194
View File
@@ -7,64 +7,6 @@ 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<String, Value>) {
// 只处理 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<String, Value>) {
// 非 Windows 平台不做任何处理
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpStatus {
@@ -397,9 +339,6 @@ 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));
}
@@ -413,136 +352,3 @@ 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"]));
}
}
}
-10
View File
@@ -192,13 +192,3 @@ 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<usize, String> {
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)
}
+3 -16
View File
@@ -1,6 +1,6 @@
#![allow(non_snake_case)]
use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use crate::init_status::InitErrorPayload;
use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt;
@@ -65,13 +65,6 @@ pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success())
}
/// 获取 Skills 自动导入(SSOT)迁移结果(若有)。
/// 只返回一次 Some({count}),之后返回 None,用于前端显示一次性 Toast 通知。
#[tauri::command]
pub async fn get_skills_migration_result() -> Result<Option<SkillsMigrationPayload>, String> {
Ok(crate::init_status::take_skills_migration_result())
}
#[derive(serde::Serialize)]
pub struct ToolVersion {
name: String,
@@ -255,18 +248,12 @@ fn scan_cli_version(tool: &str) -> (Option<String>, Option<String>) {
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 = {
// 使用 cmd /C 包装执行,确保子进程也在隐藏的控制台中运行
Command::new("cmd")
.args(["/C", &format!("\"{}\" --version", tool_path.display())])
Command::new(&tool_path)
.arg("--version")
.env("PATH", &new_path)
.creation_flags(CREATE_NO_WINDOW)
.output()
+7 -6
View File
@@ -184,16 +184,17 @@ pub async fn reset_circuit_breaker(
.await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
// 只有当该应用已被代理接管(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),
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => config.auto_failover_enabled,
Err(e) => {
log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
(false, false)
log::error!(
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled"
);
false
}
};
if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
if auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID
let current_id = db
.get_current_provider(&app_type)
+127 -142
View File
@@ -1,17 +1,12 @@
//! Skills 命令层
//!
//! v3.10.0+ 统一管理架构:
//! - 支持三应用开关(Claude/Codex/Gemini
//! - SSOT 存储在 ~/.cc-switch/skills/
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::app_config::AppType;
use crate::error::format_skill_error;
use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
use crate::services::skill::SkillState;
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc;
use tauri::State;
/// SkillService 状态包装
pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType
@@ -24,117 +19,65 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
}
}
// ========== 统一管理命令 ==========
/// 获取所有已安装的 Skills
#[tauri::command]
pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
/// 根据 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}")
}
/// 安装 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<InstalledSkill, String> {
let app_type = parse_app_type(&current_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<bool, String> {
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<bool, String> {
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<Vec<UnmanagedSkill>, String> {
SkillService::scan_unmanaged(&app_state.db).map_err(|e| e.to_string())
}
/// 从应用目录导入 Skills
#[tauri::command]
pub fn import_skills_from_apps(
directories: Vec<String>,
app_state: State<'_, AppState>,
) -> Result<Vec<InstalledSkill>, 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<Vec<DiscoverableSkill>, 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<Vec<Skill>, String> {
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())
get_skills_for_app("claude".to_string(), service, app_state).await
}
/// 获取指定应用的技能列表(兼容旧 API)
#[tauri::command]
pub async fn get_skills_for_app(
app: String,
service: State<'_, SkillServiceState>,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> {
// 新版本不再区分应用,统一返回所有技能
let _ = parse_app_type(&app)?; // 验证 app 参数有效
get_skills(service, app_state).await
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)
}
/// 安装技能(兼容旧 API
#[tauri::command]
pub async fn install_skill(
directory: String,
@@ -144,34 +87,27 @@ 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<bool, 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
.0
.discover_available(repos)
.list_skills(repos)
.await
.map_err(|e| e.to_string())?;
let skill = skills
.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)
})
.iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory))
.ok_or_else(|| {
format_skill_error(
"SKILL_NOT_FOUND",
@@ -180,54 +116,103 @@ pub async fn install_skill_for_app(
)
})?;
service
.0
.install(&app_state.db, &skill, &app_type)
.await
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(),
},
)
.map_err(|e| e.to_string())?;
Ok(true)
}
/// 卸载技能(兼容旧 API
#[tauri::command]
pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, app_state)
pub fn uninstall_skill(
directory: String,
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
}
/// 卸载指定应用的技能(兼容旧 API)
#[tauri::command]
pub fn uninstall_skill_for_app(
app: String,
directory: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
let _ = parse_app_type(&app)?; // 验证参数
let app_type = parse_app_type(&app)?;
let service = SkillService::new_for_app(app_type.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())?;
service
.uninstall_skill(directory.clone())
.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())?;
// 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())?;
Ok(true)
}
// ========== 仓库管理命令 ==========
/// 获取技能仓库列表
#[tauri::command]
pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
pub fn get_skill_repos(
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
app_state.db.get_skill_repos().map_err(|e| e.to_string())
}
/// 添加技能仓库
#[tauri::command]
pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
pub fn add_skill_repo(
repo: SkillRepo,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
.db
.save_skill_repo(&repo)
@@ -235,11 +220,11 @@ pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result
Ok(true)
}
/// 删除技能仓库
#[tauri::command]
pub fn remove_skill_repo(
owner: String,
name: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state
+2 -3
View File
@@ -19,10 +19,9 @@ pub fn get_usage_summary(
#[tauri::command]
pub fn get_usage_trends(
state: State<'_, AppState>,
start_date: Option<i64>,
end_date: Option<i64>,
days: u32,
) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(start_date, end_date)
state.db.get_daily_trends(days)
}
/// 获取 Provider 统计
+9 -9
View File
@@ -121,13 +121,13 @@ impl Database {
enabled: false,
auto_failover_enabled: false,
max_retries: 3,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
non_streaming_timeout: 600,
circuit_failure_threshold: 4,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 300,
circuit_failure_threshold: 5,
circuit_success_threshold: 2,
circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.6,
circuit_error_rate_threshold: 0.5,
circuit_min_requests: 10,
})
}
@@ -210,12 +210,12 @@ impl Database {
listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 600, // 废弃字段,返回默认值
request_timeout: 300, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(120) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).unwrap_or(60) as u64,
non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(300) as u64,
})
},
)
+37 -121
View File
@@ -1,156 +1,73 @@
//! 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;
use crate::services::skill::{SkillRepo, SkillState};
use indexmap::IndexMap;
use rusqlite::params;
impl Database {
// ========== InstalledSkill CRUD ==========
/// 获取所有已安装的 Skills
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
/// 获取所有 Skills 状态
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, 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 ORDER BY name ASC",
)
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC")
.map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt
.query_map([], |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)?,
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,
},
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 skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(skill.id.clone(), skill);
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill);
}
Ok(skills)
}
/// 获取单个已安装的 Skill
pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, 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()))?;
/// 更新 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)
};
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
(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,
],
"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()],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// 删除 Skill
pub fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
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<bool, AppError> {
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<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn);
@@ -184,8 +101,7 @@ 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(())
}
+7 -10
View File
@@ -192,16 +192,13 @@ impl Database {
tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig,
) -> Result<(), AppError> {
// v3.10.0+Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/+ 数据库统一结构。
//
// 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
// 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
//
// 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
// 迁移后可通过:
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
// - 或后续启动时的自动扫描逻辑
// 来重建已安装技能记录。
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}")))?;
}
for repo in &config.skills.repos {
tx.execute(
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 3;
pub(crate) const SCHEMA_VERSION: i32 = 2;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+52 -331
View File
@@ -71,21 +71,11 @@ impl Database {
PRIMARY KEY (id, app_type)
)", []).map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表v3.10.0+ 统一结构)
// 5. Skills 表
conn.execute(
"CREATE TABLE IF NOT EXISTS 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
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)
)",
[],
)
@@ -114,10 +104,10 @@ impl Database {
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -133,7 +123,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -142,7 +132,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -151,7 +141,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -263,15 +253,15 @@ impl Database {
// 尝试添加超时配置列到 proxy_config 表
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60",
[],
);
let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300",
[],
);
@@ -341,11 +331,6 @@ 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}"
@@ -482,19 +467,19 @@ impl Database {
conn,
"proxy_config",
"streaming_first_byte_timeout",
"INTEGER NOT NULL DEFAULT 60",
"INTEGER NOT NULL DEFAULT 30",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 120",
"INTEGER NOT NULL DEFAULT 60",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"non_streaming_timeout",
"INTEGER NOT NULL DEFAULT 600",
"INTEGER NOT NULL DEFAULT 300",
)?;
}
@@ -666,10 +651,10 @@ impl Database {
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 5000, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.5,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -704,17 +689,6 @@ 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 字段,跳过迁移");
@@ -786,77 +760,14 @@ 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 系列 (Latest Models)
// Claude 4.5 系列
(
"claude-opus-4-5-20251101",
"claude-opus-4-5",
"Claude Opus 4.5",
"5",
"25",
@@ -864,7 +775,7 @@ impl Database {
"6.25",
),
(
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
"3",
"15",
@@ -872,24 +783,16 @@ impl Database {
"3.75",
),
(
"claude-haiku-4-5-20251001",
"claude-haiku-4-5",
"Claude Haiku 4.5",
"1",
"5",
"0.10",
"1.25",
),
// Claude 4 系列 (Legacy Models)
// Claude 4.1 系列
(
"claude-opus-4-20250514",
"Claude Opus 4",
"15",
"75",
"1.50",
"18.75",
),
(
"claude-opus-4-1-20250805",
"claude-opus-4-1",
"Claude Opus 4.1",
"15",
"75",
@@ -897,8 +800,17 @@ impl Database {
"18.75",
),
(
"claude-sonnet-4-20250514",
"Claude Sonnet 4",
"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",
"3",
"15",
"0.30",
@@ -906,167 +818,38 @@ impl Database {
),
// Claude 3.5 系列
(
"claude-3-5-haiku-20241022",
"Claude 3.5 Haiku",
"0.80",
"4",
"0.08",
"1",
),
(
"claude-3-5-sonnet-20241022",
"Claude 3.5 Sonnet",
"claude-sonnet-3-5",
"Claude Sonnet 3.5",
"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",
"claude-haiku-3-5",
"Claude Haiku 3.5",
"0.80",
"4",
"0.08",
"1",
),
(
"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 系列(model_id 使用短横线格式)
("gpt-5", "GPT-5", "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-1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5-codex", "GPT-5 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",
),
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
// Gemini 3 系列
(
"gemini-3-pro-preview",
"Gemini 3 Pro Preview",
"2",
"12",
"0.2",
"0",
"0",
),
// Gemini 2.5 系列(model_id 使用短横线格式)
(
"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",
"Gemini 2.5 Pro",
"1.25",
"10",
@@ -1074,75 +857,13 @@ 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 {
+7 -6
View File
@@ -53,6 +53,7 @@ const LEGACY_SCHEMA_SQL: &str = r#"
#[derive(Debug)]
struct ColumnInfo {
name: String,
r#type: String,
notnull: i64,
default: Option<String>,
@@ -64,9 +65,10 @@ 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 column_name: String = row.get(1).expect("name");
if column_name.eq_ignore_ascii_case(column) {
let name: String = row.get(1).expect("name");
if 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<String>>(4).ok().flatten(),
@@ -294,10 +296,9 @@ fn dry_run_validates_schema_compatibility() {
},
);
let manager = ProviderManager {
providers,
current: "test-provider".to_string(),
};
let mut manager = ProviderManager::default();
manager.providers = providers;
manager.current = "test-provider".to_string();
let mut apps = HashMap::new();
apps.insert("claude".to_string(), manager);
+3 -3
View File
@@ -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!(request.enabled.unwrap());
assert_eq!(request.enabled.unwrap(), true);
}
#[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!(request.enabled.unwrap());
assert_eq!(request.enabled.unwrap(), true);
}
#[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");
-4
View File
@@ -52,10 +52,6 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
}
impl AppError {
-41
View File
@@ -52,47 +52,6 @@ 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<String>,
}
static SKILLS_MIGRATION_RESULT: OnceLock<RwLock<Option<SkillsMigrationPayload>>> = OnceLock::new();
fn skills_migration_cell() -> &'static RwLock<Option<SkillsMigrationPayload>> {
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<SkillsMigrationPayload> {
if let Ok(mut guard) = skills_migration_cell().write() {
guard.take()
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
+9 -54
View File
@@ -324,47 +324,6 @@ 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,
@@ -548,8 +507,14 @@ pub fn run() {
app.manage(app_state);
// 初始化 SkillService
let skill_service = SkillService::new();
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
match SkillService::new() {
Ok(skill_service) => {
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
}
// 异常退出恢复 + 代理状态自动恢复
let app_handle = app.handle().clone();
@@ -599,7 +564,6 @@ 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,
@@ -637,7 +601,6 @@ 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,
@@ -672,15 +635,7 @@ pub fn run() {
commands::check_env_conflicts,
commands::delete_env_vars,
commands::restore_env_backup,
// 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)
// Skill management
commands::get_skills,
commands::get_skills_for_app,
commands::install_skill,
+5 -4
View File
@@ -386,15 +386,16 @@ impl UniversalProvider {
// 生成 Codex 的 config.toml 内容
let config_toml = format!(
r#"model_provider = "newapi"
model = "{model}"
model_reasoning_effort = "{reasoning_effort}"
model = "{}"
model_reasoning_effort = "{}"
disable_response_storage = true
[model_providers.newapi]
name = "NewAPI"
base_url = "{codex_base_url}"
base_url = "{}"
wire_api = "responses"
requires_openai_auth = true"#
requires_openai_auth = true"#,
model, reasoning_effort, codex_base_url
);
let settings_config = serde_json::json!({
-303
View File
@@ -1,303 +0,0 @@
//! 请求体过滤模块
//!
//! 过滤不应透传到上游的私有参数,防止内部信息泄露。
//!
//! ## 过滤规则
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
//! - 支持嵌套对象和数组的深度过滤
//!
//! ## 使用场景
//! - `_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<String>) -> Value {
filter_recursive_with_whitelist(value, removed_keys, &HashSet::new())
}
/// 递归过滤实现(支持白名单)
fn filter_recursive_with_whitelist(
value: Value,
removed_keys: &mut Vec<String>,
whitelist: &HashSet<&str>,
) -> Value {
match value {
Value::Object(map) => {
let filtered: serde_json::Map<String, Value> = 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);
}
}
+2 -2
View File
@@ -49,10 +49,10 @@ pub struct CircuitBreakerConfig {
impl Default for CircuitBreakerConfig {
fn default() -> Self {
Self {
failure_threshold: 4,
failure_threshold: 5,
success_threshold: 2,
timeout_seconds: 60,
error_rate_threshold: 0.6,
error_rate_threshold: 0.5,
min_requests: 10,
}
}
-12
View File
@@ -23,12 +23,6 @@ pub enum ProxyError {
#[error("无可用的Provider")]
NoAvailableProvider,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
#[allow(dead_code)]
#[error("Provider不健康: {0}")]
ProviderUnhealthy(String),
@@ -117,12 +111,6 @@ 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())
}
-8
View File
@@ -27,12 +27,6 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
// 无可用 Provider503 Service Unavailable
ProxyError::NoAvailableProvider => 503,
// 所有供应商已熔断:503 Service Unavailable
ProxyError::AllProvidersCircuitOpen => 503,
// 未配置供应商:503 Service Unavailable
ProxyError::NoProvidersConfigured => 503,
// 重试耗尽:503 Service Unavailable
ProxyError::MaxRetriesExceeded => 503,
@@ -63,8 +57,6 @@ 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}"),
-15
View File
@@ -81,21 +81,6 @@ impl FailoverSwitchManager {
provider_id: &str,
provider_name: &str,
) -> Result<bool, AppError> {
// 检查该应用是否已被代理接管(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
+97 -187
View File
@@ -1,9 +1,8 @@
//! 请求转发器
//!
//! 负责将请求转发到上游Provider,支持故障转移
//! 负责将请求转发到上游Provider,支持重试和故障转移
use super::{
body_filter::filter_private_params_with_whitelist,
error::*,
failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter,
@@ -18,71 +17,6 @@ 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,
@@ -97,6 +31,8 @@ pub struct RequestForwarder {
client: Client,
/// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>,
/// 单个 Provider 内的最大重试次数
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
/// 故障转移切换管理器
@@ -112,6 +48,7 @@ impl RequestForwarder {
pub fn new(
router: Arc<ProviderRouter>,
non_streaming_timeout: u64,
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>,
@@ -140,6 +77,7 @@ impl RequestForwarder {
Self {
client,
router,
max_retries,
status,
current_providers,
failover_manager,
@@ -148,6 +86,59 @@ 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<Response, ProxyError> {
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
@@ -233,9 +224,9 @@ impl RequestForwarder {
let start = Instant::now();
// 转发请求(每个 Provider 只尝试一次,重试由客户端控制
// 转发请求(带单 Provider 内重试
match self
.forward(provider, endpoint, &body, &headers, adapter.as_ref())
.forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref())
.await
{
Ok(response) => {
@@ -486,28 +477,6 @@ 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(),
@@ -518,73 +487,28 @@ impl RequestForwarder {
// 构建请求
let mut request = self.client.post(&url);
// ========== 详细 Headers 日志 ==========
log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name());
for (key, value) in headers {
log::info!(
"[{}] {}: {:?}",
adapter.name(),
key.as_str(),
value.to_str().unwrap_or("<binary>")
);
}
// 过滤黑名单 Headers,保护隐私并避免冲突
let mut filtered_headers: Vec<String> = Vec::new();
let mut passed_headers: Vec<(String, String)> = Vec::new();
// 只透传必要的 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",
];
for (key, value) in headers {
let key_str = key.as_str().to_lowercase();
if HEADER_BLACKLIST.contains(&key_str.as_str()) {
filtered_headers.push(key_str);
continue;
}
let value_str = value.to_str().unwrap_or("<binary>").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);
if allowed_headers.contains(&key_str.as_str()) {
request = request.header(key, value);
}
}
// 处理 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()));
// 确保 Content-Type 是 json
request = request.header("Content-Type", "application/json");
// 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) {
@@ -595,15 +519,6 @@ 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 KeyProvider: {}",
@@ -612,34 +527,9 @@ 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(&filtered_body).send().await.map_err(|e| {
let response = request.json(&request_body).send().await.map_err(|e| {
log::error!("[{}] 请求失败: {}", adapter.name(), e);
if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}"))
@@ -673,6 +563,25 @@ 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 {
// 网络和上游错误:都应该尝试下一个供应商
@@ -688,6 +597,7 @@ impl RequestForwarder {
ProxyError::TransformError(_) => ErrorCategory::Retryable,
ProxyError::AuthError(_) => ErrorCategory::Retryable,
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable,
// 无可用供应商:所有供应商都试过了,无法重试
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题
+7 -11
View File
@@ -58,10 +58,10 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
.to_string()
}
/// Codex 智能流式响应模型提取(自动检测格式
fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
/// Codex Responses API 流式响应模型提取(优先使用 usage.model
fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
if let Some(usage) = TokenUsage::from_codex_stream_events(events) {
if let Some(model) = usage.model {
return model;
}
@@ -76,10 +76,6 @@ fn codex_auto_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()
}
@@ -115,11 +111,11 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
app_type_str: "codex",
};
/// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式
/// Codex Responses API 解析配置(用于 /v1/responses
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events_auto,
response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_auto_model_extractor,
stream_parser: TokenUsage::from_codex_stream_events,
response_parser: TokenUsage::from_codex_response,
model_extractor: codex_model_extractor,
app_type_str: "codex",
};
+12 -72
View File
@@ -5,10 +5,8 @@
use crate::app_config::AppType;
use crate::provider::Provider;
use crate::proxy::{
extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
ProxyError,
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError,
};
use axum::http::HeaderMap;
use std::time::Instant;
/// 流式超时配置
@@ -28,7 +26,6 @@ pub struct StreamingTimeoutConfig {
/// - 选中的 Provider 列表(用于故障转移)
/// - 请求模型名称
/// - 日志标签
/// - Session ID(用于日志关联)
pub struct RequestContext {
/// 请求开始时间
pub start_time: Instant,
@@ -38,7 +35,7 @@ pub struct RequestContext {
pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移)
providers: Vec<Provider>,
/// 请求开始时的"当前供应商"(用于判断是否需要同步 UI/托盘)
/// 请求开始时的当前供应商(用于判断是否需要同步 UI/托盘)
///
/// 这里使用本地 settings 的设备级 current provider。
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
@@ -52,8 +49,6 @@ pub struct RequestContext {
/// 应用类型(预留,目前通过 app_type_str 使用)
#[allow(dead_code)]
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
}
impl RequestContext {
@@ -62,7 +57,6 @@ impl RequestContext {
/// # Arguments
/// * `state` - 代理服务器状态
/// * `body` - 请求体 JSON
/// * `headers` - 请求头(用于提取 Session ID
/// * `app_type` - 应用类型
/// * `tag` - 日志标签
/// * `app_type_str` - 应用类型字符串
@@ -72,7 +66,6 @@ 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,
@@ -96,31 +89,13 @@ 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| match e {
crate::error::AppError::AllProvidersCircuitOpen => {
ProxyError::AllProvidersCircuitOpen
}
crate::error::AppError::NoProvidersConfigured => ProxyError::NoProvidersConfigured,
_ => ProxyError::DatabaseError(e.to_string()),
})?;
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?;
let provider = providers
.first()
@@ -128,12 +103,11 @@ impl RequestContext {
.ok_or(ProxyError::NoAvailableProvider)?;
log::info!(
"[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
"[{}] Provider: {}, model: {}, failover chain: {} providers",
tag,
provider.name,
request_model,
providers.len(),
session_id
providers.len()
);
Ok(Self {
@@ -146,7 +120,6 @@ impl RequestContext {
tag,
app_type_str,
app_type,
session_id,
})
}
@@ -175,38 +148,18 @@ 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(),
non_streaming_timeout,
self.app_config.non_streaming_timeout as u64,
self.app_config.max_retries as u8,
state.status.clone(),
state.current_providers.clone(),
state.failover_manager.clone(),
state.app_handle.clone(),
self.current_provider_id.clone(),
first_byte_timeout,
idle_timeout,
self.app_config.streaming_first_byte_timeout as u64,
self.app_config.streaming_idle_timeout as u64,
)
}
@@ -224,24 +177,11 @@ impl RequestContext {
}
/// 获取流式超时配置
///
/// 配置生效规则:
/// - 故障转移开启:返回配置的值(0 表示禁用超时检查)
/// - 故障转移关闭:返回 0(禁用超时检查)
#[inline]
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
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,
}
StreamingTimeoutConfig {
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64,
idle_timeout: self.app_config.streaming_idle_timeout as u64,
}
}
}
+6 -9
View File
@@ -61,8 +61,7 @@ pub async fn handle_messages(
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?;
let is_stream = body
.get("stream")
@@ -306,8 +305,7 @@ pub async fn handle_chat_completions(
) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
@@ -355,8 +353,7 @@ pub async fn handle_responses(
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?;
let is_stream = body
.get("stream")
@@ -404,7 +401,7 @@ pub async fn handle_gemini(
Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> {
// Gemini 的模型名称在 URI 中
let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini")
.await?
.with_model_from_uri(&uri);
@@ -468,7 +465,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,
request_id.clone(),
ctx.provider.id.clone(),
ctx.app_type_str.to_string(),
ctx.request_model.clone(),
@@ -476,7 +473,7 @@ fn log_forward_error(
error_message,
ctx.latency_ms(),
is_streaming,
Some(ctx.session_id.clone()),
Some(request_id),
None,
) {
log::warn!("记录失败请求日志失败: {e}");
+1 -4
View File
@@ -2,7 +2,6 @@
//!
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
pub mod body_filter;
pub mod circuit_breaker;
pub mod error;
pub mod error_mapper;
@@ -34,9 +33,7 @@ pub use provider_router::ProviderRouter;
#[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)]
pub use session::{
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
};
pub use session::{ClientFormat, ProxySession};
#[allow(unused_imports)]
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
+8 -32
View File
@@ -34,8 +34,6 @@ impl ProviderRouter {
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, 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 {
@@ -55,26 +53,18 @@ 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!(
"[{app_type}] Failover enabled, using queue order ({total_providers} items)"
"[{}] Failover enabled, using queue order ({} items)",
app_type,
failover_providers.len()
);
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,
@@ -84,12 +74,10 @@ impl ProviderRouter {
);
result.push(provider);
} else {
circuit_open_count += 1;
log::debug!(
"[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
"[{}] Queue provider {} circuit breaker open, skipping",
app_type,
provider.name,
state
provider.name
);
}
}
@@ -106,27 +94,15 @@ 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() {
// 区分两种情况:全部熔断 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);
}
return Err(AppError::Config(format!(
"No available provider for {app_type} (all circuit breakers open or no providers configured)"
)));
}
log::info!(
+1 -58
View File
@@ -112,19 +112,6 @@ 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
@@ -136,14 +123,6 @@ 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);
@@ -264,7 +243,6 @@ 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) {
@@ -273,7 +251,6 @@ 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(
@@ -286,32 +263,10 @@ 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 统计,跳过消费记录");
}
})
@@ -331,7 +286,6 @@ 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(
@@ -344,7 +298,6 @@ fn spawn_log_usage(
None,
is_streaming,
status_code,
Some(session_id),
)
.await;
});
@@ -362,7 +315,6 @@ async fn log_usage_internal(
first_token_ms: Option<u64>,
is_streaming: bool,
status_code: u16,
session_id: Option<String>,
) {
use super::usage::logger::UsageLogger;
@@ -386,15 +338,6 @@ 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(),
@@ -405,7 +348,7 @@ async fn log_usage_internal(
latency_ms,
first_token_ms,
status_code,
session_id,
None,
None, // provider_type
is_streaming,
) {
-269
View File
@@ -1,15 +1,7 @@
//! 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;
@@ -184,179 +176,6 @@ 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<SessionIdResult> {
// 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<SessionIdResult> {
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<String> {
// 查找 "_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::*;
@@ -476,92 +295,4 @@ 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);
}
}
+5 -5
View File
@@ -28,11 +28,11 @@ pub struct ProxyConfig {
}
fn default_streaming_first_byte_timeout() -> u64 {
60
30
}
fn default_streaming_idle_timeout() -> u64 {
120
60
}
fn default_non_streaming_timeout() -> u64 {
@@ -45,11 +45,11 @@ impl Default for ProxyConfig {
listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3,
request_timeout: 600,
request_timeout: 300,
enable_logging: true,
live_takeover_active: false,
streaming_first_byte_timeout: 60,
streaming_idle_timeout: 120,
streaming_first_byte_timeout: 30,
streaming_idle_timeout: 60,
non_streaming_timeout: 600,
}
}
+5 -13
View File
@@ -35,11 +35,6 @@ 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,
@@ -47,10 +42,7 @@ impl CostCalculator {
) -> CostBreakdown {
let million = Decimal::from(1_000_000);
// 计算实际需要按输入价格计费的 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
let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million
/ million
* cost_multiplier;
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
@@ -121,8 +113,8 @@ mod tests {
let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)
assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
// input: 1000 * 3.0 / 1M = 0.003
assert_eq!(cost.input_cost, Decimal::from_str("0.003").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
@@ -132,8 +124,8 @@ mod tests {
cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap()
);
// total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335
assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap());
}
#[test]
+18 -281
View File
@@ -163,21 +163,13 @@ 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: cached_tokens,
cache_read_tokens: usage
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: usage
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
@@ -196,27 +188,16 @@ 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 (可能在 cache_read_input_tokens 或 input_tokens_details 中)
// 获取 cached_tokens (可能在 input_tokens_details 中)
let cached_tokens = usage
.get("cache_read_input_tokens")
.get("input_tokens_details")
.and_then(|d| d.get("cached_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,
@@ -225,7 +206,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
model,
model: None,
})
}
@@ -239,7 +220,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_adjusted(response);
return Self::from_codex_response(response);
}
}
}
@@ -248,51 +229,6 @@ 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<Self> {
let usage = body.get("usage")?;
// 检测格式:OpenAI 使用 prompt_tokensCodex 使用 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<Self> {
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<Self> {
let usage = body.get("usage")?;
@@ -348,16 +284,9 @@ 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: prompt_tokens,
output_tokens,
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32,
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32,
cache_read_tokens: usage
.get("cachedContentTokenCount")
.and_then(|v| v.as_u64())
@@ -371,25 +300,20 @@ impl TokenUsage {
#[allow(dead_code)]
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
let mut total_input = 0u32;
let mut total_tokens = 0u32;
let mut total_output = 0u32;
let mut total_cache_read = 0u32;
let mut model: Option<String> = 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;
// 总 tokens (包含输入 + 输出 + 思考)
total_tokens = usage
.get("totalTokenCount")
total_output += usage
.get("candidatesTokenCount")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32;
// 缓存读取 tokens
total_cache_read = usage
.get("cachedContentTokenCount")
.and_then(|v| v.as_u64())
@@ -404,9 +328,6 @@ 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,
@@ -545,18 +466,15 @@ mod tests {
let response = json!({
"modelVersion": "gemini-3-pro-high",
"usageMetadata": {
"promptTokenCount": 8383,
"promptTokenCount": 100,
"candidatesTokenCount": 50,
"thoughtsTokenCount": 114,
"totalTokenCount": 8547,
"cachedContentTokenCount": 20
}
});
let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 8383);
// output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164
assert_eq!(usage.output_tokens, 164);
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
@@ -568,78 +486,19 @@ mod tests {
let response = json!({
"usageMetadata": {
"promptTokenCount": 100,
"totalTokenCount": 150,
"candidatesTokenCount": 50,
"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!({
@@ -675,22 +534,6 @@ 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 的边界情况
@@ -772,110 +615,4 @@ 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()));
}
}
+3 -15
View File
@@ -206,8 +206,6 @@ 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 {
@@ -219,8 +217,6 @@ impl McpService {
merged.apps.claude = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -233,7 +229,7 @@ impl McpService {
}
}
Ok(new_count)
Ok(count)
}
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
@@ -244,8 +240,6 @@ 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 {
@@ -257,8 +251,6 @@ impl McpService {
merged.apps.codex = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -271,7 +263,7 @@ impl McpService {
}
}
Ok(new_count)
Ok(count)
}
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
@@ -282,8 +274,6 @@ 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 {
@@ -295,8 +285,6 @@ impl McpService {
merged.apps.gemini = true;
merged
} else {
// 真正的新服务器
new_count += 1;
server.clone()
};
@@ -309,6 +297,6 @@ impl McpService {
}
}
Ok(new_count)
Ok(count)
}
}
+1 -2
View File
@@ -15,8 +15,7 @@ pub use mcp::McpService;
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
#[allow(unused_imports)]
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
pub use skill::{Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)]
pub use usage_stats::{
+8 -11
View File
@@ -217,12 +217,9 @@ 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 || live_taken_over) && is_proxy_running;
let should_hot_switch = is_app_taken_over && is_proxy_running;
if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config
@@ -739,15 +736,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);
}
}
@@ -760,7 +757,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() {
@@ -773,7 +770,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);
}
@@ -787,7 +784,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);
}
@@ -801,7 +798,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);
}
+7 -20
View File
@@ -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,22 +201,7 @@ impl ProxyService {
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
if current_config.enabled {
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} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份"
);
return Ok(());
}
// 3) 备份 Live 配置(严格:目标 app 不存在则报错)
@@ -1078,7 +1063,7 @@ impl ProxyService {
}
}
pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
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),
@@ -1272,8 +1257,10 @@ impl ProxyService {
/// 检查是否处于 Live 接管模式
pub async fn is_takeover_active(&self) -> Result<bool, String> {
let status = self.get_takeover_status().await?;
Ok(status.claude || status.codex || status.gemini)
self.db
.is_live_takeover_active()
.await
.map_err(|e| format!("检查接管状态失败: {e}"))
}
/// 从异常退出中恢复(启动时调用)
File diff suppressed because it is too large Load Diff
+228 -155
View File
@@ -4,7 +4,7 @@
use crate::database::{lock_conn, Database};
use crate::error::AppError;
use chrono::{Local, TimeZone};
use chrono::{Duration, Utc};
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -181,63 +181,29 @@ impl Database {
Ok(result)
}
/// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致)
pub fn get_daily_trends(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> {
/// 获取每日趋势
pub fn get_daily_trends(&self, days: u32) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn);
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);
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";
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(),
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,
@@ -245,50 +211,99 @@ impl Database {
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<i64, DailyStats> = HashMap::new();
for row in rows {
let (mut bucket_idx, stat) = row?;
if bucket_idx < 0 {
continue;
let mut buckets: HashMap<String, DailyStats> = HashMap::new();
for row in rows {
let stat = row?;
buckets.insert(stat.date.clone(), stat);
}
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
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,
});
}
}
map.insert(bucket_idx, stat);
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)
}
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 统计
@@ -602,7 +617,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(datetime(created_at, 'unixepoch', 'localtime')) = date('now', 'localtime')",
AND date(created_at, 'unixepoch') = date('now')",
params![provider_id, app_type],
|row| row.get(0),
)
@@ -614,7 +629,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', datetime(created_at, 'unixepoch', 'localtime')) = strftime('%Y-%m', 'now', 'localtime')",
AND strftime('%Y-%m', created_at, 'unixepoch') = strftime('%Y-%m', 'now')",
params![provider_id, app_type],
|row| row.get(0),
)
@@ -798,46 +813,89 @@ 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<Option<(String, String, String, String)>, AppError> {
// 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);
// 0. 标准化模型名称(去除前缀 + 点号转短横线)
// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
let normalized = normalize_model_id(model_id);
// 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}")))?;
// 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}")))?;
if exact.is_none() {
log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0");
if exact.is_some() {
if id != model_id {
log::info!("模型 {model_id} 标准化后精确匹配到: {id}");
}
return Ok(exact);
}
}
Ok(exact)
// 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",
[&current],
|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)
}
#[cfg(test)]
@@ -917,39 +975,54 @@ mod tests {
let db = Database::memory()?;
let conn = lock_conn!(db.conn);
// 准备额外定价数据,覆盖前缀/后缀清洗场景
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"
],
)?;
// 测试精确匹配
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5")?;
assert!(result.is_some(), "应该能精确匹配 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"
);
// 清洗:去除前缀和冒号后缀
// 测试带供应商前缀的模型名称(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 应能匹配到 claude-haiku-4.5"
"应该能匹配带前缀的模型 anthropic/claude-haiku-4.5"
);
let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?;
// 测试带供应商前缀 + 点号的模型名称
let result = find_model_pricing_row(&conn, "anthropic/claude-sonnet-4.5")?;
assert!(
result.is_some(),
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
"应该能匹配带前缀的模型 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 命名格式
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 claude-sonnet-4-5-20250929"
);
// 测试不存在的模型
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CC Switch",
"version": "3.9.0-3",
"version": "3.9.0-2",
"identifier": "com.ccswitch.desktop",
"build": {
"frontendDist": "../dist",
-2
View File
@@ -49,7 +49,6 @@ pub fn test_mutex() -> &'static Mutex<()> {
}
/// 创建测试用的 AppState,包含一个空的数据库
#[allow(dead_code)]
pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
let db = Arc::new(Database::init()?);
let proxy_service = ProxyService::new(db.clone());
@@ -57,7 +56,6 @@ pub fn create_test_state() -> Result<AppState, Box<dyn std::error::Error>> {
}
/// 创建测试用的 AppState,并从 MultiAppConfig 迁移数据
#[allow(dead_code)]
pub fn create_test_state_with_config(
config: &MultiAppConfig,
) -> Result<AppState, Box<dyn std::error::Error>> {
+34 -102
View File
@@ -13,8 +13,6 @@ import {
Wrench,
Server,
RefreshCw,
Search,
Download,
} from "lucide-react";
import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env";
@@ -28,7 +26,6 @@ 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";
@@ -44,7 +41,6 @@ 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";
@@ -55,7 +51,6 @@ type View =
| "settings"
| "prompts"
| "skills"
| "skillsDiscovery"
| "mcp"
| "agents"
| "universal";
@@ -78,14 +73,25 @@ function App() {
const [envConflicts, setEnvConflicts] = useState<EnvConflict[]>([]);
const [showEnvBanner, setShowEnvBanner] = useState(false);
// 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示
const effectiveEditingProvider = useLastValidValue(editingProvider);
const effectiveUsageProvider = useLastValidValue(usageProvider);
// 保存最后一个有效的 provider,用于动画退出期间显示内容
const lastUsageProviderRef = useRef<Provider | null>(null);
const lastEditingProviderRef = useRef<Provider | null>(null);
useEffect(() => {
if (usageProvider) {
lastUsageProviderRef.current = usageProvider;
}
}, [usageProvider]);
useEffect(() => {
if (editingProvider) {
lastEditingProviderRef.current = editingProvider;
}
}, [editingProvider]);
const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null);
const unifiedSkillsPanelRef = useRef<any>(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";
@@ -111,7 +117,8 @@ function App() {
});
const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? "";
const hasSkillsSupport = true;
// Skills 功能仅支持 Claude 和 Codex
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const {
@@ -222,35 +229,6 @@ 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 () => {
@@ -423,16 +401,10 @@ function App() {
/>
);
case "skills":
return (
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp}
/>
);
@@ -571,11 +543,7 @@ function App() {
<Button
variant="outline"
size="icon"
onClick={() =>
setCurrentView(
currentView === "skillsDiscovery" ? "skills" : "providers",
)
}
onClick={() => setCurrentView("providers")}
className="mr-2 rounded-lg"
>
<ArrowLeft className="w-4 h-4" />
@@ -585,7 +553,6 @@ function App() {
{currentView === "prompts" &&
t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")}
{currentView === "skillsDiscovery" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")}
{currentView === "universal" &&
@@ -631,60 +598,25 @@ function App() {
>
{currentView === "prompts" && (
<Button
variant="ghost"
size="sm"
size="icon"
onClick={() => promptPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
className={`ml-auto ${addActionButtonClass}`}
title={t("prompts.add")}
>
<Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
<Plus className="w-5 h-5" />
</Button>
)}
{currentView === "mcp" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("mcp.importExisting")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => mcpPanelRef.current?.openAdd()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Plus className="w-4 h-4 mr-2" />
{t("mcp.addMcp")}
</Button>
</>
<Button
size="icon"
onClick={() => mcpPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`}
title={t("mcp.unifiedPanel.addServer")}
>
<Plus className="w-5 h-5" />
</Button>
)}
{currentView === "skills" && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => unifiedSkillsPanelRef.current?.openImport()}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Download className="w-4 h-4 mr-2" />
{t("skills.import")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setCurrentView("skillsDiscovery")}
className="hover:bg-black/5 dark:hover:bg-white/5"
>
<Search className="w-4 h-4 mr-2" />
{t("skills.discover")}
</Button>
</>
)}
{currentView === "skillsDiscovery" && (
<>
<Button
variant="ghost"
@@ -786,7 +718,7 @@ function App() {
<EditProviderDialog
open={Boolean(editingProvider)}
provider={effectiveEditingProvider}
provider={lastEditingProviderRef.current}
onOpenChange={(open) => {
if (!open) {
setEditingProvider(null);
@@ -797,9 +729,9 @@ function App() {
isProxyTakeover={isProxyRunning && isCurrentAppTakeoverActive}
/>
{effectiveUsageProvider && (
{lastUsageProviderRef.current && (
<UsageScriptModal
provider={effectiveUsageProvider}
provider={lastUsageProviderRef.current}
appId={activeApp}
isOpen={Boolean(usageProvider)}
onClose={() => setUsageProvider(null)}
+5 -20
View File
@@ -12,9 +12,6 @@ interface FullScreenPanelProps {
footer?: React.ReactNode;
}
const DRAG_BAR_HEIGHT = 28; // px - match App.tsx
const HEADER_HEIGHT = 64; // px - match App.tsx
/**
* Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer
@@ -47,30 +44,18 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }}
>
{/* Drag region - match App.tsx */}
{/* Header */}
<div
data-tauri-drag-region
style={{
WebkitAppRegion: "drag",
height: DRAG_BAR_HEIGHT,
} as React.CSSProperties}
/>
{/* Header - match App.tsx */}
<div
className="flex-shrink-0 flex items-center"
style={{
backgroundColor: "hsl(var(--background))",
height: HEADER_HEIGHT,
}}
className="flex-shrink-0 py-3 border-b border-border-default"
style={{ backgroundColor: "hsl(var(--background))" }}
>
<div className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4">
<div className="h-4 w-full" data-tauri-drag-region />
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button
type="button"
variant="outline"
size="icon"
onClick={onClose}
className="rounded-lg"
>
<ArrowLeft className="h-4 w-4" />
</Button>
+2 -19
View File
@@ -3,11 +3,12 @@ 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, useDeleteMcpServer, useImportMcpFromApps } from "@/hooks/useMcp";
import { useAllMcpServers, useToggleMcpApp } 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";
@@ -23,7 +24,6 @@ interface UnifiedMcpPanelProps {
*/
export interface UnifiedMcpPanelHandle {
openAdd: () => void;
openImport: () => void;
}
const UnifiedMcpPanel = React.forwardRef<
@@ -44,7 +44,6 @@ 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]> => {
@@ -87,24 +86,8 @@ 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) => {
+3 -3
View File
@@ -8,7 +8,7 @@ interface PromptToggleProps {
/**
* Toggle
* 绿
*
*/
const PromptToggle: React.FC<PromptToggleProps> = ({
enabled,
@@ -23,8 +23,8 @@ const PromptToggle: React.FC<PromptToggleProps> = ({
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-emerald-500/20
${enabled ? "bg-emerald-500 dark:bg-emerald-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-blue-500/20
${enabled ? "bg-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`}
>
@@ -222,7 +222,9 @@ export function ClaudeFormFields({
{/* 推理模型 */}
<div className="space-y-2">
<FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel")}
{t("providerForm.anthropicReasoningModel", {
defaultValue: "推理模型 (Thinking)",
})}
</FormLabel>
<Input
id="reasoningModel"
@@ -231,6 +233,9 @@ export function ClaudeFormFields({
onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
}
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off"
/>
</div>
@@ -142,13 +142,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="10"
value={formData.maxRetries}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
maxRetries: isNaN(val) ? 0 : val,
});
}}
maxRetries: parseInt(e.target.value) || 3,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -169,13 +168,12 @@ export function AutoFailoverConfigPanel({
min="1"
max="20"
value={formData.circuitFailureThreshold}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
circuitFailureThreshold: parseInt(e.target.value) || 5,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -208,13 +206,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="180"
value={formData.streamingFirstByteTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
streamingFirstByteTimeout: isNaN(val) ? 0 : val,
});
}}
streamingFirstByteTimeout: parseInt(e.target.value) || 30,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -235,13 +232,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="600"
value={formData.streamingIdleTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
streamingIdleTimeout: isNaN(val) ? 0 : val,
});
}}
streamingIdleTimeout: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -262,13 +258,12 @@ export function AutoFailoverConfigPanel({
min="0"
max="1800"
value={formData.nonStreamingTimeout}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
nonStreamingTimeout: isNaN(val) ? 0 : val,
});
}}
nonStreamingTimeout: parseInt(e.target.value) || 300,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -298,13 +293,12 @@ export function AutoFailoverConfigPanel({
min="1"
max="10"
value={formData.circuitSuccessThreshold}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
});
}}
circuitSuccessThreshold: parseInt(e.target.value) || 2,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -325,13 +319,12 @@ export function AutoFailoverConfigPanel({
min="10"
max="300"
value={formData.circuitTimeoutSeconds}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
});
}}
circuitTimeoutSeconds: parseInt(e.target.value) || 60,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -353,13 +346,13 @@ export function AutoFailoverConfigPanel({
max="100"
step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
});
}}
circuitErrorRateThreshold:
(parseInt(e.target.value) || 50) / 100,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
@@ -380,13 +373,12 @@ export function AutoFailoverConfigPanel({
min="5"
max="100"
value={formData.circuitMinRequests}
onChange={(e) => {
const val = parseInt(e.target.value);
onChange={(e) =>
setFormData({
...formData,
circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
});
}}
circuitMinRequests: parseInt(e.target.value) || 10,
})
}
disabled={isDisabled}
/>
<p className="text-xs text-muted-foreground">
+1 -1
View File
@@ -312,7 +312,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
{isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3 px-1">
<div className="grid gap-3 sm:grid-cols-3">
{["claude", "codex", "gemini"].map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName);
const displayName = tool?.name ?? toolName;
+2 -2
View File
@@ -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 { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
import type { Skill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
repos: SkillRepo[];
skills: DiscoverableSkill[];
skills: Skill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
}
+3 -3
View File
@@ -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 { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
import type { Skill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerPanelProps {
repos: SkillRepo[];
skills: DiscoverableSkill[];
skills: Skill[];
onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>;
onClose: () => void;
@@ -92,7 +92,7 @@ export function RepoManagerPanel({
{/* 添加仓库表单 */}
<div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground">
{t("skills.addRepo")}
</h3>
<div className="space-y-4">
<div>
+3 -5
View File
@@ -12,12 +12,10 @@ 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 { DiscoverableSkill } from "@/lib/api/skills";
type SkillCardSkill = DiscoverableSkill & { installed: boolean };
import type { Skill } from "@/lib/api/skills";
interface SkillCardProps {
skill: SkillCardSkill;
skill: Skill;
onInstall: (directory: string) => Promise<void>;
onUninstall: (directory: string) => Promise<void>;
}
@@ -59,7 +57,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return (
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:shadow-lg group relative overflow-hidden">
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none" />
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
+128 -100
View File
@@ -1,4 +1,10 @@
import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
import {
useState,
useEffect,
useMemo,
forwardRef,
useImperativeHandle,
} from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -14,18 +20,15 @@ import { toast } from "sonner";
import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel";
import {
useDiscoverableSkills,
useInstalledSkills,
useInstallSkill,
useSkillRepos,
useAddSkillRepo,
useRemoveSkillRepo,
skillsApi,
type Skill,
type SkillRepo,
type AppType,
} from "@/hooks/useSkills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
} from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps {
onClose?: () => void;
initialApp?: AppType;
}
@@ -34,138 +37,163 @@ export interface SkillsPageHandle {
openRepoManager: () => void;
}
/**
* Skills
* Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ initialApp = "claude" }, ref) => {
({ onClose: _onClose, initialApp = "claude" }, ref) => {
const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
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;
// currentApp 用于安装时的默认应用
const currentApp = 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);
// Queries
const {
data: discoverableSkills,
isLoading: loadingDiscoverable,
isFetching: fetchingDiscoverable,
refetch: refetchDiscoverable,
} = useDiscoverableSkills();
const { data: installedSkills } = useInstalledSkills();
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
// 传入 "skills.loadFailed" 作为标题
const { title, description } = formatSkillError(
errorMessage,
t,
"skills.loadFailed",
);
// Mutations
const installMutation = useInstallSkill();
const addRepoMutation = useAddSkillRepo();
const removeRepoMutation = useRemoveSkillRepo();
toast.error(title, {
description,
duration: 8000,
});
// 已安装的 directory 集合
const installedDirs = useMemo(() => {
if (!installedSkills) return new Set<string>();
return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
}, [installedSkills]);
console.error("Load skills failed:", error);
} finally {
setLoading(false);
}
};
type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
const loadRepos = async () => {
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
// 为发现列表补齐 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;
useEffect(() => {
Promise.all([loadSkills(), loadRepos()]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useImperativeHandle(ref, () => ({
refresh: () => {
refetchDiscoverable();
refetchRepos();
},
refresh: () => loadSkills(),
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 installMutation.mutateAsync({
skill,
currentApp,
});
toast.success(t("skills.installSuccess", { name: skill.name }), {
await skillsApi.install(directory, selectedApp);
toast.success(t("skills.installSuccess", { name: directory }), {
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);
}
};
const handleUninstall = async (_directory: string) => {
// 在发现面板中,不支持卸载,需要在主面板中操作
toast.info(t("skills.uninstallInMainPanel"));
console.error("Uninstall skill failed:", {
directory,
error,
message: errorMessage,
});
}
};
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),
});
}
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) => {
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),
});
}
await skillsApi.removeRepo(owner, name);
toast.success(t("skills.repo.removeSuccess", { owner, name }), {
closeButton: true,
});
await Promise.all([loadRepos(), loadSkills()]);
};
// 过滤技能列表
@@ -1,417 +0,0 @@
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 (
<div className="mx-auto max-w-[56rem] px-6 flex flex-col h-[calc(100vh-8rem)] overflow-hidden">
{/* Info Section */}
<div className="flex-shrink-0 py-4 glass rounded-xl border border-white/10 mb-4 px-6">
<div className="text-sm text-muted-foreground">
{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}
</div>
</div>
{/* Content - Scrollable */}
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">
{t("skills.loading")}
</div>
) : !skills || skills.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 bg-muted rounded-full flex items-center justify-center">
<Sparkles size={24} className="text-muted-foreground" />
</div>
<h3 className="text-lg font-medium text-foreground mb-2">
{t("skills.noInstalled")}
</h3>
<p className="text-muted-foreground text-sm">
{t("skills.noInstalledDescription")}
</p>
</div>
) : (
<div className="space-y-3">
{skills.map((skill) => (
<InstalledSkillListItem
key={skill.id}
skill={skill}
onToggleApp={handleToggleApp}
onUninstall={() => handleUninstall(skill)}
/>
))}
</div>
)}
</div>
{/* Confirm Dialog */}
{confirmDialog && (
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
message={confirmDialog.message}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog(null)}
/>
)}
{/* Import Dialog */}
{importDialogOpen && unmanagedSkills && (
<ImportSkillsDialog
skills={unmanagedSkills}
onImport={handleImport}
onClose={() => setImportDialogOpen(false)}
/>
)}
</div>
);
});
UnifiedSkillsPanel.displayName = "UnifiedSkillsPanel";
/**
* Skill
*/
interface InstalledSkillListItemProps {
skill: InstalledSkill;
onToggleApp: (id: string, app: AppType, enabled: boolean) => void;
onUninstall: () => void;
}
const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
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 (
<div className="group relative flex items-center gap-4 p-4 rounded-xl border border-border-default bg-muted/50 hover:bg-muted hover:border-border-default/80 hover:shadow-sm transition-all duration-300">
{/* 左侧:Skill 信息 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-medium text-foreground">{skill.name}</h3>
{skill.readmeUrl && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={openDocs}
className="h-6 px-2"
>
<ExternalLink size={14} />
</Button>
)}
</div>
{skill.description && (
<p className="text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
)}
<p className="text-xs text-muted-foreground/70 mt-1">{sourceLabel}</p>
</div>
{/* 中间:应用开关 */}
<div className="flex flex-col gap-2 flex-shrink-0 min-w-[120px]">
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-claude`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.claude")}
</label>
<Switch
id={`${skill.id}-claude`}
checked={skill.apps.claude}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "claude", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-codex`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.codex")}
</label>
<Switch
id={`${skill.id}-codex`}
checked={skill.apps.codex}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "codex", checked)
}
/>
</div>
<div className="flex items-center justify-between gap-3">
<label
htmlFor={`${skill.id}-gemini`}
className="text-sm text-foreground/80 cursor-pointer"
>
{t("skills.apps.gemini")}
</label>
<Switch
id={`${skill.id}-gemini`}
checked={skill.apps.gemini}
onCheckedChange={(checked: boolean) =>
onToggleApp(skill.id, "gemini", checked)
}
/>
</div>
</div>
{/* 右侧:删除按钮 */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onUninstall}
className="hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10"
title={t("skills.uninstall")}
>
<Trash2 size={16} />
</Button>
</div>
</div>
);
};
/**
* Skills
*/
interface ImportSkillsDialogProps {
skills: Array<{
directory: string;
name: string;
description?: string;
foundIn: string[];
}>;
onImport: (directories: string[]) => void;
onClose: () => void;
}
const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
skills,
onImport,
onClose,
}) => {
const { t } = useTranslation();
const [selected, setSelected] = useState<Set<string>>(
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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-xl p-6 max-w-lg w-full mx-4 shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-2">{t("skills.import")}</h2>
<p className="text-sm text-muted-foreground mb-4">
{t("skills.importDescription")}
</p>
<div className="flex-1 overflow-y-auto space-y-2 mb-4">
{skills.map((skill) => (
<label
key={skill.directory}
className="flex items-start gap-3 p-3 rounded-lg border hover:bg-muted cursor-pointer"
>
<input
type="checkbox"
checked={selected.has(skill.directory)}
onChange={() => toggleSelect(skill.directory)}
className="mt-1"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{skill.name}</div>
{skill.description && (
<div className="text-sm text-muted-foreground line-clamp-1">
{skill.description}
</div>
)}
<div className="text-xs text-muted-foreground/70 mt-1">
{t("skills.foundIn")}: {skill.foundIn.join(", ")}
</div>
</div>
</label>
))}
</div>
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={selected.size === 0}>
{t("skills.importSelected", { count: selected.size })}
</Button>
</div>
</div>
</div>
);
};
export default UnifiedSkillsPanel;
+18 -34
View File
@@ -62,28 +62,6 @@ 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"
@@ -175,16 +153,19 @@ export function RequestLogTable() {
className="h-8 w-[200px] bg-background"
value={
tempFilters.startDate
? timestampToLocalDatetime(tempFilters.startDate)
? new Date(tempFilters.startDate * 1000)
.toISOString()
.slice(0, 16)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
onChange={(e) =>
setTempFilters({
...tempFilters,
startDate: timestamp,
});
}}
startDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
<span>-</span>
<Input
@@ -192,16 +173,19 @@ export function RequestLogTable() {
className="h-8 w-[200px] bg-background"
value={
tempFilters.endDate
? timestampToLocalDatetime(tempFilters.endDate)
? new Date(tempFilters.endDate * 1000)
.toISOString()
.slice(0, 16)
: ""
}
onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
onChange={(e) =>
setTempFilters({
...tempFilters,
endDate: timestamp,
});
}}
endDate: e.target.value
? Math.floor(new Date(e.target.value).getTime() / 1000)
: undefined,
})
}
/>
</div>
+7 -1
View File
@@ -12,7 +12,13 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
const { t } = useTranslation();
const { data: summary, isLoading } = useUsageSummary(days);
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 stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0;
+19 -43
View File
@@ -41,12 +41,7 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
return {
rawDate: stat.date,
label: isToday
? pointDate.toLocaleString(dateLocale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" })
: pointDate.toLocaleDateString(dateLocale, {
month: "2-digit",
day: "2-digit",
@@ -54,13 +49,28 @@ 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 displayData = chartData;
const hourlyData = (() => {
if (!isToday) return chartData;
const map = new Map<number, (typeof chartData)[number]>();
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 CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
@@ -121,20 +131,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient>
<linearGradient
id="colorCacheCreation"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="5%" stopColor="#f97316" stopOpacity={0.2} />
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorCacheRead" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.2} />
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
@@ -186,26 +182,6 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
fill="url(#colorOutput)"
strokeWidth={2}
/>
<Area
yAxisId="tokens"
type="monotone"
dataKey="cacheCreationTokens"
name={t("usage.cacheCreationTokens", "缓存创建")}
stroke="#f97316"
fillOpacity={1}
fill="url(#colorCacheCreation)"
strokeWidth={2}
/>
<Area
yAxisId="tokens"
type="monotone"
dataKey="cacheReadTokens"
name={t("usage.cacheReadTokens", "缓存命中")}
stroke="#a855f7"
fillOpacity={1}
fill="url(#colorCacheRead)"
strokeWidth={2}
/>
<Area
yAxisId="cost"
type="monotone"
-20
View File
@@ -1,20 +0,0 @@
import { useRef } from "react";
/**
* null/undefined
* Dialog
*
* @param value
* @returns
*/
export function useLastValidValue<T>(value: T | null | undefined): T | null {
const ref = useRef<T | null>(null);
// 同步更新 ref(在渲染期间,不在 useEffect 中)
if (value != null) {
ref.current = value;
}
// 返回当前值或最后有效值
return value ?? ref.current;
}
-13
View File
@@ -59,16 +59,3 @@ export function useDeleteMcpServer() {
},
});
}
/**
* MCP
*/
export function useImportMcpFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => mcpApi.importFromApps(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});
}
-151
View File
@@ -1,151 +0,0 @@
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 };
+12 -37
View File
@@ -343,6 +343,7 @@
"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",
@@ -422,7 +423,7 @@
"cost": "Cost",
"perMillion": "(per million)",
"trends": "Usage Trends",
"rangeToday": "Last 24 hours (hourly)",
"rangeToday": "Today (hourly)",
"rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days",
"totalTokens": "Total Tokens",
@@ -435,8 +436,8 @@
"billingModel": "Billing Model",
"inputTokens": "Input",
"outputTokens": "Output",
"cacheReadTokens": "Cache Hit",
"cacheCreationTokens": "Cache Creation",
"cacheReadTokens": "Cache Read",
"cacheCreationTokens": "Cache Write",
"timingInfo": "Duration/TTFT",
"status": "Status",
"noData": "No data",
@@ -452,8 +453,8 @@
"displayName": "Display Name",
"inputCost": "Input Cost",
"outputCost": "Output Cost",
"cacheReadCost": "Cache Hit",
"cacheWriteCost": "Cache Creation",
"cacheReadCost": "Cache Read",
"cacheWriteCost": "Cache Write",
"deleteConfirmTitle": "Confirm Delete",
"deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.",
"queryFailed": "Query failed",
@@ -480,8 +481,8 @@
"timeRange": "Time Range",
"input": "Input",
"output": "Output",
"cacheWrite": "Creation",
"cacheRead": "Hit"
"cacheWrite": "Write",
"cacheRead": "Read"
},
"usageScript": {
"title": "Configure Usage Query",
@@ -564,9 +565,6 @@
},
"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",
@@ -578,8 +576,6 @@
"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",
@@ -797,8 +793,8 @@
},
"skills": {
"manage": "Skills",
"title": "Skills Management",
"description": "Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities",
"title": "Claude Skills Management",
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities",
"refresh": "Refresh",
"refreshing": "Refreshing...",
"repoManager": "Repository Management",
@@ -874,25 +870,7 @@
"installed": "Installed",
"uninstalled": "Not installed"
},
"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"
}
"noResults": "No matching skills found"
},
"deeplink": {
"confirmImport": "Confirm Import Provider",
@@ -980,10 +958,7 @@
"clickToSelect": "Click to select icon"
},
"migration": {
"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)."
"success": "Configuration migrated successfully"
},
"agents": {
"title": "Agents"
+12 -37
View File
@@ -343,6 +343,7 @@
"anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル",
"anthropicReasoningModel": "推論モデル(Thinking",
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 互換モード",
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
@@ -422,7 +423,7 @@
"cost": "コスト",
"perMillion": "(100万あたり)",
"trends": "利用トレンド",
"rangeToday": "直近24時間 (時間別)",
"rangeToday": "今日 (時間別)",
"rangeLast7Days": "過去7日間",
"rangeLast30Days": "過去30日間",
"totalTokens": "総トークン数",
@@ -435,8 +436,8 @@
"billingModel": "課金モデル",
"inputTokens": "入力",
"outputTokens": "出力",
"cacheReadTokens": "キャッシュヒット",
"cacheCreationTokens": "キャッシュ作成",
"cacheReadTokens": "キャッシュ読取",
"cacheCreationTokens": "キャッシュ書込",
"timingInfo": "応答時間/TTFT",
"status": "ステータス",
"noData": "データなし",
@@ -452,8 +453,8 @@
"displayName": "表示名",
"inputCost": "入力コスト",
"outputCost": "出力コスト",
"cacheReadCost": "キャッシュヒット",
"cacheWriteCost": "キャッシュ作成",
"cacheReadCost": "キャッシュ読取",
"cacheWriteCost": "キャッシュ書込",
"deleteConfirmTitle": "削除の確認",
"deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。",
"queryFailed": "照会に失敗しました",
@@ -480,8 +481,8 @@
"timeRange": "期間",
"input": "Input",
"output": "Output",
"cacheWrite": "作成",
"cacheRead": "ヒット"
"cacheWrite": "Write",
"cacheRead": "Read"
},
"usageScript": {
"title": "利用状況を設定",
@@ -564,9 +565,6 @@
},
"mcp": {
"title": "MCP 管理",
"import": "インポート",
"importExisting": "既存をインポート",
"addMcp": "MCPを追加",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
@@ -578,8 +576,6 @@
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
"noServers": "まだサーバーがありません",
"enabledApps": "有効なアプリ",
"noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。",
"importSuccess": "{{count}} 個の MCP サーバーをインポートしました",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -797,8 +793,8 @@
},
"skills": {
"manage": "Skills",
"title": "Skills 管理",
"description": "人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張",
"title": "Claude スキル管理",
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張",
"refresh": "更新",
"refreshing": "更新中...",
"repoManager": "リポジトリ管理",
@@ -874,25 +870,7 @@
"installed": "インストール済み",
"uninstalled": "未インストール"
},
"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"
}
"noResults": "一致するスキルが見つかりませんでした"
},
"deeplink": {
"confirmImport": "プロバイダーのインポートを確認",
@@ -980,10 +958,7 @@
"clickToSelect": "クリックでアイコンを選択"
},
"migration": {
"success": "設定の移行が完了しました",
"skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました",
"skillsFailed": "スキルの自動インポートに失敗しました",
"skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。"
"success": "設定の移行が完了しました"
},
"agents": {
"title": "エージェント"
+12 -37
View File
@@ -343,6 +343,7 @@
"anthropicModel": "主模型",
"anthropicSmallFastModel": "快速模型",
"anthropicReasoningModel": "推理模型 (Thinking)",
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 兼容模式",
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
"anthropicDefaultHaikuModel": "Haiku 默认模型",
@@ -422,7 +423,7 @@
"cost": "成本",
"perMillion": "(每百万)",
"trends": "使用趋势",
"rangeToday": "过去 24 小时 (按小时)",
"rangeToday": "今天 (按小时)",
"rangeLast7Days": "过去 7 天",
"rangeLast30Days": "过去 30 天",
"totalTokens": "总 Token 数",
@@ -435,8 +436,8 @@
"billingModel": "计费模型",
"inputTokens": "输入",
"outputTokens": "输出",
"cacheReadTokens": "缓存命中",
"cacheCreationTokens": "缓存创建",
"cacheReadTokens": "缓存读取",
"cacheCreationTokens": "缓存写入",
"timingInfo": "用时/首字",
"status": "状态",
"noData": "暂无数据",
@@ -452,8 +453,8 @@
"displayName": "显示名称",
"inputCost": "输入成本",
"outputCost": "输出成本",
"cacheReadCost": "缓存命中",
"cacheWriteCost": "缓存创建",
"cacheReadCost": "缓存读取",
"cacheWriteCost": "缓存写入",
"deleteConfirmTitle": "确认删除",
"deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。",
"queryFailed": "查询失败",
@@ -480,8 +481,8 @@
"timeRange": "时间范围",
"input": "Input",
"output": "Output",
"cacheWrite": "创建",
"cacheRead": "命中"
"cacheWrite": "Write",
"cacheRead": "Read"
},
"usageScript": {
"title": "配置用量查询",
@@ -564,9 +565,6 @@
},
"mcp": {
"title": "MCP 管理",
"import": "导入",
"importExisting": "导入已有",
"addMcp": "添加MCP",
"claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理",
@@ -578,8 +576,6 @@
"deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。",
"noServers": "暂无服务器",
"enabledApps": "启用的应用",
"noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。",
"importSuccess": "成功导入 {{count}} 个 MCP 服务器",
"apps": {
"claude": "Claude",
"codex": "Codex",
@@ -797,8 +793,8 @@
},
"skills": {
"manage": "Skills",
"title": "Skills 管理",
"description": "从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力",
"title": "Claude Skills 管理",
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力",
"refresh": "刷新",
"refreshing": "刷新中...",
"repoManager": "仓库管理",
@@ -874,25 +870,7 @@
"installed": "已安装",
"uninstalled": "未安装"
},
"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"
}
"noResults": "未找到匹配的技能"
},
"deeplink": {
"confirmImport": "确认导入供应商配置",
@@ -980,10 +958,7 @@
"clickToSelect": "点击选择图标"
},
"migration": {
"success": "配置迁移成功",
"skillsSuccess": "已自动导入 {{count}} 个技能到统一管理",
"skillsFailed": "自动导入技能失败",
"skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。"
"success": "配置迁移成功"
},
"agents": {
"title": "智能体"
-7
View File
@@ -119,11 +119,4 @@ export const mcpApi = {
): Promise<void> {
return await invoke("toggle_mcp_app", { serverId, app, enabled });
},
/**
* MCP
*/
async importFromApps(): Promise<number> {
return await invoke("import_mcp_from_apps");
},
};
+1 -102
View File
@@ -1,51 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
// ========== 类型定义 ==========
export type AppType = "claude" | "codex" | "gemini";
/** Skill 应用启用状态 */
export interface SkillApps {
claude: boolean;
codex: boolean;
gemini: boolean;
}
/** 已安装的 Skillv3.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;
@@ -58,7 +12,6 @@ export interface Skill {
repoBranch?: string;
}
/** 仓库配置 */
export interface SkillRepo {
owner: string;
name: string;
@@ -66,56 +19,9 @@ export interface SkillRepo {
enabled: boolean;
}
// ========== API ==========
export type AppType = "claude" | "codex" | "gemini";
export const skillsApi = {
// ========== 统一管理 API (v3.10.0+) ==========
/** 获取所有已安装的 Skills */
async getInstalled(): Promise<InstalledSkill[]> {
return await invoke("get_installed_skills");
},
/** 安装 Skill(统一安装) */
async installUnified(
skill: DiscoverableSkill,
currentApp: AppType,
): Promise<InstalledSkill> {
return await invoke("install_skill_unified", { skill, currentApp });
},
/** 卸载 Skill(统一卸载) */
async uninstallUnified(id: string): Promise<boolean> {
return await invoke("uninstall_skill_unified", { id });
},
/** 切换 Skill 的应用启用状态 */
async toggleApp(
id: string,
app: AppType,
enabled: boolean,
): Promise<boolean> {
return await invoke("toggle_skill_app", { id, app, enabled });
},
/** 扫描未管理的 Skills */
async scanUnmanaged(): Promise<UnmanagedSkill[]> {
return await invoke("scan_unmanaged_skills");
},
/** 从应用目录导入 Skills */
async importFromApps(directories: string[]): Promise<InstalledSkill[]> {
return await invoke("import_skills_from_apps", { directories });
},
/** 发现可安装的 Skills(从仓库获取) */
async discoverAvailable(): Promise<DiscoverableSkill[]> {
return await invoke("discover_available_skills");
},
// ========== 兼容旧 API ==========
/** 获取技能列表(兼容旧 API) */
async getAll(app: AppType = "claude"): Promise<Skill[]> {
if (app === "claude") {
return await invoke("get_skills");
@@ -123,7 +29,6 @@ export const skillsApi = {
return await invoke("get_skills_for_app", { app });
},
/** 安装技能(兼容旧 API) */
async install(directory: string, app: AppType = "claude"): Promise<boolean> {
if (app === "claude") {
return await invoke("install_skill", { directory });
@@ -131,7 +36,6 @@ export const skillsApi = {
return await invoke("install_skill_for_app", { app, directory });
},
/** 卸载技能(兼容旧 API) */
async uninstall(
directory: string,
app: AppType = "claude",
@@ -142,19 +46,14 @@ export const skillsApi = {
return await invoke("uninstall_skill_for_app", { app, directory });
},
// ========== 仓库管理 ==========
/** 获取仓库列表 */
async getRepos(): Promise<SkillRepo[]> {
return await invoke("get_skill_repos");
},
/** 添加仓库 */
async addRepo(repo: SkillRepo): Promise<boolean> {
return await invoke("add_skill_repo", { repo });
},
/** 删除仓库 */
async removeRepo(owner: string, name: string): Promise<boolean> {
return await invoke("remove_skill_repo", { owner, name });
},
+2 -5
View File
@@ -49,11 +49,8 @@ export const usageApi = {
return invoke("get_usage_summary", { startDate, endDate });
},
getUsageTrends: async (
startDate?: number,
endDate?: number,
): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate });
getUsageTrends: async (days: number): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { days });
},
getProviderStats: async (): Promise<ProviderStats[]> => {
+6 -27
View File
@@ -5,7 +5,8 @@ import type { LogFilters } from "@/types/usage";
// Query keys
export const usageKeys = {
all: ["usage"] as const,
summary: (days: number) => [...usageKeys.all, "summary", days] as const,
summary: (startDate?: number, endDate?: number) =>
[...usageKeys.all, "summary", startDate, endDate] 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,
@@ -18,34 +19,18 @@ 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(days: number) {
export function useUsageSummary(startDate?: number, endDate?: number) {
return useQuery({
queryKey: usageKeys.summary(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false, // 后台不刷新
queryKey: usageKeys.summary(startDate, endDate),
queryFn: () => usageApi.getUsageSummary(startDate, endDate),
});
}
export function useUsageTrends(days: number) {
return useQuery({
queryKey: usageKeys.trends(days),
queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
queryFn: () => usageApi.getUsageTrends(days),
});
}
@@ -53,8 +38,6 @@ export function useProviderStats() {
return useQuery({
queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});
}
@@ -62,8 +45,6 @@ export function useModelStats() {
return useQuery({
queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});
}
@@ -75,8 +56,6 @@ export function useRequestLogs(
return useQuery({
queryKey: usageKeys.logs(filters, page, pageSize),
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
});
}
+24 -5
View File
@@ -1,7 +1,8 @@
import { Suspense, type ComponentType } from "react";
import { Suspense } 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";
@@ -108,6 +109,20 @@ vi.mock("@/components/ConfirmDialog", () => ({
) : null,
}));
vi.mock("@/components/settings/SettingsPage", () => ({
SettingsPage: ({ open, onOpenChange, onImportSuccess }: any) =>
open ? (
<div data-testid="settings-dialog">
<button onClick={() => onImportSuccess?.()}>
trigger-import-success
</button>
<button onClick={() => onOpenChange(false)}>close-settings</button>
</div>
) : (
<button onClick={() => onOpenChange(true)}>open-settings</button>
),
}));
vi.mock("@/components/AppSwitcher", () => ({
AppSwitcher: ({ activeApp, onSwitch }: any) => (
<div data-testid="app-switcher">
@@ -135,12 +150,12 @@ vi.mock("@/components/mcp/McpPanel", () => ({
),
}));
const renderApp = (AppComponent: ComponentType) => {
const renderApp = () => {
const client = new QueryClient();
return render(
<QueryClientProvider client={client}>
<Suspense fallback={<div data-testid="loading">loading</div>}>
<AppComponent />
<App />
</Suspense>
</QueryClientProvider>,
);
@@ -154,8 +169,7 @@ describe("App integration with MSW", () => {
});
it("covers basic provider flows via real hooks", async () => {
const { default: App } = await import("@/App");
renderApp(App);
renderApp();
await waitFor(() =>
expect(screen.getByTestId("provider-list").textContent).toContain(
@@ -163,6 +177,11 @@ 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(
-2
View File
@@ -36,8 +36,6 @@ const withJson = async <T>(request: Request): Promise<T> => {
const success = <T>(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));