Compare commits

...

18 Commits

Author SHA1 Message Date
YoVinchen 12128d8fa4 feat(proxy): update failover timeout and circuit breaker defaults
- Double all timeout values (streaming/non-streaming)
- Codex/Gemini: circuit_failure_threshold 5→4, error_rate 0.5→0.6
- Claude: circuit_error_rate_threshold 0.6→0.7
2026-01-04 10:56:14 +08:00
Jason 2105f2d05b fix(i18n): remove hardcoded defaultValue and unused placeholder for reasoning model 2026-01-03 12:04:34 +08:00
Jason 0a9de282a3 fix(skills): skip hidden directories when scanning for skills
Filter out directories starting with '.' (e.g., .system) during skill
scanning to avoid exposing internal system directories from Codex.
2026-01-03 11:42:10 +08:00
Jason 0e085aa01a fix(prompts): change toggle color from blue to emerald
Align with the app-wide Switch component color scheme for
visual consistency across all toggle elements.
2026-01-03 10:15:28 +08:00
Jason 2c95f697cd fix(prompts): unify add button style with other panels
Replace circular orange icon button with ghost text button
to match Skills and MCP panel styling.
2026-01-03 10:13:32 +08:00
Jason ba97a5f373 fix(mcp): unify header buttons style with skills panel
Changed MCP panel buttons to match Skills panel styling:
- Replace circular orange icon button with ghost text button
- Update button labels: "Import" → "Import Existing", add "Add MCP"
2026-01-03 10:11:26 +08:00
Jason 86e802bd4b feat(mcp): add import button to import MCP servers from apps
- Add import_mcp_from_apps command that reuses existing import logic
- Add Import button in MCP panel header (consistent with Skills)
- Fix import count to only return truly new servers (not already in DB)
- Update translations for import success/no-import messages (zh/en/ja)
2026-01-03 10:04:46 +08:00
Jason 6460c1d5dd fix(skills): move import button to header for better discoverability
- Add import button next to discover button in skills page header
- Expose openImport method via UnifiedSkillsPanel ref
- Show toast instead of dialog when no unmanaged skills found
- Remove redundant buttons from empty state view
2026-01-03 09:21:15 +08:00
Jason 6b73e55bfe fix(ui): remove hover scale effect from skill cards
The hover:scale-[1.01] effect caused cards to overflow their container
boundaries. Keeping only hover:shadow-lg provides sufficient visual
feedback without the overflow issue.
2026-01-02 23:57:22 +08:00
Jason 47aa4c6bee fix(skills): show loading indicator when refreshing discovery list
Use isFetching instead of just isLoading to show the loading spinner.
isLoading is only true on initial load, while isFetching is true
during any fetch operation including refetch.
2026-01-02 23:51:03 +08:00
Jason 4dc59dff21 fix(skills): remove refresh button from installed skills panel
The refresh button is only needed in the discovery panel (to fetch
latest skills from GitHub). The installed skills panel uses local
database which auto-updates on install/uninstall operations.
2026-01-02 23:41:28 +08:00
Jason c8750f5550 fix(i18n): rename Skills title to be app-agnostic
- Remove "Claude" prefix from Skills management title
- Update descriptions to include Gemini alongside Claude Code/Codex
- Applied to all three locales: zh, en, ja
2026-01-02 23:15:22 +08:00
Jason 22460de976 perf(skills): use infinite cache for discoverable skills
Change staleTime from 5 minutes to Infinity. Cache is still properly
invalidated when repos are added/removed or skills are installed/uninstalled.
2026-01-02 23:04:40 +08:00
Jason e69c1bd8aa fix(ui): align FullScreenPanel header with App.tsx layout
- Use same DRAG_BAR_HEIGHT (28px) and HEADER_HEIGHT (64px) as App.tsx
- Remove border-b divider line from header
- Add rounded-lg class to back button for consistency
2026-01-02 22:35:33 +08:00
Jason a17fa8098b fix(skills): remove redundant navigation buttons in skills pages
- Remove duplicate "Repo Manager" button from installed skills view
  (should only appear in discovery view)
- Remove redundant back button from SkillsPage component
  (header already provides unified navigation)
