From 4c6e5181022c7a9ee796a789a4da7aad0de8294c Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sun, 18 Jan 2026 22:44:14 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(proxy):=20=E5=AE=9E=E7=8E=B0=20Codex?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E7=9A=84=E4=BC=9A=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=92=8C=20Token=20=E7=BB=9F=E8=AE=A1=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 功能概述 新增 Codex 工具的完整会话管理、Token 统计和计费支持,包括独立的提取器架构、会话 ID 处理和费用计算。 ## 核心变更 ### Token 提取器架构 - 新增 `CodexTokenExtractor` 实现 TokenExtractor trait - 支持 Codex 特有的 SSE 事件结构: - `response.created` 事件提取 response.id(消息 ID) - `response.completed` 事件提取完整 usage 统计 - Token 字段映射: - input_tokens_details.cached_tokens → cache_read_tokens - output_tokens_details.reasoning_tokens → 记录日志(暂不计费) - 工厂函数 `create_extractor("codex")` 自动返回正确的提取器 ### 会话管理增强 - `ProxySession::extract_display_id()` 支持多种格式: - Claude: user_xxx_session_ → 提取 UUID - Codex: prompt_cache_key → 使用前 12 字符 - `CodexHeadersProcessor` 新增会话 ID 提取逻辑: - 从请求体的 `prompt_cache_key` 字段提取(区别于 Claude 的 metadata.user_id) - 自动记录会话事件到 SessionManager - 支持会话级配置查询和 Profile 覆盖 ### 数据模型扩展 - `MessageDeltaData` 新增 `input_tokens` 字段(Codex 的 input_tokens 在 completed 事件中) - `ResponseTokenInfo` 新增 `reasoning_tokens` 字段(用于日志记录) - `RequestLogContext` 新增 `tool_id` 判断逻辑,自动选择正确的提取器 ### 日志记录架构 - `CodexHeadersProcessor::record_request_log()` 使用统一的日志记录架构 - `RequestLogContext::from_request()` 根据 tool_id 自动选择会话 ID 字段 - `RequestLogContext::get_session_id()` 自动适配 Codex 的 `prompt_cache_key` 字段 ## 测试覆盖 - 新增 8 个单元测试: - 5 个 Codex SSE 解析测试(response.created / completed / 其他事件) - 3 个 display_id 提取测试(Claude / Codex / 短 ID 格式) ## 代码质量 - 所有 Clippy/rustfmt 检查通过 - 遵循扩展式架构设计,不影响 Claude 现有逻辑 - 完整的错误处理和日志记录 ## 相关文件 - services/token_stats/extractor.rs: 新增 CodexTokenExtractor (+339 行) - services/proxy/headers/codex_processor.rs: 会话管理集成 (+125 行) - services/session/models.rs: display_id 提取增强 (+39 行) - services/proxy/log_recorder/context.rs: 工具类型判断 (+36 行) - models/token_stats.rs: 数据模型扩展 (+21 行) - CLAUDE.md: 架构文档更新 (+21 行) --- .gitignore | 1 + CLAUDE.md | 21 ++ src-tauri/src/commands/analytics_commands.rs | 4 + src-tauri/src/models/pricing.rs | 9 +- src-tauri/src/models/token_stats.rs | 21 ++ src-tauri/src/services/pricing/builtin.rs | 8 + src-tauri/src/services/pricing/manager.rs | 25 +- .../services/proxy/headers/codex_processor.rs | 125 ++++++- .../services/proxy/log_recorder/context.rs | 36 +- src-tauri/src/services/session/manager.rs | 40 +-- src-tauri/src/services/session/models.rs | 39 +- .../src/services/token_stats/analytics.rs | 4 + .../token_stats/cost_calculation_test.rs | 8 +- src-tauri/src/services/token_stats/db.rs | 100 ++++-- .../src/services/token_stats/extractor.rs | 339 +++++++++++++++++- src-tauri/src/services/token_stats/manager.rs | 23 +- 16 files changed, 719 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index f116eb8..e68ee68 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,4 @@ DEPLOYMENT*.md mirror-server/ /nul /.claude/ +/src-tauri/NUL diff --git a/CLAUDE.md b/CLAUDE.md index 2347e7f..126f180 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -228,6 +228,27 @@ 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 不报告缓存创建) + - **扩展式提取器架构**: + - 独立的 `CodexTokenExtractor` 实现(位于 `services/token_stats/extractor.rs`) + - 不影响 Claude 的 `ClaudeTokenExtractor` 逻辑 + - 工厂函数 `create_extractor("codex")` 返回 Codex 提取器 + - **会话模型增强**: + - `ProxySession::extract_display_id()` 支持多种格式: + - Claude 格式:`user_xxx_session_` → 提取 UUID + - Codex 格式:`prompt_cache_key` → 使用前 12 字符 + - `RequestLogContext` 根据 tool_id 自动选择提取逻辑 + - **代码质量**:新增 8 个单元测试(Codex SSE/JSON 解析),所有检查通过 - **配置管理机制(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..b81f18e 100644 --- a/src-tauri/src/commands/analytics_commands.rs +++ b/src-tauri/src/commands/analytics_commands.rs @@ -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()), ); @@ -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()), ); diff --git a/src-tauri/src/models/pricing.rs b/src-tauri/src/models/pricing.rs index d66168f..5b18034 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, } @@ -224,6 +230,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 +265,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..100da54 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/pricing/builtin.rs b/src-tauri/src/services/pricing/builtin.rs index 8966a2a..af742f9 100644 --- a/src-tauri/src/services/pricing/builtin.rs +++ b/src-tauri/src/services/pricing/builtin.rs @@ -16,6 +16,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 +35,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 +53,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 +70,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 +88,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 +105,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 +124,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 +142,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(), diff --git a/src-tauri/src/services/pricing/manager.rs b/src-tauri/src/services/pricing/manager.rs index a7bb1aa..75b718c 100644 --- a/src-tauri/src/services/pricing/manager.rs +++ b/src-tauri/src/services/pricing/manager.rs @@ -30,6 +30,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, @@ -252,6 +256,7 @@ impl PricingManager { /// - `output_tokens`: 输出 Token 数量 /// - `cache_creation_tokens`: 缓存创建 Token 数量 /// - `cache_read_tokens`: 缓存读取 Token 数量 + /// - `reasoning_tokens`: 推理 Token 数量 /// /// # 返回 /// @@ -264,6 +269,7 @@ impl PricingManager { output_tokens: i64, cache_creation_tokens: i64, cache_read_tokens: i64, + reasoning_tokens: i64, ) -> Result { // 1. 获取模板 let template = if let Some(id) = template_id { @@ -286,14 +292,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 +354,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, }); @@ -416,6 +436,7 @@ mod tests { 500, // output 100, // cache write 200, // cache read + 0, // reasoning_tokens ) .unwrap(); @@ -493,7 +514,7 @@ mod tests { // 不指定模板 ID,应使用默认模板 let breakdown = manager - .calculate_cost(None, "claude-sonnet-4.5", 1000, 500, 0, 0) + .calculate_cost(None, "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/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/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..bc7a2c6 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..aa332a1 100644 --- a/src-tauri/src/services/token_stats/cost_calculation_test.rs +++ b/src-tauri/src/services/token_stats/cost_calculation_test.rs @@ -25,6 +25,7 @@ mod tests { output_tokens, cache_creation_tokens, cache_read_tokens, + 0, // reasoning_tokens ); // 验证计算成功 @@ -73,7 +74,7 @@ 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, "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 @@ -81,7 +82,7 @@ mod tests { // Claude Sonnet 4.5: $3 input / $15 output let sonnet_result = - PRICING_MANAGER.calculate_cost(None, "claude-sonnet-4.5", 1000, 500, 0, 0); + PRICING_MANAGER.calculate_cost(None, "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 @@ -89,7 +90,7 @@ mod tests { // 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); + PRICING_MANAGER.calculate_cost(None, "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 @@ -152,6 +153,7 @@ mod tests { token_info.output_tokens, token_info.cache_creation_tokens, token_info.cache_read_tokens, + 0, // reasoning_tokens ); assert!(result.is_ok()); diff --git a/src-tauri/src/services/token_stats/db.rs b/src-tauri/src/services/token_stats/db.rs index fb71864..c05171e 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,44 @@ 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 +182,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 +198,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 +212,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 +259,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 +275,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 +289,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 +325,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 +340,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 +405,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 +466,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 +665,7 @@ mod tests { 500, 100, 200, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -622,6 +675,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); @@ -654,6 +708,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "sse".to_string(), None, @@ -663,6 +718,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); @@ -707,6 +763,7 @@ mod tests { 50, 0, 0, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -716,6 +773,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); @@ -733,6 +791,7 @@ mod tests { 100, 0, 0, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -742,6 +801,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 index 8531019..2a8503c 100644 --- a/src-tauri/src/services/token_stats/extractor.rs +++ b/src-tauri/src/services/token_stats/extractor.rs @@ -36,6 +36,7 @@ pub struct MessageStartData { /// message_delta块数据(end_turn) #[derive(Debug, Clone)] pub struct MessageDeltaData { + pub input_tokens: Option, // Codex 的 response.completed 包含 input_tokens pub cache_creation_tokens: i64, pub cache_read_tokens: i64, pub output_tokens: i64, @@ -50,6 +51,7 @@ pub struct ResponseTokenInfo { pub output_tokens: i64, pub cache_creation_tokens: i64, pub cache_read_tokens: i64, + pub reasoning_tokens: i64, } impl ResponseTokenInfo { @@ -59,9 +61,10 @@ impl ResponseTokenInfo { /// - 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 { + let (input, cache_creation, cache_read, output) = if let Some(d) = delta { // 优先使用 delta 的值(最终统计) ( + d.input_tokens.unwrap_or(start.input_tokens), // Codex 的 input_tokens 在 delta 中 d.cache_creation_tokens, d.cache_read_tokens, d.output_tokens, @@ -69,6 +72,7 @@ impl ResponseTokenInfo { } else { // 回退到 start 的值(初始统计) ( + start.input_tokens, start.cache_creation_tokens, start.cache_read_tokens, start.output_tokens, @@ -78,10 +82,11 @@ impl ResponseTokenInfo { Self { model: start.model, message_id: start.message_id, - input_tokens: start.input_tokens, + input_tokens: input, output_tokens: output, cache_creation_tokens: cache_creation, cache_read_tokens: cache_read, + reasoning_tokens: 0, // Claude 不使用 reasoning tokens } } } @@ -240,6 +245,7 @@ impl TokenExtractor for ClaudeTokenExtractor { ); result.message_delta = Some(MessageDeltaData { + input_tokens: None, // Claude 的 input_tokens 在 message_start 中 cache_creation_tokens: cache_creation, cache_read_tokens: cache_read, output_tokens, @@ -319,6 +325,193 @@ impl TokenExtractor for ClaudeTokenExtractor { output_tokens, cache_creation_tokens: cache_creation, cache_read_tokens: cache_read, + reasoning_tokens: 0, // Claude 不使用 reasoning tokens + }) + } +} + +/// Codex 工具的 Token 提取器 +pub struct CodexTokenExtractor; + +impl TokenExtractor for CodexTokenExtractor { + 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, "解析 Codex SSE 事件"); + + match event_type { + "response.created" => { + if let Some(response) = json.get("response") { + let response_id = response + .get("id") + .and_then(|v| v.as_str()) + .context("Missing id in response.created")? + .to_string(); + + tracing::debug!(response_id = %response_id, "Codex response.created"); + + // 返回占位符 MessageStartData(model 从请求体获取) + Ok(Some(SseTokenData { + message_start: Some(MessageStartData { + model: "unknown".to_string(), + message_id: response_id, + input_tokens: 0, + output_tokens: 0, + cache_creation_tokens: 0, + cache_read_tokens: 0, + }), + message_delta: None, + })) + } else { + Ok(None) + } + } + "response.completed" => { + if let Some(response) = json.get("response") { + let response_id = response + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + + let usage = response + .get("usage") + .context("Missing usage in response.completed")?; + + let input_tokens = usage + .get("input_tokens") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + let cached_tokens = usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_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); + + let 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!( + response_id = %response_id, + input_tokens = input_tokens, + cached_tokens = cached_tokens, + output_tokens = output_tokens, + "Codex response.completed" + ); + + // 返回完整的 MessageDeltaData(包含 input_tokens) + // 注意:Codex 的 input_tokens 在这里,不在 message_start + Ok(Some(SseTokenData { + message_start: None, + message_delta: Some(MessageDeltaData { + input_tokens: Some(input_tokens), // Codex 的 input_tokens + cache_creation_tokens: 0, + cache_read_tokens: cached_tokens, + output_tokens, + }), + })) + } else { + Ok(None) + } + } + _ => Ok(None), + } + } + + fn extract_from_json(&self, json: &Value) -> Result { + let model = json + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .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 cached_tokens = usage + .get("input_tokens_details") + .and_then(|d| d.get("cached_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); + + let reasoning_tokens = usage + .get("output_tokens_details") + .and_then(|d| d.get("reasoning_tokens")) + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + Ok(ResponseTokenInfo { + model, + message_id, + input_tokens, + output_tokens, + cache_creation_tokens: 0, + cache_read_tokens: cached_tokens, + reasoning_tokens, }) } } @@ -329,8 +522,7 @@ 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"), + "codex" => Ok(Box::new(CodexTokenExtractor)), "gemini_cli" => anyhow::bail!("Gemini CLI token extractor not implemented yet"), _ => anyhow::bail!("Unknown tool type: {}", tool_type), } @@ -439,7 +631,7 @@ mod tests { #[test] fn test_create_extractor() { assert!(create_extractor("claude_code").is_ok()); - assert!(create_extractor("codex").is_err()); + assert!(create_extractor("codex").is_ok()); assert!(create_extractor("gemini_cli").is_err()); assert!(create_extractor("unknown").is_err()); } @@ -529,4 +721,141 @@ mod tests { assert_eq!(info.cache_creation_tokens, 200); assert_eq!(info.cache_read_tokens, 300); } + + // ========== Codex Token Extractor Tests ========== + + #[test] + fn test_codex_extract_model_from_request() { + let extractor = CodexTokenExtractor; + let body = r#"{"model":"gpt-4","messages":[],"prompt_cache_key":"test123"}"#; + + let model = extractor + .extract_model_from_request(body.as_bytes()) + .unwrap(); + assert_eq!(model, "gpt-4"); + } + + #[test] + fn test_codex_sse_response_created() { + let extractor = CodexTokenExtractor; + let chunk = r#"{"type":"response.created","response":{"id":"resp_abc123"}}"#; + + let result = extractor.extract_from_sse_chunk(chunk).unwrap(); + assert!(result.is_some()); + + let data = result.unwrap(); + assert!(data.message_start.is_some()); + assert!(data.message_delta.is_none()); + + let start = data.message_start.unwrap(); + assert_eq!(start.message_id, "resp_abc123"); + assert_eq!(start.input_tokens, 0); + assert_eq!(start.output_tokens, 0); + } + + #[test] + fn test_codex_sse_response_completed() { + let extractor = CodexTokenExtractor; + let chunk = r#"{ + "type":"response.completed", + "response":{ + "id":"resp_abc123", + "usage":{ + "input_tokens":8299, + "input_tokens_details":{"cached_tokens":100}, + "output_tokens":36, + "output_tokens_details":{"reasoning_tokens":0}, + "total_tokens":8335 + } + } + }"#; + + let result = extractor.extract_from_sse_chunk(chunk).unwrap(); + assert!(result.is_some()); + + let data = result.unwrap(); + assert!(data.message_start.is_none()); + assert!(data.message_delta.is_some()); + + let delta = data.message_delta.unwrap(); + assert_eq!(delta.cache_read_tokens, 100); + assert_eq!(delta.output_tokens, 36); + assert_eq!(delta.cache_creation_tokens, 0); + } + + #[test] + fn test_codex_sse_response_completed_with_reasoning() { + let extractor = CodexTokenExtractor; + let chunk = 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 + } + } + }"#; + + let result = extractor.extract_from_sse_chunk(chunk).unwrap(); + assert!(result.is_some()); + + let data = result.unwrap(); + let delta = data.message_delta.unwrap(); + assert_eq!(delta.output_tokens, 500); + // reasoning_tokens 记录到日志但不影响计费 + } + + #[test] + fn test_codex_json_response() { + let extractor = CodexTokenExtractor; + let json_str = r#"{ + "id":"resp_abc123", + "model":"gpt-4", + "usage":{ + "input_tokens":100, + "input_tokens_details":{"cached_tokens":50}, + "output_tokens":20 + } + }"#; + + let json: Value = serde_json::from_str(json_str).unwrap(); + let result = extractor.extract_from_json(&json).unwrap(); + + assert_eq!(result.model, "gpt-4"); + assert_eq!(result.message_id, "resp_abc123"); + assert_eq!(result.input_tokens, 100); + assert_eq!(result.cache_read_tokens, 50); + assert_eq!(result.output_tokens, 20); + assert_eq!(result.cache_creation_tokens, 0); + } + + #[test] + fn test_codex_json_response_no_cached() { + let extractor = CodexTokenExtractor; + let json_str = r#"{ + "id":"resp_test", + "model":"gpt-3.5-turbo", + "usage":{ + "input_tokens":200, + "output_tokens":50 + } + }"#; + + let json: Value = serde_json::from_str(json_str).unwrap(); + let result = extractor.extract_from_json(&json).unwrap(); + + assert_eq!(result.input_tokens, 200); + assert_eq!(result.cache_read_tokens, 0); + assert_eq!(result.output_tokens, 50); + } + + #[test] + fn test_create_codex_extractor() { + let extractor = create_extractor("codex"); + assert!(extractor.is_ok()); + } } diff --git a/src-tauri/src/services/token_stats/manager.rs b/src-tauri/src/services/token_stats/manager.rs index f918a9e..b0edcfa 100644 --- a/src-tauri/src/services/token_stats/manager.rs +++ b/src-tauri/src/services/token_stats/manager.rs @@ -204,6 +204,7 @@ impl TokenStatsManager { final_output_price, final_cache_write_price, final_cache_read_price, + final_reasoning_price, final_total_cost, final_pricing_template_id, ) = match PRICING_MANAGER.calculate_cost( @@ -213,6 +214,7 @@ impl TokenStatsManager { token_info.output_tokens, token_info.cache_creation_tokens, token_info.cache_read_tokens, + token_info.reasoning_tokens, ) { Ok(breakdown) => { tracing::debug!( @@ -221,6 +223,7 @@ impl TokenStatsManager { total_cost = breakdown.total_cost, input_tokens = token_info.input_tokens, output_tokens = token_info.output_tokens, + reasoning_tokens = token_info.reasoning_tokens, "成本计算成功" ); ( @@ -228,6 +231,7 @@ impl TokenStatsManager { 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), ) @@ -240,7 +244,7 @@ impl TokenStatsManager { error = ?e, "成本计算失败,使用默认值 0" ); - (None, None, None, None, 0.0, None) + (None, None, None, None, None, 0.0, None) } }; @@ -258,6 +262,7 @@ impl TokenStatsManager { token_info.output_tokens, token_info.cache_creation_tokens, token_info.cache_read_tokens, + token_info.reasoning_tokens, "success".to_string(), response_type.to_string(), None, @@ -267,6 +272,7 @@ impl TokenStatsManager { final_output_price, final_cache_write_price, final_cache_read_price, + final_reasoning_price, final_total_cost, final_pricing_template_id, ); @@ -328,6 +334,7 @@ impl TokenStatsManager { 0, 0, 0, + 0, // reasoning_tokens "failed".to_string(), response_type.to_string(), Some(error_type.to_string()), @@ -337,7 +344,8 @@ impl TokenStatsManager { None, None, None, - 0.0, // 失败时成本为 0 + None, // reasoning_price + 0.0, // 失败时成本为 0 None, ); @@ -509,6 +517,9 @@ mod tests { async fn test_log_request_with_json() { let manager = TokenStatsManager::get(); + // 使用唯一的 session_id 避免测试间干扰 + let session_id = format!("test_session_json_{}", uuid::Uuid::new_v4()); + let request_body = json!({ "model": "claude-sonnet-4-5-20250929", "messages": [] @@ -529,7 +540,7 @@ mod tests { let result = manager .log_request( "claude_code", - "test_session", + &session_id, "default", "127.0.0.1", request_body.as_bytes(), @@ -542,11 +553,11 @@ mod tests { assert!(result.is_ok()); // 等待异步插入完成 - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; // 验证统计数据 let stats = manager - .get_session_stats("claude_code", "test_session") + .get_session_stats("claude_code", &session_id) .unwrap(); assert_eq!(stats.total_input, 100); assert_eq!(stats.total_output, 50); @@ -588,6 +599,7 @@ mod tests { 50, 10, 20, + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -597,6 +609,7 @@ mod tests { None, None, None, + None, // reasoning_price 0.0, None, ); From ce0a14648d0706c635049ed817dd0fc91ee0ea18 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:25:35 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(proxy):=20=E5=AE=9E=E7=8E=B0=20Codex?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E7=9A=84=E4=BC=9A=E8=AF=9D=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=92=8C=20Token=20=E7=BB=9F=E8=AE=A1=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **核心功能** - Codex 会话 ID 提取(从 prompt_cache_key) - Codex Token 统计(处理 input_tokens 包含缓存的特殊格式) - CodexTokenExtractor 和 CodexLogger 实现 - 支持缓存读取统计(OpenAI 缓存计费 input × 0.5) - 支持 reasoning_tokens 记录 **定价系统增强** - 添加 builtin_openai 官方模板(包含 gpt-5.2-codex) - 实现默认模板迁移系统(v1 → v2,codex: builtin_claude → builtin_openai) - 添加 PricingDefaultTemplatesMigration 迁移器 **重要修复** - fix(pricing): calculate_cost 添加 tool_id 参数,修复 Codex 总是使用 Claude 模板的 bug - fix(logger): 统一 tool_type 为连字符格式(claude-code/codex),修复日志记录不一致问题 **测试覆盖** - 新增 CodexLogger 单元测试(3个) - 新增 CodexProcessor 单元测试(4个) - 新增 Codex 端到端成本计算测试 - 更新所有相关测试断言 - 279 个测试全部通过 **架构改进** - 删除旧的 extractor.rs,统一使用 processor 架构 - TokenLogger trait 定义标准化日志记录接口 - RequestLogContext 自动提取会话配置(优先级:会话 > 代理) --- CLAUDE.md | 20 +- src-tauri/src/commands/analytics_commands.rs | 10 +- src-tauri/src/models/pricing.rs | 27 +- .../migration_manager/migrations/mod.rs | 2 + .../migrations/pricing_default_templates.rs | 170 ++++ .../src/services/migration_manager/mod.rs | 7 +- src-tauri/src/services/pricing/builtin.rs | 65 ++ src-tauri/src/services/pricing/manager.rs | 31 +- .../services/proxy/log_recorder/recorder.rs | 180 ++-- .../src/services/proxy/proxy_instance.rs | 31 +- .../token_stats/cost_calculation_test.rs | 167 +++- .../src/services/token_stats/extractor.rs | 861 ------------------ .../src/services/token_stats/logger/claude.rs | 284 ++++++ .../src/services/token_stats/logger/codex.rs | 284 ++++++ .../src/services/token_stats/logger/mod.rs | 103 +++ .../src/services/token_stats/logger/types.rs | 104 +++ src-tauri/src/services/token_stats/manager.rs | 425 +-------- src-tauri/src/services/token_stats/mod.rs | 7 +- .../services/token_stats/processor/claude.rs | 338 +++++++ .../services/token_stats/processor/codex.rs | 321 +++++++ .../src/services/token_stats/processor/mod.rs | 59 ++ .../token_stats/processor/token_info.rs | 105 +++ 22 files changed, 2205 insertions(+), 1396 deletions(-) create mode 100644 src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs delete mode 100644 src-tauri/src/services/token_stats/extractor.rs create mode 100644 src-tauri/src/services/token_stats/logger/claude.rs create mode 100644 src-tauri/src/services/token_stats/logger/codex.rs create mode 100644 src-tauri/src/services/token_stats/logger/mod.rs create mode 100644 src-tauri/src/services/token_stats/logger/types.rs create mode 100644 src-tauri/src/services/token_stats/processor/claude.rs create mode 100644 src-tauri/src/services/token_stats/processor/codex.rs create mode 100644 src-tauri/src/services/token_stats/processor/mod.rs create mode 100644 src-tauri/src/services/token_stats/processor/token_info.rs diff --git a/CLAUDE.md b/CLAUDE.md index 126f180..1d0e472 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -239,16 +239,26 @@ last-updated: 2025-12-16 - `output_tokens` → output_tokens - `output_tokens_details.reasoning_tokens` → 记录日志(暂不计费) - `cache_creation_tokens` → 0(Codex 不报告缓存创建) - - **扩展式提取器架构**: - - 独立的 `CodexTokenExtractor` 实现(位于 `services/token_stats/extractor.rs`) - - 不影响 Claude 的 `ClaudeTokenExtractor` 逻辑 - - 工厂函数 `create_extractor("codex")` 返回 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 自动选择提取逻辑 - - **代码质量**:新增 8 个单元测试(Codex SSE/JSON 解析),所有检查通过 + - **代码质量**: + - 新增 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 b81f18e..247d870 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(), @@ -293,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() }; @@ -329,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), @@ -364,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() }; @@ -379,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 5b18034..c4a35e0 100644 --- a/src-tauri/src/models/pricing.rs +++ b/src-tauri/src/models/pricing.rs @@ -174,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) 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..ecdb0fc --- /dev/null +++ b/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs @@ -0,0 +1,170 @@ +// 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()), + ); + migrated = true; + } + } + + // 更新版本号 + 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 af742f9..3db618e 100644 --- a/src-tauri/src/services/pricing/builtin.rs +++ b/src-tauri/src/services/pricing/builtin.rs @@ -1,6 +1,43 @@ 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 模型的官方定价 @@ -262,4 +299,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 75b718c..dd73ef1 100644 --- a/src-tauri/src/services/pricing/manager.rs +++ b/src-tauri/src/services/pricing/manager.rs @@ -1,6 +1,6 @@ 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; @@ -106,14 +106,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) @@ -251,6 +255,7 @@ impl PricingManager { /// # 参数 /// /// - `template_id`: 价格模板 ID(None 时使用工具默认模板) + /// - `tool_id`: 工具 ID(用于获取默认模板,当 template_id 为 None 时必须提供) /// - `model`: 模型名称 /// - `input_tokens`: 输入 Token 数量 /// - `output_tokens`: 输出 Token 数量 @@ -264,6 +269,7 @@ impl PricingManager { pub fn calculate_cost( &self, template_id: Option<&str>, + tool_id: Option<&str>, model: &str, input_tokens: i64, output_tokens: i64, @@ -275,8 +281,9 @@ impl PricingManager { 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. 解析模型价格(别名 → 继承 → 倍率) @@ -431,6 +438,7 @@ mod tests { let breakdown = manager .calculate_cost( Some("builtin_claude"), + None, // 工具 ID(已指定模板则可选) "claude-sonnet-4.5", 1000, // input 500, // output @@ -514,7 +522,16 @@ mod tests { // 不指定模板 ID,应使用默认模板 let breakdown = manager - .calculate_cost(None, "claude-sonnet-4.5", 1000, 500, 0, 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/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..831422f 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); + } + } } } } 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 aa332a1..a083231 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,7 +19,8 @@ mod tests { // 使用默认模板计算成本 let result = PRICING_MANAGER.calculate_cost( - None, // 使用默认模板 + None, // 使用默认模板 + Some("claude-code"), // 工具 ID model, input_tokens, output_tokens, @@ -74,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, 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, 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, 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 @@ -102,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", @@ -115,7 +146,12 @@ 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); @@ -129,7 +165,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", @@ -143,11 +184,17 @@ 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, @@ -201,4 +248,102 @@ 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/extractor.rs b/src-tauri/src/services/token_stats/extractor.rs deleted file mode 100644 index 2a8503c..0000000 --- a/src-tauri/src/services/token_stats/extractor.rs +++ /dev/null @@ -1,861 +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 input_tokens: Option, // Codex 的 response.completed 包含 input_tokens - 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, - pub reasoning_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 (input, cache_creation, cache_read, output) = if let Some(d) = delta { - // 优先使用 delta 的值(最终统计) - ( - d.input_tokens.unwrap_or(start.input_tokens), // Codex 的 input_tokens 在 delta 中 - d.cache_creation_tokens, - d.cache_read_tokens, - d.output_tokens, - ) - } else { - // 回退到 start 的值(初始统计) - ( - start.input_tokens, - start.cache_creation_tokens, - start.cache_read_tokens, - start.output_tokens, - ) - }; - - Self { - model: start.model, - message_id: start.message_id, - input_tokens: input, - output_tokens: output, - cache_creation_tokens: cache_creation, - cache_read_tokens: cache_read, - reasoning_tokens: 0, // Claude 不使用 reasoning tokens - } - } -} - -/// 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 { - input_tokens: None, // Claude 的 input_tokens 在 message_start 中 - 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, - reasoning_tokens: 0, // Claude 不使用 reasoning tokens - }) - } -} - -/// Codex 工具的 Token 提取器 -pub struct CodexTokenExtractor; - -impl TokenExtractor for CodexTokenExtractor { - 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, "解析 Codex SSE 事件"); - - match event_type { - "response.created" => { - if let Some(response) = json.get("response") { - let response_id = response - .get("id") - .and_then(|v| v.as_str()) - .context("Missing id in response.created")? - .to_string(); - - tracing::debug!(response_id = %response_id, "Codex response.created"); - - // 返回占位符 MessageStartData(model 从请求体获取) - Ok(Some(SseTokenData { - message_start: Some(MessageStartData { - model: "unknown".to_string(), - message_id: response_id, - input_tokens: 0, - output_tokens: 0, - cache_creation_tokens: 0, - cache_read_tokens: 0, - }), - message_delta: None, - })) - } else { - Ok(None) - } - } - "response.completed" => { - if let Some(response) = json.get("response") { - let response_id = response - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - - let usage = response - .get("usage") - .context("Missing usage in response.completed")?; - - let input_tokens = usage - .get("input_tokens") - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - let cached_tokens = usage - .get("input_tokens_details") - .and_then(|d| d.get("cached_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); - - let 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!( - response_id = %response_id, - input_tokens = input_tokens, - cached_tokens = cached_tokens, - output_tokens = output_tokens, - "Codex response.completed" - ); - - // 返回完整的 MessageDeltaData(包含 input_tokens) - // 注意:Codex 的 input_tokens 在这里,不在 message_start - Ok(Some(SseTokenData { - message_start: None, - message_delta: Some(MessageDeltaData { - input_tokens: Some(input_tokens), // Codex 的 input_tokens - cache_creation_tokens: 0, - cache_read_tokens: cached_tokens, - output_tokens, - }), - })) - } else { - Ok(None) - } - } - _ => Ok(None), - } - } - - fn extract_from_json(&self, json: &Value) -> Result { - let model = json - .get("model") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .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 cached_tokens = usage - .get("input_tokens_details") - .and_then(|d| d.get("cached_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); - - let reasoning_tokens = usage - .get("output_tokens_details") - .and_then(|d| d.get("reasoning_tokens")) - .and_then(|v| v.as_i64()) - .unwrap_or(0); - - Ok(ResponseTokenInfo { - model, - message_id, - input_tokens, - output_tokens, - cache_creation_tokens: 0, - cache_read_tokens: cached_tokens, - reasoning_tokens, - }) - } -} - -/// 创建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" => Ok(Box::new(CodexTokenExtractor)), - "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_ok()); - 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); - } - - // ========== Codex Token Extractor Tests ========== - - #[test] - fn test_codex_extract_model_from_request() { - let extractor = CodexTokenExtractor; - let body = r#"{"model":"gpt-4","messages":[],"prompt_cache_key":"test123"}"#; - - let model = extractor - .extract_model_from_request(body.as_bytes()) - .unwrap(); - assert_eq!(model, "gpt-4"); - } - - #[test] - fn test_codex_sse_response_created() { - let extractor = CodexTokenExtractor; - let chunk = r#"{"type":"response.created","response":{"id":"resp_abc123"}}"#; - - let result = extractor.extract_from_sse_chunk(chunk).unwrap(); - assert!(result.is_some()); - - let data = result.unwrap(); - assert!(data.message_start.is_some()); - assert!(data.message_delta.is_none()); - - let start = data.message_start.unwrap(); - assert_eq!(start.message_id, "resp_abc123"); - assert_eq!(start.input_tokens, 0); - assert_eq!(start.output_tokens, 0); - } - - #[test] - fn test_codex_sse_response_completed() { - let extractor = CodexTokenExtractor; - let chunk = r#"{ - "type":"response.completed", - "response":{ - "id":"resp_abc123", - "usage":{ - "input_tokens":8299, - "input_tokens_details":{"cached_tokens":100}, - "output_tokens":36, - "output_tokens_details":{"reasoning_tokens":0}, - "total_tokens":8335 - } - } - }"#; - - let result = extractor.extract_from_sse_chunk(chunk).unwrap(); - assert!(result.is_some()); - - let data = result.unwrap(); - assert!(data.message_start.is_none()); - assert!(data.message_delta.is_some()); - - let delta = data.message_delta.unwrap(); - assert_eq!(delta.cache_read_tokens, 100); - assert_eq!(delta.output_tokens, 36); - assert_eq!(delta.cache_creation_tokens, 0); - } - - #[test] - fn test_codex_sse_response_completed_with_reasoning() { - let extractor = CodexTokenExtractor; - let chunk = 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 - } - } - }"#; - - let result = extractor.extract_from_sse_chunk(chunk).unwrap(); - assert!(result.is_some()); - - let data = result.unwrap(); - let delta = data.message_delta.unwrap(); - assert_eq!(delta.output_tokens, 500); - // reasoning_tokens 记录到日志但不影响计费 - } - - #[test] - fn test_codex_json_response() { - let extractor = CodexTokenExtractor; - let json_str = r#"{ - "id":"resp_abc123", - "model":"gpt-4", - "usage":{ - "input_tokens":100, - "input_tokens_details":{"cached_tokens":50}, - "output_tokens":20 - } - }"#; - - let json: Value = serde_json::from_str(json_str).unwrap(); - let result = extractor.extract_from_json(&json).unwrap(); - - assert_eq!(result.model, "gpt-4"); - assert_eq!(result.message_id, "resp_abc123"); - assert_eq!(result.input_tokens, 100); - assert_eq!(result.cache_read_tokens, 50); - assert_eq!(result.output_tokens, 20); - assert_eq!(result.cache_creation_tokens, 0); - } - - #[test] - fn test_codex_json_response_no_cached() { - let extractor = CodexTokenExtractor; - let json_str = r#"{ - "id":"resp_test", - "model":"gpt-3.5-turbo", - "usage":{ - "input_tokens":200, - "output_tokens":50 - } - }"#; - - let json: Value = serde_json::from_str(json_str).unwrap(); - let result = extractor.extract_from_json(&json).unwrap(); - - assert_eq!(result.input_tokens, 200); - assert_eq!(result.cache_read_tokens, 0); - assert_eq!(result.output_tokens, 50); - } - - #[test] - fn test_create_codex_extractor() { - let extractor = create_extractor("codex"); - assert!(extractor.is_ok()); - } -} 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..57bf085 --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/claude.rs @@ -0,0 +1,284 @@ +//! 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 + 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..2eca483 --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/codex.rs @@ -0,0 +1,284 @@ +//! 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 + 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..8ca6442 --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/mod.rs @@ -0,0 +1,103 @@ +//! 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: 日志记录对象 + 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..a8fb202 --- /dev/null +++ b/src-tauri/src/services/token_stats/logger/types.rs @@ -0,0 +1,104 @@ +//! 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", + } + } + + /// 从字符串解析 + 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", + } + } + + /// 从字符串解析 + 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 b0edcfa..3123d18 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,316 +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_reasoning_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, - token_info.reasoning_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, - reasoning_tokens = token_info.reasoning_tokens, - "成本计算成功" - ); - ( - 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) => { - // 计算失败,使用 0 - tracing::warn!( - model = %model, - template_id = ?template_id_ref, - error = ?e, - "成本计算失败,使用默认值 0" - ); - (None, 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, - token_info.reasoning_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_reasoning_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, - 0, // reasoning_tokens - "failed".to_string(), - response_type.to_string(), - Some(error_type.to_string()), - Some(error_detail.to_string()), - response_time_ms, - None, // 失败时没有价格信息 - None, - None, - None, - None, // reasoning_price - 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) } /// 查询会话实时统计 @@ -511,79 +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(); - // 使用唯一的 session_id 避免测试间干扰 - let session_id = format!("test_session_json_{}", uuid::Uuid::new_v4()); - - 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", - &session_id, - "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(200)).await; - - // 验证统计数据 - let stats = manager - .get_session_stats("claude_code", &session_id) - .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(); // 插入测试数据 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..7cd8732 --- /dev/null +++ b/src-tauri/src/services/token_stats/processor/claude.rs @@ -0,0 +1,338 @@ +//! 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..677991c --- /dev/null +++ b/src-tauri/src/services/token_stats/processor/codex.rs @@ -0,0 +1,321 @@ +//! 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); + } +} From 8b670be5c8d392ef3f3b16e019f3e4ca51c3d964 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:21:34 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(proxy):=20=E6=94=AF=E6=8C=81=20Gemini?= =?UTF-8?q?=20x-goog-api-key=20=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **核心改动**: - 透明代理支持 x-goog-api-key header 验证(proxy_instance.rs) - 为 GeminiHeadersProcessor 添加完整单元测试(5 个) **修复**: - proxy_instance.rs: 添加 x-goog-api-key 到鉴权 header 检查链 - gemini_processor.rs: 新增 5 个单元测试验证认证流程 **测试结果**: - 24/24 代理相关测试通过 ✅ - cargo clippy 无新增警告 ✅ - Prettier + fmt 检查通过 ✅ **影响范围**: - Gemini 客户端现在可使用 x-goog-api-key header 向代理鉴权 - 完全替代旧鉴权方式(authorization/Bearer) - 认证流完全闭环验证 关闭 # --- .../proxy/headers/gemini_processor.rs | 164 ++++++++++++++++++ .../src/services/proxy/proxy_instance.rs | 2 + 2 files changed, 166 insertions(+) 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/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index 831422f..025c3a1 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -274,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(""); From ff21a5e41ef76604bdb5456ef51a666c0a6688fd Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sat, 24 Jan 2026 21:22:53 +0800 Subject: [PATCH 4/6] =?UTF-8?q?style:=20=E6=A0=BC=E5=BC=8F=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=A0=B7=E5=BC=8F=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整注释对齐(token_stats) - 修复 pricing 模块格式 - 更新 .gitignore 这些改动是由 prettier + cargo fmt 自动格式化产生 --- .gitignore | 1 + src-tauri/src/commands/analytics_commands.rs | 4 +-- src-tauri/src/models/pricing.rs | 2 +- src-tauri/src/models/token_stats.rs | 6 ++--- .../migrations/pricing_default_templates.rs | 6 ++++- src-tauri/src/services/pricing/builtin.rs | 12 ++++++--- src-tauri/src/services/pricing/manager.rs | 4 ++- .../src/services/token_stats/analytics.rs | 4 +-- .../token_stats/cost_calculation_test.rs | 15 +++-------- src-tauri/src/services/token_stats/db.rs | 25 ++++++++++++------- .../src/services/token_stats/logger/claude.rs | 8 ++++-- .../src/services/token_stats/logger/codex.rs | 8 ++++-- src-tauri/src/services/token_stats/manager.rs | 4 +-- .../services/token_stats/processor/claude.rs | 10 ++++++-- .../services/token_stats/processor/codex.rs | 10 ++++++-- 15 files changed, 74 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index e68ee68..f8980ff 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ mirror-server/ /nul /.claude/ /src-tauri/NUL +/docs \ No newline at end of file diff --git a/src-tauri/src/commands/analytics_commands.rs b/src-tauri/src/commands/analytics_commands.rs index 247d870..7f38fd4 100644 --- a/src-tauri/src/commands/analytics_commands.rs +++ b/src-tauri/src/commands/analytics_commands.rs @@ -274,7 +274,7 @@ mod tests { 50, 10, 20, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -340,7 +340,7 @@ mod tests { 50, 10, 20, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, diff --git a/src-tauri/src/models/pricing.rs b/src-tauri/src/models/pricing.rs index c4a35e0..aedb20f 100644 --- a/src-tauri/src/models/pricing.rs +++ b/src-tauri/src/models/pricing.rs @@ -249,7 +249,7 @@ mod tests { 15.0, Some(3.75), Some(0.3), - None, // No reasoning price + None, // No reasoning price vec![ "claude-sonnet-4.5".to_string(), "claude-sonnet-4-5".to_string(), diff --git a/src-tauri/src/models/token_stats.rs b/src-tauri/src/models/token_stats.rs index 100da54..a908560 100644 --- a/src-tauri/src/models/token_stats.rs +++ b/src-tauri/src/models/token_stats.rs @@ -292,7 +292,7 @@ mod tests { 500, 100, 200, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "sse".to_string(), None, @@ -302,7 +302,7 @@ mod tests { Some(0.0075), Some(0.000375), Some(0.00006), - None, // reasoning_price + None, // reasoning_price 0.011235, Some("builtin_claude".to_string()), ); @@ -323,7 +323,7 @@ mod tests { total_output: 5000, total_cache_creation: 1000, total_cache_read: 2000, - total_reasoning: 0, // 新增字段 + total_reasoning: 0, // 新增字段 request_count: 10, }; 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 index ecdb0fc..48ef196 100644 --- a/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs +++ b/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs @@ -142,7 +142,11 @@ mod tests { "gemini-cli": "builtin_claude" }); - fs::write(&config_path, serde_json::to_string_pretty(&old_config).unwrap()).unwrap(); + fs::write( + &config_path, + serde_json::to_string_pretty(&old_config).unwrap(), + ) + .unwrap(); // 执行迁移 let manager = DataManager::new(); diff --git a/src-tauri/src/services/pricing/builtin.rs b/src-tauri/src/services/pricing/builtin.rs index 3db618e..e265e0e 100644 --- a/src-tauri/src/services/pricing/builtin.rs +++ b/src-tauri/src/services/pricing/builtin.rs @@ -15,9 +15,9 @@ pub fn builtin_openai_official_template() -> PricingTemplate { "openai".to_string(), 3.0, 12.0, - None, // OpenAI 不收费缓存创建 - Some(1.5), // Cache read: input * 0.5 (OpenAI 标准) - None, // 标准模型无推理 tokens + 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(), @@ -33,7 +33,11 @@ pub fn builtin_openai_official_template() -> PricingTemplate { "1.0".to_string(), vec![], // 内置模板不使用继承 custom_models, - vec!["official".to_string(), "openai".to_string(), "codex".to_string()], + vec![ + "official".to_string(), + "openai".to_string(), + "codex".to_string(), + ], true, // 标记为内置预设模板 ) } diff --git a/src-tauri/src/services/pricing/manager.rs b/src-tauri/src/services/pricing/manager.rs index dd73ef1..62990d4 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, builtin_openai_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; diff --git a/src-tauri/src/services/token_stats/analytics.rs b/src-tauri/src/services/token_stats/analytics.rs index bc7a2c6..ed2d2cb 100644 --- a/src-tauri/src/services/token_stats/analytics.rs +++ b/src-tauri/src/services/token_stats/analytics.rs @@ -442,7 +442,7 @@ mod tests { 50, 10, 20, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -506,7 +506,7 @@ mod tests { 50, 10, 20, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, 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 a083231..98d2da2 100644 --- a/src-tauri/src/services/token_stats/cost_calculation_test.rs +++ b/src-tauri/src/services/token_stats/cost_calculation_test.rs @@ -147,10 +147,7 @@ mod tests { }); let token_info = processor - .process_json_response( - &serde_json::to_vec(&request_body).unwrap(), - &response_json, - ) + .process_json_response(&serde_json::to_vec(&request_body).unwrap(), &response_json) .unwrap(); assert_eq!(token_info.input_tokens, 100); @@ -185,10 +182,7 @@ mod tests { // 步骤1: 提取 Token let token_info = processor - .process_json_response( - &serde_json::to_vec(&request_body).unwrap(), - &response_json, - ) + .process_json_response(&serde_json::to_vec(&request_body).unwrap(), &response_json) .unwrap(); // 步骤2: 计算成本 @@ -277,10 +271,7 @@ mod tests { // 步骤1: 提取 Token let token_info = processor - .process_json_response( - &serde_json::to_vec(&request_body).unwrap(), - &response_json, - ) + .process_json_response(&serde_json::to_vec(&request_body).unwrap(), &response_json) .unwrap(); // 验证 Token 提取正确(新输入 = 总输入 - 缓存) diff --git a/src-tauri/src/services/token_stats/db.rs b/src-tauri/src/services/token_stats/db.rs index c05171e..6e47192 100644 --- a/src-tauri/src/services/token_stats/db.rs +++ b/src-tauri/src/services/token_stats/db.rs @@ -137,20 +137,27 @@ impl TokenStatsDb { .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 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() + let exists = rows + .first() .and_then(|row| row.values.first()) .and_then(|v| v.as_i64()) - .unwrap_or(0) > 0; + .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") + .execute_raw( + "ALTER TABLE token_logs ADD COLUMN reasoning_tokens INTEGER NOT NULL DEFAULT 0", + ) .context("Failed to add reasoning_tokens column")?; // 添加 reasoning_price 字段 @@ -665,7 +672,7 @@ mod tests { 500, 100, 200, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -708,7 +715,7 @@ mod tests { 50, 10, 20, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "sse".to_string(), None, @@ -763,7 +770,7 @@ mod tests { 50, 0, 0, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -791,7 +798,7 @@ mod tests { 100, 0, 0, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, diff --git a/src-tauri/src/services/token_stats/logger/claude.rs b/src-tauri/src/services/token_stats/logger/claude.rs index 57bf085..50d859f 100644 --- a/src-tauri/src/services/token_stats/logger/claude.rs +++ b/src-tauri/src/services/token_stats/logger/claude.rs @@ -24,7 +24,7 @@ impl ClaudeLogger { ) -> Result { // 计算成本 let cost_result = PRICING_MANAGER.calculate_cost( - None, // 使用默认模板 + None, // 使用默认模板 Some("claude-code"), // 工具 ID &token_info.model, token_info.input_tokens, @@ -155,7 +155,11 @@ impl TokenLogger for ClaudeLogger { // 尝试从请求体提取 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())) + .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( diff --git a/src-tauri/src/services/token_stats/logger/codex.rs b/src-tauri/src/services/token_stats/logger/codex.rs index 2eca483..eb1ce21 100644 --- a/src-tauri/src/services/token_stats/logger/codex.rs +++ b/src-tauri/src/services/token_stats/logger/codex.rs @@ -24,7 +24,7 @@ impl CodexLogger { ) -> Result { // 计算成本 let cost_result = PRICING_MANAGER.calculate_cost( - None, // 使用默认模板 + None, // 使用默认模板 Some("codex"), // 工具 ID &token_info.model, token_info.input_tokens, @@ -155,7 +155,11 @@ impl TokenLogger for CodexLogger { // 尝试从请求体提取 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())) + .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( diff --git a/src-tauri/src/services/token_stats/manager.rs b/src-tauri/src/services/token_stats/manager.rs index 3123d18..433bfe6 100644 --- a/src-tauri/src/services/token_stats/manager.rs +++ b/src-tauri/src/services/token_stats/manager.rs @@ -219,7 +219,7 @@ mod tests { 50, 10, 20, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, @@ -258,7 +258,7 @@ mod tests { 50, 10, 20, - 0, // reasoning_tokens + 0, // reasoning_tokens "success".to_string(), "json".to_string(), None, diff --git a/src-tauri/src/services/token_stats/processor/claude.rs b/src-tauri/src/services/token_stats/processor/claude.rs index 7cd8732..26335e0 100644 --- a/src-tauri/src/services/token_stats/processor/claude.rs +++ b/src-tauri/src/services/token_stats/processor/claude.rs @@ -187,7 +187,11 @@ impl ToolProcessor for ClaudeProcessor { // 回退到请求体 serde_json::from_slice::(request_body) .ok() - .and_then(|req| req.get("model").and_then(|v| v.as_str()).map(|s| s.to_string())) + .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")?; @@ -199,7 +203,9 @@ impl ToolProcessor for ClaudeProcessor { .to_string(); // 3. 提取 usage - let usage = json.get("usage").context("Missing 'usage' field in response")?; + let usage = json + .get("usage") + .context("Missing 'usage' field in response")?; let input_tokens = usage .get("input_tokens") diff --git a/src-tauri/src/services/token_stats/processor/codex.rs b/src-tauri/src/services/token_stats/processor/codex.rs index 677991c..98fb594 100644 --- a/src-tauri/src/services/token_stats/processor/codex.rs +++ b/src-tauri/src/services/token_stats/processor/codex.rs @@ -161,7 +161,11 @@ impl ToolProcessor for CodexProcessor { // 回退到请求体 serde_json::from_slice::(request_body) .ok() - .and_then(|req| req.get("model").and_then(|v| v.as_str()).map(|s| s.to_string())) + .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")?; @@ -173,7 +177,9 @@ impl ToolProcessor for CodexProcessor { .to_string(); // 3. 提取 usage - let usage = json.get("usage").context("Missing 'usage' field in response")?; + let usage = json + .get("usage") + .context("Missing 'usage' field in response")?; // Codex 的 input_tokens 包括缓存的 token // 需要减去 cached_tokens 才是真正的新输入 From a376b4a3ba44277ed9d552a0ff8044549cd3b386 Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:49:57 +0800 Subject: [PATCH 5/6] =?UTF-8?q?refactor(ui):=20=E9=87=8D=E6=9E=84=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E6=9E=B6=E6=9E=84=E5=AE=9E=E7=8E=B0=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=8C=96=E5=92=8C=E7=8A=B6=E6=80=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **核心改动**: - 引入 AppContext 统一管理全局状态(工具状态、配置、更新检查、导航等) - 拆分 App.tsx 逻辑到独立模块(AppEventsHandler、ConfigWatchHandler、UpdateManager、OnboardingManager) - 新增 MainLayout 组件统一布局结构 **组件提取**: - 新增 ViewToggle、BalanceTable、HelpDialog、ProfileTable、ProviderCard、TokenCard、ToolInstanceCard 等可复用组件 - 重构 AppSidebar 为导航组分组结构,优化布局和主题切换 - 简化 PageContainer,移除冗余逻辑 **页面优化**: - 重构 ProfileManagementPage、ProviderManagementPage、ToolManagementPage 使用新组件 - 优化 DashboardPage、TokenStatisticsPage、BalancePage 布局和交互 - 统一 SettingsPage 各标签页样式 **样式更新**: - 更新主题配色方案(浅色模式 Indigo 600,深色模式 Zinc 950) - 调整 border-radius 从 0.5rem 到 0.75rem - 优化深色模式对比度 **架构优势**: - 代码量减少 -38%(1989 删除 vs 2809 新增,净减少因组件复用) - 状态管理集中化,避免 prop drilling - 组件职责单一,提升可维护性和可测试性 - 为后续功能扩展奠定基础 --- .gemini-clipboard/clipboard-1769308510109.png | Bin 0 -> 103474 bytes .gemini-clipboard/clipboard-1769308817019.png | Bin 0 -> 98581 bytes src/App.tsx | 494 +----------------- src/components/common/ViewToggle.tsx | 41 ++ src/components/layout/AppSidebar.tsx | 384 ++++++-------- src/components/layout/MainLayout.tsx | 43 ++ src/components/layout/PageContainer.tsx | 43 +- src/components/logic/AppContent.tsx | 59 +++ src/components/logic/AppEventsHandler.tsx | 149 ++++++ src/components/logic/ConfigWatchHandler.tsx | 20 + src/components/logic/OnboardingManager.tsx | 133 +++++ src/components/logic/UpdateManager.tsx | 24 + src/contexts/AppContext.tsx | 167 ++++++ src/index.css | 90 ++-- src/pages/AboutPage/index.tsx | 24 +- .../BalancePage/components/BalanceTable.tsx | 151 ++++++ src/pages/BalancePage/index.tsx | 100 ++-- src/pages/DashboardPage/index.tsx | 189 +++---- src/pages/HelpPage/index.tsx | 21 +- src/pages/InstallationPage/index.tsx | 27 +- .../components/ActiveProfileCard.tsx | 419 ++++++--------- .../components/HelpDialog.tsx | 41 ++ .../components/ProfileCard.tsx | 114 ++-- .../components/ProfileTable.tsx | 197 +++++++ src/pages/ProfileManagementPage/index.tsx | 216 ++++---- .../components/ProviderCard.tsx | 115 ++++ .../components/RemoteTokenManagement.tsx | 19 +- .../components/TokenCard.tsx | 111 ++++ src/pages/ProviderManagementPage/index.tsx | 58 +- .../components/BasicSettingsTab.tsx | 121 ++--- .../components/ProxySettingsTab.tsx | 302 ++++++----- src/pages/SettingsPage/index.tsx | 49 +- src/pages/TokenStatisticsPage/index.tsx | 351 ++++++------- .../components/ToolInstanceCard.tsx | 167 ++++++ .../components/ToolListSection.tsx | 242 +++++---- src/pages/ToolManagementPage/index.tsx | 96 ++-- src/pages/TransparentProxyPage/index.tsx | 21 +- 37 files changed, 2809 insertions(+), 1989 deletions(-) create mode 100644 .gemini-clipboard/clipboard-1769308510109.png create mode 100644 .gemini-clipboard/clipboard-1769308817019.png create mode 100644 src/components/common/ViewToggle.tsx create mode 100644 src/components/layout/MainLayout.tsx create mode 100644 src/components/logic/AppContent.tsx create mode 100644 src/components/logic/AppEventsHandler.tsx create mode 100644 src/components/logic/ConfigWatchHandler.tsx create mode 100644 src/components/logic/OnboardingManager.tsx create mode 100644 src/components/logic/UpdateManager.tsx create mode 100644 src/contexts/AppContext.tsx create mode 100644 src/pages/BalancePage/components/BalanceTable.tsx create mode 100644 src/pages/ProfileManagementPage/components/HelpDialog.tsx create mode 100644 src/pages/ProfileManagementPage/components/ProfileTable.tsx create mode 100644 src/pages/ProviderManagementPage/components/ProviderCard.tsx create mode 100644 src/pages/ProviderManagementPage/components/TokenCard.tsx create mode 100644 src/pages/ToolManagementPage/components/ToolInstanceCard.tsx diff --git a/.gemini-clipboard/clipboard-1769308510109.png b/.gemini-clipboard/clipboard-1769308510109.png new file mode 100644 index 0000000000000000000000000000000000000000..a9c87b0d02291cf475559de675cce7df1cc7025c GIT binary patch literal 103474 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�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%0Dva0Vd8i1 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 literal 0 HcmV?d00001 diff --git a/.gemini-clipboard/clipboard-1769308817019.png b/.gemini-clipboard/clipboard-1769308817019.png new file mode 100644 index 0000000000000000000000000000000000000000..5c1f56619995e7aac56e7a1ddfe89bb8a84ef0dc GIT binary patch literal 98581 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

