mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
Merge branch 'aiclientproxy:main' into main
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.17.4",
|
||||
"version": "0.17.6",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+1
-1
@@ -3377,7 +3377,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.17.4"
|
||||
version = "0.17.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(¤t_default, &credential_stats) {
|
||||
result
|
||||
.issues_found
|
||||
.push(format!("默认Provider '{}' 没有可用凭证", current_default));
|
||||
|
||||
// 寻找最佳替代Provider
|
||||
if let Some(best_provider) = find_best_available_provider(&credential_stats) {
|
||||
if let Err(e) = set_default_provider_internal(state, logs, best_provider.clone()).await
|
||||
{
|
||||
result
|
||||
.warnings
|
||||
.push(format!("无法自动修复默认Provider: {}", e));
|
||||
} else {
|
||||
result
|
||||
.fixes_applied
|
||||
.push(format!("默认Provider已自动设置为 '{}'", best_provider));
|
||||
logs.write().await.add(
|
||||
"info",
|
||||
&format!("[自动修复] 默认Provider已设置为{}", best_provider),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result
|
||||
.warnings
|
||||
.push("没有找到可用的Provider作为默认选择".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取凭证统计信息
|
||||
#[derive(Debug, Default)]
|
||||
struct CredentialStats {
|
||||
kiro_count: usize,
|
||||
gemini_count: usize,
|
||||
qwen_count: usize,
|
||||
openai_count: usize,
|
||||
claude_count: usize,
|
||||
total_count: usize,
|
||||
}
|
||||
|
||||
async fn get_credential_stats(db: &State<'_, DbConnection>) -> Result<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,3 +1,4 @@
|
||||
pub mod auto_fix_cmd;
|
||||
pub mod config_cmd;
|
||||
pub mod flow_monitor_cmd;
|
||||
pub mod injection_cmd;
|
||||
|
||||
@@ -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(¶ms.auth_url) {
|
||||
tracing::warn!("[Claude OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e);
|
||||
}
|
||||
|
||||
Ok(ClaudeOAuthParamsResponse {
|
||||
auth_url: params.auth_url,
|
||||
code_verifier: params.code_verifier,
|
||||
state: params.state,
|
||||
})
|
||||
}
|
||||
|
||||
/// 使用授权码交换 Claude OAuth Token
|
||||
///
|
||||
/// 用户在浏览器中授权后,复制授权码,调用此命令交换 token
|
||||
#[tauri::command]
|
||||
pub async fn exchange_claude_oauth_code(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
authorization_code: String,
|
||||
code_verifier: String,
|
||||
state: String,
|
||||
name: Option<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)
|
||||
}
|
||||
|
||||
@@ -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, ¤t_provider)
|
||||
.map_err(|e| format!("Failed to check config sync: {}", e))
|
||||
}
|
||||
|
||||
/// 从外部配置同步到 ProxyCast
|
||||
#[tauri::command]
|
||||
pub fn sync_from_external_config(
|
||||
db: State<'_, DbConnection>,
|
||||
app_type: String,
|
||||
) -> Result<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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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(¶ms.auth_url) {
|
||||
tracing::warn!(
|
||||
"[CLAUDE_OAUTH] 无法打开浏览器: {}. 请手动打开 URL: {}",
|
||||
e,
|
||||
params.auth_url
|
||||
);
|
||||
}
|
||||
|
||||
// 等待回调
|
||||
wait_future.await
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cookie 自动授权功能(参考 claude-relay-service 实现)
|
||||
// ============================================================================
|
||||
|
||||
/// Cookie 自动授权配置
|
||||
const CLAUDE_AI_URL: &str = "https://claude.ai";
|
||||
const CLAUDE_ORGANIZATIONS_URL: &str = "https://claude.ai/api/organizations";
|
||||
|
||||
/// 组织信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OrganizationInfo {
|
||||
pub uuid: String,
|
||||
pub capabilities: Vec<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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,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
@@ -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 />;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -130,7 +130,7 @@ export function LogsTab() {
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground">
|
||||
暂无日志,启动服务后将显示请求日志
|
||||
暂无日志,软件运行时将显示系统日志
|
||||
</p>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user