diff --git a/package.json b/package.json index 782ba58d9..95ad9625d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.17.4", +"version": "0.17.6", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a20b0ca38..6772ebd54 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3377,7 +3377,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.17.4" +version = "0.17.6" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ece71fe0b..f66f4f8b8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.17.4" +version = "0.17.6" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands/auto_fix_cmd.rs b/src-tauri/src/commands/auto_fix_cmd.rs new file mode 100644 index 000000000..7d1c167e0 --- /dev/null +++ b/src-tauri/src/commands/auto_fix_cmd.rs @@ -0,0 +1,264 @@ +//! 自动修复命令 +//! +//! 提供自动检测和修复常见配置问题的功能 + +use crate::database::dao::provider_pool::ProviderPoolDao; +use crate::database::DbConnection; +use crate::models::provider_pool_model::PoolProviderType; +use crate::{config, AppState, LogState, ProviderType}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +/// 自动修复结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AutoFixResult { + pub issues_found: Vec, + pub fixes_applied: Vec, + pub warnings: Vec, +} + +/// 自动检测并修复配置问题 +#[tauri::command] +pub async fn auto_fix_configuration( + state: State<'_, AppState>, + logs: State<'_, LogState>, + db: State<'_, DbConnection>, +) -> Result { + let mut result = AutoFixResult { + issues_found: Vec::new(), + fixes_applied: Vec::new(), + warnings: Vec::new(), + }; + + logs.write() + .await + .add("info", "[自动修复] 开始检测配置问题..."); + + // 检查默认Provider配置 + if let Err(e) = fix_default_provider_issue(&state, &logs, &db, &mut result).await { + result + .warnings + .push(format!("修复默认Provider时出错: {}", e)); + } + + // 检查凭证池状态 + if let Err(e) = check_credential_pool_issues(&db, &mut result).await { + result.warnings.push(format!("检查凭证池时出错: {}", e)); + } + + logs.write().await.add( + "info", + &format!( + "[自动修复] 完成,发现 {} 个问题,修复 {} 个", + result.issues_found.len(), + result.fixes_applied.len() + ), + ); + + Ok(result) +} + +/// 修复默认Provider配置问题 +async fn fix_default_provider_issue( + state: &State<'_, AppState>, + logs: &State<'_, LogState>, + db: &State<'_, DbConnection>, + result: &mut AutoFixResult, +) -> Result<(), String> { + let current_default = { + let s = state.read().await; + s.config.default_provider.clone() + }; + + // 获取可用的凭证类型统计 + let credential_stats = get_credential_stats(db).await?; + + // 检查是否有Kiro凭证但默认Provider不是kiro + if credential_stats.kiro_count > 0 && current_default != "kiro" { + result.issues_found.push(format!( + "默认Provider设置为 '{}' 但有 {} 个Kiro凭证可用", + current_default, credential_stats.kiro_count + )); + + // 自动修复:设置默认Provider为kiro + if let Err(e) = set_default_provider_internal(state, logs, "kiro".to_string()).await { + result + .warnings + .push(format!("无法自动修复默认Provider: {}", e)); + } else { + result + .fixes_applied + .push("默认Provider已自动设置为 'kiro'".to_string()); + logs.write() + .await + .add("info", "[自动修复] 默认Provider已设置为kiro"); + } + } + // 检查是否默认Provider指向的凭证类型不可用 + else if !is_provider_available(¤t_default, &credential_stats) { + result + .issues_found + .push(format!("默认Provider '{}' 没有可用凭证", current_default)); + + // 寻找最佳替代Provider + if let Some(best_provider) = find_best_available_provider(&credential_stats) { + if let Err(e) = set_default_provider_internal(state, logs, best_provider.clone()).await + { + result + .warnings + .push(format!("无法自动修复默认Provider: {}", e)); + } else { + result + .fixes_applied + .push(format!("默认Provider已自动设置为 '{}'", best_provider)); + logs.write().await.add( + "info", + &format!("[自动修复] 默认Provider已设置为{}", best_provider), + ); + } + } else { + result + .warnings + .push("没有找到可用的Provider作为默认选择".to_string()); + } + } + + Ok(()) +} + +/// 获取凭证统计信息 +#[derive(Debug, Default)] +struct CredentialStats { + kiro_count: usize, + gemini_count: usize, + qwen_count: usize, + openai_count: usize, + claude_count: usize, + total_count: usize, +} + +async fn get_credential_stats(db: &State<'_, DbConnection>) -> Result { + let conn = db.lock().map_err(|e| e.to_string())?; + let mut stats = CredentialStats::default(); + + // 统计各类型凭证数量(只计算启用且健康的凭证) + let all_credentials = ProviderPoolDao::get_all(&conn).map_err(|e| e.to_string())?; + + for cred in all_credentials + .iter() + .filter(|c| !c.is_disabled && c.is_healthy) + { + match cred.provider_type { + PoolProviderType::Kiro => stats.kiro_count += 1, + PoolProviderType::Gemini => stats.gemini_count += 1, + PoolProviderType::Qwen => stats.qwen_count += 1, + PoolProviderType::OpenAI => stats.openai_count += 1, + PoolProviderType::Claude => stats.claude_count += 1, + _ => {} + } + stats.total_count += 1; + } + + tracing::info!( + "[自动修复] 凭证统计: kiro={}, gemini={}, qwen={}, claude={}, openai={}, total={}", + stats.kiro_count, + stats.gemini_count, + stats.qwen_count, + stats.claude_count, + stats.openai_count, + stats.total_count + ); + + Ok(stats) +} + +/// 检查Provider是否有可用凭证 +fn is_provider_available(provider: &str, stats: &CredentialStats) -> bool { + match provider { + "kiro" => stats.kiro_count > 0, + "gemini" => stats.gemini_count > 0, + "qwen" => stats.qwen_count > 0, + "openai" => stats.openai_count > 0, + "claude" => stats.claude_count > 0, + _ => false, + } +} + +/// 寻找最佳可用Provider +fn find_best_available_provider(stats: &CredentialStats) -> Option { + // 优先级:kiro > gemini > qwen > claude > openai + if stats.kiro_count > 0 { + Some("kiro".to_string()) + } else if stats.gemini_count > 0 { + Some("gemini".to_string()) + } else if stats.qwen_count > 0 { + Some("qwen".to_string()) + } else if stats.claude_count > 0 { + Some("claude".to_string()) + } else if stats.openai_count > 0 { + Some("openai".to_string()) + } else { + None + } +} + +/// 内部设置默认Provider函数 +async fn set_default_provider_internal( + state: &State<'_, AppState>, + _logs: &State<'_, LogState>, + provider: String, +) -> Result<(), String> { + // 验证provider + let _provider_type: ProviderType = provider.parse().map_err(|e: String| e)?; + + let mut s = state.write().await; + s.config.default_provider = provider.clone(); + + // 同时更新运行中服务器的 default_provider_ref + { + let mut dp = s.default_provider_ref.write().await; + *dp = provider.clone(); + } + + config::save_config(&s.config).map_err(|e| e.to_string())?; + + Ok(()) +} + +/// 检查凭证池问题 +async fn check_credential_pool_issues( + db: &State<'_, DbConnection>, + result: &mut AutoFixResult, +) -> Result<(), String> { + let conn = db.lock().map_err(|e| e.to_string())?; + let credentials = ProviderPoolDao::get_all(&conn).map_err(|e| e.to_string())?; + + // 检查是否有过期的token缓存 + let mut expired_tokens = 0; + for cred in &credentials { + if let Some(ref token_info) = cred.cached_token { + if let Some(expiry) = token_info.expiry_time { + if chrono::Utc::now() > expiry { + expired_tokens += 1; + } + } + } + } + + if expired_tokens > 0 { + result + .issues_found + .push(format!("发现 {} 个过期的token缓存", expired_tokens)); + // 过期token会在使用时自动刷新,这里只是报告 + } + + // 检查是否有禁用的凭证 + let disabled_count = credentials.iter().filter(|c| c.is_disabled).count(); + if disabled_count > 0 { + result + .issues_found + .push(format!("有 {} 个凭证被禁用", disabled_count)); + } + + Ok(()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index d69d0a0c9..ec7374b6c 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod auto_fix_cmd; pub mod config_cmd; pub mod flow_monitor_cmd; pub mod injection_cmd; diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index c1639303d..150cf1713 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -1477,34 +1477,81 @@ pub struct ClaudeOAuthAuthUrlResponse { /// /// 启动服务器后通过事件发送授权 URL,然后等待回调 /// 成功后返回凭证 +/// Claude OAuth 授权 URL 响应(新流程) +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ClaudeOAuthParamsResponse { + pub auth_url: String, + pub code_verifier: String, + pub state: String, +} + +/// 获取 Claude OAuth 授权 URL(新流程:手动输入授权码) +/// +/// 生成授权 URL 和 PKCE 参数,用户需要: +/// 1. 打开 auth_url 进行授权 +/// 2. 授权后从页面复制授权码 +/// 3. 调用 exchange_claude_oauth_code 交换 token #[tauri::command] pub async fn get_claude_oauth_auth_url_and_wait( app: tauri::AppHandle, - db: State<'_, DbConnection>, - pool_service: State<'_, ProviderPoolServiceState>, - name: Option, -) -> Result { + _db: State<'_, DbConnection>, + _pool_service: State<'_, ProviderPoolServiceState>, + _name: Option, +) -> Result { use crate::providers::claude_oauth; - tracing::info!("[Claude OAuth] 启动服务器并获取授权 URL"); + tracing::info!("[Claude OAuth] 生成授权 URL(手动授权码流程)"); - // 启动服务器并获取授权 URL - let (auth_url, wait_future) = claude_oauth::start_claude_oauth_server_and_get_url() - .await - .map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?; + // 生成授权参数 + let params = claude_oauth::generate_claude_oauth_params() + .map_err(|e| format!("生成授权参数失败: {}", e))?; - tracing::info!("[Claude OAuth] 授权 URL: {}", auth_url); + tracing::info!("[Claude OAuth] 授权 URL: {}", params.auth_url); // 通过事件发送授权 URL 给前端 let _ = app.emit( "claude-oauth-auth-url", ClaudeOAuthAuthUrlResponse { - auth_url: auth_url.clone(), + auth_url: params.auth_url.clone(), }, ); - // 等待回调 - let result = wait_future.await.map_err(|e| e.to_string())?; + // 打开浏览器 + if let Err(e) = open::that(¶ms.auth_url) { + tracing::warn!("[Claude OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e); + } + + Ok(ClaudeOAuthParamsResponse { + auth_url: params.auth_url, + code_verifier: params.code_verifier, + state: params.state, + }) +} + +/// 使用授权码交换 Claude OAuth Token +/// +/// 用户在浏览器中授权后,复制授权码,调用此命令交换 token +#[tauri::command] +pub async fn exchange_claude_oauth_code( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + authorization_code: String, + code_verifier: String, + state: String, + name: Option, +) -> Result { + use crate::providers::claude_oauth; + + tracing::info!("[Claude OAuth] 使用授权码交换 Token"); + + // 交换 Token + let result = claude_oauth::exchange_claude_authorization_code( + &authorization_code, + &code_verifier, + &state, + ) + .await + .map_err(|e| format!("Claude OAuth Token 交换失败: {}", e))?; tracing::info!( "[Claude OAuth] 登录成功,凭证保存到: {}", @@ -1528,26 +1575,71 @@ pub async fn get_claude_oauth_auth_url_and_wait( Ok(credential) } -/// 启动 Claude OAuth 登录流程 +/// 启动 Claude OAuth 登录流程(兼容旧接口,现在返回授权参数) /// -/// 打开浏览器让用户登录 Claude 账号,获取凭证 +/// 打开浏览器让用户登录 Claude 账号 +/// 注意:新流程需要用户手动复制授权码,然后调用 exchange_claude_oauth_code #[tauri::command] pub async fn start_claude_oauth_login( + _db: State<'_, DbConnection>, + _pool_service: State<'_, ProviderPoolServiceState>, + _name: Option, +) -> Result { + use crate::providers::claude_oauth; + + tracing::info!("[Claude OAuth] 开始 OAuth 登录流程(手动授权码模式)"); + + // 生成授权参数并打开浏览器 + let params = claude_oauth::start_claude_oauth_login() + .await + .map_err(|e| format!("Claude OAuth 登录失败: {}", e))?; + + Ok(ClaudeOAuthParamsResponse { + auth_url: params.auth_url, + code_verifier: params.code_verifier, + state: params.state, + }) +} + +/// Claude Cookie 自动授权响应 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ClaudeCookieOAuthResponse { + pub organization_uuid: Option, + pub capabilities: Vec, +} + +/// 使用 Cookie (sessionKey) 自动完成 Claude OAuth 授权 +/// +/// 这是一个更便捷的授权方式,用户只需要提供从浏览器 Cookie 中获取的 sessionKey, +/// 系统会自动完成整个 OAuth 流程,无需手动复制授权码。 +/// +/// # 参数 +/// - `session_key`: 从浏览器 Cookie 中获取的 sessionKey +/// - `is_setup_token`: 是否为 Setup Token 模式(只需要推理权限,无 refresh_token) +/// - `name`: 凭证名称(可选) +#[tauri::command] +pub async fn claude_oauth_with_cookie( db: State<'_, DbConnection>, pool_service: State<'_, ProviderPoolServiceState>, + session_key: String, + is_setup_token: Option, name: Option, ) -> Result { use crate::providers::claude_oauth; - tracing::info!("[Claude OAuth] 开始 OAuth 登录流程"); + let is_setup = is_setup_token.unwrap_or(false); + tracing::info!( + "[Claude OAuth] 开始 Cookie 自动授权流程,is_setup_token: {}", + is_setup + ); - // 启动 OAuth 登录 - let result = claude_oauth::start_claude_oauth_login() + // 执行 Cookie 自动授权 + let result = claude_oauth::oauth_with_cookie(&session_key, is_setup) .await - .map_err(|e| format!("Claude OAuth 登录失败: {}", e))?; + .map_err(|e| format!("Claude Cookie 授权失败: {}", e))?; tracing::info!( - "[Claude OAuth] 登录成功,凭证保存到: {}", + "[Claude OAuth] Cookie 授权成功,凭证保存到: {}", result.creds_file_path ); @@ -1563,7 +1655,11 @@ pub async fn start_claude_oauth_login( None, )?; - tracing::info!("[Claude OAuth] 凭证已添加到凭证池: {}", credential.uuid); + tracing::info!( + "[Claude OAuth] 凭证已添加到凭证池: {}, org_uuid: {:?}", + credential.uuid, + result.organization_uuid + ); Ok(credential) } diff --git a/src-tauri/src/commands/switch_cmd.rs b/src-tauri/src/commands/switch_cmd.rs index 7feef299a..d28386b28 100644 --- a/src-tauri/src/commands/switch_cmd.rs +++ b/src-tauri/src/commands/switch_cmd.rs @@ -1,5 +1,6 @@ use crate::database::DbConnection; -use crate::models::Provider; +use crate::models::{AppType, Provider}; +use crate::services::live_sync::{check_config_sync, sync_from_external, SyncCheckResult}; use crate::services::switch::SwitchService; use serde_json::Value; use tauri::State; @@ -63,3 +64,48 @@ pub fn import_default_config( pub fn read_live_provider_settings(app_type: String) -> Result { SwitchService::read_live_settings(&app_type) } + +/// 检查配置同步状态 +#[tauri::command] +pub fn check_config_sync_status( + db: State<'_, DbConnection>, + app_type: String, +) -> Result { + // 解析 app_type + let app_type_enum: AppType = app_type + .parse() + .map_err(|e| format!("Invalid app type: {}", e))?; + + // 获取当前 ProxyCast 中设置的 provider + let current_provider = SwitchService::get_current_provider(&db, &app_type)? + .map(|p| p.id) + .unwrap_or_else(|| "unknown".to_string()); + + // 检查同步状态 + check_config_sync(&app_type_enum, ¤t_provider) + .map_err(|e| format!("Failed to check config sync: {}", e)) +} + +/// 从外部配置同步到 ProxyCast +#[tauri::command] +pub fn sync_from_external_config( + db: State<'_, DbConnection>, + app_type: String, +) -> Result { + // 解析 app_type + let app_type_enum: AppType = app_type + .parse() + .map_err(|e| format!("Invalid app type: {}", e))?; + + // 从外部配置获取 provider + let external_provider = sync_from_external(&app_type_enum) + .map_err(|e| format!("Failed to sync from external: {}", e))?; + + // 切换到外部检测到的 provider + SwitchService::switch_provider(&db, &app_type, &external_provider)?; + + Ok(format!( + "已同步到外部配置的 provider: {}", + external_provider + )) +} diff --git a/src-tauri/src/commands/telemetry_cmd.rs b/src-tauri/src/commands/telemetry_cmd.rs index a4906f155..f7f27e0cf 100644 --- a/src-tauri/src/commands/telemetry_cmd.rs +++ b/src-tauri/src/commands/telemetry_cmd.rs @@ -284,55 +284,3 @@ pub async fn get_token_stats_by_day( let tokens = state.tokens.read(); Ok(tokens.by_day(days.unwrap_or(7))) } - -// ========== 仪表盘数据命令 ========== - -/// 仪表盘数据 -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DashboardData { - /// 统计摘要 - pub stats: StatsSummary, - /// Token 摘要 - pub tokens: TokenStatsSummary, - /// 按 Provider 统计 - pub by_provider: HashMap, - /// 最近请求 - pub recent_logs: Vec, -} - -/// 获取仪表盘数据 -#[tauri::command] -pub async fn get_dashboard_data( - state: tauri::State<'_, TelemetryState>, -) -> Result { - // 获取最近 24 小时的统计 - let range = Some(TimeRange::last_hours(24)); - - let stats_guard = state.stats.read(); - let stats = stats_guard.summary(range); - let by_provider: HashMap = stats_guard - .by_provider(range) - .into_iter() - .map(|(k, v)| (k.to_string(), v)) - .collect(); - drop(stats_guard); - - let tokens_guard = state.tokens.read(); - let tokens = tokens_guard.summary( - Some(Utc::now() - chrono::Duration::hours(24)), - Some(Utc::now()), - ); - drop(tokens_guard); - - // 获取最近 20 条日志 - let mut recent_logs = state.logger.get_all(); - recent_logs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); - recent_logs.truncate(20); - - Ok(DashboardData { - stats, - tokens, - by_provider, - recent_logs, - }) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8bf4aaa14..c21bf255a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -246,9 +246,17 @@ async fn stop_server( #[tauri::command] async fn get_server_status( state: tauri::State<'_, AppState>, + telemetry_state: tauri::State<'_, commands::telemetry_cmd::TelemetryState>, ) -> Result { let s = state.read().await; - Ok(s.status()) + let mut status = s.status(); + + // 从遥测系统获取真实的请求计数 + let stats = telemetry_state.stats.read(); + let summary = stats.summary(None); + status.requests = summary.total_requests; + + Ok(status) } #[tauri::command] @@ -1743,15 +1751,61 @@ pub fn run() { let shared_flow_monitor = flow_monitor_clone.clone(); let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { - // 先加载凭证 + // 先加载凭证池中的凭证 { + logs.write().await.add("info", "[启动] 正在加载凭证池..."); + + // 获取凭证池概览信息 + match pool_service.get_overview(&db) { + Ok(overview) => { + let mut loaded_types = Vec::new(); + let mut total_credentials = 0; + + for provider_overview in overview { + let count = provider_overview.stats.total_count; + if count > 0 { + total_credentials += count; + let provider_name = + match provider_overview.provider_type.as_str() { + "kiro" => "Kiro", + "gemini" => "Gemini", + "qwen" => "通义千问", + "antigravity" => "Antigravity", + "openai" => "OpenAI", + "claude" => "Claude", + "codex" => "Codex", + "claude_oauth" => "Claude OAuth", + "iflow" => "iFlow", + _ => &provider_overview.provider_type, + }; + loaded_types.push(format!("{} ({} 个)", provider_name, count)); + } + } + + if loaded_types.is_empty() { + logs.write().await.add("warn", "[启动] 未找到任何可用凭证"); + } else { + let message = format!( + "[启动] 凭证已加载: {} (共 {} 个)", + loaded_types.join(", "), + total_credentials + ); + logs.write().await.add("info", &message); + } + } + Err(e) => { + logs.write() + .await + .add("warn", &format!("[启动] 获取凭证池信息失败: {}", e)); + } + } + + // 兼容性:仍然尝试加载旧的 Kiro 凭证(如果存在) let mut s = state.write().await; if let Err(e) = s.kiro_provider.load_credentials().await { logs.write() .await - .add("warn", &format!("[启动] 加载 Kiro 凭证失败: {e}")); - } else { - logs.write().await.add("info", "[启动] Kiro 凭证已加载"); + .add("debug", &format!("[启动] 旧版 Kiro 凭证加载失败: {e}")); } } // 启动服务器(使用共享的遥测实例和 Flow Monitor) @@ -1889,6 +1943,8 @@ pub fn run() { commands::switch_cmd::switch_provider, commands::switch_cmd::import_default_config, commands::switch_cmd::read_live_provider_settings, + commands::switch_cmd::check_config_sync_status, + commands::switch_cmd::sync_from_external_config, // Config commands commands::config_cmd::get_config_status, commands::config_cmd::get_config_dir_path, @@ -1973,6 +2029,8 @@ pub fn run() { commands::provider_pool_cmd::start_codex_oauth_login, commands::provider_pool_cmd::get_claude_oauth_auth_url_and_wait, commands::provider_pool_cmd::start_claude_oauth_login, + commands::provider_pool_cmd::exchange_claude_oauth_code, + commands::provider_pool_cmd::claude_oauth_with_cookie, commands::provider_pool_cmd::get_qwen_device_code_and_wait, commands::provider_pool_cmd::start_qwen_device_code_login, commands::provider_pool_cmd::get_iflow_auth_url_and_wait, @@ -2032,7 +2090,6 @@ pub fn run() { commands::telemetry_cmd::get_token_stats_by_provider, commands::telemetry_cmd::get_token_stats_by_model, commands::telemetry_cmd::get_token_stats_by_day, - commands::telemetry_cmd::get_dashboard_data, // Injection commands commands::injection_cmd::get_injection_config, commands::injection_cmd::set_injection_enabled, @@ -2181,6 +2238,8 @@ pub fn run() { commands::window_cmd::set_window_size_by_option, commands::window_cmd::toggle_fullscreen, commands::window_cmd::is_fullscreen, + // Auto fix commands + commands::auto_fix_cmd::auto_fix_configuration, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/providers/claude_oauth.rs b/src-tauri/src/providers/claude_oauth.rs index 2624c4c8c..ba0b5656f 100644 --- a/src-tauri/src/providers/claude_oauth.rs +++ b/src-tauri/src/providers/claude_oauth.rs @@ -1,7 +1,18 @@ //! Claude OAuth Provider //! -//! 实现 Anthropic Claude OAuth 认证流程,与 CLIProxyAPI 对齐。 -//! 支持 Token 刷新、重试机制和统一凭证格式。 +//! 实现 Anthropic Claude OAuth 认证流程,与 claude-relay-service 对齐。 +//! +//! ## 支持的授权方式 +//! +//! 1. **标准 OAuth 流程** - 使用官方 redirect_uri,用户需手动复制授权码 +//! 2. **Cookie 自动授权** - 使用 sessionKey 自动完成整个 OAuth 流程 +//! 3. **Setup Token** - 只需推理权限,无 refresh_token +//! +//! ## 主要功能 +//! +//! - Token 刷新和重试机制 +//! - 统一凭证格式 +//! - 组织信息获取 use super::error::{ create_auth_error, create_config_error, create_token_refresh_error, ProviderError, @@ -11,11 +22,16 @@ use serde::{Deserialize, Serialize}; use std::error::Error; use std::path::PathBuf; -// OAuth 端点和凭证 - 与 CLIProxyAPI 完全一致 +// OAuth 端点和凭证 - 与 claude-relay-service 完全一致 const CLAUDE_AUTH_URL: &str = "https://claude.ai/oauth/authorize"; const CLAUDE_TOKEN_URL: &str = "https://console.anthropic.com/v1/oauth/token"; const CLAUDE_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const DEFAULT_CALLBACK_PORT: u16 = 54545; +// 使用 Anthropic 官方 redirect_uri(用户需手动复制授权码) +const CLAUDE_REDIRECT_URI: &str = "https://console.anthropic.com/oauth/code/callback"; +// OAuth scopes - 与 claude-relay-service 一致 +const CLAUDE_SCOPES: &str = "org:create_api_key user:profile user:inference"; +// Setup Token 只需要推理权限 +const CLAUDE_SCOPES_SETUP: &str = "user:inference"; /// Claude OAuth 凭证存储 /// @@ -101,8 +117,6 @@ pub struct ClaudeOAuthProvider { pub client: Client, /// 凭证文件路径 pub creds_path: Option, - /// OAuth 回调端口 - pub callback_port: u16, } impl Default for ClaudeOAuthProvider { @@ -111,7 +125,6 @@ impl Default for ClaudeOAuthProvider { credentials: ClaudeOAuthCredentials::default(), client: Client::new(), creds_path: None, - callback_port: DEFAULT_CALLBACK_PORT, } } } @@ -342,9 +355,14 @@ impl ClaudeOAuthProvider { CLAUDE_CLIENT_ID } - /// 获取回调 URI - pub fn get_redirect_uri(&self) -> String { - format!("http://localhost:{}/callback", self.callback_port) + /// 获取 redirect URI(官方 Anthropic 回调地址) + pub fn get_redirect_uri(&self) -> &'static str { + CLAUDE_REDIRECT_URI + } + + /// 获取 OAuth scopes + pub fn get_scopes(&self) -> &'static str { + CLAUDE_SCOPES } } @@ -352,8 +370,6 @@ impl ClaudeOAuthProvider { // OAuth 登录功能 // ============================================================================ -use std::sync::Arc; -use tokio::sync::oneshot; use uuid::Uuid; /// OAuth 登录成功后的凭证信息 @@ -363,15 +379,19 @@ pub struct ClaudeOAuthResult { pub creds_file_path: String, } -/// 生成 Claude OAuth 授权 URL -pub fn generate_claude_auth_url(port: u16, state: &str, code_challenge: &str) -> String { - let redirect_uri = format!("http://localhost:{}/oauth-callback", port); - +/// 生成 Claude OAuth 授权 URL(使用官方 redirect_uri) +/// +/// 用户需要: +/// 1. 打开此 URL 进行授权 +/// 2. 授权后从浏览器地址栏复制授权码 +/// 3. 将授权码粘贴回应用 +pub fn generate_claude_auth_url(state: &str, code_challenge: &str) -> String { let params = [ + ("code", "true"), ("client_id", CLAUDE_CLIENT_ID), ("response_type", "code"), - ("redirect_uri", redirect_uri.as_str()), - ("scope", "user:inference user:profile"), + ("redirect_uri", CLAUDE_REDIRECT_URI), + ("scope", CLAUDE_SCOPES), ("state", state), ("code_challenge", code_challenge), ("code_challenge_method", "S256"), @@ -386,25 +406,58 @@ pub fn generate_claude_auth_url(port: u16, state: &str, code_challenge: &str) -> format!("{}?{}", CLAUDE_AUTH_URL, query) } -/// 用授权码交换 Token +/// 生成 Setup Token 授权 URL(只需要推理权限) +pub fn generate_claude_setup_token_auth_url(state: &str, code_challenge: &str) -> String { + let params = [ + ("code", "true"), + ("client_id", CLAUDE_CLIENT_ID), + ("response_type", "code"), + ("redirect_uri", CLAUDE_REDIRECT_URI), + ("scope", CLAUDE_SCOPES_SETUP), + ("state", state), + ("code_challenge", code_challenge), + ("code_challenge_method", "S256"), + ]; + + let query = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + + format!("{}?{}", CLAUDE_AUTH_URL, query) +} + +/// 用授权码交换 Token(使用官方 redirect_uri) pub async fn exchange_claude_code_for_token( client: &Client, code: &str, code_verifier: &str, - redirect_uri: &str, + state: &str, ) -> Result> { + // 清理授权码,移除 URL 片段(与 claude-relay-service 一致) + let cleaned_code = code.split('#').next().unwrap_or(code); + let cleaned_code = cleaned_code.split('&').next().unwrap_or(cleaned_code); + let body = serde_json::json!({ "grant_type": "authorization_code", "client_id": CLAUDE_CLIENT_ID, - "code": code, - "redirect_uri": redirect_uri, - "code_verifier": code_verifier + "code": cleaned_code, + "redirect_uri": CLAUDE_REDIRECT_URI, + "code_verifier": code_verifier, + "state": state }); + tracing::info!( + "[CLAUDE_OAUTH] 正在交换授权码,code 长度: {}", + cleaned_code.len() + ); + let resp = client .post(CLAUDE_TOKEN_URL) .header("Content-Type", "application/json") .header("Accept", "application/json") + .header("User-Agent", "claude-cli/1.0.56 (external, cli)") .json(&body) .send() .await?; @@ -412,300 +465,498 @@ pub async fn exchange_claude_code_for_token( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); + tracing::error!("[CLAUDE_OAUTH] Token 交换失败: {} - {}", status, body); return Err(format!("Token 交换失败: {} - {}", status, body).into()); } let data: serde_json::Value = resp.json().await?; + tracing::info!("[CLAUDE_OAUTH] Token 交换成功"); Ok(data) } -/// OAuth 成功页面 HTML -const CLAUDE_OAUTH_SUCCESS_HTML: &str = r#" - - - - 授权成功 - - - -
-