{I~%tyFc%qIghNz6QGtLQC=X2N~W z)qH8NssBS(Ag2b8R#Mc_?hv1?T^ZeZ8r2XpBbHUaRw#q7w|Be&zB;G2{P`}|6{%1) zl=_CUlRcZygvFT?884sk-mYDJ5S&G&x zed5Ft`^}<5DXrq#+^!_JO{#bn-g%)ukW{t-gN@HdoXI$O(nwYABV+9cyv~^a<2yM( ztFsa-2iu0|#9&Ztx)1ej7OFe&(4r!1jn6z5Ntf`L+s*ABe-6CizyFo=(kzZlC(qTZ zHTM>*9e>$49zVrskPKcs$ExCAb4Fh}nZC>yMIk;OPEH_>2%0ia+MYgIZtZ#V{{YDm BX1@Rc literal 0 HcmV?d00001 diff --git a/src/App.tsx b/src/App.tsx index 1eb4000..d769ac9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,484 +1,24 @@ -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 通知 */} - -
- + + + + + + + + + ); } export default App; + diff --git a/src/components/common/ViewToggle.tsx b/src/components/common/ViewToggle.tsx new file mode 100644 index 0000000..414417d --- /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 ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index f8981c3..524cfbf 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,49 @@ 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 +77,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 +90,6 @@ export function AppSidebar({ const handleTabChange = (tab: string) => { if (restrictNavigation) { - // 只允许访问 allowedPage if (allowedPage && tab !== allowedPage) { toast({ title: '请先完成引导', @@ -92,217 +104,149 @@ 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 button = ( - - ); - - if (isCollapsed) { - return ( - - {button} - + const isDisabled = restrictNavigation && allowedPage ? item.id !== allowedPage : false; + + 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..c9e3d59 --- /dev/null +++ b/src/components/layout/MainLayout.tsx @@ -0,0 +1,43 @@ +import { AppSidebar } from '@/components/layout/AppSidebar'; +import { useAppContext } from '@/contexts/AppContext'; +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} +
+
+
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/layout/PageContainer.tsx b/src/components/layout/PageContainer.tsx index 0ce4c7c..ae0bacd 100644 --- a/src/components/layout/PageContainer.tsx +++ b/src/components/layout/PageContainer.tsx @@ -1,18 +1,49 @@ 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} +
); -} +} \ No newline at end of file diff --git a/src/components/logic/AppContent.tsx b/src/components/logic/AppContent.tsx new file mode 100644 index 0000000..37cc85c --- /dev/null +++ b/src/components/logic/AppContent.tsx @@ -0,0 +1,59 @@ +import { useAppContext } from '@/contexts/AppContext'; +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..2f63475 --- /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 '@/contexts/AppContext'; +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'; + +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..47efd58 --- /dev/null +++ b/src/components/logic/OnboardingManager.tsx @@ -0,0 +1,133 @@ +import { useState, useEffect, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { listen, emit } from '@tauri-apps/api/event'; +import { useAppContext } from '@/contexts/AppContext'; +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'; + +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..f626d82 --- /dev/null +++ b/src/components/logic/UpdateManager.tsx @@ -0,0 +1,24 @@ +import { useAppContext } from '@/contexts/AppContext'; +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..8d938e2 --- /dev/null +++ b/src/contexts/AppContext.tsx @@ -0,0 +1,167 @@ +import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; +import { checkInstallations, getGlobalConfig, checkForAppUpdates } from '@/lib/tauri-commands'; +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'; + +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; +} + +const AppContext = createContext(undefined); + +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}; +} + +export function useAppContext() { + const context = useContext(AppContext); + if (context === undefined) { + throw new Error('useAppContext must be used within an AppProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/index.css b/src/index.css index 362b897..1e0af01 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..b9a475d 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 '@/contexts/AppContext'; 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,15 @@ 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..c37bf5f --- /dev/null +++ b/src/pages/BalancePage/components/BalanceTable.tsx @@ -0,0 +1,151 @@ +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)} + + +
+ + + +
+
+
+ ); + })} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/BalancePage/index.tsx b/src/pages/BalancePage/index.tsx index 59e9480..b478b0f 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,34 @@ 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 +288,59 @@ 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) => (
- {/* 第二段:供应商标签页 */} -
-
-

供应商与用量统计

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

供应商与用量统计

); -} +} \ No newline at end of file diff --git a/src/pages/HelpPage/index.tsx b/src/pages/HelpPage/index.tsx index 21cc539..dfd92c5 100644 --- a/src/pages/HelpPage/index.tsx +++ b/src/pages/HelpPage/index.tsx @@ -2,19 +2,18 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Button } from '@/components/ui/button'; import { BookOpen, Github, MessageCircle, FileText, HelpCircle } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; +import { PageContainer } from '@/components/layout/PageContainer'; -interface HelpPageProps { - onShowOnboarding: () => void; -} +export function HelpPage() { + const onShowOnboarding = () => { + window.dispatchEvent(new CustomEvent('request-show-onboarding')); + }; -export function HelpPage({ onShowOnboarding }: HelpPageProps) { return ( -
-
-

帮助中心

-

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

-
- +
{/* 新手引导 */} @@ -137,6 +136,6 @@ export function HelpPage({ onShowOnboarding }: HelpPageProps) {
-
+ ); } diff --git a/src/pages/InstallationPage/index.tsx b/src/pages/InstallationPage/index.tsx index 1121c96..f18a2a7 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 '@/contexts/AppContext'; 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,10 @@ export function InstallationPage({ }; return ( - -
-

安装工具

-

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

-
- + {loading ? (
diff --git a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx index b936caf..a7de275 100644 --- a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx +++ b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx @@ -1,9 +1,9 @@ /** - * 当前生效 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 +15,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 +40,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 +53,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 +65,221 @@ 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', - }); + 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', - }); + 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 */} +
+ {proxyRunning ? : }
-
- {selectedInstance?.version ? ( - <> - - 当前版本:{selectedInstance.version} - - {latestVersion && ( - - 最新版本:{latestVersion} - - )} - - ) : ( - 未检测到版本信息 - )} + +
+
+

{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..1a84093 --- /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.透明代理模式

+

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

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/pages/ProfileManagementPage/components/ProfileCard.tsx b/src/pages/ProfileManagementPage/components/ProfileCard.tsx index 32a7cd1..7bea286 100644 --- a/src/pages/ProfileManagementPage/components/ProfileCard.tsx +++ b/src/pages/ProfileManagementPage/components/ProfileCard.tsx @@ -3,9 +3,9 @@ */ 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 +27,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 +91,49 @@ 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 && ( + + + + )} {/* 删除确认对话框 */} @@ -202,4 +214,4 @@ export function ProfileCard({ ); -} +} \ No newline at end of file diff --git a/src/pages/ProfileManagementPage/components/ProfileTable.tsx b/src/pages/ProfileManagementPage/components/ProfileTable.tsx new file mode 100644 index 0000000..6bd4e40 --- /dev/null +++ b/src/pages/ProfileManagementPage/components/ProfileTable.tsx @@ -0,0 +1,197 @@ +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" + > + 删除 + + + + +
+ ); +} \ No newline at end of file diff --git a/src/pages/ProfileManagementPage/index.tsx b/src/pages/ProfileManagementPage/index.tsx index a00829c..adb6b92 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)}> - - 从供应商导入 - - - -
+
+ {/* 创建按钮 */} +
+
+

