Merge branch 'aiclientproxy:main' into main

This commit is contained in:
PeanutSplash
2025-12-24 23:34:10 +08:00
committed by GitHub
35 changed files with 1861 additions and 1793 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.17.4",
"version": "0.17.6",
"type": "module",
"repository": {
"type": "git",
+1 -1
View File
@@ -3377,7 +3377,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.17.4"
version = "0.17.6"
dependencies = [
"anyhow",
"async-stream",
+1 -1
View File
@@ -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"
+264
View File
@@ -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<String>,
pub fixes_applied: Vec<String>,
pub warnings: Vec<String>,
}
/// 自动检测并修复配置问题
#[tauri::command]
pub async fn auto_fix_configuration(
state: State<'_, AppState>,
logs: State<'_, LogState>,
db: State<'_, DbConnection>,
) -> Result<AutoFixResult, String> {
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(&current_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<CredentialStats, String> {
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<String> {
// 优先级: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(())
}
+1
View File
@@ -1,3 +1,4 @@
pub mod auto_fix_cmd;
pub mod config_cmd;
pub mod flow_monitor_cmd;
pub mod injection_cmd;
+117 -21
View File
@@ -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<String>,
) -> Result<ProviderCredential, String> {
_db: State<'_, DbConnection>,
_pool_service: State<'_, ProviderPoolServiceState>,
_name: Option<String>,
) -> Result<ClaudeOAuthParamsResponse, String> {
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(&params.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<String>,
) -> Result<ProviderCredential, String> {
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<String>,
) -> Result<ClaudeOAuthParamsResponse, String> {
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<String>,
pub capabilities: Vec<String>,
}
/// 使用 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<bool>,
name: Option<String>,
) -> Result<ProviderCredential, String> {
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)
}
+47 -1
View File
@@ -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<Value, String> {
SwitchService::read_live_settings(&app_type)
}
/// 检查配置同步状态
#[tauri::command]
pub fn check_config_sync_status(
db: State<'_, DbConnection>,
app_type: String,
) -> Result<SyncCheckResult, String> {
// 解析 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, &current_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<String, String> {
// 解析 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
))
}
-52
View File
@@ -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<String, ProviderStats>,
/// 最近请求
pub recent_logs: Vec<RequestLog>,
}
/// 获取仪表盘数据
#[tauri::command]
pub async fn get_dashboard_data(
state: tauri::State<'_, TelemetryState>,
) -> Result<DashboardData, String> {
// 获取最近 24 小时的统计
let range = Some(TimeRange::last_hours(24));
let stats_guard = state.stats.read();
let stats = stats_guard.summary(range);
let by_provider: HashMap<String, ProviderStats> = 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,
})
}
+65 -6
View File
@@ -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<server::ServerStatus, String> {
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");
+543 -292
View File
@@ -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<PathBuf>,
/// 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::<Vec<_>>()
.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<serde_json::Value, Box<dyn Error + Send + Sync>> {
// 清理授权码,移除 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#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权成功</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #d97706 0%, #b45309 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #d97706; margin-bottom: 16px; }
p { color: #666; margin-bottom: 8px; }
.email { color: #333; font-weight: 500; }
</style>
</head>
<body>
<div class="container">
<h1>✓ 授权成功</h1>
<p>Claude 账号已添加到 ProxyCast</p>
<p class="email">EMAIL_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
</div>
</body>
</html>"#;
/// 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#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>授权失败</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); }
.container { text-align: center; background: white; padding: 40px 60px; border-radius: 16px; box-shadow: 0 10px 40px rgba(0,0,0,0.2); }
h1 { color: #ef4444; margin-bottom: 16px; }
p { color: #666; }
.error { color: #ef4444; font-size: 14px; margin-top: 16px; }
</style>
</head>
<body>
<div class="container">
<h1>✗ 授权失败</h1>
<p>ERROR_PLACEHOLDER</p>
<p style="margin-top: 20px; color: #999;">请关闭此页面后重试</p>
</div>
</body>
</html>"#;
/// 生成 OAuth 授权参数(不启动服务器)
///
/// 返回授权 URL 和 PKCE 参数,用户需要:
/// 1. 打开 auth_url 进行授权
/// 2. 授权后从页面复制授权码
/// 3. 调用 exchange_claude_authorization_code 交换 token
pub fn generate_claude_oauth_params() -> Result<ClaudeOAuthParams, Box<dyn Error + Send + Sync>> {
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<Output = Result<ClaudeOAuthResult, Box<dyn Error + Send + Sync>>>,
),
Box<dyn Error + Send + Sync>,
> {
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<ClaudeOAuthParams, Box<dyn Error + Send + Sync>> {
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<String, Box<dyn Error + Send + Sync>> {
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<ClaudeOAuthResult, Box<dyn Error + Send + Sync>> {
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::<Result<ClaudeOAuthResult, String>>();
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<HashMap<String, String>>| {
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<dyn Error + Send + Sync>
}),
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<ClaudeOAuthResult, Box<dyn Error + Send + Sync>> {
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<ClaudeOAuthParams, Box<dyn Error + Send + Sync>> {
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(&params.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<String>,
}
/// Cookie 自动授权结果
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CookieOAuthResult {
pub credentials: ClaudeOAuthCredentials,
pub creds_file_path: String,
pub organization_uuid: Option<String>,
pub capabilities: Vec<String>,
}
/// 构建带 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<OrganizationInfo, Box<dyn Error + Send + Sync>> {
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<OrganizationInfo> = None;
let mut max_capabilities = 0;
for org in orgs {
let capabilities: Vec<String> = 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<dyn Error + Send + Sync>> {
// 生成 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<CookieOAuthResult, Box<dyn Error + Send + Sync>> {
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,
})
}
+5
View File
@@ -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<RwLock<LogStore>>,
+166
View File
@@ -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<String>,
pub conflicts: Vec<ConfigConflict>,
}
/// 从外部配置文件解析当前生效的 provider
pub fn parse_current_provider_from_live(
app_type: &AppType,
live_settings: &Value,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
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<SyncCheckResult, Box<dyn std::error::Error + Send + Sync>> {
// 读取外部配置文件
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<String> {
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<String, Box<dyn std::error::Error + Send + Sync>> {
// 读取外部配置
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)
}
+1 -1
View File
@@ -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",
+2 -14
View File
@@ -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<Page>("dashboard");
const [currentPage, setCurrentPage] = useState<Page>("api-server");
// 在应用启动时初始化 Flow 事件订阅
useEffect(() => {
@@ -31,16 +25,10 @@ function App() {
const renderPage = () => {
switch (currentPage) {
case "dashboard":
return <Dashboard />;
case "provider-pool":
return <ProviderPoolPage />;
case "routing-management":
return <RoutingManagementPage />;
case "config-management":
return <ConfigManagementPage />;
case "extensions":
return <ExtensionsPage />;
case "api-server":
return <ApiServerPage />;
case "flow-monitor":
@@ -48,7 +36,7 @@ function App() {
case "settings":
return <SettingsPage />;
default:
return <Dashboard />;
return <ApiServerPage />;
}
};
-472
View File
@@ -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<TabType>("overview");
const [status, setStatus] = useState<ServerStatus | null>(null);
const [config, setConfig] = useState<Config | null>(null);
const [defaultProvider, setDefaultProvider] = useState<string>("kiro");
const { credentials: oauthCredentials, reload: reloadCredentials } =
useAllOAuthCredentials();
// 监控数据状态
const [dashboardData, setDashboardData] = useState<DashboardData | null>(
null,
);
const [logs, setLogs] = useState<RequestLog[]>([]);
const [tokensByDay, setTokensByDay] = useState<PeriodTokenStats[]>([]);
const [tokensByProvider, setTokensByProvider] = useState<
Record<string, ProviderTokenStats>
>({});
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 (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">仪表盘</h2>
<p className="text-muted-foreground">系统状态与监控</p>
</div>
{activeTab !== "overview" && (
<button
onClick={fetchMonitoringData}
disabled={monitoringLoading}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${monitoringLoading ? "animate-spin" : ""}`}
/>
刷新
</button>
)}
</div>
{/* 标签页 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{/* 内容区域 */}
{activeTab === "overview" && (
<OverviewTab
status={status}
config={config}
defaultProvider={defaultProvider}
oauthCredentials={oauthCredentials}
serverUrl={serverUrl}
formatUptime={formatUptime}
getProviderName={getProviderName}
/>
)}
{activeTab === "stats" && dashboardData && (
<StatsOverview
stats={dashboardData.stats}
byProvider={dashboardData.by_provider}
/>
)}
{activeTab === "logs" && (
<LogViewer
logs={logs}
onClear={handleClearLogs}
onFilter={handleFilterLogs}
/>
)}
{activeTab === "tokens" && dashboardData && (
<TokenStats
summary={dashboardData.tokens}
byProvider={tokensByProvider}
byDay={tokensByDay}
/>
)}
{/* 加载状态 */}
{activeTab !== "overview" && monitoringLoading && !dashboardData && (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
</div>
);
}
// 概览标签页内容
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 (
<div className="space-y-6">
{/* Server Status Cards */}
<div className="grid grid-cols-4 gap-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">服务状态</span>
</div>
<div className="mt-2 flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${status?.running ? "bg-green-500" : "bg-red-500"}`}
/>
<span className="font-medium">
{status?.running ? "运行中" : "已停止"}
</span>
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">请求数</span>
</div>
<div className="mt-2 text-2xl font-bold">{status?.requests || 0}</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">运行时间</span>
</div>
<div className="mt-2 font-medium">
{formatUptime(status?.uptime_secs || 0)}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">默认 Provider</span>
</div>
<div className="mt-2 font-medium">
{getProviderName(defaultProvider)}
</div>
</div>
</div>
{/* Quick Links */}
<div className="grid grid-cols-3 gap-4">
<QuickLinkCard
icon={Key}
title="凭证管理"
description="管理 OAuth 凭证"
status={
oauthCredentials.filter((c) => c.is_valid).length > 0
? "success"
: "warning"
}
statusText={`${oauthCredentials.filter((c) => c.is_valid).length}/${oauthCredentials.length} 有效`}
/>
<QuickLinkCard
icon={Monitor}
title="配置切换"
description="一键切换 Claude Code/Codex/Gemini CLI 的 API 配置"
status="info"
statusText="管理 Provider 配置"
/>
<QuickLinkCard
icon={Globe}
title="API Server"
description={`${serverUrl}`}
status={status?.running ? "success" : "warning"}
statusText={status?.running ? "运行中" : "已停止"}
/>
</div>
{/* OAuth Credentials Overview */}
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Key className="h-4 w-4" />
OAuth 凭证状态
</h3>
<div className="grid grid-cols-3 gap-4">
{oauthCredentials.map((cred) => (
<div
key={cred.provider}
className="flex items-center justify-between rounded-lg border bg-background p-3"
>
<div className="flex items-center gap-3">
<ProviderIcon providerType={cred.provider} size={20} />
<div>
<div className="font-medium">
{getProviderName(cred.provider)}
</div>
<div className="text-xs text-muted-foreground">
{cred.has_access_token ? "Token 已加载" : "未配置"}
</div>
</div>
</div>
<div
className={`h-3 w-3 rounded-full ${
cred.is_valid
? "bg-green-500"
: cred.loaded
? "bg-yellow-500"
: "bg-gray-400"
}`}
/>
</div>
))}
</div>
</div>
{/* Server Info */}
{config && (
<div className="rounded-lg border bg-card p-6">
<h3 className="mb-4 font-semibold">服务器信息</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">API 地址:</span>
<code className="ml-2 rounded bg-muted px-2 py-1">
{serverUrl}
</code>
</div>
<div>
<span className="text-muted-foreground">API Key:</span>
<code className="ml-2 rounded bg-muted px-2 py-1">
{config.server.api_key.length > 8
? `${config.server.api_key.slice(0, 4)}****${config.server.api_key.slice(-4)}`
: "****"}
</code>
</div>
</div>
</div>
)}
</div>
);
}
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 (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-3 mb-2">
<div className="rounded-lg bg-primary/10 p-2">
<Icon className="h-5 w-5 text-primary" />
</div>
<div>
<h4 className="font-medium">{title}</h4>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div
className={`flex items-center gap-1 text-xs ${statusColors[status]}`}
>
<StatusIcon className="h-3 w-3" />
{statusText}
</div>
</div>
);
}
+3 -18
View File
@@ -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 },
];
+1 -1
View File
@@ -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) => (
<button
key={tab.id}
+1 -1
View File
@@ -130,7 +130,7 @@ export function LogsTab() {
>
{logs.length === 0 ? (
<p className="text-center text-muted-foreground">
暂无日志,启动服务后将显示请求日志
暂无日志,软件运行时将显示系统日志
</p>
) : (
logs.map((log, i) => (
+187
View File
@@ -0,0 +1,187 @@
import { useState } from "react";
import { AlertTriangle, CheckCircle, XCircle, RefreshCw } from "lucide-react";
import { SyncCheckResult, SyncStatus } from "@/lib/api/switch";
interface ConfigSyncDialogProps {
isOpen: boolean;
syncResult: SyncCheckResult | null;
onClose: () => void;
onSyncFromExternal: () => Promise<void>;
onRefreshCheck: () => Promise<void>;
}
export function ConfigSyncDialog({
isOpen,
syncResult,
onClose,
onSyncFromExternal,
onRefreshCheck,
}: ConfigSyncDialogProps) {
const [syncing, setSyncing] = useState(false);
const [checking, setChecking] = useState(false);
if (!isOpen || !syncResult) return null;
const getSyncStatusIcon = (status: SyncStatus) => {
switch (status) {
case "InSync":
return <CheckCircle className="h-5 w-5 text-green-500" />;
case "OutOfSync":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case "Conflict":
return <XCircle className="h-5 w-5 text-red-500" />;
}
};
const getSyncStatusText = (status: SyncStatus) => {
switch (status) {
case "InSync":
return "配置已同步";
case "OutOfSync":
return "配置有差异";
case "Conflict":
return "配置冲突";
}
};
const getSyncStatusColor = (status: SyncStatus) => {
switch (status) {
case "InSync":
return "text-green-700 bg-green-50 border-green-200";
case "OutOfSync":
return "text-yellow-700 bg-yellow-50 border-yellow-200";
case "Conflict":
return "text-red-700 bg-red-50 border-red-200";
}
};
const handleSyncFromExternal = async () => {
setSyncing(true);
try {
await onSyncFromExternal();
onClose();
} catch (_e) {
// Error is handled in the hook
} finally {
setSyncing(false);
}
};
const handleRefreshCheck = async () => {
setChecking(true);
try {
await onRefreshCheck();
} catch (_e) {
// Error is handled in the hook
} finally {
setChecking(false);
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full mx-4">
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
{getSyncStatusIcon(syncResult.status)}
<h3 className="text-lg font-semibold">配置同步状态</h3>
</div>
<div
className={`rounded-lg border p-4 mb-4 ${getSyncStatusColor(syncResult.status)}`}
>
<div className="flex items-center gap-2 mb-2">
{getSyncStatusIcon(syncResult.status)}
<span className="font-medium">
{getSyncStatusText(syncResult.status)}
</span>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="font-medium">ProxyCast 当前配置:</span>{" "}
{syncResult.current_provider}
</div>
<div>
<span className="font-medium">外部软件当前配置:</span>{" "}
{syncResult.external_provider}
</div>
{syncResult.last_modified && (
<div>
<span className="font-medium">配置文件修改时间:</span>{" "}
{new Date(
parseInt(syncResult.last_modified) * 1000,
).toLocaleString()}
</div>
)}
</div>
{syncResult.conflicts.length > 0 && (
<div className="mt-3 pt-3 border-t border-current/20">
<div className="font-medium mb-2">冲突详情:</div>
{syncResult.conflicts.map((conflict, index) => (
<div key={index} className="text-sm space-y-1">
<div>字段: {conflict.field}</div>
<div>ProxyCast: {conflict.local_value}</div>
<div>外部软件: {conflict.external_value}</div>
</div>
))}
</div>
)}
</div>
{syncResult.status !== "InSync" && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-700 dark:text-blue-400">
{syncResult.status === "OutOfSync" && (
<>
检测到外部软件的配置与 ProxyCast
不同。您可以选择同步外部配置到 ProxyCast。
</>
)}
{syncResult.status === "Conflict" && (
<>
检测到配置冲突。建议选择使用外部软件的配置,或者手动在
ProxyCast 中重新设置。
</>
)}
</p>
</div>
)}
<div className="flex gap-2 justify-end">
<button
onClick={handleRefreshCheck}
disabled={checking || syncing}
className="px-3 py-2 text-sm border rounded-lg hover:bg-muted disabled:opacity-50 flex items-center gap-2"
>
<RefreshCw
className={`h-4 w-4 ${checking ? "animate-spin" : ""}`}
/>
重新检查
</button>
{syncResult.status !== "InSync" && (
<button
onClick={handleSyncFromExternal}
disabled={syncing || checking}
className="px-3 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 flex items-center gap-2"
>
{syncing && <RefreshCw className="h-4 w-4 animate-spin" />}
使用外部配置
</button>
)}
<button
onClick={onClose}
disabled={syncing || checking}
className="px-3 py-2 text-sm border rounded-lg hover:bg-muted disabled:opacity-50"
>
关闭
</button>
</div>
</div>
</div>
</div>
);
}
+51 -2
View File
@@ -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<string | null>(null);
const [showSyncDialog, setShowSyncDialog] = useState(false);
const [syncResult, setSyncResult] = useState<SyncCheckResult | null>(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 (
<div className="flex items-center justify-center py-12">
@@ -109,6 +140,16 @@ export function ProviderList({ appType }: ProviderListProps) {
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleCheckSync}
disabled={checkingSync}
className="p-2 rounded-lg hover:bg-muted"
title="检查外部配置同步状态"
>
<GitCompare
className={`h-4 w-4 ${checkingSync ? "animate-pulse" : ""}`}
/>
</button>
<button
onClick={refresh}
className="p-2 rounded-lg hover:bg-muted"
@@ -172,6 +213,14 @@ export function ProviderList({ appType }: ProviderListProps) {
onConfirm={handleDeleteConfirm}
onCancel={() => setDeleteConfirm(null)}
/>
<ConfigSyncDialog
isOpen={showSyncDialog}
syncResult={syncResult}
onClose={() => setShowSyncDialog(false)}
onSyncFromExternal={handleSyncFromExternal}
onRefreshCheck={handleRefreshSyncCheck}
/>
</div>
);
}
-278
View File
@@ -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<string | null>(null);
const [filterProvider, setFilterProvider] = useState<string>("");
const [filterStatus, setFilterStatus] = useState<string>("");
const getStatusIcon = (status: RequestStatus) => {
switch (status) {
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "timeout":
return <Clock className="h-4 w-4 text-yellow-500" />;
case "retrying":
return <AlertTriangle className="h-4 w-4 text-orange-500" />;
case "cancelled":
return <XCircle className="h-4 w-4 text-gray-500" />;
}
};
const getStatusText = (status: RequestStatus) => {
const texts: Record<RequestStatus, string> = {
success: "成功",
failed: "失败",
timeout: "超时",
retrying: "重试中",
cancelled: "已取消",
};
return texts[status];
};
const getProviderName = (id: string) => {
const names: Record<string, string> = {
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 (
<div className="space-y-4">
{/* 过滤器和操作 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<select
className="rounded border bg-background px-2 py-1 text-sm"
value={filterProvider}
onChange={(e) => {
setFilterProvider(e.target.value);
handleFilterChange();
}}
>
<option value="">所有 Provider</option>
{providers.map((p) => (
<option key={p} value={p}>
{getProviderName(p)}
</option>
))}
</select>
<select
className="rounded border bg-background px-2 py-1 text-sm"
value={filterStatus}
onChange={(e) => {
setFilterStatus(e.target.value);
handleFilterChange();
}}
>
<option value="">所有状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="timeout">超时</option>
</select>
</div>
<button
onClick={onClear}
className="flex items-center gap-1 rounded px-2 py-1 text-sm text-muted-foreground hover:bg-muted"
>
<Trash2 className="h-3 w-3" />
清空日志
</button>
</div>
{/* 日志列表 */}
<div className="rounded-lg border bg-card">
<div className="border-b px-4 py-2 flex items-center gap-2">
<FileText className="h-4 w-4" />
<span className="font-medium">请求日志</span>
<span className="text-sm text-muted-foreground">
({logs.length} 条)
</span>
</div>
{logs.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
暂无请求日志
</div>
) : (
<div className="divide-y max-h-[500px] overflow-y-auto">
{logs.map((log) => (
<LogEntry
key={log.id}
log={log}
expanded={expandedId === log.id}
onToggle={() =>
setExpandedId(expandedId === log.id ? null : log.id)
}
getStatusIcon={getStatusIcon}
getStatusText={getStatusText}
getProviderName={getProviderName}
formatTime={formatTime}
formatDuration={formatDuration}
/>
))}
</div>
)}
</div>
</div>
);
}
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 (
<div className="hover:bg-muted/50">
<div
className="flex items-center gap-3 px-4 py-2 cursor-pointer"
onClick={onToggle}
>
{expanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
{getStatusIcon(log.status)}
<span className="text-xs text-muted-foreground w-32">
{formatTime(log.timestamp)}
</span>
<span className="font-medium w-20">
{getProviderName(log.provider)}
</span>
<span className="text-sm flex-1 truncate">{log.model}</span>
<span className="text-sm text-muted-foreground w-16 text-right">
{formatDuration(log.duration_ms)}
</span>
{log.total_tokens && (
<span className="text-xs text-muted-foreground w-20 text-right">
{log.total_tokens} tokens
</span>
)}
</div>
{expanded && (
<div className="px-4 pb-3 pl-12 space-y-2 text-sm">
<div className="grid grid-cols-2 gap-4 rounded bg-muted/50 p-3">
<div>
<span className="text-muted-foreground">状态:</span>{" "}
<span
className={
log.status === "success" ? "text-green-600" : "text-red-600"
}
>
{getStatusText(log.status)}
</span>
{log.http_status && (
<span className="ml-2 text-muted-foreground">
(HTTP {log.http_status})
</span>
)}
</div>
<div>
<span className="text-muted-foreground">流式:</span>{" "}
{log.is_streaming ? "是" : "否"}
</div>
{log.input_tokens !== undefined && (
<div>
<span className="text-muted-foreground">输入 Token:</span>{" "}
{log.input_tokens}
</div>
)}
{log.output_tokens !== undefined && (
<div>
<span className="text-muted-foreground">输出 Token:</span>{" "}
{log.output_tokens}
</div>
)}
{log.retry_count > 0 && (
<div>
<span className="text-muted-foreground">重试次数:</span>{" "}
{log.retry_count}
</div>
)}
{log.credential_id && (
<div>
<span className="text-muted-foreground">凭证 ID:</span>{" "}
<code className="text-xs">
{log.credential_id.slice(0, 8)}...
</code>
</div>
)}
</div>
{log.error_message && (
<div className="rounded bg-red-50 dark:bg-red-950/20 p-2 text-red-600 dark:text-red-400">
<span className="font-medium">错误:</span> {log.error_message}
</div>
)}
</div>
)}
</div>
);
}
@@ -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<TabType>("overview");
const [dashboardData, setDashboardData] = useState<DashboardData | null>(
null,
);
const [logs, setLogs] = useState<RequestLog[]>([]);
const [tokensByDay, setTokensByDay] = useState<PeriodTokenStats[]>([]);
const [tokensByProvider, setTokensByProvider] = useState<
Record<string, ProviderTokenStats>
>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">监控中心</h2>
<p className="text-muted-foreground">
请求统计、日志和 Token 使用情况
</p>
</div>
<button
onClick={fetchData}
disabled={loading}
className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm hover:bg-muted disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
刷新
</button>
</div>
{/* 标签页 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{/* 错误提示 */}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 dark:bg-red-950/20 p-4 text-red-600 dark:text-red-400">
{error}
</div>
)}
{/* 内容区域 */}
{loading && !dashboardData ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{activeTab === "overview" && dashboardData && (
<StatsOverview
stats={dashboardData.stats}
byProvider={dashboardData.by_provider}
/>
)}
{activeTab === "logs" && (
<LogViewer
logs={logs}
onClear={handleClearLogs}
onFilter={handleFilterLogs}
/>
)}
{activeTab === "tokens" && dashboardData && (
<TokenStats
summary={dashboardData.tokens}
byProvider={tokensByProvider}
byDay={tokensByDay}
/>
)}
</>
)}
</div>
);
}
-178
View File
@@ -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<string, ProviderStats>;
}
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<string, string> = {
kiro: "Kiro Claude",
gemini: "Gemini",
qwen: "通义千问",
openai: "OpenAI",
claude: "Claude",
antigravity: "Antigravity",
};
return names[id] || id;
};
return (
<div className="space-y-6">
{/* 总体统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<StatCard
icon={Activity}
label="总请求数"
value={stats.total_requests.toString()}
subValue={`成功 ${stats.successful_requests}`}
/>
<StatCard
icon={CheckCircle2}
label="成功率"
value={formatRate(stats.success_rate)}
subValue={`失败 ${stats.failed_requests}`}
valueColor={
stats.success_rate >= 0.9
? "text-green-600"
: stats.success_rate >= 0.7
? "text-yellow-600"
: "text-red-600"
}
/>
<StatCard
icon={Clock}
label="平均延迟"
value={formatLatency(stats.avg_latency_ms)}
subValue={
stats.max_latency_ms
? `最大 ${formatLatency(stats.max_latency_ms)}`
: undefined
}
/>
<StatCard
icon={Zap}
label="总 Token"
value={formatNumber(stats.total_tokens)}
subValue={`输入 ${formatNumber(stats.total_input_tokens)} / 输出 ${formatNumber(stats.total_output_tokens)}`}
/>
</div>
{/* 按 Provider 统计 */}
{Object.keys(byProvider).length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<TrendingUp className="h-4 w-4" />按 Provider 统计
</h3>
<div className="space-y-3">
{Object.entries(byProvider).map(([provider, providerStats]) => (
<ProviderStatRow
key={provider}
name={getProviderName(provider)}
stats={providerStats}
/>
))}
</div>
</div>
)}
</div>
);
}
function StatCard({
icon: Icon,
label,
value,
subValue,
valueColor = "text-foreground",
}: {
icon: React.ElementType;
label: string;
value: string;
subValue?: string;
valueColor?: string;
}) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{label}</span>
</div>
<div className={`mt-2 text-2xl font-bold ${valueColor}`}>{value}</div>
{subValue && (
<div className="mt-1 text-xs text-muted-foreground">{subValue}</div>
)}
</div>
);
}
function ProviderStatRow({
name,
stats,
}: {
name: string;
stats: ProviderStats;
}) {
const successRate = stats.success_rate * 100;
return (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<div className="flex items-center gap-3">
<div className="font-medium">{name}</div>
<div className="text-sm text-muted-foreground">
{stats.total_requests} 请求
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-green-500" />
<span className="text-sm">{stats.successful_requests}</span>
</div>
<div className="flex items-center gap-1">
<XCircle className="h-3 w-3 text-red-500" />
<span className="text-sm">{stats.failed_requests}</span>
</div>
<div className="w-20">
<div className="h-2 rounded-full bg-muted">
<div
className={`h-2 rounded-full ${
successRate >= 90
? "bg-green-500"
: successRate >= 70
? "bg-yellow-500"
: "bg-red-500"
}`}
style={{ width: `${successRate}%` }}
/>
</div>
</div>
<span className="text-sm font-medium w-12 text-right">
{successRate.toFixed(0)}%
</span>
</div>
</div>
);
}
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();
}
-226
View File
@@ -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<string, ProviderTokenStats>;
byDay: PeriodTokenStats[];
}
export function TokenStats({ summary, byProvider, byDay }: TokenStatsProps) {
const getProviderName = (id: string) => {
const names: Record<string, string> = {
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 (
<div className="space-y-6">
{/* Token 总览 */}
<div className="grid grid-cols-4 gap-4">
<TokenCard
icon={Coins}
label="总 Token"
value={formatNumber(summary.total_tokens)}
subValue={`${summary.record_count} 条记录`}
/>
<TokenCard
icon={TrendingUp}
label="输入 Token"
value={formatNumber(summary.total_input_tokens)}
subValue={`平均 ${Math.round(summary.avg_input_tokens)}/请求`}
/>
<TokenCard
icon={TrendingUp}
label="输出 Token"
value={formatNumber(summary.total_output_tokens)}
subValue={`平均 ${Math.round(summary.avg_output_tokens)}/请求`}
/>
<TokenCard
icon={BarChart3}
label="数据来源"
value={`${summary.actual_count}/${summary.record_count}`}
subValue={`实际值 / 估算值 ${summary.estimated_count}`}
/>
</div>
{/* 按 Provider 统计 */}
{Object.keys(byProvider).length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<Coins className="h-4 w-4" />按 Provider Token 使用
</h3>
<div className="space-y-3">
{Object.entries(byProvider).map(([provider, stats]) => (
<ProviderTokenRow
key={provider}
name={getProviderName(provider)}
stats={stats}
totalTokens={summary.total_tokens}
/>
))}
</div>
</div>
)}
{/* 每日趋势 */}
{byDay.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-4 font-semibold flex items-center gap-2">
<BarChart3 className="h-4 w-4" />
每日 Token 使用趋势
</h3>
<div className="flex items-end gap-1 h-32">
{byDay
.slice()
.reverse()
.map((day, index) => (
<DayBar
key={index}
date={formatDate(day.period_start)}
tokens={day.total_tokens}
maxTokens={maxDayTokens}
/>
))}
</div>
</div>
)}
</div>
);
}
function TokenCard({
icon: Icon,
label,
value,
subValue,
}: {
icon: React.ElementType;
label: string;
value: string;
subValue?: string;
}) {
return (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{label}</span>
</div>
<div className="mt-2 text-2xl font-bold">{value}</div>
{subValue && (
<div className="mt-1 text-xs text-muted-foreground">{subValue}</div>
)}
</div>
);
}
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 (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-3">
<div className="flex items-center gap-3">
<div className="font-medium">{name}</div>
<div className="text-sm text-muted-foreground">
{stats.record_count} 条记录
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">输入:</span>{" "}
{formatNumber(stats.total_input_tokens)}
</div>
<div className="text-sm">
<span className="text-muted-foreground">输出:</span>{" "}
{formatNumber(stats.total_output_tokens)}
</div>
<div className="w-24">
<div className="h-2 rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
<span className="text-sm font-medium w-16 text-right">
{formatNumber(stats.total_tokens)}
</span>
</div>
</div>
);
}
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 (
<div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full flex items-end justify-center h-24">
<div
className="w-full max-w-8 bg-primary/80 rounded-t hover:bg-primary transition-colors"
style={{ height: `${Math.max(height, 2)}%` }}
title={`${formatNumber(tokens)} tokens`}
/>
</div>
<span className="text-xs text-muted-foreground">{date}</span>
</div>
);
}
-4
View File
@@ -1,4 +0,0 @@
export { MonitoringPage } from "./MonitoringPage";
export { StatsOverview } from "./StatsOverview";
export { LogViewer } from "./LogViewer";
export { TokenStats } from "./TokenStats";
@@ -355,6 +355,19 @@ export function AddCredentialModal({
);
}
// Claude OAuth Cookie 模式
if (providerType === "claude_oauth" && claudeOAuthForm.mode === "cookie") {
return (
<button
onClick={claudeOAuthForm.handleCookieSubmit}
disabled={loading}
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "授权中..." : "Cookie 授权"}
</button>
);
}
// Claude OAuth 登录模式
if (providerType === "claude_oauth" && claudeOAuthForm.mode === "login") {
if (!claudeOAuthForm.authUrl) {
@@ -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<AuthMode>("cookie");
const [authUrl, setAuthUrl] = useState<string | null>(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 = () => (
<div className="flex gap-2">
<button
type="button"
onClick={() => setMode("cookie")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "cookie"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<Cookie className="h-4 w-4" />
Cookie 授权
</button>
<button
type="button"
onClick={() => setMode("login")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "login"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<Key className="h-4 w-4" />
OAuth 登录
</button>
<button
type="button"
onClick={() => setMode("file")}
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border px-3 py-2 text-sm transition-colors ${
mode === "file"
? "border-amber-500 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
: "hover:bg-muted"
}`}
>
<FileJson className="h-4 w-4" />
导入文件
</button>
</div>
);
// Cookie 授权表单
const renderCookieForm = () => (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
使用浏览器 Cookie 中的 sessionKey 自动完成 OAuth
授权,无需手动复制授权码。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
获取方式:在 claude.ai 登录后,打开开发者工具 → Application → Cookies
→ 复制 sessionKey 的值
</p>
</div>
<div>
<label className="mb-1 block text-sm font-medium">
sessionKey <span className="text-red-500">*</span>
</label>
<textarea
value={sessionKey}
onChange={(e) => setSessionKey(e.target.value)}
placeholder="粘贴从浏览器 Cookie 中获取的 sessionKey..."
className="w-full rounded-lg border bg-background px-3 py-2 text-sm font-mono"
rows={3}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isSetupToken"
checked={isSetupToken}
onChange={(e) => setIsSetupToken(e.target.checked)}
className="h-4 w-4 rounded border-gray-300"
/>
<label htmlFor="isSetupToken" className="text-sm text-muted-foreground">
Setup Token 模式(只需推理权限,无 refresh_token)
</label>
</div>
</div>
);
// OAuth 登录表单
const renderLoginForm = () => (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
Claude 登录。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
授权成功后,从页面复制授权码粘贴回应用。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="amber"
/>
</div>
);
return {
mode,
authUrl,
waitingForCallback,
handleGetAuthUrl,
handleFileSubmit,
handleCookieSubmit,
render: () => (
<>
<ModeSelector
mode={mode}
setMode={setMode}
loginLabel="Claude 登录"
fileLabel="导入文件"
/>
{renderModeSelector()}
{mode === "login" ? (
<div className="space-y-4">
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800 dark:bg-amber-950/30">
<p className="text-sm text-amber-700 dark:text-amber-300">
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
Claude 登录。
</p>
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
授权成功后,凭证将自动保存并添加到凭证池。
</p>
</div>
<OAuthUrlDisplay
authUrl={authUrl}
waitingForCallback={waitingForCallback}
colorScheme="amber"
<div className="mt-4">
{mode === "cookie" && renderCookieForm()}
{mode === "login" && renderLoginForm()}
{mode === "file" && (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 oauth.json 或 oauth_creds.json..."
hint="默认路径: ~/.claude/oauth.json 或 Claude CLI 的凭证文件"
/>
</div>
) : (
<FileImportForm
credsFilePath={credsFilePath}
setCredsFilePath={setCredsFilePath}
onSelectFile={onSelectFile}
placeholder="选择 oauth.json 或 oauth_creds.json..."
hint="默认路径: ~/.claude/oauth.json 或 Claude CLI 的凭证文件"
/>
)}
)}
</div>
</>
),
};
@@ -0,0 +1,58 @@
import { useState } from "react";
import { Plug, MessageSquare, Boxes, Puzzle } from "lucide-react";
import { cn } from "@/lib/utils";
import { McpPage } from "../mcp/McpPage";
import { PromptsPage } from "../prompts/PromptsPage";
import { SkillsPage } from "../skills/SkillsPage";
import { PluginManager } from "../plugins/PluginManager";
type Tab = "mcp" | "prompts" | "skills" | "plugins";
const tabs = [
{ id: "mcp" as Tab, label: "MCP", icon: Plug },
{ id: "prompts" as Tab, label: "Prompts", icon: MessageSquare },
{ id: "skills" as Tab, label: "Skills", icon: Boxes },
{ id: "plugins" as Tab, label: "Plugins", icon: Puzzle },
];
export function ExtensionsSettings() {
const [activeTab, setActiveTab] = useState<Tab>("mcp");
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">扩展管理</h3>
<p className="text-muted-foreground">
管理 MCP 服务器、Prompts 和 Skills
</p>
</div>
{/* Tab 切换 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{/* Tab 内容 */}
<div className="pt-2">
{activeTab === "mcp" && <McpPage hideHeader />}
{activeTab === "prompts" && <PromptsPage hideHeader />}
{activeTab === "skills" && <SkillsPage hideHeader />}
{activeTab === "plugins" && <PluginManager />}
</div>
</div>
);
}
@@ -0,0 +1,76 @@
import { useState } from "react";
import { Route, Shield, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import { RoutingPage } from "../routing/RoutingPage";
import { ResiliencePage } from "../resilience/ResiliencePage";
type Tab = "routing" | "resilience";
const tabs = [
{ id: "routing" as Tab, label: "智能路由", icon: Route },
{ id: "resilience" as Tab, label: "容错配置", icon: Shield },
];
export function RoutingSettings() {
const [activeTab, setActiveTab] = useState<Tab>("routing");
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold">路由管理</h3>
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-800 text-xs rounded-md">
<AlertTriangle className="h-3 w-3" />
实验功能
</div>
</div>
<p className="text-muted-foreground">
配置智能路由规则和容错策略(实验性功能,可能不稳定)
</p>
</div>
{/* Tab 切换 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
{/* Tab 内容 */}
<div className="pt-2">
{activeTab === "routing" && <RoutingPageContent />}
{activeTab === "resilience" && <ResiliencePageContent />}
</div>
</div>
);
}
// 路由页面内容(去掉标题)
function RoutingPageContent() {
return (
<div className="routing-content">
<RoutingPage hideHeader />
</div>
);
}
// 容错页面内容(去掉标题)
function ResiliencePageContent() {
return (
<div className="resilience-content">
<ResiliencePage hideHeader />
</div>
);
}
+14 -1
View File
@@ -7,14 +7,25 @@ import { AboutSection } from "./AboutSection";
import { TlsSettings } from "./TlsSettings";
import { QuotaSettings } from "./QuotaSettings";
import { RemoteManagementSettings } from "./RemoteManagementSettings";
import { ExtensionsSettings } from "./ExtensionsSettings";
import { RoutingSettings } from "./RoutingSettings";
type SettingsTab = "general" | "proxy" | "security" | "advanced" | "about";
type SettingsTab =
| "general"
| "proxy"
| "security"
| "advanced"
| "extensions"
| "routing"
| "about";
const tabs: { id: SettingsTab; label: string }[] = [
{ id: "general", label: "通用" },
{ id: "proxy", label: "代理服务" },
{ id: "security", label: "安全" },
{ id: "advanced", label: "高级" },
{ id: "extensions", label: "扩展" },
{ id: "routing", label: "路由管理 (实验)" },
{ id: "about", label: "关于" },
];
@@ -65,6 +76,8 @@ export function SettingsPage() {
<QuotaSettings />
</div>
)}
{activeTab === "extensions" && <ExtensionsSettings />}
{activeTab === "routing" && <RoutingSettings />}
{activeTab === "about" && <AboutSection />}
</div>
</div>
+17
View File
@@ -0,0 +1,17 @@
import { invoke } from "@tauri-apps/api/core";
interface AutoFixResult {
issues_found: string[];
fixes_applied: string[];
warnings: string[];
}
export const useAutoFix = () => {
const runAutoFix = async (): Promise<AutoFixResult> => {
return await invoke("auto_fix_configuration");
};
return {
runAutoFix,
};
};
+31 -1
View File
@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import { toast } from "sonner";
import { switchApi, Provider, AppType } from "@/lib/api/switch";
import {
switchApi,
Provider,
AppType,
SyncCheckResult,
} from "@/lib/api/switch";
export function useSwitch(appType: AppType) {
const [providers, setProviders] = useState<Provider[]>([]);
@@ -62,6 +67,29 @@ export function useSwitch(appType: AppType) {
toast.success("切换成功");
};
const checkConfigSync = async (): Promise<SyncCheckResult> => {
try {
const result = await switchApi.checkConfigSync(appType);
return result;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error("检查同步状态失败: " + message);
throw e;
}
};
const syncFromExternal = async (): Promise<void> => {
try {
const message = await switchApi.syncFromExternal(appType);
await fetchProviders(); // 刷新数据
toast.success(message);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error("同步失败: " + message);
throw e;
}
};
return {
providers,
currentProvider,
@@ -72,5 +100,7 @@ export function useSwitch(appType: AppType) {
deleteProvider,
switchToProvider,
refresh: fetchProviders,
checkConfigSync,
syncFromExternal,
};
}
+14
View File
@@ -420,6 +420,20 @@ export const providerPoolApi = {
return invoke("get_claude_oauth_auth_url_and_wait", { name });
},
// Claude Cookie 自动授权(使用 sessionKey 自动完成 OAuth 流程)
// 这是一个更便捷的授权方式,无需手动复制授权码
async claudeOAuthWithCookie(
sessionKey: string,
isSetupToken?: boolean,
name?: string,
): Promise<ProviderCredential> {
return invoke("claude_oauth_with_cookie", {
sessionKey,
isSetupToken,
name,
});
},
// Qwen Device Code Flow 登录(打开浏览器授权)
async startQwenDeviceCodeLogin(name?: string): Promise<ProviderCredential> {
return invoke("start_qwen_device_code_login", { name });
+27
View File
@@ -17,6 +17,25 @@ export interface Provider {
// proxycast 保留用于内部配置存储,但不在 UI 的 Tab 中显示
export type AppType = "claude" | "codex" | "gemini" | "proxycast";
// 同步状态枚举
export type SyncStatus = "InSync" | "OutOfSync" | "Conflict";
// 配置冲突信息
export interface ConfigConflict {
field: string;
local_value: string;
external_value: string;
}
// 同步检查结果
export interface SyncCheckResult {
status: SyncStatus;
current_provider: string;
external_provider: string;
last_modified?: string;
conflicts: ConfigConflict[];
}
export const switchApi = {
getProviders: (appType: AppType): Promise<Provider[]> =>
invoke("get_switch_providers", { appType }),
@@ -39,4 +58,12 @@ export const switchApi = {
/** 读取当前生效的配置(从实际配置文件读取) */
readLiveSettings: (appType: AppType): Promise<Record<string, unknown>> =>
invoke("read_live_provider_settings", { appType }),
/** 检查配置同步状态 */
checkConfigSync: (appType: AppType): Promise<SyncCheckResult> =>
invoke("check_config_sync_status", { appType }),
/** 从外部配置同步到 ProxyCast */
syncFromExternal: (appType: AppType): Promise<string> =>
invoke("sync_from_external_config", { appType }),
};
-13
View File
@@ -124,13 +124,6 @@ export interface TimeRangeParam {
preset?: "1h" | "24h" | "7d" | "30d";
}
export interface DashboardData {
stats: StatsSummary;
tokens: TokenStatsSummary;
by_provider: Record<string, ProviderStats>;
recent_logs: RequestLog[];
}
// ========== 请求日志 API ==========
export async function getRequestLogs(params?: {
@@ -197,9 +190,3 @@ export async function getTokenStatsByDay(
): Promise<PeriodTokenStats[]> {
return invoke("get_token_stats_by_day", { days });
}
// ========== 仪表盘 API ==========
export async function getDashboardData(): Promise<DashboardData> {
return invoke("get_dashboard_data");
}