diff --git a/.gemini-clipboard/clipboard-1769308510109.png b/.gemini-clipboard/clipboard-1769308510109.png new file mode 100644 index 0000000..a9c87b0 Binary files /dev/null and b/.gemini-clipboard/clipboard-1769308510109.png differ diff --git a/.gemini-clipboard/clipboard-1769308817019.png b/.gemini-clipboard/clipboard-1769308817019.png new file mode 100644 index 0000000..5c1f566 Binary files /dev/null and b/.gemini-clipboard/clipboard-1769308817019.png differ diff --git a/.gitignore b/.gitignore index f116eb8..f8980ff 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,5 @@ DEPLOYMENT*.md mirror-server/ /nul /.claude/ +/src-tauri/NUL +/docs \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 2347e7f..1d0e472 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,6 +228,37 @@ last-updated: 2025-12-16 - **GlobalConfig 清理**:删除 6 个废弃字段(`transparent_proxy_enabled`、`transparent_proxy_port`、`transparent_proxy_api_key`、`transparent_proxy_allow_public`、`transparent_proxy_real_api_key`、`transparent_proxy_real_base_url`) - **迁移逻辑**:`migrations/proxy_config.rs` 使用 `serde_json::Value` 手动操作 JSON,保持向后兼容 - **代码质量**:遵循 DRY 原则,所有检查通过(Clippy + fmt + ESLint + Prettier),测试 199 通过 + - **Codex 会话管理与计费系统(2026-01-18)**: + - **会话 ID 提取**:从请求体的 `prompt_cache_key` 字段提取(区别于 Claude 的 `metadata.user_id`) + - **SSE 事件结构**: + - `"type": "response.created"` → 提取 `response.id`(消息 ID) + - `"type": "response.completed"` → 提取完整 `response.usage`(所有 token 统计) + - **Token 字段映射**: + - `input_tokens` → input_tokens + - `input_tokens_details.cached_tokens` → cache_read_tokens + - `output_tokens` → output_tokens + - `output_tokens_details.reasoning_tokens` → 记录日志(暂不计费) + - `cache_creation_tokens` → 0(Codex 不报告缓存创建) + - **Tool Processor Pattern 架构(2026-01-18 重构)**: + - **核心理念**:每个工具独立实现 Token 提取逻辑,互不影响 + - **三层架构**: + - `ToolProcessor` trait(位于 `services/token_stats/processor/mod.rs`):定义提取接口,输出统一的 `TokenInfo` + - `TokenLogger` trait(位于 `services/token_stats/logger/mod.rs`):封装 Processor + 成本计算,输出完整的 `TokenLog` + - `TokenStatsManager`(简化版):仅负责批量写入数据库,单一职责 + - **工具实现**: + - Claude: `ClaudeProcessor` + `ClaudeLogger`(支持 message_start/message_delta 事件,嵌套 cache_creation 对象) + - Codex: `CodexProcessor` + `CodexLogger`(支持 response.created/response.completed 事件,平铺 usage 结构) + - **扩展性**:添加新工具仅需实现两个 trait,工厂函数自动注册 + - **优势**:工具逻辑完全隔离,Claude 和 Codex 互不影响,维护性和可测试性显著提高 + - **会话模型增强**: + - `ProxySession::extract_display_id()` 支持多种格式: + - Claude 格式:`user_xxx_session_` → 提取 UUID + - Codex 格式:`prompt_cache_key` → 使用前 12 字符 + - `RequestLogContext` 根据 tool_id 自动选择提取逻辑 + - **代码质量**: + - 新增 38 个测试(9 个 Processor 测试 + 10 个 Logger 测试 + 15 个数据库测试 + 4 个命令测试) + - 代码量减少:manager.rs 从 626 行减少到 286 行(-54%) + - 所有测试通过,编译 0 警告 - **配置管理机制(2025-12-12)**: - 代理启动时自动创建内置 Profile(`dc_proxy_*`),通过 `ProfileManager` 切换配置 - 内置 Profile 在 UI 中不可见(列表查询时过滤 `dc_proxy_` 前缀) diff --git a/src-tauri/src/commands/analytics_commands.rs b/src-tauri/src/commands/analytics_commands.rs index c2d3c96..7f38fd4 100644 --- a/src-tauri/src/commands/analytics_commands.rs +++ b/src-tauri/src/commands/analytics_commands.rs @@ -263,7 +263,7 @@ mod tests { for i in 0..10 { let log = TokenLog::new( - "claude_code".to_string(), + "claude-code".to_string(), base_time - (i * 3600 * 1000), // 每小时一条 "127.0.0.1".to_string(), "test_session".to_string(), @@ -274,6 +274,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -283,6 +284,7 @@ mod tests { Some(0.002), Some(0.0001), Some(0.0002), + None, // reasoning_price 0.0033, Some("test_template".to_string()), ); @@ -291,7 +293,7 @@ mod tests { // 创建查询 let query = TrendQuery { - tool_type: Some("claude_code".to_string()), + tool_type: Some("claude-code".to_string()), granularity: TimeGranularity::Hour, ..Default::default() }; @@ -327,7 +329,7 @@ mod tests { for (j, config) in configs.iter().enumerate() { for k in 0..3 { let log = TokenLog::new( - "claude_code".to_string(), + "claude-code".to_string(), base_time - (k * 1000), "127.0.0.1".to_string(), format!("session_{}_{}", i, j), @@ -338,6 +340,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -347,6 +350,7 @@ mod tests { Some(0.002), Some(0.0001), Some(0.0002), + None, // reasoning_price 0.0033, Some("test_template".to_string()), ); @@ -360,7 +364,7 @@ mod tests { // 按模型分组 let model_query = CostSummaryQuery { - tool_type: Some("claude_code".to_string()), + tool_type: Some("claude-code".to_string()), group_by: CostGroupBy::Model, ..Default::default() }; @@ -375,7 +379,7 @@ mod tests { // 按配置分组 let config_query = CostSummaryQuery { - tool_type: Some("claude_code".to_string()), + tool_type: Some("claude-code".to_string()), group_by: CostGroupBy::Config, ..Default::default() }; diff --git a/src-tauri/src/models/pricing.rs b/src-tauri/src/models/pricing.rs index d66168f..aedb20f 100644 --- a/src-tauri/src/models/pricing.rs +++ b/src-tauri/src/models/pricing.rs @@ -21,6 +21,10 @@ pub struct ModelPrice { #[serde(skip_serializing_if = "Option::is_none")] pub cache_read_price_per_1m: Option, + /// 推理输出价格(USD/百万 Token,可选,如 OpenAI o1 系列) + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_output_price_per_1m: Option, + /// 货币类型(默认:USD) #[serde(default = "default_currency")] pub currency: String, @@ -39,6 +43,7 @@ impl ModelPrice { output_price_per_1m: f64, cache_write_price_per_1m: Option, cache_read_price_per_1m: Option, + reasoning_output_price_per_1m: Option, aliases: Vec, ) -> Self { Self { @@ -47,6 +52,7 @@ impl ModelPrice { output_price_per_1m, cache_write_price_per_1m, cache_read_price_per_1m, + reasoning_output_price_per_1m, currency: default_currency(), aliases, } @@ -168,28 +174,47 @@ impl PricingTemplate { /// 工具默认模板配置(存储在 default_templates.json) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DefaultTemplatesConfig { + /// 配置版本号(用于自动迁移) + #[serde(default = "default_config_version")] + pub version: u32, + /// 工具 -> 默认模板 ID 的映射 /// /// 例如: /// ```json /// { - /// "claude-code": "claude_official_2025_01", - /// "codex": "claude_official_2025_01", - /// "gemini-cli": "claude_official_2025_01" + /// "version": 2, + /// "claude-code": "builtin_claude", + /// "codex": "builtin_openai", + /// "gemini-cli": "builtin_claude" /// } /// ``` #[serde(flatten)] pub tool_defaults: HashMap, } +/// 当前配置版本号 +const CURRENT_CONFIG_VERSION: u32 = 2; + +/// 默认配置版本号 +fn default_config_version() -> u32 { + 1 // 旧配置默认为版本 1 +} + impl DefaultTemplatesConfig { - /// 创建新的默认模板配置 + /// 创建新的默认模板配置(使用最新版本) pub fn new() -> Self { Self { + version: CURRENT_CONFIG_VERSION, tool_defaults: HashMap::new(), } } + /// 获取当前配置版本号 + pub fn current_version() -> u32 { + CURRENT_CONFIG_VERSION + } + /// 获取工具的默认模板 ID pub fn get_default(&self, tool_id: &str) -> Option<&String> { self.tool_defaults.get(tool_id) @@ -224,6 +249,7 @@ mod tests { 15.0, Some(3.75), Some(0.3), + None, // No reasoning price vec![ "claude-sonnet-4.5".to_string(), "claude-sonnet-4-5".to_string(), @@ -258,7 +284,7 @@ mod tests { let mut custom_models = HashMap::new(); custom_models.insert( "model1".to_string(), - ModelPrice::new("provider1".to_string(), 1.0, 2.0, None, None, vec![]), + ModelPrice::new("provider1".to_string(), 1.0, 2.0, None, None, None, vec![]), ); let full_custom = PricingTemplate::new( diff --git a/src-tauri/src/models/token_stats.rs b/src-tauri/src/models/token_stats.rs index e30de1a..a908560 100644 --- a/src-tauri/src/models/token_stats.rs +++ b/src-tauri/src/models/token_stats.rs @@ -41,6 +41,10 @@ pub struct TokenLog { /// 缓存读取Token数量 pub cache_read_tokens: i64, + /// 推理Token数量(如 OpenAI o1 系列) + #[serde(default)] + pub reasoning_tokens: i64, + /// 请求状态:success, failed pub request_status: String, @@ -79,6 +83,11 @@ pub struct TokenLog { #[serde(with = "crate::utils::precision::option_price_precision")] pub cache_read_price: Option, + /// 推理Token部分价格(USD) + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(with = "crate::utils::precision::option_price_precision")] + pub reasoning_price: Option, + /// 总成本(USD) #[serde(default)] #[serde(with = "crate::utils::precision::price_precision")] @@ -104,6 +113,7 @@ impl TokenLog { output_tokens: i64, cache_creation_tokens: i64, cache_read_tokens: i64, + reasoning_tokens: i64, request_status: String, response_type: String, error_type: Option, @@ -113,6 +123,7 @@ impl TokenLog { output_price: Option, cache_write_price: Option, cache_read_price: Option, + reasoning_price: Option, total_cost: f64, pricing_template_id: Option, ) -> Self { @@ -129,6 +140,7 @@ impl TokenLog { output_tokens, cache_creation_tokens, cache_read_tokens, + reasoning_tokens, request_status, response_type, error_type, @@ -138,6 +150,7 @@ impl TokenLog { output_price, cache_write_price, cache_read_price, + reasoning_price, total_cost, pricing_template_id, } @@ -174,6 +187,10 @@ pub struct SessionStats { /// 总缓存读取Token数量 pub total_cache_read: i64, + /// 总推理Token数量 + #[serde(default)] + pub total_reasoning: i64, + /// 请求总数 pub request_count: i64, } @@ -186,6 +203,7 @@ impl SessionStats { total_output: 0, total_cache_creation: 0, total_cache_read: 0, + total_reasoning: 0, request_count: 0, } } @@ -274,6 +292,7 @@ mod tests { 500, 100, 200, + 0, // reasoning_tokens "success".to_string(), "sse".to_string(), None, @@ -283,6 +302,7 @@ mod tests { Some(0.0075), Some(0.000375), Some(0.00006), + None, // reasoning_price 0.011235, Some("builtin_claude".to_string()), ); @@ -303,6 +323,7 @@ mod tests { total_output: 5000, total_cache_creation: 1000, total_cache_read: 2000, + total_reasoning: 0, // 新增字段 request_count: 10, }; diff --git a/src-tauri/src/services/migration_manager/migrations/mod.rs b/src-tauri/src/services/migration_manager/migrations/mod.rs index 3f90f11..b702f2d 100644 --- a/src-tauri/src/services/migration_manager/migrations/mod.rs +++ b/src-tauri/src/services/migration_manager/migrations/mod.rs @@ -4,6 +4,7 @@ mod balance_localstorage_to_json; mod global_to_providers; +mod pricing_default_templates; mod profile_v2; mod proxy_config; mod proxy_config_split; @@ -12,6 +13,7 @@ mod sqlite_to_json; pub use balance_localstorage_to_json::BalanceLocalstorageToJsonMigration; pub use global_to_providers::GlobalConfigToProvidersMigration; +pub use pricing_default_templates::PricingDefaultTemplatesMigration; pub use profile_v2::ProfileV2Migration; pub use proxy_config::ProxyConfigMigration; pub use proxy_config_split::ProxyConfigSplitMigration; diff --git a/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs b/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs new file mode 100644 index 0000000..3d104cf --- /dev/null +++ b/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs @@ -0,0 +1,173 @@ +// Pricing 默认模板配置迁移 +// +// 将 codex 的默认模板从 builtin_claude 迁移到 builtin_openai + +use crate::data::DataManager; +use crate::services::migration_manager::migration_trait::{Migration, MigrationResult}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use std::path::PathBuf; + +/// Pricing 默认模板配置迁移(目标版本 1.5.5) +pub struct PricingDefaultTemplatesMigration; + +impl Default for PricingDefaultTemplatesMigration { + fn default() -> Self { + Self::new() + } +} + +impl PricingDefaultTemplatesMigration { + pub fn new() -> Self { + Self + } + + /// 获取 default_templates.json 路径 + fn get_default_templates_path() -> Result { + let home_dir = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("无法获取用户主目录"))?; + Ok(home_dir + .join(".duckcoding") + .join("pricing") + .join("default_templates.json")) + } +} + +#[async_trait] +impl Migration for PricingDefaultTemplatesMigration { + fn id(&self) -> &str { + "pricing_default_templates_v2" + } + + fn name(&self) -> &str { + "Pricing 默认模板配置迁移" + } + + fn target_version(&self) -> &str { + "1.5.5" + } + + async fn execute(&self) -> Result { + tracing::info!("开始执行 Pricing 默认模板配置迁移"); + + let config_path = Self::get_default_templates_path()?; + + // 如果配置文件不存在,无需迁移 + if !config_path.exists() { + return Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: "配置文件不存在,跳过迁移".to_string(), + records_migrated: 0, + duration_secs: 0.0, + }); + } + + let manager = DataManager::new(); + let mut config_value = manager + .json_uncached() + .read(&config_path) + .context("Failed to read default_templates.json")?; + + let mut migrated = false; + + // 使用 serde_json::Value 手动处理 + if let Some(config_obj) = config_value.as_object_mut() { + // 检查配置版本号 + let current_version = config_obj + .get("version") + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32; + + // v1 -> v2 迁移:将 codex 的默认模板从 builtin_claude 改为 builtin_openai + if current_version < 2 { + if let Some(codex_template) = config_obj.get("codex") { + if codex_template.as_str() == Some("builtin_claude") { + tracing::info!("迁移 codex 默认模板: builtin_claude -> builtin_openai"); + config_obj.insert( + "codex".to_string(), + serde_json::Value::String("builtin_openai".to_string()), + ); + } + } + + // 更新版本号 + config_obj.insert("version".to_string(), serde_json::Value::Number(2.into())); + migrated = true; + } + } + + if migrated { + // 保存迁移后的配置 + manager + .json_uncached() + .write(&config_path, &config_value) + .context("Failed to write migrated default_templates.json")?; + + Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: "成功迁移 codex 默认模板配置".to_string(), + records_migrated: 1, + duration_secs: 0.0, + }) + } else { + Ok(MigrationResult { + migration_id: self.id().to_string(), + success: true, + message: "配置已是最新版本,无需迁移".to_string(), + records_migrated: 0, + duration_secs: 0.0, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + use tempfile::TempDir; + + #[tokio::test] + async fn test_migrate_codex_template() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("default_templates.json"); + + // 创建 v1 配置(codex 使用 builtin_claude) + let old_config = json!({ + "claude-code": "builtin_claude", + "codex": "builtin_claude", + "gemini-cli": "builtin_claude" + }); + + fs::write( + &config_path, + serde_json::to_string_pretty(&old_config).unwrap(), + ) + .unwrap(); + + // 执行迁移 + let manager = DataManager::new(); + let mut config_value = manager.json_uncached().read(&config_path).unwrap(); + + if let Some(config_obj) = config_value.as_object_mut() { + config_obj.insert( + "codex".to_string(), + serde_json::Value::String("builtin_openai".to_string()), + ); + config_obj.insert("version".to_string(), serde_json::Value::Number(2.into())); + } + + manager + .json_uncached() + .write(&config_path, &config_value) + .unwrap(); + + // 验证结果 + let migrated_value = manager.json_uncached().read(&config_path).unwrap(); + assert_eq!(migrated_value["codex"], "builtin_openai"); + assert_eq!(migrated_value["version"], 2); + assert_eq!(migrated_value["claude-code"], "builtin_claude"); + } +} diff --git a/src-tauri/src/services/migration_manager/mod.rs b/src-tauri/src/services/migration_manager/mod.rs index c36c91c..0a11141 100644 --- a/src-tauri/src/services/migration_manager/mod.rs +++ b/src-tauri/src/services/migration_manager/mod.rs @@ -9,8 +9,9 @@ mod migrations; pub use manager::MigrationManager; pub use migration_trait::{Migration, MigrationResult}; pub use migrations::{ - BalanceLocalstorageToJsonMigration, GlobalConfigToProvidersMigration, ProfileV2Migration, - ProxyConfigMigration, ProxyConfigSplitMigration, SessionConfigMigration, SqliteToJsonMigration, + BalanceLocalstorageToJsonMigration, GlobalConfigToProvidersMigration, + PricingDefaultTemplatesMigration, ProfileV2Migration, ProxyConfigMigration, + ProxyConfigSplitMigration, SessionConfigMigration, SqliteToJsonMigration, }; use std::sync::Arc; @@ -25,6 +26,7 @@ use std::sync::Arc; /// - ProxyConfigSplitMigration (1.4.0) - 透明代理配置拆分到 proxy.json /// - BalanceLocalstorageToJsonMigration (1.4.1) - 余额监控 LocalStorage → JSON 迁移 /// - GlobalConfigToProvidersMigration (1.5.0) - GlobalConfig 用户信息迁移到 Providers +/// - PricingDefaultTemplatesMigration (1.5.5) - Codex 默认模板迁移到 builtin_openai pub fn create_migration_manager() -> MigrationManager { let mut manager = MigrationManager::new(); @@ -36,6 +38,7 @@ pub fn create_migration_manager() -> MigrationManager { manager.register(Arc::new(ProxyConfigSplitMigration::new())); manager.register(Arc::new(BalanceLocalstorageToJsonMigration::new())); manager.register(Arc::new(GlobalConfigToProvidersMigration::new())); + manager.register(Arc::new(PricingDefaultTemplatesMigration::new())); tracing::debug!( "迁移管理器初始化完成,已注册 {} 个迁移", diff --git a/src-tauri/src/services/pricing/builtin.rs b/src-tauri/src/services/pricing/builtin.rs index 8966a2a..e265e0e 100644 --- a/src-tauri/src/services/pricing/builtin.rs +++ b/src-tauri/src/services/pricing/builtin.rs @@ -1,6 +1,47 @@ use crate::models::pricing::{ModelPrice, PricingTemplate}; use std::collections::HashMap; +/// 生成内置 OpenAI/Codex 价格模板 +/// +/// 包含 OpenAI/Codex 模型的定价 +pub fn builtin_openai_official_template() -> PricingTemplate { + let mut custom_models = HashMap::new(); + + // GPT-5.2 Codex: $3 input / $12 output + // 注意:OpenAI 的缓存机制不同,没有 cache_creation,只有 cached_tokens(读取) + custom_models.insert( + "gpt-5.2-codex".to_string(), + ModelPrice::new( + "openai".to_string(), + 3.0, + 12.0, + None, // OpenAI 不收费缓存创建 + Some(1.5), // Cache read: input * 0.5 (OpenAI 标准) + None, // 标准模型无推理 tokens + vec![ + "gpt-5.2-codex".to_string(), + "gpt-5.2".to_string(), + "gpt-5-2-codex".to_string(), + ], + ), + ); + + PricingTemplate::new( + "builtin_openai".to_string(), + "内置OpenAI价格".to_string(), + "OpenAI 官方定价,包含 GPT/Codex 模型".to_string(), + "1.0".to_string(), + vec![], // 内置模板不使用继承 + custom_models, + vec![ + "official".to_string(), + "openai".to_string(), + "codex".to_string(), + ], + true, // 标记为内置预设模板 + ) +} + /// 生成内置 Claude 价格模板 /// /// 包含 8 个 Claude 模型的官方定价 @@ -16,6 +57,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 25.0, Some(6.25), // Cache write: 5.0 * 1.25 Some(0.5), // Cache read: 5.0 * 0.1 + None, // No reasoning tokens vec![ "claude-opus-4.5".to_string(), "claude-opus-4-5".to_string(), @@ -34,6 +76,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 75.0, Some(18.75), // Cache write: 15.0 * 1.25 Some(1.5), // Cache read: 15.0 * 0.1 + None, // No reasoning tokens vec![ "claude-opus-4.1".to_string(), "claude-opus-4-1".to_string(), @@ -51,6 +94,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 75.0, Some(18.75), // Cache write: 15.0 * 1.25 Some(1.5), // Cache read: 15.0 * 0.1 + None, // No reasoning tokens vec![ "claude-opus-4".to_string(), "claude-opus-4-20250514".to_string(), @@ -67,6 +111,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 15.0, Some(3.75), // Cache write: 3.0 * 1.25 Some(0.3), // Cache read: 3.0 * 0.1 + None, // No reasoning tokens vec![ "claude-sonnet-4.5".to_string(), "claude-sonnet-4-5".to_string(), @@ -84,6 +129,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 15.0, Some(3.75), // Cache write: 3.0 * 1.25 Some(0.3), // Cache read: 3.0 * 0.1 + None, // No reasoning tokens vec![ "claude-sonnet-4".to_string(), "claude-sonnet-4-20250514".to_string(), @@ -100,6 +146,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 15.0, Some(3.75), // Cache write: 3.0 * 1.25 Some(0.3), // Cache read: 3.0 * 0.1 + None, // No reasoning tokens vec![ "claude-3-7-sonnet".to_string(), "claude-3-7-sonnet-20250219".to_string(), @@ -118,6 +165,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 5.0, Some(1.25), // Cache write: 1.0 * 1.25 Some(0.1), // Cache read: 1.0 * 0.1 + None, // No reasoning tokens vec![ "claude-haiku-4.5".to_string(), "claude-haiku-4-5".to_string(), @@ -135,6 +183,7 @@ pub fn builtin_claude_official_template() -> PricingTemplate { 4.0, Some(1.0), // Cache write: 0.8 * 1.25 Some(0.08), // Cache read: 0.8 * 0.1 + None, // No reasoning tokens vec![ "claude-haiku-3.5".to_string(), "claude-haiku-3-5".to_string(), @@ -254,4 +303,32 @@ mod tests { ); } } + + #[test] + fn test_builtin_openai_template() { + let template = builtin_openai_official_template(); + + // 验证基本信息 + assert_eq!(template.id, "builtin_openai"); + assert!(template.is_default_preset); + assert!(template.is_full_custom()); + + // 验证包含 1 个模型 + assert_eq!(template.custom_models.len(), 1); + + // 验证 GPT-5.2 Codex 价格 + let gpt_5_2 = template.custom_models.get("gpt-5.2-codex").unwrap(); + assert_eq!(gpt_5_2.provider, "openai"); + assert_eq!(gpt_5_2.input_price_per_1m, 3.0); + assert_eq!(gpt_5_2.output_price_per_1m, 12.0); + assert_eq!(gpt_5_2.cache_write_price_per_1m, None); // OpenAI 不收费缓存创建 + assert_eq!(gpt_5_2.cache_read_price_per_1m, Some(1.5)); + assert_eq!(gpt_5_2.reasoning_output_price_per_1m, None); + assert_eq!(gpt_5_2.aliases.len(), 3); + + // 验证别名 + assert!(gpt_5_2.aliases.contains(&"gpt-5.2-codex".to_string())); + assert!(gpt_5_2.aliases.contains(&"gpt-5.2".to_string())); + assert!(gpt_5_2.aliases.contains(&"gpt-5-2-codex".to_string())); + } } diff --git a/src-tauri/src/services/pricing/manager.rs b/src-tauri/src/services/pricing/manager.rs index a7bb1aa..ad1fdfa 100644 --- a/src-tauri/src/services/pricing/manager.rs +++ b/src-tauri/src/services/pricing/manager.rs @@ -1,6 +1,8 @@ use crate::data::DataManager; use crate::models::pricing::{DefaultTemplatesConfig, ModelPrice, PricingTemplate}; -use crate::services::pricing::builtin::builtin_claude_official_template; +use crate::services::pricing::builtin::{ + builtin_claude_official_template, builtin_openai_official_template, +}; use crate::utils::precision::price_precision; use anyhow::{anyhow, Context, Result}; use lazy_static::lazy_static; @@ -30,6 +32,10 @@ pub struct CostBreakdown { #[serde(with = "price_precision")] pub cache_read_price: f64, + /// 推理输出部分价格(USD) + #[serde(with = "price_precision")] + pub reasoning_price: f64, + /// 总成本(USD) #[serde(with = "price_precision")] pub total_cost: f64, @@ -102,14 +108,18 @@ impl PricingManager { .context("Failed to create templates directory")?; // 保存内置 Claude 官方模板 - let builtin_template = builtin_claude_official_template(); - self.save_template(&builtin_template)?; + let builtin_claude_template = builtin_claude_official_template(); + self.save_template(&builtin_claude_template)?; + + // 保存内置 OpenAI 官方模板 + let builtin_openai_template = builtin_openai_official_template(); + self.save_template(&builtin_openai_template)?; // 初始化默认模板配置(如果不存在) if !self.default_templates_path.exists() { let mut config = DefaultTemplatesConfig::new(); config.set_default("claude-code".to_string(), "builtin_claude".to_string()); - config.set_default("codex".to_string(), "builtin_claude".to_string()); + config.set_default("codex".to_string(), "builtin_openai".to_string()); config.set_default("gemini-cli".to_string(), "builtin_claude".to_string()); let value = serde_json::to_value(&config) @@ -247,30 +257,36 @@ impl PricingManager { /// # 参数 /// /// - `template_id`: 价格模板 ID(None 时使用工具默认模板) + /// - `tool_id`: 工具 ID(用于获取默认模板,当 template_id 为 None 时必须提供) /// - `model`: 模型名称 /// - `input_tokens`: 输入 Token 数量 /// - `output_tokens`: 输出 Token 数量 /// - `cache_creation_tokens`: 缓存创建 Token 数量 /// - `cache_read_tokens`: 缓存读取 Token 数量 + /// - `reasoning_tokens`: 推理 Token 数量 /// /// # 返回 /// /// 成本分解结果 + #[allow(clippy::too_many_arguments)] pub fn calculate_cost( &self, template_id: Option<&str>, + tool_id: Option<&str>, model: &str, input_tokens: i64, output_tokens: i64, cache_creation_tokens: i64, cache_read_tokens: i64, + reasoning_tokens: i64, ) -> Result { // 1. 获取模板 let template = if let Some(id) = template_id { self.get_template(id)? } else { - // 使用 claude-code 的默认模板作为回退 - self.get_default_template("claude-code")? + // 使用工具的默认模板(回退到 claude-code) + let default_tool_id = tool_id.unwrap_or("claude-code"); + self.get_default_template(default_tool_id)? }; // 2. 解析模型价格(别名 → 继承 → 倍率) @@ -286,14 +302,25 @@ impl PricingManager { * model_price.cache_read_price_per_1m.unwrap_or(0.0) / 1_000_000.0; + // 计算推理 Token 价格(如果有专用价格则使用,否则使用普通输出价格) + let reasoning_price = + if let Some(reasoning_price_per_1m) = model_price.reasoning_output_price_per_1m { + reasoning_tokens as f64 * reasoning_price_per_1m / 1_000_000.0 + } else { + // 回退:使用普通输出价格 + reasoning_tokens as f64 * model_price.output_price_per_1m / 1_000_000.0 + }; + // 4. 计算总成本 - let total_cost = input_price + output_price + cache_write_price + cache_read_price; + let total_cost = + input_price + output_price + cache_write_price + cache_read_price + reasoning_price; Ok(CostBreakdown { input_price, output_price, cache_write_price, cache_read_price, + reasoning_price, total_cost, template_id: template.id.clone(), }) @@ -337,6 +364,9 @@ impl PricingManager { cache_read_price_per_1m: base_price .cache_read_price_per_1m .map(|p| p * inherited.multiplier), + reasoning_output_price_per_1m: base_price + .reasoning_output_price_per_1m + .map(|p| p * inherited.multiplier), currency: base_price.currency, aliases: base_price.aliases, }); @@ -411,11 +441,13 @@ mod tests { let breakdown = manager .calculate_cost( Some("builtin_claude"), + None, // 工具 ID(已指定模板则可选) "claude-sonnet-4.5", 1000, // input 500, // output 100, // cache write 200, // cache read + 0, // reasoning_tokens ) .unwrap(); @@ -493,7 +525,16 @@ mod tests { // 不指定模板 ID,应使用默认模板 let breakdown = manager - .calculate_cost(None, "claude-sonnet-4.5", 1000, 500, 0, 0) + .calculate_cost( + None, + Some("claude-code"), + "claude-sonnet-4.5", + 1000, + 500, + 0, + 0, + 0, + ) .unwrap(); assert_eq!(breakdown.template_id, "builtin_claude"); diff --git a/src-tauri/src/services/proxy/headers/codex_processor.rs b/src-tauri/src/services/proxy/headers/codex_processor.rs index 11f8191..c722edd 100644 --- a/src-tauri/src/services/proxy/headers/codex_processor.rs +++ b/src-tauri/src/services/proxy/headers/codex_processor.rs @@ -1,6 +1,7 @@ // Codex 请求处理器 use super::{ProcessedRequest, RequestProcessor}; +use crate::services::session::{SessionEvent, SESSION_MANAGER}; use anyhow::Result; use async_trait::async_trait; use bytes::Bytes; @@ -39,8 +40,73 @@ impl RequestProcessor for CodexHeadersProcessor { original_headers: &HyperHeaderMap, body: &[u8], ) -> Result { + // 0. 查询会话配置并决定使用哪个 URL 和 API Key + let (final_base_url, final_api_key) = if !body.is_empty() { + // 尝试解析请求体 JSON 提取 prompt_cache_key + if let Ok(json_body) = serde_json::from_slice::(body) { + if let Some(session_id) = json_body["prompt_cache_key"].as_str() { + let timestamp = chrono::Utc::now().timestamp(); + + // 查询会话配置 + if let Ok(Some(( + config_name, + _custom_profile_name, + session_url, + session_api_key, + _session_pricing_template_id, + ))) = SESSION_MANAGER.get_session_config(session_id) + { + // 如果是自定义配置且有 URL 和 API Key,使用数据库的配置 + if config_name == "custom" + && !session_url.is_empty() + && !session_api_key.is_empty() + { + // 记录会话事件(使用自定义配置) + if let Err(e) = SESSION_MANAGER.send_event(SessionEvent::NewRequest { + session_id: session_id.to_string(), + tool_id: "codex".to_string(), + timestamp, + }) { + tracing::warn!("Session 事件发送失败: {}", e); + } + (session_url, session_api_key) + } else { + // 使用全局配置并记录会话 + if let Err(e) = SESSION_MANAGER.send_event(SessionEvent::NewRequest { + session_id: session_id.to_string(), + tool_id: "codex".to_string(), + timestamp, + }) { + tracing::warn!("Session 事件发送失败: {}", e); + } + (base_url.to_string(), api_key.to_string()) + } + } else { + // 会话不存在,使用全局配置并记录新会话 + if let Err(e) = SESSION_MANAGER.send_event(SessionEvent::NewRequest { + session_id: session_id.to_string(), + tool_id: "codex".to_string(), + timestamp, + }) { + tracing::warn!("Session 事件发送失败: {}", e); + } + (base_url.to_string(), api_key.to_string()) + } + } else { + // 没有 prompt_cache_key,使用全局配置 + (base_url.to_string(), api_key.to_string()) + } + } else { + // JSON 解析失败,使用全局配置 + (base_url.to_string(), api_key.to_string()) + } + } else { + // 空 body,使用全局配置 + (base_url.to_string(), api_key.to_string()) + }; + // 1. 构建目标 URL(Codex 特殊逻辑:避免 /v1 路径重复) - let base = base_url.trim_end_matches('/'); + let base = final_base_url.trim_end_matches('/'); // Codex 特殊逻辑:避免 /v1 路径重复 let adjusted_path = if base.ends_with("/v1") && path.starts_with("/v1") { @@ -69,7 +135,7 @@ impl RequestProcessor for CodexHeadersProcessor { // 3. 添加真实的 OpenAI API Key(Bearer Token 格式) headers.insert( "authorization", - format!("Bearer {api_key}") + format!("Bearer {final_api_key}") .parse() .map_err(|e| anyhow::anyhow!("Invalid authorization header: {e}"))?, ); @@ -90,4 +156,59 @@ impl RequestProcessor for CodexHeadersProcessor { // Codex 当前不需要特殊的响应处理 // 如果未来需要(例如处理速率限制信息),可以在此实现 + + /// 提取模型名称 + fn extract_model(&self, request_body: &[u8]) -> Option { + if request_body.is_empty() { + return None; + } + + // 尝试解析请求体 JSON + if let Ok(json_body) = serde_json::from_slice::(request_body) { + // OpenAI API 的模型字段在顶层 + json_body + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + None + } + } + + /// Codex 的请求日志记录实现 + /// + /// 使用统一的日志记录架构,自动处理所有错误场景 + async fn record_request_log( + &self, + client_ip: &str, + config_name: &str, + proxy_pricing_template_id: Option<&str>, + request_body: &[u8], + response_status: u16, + response_body: &[u8], + is_sse: bool, + response_time_ms: Option, + ) -> Result<()> { + use crate::services::proxy::log_recorder::{ + LogRecorder, RequestLogContext, ResponseParser, + }; + + // 1. 创建请求上下文(一次性提取所有信息) + let context = RequestLogContext::from_request( + self.tool_id(), + config_name, + client_ip, + proxy_pricing_template_id, + request_body, + response_time_ms, + ); + + // 2. 解析响应 + let parsed = ResponseParser::parse(response_body, response_status, is_sse); + + // 3. 记录日志(自动处理成功/失败/解析错误) + LogRecorder::record(&context, response_status, parsed).await?; + + Ok(()) + } } diff --git a/src-tauri/src/services/proxy/headers/gemini_processor.rs b/src-tauri/src/services/proxy/headers/gemini_processor.rs index 13bc925..eec0d26 100644 --- a/src-tauri/src/services/proxy/headers/gemini_processor.rs +++ b/src-tauri/src/services/proxy/headers/gemini_processor.rs @@ -82,3 +82,167 @@ impl RequestProcessor for GeminiHeadersProcessor { // Gemini CLI 当前不需要特殊的响应处理 // 如果未来需要(例如处理配额信息),可以在此实现 } + +#[cfg(test)] +mod tests { + use super::*; + use hyper::HeaderMap as HyperHeaderMap; + + #[tokio::test] + async fn test_x_goog_api_key_header_added() { + let processor = GeminiHeadersProcessor; + let headers = HyperHeaderMap::new(); + let api_key = "test-api-key-12345"; + + let result = processor + .process_outgoing_request( + "https://generativelanguage.googleapis.com", + api_key, + "/v1beta/models/gemini-2.0-flash:generateContent", + None, + &headers, + b"{}", + ) + .await; + + assert!(result.is_ok()); + let processed = result.unwrap(); + + // 验证 x-goog-api-key header 存在且值正确 + assert_eq!( + processed + .headers + .get("x-goog-api-key") + .and_then(|v| v.to_str().ok()), + Some(api_key) + ); + } + + #[tokio::test] + async fn test_old_headers_removed() { + let processor = GeminiHeadersProcessor; + let mut headers = HyperHeaderMap::new(); + headers.insert("authorization", "Bearer sk-ant-xxx".parse().unwrap()); + headers.insert("x-api-key", "old-key".parse().unwrap()); + headers.insert("x-goog-api-key", "old-goog-key".parse().unwrap()); + headers.insert("content-type", "application/json".parse().unwrap()); + + let result = processor + .process_outgoing_request( + "https://generativelanguage.googleapis.com", + "new-api-key", + "/v1beta/models/gemini-2.0-flash:generateContent", + None, + &headers, + b"{}", + ) + .await; + + assert!(result.is_ok()); + let processed = result.unwrap(); + + // 验证旧 header 被移除 + assert!(processed.headers.get("authorization").is_none()); + assert!(processed.headers.get("x-api-key").is_none()); + + // 验证新的 x-goog-api-key 被添加(旧值被覆盖) + assert_eq!( + processed + .headers + .get("x-goog-api-key") + .and_then(|v| v.to_str().ok()), + Some("new-api-key") + ); + + // 验证其他 header 保留 + assert_eq!( + processed + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()), + Some("application/json") + ); + } + + #[tokio::test] + async fn test_api_key_format_no_bearer() { + let processor = GeminiHeadersProcessor; + let headers = HyperHeaderMap::new(); + let api_key = "AIzaSyDl3-some-long-api-key-string"; + + let result = processor + .process_outgoing_request( + "https://generativelanguage.googleapis.com", + api_key, + "/v1beta/models/gemini-2.0-flash:generateContent", + None, + &headers, + b"{}", + ) + .await; + + assert!(result.is_ok()); + let processed = result.unwrap(); + + // 验证 API Key 没有 Bearer 前缀 + let api_key_value = processed + .headers + .get("x-goog-api-key") + .and_then(|v| v.to_str().ok()) + .unwrap(); + assert!(!api_key_value.starts_with("Bearer ")); + assert_eq!(api_key_value, api_key); + } + + #[tokio::test] + async fn test_url_construction() { + let processor = GeminiHeadersProcessor; + let headers = HyperHeaderMap::new(); + + let result = processor + .process_outgoing_request( + "https://generativelanguage.googleapis.com/", + "test-key", + "/v1beta/models/gemini-2.0-flash:generateContent", + None, + &headers, + b"{}", + ) + .await; + + assert!(result.is_ok()); + let processed = result.unwrap(); + + // 验证 URL 正确拼接(base_url 末尾 / 被移除,path 直接拼接) + assert_eq!( + processed.target_url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent" + ); + } + + #[tokio::test] + async fn test_query_string_preserved() { + let processor = GeminiHeadersProcessor; + let headers = HyperHeaderMap::new(); + + let result = processor + .process_outgoing_request( + "https://generativelanguage.googleapis.com", + "test-key", + "/v1beta/models/gemini-2.0-flash:generateContent", + Some("key=value&foo=bar"), + &headers, + b"{}", + ) + .await; + + assert!(result.is_ok()); + let processed = result.unwrap(); + + // 验证 query string 被正确保留 + assert_eq!( + processed.target_url, + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=value&foo=bar" + ); + } +} diff --git a/src-tauri/src/services/proxy/log_recorder/context.rs b/src-tauri/src/services/proxy/log_recorder/context.rs index 57ea226..b360871 100644 --- a/src-tauri/src/services/proxy/log_recorder/context.rs +++ b/src-tauri/src/services/proxy/log_recorder/context.rs @@ -29,37 +29,45 @@ impl RequestLogContext { request_body: &[u8], response_time_ms: Option, ) -> Self { - // 提取 user_id(完整)、display_id(用于日志)、model 和 stream(仅解析一次) - let (user_id, session_id, model, is_stream) = if !request_body.is_empty() { + // 提取 session_id(完整)、display_id(用于日志)、model 和 stream(仅解析一次) + let (full_session_id, session_id, model, is_stream) = if !request_body.is_empty() { match serde_json::from_slice::(request_body) { Ok(json) => { - // 提取完整 user_id(用于查询配置) - let user_id = json["metadata"]["user_id"] - .as_str() - .map(|s| s.to_string()) - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + // 根据工具类型提取 session_id + let full_session_id = if tool_id == "codex" { + // Codex: 从 prompt_cache_key 提取 + json["prompt_cache_key"] + .as_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + } else { + // Claude 和其他: 从 metadata.user_id 提取 + json["metadata"]["user_id"] + .as_str() + .map(|s| s.to_string()) + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()) + }; // 提取 display_id(用于存储日志) - let session_id = ProxySession::extract_display_id(&user_id) - .unwrap_or_else(|| user_id.clone()); + let session_id = ProxySession::extract_display_id(&full_session_id); let model = json["model"].as_str().map(|s| s.to_string()); let is_stream = json["stream"].as_bool().unwrap_or(false); - (user_id, session_id, model, is_stream) + (full_session_id, session_id, model, is_stream) } Err(_) => { let fallback_id = uuid::Uuid::new_v4().to_string(); - (fallback_id.clone(), fallback_id, None, false) + (fallback_id.clone(), fallback_id.clone(), None, false) } } } else { let fallback_id = uuid::Uuid::new_v4().to_string(); - (fallback_id.clone(), fallback_id, None, false) + (fallback_id.clone(), fallback_id.clone(), None, false) }; - // 查询会话级别的配置(优先级:会话 > 代理),使用完整 user_id 查询 + // 查询会话级别的配置(优先级:会话 > 代理),使用完整 session_id 查询 let (config_name, pricing_template_id) = - Self::resolve_session_config(&user_id, config_name, proxy_pricing_template_id); + Self::resolve_session_config(&full_session_id, config_name, proxy_pricing_template_id); Self { tool_id: tool_id.to_string(), diff --git a/src-tauri/src/services/proxy/log_recorder/recorder.rs b/src-tauri/src/services/proxy/log_recorder/recorder.rs index 57e2637..9079dd9 100644 --- a/src-tauri/src/services/proxy/log_recorder/recorder.rs +++ b/src-tauri/src/services/proxy/log_recorder/recorder.rs @@ -3,7 +3,8 @@ // 职责:统一的日志记录接口,处理成功/失败/解析错误等所有场景 use super::{ParsedResponse, RequestLogContext}; -use crate::services::token_stats::manager::{ResponseData, TokenStatsManager}; +use crate::services::token_stats::logger::create_logger; +use crate::services::token_stats::manager::TokenStatsManager; use anyhow::Result; use hyper::StatusCode; @@ -55,22 +56,19 @@ impl LogRecorder { context: &RequestLogContext, data_lines: Vec, ) -> Result<()> { + let logger = create_logger(&context.tool_id)?; let manager = TokenStatsManager::get(); - match manager - .log_request( - &context.tool_id, - &context.session_id, - &context.config_name, - &context.client_ip, - &context.request_body, - ResponseData::Sse(data_lines), - context.response_time_ms, - context.pricing_template_id.clone(), - ) - .await - { - Ok(_) => { + match logger.log_sse_response( + &context.request_body, + data_lines, + context.session_id.clone(), + context.config_name.clone(), + context.client_ip.clone(), + context.response_time_ms, + ) { + Ok(log) => { + manager.write_log(log); tracing::debug!( tool_id = %context.tool_id, session_id = %context.session_id, @@ -88,19 +86,17 @@ impl LogRecorder { // Token 提取失败,记录为 parse_error let error_detail = format!("SSE Token 提取失败: {}", e); - manager - .log_failed_request( - &context.tool_id, - &context.session_id, - &context.config_name, - &context.client_ip, - &context.request_body, - "parse_error", - &error_detail, - "sse", - context.response_time_ms, - ) - .await + let failed_log = logger.log_failed_request( + &context.request_body, + context.session_id.clone(), + context.config_name.clone(), + context.client_ip.clone(), + context.response_time_ms, + "parse_error".to_string(), + error_detail, + )?; + manager.write_log(failed_log); + Ok(()) } } } @@ -110,22 +106,19 @@ impl LogRecorder { context: &RequestLogContext, data: serde_json::Value, ) -> Result<()> { + let logger = create_logger(&context.tool_id)?; let manager = TokenStatsManager::get(); - match manager - .log_request( - &context.tool_id, - &context.session_id, - &context.config_name, - &context.client_ip, - &context.request_body, - ResponseData::Json(data), - context.response_time_ms, - context.pricing_template_id.clone(), - ) - .await - { - Ok(_) => { + match logger.log_json_response( + &context.request_body, + &data, + context.session_id.clone(), + context.config_name.clone(), + context.client_ip.clone(), + context.response_time_ms, + ) { + Ok(log) => { + manager.write_log(log); tracing::debug!( tool_id = %context.tool_id, session_id = %context.session_id, @@ -143,19 +136,17 @@ impl LogRecorder { // Token 提取失败,记录为 parse_error let error_detail = format!("JSON Token 提取失败: {}", e); - manager - .log_failed_request( - &context.tool_id, - &context.session_id, - &context.config_name, - &context.client_ip, - &context.request_body, - "parse_error", - &error_detail, - "json", - context.response_time_ms, - ) - .await + let failed_log = logger.log_failed_request( + &context.request_body, + context.session_id.clone(), + context.config_name.clone(), + context.client_ip.clone(), + context.response_time_ms, + "parse_error".to_string(), + error_detail, + )?; + manager.write_log(failed_log); + Ok(()) } } } @@ -176,19 +167,18 @@ impl LogRecorder { "响应解析失败" ); - TokenStatsManager::get() - .log_failed_request( - &context.tool_id, - &context.session_id, - &context.config_name, - &context.client_ip, - &context.request_body, - "parse_error", - &error_detail, - response_type, - context.response_time_ms, - ) - .await + let logger = create_logger(&context.tool_id)?; + let failed_log = logger.log_failed_request( + &context.request_body, + context.session_id.clone(), + context.config_name.clone(), + context.client_ip.clone(), + context.response_time_ms, + "parse_error".to_string(), + error_detail, + )?; + TokenStatsManager::get().write_log(failed_log); + Ok(()) } /// 记录上游错误(空响应或连接失败) @@ -201,21 +191,18 @@ impl LogRecorder { "上游请求失败" ); - let response_type = if context.is_stream { "sse" } else { "json" }; - - TokenStatsManager::get() - .log_failed_request( - &context.tool_id, - &context.session_id, - &context.config_name, - &context.client_ip, - &context.request_body, - "upstream_error", - detail, - response_type, // 根据请求体的 stream 字段判断 - context.response_time_ms, - ) - .await + let logger = create_logger(&context.tool_id)?; + let failed_log = logger.log_failed_request( + &context.request_body, + context.session_id.clone(), + context.config_name.clone(), + context.client_ip.clone(), + context.response_time_ms, + "upstream_error".to_string(), + detail.to_string(), + )?; + TokenStatsManager::get().write_log(failed_log); + Ok(()) } /// 记录 HTTP 错误(4xx/5xx) @@ -238,20 +225,17 @@ impl LogRecorder { "HTTP 错误响应" ); - let response_type = if context.is_stream { "sse" } else { "json" }; - - TokenStatsManager::get() - .log_failed_request( - &context.tool_id, - &context.session_id, - &context.config_name, - &context.client_ip, - &context.request_body, - "upstream_error", - &error_detail, - response_type, // 根据请求体的 stream 字段判断 - context.response_time_ms, - ) - .await + let logger = create_logger(&context.tool_id)?; + let failed_log = logger.log_failed_request( + &context.request_body, + context.session_id.clone(), + context.config_name.clone(), + context.client_ip.clone(), + context.response_time_ms, + "upstream_error".to_string(), + error_detail, + )?; + TokenStatsManager::get().write_log(failed_log); + Ok(()) } } diff --git a/src-tauri/src/services/proxy/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index b3f3cad..025c3a1 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -152,22 +152,23 @@ impl ProxyInstance { ); // 记录连接层错误到数据库(无 session_id) - let manager = - crate::services::token_stats::manager::TokenStatsManager::get(); let error_detail = format!("连接处理失败: {:?}", e); - let _ = manager - .log_failed_request( - &tool_id, - "connection_error", // 通用会话 ID - "global", - "unknown", // 无法获取客户端 IP - &[], // 无请求体 - "connection_error", - &error_detail, - "unknown", // 无法确定响应类型 - None, // 无响应时间 - ) - .await; + if let Ok(logger) = + crate::services::token_stats::logger::create_logger(&tool_id) + { + if let Ok(failed_log) = logger.log_failed_request( + &[], + "connection_error".to_string(), + "global".to_string(), + "unknown".to_string(), + None, + "connection_error".to_string(), + error_detail, + ) { + crate::services::token_stats::manager::TokenStatsManager::get() + .write_log(failed_log); + } + } } } } @@ -273,10 +274,12 @@ async fn handle_request_inner( }; // 验证本地 API Key + // 支持多种鉴权方式:authorization (Bearer), x-api-key, x-goog-api-key let auth_header = req .headers() .get("authorization") .or_else(|| req.headers().get("x-api-key")) + .or_else(|| req.headers().get("x-goog-api-key")) .and_then(|v| v.to_str().ok()) .unwrap_or(""); diff --git a/src-tauri/src/services/session/manager.rs b/src-tauri/src/services/session/manager.rs index cbc71d2..9ea55a7 100644 --- a/src-tauri/src/services/session/manager.rs +++ b/src-tauri/src/services/session/manager.rs @@ -151,26 +151,26 @@ impl SessionManager { timestamp, } => { // 提取 display_id - if let Some(display_id) = ProxySession::extract_display_id(&session_id) { - // Upsert 会话 - if let Ok(db) = manager.sqlite(db_path) { - if db - .execute( - "INSERT INTO claude_proxy_sessions ( - session_id, display_id, tool_id, config_name, url, api_key, - first_seen_at, last_seen_at, request_count, - created_at, updated_at - ) VALUES (?1, ?2, ?3, 'global', '', '', ?4, ?4, 1, ?4, ?4) - ON CONFLICT(session_id) DO UPDATE SET - last_seen_at = ?4, - request_count = request_count + 1, - updated_at = ?4", - &[&session_id, &display_id, &tool_id, ×tamp.to_string()], - ) - .is_ok() - { - has_writes = true; - } + let display_id = ProxySession::extract_display_id(&session_id); + + // Upsert 会话 + if let Ok(db) = manager.sqlite(db_path) { + if db + .execute( + "INSERT INTO claude_proxy_sessions ( + session_id, display_id, tool_id, config_name, url, api_key, + first_seen_at, last_seen_at, request_count, + created_at, updated_at + ) VALUES (?1, ?2, ?3, 'global', '', '', ?4, ?4, 1, ?4, ?4) + ON CONFLICT(session_id) DO UPDATE SET + last_seen_at = ?4, + request_count = request_count + 1, + updated_at = ?4", + &[&session_id, &display_id, &tool_id, ×tamp.to_string()], + ) + .is_ok() + { + has_writes = true; } } } diff --git a/src-tauri/src/services/session/models.rs b/src-tauri/src/services/session/models.rs index 5f06fce..90b6769 100644 --- a/src-tauri/src/services/session/models.rs +++ b/src-tauri/src/services/session/models.rs @@ -61,9 +61,20 @@ pub struct SessionListResponse { } impl ProxySession { - /// 从 user_id 提取 display_id(_session_ 后的 UUID 部分) - pub fn extract_display_id(user_id: &str) -> Option { - user_id.split("_session_").nth(1).map(|s| s.to_string()) + /// 从 session_id 提取 display_id + /// - Claude 格式:user_xxx_session_ → 提取 UUID + /// - Codex 格式:prompt_cache_key → 使用前 12 字符 + pub fn extract_display_id(session_id: &str) -> String { + // Claude 格式:提取 _session_ 后的 UUID + if let Some(uuid) = session_id.split("_session_").nth(1) { + return uuid.to_string(); + } + // Codex/其他格式:使用前 12 字符或完整 ID + if session_id.len() <= 12 { + session_id.to_string() + } else { + session_id[..12].to_string() + } } } @@ -72,19 +83,23 @@ mod tests { use super::*; #[test] - fn test_extract_display_id() { + fn test_extract_display_id_claude() { let user_id = "user_42a8ca527d882b0a9b60e27011856f2018295786e9c6dc09cefbb7e0caba49ab_account__session_f7aa73fc-73a9-4148-ba8b-1b9f4aa5ebc3"; let display_id = ProxySession::extract_display_id(user_id); - assert_eq!( - display_id, - Some("f7aa73fc-73a9-4148-ba8b-1b9f4aa5ebc3".to_string()) - ); + assert_eq!(display_id, "f7aa73fc-73a9-4148-ba8b-1b9f4aa5ebc3"); } #[test] - fn test_extract_display_id_no_session() { - let user_id = "user_42a8ca527d882b0a9b60e27011856f20"; - let display_id = ProxySession::extract_display_id(user_id); - assert_eq!(display_id, None); + fn test_extract_display_id_codex() { + let session_id = "abc123def456ghi789"; + let display_id = ProxySession::extract_display_id(session_id); + assert_eq!(display_id, "abc123def456"); + } + + #[test] + fn test_extract_display_id_short() { + let session_id = "short"; + let display_id = ProxySession::extract_display_id(session_id); + assert_eq!(display_id, "short"); } } diff --git a/src-tauri/src/services/token_stats/analytics.rs b/src-tauri/src/services/token_stats/analytics.rs index c5319ba..ed2d2cb 100644 --- a/src-tauri/src/services/token_stats/analytics.rs +++ b/src-tauri/src/services/token_stats/analytics.rs @@ -442,6 +442,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -451,6 +452,7 @@ mod tests { Some(0.002), Some(0.0001), Some(0.0002), + None, // reasoning_price 0.0033, Some("test_template".to_string()), ); @@ -504,6 +506,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -513,6 +516,7 @@ mod tests { Some(0.002), Some(0.0001), Some(0.0002), + None, // reasoning_price 0.0033, Some("test_template".to_string()), ); diff --git a/src-tauri/src/services/token_stats/cost_calculation_test.rs b/src-tauri/src/services/token_stats/cost_calculation_test.rs index fc33fff..98d2da2 100644 --- a/src-tauri/src/services/token_stats/cost_calculation_test.rs +++ b/src-tauri/src/services/token_stats/cost_calculation_test.rs @@ -5,7 +5,7 @@ #[cfg(test)] mod tests { use crate::services::pricing::PRICING_MANAGER; - use crate::services::token_stats::create_extractor; + use crate::services::token_stats::processor::create_processor; use serde_json::json; #[test] @@ -19,12 +19,14 @@ mod tests { // 使用默认模板计算成本 let result = PRICING_MANAGER.calculate_cost( - None, // 使用默认模板 + None, // 使用默认模板 + Some("claude-code"), // 工具 ID model, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, + 0, // reasoning_tokens ); // 验证计算成功 @@ -73,23 +75,48 @@ mod tests { // 测试不同模型的成本计算 // Claude Opus 4.5: $5 input / $25 output - let opus_result = PRICING_MANAGER.calculate_cost(None, "claude-opus-4.5", 1000, 500, 0, 0); + let opus_result = PRICING_MANAGER.calculate_cost( + None, + Some("claude-code"), + "claude-opus-4.5", + 1000, + 500, + 0, + 0, + 0, + ); assert!(opus_result.is_ok()); let opus_breakdown = opus_result.unwrap(); assert!((opus_breakdown.input_price - 0.005).abs() < 1e-9); // 1000 * 5 / 1M assert!((opus_breakdown.output_price - 0.0125).abs() < 1e-9); // 500 * 25 / 1M // Claude Sonnet 4.5: $3 input / $15 output - let sonnet_result = - PRICING_MANAGER.calculate_cost(None, "claude-sonnet-4.5", 1000, 500, 0, 0); + let sonnet_result = PRICING_MANAGER.calculate_cost( + None, + Some("claude-code"), + "claude-sonnet-4.5", + 1000, + 500, + 0, + 0, + 0, + ); assert!(sonnet_result.is_ok()); let sonnet_breakdown = sonnet_result.unwrap(); assert!((sonnet_breakdown.input_price - 0.003).abs() < 1e-9); // 1000 * 3 / 1M assert!((sonnet_breakdown.output_price - 0.0075).abs() < 1e-9); // 500 * 15 / 1M // Claude Haiku 3.5: $0.8 input / $4 output - let haiku_result = - PRICING_MANAGER.calculate_cost(None, "claude-haiku-3.5", 1000, 500, 0, 0); + let haiku_result = PRICING_MANAGER.calculate_cost( + None, + Some("claude-code"), + "claude-haiku-3.5", + 1000, + 500, + 0, + 0, + 0, + ); assert!(haiku_result.is_ok()); let haiku_breakdown = haiku_result.unwrap(); assert!((haiku_breakdown.input_price - 0.0008).abs() < 1e-9); // 1000 * 0.8 / 1M @@ -101,7 +128,12 @@ mod tests { #[test] fn test_token_extraction_from_response() { // 测试从响应中提取 Token 信息 - let extractor = create_extractor("claude_code").unwrap(); + let processor = create_processor("claude-code").unwrap(); + + let request_body = json!({ + "model": "claude-sonnet-4-5-20250929", + "messages": [] + }); let response_json = json!({ "id": "msg_test_123", @@ -114,7 +146,9 @@ mod tests { } }); - let token_info = extractor.extract_from_json(&response_json).unwrap(); + let token_info = processor + .process_json_response(&serde_json::to_vec(&request_body).unwrap(), &response_json) + .unwrap(); assert_eq!(token_info.input_tokens, 100); assert_eq!(token_info.output_tokens, 50); @@ -128,7 +162,12 @@ mod tests { #[test] fn test_end_to_end_cost_calculation() { // 端到端测试:从响应提取 Token -> 计算成本 - let extractor = create_extractor("claude_code").unwrap(); + let processor = create_processor("claude-code").unwrap(); + + let request_body = json!({ + "model": "claude-sonnet-4-5-20250929", + "messages": [] + }); let response_json = json!({ "id": "msg_end_to_end", @@ -142,16 +181,20 @@ mod tests { }); // 步骤1: 提取 Token - let token_info = extractor.extract_from_json(&response_json).unwrap(); + let token_info = processor + .process_json_response(&serde_json::to_vec(&request_body).unwrap(), &response_json) + .unwrap(); // 步骤2: 计算成本 let result = PRICING_MANAGER.calculate_cost( None, + Some("claude-code"), // 工具 ID "claude-sonnet-4-5-20250929", token_info.input_tokens, token_info.output_tokens, token_info.cache_creation_tokens, token_info.cache_read_tokens, + 0, // reasoning_tokens ); assert!(result.is_ok()); @@ -199,4 +242,99 @@ mod tests { ); println!(" 总成本: ${:.6}", breakdown.total_cost); } + + #[test] + fn test_codex_end_to_end_cost_calculation() { + // Codex 端到端测试:从响应提取 Token -> 计算成本(使用 builtin_openai 模板) + let processor = create_processor("codex").unwrap(); + + let request_body = json!({ + "model": "gpt-5.2-codex", + "messages": [] + }); + + // 模拟 Codex 响应:input_tokens 包含缓存的 token + let response_json = json!({ + "id": "resp_codex_test", + "model": "gpt-5.2-codex", + "usage": { + "input_tokens": 10000, // 总输入(包含缓存) + "input_tokens_details": { + "cached_tokens": 8000 // 缓存读取 + }, + "output_tokens": 500, + "output_tokens_details": { + "reasoning_tokens": 0 + } + } + }); + + // 步骤1: 提取 Token + let token_info = processor + .process_json_response(&serde_json::to_vec(&request_body).unwrap(), &response_json) + .unwrap(); + + // 验证 Token 提取正确(新输入 = 总输入 - 缓存) + assert_eq!(token_info.input_tokens, 2000); // 10000 - 8000 + assert_eq!(token_info.output_tokens, 500); + assert_eq!(token_info.cache_creation_tokens, 0); + assert_eq!(token_info.cache_read_tokens, 8000); + + // 步骤2: 计算成本(使用 builtin_openai 模板) + let result = PRICING_MANAGER.calculate_cost( + Some("builtin_openai"), // 使用 OpenAI 模板 + Some("codex"), // 工具 ID + "gpt-5.2-codex", + token_info.input_tokens, + token_info.output_tokens, + token_info.cache_creation_tokens, + token_info.cache_read_tokens, + 0, // reasoning_tokens + ); + + assert!(result.is_ok()); + let breakdown = result.unwrap(); + + // 验证使用了正确的模板 + assert_eq!(breakdown.template_id, "builtin_openai"); + + // 预期成本计算(gpt-5.2-codex: $3 input / $12 output / $1.5 cache read) + // input: 2000 * 3.0 / 1,000,000 = 0.006 + // output: 500 * 12.0 / 1,000,000 = 0.006 + // cache_write: 0(OpenAI 不收费) + // cache_read: 8000 * 1.5 / 1,000,000 = 0.012 + // total: 0.006 + 0.006 + 0 + 0.012 = 0.024 + + println!("Codex 端到端实际计算结果:"); + println!(" 输入价格: {:.10}", breakdown.input_price); + println!(" 输出价格: {:.10}", breakdown.output_price); + println!(" 缓存写入价格: {:.10}", breakdown.cache_write_price); + println!(" 缓存读取价格: {:.10}", breakdown.cache_read_price); + println!(" 总成本: {:.10}", breakdown.total_cost); + + assert!((breakdown.input_price - 0.006).abs() < 1e-9); + assert!((breakdown.output_price - 0.006).abs() < 1e-9); + assert!((breakdown.cache_write_price - 0.0).abs() < 1e-9); // OpenAI 不收费缓存创建 + assert!((breakdown.cache_read_price - 0.012).abs() < 1e-9); + assert!( + (breakdown.total_cost - 0.024).abs() < 1e-6, + "expected 0.024, got {}", + breakdown.total_cost + ); + + println!("✅ Codex 端到端成本计算测试通过"); + println!( + " 新输入: {} tokens -> ${:.6}", + token_info.input_tokens, breakdown.input_price + ); + println!( + " 输出: {} tokens -> ${:.6}", + token_info.output_tokens, breakdown.output_price + ); + println!( + " 缓存读取: {} tokens -> ${:.6}", + token_info.cache_read_tokens, breakdown.cache_read_price + ); + println!(" 总成本: ${:.6}", breakdown.total_cost); + } } diff --git a/src-tauri/src/services/token_stats/db.rs b/src-tauri/src/services/token_stats/db.rs index fb71864..6e47192 100644 --- a/src-tauri/src/services/token_stats/db.rs +++ b/src-tauri/src/services/token_stats/db.rs @@ -43,6 +43,7 @@ impl TokenStatsDb { output_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, -- 请求状态 request_status TEXT NOT NULL DEFAULT 'success', @@ -55,6 +56,7 @@ impl TokenStatsDb { output_price REAL, cache_write_price REAL, cache_read_price REAL, + reasoning_price REAL, -- 总成本(USD) total_cost REAL NOT NULL DEFAULT 0.0, @@ -121,6 +123,51 @@ impl TokenStatsDb { ) .context("Failed to create tool_model index")?; + // 数据库迁移:添加 reasoning_tokens 和 reasoning_price 字段(如果不存在) + // 这是为了兼容旧版本数据库 + self.migrate_add_reasoning_fields()?; + + Ok(()) + } + + /// 迁移:添加 reasoning_tokens 和 reasoning_price 字段 + fn migrate_add_reasoning_fields(&self) -> Result<()> { + let manager = DataManager::global() + .sqlite(&self.db_path) + .context("Failed to get SQLite manager for migration")?; + + // 检查 reasoning_tokens 字段是否存在 + let check_query = + "SELECT COUNT(*) FROM pragma_table_info('token_logs') WHERE name='reasoning_tokens'"; + let rows = manager + .query(check_query, &[]) + .context("Failed to check reasoning_tokens column")?; + + let exists = rows + .first() + .and_then(|row| row.values.first()) + .and_then(|v| v.as_i64()) + .unwrap_or(0) + > 0; + + if !exists { + eprintln!("Migrating database: adding reasoning_tokens and reasoning_price columns"); + + // 添加 reasoning_tokens 字段 + manager + .execute_raw( + "ALTER TABLE token_logs ADD COLUMN reasoning_tokens INTEGER NOT NULL DEFAULT 0", + ) + .context("Failed to add reasoning_tokens column")?; + + // 添加 reasoning_price 字段 + manager + .execute_raw("ALTER TABLE token_logs ADD COLUMN reasoning_price REAL") + .context("Failed to add reasoning_price column")?; + + eprintln!("Database migration completed successfully"); + } + Ok(()) } @@ -142,6 +189,7 @@ impl TokenStatsDb { log.output_tokens.to_string(), log.cache_creation_tokens.to_string(), log.cache_read_tokens.to_string(), + log.reasoning_tokens.to_string(), log.request_status.clone(), log.response_type.clone(), log.error_type.clone().unwrap_or_default(), @@ -157,6 +205,9 @@ impl TokenStatsDb { log.cache_read_price .map(|v| v.to_string()) .unwrap_or_default(), + log.reasoning_price + .map(|v| v.to_string()) + .unwrap_or_default(), log.total_cost.to_string(), log.pricing_template_id.clone().unwrap_or_default(), ]; @@ -168,11 +219,11 @@ impl TokenStatsDb { "INSERT INTO token_logs ( tool_type, timestamp, client_ip, session_id, config_name, model, message_id, input_tokens, output_tokens, - cache_creation_tokens, cache_read_tokens, + cache_creation_tokens, cache_read_tokens, reasoning_tokens, request_status, response_type, error_type, error_detail, - response_time_ms, input_price, output_price, cache_write_price, cache_read_price, + response_time_ms, input_price, output_price, cache_write_price, cache_read_price, reasoning_price, total_cost, pricing_template_id - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", ¶ms_refs, ) .context("Failed to insert token log")?; @@ -215,6 +266,7 @@ impl TokenStatsDb { log.output_tokens.to_string(), log.cache_creation_tokens.to_string(), log.cache_read_tokens.to_string(), + log.reasoning_tokens.to_string(), log.request_status.clone(), log.response_type.clone(), log.error_type.clone().unwrap_or_default(), @@ -230,6 +282,9 @@ impl TokenStatsDb { log.cache_read_price .map(|v| v.to_string()) .unwrap_or_default(), + log.reasoning_price + .map(|v| v.to_string()) + .unwrap_or_default(), log.total_cost.to_string(), log.pricing_template_id.clone().unwrap_or_default(), ]; @@ -241,11 +296,11 @@ impl TokenStatsDb { "INSERT INTO token_logs ( tool_type, timestamp, client_ip, session_id, config_name, model, message_id, input_tokens, output_tokens, - cache_creation_tokens, cache_read_tokens, + cache_creation_tokens, cache_read_tokens, reasoning_tokens, request_status, response_type, error_type, error_detail, - response_time_ms, input_price, output_price, cache_write_price, cache_read_price, + response_time_ms, input_price, output_price, cache_write_price, cache_read_price, reasoning_price, total_cost, pricing_template_id - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", ¶ms_refs, ) .context("Failed to insert token log")?; @@ -277,6 +332,7 @@ impl TokenStatsDb { COALESCE(SUM(output_tokens), 0) as total_output, COALESCE(SUM(cache_creation_tokens), 0) as total_cache_creation, COALESCE(SUM(cache_read_tokens), 0) as total_cache_read, + COALESCE(SUM(reasoning_tokens), 0) as total_reasoning, COUNT(*) as request_count FROM token_logs WHERE session_id = ?1 AND tool_type = ?2", @@ -291,7 +347,8 @@ impl TokenStatsDb { total_output: row.values.get(1).and_then(|v| v.as_i64()).unwrap_or(0), total_cache_creation: row.values.get(2).and_then(|v| v.as_i64()).unwrap_or(0), total_cache_read: row.values.get(3).and_then(|v| v.as_i64()).unwrap_or(0), - request_count: row.values.get(4).and_then(|v| v.as_i64()).unwrap_or(0), + total_reasoning: row.values.get(4).and_then(|v| v.as_i64()).unwrap_or(0), + request_count: row.values.get(5).and_then(|v| v.as_i64()).unwrap_or(0), }) } @@ -355,9 +412,9 @@ impl TokenStatsDb { let list_sql = format!( "SELECT id, tool_type, timestamp, client_ip, session_id, config_name, model, message_id, input_tokens, output_tokens, - cache_creation_tokens, cache_read_tokens, + cache_creation_tokens, cache_read_tokens, reasoning_tokens, request_status, response_type, error_type, error_detail, - response_time_ms, input_price, output_price, cache_write_price, cache_read_price, + response_time_ms, input_price, output_price, cache_write_price, cache_read_price, reasoning_price, total_cost, pricing_template_id FROM token_logs {} ORDER BY timestamp DESC @@ -416,37 +473,39 @@ impl TokenStatsDb { output_tokens: row.values.get(9).and_then(|v| v.as_i64()).unwrap_or(0), cache_creation_tokens: row.values.get(10).and_then(|v| v.as_i64()).unwrap_or(0), cache_read_tokens: row.values.get(11).and_then(|v| v.as_i64()).unwrap_or(0), + reasoning_tokens: row.values.get(12).and_then(|v| v.as_i64()).unwrap_or(0), request_status: row .values - .get(12) + .get(13) .and_then(|v| v.as_str()) .unwrap_or("success") .to_string(), response_type: row .values - .get(13) + .get(14) .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_string(), error_type: row .values - .get(14) + .get(15) .and_then(|v| v.as_str()) .map(String::from), error_detail: row .values - .get(15) + .get(16) .and_then(|v| v.as_str()) .map(String::from), - response_time_ms: row.values.get(16).and_then(|v| v.as_i64()), - input_price: row.values.get(17).and_then(|v| v.as_f64()), - output_price: row.values.get(18).and_then(|v| v.as_f64()), - cache_write_price: row.values.get(19).and_then(|v| v.as_f64()), - cache_read_price: row.values.get(20).and_then(|v| v.as_f64()), - total_cost: row.values.get(21).and_then(|v| v.as_f64()).unwrap_or(0.0), + response_time_ms: row.values.get(17).and_then(|v| v.as_i64()), + input_price: row.values.get(18).and_then(|v| v.as_f64()), + output_price: row.values.get(19).and_then(|v| v.as_f64()), + cache_write_price: row.values.get(20).and_then(|v| v.as_f64()), + cache_read_price: row.values.get(21).and_then(|v| v.as_f64()), + reasoning_price: row.values.get(22).and_then(|v| v.as_f64()), + total_cost: row.values.get(23).and_then(|v| v.as_f64()).unwrap_or(0.0), pricing_template_id: row .values - .get(22) + .get(24) .and_then(|v| v.as_str()) .map(String::from), }) @@ -613,6 +672,7 @@ mod tests { 500, 100, 200, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -622,6 +682,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); @@ -654,6 +715,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "sse".to_string(), None, @@ -663,6 +725,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); @@ -707,6 +770,7 @@ mod tests { 50, 0, 0, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -716,6 +780,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); @@ -733,6 +798,7 @@ mod tests { 100, 0, 0, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -742,6 +808,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); diff --git a/src-tauri/src/services/token_stats/extractor.rs b/src-tauri/src/services/token_stats/extractor.rs deleted file mode 100644 index 8531019..0000000 --- a/src-tauri/src/services/token_stats/extractor.rs +++ /dev/null @@ -1,532 +0,0 @@ -use anyhow::{Context, Result}; -use serde_json::Value; - -/// Token提取器统一接口 -pub trait TokenExtractor: Send + Sync { - /// 从请求体中提取模型名称 - fn extract_model_from_request(&self, body: &[u8]) -> Result; - - /// 从SSE数据块中提取Token信息 - fn extract_from_sse_chunk(&self, chunk: &str) -> Result>; - - /// 从JSON响应中提取Token信息 - fn extract_from_json(&self, json: &Value) -> Result; -} - -/// SSE流式数据中的Token信息 -#[derive(Debug, Clone, Default)] -pub struct SseTokenData { - /// message_start块数据 - pub message_start: Option, - /// message_delta块数据(end_turn) - pub message_delta: Option, -} - -/// message_start块数据 -#[derive(Debug, Clone)] -pub struct MessageStartData { - pub model: String, - pub message_id: String, - pub input_tokens: i64, - pub output_tokens: i64, - pub cache_creation_tokens: i64, - pub cache_read_tokens: i64, -} - -/// message_delta块数据(end_turn) -#[derive(Debug, Clone)] -pub struct MessageDeltaData { - pub cache_creation_tokens: i64, - pub cache_read_tokens: i64, - pub output_tokens: i64, -} - -/// 响应Token信息(完整) -#[derive(Debug, Clone)] -pub struct ResponseTokenInfo { - pub model: String, - pub message_id: String, - pub input_tokens: i64, - pub output_tokens: i64, - pub cache_creation_tokens: i64, - pub cache_read_tokens: i64, -} - -impl ResponseTokenInfo { - /// 从SSE数据合并得到完整信息 - /// - /// 合并规则: - /// - model, message_id, input_tokens: 始终使用 message_start 的值 - /// - output_tokens, cache_*: 优先使用 message_delta 的值,回退到 message_start - pub fn from_sse_data(start: MessageStartData, delta: Option) -> Self { - let (cache_creation, cache_read, output) = if let Some(d) = delta { - // 优先使用 delta 的值(最终统计) - ( - d.cache_creation_tokens, - d.cache_read_tokens, - d.output_tokens, - ) - } else { - // 回退到 start 的值(初始统计) - ( - start.cache_creation_tokens, - start.cache_read_tokens, - start.output_tokens, - ) - }; - - Self { - model: start.model, - message_id: start.message_id, - input_tokens: start.input_tokens, - output_tokens: output, - cache_creation_tokens: cache_creation, - cache_read_tokens: cache_read, - } - } -} - -/// Claude Code工具的Token提取器 -pub struct ClaudeTokenExtractor; - -impl TokenExtractor for ClaudeTokenExtractor { - fn extract_model_from_request(&self, body: &[u8]) -> Result { - let json: Value = - serde_json::from_slice(body).context("Failed to parse request body as JSON")?; - - json.get("model") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .context("Missing 'model' field in request body") - } - - fn extract_from_sse_chunk(&self, chunk: &str) -> Result> { - // SSE格式: data: {...} 或直接 {...}(已去掉前缀) - let data_line = chunk.trim(); - - // 跳过空行 - if data_line.is_empty() { - return Ok(None); - } - - // 兼容处理:去掉 "data: " 前缀(如果存在) - let json_str = if let Some(stripped) = data_line.strip_prefix("data: ") { - stripped - } else { - data_line - }; - - // 跳过 [DONE] 标记 - if json_str.trim() == "[DONE]" { - return Ok(None); - } - - let json: Value = - serde_json::from_str(json_str).context("Failed to parse SSE chunk as JSON")?; - - let event_type = json.get("type").and_then(|v| v.as_str()).unwrap_or(""); - - tracing::debug!(event_type = event_type, "解析 SSE 事件"); - - let mut result = SseTokenData::default(); - - match event_type { - "message_start" => { - if let Some(message) = json.get("message") { - let model = message - .get("model") - .and_then(|v| v.as_str()) - .context("Missing model in message_start")? - .to_string(); - - let message_id = message - .get("id") - .and_then(|v| v.as_str()) - .context("Missing id in message_start")? - .to_string(); - - let usage = message - .get("usage") - .context("Missing usage in message_start")?; - - let input_tokens = usage - .get("input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - let output_tokens = usage - .get("output_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - // 提取缓存创建 token:优先读取扁平字段,回退到嵌套对象 - let cache_creation_tokens = usage - .get("cache_creation_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or_else(|| { - if let Some(cache_obj) = usage.get("cache_creation") { - let ephemeral_5m = cache_obj - .get("ephemeral_5m_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let ephemeral_1h = cache_obj - .get("ephemeral_1h_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - ephemeral_5m + ephemeral_1h - } else { - 0 - } - }); - - let cache_read_tokens = usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - result.message_start = Some(MessageStartData { - model, - message_id, - input_tokens, - output_tokens, - cache_creation_tokens, - cache_read_tokens, - }); - } - } - "message_delta" => { - tracing::info!("检测到 message_delta 事件"); - - // message_delta 事件包含最终的usage统计 - // 条件:必须有 usage 字段(无论是否有 stop_reason) - if let Some(usage) = json.get("usage") { - tracing::info!("message_delta 包含 usage 字段"); - - // 提取缓存创建 token:优先读取扁平字段,回退到嵌套对象 - let cache_creation = usage - .get("cache_creation_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or_else(|| { - if let Some(cache_obj) = usage.get("cache_creation") { - let ephemeral_5m = cache_obj - .get("ephemeral_5m_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let ephemeral_1h = cache_obj - .get("ephemeral_1h_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - ephemeral_5m + ephemeral_1h - } else { - 0 - } - }); - - let cache_read = usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - let output_tokens = usage - .get("output_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - tracing::info!( - output_tokens = output_tokens, - cache_creation = cache_creation, - cache_read = cache_read, - "message_delta 提取成功" - ); - - result.message_delta = Some(MessageDeltaData { - cache_creation_tokens: cache_creation, - cache_read_tokens: cache_read, - output_tokens, - }); - } else { - tracing::warn!("message_delta 事件缺少 usage 字段"); - } - } - _ => {} - } - - Ok( - if result.message_start.is_some() || result.message_delta.is_some() { - Some(result) - } else { - None - }, - ) - } - - fn extract_from_json(&self, json: &Value) -> Result { - let model = json - .get("model") - .and_then(|v| v.as_str()) - .context("Missing model field")? - .to_string(); - - let message_id = json - .get("id") - .and_then(|v| v.as_str()) - .context("Missing id field")? - .to_string(); - - let usage = json.get("usage").context("Missing usage field")?; - - let input_tokens = usage - .get("input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - let output_tokens = usage - .get("output_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - // 提取 cache_creation_input_tokens: - // 优先读取扁平字段,如果不存在则尝试从嵌套对象聚合 - let cache_creation = usage - .get("cache_creation_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or_else(|| { - // 回退:尝试从嵌套的 cache_creation 对象聚合 - if let Some(cache_obj) = usage.get("cache_creation") { - let ephemeral_5m = cache_obj - .get("ephemeral_5m_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - let ephemeral_1h = cache_obj - .get("ephemeral_1h_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - ephemeral_5m + ephemeral_1h - } else { - 0 - } - }); - - let cache_read = usage - .get("cache_read_input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - Ok(ResponseTokenInfo { - model, - message_id, - input_tokens, - output_tokens, - cache_creation_tokens: cache_creation, - cache_read_tokens: cache_read, - }) - } -} - -/// 创建Token提取器工厂函数 -pub fn create_extractor(tool_type: &str) -> Result> { - // 支持破折号和下划线两种格式 - let normalized = tool_type.replace('-', "_"); - match normalized.as_str() { - "claude_code" => Ok(Box::new(ClaudeTokenExtractor)), - // 预留扩展点 - "codex" => anyhow::bail!("Codex token extractor not implemented yet"), - "gemini_cli" => anyhow::bail!("Gemini CLI token extractor not implemented yet"), - _ => anyhow::bail!("Unknown tool type: {}", tool_type), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_model_from_request() { - let extractor = ClaudeTokenExtractor; - let body = r#"{"model":"claude-sonnet-4-5-20250929","messages":[]}"#; - - let model = extractor - .extract_model_from_request(body.as_bytes()) - .unwrap(); - assert_eq!(model, "claude-sonnet-4-5-20250929"); - } - - #[test] - fn test_extract_from_sse_message_start() { - let extractor = ClaudeTokenExtractor; - let chunk = r#"data: {"type":"message_start","message":{"model":"claude-haiku-4-5-20251001","id":"msg_123","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":27592,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1}}}"#; - - let result = extractor.extract_from_sse_chunk(chunk).unwrap().unwrap(); - assert!(result.message_start.is_some()); - - let start = result.message_start.unwrap(); - assert_eq!(start.model, "claude-haiku-4-5-20251001"); - assert_eq!(start.message_id, "msg_123"); - assert_eq!(start.input_tokens, 27592); - assert_eq!(start.output_tokens, 1); - assert_eq!(start.cache_creation_tokens, 0); - assert_eq!(start.cache_read_tokens, 0); - } - - #[test] - fn test_extract_from_sse_message_delta() { - let extractor = ClaudeTokenExtractor; - let chunk = r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":27592,"cache_creation_input_tokens":100,"cache_read_input_tokens":200,"output_tokens":12}}"#; - - let result = extractor.extract_from_sse_chunk(chunk).unwrap().unwrap(); - assert!(result.message_delta.is_some()); - - let delta = result.message_delta.unwrap(); - assert_eq!(delta.cache_creation_tokens, 100); - assert_eq!(delta.cache_read_tokens, 200); - assert_eq!(delta.output_tokens, 12); - } - - #[test] - fn test_extract_from_json() { - let extractor = ClaudeTokenExtractor; - let json_str = r#"{ - "content": [{"text": "test", "type": "text"}], - "id": "msg_018K1Hs5Tm7sC7xdeYpYhUFN", - "model": "claude-haiku-4-5-20251001", - "role": "assistant", - "stop_reason": "end_turn", - "type": "message", - "usage": { - "cache_creation_input_tokens": 50, - "cache_read_input_tokens": 100, - "input_tokens": 119, - "output_tokens": 21 - } - }"#; - - let json: Value = serde_json::from_str(json_str).unwrap(); - let result = extractor.extract_from_json(&json).unwrap(); - - assert_eq!(result.model, "claude-haiku-4-5-20251001"); - assert_eq!(result.message_id, "msg_018K1Hs5Tm7sC7xdeYpYhUFN"); - assert_eq!(result.input_tokens, 119); - assert_eq!(result.output_tokens, 21); - assert_eq!(result.cache_creation_tokens, 50); - assert_eq!(result.cache_read_tokens, 100); - } - - #[test] - fn test_response_token_info_from_sse() { - let start = MessageStartData { - model: "claude-3".to_string(), - message_id: "msg_123".to_string(), - input_tokens: 1000, - output_tokens: 1, - cache_creation_tokens: 50, - cache_read_tokens: 100, - }; - - let delta = MessageDeltaData { - cache_creation_tokens: 50, - cache_read_tokens: 100, - output_tokens: 200, - }; - - let info = ResponseTokenInfo::from_sse_data(start, Some(delta)); - assert_eq!(info.model, "claude-3"); - assert_eq!(info.input_tokens, 1000); - assert_eq!(info.output_tokens, 200); - assert_eq!(info.cache_creation_tokens, 50); - assert_eq!(info.cache_read_tokens, 100); - } - - #[test] - fn test_create_extractor() { - assert!(create_extractor("claude_code").is_ok()); - assert!(create_extractor("codex").is_err()); - assert!(create_extractor("gemini_cli").is_err()); - assert!(create_extractor("unknown").is_err()); - } - - #[test] - fn test_extract_nested_cache_creation_json() { - // 测试嵌套 cache_creation 对象的提取(JSON 响应) - let extractor = ClaudeTokenExtractor; - let json_str = r#"{ - "id": "msg_013B8kRbTZdntKmHWE6AZzuU", - "model": "claude-sonnet-4-5-20250929", - "type": "message", - "role": "assistant", - "content": [{"type": "text", "text": "test"}], - "usage": { - "cache_creation": { - "ephemeral_1h_input_tokens": 0, - "ephemeral_5m_input_tokens": 73444 - }, - "cache_creation_input_tokens": 73444, - "cache_read_input_tokens": 19198, - "input_tokens": 12, - "output_tokens": 259, - "service_tier": "standard" - } - }"#; - - let json: Value = serde_json::from_str(json_str).unwrap(); - let result = extractor.extract_from_json(&json).unwrap(); - - assert_eq!(result.model, "claude-sonnet-4-5-20250929"); - assert_eq!(result.message_id, "msg_013B8kRbTZdntKmHWE6AZzuU"); - assert_eq!(result.input_tokens, 12); - assert_eq!(result.output_tokens, 259); - assert_eq!(result.cache_creation_tokens, 73444); - assert_eq!(result.cache_read_tokens, 19198); - } - - #[test] - fn test_extract_nested_cache_creation_sse_start() { - // 测试嵌套 cache_creation 对象的提取(SSE message_start) - let extractor = ClaudeTokenExtractor; - let chunk = r#"data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_018GWR1gBaJBchrC6t5nnRui","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":9,"cache_creation_input_tokens":2122,"cache_read_input_tokens":123663,"cache_creation":{"ephemeral_5m_input_tokens":2122,"ephemeral_1h_input_tokens":0},"output_tokens":1,"service_tier":"standard"}}}"#; - - let result = extractor.extract_from_sse_chunk(chunk).unwrap().unwrap(); - assert!(result.message_start.is_some()); - - let start = result.message_start.unwrap(); - assert_eq!(start.model, "claude-sonnet-4-5-20250929"); - assert_eq!(start.message_id, "msg_018GWR1gBaJBchrC6t5nnRui"); - assert_eq!(start.input_tokens, 9); - assert_eq!(start.output_tokens, 1); - assert_eq!(start.cache_creation_tokens, 2122); - assert_eq!(start.cache_read_tokens, 123663); - } - - #[test] - fn test_extract_message_delta_with_tool_use() { - // 测试 stop_reason="tool_use" 的情况 - let extractor = ClaudeTokenExtractor; - let chunk = r#"data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":9,"cache_creation_input_tokens":2122,"cache_read_input_tokens":123663,"output_tokens":566}}"#; - - let result = extractor.extract_from_sse_chunk(chunk).unwrap().unwrap(); - assert!(result.message_delta.is_some()); - - let delta = result.message_delta.unwrap(); - assert_eq!(delta.cache_creation_tokens, 2122); - assert_eq!(delta.cache_read_tokens, 123663); - assert_eq!(delta.output_tokens, 566); - } - - #[test] - fn test_from_sse_data_without_delta() { - // 测试没有 delta 时使用 start 的缓存值 - let start = MessageStartData { - model: "claude-3".to_string(), - message_id: "msg_test".to_string(), - input_tokens: 100, - output_tokens: 50, - cache_creation_tokens: 200, - cache_read_tokens: 300, - }; - - let info = ResponseTokenInfo::from_sse_data(start, None); - assert_eq!(info.input_tokens, 100); - assert_eq!(info.output_tokens, 50); - assert_eq!(info.cache_creation_tokens, 200); - assert_eq!(info.cache_read_tokens, 300); - } -} diff --git a/src-tauri/src/services/token_stats/logger/claude.rs b/src-tauri/src/services/token_stats/logger/claude.rs new file mode 100644 index 0000000..83b5b98 --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/claude.rs @@ -0,0 +1,289 @@ +//! Claude Code 工具的日志记录器 + +use super::{LogStatus, ResponseType, TokenLogger}; +use crate::models::token_stats::TokenLog; +use crate::services::pricing::PRICING_MANAGER; +use crate::services::token_stats::processor::{create_processor, TokenInfo}; +use anyhow::Result; +use chrono::Utc; + +/// Claude Code 日志记录器 +pub struct ClaudeLogger; + +impl ClaudeLogger { + /// 从 TokenInfo 构建 TokenLog + #[allow(clippy::too_many_arguments)] + fn build_log( + &self, + token_info: TokenInfo, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + response_type: ResponseType, + status: LogStatus, + ) -> Result { + // 计算成本 + let cost_result = PRICING_MANAGER.calculate_cost( + None, // 使用默认模板 + Some("claude-code"), // 工具 ID + &token_info.model, + token_info.input_tokens, + token_info.output_tokens, + token_info.cache_creation_tokens, + token_info.cache_read_tokens, + token_info.reasoning_tokens, + ); + + let ( + input_price, + output_price, + cache_write_price, + cache_read_price, + reasoning_price, + total_cost, + template_id, + ) = match cost_result { + Ok(breakdown) => ( + Some(breakdown.input_price), + Some(breakdown.output_price), + Some(breakdown.cache_write_price), + Some(breakdown.cache_read_price), + Some(breakdown.reasoning_price), + breakdown.total_cost, + Some(breakdown.template_id), + ), + Err(e) => { + tracing::warn!("Failed to calculate cost: {}", e); + (None, None, None, None, None, 0.0, None) + } + }; + + Ok(TokenLog::new( + self.tool_id().to_string(), + Utc::now().timestamp_millis(), + client_ip, + session_id, + config_name, + token_info.model, + Some(token_info.message_id), + token_info.input_tokens, + token_info.output_tokens, + token_info.cache_creation_tokens, + token_info.cache_read_tokens, + token_info.reasoning_tokens, + status.as_str().to_string(), + response_type.as_str().to_string(), + None, // error_type + None, // error_detail + response_time_ms, + input_price, + output_price, + cache_write_price, + cache_read_price, + reasoning_price, + total_cost, + template_id, + )) + } +} + +impl TokenLogger for ClaudeLogger { + fn tool_id(&self) -> &str { + "claude-code" + } + + fn log_sse_response( + &self, + request_body: &[u8], + sse_chunks: Vec, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + ) -> Result { + // 使用 processor 提取 TokenInfo + let processor = create_processor("claude-code")?; + let token_info = processor.process_sse_response(request_body, sse_chunks)?; + + // 构建日志(成功状态) + self.build_log( + token_info, + session_id, + config_name, + client_ip, + response_time_ms, + ResponseType::Sse, + LogStatus::Success, + ) + } + + fn log_json_response( + &self, + request_body: &[u8], + json: &serde_json::Value, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + ) -> Result { + // 使用 processor 提取 TokenInfo + let processor = create_processor("claude-code")?; + let token_info = processor.process_json_response(request_body, json)?; + + // 构建日志(成功状态) + self.build_log( + token_info, + session_id, + config_name, + client_ip, + response_time_ms, + ResponseType::Json, + LogStatus::Success, + ) + } + + fn log_failed_request( + &self, + request_body: &[u8], + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + error_type: String, + error_detail: String, + ) -> Result { + // 尝试从请求体提取 model + let model = serde_json::from_slice::(request_body) + .ok() + .and_then(|req| { + req.get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(TokenLog::new( + self.tool_id().to_string(), + Utc::now().timestamp_millis(), + client_ip, + session_id, + config_name, + model, + None, // message_id + 0, // input_tokens + 0, // output_tokens + 0, // cache_creation_tokens + 0, // cache_read_tokens + 0, // reasoning_tokens + LogStatus::Failed.as_str().to_string(), + ResponseType::Unknown.as_str().to_string(), + Some(error_type), + Some(error_detail), + response_time_ms, + None, // input_price + None, // output_price + None, // cache_write_price + None, // cache_read_price + None, // reasoning_price + 0.0, // total_cost + None, // pricing_template_id + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_sse_response() { + let logger = ClaudeLogger; + let request_body = r#"{"model":"claude-sonnet-4-5-20250929","messages":[]}"#; + let sse_chunks = vec![ + r#"data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_123","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":200,"output_tokens":1}}}"#.to_string(), + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":500}}"#.to_string(), + ]; + + let log = logger + .log_sse_response( + request_body.as_bytes(), + sse_chunks, + "session_123".to_string(), + "default".to_string(), + "127.0.0.1".to_string(), + Some(100), + ) + .unwrap(); + + assert_eq!(log.tool_type, "claude-code"); + assert_eq!(log.model, "claude-sonnet-4-5-20250929"); + assert_eq!(log.message_id, Some("msg_123".to_string())); + assert_eq!(log.input_tokens, 1000); + assert_eq!(log.output_tokens, 500); + assert_eq!(log.request_status, "success"); + assert_eq!(log.response_type, "sse"); + assert!(log.total_cost > 0.0); + } + + #[test] + fn test_log_json_response() { + let logger = ClaudeLogger; + let request_body = r#"{"model":"claude-sonnet-4-5-20250929","messages":[]}"#; + let json_str = r#"{ + "id": "msg_456", + "model": "claude-sonnet-4-5-20250929", + "usage": { + "input_tokens": 500, + "output_tokens": 300, + "cache_creation_input_tokens": 50, + "cache_read_input_tokens": 100 + } + }"#; + let json: serde_json::Value = serde_json::from_str(json_str).unwrap(); + + let log = logger + .log_json_response( + request_body.as_bytes(), + &json, + "session_456".to_string(), + "custom".to_string(), + "192.168.1.1".to_string(), + Some(200), + ) + .unwrap(); + + assert_eq!(log.tool_type, "claude-code"); + assert_eq!(log.model, "claude-sonnet-4-5-20250929"); + assert_eq!(log.input_tokens, 500); + assert_eq!(log.output_tokens, 300); + assert_eq!(log.request_status, "success"); + assert_eq!(log.response_type, "json"); + } + + #[test] + fn test_log_failed_request() { + let logger = ClaudeLogger; + let request_body = r#"{"model":"claude-sonnet-4-5-20250929","messages":[]}"#; + + let log = logger + .log_failed_request( + request_body.as_bytes(), + "session_789".to_string(), + "default".to_string(), + "127.0.0.1".to_string(), + Some(50), + "network_error".to_string(), + "Connection timeout".to_string(), + ) + .unwrap(); + + assert_eq!(log.tool_type, "claude-code"); + assert_eq!(log.model, "claude-sonnet-4-5-20250929"); + assert_eq!(log.request_status, "failed"); + assert_eq!(log.response_type, "unknown"); + assert_eq!(log.error_type, Some("network_error".to_string())); + assert_eq!(log.error_detail, Some("Connection timeout".to_string())); + assert_eq!(log.total_cost, 0.0); + } +} diff --git a/src-tauri/src/services/token_stats/logger/codex.rs b/src-tauri/src/services/token_stats/logger/codex.rs new file mode 100644 index 0000000..61caa12 --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/codex.rs @@ -0,0 +1,289 @@ +//! Codex 工具的日志记录器 + +use super::{LogStatus, ResponseType, TokenLogger}; +use crate::models::token_stats::TokenLog; +use crate::services::pricing::PRICING_MANAGER; +use crate::services::token_stats::processor::{create_processor, TokenInfo}; +use anyhow::Result; +use chrono::Utc; + +/// Codex 日志记录器 +pub struct CodexLogger; + +impl CodexLogger { + /// 从 TokenInfo 构建 TokenLog + #[allow(clippy::too_many_arguments)] + fn build_log( + &self, + token_info: TokenInfo, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + response_type: ResponseType, + status: LogStatus, + ) -> Result { + // 计算成本 + let cost_result = PRICING_MANAGER.calculate_cost( + None, // 使用默认模板 + Some("codex"), // 工具 ID + &token_info.model, + token_info.input_tokens, + token_info.output_tokens, + token_info.cache_creation_tokens, + token_info.cache_read_tokens, + token_info.reasoning_tokens, + ); + + let ( + input_price, + output_price, + cache_write_price, + cache_read_price, + reasoning_price, + total_cost, + template_id, + ) = match cost_result { + Ok(breakdown) => ( + Some(breakdown.input_price), + Some(breakdown.output_price), + Some(breakdown.cache_write_price), + Some(breakdown.cache_read_price), + Some(breakdown.reasoning_price), + breakdown.total_cost, + Some(breakdown.template_id), + ), + Err(e) => { + tracing::warn!("Failed to calculate cost: {}", e); + (None, None, None, None, None, 0.0, None) + } + }; + + Ok(TokenLog::new( + self.tool_id().to_string(), + Utc::now().timestamp_millis(), + client_ip, + session_id, + config_name, + token_info.model, + Some(token_info.message_id), + token_info.input_tokens, + token_info.output_tokens, + token_info.cache_creation_tokens, + token_info.cache_read_tokens, + token_info.reasoning_tokens, + status.as_str().to_string(), + response_type.as_str().to_string(), + None, // error_type + None, // error_detail + response_time_ms, + input_price, + output_price, + cache_write_price, + cache_read_price, + reasoning_price, + total_cost, + template_id, + )) + } +} + +impl TokenLogger for CodexLogger { + fn tool_id(&self) -> &str { + "codex" + } + + fn log_sse_response( + &self, + request_body: &[u8], + sse_chunks: Vec, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + ) -> Result { + // 使用 processor 提取 TokenInfo + let processor = create_processor("codex")?; + let token_info = processor.process_sse_response(request_body, sse_chunks)?; + + // 构建日志(成功状态) + self.build_log( + token_info, + session_id, + config_name, + client_ip, + response_time_ms, + ResponseType::Sse, + LogStatus::Success, + ) + } + + fn log_json_response( + &self, + request_body: &[u8], + json: &serde_json::Value, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + ) -> Result { + // 使用 processor 提取 TokenInfo + let processor = create_processor("codex")?; + let token_info = processor.process_json_response(request_body, json)?; + + // 构建日志(成功状态) + self.build_log( + token_info, + session_id, + config_name, + client_ip, + response_time_ms, + ResponseType::Json, + LogStatus::Success, + ) + } + + fn log_failed_request( + &self, + request_body: &[u8], + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + error_type: String, + error_detail: String, + ) -> Result { + // 尝试从请求体提取 model + let model = serde_json::from_slice::(request_body) + .ok() + .and_then(|req| { + req.get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()); + + Ok(TokenLog::new( + self.tool_id().to_string(), + Utc::now().timestamp_millis(), + client_ip, + session_id, + config_name, + model, + None, // message_id + 0, // input_tokens + 0, // output_tokens + 0, // cache_creation_tokens + 0, // cache_read_tokens + 0, // reasoning_tokens + LogStatus::Failed.as_str().to_string(), + ResponseType::Unknown.as_str().to_string(), + Some(error_type), + Some(error_detail), + response_time_ms, + None, // input_price + None, // output_price + None, // cache_write_price + None, // cache_read_price + None, // reasoning_price + 0.0, // total_cost + None, // pricing_template_id + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_sse_response() { + let logger = CodexLogger; + let request_body = r#"{"model":"gpt-5.1","messages":[]}"#; + let sse_chunks = vec![ + r#"{"type":"response.created","response":{"id":"resp_abc123"}}"#.to_string(), + r#"{"type":"response.completed","response":{"id":"resp_abc123","usage":{"input_tokens":10591,"input_tokens_details":{"cached_tokens":10240},"output_tokens":15,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":10606}}}"#.to_string(), + ]; + + let log = logger + .log_sse_response( + request_body.as_bytes(), + sse_chunks, + "session_123".to_string(), + "default".to_string(), + "127.0.0.1".to_string(), + Some(100), + ) + .unwrap(); + + assert_eq!(log.tool_type, "codex"); + assert_eq!(log.model, "gpt-5.1"); + assert_eq!(log.message_id, Some("resp_abc123".to_string())); + assert_eq!(log.input_tokens, 351); // 10591 - 10240(总输入 - 缓存) + assert_eq!(log.output_tokens, 15); + assert_eq!(log.cache_read_tokens, 10240); + assert_eq!(log.request_status, "success"); + assert_eq!(log.response_type, "sse"); + } + + #[test] + fn test_log_json_response() { + let logger = CodexLogger; + let request_body = r#"{"model":"gpt-4","messages":[]}"#; + let json_str = r#"{ + "id": "resp_test123", + "model": "gpt-4", + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 50}, + "output_tokens": 20 + } + }"#; + let json: serde_json::Value = serde_json::from_str(json_str).unwrap(); + + let log = logger + .log_json_response( + request_body.as_bytes(), + &json, + "session_456".to_string(), + "custom".to_string(), + "192.168.1.1".to_string(), + Some(200), + ) + .unwrap(); + + assert_eq!(log.tool_type, "codex"); + assert_eq!(log.model, "gpt-4"); + assert_eq!(log.input_tokens, 50); // 100 - 50(总输入 - 缓存) + assert_eq!(log.cache_read_tokens, 50); + assert_eq!(log.output_tokens, 20); + assert_eq!(log.request_status, "success"); + assert_eq!(log.response_type, "json"); + } + + #[test] + fn test_log_failed_request() { + let logger = CodexLogger; + let request_body = r#"{"model":"gpt-3.5","messages":[]}"#; + + let log = logger + .log_failed_request( + request_body.as_bytes(), + "session_789".to_string(), + "default".to_string(), + "127.0.0.1".to_string(), + Some(50), + "api_error".to_string(), + "Rate limit exceeded".to_string(), + ) + .unwrap(); + + assert_eq!(log.tool_type, "codex"); + assert_eq!(log.model, "gpt-3.5"); + assert_eq!(log.request_status, "failed"); + assert_eq!(log.response_type, "unknown"); + assert_eq!(log.error_type, Some("api_error".to_string())); + assert_eq!(log.error_detail, Some("Rate limit exceeded".to_string())); + assert_eq!(log.total_cost, 0.0); + } +} diff --git a/src-tauri/src/services/token_stats/logger/mod.rs b/src-tauri/src/services/token_stats/logger/mod.rs new file mode 100644 index 0000000..fce1fcf --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/mod.rs @@ -0,0 +1,104 @@ +//! Token 日志记录器模块 +//! +//! 负责将 Token 信息记录到日志,各工具独立实现 + +mod claude; +mod codex; +mod types; + +pub use claude::ClaudeLogger; +pub use codex::CodexLogger; +pub use types::{LogStatus, ResponseType}; + +use crate::models::token_stats::TokenLog; +use anyhow::{anyhow, Result}; + +/// 工具日志记录器 - 负责将 Token 信息记录到日志 +pub trait TokenLogger: Send + Sync { + /// 工具 ID + fn tool_id(&self) -> &str; + + /// 记录 SSE 响应日志 + /// + /// # 参数 + /// - `request_body`: 请求体(用于提取 model) + /// - `sse_chunks`: SSE 数据行(Vec) + /// - `session_id`: 会话 ID + /// - `config_name`: 配置名称 + /// - `client_ip`: 客户端 IP + /// - `response_time_ms`: 响应时间(毫秒) + /// + /// # 返回 + /// - TokenLog: 日志记录对象 + fn log_sse_response( + &self, + request_body: &[u8], + sse_chunks: Vec, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + ) -> Result; + + /// 记录 JSON 响应日志 + /// + /// # 参数 + /// - `request_body`: 请求体(用于提取 model,如果响应中没有) + /// - `json`: JSON 响应体 + /// - `session_id`: 会话 ID + /// - `config_name`: 配置名称 + /// - `client_ip`: 客户端 IP + /// - `response_time_ms`: 响应时间(毫秒) + /// + /// # 返回 + /// - TokenLog: 日志记录对象 + fn log_json_response( + &self, + request_body: &[u8], + json: &serde_json::Value, + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + ) -> Result; + + /// 记录失败请求日志 + /// + /// # 参数 + /// - `request_body`: 请求体(用于提取 model) + /// - `session_id`: 会话 ID + /// - `config_name`: 配置名称 + /// - `client_ip`: 客户端 IP + /// - `response_time_ms`: 响应时间(毫秒) + /// - `error_type`: 错误类型(如 "network_error", "api_error") + /// - `error_detail`: 错误详情 + /// + /// # 返回 + /// - TokenLog: 日志记录对象 + #[allow(clippy::too_many_arguments)] + fn log_failed_request( + &self, + request_body: &[u8], + session_id: String, + config_name: String, + client_ip: String, + response_time_ms: Option, + error_type: String, + error_detail: String, + ) -> Result; +} + +/// 创建工具日志记录器 +/// +/// # 参数 +/// - `tool_id`: 工具标识(claude-code/codex) +/// +/// # 返回 +/// - Box: 对应的日志记录器实例 +pub fn create_logger(tool_id: &str) -> Result> { + match tool_id { + "claude-code" => Ok(Box::new(ClaudeLogger)), + "codex" => Ok(Box::new(CodexLogger)), + _ => Err(anyhow!("Unsupported tool: {}", tool_id)), + } +} diff --git a/src-tauri/src/services/token_stats/logger/types.rs b/src-tauri/src/services/token_stats/logger/types.rs new file mode 100644 index 0000000..4f188e4 --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/types.rs @@ -0,0 +1,106 @@ +//! Logger 类型定义 +//! +//! 定义日志记录中使用的枚举类型 + +use serde::{Deserialize, Serialize}; + +/// 日志状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LogStatus { + /// 成功 + Success, + /// 失败 + Failed, + /// 部分成功(已提取部分 Token 信息) + Partial, +} + +impl LogStatus { + /// 转换为字符串(用于数据库存储) + pub fn as_str(&self) -> &'static str { + match self { + LogStatus::Success => "success", + LogStatus::Failed => "failed", + LogStatus::Partial => "partial", + } + } + + /// 从字符串解析 + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + match s { + "success" => LogStatus::Success, + "failed" => LogStatus::Failed, + "partial" => LogStatus::Partial, + _ => LogStatus::Failed, + } + } +} + +/// 响应类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ResponseType { + /// SSE 流式响应 + Sse, + /// JSON 响应 + Json, + /// 未知类型 + Unknown, +} + +impl ResponseType { + /// 转换为字符串(用于数据库存储) + pub fn as_str(&self) -> &'static str { + match self { + ResponseType::Sse => "sse", + ResponseType::Json => "json", + ResponseType::Unknown => "unknown", + } + } + + /// 从字符串解析 + #[allow(clippy::should_implement_trait)] + pub fn from_str(s: &str) -> Self { + match s { + "sse" => ResponseType::Sse, + "json" => ResponseType::Json, + _ => ResponseType::Unknown, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_log_status_as_str() { + assert_eq!(LogStatus::Success.as_str(), "success"); + assert_eq!(LogStatus::Failed.as_str(), "failed"); + assert_eq!(LogStatus::Partial.as_str(), "partial"); + } + + #[test] + fn test_log_status_from_str() { + assert_eq!(LogStatus::from_str("success"), LogStatus::Success); + assert_eq!(LogStatus::from_str("failed"), LogStatus::Failed); + assert_eq!(LogStatus::from_str("partial"), LogStatus::Partial); + assert_eq!(LogStatus::from_str("unknown"), LogStatus::Failed); // 回退 + } + + #[test] + fn test_response_type_as_str() { + assert_eq!(ResponseType::Sse.as_str(), "sse"); + assert_eq!(ResponseType::Json.as_str(), "json"); + assert_eq!(ResponseType::Unknown.as_str(), "unknown"); + } + + #[test] + fn test_response_type_from_str() { + assert_eq!(ResponseType::from_str("sse"), ResponseType::Sse); + assert_eq!(ResponseType::from_str("json"), ResponseType::Json); + assert_eq!(ResponseType::from_str("xyz"), ResponseType::Unknown); // 回退 + } +} diff --git a/src-tauri/src/services/token_stats/manager.rs b/src-tauri/src/services/token_stats/manager.rs index f918a9e..433bfe6 100644 --- a/src-tauri/src/services/token_stats/manager.rs +++ b/src-tauri/src/services/token_stats/manager.rs @@ -1,13 +1,8 @@ use crate::models::token_stats::{SessionStats, TokenLog, TokenLogsPage, TokenStatsQuery}; -use crate::services::pricing::PRICING_MANAGER; use crate::services::token_stats::db::TokenStatsDb; -use crate::services::token_stats::extractor::{ - create_extractor, MessageDeltaData, MessageStartData, ResponseTokenInfo, -}; use crate::utils::config_dir; -use anyhow::{Context, Result}; +use anyhow::Result; use once_cell::sync::OnceCell; -use serde_json::Value; use std::path::PathBuf; use tokio::sync::mpsc; use tokio::time::{interval, Duration}; @@ -20,15 +15,9 @@ static TOKEN_STATS_MANAGER: OnceCell = OnceCell::new(); static CANCELLATION_TOKEN: once_cell::sync::Lazy = once_cell::sync::Lazy::new(CancellationToken::new); -/// 响应数据类型 -pub enum ResponseData { - /// SSE流式响应(收集的所有data块) - Sse(Vec), - /// JSON响应 - Json(Value), -} - -/// Token统计管理器 +/// Token统计管理器(简化版) +/// +/// 职责:仅负责将 TokenLog 写入数据库,不再负责提取 Token 信息和计算成本 pub struct TokenStatsManager { db: TokenStatsDb, event_sender: mpsc::UnboundedSender, @@ -152,308 +141,17 @@ impl TokenStatsManager { } } - /// 记录请求日志 + /// 写入日志(新架构) /// - /// # 参数 + /// 直接写入已经构建好的 TokenLog 到队列 /// - /// - `tool_type`: 工具类型(claude_code/codex/gemini_cli) - /// - `session_id`: 会话ID - /// - `config_name`: 使用的配置名称 - /// - `client_ip`: 客户端IP地址 - /// - `request_body`: 请求体(用于提取model) - /// - `response_data`: 响应数据(SSE流或JSON) - /// - `response_time_ms`: 响应时间(毫秒) - /// - `pricing_template_id`: 价格模板ID(None则使用默认模板) - #[allow(clippy::too_many_arguments)] - pub async fn log_request( - &self, - tool_type: &str, - session_id: &str, - config_name: &str, - client_ip: &str, - request_body: &[u8], - response_data: ResponseData, - response_time_ms: Option, - pricing_template_id: Option, - ) -> Result<()> { - // 创建提取器 - let extractor = create_extractor(tool_type).context("Failed to create token extractor")?; - - // 提取请求中的模型名称 - let model = extractor - .extract_model_from_request(request_body) - .context("Failed to extract model from request")?; - - // 确定响应类型 - let response_type = match &response_data { - ResponseData::Sse(_) => "sse", - ResponseData::Json(_) => "json", - }; - - // 提取响应中的Token信息 - let token_info = match response_data { - ResponseData::Sse(chunks) => self.parse_sse_chunks(&*extractor, chunks)?, - ResponseData::Json(json) => extractor.extract_from_json(&json)?, - }; - - // 使用价格模板计算成本 - let template_id_ref = pricing_template_id.as_deref(); - - let ( - final_input_price, - final_output_price, - final_cache_write_price, - final_cache_read_price, - final_total_cost, - final_pricing_template_id, - ) = match PRICING_MANAGER.calculate_cost( - template_id_ref, - &model, - token_info.input_tokens, - token_info.output_tokens, - token_info.cache_creation_tokens, - token_info.cache_read_tokens, - ) { - Ok(breakdown) => { - tracing::debug!( - model = %model, - template_id = %breakdown.template_id, - total_cost = breakdown.total_cost, - input_tokens = token_info.input_tokens, - output_tokens = token_info.output_tokens, - "成本计算成功" - ); - ( - Some(breakdown.input_price), - Some(breakdown.output_price), - Some(breakdown.cache_write_price), - Some(breakdown.cache_read_price), - breakdown.total_cost, - Some(breakdown.template_id), - ) - } - Err(e) => { - // 计算失败,使用 0 - tracing::warn!( - model = %model, - template_id = ?template_id_ref, - error = ?e, - "成本计算失败,使用默认值 0" - ); - (None, None, None, None, 0.0, None) - } - }; - - // 创建日志记录(成功) - let timestamp = chrono::Utc::now().timestamp_millis(); - let log = TokenLog::new( - tool_type.to_string(), - timestamp, - client_ip.to_string(), - session_id.to_string(), - config_name.to_string(), - model, - Some(token_info.message_id), - token_info.input_tokens, - token_info.output_tokens, - token_info.cache_creation_tokens, - token_info.cache_read_tokens, - "success".to_string(), - response_type.to_string(), - None, - None, - response_time_ms, - final_input_price, - final_output_price, - final_cache_write_price, - final_cache_read_price, - final_total_cost, - final_pricing_template_id, - ); - + /// # 参数 + /// - `log`: 已经构建好的 TokenLog 对象 + pub fn write_log(&self, log: TokenLog) { // 发送到批量写入队列(异步,不阻塞) if let Err(e) = self.event_sender.send(log) { tracing::error!("发送 Token 日志事件失败: {}", e); } - - Ok(()) - } - - /// 记录失败的请求 - /// - /// # 参数 - /// - /// - `tool_type`: 工具类型 - /// - `session_id`: 会话ID - /// - `config_name`: 配置名称 - /// - `client_ip`: 客户端IP - /// - `request_body`: 请求体(用于提取model,失败时可能为空) - /// - `error_type`: 错误类型(parse_error/request_interrupted/upstream_error) - /// - `error_detail`: 错误详情 - /// - `response_type`: 响应类型(sse/json/unknown) - /// - `response_time_ms`: 响应时间(毫秒) - #[allow(clippy::too_many_arguments)] - pub async fn log_failed_request( - &self, - tool_type: &str, - session_id: &str, - config_name: &str, - client_ip: &str, - request_body: &[u8], - error_type: &str, - error_detail: &str, - response_type: &str, - response_time_ms: Option, - ) -> Result<()> { - // 尝试提取模型名称(失败时使用 "unknown") - let model = if !request_body.is_empty() { - create_extractor(tool_type) - .and_then(|extractor| extractor.extract_model_from_request(request_body)) - .unwrap_or_else(|_| "unknown".to_string()) - } else { - "unknown".to_string() - }; - - // 创建日志记录(失败) - let timestamp = chrono::Utc::now().timestamp_millis(); - let log = TokenLog::new( - tool_type.to_string(), - timestamp, - client_ip.to_string(), - session_id.to_string(), - config_name.to_string(), - model, - None, // 失败时没有 message_id - 0, // 失败时 token 数量为 0 - 0, - 0, - 0, - "failed".to_string(), - response_type.to_string(), - Some(error_type.to_string()), - Some(error_detail.to_string()), - response_time_ms, - None, // 失败时没有价格信息 - None, - None, - None, - 0.0, // 失败时成本为 0 - None, - ); - - // 发送到批量写入队列 - if let Err(e) = self.event_sender.send(log) { - tracing::error!("发送失败请求日志事件失败: {}", e); - } - - Ok(()) - } - - /// 解析SSE流数据块 - fn parse_sse_chunks( - &self, - extractor: &dyn crate::services::token_stats::extractor::TokenExtractor, - chunks: Vec, - ) -> Result { - let mut message_start: Option = None; - let mut message_delta: Option = None; - - tracing::info!(chunks_count = chunks.len(), "开始解析 SSE chunks"); - - for (i, chunk) in chunks.iter().enumerate() { - // 记录每个chunk的类型(前100个字符) - let chunk_preview = chunk.chars().take(100).collect::(); - - // 尝试提取事件类型用于日志 - let event_type = if let Ok(json) = serde_json::from_str::(chunk) { - json.get("type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string() - } else { - "parse_error".to_string() - }; - - tracing::debug!( - chunk_index = i, - event_type = %event_type, - preview = %chunk_preview, - "处理 SSE chunk" - ); - - match extractor.extract_from_sse_chunk(chunk) { - Ok(Some(data)) => { - if let Some(start) = data.message_start { - tracing::info!( - chunk_index = i, - input_tokens = start.input_tokens, - output_tokens = start.output_tokens, - cache_creation = start.cache_creation_tokens, - cache_read = start.cache_read_tokens, - "✓ 找到 message_start 事件" - ); - message_start = Some(start); - } - if let Some(delta) = data.message_delta { - tracing::info!( - chunk_index = i, - output_tokens = delta.output_tokens, - cache_creation = delta.cache_creation_tokens, - cache_read = delta.cache_read_tokens, - "✓ 找到 message_delta 事件" - ); - message_delta = Some(delta); - } - } - Ok(None) => { - // 正常跳过非数据块(如 ping、空行等) - } - Err(e) => { - tracing::warn!( - chunk_index = i, - error = ?e, - chunk_preview = %chunk.chars().take(200).collect::(), - "SSE chunk 解析失败" - ); - } - } - } - - if message_start.is_none() { - tracing::error!( - chunks_count = chunks.len(), - "所有 SSE chunks 中未找到 message_start 事件" - ); - } - - if message_delta.is_none() { - tracing::warn!( - chunks_count = chunks.len(), - "所有 SSE chunks 中未找到 message_delta 事件(可能流未完成或被截断)" - ); - } - - let start = message_start.context("Missing message_start in SSE stream")?; - - // 添加日志查看最终使用的值 - tracing::info!( - has_delta = message_delta.is_some(), - start_output = start.output_tokens, - delta_output = message_delta.as_ref().map(|d| d.output_tokens), - "合并前: start vs delta" - ); - - let result = ResponseTokenInfo::from_sse_data(start, message_delta); - - tracing::info!( - final_input = result.input_tokens, - final_output = result.output_tokens, - final_cache_creation = result.cache_creation_tokens, - final_cache_read = result.cache_read_tokens, - "合并后: 最终 Token 信息" - ); - - Ok(result) } /// 查询会话实时统计 @@ -503,76 +201,48 @@ pub fn shutdown_token_stats_manager() { #[cfg(test)] mod tests { use super::*; - use serde_json::json; #[tokio::test] - async fn test_log_request_with_json() { + async fn test_write_log() { let manager = TokenStatsManager::get(); - let request_body = json!({ - "model": "claude-sonnet-4-5-20250929", - "messages": [] - }) - .to_string(); - - let response_json = json!({ - "id": "msg_test_123", - "model": "claude-sonnet-4-5-20250929", - "usage": { - "input_tokens": 100, - "output_tokens": 50, - "cache_creation_input_tokens": 10, - "cache_read_input_tokens": 20 - } - }); + // 创建测试日志 + let log = TokenLog::new( + "claude_code".to_string(), + chrono::Utc::now().timestamp_millis(), + "127.0.0.1".to_string(), + "test_write_session".to_string(), + "default".to_string(), + "claude-3".to_string(), + Some("msg_write_test".to_string()), + 100, + 50, + 10, + 20, + 0, // reasoning_tokens + "success".to_string(), + "json".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, // reasoning_price + 0.0, + None, + ); - let result = manager - .log_request( - "claude_code", - "test_session", - "default", - "127.0.0.1", - request_body.as_bytes(), - ResponseData::Json(response_json), - None, // response_time_ms - None, // pricing_template_id - ) - .await; - - assert!(result.is_ok()); + // 写入日志 + manager.write_log(log); // 等待异步插入完成 - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - - // 验证统计数据 - let stats = manager - .get_session_stats("claude_code", "test_session") - .unwrap(); - assert_eq!(stats.total_input, 100); - assert_eq!(stats.total_output, 50); + std::thread::sleep(std::time::Duration::from_millis(200)); } - #[test] - fn test_parse_sse_chunks() { - let manager = TokenStatsManager::get(); - let extractor = create_extractor("claude_code").unwrap(); - - let chunks = vec![ - r#"data: {"type":"message_start","message":{"model":"claude-3","id":"msg_123","usage":{"input_tokens":1000,"output_tokens":1}}}"#.to_string(), - r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"cache_creation_input_tokens":50,"cache_read_input_tokens":100,"output_tokens":200}}"#.to_string(), - ]; - - let info = manager.parse_sse_chunks(&*extractor, chunks).unwrap(); - assert_eq!(info.model, "claude-3"); - assert_eq!(info.message_id, "msg_123"); - assert_eq!(info.input_tokens, 1000); - assert_eq!(info.output_tokens, 200); - assert_eq!(info.cache_creation_tokens, 50); - assert_eq!(info.cache_read_tokens, 100); - } - - #[test] - fn test_query_logs() { + #[tokio::test] + async fn test_query_logs() { let manager = TokenStatsManager::get(); // 插入测试数据 @@ -588,6 +258,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -597,6 +268,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); diff --git a/src-tauri/src/services/token_stats/mod.rs b/src-tauri/src/services/token_stats/mod.rs index 0ef2696..3f8c27f 100644 --- a/src-tauri/src/services/token_stats/mod.rs +++ b/src-tauri/src/services/token_stats/mod.rs @@ -4,8 +4,9 @@ pub mod analytics; pub mod db; -pub mod extractor; +pub mod logger; pub mod manager; +pub mod processor; #[cfg(test)] mod cost_calculation_test; @@ -15,8 +16,4 @@ pub use analytics::{ TrendDataPoint, TrendQuery, }; pub use db::TokenStatsDb; -pub use extractor::{ - create_extractor, ClaudeTokenExtractor, MessageDeltaData, MessageStartData, ResponseTokenInfo, - SseTokenData, TokenExtractor, -}; pub use manager::{shutdown_token_stats_manager, TokenStatsManager}; diff --git a/src-tauri/src/services/token_stats/processor/claude.rs b/src-tauri/src/services/token_stats/processor/claude.rs new file mode 100644 index 0000000..26335e0 --- /dev/null +++ b/src-tauri/src/services/token_stats/processor/claude.rs @@ -0,0 +1,344 @@ +//! Claude Code 工具的 Token 处理器 + +use super::{TokenInfo, ToolProcessor}; +use anyhow::{Context, Result}; +use serde_json::Value; + +/// Claude Code 工具处理器 +pub struct ClaudeProcessor; + +impl ToolProcessor for ClaudeProcessor { + fn tool_id(&self) -> &str { + "claude-code" + } + + fn process_sse_response( + &self, + request_body: &[u8], + sse_chunks: Vec, + ) -> Result { + // 1. 从请求体提取 model + let request_json: Value = + serde_json::from_slice(request_body).context("Failed to parse request body")?; + let model = request_json + .get("model") + .and_then(|v| v.as_str()) + .context("Missing 'model' field in request body")? + .to_string(); + + // 2. 解析 SSE 事件,收集 message_start 和 message_delta + let mut message_id: Option = None; + let mut input_tokens = 0i64; + let mut output_tokens = 0i64; + let mut cache_creation_tokens = 0i64; + let mut cache_read_tokens = 0i64; + + for chunk in sse_chunks { + let data_line = chunk.trim(); + + // 跳过空行 + if data_line.is_empty() { + continue; + } + + // 去掉 "data: " 前缀 + let json_str = if let Some(stripped) = data_line.strip_prefix("data: ") { + stripped + } else { + data_line + }; + + // 跳过 [DONE] 标记 + if json_str.trim() == "[DONE]" { + continue; + } + + let json: Value = match serde_json::from_str(json_str) { + Ok(j) => j, + Err(e) => { + tracing::warn!("Failed to parse SSE chunk: {}", e); + continue; + } + }; + + let event_type = json.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match event_type { + "message_start" => { + if let Some(message) = json.get("message") { + // 提取 message_id + if let Some(id) = message.get("id").and_then(|v| v.as_str()) { + message_id = Some(id.to_string()); + } + + // 提取 usage + if let Some(usage) = message.get("usage") { + input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 提取缓存创建 token:优先读取扁平字段,回退到嵌套对象 + cache_creation_tokens = usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or_else(|| { + if let Some(cache_obj) = usage.get("cache_creation") { + let ephemeral_5m = cache_obj + .get("ephemeral_5m_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let ephemeral_1h = cache_obj + .get("ephemeral_1h_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + ephemeral_5m + ephemeral_1h + } else { + 0 + } + }); + + cache_read_tokens = usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + tracing::debug!( + model = %model, + message_id = ?message_id, + input_tokens = input_tokens, + "Claude message_start 提取成功" + ); + } + } + } + "message_delta" => { + // message_delta 包含最终的 usage 统计(累加值) + if let Some(usage) = json.get("usage") { + // 更新 output_tokens 和缓存统计(这些是最终值) + output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(output_tokens); + + cache_creation_tokens = usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or_else(|| { + if let Some(cache_obj) = usage.get("cache_creation") { + let ephemeral_5m = cache_obj + .get("ephemeral_5m_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let ephemeral_1h = cache_obj + .get("ephemeral_1h_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + ephemeral_5m + ephemeral_1h + } else { + cache_creation_tokens + } + }); + + cache_read_tokens = usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(cache_read_tokens); + + tracing::debug!( + output_tokens = output_tokens, + cache_creation_tokens = cache_creation_tokens, + cache_read_tokens = cache_read_tokens, + "Claude message_delta 提取成功" + ); + } + } + _ => {} + } + } + + // 3. 验证必需字段 + let message_id = message_id.context("Missing message_id in SSE stream")?; + + // 4. 构建 TokenInfo + Ok(TokenInfo::new( + model, + message_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + 0, // Claude 不使用 reasoning tokens + )) + } + + fn process_json_response(&self, request_body: &[u8], json: &Value) -> Result { + // 1. 提取 model(优先使用响应中的 model,回退到请求体) + let model = json + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + // 回退到请求体 + serde_json::from_slice::(request_body) + .ok() + .and_then(|req| { + req.get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + }) + .context("Missing 'model' field in both response and request")?; + + // 2. 提取 message_id + let message_id = json + .get("id") + .and_then(|v| v.as_str()) + .context("Missing 'id' field in response")? + .to_string(); + + // 3. 提取 usage + let usage = json + .get("usage") + .context("Missing 'usage' field in response")?; + + let input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + let output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 提取缓存创建 token:优先读取扁平字段,回退到嵌套对象 + let cache_creation_tokens = usage + .get("cache_creation_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or_else(|| { + if let Some(cache_obj) = usage.get("cache_creation") { + let ephemeral_5m = cache_obj + .get("ephemeral_5m_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let ephemeral_1h = cache_obj + .get("ephemeral_1h_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + ephemeral_5m + ephemeral_1h + } else { + 0 + } + }); + + let cache_read_tokens = usage + .get("cache_read_input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 4. 构建 TokenInfo + Ok(TokenInfo::new( + model, + message_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + 0, // Claude 不使用 reasoning tokens + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_process_sse_response() { + let processor = ClaudeProcessor; + let request_body = r#"{"model":"claude-sonnet-4-5-20250929","messages":[]}"#; + let sse_chunks = vec![ + r#"data: {"type":"message_start","message":{"model":"claude-sonnet-4-5-20250929","id":"msg_123","type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":1000,"cache_creation_input_tokens":100,"cache_read_input_tokens":200,"output_tokens":1}}}"#.to_string(), + r#"data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#.to_string(), + r#"data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#.to_string(), + r#"data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":12}}"#.to_string(), + ]; + + let result = processor + .process_sse_response(request_body.as_bytes(), sse_chunks) + .unwrap(); + + assert_eq!(result.model, "claude-sonnet-4-5-20250929"); + assert_eq!(result.message_id, "msg_123"); + assert_eq!(result.input_tokens, 1000); + assert_eq!(result.output_tokens, 12); // message_delta 的最终值 + assert_eq!(result.cache_creation_tokens, 100); + assert_eq!(result.cache_read_tokens, 200); + assert_eq!(result.reasoning_tokens, 0); + } + + #[test] + fn test_process_json_response() { + let processor = ClaudeProcessor; + let request_body = r#"{"model":"claude-sonnet-4-5-20250929","messages":[]}"#; + let json_str = r#"{ + "id": "msg_123", + "model": "claude-sonnet-4-5-20250929", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "Hello"}], + "usage": { + "input_tokens": 1000, + "output_tokens": 500, + "cache_creation_input_tokens": 100, + "cache_read_input_tokens": 200 + } + }"#; + + let json: Value = serde_json::from_str(json_str).unwrap(); + let result = processor + .process_json_response(request_body.as_bytes(), &json) + .unwrap(); + + assert_eq!(result.model, "claude-sonnet-4-5-20250929"); + assert_eq!(result.message_id, "msg_123"); + assert_eq!(result.input_tokens, 1000); + assert_eq!(result.output_tokens, 500); + assert_eq!(result.cache_creation_tokens, 100); + assert_eq!(result.cache_read_tokens, 200); + assert_eq!(result.reasoning_tokens, 0); + } + + #[test] + fn test_process_json_nested_cache_creation() { + let processor = ClaudeProcessor; + let request_body = r#"{"model":"claude-sonnet-4-5-20250929","messages":[]}"#; + let json_str = r#"{ + "id": "msg_456", + "model": "claude-sonnet-4-5-20250929", + "usage": { + "input_tokens": 500, + "output_tokens": 300, + "cache_creation": { + "ephemeral_5m_input_tokens": 50, + "ephemeral_1h_input_tokens": 100 + }, + "cache_read_input_tokens": 200 + } + }"#; + + let json: Value = serde_json::from_str(json_str).unwrap(); + let result = processor + .process_json_response(request_body.as_bytes(), &json) + .unwrap(); + + assert_eq!(result.cache_creation_tokens, 150); // 50 + 100 + assert_eq!(result.cache_read_tokens, 200); + } +} diff --git a/src-tauri/src/services/token_stats/processor/codex.rs b/src-tauri/src/services/token_stats/processor/codex.rs new file mode 100644 index 0000000..98fb594 --- /dev/null +++ b/src-tauri/src/services/token_stats/processor/codex.rs @@ -0,0 +1,327 @@ +//! Codex 工具的 Token 处理器 + +use super::{TokenInfo, ToolProcessor}; +use anyhow::{Context, Result}; +use serde_json::Value; + +/// Codex 工具处理器 +pub struct CodexProcessor; + +impl ToolProcessor for CodexProcessor { + fn tool_id(&self) -> &str { + "codex" + } + + fn process_sse_response( + &self, + request_body: &[u8], + sse_chunks: Vec, + ) -> Result { + // 1. 从请求体提取 model + let request_json: Value = + serde_json::from_slice(request_body).context("Failed to parse request body")?; + let model = request_json + .get("model") + .and_then(|v| v.as_str()) + .context("Missing 'model' field in request body")? + .to_string(); + + // 2. 解析 SSE 事件,收集 response.created 和 response.completed + let mut message_id: Option = None; + let mut input_tokens = 0i64; + let mut output_tokens = 0i64; + let mut cache_read_tokens = 0i64; + let mut reasoning_tokens = 0i64; + + for chunk in sse_chunks { + let data_line = chunk.trim(); + + // 跳过空行 + if data_line.is_empty() { + continue; + } + + // 去掉 "data: " 前缀 + let json_str = if let Some(stripped) = data_line.strip_prefix("data: ") { + stripped + } else { + data_line + }; + + // 跳过 [DONE] 标记 + if json_str.trim() == "[DONE]" { + continue; + } + + let json: Value = match serde_json::from_str(json_str) { + Ok(j) => j, + Err(e) => { + tracing::warn!("Failed to parse SSE chunk: {}", e); + continue; + } + }; + + let event_type = json.get("type").and_then(|v| v.as_str()).unwrap_or(""); + + match event_type { + "response.created" => { + // 提取 response_id + if let Some(response) = json.get("response") { + if let Some(id) = response.get("id").and_then(|v| v.as_str()) { + message_id = Some(id.to_string()); + tracing::debug!(response_id = %id, "Codex response.created"); + } + } + } + "response.completed" => { + // 提取完整的 usage 统计 + if let Some(response) = json.get("response") { + // 更新 response_id(以防 created 事件缺失) + if message_id.is_none() { + if let Some(id) = response.get("id").and_then(|v| v.as_str()) { + message_id = Some(id.to_string()); + } + } + + if let Some(usage) = response.get("usage") { + // Codex 的 input_tokens 包括缓存的 token + // 需要减去 cached_tokens 才是真正的新输入 + let total_input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 提取 cached_tokens(缓存读取) + cache_read_tokens = usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 计算实际新输入 = 总输入 - 缓存读取 + // 这样才能避免重复计费 + input_tokens = total_input_tokens - cache_read_tokens; + + // 提取 reasoning_tokens + reasoning_tokens = usage + .get("output_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + if reasoning_tokens > 0 { + tracing::info!( + reasoning_tokens = reasoning_tokens, + "Codex 响应包含 reasoning tokens(暂不计费)" + ); + } + + tracing::debug!( + message_id = ?message_id, + total_input = total_input_tokens, + cached = cache_read_tokens, + new_input = input_tokens, + output_tokens = output_tokens, + "Codex response.completed 提取成功(input = total - cached)" + ); + } + } + } + _ => {} + } + } + + // 3. 验证必需字段 + let message_id = message_id.context("Missing response_id in SSE stream")?; + + // 4. 构建 TokenInfo + Ok(TokenInfo::new( + model, + message_id, + input_tokens, + output_tokens, + 0, // Codex 不报告 cache_creation_tokens + cache_read_tokens, + reasoning_tokens, + )) + } + + fn process_json_response(&self, request_body: &[u8], json: &Value) -> Result { + // 1. 提取 model(优先使用响应中的 model,回退到请求体) + let model = json + .get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .or_else(|| { + // 回退到请求体 + serde_json::from_slice::(request_body) + .ok() + .and_then(|req| { + req.get("model") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + }) + .context("Missing 'model' field in both response and request")?; + + // 2. 提取 response_id + let message_id = json + .get("id") + .and_then(|v| v.as_str()) + .context("Missing 'id' field in response")? + .to_string(); + + // 3. 提取 usage + let usage = json + .get("usage") + .context("Missing 'usage' field in response")?; + + // Codex 的 input_tokens 包括缓存的 token + // 需要减去 cached_tokens 才是真正的新输入 + let total_input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + let output_tokens = usage + .get("output_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 提取 cached_tokens(缓存读取) + let cache_read_tokens = usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 计算实际新输入 = 总输入 - 缓存读取 + let input_tokens = total_input_tokens - cache_read_tokens; + + // 提取 reasoning_tokens + let reasoning_tokens = usage + .get("output_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + // 4. 构建 TokenInfo + Ok(TokenInfo::new( + model, + message_id, + input_tokens, + output_tokens, + 0, // Codex 不报告 cache_creation_tokens + cache_read_tokens, + reasoning_tokens, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_process_sse_response() { + let processor = CodexProcessor; + let request_body = r#"{"model":"gpt-5.1","messages":[],"prompt_cache_key":"test123"}"#; + let sse_chunks = vec![ + r#"{"type":"response.created","response":{"id":"resp_abc123"}}"#.to_string(), + r#"{"type":"response.output_item.added","item":{"type":"message","role":"assistant"}}"# + .to_string(), + r#"{"type":"response.completed","response":{"id":"resp_abc123","usage":{"input_tokens":10591,"input_tokens_details":{"cached_tokens":10240},"output_tokens":15,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":10606}}}"#.to_string(), + ]; + + let result = processor + .process_sse_response(request_body.as_bytes(), sse_chunks) + .unwrap(); + + assert_eq!(result.model, "gpt-5.1"); + assert_eq!(result.message_id, "resp_abc123"); + // input_tokens 应该是新输入 = 总输入 - 缓存 = 10591 - 10240 = 351 + assert_eq!(result.input_tokens, 351); + assert_eq!(result.output_tokens, 15); + assert_eq!(result.cache_creation_tokens, 0); + assert_eq!(result.cache_read_tokens, 10240); + assert_eq!(result.reasoning_tokens, 0); + } + + #[test] + fn test_process_sse_with_reasoning_tokens() { + let processor = CodexProcessor; + let request_body = r#"{"model":"gpt-5.1","messages":[]}"#; + let sse_chunks = vec![ + r#"{"type":"response.created","response":{"id":"resp_xyz"}}"#.to_string(), + r#"{"type":"response.completed","response":{"id":"resp_xyz","usage":{"input_tokens":1000,"input_tokens_details":{"cached_tokens":0},"output_tokens":500,"output_tokens_details":{"reasoning_tokens":200},"total_tokens":1500}}}"#.to_string(), + ]; + + let result = processor + .process_sse_response(request_body.as_bytes(), sse_chunks) + .unwrap(); + + // 无缓存时,新输入 = 总输入 = 1000 + assert_eq!(result.input_tokens, 1000); + assert_eq!(result.output_tokens, 500); + assert_eq!(result.reasoning_tokens, 200); + } + + #[test] + fn test_process_json_response() { + let processor = CodexProcessor; + let request_body = r#"{"model":"gpt-4","messages":[]}"#; + let json_str = r#"{ + "id": "resp_test123", + "model": "gpt-4", + "usage": { + "input_tokens": 100, + "input_tokens_details": {"cached_tokens": 50}, + "output_tokens": 20, + "output_tokens_details": {"reasoning_tokens": 0} + } + }"#; + + let json: Value = serde_json::from_str(json_str).unwrap(); + let result = processor + .process_json_response(request_body.as_bytes(), &json) + .unwrap(); + + assert_eq!(result.model, "gpt-4"); + assert_eq!(result.message_id, "resp_test123"); + // input_tokens 应该是新输入 = 100 - 50 = 50 + assert_eq!(result.input_tokens, 50); + assert_eq!(result.output_tokens, 20); + assert_eq!(result.cache_creation_tokens, 0); + assert_eq!(result.cache_read_tokens, 50); + assert_eq!(result.reasoning_tokens, 0); + } + + #[test] + fn test_process_json_no_cached_tokens() { + let processor = CodexProcessor; + let request_body = r#"{"model":"gpt-3.5","messages":[]}"#; + let json_str = r#"{ + "id": "resp_456", + "model": "gpt-3.5", + "usage": { + "input_tokens": 200, + "output_tokens": 50 + } + }"#; + + let json: Value = serde_json::from_str(json_str).unwrap(); + let result = processor + .process_json_response(request_body.as_bytes(), &json) + .unwrap(); + + assert_eq!(result.input_tokens, 200); + assert_eq!(result.output_tokens, 50); + assert_eq!(result.cache_read_tokens, 0); + assert_eq!(result.reasoning_tokens, 0); + } +} diff --git a/src-tauri/src/services/token_stats/processor/mod.rs b/src-tauri/src/services/token_stats/processor/mod.rs new file mode 100644 index 0000000..49666a1 --- /dev/null +++ b/src-tauri/src/services/token_stats/processor/mod.rs @@ -0,0 +1,59 @@ +//! Token 处理器模块 +//! +//! 负责从原始响应中提取 Token 信息,各工具独立实现 + +mod claude; +mod codex; +mod token_info; + +pub use claude::ClaudeProcessor; +pub use codex::CodexProcessor; +pub use token_info::TokenInfo; + +use anyhow::{anyhow, Result}; +use serde_json::Value; + +/// 工具处理器 - 负责从原始响应中提取 Token 信息 +pub trait ToolProcessor: Send + Sync { + /// 工具 ID + fn tool_id(&self) -> &str; + + /// 从 SSE 响应中提取 Token 信息(完整流程) + /// + /// # 参数 + /// - `request_body`: 请求体(用于提取 model) + /// - `sse_chunks`: SSE 数据行(Vec) + /// + /// # 返回 + /// - TokenInfo: 完整的 Token 统计信息 + fn process_sse_response( + &self, + request_body: &[u8], + sse_chunks: Vec, + ) -> Result; + + /// 从 JSON 响应中提取 Token 信息 + /// + /// # 参数 + /// - `request_body`: 请求体(用于提取 model,如果响应中没有) + /// - `json`: JSON 响应体 + /// + /// # 返回 + /// - TokenInfo: 完整的 Token 统计信息 + fn process_json_response(&self, request_body: &[u8], json: &Value) -> Result; +} + +/// 创建工具处理器 +/// +/// # 参数 +/// - `tool_id`: 工具标识(claude-code/codex) +/// +/// # 返回 +/// - Box: 对应的处理器实例 +pub fn create_processor(tool_id: &str) -> Result> { + match tool_id { + "claude-code" => Ok(Box::new(ClaudeProcessor)), + "codex" => Ok(Box::new(CodexProcessor)), + _ => Err(anyhow!("Unsupported tool: {}", tool_id)), + } +} diff --git a/src-tauri/src/services/token_stats/processor/token_info.rs b/src-tauri/src/services/token_stats/processor/token_info.rs new file mode 100644 index 0000000..48ba867 --- /dev/null +++ b/src-tauri/src/services/token_stats/processor/token_info.rs @@ -0,0 +1,105 @@ +//! Token 信息统一输出格式 +//! +//! 所有工具处理器的统一返回值 + +use serde::{Deserialize, Serialize}; + +/// Token 信息(统一输出格式) +/// +/// 各工具处理器从响应中提取信息后统一返回此结构 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenInfo { + /// 模型名称 + pub model: String, + + /// 消息 ID + pub message_id: String, + + /// 输入 Token 数量 + pub input_tokens: i64, + + /// 输出 Token 数量 + pub output_tokens: i64, + + /// 缓存创建 Token 数量 + pub cache_creation_tokens: i64, + + /// 缓存读取 Token 数量 + pub cache_read_tokens: i64, + + /// 推理 Token 数量 + pub reasoning_tokens: i64, +} + +impl TokenInfo { + /// 创建新的 TokenInfo 实例 + pub fn new( + model: String, + message_id: String, + input_tokens: i64, + output_tokens: i64, + cache_creation_tokens: i64, + cache_read_tokens: i64, + reasoning_tokens: i64, + ) -> Self { + Self { + model, + message_id, + input_tokens, + output_tokens, + cache_creation_tokens, + cache_read_tokens, + reasoning_tokens, + } + } + + /// 计算总 Token 数量 + pub fn total_tokens(&self) -> i64 { + self.input_tokens + + self.output_tokens + + self.cache_creation_tokens + + self.cache_read_tokens + + self.reasoning_tokens + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_token_info_creation() { + let info = TokenInfo::new( + "claude-sonnet-4-5-20250929".to_string(), + "msg_123".to_string(), + 1000, + 500, + 100, + 200, + 50, + ); + + assert_eq!(info.model, "claude-sonnet-4-5-20250929"); + assert_eq!(info.message_id, "msg_123"); + assert_eq!(info.input_tokens, 1000); + assert_eq!(info.output_tokens, 500); + assert_eq!(info.cache_creation_tokens, 100); + assert_eq!(info.cache_read_tokens, 200); + assert_eq!(info.reasoning_tokens, 50); + } + + #[test] + fn test_total_tokens() { + let info = TokenInfo::new( + "test-model".to_string(), + "msg_test".to_string(), + 1000, + 500, + 100, + 200, + 50, + ); + + assert_eq!(info.total_tokens(), 1850); + } +} diff --git a/src/App.tsx b/src/App.tsx index 1eb4000..5ad374f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,483 +1,22 @@ -import { useState, useEffect, useCallback } from 'react'; -import { listen, emit } from '@tauri-apps/api/event'; -import { invoke } from '@tauri-apps/api/core'; -import { AppSidebar } from '@/components/layout/AppSidebar'; -import { CloseActionDialog } from '@/components/dialogs/CloseActionDialog'; -import { UpdateDialog } from '@/components/dialogs/UpdateDialog'; -import { ConfigChangeDialog } from '@/components/dialogs/ConfigChangeDialog'; -import { InstallationPage } from '@/pages/InstallationPage'; -import { DashboardPage } from '@/pages/DashboardPage'; -import ProfileManagementPage from '@/pages/ProfileManagementPage'; -import { ProviderManagementPage } from '@/pages/ProviderManagementPage'; -import { SettingsPage } from '@/pages/SettingsPage'; -import { TransparentProxyPage } from '@/pages/TransparentProxyPage'; -import { ToolManagementPage } from '@/pages/ToolManagementPage'; -import { HelpPage } from '@/pages/HelpPage'; -import { AboutPage } from '@/pages/AboutPage'; -import { BalancePage } from '@/pages/BalancePage'; -import TokenStatisticsPage from '@/pages/TokenStatisticsPage'; -import { useToast } from '@/hooks/use-toast'; -import { useAppEvents } from '@/hooks/useAppEvents'; -import { useCloseAction } from '@/hooks/useCloseAction'; -import { useConfigWatch } from '@/hooks/useConfigWatch'; -import { Toaster } from '@/components/ui/toaster'; -import OnboardingOverlay from '@/components/Onboarding/OnboardingOverlay'; -import { - getRequiredSteps, - getAllSteps, - CURRENT_ONBOARDING_VERSION, -} from '@/components/Onboarding/config/versions'; -import type { OnboardingStatus, OnboardingStep } from '@/types/onboarding'; -import { - checkInstallations, - checkForAppUpdates, - getGlobalConfig, - type CloseAction, - type ToolStatus, - type GlobalConfig, - type UpdateInfo, -} from '@/lib/tauri-commands'; -import type { ToolType } from '@/types/token-stats'; - -type TabType = - | 'dashboard' - | 'tool-management' - | 'install' - | 'profile-management' - | 'balance' - | 'transparent-proxy' - | 'token-statistics' - | 'provider-management' - | 'settings' - | 'help' - | 'about'; +import { AppProvider } from '@/contexts/AppContext'; +import { MainLayout } from '@/components/layout/MainLayout'; +import { AppContent } from '@/components/logic/AppContent'; +import { AppEventsHandler } from '@/components/logic/AppEventsHandler'; +import { ConfigWatchHandler } from '@/components/logic/ConfigWatchHandler'; +import { UpdateManager } from '@/components/logic/UpdateManager'; +import { OnboardingManager } from '@/components/logic/OnboardingManager'; function App() { - const { toast } = useToast(); - const [activeTab, setActiveTab] = useState('dashboard'); - const [selectedProxyToolId, setSelectedProxyToolId] = useState(undefined); - const [settingsInitialTab, setSettingsInitialTab] = useState('basic'); - const [settingsRestrictToTab, setSettingsRestrictToTab] = useState(undefined); - const [restrictedPage, setRestrictedPage] = useState(undefined); - - // Token 统计页面导航参数 - const [tokenStatsParams, setTokenStatsParams] = useState<{ - sessionId?: string; - toolType?: ToolType; - }>({}); - - // 引导状态管理 - const [showOnboarding, setShowOnboarding] = useState(false); - const [onboardingSteps, setOnboardingSteps] = useState([]); - const [onboardingChecked, setOnboardingChecked] = useState(false); - const [canExitOnboarding, setCanExitOnboarding] = useState(false); - - // 全局工具状态缓存 - const [tools, setTools] = useState([]); - const [toolsLoading, setToolsLoading] = useState(true); - - // 全局配置缓存(供 SettingsPage 使用) - const [globalConfig, setGlobalConfig] = useState(null); - const [configLoading, setConfigLoading] = useState(false); - - // 更新检查状态 - const [updateInfo, setUpdateInfo] = useState(null); - const [updateCheckDone, setUpdateCheckDone] = useState(false); - const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); - - // 配置监听 - const { - change: configChange, - showDialog: showConfigDialog, - closeDialog: closeConfigDialog, - queueLength, - } = useConfigWatch(); - - // 加载工具状态(全局缓存) - const loadTools = useCallback(async () => { - try { - setToolsLoading(true); - const result = await checkInstallations(); - setTools(result); - } catch (error) { - console.error('Failed to load tools:', error); - } finally { - setToolsLoading(false); - } - }, []); - - // 加载全局配置(供多处使用) - const loadGlobalConfig = useCallback(async () => { - try { - setConfigLoading(true); - const config = await getGlobalConfig(); - setGlobalConfig(config); - } catch (error) { - console.error('Failed to load global config:', error); - } finally { - setConfigLoading(false); - } - }, []); - - // 检查应用更新 - const checkAppUpdates = useCallback( - async (force = false) => { - // 避免重复检查,除非强制检查 - if (updateCheckDone && !force) { - return; - } - - try { - console.log('Checking for app updates...'); - const update = await checkForAppUpdates(); - setUpdateInfo(update); - - // 如果有可用更新,直接打开更新弹窗 - if (update.has_update) { - setIsUpdateDialogOpen(true); - } - } catch (error) { - console.error('Failed to check for updates:', error); - // 静默失败,不显示错误通知给用户 - } finally { - setUpdateCheckDone(true); - } - }, - [updateCheckDone], - ); - - // 初始化加载工具和全局配置 - useEffect(() => { - loadTools(); - loadGlobalConfig(); - }, [loadTools, loadGlobalConfig]); - - // 检查是否需要显示引导 - useEffect(() => { - const checkOnboardingStatus = async () => { - try { - const status = await invoke('get_onboarding_status'); - const currentVersion = CURRENT_ONBOARDING_VERSION; - - // 判断是否需要显示引导 - if (!status || !status.completed_version) { - // 首次使用:显示完整引导 - const steps = getRequiredSteps(null); - setOnboardingSteps(steps); - setShowOnboarding(steps.length > 0); - } else if (status.completed_version < currentVersion) { - // 版本升级:显示新增内容 - const steps = getRequiredSteps(status.completed_version); - setOnboardingSteps(steps); - setShowOnboarding(steps.length > 0); - } - // else: 已是最新版本,无需引导 - } catch (error) { - console.error('检查引导状态失败:', error); - } finally { - setOnboardingChecked(true); - } - }; - - checkOnboardingStatus(); - }, []); - - // 完成引导的处理函数 - const handleOnboardingComplete = useCallback(() => { - setShowOnboarding(false); - toast({ - title: '欢迎使用 DuckCoding', - description: '您已完成初始配置,现在可以开始使用了', - }); - }, [toast]); - - // 显示引导(帮助页面调用) - const handleShowOnboarding = useCallback(() => { - // 无论用户是否完成引导,都显示完整的引导步骤 - const steps = getAllSteps(); - setOnboardingSteps(steps); - setCanExitOnboarding(true); // 主动查看,允许退出 - setShowOnboarding(true); - }, []); - - // 退出引导(用户主动退出) - const handleExitOnboarding = useCallback(() => { - setShowOnboarding(false); - setCanExitOnboarding(false); - toast({ - title: '已退出引导', - description: '您可以随时从帮助页面重新查看引导', - }); - }, [toast]); - - // 应用启动时检查更新(延迟1秒,避免影响启动速度) - useEffect(() => { - const timer = setTimeout(() => { - checkAppUpdates(); - }, 1000); // 1秒后检查更新 - - return () => clearTimeout(timer); - }, [checkAppUpdates]); - - // 监听后端推送的更新事件 - useEffect(() => { - // 监听后端主动推送的更新可用事件 - const unlistenUpdateAvailable = listen('update-available', (event) => { - const updateInfo = event.payload; - setUpdateInfo(updateInfo); - - // 直接打开更新弹窗 - setIsUpdateDialogOpen(true); - }); - - // 监听托盘菜单触发的检查更新请求 - const unlistenRequestCheck = listen('request-check-update', () => { - // 清空旧的更新信息,打开弹窗,触发重新检查 - setUpdateInfo(null); - setIsUpdateDialogOpen(true); - }); - - // 监听未发现更新事件(用于托盘触发后的反馈) - const unlistenNotFound = listen('update-not-found', () => { - toast({ - title: '已是最新版本', - description: '当前无可用更新', - }); - }); - - // 监听打开设置事件(用于引导流程) - const unlistenOpenSettings = listen<{ tab?: string; restrictToTab?: boolean }>( - 'open-settings', - (event) => { - const tab = event.payload?.tab || 'basic'; - const restrictToTab = event.payload?.restrictToTab || false; - setSettingsInitialTab(tab); - setSettingsRestrictToTab(restrictToTab ? tab : undefined); - setActiveTab('settings'); - }, - ); - - // 监听统一引导导航事件(标准化) - const unlistenOnboardingNavigate = listen<{ - targetPage: string; - restrictToTab?: string; - autoAction?: string; - }>('onboarding-navigate', (event) => { - const { targetPage, restrictToTab, autoAction } = event.payload || {}; - - console.log('[Onboarding Nav] 接收导航事件:', { targetPage, restrictToTab, autoAction }); - - // 设置页面限制 - setRestrictedPage(targetPage); - - // 处理设置页面的特殊逻辑 - if (targetPage === 'settings' && restrictToTab) { - setSettingsInitialTab(restrictToTab); - setSettingsRestrictToTab(restrictToTab); - } - - // 跳转到目标页面 - setActiveTab(targetPage as TabType); - - // 延迟触发自动动作(等待页面渲染和事件监听建立) - if (autoAction) { - console.log('[Onboarding Nav] 将在 500ms 后触发自动动作:', autoAction); - setTimeout(() => { - console.log('[Onboarding Nav] 触发自动动作:', autoAction); - emit(autoAction); - }, 500); - } - }); - - // 监听清除引导限制 - const unlistenClearRestriction = listen('clear-onboarding-restriction', () => { - setRestrictedPage(undefined); - setSettingsRestrictToTab(undefined); - }); - - // 监听应用内导航事件 - const unlistenAppNavigate = listen<{ - tab: TabType; - params?: { sessionId?: string; toolType?: ToolType }; - }>('app-navigate', (event) => { - const { tab, params } = event.payload || {}; - if (tab) { - setActiveTab(tab); - // 如果是导航到 Token 统计页面,保存参数 - if (tab === 'token-statistics' && params) { - setTokenStatsParams(params); - } else if (tab !== 'token-statistics') { - // 清空参数 - setTokenStatsParams({}); - } - } - }); - - return () => { - unlistenUpdateAvailable.then((fn) => fn()); - unlistenRequestCheck.then((fn) => fn()); - unlistenNotFound.then((fn) => fn()); - unlistenOpenSettings.then((fn) => fn()); - unlistenOnboardingNavigate.then((fn) => fn()); - unlistenClearRestriction.then((fn) => fn()); - unlistenAppNavigate.then((fn) => fn()); - }; - }, [toast]); - - // 使用关闭动作 Hook - const { - closeDialogOpen, - rememberCloseChoice, - closeActionLoading, - setRememberCloseChoice, - executeCloseAction, - openCloseDialog, - closeDialog, - } = useCloseAction((message: string) => { - toast({ - variant: 'destructive', - title: '窗口操作失败', - description: message, - }); - }); - - // 使用应用事件 Hook - useAppEvents({ - onCloseRequest: openCloseDialog, - onSingleInstance: (message: string) => { - toast({ - title: 'DuckCoding 已在运行', - description: message, - }); - }, - onNavigateToInstall: () => setActiveTab('install'), - onNavigateToList: () => setActiveTab('tool-management'), - onNavigateToConfig: (_detail) => { - setActiveTab('profile-management'); - // TODO: 如果需要滚动到指定工具,可以通过 _detail.toolId 实现 - }, - onNavigateToSettings: (detail) => { - setSettingsInitialTab(detail?.tab ?? 'basic'); - setActiveTab('settings'); - }, - onNavigateToTransparentProxy: (detail) => { - setActiveTab('transparent-proxy'); - if (detail?.toolId) { - setSelectedProxyToolId(detail.toolId); - } - }, - onRefreshTools: loadTools, - executeCloseAction, - }); - return ( - <> - {/* 引导遮罩层(如果需要显示) */} - {showOnboarding && onboardingChecked && onboardingSteps.length > 0 && ( - - )} - - {/* 主应用界面 */} -
- {/* 侧边栏 */} - setActiveTab(tab as TabType)} - restrictNavigation={!!settingsRestrictToTab || !!restrictedPage} - allowedPage={restrictedPage || (settingsRestrictToTab ? 'settings' : undefined)} - /> - - {/* 主内容区域 */} -
- {activeTab === 'dashboard' && } - {activeTab === 'tool-management' && ( - - )} - {activeTab === 'install' && } - {activeTab === 'balance' && } - {activeTab === 'profile-management' && } - {activeTab === 'transparent-proxy' && ( - - )} - {activeTab === 'token-statistics' && ( - - )} - {activeTab === 'settings' && ( - { - // 清空旧的更新信息,打开弹窗,触发重新检查 - setUpdateInfo(null); - setIsUpdateDialogOpen(true); - }} - /> - )} - {activeTab === 'provider-management' && } - {activeTab === 'help' && } - {activeTab === 'about' && ( - { - setUpdateInfo(null); - setIsUpdateDialogOpen(true); - }} - /> - )} -
- - {/* 更新对话框 */} - { - // 清空旧信息,触发重新检查 - setUpdateInfo(null); - checkAppUpdates(true); - }} - /> - - {/* 关闭动作选择对话框 */} - - executeCloseAction(action, remember, false) - } - /> - - {/* 配置变更通知对话框 */} - - - {/* Toast 通知 */} - -
- + + + + + + + + + ); } diff --git a/src/components/common/ViewToggle.tsx b/src/components/common/ViewToggle.tsx new file mode 100644 index 0000000..3654c67 --- /dev/null +++ b/src/components/common/ViewToggle.tsx @@ -0,0 +1,41 @@ +import { LayoutGrid, List } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +export type ViewMode = 'grid' | 'list'; + +interface ViewToggleProps { + mode: ViewMode; + onChange: (mode: ViewMode) => void; +} + +export function ViewToggle({ mode, onChange }: ViewToggleProps) { + return ( +
+ + +
+ ); +} diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index f8981c3..66f8c7c 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -1,7 +1,4 @@ import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; -// 预留 -// import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { LayoutDashboard, Wrench, @@ -17,8 +14,7 @@ import { Moon, Monitor, Info, - //预留 - // User, + BarChart3, } from 'lucide-react'; import DuckLogo from '@/assets/duck-logo.png'; import { useToast } from '@/hooks/use-toast'; @@ -29,32 +25,47 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, - DropdownMenuSeparator, - DropdownMenuLabel, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; interface AppSidebarProps { activeTab: string; onTabChange: (tab: string) => void; restrictNavigation?: boolean; - allowedPage?: string; // 新增:允许访问的页面(引导模式下) + allowedPage?: string; } -// 导航项配置 -const navigationItems = [ - { id: 'dashboard', label: '仪表板', icon: LayoutDashboard }, - { id: 'tool-management', label: '工具管理', icon: Wrench }, - { id: 'profile-management', label: '配置管理', icon: Settings2 }, - { id: 'balance', label: '余额查询', icon: Wallet }, - { id: 'transparent-proxy', label: '透明代理', icon: Radio }, -]; - -const secondaryItems = [ - { id: 'provider-management', label: '供应商', icon: Building2 }, - { id: 'help', label: '帮助', icon: HelpCircle }, - { id: 'settings', label: '设置', icon: SettingsIcon }, - { id: 'about', label: '关于', icon: Info }, +// 导航组配置 +const navigationGroups = [ + { + label: '概览', + items: [{ id: 'dashboard', label: '仪表板', icon: LayoutDashboard }], + }, + { + label: '核心工具', + items: [ + { id: 'tool-management', label: '工具管理', icon: Wrench }, + { id: 'profile-management', label: '配置方案', icon: Settings2 }, + ], + }, + { + label: '网关与监控', + items: [ + { id: 'transparent-proxy', label: '透明代理', icon: Radio }, + { id: 'provider-management', label: '模型供应商', icon: Building2 }, + { id: 'token-statistics', label: '用量统计', icon: BarChart3 }, + { id: 'balance', label: '余额监控', icon: Wallet }, + ], + }, + { + label: '系统', + items: [ + { id: 'settings', label: '全局设置', icon: SettingsIcon }, + { id: 'help', label: '帮助中心', icon: HelpCircle }, + { id: 'about', label: '关于应用', icon: Info }, + ], + }, ]; export function AppSidebar({ @@ -64,7 +75,7 @@ export function AppSidebar({ allowedPage, }: AppSidebarProps) { const { toast } = useToast(); - const { theme, actualTheme, setTheme } = useTheme(); + const { actualTheme, setTheme } = useTheme(); const [isCollapsed, setIsCollapsed] = useState(() => { const stored = localStorage.getItem('duckcoding-sidebar-collapsed'); @@ -77,7 +88,6 @@ export function AppSidebar({ const handleTabChange = (tab: string) => { if (restrictNavigation) { - // 只允许访问 allowedPage if (allowedPage && tab !== allowedPage) { toast({ title: '请先完成引导', @@ -92,217 +102,167 @@ export function AppSidebar({ const ThemeIcon = actualTheme === 'dark' ? Moon : Sun; - // 导航按钮组件 - const NavButton = ({ item }: { item: (typeof navigationItems)[0] }) => { + const NavButton = ({ item }: { item: { id: string; label: string; icon: any } }) => { const Icon = item.icon; const isActive = activeTab === item.id; + const isDisabled = restrictNavigation && allowedPage ? item.id !== allowedPage : false; - const button = ( - - ); - - if (isCollapsed) { - return ( - - {button} - + return ( + + + + + {isCollapsed && ( +

{item.label}

-
- ); - } - - return button; + )} +
+ ); }; return ( diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx new file mode 100644 index 0000000..d55e532 --- /dev/null +++ b/src/components/layout/MainLayout.tsx @@ -0,0 +1,38 @@ +import { AppSidebar } from '@/components/layout/AppSidebar'; +import { useAppContext } from '@/hooks/useAppContext'; +import { Toaster } from '@/components/ui/toaster'; + +interface MainLayoutProps { + children: React.ReactNode; +} + +export function MainLayout({ children }: MainLayoutProps) { + const { activeTab, setActiveTab, settingsRestrictToTab, restrictedPage } = useAppContext(); + + return ( +
+ {/* Sidebar */} + setActiveTab(tab as any)} + restrictNavigation={!!settingsRestrictToTab || !!restrictedPage} + allowedPage={restrictedPage || (settingsRestrictToTab ? 'settings' : undefined)} + /> + + {/* Main Content Area */} +
+ {/* Background Gradients/Effects can go here */} +
+ + {/* Scrollable Content */} +
+
+ {children} +
+
+
+ + +
+ ); +} diff --git a/src/components/layout/PageContainer.tsx b/src/components/layout/PageContainer.tsx index 0ce4c7c..c8189cc 100644 --- a/src/components/layout/PageContainer.tsx +++ b/src/components/layout/PageContainer.tsx @@ -1,18 +1,43 @@ import { ReactNode } from 'react'; +import { cn } from '@/lib/utils'; interface PageContainerProps { children: ReactNode; className?: string; + header?: ReactNode; + title?: string; + description?: string; + actions?: ReactNode; } /** - * 页面容器组件 - * 为所有页面提供统一的布局和样式 + * Enhanced Page Container Component + * Provides a unified layout structure for all pages with optional header, title, and description. */ -export function PageContainer({ children, className = '' }: PageContainerProps) { +export function PageContainer({ + children, + className = '', + header, + title, + description, + actions, +}: PageContainerProps) { return ( -
-
{children}
+
+ {/* Optional Standard Header Section */} + {(title || header || actions) && ( +
+
+ {title &&

{title}

} + {description &&

{description}

} + {header} +
+ {actions &&
{actions}
} +
+ )} + + {/* Main Content */} +
{children}
); } diff --git a/src/components/logic/AppContent.tsx b/src/components/logic/AppContent.tsx new file mode 100644 index 0000000..b1fc73b --- /dev/null +++ b/src/components/logic/AppContent.tsx @@ -0,0 +1,55 @@ +import { useAppContext } from '@/hooks/useAppContext'; +import { DashboardPage } from '@/pages/DashboardPage'; +import { InstallationPage } from '@/pages/InstallationPage'; +import ProfileManagementPage from '@/pages/ProfileManagementPage'; +import { TransparentProxyPage } from '@/pages/TransparentProxyPage'; +import { ToolManagementPage } from '@/pages/ToolManagementPage'; +import { BalancePage } from '@/pages/BalancePage'; +import TokenStatisticsPage from '@/pages/TokenStatisticsPage'; +import { ProviderManagementPage } from '@/pages/ProviderManagementPage'; +import { SettingsPage } from '@/pages/SettingsPage'; +import { HelpPage } from '@/pages/HelpPage'; +import { AboutPage } from '@/pages/AboutPage'; + +// We need to pass some props that are not yet in context, or refactor pages to use context +// For now, we'll try to get what we can from context. + +export function AppContent() { + const { activeTab, tokenStatsParams, selectedProxyToolId } = useAppContext(); + + // Note: Some pages might need props that were handled in App.tsx (like onUpdateCheck) + // We will need to handle those interactions via context or a global event bus later. + // For this step, I'll pass dummy or context-derived functions. + + switch (activeTab) { + case 'dashboard': + return ; + case 'tool-management': + return ; + case 'install': + return ; + case 'balance': + return ; + case 'profile-management': + return ; + case 'transparent-proxy': + return ; + case 'token-statistics': + return ( + + ); + case 'settings': + return ; + case 'provider-management': + return ; + case 'help': + return ; + case 'about': + return ; + default: + return null; + } +} diff --git a/src/components/logic/AppEventsHandler.tsx b/src/components/logic/AppEventsHandler.tsx new file mode 100644 index 0000000..fa4dacb --- /dev/null +++ b/src/components/logic/AppEventsHandler.tsx @@ -0,0 +1,149 @@ +import { useEffect } from 'react'; +import { listen } from '@tauri-apps/api/event'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useToast } from '@/hooks/use-toast'; +import { useAppEvents } from '@/hooks/useAppEvents'; +import { useCloseAction } from '@/hooks/useCloseAction'; +import { CloseActionDialog } from '@/components/dialogs/CloseActionDialog'; +import type { UpdateInfo, CloseAction } from '@/lib/tauri-commands'; +import type { ToolType } from '@/types/token-stats'; +import type { TabType } from '@/contexts/AppContext.types'; + +export function AppEventsHandler() { + const { + setActiveTab, + setSettingsInitialTab, + setSettingsRestrictToTab, + setSelectedProxyToolId, + setTokenStatsParams, + setUpdateInfo, + setIsUpdateDialogOpen, + refreshTools, + } = useAppContext(); + + const { toast } = useToast(); + + const { + closeDialogOpen, + rememberCloseChoice, + closeActionLoading, + setRememberCloseChoice, + executeCloseAction, + openCloseDialog, + closeDialog, + } = useCloseAction((message: string) => { + toast({ + variant: 'destructive', + title: '窗口操作失败', + description: message, + }); + }); + + useAppEvents({ + onCloseRequest: openCloseDialog, + onSingleInstance: (message: string) => { + toast({ + title: 'DuckCoding 已在运行', + description: message, + }); + }, + onNavigateToInstall: () => setActiveTab('install'), + onNavigateToList: () => setActiveTab('tool-management'), + onNavigateToConfig: (_detail) => { + setActiveTab('profile-management'); + }, + onNavigateToSettings: (detail) => { + setSettingsInitialTab(detail?.tab ?? 'basic'); + setActiveTab('settings'); + }, + onNavigateToTransparentProxy: (detail) => { + setActiveTab('transparent-proxy'); + if (detail?.toolId) { + setSelectedProxyToolId(detail.toolId); + } + }, + onRefreshTools: refreshTools, + executeCloseAction, + }); + + // Additional Event Listeners + useEffect(() => { + const unlistenUpdateAvailable = listen('update-available', (event) => { + setUpdateInfo(event.payload); + setIsUpdateDialogOpen(true); + }); + + const unlistenRequestCheck = listen('request-check-update', () => { + setUpdateInfo(null); + setIsUpdateDialogOpen(true); + }); + + const unlistenNotFound = listen('update-not-found', () => { + toast({ + title: '已是最新版本', + description: '当前无可用更新', + }); + }); + + const unlistenOpenSettings = listen<{ tab?: string; restrictToTab?: boolean }>( + 'open-settings', + (event) => { + const tab = event.payload?.tab || 'basic'; + const restrictToTab = event.payload?.restrictToTab || false; + setSettingsInitialTab(tab); + if (restrictToTab) { + setSettingsRestrictToTab(tab); + } else { + setSettingsRestrictToTab(undefined); + } + setActiveTab('settings'); + }, + ); + + // TODO: Add Onboarding Navigation Logic here or in OnboardingManager + + const unlistenAppNavigate = listen<{ + tab: TabType; + params?: { sessionId?: string; toolType?: ToolType }; + }>('app-navigate', (event) => { + const { tab, params } = event.payload || {}; + if (tab) { + setActiveTab(tab); + if (tab === 'token-statistics' && params) { + setTokenStatsParams(params); + } else if (tab !== 'token-statistics') { + setTokenStatsParams({}); + } + } + }); + + return () => { + unlistenUpdateAvailable.then((fn) => fn()); + unlistenRequestCheck.then((fn) => fn()); + unlistenNotFound.then((fn) => fn()); + unlistenOpenSettings.then((fn) => fn()); + unlistenAppNavigate.then((fn) => fn()); + }; + }, [ + setActiveTab, + setSettingsInitialTab, + setSettingsRestrictToTab, + setIsUpdateDialogOpen, + setUpdateInfo, + setTokenStatsParams, + toast, + ]); + + return ( + + executeCloseAction(action, remember, false) + } + /> + ); +} diff --git a/src/components/logic/ConfigWatchHandler.tsx b/src/components/logic/ConfigWatchHandler.tsx new file mode 100644 index 0000000..2696ff4 --- /dev/null +++ b/src/components/logic/ConfigWatchHandler.tsx @@ -0,0 +1,20 @@ +import { useConfigWatch } from '@/hooks/useConfigWatch'; +import { ConfigChangeDialog } from '@/components/dialogs/ConfigChangeDialog'; + +export function ConfigWatchHandler() { + const { + change: configChange, + showDialog: showConfigDialog, + closeDialog: closeConfigDialog, + queueLength, + } = useConfigWatch(); + + return ( + + ); +} diff --git a/src/components/logic/OnboardingManager.tsx b/src/components/logic/OnboardingManager.tsx new file mode 100644 index 0000000..9a3c15d --- /dev/null +++ b/src/components/logic/OnboardingManager.tsx @@ -0,0 +1,135 @@ +import { useState, useEffect, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen, emit } from '@tauri-apps/api/event'; +import { useAppContext } from '@/hooks/useAppContext'; +import { useToast } from '@/hooks/use-toast'; +import OnboardingOverlay from '@/components/Onboarding/OnboardingOverlay'; +import { + getRequiredSteps, + getAllSteps, + CURRENT_ONBOARDING_VERSION, +} from '@/components/Onboarding/config/versions'; +import type { OnboardingStatus, OnboardingStep } from '@/types/onboarding'; +import type { TabType } from '@/contexts/AppContext.types'; + +export function OnboardingManager() { + const { setActiveTab, setSettingsInitialTab, setSettingsRestrictToTab, setRestrictedPage } = + useAppContext(); + + const { toast } = useToast(); + + const [showOnboarding, setShowOnboarding] = useState(false); + const [onboardingSteps, setOnboardingSteps] = useState([]); + const [onboardingChecked, setOnboardingChecked] = useState(false); + const [canExitOnboarding, setCanExitOnboarding] = useState(false); + + // Check Onboarding Status + useEffect(() => { + const checkOnboardingStatus = async () => { + try { + const status = await invoke('get_onboarding_status'); + const currentVersion = CURRENT_ONBOARDING_VERSION; + + if (!status || !status.completed_version) { + const steps = getRequiredSteps(null); + setOnboardingSteps(steps); + setShowOnboarding(steps.length > 0); + } else if (status.completed_version < currentVersion) { + const steps = getRequiredSteps(status.completed_version); + setOnboardingSteps(steps); + setShowOnboarding(steps.length > 0); + } + } catch (error) { + console.error('Check onboarding status failed:', error); + } finally { + setOnboardingChecked(true); + } + }; + + checkOnboardingStatus(); + }, []); + + const handleOnboardingComplete = useCallback(() => { + setShowOnboarding(false); + toast({ + title: '欢迎使用 DuckCoding', + description: '您已完成初始配置,现在可以开始使用了', + }); + }, [toast]); + + const handleShowOnboarding = useCallback(() => { + const steps = getAllSteps(); + setOnboardingSteps(steps); + setCanExitOnboarding(true); + setShowOnboarding(true); + }, []); + + const handleExitOnboarding = useCallback(() => { + setShowOnboarding(false); + setCanExitOnboarding(false); + toast({ + title: '已退出引导', + description: '您可以随时从帮助页面重新查看引导', + }); + }, [toast]); + + // Listen for onboarding events + useEffect(() => { + const unlistenRequest = listen('request-show-onboarding', () => { + handleShowOnboarding(); + }); + + const unlistenNavigate = listen<{ + targetPage: string; + restrictToTab?: string; + autoAction?: string; + }>('onboarding-navigate', (event) => { + const { targetPage, restrictToTab, autoAction } = event.payload || {}; + + setRestrictedPage(targetPage); + + if (targetPage === 'settings' && restrictToTab) { + setSettingsInitialTab(restrictToTab); + setSettingsRestrictToTab(restrictToTab); + } + + setActiveTab(targetPage as TabType); + + if (autoAction) { + setTimeout(() => { + emit(autoAction); + }, 500); + } + }); + + const unlistenClear = listen('clear-onboarding-restriction', () => { + setRestrictedPage(undefined); + setSettingsRestrictToTab(undefined); + }); + + return () => { + unlistenRequest.then((fn) => fn()); + unlistenNavigate.then((fn) => fn()); + unlistenClear.then((fn) => fn()); + }; + }, [ + handleShowOnboarding, + setActiveTab, + setRestrictedPage, + setSettingsInitialTab, + setSettingsRestrictToTab, + ]); + + return ( + <> + {showOnboarding && onboardingChecked && onboardingSteps.length > 0 && ( + + )} + + ); +} diff --git a/src/components/logic/UpdateManager.tsx b/src/components/logic/UpdateManager.tsx new file mode 100644 index 0000000..e692618 --- /dev/null +++ b/src/components/logic/UpdateManager.tsx @@ -0,0 +1,19 @@ +import { useAppContext } from '@/hooks/useAppContext'; +import { UpdateDialog } from '@/components/dialogs/UpdateDialog'; + +export function UpdateManager() { + const { updateInfo, setUpdateInfo, isUpdateDialogOpen, setIsUpdateDialogOpen, checkAppUpdates } = + useAppContext(); + + return ( + { + setUpdateInfo(null); + checkAppUpdates(true); + }} + /> + ); +} diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx new file mode 100644 index 0000000..2f20f39 --- /dev/null +++ b/src/contexts/AppContext.tsx @@ -0,0 +1,112 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { checkInstallations, getGlobalConfig, checkForAppUpdates } from '@/lib/tauri-commands'; +import { AppContext, type TabType } from '@/contexts/AppContext.types'; + +export function AppProvider({ children }: { children: React.ReactNode }) { + // Navigation State + const [activeTab, setActiveTab] = useState('dashboard'); + const [selectedProxyToolId, setSelectedProxyToolId] = useState(undefined); + const [settingsInitialTab, setSettingsInitialTab] = useState('basic'); + const [settingsRestrictToTab, setSettingsRestrictToTab] = useState(undefined); + const [restrictedPage, setRestrictedPage] = useState(undefined); + const [tokenStatsParams, setTokenStatsParams] = useState<{ + sessionId?: string; + toolType?: ToolType; + }>({}); + + // Data State + const [tools, setTools] = useState([]); + const [toolsLoading, setToolsLoading] = useState(true); + const [globalConfig, setGlobalConfig] = useState(null); + const [configLoading, setConfigLoading] = useState(false); + + // Update State + const [updateInfo, setUpdateInfo] = useState(null); + const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false); + const [updateCheckDone, setUpdateCheckDone] = useState(false); + + const refreshTools = useCallback(async () => { + try { + setToolsLoading(true); + const result = await checkInstallations(); + setTools(result); + } catch (error) { + console.error('Failed to load tools:', error); + } finally { + setToolsLoading(false); + } + }, []); + + const refreshGlobalConfig = useCallback(async () => { + try { + setConfigLoading(true); + const config = await getGlobalConfig(); + setGlobalConfig(config); + } catch (error) { + console.error('Failed to load global config:', error); + } finally { + setConfigLoading(false); + } + }, []); + + const checkAppUpdates = useCallback( + async (force = false) => { + if (updateCheckDone && !force) return; + + try { + console.log('Checking for app updates...'); + const update = await checkForAppUpdates(); + setUpdateInfo(update); + + if (update.has_update) { + setIsUpdateDialogOpen(true); + } + } catch (error) { + console.error('Failed to check for updates:', error); + } finally { + setUpdateCheckDone(true); + } + }, + [updateCheckDone], + ); + + // Initial Load + useEffect(() => { + refreshTools(); + refreshGlobalConfig(); + + // Initial update check delay + const timer = setTimeout(() => { + checkAppUpdates(); + }, 1000); + return () => clearTimeout(timer); + }, [refreshTools, refreshGlobalConfig, checkAppUpdates]); + + const value = { + activeTab, + setActiveTab, + selectedProxyToolId, + setSelectedProxyToolId, + settingsInitialTab, + setSettingsInitialTab, + settingsRestrictToTab, + setSettingsRestrictToTab, + restrictedPage, + setRestrictedPage, + tokenStatsParams, + setTokenStatsParams, + tools, + toolsLoading, + refreshTools, + globalConfig, + configLoading, + refreshGlobalConfig, + updateInfo, + setUpdateInfo, + isUpdateDialogOpen, + setIsUpdateDialogOpen, + checkAppUpdates, + }; + + return {children}; +} diff --git a/src/contexts/AppContext.types.ts b/src/contexts/AppContext.types.ts new file mode 100644 index 0000000..03bbc8f --- /dev/null +++ b/src/contexts/AppContext.types.ts @@ -0,0 +1,49 @@ +import { createContext } from 'react'; +import type { ToolStatus, GlobalConfig, UpdateInfo } from '@/lib/tauri-commands'; +import type { ToolType } from '@/types/token-stats'; + +export type TabType = + | 'dashboard' + | 'tool-management' + | 'install' + | 'profile-management' + | 'balance' + | 'transparent-proxy' + | 'token-statistics' + | 'provider-management' + | 'settings' + | 'help' + | 'about'; + +export interface AppContextType { + // Navigation + activeTab: TabType; + setActiveTab: (tab: TabType) => void; + selectedProxyToolId: string | undefined; + setSelectedProxyToolId: (id: string | undefined) => void; + settingsInitialTab: string; + setSettingsInitialTab: (tab: string) => void; + settingsRestrictToTab: string | undefined; + setSettingsRestrictToTab: (tab: string | undefined) => void; + restrictedPage: string | undefined; + setRestrictedPage: (page: string | undefined) => void; + tokenStatsParams: { sessionId?: string; toolType?: ToolType }; + setTokenStatsParams: (params: { sessionId?: string; toolType?: ToolType }) => void; + + // Global Data + tools: ToolStatus[]; + toolsLoading: boolean; + refreshTools: () => Promise; + globalConfig: GlobalConfig | null; + configLoading: boolean; + refreshGlobalConfig: () => Promise; + + // Updates + updateInfo: UpdateInfo | null; + setUpdateInfo: (info: UpdateInfo | null) => void; + isUpdateDialogOpen: boolean; + setIsUpdateDialogOpen: (open: boolean) => void; + checkAppUpdates: (force?: boolean) => Promise; +} + +export const AppContext = createContext(undefined); diff --git a/src/hooks/useAppContext.ts b/src/hooks/useAppContext.ts new file mode 100644 index 0000000..8f2cb70 --- /dev/null +++ b/src/hooks/useAppContext.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { AppContext } from '@/contexts/AppContext.types'; + +export function useAppContext() { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error('useAppContext must be used within an AppProvider'); + } + return context; +} diff --git a/src/index.css b/src/index.css index 362b897..89beee0 100644 --- a/src/index.css +++ b/src/index.css @@ -5,47 +5,47 @@ @layer base { :root { --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + --foreground: 240 10% 3.9%; --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; - --primary: 222.2 47.4% 11.2%; - --primary-foreground: 210 40% 98%; - --secondary: 210 40% 96.1%; - --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 210 40% 96.1%; - --muted-foreground: 215.4 16.3% 46.9%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --popover-foreground: 240 10% 3.9%; + --primary: 226 70% 55.5%; /* Indigo 600 */ + --primary-foreground: 0 0% 98%; + --secondary: 240 4.8% 95.9%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; - --border: 214.3 31.8% 91.4%; - --input: 214.3 31.8% 91.4%; - --ring: 222.2 84% 4.9%; - --radius: 0.5rem; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 226 70% 55.5%; + --radius: 0.75rem; } .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; - --primary: 189 94% 43%; - --primary-foreground: 210 40% 98%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; - --muted: 217.2 32.6% 17.5%; - --muted-foreground: 215 20.2% 65.1%; - --accent: 217.2 32.6% 17.5%; - --accent-foreground: 210 40% 98%; + --background: 240 10% 3.9%; /* Zinc 950 */ + --foreground: 0 0% 98%; + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 226 70% 55.5%; /* Indigo 600 - keeping it vibrant */ + --primary-foreground: 0 0% 98%; + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; - --destructive-foreground: 210 40% 98%; - --border: 217.2 32.6% 17.5%; - --input: 217.2 32.6% 17.5%; - --ring: 189 94% 43%; + --destructive-foreground: 0 0% 98%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 226 70% 55.5%; } } @@ -54,6 +54,26 @@ @apply border-border; } body { - @apply bg-background text-foreground; + @apply bg-background text-foreground antialiased selection:bg-primary/20 selection:text-primary; + } +} + +@layer utilities { + .custom-scrollbar::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + .custom-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .custom-scrollbar::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.3); + border-radius: 4px; + } + + .custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: hsl(var(--muted-foreground) / 0.5); } } diff --git a/src/pages/AboutPage/index.tsx b/src/pages/AboutPage/index.tsx index df3b6e2..d83e4ba 100644 --- a/src/pages/AboutPage/index.tsx +++ b/src/pages/AboutPage/index.tsx @@ -6,16 +6,12 @@ import { Badge } from '@/components/ui/badge'; import { Info, RefreshCw, Github, Globe } from 'lucide-react'; import { getCurrentAppVersion } from '@/services/update'; import { PageContainer } from '@/components/layout/PageContainer'; -import type { UpdateInfo } from '@/lib/tauri-commands'; +import { useAppContext } from '@/hooks/useAppContext'; import duckLogo from '@/assets/duck-logo.png'; -interface AboutPageProps { - updateInfo?: UpdateInfo | null; - onUpdateCheck?: () => void; -} - -export function AboutPage({ onUpdateCheck }: AboutPageProps) { +export function AboutPage() { const [version, setVersion] = useState('Loading...'); + const { checkAppUpdates } = useAppContext(); useEffect(() => { getCurrentAppVersion() @@ -26,13 +22,12 @@ export function AboutPage({ onUpdateCheck }: AboutPageProps) { }); }, []); - return ( - -
-

关于 DuckCoding

-

应用信息与版本管理

-
+ const onUpdateCheck = () => { + checkAppUpdates(true); + }; + return ( +
diff --git a/src/pages/BalancePage/components/BalanceTable.tsx b/src/pages/BalancePage/components/BalanceTable.tsx new file mode 100644 index 0000000..5018a85 --- /dev/null +++ b/src/pages/BalancePage/components/BalanceTable.tsx @@ -0,0 +1,154 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { RefreshCw, Pencil, Trash2, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'; +import type { BalanceConfig, BalanceRuntimeState } from '../types'; +import { formatDistanceToNow } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; + +interface BalanceTableProps { + configs: BalanceConfig[]; + stateMap: Record; + onRefresh: (id: string) => void; + onEdit: (config: BalanceConfig) => void; + onDelete: (id: string) => void; +} + +export function BalanceTable({ + configs, + stateMap, + onRefresh, + onEdit, + onDelete, +}: BalanceTableProps) { + const formatTime = (timestamp?: number) => { + if (!timestamp) return '-'; + try { + return formatDistanceToNow(timestamp, { addSuffix: true, locale: zhCN }); + } catch { + return '-'; + } + }; + + const formatCurrency = (amount?: number) => { + if (amount === undefined || amount === null) return '-'; + return `$${amount.toFixed(4)}`; + }; + + const getStatusIcon = (state?: BalanceRuntimeState) => { + if (!state) return -; + if (state.loading) return ; + if (state.error) + return ( + + + + ); + if (state.lastResult) return ; + return -; + }; + + const getUsagePercentage = (data?: any) => { + if (!data || !data.total) return 0; + const used = data.used || 0; + const total = data.total || 1; // avoid division by zero + const percent = (used / total) * 100; + return Math.min(Math.max(percent, 0), 100); + }; + + return ( +
+ + + + 配置名称 + 状态 + 剩余余额 + 已用额度 + 总额度 + 使用情况 + 最后更新 + 操作 + + + + {configs.map((config) => { + const state = stateMap[config.id]; + const data = state?.lastResult; + const percentage = getUsagePercentage(data); + + return ( + + {config.name} + {getStatusIcon(state)} + + {formatCurrency(data?.remaining)} + + + {formatCurrency(data?.used)} + + + {formatCurrency(data?.total)} + + + {data?.total ? ( +
+ + + {percentage.toFixed(1)}% + +
+ ) : ( + - + )} +
+ + {formatTime(state?.lastFetchedAt)} + + +
+ + + +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/pages/BalancePage/index.tsx b/src/pages/BalancePage/index.tsx index 59e9480..1b42999 100644 --- a/src/pages/BalancePage/index.tsx +++ b/src/pages/BalancePage/index.tsx @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react'; import { Button } from '@/components/ui/button'; import { PageContainer } from '@/components/layout/PageContainer'; -import { Separator } from '@/components/ui/separator'; import { Loader2, Plus, RefreshCw } from 'lucide-react'; import { useBalanceConfigs } from './hooks/useBalanceConfigs'; import { useApiKeys } from './hooks/useApiKeys'; @@ -10,6 +9,8 @@ import { BalanceConfig, BalanceFormValues } from './types'; import { EmptyState } from './components/EmptyState'; import { ConfigCard } from './components/ConfigCard'; import { ConfigFormDialog } from './components/ConfigFormDialog'; +import { BalanceTable } from './components/BalanceTable'; +import { ViewToggle, ViewMode } from '@/components/common/ViewToggle'; import { useToast } from '@/hooks/use-toast'; function createId() { @@ -24,6 +25,7 @@ export function BalancePage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingConfig, setEditingConfig] = useState(null); const [refreshingAll, setRefreshingAll] = useState(false); + const [viewMode, setViewMode] = useState('grid'); const { toast } = useToast(); const { stateMap, refreshOne, refreshAll } = useBalanceMonitor(configs, getApiKey, true); @@ -139,6 +141,18 @@ export function BalancePage() { return setDialogOpen(true)} />; } + if (viewMode === 'list') { + return ( + + ); + } + return (
{sortedConfigs.map((config) => ( @@ -155,50 +169,46 @@ export function BalancePage() { ); }; - return ( - -
-
-
-

余额监控

-

- 管理多个 API 余额配置,支持自定义提取器脚本(API Key 可选择保存到文件) -

-
-
- - -
-
- - + const pageActions = ( +
+ +
+ + +
+ ); - {renderContent()} -
+ return ( + + {renderContent()} (null); @@ -166,9 +164,6 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo title: '更新成功', description: `${getToolDisplayName(toolId)} ${result.message}`, }); - // 更新成功后,handleUpdate 已经设置了 hasUpdate: false - // 不需要再调用 handleRefreshToolStatus 和 checkSingleToolUpdate - // 因为这会导致状态竞态问题 } else { toast({ title: '更新失败', @@ -179,18 +174,18 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo }; // 切换到配置页面 - const switchToConfig = (toolId?: string) => { - window.dispatchEvent(new CustomEvent('navigate-to-config', { detail: { toolId } })); + const switchToConfig = (_toolId?: string) => { + setActiveTab('profile-management'); }; // 切换到安装页面 const switchToInstall = () => { - window.dispatchEvent(new CustomEvent('navigate-to-install')); + setActiveTab('install'); }; - // 切换到安装页面 + // 切换到工具列表页面 const switchToList = () => { - window.dispatchEvent(new CustomEvent('navigate-to-list')); + setActiveTab('tool-management'); }; // 处理供应商切换(持久化到后端) @@ -213,11 +208,9 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo // 处理实例选择变更 const handleInstanceChange = async (toolId: string, instanceId: string) => { - // 使用 Hook 提供的函数,直接保存 instance_id const result = await setInstanceSelection(toolId, instanceId); if (result.success) { - // 获取实例的 label 用于提示 const instances = getInstanceOptions(toolId); const selectedInstance = instances.find((opt) => opt.value === instanceId); @@ -226,7 +219,6 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo description: `${getToolDisplayName(toolId)} 已切换到 ${selectedInstance?.label || instanceId}`, }); - // 切换成功后重新加载工具实例数据以刷新UI try { await loadToolInstances(); } catch (error) { @@ -246,9 +238,7 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo // 确保始终显示这三个工具,不论是否安装 const displayTools = FIXED_TOOL_IDS.map((toolId) => { - // 从后端数据中查找该工具 const foundTool = tools.find((t) => t.id === toolId); - // 如果找到则使用后端数据,否则创建占位数据 return ( foundTool || { id: toolId, @@ -263,12 +253,32 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo ); }); - return ( - -
-

仪表板

-
+ const installedCount = displayTools.filter((t) => t.installed).length; + const updateCount = displayTools.filter((t) => t.hasUpdate).length; + const pageActions = ( +
+ + +
+ ); + + return ( + {loading ? (
@@ -276,58 +286,63 @@ export function DashboardPage({ tools: toolsProp, loading: loadingProp }: Dashbo
) : ( <> - {/* 更新检查提示 */} {updateCheckMessage && } -
- {/* 第一段:工具卡片 + 操作按钮 */} -
-
-

工具状态

-
- - -
-
+ {/* 状态概览卡片 */} +
+ + + 已安装工具 + + + +
{installedCount}
+

+ 共 {FIXED_TOOL_IDS.length} 个支持工具 +

+
+
+ + + 可用更新 + 0 ? 'text-amber-500' : 'text-muted-foreground'}`} + /> + + +
{updateCount}
+

建议及时更新以获取新特性

+
+
+ + + 供应商状态 + + + +
{quota ? '正常' : '-'}
+

+ {providers.length > 0 ? `已配置 ${providers.length} 个供应商` : '暂无供应商配置'} +

+
+
+ + + 系统状态 + + + +
运行中
+

所有服务正常运行

+
+
+
- {/* 工具卡片列表 */} -
+
+ {/* 工具状态 */} +
+

工具管理

+
{displayTools.map((tool) => (
- {/* 第二段:供应商标签页 */} -
-
-

供应商与用量统计

+ {/* 供应商与用量 */} +
+
+

供应商与用量统计

void; -} +export function HelpPage() { + const onShowOnboarding = () => { + window.dispatchEvent(new CustomEvent('request-show-onboarding')); + }; -export function HelpPage({ onShowOnboarding }: HelpPageProps) { return ( -
-
-

帮助中心

-

获取使用帮助、查看文档或反馈问题

-
- +
{/* 新手引导 */} @@ -137,6 +133,6 @@ export function HelpPage({ onShowOnboarding }: HelpPageProps) {
-
+ ); } diff --git a/src/pages/InstallationPage/index.tsx b/src/pages/InstallationPage/index.tsx index 1121c96..dc5304c 100644 --- a/src/pages/InstallationPage/index.tsx +++ b/src/pages/InstallationPage/index.tsx @@ -5,18 +5,12 @@ import { MirrorStaleDialog } from '@/components/dialogs/MirrorStaleDialog'; import { ToolCard } from './components/ToolCard'; import { useInstallation } from './hooks/useInstallation'; import { useToast } from '@/hooks/use-toast'; +import { useAppContext } from '@/hooks/useAppContext'; import type { ToolStatus } from '@/lib/tauri-commands'; -interface InstallationPageProps { - tools: ToolStatus[]; - loading: boolean; -} - -export function InstallationPage({ - tools: toolsProp, - loading: loadingProp, -}: InstallationPageProps) { +export function InstallationPage() { const { toast } = useToast(); + const { tools: toolsProp, toolsLoading: loadingProp, refreshTools } = useAppContext(); const [tools, setTools] = useState(toolsProp); const [loading, setLoading] = useState(loadingProp); @@ -40,11 +34,6 @@ export function InstallationPage({ setLoading(loadingProp); }, [toolsProp, loadingProp]); - // 通知父组件刷新工具列表 - const refreshTools = () => { - window.dispatchEvent(new CustomEvent('refresh-tools')); - }; - // 安装工具处理 const onInstall = async (toolId: string) => { const result = await handleInstall(toolId); @@ -110,12 +99,7 @@ export function InstallationPage({ }; return ( - -
-

安装工具

-

选择并安装您需要的 AI 开发工具

-
- + {loading ? (
diff --git a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx index b936caf..7af0b67 100644 --- a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx +++ b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx @@ -1,9 +1,21 @@ /** - * 当前生效 Profile 卡片组件 + * 当前生效 Profile 卡片组件 - Clean / Professional Design */ import { useState, useEffect } from 'react'; -import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; +import { + ChevronDown, + ChevronUp, + Loader2, + Server, + Terminal, + Laptop, + Settings, + RefreshCw, + CheckCircle2, + Zap, + Download, +} from 'lucide-react'; import type { ProfileGroup } from '@/types/profile'; import type { ToolInstance, ToolType } from '@/types/tool-management'; import { getToolInstances, checkUpdate, updateToolInstance } from '@/lib/tauri-commands'; @@ -15,27 +27,21 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; import { ToolAdvancedConfigDialog } from '@/components/ToolAdvancedConfigDialog'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; interface ActiveProfileCardProps { group: ProfileGroup; proxyRunning: boolean; } -// 工具类型显示名称映射 -const TOOL_TYPE_LABELS: Record = { - Local: '本地', - WSL: 'WSL', - SSH: 'SSH', -}; - -// 工具类型 Badge 颜色 -const TOOL_TYPE_VARIANTS: Record = { - Local: 'default', - WSL: 'secondary', - SSH: 'outline', +const TOOL_ICONS: Record = { + Local: Laptop, + WSL: Terminal, + SSH: Server, }; export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProps) { @@ -46,16 +52,12 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp const [detailsExpanded, setDetailsExpanded] = useState(false); const [loading, setLoading] = useState(true); - // 更新相关状态 const [hasUpdate, setHasUpdate] = useState(false); const [checkingUpdate, setCheckingUpdate] = useState(false); const [updating, setUpdating] = useState(false); const [latestVersion, setLatestVersion] = useState(null); - - // 高级配置 Dialog 状态 const [advancedConfigOpen, setAdvancedConfigOpen] = useState(false); - // 加载工具实例 useEffect(() => { const loadInstances = async () => { try { @@ -63,8 +65,6 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp const allInstances = await getToolInstances(); const instances = allInstances[group.tool_id] || []; setToolInstances(instances); - - // 默认选中 Local 实例(如果存在) const localInstance = instances.find((i) => i.tool_type === 'Local'); if (localInstance) { setSelectedInstanceId(localInstance.instance_id); @@ -77,334 +77,252 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp setLoading(false); } }; - loadInstances(); }, [group.tool_id]); - // 获取当前选中的实例 const selectedInstance = toolInstances.find((i) => i.instance_id === selectedInstanceId); - // 处理实例切换 const handleInstanceChange = (instanceId: string) => { setSelectedInstanceId(instanceId); - setHasUpdate(false); // 切换实例后重置更新状态 - setLatestVersion(null); // 清除最新版本信息 + setHasUpdate(false); + setLatestVersion(null); }; - // 检测更新 const handleCheckUpdate = async () => { if (!selectedInstance) return; - try { setCheckingUpdate(true); const result = await checkUpdate(group.tool_id); - if (result.has_update) { setHasUpdate(true); setLatestVersion(result.latest_version || null); - toast({ - title: '发现新版本', - description: `${group.tool_name}: ${result.current_version || '未知'} → ${result.latest_version || '未知'}`, - }); + toast({ title: '发现新版本', description: `v${result.latest_version}` }); } else { setHasUpdate(false); - setLatestVersion(result.latest_version || null); - toast({ - title: '已是最新版本', - description: `${group.tool_name} 当前版本: ${result.current_version || '未知'}`, - }); + setLatestVersion(null); + toast({ title: '已是最新版本' }); } - } catch (error) { - toast({ - title: '检测失败', - description: error instanceof Error ? error.message : '检测更新失败', - variant: 'destructive', - }); + } catch { + toast({ title: '检测失败', variant: 'destructive' }); } finally { setCheckingUpdate(false); } }; - // 执行更新 const handleUpdate = async () => { if (!selectedInstance) return; - try { setUpdating(true); - toast({ - title: '正在更新', - description: `正在更新 ${group.tool_name}...`, - }); - const result = await updateToolInstance(selectedInstance.instance_id); - if (result.success) { setHasUpdate(false); - toast({ - title: '更新成功', - description: `${group.tool_name} 已更新到 ${result.latest_version || '最新版本'}`, - }); - - // 重新加载工具实例以获取新版本号 + toast({ title: '更新成功' }); const allInstances = await getToolInstances(); const instances = allInstances[group.tool_id] || []; setToolInstances(instances); } else { - toast({ - title: '更新失败', - description: result.message || '未知错误', - variant: 'destructive', - }); + toast({ title: '更新失败', description: result.message, variant: 'destructive' }); } - } catch (error) { - toast({ - title: '更新失败', - description: error instanceof Error ? error.message : '更新失败', - variant: 'destructive', - }); + } catch { + toast({ title: '更新失败', variant: 'destructive' }); } finally { setUpdating(false); } }; return ( -
-
- {/* 左侧:状态信息 */} -
-
-
-

{group.tool_name}

- - {activeProfile - ? proxyRunning - ? '透明代理模式' - : '激活中' - : proxyRunning - ? '透明代理模式' - : '未激活'} - - {(activeProfile || proxyRunning) && ( - <> - - {!proxyRunning ? `配置:${activeProfile?.name}` : '配置:透明代理'} - - {hasUpdate && ( - - 有更新 - - )} - + +
+ {/* Left: Info Area */} +
+ {/* Icon Box */} +
-
- {selectedInstance?.version ? ( - <> - - 当前版本:{selectedInstance.version} - - {latestVersion && ( - - 最新版本:{latestVersion} - - )} - + > + {proxyRunning ? ( + ) : ( - 未检测到版本信息 + )}
+ +
+
+

{group.tool_name}

+ {hasUpdate && ( + + + + + )} +
+
+ {proxyRunning ? ( +
+ 透明代理模式 +
+ ) : activeProfile ? ( +
+ 当前配置: + {activeProfile.name} +
+ ) : ( + 未激活任何配置 + )} + + {selectedInstance?.version && ( +
+ + v{selectedInstance.version} +
+ )} + + {hasUpdate && latestVersion && ( +
+ New v{latestVersion} +
+ )} +
+
-
- {/* 右侧控制区域 */} -
- {/* 第一行:工具实例选择器 + 详情按钮 */} -
- {/* 工具实例选择器 */} + {/* Right: Controls Area */} +
+ {/* Instance Selector */} {!loading && toolInstances.length > 0 && ( - + + - - {toolInstances.map((instance) => ( - -
- - {TOOL_TYPE_LABELS[instance.tool_type]} - - - {instance.tool_type === 'WSL' && instance.wsl_distro - ? instance.wsl_distro - : instance.tool_type === 'SSH' && instance.ssh_config?.display_name - ? instance.ssh_config.display_name - : instance.tool_name} - - {instance.version && ( - v{instance.version} - )} -
-
- ))} + + {toolInstances.map((instance) => { + const Icon = TOOL_ICONS[instance.tool_type]; + return ( + +
+ + + {instance.tool_type === 'WSL' + ? instance.wsl_distro + : instance.tool_type === 'SSH' + ? instance.ssh_config?.display_name + : 'Local'} + +
+
+ ); + })}
)} - {/* 详情展开/折叠按钮 */} - {activeProfile && ( + {/* Actions */} +
+ - )} -
- - {/* 第二行:小按钮组 */} -
- {/* 高级配置按钮 */} - - {/* 检测更新/立即更新按钮 */} - )} - +
-
- {/* 配置详情 */} - {activeProfile ? ( - <> - {/* 详细信息(可折叠) */} - {detailsExpanded && ( -
- {proxyRunning ? ( -
-

透明代理运行中

-

配置详情已由透明代理接管

-
- ) : ( -
- - - - {selectedInstance && ( - <> - - {selectedInstance.version && ( - - )} - {selectedInstance.tool_type === 'WSL' && selectedInstance.wsl_distro && ( - - )} - {selectedInstance.tool_type === 'SSH' && selectedInstance.ssh_config && ( - <> - - - - )} - - )} - {activeProfile.switched_at && ( -
- 最后切换: {new Date(activeProfile.switched_at).toLocaleString('zh-CN')} -
- )} -
- )} + {/* Details Panel */} + {detailsExpanded && activeProfile && !proxyRunning && ( + <> + +
+ + +
- )} - - ) : null} + + )} + - {/* 高级配置 Dialog */} -
+ ); } -// 配置字段显示组件(参考 ProxyControlBar 的 ProxyDetails) -interface ConfigFieldProps { - label: string; - value: string; -} - -function ConfigField({ label, value }: ConfigFieldProps) { +function DetailItem({ label, value }: { label: string; value: string }) { return (
- {label} - {value} + + {label} + +

+ {value} +

); } diff --git a/src/pages/ProfileManagementPage/components/HelpDialog.tsx b/src/pages/ProfileManagementPage/components/HelpDialog.tsx new file mode 100644 index 0000000..9dc7d85 --- /dev/null +++ b/src/pages/ProfileManagementPage/components/HelpDialog.tsx @@ -0,0 +1,41 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +/** + * 帮助弹窗组件 + */ +export function HelpDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + 配置管理帮助 + 了解如何使用 Profile 配置管理功能 + +
+
+

1.正常配置模式[未开启透明代理]

+

+ 切换配置后,如果工具正在运行,需要重启对应的工具才能使新配置生效。 +

+

2.透明代理模式

+

+ 切换配置请前往透明代理页面进行,切换配置后无需重启工具即可生效。 +

+
+
+
+
+ ); +} diff --git a/src/pages/ProfileManagementPage/components/ProfileCard.tsx b/src/pages/ProfileManagementPage/components/ProfileCard.tsx index 32a7cd1..5851057 100644 --- a/src/pages/ProfileManagementPage/components/ProfileCard.tsx +++ b/src/pages/ProfileManagementPage/components/ProfileCard.tsx @@ -3,9 +3,16 @@ */ import { useState } from 'react'; -import { Check, MoreVertical, Pencil, Power, Trash2, Tag, AlertCircle } from 'lucide-react'; +import { MoreVertical, Pencil, Trash2, AlertCircle, Play, CheckCircle2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + CardFooter, +} from '@/components/ui/card'; import { DropdownMenu, DropdownMenuContent, @@ -27,6 +34,7 @@ import { Badge } from '@/components/ui/badge'; import type { ProfileDescriptor } from '@/types/profile'; import { formatDistanceToNow } from 'date-fns'; import { zhCN } from 'date-fns/locale'; +import { cn } from '@/lib/utils'; interface ProfileCardProps { profile: ProfileDescriptor; @@ -90,63 +98,47 @@ export function ProfileCard({ }; const sourceInfo = getSourceInfo(); + const isActive = profile.is_active; return ( <> - +
- {profile.name} - {profile.is_active && !proxyRunning && ( - - - 激活中 - - )} + + {profile.name} + {isActive && !proxyRunning && } + +
+ - {sourceInfo.text} -
- - API Key: {profile.api_key_preview}
- - {(!profile.is_active || proxyRunning) && ( - <> - - - 激活 - - {proxyRunning && ( -
- - 透明代理运行中,请先停止代理 -
- )} - - - )} - 编辑 + 编辑配置 - -
- Base URL: - - {profile.base_url} - + +
+
+ API Key + + {profile.api_key_preview} + +
+
+ Base URL + + {profile.base_url} + +
-
- 创建于 {formatTime(profile.created_at)} +
+ + {isActive ? '切换于' : '创建于'}{' '} + {isActive && profile.switched_at + ? formatTime(profile.switched_at) + : formatTime(profile.created_at)} +
- - {profile.is_active && profile.switched_at && ( -
- 切换于 {formatTime(profile.switched_at)} -
- )} + + {/* Footer Actions */} + {!isActive && ( + + + + )} {/* 删除确认对话框 */} diff --git a/src/pages/ProfileManagementPage/components/ProfileTable.tsx b/src/pages/ProfileManagementPage/components/ProfileTable.tsx new file mode 100644 index 0000000..d87534c --- /dev/null +++ b/src/pages/ProfileManagementPage/components/ProfileTable.tsx @@ -0,0 +1,211 @@ +import { useState } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { MoreVertical, Pencil, Trash2, Power, AlertCircle, Check } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import type { ProfileDescriptor } from '@/types/profile'; +import { formatDistanceToNow } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; + +interface ProfileTableProps { + profiles: ProfileDescriptor[]; + onActivate: (profileName: string) => void; + onEdit: (profile: ProfileDescriptor) => void; + onDelete: (profileName: string) => void; + proxyRunning: boolean; +} + +export function ProfileTable({ + profiles, + onActivate, + onEdit, + onDelete, + proxyRunning, +}: ProfileTableProps) { + const [profileToDelete, setProfileToDelete] = useState(null); + + const formatTime = (isoString: string) => { + try { + return formatDistanceToNow(new Date(isoString), { + addSuffix: true, + locale: zhCN, + }); + } catch { + return '未知'; + } + }; + + const getSourceBadge = (profile: ProfileDescriptor) => { + if (profile.source.type === 'Custom') { + return ( + + 自定义 + + ); + } + return ( + + 导入: {profile.source.provider_name} + + ); + }; + + return ( + +
+ + + + 名称 + 状态 + 来源 + Base URL + 创建时间 + 最后切换 + 操作 + + + + {profiles.map((profile) => ( + + {profile.name} + + {profile.is_active ? ( + + + 激活中 + + ) : ( + + 未激活 + + )} + + {getSourceBadge(profile)} + + {profile.base_url} + + + {formatTime(profile.created_at)} + + + {profile.switched_at ? formatTime(profile.switched_at) : '-'} + + +
+ {!profile.is_active && ( + + + + + + + {proxyRunning && ( + +

+ 透明代理模式运行中 +
+ 无法切换本地配置 +

+
+ )} +
+ )} + + + + + + + onEdit(profile)}> + + 编辑 + + + setProfileToDelete(profile.name)} + className="text-destructive focus:text-destructive" + > + + 删除 + + + +
+
+
+ ))} +
+
+
+ + !open && setProfileToDelete(null)} + > + + + 确认删除 + + 确定要删除 Profile "{profileToDelete}" 吗?此操作无法撤销。 + + + + 取消 + { + if (profileToDelete) { + onDelete(profileToDelete); + setProfileToDelete(null); + } + }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + 删除 + + + + +
+ ); +} diff --git a/src/pages/ProfileManagementPage/index.tsx b/src/pages/ProfileManagementPage/index.tsx index a00829c..743cc68 100644 --- a/src/pages/ProfileManagementPage/index.tsx +++ b/src/pages/ProfileManagementPage/index.tsx @@ -12,13 +12,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; + import { PageContainer } from '@/components/layout/PageContainer'; import { ProfileCard } from './components/ProfileCard'; import { ProfileEditor } from './components/ProfileEditor'; @@ -26,7 +20,10 @@ import { ActiveProfileCard } from './components/ActiveProfileCard'; import { ImportFromProviderDialog } from './components/ImportFromProviderDialog'; import { CreateCustomProfileDialog } from './components/CreateCustomProfileDialog'; import { AmpProfileSelector } from './components/AmpProfileSelector'; +import { HelpDialog } from './components/HelpDialog'; import { useProfileManagement } from './hooks/useProfileManagement'; +import { ProfileTable } from './components/ProfileTable'; +import { ViewToggle, ViewMode } from '@/components/common/ViewToggle'; import type { ProfileToolId, ProfileFormData, ProfileDescriptor, ToolId } from '@/types/profile'; import { logoMap } from '@/utils/constants'; @@ -53,6 +50,7 @@ export default function ProfileManagementPage() { const [importDialogOpen, setImportDialogOpen] = useState(false); const [customProfileDialogOpen, setCustomProfileDialogOpen] = useState(false); const [autoTriggerGenerate, setAutoTriggerGenerate] = useState(false); + const [viewMode, setViewMode] = useState('grid'); // ImportFromProviderDialog ref 用于触发一键生成 const importDialogRef = useRef<{ triggerGenerate: () => void } | null>(null); @@ -108,33 +106,34 @@ export default function ProfileManagementPage() { }; }; - return ( - - {/* 页面标题 */} -
-
-
-

配置管理

-

- 管理所有工具的 Profile 配置,快速切换不同的 API 端点 -

-
-
- - -
-
-
+ const pageActions = ( +
+ {selectedTab !== 'amp-code' && ( + <> + +
+ + )} + + +
+ ); + return ( + {/* 错误提示 */} {error && ( -
+

加载失败: {error}

+ + + setCustomProfileDialogOpen(true)}> + + 手动创建 + + setImportDialogOpen(true)}> + + 从供应商导入 + + +
- - - - - - setCustomProfileDialogOpen(true)}> - - 手动创建 - - setImportDialogOpen(true)}> - - 从供应商导入 - - - -
- {/* Profile 卡片列表 */} - {group.profiles.length === 0 ? ( -
-

暂无 Profile 配置

-
- ) : ( -
- {group.profiles.map((profile) => ( - handleActivateProfile(profile.name)} - onEdit={() => handleEditProfile(profile)} - onDelete={() => handleDeleteProfile(profile.name)} - proxyRunning={allProxyStatus[group.tool_id]?.running || false} - /> - ))} -
- )} + {/* Profile 列表 (Grid 或 Table) */} + {group.profiles.length === 0 ? ( +
+

暂无 Profile 配置

+
+ ) : viewMode === 'grid' ? ( +
+ {group.profiles.map((profile) => ( + handleActivateProfile(profile.name)} + onEdit={() => handleEditProfile(profile)} + onDelete={() => handleDeleteProfile(profile.name)} + proxyRunning={allProxyStatus[group.tool_id]?.running || false} + /> + ))} +
+ ) : ( + + )} +
))} {/* AMP Code Tab 内容 */} - + setSelectedTab(toolId)} @@ -290,37 +306,3 @@ export default function ProfileManagementPage() {
); } - -/** - * 帮助弹窗组件 - */ -function HelpDialog({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - return ( - - - - 配置管理帮助 - 了解如何使用 Profile 配置管理功能 - -
-
-

1.正常配置模式[未开启透明代理]

-

- 切换配置后,如果工具正在运行,需要重启对应的工具才能使新配置生效。 -

-

2.透明代理模式

-

- 切换配置请前往透明代理页面进行,切换配置后无需重启工具即可生效。 -

-
-
-
-
- ); -} diff --git a/src/pages/ProviderManagementPage/components/ProviderCard.tsx b/src/pages/ProviderManagementPage/components/ProviderCard.tsx new file mode 100644 index 0000000..22c94cb --- /dev/null +++ b/src/pages/ProviderManagementPage/components/ProviderCard.tsx @@ -0,0 +1,113 @@ +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Building2, Pencil, Trash2, Coins, Globe, User, Clock } from 'lucide-react'; +import type { Provider } from '@/lib/tauri-commands'; + +interface ProviderCardProps { + provider: Provider; + onEdit: (provider: Provider) => void; + onDelete: (provider: Provider) => void; + onViewTokens: (providerId: string) => void; +} + +export function ProviderCard({ provider, onEdit, onDelete, onViewTokens }: ProviderCardProps) { + const formatTimestamp = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleString('zh-CN'); + }; + + return ( + + +
+
+
+ +
+
+ {provider.name} + + + {formatTimestamp(provider.updated_at)} + +
+
+ {provider.is_default && ( + + 默认 + + )} +
+
+ + +
+ + 官网地址 + + + {provider.website_url} + +
+ +
+ + 用户名 + +
+ {provider.username || '-'} +
+
+
+ + + + + + + + +
+ ); +} diff --git a/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx b/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx index 7f815d7..1c4f571 100644 --- a/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx +++ b/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx @@ -40,6 +40,8 @@ import { fetchProviderTokens, deleteProviderToken } from '@/lib/tauri-commands/t import { useToast } from '@/hooks/use-toast'; import { TokenFormDialog } from './TokenFormDialog'; import { ImportTokenDialog } from './ImportTokenDialog'; +import { TokenCard } from './TokenCard'; +import { ViewToggle, ViewMode } from '@/components/common/ViewToggle'; interface RemoteTokenManagementProps { provider: Provider; @@ -63,6 +65,7 @@ export function RemoteTokenManagement({ provider }: RemoteTokenManagementProps) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deletingToken, setDeletingToken] = useState(null); const [deleting, setDeleting] = useState(false); + const [viewMode, setViewMode] = useState('list'); const [page, setPage] = useState(1); const [total, setTotal] = useState(0); @@ -199,6 +202,8 @@ export function RemoteTokenManagement({ provider }: RemoteTokenManagementProps)

远程令牌

+ +
+ + + + + + + ); +} diff --git a/src/pages/ProviderManagementPage/index.tsx b/src/pages/ProviderManagementPage/index.tsx index aba6252..bda4cc5 100644 --- a/src/pages/ProviderManagementPage/index.tsx +++ b/src/pages/ProviderManagementPage/index.tsx @@ -1,6 +1,5 @@ import { PageContainer } from '@/components/layout/PageContainer'; import { Button } from '@/components/ui/button'; -import { Separator } from '@/components/ui/separator'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, @@ -18,6 +17,8 @@ import { useProviderManagement } from './hooks/useProviderManagement'; import { ProviderFormDialog } from './components/ProviderFormDialog'; import { DeleteConfirmDialog } from './components/DeleteConfirmDialog'; import { TokenManagementTab } from './components/TokenManagementTab'; +import { ProviderCard } from './components/ProviderCard'; +import { ViewToggle, ViewMode } from '@/components/common/ViewToggle'; /** * 供应商管理页面 @@ -35,6 +36,7 @@ export function ProviderManagementPage() { const [deleting, setDeleting] = useState(false); const [activeTab, setActiveTab] = useState<'providers' | 'tokens'>('providers'); const [selectedProviderId, setSelectedProviderId] = useState(null); + const [viewMode, setViewMode] = useState('list'); /** * 打开新增对话框 @@ -114,18 +116,25 @@ export function ProviderManagementPage() { setActiveTab('tokens'); }; - return ( - -
- {/* 顶部标题栏 */} -
-
- -

供应商

-
-
- + const pageActions = + activeTab === 'providers' ? ( +
+ +
+ +
+ ) : null; + return ( + +
{/* Tabs 组件 */} - 供应商管理 + 供应商列表 @@ -144,13 +153,6 @@ export function ProviderManagementPage() { {/* Tab 1: 供应商管理 */} -
- -
- {/* 错误提示 */} {error && (
@@ -169,6 +171,21 @@ export function ProviderManagementPage() {

暂无供应商,请点击「新增供应商」按钮添加

+ ) : viewMode === 'grid' ? ( +
+ {providers.map((provider) => ( + { + setDeletingProvider(p); + setDeleteDialogOpen(true); + }} + onViewTokens={handleViewTokens} + /> + ))} +
) : (
diff --git a/src/pages/SettingsPage/components/BasicSettingsTab.tsx b/src/pages/SettingsPage/components/BasicSettingsTab.tsx index bed8530..8cd6234 100644 --- a/src/pages/SettingsPage/components/BasicSettingsTab.tsx +++ b/src/pages/SettingsPage/components/BasicSettingsTab.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; -import { Settings as SettingsIcon, Info, RefreshCw, Loader2 } from 'lucide-react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { RefreshCw, Power, MonitorPlay } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { getSingleInstanceConfig, @@ -103,22 +103,25 @@ export function BasicSettingsTab() { }; return ( -
-
- -

系统设置

-
- - -
- {/* 基本设置分类 */} -
-

基本设置

- -
-
- -

开机时自动启动 DuckCoding 应用

+
+ {/* 启动设置 */} + + +
+ + 启动设置 +
+ 控制应用启动时的行为 +
+ +
+
+ +

+ 系统启动时自动运行 DuckCoding,方便快速访问。更改后立即生效。 +

+
+
-
-
- -
-

关于开机自启动

-
    -
  • - 启用:系统启动时自动运行应用,方便快速访问 -
  • -
  • - 禁用:需要手动启动应用 -
  • -
  • - 跨平台支持:Windows、macOS、Linux 均支持此功能 -
  • -
  • - 生效方式:更改后立即生效,无需重启应用 -
  • -
-
-
+ {/* 运行模式 */} + + +
+ + 运行模式
-
- - - - {/* 开发者设置分类 */} -
-

开发者设置

- -
-
- + 配置应用实例的运行方式(仅供高级用户使用) + + +
+
+

- 启用后,同时只能运行一个应用实例(生产环境) + 启用后,尝试打开第二个实例时会聚焦到现有窗口。禁用可允许同时运行多个实例。

- -
-
- -
-

关于单实例模式

-
    -
  • - 启用(推荐):打开第二个实例时会聚焦到第一个窗口,节省系统资源 -
  • -
  • - 禁用:允许同时运行多个实例,适用于多账户测试或特殊需求 -
  • -
  • - 开发环境:始终允许多实例(与正式版隔离) -
  • -
  • - 生效方式:更改后需要重启应用才能生效 -
  • -
-
-
-
-
- - {(loading || saving) && ( -
- - {loading ? '加载中...' : '保存中...'} -
- )} -
+ +
); } diff --git a/src/pages/SettingsPage/components/ProxySettingsTab.tsx b/src/pages/SettingsPage/components/ProxySettingsTab.tsx index 6402aff..ff57206 100644 --- a/src/pages/SettingsPage/components/ProxySettingsTab.tsx +++ b/src/pages/SettingsPage/components/ProxySettingsTab.tsx @@ -1,7 +1,8 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; +import { Switch } from '@/components/ui/switch'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Select, SelectContent, @@ -9,7 +10,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Info, Loader2, AlertCircle, Plus, X } from 'lucide-react'; +import { Info, ShieldCheck, Globe, ListFilter, Plus, X, PlayCircle, Loader2 } from 'lucide-react'; interface ProxySettingsTabProps { proxyEnabled: boolean; @@ -52,19 +53,16 @@ export function ProxySettingsTab({ proxyBypassUrls, setProxyBypassUrls, }: ProxySettingsTabProps) { - // 添加新的过滤规则 const addBypassRule = () => { const newUrls = [...proxyBypassUrls, '']; setProxyBypassUrls(newUrls); }; - // 删除过滤规则 const removeBypassRule = (index: number) => { const newUrls = proxyBypassUrls.filter((_, i) => i !== index); setProxyBypassUrls(newUrls); }; - // 更新过滤规则 const updateBypassRule = (index: number, value: string) => { const newUrls = [...proxyBypassUrls]; newUrls[index] = value; @@ -72,183 +70,135 @@ export function ProxySettingsTab({ }; return ( -
-
- -

网络代理配置

-
- - -
-
-
- -

通过代理服务器转发所有网络请求

+
+ {/* 总开关 */} + + +
+
+ + + 启用网络代理 + + 通过代理服务器转发所有网络请求,适用于受限网络环境 +
+
- setProxyEnabled(e.target.checked)} - className="h-4 w-4 rounded border-slate-300" - /> -
+ + - {proxyEnabled && ( - <> -
- - -
+ {proxyEnabled && ( +
+ {/* 服务器配置 */} + + + + + 服务器配置 + + + +
+ + +
-
- + setProxyHost(e.target.value)} />
- + setProxyPort(e.target.value)} />
-
+
+
-
+ {/* 认证信息 */} + + + + + 身份认证 (可选) + + +
- + setProxyUsername(e.target.value)} />
- + setProxyPassword(e.target.value)} />
-
- - {/* 代理过滤列表 */} -
-
- -

- 这些URL/IP将不使用代理,例如本地地址、内网地址等 -

-
- -
- {proxyBypassUrls.map((url, index) => ( -
- updateBypassRule(index, e.target.value)} - placeholder="例如: 127.0.0.1, localhost, 192.168.*" - className="flex-1" - /> - -
- ))} - - -
+ + -
-

支持格式示例:

-
    -
  • 域名: localhost, 127.0.0.1
  • -
  • IP段: 192.168.*, 10.*
  • -
  • 通配符: *.local, *.lan
  • -
-
-
- - {/* 测试代理连接 */} -
+ {/* 连接测试 */} + + + + + 连接测试 + + +
-
- -
- - - -
+ +
+ +
setProxyTestUrl(e.target.value)} + placeholder="https://..." /> -

选择或输入一个URL来测试代理连接

- -
- - )} -
+ + + + {/* 绕过列表 */} + + +
+ + + 绕过列表 (Bypass) + + +
+ + 以下地址将直接连接,不经过代理服务器。支持 IP、域名和通配符。 + +
+ + {proxyBypassUrls.length === 0 ? ( +
+

暂无规则,所有流量都将经过代理

+
+ ) : ( +
+ {proxyBypassUrls.map((url, index) => ( +
+ updateBypassRule(index, e.target.value)} + placeholder="例如: localhost" + /> + +
+ ))} +
+ )} +
+
+
+ )}
); } diff --git a/src/pages/SettingsPage/index.tsx b/src/pages/SettingsPage/index.tsx index fdc62f9..f857541 100644 --- a/src/pages/SettingsPage/index.tsx +++ b/src/pages/SettingsPage/index.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Loader2, Save } from 'lucide-react'; import { PageContainer } from '@/components/layout/PageContainer'; import { useToast } from '@/hooks/use-toast'; +import { useAppContext } from '@/hooks/useAppContext'; import { useSettingsForm } from './hooks/useSettingsForm'; import { BasicSettingsTab } from './components/BasicSettingsTab'; import { ProxySettingsTab } from './components/ProxySettingsTab'; @@ -11,28 +12,24 @@ import { LogSettingsTab } from './components/LogSettingsTab'; import { ConfigGuardTab } from './components/ConfigGuardTab'; import { TokenStatsTab } from './components/TokenStatsTab'; import { PricingTab } from './components/PricingTab'; -import type { GlobalConfig, UpdateInfo } from '@/lib/tauri-commands'; - -interface SettingsPageProps { - globalConfig: GlobalConfig | null; - configLoading: boolean; - onConfigChange: () => void; - updateInfo?: UpdateInfo | null; - initialTab?: string; - restrictToTab?: string; // 限制只能访问特定 tab - onUpdateCheck?: () => void; -} -export function SettingsPage({ - globalConfig, - onConfigChange, - updateInfo: _updateInfo, - initialTab = 'basic', - restrictToTab, - onUpdateCheck: _onUpdateCheck, -}: SettingsPageProps) { +export function SettingsPage() { const { toast } = useToast(); - const [activeTab, setActiveTab] = useState(initialTab); + const { + globalConfig, + refreshGlobalConfig: onConfigChange, + settingsInitialTab, + settingsRestrictToTab: restrictToTab, + } = useAppContext(); + + const [activeTab, setActiveTab] = useState(settingsInitialTab || 'basic'); + + // Update activeTab when initialTab changes in context + useEffect(() => { + if (settingsInitialTab) { + setActiveTab(settingsInitialTab); + } + }, [settingsInitialTab]); // 如果有 restrictToTab,阻止切换到其他 tab const handleTabChange = (value: string) => { @@ -108,7 +105,7 @@ export function SettingsPage({ }; return ( - + {/* 引导模式提示 */} {restrictToTab && (
@@ -137,11 +134,6 @@ export function SettingsPage({
)} -
-

全局设置

-

配置 DuckCoding 的全局参数和功能

-
- diff --git a/src/pages/TokenStatisticsPage/index.tsx b/src/pages/TokenStatisticsPage/index.tsx index c384b95..24efb48 100644 --- a/src/pages/TokenStatisticsPage/index.tsx +++ b/src/pages/TokenStatisticsPage/index.tsx @@ -11,6 +11,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { PageContainer } from '@/components/layout/PageContainer'; import { ArrowLeft, Database, RefreshCw, AlertCircle, Calendar } from 'lucide-react'; import { useToast } from '@/hooks/use-toast'; import { RealtimeStats } from '../TransparentProxyPage/components/RealtimeStats'; @@ -198,187 +199,187 @@ export default function TokenStatisticsPage({ } }; - return ( -
- {/* 页头 */} -
-
+ const pageActions = ( +
+ {/* 数据库信息 */} + {summary && ( +
- -

Token 统计

+ + 总记录: + {summary.total_logs.toLocaleString('zh-CN')} +
+
+
+ {formatQueryTimeRange()}
-

查看透明代理的 Token 使用情况和请求历史

-
- - {/* 操作按钮 */} -
- {/* 数据库信息 */} - {summary && ( -
-
- - 总记录: - {summary.total_logs.toLocaleString('zh-CN')} -
-
-
- {formatQueryTimeRange()} -
-
- )} - - {/* 时间范围选择器 */} - - - {/* 时间粒度选择器 */} - - - {/* 刷新按钮 */} -
-
- - {/* 实时统计(如果提供了 sessionId 和 toolType) */} - {sessionId && toolType && } - - {/* 仪表盘 - 关键指标 */} - {costSummary && } - - {/* 趋势图表 */} - {trendsData.length > 0 && ( - <> - {/* 成本趋势 */} - `$${value.toFixed(4)}`, - }, - { - key: 'input_price', - name: '输入成本', - color: '#3b82f6', - formatter: (value) => `$${value.toFixed(4)}`, - }, - { - key: 'output_price', - name: '输出成本', - color: '#f59e0b', - formatter: (value) => `$${value.toFixed(4)}`, - }, - ]} - yAxisLabel="成本 (USD)" - height={300} - /> - - {/* Token 使用趋势 */} - value.toLocaleString(), - }, - { - key: 'output_tokens', - name: '输出 Tokens', - color: '#f59e0b', - formatter: (value) => value.toLocaleString(), - }, - { - key: 'cache_read_tokens', - name: '缓存读取 Tokens', - color: '#8b5cf6', - formatter: (value) => value.toLocaleString(), - }, - ]} - yAxisLabel="Token 数量" - height={300} - /> - - {/* 响应时间趋势 */} - - value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`, - }, - ]} - yAxisLabel="响应时间 (ms)" - height={300} - /> - )} - {/* 历史日志表格 */} - - - {/* 配置提示 */} - {config && config.auto_cleanup_enabled && ( -
- -
-

自动清理已启用

-

- 系统将自动清理 - {config.retention_days && ` ${config.retention_days} 天前的日志`} - {config.retention_days && config.max_log_count && ',并'} - {config.max_log_count && - ` 保留最多 ${config.max_log_count.toLocaleString('zh-CN')} 条记录`} - 。可在设置页面修改配置。 -

+ {/* 时间范围选择器 */} + + + {/* 时间粒度选择器 */} + + + {/* 刷新按钮 */} + + + {/* 返回按钮 */} + +
+ ); + + return ( +
+ + {/* 实时统计(如果提供了 sessionId 和 toolType) */} + {sessionId && toolType && } + + {/* 仪表盘 - 关键指标 */} + {costSummary && } + + {/* 趋势图表 */} + {trendsData.length > 0 && ( + <> + {/* 成本趋势 */} + `$${value.toFixed(4)}`, + }, + { + key: 'input_price', + name: '输入成本', + color: '#3b82f6', + formatter: (value) => `$${value.toFixed(4)}`, + }, + { + key: 'output_price', + name: '输出成本', + color: '#f59e0b', + formatter: (value) => `$${value.toFixed(4)}`, + }, + ]} + yAxisLabel="成本 (USD)" + height={300} + /> + + {/* Token 使用趋势 */} + value.toLocaleString(), + }, + { + key: 'output_tokens', + name: '输出 Tokens', + color: '#f59e0b', + formatter: (value) => value.toLocaleString(), + }, + { + key: 'cache_read_tokens', + name: '缓存读取 Tokens', + color: '#8b5cf6', + formatter: (value) => value.toLocaleString(), + }, + ]} + yAxisLabel="Token 数量" + height={300} + /> + + {/* 响应时间趋势 */} + + value >= 1000 ? `${(value / 1000).toFixed(2)}s` : `${Math.round(value)}ms`, + }, + ]} + yAxisLabel="响应时间 (ms)" + height={300} + /> + + )} + + {/* 历史日志表格 */} + + + {/* 配置提示 */} + {config && config.auto_cleanup_enabled && ( +
+ +
+

自动清理已启用

+

+ 系统将自动清理 + {config.retention_days && ` ${config.retention_days} 天前的日志`} + {config.retention_days && config.max_log_count && ',并'} + {config.max_log_count && + ` 保留最多 ${config.max_log_count.toLocaleString('zh-CN')} 条记录`} + 。可在设置页面修改配置。 +

+
-
- )} + )} + {/* 自定义时间范围对话框 */} void; + onUpdate: (instanceId: string) => void; + onDelete: (instanceId: string) => void; + onVersionManage?: (instanceId: string) => void; + updateInfo?: UpdateInfo; + checkingUpdate: boolean; + updating: boolean; +} + +export function ToolInstanceCard({ + instance, + onCheckUpdate, + onUpdate, + onDelete, + onVersionManage, + updateInfo, + checkingUpdate, + updating, +}: ToolInstanceCardProps) { + const isDuckCoding = instance.tool_source === ToolSource.DuckCodingManaged; + const isSSH = instance.tool_type === ToolType.SSH; + const canDelete = isSSH && !instance.is_builtin; + const hasUpdate = updateInfo?.hasUpdate ?? false; + + const getTypeIcon = (type: ToolType) => { + switch (type) { + case ToolType.Local: + return ; + case ToolType.WSL: + return ; + case ToolType.SSH: + return ; + default: + return ; + } + }; + + const getSourceBadge = (source: ToolSource) => { + if (source === ToolSource.DuckCodingManaged) { + return ( + + DuckCoding + + ); + } + return ( + + External + + ); + }; + + return ( + + +
+
+
{getTypeIcon(instance.tool_type)}
+
+ {instance.tool_type} +
+ {getSourceBadge(instance.tool_source)} + {instance.installed ? ( + + 已安装 + + ) : ( + + 未安装 + + )} +
+
+
+
+
+ + +
+ 版本 +
+ {instance.version || '-'} + {hasUpdate && updateInfo?.latestVersion && ( + + New: {updateInfo.latestVersion} + + )} +
+
+ +
+ 路径 +
+ {instance.install_path || '-'} +
+
+
+ + + {hasUpdate ? ( + + ) : ( + + )} + + {isDuckCoding && ( + + )} + + {canDelete && ( + + )} + +
+ ); +} diff --git a/src/pages/ToolManagementPage/components/ToolListSection.tsx b/src/pages/ToolManagementPage/components/ToolListSection.tsx index 6d973ef..e9d1291 100644 --- a/src/pages/ToolManagementPage/components/ToolListSection.tsx +++ b/src/pages/ToolManagementPage/components/ToolListSection.tsx @@ -11,6 +11,8 @@ import { Badge } from '@/components/ui/badge'; import { RefreshCw, Trash2, History, Download } from 'lucide-react'; import type { ToolInstance } from '@/types/tool-management'; import { ToolType, ToolSource } from '@/types/tool-management'; +import { ToolInstanceCard } from './ToolInstanceCard'; +import type { ViewMode } from '@/components/common/ViewToggle'; // 更新状态信息 interface UpdateInfo { @@ -31,6 +33,7 @@ interface ToolListSectionProps { updateInfoMap: Record; checkingUpdate: string | null; updating: string | null; + viewMode?: ViewMode; } export function ToolListSection({ @@ -42,6 +45,7 @@ export function ToolListSection({ updateInfoMap, checkingUpdate, updating, + viewMode = 'list', }: ToolListSectionProps) { const getTypeLabel = (type: ToolType) => { switch (type) { @@ -83,118 +87,141 @@ export function ToolListSection({ ); }; + if (instances.length === 0) { + return ( +
+