✓ 授权成功

-

Claude 账号已添加到 ProxyCast

- -

可以关闭此页面

-
- -"#; +/// OAuth 参数(用于手动授权码流程) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClaudeOAuthParams { + /// 授权 URL + pub auth_url: String, + /// PKCE code_verifier(需要保存用于后续交换 token) + pub code_verifier: String, + /// state 参数 + pub state: String, + /// code_challenge + pub code_challenge: String, +} -/// OAuth 失败页面 HTML -const CLAUDE_OAUTH_ERROR_HTML: &str = r#" - - - - 授权失败 - - - -
-

✗ 授权失败

-

ERROR_PLACEHOLDER

-

请关闭此页面后重试

-
- -"#; +/// 生成 OAuth 授权参数(不启动服务器) +/// +/// 返回授权 URL 和 PKCE 参数,用户需要: +/// 1. 打开 auth_url 进行授权 +/// 2. 授权后从页面复制授权码 +/// 3. 调用 exchange_claude_authorization_code 交换 token +pub fn generate_claude_oauth_params() -> Result> { + let pkce_codes = PKCECodes::generate()?; + let state = Uuid::new_v4().to_string(); -/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器) -pub async fn start_claude_oauth_server_and_get_url() -> Result< - ( - String, - impl std::future::Future>>, - ), - Box, -> { - use axum::{extract::Query, response::Html, routing::get, Router}; - use std::collections::HashMap; - use tokio::net::TcpListener; + let auth_url = generate_claude_auth_url(&state, &pkce_codes.code_challenge); + tracing::info!( + "[CLAUDE_OAUTH] 生成授权参数,state: {}, auth_url: {}", + state, + auth_url + ); + + Ok(ClaudeOAuthParams { + auth_url, + code_verifier: pkce_codes.code_verifier, + state, + code_challenge: pkce_codes.code_challenge, + }) +} + +/// 生成 Setup Token 授权参数 +pub fn generate_claude_setup_token_params( +) -> Result> { + let pkce_codes = PKCECodes::generate()?; + let state = Uuid::new_v4().to_string(); + + let auth_url = generate_claude_setup_token_auth_url(&state, &pkce_codes.code_challenge); + + tracing::info!("[CLAUDE_OAUTH] 生成 Setup Token 授权参数,state: {}", state); + + Ok(ClaudeOAuthParams { + auth_url, + code_verifier: pkce_codes.code_verifier, + state, + code_challenge: pkce_codes.code_challenge, + }) +} + +/// 解析授权码(支持完整 URL 或直接授权码) +pub fn parse_claude_authorization_code( + input: &str, +) -> Result> { + let trimmed = input.trim(); + + // 情况1: 完整 URL + if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + if let Ok(url) = url::Url::parse(trimmed) { + if let Some(code) = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + { + return Ok(code); + } + } + return Err("回调 URL 中未找到授权码 (code 参数)".into()); + } + + // 情况2: 直接授权码(可能包含 URL fragments) + let cleaned = trimmed.split('#').next().unwrap_or(trimmed); + let cleaned = cleaned.split('&').next().unwrap_or(cleaned); + + if cleaned.len() < 10 { + return Err("授权码格式无效,请确保复制了完整的授权码".into()); + } + + Ok(cleaned.to_string()) +} + +/// 使用授权码交换 Token 并保存凭证 +pub async fn exchange_claude_authorization_code( + authorization_code: &str, + code_verifier: &str, + state: &str, +) -> Result> { let client = Client::builder() .timeout(std::time::Duration::from_secs(30)) .build()?; - // 生成 PKCE codes - let pkce_codes = PKCECodes::generate()?; - let code_verifier = pkce_codes.code_verifier.clone(); - let code_challenge = pkce_codes.code_challenge.clone(); + // 解析授权码 + let code = parse_claude_authorization_code(authorization_code)?; - // 生成随机 state - let state = Uuid::new_v4().to_string(); - let state_clone = state.clone(); + // 交换 Token + let token_data = exchange_claude_code_for_token(&client, &code, code_verifier, state).await?; - // 创建 channel 用于接收回调结果 - let (tx, rx) = oneshot::channel::>(); - let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); + let access_token = token_data["access_token"].as_str().unwrap_or_default(); + let refresh_token = token_data["refresh_token"].as_str().map(|s| s.to_string()); + let expires_in = token_data["expires_in"].as_i64(); - // 绑定到随机端口 - let listener = TcpListener::bind("127.0.0.1:0").await?; - let port = listener.local_addr()?.port(); + // 从响应中提取用户邮箱 + let email = token_data["account"]["email_address"] + .as_str() + .map(|s| s.to_string()); - let redirect_uri = format!("http://localhost:{}/oauth-callback", port); - let redirect_uri_clone = redirect_uri.clone(); - - // 生成授权 URL - let auth_url = generate_claude_auth_url(port, &state, &code_challenge); - - tracing::info!( - "[Claude OAuth] 服务器启动在端口 {}, 授权 URL: {}", - port, - auth_url - ); - - // 构建路由 - let app = Router::new().route( - "/oauth-callback", - get(move |Query(params): Query>| { - let tx = tx.clone(); - let client = client.clone(); - let state_expected = state_clone.clone(); - let redirect_uri = redirect_uri_clone.clone(); - let code_verifier = code_verifier.clone(); - - async move { - let code = params.get("code"); - let returned_state = params.get("state"); - let error = params.get("error"); - - // 检查错误 - if let Some(err) = error { - let html = CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", err); - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Err(format!("OAuth 错误: {}", err))); - } - return Html(html); - } - - // 检查 state - if returned_state.map(|s| s.as_str()) != Some(&state_expected) { - let html = - CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "State 验证失败"); - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Err("State 验证失败".to_string())); - } - return Html(html); - } - - // 检查 code - let code = match code { - Some(c) => c, - None => { - let html = - CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", "未收到授权码"); - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Err("未收到授权码".to_string())); - } - return Html(html); - } - }; - - // 交换 Token - let token_result = - exchange_claude_code_for_token(&client, code, &code_verifier, &redirect_uri) - .await; - let token_data = match token_result { - Ok(data) => data, - Err(e) => { - let html = - CLAUDE_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &e.to_string()); - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Err(e.to_string())); - } - return Html(html); - } - }; - - let access_token = token_data["access_token"].as_str().unwrap_or_default(); - let refresh_token = token_data["refresh_token"].as_str().map(|s| s.to_string()); - let expires_in = token_data["expires_in"].as_i64(); - - // 从响应中提取用户邮箱 - let email = token_data["account"]["email_address"] - .as_str() - .map(|s| s.to_string()); - - // 构建凭证 - let now = chrono::Utc::now(); - let credentials = ClaudeOAuthCredentials { - access_token: Some(access_token.to_string()), - refresh_token, - email: email.clone(), - expire: expires_in.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), - last_refresh: Some(now.to_rfc3339()), - cred_type: "claude_oauth".to_string(), - }; - - // 保存凭证到应用数据目录 - let creds_dir = dirs::data_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join("proxycast") - .join("credentials") - .join("claude_oauth"); - - if let Err(e) = std::fs::create_dir_all(&creds_dir) { - let html = CLAUDE_OAUTH_ERROR_HTML - .replace("ERROR_PLACEHOLDER", &format!("创建目录失败: {}", e)); - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Err(format!("创建目录失败: {}", e))); - } - return Html(html); - } - - // 生成唯一文件名 - let uuid = Uuid::new_v4().to_string(); - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - let filename = format!("claude_oauth_{}_{}.json", &uuid[..8], timestamp); - let creds_file_path = creds_dir.join(&filename); - - // 保存凭证 - let creds_json = match serde_json::to_string_pretty(&credentials) { - Ok(json) => json, - Err(e) => { - let html = CLAUDE_OAUTH_ERROR_HTML - .replace("ERROR_PLACEHOLDER", &format!("序列化凭证失败: {}", e)); - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Err(format!("序列化凭证失败: {}", e))); - } - return Html(html); - } - }; - - if let Err(e) = std::fs::write(&creds_file_path, &creds_json) { - let html = CLAUDE_OAUTH_ERROR_HTML - .replace("ERROR_PLACEHOLDER", &format!("保存凭证失败: {}", e)); - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Err(format!("保存凭证失败: {}", e))); - } - return Html(html); - } - - tracing::info!("[Claude OAuth] 凭证已保存到: {:?}", creds_file_path); - - // 发送成功结果 - let result = ClaudeOAuthResult { - credentials, - creds_file_path: creds_file_path.to_string_lossy().to_string(), - }; - - if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(Ok(result)); - } - - // 返回成功页面 - let html = CLAUDE_OAUTH_SUCCESS_HTML.replace( - "EMAIL_PLACEHOLDER", - &email.unwrap_or_else(|| "未知邮箱".to_string()), - ); - Html(html) - } - }), - ); - - // 启动服务器 - let server = axum::serve(listener, app); - - // 创建等待 future - let wait_future = async move { - // 设置超时(5 分钟) - let timeout = tokio::time::timeout(std::time::Duration::from_secs(300), async { - // 启动服务器(在后台运行) - tokio::spawn(async move { - if let Err(e) = server.await { - tracing::error!("[Claude OAuth] 服务器错误: {}", e); - } - }); - - // 等待回调结果 - match rx.await { - Ok(result) => result.map_err(|e| { - Box::new(std::io::Error::other(e)) as Box - }), - Err(_) => Err("OAuth 回调通道关闭".into()), - } - }); - - match timeout.await { - Ok(result) => result, - Err(_) => Err("OAuth 登录超时(5分钟)".into()), - } + // 构建凭证 + let now = chrono::Utc::now(); + let credentials = ClaudeOAuthCredentials { + access_token: Some(access_token.to_string()), + refresh_token, + email: email.clone(), + expire: expires_in.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), + last_refresh: Some(now.to_rfc3339()), + cred_type: "claude_oauth".to_string(), }; - Ok((auth_url, wait_future)) + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("claude_oauth"); + + std::fs::create_dir_all(&creds_dir)?; + + // 生成唯一文件名 + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let filename = format!("claude_oauth_{}_{}.json", &uuid[..8], timestamp); + let creds_file_path = creds_dir.join(&filename); + + // 保存凭证 + let creds_json = serde_json::to_string_pretty(&credentials)?; + std::fs::write(&creds_file_path, &creds_json)?; + + tracing::info!( + "[CLAUDE_OAUTH] 凭证已保存到: {:?}, email: {:?}", + creds_file_path, + email + ); + + Ok(ClaudeOAuthResult { + credentials, + creds_file_path: creds_file_path.to_string_lossy().to_string(), + }) } -/// 启动 Claude OAuth 登录流程(自动打开浏览器) -pub async fn start_claude_oauth_login() -> Result> { - let (auth_url, wait_future) = start_claude_oauth_server_and_get_url().await?; +/// 启动 Claude OAuth 登录流程(打开浏览器,返回授权参数) +/// +/// 新流程: +/// 1. 生成授权参数 +/// 2. 打开浏览器 +/// 3. 返回参数供后续使用(用户需手动输入授权码) +pub async fn start_claude_oauth_login() -> Result> { + let params = generate_claude_oauth_params()?; - tracing::info!("[Claude OAuth] 打开浏览器进行授权: {}", auth_url); + tracing::info!("[CLAUDE_OAUTH] 打开浏览器进行授权: {}", params.auth_url); // 打开浏览器 - if let Err(e) = open::that(&auth_url) { - tracing::warn!("[Claude OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e); + if let Err(e) = open::that(¶ms.auth_url) { + tracing::warn!( + "[CLAUDE_OAUTH] 无法打开浏览器: {}. 请手动打开 URL: {}", + e, + params.auth_url + ); } - // 等待回调 - wait_future.await + Ok(params) +} + +// ============================================================================ +// Cookie 自动授权功能(参考 claude-relay-service 实现) +// ============================================================================ + +/// Cookie 自动授权配置 +const CLAUDE_AI_URL: &str = "https://claude.ai"; +const CLAUDE_ORGANIZATIONS_URL: &str = "https://claude.ai/api/organizations"; + +/// 组织信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OrganizationInfo { + pub uuid: String, + pub capabilities: Vec, +} + +/// Cookie 自动授权结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CookieOAuthResult { + pub credentials: ClaudeOAuthCredentials, + pub creds_file_path: String, + pub organization_uuid: Option, + pub capabilities: Vec, +} + +/// 构建带 Cookie 的请求头 +fn build_cookie_headers(session_key: &str) -> reqwest::header::HeaderMap { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert("Accept", "application/json".parse().unwrap()); + headers.insert("Accept-Language", "en-US,en;q=0.9".parse().unwrap()); + headers.insert("Cache-Control", "no-cache".parse().unwrap()); + headers.insert( + "Cookie", + format!("sessionKey={}", session_key).parse().unwrap(), + ); + headers.insert("Origin", CLAUDE_AI_URL.parse().unwrap()); + headers.insert("Referer", format!("{}/new", CLAUDE_AI_URL).parse().unwrap()); + headers.insert( + "User-Agent", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + .parse() + .unwrap(), + ); + headers +} + +/// 使用 Cookie 获取组织信息 +async fn get_organization_info( + client: &Client, + session_key: &str, +) -> Result> { + let headers = build_cookie_headers(session_key); + + tracing::info!("[CLAUDE_OAUTH] 使用 Cookie 获取组织信息"); + + let resp = client + .get(CLAUDE_ORGANIZATIONS_URL) + .headers(headers) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + if status.as_u16() == 403 || status.as_u16() == 401 { + return Err("Cookie 授权失败:无效的 sessionKey 或已过期".into()); + } + if status.as_u16() == 302 { + return Err("请求被 Cloudflare 拦截,请稍后重试".into()); + } + return Err(format!("获取组织信息失败:HTTP {}", status).into()); + } + + let data: serde_json::Value = resp.json().await?; + + if !data.is_array() { + return Err("获取组织信息失败:响应格式无效".into()); + } + + let orgs = data.as_array().unwrap(); + + // 找到具有 chat 能力且能力最多的组织 + let mut best_org: Option = None; + let mut max_capabilities = 0; + + for org in orgs { + let capabilities: Vec = org["capabilities"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + // 必须有 chat 能力 + if !capabilities.contains(&"chat".to_string()) { + continue; + } + + // 选择能力最多的组织 + if capabilities.len() > max_capabilities { + if let Some(uuid) = org["uuid"].as_str() { + best_org = Some(OrganizationInfo { + uuid: uuid.to_string(), + capabilities: capabilities.clone(), + }); + max_capabilities = capabilities.len(); + } + } + } + + best_org.ok_or_else(|| "未找到具有 chat 能力的组织".into()) +} + +/// 使用 Cookie 自动获取授权码 +async fn authorize_with_cookie( + client: &Client, + session_key: &str, + organization_uuid: &str, + scope: &str, +) -> Result<(String, String, String), Box> { + // 生成 PKCE 参数 + let pkce_codes = PKCECodes::generate()?; + let state = Uuid::new_v4().to_string(); + + // 构建授权 URL + let authorize_url = format!("https://claude.ai/v1/oauth/{}/authorize", organization_uuid); + + // 构建请求 payload + let payload = serde_json::json!({ + "response_type": "code", + "client_id": CLAUDE_CLIENT_ID, + "organization_uuid": organization_uuid, + "redirect_uri": CLAUDE_REDIRECT_URI, + "scope": scope, + "state": state, + "code_challenge": pkce_codes.code_challenge, + "code_challenge_method": "S256" + }); + + let mut headers = build_cookie_headers(session_key); + headers.insert("Content-Type", "application/json".parse().unwrap()); + + tracing::info!("[CLAUDE_OAUTH] 使用 Cookie 请求授权,scope: {}", scope); + + let resp = client + .post(&authorize_url) + .headers(headers) + .json(&payload) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + if status.as_u16() == 403 || status.as_u16() == 401 { + return Err("Cookie 授权失败:无效的 sessionKey 或已过期".into()); + } + if status.as_u16() == 302 { + return Err("请求被 Cloudflare 拦截,请稍后重试".into()); + } + let body = resp.text().await.unwrap_or_default(); + return Err(format!("授权请求失败:HTTP {} - {}", status, body).into()); + } + + let data: serde_json::Value = resp.json().await?; + + // 从响应中获取 redirect_uri + let redirect_uri = data["redirect_uri"] + .as_str() + .ok_or("授权响应中未找到 redirect_uri")?; + + tracing::info!( + "[CLAUDE_OAUTH] 获取到 redirect_uri: {}...", + &redirect_uri[..redirect_uri.len().min(80)] + ); + + // 解析 redirect_uri 获取授权码 + let url = url::Url::parse(redirect_uri)?; + let authorization_code = url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or("redirect_uri 中未找到授权码")?; + + tracing::info!( + "[CLAUDE_OAUTH] 通过 Cookie 获取授权码成功,长度: {}", + authorization_code.len() + ); + + Ok((authorization_code, pkce_codes.code_verifier, state)) +} + +/// 完整的 Cookie 自动授权流程 +/// +/// 参考 claude-relay-service 的 oauthWithCookie 实现 +/// +/// # 参数 +/// - `session_key`: 从浏览器 Cookie 中获取的 sessionKey +/// - `is_setup_token`: 是否为 Setup Token 模式(只需要推理权限) +/// +/// # 返回 +/// - 成功时返回凭证信息和组织信息 +pub async fn oauth_with_cookie( + session_key: &str, + is_setup_token: bool, +) -> Result> { + let client = Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) // 禁止自动重定向 + .build()?; + + tracing::info!( + "[CLAUDE_OAUTH] 开始 Cookie 自动授权流程,is_setup_token: {}", + is_setup_token + ); + + // 步骤1:获取组织信息 + tracing::info!("[CLAUDE_OAUTH] 步骤 1/3: 获取组织信息..."); + let org_info = get_organization_info(&client, session_key).await?; + tracing::info!( + "[CLAUDE_OAUTH] 找到组织: uuid={}, capabilities={:?}", + org_info.uuid, + org_info.capabilities + ); + + // 步骤2:确定 scope 并获取授权码 + let scope = if is_setup_token { + CLAUDE_SCOPES_SETUP + } else { + "user:profile user:inference" + }; + + tracing::info!("[CLAUDE_OAUTH] 步骤 2/3: 获取授权码..."); + let (authorization_code, code_verifier, state) = + authorize_with_cookie(&client, session_key, &org_info.uuid, scope).await?; + + // 步骤3:交换 Token + tracing::info!("[CLAUDE_OAUTH] 步骤 3/3: 交换 Token..."); + let token_data = + exchange_claude_code_for_token(&client, &authorization_code, &code_verifier, &state) + .await?; + + let access_token = token_data["access_token"].as_str().unwrap_or_default(); + let refresh_token = if is_setup_token { + None // Setup Token 没有 refresh_token + } else { + token_data["refresh_token"].as_str().map(|s| s.to_string()) + }; + let expires_in = token_data["expires_in"].as_i64(); + + // 从响应中提取用户邮箱 + let email = token_data["account"]["email_address"] + .as_str() + .map(|s| s.to_string()); + + // 构建凭证 + let now = chrono::Utc::now(); + let credentials = ClaudeOAuthCredentials { + access_token: Some(access_token.to_string()), + refresh_token, + email: email.clone(), + expire: expires_in.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), + last_refresh: Some(now.to_rfc3339()), + cred_type: if is_setup_token { + "claude_setup_token".to_string() + } else { + "claude_oauth".to_string() + }, + }; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("claude_oauth"); + + std::fs::create_dir_all(&creds_dir)?; + + // 生成唯一文件名 + let uuid = Uuid::new_v4().to_string(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let token_type = if is_setup_token { "setup" } else { "oauth" }; + let filename = format!("claude_{}_{}_{}.json", token_type, &uuid[..8], timestamp); + let creds_file_path = creds_dir.join(&filename); + + // 保存凭证 + let creds_json = serde_json::to_string_pretty(&credentials)?; + std::fs::write(&creds_file_path, &creds_json)?; + + tracing::info!( + "[CLAUDE_OAUTH] Cookie 自动授权成功,凭证已保存到: {:?}, email: {:?}", + creds_file_path, + email + ); + + Ok(CookieOAuthResult { + credentials, + creds_file_path: creds_file_path.to_string_lossy().to_string(), + organization_uuid: Some(org_info.uuid), + capabilities: org_info.capabilities, + }) } diff --git a/src-tauri/src/server/mod.rs b/src-tauri/src/server/mod.rs index 18617791c..c3533b967 100644 --- a/src-tauri/src/server/mod.rs +++ b/src-tauri/src/server/mod.rs @@ -206,6 +206,11 @@ impl ServerState { } } + /// 增加请求计数 + pub fn increment_request_count(&mut self) { + self.requests = self.requests.saturating_add(1); + } + pub async fn start( &mut self, logs: Arc>, diff --git a/src-tauri/src/services/live_sync.rs b/src-tauri/src/services/live_sync.rs index da91bde2f..98c0e4eba 100644 --- a/src-tauri/src/services/live_sync.rs +++ b/src-tauri/src/services/live_sync.rs @@ -267,3 +267,169 @@ pub fn read_live_settings( AppType::ProxyCast => Ok(json!({})), } } + +/// 同步状态枚举 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum SyncStatus { + InSync, // 完全同步 + OutOfSync, // 有差异但无冲突 + Conflict, // 有冲突需要用户选择 +} + +/// 配置冲突信息 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct ConfigConflict { + pub field: String, + pub local_value: String, + pub external_value: String, +} + +/// 同步检查结果 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SyncCheckResult { + pub status: SyncStatus, + pub current_provider: String, + pub external_provider: String, + pub last_modified: Option, + pub conflicts: Vec, +} + +/// 从外部配置文件解析当前生效的 provider +pub fn parse_current_provider_from_live( + app_type: &AppType, + live_settings: &Value, +) -> Result> { + match app_type { + AppType::Claude => { + // 检查 Claude 配置中的认证信息来判断当前 provider + if let Some(env) = live_settings.get("env").and_then(|v| v.as_object()) { + // 优先检查 ANTHROPIC_AUTH_TOKEN (OAuth) + if let Some(token) = env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()) { + if !token.is_empty() { + return Ok("claude_oauth".to_string()); + } + } + + // 检查 ANTHROPIC_API_KEY (API Key) + if let Some(api_key) = env.get("ANTHROPIC_API_KEY").and_then(|v| v.as_str()) { + if !api_key.is_empty() { + return Ok("claude".to_string()); + } + } + } + + Ok("unknown".to_string()) + } + AppType::Codex => { + // 检查 Codex 认证信息 + if let Some(auth) = live_settings.get("auth").and_then(|v| v.as_object()) { + if auth + .get("access_token") + .and_then(|v| v.as_str()) + .map(|s| !s.is_empty()) + .unwrap_or(false) + { + return Ok("codex".to_string()); + } + } + + Ok("unknown".to_string()) + } + AppType::Gemini => { + // 检查 Gemini 环境变量 + if let Some(env) = live_settings.get("env").and_then(|v| v.as_object()) { + if let Some(api_key) = env.get("GOOGLE_API_KEY").and_then(|v| v.as_str()) { + if !api_key.is_empty() { + return Ok("gemini".to_string()); + } + } + } + + Ok("unknown".to_string()) + } + AppType::ProxyCast => Ok("proxycast".to_string()), + } +} + +/// 检查配置同步状态 +pub fn check_config_sync( + app_type: &AppType, + current_provider: &str, +) -> Result> { + // 读取外部配置文件 + let live_settings = read_live_settings(app_type)?; + + // 解析外部配置中的当前 provider + let external_provider = parse_current_provider_from_live(app_type, &live_settings)?; + + // 获取配置文件的修改时间 + let last_modified = get_config_last_modified(app_type); + + // 比较配置 + let status = if current_provider == external_provider { + SyncStatus::InSync + } else if external_provider == "unknown" { + SyncStatus::OutOfSync + } else { + SyncStatus::Conflict + }; + + // 检测具体的冲突字段 + let conflicts = if matches!(status, SyncStatus::Conflict) { + vec![ConfigConflict { + field: "provider".to_string(), + local_value: current_provider.to_string(), + external_value: external_provider.clone(), + }] + } else { + vec![] + }; + + Ok(SyncCheckResult { + status, + current_provider: current_provider.to_string(), + external_provider, + last_modified, + conflicts, + }) +} + +/// 获取配置文件的最后修改时间 +fn get_config_last_modified(app_type: &AppType) -> Option { + let home = dirs::home_dir()?; + let path = match app_type { + AppType::Claude => home.join(".claude").join("settings.json"), + AppType::Codex => home.join(".codex").join("auth.json"), + AppType::Gemini => home.join(".gemini").join(".env"), + AppType::ProxyCast => return None, + }; + + if let Ok(metadata) = std::fs::metadata(&path) { + if let Ok(modified) = metadata.modified() { + if let Ok(datetime) = modified.duration_since(std::time::UNIX_EPOCH) { + return Some(datetime.as_secs().to_string()); + } + } + } + + None +} + +/// 从外部配置同步到 ProxyCast 数据库 +/// 这个函数需要与 switch service 集成来更新数据库中的 provider 记录 +pub fn sync_from_external( + app_type: &AppType, +) -> Result> { + // 读取外部配置 + let live_settings = read_live_settings(app_type)?; + + // 解析当前生效的 provider + let external_provider = parse_current_provider_from_live(app_type, &live_settings)?; + + if external_provider == "unknown" { + return Err("无法识别外部配置中的 provider".into()); + } + + // 返回检测到的 provider,由调用方负责更新数据库 + Ok(external_provider) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 233017bae..2f3e70bde 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.17.4", +"version": "0.17.6", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index d04880d2d..5c642da0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,21 @@ import { useState, useEffect } from "react"; import { Sidebar } from "./components/Sidebar"; -import { Dashboard } from "./components/Dashboard"; import { SettingsPage } from "./components/settings"; import { ApiServerPage } from "./components/api-server/ApiServerPage"; import { ProviderPoolPage } from "./components/provider-pool"; -import { RoutingManagementPage } from "./components/routing/RoutingManagementPage"; import { ConfigManagementPage } from "./components/config/ConfigManagementPage"; -import { ExtensionsPage } from "./components/extensions"; import { FlowMonitorPage } from "./pages"; import { flowEventManager } from "./lib/flowEventManager"; type Page = - | "dashboard" | "provider-pool" - | "routing-management" | "config-management" - | "extensions" | "api-server" | "flow-monitor" | "settings"; function App() { - const [currentPage, setCurrentPage] = useState("dashboard"); + const [currentPage, setCurrentPage] = useState("api-server"); // 在应用启动时初始化 Flow 事件订阅 useEffect(() => { @@ -31,16 +25,10 @@ function App() { const renderPage = () => { switch (currentPage) { - case "dashboard": - return ; case "provider-pool": return ; - case "routing-management": - return ; case "config-management": return ; - case "extensions": - return ; case "api-server": return ; case "flow-monitor": @@ -48,7 +36,7 @@ function App() { case "settings": return ; default: - return ; + return ; } }; diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx deleted file mode 100644 index 534972239..000000000 --- a/src/components/Dashboard.tsx +++ /dev/null @@ -1,472 +0,0 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { - Activity, - Server, - Zap, - Clock, - Key, - Monitor, - Globe, - CheckCircle2, - AlertCircle, - FileText, - Coins, - RefreshCw, - LayoutDashboard, -} from "lucide-react"; -import { - getServerStatus, - getConfig, - ServerStatus, - Config, - getDefaultProvider, -} from "@/hooks/useTauri"; -import { useAllOAuthCredentials } from "@/hooks/useOAuthCredentials"; -import { StatsOverview } from "./monitoring/StatsOverview"; -import { LogViewer } from "./monitoring/LogViewer"; -import { TokenStats } from "./monitoring/TokenStats"; -import { ProviderIcon } from "@/icons/providers"; -import { - getDashboardData, - getRequestLogs, - clearRequestLogs, - getTokenStatsByDay, - getTokenStatsByProvider, - type DashboardData, - type RequestLog, - type PeriodTokenStats, - type ProviderTokenStats, - type RequestStatus, -} from "@/lib/api/telemetry"; - -type TabType = "overview" | "stats" | "logs" | "tokens"; - -export function Dashboard() { - const [activeTab, setActiveTab] = useState("overview"); - const [status, setStatus] = useState(null); - const [config, setConfig] = useState(null); - const [defaultProvider, setDefaultProvider] = useState("kiro"); - const { credentials: oauthCredentials, reload: reloadCredentials } = - useAllOAuthCredentials(); - - // 监控数据状态 - const [dashboardData, setDashboardData] = useState( - null, - ); - const [logs, setLogs] = useState([]); - const [tokensByDay, setTokensByDay] = useState([]); - const [tokensByProvider, setTokensByProvider] = useState< - Record - >({}); - const [monitoringLoading, setMonitoringLoading] = useState(false); - - // 获取基础数据 - useEffect(() => { - const fetchData = async () => { - try { - const [s, c, dp] = await Promise.all([ - getServerStatus(), - getConfig(), - getDefaultProvider(), - ]); - setStatus(s); - setConfig(c); - setDefaultProvider(dp); - } catch (e) { - console.error("Failed to fetch data:", e); - } - }; - - fetchData(); - reloadCredentials(); - - const interval = setInterval(fetchData, 5000); - return () => clearInterval(interval); - }, [reloadCredentials]); - - // 获取监控数据 - const fetchMonitoringData = useCallback(async () => { - try { - setMonitoringLoading(true); - const [dashboard, logsData, dayStats, providerStats] = await Promise.all([ - getDashboardData(), - getRequestLogs({ limit: 100 }), - getTokenStatsByDay(7), - getTokenStatsByProvider({ preset: "7d" }), - ]); - - setDashboardData(dashboard); - setLogs(logsData); - setTokensByDay(dayStats); - setTokensByProvider(providerStats); - } catch (e) { - console.error("Failed to fetch monitoring data:", e); - } finally { - setMonitoringLoading(false); - } - }, []); - - // 切换到监控相关标签时加载数据 - useEffect(() => { - if (activeTab !== "overview") { - fetchMonitoringData(); - } - }, [activeTab, fetchMonitoringData]); - - // 定时刷新监控数据 - useEffect(() => { - if (activeTab !== "overview") { - const interval = setInterval(fetchMonitoringData, 30000); - return () => clearInterval(interval); - } - }, [activeTab, fetchMonitoringData]); - - const handleClearLogs = async () => { - try { - await clearRequestLogs(); - setLogs([]); - } catch (e) { - console.error("Failed to clear logs:", e); - } - }; - - const handleFilterLogs = async (filter: { - provider?: string; - status?: RequestStatus; - }) => { - try { - const filteredLogs = await getRequestLogs({ - ...filter, - limit: 100, - }); - setLogs(filteredLogs); - } catch (e) { - console.error("Failed to filter logs:", e); - } - }; - - const formatUptime = (secs: number) => { - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - return `${h}h ${m}m`; - }; - - const serverUrl = status - ? `http://${status.host}:${status.port}` - : "http://localhost:8999"; - - const getProviderName = (id: string) => { - switch (id) { - case "kiro": - return "Kiro Claude"; - case "gemini": - return "Gemini CLI"; - case "qwen": - return "通义千问"; - case "openai": - return "OpenAI 自定义"; - case "claude": - return "Claude 自定义"; - default: - return id; - } - }; - - const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [ - { id: "overview", label: "概览", icon: LayoutDashboard }, - { id: "stats", label: "统计", icon: Activity }, - { id: "logs", label: "日志", icon: FileText }, - { id: "tokens", label: "Token", icon: Coins }, - ]; - - return ( -
- {/* 页面标题 */} -
-
-