- Clean up unused openRepoManagerOnDiscovery state and related useEffect
- Remove unused onClose prop and ArrowLeft import from SkillsPage
2026-01-02 22:33:33 +08:00
Jason ff03ca1e63 feat(skills): unified management architecture with SSOT and React Query
- Introduce SSOT (Single Source of Truth) at ~/.cc-switch/skills/
- Add three-app toggle support (Claude/Codex/Gemini) for each skill
- Refactor frontend to use TanStack Query hooks instead of manual state
- Add UnifiedSkillsPanel for managing installed skills with app toggles
- Add useSkills.ts with declarative data fetching hooks
- Extend skills.ts API with unified install/uninstall/toggle methods
- Support importing unmanaged skills from app directories
- Add v2→v3 database migration for new skills table structure
2026-01-02 22:04:02 +08:00
Jason cce6ae86a5 fix: prevent env check card border overflow on hover
Add horizontal padding to the grid container to accommodate the scale
transform effect when hovering over environment check cards.
2025-12-31 22:58:03 +08:00
Dex Miller 5376ea042b Feat/usage improvements (#508)
* i18n: update cache terminology across all languages

- Change 'Cache Read' to 'Cache Hit' in all languages
- Change 'Cache Write' to 'Cache Creation' in all languages
- Update zh: 缓存读取 → 缓存命中, 缓存写入 → 缓存创建
- Update en: Cache Read → Cache Hit, Cache Write → Cache Creation
- Update ja: キャッシュ読取 → キャッシュヒット, キャッシュ書込 → キャッシュ作成

Affected keys: cacheReadTokens, cacheCreationTokens, cacheReadCost,
cacheWriteCost, cacheRead, cacheWrite

* feat(usage): add cache metrics to trend chart

- Add cache creation tokens visualization (orange line)
- Add cache hit tokens visualization (purple line)
- Add gradient definitions for new cache metrics
- Include cache data in hourly aggregation
- Display cache metrics alongside input/output tokens

This provides better visibility into cache usage patterns over time.

* fix(usage): fix timezone handling in datetime picker

- Add timestampToLocalDatetime() to convert Unix timestamp to local datetime
- Add localDatetimeToTimestamp() with validation for incomplete input
- Fix issue where typing hours/minutes would jump to previous day
- Validate datetime format completeness before conversion
- Use local timezone instead of UTC for datetime-local input

This resolves the issue where users couldn't fine-tune time selection
and the input would jump unexpectedly when editing hours or minutes.

* feat(usage): add auto-refresh for usage statistics

- Add 30-second auto-refresh interval for all usage queries
- Disable background refresh to save resources
- Apply to: summary, trends, provider stats, model stats, request logs
- Queries automatically update when tab is active
- Pause refresh when user switches to another tab

This keeps usage data fresh without manual refresh.

* fix(proxy): improve usage logging and cache token parsing

- Log requests even when usage parsing fails (with default values)
- Add detailed debug logging for usage metrics
- Support cache_read_input_tokens field in Codex responses
- Fallback to input_tokens_details.cached_tokens if needed
- Add test case for cached_tokens in input_tokens_details
- Ensure all requests are tracked in database for analytics

This fixes missing request logs when API responses lack usage data
and improves cache token detection across different response formats.

* style(rust): use inline format args in format! macros

- Replace format!("...", var) with format!("...{var}")
- Update universal provider ID formatting
- Update error message formatting
- Update config.toml generation in Codex provider

Fixes clippy::uninlined_format_args warnings.

* feat(proxy): enhance provider router logging

- Add debug logs for failover queue provider count
- Log circuit breaker state for each provider check
- Add logs for missing current provider scenarios
- Log when no current provider is configured
- Use inline format args for better readability

This improves debugging of provider selection and failover behavior.

* feat(database): update model pricing data

- Update Claude models to full version format (e.g. claude-opus-4-5-20251101)
- Add GPT-5.2 series model pricing (10 models)
- Add GPT-5.1 series model pricing (10 models)
- Add GPT-5 series model pricing (12 models)
- Add Gemini 3 series model pricing (2 models)
- Update Gemini 2.5 series model ID format (use dot separator)
- Unify display names by removing thinking level suffixes

* fix(usage): correct Gemini output token calculation

Fix Gemini API output token parsing to use totalTokenCount - promptTokenCount
instead of candidatesTokenCount alone. This ensures thoughtsTokenCount is
included in output statistics.

- Update from_gemini_response to calculate output from total - input
- Update from_gemini_stream_chunks with same logic for consistency
- Fix from_codex_stream_events to use adjusted token calculation
- Add test case for responses with thoughtsTokenCount
- Update existing tests to match new calculation logic

* fix(usage): correct cache token billing and add Codex format auto-detection

- Avoid double-billing cache tokens by subtracting from input before calculation
- Add smart Codex parser that auto-detects OpenAI vs Codex API format
- Extract model name from Codex responses for accurate tracking

* fix(proxy): improve takeover detection with live config check

- Add live config takeover detection for hot-switch decision
- Rebuild takeover when backup is missing or placeholder remains
- Make detect_takeover_in_live_config_for_app public
- Fix is_takeover_active to use actual takeover status

* refactor(usage): simplify model pricing lookup by removing suffix fallback

Replace complex suffix-stripping fallback with direct prefix/suffix cleanup.
Model IDs are now cleaned by removing vendor prefix (before /) and colon
suffix (after :), then matched exactly against pricing table.

* feat(database): add Chinese AI model pricing data

Add pricing for domestic AI models (CNY/1M tokens):
- Doubao-Seed-Code (ByteDance)
- DeepSeek V3/V3.1/V3.2
- Kimi K2/K2-Thinking/K2-Turbo (Moonshot)
- MiniMax M2/M2.1/M2.1-Lightning
- GLM-4.6/4.7 (Zhipu)
- Mimo V2 Flash (Xiaomi)

Also fix test case to use correct model ID and remove invalid currency column.

* refactor(proxy): improve header forwarding with blacklist approach

Change from whitelist to blacklist mode for request header forwarding.
Only skip headers that will be overridden (auth, host, content-length).
This preserves client's original headers and improves compatibility.

* fix(proxy): bypass timeout and retry configs when failover is disabled

When auto_failover_enabled is false, timeout and retry configurations
should not affect normal request flow. This change ensures:

- create_forwarder: passes 0 for all timeout/retry params when failover
  is disabled, effectively bypassing these checks
- streaming_timeout_config: returns 0 for both first_byte_timeout and
  idle_timeout when failover is disabled

This prevents unnecessary timeout errors and retry attempts when users
have explicitly disabled the failover feature.

* fix(proxy): handle zero value input in failover config fields

* refactor(proxy): remove retry logic and add enabled check for failover

* refactor(proxy): distinguish circuit-open from no-provider errors

* Align usage stats to sliding windows

* feat(proxy): add body and header filtering for upstream requests

* feat(proxy): enable transparent passthrough for headers

- Passthrough anthropic-beta header as-is from client
- Passthrough anthropic-version header from client
- Passthrough client IP headers (x-forwarded-for, x-real-ip) by default
- Filter private params (underscore-prefixed fields) from request body
- No database changes required

* feat(proxy): extract session ID from client requests for logging

- Add SessionIdExtractor to parse session ID from Claude/Codex requests
- Support extraction from metadata.user_id, headers, previous_response_id
- Pass session_id through RequestContext to usage logger
- Enable request correlation by session in proxy_request_logs
2025-12-31 22:57:00 +08:00
62 changed files with 4223 additions and 1277 deletions
+104
View File
@@ -55,6 +55,110 @@ impl McpApps {
} }
} }
/// Skill 应用启用状态(标记 Skill 应用到哪些客户端)
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SkillApps {
#[serde(default)]
pub claude: bool,
#[serde(default)]
pub codex: bool,
#[serde(default)]
pub gemini: bool,
}
impl SkillApps {
/// 检查指定应用是否启用
pub fn is_enabled_for(&self, app: &AppType) -> bool {
match app {
AppType::Claude => self.claude,
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
}
}
/// 设置指定应用的启用状态
pub fn set_enabled_for(&mut self, app: &AppType, enabled: bool) {
match app {
AppType::Claude => self.claude = enabled,
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
}
}
/// 获取所有启用的应用列表
pub fn enabled_apps(&self) -> Vec<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 统一结构) /// MCP 服务器定义(v3.7.0 统一结构)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer { pub struct McpServer {
+10
View File
@@ -192,3 +192,13 @@ pub async fn toggle_mcp_app(
let app_ty = AppType::from_str(&app).map_err(|e| e.to_string())?; 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()) 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)
}
+8 -1
View File
@@ -1,6 +1,6 @@
#![allow(non_snake_case)] #![allow(non_snake_case)]
use crate::init_status::InitErrorPayload; use crate::init_status::{InitErrorPayload, SkillsMigrationPayload};
use tauri::AppHandle; use tauri::AppHandle;
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
@@ -65,6 +65,13 @@ pub async fn get_migration_result() -> Result<bool, String> {
Ok(crate::init_status::take_migration_success()) 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)] #[derive(serde::Serialize)]
pub struct ToolVersion { pub struct ToolVersion {
name: String, name: String,
+6 -7
View File
@@ -184,17 +184,16 @@ pub async fn reset_circuit_breaker(
.await?; .await?;
// 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取) // 3. 检查是否应该切回优先级更高的供应商(从 proxy_config 表读取)
let auto_failover_enabled = match db.get_proxy_config_for_app(&app_type).await { // 只有当该应用已被代理接管(enabled=true)且开启了自动故障转移时才执行
Ok(config) => config.auto_failover_enabled, let (app_enabled, auto_failover_enabled) = match db.get_proxy_config_for_app(&app_type).await {
Ok(config) => (config.enabled, config.auto_failover_enabled),
Err(e) => { Err(e) => {
log::error!( log::error!("[{app_type}] Failed to read proxy_config: {e}, defaulting to disabled");
"[{app_type}] Failed to read proxy_config for auto_failover_enabled: {e}, defaulting to disabled" (false, false)
);
false
} }
}; };
if auto_failover_enabled && state.proxy_service.is_running().await { if app_enabled && auto_failover_enabled && state.proxy_service.is_running().await {
// 获取当前供应商 ID // 获取当前供应商 ID
let current_id = db let current_id = db
.get_current_provider(&app_type) .get_current_provider(&app_type)
+142 -127
View File
@@ -1,12 +1,17 @@
use crate::app_config::AppType; //! Skills 命令层
//!
//! v3.10.0+ 统一管理架构:
//! - 支持三应用开关(Claude/Codex/Gemini
//! - SSOT 存储在 ~/.cc-switch/skills/
use crate::app_config::{AppType, InstalledSkill, UnmanagedSkill};
use crate::error::format_skill_error; use crate::error::format_skill_error;
use crate::services::skill::SkillState; use crate::services::skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
use crate::services::{Skill, SkillRepo, SkillService};
use crate::store::AppState; use crate::store::AppState;
use chrono::Utc;
use std::sync::Arc; use std::sync::Arc;
use tauri::State; use tauri::State;
/// SkillService 状态包装
pub struct SkillServiceState(pub Arc<SkillService>); pub struct SkillServiceState(pub Arc<SkillService>);
/// 解析 app 参数为 AppType /// 解析 app 参数为 AppType
@@ -19,65 +24,117 @@ fn parse_app_type(app: &str) -> Result<AppType, String> {
} }
} }
/// 根据 app_type 生成带前缀的 skill key // ========== 统一管理命令 ==========
fn get_skill_key(app_type: &AppType, directory: &str) -> String {
let prefix = match app_type { /// 获取所有已安装的 Skills
AppType::Claude => "claude", #[tauri::command]
AppType::Codex => "codex", pub fn get_installed_skills(app_state: State<'_, AppState>) -> Result<Vec<InstalledSkill>, String> {
AppType::Gemini => "gemini", SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())
};
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] #[tauri::command]
pub async fn get_skills( pub async fn get_skills(
service: State<'_, SkillServiceState>, service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> { ) -> Result<Vec<Skill>, String> {
get_skills_for_app("claude".to_string(), service, app_state).await let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
service
.0
.list_skills(repos, &app_state.db)
.await
.map_err(|e| e.to_string())
} }
/// 获取指定应用的技能列表(兼容旧 API)
#[tauri::command] #[tauri::command]
pub async fn get_skills_for_app( pub async fn get_skills_for_app(
app: String, app: String,
_service: State<'_, SkillServiceState>, service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<Vec<Skill>, String> { ) -> Result<Vec<Skill>, String> {
let app_type = parse_app_type(&app)?; // 新版本不再区分应用,统一返回所有技能
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?; let _ = parse_app_type(&app)?; // 验证 app 参数有效
get_skills(service, app_state).await
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] #[tauri::command]
pub async fn install_skill( pub async fn install_skill(
directory: String, directory: String,
@@ -87,27 +144,34 @@ pub async fn install_skill(
install_skill_for_app("claude".to_string(), directory, service, app_state).await install_skill_for_app("claude".to_string(), directory, service, app_state).await
} }
/// 安装指定应用的技能(兼容旧 API)
#[tauri::command] #[tauri::command]
pub async fn install_skill_for_app( pub async fn install_skill_for_app(
app: String, app: String,
directory: String, directory: String,
_service: State<'_, SkillServiceState>, service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let app_type = 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())?;
// 先在不持有写锁的情况下收集仓库与技能信息 // 先获取技能信息
let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?; let repos = app_state.db.get_skill_repos().map_err(|e| e.to_string())?;
let skills = service let skills = service
.list_skills(repos) .0
.discover_available(repos)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let skill = skills let skill = skills
.iter() .into_iter()
.find(|s| s.directory.eq_ignore_ascii_case(&directory)) .find(|s| {
let install_name = std::path::Path::new(&s.directory)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| s.directory.clone());
install_name.eq_ignore_ascii_case(&directory)
|| s.directory.eq_ignore_ascii_case(&directory)
})
.ok_or_else(|| { .ok_or_else(|| {
format_skill_error( format_skill_error(
"SKILL_NOT_FOUND", "SKILL_NOT_FOUND",
@@ -116,103 +180,54 @@ pub async fn install_skill_for_app(
) )
})?; })?;
if !skill.installed { service
let repo = SkillRepo { .0
owner: skill.repo_owner.clone().ok_or_else(|| { .install(&app_state.db, &skill, &app_type)
format_skill_error( .await
"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())?; .map_err(|e| e.to_string())?;
Ok(true) Ok(true)
} }
/// 卸载技能(兼容旧 API
#[tauri::command] #[tauri::command]
pub fn uninstall_skill( pub fn uninstall_skill(directory: String, app_state: State<'_, AppState>) -> Result<bool, String> {
directory: String, uninstall_skill_for_app("claude".to_string(), directory, app_state)
service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
uninstall_skill_for_app("claude".to_string(), directory, service, app_state)
} }
/// 卸载指定应用的技能(兼容旧 API)
#[tauri::command] #[tauri::command]
pub fn uninstall_skill_for_app( pub fn uninstall_skill_for_app(
app: String, app: String,
directory: String, directory: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
let app_type = parse_app_type(&app)?; let _ = parse_app_type(&app)?; // 验证参数
let service = SkillService::new_for_app(app_type.clone()).map_err(|e| e.to_string())?;
service // 通过 directory 找到对应的 skill id
.uninstall_skill(directory.clone()) let skills = SkillService::get_all_installed(&app_state.db).map_err(|e| e.to_string())?;
.map_err(|e| e.to_string())?;
// Remove from database by setting installed = false let skill = skills
let key = get_skill_key(&app_type, &directory); .into_iter()
app_state .find(|s| s.directory.eq_ignore_ascii_case(&directory))
.db .ok_or_else(|| format!("未找到已安装的 Skill: {directory}"))?;
.update_skill_state(
&key, SkillService::uninstall(&app_state.db, &skill.id).map_err(|e| e.to_string())?;
&SkillState {
installed: false,
installed_at: Utc::now(),
},
)
.map_err(|e| e.to_string())?;
Ok(true) Ok(true)
} }
// ========== 仓库管理命令 ==========
/// 获取技能仓库列表
#[tauri::command] #[tauri::command]
pub fn get_skill_repos( pub fn get_skill_repos(app_state: State<'_, AppState>) -> Result<Vec<SkillRepo>, String> {
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<Vec<SkillRepo>, String> {
app_state.db.get_skill_repos().map_err(|e| e.to_string()) app_state.db.get_skill_repos().map_err(|e| e.to_string())
} }
/// 添加技能仓库
#[tauri::command] #[tauri::command]
pub fn add_skill_repo( pub fn add_skill_repo(repo: SkillRepo, app_state: State<'_, AppState>) -> Result<bool, String> {
repo: SkillRepo,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>,
) -> Result<bool, String> {
app_state app_state
.db .db
.save_skill_repo(&repo) .save_skill_repo(&repo)
@@ -220,11 +235,11 @@ pub fn add_skill_repo(
Ok(true) Ok(true)
} }
/// 删除技能仓库
#[tauri::command] #[tauri::command]
pub fn remove_skill_repo( pub fn remove_skill_repo(
owner: String, owner: String,
name: String, name: String,
_service: State<'_, SkillServiceState>,
app_state: State<'_, AppState>, app_state: State<'_, AppState>,
) -> Result<bool, String> { ) -> Result<bool, String> {
app_state app_state
+3 -2
View File
@@ -19,9 +19,10 @@ pub fn get_usage_summary(
#[tauri::command] #[tauri::command]
pub fn get_usage_trends( pub fn get_usage_trends(
state: State<'_, AppState>, state: State<'_, AppState>,
days: u32, start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> { ) -> Result<Vec<DailyStats>, AppError> {
state.db.get_daily_trends(days) state.db.get_daily_trends(start_date, end_date)
} }
/// 获取 Provider 统计 /// 获取 Provider 统计
+9 -9
View File
@@ -121,13 +121,13 @@ impl Database {
enabled: false, enabled: false,
auto_failover_enabled: false, auto_failover_enabled: false,
max_retries: 3, max_retries: 3,
streaming_first_byte_timeout: 30, streaming_first_byte_timeout: 60,
streaming_idle_timeout: 60, streaming_idle_timeout: 120,
non_streaming_timeout: 300, non_streaming_timeout: 600,
circuit_failure_threshold: 5, circuit_failure_threshold: 4,
circuit_success_threshold: 2, circuit_success_threshold: 2,
circuit_timeout_seconds: 60, circuit_timeout_seconds: 60,
circuit_error_rate_threshold: 0.5, circuit_error_rate_threshold: 0.6,
circuit_min_requests: 10, circuit_min_requests: 10,
}) })
} }
@@ -210,12 +210,12 @@ impl Database {
listen_address: row.get(0)?, listen_address: row.get(0)?,
listen_port: row.get::<_, i32>(1)? as u16, listen_port: row.get::<_, i32>(1)? as u16,
max_retries: row.get::<_, i32>(2)? as u8, max_retries: row.get::<_, i32>(2)? as u8,
request_timeout: 300, // 废弃字段,返回默认值 request_timeout: 600, // 废弃字段,返回默认值
enable_logging: row.get::<_, i32>(3)? != 0, enable_logging: row.get::<_, i32>(3)? != 0,
live_takeover_active: false, // 废弃字段 live_takeover_active: false, // 废弃字段
streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(30) as u64, streaming_first_byte_timeout: row.get::<_, i32>(4).unwrap_or(60) as u64,
streaming_idle_timeout: row.get::<_, i32>(5).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(300) as u64, non_streaming_timeout: row.get::<_, i32>(6).unwrap_or(600) as u64,
}) })
}, },
) )
+121 -37
View File
@@ -1,73 +1,156 @@
//! Skills 数据访问对象 //! Skills 数据访问对象
//! //!
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。 //! 提供 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::database::{lock_conn, Database};
use crate::error::AppError; use crate::error::AppError;
use crate::services::skill::{SkillRepo, SkillState}; use crate::services::skill::SkillRepo;
use indexmap::IndexMap; use indexmap::IndexMap;
use rusqlite::params; use rusqlite::params;
impl Database { impl Database {
/// 获取所有 Skills 状态 // ========== InstalledSkill CRUD ==========
pub fn get_skills(&self) -> Result<IndexMap<String, SkillState>, AppError> {
/// 获取所有已安装的 Skills
pub fn get_all_installed_skills(&self) -> Result<IndexMap<String, InstalledSkill>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
let mut stmt = conn let mut stmt = conn
.prepare("SELECT directory, app_type, installed, installed_at FROM skills ORDER BY directory ASC, app_type ASC") .prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
let skill_iter = stmt let skill_iter = stmt
.query_map([], |row| { .query_map([], |row| {
let directory: String = row.get(0)?; Ok(InstalledSkill {
let app_type: String = row.get(1)?; id: row.get(0)?,
let installed: bool = row.get(2)?; name: row.get(1)?,
let installed_at_ts: i64 = row.get(3)?; description: row.get(2)?,
directory: row.get(3)?,
let installed_at = repo_owner: row.get(4)?,
chrono::DateTime::from_timestamp(installed_at_ts, 0).unwrap_or_default(); repo_name: row.get(5)?,
repo_branch: row.get(6)?,
// 构建复合 key"app_type:directory" readme_url: row.get(7)?,
let key = format!("{app_type}:{directory}"); apps: SkillApps {
claude: row.get(8)?,
Ok(( codex: row.get(9)?,
key, gemini: row.get(10)?,
SkillState {
installed,
installed_at,
}, },
)) installed_at: row.get(11)?,
})
}) })
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
let mut skills = IndexMap::new(); let mut skills = IndexMap::new();
for skill_res in skill_iter { for skill_res in skill_iter {
let (key, skill) = skill_res.map_err(|e| AppError::Database(e.to_string()))?; let skill = skill_res.map_err(|e| AppError::Database(e.to_string()))?;
skills.insert(key, skill); skills.insert(skill.id.clone(), skill);
} }
Ok(skills) Ok(skills)
} }
/// 更新 Skill 状态 /// 获取单个已安装的 Skill
/// key 格式为 "app_type:directory" pub fn get_installed_skill(&self, id: &str) -> Result<Option<InstalledSkill>, AppError> {
pub fn update_skill_state(&self, key: &str, state: &SkillState) -> Result<(), AppError> { let conn = lock_conn!(self.conn);
// 解析 key let mut stmt = conn
let (app_type, directory) = if let Some(idx) = key.find(':') { .prepare(
let (app, dir) = key.split_at(idx); "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
(app, &dir[1..]) // 跳过冒号 readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at
} else { FROM skills WHERE id = ?1",
// 向后兼容:如果没有前缀,默认为 claude )
("claude", key) .map_err(|e| AppError::Database(e.to_string()))?;
};
let result = stmt.query_row([id], |row| {
Ok(InstalledSkill {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
directory: row.get(3)?,
repo_owner: row.get(4)?,
repo_name: row.get(5)?,
repo_branch: row.get(6)?,
readme_url: row.get(7)?,
apps: SkillApps {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
},
installed_at: row.get(11)?,
})
});
match result {
Ok(skill) => Ok(Some(skill)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(AppError::Database(e.to_string())),
}
}
/// 保存 Skill(添加或更新)
pub fn save_skill(&self, skill: &InstalledSkill) -> Result<(), AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
conn.execute( conn.execute(
"INSERT OR REPLACE INTO skills (directory, app_type, installed, installed_at) VALUES (?1, ?2, ?3, ?4)", "INSERT OR REPLACE INTO skills
params![directory, app_type, state.installed, state.installed_at.timestamp()], (id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, installed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
skill.id,
skill.name,
skill.description,
skill.directory,
skill.repo_owner,
skill.repo_name,
skill.repo_branch,
skill.readme_url,
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.installed_at,
],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
Ok(()) 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 仓库 /// 获取所有 Skill 仓库
pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> { pub fn get_skill_repos(&self) -> Result<Vec<SkillRepo>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
@@ -101,7 +184,8 @@ impl Database {
conn.execute( conn.execute(
"INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)", "INSERT OR REPLACE INTO skill_repos (owner, name, branch, enabled) VALUES (?1, ?2, ?3, ?4)",
params![repo.owner, repo.name, repo.branch, repo.enabled], 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(()) Ok(())
} }
+10 -7
View File
@@ -192,13 +192,16 @@ impl Database {
tx: &rusqlite::Transaction<'_>, tx: &rusqlite::Transaction<'_>,
config: &MultiAppConfig, config: &MultiAppConfig,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
for (key, state) in &config.skills.skills { // v3.10.0+Skills 的 SSOT 已迁移到文件系统(~/.cc-switch/skills/+ 数据库统一结构。
tx.execute( //
"INSERT OR REPLACE INTO skills (key, installed, installed_at) VALUES (?1, ?2, ?3)", // 旧版 config.json 里的 `skills.skills` 仅记录“安装状态”,但不包含完整元数据,
params![key, state.installed, state.installed_at.timestamp()], // 且无法保证 SSOT 目录中一定存在对应的 skill 文件。
) //
.map_err(|e| AppError::Database(format!("Migrate skill failed: {e}")))?; // 因此这里不再直接把旧的安装状态写入新 skills 表,避免产生“数据库显示已安装但文件缺失”的不一致。
} // 迁移后可通过:
// - 前端「导入已有」(扫描各应用的 skills 目录并复制到 SSOT)
// - 或后续启动时的自动扫描逻辑
// 来重建已安装技能记录。
for repo in &config.skills.repos { for repo in &config.skills.repos {
tx.execute( tx.execute(
+1 -1
View File
@@ -47,7 +47,7 @@ const DB_BACKUP_RETAIN: usize = 10;
/// 当前 Schema 版本号 /// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 2; pub(crate) const SCHEMA_VERSION: i32 = 3;
/// 安全地序列化 JSON,避免 unwrap panic /// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> { pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+337 -58
View File
@@ -71,11 +71,21 @@ impl Database {
PRIMARY KEY (id, app_type) PRIMARY KEY (id, app_type)
)", []).map_err(|e| AppError::Database(e.to_string()))?; )", []).map_err(|e| AppError::Database(e.to_string()))?;
// 5. Skills 表 // 5. Skills 表v3.10.0+ 统一结构)
conn.execute( conn.execute(
"CREATE TABLE IF NOT EXISTS skills ( "CREATE TABLE IF NOT EXISTS skills (
directory TEXT NOT NULL, app_type TEXT NOT NULL, installed BOOLEAN NOT NULL DEFAULT 0, id TEXT PRIMARY KEY,
installed_at INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (directory, app_type) 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
)", )",
[], [],
) )
@@ -104,10 +114,10 @@ impl Database {
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1', 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, 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, enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30, max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300, streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2, 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.5, circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10, circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", []).map_err(|e| AppError::Database(e.to_string()))?; )", []).map_err(|e| AppError::Database(e.to_string()))?;
@@ -123,7 +133,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests) circuit_error_rate_threshold, circuit_min_requests)
VALUES ('claude', 6, 45, 90, 300, 8, 3, 90, 0.6, 15)", VALUES ('claude', 6, 90, 180, 600, 8, 3, 90, 0.7, 15)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -132,7 +142,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests) circuit_error_rate_threshold, circuit_min_requests)
VALUES ('codex', 3, 30, 60, 300, 5, 2, 60, 0.5, 10)", VALUES ('codex', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -141,7 +151,7 @@ impl Database {
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout, streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds, circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests) circuit_error_rate_threshold, circuit_min_requests)
VALUES ('gemini', 5, 30, 60, 300, 5, 2, 60, 0.5, 10)", VALUES ('gemini', 5, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[], [],
) )
.map_err(|e| AppError::Database(e.to_string()))?; .map_err(|e| AppError::Database(e.to_string()))?;
@@ -253,15 +263,15 @@ impl Database {
// 尝试添加超时配置列到 proxy_config 表 // 尝试添加超时配置列到 proxy_config 表
let _ = conn.execute( let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30", "ALTER TABLE proxy_config ADD COLUMN streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60",
[], [],
); );
let _ = conn.execute( let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 60", "ALTER TABLE proxy_config ADD COLUMN streaming_idle_timeout INTEGER NOT NULL DEFAULT 120",
[], [],
); );
let _ = conn.execute( let _ = conn.execute(
"ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 300", "ALTER TABLE proxy_config ADD COLUMN non_streaming_timeout INTEGER NOT NULL DEFAULT 600",
[], [],
); );
@@ -331,6 +341,11 @@ impl Database {
Self::migrate_v1_to_v2(conn)?; Self::migrate_v1_to_v2(conn)?;
Self::set_user_version(conn, 2)?; 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!( return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -467,19 +482,19 @@ impl Database {
conn, conn,
"proxy_config", "proxy_config",
"streaming_first_byte_timeout", "streaming_first_byte_timeout",
"INTEGER NOT NULL DEFAULT 30",
)?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 60", "INTEGER NOT NULL DEFAULT 60",
)?; )?;
Self::add_column_if_missing(
conn,
"proxy_config",
"streaming_idle_timeout",
"INTEGER NOT NULL DEFAULT 120",
)?;
Self::add_column_if_missing( Self::add_column_if_missing(
conn, conn,
"proxy_config", "proxy_config",
"non_streaming_timeout", "non_streaming_timeout",
"INTEGER NOT NULL DEFAULT 300", "INTEGER NOT NULL DEFAULT 600",
)?; )?;
} }
@@ -651,10 +666,10 @@ impl Database {
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1', 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, 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, enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 30, max_retries INTEGER NOT NULL DEFAULT 3, streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 60, non_streaming_timeout INTEGER NOT NULL DEFAULT 300, streaming_idle_timeout INTEGER NOT NULL DEFAULT 120, non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 5, circuit_success_threshold INTEGER NOT NULL DEFAULT 2, 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.5, circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60, circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10, circuit_min_requests INTEGER NOT NULL DEFAULT 10,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?; )", [])?;
@@ -689,6 +704,17 @@ impl Database {
/// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键 /// 迁移 skills 表:从单 key 主键改为 (directory, app_type) 复合主键
fn migrate_skills_table(conn: &Connection) -> Result<(), AppError> { 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")? { if Self::has_column(conn, "skills", "app_type")? {
log::info!("skills 表已经包含 app_type 字段,跳过迁移"); log::info!("skills 表已经包含 app_type 字段,跳过迁移");
@@ -760,14 +786,77 @@ impl Database {
Ok(()) 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, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> { fn seed_model_pricing(conn: &Connection) -> Result<(), AppError> {
let pricing_data = [ let pricing_data = [
// Claude 4.5 系列 // Claude 4.5 系列 (Latest Models)
( (
"claude-opus-4-5", "claude-opus-4-5-20251101",
"Claude Opus 4.5", "Claude Opus 4.5",
"5", "5",
"25", "25",
@@ -775,7 +864,7 @@ impl Database {
"6.25", "6.25",
), ),
( (
"claude-sonnet-4-5", "claude-sonnet-4-5-20250929",
"Claude Sonnet 4.5", "Claude Sonnet 4.5",
"3", "3",
"15", "15",
@@ -783,16 +872,24 @@ impl Database {
"3.75", "3.75",
), ),
( (
"claude-haiku-4-5", "claude-haiku-4-5-20251001",
"Claude Haiku 4.5", "Claude Haiku 4.5",
"1", "1",
"5", "5",
"0.10", "0.10",
"1.25", "1.25",
), ),
// Claude 4.1 系列 // Claude 4 系列 (Legacy Models)
( (
"claude-opus-4-1", "claude-opus-4-20250514",
"Claude Opus 4",
"15",
"75",
"1.50",
"18.75",
),
(
"claude-opus-4-1-20250805",
"Claude Opus 4.1", "Claude Opus 4.1",
"15", "15",
"75", "75",
@@ -800,17 +897,8 @@ impl Database {
"18.75", "18.75",
), ),
( (
"claude-sonnet-4-1", "claude-sonnet-4-20250514",
"Claude Sonnet 4.1", "Claude Sonnet 4",
"3",
"15",
"0.30",
"3.75",
),
// Claude 3.7 系列
(
"claude-sonnet-3-7",
"Claude Sonnet 3.7",
"3", "3",
"15", "15",
"0.30", "0.30",
@@ -818,38 +906,167 @@ impl Database {
), ),
// Claude 3.5 系列 // Claude 3.5 系列
( (
"claude-sonnet-3-5", "claude-3-5-haiku-20241022",
"Claude Sonnet 3.5", "Claude 3.5 Haiku",
"3",
"15",
"0.30",
"3.75",
),
(
"claude-haiku-3-5",
"Claude Haiku 3.5",
"0.80", "0.80",
"4", "4",
"0.08", "0.08",
"1", "1",
), ),
// GPT-5 系列(model_id 使用短横线格式) (
"claude-3-5-sonnet-20241022",
"Claude 3.5 Sonnet",
"3",
"15",
"0.30",
"3.75",
),
// GPT-5.2 系列
("gpt-5.2", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-low", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-medium", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-high", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-xhigh", "GPT-5.2", "1.75", "14", "0.175", "0"),
("gpt-5.2-codex", "GPT-5.2 Codex", "1.75", "14", "0.175", "0"),
(
"gpt-5.2-codex-low",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.2-codex-medium",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.2-codex-high",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
(
"gpt-5.2-codex-xhigh",
"GPT-5.2 Codex",
"1.75",
"14",
"0.175",
"0",
),
// GPT-5.1 系列
("gpt-5.1", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-low", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-medium", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-high", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-minimal", "GPT-5.1", "1.25", "10", "0.125", "0"),
("gpt-5.1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"),
(
"gpt-5.1-codex-mini",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5.1-codex-max",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5.1-codex-max-high",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5.1-codex-max-xhigh",
"GPT-5.1 Codex",
"1.25",
"10",
"0.125",
"0",
),
// GPT-5 系列
("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"), ("gpt-5", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-1", "GPT-5.1", "1.25", "10", "0.125", "0"), ("gpt-5-low", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-medium", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-high", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-minimal", "GPT-5", "1.25", "10", "0.125", "0"),
("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"), ("gpt-5-codex", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
("gpt-5-1-codex", "GPT-5.1 Codex", "1.25", "10", "0.125", "0"), ("gpt-5-codex-low", "GPT-5 Codex", "1.25", "10", "0.125", "0"),
(
"gpt-5-codex-medium",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-high",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-mini",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-mini-medium",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
(
"gpt-5-codex-mini-high",
"GPT-5 Codex",
"1.25",
"10",
"0.125",
"0",
),
// Gemini 3 系列 // Gemini 3 系列
( (
"gemini-3-pro-preview", "gemini-3-pro-preview",
"Gemini 3 Pro Preview", "Gemini 3 Pro Preview",
"2", "2",
"12", "12",
"0", "0.2",
"0", "0",
), ),
// Gemini 2.5 系列(model_id 使用短横线格式)
( (
"gemini-2-5-pro", "gemini-3-flash-preview",
"Gemini 3 Flash Preview",
"0.5",
"3",
"0.05",
"0",
),
// Gemini 2.5 系列
(
"gemini-2.5-pro",
"Gemini 2.5 Pro", "Gemini 2.5 Pro",
"1.25", "1.25",
"10", "10",
@@ -857,13 +1074,75 @@ impl Database {
"0", "0",
), ),
( (
"gemini-2-5-flash", "gemini-2.5-flash",
"Gemini 2.5 Flash", "Gemini 2.5 Flash",
"0.3", "0.3",
"2.5", "2.5",
"0.03", "0.03",
"0", "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 { for (model_id, display_name, input, output, cache_read, cache_creation) in pricing_data {
+4
View File
@@ -52,6 +52,10 @@ pub enum AppError {
}, },
#[error("数据库错误: {0}")] #[error("数据库错误: {0}")]
Database(String), Database(String),
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
} }
impl AppError { impl AppError {
+41
View File
@@ -52,6 +52,47 @@ pub fn take_migration_success() -> bool {
} }
} }
// ============================================================
// Skills SSOT 迁移结果状态
// ============================================================
#[derive(Debug, Clone, Serialize)]
pub struct SkillsMigrationPayload {
pub count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+54 -9
View File
@@ -324,6 +324,47 @@ pub fn run() {
Err(e) => log::warn!("✗ Failed to initialize default skill repos: {e}"), 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. 导入供应商配置(已有内置检查:该应用已有供应商则跳过) // 2. 导入供应商配置(已有内置检查:该应用已有供应商则跳过)
for app in [ for app in [
crate::app_config::AppType::Claude, crate::app_config::AppType::Claude,
@@ -507,14 +548,8 @@ pub fn run() {
app.manage(app_state); app.manage(app_state);
// 初始化 SkillService // 初始化 SkillService
match SkillService::new() { let skill_service = SkillService::new();
Ok(skill_service) => { app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
app.manage(commands::skill::SkillServiceState(Arc::new(skill_service)));
}
Err(e) => {
log::warn!("初始化 SkillService 失败: {e}");
}
}
// 异常退出恢复 + 代理状态自动恢复 // 异常退出恢复 + 代理状态自动恢复
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
@@ -564,6 +599,7 @@ pub fn run() {
commands::open_external, commands::open_external,
commands::get_init_error, commands::get_init_error,
commands::get_migration_result, commands::get_migration_result,
commands::get_skills_migration_result,
commands::get_app_config_path, commands::get_app_config_path,
commands::open_app_config_folder, commands::open_app_config_folder,
commands::get_claude_common_config_snippet, commands::get_claude_common_config_snippet,
@@ -601,6 +637,7 @@ pub fn run() {
commands::upsert_mcp_server, commands::upsert_mcp_server,
commands::delete_mcp_server, commands::delete_mcp_server,
commands::toggle_mcp_app, commands::toggle_mcp_app,
commands::import_mcp_from_apps,
// Prompt management // Prompt management
commands::get_prompts, commands::get_prompts,
commands::upsert_prompt, commands::upsert_prompt,
@@ -635,7 +672,15 @@ pub fn run() {
commands::check_env_conflicts, commands::check_env_conflicts,
commands::delete_env_vars, commands::delete_env_vars,
commands::restore_env_backup, commands::restore_env_backup,
// Skill management // Skill management (v3.10.0+ unified)
commands::get_installed_skills,
commands::install_skill_unified,
commands::uninstall_skill_unified,
commands::toggle_skill_app,
commands::scan_unmanaged_skills,
commands::import_skills_from_apps,
commands::discover_available_skills,
// Skill management (legacy API compatibility)
commands::get_skills, commands::get_skills,
commands::get_skills_for_app, commands::get_skills_for_app,
commands::install_skill, commands::install_skill,
+4 -5
View File
@@ -386,16 +386,15 @@ impl UniversalProvider {
// 生成 Codex 的 config.toml 内容 // 生成 Codex 的 config.toml 内容
let config_toml = format!( let config_toml = format!(
r#"model_provider = "newapi" r#"model_provider = "newapi"
model = "{}" model = "{model}"
model_reasoning_effort = "{}" model_reasoning_effort = "{reasoning_effort}"
disable_response_storage = true disable_response_storage = true
[model_providers.newapi] [model_providers.newapi]
name = "NewAPI" name = "NewAPI"
base_url = "{}" base_url = "{codex_base_url}"
wire_api = "responses" wire_api = "responses"
requires_openai_auth = true"#, requires_openai_auth = true"#
model, reasoning_effort, codex_base_url
); );
let settings_config = serde_json::json!({ let settings_config = serde_json::json!({
+303
View File
@@ -0,0 +1,303 @@
//! 请求体过滤模块
//!
//! 过滤不应透传到上游的私有参数,防止内部信息泄露。
//!
//! ## 过滤规则
//! - 以 `_` 开头的字段被视为私有参数,会被递归过滤
//! - 支持白名单机制,允许透传特定的 `_` 前缀字段
//! - 支持嵌套对象和数组的深度过滤
//!
//! ## 使用场景
//! - `_internal_id`: 内部追踪 ID
//! - `_debug_mode`: 调试标记
//! - `_session_token`: 会话令牌
//! - `_client_version`: 客户端版本
use serde_json::Value;
use std::collections::HashSet;
/// 过滤私有参数(以 `_` 开头的字段)
///
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段。
///
/// # Arguments
/// * `body` - 原始请求体
///
/// # Returns
/// 过滤后的请求体
///
/// # Example
/// ```ignore
/// let input = json!({
/// "model": "claude-3",
/// "_internal_id": "abc123",
/// "messages": [{"role": "user", "content": "hello", "_token": "secret"}]
/// });
/// let output = filter_private_params(input);
/// // output 中不包含 _internal_id 和 _token
/// ```
#[cfg(test)]
pub fn filter_private_params(body: Value) -> Value {
filter_private_params_with_whitelist(body, &[])
}
/// 过滤私有参数(支持白名单)
///
/// 递归遍历 JSON 结构,移除所有以下划线开头的字段,
/// 但保留白名单中指定的字段。
///
/// # Arguments
/// * `body` - 原始请求体
/// * `whitelist` - 白名单字段列表(不过滤这些字段)
///
/// # Returns
/// 过滤后的请求体
///
/// # Example
/// ```ignore
/// let input = json!({
/// "model": "claude-3",
/// "_metadata": {"key": "value"}, // 白名单中,保留
/// "_internal_id": "abc123" // 不在白名单中,过滤
/// });
/// let output = filter_private_params_with_whitelist(input, &["_metadata"]);
/// // output 包含 _metadata,不包含 _internal_id
/// ```
pub fn filter_private_params_with_whitelist(body: Value, whitelist: &[String]) -> Value {
let whitelist_set: HashSet<&str> = whitelist.iter().map(|s| s.as_str()).collect();
filter_recursive_with_whitelist(body, &mut Vec::new(), &whitelist_set)
}
/// 递归过滤实现
#[cfg(test)]
fn filter_recursive(value: Value, removed_keys: &mut Vec<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 { impl Default for CircuitBreakerConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
failure_threshold: 5, failure_threshold: 4,
success_threshold: 2, success_threshold: 2,
timeout_seconds: 60, timeout_seconds: 60,
error_rate_threshold: 0.5, error_rate_threshold: 0.6,
min_requests: 10, min_requests: 10,
} }
} }
+12
View File
@@ -23,6 +23,12 @@ pub enum ProxyError {
#[error("无可用的Provider")] #[error("无可用的Provider")]
NoAvailableProvider, NoAvailableProvider,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
NoProvidersConfigured,
#[allow(dead_code)] #[allow(dead_code)]
#[error("Provider不健康: {0}")] #[error("Provider不健康: {0}")]
ProviderUnhealthy(String), ProviderUnhealthy(String),
@@ -111,6 +117,12 @@ impl IntoResponse for ProxyError {
ProxyError::NoAvailableProvider => { ProxyError::NoAvailableProvider => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string()) (StatusCode::SERVICE_UNAVAILABLE, self.to_string())
} }
ProxyError::AllProvidersCircuitOpen => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::NoProvidersConfigured => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string())
}
ProxyError::ProviderUnhealthy(_) => { ProxyError::ProviderUnhealthy(_) => {
(StatusCode::SERVICE_UNAVAILABLE, self.to_string()) (StatusCode::SERVICE_UNAVAILABLE, self.to_string())
} }
+8
View File
@@ -27,6 +27,12 @@ pub fn map_proxy_error_to_status(error: &ProxyError) -> u16 {
// 无可用 Provider503 Service Unavailable // 无可用 Provider503 Service Unavailable
ProxyError::NoAvailableProvider => 503, ProxyError::NoAvailableProvider => 503,
// 所有供应商已熔断:503 Service Unavailable
ProxyError::AllProvidersCircuitOpen => 503,
// 未配置供应商:503 Service Unavailable
ProxyError::NoProvidersConfigured => 503,
// 重试耗尽:503 Service Unavailable // 重试耗尽:503 Service Unavailable
ProxyError::MaxRetriesExceeded => 503, ProxyError::MaxRetriesExceeded => 503,
@@ -57,6 +63,8 @@ pub fn get_error_message(error: &ProxyError) -> String {
ProxyError::Timeout(msg) => format!("请求超时: {msg}"), ProxyError::Timeout(msg) => format!("请求超时: {msg}"),
ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"), ProxyError::ForwardFailed(msg) => format!("转发失败: {msg}"),
ProxyError::NoAvailableProvider => "无可用 Provider".to_string(), ProxyError::NoAvailableProvider => "无可用 Provider".to_string(),
ProxyError::AllProvidersCircuitOpen => "所有供应商已熔断,无可用渠道".to_string(),
ProxyError::NoProvidersConfigured => "未配置供应商".to_string(),
ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(), ProxyError::MaxRetriesExceeded => "所有 Provider 都失败,重试耗尽".to_string(),
ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"), ProxyError::ProviderUnhealthy(msg) => format!("Provider 不健康: {msg}"),
ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"), ProxyError::DatabaseError(msg) => format!("数据库错误: {msg}"),
+15
View File
@@ -81,6 +81,21 @@ impl FailoverSwitchManager {
provider_id: &str, provider_id: &str,
provider_name: &str, provider_name: &str,
) -> Result<bool, AppError> { ) -> 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})"); log::info!("[Failover] 开始切换供应商: {app_type} -> {provider_name} ({provider_id})");
// 1. 更新数据库 is_current // 1. 更新数据库 is_current
+187 -97
View File
@@ -1,8 +1,9 @@
//! 请求转发器 //! 请求转发器
//! //!
//! 负责将请求转发到上游Provider,支持重试和故障转移 //! 负责将请求转发到上游Provider,支持故障转移
use super::{ use super::{
body_filter::filter_private_params_with_whitelist,
error::*, error::*,
failover_switch::FailoverSwitchManager, failover_switch::FailoverSwitchManager,
provider_router::ProviderRouter, provider_router::ProviderRouter,
@@ -17,6 +18,71 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::RwLock; 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 struct ForwardResult {
pub response: Response, pub response: Response,
pub provider: Provider, pub provider: Provider,
@@ -31,8 +97,6 @@ pub struct RequestForwarder {
client: Client, client: Client,
/// 共享的 ProviderRouter(持有熔断器状态) /// 共享的 ProviderRouter(持有熔断器状态)
router: Arc<ProviderRouter>, router: Arc<ProviderRouter>,
/// 单个 Provider 内的最大重试次数
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>, current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
/// 故障转移切换管理器 /// 故障转移切换管理器
@@ -48,7 +112,6 @@ impl RequestForwarder {
pub fn new( pub fn new(
router: Arc<ProviderRouter>, router: Arc<ProviderRouter>,
non_streaming_timeout: u64, non_streaming_timeout: u64,
max_retries: u8,
status: Arc<RwLock<ProxyStatus>>, status: Arc<RwLock<ProxyStatus>>,
current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>, current_providers: Arc<RwLock<std::collections::HashMap<String, (String, String)>>>,
failover_manager: Arc<FailoverSwitchManager>, failover_manager: Arc<FailoverSwitchManager>,
@@ -77,7 +140,6 @@ impl RequestForwarder {
Self { Self {
client, client,
router, router,
max_retries,
status, status,
current_providers, current_providers,
failover_manager, failover_manager,
@@ -86,59 +148,6 @@ impl RequestForwarder {
} }
} }
/// 对单个 Provider 执行请求(带重试)
///
/// 在同一个 Provider 上最多重试 max_retries 次,使用指数退避
async fn forward_with_provider_retry(
&self,
provider: &Provider,
endpoint: &str,
body: &Value,
headers: &axum::http::HeaderMap,
adapter: &dyn ProviderAdapter,
) -> Result<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 /// # Arguments
@@ -224,9 +233,9 @@ impl RequestForwarder {
let start = Instant::now(); let start = Instant::now();
// 转发请求(带单 Provider 内重试 // 转发请求(每个 Provider 只尝试一次,重试由客户端控制
match self match self
.forward_with_provider_retry(provider, endpoint, &body, &headers, adapter.as_ref()) .forward(provider, endpoint, &body, &headers, adapter.as_ref())
.await .await
{ {
Ok(response) => { Ok(response) => {
@@ -477,6 +486,28 @@ impl RequestForwarder {
mapped_body 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!( log::info!(
"[{}] 转发请求: {} -> {}", "[{}] 转发请求: {} -> {}",
adapter.name(), adapter.name(),
@@ -487,28 +518,73 @@ impl RequestForwarder {
// 构建请求 // 构建请求
let mut request = self.client.post(&url); let mut request = self.client.post(&url);
// 只透传必要的 Headers(白名单模式) // ========== 详细 Headers 日志 ==========
let allowed_headers = [ log::info!("[{}] ====== 客户端原始 Headers ======", adapter.name());
"accept", for (key, value) in headers {
"user-agent", log::info!(
"x-request-id", "[{}] {}: {:?}",
"x-stainless-arch", adapter.name(),
"x-stainless-lang", key.as_str(),
"x-stainless-os", value.to_str().unwrap_or("<binary>")
"x-stainless-package-version", );
"x-stainless-runtime", }
"x-stainless-runtime-version",
]; // 过滤黑名单 Headers,保护隐私并避免冲突
let mut filtered_headers: Vec<String> = Vec::new();
let mut passed_headers: Vec<(String, String)> = Vec::new();
for (key, value) in headers { for (key, value) in headers {
let key_str = key.as_str().to_lowercase(); let key_str = key.as_str().to_lowercase();
if allowed_headers.contains(&key_str.as_str()) { if HEADER_BLACKLIST.contains(&key_str.as_str()) {
request = request.header(key, value); 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);
} }
} }
// 确保 Content-Type 是 json // 处理 anthropic-beta Header(透传)
request = request.header("Content-Type", "application/json"); // 参考 Claude Code Hub 的实现,直接透传客户端的 beta 标记
if let Some(beta) = headers.get("anthropic-beta") {
if let Ok(beta_str) = beta.to_str() {
request = request.header("anthropic-beta", beta_str);
passed_headers.push(("anthropic-beta".to_string(), beta_str.to_string()));
log::info!("[{}] 透传 anthropic-beta: {}", adapter.name(), beta_str);
}
}
// 客户端 IP 透传(默认开启)
if let Some(xff) = headers.get("x-forwarded-for") {
if let Ok(xff_str) = xff.to_str() {
request = request.header("x-forwarded-for", xff_str);
passed_headers.push(("x-forwarded-for".to_string(), xff_str.to_string()));
log::debug!("[{}] 透传 x-forwarded-for: {}", adapter.name(), xff_str);
}
}
if let Some(real_ip) = headers.get("x-real-ip") {
if let Ok(real_ip_str) = real_ip.to_str() {
request = request.header("x-real-ip", real_ip_str);
passed_headers.push(("x-real-ip".to_string(), real_ip_str.to_string()));
log::debug!("[{}] 透传 x-real-ip: {}", adapter.name(), real_ip_str);
}
}
// 禁用压缩,避免 gzip 流式响应解析错误
// 参考 CCH: undici 在连接提前关闭时会对不完整的 gzip 流抛出错误
request = request.header("accept-encoding", "identity");
passed_headers.push(("accept-encoding".to_string(), "identity".to_string()));
// 使用适配器添加认证头 // 使用适配器添加认证头
if let Some(auth) = adapter.extract_auth(provider) { if let Some(auth) = adapter.extract_auth(provider) {
@@ -519,6 +595,15 @@ impl RequestForwarder {
auth.masked_key() auth.masked_key()
); );
request = adapter.add_auth_headers(request, &auth); 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 { } else {
log::error!( log::error!(
"[{}] 未找到 API KeyProvider: {}", "[{}] 未找到 API KeyProvider: {}",
@@ -527,9 +612,34 @@ impl RequestForwarder {
); );
} }
// anthropic-version 透传:优先使用客户端的版本号
// 参考 Claude Code Hub:透传客户端值而非固定版本
if let Some(version) = headers.get("anthropic-version") {
if let Ok(version_str) = version.to_str() {
// 覆盖适配器设置的默认版本
request = request.header("anthropic-version", version_str);
passed_headers.push(("anthropic-version".to_string(), version_str.to_string()));
log::info!(
"[{}] 透传 anthropic-version: {}",
adapter.name(),
version_str
);
}
}
// ========== 最终发送的 Headers 日志 ==========
log::info!(
"[{}] ====== 最终发送的 Headers ({}) ======",
adapter.name(),
passed_headers.len()
);
for (k, v) in &passed_headers {
log::info!("[{}] {}: {}", adapter.name(), k, v);
}
// 发送请求 // 发送请求
log::info!("[{}] 发送请求到: {}", adapter.name(), url); log::info!("[{}] 发送请求到: {}", adapter.name(), url);
let response = request.json(&request_body).send().await.map_err(|e| { let response = request.json(&filtered_body).send().await.map_err(|e| {
log::error!("[{}] 请求失败: {}", adapter.name(), e); log::error!("[{}] 请求失败: {}", adapter.name(), e);
if e.is_timeout() { if e.is_timeout() {
ProxyError::Timeout(format!("请求超时: {e}")) ProxyError::Timeout(format!("请求超时: {e}"))
@@ -563,25 +673,6 @@ impl RequestForwarder {
} }
} }
/// 分类ProxyError
///
/// 决定哪些错误应该触发故障转移到下一个 Provider
///
/// 设计原则:既然用户配置了多个供应商,就应该让所有供应商都尝试一遍。
/// 只有明确是客户端中断的情况才不重试。
fn should_retry_same_provider(&self, error: &ProxyError) -> bool {
match error {
// 网络类错误:短暂抖动时同一 Provider 内重试有意义
ProxyError::Timeout(_) => true,
ProxyError::ForwardFailed(_) => true,
// 上游 HTTP 错误:只对“可能瞬态”的状态码做同 Provider 重试(其余交给 failover
ProxyError::UpstreamError { status, .. } => {
*status == 408 || *status == 429 || *status >= 500
}
_ => false,
}
}
fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory { fn categorize_proxy_error(&self, error: &ProxyError) -> ErrorCategory {
match error { match error {
// 网络和上游错误:都应该尝试下一个供应商 // 网络和上游错误:都应该尝试下一个供应商
@@ -597,7 +688,6 @@ impl RequestForwarder {
ProxyError::TransformError(_) => ErrorCategory::Retryable, ProxyError::TransformError(_) => ErrorCategory::Retryable,
ProxyError::AuthError(_) => ErrorCategory::Retryable, ProxyError::AuthError(_) => ErrorCategory::Retryable,
ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable, ProxyError::StreamIdleTimeout(_) => ErrorCategory::Retryable,
ProxyError::MaxRetriesExceeded => ErrorCategory::Retryable,
// 无可用供应商:所有供应商都试过了,无法重试 // 无可用供应商:所有供应商都试过了,无法重试
ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable, ProxyError::NoAvailableProvider => ErrorCategory::NonRetryable,
// 其他错误(数据库/内部错误等):不是换供应商能解决的问题 // 其他错误(数据库/内部错误等):不是换供应商能解决的问题
+11 -7
View File
@@ -58,10 +58,10 @@ fn openai_model_extractor(events: &[Value], request_model: &str) -> String {
.to_string() .to_string()
} }
/// Codex Responses API 流式响应模型提取(优先使用 usage.model /// Codex 智能流式响应模型提取(自动检测格式
fn codex_model_extractor(events: &[Value], request_model: &str) -> String { fn codex_auto_model_extractor(events: &[Value], request_model: &str) -> String {
// 首先尝试从解析的 usage 中获取模型 // 首先尝试从解析的 usage 中获取模型
if let Some(usage) = TokenUsage::from_codex_stream_events(events) { if let Some(usage) = TokenUsage::from_codex_stream_events_auto(events) {
if let Some(model) = usage.model { if let Some(model) = usage.model {
return model; return model;
} }
@@ -76,6 +76,10 @@ fn codex_model_extractor(events: &[Value], request_model: &str) -> String {
None None
} }
}) })
.or_else(|| {
// 再回退:从 OpenAI 格式事件中提取
events.iter().find_map(|e| e.get("model")?.as_str())
})
.unwrap_or(request_model) .unwrap_or(request_model)
.to_string() .to_string()
} }
@@ -111,11 +115,11 @@ pub const OPENAI_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
app_type_str: "codex", app_type_str: "codex",
}; };
/// Codex Responses API 解析配置(用于 /v1/responses /// Codex 智能解析配置(自动检测 OpenAI 或 Codex 格式
pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig { pub const CODEX_PARSER_CONFIG: UsageParserConfig = UsageParserConfig {
stream_parser: TokenUsage::from_codex_stream_events, stream_parser: TokenUsage::from_codex_stream_events_auto,
response_parser: TokenUsage::from_codex_response, response_parser: TokenUsage::from_codex_response_auto,
model_extractor: codex_model_extractor, model_extractor: codex_auto_model_extractor,
app_type_str: "codex", app_type_str: "codex",
}; };
+72 -12
View File
@@ -5,8 +5,10 @@
use crate::app_config::AppType; use crate::app_config::AppType;
use crate::provider::Provider; use crate::provider::Provider;
use crate::proxy::{ use crate::proxy::{
forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig, ProxyError, extract_session_id, forwarder::RequestForwarder, server::ProxyState, types::AppProxyConfig,
ProxyError,
}; };
use axum::http::HeaderMap;
use std::time::Instant; use std::time::Instant;
/// 流式超时配置 /// 流式超时配置
@@ -26,6 +28,7 @@ pub struct StreamingTimeoutConfig {
/// - 选中的 Provider 列表(用于故障转移) /// - 选中的 Provider 列表(用于故障转移)
/// - 请求模型名称 /// - 请求模型名称
/// - 日志标签 /// - 日志标签
/// - Session ID(用于日志关联)
pub struct RequestContext { pub struct RequestContext {
/// 请求开始时间 /// 请求开始时间
pub start_time: Instant, pub start_time: Instant,
@@ -35,7 +38,7 @@ pub struct RequestContext {
pub provider: Provider, pub provider: Provider,
/// 完整的 Provider 列表(用于故障转移) /// 完整的 Provider 列表(用于故障转移)
providers: Vec<Provider>, providers: Vec<Provider>,
/// 请求开始时的当前供应商(用于判断是否需要同步 UI/托盘) /// 请求开始时的"当前供应商"(用于判断是否需要同步 UI/托盘)
/// ///
/// 这里使用本地 settings 的设备级 current provider。 /// 这里使用本地 settings 的设备级 current provider。
/// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。 /// 代理模式下如果实际使用的 provider 与此不一致,会触发切换以确保 UI 始终准确。
@@ -49,6 +52,8 @@ pub struct RequestContext {
/// 应用类型(预留,目前通过 app_type_str 使用) /// 应用类型(预留,目前通过 app_type_str 使用)
#[allow(dead_code)] #[allow(dead_code)]
pub app_type: AppType, pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
} }
impl RequestContext { impl RequestContext {
@@ -57,6 +62,7 @@ impl RequestContext {
/// # Arguments /// # Arguments
/// * `state` - 代理服务器状态 /// * `state` - 代理服务器状态
/// * `body` - 请求体 JSON /// * `body` - 请求体 JSON
/// * `headers` - 请求头(用于提取 Session ID
/// * `app_type` - 应用类型 /// * `app_type` - 应用类型
/// * `tag` - 日志标签 /// * `tag` - 日志标签
/// * `app_type_str` - 应用类型字符串 /// * `app_type_str` - 应用类型字符串
@@ -66,6 +72,7 @@ impl RequestContext {
pub async fn new( pub async fn new(
state: &ProxyState, state: &ProxyState,
body: &serde_json::Value, body: &serde_json::Value,
headers: &HeaderMap,
app_type: AppType, app_type: AppType,
tag: &'static str, tag: &'static str,
app_type_str: &'static str, app_type_str: &'static str,
@@ -89,13 +96,31 @@ impl RequestContext {
.unwrap_or("unknown") .unwrap_or("unknown")
.to_string(); .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(熔断器状态跨请求保持) // 使用共享的 ProviderRouter 选择 Provider(熔断器状态跨请求保持)
// 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额 // 注意:只在这里调用一次,结果传递给 forwarder,避免重复消耗 HalfOpen 名额
let providers = state let providers = state
.provider_router .provider_router
.select_providers(app_type_str) .select_providers(app_type_str)
.await .await
.map_err(|e| ProxyError::DatabaseError(e.to_string()))?; .map_err(|e| match e {
crate::error::AppError::AllProvidersCircuitOpen => {
ProxyError::AllProvidersCircuitOpen
}
crate::error::AppError::NoProvidersConfigured => ProxyError::NoProvidersConfigured,
_ => ProxyError::DatabaseError(e.to_string()),
})?;
let provider = providers let provider = providers
.first() .first()
@@ -103,11 +128,12 @@ impl RequestContext {
.ok_or(ProxyError::NoAvailableProvider)?; .ok_or(ProxyError::NoAvailableProvider)?;
log::info!( log::info!(
"[{}] Provider: {}, model: {}, failover chain: {} providers", "[{}] Provider: {}, model: {}, failover chain: {} providers, session: {}",
tag, tag,
provider.name, provider.name,
request_model, request_model,
providers.len() providers.len(),
session_id
); );
Ok(Self { Ok(Self {
@@ -120,6 +146,7 @@ impl RequestContext {
tag, tag,
app_type_str, app_type_str,
app_type, app_type,
session_id,
}) })
} }
@@ -148,18 +175,38 @@ impl RequestContext {
/// 创建 RequestForwarder /// 创建 RequestForwarder
/// ///
/// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持 /// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持
///
/// 配置生效规则:
/// - 故障转移开启:超时配置正常生效(0 表示禁用超时)
/// - 故障转移关闭:超时配置不生效(全部传入 0)
pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { 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( RequestForwarder::new(
state.provider_router.clone(), state.provider_router.clone(),
self.app_config.non_streaming_timeout as u64, non_streaming_timeout,
self.app_config.max_retries as u8,
state.status.clone(), state.status.clone(),
state.current_providers.clone(), state.current_providers.clone(),
state.failover_manager.clone(), state.failover_manager.clone(),
state.app_handle.clone(), state.app_handle.clone(),
self.current_provider_id.clone(), self.current_provider_id.clone(),
self.app_config.streaming_first_byte_timeout as u64, first_byte_timeout,
self.app_config.streaming_idle_timeout as u64, idle_timeout,
) )
} }
@@ -177,11 +224,24 @@ impl RequestContext {
} }
/// 获取流式超时配置 /// 获取流式超时配置
///
/// 配置生效规则:
/// - 故障转移开启:返回配置的值(0 表示禁用超时检查)
/// - 故障转移关闭:返回 0(禁用超时检查)
#[inline] #[inline]
pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig { pub fn streaming_timeout_config(&self) -> StreamingTimeoutConfig {
StreamingTimeoutConfig { if self.app_config.auto_failover_enabled {
first_byte_timeout: self.app_config.streaming_first_byte_timeout as u64, // 故障转移开启:使用配置的值(0 = 禁用超时)
idle_timeout: self.app_config.streaming_idle_timeout as u64, 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,
}
} }
} }
} }
+9 -6
View File
@@ -61,7 +61,8 @@ pub async fn handle_messages(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let mut ctx = RequestContext::new(&state, &body, AppType::Claude, "Claude", "claude").await?; let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Claude, "Claude", "claude").await?;
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -305,7 +306,8 @@ pub async fn handle_chat_completions(
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======"); log::info!("[Codex] ====== /v1/chat/completions 请求开始 ======");
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -353,7 +355,8 @@ pub async fn handle_responses(
headers: axum::http::HeaderMap, headers: axum::http::HeaderMap,
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
let mut ctx = RequestContext::new(&state, &body, AppType::Codex, "Codex", "codex").await?; let mut ctx =
RequestContext::new(&state, &body, &headers, AppType::Codex, "Codex", "codex").await?;
let is_stream = body let is_stream = body
.get("stream") .get("stream")
@@ -401,7 +404,7 @@ pub async fn handle_gemini(
Json(body): Json<Value>, Json(body): Json<Value>,
) -> Result<axum::response::Response, ProxyError> { ) -> Result<axum::response::Response, ProxyError> {
// Gemini 的模型名称在 URI 中 // Gemini 的模型名称在 URI 中
let mut ctx = RequestContext::new(&state, &body, AppType::Gemini, "Gemini", "gemini") let mut ctx = RequestContext::new(&state, &body, &headers, AppType::Gemini, "Gemini", "gemini")
.await? .await?
.with_model_from_uri(&uri); .with_model_from_uri(&uri);
@@ -465,7 +468,7 @@ fn log_forward_error(
let request_id = uuid::Uuid::new_v4().to_string(); let request_id = uuid::Uuid::new_v4().to_string();
if let Err(e) = logger.log_error_with_context( if let Err(e) = logger.log_error_with_context(
request_id.clone(), request_id,
ctx.provider.id.clone(), ctx.provider.id.clone(),
ctx.app_type_str.to_string(), ctx.app_type_str.to_string(),
ctx.request_model.clone(), ctx.request_model.clone(),
@@ -473,7 +476,7 @@ fn log_forward_error(
error_message, error_message,
ctx.latency_ms(), ctx.latency_ms(),
is_streaming, is_streaming,
Some(request_id), Some(ctx.session_id.clone()),
None, None,
) { ) {
log::warn!("记录失败请求日志失败: {e}"); log::warn!("记录失败请求日志失败: {e}");
+4 -1
View File
@@ -2,6 +2,7 @@
//! //!
//! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传 //! 提供本地HTTP代理服务,支持多Provider故障转移和请求透传
pub mod body_filter;
pub mod circuit_breaker; pub mod circuit_breaker;
pub mod error; pub mod error;
pub mod error_mapper; pub mod error_mapper;
@@ -33,7 +34,9 @@ pub use provider_router::ProviderRouter;
#[allow(unused_imports)] #[allow(unused_imports)]
pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler}; pub use response_handler::{NonStreamHandler, ResponseType, StreamHandler};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use session::{ClientFormat, ProxySession}; pub use session::{
extract_session_id, ClientFormat, ProxySession, SessionIdResult, SessionIdSource,
};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus}; pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus};
+32 -8
View File
@@ -34,6 +34,8 @@ impl ProviderRouter {
/// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置 /// - 故障转移开启时:完全按照故障转移队列顺序返回,忽略当前供应商设置
pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> { pub async fn select_providers(&self, app_type: &str) -> Result<Vec<Provider>, AppError> {
let mut result = Vec::new(); let mut result = Vec::new();
let mut total_providers = 0usize;
let mut circuit_open_count = 0usize;
// 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取) // 检查该应用的自动故障转移开关是否开启(从 proxy_config 表读取)
let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await { let auto_failover_enabled = match self.db.get_proxy_config_for_app(app_type).await {
@@ -53,18 +55,26 @@ impl ProviderRouter {
if auto_failover_enabled { if auto_failover_enabled {
// 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序 // 故障转移开启:使用 in_failover_queue 标记的供应商,按 sort_index 排序
let failover_providers = self.db.get_failover_providers(app_type)?; 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!( log::info!(
"[{}] Failover enabled, using queue order ({} items)", "[{app_type}] Failover enabled, using queue order ({total_providers} items)"
app_type,
failover_providers.len()
); );
for provider in failover_providers { for provider in failover_providers {
// 检查熔断器状态 // 检查熔断器状态
let circuit_key = format!("{}:{}", app_type, provider.id); let circuit_key = format!("{}:{}", app_type, provider.id);
let breaker = self.get_or_create_circuit_breaker(&circuit_key).await; let breaker = self.get_or_create_circuit_breaker(&circuit_key).await;
let state = breaker.get_state().await;
if breaker.is_available().await { if breaker.is_available().await {
log::debug!(
"[{}] Queue provider available: {} ({}) (state: {:?})",
app_type,
provider.name,
provider.id,
state
);
log::info!( log::info!(
"[{}] Queue provider available: {} ({}) at sort_index {:?}", "[{}] Queue provider available: {} ({}) at sort_index {:?}",
app_type, app_type,
@@ -74,10 +84,12 @@ impl ProviderRouter {
); );
result.push(provider); result.push(provider);
} else { } else {
circuit_open_count += 1;
log::debug!( log::debug!(
"[{}] Queue provider {} circuit breaker open, skipping", "[{}] Queue provider {} circuit breaker open (state: {:?}), skipping",
app_type, app_type,
provider.name provider.name,
state
); );
} }
} }
@@ -94,15 +106,27 @@ impl ProviderRouter {
current.name, current.name,
current.id current.id
); );
total_providers = 1;
result.push(current); 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() { if result.is_empty() {
return Err(AppError::Config(format!( // 区分两种情况:全部熔断 vs 未配置供应商
"No available provider for {app_type} (all circuit breakers open or no providers configured)" if total_providers > 0 && circuit_open_count == total_providers {
))); log::warn!("[{app_type}] 所有 {total_providers} 个供应商均已熔断,无可用渠道");
return Err(AppError::AllProvidersCircuitOpen);
} else {
log::warn!("[{app_type}] 未配置供应商或故障转移队列为空");
return Err(AppError::NoProvidersConfigured);
}
} }
log::info!( log::info!(
+58 -1
View File
@@ -112,6 +112,19 @@ pub async fn handle_non_streaming(
spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false); spawn_log_usage(state, ctx, usage, &model, status.as_u16(), false);
} else { } 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!( log::debug!(
"[{}] 未能解析 usage 信息,跳过记录", "[{}] 未能解析 usage 信息,跳过记录",
parser_config.app_type_str parser_config.app_type_str
@@ -123,6 +136,14 @@ pub async fn handle_non_streaming(
ctx.tag, ctx.tag,
body_bytes.len() body_bytes.len()
); );
spawn_log_usage(
state,
ctx,
TokenUsage::default(),
&ctx.request_model,
status.as_u16(),
false,
);
} }
log::info!("[{}] ====== 请求结束 ======", ctx.tag); log::info!("[{}] ====== 请求结束 ======", ctx.tag);
@@ -243,6 +264,7 @@ fn create_usage_collector(
let start_time = ctx.start_time; let start_time = ctx.start_time;
let stream_parser = parser_config.stream_parser; let stream_parser = parser_config.stream_parser;
let model_extractor = parser_config.model_extractor; let model_extractor = parser_config.model_extractor;
let session_id = ctx.session_id.clone();
SseUsageCollector::new(start_time, move |events, first_token_ms| { SseUsageCollector::new(start_time, move |events, first_token_ms| {
if let Some(usage) = stream_parser(&events) { if let Some(usage) = stream_parser(&events) {
@@ -251,6 +273,7 @@ fn create_usage_collector(
let state = state.clone(); let state = state.clone();
let provider_id = provider_id.clone(); let provider_id = provider_id.clone();
let session_id = session_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
log_usage_internal( log_usage_internal(
@@ -263,10 +286,32 @@ fn create_usage_collector(
first_token_ms, first_token_ms,
true, // is_streaming true, // is_streaming
status_code, status_code,
Some(session_id),
) )
.await; .await;
}); });
} else { } 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 统计,跳过消费记录"); log::debug!("[{tag}] 流式响应缺少 usage 统计,跳过消费记录");
} }
}) })
@@ -286,6 +331,7 @@ fn spawn_log_usage(
let app_type_str = ctx.app_type_str.to_string(); let app_type_str = ctx.app_type_str.to_string();
let model = model.to_string(); let model = model.to_string();
let latency_ms = ctx.latency_ms(); let latency_ms = ctx.latency_ms();
let session_id = ctx.session_id.clone();
tokio::spawn(async move { tokio::spawn(async move {
log_usage_internal( log_usage_internal(
@@ -298,6 +344,7 @@ fn spawn_log_usage(
None, None,
is_streaming, is_streaming,
status_code, status_code,
Some(session_id),
) )
.await; .await;
}); });
@@ -315,6 +362,7 @@ async fn log_usage_internal(
first_token_ms: Option<u64>, first_token_ms: Option<u64>,
is_streaming: bool, is_streaming: bool,
status_code: u16, status_code: u16,
session_id: Option<String>,
) { ) {
use super::usage::logger::UsageLogger; use super::usage::logger::UsageLogger;
@@ -338,6 +386,15 @@ async fn log_usage_internal(
let request_id = uuid::Uuid::new_v4().to_string(); 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( if let Err(e) = logger.log_with_calculation(
request_id, request_id,
provider_id.to_string(), provider_id.to_string(),
@@ -348,7 +405,7 @@ async fn log_usage_internal(
latency_ms, latency_ms,
first_token_ms, first_token_ms,
status_code, status_code,
None, session_id,
None, // provider_type None, // provider_type
is_streaming, is_streaming,
) { ) {
+269
View File
@@ -1,7 +1,15 @@
//! Proxy Session - 请求会话管理 //! 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 std::time::Instant;
use uuid::Uuid; use uuid::Uuid;
@@ -176,6 +184,179 @@ impl ProxySession {
} }
} }
// ============================================================================
// Session ID 提取器
// ============================================================================
/// Session ID 来源
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionIdSource {
/// 从 metadata.user_id 提取 (Claude)
MetadataUserId,
/// 从 metadata.session_id 提取
MetadataSessionId,
/// 从 headers 提取 (Codex)
Header,
/// 从 previous_response_id 提取 (Codex)
PreviousResponseId,
/// 新生成
Generated,
}
/// Session ID 提取结果
#[derive(Debug, Clone)]
pub struct SessionIdResult {
/// 提取或生成的 Session ID
pub session_id: String,
/// Session ID 来源
pub source: SessionIdSource,
/// 是否为客户端提供的 ID(非新生成)
pub client_provided: bool,
}
/// 从请求中提取或生成 Session ID
///
/// 轻量化实现,仅提取 session_id 用于日志记录,不做复杂的 Session 管理。
///
/// ## 提取优先级
///
/// ### Claude 请求
/// 1. `metadata.user_id` (格式: `user_xxx_session_yyy`) → 提取 `yyy` 部分
/// 2. `metadata.session_id` → 直接使用
/// 3. 生成新 UUID
///
/// ### Codex 请求
/// 1. Headers: `session_id` 或 `x-session-id`
/// 2. `metadata.session_id`
/// 3. `previous_response_id` (对话延续)
/// 4. 生成新 UUID
///
/// ## 示例
///
/// ```ignore
/// let result = extract_session_id(&headers, &body, "claude");
/// println!("Session ID: {} (from {:?})", result.session_id, result.source);
/// ```
pub fn extract_session_id(
headers: &HeaderMap,
body: &serde_json::Value,
client_format: &str,
) -> SessionIdResult {
// Codex 请求特殊处理
if client_format == "codex" || client_format == "openai" {
if let Some(result) = extract_codex_session(headers, body) {
return result;
}
}
// Claude 请求:从 metadata 提取
if let Some(result) = extract_from_metadata(body) {
return result;
}
// 兜底:生成新 Session ID
generate_new_session_id()
}
/// 提取 Codex Session ID
fn extract_codex_session(headers: &HeaderMap, body: &serde_json::Value) -> Option<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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -295,4 +476,92 @@ mod tests {
assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli"); assert_eq!(ClientFormat::GeminiCli.as_str(), "gemini_cli");
assert_eq!(ClientFormat::Unknown.as_str(), "unknown"); 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 { fn default_streaming_first_byte_timeout() -> u64 {
30 60
} }
fn default_streaming_idle_timeout() -> u64 { fn default_streaming_idle_timeout() -> u64 {
60 120
} }
fn default_non_streaming_timeout() -> u64 { fn default_non_streaming_timeout() -> u64 {
@@ -45,11 +45,11 @@ impl Default for ProxyConfig {
listen_address: "127.0.0.1".to_string(), listen_address: "127.0.0.1".to_string(),
listen_port: 15721, // 使用较少占用的高位端口 listen_port: 15721, // 使用较少占用的高位端口
max_retries: 3, max_retries: 3,
request_timeout: 300, request_timeout: 600,
enable_logging: true, enable_logging: true,
live_takeover_active: false, live_takeover_active: false,
streaming_first_byte_timeout: 30, streaming_first_byte_timeout: 60,
streaming_idle_timeout: 60, streaming_idle_timeout: 120,
non_streaming_timeout: 600, non_streaming_timeout: 600,
} }
} }
+13 -5
View File
@@ -35,6 +35,11 @@ impl CostCalculator {
/// - `usage`: Token 使用量 /// - `usage`: Token 使用量
/// - `pricing`: 模型定价 /// - `pricing`: 模型定价
/// - `cost_multiplier`: 成本倍数 (provider 自定义) /// - `cost_multiplier`: 成本倍数 (provider 自定义)
///
/// # 计算逻辑
/// - input_cost: (input_tokens - cache_read_tokens) × 输入价格
/// - cache_read_cost: cache_read_tokens × 缓存读取价格
/// - 这样避免缓存部分被重复计费
pub fn calculate( pub fn calculate(
usage: &TokenUsage, usage: &TokenUsage,
pricing: &ModelPricing, pricing: &ModelPricing,
@@ -42,7 +47,10 @@ impl CostCalculator {
) -> CostBreakdown { ) -> CostBreakdown {
let million = Decimal::from(1_000_000); let million = Decimal::from(1_000_000);
let input_cost = Decimal::from(usage.input_tokens) * pricing.input_cost_per_million // 计算实际需要按输入价格计费的 token 数(减去缓存命中部分)
let billable_input_tokens = usage.input_tokens.saturating_sub(usage.cache_read_tokens);
let input_cost = Decimal::from(billable_input_tokens) * pricing.input_cost_per_million
/ million / million
* cost_multiplier; * cost_multiplier;
let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million let output_cost = Decimal::from(usage.output_tokens) * pricing.output_cost_per_million
@@ -113,8 +121,8 @@ mod tests {
let cost = CostCalculator::calculate(&usage, &pricing, multiplier); let cost = CostCalculator::calculate(&usage, &pricing, multiplier);
// input: 1000 * 3.0 / 1M = 0.003 // input: (1000 - 200) * 3.0 / 1M = 0.0024 (只计算非缓存部分)
assert_eq!(cost.input_cost, Decimal::from_str("0.003").unwrap()); assert_eq!(cost.input_cost, Decimal::from_str("0.0024").unwrap());
// output: 500 * 15.0 / 1M = 0.0075 // output: 500 * 15.0 / 1M = 0.0075
assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap()); assert_eq!(cost.output_cost, Decimal::from_str("0.0075").unwrap());
// cache_read: 200 * 0.3 / 1M = 0.00006 // cache_read: 200 * 0.3 / 1M = 0.00006
@@ -124,8 +132,8 @@ mod tests {
cost.cache_creation_cost, cost.cache_creation_cost,
Decimal::from_str("0.000375").unwrap() Decimal::from_str("0.000375").unwrap()
); );
// total: 0.003 + 0.0075 + 0.00006 + 0.000375 = 0.010935 // total: 0.0024 + 0.0075 + 0.00006 + 0.000375 = 0.010335
assert_eq!(cost.total_cost, Decimal::from_str("0.010935").unwrap()); assert_eq!(cost.total_cost, Decimal::from_str("0.010335").unwrap());
} }
#[test] #[test]
+281 -18
View File
@@ -163,13 +163,21 @@ impl TokenUsage {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .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 { Some(Self {
input_tokens: input_tokens? as u32, input_tokens: input_tokens? as u32,
output_tokens: output_tokens? as u32, output_tokens: output_tokens? as u32,
cache_read_tokens: usage cache_read_tokens: cached_tokens,
.get("cache_read_input_tokens")
.and_then(|v| v.as_u64())
.unwrap_or(0) as u32,
cache_creation_tokens: usage cache_creation_tokens: usage
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
@@ -188,16 +196,27 @@ impl TokenUsage {
let input_tokens = usage.get("input_tokens")?.as_u64()? as u32; let input_tokens = usage.get("input_tokens")?.as_u64()? as u32;
let output_tokens = usage.get("output_tokens")?.as_u64()? as u32; let output_tokens = usage.get("output_tokens")?.as_u64()? as u32;
// 获取 cached_tokens (可能在 input_tokens_details 中) // 获取 cached_tokens (可能在 cache_read_input_tokens 或 input_tokens_details 中)
let cached_tokens = usage let cached_tokens = usage
.get("input_tokens_details") .get("cache_read_input_tokens")
.and_then(|d| d.get("cached_tokens"))
.and_then(|v| v.as_u64()) .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; .unwrap_or(0) as u32;
// 调整 input_tokens: 减去 cached_tokens // 调整 input_tokens: 减去 cached_tokens
let adjusted_input = input_tokens.saturating_sub(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 { Some(Self {
input_tokens: adjusted_input, input_tokens: adjusted_input,
output_tokens, output_tokens,
@@ -206,7 +225,7 @@ impl TokenUsage {
.get("cache_creation_input_tokens") .get("cache_creation_input_tokens")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32, .unwrap_or(0) as u32,
model: None, model,
}) })
} }
@@ -220,7 +239,7 @@ impl TokenUsage {
if event_type == "response.completed" { if event_type == "response.completed" {
if let Some(response) = event.get("response") { if let Some(response) = event.get("response") {
log::debug!("[Codex] 找到 response.completed 事件,解析 usage"); log::debug!("[Codex] 找到 response.completed 事件,解析 usage");
return Self::from_codex_response(response); return Self::from_codex_response_adjusted(response);
} }
} }
} }
@@ -229,6 +248,51 @@ impl TokenUsage {
None 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) /// 从 OpenAI Chat Completions API 响应解析 (prompt_tokens, completion_tokens)
pub fn from_openai_response(body: &Value) -> Option<Self> { pub fn from_openai_response(body: &Value) -> Option<Self> {
let usage = body.get("usage")?; let usage = body.get("usage")?;
@@ -284,9 +348,16 @@ impl TokenUsage {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .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 { Some(Self {
input_tokens: usage.get("promptTokenCount")?.as_u64()? as u32, input_tokens: prompt_tokens,
output_tokens: usage.get("candidatesTokenCount")?.as_u64()? as u32, output_tokens,
cache_read_tokens: usage cache_read_tokens: usage
.get("cachedContentTokenCount") .get("cachedContentTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
@@ -300,20 +371,25 @@ impl TokenUsage {
#[allow(dead_code)] #[allow(dead_code)]
pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> { pub fn from_gemini_stream_chunks(chunks: &[Value]) -> Option<Self> {
let mut total_input = 0u32; let mut total_input = 0u32;
let mut total_output = 0u32; let mut total_tokens = 0u32;
let mut total_cache_read = 0u32; let mut total_cache_read = 0u32;
let mut model: Option<String> = None; let mut model: Option<String> = None;
for chunk in chunks { for chunk in chunks {
if let Some(usage) = chunk.get("usageMetadata") { if let Some(usage) = chunk.get("usageMetadata") {
// 输入 tokens (通常在所有 chunk 中保持不变)
total_input = usage total_input = usage
.get("promptTokenCount") .get("promptTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
total_output += usage
.get("candidatesTokenCount") // 总 tokens (包含输入 + 输出 + 思考)
total_tokens = usage
.get("totalTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
.unwrap_or(0) as u32; .unwrap_or(0) as u32;
// 缓存读取 tokens
total_cache_read = usage total_cache_read = usage
.get("cachedContentTokenCount") .get("cachedContentTokenCount")
.and_then(|v| v.as_u64()) .and_then(|v| v.as_u64())
@@ -328,6 +404,9 @@ impl TokenUsage {
} }
} }
// 输出 tokens = 总 tokens - 输入 tokens
let total_output = total_tokens.saturating_sub(total_input);
if total_input > 0 || total_output > 0 { if total_input > 0 || total_output > 0 {
Some(Self { Some(Self {
input_tokens: total_input, input_tokens: total_input,
@@ -466,15 +545,18 @@ mod tests {
let response = json!({ let response = json!({
"modelVersion": "gemini-3-pro-high", "modelVersion": "gemini-3-pro-high",
"usageMetadata": { "usageMetadata": {
"promptTokenCount": 100, "promptTokenCount": 8383,
"candidatesTokenCount": 50, "candidatesTokenCount": 50,
"thoughtsTokenCount": 114,
"totalTokenCount": 8547,
"cachedContentTokenCount": 20 "cachedContentTokenCount": 20
} }
}); });
let usage = TokenUsage::from_gemini_response(&response).unwrap(); let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100); assert_eq!(usage.input_tokens, 8383);
assert_eq!(usage.output_tokens, 50); // output_tokens = totalTokenCount - promptTokenCount = 8547 - 8383 = 164
assert_eq!(usage.output_tokens, 164);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0); assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, Some("gemini-3-pro-high".to_string())); assert_eq!(usage.model, Some("gemini-3-pro-high".to_string()));
@@ -486,19 +568,78 @@ mod tests {
let response = json!({ let response = json!({
"usageMetadata": { "usageMetadata": {
"promptTokenCount": 100, "promptTokenCount": 100,
"candidatesTokenCount": 50, "totalTokenCount": 150,
"cachedContentTokenCount": 20 "cachedContentTokenCount": 20
} }
}); });
let usage = TokenUsage::from_gemini_response(&response).unwrap(); let usage = TokenUsage::from_gemini_response(&response).unwrap();
assert_eq!(usage.input_tokens, 100); assert_eq!(usage.input_tokens, 100);
// output_tokens = totalTokenCount - promptTokenCount = 150 - 100 = 50
assert_eq!(usage.output_tokens, 50); assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 20); assert_eq!(usage.cache_read_tokens, 20);
assert_eq!(usage.cache_creation_tokens, 0); assert_eq!(usage.cache_creation_tokens, 0);
assert_eq!(usage.model, None); 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] #[test]
fn test_codex_response_adjusted() { fn test_codex_response_adjusted() {
let response = json!({ let response = json!({
@@ -534,6 +675,22 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 0); 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] #[test]
fn test_codex_response_adjusted_saturating_sub() { fn test_codex_response_adjusted_saturating_sub() {
// 测试 cached_tokens > input_tokens 的边界情况 // 测试 cached_tokens > input_tokens 的边界情况
@@ -615,4 +772,110 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 50); assert_eq!(usage.cache_read_tokens, 50);
assert_eq!(usage.model, Some("claude-sonnet-4-20250514".to_string())); 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()));
}
} }
+15 -3
View File
@@ -206,6 +206,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs) // 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_claude(&mut temp_config)?; let count = crate::mcp::import_from_claude(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
@@ -217,6 +219,8 @@ impl McpService {
merged.apps.claude = true; merged.apps.claude = true;
merged merged
} else { } else {
// 真正的新服务器
new_count += 1;
server.clone() server.clone()
}; };
@@ -229,7 +233,7 @@ impl McpService {
} }
} }
Ok(count) Ok(new_count)
} }
/// 从 Codex 导入 MCPv3.7.0 已更新为统一结构) /// 从 Codex 导入 MCPv3.7.0 已更新为统一结构)
@@ -240,6 +244,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs) // 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_codex(&mut temp_config)?; let count = crate::mcp::import_from_codex(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
@@ -251,6 +257,8 @@ impl McpService {
merged.apps.codex = true; merged.apps.codex = true;
merged merged
} else { } else {
// 真正的新服务器
new_count += 1;
server.clone() server.clone()
}; };
@@ -263,7 +271,7 @@ impl McpService {
} }
} }
Ok(count) Ok(new_count)
} }
/// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构) /// 从 Gemini 导入 MCPv3.7.0 已更新为统一结构)
@@ -274,6 +282,8 @@ impl McpService {
// 调用原有的导入逻辑(从 mcp.rs) // 调用原有的导入逻辑(从 mcp.rs)
let count = crate::mcp::import_from_gemini(&mut temp_config)?; let count = crate::mcp::import_from_gemini(&mut temp_config)?;
let mut new_count = 0;
// 如果有导入的服务器,保存到数据库 // 如果有导入的服务器,保存到数据库
if count > 0 { if count > 0 {
if let Some(servers) = &temp_config.mcp.servers { if let Some(servers) = &temp_config.mcp.servers {
@@ -285,6 +295,8 @@ impl McpService {
merged.apps.gemini = true; merged.apps.gemini = true;
merged merged
} else { } else {
// 真正的新服务器
new_count += 1;
server.clone() server.clone()
}; };
@@ -297,6 +309,6 @@ impl McpService {
} }
} }
Ok(count) Ok(new_count)
} }
} }
+2 -1
View File
@@ -15,7 +15,8 @@ pub use mcp::McpService;
pub use prompt::PromptService; pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate}; pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService; pub use proxy::ProxyService;
pub use skill::{Skill, SkillRepo, SkillService}; #[allow(unused_imports)]
pub use skill::{DiscoverableSkill, Skill, SkillRepo, SkillService};
pub use speedtest::{EndpointLatency, SpeedtestService}; pub use speedtest::{EndpointLatency, SpeedtestService};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use usage_stats::{ pub use usage_stats::{
+11 -8
View File
@@ -217,9 +217,12 @@ impl ProviderService {
.flatten() .flatten()
.is_some(); .is_some();
let is_proxy_running = futures::executor::block_on(state.proxy_service.is_running()); 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 // Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
let should_hot_switch = is_app_taken_over && is_proxy_running; let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
if should_hot_switch { if should_hot_switch {
// Proxy takeover mode: hot-switch only, don't write Live config // Proxy takeover mode: hot-switch only, don't write Live config
@@ -736,15 +739,15 @@ impl ProviderService {
// 删除生成的子供应商 // 删除生成的子供应商
if let Some(p) = provider { if let Some(p) = provider {
if p.apps.claude { 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); let _ = state.db.delete_provider("claude", &claude_id);
} }
if p.apps.codex { 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); let _ = state.db.delete_provider("codex", &codex_id);
} }
if p.apps.gemini { 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); let _ = state.db.delete_provider("gemini", &gemini_id);
} }
} }
@@ -757,7 +760,7 @@ impl ProviderService {
let provider = state let provider = state
.db .db
.get_universal_provider(id)? .get_universal_provider(id)?
.ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?; .ok_or_else(|| AppError::Message(format!("统一供应商 {id} 不存在")))?;
// 同步到 Claude // 同步到 Claude
if let Some(mut claude_provider) = provider.to_claude_provider() { if let Some(mut claude_provider) = provider.to_claude_provider() {
@@ -770,7 +773,7 @@ impl ProviderService {
state.db.save_provider("claude", &claude_provider)?; state.db.save_provider("claude", &claude_provider)?;
} else { } else {
// 如果禁用了 Claude,删除对应的子供应商 // 如果禁用了 Claude,删除对应的子供应商
let claude_id = format!("universal-claude-{}", id); let claude_id = format!("universal-claude-{id}");
let _ = state.db.delete_provider("claude", &claude_id); let _ = state.db.delete_provider("claude", &claude_id);
} }
@@ -784,7 +787,7 @@ impl ProviderService {
} }
state.db.save_provider("codex", &codex_provider)?; state.db.save_provider("codex", &codex_provider)?;
} else { } else {
let codex_id = format!("universal-codex-{}", id); let codex_id = format!("universal-codex-{id}");
let _ = state.db.delete_provider("codex", &codex_id); let _ = state.db.delete_provider("codex", &codex_id);
} }
@@ -798,7 +801,7 @@ impl ProviderService {
} }
state.db.save_provider("gemini", &gemini_provider)?; state.db.save_provider("gemini", &gemini_provider)?;
} else { } else {
let gemini_id = format!("universal-gemini-{}", id); let gemini_id = format!("universal-gemini-{id}");
let _ = state.db.delete_provider("gemini", &gemini_id); let _ = state.db.delete_provider("gemini", &gemini_id);
} }
+20 -7
View File
@@ -193,7 +193,7 @@ impl ProxyService {
self.start().await?; self.start().await?;
} }
// 2) 已接管则直接返回(幂等) // 2) 已接管则直接返回(幂等);但如果缺少备份或占位符残留,需要重建接管
let current_config = self let current_config = self
.db .db
.get_proxy_config_for_app(app_type_str) .get_proxy_config_for_app(app_type_str)
@@ -201,7 +201,22 @@ impl ProxyService {
.map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?; .map_err(|e| format!("获取 {app_type_str} 配置失败: {e}"))?;
if current_config.enabled { if current_config.enabled {
return Ok(()); let has_backup = match self.db.get_live_backup(app_type_str).await {
Ok(v) => v.is_some(),
Err(e) => {
log::warn!("读取 {app_type_str} 备份失败(将继续重建接管): {e}");
false
}
};
let live_taken_over = self.detect_takeover_in_live_config_for_app(&app);
if has_backup || live_taken_over {
return Ok(());
}
log::warn!(
"{app_type_str} 标记为已接管,但缺少备份或占位符,正在重新接管并补齐备份"
);
} }
// 3) 备份 Live 配置(严格:目标 app 不存在则报错) // 3) 备份 Live 配置(严格:目标 app 不存在则报错)
@@ -1063,7 +1078,7 @@ impl ProxyService {
} }
} }
fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool { pub fn detect_takeover_in_live_config_for_app(&self, app_type: &AppType) -> bool {
match app_type { match app_type {
AppType::Claude => match self.read_claude_live() { AppType::Claude => match self.read_claude_live() {
Ok(config) => Self::is_claude_live_taken_over(&config), Ok(config) => Self::is_claude_live_taken_over(&config),
@@ -1257,10 +1272,8 @@ impl ProxyService {
/// 检查是否处于 Live 接管模式 /// 检查是否处于 Live 接管模式
pub async fn is_takeover_active(&self) -> Result<bool, String> { pub async fn is_takeover_active(&self) -> Result<bool, String> {
self.db let status = self.get_takeover_status().await?;
.is_live_takeover_active() Ok(status.claude || status.codex || status.gemini)
.await
.map_err(|e| format!("检查接管状态失败: {e}"))
} }
/// 从异常退出中恢复(启动时调用) /// 从异常退出中恢复(启动时调用)
File diff suppressed because it is too large Load Diff
+166 -255
View File
@@ -4,7 +4,7 @@
use crate::database::{lock_conn, Database}; use crate::database::{lock_conn, Database};
use crate::error::AppError; use crate::error::AppError;
use chrono::{Duration, Local, TimeZone}; use chrono::{Local, TimeZone};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -181,145 +181,114 @@ impl Database {
Ok(result) Ok(result)
} }
/// 获取每日趋势 /// 获取每日趋势(滑动窗口,<=24h 按小时,>24h 按天,窗口与汇总一致)
pub fn get_daily_trends(&self, days: u32) -> Result<Vec<DailyStats>, AppError> { pub fn get_daily_trends(
&self,
start_date: Option<i64>,
end_date: Option<i64>,
) -> Result<Vec<DailyStats>, AppError> {
let conn = lock_conn!(self.conn); let conn = lock_conn!(self.conn);
if days <= 1 { let end_ts = end_date.unwrap_or_else(|| Local::now().timestamp());
let today = Local::now().date_naive(); let mut start_ts = start_date.unwrap_or_else(|| end_ts - 24 * 60 * 60);
let start_of_today = today.and_hms_opt(0, 0, 0).unwrap();
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减一天
let start_ts = Local
.from_local_datetime(&start_of_today)
.earliest()
.unwrap_or_else(|| Local::now() - Duration::days(1))
.timestamp();
let sql = "SELECT if start_ts >= end_ts {
strftime('%Y-%m-%dT%H:00:00', datetime(created_at, 'unixepoch', 'localtime')) as bucket, start_ts = end_ts - 24 * 60 * 60;
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 >= ?
GROUP BY bucket
ORDER BY bucket ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([start_ts], |row| {
Ok(DailyStats {
date: row.get(0)?,
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
})
})?;
let mut buckets: HashMap<String, DailyStats> = HashMap::new();
for row in rows {
let stat = row?;
buckets.insert(stat.date.clone(), stat);
}
let mut stats = Vec::new();
for hour in 0..24 {
let bucket = today
.and_hms_opt(hour, 0, 0)
.unwrap()
.format("%Y-%m-%dT%H:00:00")
.to_string();
if let Some(stat) = buckets.remove(&bucket) {
stats.push(stat);
} else {
stats.push(DailyStats {
date: bucket,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
Ok(stats)
} else {
let today = Local::now().date_naive();
let start_day = today - Duration::days((days.saturating_sub(1)) as i64);
let start_of_window = start_day.and_hms_opt(0, 0, 0).unwrap();
// 使用 earliest() 处理 DST 切换时的歧义时间,fallback 到当前时间减 days 天
let start_ts = Local
.from_local_datetime(&start_of_window)
.earliest()
.unwrap_or_else(|| Local::now() - Duration::days(days as i64))
.timestamp();
let sql = "SELECT
strftime('%Y-%m-%dT00:00:00', datetime(created_at, 'unixepoch', 'localtime')) 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 >= ?
GROUP BY bucket
ORDER BY bucket ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map([start_ts], |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();
for i in 0..days {
let day = start_day + Duration::days(i as i64);
let key = day.format("%Y-%m-%dT00:00:00").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 duration = end_ts - start_ts;
let bucket_seconds: i64 = if duration <= 24 * 60 * 60 {
60 * 60
} else {
24 * 60 * 60
};
let mut bucket_count: i64 = if duration <= 0 {
1
} else {
((duration as f64) / bucket_seconds as f64).ceil() as i64
};
// 固定 24 小时窗口为 24 个小时桶,避免浮点误差
if bucket_seconds == 60 * 60 {
bucket_count = 24;
}
if bucket_count < 1 {
bucket_count = 1;
}
let sql = "
SELECT
CAST((created_at - ?1) / ?3 AS INTEGER) as bucket_idx,
COUNT(*) as request_count,
COALESCE(SUM(CAST(total_cost_usd AS REAL)), 0) as total_cost,
COALESCE(SUM(input_tokens + output_tokens), 0) as total_tokens,
COALESCE(SUM(input_tokens), 0) as total_input_tokens,
COALESCE(SUM(output_tokens), 0) as total_output_tokens,
COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation_tokens,
COALESCE(SUM(cache_read_tokens), 0) as total_cache_read_tokens
FROM proxy_request_logs
WHERE created_at >= ?1 AND created_at <= ?2
GROUP BY bucket_idx
ORDER BY bucket_idx ASC";
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![start_ts, end_ts, bucket_seconds], |row| {
Ok((
row.get::<_, i64>(0)?,
DailyStats {
date: String::new(),
request_count: row.get::<_, i64>(1)? as u64,
total_cost: format!("{:.6}", row.get::<_, f64>(2)?),
total_tokens: row.get::<_, i64>(3)? as u64,
total_input_tokens: row.get::<_, i64>(4)? as u64,
total_output_tokens: row.get::<_, i64>(5)? as u64,
total_cache_creation_tokens: row.get::<_, i64>(6)? as u64,
total_cache_read_tokens: row.get::<_, i64>(7)? as u64,
},
))
})?;
let mut map: HashMap<i64, DailyStats> = HashMap::new();
for row in rows {
let (mut bucket_idx, stat) = row?;
if bucket_idx < 0 {
continue;
}
if bucket_idx >= bucket_count {
bucket_idx = bucket_count - 1;
}
map.insert(bucket_idx, stat);
}
let mut stats = Vec::with_capacity(bucket_count as usize);
for i in 0..bucket_count {
let bucket_start_ts = start_ts + i * bucket_seconds;
let bucket_start = Local
.timestamp_opt(bucket_start_ts, 0)
.single()
.unwrap_or_else(Local::now);
let date = bucket_start.format("%Y-%m-%dT%H:%M:%S").to_string();
if let Some(mut stat) = map.remove(&i) {
stat.date = date;
stats.push(stat);
} else {
stats.push(DailyStats {
date,
request_count: 0,
total_cost: "0.000000".to_string(),
total_tokens: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_creation_tokens: 0,
total_cache_read_tokens: 0,
});
}
}
Ok(stats)
} }
/// 获取 Provider 统计 /// 获取 Provider 统计
@@ -829,89 +798,46 @@ impl Database {
} }
} }
/// 标准化模型名称:去除供应商前缀并将点号替换为短横线
/// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5
fn normalize_model_id(model_id: &str) -> String {
// 1. 去除供应商前缀(如 anthropic/、openai/
let stripped = if let Some(pos) = model_id.find('/') {
&model_id[pos + 1..]
} else {
model_id
};
// 2. 将点号替换为短横线(如 claude-haiku-4.5 → claude-haiku-4-5
stripped.replace('.', "-")
}
pub(crate) fn find_model_pricing_row( pub(crate) fn find_model_pricing_row(
conn: &Connection, conn: &Connection,
model_id: &str, model_id: &str,
) -> Result<Option<(String, String, String, String)>, AppError> { ) -> Result<Option<(String, String, String, String)>, AppError> {
// 0. 标准化模型名称(去除前缀 + 点号转短横线) // 1) 去除供应商前缀(/ 之前)与冒号后缀(: 之后),例如 moonshotai/kimi-k2-0905:exa → kimi-k2-0905
// 例如:anthropic/claude-haiku-4.5 → claude-haiku-4-5 let without_prefix = model_id
let normalized = normalize_model_id(model_id); .rsplit_once('/')
.map(|(_, rest)| rest)
.unwrap_or(model_id);
let cleaned = without_prefix
.split(':')
.next()
.map(str::trim)
.unwrap_or(without_prefix);
// 1. 精确匹配(先尝试原始名称,再尝试标准化后的名称 // 2) 精确匹配清洗后的名称
for id in [model_id, normalized.as_str()] { let exact = conn
let exact = conn .query_row(
.query_row( "SELECT input_cost_per_million, output_cost_per_million,
"SELECT input_cost_per_million, output_cost_per_million, cache_read_cost_per_million, cache_creation_cost_per_million
cache_read_cost_per_million, cache_creation_cost_per_million FROM model_pricing
FROM model_pricing WHERE model_id = ?1",
WHERE model_id = ?1", [cleaned],
[id], |row| {
|row| { Ok((
Ok(( row.get::<_, String>(0)?,
row.get::<_, String>(0)?, row.get::<_, String>(1)?,
row.get::<_, String>(1)?, row.get::<_, String>(2)?,
row.get::<_, String>(2)?, row.get::<_, String>(3)?,
row.get::<_, String>(3)?, ))
)) },
}, )
) .optional()
.optional() .map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
.map_err(|e| AppError::Database(format!("查询模型定价失败: {e}")))?;
if exact.is_some() { if exact.is_none() {
if id != model_id { log::warn!("模型 {model_id}(清洗后: {cleaned})未找到定价信息,成本将记录为 0");
log::info!("模型 {model_id} 标准化后精确匹配到: {id}");
}
return Ok(exact);
}
} }
// 2. 逐步删除后缀匹配(claude-haiku-4-5-20250929 → claude-haiku-4-5 → claude-haiku-4 → claude-haiku Ok(exact)
// 使用标准化后的名称进行后缀匹配
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)] #[cfg(test)]
@@ -991,54 +917,39 @@ mod tests {
let db = Database::memory()?; let db = Database::memory()?;
let conn = lock_conn!(db.conn); let conn = lock_conn!(db.conn);
// 测试精确匹配 // 准备额外定价数据,覆盖前缀/后缀清洗场景
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5")?; conn.execute(
assert!(result.is_some(), "应该能精确匹配 claude-sonnet-4-5"); "INSERT OR REPLACE INTO model_pricing (
model_id, display_name, input_cost_per_million, output_cost_per_million,
cache_read_cost_per_million, cache_creation_cost_per_million
) VALUES (?, ?, ?, ?, ?, ?)",
params![
"claude-haiku-4.5",
"Claude Haiku 4.5",
"1.0",
"2.0",
"0.0",
"0.0"
],
)?;
// 测试带供应商前缀的模型名称(anthropic/claude-haiku-4.5 → claude-haiku-4-5 // 测试精确匹配(seed_model_pricing 已预置 claude-sonnet-4-5-20250929
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
assert!(
result.is_some(),
"应该能匹配带前缀的模型 anthropic/claude-haiku-4.5"
);
// 测试带供应商前缀 + 点号的模型名称
let result = find_model_pricing_row(&conn, "anthropic/claude-sonnet-4.5")?;
assert!(
result.is_some(),
"应该能匹配带前缀的模型 anthropic/claude-sonnet-4.5"
);
// 测试逐步删除后缀匹配 - 日期后缀
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20241022")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 claude-sonnet-4-5-20241022"
);
// 测试逐步删除后缀匹配 - 多个后缀
let result = find_model_pricing_row(&conn, "claude-haiku-4-5-20240229-preview")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 claude-haiku-4-5-20240229-preview"
);
// 测试 GPT 模型
let result = find_model_pricing_row(&conn, "gpt-5-2024-11-20")?;
assert!(result.is_some(), "应该能通过删除后缀匹配 gpt-5-2024-11-20");
// 测试 Gemini 模型
let result = find_model_pricing_row(&conn, "gemini-2.5-flash-exp")?;
assert!(
result.is_some(),
"应该能通过删除后缀匹配 gemini-2.5-flash-exp"
);
// 测试 claude-sonnet-4-5 命名格式
let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?; let result = find_model_pricing_row(&conn, "claude-sonnet-4-5-20250929")?;
assert!( assert!(
result.is_some(), result.is_some(),
"应该能通过删除后缀匹配 claude-sonnet-4-5-20250929" "应该能精确匹配 claude-sonnet-4-5-20250929"
);
// 清洗:去除前缀和冒号后缀
let result = find_model_pricing_row(&conn, "anthropic/claude-haiku-4.5")?;
assert!(
result.is_some(),
"带前缀的模型 anthropic/claude-haiku-4.5 应能匹配到 claude-haiku-4.5"
);
let result = find_model_pricing_row(&conn, "moonshotai/kimi-k2-0905:exa")?;
assert!(
result.is_some(),
"带前缀+冒号后缀的模型应清洗后匹配到 kimi-k2-0905"
); );
// 测试不存在的模型 // 测试不存在的模型
+95 -16
View File
@@ -13,6 +13,8 @@ import {
Wrench, Wrench,
Server, Server,
RefreshCw, RefreshCw,
Search,
Download,
} from "lucide-react"; } from "lucide-react";
import type { Provider } from "@/types"; import type { Provider } from "@/types";
import type { EnvConflict } from "@/types/env"; import type { EnvConflict } from "@/types/env";
@@ -42,6 +44,7 @@ import UsageScriptModal from "@/components/UsageScriptModal";
import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel";
import PromptPanel from "@/components/prompts/PromptPanel"; import PromptPanel from "@/components/prompts/PromptPanel";
import { SkillsPage } from "@/components/skills/SkillsPage"; import { SkillsPage } from "@/components/skills/SkillsPage";
import UnifiedSkillsPanel from "@/components/skills/UnifiedSkillsPanel";
import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog";
import { AgentsPanel } from "@/components/agents/AgentsPanel"; import { AgentsPanel } from "@/components/agents/AgentsPanel";
import { UniversalProviderPanel } from "@/components/universal"; import { UniversalProviderPanel } from "@/components/universal";
@@ -52,6 +55,7 @@ type View =
| "settings" | "settings"
| "prompts" | "prompts"
| "skills" | "skills"
| "skillsDiscovery"
| "mcp" | "mcp"
| "agents" | "agents"
| "universal"; | "universal";
@@ -81,6 +85,7 @@ function App() {
const promptPanelRef = useRef<any>(null); const promptPanelRef = useRef<any>(null);
const mcpPanelRef = useRef<any>(null); const mcpPanelRef = useRef<any>(null);
const skillsPageRef = useRef<any>(null); const skillsPageRef = useRef<any>(null);
const unifiedSkillsPanelRef = useRef<any>(null);
const addActionButtonClass = 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"; "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";
@@ -106,8 +111,7 @@ function App() {
}); });
const providers = useMemo(() => data?.providers ?? {}, [data]); const providers = useMemo(() => data?.providers ?? {}, [data]);
const currentProviderId = data?.currentProviderId ?? ""; const currentProviderId = data?.currentProviderId ?? "";
// Skills 功能仅支持 Claude 和 Codex const hasSkillsSupport = true;
const hasSkillsSupport = activeApp === "claude" || activeApp === "codex";
// 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作
const { const {
@@ -218,6 +222,35 @@ function App() {
checkMigration(); checkMigration();
}, [t]); }, [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(() => { useEffect(() => {
const checkEnvOnSwitch = async () => { const checkEnvOnSwitch = async () => {
@@ -390,10 +423,16 @@ function App() {
/> />
); );
case "skills": case "skills":
return (
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
/>
);
case "skillsDiscovery":
return ( return (
<SkillsPage <SkillsPage
ref={skillsPageRef} ref={skillsPageRef}
onClose={() => setCurrentView("providers")}
initialApp={activeApp} initialApp={activeApp}
/> />
); );
@@ -532,7 +571,11 @@ function App() {
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setCurrentView("providers")} onClick={() =>
setCurrentView(
currentView === "skillsDiscovery" ? "skills" : "providers",
)
}
className="mr-2 rounded-lg" className="mr-2 rounded-lg"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
@@ -542,6 +585,7 @@ function App() {
{currentView === "prompts" && {currentView === "prompts" &&
t("prompts.title", { appName: t(`apps.${activeApp}`) })} t("prompts.title", { appName: t(`apps.${activeApp}`) })}
{currentView === "skills" && t("skills.title")} {currentView === "skills" && t("skills.title")}
{currentView === "skillsDiscovery" && t("skills.title")}
{currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")}
{currentView === "agents" && t("agents.title")} {currentView === "agents" && t("agents.title")}
{currentView === "universal" && {currentView === "universal" &&
@@ -587,25 +631,60 @@ function App() {
> >
{currentView === "prompts" && ( {currentView === "prompts" && (
<Button <Button
size="icon" variant="ghost"
size="sm"
onClick={() => promptPanelRef.current?.openAdd()} onClick={() => promptPanelRef.current?.openAdd()}
className={`ml-auto ${addActionButtonClass}`} className="hover:bg-black/5 dark:hover:bg-white/5"
title={t("prompts.add")}
> >
<Plus className="w-5 h-5" /> <Plus className="w-4 h-4 mr-2" />
{t("prompts.add")}
</Button> </Button>
)} )}
{currentView === "mcp" && ( {currentView === "mcp" && (
<Button <>
size="icon" <Button
onClick={() => mcpPanelRef.current?.openAdd()} variant="ghost"
className={`ml-auto ${addActionButtonClass}`} size="sm"
title={t("mcp.unifiedPanel.addServer")} onClick={() => mcpPanelRef.current?.openImport()}
> className="hover:bg-black/5 dark:hover:bg-white/5"
<Plus className="w-5 h-5" /> >
</Button> <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>
</>
)} )}
{currentView === "skills" && ( {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 <Button
variant="ghost" variant="ghost"
+20 -5
View File
@@ -12,6 +12,9 @@ interface FullScreenPanelProps {
footer?: React.ReactNode; 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 * Reusable full-screen panel component
* Handles portal rendering, header with back button, and footer * Handles portal rendering, header with back button, and footer
@@ -44,18 +47,30 @@ export const FullScreenPanel: React.FC<FullScreenPanelProps> = ({
className="fixed inset-0 z-[60] flex flex-col" className="fixed inset-0 z-[60] flex flex-col"
style={{ backgroundColor: "hsl(var(--background))" }} style={{ backgroundColor: "hsl(var(--background))" }}
> >
{/* Header */} {/* Drag region - match App.tsx */}
<div <div
className="flex-shrink-0 py-3 border-b border-border-default" data-tauri-drag-region
style={{ backgroundColor: "hsl(var(--background))" }} 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,
}}
> >
<div className="h-4 w-full" data-tauri-drag-region /> <div className="mx-auto max-w-[56rem] px-6 w-full flex items-center gap-4">
<div className="mx-auto max-w-[56rem] px-6 flex items-center gap-4">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
onClick={onClose} onClick={onClose}
className="rounded-lg"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
+19 -2
View File
@@ -3,12 +3,11 @@ import { useTranslation } from "react-i18next";
import { Server } from "lucide-react"; import { Server } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useAllMcpServers, useToggleMcpApp } from "@/hooks/useMcp"; import { useAllMcpServers, useToggleMcpApp, useDeleteMcpServer, useImportMcpFromApps } from "@/hooks/useMcp";
import type { McpServer } from "@/types"; import type { McpServer } from "@/types";
import type { AppId } from "@/lib/api/types"; import type { AppId } from "@/lib/api/types";
import McpFormModal from "./McpFormModal"; import McpFormModal from "./McpFormModal";
import { ConfirmDialog } from "../ConfirmDialog"; import { ConfirmDialog } from "../ConfirmDialog";
import { useDeleteMcpServer } from "@/hooks/useMcp";
import { Edit3, Trash2 } from "lucide-react"; import { Edit3, Trash2 } from "lucide-react";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
import { mcpPresets } from "@/config/mcpPresets"; import { mcpPresets } from "@/config/mcpPresets";
@@ -24,6 +23,7 @@ interface UnifiedMcpPanelProps {
*/ */
export interface UnifiedMcpPanelHandle { export interface UnifiedMcpPanelHandle {
openAdd: () => void; openAdd: () => void;
openImport: () => void;
} }
const UnifiedMcpPanel = React.forwardRef< const UnifiedMcpPanel = React.forwardRef<
@@ -44,6 +44,7 @@ const UnifiedMcpPanel = React.forwardRef<
const { data: serversMap, isLoading } = useAllMcpServers(); const { data: serversMap, isLoading } = useAllMcpServers();
const toggleAppMutation = useToggleMcpApp(); const toggleAppMutation = useToggleMcpApp();
const deleteServerMutation = useDeleteMcpServer(); const deleteServerMutation = useDeleteMcpServer();
const importMutation = useImportMcpFromApps();
// Convert serversMap to array for easier rendering // Convert serversMap to array for easier rendering
const serverEntries = useMemo((): Array<[string, McpServer]> => { const serverEntries = useMemo((): Array<[string, McpServer]> => {
@@ -86,8 +87,24 @@ const UnifiedMcpPanel = React.forwardRef<
setIsFormOpen(true); 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, () => ({ React.useImperativeHandle(ref, () => ({
openAdd: handleAdd, openAdd: handleAdd,
openImport: handleImport,
})); }));
const handleDelete = (id: string) => { const handleDelete = (id: string) => {
+3 -3
View File
@@ -8,7 +8,7 @@ interface PromptToggleProps {
/** /**
* Toggle 开关组件(提示词专用) * Toggle 开关组件(提示词专用)
* 启用时为色,禁用时为灰色 * 启用时为绿色,禁用时为灰色
*/ */
const PromptToggle: React.FC<PromptToggleProps> = ({ const PromptToggle: React.FC<PromptToggleProps> = ({
enabled, enabled,
@@ -23,8 +23,8 @@ const PromptToggle: React.FC<PromptToggleProps> = ({
disabled={disabled} disabled={disabled}
onClick={() => onChange(!enabled)} onClick={() => onChange(!enabled)}
className={` className={`
relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/20 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-blue-500 dark:bg-blue-600" : "bg-gray-300 dark:bg-gray-600"} ${enabled ? "bg-emerald-500 dark:bg-emerald-600" : "bg-gray-300 dark:bg-gray-600"}
${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"} ${disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}
`} `}
> >
@@ -222,9 +222,7 @@ export function ClaudeFormFields({
{/* 推理模型 */} {/* 推理模型 */}
<div className="space-y-2"> <div className="space-y-2">
<FormLabel htmlFor="reasoningModel"> <FormLabel htmlFor="reasoningModel">
{t("providerForm.anthropicReasoningModel", { {t("providerForm.anthropicReasoningModel")}
defaultValue: "推理模型 (Thinking)",
})}
</FormLabel> </FormLabel>
<Input <Input
id="reasoningModel" id="reasoningModel"
@@ -233,9 +231,6 @@ export function ClaudeFormFields({
onChange={(e) => onChange={(e) =>
onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value) onModelChange("ANTHROPIC_REASONING_MODEL", e.target.value)
} }
placeholder={t("providerForm.reasoningModelPlaceholder", {
defaultValue: "",
})}
autoComplete="off" autoComplete="off"
/> />
</div> </div>
@@ -142,12 +142,13 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="10" max="10"
value={formData.maxRetries} value={formData.maxRetries}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
maxRetries: parseInt(e.target.value) || 3, maxRetries: isNaN(val) ? 0 : val,
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -168,12 +169,13 @@ export function AutoFailoverConfigPanel({
min="1" min="1"
max="20" max="20"
value={formData.circuitFailureThreshold} value={formData.circuitFailureThreshold}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitFailureThreshold: parseInt(e.target.value) || 5, circuitFailureThreshold: isNaN(val) ? 1 : Math.max(1, val),
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -206,12 +208,13 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="180" max="180"
value={formData.streamingFirstByteTimeout} value={formData.streamingFirstByteTimeout}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
streamingFirstByteTimeout: parseInt(e.target.value) || 30, streamingFirstByteTimeout: isNaN(val) ? 0 : val,
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -232,12 +235,13 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="600" max="600"
value={formData.streamingIdleTimeout} value={formData.streamingIdleTimeout}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
streamingIdleTimeout: parseInt(e.target.value) || 60, streamingIdleTimeout: isNaN(val) ? 0 : val,
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -258,12 +262,13 @@ export function AutoFailoverConfigPanel({
min="0" min="0"
max="1800" max="1800"
value={formData.nonStreamingTimeout} value={formData.nonStreamingTimeout}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
nonStreamingTimeout: parseInt(e.target.value) || 300, nonStreamingTimeout: isNaN(val) ? 0 : val,
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -293,12 +298,13 @@ export function AutoFailoverConfigPanel({
min="1" min="1"
max="10" max="10"
value={formData.circuitSuccessThreshold} value={formData.circuitSuccessThreshold}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitSuccessThreshold: parseInt(e.target.value) || 2, circuitSuccessThreshold: isNaN(val) ? 1 : Math.max(1, val),
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -319,12 +325,13 @@ export function AutoFailoverConfigPanel({
min="10" min="10"
max="300" max="300"
value={formData.circuitTimeoutSeconds} value={formData.circuitTimeoutSeconds}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitTimeoutSeconds: parseInt(e.target.value) || 60, circuitTimeoutSeconds: isNaN(val) ? 10 : Math.max(10, val),
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -346,13 +353,13 @@ export function AutoFailoverConfigPanel({
max="100" max="100"
step="5" step="5"
value={Math.round(formData.circuitErrorRateThreshold * 100)} value={Math.round(formData.circuitErrorRateThreshold * 100)}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitErrorRateThreshold: circuitErrorRateThreshold: isNaN(val) ? 0.5 : val / 100,
(parseInt(e.target.value) || 50) / 100, });
}) }}
}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@@ -373,12 +380,13 @@ export function AutoFailoverConfigPanel({
min="5" min="5"
max="100" max="100"
value={formData.circuitMinRequests} value={formData.circuitMinRequests}
onChange={(e) => onChange={(e) => {
const val = parseInt(e.target.value);
setFormData({ setFormData({
...formData, ...formData,
circuitMinRequests: parseInt(e.target.value) || 10, circuitMinRequests: isNaN(val) ? 5 : Math.max(5, val),
}) });
} }}
disabled={isDisabled} disabled={isDisabled}
/> />
<p className="text-xs text-muted-foreground"> <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")} {isLoadingTools ? t("common.refreshing") : t("common.refresh")}
</Button> </Button>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3 px-1">
{["claude", "codex", "gemini"].map((toolName, index) => { {["claude", "codex", "gemini"].map((toolName, index) => {
const tool = toolVersions.find((item) => item.name === toolName); const tool = toolVersions.find((item) => item.name === toolName);
const displayName = tool?.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 { Label } from "@/components/ui/label";
import { Trash2, ExternalLink, Plus } from "lucide-react"; import { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
import type { Skill, SkillRepo } from "@/lib/api/skills"; import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerProps { interface RepoManagerProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
repos: SkillRepo[]; repos: SkillRepo[];
skills: Skill[]; skills: DiscoverableSkill[];
onAdd: (repo: SkillRepo) => Promise<void>; onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => 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 { Trash2, ExternalLink, Plus } from "lucide-react";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { FullScreenPanel } from "@/components/common/FullScreenPanel";
import type { Skill, SkillRepo } from "@/lib/api/skills"; import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
interface RepoManagerPanelProps { interface RepoManagerPanelProps {
repos: SkillRepo[]; repos: SkillRepo[];
skills: Skill[]; skills: DiscoverableSkill[];
onAdd: (repo: SkillRepo) => Promise<void>; onAdd: (repo: SkillRepo) => Promise<void>;
onRemove: (owner: string, name: string) => Promise<void>; onRemove: (owner: string, name: string) => Promise<void>;
onClose: () => void; onClose: () => void;
@@ -92,7 +92,7 @@ export function RepoManagerPanel({
{/* 添加仓库表单 */} {/* 添加仓库表单 */}
<div className="space-y-4 glass-card rounded-xl p-6"> <div className="space-y-4 glass-card rounded-xl p-6">
<h3 className="text-base font-semibold text-foreground"> <h3 className="text-base font-semibold text-foreground">
{t("skills.addRepo")}
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
+5 -3
View File
@@ -12,10 +12,12 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react"; import { ExternalLink, Download, Trash2, Loader2 } from "lucide-react";
import { settingsApi } from "@/lib/api"; import { settingsApi } from "@/lib/api";
import type { Skill } from "@/lib/api/skills"; import type { DiscoverableSkill } from "@/lib/api/skills";
type SkillCardSkill = DiscoverableSkill & { installed: boolean };
interface SkillCardProps { interface SkillCardProps {
skill: Skill; skill: SkillCardSkill;
onInstall: (directory: string) => Promise<void>; onInstall: (directory: string) => Promise<void>;
onUninstall: (directory: string) => Promise<void>; onUninstall: (directory: string) => Promise<void>;
} }
@@ -57,7 +59,7 @@ export function SkillCard({ skill, onInstall, onUninstall }: SkillCardProps) {
skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase(); skill.directory.trim().toLowerCase() !== skill.name.trim().toLowerCase();
return ( return (
<Card className="glass-card flex flex-col h-full transition-all duration-300 hover:scale-[1.01] hover:shadow-lg group relative overflow-hidden"> <Card className="glass-card flex flex-col h-full transition-all duration-300 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" /> <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"> <CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
+101 -129
View File
@@ -1,10 +1,4 @@
import { import { useState, useMemo, forwardRef, useImperativeHandle } from "react";
useState,
useEffect,
useMemo,
forwardRef,
useImperativeHandle,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -20,15 +14,18 @@ import { toast } from "sonner";
import { SkillCard } from "./SkillCard"; import { SkillCard } from "./SkillCard";
import { RepoManagerPanel } from "./RepoManagerPanel"; import { RepoManagerPanel } from "./RepoManagerPanel";
import { import {
skillsApi, useDiscoverableSkills,
type Skill, useInstalledSkills,
type SkillRepo, useInstallSkill,
useSkillRepos,
useAddSkillRepo,
useRemoveSkillRepo,
type AppType, type AppType,
} from "@/lib/api/skills"; } from "@/hooks/useSkills";
import type { DiscoverableSkill, SkillRepo } from "@/lib/api/skills";
import { formatSkillError } from "@/lib/errors/skillErrorParser"; import { formatSkillError } from "@/lib/errors/skillErrorParser";
interface SkillsPageProps { interface SkillsPageProps {
onClose?: () => void;
initialApp?: AppType; initialApp?: AppType;
} }
@@ -37,163 +34,138 @@ export interface SkillsPageHandle {
openRepoManager: () => void; openRepoManager: () => void;
} }
/**
* Skills 发现面板
* 用于浏览和安装来自仓库的 Skills
*/
export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>( export const SkillsPage = forwardRef<SkillsPageHandle, SkillsPageProps>(
({ onClose: _onClose, initialApp = "claude" }, ref) => { ({ initialApp = "claude" }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [skills, setSkills] = useState<Skill[]>([]);
const [repos, setRepos] = useState<SkillRepo[]>([]);
const [loading, setLoading] = useState(true);
const [repoManagerOpen, setRepoManagerOpen] = useState(false); const [repoManagerOpen, setRepoManagerOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [filterStatus, setFilterStatus] = useState< const [filterStatus, setFilterStatus] = useState<
"all" | "installed" | "uninstalled" "all" | "installed" | "uninstalled"
>("all"); >("all");
// 使用 initialApp,不允许切换
const selectedApp = initialApp;
const loadSkills = async (afterLoad?: (data: Skill[]) => void) => { // currentApp 用于安装时的默认应用
try { const currentApp = initialApp;
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);
// 传入 "skills.loadFailed" 作为标题 // Queries
const { title, description } = formatSkillError( const {
errorMessage, data: discoverableSkills,
t, isLoading: loadingDiscoverable,
"skills.loadFailed", isFetching: fetchingDiscoverable,
); refetch: refetchDiscoverable,
} = useDiscoverableSkills();
const { data: installedSkills } = useInstalledSkills();
const { data: repos = [], refetch: refetchRepos } = useSkillRepos();
toast.error(title, { // Mutations
description, const installMutation = useInstallSkill();
duration: 8000, const addRepoMutation = useAddSkillRepo();
}); const removeRepoMutation = useRemoveSkillRepo();
console.error("Load skills failed:", error); // 已安装的 directory 集合
} finally { const installedDirs = useMemo(() => {
setLoading(false); if (!installedSkills) return new Set<string>();
} return new Set(installedSkills.map((s) => s.directory.toLowerCase()));
}; }, [installedSkills]);
const loadRepos = async () => { type DiscoverableSkillItem = DiscoverableSkill & { installed: boolean };
try {
const data = await skillsApi.getRepos();
setRepos(data);
} catch (error) {
console.error("Failed to load repos:", error);
}
};
useEffect(() => { // 为发现列表补齐 installed 状态,供 SkillCard 使用
Promise.all([loadSkills(), loadRepos()]); const skills: DiscoverableSkillItem[] = useMemo(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps if (!discoverableSkills) return [];
}, []); return discoverableSkills.map((d) => {
const installName =
d.directory.split("/").pop()?.toLowerCase() ||
d.directory.toLowerCase();
return {
...d,
installed: installedDirs.has(installName),
};
});
}, [discoverableSkills, installedDirs]);
const loading = loadingDiscoverable || fetchingDiscoverable;
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
refresh: () => loadSkills(), refresh: () => {
refetchDiscoverable();
refetchRepos();
},
openRepoManager: () => setRepoManagerOpen(true), openRepoManager: () => setRepoManagerOpen(true),
})); }));
const handleInstall = async (directory: string) => { 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 { try {
await skillsApi.install(directory, selectedApp); await installMutation.mutateAsync({
toast.success(t("skills.installSuccess", { name: directory }), { skill,
currentApp,
});
toast.success(t("skills.installSuccess", { name: skill.name }), {
closeButton: true, closeButton: true,
}); });
await loadSkills();
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
// 使用错误解析器格式化错误,传入 "skills.installFailed"
const { title, description } = formatSkillError( const { title, description } = formatSkillError(
errorMessage, errorMessage,
t, t,
"skills.installFailed", "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, { toast.error(title, {
description, description,
duration: 10000, duration: 10000,
}); });
console.error("Install skill failed:", error);
}
};
console.error("Uninstall skill failed:", { const handleUninstall = async (_directory: string) => {
directory, // 在发现面板中,不支持卸载,需要在主面板中操作
error, toast.info(t("skills.uninstallInMainPanel"));
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),
}); });
} }
}; };
const handleAddRepo = async (repo: SkillRepo) => {
await skillsApi.addRepo(repo);
let repoSkillCount = 0;
await Promise.all([
loadRepos(),
loadSkills((data) => {
repoSkillCount = data.filter(
(skill) =>
skill.repoOwner === repo.owner &&
skill.repoName === repo.name &&
(skill.repoBranch || "main") === (repo.branch || "main"),
).length;
}),
]);
toast.success(
t("skills.repo.addSuccess", {
owner: repo.owner,
name: repo.name,
count: repoSkillCount,
}),
{ closeButton: true },
);
};
const handleRemoveRepo = async (owner: string, name: string) => { const handleRemoveRepo = async (owner: string, name: string) => {
await skillsApi.removeRepo(owner, name); try {
toast.success(t("skills.repo.removeSuccess", { owner, name }), { await removeRepoMutation.mutateAsync({ owner, name });
closeButton: true, toast.success(t("skills.repo.removeSuccess", { owner, name }), {
}); closeButton: true,
await Promise.all([loadRepos(), loadSkills()]); });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
}; };
// 过滤技能列表 // 过滤技能列表
@@ -0,0 +1,417 @@
import React, { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Sparkles, Trash2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
useInstalledSkills,
useToggleSkillApp,
useUninstallSkill,
useScanUnmanagedSkills,
useImportSkillsFromApps,
type InstalledSkill,
type AppType,
} from "@/hooks/useSkills";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { settingsApi } from "@/lib/api";
import { toast } from "sonner";
interface UnifiedSkillsPanelProps {
onOpenDiscovery: () => void;
}
/**
* Skills
* v3.10.0 Skills Skill
*/
export interface UnifiedSkillsPanelHandle {
openDiscovery: () => void;
openImport: () => void;
}
const UnifiedSkillsPanel = React.forwardRef<
UnifiedSkillsPanelHandle,
UnifiedSkillsPanelProps
>(({ onOpenDiscovery }, ref) => {
const { t } = useTranslation();
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
} | null>(null);
const [importDialogOpen, setImportDialogOpen] = useState(false);
// Queries and Mutations
const { data: skills, isLoading } = useInstalledSkills();
const toggleAppMutation = useToggleSkillApp();
const uninstallMutation = useUninstallSkill();
const { data: unmanagedSkills, refetch: scanUnmanaged } =
useScanUnmanagedSkills();
const importMutation = useImportSkillsFromApps();
// Count enabled skills per app
const enabledCounts = useMemo(() => {
const counts = { claude: 0, codex: 0, gemini: 0 };
if (!skills) return counts;
skills.forEach((skill) => {
if (skill.apps.claude) counts.claude++;
if (skill.apps.codex) counts.codex++;
if (skill.apps.gemini) counts.gemini++;
});
return counts;
}, [skills]);
const handleToggleApp = async (
id: string,
app: AppType,
enabled: boolean,
) => {
try {
await toggleAppMutation.mutateAsync({ id, app, enabled });
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleUninstall = (skill: InstalledSkill) => {
setConfirmDialog({
isOpen: true,
title: t("skills.uninstall"),
message: t("skills.uninstallConfirm", { name: skill.name }),
onConfirm: async () => {
try {
await uninstallMutation.mutateAsync(skill.id);
setConfirmDialog(null);
toast.success(t("skills.uninstallSuccess", { name: skill.name }), {
closeButton: true,
});
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
},
});
};
const handleOpenImport = async () => {
try {
const result = await scanUnmanaged();
if (!result.data || result.data.length === 0) {
toast.success(t("skills.noUnmanagedFound"), { closeButton: true });
return;
}
setImportDialogOpen(true);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
const handleImport = async (directories: string[]) => {
try {
const imported = await importMutation.mutateAsync(directories);
setImportDialogOpen(false);
toast.success(
t("skills.importSuccess", { count: imported.length }),
{ closeButton: true },
);
} catch (error) {
toast.error(t("common.error"), {
description: String(error),
});
}
};
React.useImperativeHandle(ref, () => ({
openDiscovery: onOpenDiscovery,
openImport: handleOpenImport,
}));
return (
<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;
+34 -18
View File
@@ -62,6 +62,28 @@ export function RequestLogTable() {
}); });
}; };
// 将 Unix 时间戳转换为本地时间的 datetime-local 格式
const timestampToLocalDatetime = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
// 将 datetime-local 格式转换为 Unix 时间戳
const localDatetimeToTimestamp = (datetime: string): number | undefined => {
if (!datetime) return undefined;
// 验证格式是否完整 (YYYY-MM-DDTHH:mm)
if (datetime.length < 16) return undefined;
const timestamp = new Date(datetime).getTime();
// 验证是否为有效日期
if (isNaN(timestamp)) return undefined;
return Math.floor(timestamp / 1000);
};
const dateLocale = const dateLocale =
i18n.language === "zh" i18n.language === "zh"
? "zh-CN" ? "zh-CN"
@@ -153,19 +175,16 @@ export function RequestLogTable() {
className="h-8 w-[200px] bg-background" className="h-8 w-[200px] bg-background"
value={ value={
tempFilters.startDate tempFilters.startDate
? new Date(tempFilters.startDate * 1000) ? timestampToLocalDatetime(tempFilters.startDate)
.toISOString()
.slice(0, 16)
: "" : ""
} }
onChange={(e) => onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({ setTempFilters({
...tempFilters, ...tempFilters,
startDate: e.target.value startDate: timestamp,
? Math.floor(new Date(e.target.value).getTime() / 1000) });
: undefined, }}
})
}
/> />
<span>-</span> <span>-</span>
<Input <Input
@@ -173,19 +192,16 @@ export function RequestLogTable() {
className="h-8 w-[200px] bg-background" className="h-8 w-[200px] bg-background"
value={ value={
tempFilters.endDate tempFilters.endDate
? new Date(tempFilters.endDate * 1000) ? timestampToLocalDatetime(tempFilters.endDate)
.toISOString()
.slice(0, 16)
: "" : ""
} }
onChange={(e) => onChange={(e) => {
const timestamp = localDatetimeToTimestamp(e.target.value);
setTempFilters({ setTempFilters({
...tempFilters, ...tempFilters,
endDate: e.target.value endDate: timestamp,
? Math.floor(new Date(e.target.value).getTime() / 1000) });
: undefined, }}
})
}
/> />
</div> </div>
+1 -7
View File
@@ -12,13 +12,7 @@ interface UsageSummaryCardsProps {
export function UsageSummaryCards({ days }: UsageSummaryCardsProps) { export function UsageSummaryCards({ days }: UsageSummaryCardsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { startDate, endDate } = useMemo(() => { const { data: summary, isLoading } = useUsageSummary(days);
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 stats = useMemo(() => {
const totalRequests = summary?.totalRequests ?? 0; const totalRequests = summary?.totalRequests ?? 0;
+43 -19
View File
@@ -41,7 +41,12 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
return { return {
rawDate: stat.date, rawDate: stat.date,
label: isToday label: isToday
? pointDate.toLocaleTimeString(dateLocale, { hour: "2-digit" }) ? pointDate.toLocaleString(dateLocale, {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})
: pointDate.toLocaleDateString(dateLocale, { : pointDate.toLocaleDateString(dateLocale, {
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
@@ -49,28 +54,13 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
hour: pointDate.getHours(), hour: pointDate.getHours(),
inputTokens: stat.totalInputTokens, inputTokens: stat.totalInputTokens,
outputTokens: stat.totalOutputTokens, outputTokens: stat.totalOutputTokens,
cacheCreationTokens: stat.totalCacheCreationTokens,
cacheReadTokens: stat.totalCacheReadTokens,
cost: parseFloat(stat.totalCost), cost: parseFloat(stat.totalCost),
}; };
}) || []; }) || [];
const hourlyData = (() => { const displayData = chartData;
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) => { const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) { if (active && payload && payload.length) {
@@ -131,6 +121,20 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} /> <stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} /> <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
</linearGradient> </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> </defs>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
@@ -182,6 +186,26 @@ export function UsageTrendChart({ days }: UsageTrendChartProps) {
fill="url(#colorOutput)" fill="url(#colorOutput)"
strokeWidth={2} 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 <Area
yAxisId="cost" yAxisId="cost"
type="monotone" type="monotone"
+13
View File
@@ -59,3 +59,16 @@ export function useDeleteMcpServer() {
}, },
}); });
} }
/**
* MCP
*/
export function useImportMcpFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => mcpApi.importFromApps(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["mcp", "all"] });
},
});
}
+151
View File
@@ -0,0 +1,151 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
skillsApi,
type AppType,
type DiscoverableSkill,
type InstalledSkill,
} from "@/lib/api/skills";
/**
* Skills
*/
export function useInstalledSkills() {
return useQuery({
queryKey: ["skills", "installed"],
queryFn: () => skillsApi.getInstalled(),
});
}
/**
* Skills
*/
export function useDiscoverableSkills() {
return useQuery({
queryKey: ["skills", "discoverable"],
queryFn: () => skillsApi.discoverAvailable(),
staleTime: Infinity, // 无限缓存,直到仓库变化时 invalidate
});
}
/**
* Skill
*/
export function useInstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
skill,
currentApp,
}: {
skill: DiscoverableSkill;
currentApp: AppType;
}) => skillsApi.installUnified(skill, currentApp),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* Skill
*/
export function useUninstallSkill() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => skillsApi.uninstallUnified(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
* Skill
*/
export function useToggleSkillApp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
app,
enabled,
}: {
id: string;
app: AppType;
enabled: boolean;
}) => skillsApi.toggleApp(id, app, enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
},
});
}
/**
* Skills
*/
export function useScanUnmanagedSkills() {
return useQuery({
queryKey: ["skills", "unmanaged"],
queryFn: () => skillsApi.scanUnmanaged(),
enabled: false, // 手动触发
});
}
/**
* Skills
*/
export function useImportSkillsFromApps() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (directories: string[]) => skillsApi.importFromApps(directories),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "installed"] });
queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] });
},
});
}
/**
*
*/
export function useSkillRepos() {
return useQuery({
queryKey: ["skills", "repos"],
queryFn: () => skillsApi.getRepos(),
});
}
/**
*
*/
export function useAddSkillRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: skillsApi.addRepo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
/**
*
*/
export function useRemoveSkillRepo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ owner, name }: { owner: string; name: string }) =>
skillsApi.removeRepo(owner, name),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["skills", "repos"] });
queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] });
},
});
}
// ========== 辅助类型 ==========
export type { InstalledSkill, DiscoverableSkill, AppType };
+37 -12
View File
@@ -343,7 +343,6 @@
"anthropicModel": "Main Model", "anthropicModel": "Main Model",
"anthropicSmallFastModel": "Fast Model", "anthropicSmallFastModel": "Fast Model",
"anthropicReasoningModel": "Reasoning Model (Thinking)", "anthropicReasoningModel": "Reasoning Model (Thinking)",
"reasoningModelPlaceholder": "e.g. claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter Compatibility Mode", "openrouterCompatMode": "OpenRouter Compatibility Mode",
"openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.", "openrouterCompatModeHint": "Use OpenAI Chat Completions interface and convert to Anthropic SSE.",
"anthropicDefaultHaikuModel": "Default Haiku Model", "anthropicDefaultHaikuModel": "Default Haiku Model",
@@ -423,7 +422,7 @@
"cost": "Cost", "cost": "Cost",
"perMillion": "(per million)", "perMillion": "(per million)",
"trends": "Usage Trends", "trends": "Usage Trends",
"rangeToday": "Today (hourly)", "rangeToday": "Last 24 hours (hourly)",
"rangeLast7Days": "Last 7 days", "rangeLast7Days": "Last 7 days",
"rangeLast30Days": "Last 30 days", "rangeLast30Days": "Last 30 days",
"totalTokens": "Total Tokens", "totalTokens": "Total Tokens",
@@ -436,8 +435,8 @@
"billingModel": "Billing Model", "billingModel": "Billing Model",
"inputTokens": "Input", "inputTokens": "Input",
"outputTokens": "Output", "outputTokens": "Output",
"cacheReadTokens": "Cache Read", "cacheReadTokens": "Cache Hit",
"cacheCreationTokens": "Cache Write", "cacheCreationTokens": "Cache Creation",
"timingInfo": "Duration/TTFT", "timingInfo": "Duration/TTFT",
"status": "Status", "status": "Status",
"noData": "No data", "noData": "No data",
@@ -453,8 +452,8 @@
"displayName": "Display Name", "displayName": "Display Name",
"inputCost": "Input Cost", "inputCost": "Input Cost",
"outputCost": "Output Cost", "outputCost": "Output Cost",
"cacheReadCost": "Cache Read", "cacheReadCost": "Cache Hit",
"cacheWriteCost": "Cache Write", "cacheWriteCost": "Cache Creation",
"deleteConfirmTitle": "Confirm Delete", "deleteConfirmTitle": "Confirm Delete",
"deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.", "deleteConfirmDesc": "Are you sure you want to delete this model pricing? This action cannot be undone.",
"queryFailed": "Query failed", "queryFailed": "Query failed",
@@ -481,8 +480,8 @@
"timeRange": "Time Range", "timeRange": "Time Range",
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "Write", "cacheWrite": "Creation",
"cacheRead": "Read" "cacheRead": "Hit"
}, },
"usageScript": { "usageScript": {
"title": "Configure Usage Query", "title": "Configure Usage Query",
@@ -565,6 +564,9 @@
}, },
"mcp": { "mcp": {
"title": "MCP Management", "title": "MCP Management",
"import": "Import",
"importExisting": "Import Existing",
"addMcp": "Add MCP",
"claudeTitle": "Claude Code MCP Management", "claudeTitle": "Claude Code MCP Management",
"codexTitle": "Codex MCP Management", "codexTitle": "Codex MCP Management",
"geminiTitle": "Gemini MCP Management", "geminiTitle": "Gemini MCP Management",
@@ -576,6 +578,8 @@
"deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.", "deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.",
"noServers": "No servers yet", "noServers": "No servers yet",
"enabledApps": "Enabled Apps", "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": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
@@ -793,8 +797,8 @@
}, },
"skills": { "skills": {
"manage": "Skills", "manage": "Skills",
"title": "Claude Skills Management", "title": "Skills Management",
"description": "Discover and install Claude skills from popular repositories to extend Claude Code/Codex capabilities", "description": "Discover and install skills from popular repositories to extend Claude Code/Codex/Gemini capabilities",
"refresh": "Refresh", "refresh": "Refresh",
"refreshing": "Refreshing...", "refreshing": "Refreshing...",
"repoManager": "Repository Management", "repoManager": "Repository Management",
@@ -870,7 +874,25 @@
"installed": "Installed", "installed": "Installed",
"uninstalled": "Not installed" "uninstalled": "Not installed"
}, },
"noResults": "No matching skills found" "noResults": "No matching skills found",
"noInstalled": "No skills installed",
"noInstalledDescription": "Discover and install skills from repositories, or import existing skills",
"discover": "Discover Skills",
"import": "Import Existing",
"importDescription": "Select skills to import into CC Switch unified management",
"importSuccess": "Successfully imported {{count}} skills",
"importSelected": "Import Selected ({{count}})",
"noUnmanagedFound": "No skills to import found. All skills are already managed by CC Switch.",
"foundIn": "Found in",
"local": "Local",
"uninstallConfirm": "Are you sure you want to uninstall \"{{name}}\"? This will remove the skill from all apps.",
"uninstallInMainPanel": "Please uninstall skills from the main panel",
"notFound": "Skill not found",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
}, },
"deeplink": { "deeplink": {
"confirmImport": "Confirm Import Provider", "confirmImport": "Confirm Import Provider",
@@ -958,7 +980,10 @@
"clickToSelect": "Click to select icon" "clickToSelect": "Click to select icon"
}, },
"migration": { "migration": {
"success": "Configuration migrated successfully" "success": "Configuration migrated successfully",
"skillsSuccess": "Automatically imported {{count}} skill(s) into unified management",
"skillsFailed": "Failed to auto import skills",
"skillsFailedDescription": "Open the Skills page and click \"Import Existing\" to import manually (or restart and try again)."
}, },
"agents": { "agents": {
"title": "Agents" "title": "Agents"
+37 -12
View File
@@ -343,7 +343,6 @@
"anthropicModel": "メインモデル", "anthropicModel": "メインモデル",
"anthropicSmallFastModel": "高速モデル", "anthropicSmallFastModel": "高速モデル",
"anthropicReasoningModel": "推論モデル(Thinking", "anthropicReasoningModel": "推論モデル(Thinking",
"reasoningModelPlaceholder": "例: claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 互換モード", "openrouterCompatMode": "OpenRouter 互換モード",
"openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。", "openrouterCompatModeHint": "OpenAI Chat Completions インターフェースを使用し、Anthropic SSE に変換します。",
"anthropicDefaultHaikuModel": "既定 Haiku モデル", "anthropicDefaultHaikuModel": "既定 Haiku モデル",
@@ -423,7 +422,7 @@
"cost": "コスト", "cost": "コスト",
"perMillion": "(100万あたり)", "perMillion": "(100万あたり)",
"trends": "利用トレンド", "trends": "利用トレンド",
"rangeToday": "今日 (時間別)", "rangeToday": "直近24時間 (時間別)",
"rangeLast7Days": "過去7日間", "rangeLast7Days": "過去7日間",
"rangeLast30Days": "過去30日間", "rangeLast30Days": "過去30日間",
"totalTokens": "総トークン数", "totalTokens": "総トークン数",
@@ -436,8 +435,8 @@
"billingModel": "課金モデル", "billingModel": "課金モデル",
"inputTokens": "入力", "inputTokens": "入力",
"outputTokens": "出力", "outputTokens": "出力",
"cacheReadTokens": "キャッシュ読取", "cacheReadTokens": "キャッシュヒット",
"cacheCreationTokens": "キャッシュ書込", "cacheCreationTokens": "キャッシュ作成",
"timingInfo": "応答時間/TTFT", "timingInfo": "応答時間/TTFT",
"status": "ステータス", "status": "ステータス",
"noData": "データなし", "noData": "データなし",
@@ -453,8 +452,8 @@
"displayName": "表示名", "displayName": "表示名",
"inputCost": "入力コスト", "inputCost": "入力コスト",
"outputCost": "出力コスト", "outputCost": "出力コスト",
"cacheReadCost": "キャッシュ読取", "cacheReadCost": "キャッシュヒット",
"cacheWriteCost": "キャッシュ書込", "cacheWriteCost": "キャッシュ作成",
"deleteConfirmTitle": "削除の確認", "deleteConfirmTitle": "削除の確認",
"deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。", "deleteConfirmDesc": "このモデル料金を削除しますか?この操作は元に戻せません。",
"queryFailed": "照会に失敗しました", "queryFailed": "照会に失敗しました",
@@ -481,8 +480,8 @@
"timeRange": "期間", "timeRange": "期間",
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "Write", "cacheWrite": "作成",
"cacheRead": "Read" "cacheRead": "ヒット"
}, },
"usageScript": { "usageScript": {
"title": "利用状況を設定", "title": "利用状況を設定",
@@ -565,6 +564,9 @@
}, },
"mcp": { "mcp": {
"title": "MCP 管理", "title": "MCP 管理",
"import": "インポート",
"importExisting": "既存をインポート",
"addMcp": "MCPを追加",
"claudeTitle": "Claude Code MCP 管理", "claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理", "codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理", "geminiTitle": "Gemini MCP 管理",
@@ -576,6 +578,8 @@
"deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。", "deleteConfirm": "サーバー「{{id}}」を削除しますか?この操作は元に戻せません。",
"noServers": "まだサーバーがありません", "noServers": "まだサーバーがありません",
"enabledApps": "有効なアプリ", "enabledApps": "有効なアプリ",
"noImportFound": "インポートする MCP サーバーが見つかりませんでした。すべてのサーバーは CC Switch で管理されています。",
"importSuccess": "{{count}} 個の MCP サーバーをインポートしました",
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
@@ -793,8 +797,8 @@
}, },
"skills": { "skills": {
"manage": "Skills", "manage": "Skills",
"title": "Claude スキル管理", "title": "Skills 管理",
"description": "人気リポジトリから Claude Skills を探してインストールし、Claude Code/Codex を拡張", "description": "人気リポジトリからスキルを探してインストールし、Claude Code/Codex/Gemini を拡張",
"refresh": "更新", "refresh": "更新",
"refreshing": "更新中...", "refreshing": "更新中...",
"repoManager": "リポジトリ管理", "repoManager": "リポジトリ管理",
@@ -870,7 +874,25 @@
"installed": "インストール済み", "installed": "インストール済み",
"uninstalled": "未インストール" "uninstalled": "未インストール"
}, },
"noResults": "一致するスキルが見つかりませんでした" "noResults": "一致するスキルが見つかりませんでした",
"noInstalled": "インストールされたスキルがありません",
"noInstalledDescription": "リポジトリからスキルを発見してインストールするか、既存のスキルをインポートしてください",
"discover": "スキルを発見",
"import": "既存をインポート",
"importDescription": "CC Switch 統合管理にインポートするスキルを選択してください",
"importSuccess": "{{count}} 件のスキルをインポートしました",
"importSelected": "選択をインポート ({{count}})",
"noUnmanagedFound": "インポートするスキルが見つかりませんでした。すべてのスキルは CC Switch で管理されています。",
"foundIn": "発見場所",
"local": "ローカル",
"uninstallConfirm": "「{{name}}」をアンインストールしますか?すべてのアプリからこのスキルが削除されます。",
"uninstallInMainPanel": "メインパネルからスキルをアンインストールしてください",
"notFound": "スキルが見つかりません",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
}, },
"deeplink": { "deeplink": {
"confirmImport": "プロバイダーのインポートを確認", "confirmImport": "プロバイダーのインポートを確認",
@@ -958,7 +980,10 @@
"clickToSelect": "クリックでアイコンを選択" "clickToSelect": "クリックでアイコンを選択"
}, },
"migration": { "migration": {
"success": "設定の移行が完了しました" "success": "設定の移行が完了しました",
"skillsSuccess": "スキルを {{count}} 件、自動的に統合管理へインポートしました",
"skillsFailed": "スキルの自動インポートに失敗しました",
"skillsFailedDescription": "Skills 画面で「既存をインポート」をクリックして手動でインポートしてください(または再起動して再試行)。"
}, },
"agents": { "agents": {
"title": "エージェント" "title": "エージェント"
+37 -12
View File
@@ -343,7 +343,6 @@
"anthropicModel": "主模型", "anthropicModel": "主模型",
"anthropicSmallFastModel": "快速模型", "anthropicSmallFastModel": "快速模型",
"anthropicReasoningModel": "推理模型 (Thinking)", "anthropicReasoningModel": "推理模型 (Thinking)",
"reasoningModelPlaceholder": "如 claude-sonnet-4-20250514",
"openrouterCompatMode": "OpenRouter 兼容模式", "openrouterCompatMode": "OpenRouter 兼容模式",
"openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。", "openrouterCompatModeHint": "使用 OpenAI Chat Completions 接口并转换为 Anthropic SSE。",
"anthropicDefaultHaikuModel": "Haiku 默认模型", "anthropicDefaultHaikuModel": "Haiku 默认模型",
@@ -423,7 +422,7 @@
"cost": "成本", "cost": "成本",
"perMillion": "(每百万)", "perMillion": "(每百万)",
"trends": "使用趋势", "trends": "使用趋势",
"rangeToday": "今天 (按小时)", "rangeToday": "过去 24 小时 (按小时)",
"rangeLast7Days": "过去 7 天", "rangeLast7Days": "过去 7 天",
"rangeLast30Days": "过去 30 天", "rangeLast30Days": "过去 30 天",
"totalTokens": "总 Token 数", "totalTokens": "总 Token 数",
@@ -436,8 +435,8 @@
"billingModel": "计费模型", "billingModel": "计费模型",
"inputTokens": "输入", "inputTokens": "输入",
"outputTokens": "输出", "outputTokens": "输出",
"cacheReadTokens": "缓存读取", "cacheReadTokens": "缓存命中",
"cacheCreationTokens": "缓存写入", "cacheCreationTokens": "缓存创建",
"timingInfo": "用时/首字", "timingInfo": "用时/首字",
"status": "状态", "status": "状态",
"noData": "暂无数据", "noData": "暂无数据",
@@ -453,8 +452,8 @@
"displayName": "显示名称", "displayName": "显示名称",
"inputCost": "输入成本", "inputCost": "输入成本",
"outputCost": "输出成本", "outputCost": "输出成本",
"cacheReadCost": "缓存读取", "cacheReadCost": "缓存命中",
"cacheWriteCost": "缓存写入", "cacheWriteCost": "缓存创建",
"deleteConfirmTitle": "确认删除", "deleteConfirmTitle": "确认删除",
"deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。", "deleteConfirmDesc": "确定要删除此模型定价配置吗?此操作无法撤销。",
"queryFailed": "查询失败", "queryFailed": "查询失败",
@@ -481,8 +480,8 @@
"timeRange": "时间范围", "timeRange": "时间范围",
"input": "Input", "input": "Input",
"output": "Output", "output": "Output",
"cacheWrite": "Write", "cacheWrite": "创建",
"cacheRead": "Read" "cacheRead": "命中"
}, },
"usageScript": { "usageScript": {
"title": "配置用量查询", "title": "配置用量查询",
@@ -565,6 +564,9 @@
}, },
"mcp": { "mcp": {
"title": "MCP 管理", "title": "MCP 管理",
"import": "导入",
"importExisting": "导入已有",
"addMcp": "添加MCP",
"claudeTitle": "Claude Code MCP 管理", "claudeTitle": "Claude Code MCP 管理",
"codexTitle": "Codex MCP 管理", "codexTitle": "Codex MCP 管理",
"geminiTitle": "Gemini MCP 管理", "geminiTitle": "Gemini MCP 管理",
@@ -576,6 +578,8 @@
"deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。", "deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。",
"noServers": "暂无服务器", "noServers": "暂无服务器",
"enabledApps": "启用的应用", "enabledApps": "启用的应用",
"noImportFound": "未发现需要导入的 MCP 服务器。所有服务器已在 CC Switch 统一管理中。",
"importSuccess": "成功导入 {{count}} 个 MCP 服务器",
"apps": { "apps": {
"claude": "Claude", "claude": "Claude",
"codex": "Codex", "codex": "Codex",
@@ -793,8 +797,8 @@
}, },
"skills": { "skills": {
"manage": "Skills", "manage": "Skills",
"title": "Claude Skills 管理", "title": "Skills 管理",
"description": "从流行的仓库发现并安装 Claude 技能,扩展 Claude Code/Codex 的能力", "description": "从流行的仓库发现并安装技能,扩展 Claude Code/Codex/Gemini 的能力",
"refresh": "刷新", "refresh": "刷新",
"refreshing": "刷新中...", "refreshing": "刷新中...",
"repoManager": "仓库管理", "repoManager": "仓库管理",
@@ -870,7 +874,25 @@
"installed": "已安装", "installed": "已安装",
"uninstalled": "未安装" "uninstalled": "未安装"
}, },
"noResults": "未找到匹配的技能" "noResults": "未找到匹配的技能",
"noInstalled": "暂无已安装的技能",
"noInstalledDescription": "从仓库发现并安装技能,或导入已有的技能",
"discover": "发现技能",
"import": "导入已有",
"importDescription": "选择要导入到 CC Switch 统一管理的技能",
"importSuccess": "成功导入 {{count}} 个技能",
"importSelected": "导入已选 ({{count}})",
"noUnmanagedFound": "未发现需要导入的技能。所有技能已在 CC Switch 统一管理中。",
"foundIn": "发现于",
"local": "本地",
"uninstallConfirm": "确定要卸载技能 \"{{name}}\" 吗?这将从所有应用中移除该技能。",
"uninstallInMainPanel": "请在主面板中卸载技能",
"notFound": "未找到技能",
"apps": {
"claude": "Claude",
"codex": "Codex",
"gemini": "Gemini"
}
}, },
"deeplink": { "deeplink": {
"confirmImport": "确认导入供应商配置", "confirmImport": "确认导入供应商配置",
@@ -958,7 +980,10 @@
"clickToSelect": "点击选择图标" "clickToSelect": "点击选择图标"
}, },
"migration": { "migration": {
"success": "配置迁移成功" "success": "配置迁移成功",
"skillsSuccess": "已自动导入 {{count}} 个技能到统一管理",
"skillsFailed": "自动导入技能失败",
"skillsFailedDescription": "请打开 Skills 页面点击“导入已有”手动导入(或重启后再试)。"
}, },
"agents": { "agents": {
"title": "智能体" "title": "智能体"
+7
View File
@@ -119,4 +119,11 @@ export const mcpApi = {
): Promise<void> { ): Promise<void> {
return await invoke("toggle_mcp_app", { serverId, app, enabled }); return await invoke("toggle_mcp_app", { serverId, app, enabled });
}, },
/**
* MCP
*/
async importFromApps(): Promise<number> {
return await invoke("import_mcp_from_apps");
},
}; };
+102 -1
View File
@@ -1,5 +1,51 @@
import { invoke } from "@tauri-apps/api/core"; 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 { export interface Skill {
key: string; key: string;
name: string; name: string;
@@ -12,6 +58,7 @@ export interface Skill {
repoBranch?: string; repoBranch?: string;
} }
/** 仓库配置 */
export interface SkillRepo { export interface SkillRepo {
owner: string; owner: string;
name: string; name: string;
@@ -19,9 +66,56 @@ export interface SkillRepo {
enabled: boolean; enabled: boolean;
} }
export type AppType = "claude" | "codex" | "gemini"; // ========== API ==========
export const skillsApi = { 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[]> { async getAll(app: AppType = "claude"): Promise<Skill[]> {
if (app === "claude") { if (app === "claude") {
return await invoke("get_skills"); return await invoke("get_skills");
@@ -29,6 +123,7 @@ export const skillsApi = {
return await invoke("get_skills_for_app", { app }); return await invoke("get_skills_for_app", { app });
}, },
/** 安装技能(兼容旧 API) */
async install(directory: string, app: AppType = "claude"): Promise<boolean> { async install(directory: string, app: AppType = "claude"): Promise<boolean> {
if (app === "claude") { if (app === "claude") {
return await invoke("install_skill", { directory }); return await invoke("install_skill", { directory });
@@ -36,6 +131,7 @@ export const skillsApi = {
return await invoke("install_skill_for_app", { app, directory }); return await invoke("install_skill_for_app", { app, directory });
}, },
/** 卸载技能(兼容旧 API) */
async uninstall( async uninstall(
directory: string, directory: string,
app: AppType = "claude", app: AppType = "claude",
@@ -46,14 +142,19 @@ export const skillsApi = {
return await invoke("uninstall_skill_for_app", { app, directory }); return await invoke("uninstall_skill_for_app", { app, directory });
}, },
// ========== 仓库管理 ==========
/** 获取仓库列表 */
async getRepos(): Promise<SkillRepo[]> { async getRepos(): Promise<SkillRepo[]> {
return await invoke("get_skill_repos"); return await invoke("get_skill_repos");
}, },
/** 添加仓库 */
async addRepo(repo: SkillRepo): Promise<boolean> { async addRepo(repo: SkillRepo): Promise<boolean> {
return await invoke("add_skill_repo", { repo }); return await invoke("add_skill_repo", { repo });
}, },
/** 删除仓库 */
async removeRepo(owner: string, name: string): Promise<boolean> { async removeRepo(owner: string, name: string): Promise<boolean> {
return await invoke("remove_skill_repo", { owner, name }); return await invoke("remove_skill_repo", { owner, name });
}, },
+5 -2
View File
@@ -49,8 +49,11 @@ export const usageApi = {
return invoke("get_usage_summary", { startDate, endDate }); return invoke("get_usage_summary", { startDate, endDate });
}, },
getUsageTrends: async (days: number): Promise<DailyStats[]> => { getUsageTrends: async (
return invoke("get_usage_trends", { days }); startDate?: number,
endDate?: number,
): Promise<DailyStats[]> => {
return invoke("get_usage_trends", { startDate, endDate });
}, },
getProviderStats: async (): Promise<ProviderStats[]> => { getProviderStats: async (): Promise<ProviderStats[]> => {
+27 -6
View File
@@ -5,8 +5,7 @@ import type { LogFilters } from "@/types/usage";
// Query keys // Query keys
export const usageKeys = { export const usageKeys = {
all: ["usage"] as const, all: ["usage"] as const,
summary: (startDate?: number, endDate?: number) => summary: (days: number) => [...usageKeys.all, "summary", days] as const,
[...usageKeys.all, "summary", startDate, endDate] as const,
trends: (days: number) => [...usageKeys.all, "trends", days] as const, trends: (days: number) => [...usageKeys.all, "trends", days] as const,
providerStats: () => [...usageKeys.all, "provider-stats"] as const, providerStats: () => [...usageKeys.all, "provider-stats"] as const,
modelStats: () => [...usageKeys.all, "model-stats"] as const, modelStats: () => [...usageKeys.all, "model-stats"] as const,
@@ -19,18 +18,34 @@ export const usageKeys = {
[...usageKeys.all, "limits", providerId, appType] as const, [...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 // Hooks
export function useUsageSummary(startDate?: number, endDate?: number) { export function useUsageSummary(days: number) {
return useQuery({ return useQuery({
queryKey: usageKeys.summary(startDate, endDate), queryKey: usageKeys.summary(days),
queryFn: () => usageApi.getUsageSummary(startDate, endDate), queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageSummary(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false, // 后台不刷新
}); });
} }
export function useUsageTrends(days: number) { export function useUsageTrends(days: number) {
return useQuery({ return useQuery({
queryKey: usageKeys.trends(days), queryKey: usageKeys.trends(days),
queryFn: () => usageApi.getUsageTrends(days), queryFn: () => {
const { startDate, endDate } = getWindow(days);
return usageApi.getUsageTrends(startDate, endDate);
},
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
}); });
} }
@@ -38,6 +53,8 @@ export function useProviderStats() {
return useQuery({ return useQuery({
queryKey: usageKeys.providerStats(), queryKey: usageKeys.providerStats(),
queryFn: usageApi.getProviderStats, queryFn: usageApi.getProviderStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
}); });
} }
@@ -45,6 +62,8 @@ export function useModelStats() {
return useQuery({ return useQuery({
queryKey: usageKeys.modelStats(), queryKey: usageKeys.modelStats(),
queryFn: usageApi.getModelStats, queryFn: usageApi.getModelStats,
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
}); });
} }
@@ -56,6 +75,8 @@ export function useRequestLogs(
return useQuery({ return useQuery({
queryKey: usageKeys.logs(filters, page, pageSize), queryKey: usageKeys.logs(filters, page, pageSize),
queryFn: () => usageApi.getRequestLogs(filters, page, pageSize), queryFn: () => usageApi.getRequestLogs(filters, page, pageSize),
refetchInterval: 30000, // 每30秒自动刷新
refetchIntervalInBackground: false,
}); });
} }
+2
View File
@@ -36,6 +36,8 @@ const withJson = async <T>(request: Request): Promise<T> => {
const success = <T>(payload: T) => HttpResponse.json(payload as any); const success = <T>(payload: T) => HttpResponse.json(payload as any);
export const handlers = [ 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 }) => { http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
const { app } = await withJson<{ app: AppId }>(request); const { app } = await withJson<{ app: AppId }>(request);
return success(getProviders(app)); return success(getProviders(app));