暂无实例

+

点击右上角"添加实例"来添加

+
+ ); + } + + if (viewMode === 'grid') { + return ( +
+ {instances.map((instance) => ( + + ))} +
+ ); + } + return ( -
- {instances.length === 0 ? ( -
-

暂无实例

-

点击右上角"添加实例"来添加

-
- ) : ( -
- - - 环境类型 - 安装来源 - 状态 - 版本 - 安装路径 - 操作 - - - - {instances.map((instance) => { - const isDuckCoding = instance.tool_source === ToolSource.DuckCodingManaged; - const isSSH = instance.tool_type === ToolType.SSH; - const canDelete = isSSH && !instance.is_builtin; - const updateInfo = updateInfoMap[instance.instance_id]; - const hasUpdate = updateInfo?.hasUpdate ?? false; - const isChecking = checkingUpdate === instance.instance_id; - const isUpdating = updating === instance.instance_id; +
+
+ + + 环境类型 + 安装来源 + 状态 + 版本 + 安装路径 + 操作 + + + + {instances.map((instance) => { + const isDuckCoding = instance.tool_source === ToolSource.DuckCodingManaged; + const isSSH = instance.tool_type === ToolType.SSH; + const canDelete = isSSH && !instance.is_builtin; + const updateInfo = updateInfoMap[instance.instance_id]; + const hasUpdate = updateInfo?.hasUpdate ?? false; + const isChecking = checkingUpdate === instance.instance_id; + const isUpdating = updating === instance.instance_id; - return ( - - {getTypeBadge(instance.tool_type)} - {getSourceBadge(instance.tool_source)} - - {instance.installed ? ( - + {getTypeBadge(instance.tool_type)} + {getSourceBadge(instance.tool_source)} + + {instance.installed ? ( + + 已安装 + + ) : ( + + 未安装 + + )} + + +
+ {instance.version || '-'} + {hasUpdate && updateInfo?.latestVersion && ( + → {updateInfo.latestVersion} + )} +
+
+ + {instance.install_path || '-'} + + +
+ {hasUpdate ? ( + ) : ( - - 未安装 - + + )} + {isDuckCoding && ( + + )} + {canDelete && ( + )} - - -
- {instance.version || '-'} - {hasUpdate && updateInfo?.latestVersion && ( - → {updateInfo.latestVersion} - )} -
-
- - {instance.install_path || '-'} - - -
- {hasUpdate ? ( - - ) : ( - - )} - {isDuckCoding && ( - - )} - {canDelete && ( - - )} -
-
- - ); - })} - -
- )} +
+ + + ); + })} + +
); } diff --git a/src/pages/ToolManagementPage/index.tsx b/src/pages/ToolManagementPage/index.tsx index 5c1a589..2a55eb5 100644 --- a/src/pages/ToolManagementPage/index.tsx +++ b/src/pages/ToolManagementPage/index.tsx @@ -9,21 +9,21 @@ import { ToolListSection } from './components/ToolListSection'; import { AddInstanceDialog } from './components/AddInstanceDialog/AddInstanceDialog'; import { VersionManagementDialog } from './components/VersionManagementDialog'; import { useToolManagement } from './hooks/useToolManagement'; -import type { ToolStatus } from '@/lib/tauri-commands'; +import { useAppContext } from '@/hooks/useAppContext'; +import { ViewToggle, ViewMode } from '@/components/common/ViewToggle'; -interface ToolManagementPageProps { - tools: ToolStatus[]; - loading: boolean; - restrictNavigation?: boolean; // 新增:引导模式限制 -} +export function ToolManagementPage() { + const { + tools: _toolsProp, + toolsLoading: loadingProp, + restrictedPage, + setActiveTab, + refreshTools: globalRefreshTools, + } = useAppContext(); + + const restrictNavigation = restrictedPage === 'tool-management'; + const [viewMode, setViewMode] = useState('list'); -export function ToolManagementPage({ - tools: _toolsProp, - loading: loadingProp, - restrictNavigation, -}: ToolManagementPageProps) { - // _toolsProp 和 loadingProp 用于全局缓存,但工具管理需要更详细的 ToolInstance 数据 - // 所以仍然需要加载完整的工具实例信息 const { groupedByTool, loading: dataLoading, @@ -40,7 +40,8 @@ export function ToolManagementPage({ // 通知父组件刷新工具列表 const onRefreshTools = () => { - window.dispatchEvent(new CustomEvent('refresh-tools')); + // Also trigger global refresh + globalRefreshTools(); refreshTools(); }; @@ -86,33 +87,28 @@ export function ToolManagementPage({ }; }, []); - return ( - - {/* 页面标题和操作按钮 */} -
-
-

工具管理

-

管理所有 AI 开发工具的安装和配置

-
-
- - - -
-
+ const pageActions = ( +
+ +
+ + + +
+ ); + return ( + {/* 引导模式提示 */} {restrictNavigation && ( @@ -145,20 +141,29 @@ export function ToolManagementPage({ {/* Tab 按工具切换 */} {!loadingProp && !dataLoading && !error && ( - - + + Claude Code - + CodeX - + Gemini CLI {/* Claude Code Tab */} - + {/* CodeX Tab */} - + {/* Gemini CLI Tab */} - + diff --git a/src/pages/TransparentProxyPage/index.tsx b/src/pages/TransparentProxyPage/index.tsx index 5d373cf..9ee0e75 100644 --- a/src/pages/TransparentProxyPage/index.tsx +++ b/src/pages/TransparentProxyPage/index.tsx @@ -129,20 +129,19 @@ export function TransparentProxyPage({ selectedToolId: initialToolId }: Transpar } return ( - - {/* 页面标题 */} -
-

透明代理

-

- 为不同 AI 编程工具提供统一的透明代理服务,支持配置热切换 -

-
- + {/* 四工具 Tab 切换 */} setSelectedToolId(val as ToolId)}> - + {SUPPORTED_TOOLS.map((tool) => ( - + {tool.name} {tool.name}