仪表盘

-

系统状态与监控

-
- {activeTab !== "overview" && ( - - )} -
- - {/* 标签页 */} -
- {tabs.map((tab) => ( - - ))} -
- - {/* 内容区域 */} - {activeTab === "overview" && ( - - )} - - {activeTab === "stats" && dashboardData && ( - - )} - - {activeTab === "logs" && ( - - )} - - {activeTab === "tokens" && dashboardData && ( - - )} - - {/* 加载状态 */} - {activeTab !== "overview" && monitoringLoading && !dashboardData && ( -
- -
- )} -
- ); -} - -// 概览标签页内容 -function OverviewTab({ - status, - config, - defaultProvider, - oauthCredentials, - serverUrl, - formatUptime, - getProviderName, -}: { - status: ServerStatus | null; - config: Config | null; - defaultProvider: string; - oauthCredentials: Array<{ - provider: string; - is_valid: boolean; - loaded: boolean; - has_access_token: boolean; - }>; - serverUrl: string; - formatUptime: (secs: number) => string; - getProviderName: (id: string) => string; -}) { - return ( -
- {/* Server Status Cards */} -
-
-
- - 服务状态 -
-
-
- - {status?.running ? "运行中" : "已停止"} - -
-
- -
-
- - 请求数 -
-
{status?.requests || 0}
-
- -
-
- - 运行时间 -
-
- {formatUptime(status?.uptime_secs || 0)} -
-
- -
-
- - 默认 Provider -
-
- {getProviderName(defaultProvider)} -
-
-
- - {/* Quick Links */} -
- c.is_valid).length > 0 - ? "success" - : "warning" - } - statusText={`${oauthCredentials.filter((c) => c.is_valid).length}/${oauthCredentials.length} 有效`} - /> - - -
- - {/* OAuth Credentials Overview */} -
-

