mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
v0.15.0: Gemini OAuth 授权码流程 + 健康检查自动刷新 Token
主要更新: - 修复 Gemini OAuth 登录流程,改用授权码模式(用户手动复制授权码) - 修复 Gemini 健康检查 API 端点(使用 cloudcode-pa.googleapis.com) - 健康检查遇到 401 错误时自动尝试刷新 Token 后重试 - 添加 once_cell 依赖用于存储 OAuth 会话 - 新增 exchange_gemini_code 命令用于交换授权码
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.14.10",
|
||||
"version": "0.15.0",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+2
-1
@@ -3367,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.14.10"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -3382,6 +3382,7 @@ dependencies = [
|
||||
"indexmap 2.12.1",
|
||||
"md5",
|
||||
"notify",
|
||||
"once_cell",
|
||||
"open",
|
||||
"parking_lot",
|
||||
"proptest",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.14.10"
|
||||
version = "0.15.0"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -55,6 +55,7 @@ sha2 = "0.10"
|
||||
serde_urlencoded = "0.7"
|
||||
open = "5"
|
||||
url = "2"
|
||||
once_cell = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
|
||||
@@ -1250,6 +1250,414 @@ pub async fn start_antigravity_oauth_login(
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// Codex OAuth 授权 URL 响应
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct CodexAuthUrlResponse {
|
||||
pub auth_url: String,
|
||||
}
|
||||
|
||||
/// 获取 Codex OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
///
|
||||
/// 启动服务器后通过事件发送授权 URL,然后等待回调
|
||||
/// 成功后返回凭证
|
||||
#[tauri::command]
|
||||
pub async fn get_codex_auth_url_and_wait(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::codex;
|
||||
|
||||
tracing::info!("[Codex OAuth] 启动服务器并获取授权 URL");
|
||||
|
||||
// 启动服务器并获取授权 URL
|
||||
let (auth_url, wait_future) = codex::start_codex_oauth_server_and_get_url()
|
||||
.await
|
||||
.map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?;
|
||||
|
||||
tracing::info!("[Codex OAuth] 授权 URL: {}", auth_url);
|
||||
|
||||
// 通过事件发送授权 URL 给前端
|
||||
let _ = app.emit(
|
||||
"codex-auth-url",
|
||||
CodexAuthUrlResponse {
|
||||
auth_url: auth_url.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// 等待回调
|
||||
let result = wait_future.await.map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
"[Codex OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"codex",
|
||||
CredentialData::CodexOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Codex OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// 启动 Codex OAuth 登录流程
|
||||
///
|
||||
/// 打开浏览器让用户登录 OpenAI 账号,获取 Codex 凭证
|
||||
#[tauri::command]
|
||||
pub async fn start_codex_oauth_login(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::codex;
|
||||
|
||||
tracing::info!("[Codex OAuth] 开始 OAuth 登录流程");
|
||||
|
||||
// 启动 OAuth 登录
|
||||
let result = codex::start_codex_oauth_login()
|
||||
.await
|
||||
.map_err(|e| format!("Codex OAuth 登录失败: {}", e))?;
|
||||
|
||||
tracing::info!(
|
||||
"[Codex OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"codex",
|
||||
CredentialData::CodexOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Codex OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// Claude OAuth 授权 URL 响应
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ClaudeOAuthAuthUrlResponse {
|
||||
pub auth_url: String,
|
||||
}
|
||||
|
||||
/// 获取 Claude OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
///
|
||||
/// 启动服务器后通过事件发送授权 URL,然后等待回调
|
||||
/// 成功后返回凭证
|
||||
#[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> {
|
||||
use crate::providers::claude_oauth;
|
||||
|
||||
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))?;
|
||||
|
||||
tracing::info!("[Claude OAuth] 授权 URL: {}", auth_url);
|
||||
|
||||
// 通过事件发送授权 URL 给前端
|
||||
let _ = app.emit(
|
||||
"claude-oauth-auth-url",
|
||||
ClaudeOAuthAuthUrlResponse {
|
||||
auth_url: auth_url.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// 等待回调
|
||||
let result = wait_future.await.map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
"[Claude OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"claude_oauth",
|
||||
CredentialData::ClaudeOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Claude OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// 启动 Claude OAuth 登录流程
|
||||
///
|
||||
/// 打开浏览器让用户登录 Claude 账号,获取凭证
|
||||
#[tauri::command]
|
||||
pub async fn start_claude_oauth_login(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::claude_oauth;
|
||||
|
||||
tracing::info!("[Claude OAuth] 开始 OAuth 登录流程");
|
||||
|
||||
// 启动 OAuth 登录
|
||||
let result = claude_oauth::start_claude_oauth_login()
|
||||
.await
|
||||
.map_err(|e| format!("Claude OAuth 登录失败: {}", e))?;
|
||||
|
||||
tracing::info!(
|
||||
"[Claude OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"claude_oauth",
|
||||
CredentialData::ClaudeOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Claude OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// Qwen Device Code 响应
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct QwenDeviceCodeResponse {
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub verification_uri_complete: Option<String>,
|
||||
pub expires_in: i64,
|
||||
}
|
||||
|
||||
/// 获取 Qwen Device Code 并等待用户授权
|
||||
///
|
||||
/// 启动 Device Code Flow 后通过事件发送设备码信息,然后轮询等待授权
|
||||
/// 成功后返回凭证
|
||||
#[tauri::command]
|
||||
pub async fn get_qwen_device_code_and_wait(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::qwen;
|
||||
|
||||
tracing::info!("[Qwen] 启动 Device Code Flow");
|
||||
|
||||
// 启动 Device Code Flow 并获取设备码信息
|
||||
let (device_response, wait_future) = qwen::start_qwen_device_code_and_get_info()
|
||||
.await
|
||||
.map_err(|e| format!("启动 Device Code Flow 失败: {}", e))?;
|
||||
|
||||
tracing::info!(
|
||||
"[Qwen] Device Code: user_code={}, verification_uri={}",
|
||||
device_response.user_code,
|
||||
device_response.verification_uri
|
||||
);
|
||||
|
||||
// 通过事件发送设备码信息给前端
|
||||
let _ = app.emit(
|
||||
"qwen-device-code",
|
||||
QwenDeviceCodeResponse {
|
||||
user_code: device_response.user_code.clone(),
|
||||
verification_uri: device_response.verification_uri.clone(),
|
||||
verification_uri_complete: device_response.verification_uri_complete.clone(),
|
||||
expires_in: device_response.expires_in,
|
||||
},
|
||||
);
|
||||
|
||||
// 等待用户授权
|
||||
let result = wait_future.await.map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!("[Qwen] 登录成功,凭证保存到: {}", result.creds_file_path);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"qwen",
|
||||
CredentialData::QwenOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Qwen] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// 启动 Qwen Device Code Flow 登录流程
|
||||
///
|
||||
/// 自动打开浏览器让用户完成授权
|
||||
#[tauri::command]
|
||||
pub async fn start_qwen_device_code_login(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::qwen;
|
||||
|
||||
tracing::info!("[Qwen] 开始 Device Code Flow 登录流程");
|
||||
|
||||
// 启动 Device Code Flow 登录
|
||||
let result = qwen::start_qwen_device_code_login()
|
||||
.await
|
||||
.map_err(|e| format!("Qwen Device Code Flow 登录失败: {}", e))?;
|
||||
|
||||
tracing::info!("[Qwen] 登录成功,凭证保存到: {}", result.creds_file_path);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"qwen",
|
||||
CredentialData::QwenOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Qwen] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// iFlow OAuth 授权 URL 响应
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IFlowAuthUrlResponse {
|
||||
pub auth_url: String,
|
||||
}
|
||||
|
||||
/// 获取 iFlow OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
///
|
||||
/// 启动服务器后通过事件发送授权 URL,然后等待回调
|
||||
/// 成功后返回凭证
|
||||
#[tauri::command]
|
||||
pub async fn get_iflow_auth_url_and_wait(
|
||||
app: tauri::AppHandle,
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::iflow;
|
||||
|
||||
tracing::info!("[iFlow OAuth] 启动服务器并获取授权 URL");
|
||||
|
||||
// 启动服务器并获取授权 URL
|
||||
let (auth_url, wait_future) = iflow::start_iflow_oauth_server_and_get_url()
|
||||
.await
|
||||
.map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?;
|
||||
|
||||
tracing::info!("[iFlow OAuth] 授权 URL: {}", auth_url);
|
||||
|
||||
// 通过事件发送授权 URL 给前端
|
||||
let _ = app.emit(
|
||||
"iflow-auth-url",
|
||||
IFlowAuthUrlResponse {
|
||||
auth_url: auth_url.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// 等待回调
|
||||
let result = wait_future.await.map_err(|e| e.to_string())?;
|
||||
|
||||
tracing::info!(
|
||||
"[iFlow OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"iflow",
|
||||
CredentialData::IFlowOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[iFlow OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// 启动 iFlow OAuth 登录流程
|
||||
///
|
||||
/// 打开浏览器让用户登录 iFlow 账号,获取凭证
|
||||
#[tauri::command]
|
||||
pub async fn start_iflow_oauth_login(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::iflow;
|
||||
|
||||
tracing::info!("[iFlow OAuth] 开始 OAuth 登录流程");
|
||||
|
||||
// 启动 OAuth 登录
|
||||
let result = iflow::start_iflow_oauth_login()
|
||||
.await
|
||||
.map_err(|e| format!("iFlow OAuth 登录失败: {}", e))?;
|
||||
|
||||
tracing::info!(
|
||||
"[iFlow OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"iflow",
|
||||
CredentialData::IFlowOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[iFlow OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// 获取 Kiro 凭证的 Machine ID 指纹信息
|
||||
///
|
||||
/// 返回凭证的唯一设备指纹,用于在 UI 中展示
|
||||
@@ -1329,3 +1737,170 @@ pub async fn get_kiro_credential_fingerprint(
|
||||
auth_method,
|
||||
})
|
||||
}
|
||||
|
||||
/// Gemini OAuth 授权 URL 响应
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GeminiAuthUrlResponse {
|
||||
pub auth_url: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
/// Gemini OAuth 会话存储(用于存储 code_verifier)
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
static GEMINI_OAUTH_SESSIONS: Lazy<
|
||||
RwLock<HashMap<String, crate::providers::gemini::GeminiOAuthSession>>,
|
||||
> = Lazy::new(|| RwLock::new(HashMap::new()));
|
||||
|
||||
/// 获取 Gemini OAuth 授权 URL(不等待回调)
|
||||
///
|
||||
/// 生成授权 URL 和 session_id,通过事件发送给前端
|
||||
/// 用户需要手动复制授权码回来,然后调用 exchange_gemini_code
|
||||
#[tauri::command]
|
||||
pub async fn get_gemini_auth_url_and_wait(
|
||||
app: tauri::AppHandle,
|
||||
_db: State<'_, DbConnection>,
|
||||
_pool_service: State<'_, ProviderPoolServiceState>,
|
||||
_name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::gemini;
|
||||
|
||||
tracing::info!("[Gemini OAuth] 生成授权 URL");
|
||||
|
||||
// 生成授权 URL 和会话信息
|
||||
let (auth_url, session) = gemini::generate_gemini_auth_url_with_session();
|
||||
let session_id = session.session_id.clone();
|
||||
|
||||
tracing::info!("[Gemini OAuth] 授权 URL: {}", auth_url);
|
||||
tracing::info!("[Gemini OAuth] Session ID: {}", session_id);
|
||||
|
||||
// 存储会话信息(用于后续交换 token)
|
||||
{
|
||||
let mut sessions = GEMINI_OAUTH_SESSIONS.write().await;
|
||||
sessions.insert(session_id.clone(), session);
|
||||
|
||||
// 清理过期的会话(超过 10 分钟)
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
sessions.retain(|_, s| now - s.created_at < 600);
|
||||
}
|
||||
|
||||
// 通过事件发送授权 URL 给前端
|
||||
let _ = app.emit(
|
||||
"gemini-auth-url",
|
||||
GeminiAuthUrlResponse {
|
||||
auth_url: auth_url.clone(),
|
||||
session_id: session_id.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
// 返回错误,让前端知道需要用户手动输入授权码
|
||||
// 这不是真正的错误,只是流程需要用户交互
|
||||
Err(format!("AUTH_URL:{}", auth_url))
|
||||
}
|
||||
|
||||
/// 用 Gemini 授权码交换 Token 并添加凭证
|
||||
#[tauri::command]
|
||||
pub async fn exchange_gemini_code(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
code: String,
|
||||
session_id: Option<String>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::gemini;
|
||||
|
||||
tracing::info!("[Gemini OAuth] 开始交换授权码");
|
||||
|
||||
// 获取 code_verifier
|
||||
let code_verifier = if let Some(ref sid) = session_id {
|
||||
let sessions = GEMINI_OAUTH_SESSIONS.read().await;
|
||||
sessions
|
||||
.get(sid)
|
||||
.map(|s| s.code_verifier.clone())
|
||||
.ok_or_else(|| "会话已过期,请重新获取授权 URL".to_string())?
|
||||
} else {
|
||||
// 如果没有 session_id,尝试使用最近的会话
|
||||
let sessions = GEMINI_OAUTH_SESSIONS.read().await;
|
||||
sessions
|
||||
.values()
|
||||
.max_by_key(|s| s.created_at)
|
||||
.map(|s| s.code_verifier.clone())
|
||||
.ok_or_else(|| "没有可用的会话,请先获取授权 URL".to_string())?
|
||||
};
|
||||
|
||||
// 交换 token 并创建凭证
|
||||
let result = gemini::exchange_gemini_code_and_create_credentials(&code, &code_verifier)
|
||||
.await
|
||||
.map_err(|e| format!("交换授权码失败: {}", e))?;
|
||||
|
||||
tracing::info!(
|
||||
"[Gemini OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 清理使用过的会话
|
||||
if let Some(ref sid) = session_id {
|
||||
let mut sessions = GEMINI_OAUTH_SESSIONS.write().await;
|
||||
sessions.remove(sid);
|
||||
}
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"gemini",
|
||||
CredentialData::GeminiOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
project_id: None, // 项目 ID 会在健康检查时自动获取
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Gemini OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
/// 启动 Gemini OAuth 登录流程
|
||||
///
|
||||
/// 打开浏览器让用户登录 Google 账号,获取 Gemini 凭证
|
||||
#[tauri::command]
|
||||
pub async fn start_gemini_oauth_login(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
use crate::providers::gemini;
|
||||
|
||||
tracing::info!("[Gemini OAuth] 开始 OAuth 登录流程");
|
||||
|
||||
// 启动 OAuth 登录
|
||||
let result = gemini::start_gemini_oauth_login()
|
||||
.await
|
||||
.map_err(|e| format!("Gemini OAuth 登录失败: {}", e))?;
|
||||
|
||||
tracing::info!(
|
||||
"[Gemini OAuth] 登录成功,凭证保存到: {}",
|
||||
result.creds_file_path
|
||||
);
|
||||
|
||||
// 添加到凭证池
|
||||
let credential = pool_service.0.add_credential(
|
||||
&db,
|
||||
"gemini",
|
||||
CredentialData::GeminiOAuth {
|
||||
creds_file_path: result.creds_file_path,
|
||||
project_id: None,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)?;
|
||||
|
||||
tracing::info!("[Gemini OAuth] 凭证已添加到凭证池: {}", credential.uuid);
|
||||
|
||||
Ok(credential)
|
||||
}
|
||||
|
||||
@@ -1683,6 +1683,17 @@ pub fn run() {
|
||||
commands::provider_pool_cmd::migrate_private_config_to_pool,
|
||||
commands::provider_pool_cmd::start_antigravity_oauth_login,
|
||||
commands::provider_pool_cmd::get_antigravity_auth_url_and_wait,
|
||||
commands::provider_pool_cmd::get_codex_auth_url_and_wait,
|
||||
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::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,
|
||||
commands::provider_pool_cmd::start_iflow_oauth_login,
|
||||
commands::provider_pool_cmd::get_gemini_auth_url_and_wait,
|
||||
commands::provider_pool_cmd::start_gemini_oauth_login,
|
||||
commands::provider_pool_cmd::exchange_gemini_code,
|
||||
commands::provider_pool_cmd::get_kiro_credential_fingerprint,
|
||||
// Route commands
|
||||
commands::route_cmd::get_available_routes,
|
||||
|
||||
@@ -347,3 +347,366 @@ impl ClaudeOAuthProvider {
|
||||
format!("http://localhost:{}/callback", self.callback_port)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth 登录功能
|
||||
// ============================================================================
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// OAuth 登录成功后的凭证信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClaudeOAuthResult {
|
||||
pub credentials: ClaudeOAuthCredentials,
|
||||
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);
|
||||
|
||||
let params = [
|
||||
("client_id", CLAUDE_CLIENT_ID),
|
||||
("response_type", "code"),
|
||||
("redirect_uri", redirect_uri.as_str()),
|
||||
("scope", "user:inference user:profile"),
|
||||
("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
|
||||
pub async fn exchange_claude_code_for_token(
|
||||
client: &Client,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
|
||||
let body = serde_json::json!({
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": CLAUDE_CLIENT_ID,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": code_verifier
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post(CLAUDE_TOKEN_URL)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Token 交换失败: {} - {}", status, body).into());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
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 失败页面 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(不打开浏览器)
|
||||
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 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();
|
||||
|
||||
// 生成随机 state
|
||||
let state = Uuid::new_v4().to_string();
|
||||
let state_clone = state.clone();
|
||||
|
||||
// 创建 channel 用于接收回调结果
|
||||
let (tx, rx) = oneshot::channel::<Result<ClaudeOAuthResult, String>>();
|
||||
let tx = Arc::new(tokio::sync::Mutex::new(Some(tx)));
|
||||
|
||||
// 绑定到随机端口
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
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::new(std::io::ErrorKind::Other, e))
|
||||
as Box<dyn Error + Send + Sync>
|
||||
}),
|
||||
Err(_) => Err("OAuth 回调通道关闭".into()),
|
||||
}
|
||||
});
|
||||
|
||||
match timeout.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("OAuth 登录超时(5分钟)".into()),
|
||||
}
|
||||
};
|
||||
|
||||
Ok((auth_url, wait_future))
|
||||
}
|
||||
|
||||
/// 启动 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?;
|
||||
|
||||
tracing::info!("[Claude OAuth] 打开浏览器进行授权: {}", auth_url);
|
||||
|
||||
// 打开浏览器
|
||||
if let Err(e) = open::that(&auth_url) {
|
||||
tracing::warn!("[Claude OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e);
|
||||
}
|
||||
|
||||
// 等待回调
|
||||
wait_future.await
|
||||
}
|
||||
|
||||
@@ -1441,3 +1441,402 @@ mod tests {
|
||||
assert_eq!(result["top_p"], 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth 登录功能(参考 Antigravity 实现)
|
||||
// ============================================================================
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// OAuth 登录成功后的凭证信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CodexOAuthResult {
|
||||
pub credentials: CodexCredentials,
|
||||
pub creds_file_path: String,
|
||||
}
|
||||
|
||||
/// OpenAI OAuth 固定回调端口(必须与 client_id 注册的回调地址一致)
|
||||
const OPENAI_OAUTH_CALLBACK_PORT: u16 = 1455;
|
||||
|
||||
/// OpenAI OAuth 固定回调路径(必须与 client_id 注册的回调地址一致)
|
||||
const OPENAI_OAUTH_CALLBACK_PATH: &str = "/auth/callback";
|
||||
|
||||
/// 生成 OAuth 授权 URL(用于外部浏览器登录)
|
||||
///
|
||||
/// 注意:OpenAI OAuth 要求 redirect_uri 必须是预先注册的固定地址
|
||||
/// Codex CLI 的 client_id 只注册了 http://localhost:1455/auth/callback
|
||||
pub fn generate_codex_auth_url(state: &str, code_challenge: &str) -> String {
|
||||
let redirect_uri = format!(
|
||||
"http://localhost:{}{}",
|
||||
OPENAI_OAUTH_CALLBACK_PORT, OPENAI_OAUTH_CALLBACK_PATH
|
||||
);
|
||||
|
||||
let params = [
|
||||
("client_id", OPENAI_CLIENT_ID),
|
||||
("response_type", "code"),
|
||||
("redirect_uri", redirect_uri.as_str()),
|
||||
("scope", "openid email profile offline_access"),
|
||||
("state", state),
|
||||
("code_challenge", code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("prompt", "login"),
|
||||
("id_token_add_organizations", "true"),
|
||||
("codex_cli_simplified_flow", "true"),
|
||||
];
|
||||
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
format!("{}?{}", OPENAI_AUTH_URL, query)
|
||||
}
|
||||
|
||||
/// 用授权码交换 Token
|
||||
pub async fn exchange_codex_code_for_token(
|
||||
client: &Client,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn Error + Send + Sync>> {
|
||||
let params = [
|
||||
("grant_type", "authorization_code"),
|
||||
("client_id", OPENAI_CLIENT_ID),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("code_verifier", code_verifier),
|
||||
];
|
||||
|
||||
let resp = client
|
||||
.post(OPENAI_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Token 交换失败: {} - {}", status, body).into());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// OAuth 成功页面 HTML
|
||||
const CODEX_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, #10a37f 0%, #1a7f64 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: #10a37f; margin-bottom: 16px; }
|
||||
p { color: #666; margin-bottom: 8px; }
|
||||
.email { color: #333; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✓ 授权成功</h1>
|
||||
<p>Codex 账号已添加到 ProxyCast</p>
|
||||
<p class="email">EMAIL_PLACEHOLDER</p>
|
||||
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
/// OAuth 失败页面 HTML
|
||||
const CODEX_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(不打开浏览器)
|
||||
/// 服务器会在后台等待回调,成功后返回凭证
|
||||
///
|
||||
/// 注意:OpenAI OAuth 要求使用固定的回调地址 http://localhost:1455/auth/callback
|
||||
pub async fn start_codex_oauth_server_and_get_url() -> Result<
|
||||
(
|
||||
String,
|
||||
impl std::future::Future<Output = Result<CodexOAuthResult, 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 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();
|
||||
|
||||
// 生成随机 state
|
||||
let state = Uuid::new_v4().to_string();
|
||||
let state_clone = state.clone();
|
||||
|
||||
// 创建 channel 用于接收回调结果
|
||||
let (tx, rx) = oneshot::channel::<Result<CodexOAuthResult, String>>();
|
||||
let tx = Arc::new(tokio::sync::Mutex::new(Some(tx)));
|
||||
|
||||
// 使用固定端口 1455(OpenAI OAuth 要求)
|
||||
let port = OPENAI_OAUTH_CALLBACK_PORT;
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::AddrInUse {
|
||||
format!(
|
||||
"端口 {} 已被占用。OpenAI OAuth 要求使用固定端口 1455,请关闭占用该端口的应用后重试。",
|
||||
port
|
||||
)
|
||||
} else {
|
||||
format!("绑定端口 {} 失败: {}", port, e)
|
||||
}
|
||||
})?;
|
||||
|
||||
let redirect_uri = format!(
|
||||
"http://localhost:{}{}",
|
||||
OPENAI_OAUTH_CALLBACK_PORT, OPENAI_OAUTH_CALLBACK_PATH
|
||||
);
|
||||
let redirect_uri_clone = redirect_uri.clone();
|
||||
|
||||
// 生成授权 URL(不再传入 port 参数)
|
||||
let auth_url = generate_codex_auth_url(&state, &code_challenge);
|
||||
|
||||
tracing::info!(
|
||||
"[Codex OAuth] 服务器启动在端口 {}, 授权 URL: {}",
|
||||
port,
|
||||
auth_url
|
||||
);
|
||||
|
||||
// 构建路由(使用固定的回调路径 /auth/callback)
|
||||
let app = Router::new().route(
|
||||
OPENAI_OAUTH_CALLBACK_PATH,
|
||||
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 = CODEX_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 =
|
||||
CODEX_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 =
|
||||
CODEX_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_codex_code_for_token(&client, code, &code_verifier, &redirect_uri)
|
||||
.await;
|
||||
let token_data = match token_result {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
let html =
|
||||
CODEX_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 id_token = token_data["id_token"].as_str().map(|s| s.to_string());
|
||||
let expires_in = token_data["expires_in"].as_i64();
|
||||
|
||||
// 解析 ID Token 获取用户信息
|
||||
let (account_id, email) = if let Some(ref id_token) = id_token {
|
||||
parse_jwt_claims(id_token)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// 构建凭证
|
||||
let now = chrono::Utc::now();
|
||||
let credentials = CodexCredentials {
|
||||
id_token,
|
||||
access_token: Some(access_token.to_string()),
|
||||
refresh_token,
|
||||
account_id,
|
||||
last_refresh: Some(now.to_rfc3339()),
|
||||
email: email.clone(),
|
||||
r#type: "codex".to_string(),
|
||||
expires_at: expires_in
|
||||
.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()),
|
||||
};
|
||||
|
||||
// 保存凭证到应用数据目录
|
||||
let creds_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("proxycast")
|
||||
.join("credentials")
|
||||
.join("codex");
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&creds_dir) {
|
||||
let html = CODEX_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!("codex_{}_{}.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 = CODEX_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 = CODEX_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!("[Codex OAuth] 凭证已保存到: {:?}", creds_file_path);
|
||||
|
||||
// 发送成功结果
|
||||
let result = CodexOAuthResult {
|
||||
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 = CODEX_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!("[Codex OAuth] 服务器错误: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 等待回调结果
|
||||
match rx.await {
|
||||
Ok(result) => result.map_err(|e| {
|
||||
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
as Box<dyn Error + Send + Sync>
|
||||
}),
|
||||
Err(_) => Err("OAuth 回调通道关闭".into()),
|
||||
}
|
||||
});
|
||||
|
||||
match timeout.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("OAuth 登录超时(5分钟)".into()),
|
||||
}
|
||||
};
|
||||
|
||||
Ok((auth_url, wait_future))
|
||||
}
|
||||
|
||||
/// 启动 Codex OAuth 登录流程(自动打开浏览器)
|
||||
pub async fn start_codex_oauth_login() -> Result<CodexOAuthResult, Box<dyn Error + Send + Sync>> {
|
||||
let (auth_url, wait_future) = start_codex_oauth_server_and_get_url().await?;
|
||||
|
||||
tracing::info!("[Codex OAuth] 打开浏览器进行授权: {}", auth_url);
|
||||
|
||||
// 打开浏览器
|
||||
if let Err(e) = open::that(&auth_url) {
|
||||
tracing::warn!("[Codex OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e);
|
||||
}
|
||||
|
||||
// 等待回调
|
||||
wait_future.await
|
||||
}
|
||||
|
||||
@@ -747,3 +747,609 @@ mod gemini_api_key_tests {
|
||||
let _ = provider;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gemini OAuth 登录功能
|
||||
// ============================================================================
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Gemini CLI OAuth 配置 - 与 claude-relay-service 对齐
|
||||
pub const GEMINI_OAUTH_CLIENT_ID: &str =
|
||||
"681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com";
|
||||
pub const GEMINI_OAUTH_CLIENT_SECRET: &str = "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";
|
||||
pub const GEMINI_OAUTH_SCOPES: &[&str] = &["https://www.googleapis.com/auth/cloud-platform"];
|
||||
pub const GEMINI_OAUTH_REDIRECT_URI: &str = "https://codeassist.google.com/authcode";
|
||||
|
||||
/// OAuth 登录成功后的凭证信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeminiOAuthResult {
|
||||
pub credentials: GeminiCredentials,
|
||||
pub creds_file_path: String,
|
||||
}
|
||||
|
||||
/// 生成 PKCE code_verifier 和 code_challenge
|
||||
fn generate_pkce() -> (String, String) {
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// 生成 43-128 字符的随机字符串作为 code_verifier
|
||||
let code_verifier: String = (0..64)
|
||||
.map(|_| {
|
||||
let idx = rand::random::<u8>() % 66;
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
|
||||
chars[idx as usize] as char
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 计算 code_challenge = BASE64URL(SHA256(code_verifier))
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code_verifier.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let code_challenge = URL_SAFE_NO_PAD.encode(hash);
|
||||
|
||||
(code_verifier, code_challenge)
|
||||
}
|
||||
|
||||
/// 生成 OAuth 授权 URL(使用 PKCE)
|
||||
pub fn generate_gemini_auth_url(state: &str, code_challenge: &str) -> String {
|
||||
let scopes = GEMINI_OAUTH_SCOPES.join(" ");
|
||||
|
||||
let params = [
|
||||
("access_type", "offline"),
|
||||
("client_id", GEMINI_OAUTH_CLIENT_ID),
|
||||
("code_challenge", code_challenge),
|
||||
("code_challenge_method", "S256"),
|
||||
("prompt", "select_account"),
|
||||
("redirect_uri", GEMINI_OAUTH_REDIRECT_URI),
|
||||
("response_type", "code"),
|
||||
("scope", &scopes),
|
||||
("state", state),
|
||||
];
|
||||
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
format!("https://accounts.google.com/o/oauth2/v2/auth?{}", query)
|
||||
}
|
||||
|
||||
/// Gemini OAuth 会话信息(用于存储 PKCE code_verifier)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeminiOAuthSession {
|
||||
pub session_id: String,
|
||||
pub code_verifier: String,
|
||||
pub state: String,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// 生成 Gemini OAuth 授权 URL 和会话信息
|
||||
///
|
||||
/// 返回 (auth_url, session) 元组
|
||||
/// - auth_url: 用户需要在浏览器中打开的授权 URL
|
||||
/// - session: 包含 code_verifier 的会话信息,用于后续交换 token
|
||||
pub fn generate_gemini_auth_url_with_session() -> (String, GeminiOAuthSession) {
|
||||
let (code_verifier, code_challenge) = generate_pkce();
|
||||
let state = Uuid::new_v4().to_string();
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
|
||||
let auth_url = generate_gemini_auth_url(&state, &code_challenge);
|
||||
|
||||
let session = GeminiOAuthSession {
|
||||
session_id,
|
||||
code_verifier,
|
||||
state,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
};
|
||||
|
||||
(auth_url, session)
|
||||
}
|
||||
|
||||
/// 用授权码交换 Token 并创建凭证
|
||||
///
|
||||
/// 完整流程:
|
||||
/// 1. 用 code + code_verifier 交换 tokens
|
||||
/// 2. 获取用户邮箱
|
||||
/// 3. 获取项目 ID
|
||||
/// 4. 保存凭证到文件
|
||||
pub async fn exchange_gemini_code_and_create_credentials(
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<GeminiOAuthResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
tracing::info!("[Gemini OAuth] 正在用授权码交换 Token...");
|
||||
|
||||
// 交换 Token
|
||||
let token_data = exchange_gemini_code_for_token(&client, code, code_verifier).await?;
|
||||
|
||||
let access_token = token_data["access_token"]
|
||||
.as_str()
|
||||
.ok_or("响应中没有 access_token")?
|
||||
.to_string();
|
||||
let refresh_token = token_data["refresh_token"].as_str().map(|s| s.to_string());
|
||||
let expires_in = token_data["expires_in"].as_i64();
|
||||
|
||||
tracing::info!("[Gemini OAuth] Token 交换成功");
|
||||
|
||||
// 获取用户邮箱
|
||||
let email = fetch_gemini_user_email(&client, &access_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
tracing::info!("[Gemini OAuth] 用户邮箱: {:?}", email);
|
||||
|
||||
// 获取项目 ID
|
||||
let _project_id = fetch_gemini_project_id(&client, &access_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 构建凭证
|
||||
let now = chrono::Utc::now();
|
||||
let expires_at = expires_in.map(|secs| now + chrono::Duration::seconds(secs));
|
||||
|
||||
let credentials = GeminiCredentials {
|
||||
access_token: Some(access_token),
|
||||
refresh_token,
|
||||
token_type: Some("Bearer".to_string()),
|
||||
expiry_date: expires_at.map(|t| t.timestamp_millis()),
|
||||
expire: expires_at.map(|t| t.to_rfc3339()),
|
||||
scope: Some(GEMINI_OAUTH_SCOPES.join(" ")),
|
||||
email,
|
||||
last_refresh: Some(now.to_rfc3339()),
|
||||
cred_type: "gemini".to_string(),
|
||||
token: None,
|
||||
};
|
||||
|
||||
// 保存凭证到文件
|
||||
let file_path = save_gemini_credentials_to_file(&credentials).await?;
|
||||
|
||||
tracing::info!("[Gemini OAuth] 凭证已保存到: {}", file_path);
|
||||
|
||||
Ok(GeminiOAuthResult {
|
||||
credentials,
|
||||
creds_file_path: file_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// 用授权码交换 Token(使用 PKCE)
|
||||
pub async fn exchange_gemini_code_for_token(
|
||||
client: &Client,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let params = [
|
||||
("code", code),
|
||||
("client_id", GEMINI_OAUTH_CLIENT_ID),
|
||||
("client_secret", GEMINI_OAUTH_CLIENT_SECRET),
|
||||
("code_verifier", code_verifier),
|
||||
("redirect_uri", GEMINI_OAUTH_REDIRECT_URI),
|
||||
("grant_type", "authorization_code"),
|
||||
];
|
||||
|
||||
let resp = client.post(GEMINI_TOKEN_URL).form(¶ms).send().await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Token 交换失败: {} - {}", status, body).into());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// 获取用户邮箱
|
||||
pub async fn fetch_gemini_user_email(
|
||||
client: &Client,
|
||||
access_token: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let resp = client
|
||||
.get("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if resp.status().is_success() {
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
Ok(data["email"].as_str().map(|s| s.to_string()))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取项目 ID(通过 loadCodeAssist 接口)
|
||||
pub async fn fetch_gemini_project_id(
|
||||
client: &Client,
|
||||
access_token: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
tracing::info!("[Gemini OAuth] 正在获取 projectId...");
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/{CODE_ASSIST_API_VERSION}:loadCodeAssist",
|
||||
CODE_ASSIST_ENDPOINT
|
||||
))
|
||||
.header("Authorization", format!("Bearer {}", access_token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({
|
||||
"cloudaicompanionProject": "",
|
||||
"metadata": {
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
"duetProject": ""
|
||||
}
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
tracing::info!("[Gemini OAuth] loadCodeAssist 响应状态: {}", status);
|
||||
|
||||
if status.is_success() {
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
if let Some(project) = data["cloudaicompanionProject"].as_str() {
|
||||
if !project.is_empty() {
|
||||
tracing::info!("[Gemini OAuth] 获取到 projectId: {}", project);
|
||||
return Ok(Some(project.to_string()));
|
||||
}
|
||||
}
|
||||
tracing::info!("[Gemini OAuth] cloudaicompanionProject 为空");
|
||||
Ok(None)
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(
|
||||
"[Gemini OAuth] loadCodeAssist 请求失败: {} - {}",
|
||||
status,
|
||||
body
|
||||
);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth 成功页面 HTML
|
||||
const GEMINI_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, #4285f4 0%, #34a853 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: #22c55e; margin-bottom: 16px; }
|
||||
p { color: #666; margin-bottom: 8px; }
|
||||
.email { color: #333; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✓ 授权成功</h1>
|
||||
<p>Gemini 账号已添加到 ProxyCast</p>
|
||||
<p class="email">EMAIL_PLACEHOLDER</p>
|
||||
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
/// OAuth 失败页面 HTML
|
||||
const GEMINI_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, #4285f4 0%, #34a853 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>"#;
|
||||
|
||||
/// 保存 Gemini 凭证到文件
|
||||
async fn save_gemini_credentials_to_file(
|
||||
credentials: &GeminiCredentials,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// 生成唯一文件名
|
||||
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!("gemini_{}_{}_gemini.json", &uuid[..8], timestamp);
|
||||
|
||||
// 获取凭证存储目录
|
||||
let credentials_dir = dirs::data_dir()
|
||||
.ok_or_else(|| "无法获取应用数据目录")?
|
||||
.join("proxycast")
|
||||
.join("credentials");
|
||||
|
||||
// 确保目录存在
|
||||
tokio::fs::create_dir_all(&credentials_dir).await?;
|
||||
|
||||
let file_path = credentials_dir.join(&filename);
|
||||
|
||||
// 写入凭证
|
||||
let content = serde_json::to_string_pretty(credentials)?;
|
||||
tokio::fs::write(&file_path, content).await?;
|
||||
|
||||
Ok(file_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器)
|
||||
/// 服务器会在后台等待回调,成功后返回凭证
|
||||
pub async fn start_gemini_oauth_server_and_get_url() -> Result<
|
||||
(
|
||||
String,
|
||||
impl std::future::Future<
|
||||
Output = Result<GeminiOAuthResult, Box<dyn std::error::Error + Send + Sync>>,
|
||||
>,
|
||||
),
|
||||
Box<dyn std::error::Error + Send + Sync>,
|
||||
> {
|
||||
use axum::{extract::Query, response::Html, routing::get, Router};
|
||||
use std::collections::HashMap;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
// 生成 PKCE
|
||||
let (code_verifier, code_challenge) = generate_pkce();
|
||||
|
||||
// 生成随机 state
|
||||
let state = Uuid::new_v4().to_string();
|
||||
let state_clone = state.clone();
|
||||
let code_verifier_clone = code_verifier.clone();
|
||||
|
||||
// 创建 channel 用于接收回调结果
|
||||
let (tx, rx) = oneshot::channel::<Result<GeminiOAuthResult, String>>();
|
||||
let tx = Arc::new(tokio::sync::Mutex::new(Some(tx)));
|
||||
|
||||
// 尝试绑定到多个端口
|
||||
let ports_to_try = [11451, 11452, 11453, 11454, 11455, 0];
|
||||
let mut listener = None;
|
||||
let mut bound_port = 0;
|
||||
|
||||
for port in ports_to_try {
|
||||
match TcpListener::bind(format!("127.0.0.1:{}", port)).await {
|
||||
Ok(l) => {
|
||||
bound_port = l.local_addr()?.port();
|
||||
listener = Some(l);
|
||||
tracing::info!("[Gemini OAuth] 成功绑定到端口 {}", bound_port);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[Gemini OAuth] 端口 {} 绑定失败: {}", port, e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let listener = listener.ok_or("无法绑定到任何可用端口")?;
|
||||
|
||||
// 生成授权 URL
|
||||
let auth_url = generate_gemini_auth_url(&state, &code_challenge);
|
||||
|
||||
tracing::info!(
|
||||
"[Gemini OAuth] 服务器启动在端口 {}, 授权 URL: {}",
|
||||
bound_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 code_verifier = code_verifier_clone.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 error_desc = params
|
||||
.get("error_description")
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("未知错误");
|
||||
let error_msg = format!("{}: {}", err, error_desc);
|
||||
tracing::error!("[Gemini OAuth] 授权失败: {}", error_msg);
|
||||
|
||||
if let Some(tx) = tx.lock().await.take() {
|
||||
let _ = tx.send(Err(error_msg.clone()));
|
||||
}
|
||||
|
||||
let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &error_msg);
|
||||
return Html(html);
|
||||
}
|
||||
|
||||
// 验证 state
|
||||
if returned_state.map(|s| s.as_str()) != Some(&state_expected) {
|
||||
let error_msg = "State 验证失败";
|
||||
tracing::error!("[Gemini OAuth] {}", error_msg);
|
||||
|
||||
if let Some(tx) = tx.lock().await.take() {
|
||||
let _ = tx.send(Err(error_msg.to_string()));
|
||||
}
|
||||
|
||||
let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", error_msg);
|
||||
return Html(html);
|
||||
}
|
||||
|
||||
// 获取授权码
|
||||
let code = match code {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
let error_msg = "未收到授权码";
|
||||
tracing::error!("[Gemini OAuth] {}", error_msg);
|
||||
|
||||
if let Some(tx) = tx.lock().await.take() {
|
||||
let _ = tx.send(Err(error_msg.to_string()));
|
||||
}
|
||||
|
||||
let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", error_msg);
|
||||
return Html(html);
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!("[Gemini OAuth] 收到授权码,正在交换 Token...");
|
||||
|
||||
// 交换 Token
|
||||
let token_result =
|
||||
exchange_gemini_code_for_token(&client, code, &code_verifier).await;
|
||||
|
||||
match token_result {
|
||||
Ok(token_data) => {
|
||||
let access_token = token_data["access_token"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
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 = fetch_gemini_user_email(&client, &access_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 获取项目 ID
|
||||
let project_id = fetch_gemini_project_id(&client, &access_token)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
// 构建凭证
|
||||
let now = chrono::Utc::now();
|
||||
let expires_at =
|
||||
expires_in.map(|secs| now + chrono::Duration::seconds(secs));
|
||||
|
||||
let credentials = GeminiCredentials {
|
||||
access_token: Some(access_token),
|
||||
refresh_token,
|
||||
token_type: Some("Bearer".to_string()),
|
||||
expiry_date: expires_at.map(|t| t.timestamp_millis()),
|
||||
expire: expires_at.map(|t| t.to_rfc3339()),
|
||||
scope: Some(GEMINI_OAUTH_SCOPES.join(" ")),
|
||||
email: email.clone(),
|
||||
last_refresh: Some(now.to_rfc3339()),
|
||||
cred_type: "gemini".to_string(),
|
||||
token: None,
|
||||
};
|
||||
|
||||
// 保存凭证到文件
|
||||
match save_gemini_credentials_to_file(&credentials).await {
|
||||
Ok(file_path) => {
|
||||
tracing::info!("[Gemini OAuth] 凭证已保存到: {}", file_path);
|
||||
|
||||
let result = GeminiOAuthResult {
|
||||
credentials: credentials.clone(),
|
||||
creds_file_path: file_path,
|
||||
};
|
||||
|
||||
if let Some(tx) = tx.lock().await.take() {
|
||||
let _ = tx.send(Ok(result));
|
||||
}
|
||||
|
||||
let email_display = email.unwrap_or_else(|| "未知邮箱".to_string());
|
||||
let project_display = project_id
|
||||
.map(|p| format!("<p>Project ID: {}</p>", p))
|
||||
.unwrap_or_default();
|
||||
let html = GEMINI_OAUTH_SUCCESS_HTML
|
||||
.replace("EMAIL_PLACEHOLDER", &email_display)
|
||||
.replace(
|
||||
"</div>\n</body>",
|
||||
&format!("{}</div>\n</body>", project_display),
|
||||
);
|
||||
Html(html)
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("保存凭证失败: {}", e);
|
||||
tracing::error!("[Gemini OAuth] {}", error_msg);
|
||||
|
||||
if let Some(tx) = tx.lock().await.take() {
|
||||
let _ = tx.send(Err(error_msg.clone()));
|
||||
}
|
||||
|
||||
let html = GEMINI_OAUTH_ERROR_HTML
|
||||
.replace("ERROR_PLACEHOLDER", &error_msg);
|
||||
Html(html)
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg = format!("Token 交换失败: {}", e);
|
||||
tracing::error!("[Gemini OAuth] {}", error_msg);
|
||||
|
||||
if let Some(tx) = tx.lock().await.take() {
|
||||
let _ = tx.send(Err(error_msg.clone()));
|
||||
}
|
||||
|
||||
let html = GEMINI_OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &error_msg);
|
||||
Html(html)
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// 启动服务器
|
||||
let server_future = async move {
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.map_err(|e| format!("服务器错误: {}", e))
|
||||
};
|
||||
|
||||
// 启动服务器任务
|
||||
tokio::spawn(server_future);
|
||||
|
||||
// 返回授权 URL 和等待结果的 Future
|
||||
let wait_future = async move {
|
||||
match rx.await {
|
||||
Ok(result) => result.map_err(|e| e.into()),
|
||||
Err(_) => Err("OAuth 回调通道关闭".into()),
|
||||
}
|
||||
};
|
||||
|
||||
Ok((auth_url, wait_future))
|
||||
}
|
||||
|
||||
/// 启动 Gemini OAuth 登录流程(自动打开浏览器)
|
||||
pub async fn start_gemini_oauth_login(
|
||||
) -> Result<GeminiOAuthResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let (auth_url, wait_future) = start_gemini_oauth_server_and_get_url().await?;
|
||||
|
||||
// 打开浏览器
|
||||
tracing::info!("[Gemini OAuth] 正在打开浏览器...");
|
||||
if let Err(e) = open::that(&auth_url) {
|
||||
tracing::warn!("[Gemini OAuth] 无法自动打开浏览器: {}", e);
|
||||
}
|
||||
|
||||
// 等待回调
|
||||
wait_future.await
|
||||
}
|
||||
|
||||
@@ -1575,3 +1575,431 @@ mod tests {
|
||||
assert!(provider.are_cookies_expired());
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth 登录功能(与 CLIProxyAPI 对齐)
|
||||
// ============================================================================
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// iFlow OAuth 登录结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IFlowOAuthResult {
|
||||
pub credentials: IFlowCredentials,
|
||||
pub creds_file_path: String,
|
||||
}
|
||||
|
||||
/// OAuth 成功页面 HTML
|
||||
const IFLOW_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, #667eea 0%, #764ba2 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: #667eea; margin-bottom: 16px; }
|
||||
p { color: #666; margin-bottom: 8px; }
|
||||
.email { color: #333; font-weight: 500; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>✓ 授权成功</h1>
|
||||
<p>iFlow 账号已添加到 ProxyCast</p>
|
||||
<p class="email">EMAIL_PLACEHOLDER</p>
|
||||
<p style="margin-top: 20px; color: #999;">可以关闭此页面</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
/// OAuth 失败页面 HTML
|
||||
const IFLOW_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>"#;
|
||||
|
||||
/// 生成 iFlow OAuth 授权 URL
|
||||
pub fn generate_iflow_auth_url(port: u16, state: &str) -> String {
|
||||
let redirect_uri = format!("http://localhost:{}/oauth2callback", port);
|
||||
|
||||
let params = [
|
||||
("loginMethod", "phone"),
|
||||
("type", "phone"),
|
||||
("redirect", redirect_uri.as_str()),
|
||||
("state", state),
|
||||
("client_id", IFLOW_CLIENT_ID),
|
||||
];
|
||||
|
||||
let query = params
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&");
|
||||
|
||||
format!("{}?{}", IFLOW_AUTH_URL, query)
|
||||
}
|
||||
|
||||
/// 用授权码交换 Token
|
||||
pub async fn exchange_iflow_code_for_token(
|
||||
client: &Client,
|
||||
code: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<IFlowCredentials, Box<dyn Error + Send + Sync>> {
|
||||
// 构建 Basic Auth 头
|
||||
let basic_auth = BASE64_STANDARD.encode(format!("{}:{}", IFLOW_CLIENT_ID, IFLOW_CLIENT_SECRET));
|
||||
|
||||
let params = [
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("redirect_uri", redirect_uri),
|
||||
("client_id", IFLOW_CLIENT_ID),
|
||||
("client_secret", IFLOW_CLIENT_SECRET),
|
||||
];
|
||||
|
||||
let resp = client
|
||||
.post(IFLOW_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", format!("Basic {}", basic_auth))
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Token 交换失败: {} - {}", status, body).into());
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp.json().await?;
|
||||
|
||||
let access_token = data["access_token"]
|
||||
.as_str()
|
||||
.ok_or("响应中没有 access_token")?
|
||||
.to_string();
|
||||
let refresh_token = data["refresh_token"].as_str().map(|s| s.to_string());
|
||||
let token_type = data["token_type"].as_str().map(|s| s.to_string());
|
||||
let scope = data["scope"].as_str().map(|s| s.to_string());
|
||||
let expires_in = data["expires_in"].as_i64().unwrap_or(3600);
|
||||
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
|
||||
|
||||
// 获取用户信息和 API Key
|
||||
let user_info_url = format!(
|
||||
"{}?accessToken={}",
|
||||
IFLOW_USER_INFO_URL,
|
||||
urlencoding::encode(&access_token)
|
||||
);
|
||||
|
||||
let user_resp = client
|
||||
.get(&user_info_url)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let (api_key, email) = if let Ok(resp) = user_resp {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(user_data) = resp.json::<serde_json::Value>().await {
|
||||
if user_data["success"].as_bool().unwrap_or(false) {
|
||||
let api_key = user_data["data"]["apiKey"].as_str().map(|s| s.to_string());
|
||||
let email = user_data["data"]["email"]
|
||||
.as_str()
|
||||
.or_else(|| user_data["data"]["phone"].as_str())
|
||||
.map(|s| s.to_string());
|
||||
(api_key, email)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
let credentials = IFlowCredentials {
|
||||
auth_type: "oauth".to_string(),
|
||||
access_token: Some(access_token),
|
||||
refresh_token,
|
||||
expire: Some(expires_at.to_rfc3339()),
|
||||
expires_at: Some(expires_at.to_rfc3339()),
|
||||
cookies: None,
|
||||
cookie_expires_at: None,
|
||||
email,
|
||||
user_id: None,
|
||||
last_refresh: Some(chrono::Utc::now().to_rfc3339()),
|
||||
api_key,
|
||||
token_type,
|
||||
scope,
|
||||
cred_type: "iflow".to_string(),
|
||||
};
|
||||
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器)
|
||||
pub async fn start_iflow_oauth_server_and_get_url() -> Result<
|
||||
(
|
||||
String,
|
||||
impl std::future::Future<Output = Result<IFlowOAuthResult, 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 client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
// 生成随机 state
|
||||
let state = Uuid::new_v4().to_string();
|
||||
let state_clone = state.clone();
|
||||
|
||||
// 创建 channel 用于接收回调结果
|
||||
let (tx, rx) = oneshot::channel::<Result<IFlowOAuthResult, String>>();
|
||||
let tx = Arc::new(tokio::sync::Mutex::new(Some(tx)));
|
||||
|
||||
// 尝试绑定到端口,如果默认端口被占用则尝试其他端口
|
||||
let ports_to_try = [DEFAULT_CALLBACK_PORT, 11452, 11453, 11454, 11455, 0]; // 0 表示让系统分配
|
||||
let mut listener = None;
|
||||
let mut last_error = None;
|
||||
|
||||
for port in ports_to_try {
|
||||
match TcpListener::bind(format!("127.0.0.1:{}", port)).await {
|
||||
Ok(l) => {
|
||||
listener = Some(l);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let listener = listener.ok_or_else(|| {
|
||||
format!(
|
||||
"启动 OAuth 服务器失败: {}",
|
||||
last_error
|
||||
.map(|e| e.to_string())
|
||||
.unwrap_or_else(|| "无法绑定端口".to_string())
|
||||
)
|
||||
})?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let redirect_uri = format!("http://localhost:{}/oauth2callback", port);
|
||||
let redirect_uri_clone = redirect_uri.clone();
|
||||
|
||||
// 生成授权 URL
|
||||
let auth_url = generate_iflow_auth_url(port, &state);
|
||||
|
||||
tracing::info!(
|
||||
"[iFlow OAuth] 服务器启动在端口 {}, 授权 URL: {}",
|
||||
port,
|
||||
auth_url
|
||||
);
|
||||
|
||||
// 构建路由
|
||||
let app = Router::new().route(
|
||||
"/oauth2callback",
|
||||
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();
|
||||
|
||||
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 = IFLOW_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 =
|
||||
IFLOW_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 =
|
||||
IFLOW_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_iflow_code_for_token(&client, code, &redirect_uri).await;
|
||||
let credentials = match token_result {
|
||||
Ok(creds) => creds,
|
||||
Err(e) => {
|
||||
let html =
|
||||
IFLOW_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 email = credentials.email.clone();
|
||||
|
||||
// 保存凭证到应用数据目录
|
||||
let creds_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("proxycast")
|
||||
.join("credentials")
|
||||
.join("iflow");
|
||||
|
||||
if let Err(e) = std::fs::create_dir_all(&creds_dir) {
|
||||
let html = IFLOW_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!("iflow_{}_{}.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 = IFLOW_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 = IFLOW_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!("[iFlow OAuth] 凭证已保存到: {:?}", creds_file_path);
|
||||
|
||||
// 发送成功结果
|
||||
let result = IFlowOAuthResult {
|
||||
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 = IFLOW_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!("[iFlow OAuth] 服务器错误: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
// 等待回调结果
|
||||
match rx.await {
|
||||
Ok(result) => result.map_err(|e| {
|
||||
Box::new(std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||
as Box<dyn Error + Send + Sync>
|
||||
}),
|
||||
Err(_) => Err("OAuth 回调通道关闭".into()),
|
||||
}
|
||||
});
|
||||
|
||||
match timeout.await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err("OAuth 登录超时(5分钟)".into()),
|
||||
}
|
||||
};
|
||||
|
||||
Ok((auth_url, wait_future))
|
||||
}
|
||||
|
||||
/// 启动 iFlow OAuth 登录流程(自动打开浏览器)
|
||||
pub async fn start_iflow_oauth_login() -> Result<IFlowOAuthResult, Box<dyn Error + Send + Sync>> {
|
||||
let (auth_url, wait_future) = start_iflow_oauth_server_and_get_url().await?;
|
||||
|
||||
tracing::info!("[iFlow OAuth] 打开浏览器进行授权: {}", auth_url);
|
||||
|
||||
// 打开浏览器
|
||||
if let Err(e) = open::that(&auth_url) {
|
||||
tracing::warn!("[iFlow OAuth] 无法打开浏览器: {}. 请手动打开 URL.", e);
|
||||
}
|
||||
|
||||
// 等待回调
|
||||
wait_future.await
|
||||
}
|
||||
|
||||
@@ -316,3 +316,340 @@ impl QwenProvider {
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Device Code Flow 登录功能(与 CLIProxyAPI 对齐)
|
||||
// ============================================================================
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Device Code Flow 端点
|
||||
const QWEN_DEVICE_CODE_URL: &str = "https://chat.qwen.ai/api/v1/oauth2/device/code";
|
||||
const QWEN_OAUTH_SCOPE: &str = "openid profile email model.completion";
|
||||
const QWEN_DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
|
||||
|
||||
/// Device Code Flow 响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceCodeResponse {
|
||||
/// 设备码(用于轮询)
|
||||
#[serde(alias = "deviceCode")]
|
||||
pub device_code: String,
|
||||
/// 用户码(用户在浏览器中输入)
|
||||
#[serde(alias = "userCode")]
|
||||
pub user_code: String,
|
||||
/// 验证 URL
|
||||
#[serde(alias = "verificationUri")]
|
||||
pub verification_uri: String,
|
||||
/// 完整验证 URL(包含 user_code)
|
||||
#[serde(default, alias = "verificationUriComplete")]
|
||||
pub verification_uri_complete: Option<String>,
|
||||
/// 过期时间(秒)
|
||||
#[serde(alias = "expiresIn")]
|
||||
pub expires_in: i64,
|
||||
/// 轮询间隔(秒),默认 5 秒
|
||||
#[serde(default = "default_interval")]
|
||||
pub interval: i64,
|
||||
}
|
||||
|
||||
fn default_interval() -> i64 {
|
||||
5
|
||||
}
|
||||
|
||||
/// Qwen OAuth 登录结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct QwenOAuthResult {
|
||||
pub credentials: QwenCredentials,
|
||||
pub creds_file_path: String,
|
||||
}
|
||||
|
||||
/// PKCE 代码生成
|
||||
fn generate_pkce_pair() -> Result<(String, String), Box<dyn Error + Send + Sync>> {
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let code_verifier = URL_SAFE_NO_PAD.encode(bytes);
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code_verifier.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let code_challenge = URL_SAFE_NO_PAD.encode(hash);
|
||||
|
||||
Ok((code_verifier, code_challenge))
|
||||
}
|
||||
|
||||
/// 发起 Device Code Flow
|
||||
pub async fn initiate_device_flow(
|
||||
client: &Client,
|
||||
) -> Result<(DeviceCodeResponse, String), Box<dyn Error + Send + Sync>> {
|
||||
let (code_verifier, code_challenge) = generate_pkce_pair()?;
|
||||
|
||||
let params = [
|
||||
("client_id", QWEN_CLIENT_ID),
|
||||
("scope", QWEN_OAUTH_SCOPE),
|
||||
("code_challenge", code_challenge.as_str()),
|
||||
("code_challenge_method", "S256"),
|
||||
];
|
||||
|
||||
let resp = client
|
||||
.post(QWEN_DEVICE_CODE_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Device code 请求失败: {} - {}", status, body).into());
|
||||
}
|
||||
|
||||
// 先获取响应体文本,以便在解析失败时提供详细错误信息
|
||||
let body = resp.text().await?;
|
||||
tracing::debug!("[QWEN] Device Code 响应: {}", body);
|
||||
|
||||
let device_response: DeviceCodeResponse = serde_json::from_str(&body)
|
||||
.map_err(|e| format!("解析 Device Code 响应失败: {} - 响应内容: {}", e, body))?;
|
||||
|
||||
if device_response.device_code.is_empty() {
|
||||
return Err("Device code 响应中没有 device_code".into());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
"[QWEN] Device Code Flow 已启动,user_code: {}, verification_uri: {}",
|
||||
device_response.user_code,
|
||||
device_response.verification_uri
|
||||
);
|
||||
|
||||
Ok((device_response, code_verifier))
|
||||
}
|
||||
|
||||
/// 轮询 Token 端点
|
||||
pub async fn poll_for_token(
|
||||
client: &Client,
|
||||
device_code: &str,
|
||||
code_verifier: &str,
|
||||
interval: u64,
|
||||
max_attempts: u32,
|
||||
) -> Result<QwenCredentials, Box<dyn Error + Send + Sync>> {
|
||||
let poll_interval = std::time::Duration::from_secs(interval.max(5));
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
if attempt > 0 {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
}
|
||||
|
||||
tracing::debug!("[QWEN] 轮询 Token,第 {} 次尝试", attempt + 1);
|
||||
|
||||
let params = [
|
||||
("grant_type", QWEN_DEVICE_GRANT_TYPE),
|
||||
("client_id", QWEN_CLIENT_ID),
|
||||
("device_code", device_code),
|
||||
("code_verifier", code_verifier),
|
||||
];
|
||||
|
||||
let resp = client
|
||||
.post(QWEN_TOKEN_URL)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.header("Accept", "application/json")
|
||||
.form(¶ms)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("[QWEN] 轮询请求失败: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
|
||||
if status.is_success() {
|
||||
// 成功获取 Token
|
||||
let data: serde_json::Value = serde_json::from_str(&body)?;
|
||||
|
||||
let access_token = data["access_token"]
|
||||
.as_str()
|
||||
.ok_or("响应中没有 access_token")?
|
||||
.to_string();
|
||||
let refresh_token = data["refresh_token"].as_str().map(|s| s.to_string());
|
||||
let token_type = data["token_type"].as_str().map(|s| s.to_string());
|
||||
let resource_url = data["resource_url"].as_str().map(|s| s.to_string());
|
||||
let expires_in = data["expires_in"].as_i64().unwrap_or(3600);
|
||||
|
||||
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in);
|
||||
|
||||
let credentials = QwenCredentials {
|
||||
access_token: Some(access_token),
|
||||
refresh_token,
|
||||
token_type,
|
||||
resource_url,
|
||||
expiry_date: Some(expires_at.timestamp_millis()),
|
||||
expire: Some(expires_at.to_rfc3339()),
|
||||
last_refresh: Some(chrono::Utc::now().to_rfc3339()),
|
||||
cred_type: "qwen".to_string(),
|
||||
};
|
||||
|
||||
tracing::info!("[QWEN] Token 获取成功");
|
||||
return Ok(credentials);
|
||||
}
|
||||
|
||||
// 解析错误响应
|
||||
if let Ok(error_data) = serde_json::from_str::<serde_json::Value>(&body) {
|
||||
let error_type = error_data["error"].as_str().unwrap_or("");
|
||||
|
||||
match error_type {
|
||||
"authorization_pending" => {
|
||||
// 用户尚未完成授权,继续轮询
|
||||
tracing::debug!("[QWEN] 等待用户授权...");
|
||||
continue;
|
||||
}
|
||||
"slow_down" => {
|
||||
// 轮询太频繁,增加间隔
|
||||
tracing::debug!("[QWEN] 服务器要求降低轮询频率");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
continue;
|
||||
}
|
||||
"expired_token" => {
|
||||
return Err("Device code 已过期,请重新开始授权流程".into());
|
||||
}
|
||||
"access_denied" => {
|
||||
return Err("用户拒绝了授权请求".into());
|
||||
}
|
||||
_ => {
|
||||
let error_desc = error_data["error_description"]
|
||||
.as_str()
|
||||
.unwrap_or("未知错误");
|
||||
return Err(format!("Token 获取失败: {} - {}", error_type, error_desc).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
if status.as_u16() != 400 {
|
||||
return Err(format!("Token 请求失败: {} - {}", status, body).into());
|
||||
}
|
||||
}
|
||||
|
||||
Err("授权超时,请重新开始授权流程".into())
|
||||
}
|
||||
|
||||
/// 启动 Qwen Device Code Flow 登录
|
||||
pub async fn start_qwen_device_code_login() -> Result<QwenOAuthResult, Box<dyn Error + Send + Sync>>
|
||||
{
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
// 发起 Device Code Flow
|
||||
let (device_response, code_verifier) = initiate_device_flow(&client).await?;
|
||||
|
||||
// 打开浏览器
|
||||
let verification_url = device_response
|
||||
.verification_uri_complete
|
||||
.as_ref()
|
||||
.unwrap_or(&device_response.verification_uri);
|
||||
|
||||
tracing::info!("[QWEN] 打开浏览器进行授权: {}", verification_url);
|
||||
|
||||
if let Err(e) = open::that(verification_url) {
|
||||
tracing::warn!("[QWEN] 无法打开浏览器: {}. 请手动打开 URL.", e);
|
||||
}
|
||||
|
||||
// 轮询 Token
|
||||
let credentials = poll_for_token(
|
||||
&client,
|
||||
&device_response.device_code,
|
||||
&code_verifier,
|
||||
device_response.interval as u64,
|
||||
60, // 最多轮询 60 次(约 5 分钟)
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 保存凭证到应用数据目录
|
||||
let creds_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("proxycast")
|
||||
.join("credentials")
|
||||
.join("qwen");
|
||||
|
||||
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!("qwen_{}_{}.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!("[QWEN] 凭证已保存到: {:?}", creds_file_path);
|
||||
|
||||
Ok(QwenOAuthResult {
|
||||
credentials,
|
||||
creds_file_path: creds_file_path.to_string_lossy().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 启动 Qwen Device Code Flow 并返回设备码信息(不自动打开浏览器)
|
||||
pub async fn start_qwen_device_code_and_get_info() -> Result<
|
||||
(
|
||||
DeviceCodeResponse,
|
||||
impl std::future::Future<Output = Result<QwenOAuthResult, Box<dyn Error + Send + Sync>>>,
|
||||
),
|
||||
Box<dyn Error + Send + Sync>,
|
||||
> {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
|
||||
// 发起 Device Code Flow
|
||||
let (device_response, code_verifier) = initiate_device_flow(&client).await?;
|
||||
|
||||
let device_code = device_response.device_code.clone();
|
||||
let interval = device_response.interval as u64;
|
||||
|
||||
// 创建等待 future
|
||||
let wait_future = async move {
|
||||
// 轮询 Token
|
||||
let credentials =
|
||||
poll_for_token(&client, &device_code, &code_verifier, interval, 60).await?;
|
||||
|
||||
// 保存凭证到应用数据目录
|
||||
let creds_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("proxycast")
|
||||
.join("credentials")
|
||||
.join("qwen");
|
||||
|
||||
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!("qwen_{}_{}.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!("[QWEN] 凭证已保存到: {:?}", creds_file_path);
|
||||
|
||||
Ok(QwenOAuthResult {
|
||||
credentials,
|
||||
creds_file_path: creds_file_path.to_string_lossy().to_string(),
|
||||
})
|
||||
};
|
||||
|
||||
Ok((device_response, wait_future))
|
||||
}
|
||||
|
||||
@@ -300,6 +300,8 @@ impl ProviderPoolService {
|
||||
}
|
||||
|
||||
/// 执行单个凭证的健康检查
|
||||
///
|
||||
/// 如果遇到 401 错误,会自动尝试刷新 token 后重试
|
||||
pub async fn check_credential_health(
|
||||
&self,
|
||||
db: &DbConnection,
|
||||
@@ -335,6 +337,71 @@ impl ProviderPoolService {
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
// 如果是 401 错误,尝试刷新 token 后重试
|
||||
if e.contains("401") || e.contains("Unauthorized") {
|
||||
tracing::info!("[健康检查] 检测到 401 错误,尝试刷新 token: {}", uuid);
|
||||
|
||||
// 尝试刷新 token
|
||||
match self.refresh_credential_token(db, uuid).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[健康检查] Token 刷新成功,重新检查健康状态");
|
||||
|
||||
// 重新获取凭证(token 已更新)
|
||||
let updated_cred = {
|
||||
let conn = db.lock().map_err(|e| e.to_string())?;
|
||||
ProviderPoolDao::get_by_uuid(&conn, uuid)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("Credential not found: {}", uuid))?
|
||||
};
|
||||
|
||||
// 重新执行健康检查
|
||||
let retry_start = std::time::Instant::now();
|
||||
let retry_result = self
|
||||
.perform_health_check(&updated_cred.credential, &check_model)
|
||||
.await;
|
||||
let retry_duration_ms = retry_start.elapsed().as_millis() as u64;
|
||||
|
||||
match retry_result {
|
||||
Ok(_) => {
|
||||
self.mark_healthy(db, uuid, Some(&check_model))?;
|
||||
return Ok(HealthCheckResult {
|
||||
uuid: uuid.to_string(),
|
||||
success: true,
|
||||
model: Some(check_model),
|
||||
message: Some(
|
||||
"Health check passed after token refresh".to_string(),
|
||||
),
|
||||
duration_ms: duration_ms + retry_duration_ms,
|
||||
});
|
||||
}
|
||||
Err(retry_e) => {
|
||||
tracing::warn!("[健康检查] Token 刷新后仍然失败: {}", retry_e);
|
||||
self.mark_unhealthy(db, uuid, Some(&retry_e))?;
|
||||
return Ok(HealthCheckResult {
|
||||
uuid: uuid.to_string(),
|
||||
success: false,
|
||||
model: Some(check_model),
|
||||
message: Some(retry_e),
|
||||
duration_ms: duration_ms + retry_duration_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(refresh_err) => {
|
||||
tracing::warn!("[健康检查] Token 刷新失败: {}", refresh_err);
|
||||
// Token 刷新失败,返回原始错误
|
||||
self.mark_unhealthy(db, uuid, Some(&e))?;
|
||||
return Ok(HealthCheckResult {
|
||||
uuid: uuid.to_string(),
|
||||
success: false,
|
||||
model: Some(check_model),
|
||||
message: Some(format!("{} (Token 刷新失败: {})", e, refresh_err)),
|
||||
duration_ms,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.mark_unhealthy(db, uuid, Some(&e))?;
|
||||
Ok(HealthCheckResult {
|
||||
uuid: uuid.to_string(),
|
||||
@@ -550,11 +617,13 @@ impl ProviderPoolService {
|
||||
}
|
||||
|
||||
// Gemini OAuth 健康检查
|
||||
// 使用 cloudcode-pa.googleapis.com API(与 Gemini CLI 兼容)
|
||||
// 使用 loadCodeAssist 接口进行健康检查,这是最简单可靠的方式
|
||||
async fn check_gemini_health(
|
||||
&self,
|
||||
creds_path: &str,
|
||||
_project_id: Option<&str>,
|
||||
model: &str,
|
||||
_model: &str,
|
||||
) -> Result<(), String> {
|
||||
let creds_content =
|
||||
std::fs::read_to_string(creds_path).map_err(|e| format!("读取凭证文件失败: {}", e))?;
|
||||
@@ -565,24 +634,25 @@ impl ProviderPoolService {
|
||||
.as_str()
|
||||
.ok_or_else(|| "凭证中缺少 access_token".to_string())?;
|
||||
|
||||
let url = format!(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent",
|
||||
model
|
||||
);
|
||||
// 使用 loadCodeAssist 接口进行健康检查
|
||||
// 这个接口用于获取项目信息,是最简单可靠的健康检查方式
|
||||
let url = "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist";
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"contents": [{
|
||||
"parts": [{"text": "Say OK"}]
|
||||
}],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 10
|
||||
"cloudaicompanionProject": "",
|
||||
"metadata": {
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
"duetProject": ""
|
||||
}
|
||||
});
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(&url)
|
||||
.post(url)
|
||||
.bearer_auth(access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
@@ -592,7 +662,9 @@ impl ProviderPoolService {
|
||||
if response.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("HTTP {}", response.status()))
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Err(format!("HTTP {} - {}", status, body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -607,16 +679,31 @@ impl ProviderPoolService {
|
||||
.as_str()
|
||||
.ok_or_else(|| "凭证中缺少 access_token".to_string())?;
|
||||
|
||||
// 获取 base_url,优先使用 resource_url,否则使用默认值
|
||||
let base_url = if let Some(resource_url) = creds["resource_url"].as_str() {
|
||||
if resource_url.starts_with("http") {
|
||||
format!("{}/v1", resource_url.trim_end_matches('/'))
|
||||
} else {
|
||||
format!("https://{}/v1", resource_url)
|
||||
}
|
||||
} else {
|
||||
"https://portal.qwen.ai/v1".to_string()
|
||||
};
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": "Say OK"}],
|
||||
"max_tokens": 10
|
||||
});
|
||||
|
||||
let url = format!("{}/chat/completions", base_url);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post("https://chat.qwen.ai/api/v1/chat/completions")
|
||||
.post(&url)
|
||||
.bearer_auth(access_token)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&request_body)
|
||||
.timeout(self.health_check_timeout)
|
||||
.send()
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Key, FolderOpen, LogIn, Copy, Check, Loader2 } from "lucide-react";
|
||||
/**
|
||||
* 添加凭证模态框
|
||||
* 根据 Provider 类型显示不同的表单
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Key, FolderOpen } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { providerPoolApi, PoolProviderType } from "@/lib/api/providerPool";
|
||||
import { AntigravityForm } from "./credential-forms/AntigravityForm";
|
||||
import { CodexForm } from "./credential-forms/CodexForm";
|
||||
import { ClaudeOAuthForm } from "./credential-forms/ClaudeOAuthForm";
|
||||
import { QwenForm } from "./credential-forms/QwenForm";
|
||||
import { IFlowForm } from "./credential-forms/IFlowForm";
|
||||
import { GeminiForm } from "./credential-forms/GeminiForm";
|
||||
import { defaultCredsPath, providerLabels } from "./credential-forms/types";
|
||||
|
||||
interface AddCredentialModalProps {
|
||||
providerType: PoolProviderType;
|
||||
@@ -10,17 +21,6 @@ interface AddCredentialModalProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// Default credential paths
|
||||
const defaultCredsPath: Record<string, string> = {
|
||||
kiro: "~/.aws/sso/cache/kiro-auth-token.json",
|
||||
gemini: "~/.gemini/oauth_creds.json",
|
||||
qwen: "~/.qwen/oauth_creds.json",
|
||||
antigravity: "",
|
||||
codex: "~/.codex/oauth.json",
|
||||
claude_oauth: "~/.claude/oauth.json",
|
||||
iflow: "~/.iflow/oauth_creds.json",
|
||||
};
|
||||
|
||||
export function AddCredentialModal({
|
||||
providerType,
|
||||
onClose,
|
||||
@@ -30,42 +30,19 @@ export function AddCredentialModal({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// OAuth fields - initialize with default path
|
||||
// OAuth 字段
|
||||
const [credsFilePath, setCredsFilePath] = useState(
|
||||
defaultCredsPath[providerType] || "",
|
||||
);
|
||||
const [projectId, setProjectId] = useState("");
|
||||
|
||||
// API Key fields
|
||||
// API Key 字段
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState("");
|
||||
|
||||
// Antigravity 添加方式: "login" | "file"
|
||||
const [antigravityMode, setAntigravityMode] = useState<"login" | "file">(
|
||||
"login",
|
||||
);
|
||||
|
||||
const isOAuth = [
|
||||
"kiro",
|
||||
"gemini",
|
||||
"qwen",
|
||||
"antigravity",
|
||||
"codex",
|
||||
"claude_oauth",
|
||||
"iflow",
|
||||
].includes(providerType);
|
||||
|
||||
const providerLabels: Record<PoolProviderType, string> = {
|
||||
kiro: "Kiro (AWS)",
|
||||
gemini: "Gemini (Google)",
|
||||
qwen: "Qwen (阿里)",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude (Anthropic)",
|
||||
antigravity: "Antigravity (Gemini 3 Pro)",
|
||||
codex: "Codex (OpenAI OAuth)",
|
||||
claude_oauth: "Claude OAuth",
|
||||
iflow: "iFlow",
|
||||
};
|
||||
// 判断是否为 OAuth 类型(不包括有特殊表单的 antigravity、codex、claude_oauth、qwen、iflow、gemini)
|
||||
const isSimpleOAuth = ["kiro"].includes(providerType);
|
||||
const isApiKey = ["openai", "claude"].includes(providerType);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
try {
|
||||
@@ -81,100 +58,94 @@ export function AddCredentialModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Antigravity OAuth 登录状态
|
||||
const [antigravityAuthUrl, setAntigravityAuthUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [urlCopied, setUrlCopied] = useState(false);
|
||||
const [waitingForCallback, setWaitingForCallback] = useState(false);
|
||||
// Antigravity 表单
|
||||
const antigravityForm = AntigravityForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
projectId,
|
||||
setProjectId,
|
||||
onSelectFile: handleSelectFile,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
// 监听后端发送的授权 URL 事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
// Codex 表单
|
||||
const codexForm = CodexForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile: handleSelectFile,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ auth_url: string }>(
|
||||
"antigravity-auth-url",
|
||||
(event) => {
|
||||
setAntigravityAuthUrl(event.payload.auth_url);
|
||||
},
|
||||
);
|
||||
};
|
||||
// Claude OAuth 表单
|
||||
const claudeOAuthForm = ClaudeOAuthForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile: handleSelectFile,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
setupListener();
|
||||
// Qwen 表单
|
||||
const qwenForm = QwenForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile: handleSelectFile,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (unlisten) {
|
||||
unlisten();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// iFlow 表单
|
||||
const iflowForm = IFlowForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile: handleSelectFile,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const handleAntigravityLogin = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAntigravityAuthUrl(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.startAntigravityOAuthLogin(trimmedName, false);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取授权 URL 并启动服务器等待回调
|
||||
const handleGetAntigravityAuthUrl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAntigravityAuthUrl(null);
|
||||
setUrlCopied(false);
|
||||
setWaitingForCallback(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
// 调用后端:启动服务器并等待回调
|
||||
// 授权 URL 会通过事件发送
|
||||
await providerPoolApi.getAntigravityAuthUrlAndWait(trimmedName, false);
|
||||
// 如果成功返回,说明授权完成
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errorMsg);
|
||||
setWaitingForCallback(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 复制授权 URL
|
||||
const handleCopyAuthUrl = () => {
|
||||
if (antigravityAuthUrl) {
|
||||
navigator.clipboard.writeText(antigravityAuthUrl);
|
||||
setUrlCopied(true);
|
||||
setTimeout(() => setUrlCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
// Gemini 表单
|
||||
const geminiForm = GeminiForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
projectId,
|
||||
setProjectId,
|
||||
onSelectFile: handleSelectFile,
|
||||
loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
// 简单 OAuth 和 API Key 的提交处理
|
||||
const handleSubmit = async () => {
|
||||
// Antigravity 登录模式单独处理
|
||||
if (providerType === "antigravity" && antigravityMode === "login") {
|
||||
await handleAntigravityLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
|
||||
if (isOAuth) {
|
||||
if (isSimpleOAuth) {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,29 +160,11 @@ export function AddCredentialModal({
|
||||
trimmedName,
|
||||
);
|
||||
break;
|
||||
case "qwen":
|
||||
await providerPoolApi.addQwenOAuth(credsFilePath, trimmedName);
|
||||
break;
|
||||
case "antigravity":
|
||||
await providerPoolApi.addAntigravityOAuth(
|
||||
credsFilePath,
|
||||
projectId.trim() || undefined,
|
||||
trimmedName,
|
||||
);
|
||||
break;
|
||||
case "codex":
|
||||
await providerPoolApi.addCodexOAuth(credsFilePath, trimmedName);
|
||||
break;
|
||||
case "claude_oauth":
|
||||
await providerPoolApi.addClaudeOAuth(credsFilePath, trimmedName);
|
||||
break;
|
||||
case "iflow":
|
||||
await providerPoolApi.addIFlowOAuth(credsFilePath, trimmedName);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
} else if (isApiKey) {
|
||||
if (!apiKey) {
|
||||
setError("请输入 API Key");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -241,138 +194,283 @@ export function AddCredentialModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Antigravity 特殊渲染
|
||||
const renderAntigravityContent = () => (
|
||||
// 渲染简单 OAuth 表单
|
||||
const renderSimpleOAuthForm = () => (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAntigravityMode("login")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
|
||||
antigravityMode === "login"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<LogIn className="inline h-4 w-4 mr-1" />
|
||||
Google 登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAntigravityMode("file")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
|
||||
antigravityMode === "file"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="inline h-4 w-4 mr-1" />
|
||||
导入文件
|
||||
</button>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
凭证文件路径 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={credsFilePath}
|
||||
onChange={(e) => setCredsFilePath(e.target.value)}
|
||||
placeholder="输入凭证文件的完整路径..."
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFile}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{providerType === "kiro" &&
|
||||
"默认路径: ~/.aws/sso/cache/kiro-auth-token.json"}
|
||||
{providerType === "gemini" && "默认路径: ~/.gemini/oauth_creds.json"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{antigravityMode === "login" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
点击下方按钮获取授权
|
||||
URL,然后复制到浏览器(支持指纹浏览器)完成登录。
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
授权成功后,凭证将自动保存并添加到凭证池。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 授权 URL 显示区域 */}
|
||||
{antigravityAuthUrl && (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">授权 URL</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyAuthUrl}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
{urlCopied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<span className="text-green-500">已复制</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>复制</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all font-mono">
|
||||
{antigravityAuthUrl.slice(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{waitingForCallback && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-yellow-600" />
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
请复制上方 URL 到浏览器完成登录,正在等待授权回调...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{providerType === "gemini" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Project ID (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
placeholder="Google Cloud Project ID..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 文件选择 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
凭证文件路径 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={credsFilePath}
|
||||
onChange={(e) => setCredsFilePath(e.target.value)}
|
||||
placeholder="选择 accounts.json 或 oauth_creds.json..."
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFile}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
支持 antigravity2api-nodejs 的 data/accounts.json 格式
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project ID */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Project ID (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
placeholder="Google Cloud Project ID..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// 渲染 API Key 表单
|
||||
const renderApiKeyForm = () => (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border bg-background pl-10 pr-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Base URL (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder={
|
||||
providerType === "openai"
|
||||
? "https://api.openai.com/v1"
|
||||
: "https://api.anthropic.com/v1"
|
||||
}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
留空使用默认 URL,或输入自定义代理地址
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 渲染底部按钮
|
||||
const renderFooterButton = () => {
|
||||
// Antigravity 登录模式
|
||||
if (providerType === "antigravity" && antigravityForm.mode === "login") {
|
||||
if (!antigravityForm.authUrl) {
|
||||
return (
|
||||
<button
|
||||
onClick={antigravityForm.handleGetAuthUrl}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "获取中..." : "获取授权 URL"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Antigravity 文件模式
|
||||
if (providerType === "antigravity" && antigravityForm.mode === "file") {
|
||||
return (
|
||||
<button
|
||||
onClick={antigravityForm.handleFileSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Codex 登录模式
|
||||
if (providerType === "codex" && codexForm.mode === "login") {
|
||||
if (!codexForm.authUrl) {
|
||||
return (
|
||||
<button
|
||||
onClick={codexForm.handleGetAuthUrl}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "获取中..." : "获取授权 URL"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Codex 文件模式
|
||||
if (providerType === "codex" && codexForm.mode === "file") {
|
||||
return (
|
||||
<button
|
||||
onClick={codexForm.handleFileSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Claude OAuth 登录模式
|
||||
if (providerType === "claude_oauth" && claudeOAuthForm.mode === "login") {
|
||||
if (!claudeOAuthForm.authUrl) {
|
||||
return (
|
||||
<button
|
||||
onClick={claudeOAuthForm.handleGetAuthUrl}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "获取中..." : "获取授权 URL"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Claude OAuth 文件模式
|
||||
if (providerType === "claude_oauth" && claudeOAuthForm.mode === "file") {
|
||||
return (
|
||||
<button
|
||||
onClick={claudeOAuthForm.handleFileSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Qwen 登录模式
|
||||
if (providerType === "qwen" && qwenForm.mode === "login") {
|
||||
if (!qwenForm.deviceCode) {
|
||||
return (
|
||||
<button
|
||||
onClick={qwenForm.handleGetDeviceCode}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "获取中..." : "获取设备码"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Qwen 文件模式
|
||||
if (providerType === "qwen" && qwenForm.mode === "file") {
|
||||
return (
|
||||
<button
|
||||
onClick={qwenForm.handleFileSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// iFlow 登录模式
|
||||
if (providerType === "iflow" && iflowForm.mode === "login") {
|
||||
if (!iflowForm.authUrl) {
|
||||
return (
|
||||
<button
|
||||
onClick={iflowForm.handleGetAuthUrl}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "获取中..." : "获取授权 URL"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// iFlow 文件模式
|
||||
if (providerType === "iflow" && iflowForm.mode === "file") {
|
||||
return (
|
||||
<button
|
||||
onClick={iflowForm.handleFileSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Gemini 登录模式
|
||||
if (providerType === "gemini" && geminiForm.mode === "login") {
|
||||
if (!geminiForm.authUrl) {
|
||||
return (
|
||||
<button
|
||||
onClick={geminiForm.handleGetAuthUrl}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "获取中..." : "获取授权 URL"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gemini 文件模式
|
||||
if (providerType === "gemini" && geminiForm.mode === "file") {
|
||||
return (
|
||||
<button
|
||||
onClick={geminiForm.handleFileSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他类型
|
||||
return (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="w-full max-w-md rounded-lg bg-background p-6 shadow-xl">
|
||||
@@ -388,7 +486,7 @@ export function AddCredentialModal({
|
||||
|
||||
{/* Content */}
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Name field */}
|
||||
{/* 名称字段 */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
名称 (可选)
|
||||
@@ -402,106 +500,17 @@ export function AddCredentialModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{providerType === "antigravity" ? (
|
||||
renderAntigravityContent()
|
||||
) : isOAuth ? (
|
||||
<>
|
||||
{/* Credential File */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
凭证文件路径 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={credsFilePath}
|
||||
onChange={(e) => setCredsFilePath(e.target.value)}
|
||||
placeholder="输入凭证文件的完整路径..."
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFile}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{providerType === "kiro" &&
|
||||
"默认路径: ~/.aws/sso/cache/kiro-auth-token.json"}
|
||||
{providerType === "gemini" &&
|
||||
"默认路径: ~/.gemini/oauth_creds.json"}
|
||||
{providerType === "qwen" &&
|
||||
"默认路径: ~/.qwen/oauth_creds.json"}
|
||||
{providerType === "codex" && "默认路径: ~/.codex/oauth.json"}
|
||||
{providerType === "claude_oauth" &&
|
||||
"默认路径: ~/.claude/oauth.json"}
|
||||
{providerType === "iflow" &&
|
||||
"默认路径: ~/.iflow/oauth_creds.json"}
|
||||
</p>
|
||||
</div>
|
||||
{/* 根据类型渲染不同表单 */}
|
||||
{providerType === "antigravity" && antigravityForm.render()}
|
||||
{providerType === "codex" && codexForm.render()}
|
||||
{providerType === "claude_oauth" && claudeOAuthForm.render()}
|
||||
{providerType === "qwen" && qwenForm.render()}
|
||||
{providerType === "iflow" && iflowForm.render()}
|
||||
{providerType === "gemini" && geminiForm.render()}
|
||||
{isSimpleOAuth && renderSimpleOAuthForm()}
|
||||
{isApiKey && renderApiKeyForm()}
|
||||
|
||||
{/* Gemini specific: Project ID */}
|
||||
{providerType === "gemini" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Project ID (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectId}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
placeholder="Google Cloud Project ID..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
API Key <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Key className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border bg-background pl-10 pr-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Base URL (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder={
|
||||
providerType === "openai"
|
||||
? "https://api.openai.com/v1"
|
||||
: "https://api.anthropic.com/v1"
|
||||
}
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
留空使用默认 URL,或输入自定义代理地址
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500 bg-red-50 p-3 text-sm text-red-700 dark:bg-red-950/30">
|
||||
{error}
|
||||
@@ -517,26 +526,7 @@ export function AddCredentialModal({
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
{providerType === "antigravity" && antigravityMode === "login" ? (
|
||||
// Antigravity 登录模式:显示获取授权 URL 按钮
|
||||
!antigravityAuthUrl && (
|
||||
<button
|
||||
onClick={handleGetAntigravityAuthUrl}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "获取中..." : "获取授权 URL"}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "添加中..." : "添加凭证"}
|
||||
</button>
|
||||
)}
|
||||
{renderFooterButton()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Antigravity 凭证添加表单
|
||||
* 支持 Google OAuth 登录和文件导入两种模式
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { providerPoolApi } from "@/lib/api/providerPool";
|
||||
import { ModeSelector } from "./ModeSelector";
|
||||
import { FileImportForm } from "./FileImportForm";
|
||||
import { OAuthUrlDisplay } from "./OAuthUrlDisplay";
|
||||
|
||||
interface AntigravityFormProps {
|
||||
name: string;
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
projectId: string;
|
||||
setProjectId: (id: string) => void;
|
||||
onSelectFile: () => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function AntigravityForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
projectId,
|
||||
setProjectId,
|
||||
onSelectFile,
|
||||
loading: _loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
}: AntigravityFormProps) {
|
||||
const [mode, setMode] = useState<"login" | "file">("login");
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [waitingForCallback, setWaitingForCallback] = useState(false);
|
||||
|
||||
// 监听后端发送的授权 URL 事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ auth_url: string }>(
|
||||
"antigravity-auth-url",
|
||||
(event) => {
|
||||
setAuthUrl(event.payload.auth_url);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取授权 URL 并启动服务器等待回调
|
||||
const handleGetAuthUrl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAuthUrl(null);
|
||||
setWaitingForCallback(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.getAntigravityAuthUrlAndWait(trimmedName, false);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errorMsg);
|
||||
setWaitingForCallback(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入提交
|
||||
const handleFileSubmit = async () => {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addAntigravityOAuth(
|
||||
credsFilePath,
|
||||
projectId.trim() || undefined,
|
||||
trimmedName,
|
||||
);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
authUrl,
|
||||
waitingForCallback,
|
||||
handleGetAuthUrl,
|
||||
handleFileSubmit,
|
||||
render: () => (
|
||||
<>
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
loginLabel="Google 登录"
|
||||
fileLabel="导入文件"
|
||||
/>
|
||||
|
||||
{mode === "login" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
点击下方按钮获取授权
|
||||
URL,然后复制到浏览器(支持指纹浏览器)完成登录。
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
授权成功后,凭证将自动保存并添加到凭证池。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OAuthUrlDisplay
|
||||
authUrl={authUrl}
|
||||
waitingForCallback={waitingForCallback}
|
||||
colorScheme="blue"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 accounts.json 或 oauth_creds.json..."
|
||||
hint="支持 antigravity2api-nodejs 的 data/accounts.json 格式"
|
||||
projectId={projectId}
|
||||
setProjectId={setProjectId}
|
||||
showProjectId
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Claude OAuth 凭证添加表单
|
||||
* 支持 Claude OAuth 登录和文件导入两种模式
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { providerPoolApi } from "@/lib/api/providerPool";
|
||||
import { ModeSelector } from "./ModeSelector";
|
||||
import { FileImportForm } from "./FileImportForm";
|
||||
import { OAuthUrlDisplay } from "./OAuthUrlDisplay";
|
||||
|
||||
interface ClaudeOAuthFormProps {
|
||||
name: string;
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
onSelectFile: () => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ClaudeOAuthForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile,
|
||||
loading: _loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
}: ClaudeOAuthFormProps) {
|
||||
const [mode, setMode] = useState<"login" | "file">("login");
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [waitingForCallback, setWaitingForCallback] = useState(false);
|
||||
|
||||
// 监听后端发送的授权 URL 事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ auth_url: string }>(
|
||||
"claude-oauth-auth-url",
|
||||
(event) => {
|
||||
setAuthUrl(event.payload.auth_url);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取授权 URL 并启动服务器等待回调
|
||||
const handleGetAuthUrl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAuthUrl(null);
|
||||
setWaitingForCallback(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.getClaudeOAuthAuthUrlAndWait(trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errorMsg);
|
||||
setWaitingForCallback(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入提交
|
||||
const handleFileSubmit = async () => {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addClaudeOAuth(credsFilePath, trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
authUrl,
|
||||
waitingForCallback,
|
||||
handleGetAuthUrl,
|
||||
handleFileSubmit,
|
||||
render: () => (
|
||||
<>
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
loginLabel="Claude 登录"
|
||||
fileLabel="导入文件"
|
||||
/>
|
||||
|
||||
{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>
|
||||
) : (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 oauth.json 或 oauth_creds.json..."
|
||||
hint="默认路径: ~/.claude/oauth.json 或 Claude CLI 的凭证文件"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Codex 凭证添加表单
|
||||
* 支持 OpenAI OAuth 登录和文件导入两种模式
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { providerPoolApi } from "@/lib/api/providerPool";
|
||||
import { ModeSelector } from "./ModeSelector";
|
||||
import { FileImportForm } from "./FileImportForm";
|
||||
import { OAuthUrlDisplay } from "./OAuthUrlDisplay";
|
||||
|
||||
interface CodexFormProps {
|
||||
name: string;
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
onSelectFile: () => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function CodexForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile,
|
||||
loading: _loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
}: CodexFormProps) {
|
||||
const [mode, setMode] = useState<"login" | "file">("login");
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [waitingForCallback, setWaitingForCallback] = useState(false);
|
||||
|
||||
// 监听后端发送的授权 URL 事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ auth_url: string }>(
|
||||
"codex-auth-url",
|
||||
(event) => {
|
||||
setAuthUrl(event.payload.auth_url);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取授权 URL 并启动服务器等待回调
|
||||
const handleGetAuthUrl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAuthUrl(null);
|
||||
setWaitingForCallback(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.getCodexAuthUrlAndWait(trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errorMsg);
|
||||
setWaitingForCallback(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入提交
|
||||
const handleFileSubmit = async () => {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addCodexOAuth(credsFilePath, trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
authUrl,
|
||||
waitingForCallback,
|
||||
handleGetAuthUrl,
|
||||
handleFileSubmit,
|
||||
render: () => (
|
||||
<>
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
loginLabel="OpenAI 登录"
|
||||
fileLabel="导入文件"
|
||||
/>
|
||||
|
||||
{mode === "login" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950/30">
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
|
||||
OpenAI 登录。
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-green-600 dark:text-green-400">
|
||||
授权成功后,凭证将自动保存并添加到凭证池。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OAuthUrlDisplay
|
||||
authUrl={authUrl}
|
||||
waitingForCallback={waitingForCallback}
|
||||
colorScheme="green"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 auth.json 或 oauth.json..."
|
||||
hint="默认路径: ~/.codex/auth.json 或 Codex CLI 的凭证文件"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 文件导入表单组件
|
||||
*/
|
||||
|
||||
import { FolderOpen } from "lucide-react";
|
||||
|
||||
interface FileImportFormProps {
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
onSelectFile: () => void;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
projectId?: string;
|
||||
setProjectId?: (id: string) => void;
|
||||
showProjectId?: boolean;
|
||||
}
|
||||
|
||||
export function FileImportForm({
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile,
|
||||
placeholder = "选择凭证文件...",
|
||||
hint,
|
||||
projectId,
|
||||
setProjectId,
|
||||
showProjectId = false,
|
||||
}: FileImportFormProps) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
凭证文件路径 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={credsFilePath}
|
||||
onChange={(e) => setCredsFilePath(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectFile}
|
||||
className="flex items-center gap-1 rounded-lg border px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
|
||||
{showProjectId && setProjectId && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Project ID (可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={projectId || ""}
|
||||
onChange={(e) => setProjectId(e.target.value)}
|
||||
placeholder="Google Cloud Project ID..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Gemini 凭证添加表单
|
||||
* 支持 Google OAuth 登录和文件导入两种模式
|
||||
*
|
||||
* Gemini OAuth 流程:
|
||||
* 1. 生成授权 URL(包含 PKCE)
|
||||
* 2. 用户在浏览器中打开 URL 并授权
|
||||
* 3. 浏览器跳转到 codeassist.google.com/authcode 显示 code
|
||||
* 4. 用户复制 code 回应用
|
||||
* 5. 应用用 code 交换 tokens
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { providerPoolApi } from "@/lib/api/providerPool";
|
||||
import { ModeSelector } from "./ModeSelector";
|
||||
import { FileImportForm } from "./FileImportForm";
|
||||
import { Copy, Check, Loader2 } from "lucide-react";
|
||||
|
||||
interface GeminiFormProps {
|
||||
name: string;
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
projectId: string;
|
||||
setProjectId: (id: string) => void;
|
||||
onSelectFile: () => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function GeminiForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
projectId,
|
||||
setProjectId,
|
||||
onSelectFile,
|
||||
loading: _loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
}: GeminiFormProps) {
|
||||
const [mode, setMode] = useState<"login" | "file">("login");
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [authCode, setAuthCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [exchanging, setExchanging] = useState(false);
|
||||
|
||||
// 监听后端发送的授权 URL 事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ auth_url: string; session_id: string }>(
|
||||
"gemini-auth-url",
|
||||
(event) => {
|
||||
console.log("[Gemini OAuth] 收到授权 URL 事件:", event.payload);
|
||||
setAuthUrl(event.payload.auth_url);
|
||||
setSessionId(event.payload.session_id);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取授权 URL
|
||||
const handleGetAuthUrl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAuthUrl(null);
|
||||
setSessionId(null);
|
||||
setAuthCode("");
|
||||
|
||||
try {
|
||||
// 调用后端生成授权 URL
|
||||
await providerPoolApi.getGeminiAuthUrlAndWait(name.trim() || undefined);
|
||||
// URL 会通过事件返回
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
// 检查是否包含 AUTH_URL 前缀(后端返回的授权 URL)
|
||||
if (errorMsg.includes("AUTH_URL:")) {
|
||||
const urlMatch = errorMsg.match(/AUTH_URL:(.+?)(?:\s|$)/);
|
||||
if (urlMatch) {
|
||||
setAuthUrl(urlMatch[1]);
|
||||
}
|
||||
} else {
|
||||
setError(errorMsg);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 用 code 交换 token
|
||||
const handleExchangeCode = async () => {
|
||||
if (!authCode.trim()) {
|
||||
setError("请输入授权码");
|
||||
return;
|
||||
}
|
||||
|
||||
setExchanging(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.exchangeGeminiCode(
|
||||
authCode.trim(),
|
||||
sessionId || undefined,
|
||||
trimmedName,
|
||||
);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setExchanging(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 复制 URL
|
||||
const handleCopyUrl = async () => {
|
||||
if (authUrl) {
|
||||
await navigator.clipboard.writeText(authUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入提交
|
||||
const handleFileSubmit = async () => {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addGeminiOAuth(
|
||||
credsFilePath,
|
||||
projectId.trim() || undefined,
|
||||
trimmedName,
|
||||
);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
authUrl,
|
||||
sessionId,
|
||||
authCode,
|
||||
exchanging,
|
||||
handleGetAuthUrl,
|
||||
handleExchangeCode,
|
||||
handleFileSubmit,
|
||||
render: () => (
|
||||
<>
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
loginLabel="Google 登录"
|
||||
fileLabel="导入文件"
|
||||
/>
|
||||
|
||||
{mode === "login" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
点击下方按钮获取授权 URL,然后复制到浏览器完成 Google 登录。
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
授权成功后,复制页面显示的授权码粘贴到下方输入框。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 授权 URL 显示 */}
|
||||
{authUrl && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">授权 URL</span>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-blue-600 hover:bg-blue-100 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<p className="break-all text-xs text-muted-foreground">
|
||||
{authUrl.length > 100
|
||||
? `${authUrl.slice(0, 100)}...`
|
||||
: authUrl}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 授权码输入 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
授权码 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={authCode}
|
||||
onChange={(e) => setAuthCode(e.target.value)}
|
||||
placeholder="粘贴浏览器页面显示的授权码..."
|
||||
className="w-full rounded-lg border bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
在浏览器中完成授权后,复制页面显示的授权码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<button
|
||||
onClick={handleExchangeCode}
|
||||
disabled={exchanging || !authCode.trim()}
|
||||
className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{exchanging ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
验证中...
|
||||
</span>
|
||||
) : (
|
||||
"验证授权码"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 oauth_creds.json..."
|
||||
hint="默认路径: ~/.gemini/oauth_creds.json"
|
||||
projectId={projectId}
|
||||
setProjectId={setProjectId}
|
||||
showProjectId
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* iFlow 凭证添加表单
|
||||
* 支持 OAuth 登录和文件导入两种模式
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { providerPoolApi } from "@/lib/api/providerPool";
|
||||
import { ModeSelector } from "./ModeSelector";
|
||||
import { FileImportForm } from "./FileImportForm";
|
||||
import { OAuthUrlDisplay } from "./OAuthUrlDisplay";
|
||||
|
||||
interface IFlowFormProps {
|
||||
name: string;
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
onSelectFile: () => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function IFlowForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile,
|
||||
loading: _loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
}: IFlowFormProps) {
|
||||
const [mode, setMode] = useState<"login" | "file">("login");
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [waitingForCallback, setWaitingForCallback] = useState(false);
|
||||
|
||||
// 监听后端发送的授权 URL 事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ auth_url: string }>(
|
||||
"iflow-auth-url",
|
||||
(event) => {
|
||||
setAuthUrl(event.payload.auth_url);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 获取授权 URL 并启动服务器等待回调
|
||||
const handleGetAuthUrl = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setAuthUrl(null);
|
||||
setWaitingForCallback(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.getIFlowAuthUrlAndWait(trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errorMsg);
|
||||
setWaitingForCallback(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入提交
|
||||
const handleFileSubmit = async () => {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addIFlowOAuth(credsFilePath, trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
authUrl,
|
||||
waitingForCallback,
|
||||
handleGetAuthUrl,
|
||||
handleFileSubmit,
|
||||
render: () => (
|
||||
<>
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
loginLabel="iFlow 登录"
|
||||
fileLabel="导入文件"
|
||||
/>
|
||||
|
||||
{mode === "login" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-950/30">
|
||||
<p className="text-sm text-purple-700 dark:text-purple-300">
|
||||
点击下方按钮获取授权 URL,然后复制到浏览器(支持指纹浏览器)完成
|
||||
iFlow 登录。
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-purple-600 dark:text-purple-400">
|
||||
授权成功后,凭证将自动保存并添加到凭证池。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<OAuthUrlDisplay
|
||||
authUrl={authUrl}
|
||||
waitingForCallback={waitingForCallback}
|
||||
colorScheme="purple"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 auth.json 或 oauth_creds.json..."
|
||||
hint="默认路径: ~/.iflow/auth.json"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 登录/文件导入模式选择器
|
||||
*/
|
||||
|
||||
import { LogIn, FolderOpen } from "lucide-react";
|
||||
|
||||
interface ModeSelectorProps {
|
||||
mode: "login" | "file";
|
||||
setMode: (mode: "login" | "file") => void;
|
||||
loginLabel?: string;
|
||||
fileLabel?: string;
|
||||
}
|
||||
|
||||
export function ModeSelector({
|
||||
mode,
|
||||
setMode,
|
||||
loginLabel = "登录",
|
||||
fileLabel = "导入文件",
|
||||
}: ModeSelectorProps) {
|
||||
return (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("login")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
|
||||
mode === "login"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<LogIn className="inline h-4 w-4 mr-1" />
|
||||
{loginLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("file")}
|
||||
className={`flex-1 rounded-lg border px-3 py-2 text-sm ${
|
||||
mode === "file"
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="inline h-4 w-4 mr-1" />
|
||||
{fileLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* OAuth 授权 URL 显示组件
|
||||
* 用于显示授权 URL 和等待回调状态
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Copy, Check, Loader2 } from "lucide-react";
|
||||
|
||||
interface OAuthUrlDisplayProps {
|
||||
authUrl: string | null;
|
||||
waitingForCallback: boolean;
|
||||
colorScheme?: "blue" | "green" | "purple" | "amber";
|
||||
}
|
||||
|
||||
export function OAuthUrlDisplay({
|
||||
authUrl,
|
||||
waitingForCallback,
|
||||
colorScheme: _colorScheme = "blue",
|
||||
}: OAuthUrlDisplayProps) {
|
||||
const [urlCopied, setUrlCopied] = useState(false);
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (authUrl) {
|
||||
navigator.clipboard.writeText(authUrl);
|
||||
setUrlCopied(true);
|
||||
setTimeout(() => setUrlCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
if (!authUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border bg-muted/50 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium">授权 URL</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs hover:bg-muted"
|
||||
>
|
||||
{urlCopied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-500" />
|
||||
<span className="text-green-500">已复制</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" />
|
||||
<span>复制</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground break-all font-mono">
|
||||
{authUrl.slice(0, 100)}...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{waitingForCallback && (
|
||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-3 dark:border-yellow-800 dark:bg-yellow-950/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-yellow-600" />
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
请复制上方 URL 到浏览器完成登录,正在等待授权回调...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Qwen 凭证添加表单
|
||||
* 支持 Device Code Flow 登录和文件导入两种模式
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
import { providerPoolApi } from "@/lib/api/providerPool";
|
||||
import { ModeSelector } from "./ModeSelector";
|
||||
import { FileImportForm } from "./FileImportForm";
|
||||
import { Copy, Check, ExternalLink } from "lucide-react";
|
||||
|
||||
interface QwenFormProps {
|
||||
name: string;
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
onSelectFile: () => void;
|
||||
loading: boolean;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface DeviceCodeInfo {
|
||||
user_code: string;
|
||||
verification_uri: string;
|
||||
verification_uri_complete?: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
export function QwenForm({
|
||||
name,
|
||||
credsFilePath,
|
||||
setCredsFilePath,
|
||||
onSelectFile,
|
||||
loading: _loading,
|
||||
setLoading,
|
||||
setError,
|
||||
onSuccess,
|
||||
}: QwenFormProps) {
|
||||
const [mode, setMode] = useState<"login" | "file">("login");
|
||||
const [deviceCode, setDeviceCode] = useState<DeviceCodeInfo | null>(null);
|
||||
const [waitingForAuth, setWaitingForAuth] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// 监听后端发送的设备码事件
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<DeviceCodeInfo>("qwen-device-code", (event) => {
|
||||
setDeviceCode(event.payload);
|
||||
});
|
||||
};
|
||||
|
||||
setupListener();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 复制用户码
|
||||
const handleCopyCode = async () => {
|
||||
if (deviceCode?.user_code) {
|
||||
await navigator.clipboard.writeText(deviceCode.user_code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 打开验证链接
|
||||
const handleOpenVerificationUrl = async () => {
|
||||
if (deviceCode) {
|
||||
const url =
|
||||
deviceCode.verification_uri_complete || deviceCode.verification_uri;
|
||||
try {
|
||||
await open(url);
|
||||
} catch (e) {
|
||||
console.error("Failed to open URL:", e);
|
||||
// 如果 Tauri shell 失败,尝试使用 window.open 作为后备
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 获取设备码并启动轮询
|
||||
const handleGetDeviceCode = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setDeviceCode(null);
|
||||
setWaitingForAuth(true);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.getQwenDeviceCodeAndWait(trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
setError(errorMsg);
|
||||
setWaitingForAuth(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 文件导入提交
|
||||
const handleFileSubmit = async () => {
|
||||
if (!credsFilePath) {
|
||||
setError("请选择凭证文件");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const trimmedName = name.trim() || undefined;
|
||||
await providerPoolApi.addQwenOAuth(credsFilePath, trimmedName);
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mode,
|
||||
deviceCode,
|
||||
waitingForAuth,
|
||||
handleGetDeviceCode,
|
||||
handleFileSubmit,
|
||||
render: () => (
|
||||
<>
|
||||
<ModeSelector
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
loginLabel="Qwen 登录"
|
||||
fileLabel="导入文件"
|
||||
/>
|
||||
|
||||
{mode === "login" ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
点击下方按钮获取设备码,然后在浏览器中完成 Qwen 登录授权。
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
授权成功后,凭证将自动保存并添加到凭证池。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{deviceCode && (
|
||||
<div className="space-y-3">
|
||||
{/* 用户码显示 */}
|
||||
<div className="rounded-lg border border-blue-300 bg-blue-100 p-4 dark:border-blue-700 dark:bg-blue-900/50">
|
||||
<p className="mb-2 text-sm font-medium text-blue-800 dark:text-blue-200">
|
||||
请在浏览器中输入以下验证码:
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<code className="text-2xl font-bold tracking-wider text-blue-900 dark:text-blue-100">
|
||||
{deviceCode.user_code}
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopyCode}
|
||||
className="flex items-center gap-1 rounded-lg border border-blue-300 bg-white px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-50 dark:border-blue-600 dark:bg-blue-800 dark:text-blue-200 dark:hover:bg-blue-700"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 验证链接 */}
|
||||
<button
|
||||
onClick={handleOpenVerificationUrl}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-blue-300 bg-white px-4 py-2 text-sm text-blue-700 hover:bg-blue-50 dark:border-blue-600 dark:bg-blue-800 dark:text-blue-200 dark:hover:bg-blue-700"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
打开验证页面
|
||||
</button>
|
||||
|
||||
{waitingForAuth && (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-blue-600 dark:text-blue-400">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
|
||||
等待授权中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FileImportForm
|
||||
credsFilePath={credsFilePath}
|
||||
setCredsFilePath={setCredsFilePath}
|
||||
onSelectFile={onSelectFile}
|
||||
placeholder="选择 oauth_creds.json..."
|
||||
hint="默认路径: ~/.qwen/oauth_creds.json"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 凭证表单组件导出
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export * from "./ModeSelector";
|
||||
export * from "./OAuthUrlDisplay";
|
||||
export * from "./FileImportForm";
|
||||
export * from "./AntigravityForm";
|
||||
export * from "./CodexForm";
|
||||
export * from "./ClaudeOAuthForm";
|
||||
export * from "./QwenForm";
|
||||
export * from "./IFlowForm";
|
||||
export * from "./GeminiForm";
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 凭证表单共享类型定义
|
||||
*/
|
||||
|
||||
import { PoolProviderType } from "@/lib/api/providerPool";
|
||||
|
||||
/** OAuth 登录表单的通用 Props */
|
||||
export interface OAuthLoginFormProps {
|
||||
name: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onSuccess: () => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
}
|
||||
|
||||
/** 文件导入表单的通用 Props */
|
||||
export interface FileImportFormProps {
|
||||
credsFilePath: string;
|
||||
setCredsFilePath: (path: string) => void;
|
||||
projectId?: string;
|
||||
setProjectId?: (id: string) => void;
|
||||
onSelectFile: () => void;
|
||||
defaultPathHint?: string;
|
||||
fileHint?: string;
|
||||
}
|
||||
|
||||
/** API Key 表单的通用 Props */
|
||||
export interface ApiKeyFormProps {
|
||||
apiKey: string;
|
||||
setApiKey: (key: string) => void;
|
||||
baseUrl: string;
|
||||
setBaseUrl: (url: string) => void;
|
||||
providerType: PoolProviderType;
|
||||
}
|
||||
|
||||
/** 默认凭证文件路径 */
|
||||
export const defaultCredsPath: Record<string, string> = {
|
||||
kiro: "~/.aws/sso/cache/kiro-auth-token.json",
|
||||
gemini: "~/.gemini/oauth_creds.json",
|
||||
qwen: "~/.qwen/oauth_creds.json",
|
||||
antigravity: "",
|
||||
codex: "~/.codex/auth.json",
|
||||
claude_oauth: "~/.claude/oauth.json",
|
||||
iflow: "~/.iflow/oauth_creds.json",
|
||||
};
|
||||
|
||||
/** Provider 显示名称 */
|
||||
export const providerLabels: Record<PoolProviderType, string> = {
|
||||
kiro: "Kiro (AWS)",
|
||||
gemini: "Gemini (Google)",
|
||||
qwen: "Qwen (阿里)",
|
||||
openai: "OpenAI",
|
||||
claude: "Claude (Anthropic)",
|
||||
antigravity: "Antigravity (Gemini 3 Pro)",
|
||||
codex: "Codex (OpenAI OAuth)",
|
||||
claude_oauth: "Claude OAuth",
|
||||
iflow: "iFlow",
|
||||
};
|
||||
@@ -383,6 +383,72 @@ export const providerPoolApi = {
|
||||
});
|
||||
},
|
||||
|
||||
// Codex OAuth 登录(打开浏览器授权)
|
||||
async startCodexOAuthLogin(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("start_codex_oauth_login", { name });
|
||||
},
|
||||
|
||||
// 获取 Codex OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
// 服务器会在后台等待回调,成功后返回凭证
|
||||
async getCodexAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("get_codex_auth_url_and_wait", { name });
|
||||
},
|
||||
|
||||
// Claude OAuth 登录(打开浏览器授权)
|
||||
async startClaudeOAuthLogin(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("start_claude_oauth_login", { name });
|
||||
},
|
||||
|
||||
// 获取 Claude OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
// 服务器会在后台等待回调,成功后返回凭证
|
||||
async getClaudeOAuthAuthUrlAndWait(
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("get_claude_oauth_auth_url_and_wait", { name });
|
||||
},
|
||||
|
||||
// Qwen Device Code Flow 登录(打开浏览器授权)
|
||||
async startQwenDeviceCodeLogin(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("start_qwen_device_code_login", { name });
|
||||
},
|
||||
|
||||
// 获取 Qwen Device Code 并等待用户授权(不自动打开浏览器)
|
||||
// 服务器会在后台轮询等待授权,成功后返回凭证
|
||||
async getQwenDeviceCodeAndWait(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("get_qwen_device_code_and_wait", { name });
|
||||
},
|
||||
|
||||
// iFlow OAuth 登录(打开浏览器授权)
|
||||
async startIFlowOAuthLogin(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("start_iflow_oauth_login", { name });
|
||||
},
|
||||
|
||||
// 获取 iFlow OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
// 服务器会在后台等待回调,成功后返回凭证
|
||||
async getIFlowAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("get_iflow_auth_url_and_wait", { name });
|
||||
},
|
||||
|
||||
// Gemini OAuth 登录(打开浏览器授权)
|
||||
async startGeminiOAuthLogin(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("start_gemini_oauth_login", { name });
|
||||
},
|
||||
|
||||
// 获取 Gemini OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
// 服务器会在后台等待回调,成功后返回凭证
|
||||
async getGeminiAuthUrlAndWait(name?: string): Promise<ProviderCredential> {
|
||||
return invoke("get_gemini_auth_url_and_wait", { name });
|
||||
},
|
||||
|
||||
// 用 Gemini 授权码交换 token
|
||||
async exchangeGeminiCode(
|
||||
code: string,
|
||||
sessionId?: string,
|
||||
name?: string,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("exchange_gemini_code", { code, sessionId, name });
|
||||
},
|
||||
|
||||
// OAuth token management
|
||||
async refreshCredentialToken(uuid: string): Promise<string> {
|
||||
return invoke("refresh_pool_credential_token", { uuid });
|
||||
|
||||
Reference in New Issue
Block a user