+ {group.profiles.length === 0 + ? '暂无 Profile,点击创建新配置' + : `共 ${group.profiles.length} 个配置`} + {group.active_profile && ` · 当前激活: ${group.active_profile.name}`} +

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

暂无 Profile 配置

-
- ) : ( -
- {group.profiles.map((profile) => ( - handleActivateProfile(profile.name)} - onEdit={() => handleEditProfile(profile)} - onDelete={() => handleDeleteProfile(profile.name)} + {/* 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)} @@ -289,38 +305,4 @@ export default function ProfileManagementPage() { /> ); -} - -/** - * 帮助弹窗组件 - */ -function HelpDialog({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - return ( - - - - 配置管理帮助 - 了解如何使用 Profile 配置管理功能 - -
-
-

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

-

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

-

2.透明代理模式

-

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

-
-
-
-
- ); -} +} \ No newline at end of file diff --git a/src/pages/ProviderManagementPage/components/ProviderCard.tsx b/src/pages/ProviderManagementPage/components/ProviderCard.tsx new file mode 100644 index 0000000..cf4b057 --- /dev/null +++ b/src/pages/ProviderManagementPage/components/ProviderCard.tsx @@ -0,0 +1,115 @@ +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 d98ec56..ccd748f 100644 --- a/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx +++ b/src/pages/ProviderManagementPage/components/RemoteTokenManagement.tsx @@ -31,6 +31,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; @@ -52,6 +54,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'); /** * 加载令牌列表 @@ -166,6 +169,8 @@ export function RemoteTokenManagement({ provider }: RemoteTokenManagementProps)

远程令牌

+ +
); -} +} \ No newline at end of file diff --git a/src/pages/ToolManagementPage/index.tsx b/src/pages/ToolManagementPage/index.tsx index 5c1a589..b115493 100644 --- a/src/pages/ToolManagementPage/index.tsx +++ b/src/pages/ToolManagementPage/index.tsx @@ -9,21 +9,15 @@ 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'; - -interface ToolManagementPageProps { - tools: ToolStatus[]; - loading: boolean; - restrictNavigation?: boolean; // 新增:引导模式限制 -} - -export function ToolManagementPage({ - tools: _toolsProp, - loading: loadingProp, - restrictNavigation, -}: ToolManagementPageProps) { - // _toolsProp 和 loadingProp 用于全局缓存,但工具管理需要更详细的 ToolInstance 数据 - // 所以仍然需要加载完整的工具实例信息 +import { useAppContext } from '@/contexts/AppContext'; +import { ViewToggle, ViewMode } from '@/components/common/ViewToggle'; + +export function ToolManagementPage() { + const { tools: _toolsProp, toolsLoading: loadingProp, restrictedPage, setActiveTab, refreshTools: globalRefreshTools } = useAppContext(); + + const restrictNavigation = restrictedPage === 'tool-management'; + const [viewMode, setViewMode] = useState('list'); + const { groupedByTool, loading: dataLoading, @@ -40,7 +34,8 @@ export function ToolManagementPage({ // 通知父组件刷新工具列表 const onRefreshTools = () => { - window.dispatchEvent(new CustomEvent('refresh-tools')); + // Also trigger global refresh + globalRefreshTools(); refreshTools(); }; @@ -86,33 +81,31 @@ export function ToolManagementPage({ }; }, []); - return ( - - {/* 页面标题和操作按钮 */} -
-
-

工具管理

-

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

-
-
- - - -
-
+ const pageActions = ( +
+ +
+ + + +
+ ); + return ( + {/* 引导模式提示 */} {restrictNavigation && ( @@ -145,20 +138,20 @@ export function ToolManagementPage({ {/* Tab 按工具切换 */} {!loadingProp && !dataLoading && !error && ( - - + + Claude Code - + CodeX - + Gemini CLI {/* Claude Code Tab */} - + {/* CodeX Tab */} - + {/* Gemini CLI Tab */} - + @@ -226,4 +222,4 @@ export function ToolManagementPage({ /> ); -} +} \ No newline at end of file diff --git a/src/pages/TransparentProxyPage/index.tsx b/src/pages/TransparentProxyPage/index.tsx index 5d373cf..4adc199 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} From f5c3adeeb021fb2ee413f44d3067c693634dac9a Mon Sep 17 00:00:00 2001 From: JSRCode <139555610+jsrcode@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:58:39 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(lint):=20=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E5=A4=B1=E8=B4=A5=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **前端修复**: - 移除 ActiveProfileCard.tsx 中未使用的 error 变量 - 修复 AppContext Fast Refresh 警告: - 新建 AppContext.types.ts 存储类型定义 - 新建 hooks/useAppContext.ts 存储自定义 hook - 分离组件和非组件导出,符合 Fast Refresh 规范 - 更新所有导入路径使用新的模块结构 - 运行 Prettier 统一代码格式 **后端修复**: - 移除 pricing_default_templates.rs 中重复的 migrated 赋值 - 添加 #[allow(clippy::too_many_arguments)] 到: - pricing/manager.rs::calculate_cost - logger/claude.rs::build_log - logger/codex.rs::build_log - logger/mod.rs::log_failed_request - 添加 #[allow(clippy::should_implement_trait)] 到: - logger/types.rs::LogStatus::from_str - logger/types.rs::ResponseType::from_str **检查结果**: ✅ AI Agent 配置检查通过 ✅ ESLint 检查通过 ✅ Clippy 检查通过 ✅ Prettier 检查通过 ✅ Rust fmt 检查通过 --- .../migrations/pricing_default_templates.rs | 1 - src-tauri/src/services/pricing/manager.rs | 1 + .../src/services/token_stats/logger/claude.rs | 1 + .../src/services/token_stats/logger/codex.rs | 1 + .../src/services/token_stats/logger/mod.rs | 1 + .../src/services/token_stats/logger/types.rs | 2 + src/App.tsx | 1 - src/components/common/ViewToggle.tsx | 10 +- src/components/layout/AppSidebar.tsx | 136 ++++++++++-------- src/components/layout/MainLayout.tsx | 21 ++- src/components/layout/PageContainer.tsx | 24 ++-- src/components/logic/AppContent.tsx | 8 +- src/components/logic/AppEventsHandler.tsx | 26 ++-- src/components/logic/OnboardingManager.tsx | 36 ++--- src/components/logic/UpdateManager.tsx | 11 +- src/contexts/AppContext.tsx | 61 +------- src/contexts/AppContext.types.ts | 49 +++++++ src/hooks/useAppContext.ts | 10 ++ src/index.css | 6 +- src/pages/AboutPage/index.tsx | 7 +- .../BalancePage/components/BalanceTable.tsx | 17 ++- src/pages/BalancePage/index.tsx | 2 +- src/pages/DashboardPage/index.tsx | 52 ++++--- src/pages/HelpPage/index.tsx | 5 +- src/pages/InstallationPage/index.tsx | 7 +- .../components/ActiveProfileCard.tsx | 109 +++++++++----- .../components/HelpDialog.tsx | 2 +- .../components/ProfileCard.tsx | 99 +++++++------ .../components/ProfileTable.tsx | 42 ++++-- src/pages/ProfileManagementPage/index.tsx | 122 ++++++++-------- .../components/ProviderCard.tsx | 12 +- .../components/RemoteTokenManagement.tsx | 2 +- .../components/TokenCard.tsx | 12 +- src/pages/ProviderManagementPage/index.tsx | 23 +-- .../components/BasicSettingsTab.tsx | 10 +- .../components/ProxySettingsTab.tsx | 11 +- src/pages/SettingsPage/index.tsx | 13 +- src/pages/TokenStatisticsPage/index.tsx | 8 +- .../components/ToolInstanceCard.tsx | 42 +++--- .../components/ToolListSection.tsx | 11 +- src/pages/ToolManagementPage/index.tsx | 38 +++-- src/pages/TransparentProxyPage/index.tsx | 6 +- 42 files changed, 573 insertions(+), 485 deletions(-) create mode 100644 src/contexts/AppContext.types.ts create mode 100644 src/hooks/useAppContext.ts 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 index 48ef196..3d104cf 100644 --- a/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs +++ b/src-tauri/src/services/migration_manager/migrations/pricing_default_templates.rs @@ -87,7 +87,6 @@ impl Migration for PricingDefaultTemplatesMigration { "codex".to_string(), serde_json::Value::String("builtin_openai".to_string()), ); - migrated = true; } } diff --git a/src-tauri/src/services/pricing/manager.rs b/src-tauri/src/services/pricing/manager.rs index 62990d4..ad1fdfa 100644 --- a/src-tauri/src/services/pricing/manager.rs +++ b/src-tauri/src/services/pricing/manager.rs @@ -268,6 +268,7 @@ impl PricingManager { /// # 返回 /// /// 成本分解结果 + #[allow(clippy::too_many_arguments)] pub fn calculate_cost( &self, template_id: Option<&str>, diff --git a/src-tauri/src/services/token_stats/logger/claude.rs b/src-tauri/src/services/token_stats/logger/claude.rs index 50d859f..83b5b98 100644 --- a/src-tauri/src/services/token_stats/logger/claude.rs +++ b/src-tauri/src/services/token_stats/logger/claude.rs @@ -12,6 +12,7 @@ pub struct ClaudeLogger; impl ClaudeLogger { /// 从 TokenInfo 构建 TokenLog + #[allow(clippy::too_many_arguments)] fn build_log( &self, token_info: TokenInfo, diff --git a/src-tauri/src/services/token_stats/logger/codex.rs b/src-tauri/src/services/token_stats/logger/codex.rs index eb1ce21..61caa12 100644 --- a/src-tauri/src/services/token_stats/logger/codex.rs +++ b/src-tauri/src/services/token_stats/logger/codex.rs @@ -12,6 +12,7 @@ pub struct CodexLogger; impl CodexLogger { /// 从 TokenInfo 构建 TokenLog + #[allow(clippy::too_many_arguments)] fn build_log( &self, token_info: TokenInfo, diff --git a/src-tauri/src/services/token_stats/logger/mod.rs b/src-tauri/src/services/token_stats/logger/mod.rs index 8ca6442..fce1fcf 100644 --- a/src-tauri/src/services/token_stats/logger/mod.rs +++ b/src-tauri/src/services/token_stats/logger/mod.rs @@ -75,6 +75,7 @@ pub trait TokenLogger: Send + Sync { /// /// # 返回 /// - TokenLog: 日志记录对象 + #[allow(clippy::too_many_arguments)] fn log_failed_request( &self, request_body: &[u8], diff --git a/src-tauri/src/services/token_stats/logger/types.rs b/src-tauri/src/services/token_stats/logger/types.rs index a8fb202..4f188e4 100644 --- a/src-tauri/src/services/token_stats/logger/types.rs +++ b/src-tauri/src/services/token_stats/logger/types.rs @@ -27,6 +27,7 @@ impl LogStatus { } /// 从字符串解析 + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Self { match s { "success" => LogStatus::Success, @@ -60,6 +61,7 @@ impl ResponseType { } /// 从字符串解析 + #[allow(clippy::should_implement_trait)] pub fn from_str(s: &str) -> Self { match s { "sse" => ResponseType::Sse, diff --git a/src/App.tsx b/src/App.tsx index d769ac9..5ad374f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,4 +21,3 @@ function App() { } export default App; - diff --git a/src/components/common/ViewToggle.tsx b/src/components/common/ViewToggle.tsx index 414417d..3654c67 100644 --- a/src/components/common/ViewToggle.tsx +++ b/src/components/common/ViewToggle.tsx @@ -16,8 +16,8 @@ export function ViewToggle({ mode, onChange }: ViewToggleProps) { variant="ghost" size="icon" className={cn( - "h-7 w-7 rounded-sm", - mode === 'grid' && "bg-background shadow-sm hover:bg-background" + 'h-7 w-7 rounded-sm', + mode === 'grid' && 'bg-background shadow-sm hover:bg-background', )} onClick={() => onChange('grid')} title="卡片视图" @@ -28,8 +28,8 @@ export function ViewToggle({ mode, onChange }: ViewToggleProps) { variant="ghost" size="icon" className={cn( - "h-7 w-7 rounded-sm", - mode === 'list' && "bg-background shadow-sm hover:bg-background" + 'h-7 w-7 rounded-sm', + mode === 'list' && 'bg-background shadow-sm hover:bg-background', )} onClick={() => onChange('list')} title="列表视图" @@ -38,4 +38,4 @@ export function ViewToggle({ mode, onChange }: ViewToggleProps) {
); -} \ No newline at end of file +} diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 524cfbf..66f8c7c 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -39,35 +39,33 @@ interface AppSidebarProps { // 导航组配置 const navigationGroups = [ { - label: "概览", - items: [ - { id: 'dashboard', label: '仪表板', icon: LayoutDashboard }, - ] + label: '概览', + items: [{ id: 'dashboard', label: '仪表板', icon: LayoutDashboard }], }, { - label: "核心工具", + label: '核心工具', items: [ { id: 'tool-management', label: '工具管理', icon: Wrench }, { id: 'profile-management', label: '配置方案', icon: Settings2 }, - ] + ], }, { - label: "网关与监控", + 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: "系统", + label: '系统', items: [ { id: 'settings', label: '全局设置', icon: SettingsIcon }, { id: 'help', label: '帮助中心', icon: HelpCircle }, { id: 'about', label: '关于应用', icon: Info }, - ] - } + ], + }, ]; export function AppSidebar({ @@ -116,11 +114,11 @@ export function AppSidebar({ variant={isActive ? 'secondary' : 'ghost'} size={isCollapsed ? 'icon' : 'default'} className={cn( - "w-full transition-all duration-200 relative overflow-hidden group mb-1", - isCollapsed ? "h-10 w-10 mx-auto" : "justify-start h-9 px-3", - isActive && "bg-primary/10 text-primary hover:bg-primary/15 font-semibold", - !isActive && !isDisabled && "hover:bg-muted/60 hover:text-foreground", - isDisabled && "opacity-50 cursor-not-allowed" + 'w-full transition-all duration-200 relative overflow-hidden group mb-1', + isCollapsed ? 'h-10 w-10 mx-auto' : 'justify-start h-9 px-3', + isActive && 'bg-primary/10 text-primary hover:bg-primary/15 font-semibold', + !isActive && !isDisabled && 'hover:bg-muted/60 hover:text-foreground', + isDisabled && 'opacity-50 cursor-not-allowed', )} onClick={() => handleTabChange(item.id)} disabled={isDisabled} @@ -130,19 +128,20 @@ export function AppSidebar({ )} - {!isCollapsed && ( - {item.label} - )} + {!isCollapsed && {item.label}} {isCollapsed && ( - +

{item.label}

)} @@ -154,16 +153,18 @@ export function AppSidebar({ diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index c9e3d59..d55e532 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -1,5 +1,5 @@ import { AppSidebar } from '@/components/layout/AppSidebar'; -import { useAppContext } from '@/contexts/AppContext'; +import { useAppContext } from '@/hooks/useAppContext'; import { Toaster } from '@/components/ui/toaster'; interface MainLayoutProps { @@ -7,13 +7,8 @@ interface MainLayoutProps { } export function MainLayout({ children }: MainLayoutProps) { - const { - activeTab, - setActiveTab, - settingsRestrictToTab, - restrictedPage - } = useAppContext(); - + const { activeTab, setActiveTab, settingsRestrictToTab, restrictedPage } = useAppContext(); + return (
{/* Sidebar */} @@ -28,16 +23,16 @@ export function MainLayout({ children }: MainLayoutProps) {
{/* Background Gradients/Effects can go here */}
- + {/* Scrollable Content */}
-
- {children} -
+
+ {children} +
); -} \ No newline at end of file +} diff --git a/src/components/layout/PageContainer.tsx b/src/components/layout/PageContainer.tsx index ae0bacd..c8189cc 100644 --- a/src/components/layout/PageContainer.tsx +++ b/src/components/layout/PageContainer.tsx @@ -14,16 +14,16 @@ interface PageContainerProps { * Enhanced Page Container Component * Provides a unified layout structure for all pages with optional header, title, and description. */ -export function PageContainer({ - children, - className = '', +export function PageContainer({ + children, + className = '', header, title, description, - actions + actions, }: PageContainerProps) { return ( -
+
{/* Optional Standard Header Section */} {(title || header || actions) && (
@@ -32,18 +32,12 @@ export function PageContainer({ {description &&

{description}

} {header}
- {actions && ( -
- {actions} -
- )} + {actions &&
{actions}
}
)} - + {/* Main Content */} -
- {children} -
+
{children}
); -} \ No newline at end of file +} diff --git a/src/components/logic/AppContent.tsx b/src/components/logic/AppContent.tsx index 37cc85c..b1fc73b 100644 --- a/src/components/logic/AppContent.tsx +++ b/src/components/logic/AppContent.tsx @@ -1,4 +1,4 @@ -import { useAppContext } from '@/contexts/AppContext'; +import { useAppContext } from '@/hooks/useAppContext'; import { DashboardPage } from '@/pages/DashboardPage'; import { InstallationPage } from '@/pages/InstallationPage'; import ProfileManagementPage from '@/pages/ProfileManagementPage'; @@ -15,11 +15,7 @@ import { AboutPage } from '@/pages/AboutPage'; // For now, we'll try to get what we can from context. export function AppContent() { - const { - activeTab, - tokenStatsParams, - selectedProxyToolId, - } = useAppContext(); + 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. diff --git a/src/components/logic/AppEventsHandler.tsx b/src/components/logic/AppEventsHandler.tsx index 2f63475..fa4dacb 100644 --- a/src/components/logic/AppEventsHandler.tsx +++ b/src/components/logic/AppEventsHandler.tsx @@ -1,13 +1,13 @@ import { useEffect } from 'react'; import { listen } from '@tauri-apps/api/event'; -import { useAppContext } from '@/contexts/AppContext'; +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'; +import type { TabType } from '@/contexts/AppContext.types'; export function AppEventsHandler() { const { @@ -20,7 +20,7 @@ export function AppEventsHandler() { setIsUpdateDialogOpen, refreshTools, } = useAppContext(); - + const { toast } = useToast(); const { @@ -92,14 +92,14 @@ export function AppEventsHandler() { const restrictToTab = event.payload?.restrictToTab || false; setSettingsInitialTab(tab); if (restrictToTab) { - setSettingsRestrictToTab(tab); + setSettingsRestrictToTab(tab); } else { - setSettingsRestrictToTab(undefined); + setSettingsRestrictToTab(undefined); } setActiveTab('settings'); - } + }, ); - + // TODO: Add Onboarding Navigation Logic here or in OnboardingManager const unlistenAppNavigate = listen<{ @@ -125,13 +125,13 @@ export function AppEventsHandler() { unlistenAppNavigate.then((fn) => fn()); }; }, [ - setActiveTab, - setSettingsInitialTab, - setSettingsRestrictToTab, - setIsUpdateDialogOpen, - setUpdateInfo, + setActiveTab, + setSettingsInitialTab, + setSettingsRestrictToTab, + setIsUpdateDialogOpen, + setUpdateInfo, setTokenStatsParams, - toast + toast, ]); return ( diff --git a/src/components/logic/OnboardingManager.tsx b/src/components/logic/OnboardingManager.tsx index 47efd58..9a3c15d 100644 --- a/src/components/logic/OnboardingManager.tsx +++ b/src/components/logic/OnboardingManager.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen, emit } from '@tauri-apps/api/event'; -import { useAppContext } from '@/contexts/AppContext'; +import { useAppContext } from '@/hooks/useAppContext'; import { useToast } from '@/hooks/use-toast'; import OnboardingOverlay from '@/components/Onboarding/OnboardingOverlay'; import { @@ -10,16 +10,12 @@ import { CURRENT_ONBOARDING_VERSION, } from '@/components/Onboarding/config/versions'; import type { OnboardingStatus, OnboardingStep } from '@/types/onboarding'; -import type { TabType } from '@/contexts/AppContext'; +import type { TabType } from '@/contexts/AppContext.types'; export function OnboardingManager() { - const { - setActiveTab, - setSettingsInitialTab, - setSettingsRestrictToTab, - setRestrictedPage - } = useAppContext(); - + const { setActiveTab, setSettingsInitialTab, setSettingsRestrictToTab, setRestrictedPage } = + useAppContext(); + const { toast } = useToast(); const [showOnboarding, setShowOnboarding] = useState(false); @@ -79,11 +75,11 @@ export function OnboardingManager() { // Listen for onboarding events useEffect(() => { - const unlistenRequest = listen('request-show-onboarding', () => { - handleShowOnboarding(); - }); + const unlistenRequest = listen('request-show-onboarding', () => { + handleShowOnboarding(); + }); - const unlistenNavigate = listen<{ + const unlistenNavigate = listen<{ targetPage: string; restrictToTab?: string; autoAction?: string; @@ -112,11 +108,17 @@ export function OnboardingManager() { }); return () => { - unlistenRequest.then(fn => fn()); - unlistenNavigate.then(fn => fn()); - unlistenClear.then(fn => fn()); + unlistenRequest.then((fn) => fn()); + unlistenNavigate.then((fn) => fn()); + unlistenClear.then((fn) => fn()); }; - }, [handleShowOnboarding, setActiveTab, setRestrictedPage, setSettingsInitialTab, setSettingsRestrictToTab]); + }, [ + handleShowOnboarding, + setActiveTab, + setRestrictedPage, + setSettingsInitialTab, + setSettingsRestrictToTab, + ]); return ( <> diff --git a/src/components/logic/UpdateManager.tsx b/src/components/logic/UpdateManager.tsx index f626d82..e692618 100644 --- a/src/components/logic/UpdateManager.tsx +++ b/src/components/logic/UpdateManager.tsx @@ -1,14 +1,9 @@ -import { useAppContext } from '@/contexts/AppContext'; +import { useAppContext } from '@/hooks/useAppContext'; import { UpdateDialog } from '@/components/dialogs/UpdateDialog'; export function UpdateManager() { - const { - updateInfo, - setUpdateInfo, - isUpdateDialogOpen, - setIsUpdateDialogOpen, - checkAppUpdates - } = useAppContext(); + const { updateInfo, setUpdateInfo, isUpdateDialogOpen, setIsUpdateDialogOpen, checkAppUpdates } = + useAppContext(); return ( 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; -} - -const AppContext = createContext(undefined); +import { AppContext, type TabType } from '@/contexts/AppContext.types'; export function AppProvider({ children }: { children: React.ReactNode }) { // Navigation State @@ -121,7 +74,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) { useEffect(() => { refreshTools(); refreshGlobalConfig(); - + // Initial update check delay const timer = setTimeout(() => { checkAppUpdates(); @@ -157,11 +110,3 @@ export function AppProvider({ children }: { children: React.ReactNode }) { return {children}; } - -export function useAppContext() { - const context = useContext(AppContext); - if (context === undefined) { - throw new Error('useAppContext must be used within an AppProvider'); - } - return context; -} \ No newline at end of file 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 1e0af01..89beee0 100644 --- a/src/index.css +++ b/src/index.css @@ -63,16 +63,16 @@ 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 b9a475d..d83e4ba 100644 --- a/src/pages/AboutPage/index.tsx +++ b/src/pages/AboutPage/index.tsx @@ -6,7 +6,7 @@ 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 { useAppContext } from '@/contexts/AppContext'; +import { useAppContext } from '@/hooks/useAppContext'; import duckLogo from '@/assets/duck-logo.png'; export function AboutPage() { @@ -27,10 +27,7 @@ export function AboutPage() { }; return ( - +
diff --git a/src/pages/BalancePage/components/BalanceTable.tsx b/src/pages/BalancePage/components/BalanceTable.tsx index c37bf5f..5018a85 100644 --- a/src/pages/BalancePage/components/BalanceTable.tsx +++ b/src/pages/BalancePage/components/BalanceTable.tsx @@ -45,11 +45,12 @@ export function BalanceTable({ const getStatusIcon = (state?: BalanceRuntimeState) => { if (!state) return -; if (state.loading) return ; - if (state.error) return ( - - - - ); + if (state.error) + return ( + + + + ); if (state.lastResult) return ; return -; }; @@ -100,7 +101,9 @@ export function BalanceTable({ {data?.total ? (
- {percentage.toFixed(1)}% + + {percentage.toFixed(1)}% +
) : ( - @@ -148,4 +151,4 @@ export function BalanceTable({
); -} \ No newline at end of file +} diff --git a/src/pages/BalancePage/index.tsx b/src/pages/BalancePage/index.tsx index b478b0f..1b42999 100644 --- a/src/pages/BalancePage/index.tsx +++ b/src/pages/BalancePage/index.tsx @@ -223,4 +223,4 @@ export function BalancePage() { ); } -export default BalancePage; \ No newline at end of file +export default BalancePage; diff --git a/src/pages/DashboardPage/index.tsx b/src/pages/DashboardPage/index.tsx index e3e12c6..031ef2b 100644 --- a/src/pages/DashboardPage/index.tsx +++ b/src/pages/DashboardPage/index.tsx @@ -10,7 +10,7 @@ import { useDashboard } from './hooks/useDashboard'; import { useDashboardProviders } from './hooks/useDashboardProviders'; import { getToolDisplayName } from '@/utils/constants'; import { useToast } from '@/hooks/use-toast'; -import { useAppContext } from '@/contexts/AppContext'; +import { useAppContext } from '@/hooks/useAppContext'; import { getUserQuota, getUsageStats, @@ -23,7 +23,7 @@ import type { UserQuotaResult, UsageStatsResult } from '@/lib/tauri-commands/typ export function DashboardPage() { const { toast } = useToast(); const { tools: toolsProp, toolsLoading: loadingProp, setActiveTab } = useAppContext(); - + const [loading, setLoading] = useState(loadingProp); const [refreshing, setRefreshing] = useState(false); const [quota, setQuota] = useState(null); @@ -175,7 +175,7 @@ export function DashboardPage() { // 切换到配置页面 const switchToConfig = (_toolId?: string) => { - setActiveTab('profile-management'); + setActiveTab('profile-management'); }; // 切换到安装页面 @@ -253,27 +253,25 @@ export function DashboardPage() { ); }); - const installedCount = displayTools.filter(t => t.installed).length; - const updateCount = displayTools.filter(t => t.hasUpdate).length; + const installedCount = displayTools.filter((t) => t.installed).length; + const updateCount = displayTools.filter((t) => t.hasUpdate).length; const pageActions = (
- -
@@ -299,13 +297,17 @@ export function DashboardPage() {
{installedCount}
-

共 {FIXED_TOOL_IDS.length} 个支持工具

+

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

可用更新 - 0 ? "text-amber-500" : "text-muted-foreground"}`} /> + 0 ? 'text-amber-500' : 'text-muted-foreground'}`} + />
{updateCount}
@@ -372,7 +374,11 @@ export function DashboardPage() { onClick={handleRefreshProviderData} disabled={quotaLoading || statsLoading} > - {quotaLoading || statsLoading ? : } + {quotaLoading || statsLoading ? ( + + ) : ( + + )} 刷新数据
@@ -392,4 +398,4 @@ export function DashboardPage() { )} ); -} \ No newline at end of file +} diff --git a/src/pages/HelpPage/index.tsx b/src/pages/HelpPage/index.tsx index dfd92c5..9eae394 100644 --- a/src/pages/HelpPage/index.tsx +++ b/src/pages/HelpPage/index.tsx @@ -10,10 +10,7 @@ export function HelpPage() { }; return ( - +
{/* 新手引导 */} diff --git a/src/pages/InstallationPage/index.tsx b/src/pages/InstallationPage/index.tsx index f18a2a7..dc5304c 100644 --- a/src/pages/InstallationPage/index.tsx +++ b/src/pages/InstallationPage/index.tsx @@ -5,7 +5,7 @@ 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 '@/contexts/AppContext'; +import { useAppContext } from '@/hooks/useAppContext'; import type { ToolStatus } from '@/lib/tauri-commands'; export function InstallationPage() { @@ -99,10 +99,7 @@ export function InstallationPage() { }; return ( - + {loading ? (
diff --git a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx index a7de275..7af0b67 100644 --- a/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx +++ b/src/pages/ProfileManagementPage/components/ActiveProfileCard.tsx @@ -3,7 +3,19 @@ */ import { useState, useEffect } from 'react'; -import { ChevronDown, ChevronUp, Loader2, Server, Terminal, Laptop, Settings, RefreshCw, CheckCircle2, Zap, Download } 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'; @@ -90,7 +102,7 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp setLatestVersion(null); toast({ title: '已是最新版本' }); } - } catch (error) { + } catch { toast({ title: '检测失败', variant: 'destructive' }); } finally { setCheckingUpdate(false); @@ -111,7 +123,7 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp } else { toast({ title: '更新失败', description: result.message, variant: 'destructive' }); } - } catch (error) { + } catch { toast({ title: '更新失败', variant: 'destructive' }); } finally { setUpdating(false); @@ -119,27 +131,38 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp }; return ( - +
- {/* Left: Info Area */}
{/* Icon Box */} -
- {proxyRunning ? : } +
+ {proxyRunning ? ( + + ) : ( + + )}
- +

{group.tool_name}

@@ -163,14 +186,14 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp ) : ( 未激活任何配置 )} - + {selectedInstance?.version && (
v{selectedInstance.version}
)} - + {hasUpdate && latestVersion && (
New v{latestVersion} @@ -182,7 +205,6 @@ export function ActiveProfileCard({ group, proxyRunning }: ActiveProfileCardProp {/* Right: Controls Area */}
- {/* Instance Selector */} {!loading && toolInstances.length > 0 && (