- - OAuth 凭证状态 -

-
- {oauthCredentials.map((cred) => ( -
-
- -
-
- {getProviderName(cred.provider)} -
-
- {cred.has_access_token ? "Token 已加载" : "未配置"} -
-
-
-
-
- ))} -
-
- - {/* Server Info */} - {config && ( -
-

服务器信息

-
-
- API 地址: - - {serverUrl} - -
-
- API Key: - - {config.server.api_key.length > 8 - ? `${config.server.api_key.slice(0, 4)}****${config.server.api_key.slice(-4)}` - : "****"} - -
-
-
- )} -
- ); -} - -function QuickLinkCard({ - icon: Icon, - title, - description, - status, - statusText, -}: { - icon: React.ElementType; - title: string; - description: string; - status: "success" | "warning" | "error" | "info"; - statusText: string; -}) { - const statusColors = { - success: "text-green-600", - warning: "text-yellow-600", - error: "text-red-600", - info: "text-blue-600", - }; - - const StatusIcon = status === "success" ? CheckCircle2 : AlertCircle; - - return ( -
-
-
- -
-
-

{title}

-

{description}

-
-
-
- - {statusText} -
-
- ); -} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 6fa0471d1..75d8e98bc 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,21 +1,9 @@ -import { - LayoutDashboard, - Settings, - Globe, - Database, - Route, - FileCode, - Puzzle, - Activity, -} from "lucide-react"; +import { Settings, Globe, Database, FileCode, Activity } from "lucide-react"; import { cn } from "@/lib/utils"; type Page = - | "dashboard" | "provider-pool" - | "routing-management" | "config-management" - | "extensions" | "api-server" | "flow-monitor" | "settings"; @@ -26,12 +14,9 @@ interface SidebarProps { } const navItems = [ - { id: "dashboard" as Page, label: "仪表盘", icon: LayoutDashboard }, - { id: "provider-pool" as Page, label: "凭证池", icon: Database }, - { id: "routing-management" as Page, label: "路由管理", icon: Route }, - { id: "config-management" as Page, label: "配置管理", icon: FileCode }, - { id: "extensions" as Page, label: "扩展", icon: Puzzle }, { id: "api-server" as Page, label: "API Server", icon: Globe }, + { id: "provider-pool" as Page, label: "凭证池", icon: Database }, + { id: "config-management" as Page, label: "配置管理", icon: FileCode }, { id: "flow-monitor" as Page, label: "Flow Monitor", icon: Activity }, { id: "settings" as Page, label: "设置", icon: Settings }, ]; diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index 72d09685a..5b7c96ad0 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -532,7 +532,7 @@ export function ApiServerPage() { {[ { id: "server" as TabId, name: "服务器控制" }, { id: "routes" as TabId, name: "路由端点" }, - { id: "logs" as TabId, name: "日志" }, + { id: "logs" as TabId, name: "系统日志" }, ].map((tab) => ( + + {syncResult.status !== "InSync" && ( + + )} + + +
+
+ + + ); +} diff --git a/src/components/clients/ProviderList.tsx b/src/components/clients/ProviderList.tsx index f0d436dbb..52891617d 100644 --- a/src/components/clients/ProviderList.tsx +++ b/src/components/clients/ProviderList.tsx @@ -1,10 +1,11 @@ import { useState } from "react"; -import { Plus, RefreshCw, Eye } from "lucide-react"; -import { AppType } from "@/lib/api/switch"; +import { Plus, RefreshCw, Eye, GitCompare } from "lucide-react"; +import { AppType, SyncCheckResult } from "@/lib/api/switch"; import { useSwitch } from "@/hooks/useSwitch"; import { ProviderCard } from "./ProviderCard"; import { ProviderForm } from "./ProviderForm"; import { LiveConfigModal } from "./LiveConfigModal"; +import { ConfigSyncDialog } from "./ConfigSyncDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog"; interface ProviderListProps { @@ -22,6 +23,8 @@ export function ProviderList({ appType }: ProviderListProps) { deleteProvider, switchToProvider, refresh, + checkConfigSync, + syncFromExternal, } = useSwitch(appType); const [showForm, setShowForm] = useState(false); @@ -30,6 +33,9 @@ export function ProviderList({ appType }: ProviderListProps) { >(null); const [showLiveConfig, setShowLiveConfig] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); + const [showSyncDialog, setShowSyncDialog] = useState(false); + const [syncResult, setSyncResult] = useState(null); + const [checkingSync, setCheckingSync] = useState(false); const handleAdd = () => { setEditingProvider(null); @@ -71,6 +77,31 @@ export function ProviderList({ appType }: ProviderListProps) { } }; + const handleCheckSync = async () => { + setCheckingSync(true); + try { + const result = await checkConfigSync(); + setSyncResult(result); + setShowSyncDialog(true); + } catch (_e) { + // Error is handled in the hook + } finally { + setCheckingSync(false); + } + }; + + const handleSyncFromExternal = async () => { + await syncFromExternal(); + // 重新检查同步状态 + const result = await checkConfigSync(); + setSyncResult(result); + }; + + const handleRefreshSyncCheck = async () => { + const result = await checkConfigSync(); + setSyncResult(result); + }; + if (loading) { return (
@@ -109,6 +140,16 @@ export function ProviderList({ appType }: ProviderListProps) {
+
); } diff --git a/src/components/monitoring/LogViewer.tsx b/src/components/monitoring/LogViewer.tsx deleted file mode 100644 index 55e8d1924..000000000 --- a/src/components/monitoring/LogViewer.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import React, { useState } from "react"; -import { - FileText, - CheckCircle2, - XCircle, - Clock, - AlertTriangle, - ChevronDown, - ChevronRight, - Trash2, - Filter, -} from "lucide-react"; -import type { RequestLog, RequestStatus } from "@/lib/api/telemetry"; - -interface LogViewerProps { - logs: RequestLog[]; - onClear: () => void; - onFilter?: (filter: { provider?: string; status?: RequestStatus }) => void; -} - -export function LogViewer({ logs, onClear, onFilter }: LogViewerProps) { - const [expandedId, setExpandedId] = useState(null); - const [filterProvider, setFilterProvider] = useState(""); - const [filterStatus, setFilterStatus] = useState(""); - - const getStatusIcon = (status: RequestStatus) => { - switch (status) { - case "success": - return ; - case "failed": - return ; - case "timeout": - return ; - case "retrying": - return ; - case "cancelled": - return ; - } - }; - - const getStatusText = (status: RequestStatus) => { - const texts: Record = { - success: "成功", - failed: "失败", - timeout: "超时", - retrying: "重试中", - cancelled: "已取消", - }; - return texts[status]; - }; - - const getProviderName = (id: string) => { - const names: Record = { - kiro: "Kiro", - gemini: "Gemini", - qwen: "Qwen", - openai: "OpenAI", - claude: "Claude", - antigravity: "Antigravity", - }; - return names[id] || id; - }; - - const formatTime = (timestamp: string) => { - const date = new Date(timestamp); - return date.toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); - }; - - const formatDuration = (ms: number) => { - if (ms < 1000) return `${ms}ms`; - return `${(ms / 1000).toFixed(2)}s`; - }; - - const handleFilterChange = () => { - onFilter?.({ - provider: filterProvider || undefined, - status: (filterStatus as RequestStatus) || undefined, - }); - }; - - // 获取唯一的 providers - const providers = [...new Set(logs.map((l) => l.provider))]; - - return ( -
- {/* 过滤器和操作 */} -
-
- - - -
- -
- - {/* 日志列表 */} -
-
- - 请求日志 - - ({logs.length} 条) - -
- - {logs.length === 0 ? ( -
- 暂无请求日志 -
- ) : ( -
- {logs.map((log) => ( - - setExpandedId(expandedId === log.id ? null : log.id) - } - getStatusIcon={getStatusIcon} - getStatusText={getStatusText} - getProviderName={getProviderName} - formatTime={formatTime} - formatDuration={formatDuration} - /> - ))} -
- )} -
-
- ); -} - -function LogEntry({ - log, - expanded, - onToggle, - getStatusIcon, - getStatusText, - getProviderName, - formatTime, - formatDuration, -}: { - log: RequestLog; - expanded: boolean; - onToggle: () => void; - getStatusIcon: (status: RequestStatus) => React.ReactNode; - getStatusText: (status: RequestStatus) => string; - getProviderName: (id: string) => string; - formatTime: (timestamp: string) => string; - formatDuration: (ms: number) => string; -}) { - return ( -
-
- {expanded ? ( - - ) : ( - - )} - {getStatusIcon(log.status)} - - {formatTime(log.timestamp)} - - - {getProviderName(log.provider)} - - {log.model} - - {formatDuration(log.duration_ms)} - - {log.total_tokens && ( - - {log.total_tokens} tokens - - )} -
- - {expanded && ( -
-
-
- 状态:{" "} - - {getStatusText(log.status)} - - {log.http_status && ( - - (HTTP {log.http_status}) - - )} -
-
- 流式:{" "} - {log.is_streaming ? "是" : "否"} -
- {log.input_tokens !== undefined && ( -
- 输入 Token:{" "} - {log.input_tokens} -
- )} - {log.output_tokens !== undefined && ( -
- 输出 Token:{" "} - {log.output_tokens} -
- )} - {log.retry_count > 0 && ( -
- 重试次数:{" "} - {log.retry_count} -
- )} - {log.credential_id && ( -
- 凭证 ID:{" "} - - {log.credential_id.slice(0, 8)}... - -
- )} -
- {log.error_message && ( -
- 错误: {log.error_message} -
- )} -
- )} -
- ); -} diff --git a/src/components/monitoring/MonitoringPage.tsx b/src/components/monitoring/MonitoringPage.tsx deleted file mode 100644 index 5fccf7b18..000000000 --- a/src/components/monitoring/MonitoringPage.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import React, { useState, useEffect, useCallback } from "react"; -import { Activity, FileText, Coins, RefreshCw } from "lucide-react"; -import { StatsOverview } from "./StatsOverview"; -import { LogViewer } from "./LogViewer"; -import { TokenStats } from "./TokenStats"; -import { - getDashboardData, - getRequestLogs, - clearRequestLogs, - getTokenStatsByDay, - getTokenStatsByProvider, - type DashboardData, - type RequestLog, - type PeriodTokenStats, - type ProviderTokenStats, - type RequestStatus, -} from "@/lib/api/telemetry"; - -type TabType = "overview" | "logs" | "tokens"; - -export function MonitoringPage() { - const [activeTab, setActiveTab] = useState("overview"); - const [dashboardData, setDashboardData] = useState( - null, - ); - const [logs, setLogs] = useState([]); - const [tokensByDay, setTokensByDay] = useState([]); - const [tokensByProvider, setTokensByProvider] = useState< - Record - >({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetchData = useCallback(async () => { - try { - setLoading(true); - setError(null); - - const [dashboard, logsData, dayStats, providerStats] = await Promise.all([ - getDashboardData(), - getRequestLogs({ limit: 100 }), - getTokenStatsByDay(7), - getTokenStatsByProvider({ preset: "7d" }), - ]); - - setDashboardData(dashboard); - setLogs(logsData); - setTokensByDay(dayStats); - setTokensByProvider(providerStats); - } catch (e) { - console.error("Failed to fetch monitoring data:", e); - setError(e instanceof Error ? e.message : "加载数据失败"); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetchData(); - // 每 30 秒刷新一次 - const interval = setInterval(fetchData, 30000); - return () => clearInterval(interval); - }, [fetchData]); - - const handleClearLogs = async () => { - try { - await clearRequestLogs(); - setLogs([]); - } catch (e) { - console.error("Failed to clear logs:", e); - } - }; - - const handleFilterLogs = async (filter: { - provider?: string; - status?: RequestStatus; - }) => { - try { - const filteredLogs = await getRequestLogs({ - ...filter, - limit: 100, - }); - setLogs(filteredLogs); - } catch (e) { - console.error("Failed to filter logs:", e); - } - }; - - const tabs: { id: TabType; label: string; icon: React.ElementType }[] = [ - { id: "overview", label: "统计概览", icon: Activity }, - { id: "logs", label: "请求日志", icon: FileText }, - { id: "tokens", label: "Token 统计", icon: Coins }, - ]; - - return ( -
- {/* 页面标题 */} -
-
-

监控中心

-

- 请求统计、日志和 Token 使用情况 -

-
- -
- - {/* 标签页 */} -
- {tabs.map((tab) => ( - - ))} -
- - {/* 错误提示 */} - {error && ( -
- {error} -
- )} - - {/* 内容区域 */} - {loading && !dashboardData ? ( -
- -
- ) : ( - <> - {activeTab === "overview" && dashboardData && ( - - )} - - {activeTab === "logs" && ( - - )} - - {activeTab === "tokens" && dashboardData && ( - - )} - - )} -
- ); -} diff --git a/src/components/monitoring/StatsOverview.tsx b/src/components/monitoring/StatsOverview.tsx deleted file mode 100644 index 26ab69c04..000000000 --- a/src/components/monitoring/StatsOverview.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from "react"; -import { - Activity, - CheckCircle2, - XCircle, - Clock, - Zap, - TrendingUp, -} from "lucide-react"; -import type { StatsSummary, ProviderStats } from "@/lib/api/telemetry"; - -interface StatsOverviewProps { - stats: StatsSummary; - byProvider: Record; -} - -export function StatsOverview({ stats, byProvider }: StatsOverviewProps) { - const formatLatency = (ms: number) => { - if (ms < 1000) return `${Math.round(ms)}ms`; - return `${(ms / 1000).toFixed(2)}s`; - }; - - const formatRate = (rate: number) => `${(rate * 100).toFixed(1)}%`; - - const getProviderName = (id: string) => { - const names: Record = { - kiro: "Kiro Claude", - gemini: "Gemini", - qwen: "通义千问", - openai: "OpenAI", - claude: "Claude", - antigravity: "Antigravity", - }; - return names[id] || id; - }; - - return ( -
- {/* 总体统计卡片 */} -
- - = 0.9 - ? "text-green-600" - : stats.success_rate >= 0.7 - ? "text-yellow-600" - : "text-red-600" - } - /> - - -
- - {/* 按 Provider 统计 */} - {Object.keys(byProvider).length > 0 && ( -
-

- 按 Provider 统计 -

-
- {Object.entries(byProvider).map(([provider, providerStats]) => ( - - ))} -
-
- )} -
- ); -} - -function StatCard({ - icon: Icon, - label, - value, - subValue, - valueColor = "text-foreground", -}: { - icon: React.ElementType; - label: string; - value: string; - subValue?: string; - valueColor?: string; -}) { - return ( -
-
- - {label} -
-
{value}
- {subValue && ( -
{subValue}
- )} -
- ); -} - -function ProviderStatRow({ - name, - stats, -}: { - name: string; - stats: ProviderStats; -}) { - const successRate = stats.success_rate * 100; - - return ( -
-
-
{name}
-
- {stats.total_requests} 请求 -
-
-
-
- - {stats.successful_requests} -
-
- - {stats.failed_requests} -
-
-
-
= 90 - ? "bg-green-500" - : successRate >= 70 - ? "bg-yellow-500" - : "bg-red-500" - }`} - style={{ width: `${successRate}%` }} - /> -
-
- - {successRate.toFixed(0)}% - -
-
- ); -} - -function formatNumber(num: number): string { - if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; - if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; - return num.toString(); -} diff --git a/src/components/monitoring/TokenStats.tsx b/src/components/monitoring/TokenStats.tsx deleted file mode 100644 index d45835beb..000000000 --- a/src/components/monitoring/TokenStats.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import React from "react"; -import { Coins, TrendingUp, BarChart3 } from "lucide-react"; -import type { - TokenStatsSummary, - ProviderTokenStats, - PeriodTokenStats, -} from "@/lib/api/telemetry"; - -interface TokenStatsProps { - summary: TokenStatsSummary; - byProvider: Record; - byDay: PeriodTokenStats[]; -} - -export function TokenStats({ summary, byProvider, byDay }: TokenStatsProps) { - const getProviderName = (id: string) => { - const names: Record = { - kiro: "Kiro Claude", - gemini: "Gemini", - qwen: "通义千问", - openai: "OpenAI", - claude: "Claude", - antigravity: "Antigravity", - }; - return names[id] || id; - }; - - const formatNumber = (num: number): string => { - if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M`; - if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; - return num.toString(); - }; - - const formatDate = (dateStr?: string) => { - if (!dateStr) return ""; - const date = new Date(dateStr); - return date.toLocaleDateString("zh-CN", { - month: "2-digit", - day: "2-digit", - }); - }; - - // 计算每日最大值用于图表缩放 - const maxDayTokens = Math.max(...byDay.map((d) => d.total_tokens), 1); - - return ( -
- {/* Token 总览 */} -
- - - - -
- - {/* 按 Provider 统计 */} - {Object.keys(byProvider).length > 0 && ( -
-

- 按 Provider Token 使用 -

-
- {Object.entries(byProvider).map(([provider, stats]) => ( - - ))} -
-
- )} - - {/* 每日趋势 */} - {byDay.length > 0 && ( -
-

- - 每日 Token 使用趋势 -

-
- {byDay - .slice() - .reverse() - .map((day, index) => ( - - ))} -
-
- )} -
- ); -} - -function TokenCard({ - icon: Icon, - label, - value, - subValue, -}: { - icon: React.ElementType; - label: string; - value: string; - subValue?: string; -}) { - return ( -
-
- - {label} -
-
{value}
- {subValue && ( -
{subValue}
- )} -
- ); -} - -function ProviderTokenRow({ - name, - stats, - totalTokens, -}: { - name: string; - stats: ProviderTokenStats; - totalTokens: number; -}) { - const percentage = - totalTokens > 0 ? (stats.total_tokens / totalTokens) * 100 : 0; - - const formatNumber = (num: number): string => { - if (num >= 1000000) return `${(num / 1000000).toFixed(2)}M`; - if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; - return num.toString(); - }; - - return ( -
-
-
{name}
-
- {stats.record_count} 条记录 -
-
-
-
- 输入:{" "} - {formatNumber(stats.total_input_tokens)} -
-
- 输出:{" "} - {formatNumber(stats.total_output_tokens)} -
-
-
-
-
-
- - {formatNumber(stats.total_tokens)} - -
-
- ); -} - -function DayBar({ - date, - tokens, - maxTokens, -}: { - date: string; - tokens: number; - maxTokens: number; -}) { - const height = maxTokens > 0 ? (tokens / maxTokens) * 100 : 0; - - const formatNumber = (num: number): string => { - if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; - if (num >= 1000) return `${(num / 1000).toFixed(0)}K`; - return num.toString(); - }; - - return ( -
-
-
-
- {date} -
- ); -} diff --git a/src/components/monitoring/index.ts b/src/components/monitoring/index.ts deleted file mode 100644 index 3fc1c4596..000000000 --- a/src/components/monitoring/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { MonitoringPage } from "./MonitoringPage"; -export { StatsOverview } from "./StatsOverview"; -export { LogViewer } from "./LogViewer"; -export { TokenStats } from "./TokenStats"; diff --git a/src/components/provider-pool/AddCredentialModal.tsx b/src/components/provider-pool/AddCredentialModal.tsx index c6a51b763..5c3f1149f 100644 --- a/src/components/provider-pool/AddCredentialModal.tsx +++ b/src/components/provider-pool/AddCredentialModal.tsx @@ -355,6 +355,19 @@ export function AddCredentialModal({ ); } + // Claude OAuth Cookie 模式 + if (providerType === "claude_oauth" && claudeOAuthForm.mode === "cookie") { + return ( + + ); + } + // Claude OAuth 登录模式 if (providerType === "claude_oauth" && claudeOAuthForm.mode === "login") { if (!claudeOAuthForm.authUrl) { diff --git a/src/components/provider-pool/credential-forms/ClaudeOAuthForm.tsx b/src/components/provider-pool/credential-forms/ClaudeOAuthForm.tsx index 7e3dc9f77..c5395dabe 100644 --- a/src/components/provider-pool/credential-forms/ClaudeOAuthForm.tsx +++ b/src/components/provider-pool/credential-forms/ClaudeOAuthForm.tsx @@ -1,12 +1,15 @@ /** * Claude OAuth 凭证添加表单 - * 支持 Claude OAuth 登录和文件导入两种模式 + * 支持三种模式: + * 1. OAuth 登录 - 通过授权 URL 手动复制授权码 + * 2. Cookie 授权 - 使用 sessionKey 自动完成 OAuth 流程 + * 3. 文件导入 - 导入已有的凭证文件 */ import { useState, useEffect } from "react"; import { listen } from "@tauri-apps/api/event"; +import { Cookie, Key, FileJson } from "lucide-react"; import { providerPoolApi } from "@/lib/api/providerPool"; -import { ModeSelector } from "./ModeSelector"; import { FileImportForm } from "./FileImportForm"; import { OAuthUrlDisplay } from "./OAuthUrlDisplay"; @@ -21,6 +24,8 @@ interface ClaudeOAuthFormProps { onSuccess: () => void; } +type AuthMode = "login" | "cookie" | "file"; + export function ClaudeOAuthForm({ name, credsFilePath, @@ -31,9 +36,11 @@ export function ClaudeOAuthForm({ setError, onSuccess, }: ClaudeOAuthFormProps) { - const [mode, setMode] = useState<"login" | "file">("login"); + const [mode, setMode] = useState("cookie"); const [authUrl, setAuthUrl] = useState(null); const [waitingForCallback, setWaitingForCallback] = useState(false); + const [sessionKey, setSessionKey] = useState(""); + const [isSetupToken, setIsSetupToken] = useState(false); // 监听后端发送的授权 URL 事件 useEffect(() => { @@ -75,6 +82,31 @@ export function ClaudeOAuthForm({ } }; + // Cookie 自动授权 + const handleCookieSubmit = async () => { + if (!sessionKey.trim()) { + setError("请输入 sessionKey"); + return; + } + + setLoading(true); + setError(null); + + try { + const trimmedName = name.trim() || undefined; + await providerPoolApi.claudeOAuthWithCookie( + sessionKey.trim(), + isSetupToken, + trimmedName, + ); + onSuccess(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }; + // 文件导入提交 const handleFileSubmit = async () => { if (!credsFilePath) { @@ -96,48 +128,135 @@ export function ClaudeOAuthForm({ } }; + // 模式选择器 + const renderModeSelector = () => ( +
+ + + +
+ ); + + // Cookie 授权表单 + const renderCookieForm = () => ( +
+
+

+ 使用浏览器 Cookie 中的 sessionKey 自动完成 OAuth + 授权,无需手动复制授权码。 +

+

+ 获取方式:在 claude.ai 登录后,打开开发者工具 → Application → Cookies + → 复制 sessionKey 的值 +

+
+ +
+ +