diff --git a/package.json b/package.json index 645ad08ae..4908778ac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.14.6", + "version": "0.14.8", "type": "module", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index dfa4639a1..f23c4b1af 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3367,7 +3367,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.14.5" +version = "0.14.8" dependencies = [ "anyhow", "async-stream", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b0879f7df..05629bc3e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.14.6" +version = "0.14.8" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index f2caa0ff4..10c73bffc 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -12,7 +12,7 @@ use chrono::Utc; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; -use tauri::State; +use tauri::{Emitter, State}; use uuid::Uuid; pub struct ProviderPoolServiceState(pub Arc); @@ -1132,3 +1132,120 @@ pub struct MigrationResultResponse { /// 错误信息列表 pub errors: Vec, } + +/// 获取 Antigravity OAuth 授权 URL 并等待回调(不自动打开浏览器) +/// +/// 启动服务器后通过事件发送授权 URL,然后等待回调 +/// 成功后返回凭证 +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AntigravityAuthUrlResponse { + pub auth_url: String, +} + +#[tauri::command] +pub async fn get_antigravity_auth_url_and_wait( + app: tauri::AppHandle, + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, + skip_project_id_fetch: Option, +) -> Result { + use crate::providers::antigravity; + + tracing::info!("[Antigravity OAuth] 启动服务器并获取授权 URL"); + + // 启动服务器并获取授权 URL + let (auth_url, wait_future) = + antigravity::start_oauth_server_and_get_url(skip_project_id_fetch.unwrap_or(false)) + .await + .map_err(|e| format!("启动 OAuth 服务器失败: {}", e))?; + + tracing::info!("[Antigravity OAuth] 授权 URL: {}", auth_url); + + // 通过事件发送授权 URL 给前端 + let _ = app.emit( + "antigravity-auth-url", + AntigravityAuthUrlResponse { + auth_url: auth_url.clone(), + }, + ); + + // 等待回调 + let result = wait_future.await.map_err(|e| e.to_string())?; + + tracing::info!( + "[Antigravity OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 从凭证中获取 project_id + let project_id = result.credentials.projectId.clone(); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "antigravity", + CredentialData::AntigravityOAuth { + creds_file_path: result.creds_file_path, + project_id, + }, + name, + Some(true), + None, + )?; + + tracing::info!( + "[Antigravity OAuth] 凭证已添加到凭证池: {}", + credential.uuid + ); + + Ok(credential) +} + +/// 启动 Antigravity OAuth 登录流程 +/// +/// 打开浏览器让用户登录 Google 账号,获取 Antigravity 凭证 +#[tauri::command] +pub async fn start_antigravity_oauth_login( + db: State<'_, DbConnection>, + pool_service: State<'_, ProviderPoolServiceState>, + name: Option, + skip_project_id_fetch: Option, +) -> Result { + use crate::providers::antigravity; + + tracing::info!("[Antigravity OAuth] 开始 OAuth 登录流程"); + + // 启动 OAuth 登录 + let result = antigravity::start_oauth_login(skip_project_id_fetch.unwrap_or(false)) + .await + .map_err(|e| format!("Antigravity OAuth 登录失败: {}", e))?; + + tracing::info!( + "[Antigravity OAuth] 登录成功,凭证保存到: {}", + result.creds_file_path + ); + + // 从凭证中获取 project_id + let project_id = result.credentials.projectId.clone(); + + // 添加到凭证池 + let credential = pool_service.0.add_credential( + &db, + "antigravity", + CredentialData::AntigravityOAuth { + creds_file_path: result.creds_file_path, + project_id, + }, + name, + Some(true), + None, + )?; + + tracing::info!( + "[Antigravity OAuth] 凭证已添加到凭证池: {}", + credential.uuid + ); + + Ok(credential) +} diff --git a/src-tauri/src/converter/openai_to_antigravity.rs b/src-tauri/src/converter/openai_to_antigravity.rs index 26a7ec977..7753bbb9e 100644 --- a/src-tauri/src/converter/openai_to_antigravity.rs +++ b/src-tauri/src/converter/openai_to_antigravity.rs @@ -1,9 +1,11 @@ //! OpenAI 格式转换为 Antigravity (Gemini) 格式 use crate::models::openai::*; use serde::{Deserialize, Serialize}; +use uuid::Uuid; /// Antigravity/Gemini 内容部分 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GeminiPart { #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, @@ -16,6 +18,7 @@ pub struct GeminiPart { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct InlineData { pub mime_type: String, pub data: String, @@ -23,12 +26,16 @@ pub struct InlineData { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeminiFunctionCall { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, pub name: String, pub args: serde_json::Value, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GeminiFunctionResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, pub name: String, pub response: serde_json::Value, } @@ -42,6 +49,7 @@ pub struct GeminiContent { /// Antigravity/Gemini 工具定义 #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct GeminiTool { pub function_declarations: Vec, } @@ -69,12 +77,23 @@ pub struct GeminiGenerationConfig { pub top_k: Option, #[serde(skip_serializing_if = "Option::is_none")] pub stop_sequences: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking_config: Option, } -/// Antigravity 请求体 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AntigravityRequestBody { +pub struct ThinkingConfig { + pub include_thoughts: bool, + pub thinking_budget: i32, +} + +/// Antigravity 请求体内部结构 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AntigravityRequestInner { pub contents: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub system_instruction: Option, @@ -82,10 +101,59 @@ pub struct AntigravityRequestBody { pub generation_config: Option, #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +/// 生成随机请求 ID +fn generate_request_id() -> String { + format!("agent-{}", Uuid::new_v4()) +} + +/// 生成随机会话 ID +fn generate_session_id() -> String { + let uuid = Uuid::new_v4(); + let bytes = uuid.as_bytes(); + let n: u64 = u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]) % 9_000_000_000_000_000_000; + format!("-{}", n) +} + +/// 模型名称映射 +fn model_mapping(model: &str) -> &str { + match model { + "claude-sonnet-4-5-thinking" => "claude-sonnet-4-5", + "claude-opus-4-5" => "claude-opus-4-5-thinking", + "gemini-2.5-flash-thinking" => "gemini-2.5-flash", + "gemini-2.5-computer-use-preview-10-2025" => "rev19-uic3-1p", + "gemini-3-pro-image-preview" => "gemini-3-pro-image", + "gemini-3-pro-preview" => "gemini-3-pro-high", + "gemini-claude-sonnet-4-5" => "claude-sonnet-4-5", + "gemini-claude-sonnet-4-5-thinking" => "claude-sonnet-4-5-thinking", + _ => model, + } +} + +/// 是否启用思维链 +fn is_enable_thinking(model: &str) -> bool { + model.ends_with("-thinking") + || model == "gemini-2.5-pro" + || model.starts_with("gemini-3-pro-") + || model == "rev19-uic3-1p" + || model == "gpt-oss-120b-medium" } /// 将 OpenAI ChatCompletionRequest 转换为 Antigravity 请求体 -pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_json::Value { +pub fn convert_openai_to_antigravity_with_context( + request: &ChatCompletionRequest, + project_id: &str, +) -> serde_json::Value { + let actual_model = model_mapping(&request.model); + let enable_thinking = is_enable_thinking(&request.model); + let mut contents: Vec = Vec::new(); let mut system_instruction: Option = None; @@ -116,35 +184,67 @@ pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_j } } "assistant" => { - let parts = convert_assistant_content(msg); + let parts = convert_assistant_content(msg, &contents); if !parts.is_empty() { - contents.push(GeminiContent { - role: "model".to_string(), - parts, - }); + // 检查是否需要合并到上一条 model 消息 + let should_merge = if let Some(last) = contents.last() { + last.role == "model" + && msg.tool_calls.is_some() + && msg.get_content_text().is_empty() + } else { + false + }; + + if should_merge { + if let Some(last) = contents.last_mut() { + last.parts.extend(parts); + } + } else { + contents.push(GeminiContent { + role: "model".to_string(), + parts, + }); + } } } "tool" => { - // Tool 响应需要合并到 user 消息 + // Tool 响应 let tool_id = msg.tool_call_id.clone().unwrap_or_default(); let content = msg.get_content_text(); - // 尝试解析为 JSON,否则包装为对象 - let response_value = serde_json::from_str(&content) - .unwrap_or_else(|_| serde_json::json!({ "result": content })); + // 从之前的 model 消息中找到对应的 functionCall name + let function_name = find_function_name(&contents, &tool_id); - contents.push(GeminiContent { - role: "user".to_string(), - parts: vec![GeminiPart { - text: None, - inline_data: None, - function_call: None, - function_response: Some(GeminiFunctionResponse { - name: tool_id, - response: response_value, - }), - }], - }); + let response_value = serde_json::json!({ "output": content }); + + let function_response = GeminiPart { + text: None, + inline_data: None, + function_call: None, + function_response: Some(GeminiFunctionResponse { + id: Some(tool_id), + name: function_name, + response: response_value, + }), + }; + + // 检查是否需要合并到上一条 user 消息 + let should_merge = if let Some(last) = contents.last() { + last.role == "user" && last.parts.iter().any(|p| p.function_response.is_some()) + } else { + false + }; + + if should_merge { + if let Some(last) = contents.last_mut() { + last.parts.push(function_response); + } + } else { + contents.push(GeminiContent { + role: "user".to_string(), + parts: vec![function_response], + }); + } } _ => {} } @@ -152,40 +252,120 @@ pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_j // 构建生成配置 let generation_config = Some(GeminiGenerationConfig { - temperature: request.temperature, - max_output_tokens: request.max_tokens.map(|t| t as i32), - top_p: None, - top_k: None, - stop_sequences: None, + temperature: request.temperature.or(Some(1.0)), + max_output_tokens: request.max_tokens.map(|t| t as i32).or(Some(8096)), + top_p: Some(0.85), + top_k: Some(50), + stop_sequences: Some(vec![ + "<|user|>".to_string(), + "<|bot|>".to_string(), + "<|context_request|>".to_string(), + "<|endoftext|>".to_string(), + "<|end_of_turn|>".to_string(), + ]), + candidate_count: Some(1), + thinking_config: Some(ThinkingConfig { + include_thoughts: enable_thinking, + thinking_budget: if enable_thinking { 1024 } else { 0 }, + }), }); // 转换工具 let tools = request.tools.as_ref().map(|tools| { - vec![GeminiTool { - function_declarations: tools - .iter() - .map(|t| GeminiFunctionDeclaration { + tools + .iter() + .map(|t| GeminiTool { + function_declarations: vec![GeminiFunctionDeclaration { name: t.function.name.clone(), description: t.function.description.clone(), - parameters: t.function.parameters.clone(), - }) - .collect(), - }] + parameters: clean_parameters(t.function.parameters.clone()), + }], + }) + .collect() }); - let body = AntigravityRequestBody { + let tool_config = if tools.is_some() { + Some(serde_json::json!({ + "functionCallingConfig": { + "mode": "VALIDATED" + } + })) + } else { + None + }; + + let inner = AntigravityRequestInner { contents, system_instruction, generation_config, tools, + tool_config, + session_id: Some(generate_session_id()), }; - // 包装为 Antigravity 请求格式 + // 构建完整的 Antigravity 请求体 serde_json::json!({ - "request": body + "project": project_id, + "requestId": generate_request_id(), + "request": inner, + "model": actual_model, + "userAgent": "antigravity" }) } +/// 从之前的 model 消息中找到对应的 functionCall name +fn find_function_name(contents: &[GeminiContent], tool_id: &str) -> String { + for content in contents.iter().rev() { + if content.role == "model" { + for part in &content.parts { + if let Some(fc) = &part.function_call { + if fc.id.as_deref() == Some(tool_id) { + return fc.name.clone(); + } + } + } + } + } + String::new() +} + +/// 清理参数中不需要的字段 +fn clean_parameters(params: Option) -> Option { + params.map(|v| clean_value(v)) +} + +fn clean_value(value: serde_json::Value) -> serde_json::Value { + const EXCLUDED_KEYS: &[&str] = &[ + "$schema", + "additionalProperties", + "minLength", + "maxLength", + "minItems", + "maxItems", + "uniqueItems", + ]; + + match value { + serde_json::Value::Object(map) => { + let cleaned: serde_json::Map = map + .into_iter() + .filter(|(k, _)| !EXCLUDED_KEYS.contains(&k.as_str())) + .map(|(k, v)| (k, clean_value(v))) + .collect(); + serde_json::Value::Object(cleaned) + } + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.into_iter().map(clean_value).collect()) + } + other => other, + } +} + +/// 兼容旧接口 +pub fn convert_openai_to_antigravity(request: &ChatCompletionRequest) -> serde_json::Value { + convert_openai_to_antigravity_with_context(request, "") +} + /// 转换用户消息内容 fn convert_user_content(msg: &ChatMessage) -> Vec { let mut parts = Vec::new(); @@ -234,14 +414,14 @@ fn convert_user_content(msg: &ChatMessage) -> Vec { } /// 转换助手消息内容 -fn convert_assistant_content(msg: &ChatMessage) -> Vec { +fn convert_assistant_content(msg: &ChatMessage, _contents: &[GeminiContent]) -> Vec { let mut parts = Vec::new(); // 文本内容 let text = msg.get_content_text(); if !text.is_empty() { parts.push(GeminiPart { - text: Some(text), + text: Some(text.trim_end().to_string()), inline_data: None, function_call: None, function_response: None, @@ -258,8 +438,9 @@ fn convert_assistant_content(msg: &ChatMessage) -> Vec { text: None, inline_data: None, function_call: Some(GeminiFunctionCall { + id: Some(tc.id.clone()), name: tc.function.name.clone(), - args, + args: serde_json::json!({ "query": args }), }), function_response: None, }); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 579e3cee3..a4494cd41 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1681,6 +1681,8 @@ pub fn run() { commands::provider_pool_cmd::debug_kiro_credentials, commands::provider_pool_cmd::test_user_credentials, 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, // Route commands commands::route_cmd::get_available_routes, commands::route_cmd::get_route_curl_examples, diff --git a/src-tauri/src/models/provider_pool_model.rs b/src-tauri/src/models/provider_pool_model.rs index c18c2c9c6..39d3e4217 100644 --- a/src-tauri/src/models/provider_pool_model.rs +++ b/src-tauri/src/models/provider_pool_model.rs @@ -334,6 +334,7 @@ impl ProviderCredential { /// 检查两个来源的排除列表: /// 1. `not_supported_models` - 通用的不支持模型列表(精确匹配) /// 2. `excluded_models` - 来自 CredentialData::GeminiApiKey 的排除列表(支持通配符) + /// 3. Antigravity 凭证只支持特定的模型列表 pub fn supports_model(&self, model: &str) -> bool { // 检查通用的不支持模型列表(精确匹配) if self.not_supported_models.contains(&model.to_string()) { @@ -352,6 +353,19 @@ impl ProviderCredential { } } + // Antigravity 凭证只支持特定的模型 + if let CredentialData::AntigravityOAuth { .. } = &self.credential { + // Antigravity 支持的模型列表 + const ANTIGRAVITY_SUPPORTED_MODELS: &[&str] = &[ + "gemini-3-pro-preview", + "gemini-3-pro-image-preview", + "gemini-2.5-computer-use-preview-10-2025", + "gemini-claude-sonnet-4-5", + "gemini-claude-sonnet-4-5-thinking", + ]; + return ANTIGRAVITY_SUPPORTED_MODELS.contains(&model); + } + true } diff --git a/src-tauri/src/providers/antigravity.rs b/src-tauri/src/providers/antigravity.rs index ad03af118..842886e60 100644 --- a/src-tauri/src/providers/antigravity.rs +++ b/src-tauri/src/providers/antigravity.rs @@ -6,6 +6,8 @@ use reqwest::Client; use serde::{Deserialize, Serialize}; use std::error::Error; use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::oneshot; use uuid::Uuid; // Constants @@ -16,9 +18,18 @@ const CREDENTIALS_DIR: &str = ".antigravity"; const CREDENTIALS_FILE: &str = "oauth_creds.json"; // OAuth credentials - 与 Antigravity CLI 相同 -const OAUTH_CLIENT_ID: &str = +pub const OAUTH_CLIENT_ID: &str = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"; -const OAUTH_CLIENT_SECRET: &str = "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"; +pub const OAUTH_CLIENT_SECRET: &str = "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"; + +// OAuth scopes +const OAUTH_SCOPES: &[&str] = &[ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", +]; // Token 刷新提前量(秒) const REFRESH_SKEW: i64 = 3000; @@ -93,8 +104,7 @@ pub struct AntigravityCredentials { /// 过期时间戳(毫秒)- 兼容旧格式 #[serde(skip_serializing_if = "Option::is_none")] pub expiry_date: Option, - /// 过期时间(RFC3339 格式)- 与 CLIProxyAPI 兼容 - #[serde(skip_serializing_if = "Option::is_none")] + /// 过期时间(RFC3339 格式)- pub expire: Option, pub scope: Option, /// 最后刷新时间(RFC3339 格式) @@ -103,6 +113,21 @@ pub struct AntigravityCredentials { /// 凭证类型标识 #[serde(default = "default_antigravity_type", rename = "type")] pub cred_type: String, + /// Token 有效期(秒) + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_in: Option, + /// Token 获取时间戳(毫秒)- + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + /// 是否启用 - + #[serde(skip_serializing_if = "Option::is_none")] + pub enable: Option, + /// 项目 ID + #[serde(skip_serializing_if = "Option::is_none", alias = "project_id")] + pub projectId: Option, + /// 用户邮箱 + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, } fn default_antigravity_type() -> String { @@ -120,6 +145,11 @@ impl Default for AntigravityCredentials { scope: None, last_refresh: None, cred_type: default_antigravity_type(), + expires_in: None, + timestamp: None, + enable: None, + projectId: None, + email: None, } } } @@ -180,9 +210,32 @@ impl AntigravityProvider { path: &str, ) -> Result<(), Box> { let content = tokio::fs::read_to_string(path).await?; - let creds: AntigravityCredentials = serde_json::from_str(&content)?; - self.credentials = creds; - Ok(()) + + // 尝试解析为单个凭证对象 + if let Ok(creds) = serde_json::from_str::(&content) { + self.credentials = creds; + // 如果凭证中有 projectId,设置到 provider + if let Some(ref pid) = self.credentials.projectId { + self.project_id = Some(pid.clone()); + } + return Ok(()); + } + + // 尝试解析为数组格式(兼容 antigravity2api-nodejs 的 accounts.json) + if let Ok(creds_array) = serde_json::from_str::>(&content) { + // 找到第一个启用的凭证 + if let Some(creds) = creds_array.into_iter().find(|c| c.enable != Some(false)) { + self.credentials = creds; + // 如果凭证中有 projectId,设置到 provider + if let Some(ref pid) = self.credentials.projectId { + self.project_id = Some(pid.clone()); + } + return Ok(()); + } + return Err("凭证文件中没有可用的账号(所有账号都被禁用)".into()); + } + + Err("无法解析凭证文件,请确保是有效的 JSON 格式".into()) } pub async fn save_credentials(&self) -> Result<(), Box> { @@ -200,6 +253,11 @@ impl AntigravityProvider { return false; } + // 检查是否被禁用 + if self.credentials.enable == Some(false) { + return false; + } + // 优先检查 RFC3339 格式的过期时间 if let Some(expire_str) = &self.credentials.expire { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(expire_str) { @@ -215,6 +273,17 @@ impl AntigravityProvider { // Token valid if more than 5 minutes until expiry return expiry > now + 300_000; } + + // 兼容 antigravity2api-nodejs 格式:timestamp + expires_in + if let (Some(timestamp), Some(expires_in)) = + (self.credentials.timestamp, self.credentials.expires_in) + { + let expiry = timestamp + (expires_in * 1000); + let now = chrono::Utc::now().timestamp_millis(); + // Token valid if more than 5 minutes until expiry + return expiry > now + 300_000; + } + true } @@ -234,6 +303,17 @@ impl AntigravityProvider { let refresh_skew_ms = REFRESH_SKEW * 1000; return expiry <= now + refresh_skew_ms; } + + // 兼容 antigravity2api-nodejs 格式:timestamp + expires_in + if let (Some(timestamp), Some(expires_in)) = + (self.credentials.timestamp, self.credentials.expires_in) + { + let expiry = timestamp + (expires_in * 1000); + let now = chrono::Utc::now().timestamp_millis(); + let refresh_skew_ms = REFRESH_SKEW * 1000; + return expiry <= now + refresh_skew_ms; + } + true } @@ -272,11 +352,18 @@ impl AntigravityProvider { self.credentials.access_token = Some(new_token.to_string()); - // 更新过期时间(同时保存两种格式以兼容) + // 更新过期时间(同时保存多种格式以兼容) if let Some(expires_in) = data["expires_in"].as_i64() { - let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in); + let now = chrono::Utc::now(); + let expires_at = now + chrono::Duration::seconds(expires_in); + + // RFC3339 格式 self.credentials.expire = Some(expires_at.to_rfc3339()); + // 毫秒时间戳格式 self.credentials.expiry_date = Some(expires_at.timestamp_millis()); + // antigravity2api-nodejs 格式 + self.credentials.expires_in = Some(expires_in); + self.credentials.timestamp = Some(now.timestamp_millis()); } // 如果返回了新的 refresh_token,也更新它 @@ -520,3 +607,917 @@ impl AntigravityProvider { self.available_models.iter().any(|m| m == model) } } + +// ============================================================================ +// OAuth 登录功能 +// ============================================================================ + +/// OAuth 回调结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthCallbackResult { + pub code: String, + pub state: String, +} + +/// OAuth 登录成功后的凭证信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AntigravityOAuthResult { + pub credentials: AntigravityCredentials, + pub creds_file_path: String, +} + +/// 生成 OAuth 授权 URL +pub fn generate_auth_url(port: u16, state: &str) -> String { + let scopes = OAUTH_SCOPES.join(" "); + let redirect_uri = format!("http://localhost:{}/oauth-callback", port); + + let params = [ + ("access_type", "offline"), + ("client_id", OAUTH_CLIENT_ID), + ("prompt", "consent"), + ("redirect_uri", &redirect_uri), + ("response_type", "code"), + ("scope", &scopes), + ("state", state), + ]; + + let query = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + + format!("https://accounts.google.com/o/oauth2/v2/auth?{}", query) +} + +/// 用授权码交换 Token +pub async fn exchange_code_for_token( + client: &Client, + code: &str, + redirect_uri: &str, +) -> Result> { + let params = [ + ("code", code), + ("client_id", OAUTH_CLIENT_ID), + ("client_secret", OAUTH_CLIENT_SECRET), + ("redirect_uri", redirect_uri), + ("grant_type", "authorization_code"), + ]; + + let resp = client + .post("https://oauth2.googleapis.com/token") + .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_user_email( + client: &Client, + access_token: &str, +) -> Result, Box> { + 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(验证账号资格) +/// 返回值说明: +/// - Ok(Some(FetchedProjectId::HasProject(id))) - 有资格,且有 projectId +/// - Ok(Some(FetchedProjectId::NoProject)) - 有资格,但 projectId 为空(需要生成随机 ID) +/// - Ok(None) - 无资格(字段不存在,即 undefined) +/// - Err(_) - 请求失败 +#[derive(Debug, Clone)] +pub enum FetchedProjectId { + /// 有 projectId + HasProject(String), + /// projectId 为空字符串(有资格但无 projectId) + NoProject, +} + +pub async fn fetch_project_id_for_oauth( + client: &Client, + access_token: &str, +) -> Result, Box> { + tracing::info!("[Antigravity OAuth] 正在获取 projectId..."); + + let resp = client + .post("https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist") + .header("Authorization", format!("Bearer {}", access_token)) + .header("User-Agent", "antigravity/1.11.9 windows/amd64") + .header("Content-Type", "application/json") + .json(&serde_json::json!({ "metadata": { "ideType": "ANTIGRAVITY" } })) + .send() + .await?; + + let status = resp.status(); + tracing::info!("[Antigravity OAuth] loadCodeAssist 响应状态: {}", status); + + if status.is_success() { + let body_text = resp.text().await?; + tracing::info!("[Antigravity OAuth] loadCodeAssist 响应体: {}", body_text); + + let data: serde_json::Value = serde_json::from_str(&body_text)?; + // 检查字段是否存在 + // - 如果字段不存在(undefined)-> None(无资格) + // - 如果字段存在但为空字符串 -> Some(NoProject)(有资格但无 projectId) + // - 如果字段存在且有值 -> Some(HasProject(id))(有资格且有 projectId) + match data.get("cloudaicompanionProject") { + None => { + tracing::warn!("[Antigravity OAuth] cloudaicompanionProject 字段不存在"); + Ok(None) // 字段不存在,无资格 + } + Some(value) => { + if value.is_null() { + tracing::warn!("[Antigravity OAuth] cloudaicompanionProject 为 null"); + Ok(None) // null 也视为无资格 + } else if let Some(s) = value.as_str() { + if s.is_empty() { + tracing::info!("[Antigravity OAuth] cloudaicompanionProject 为空字符串,有资格但无 projectId"); + Ok(Some(FetchedProjectId::NoProject)) // 空字符串,有资格但无 projectId + } else { + tracing::info!("[Antigravity OAuth] 获取到 projectId: {}", s); + Ok(Some(FetchedProjectId::HasProject(s.to_string()))) // 有 projectId + } + } else { + tracing::warn!( + "[Antigravity OAuth] cloudaicompanionProject 不是字符串类型: {:?}", + value + ); + Ok(None) // 非字符串类型,视为无资格 + } + } + } + } else { + let body = resp.text().await.unwrap_or_default(); + tracing::error!( + "[Antigravity OAuth] loadCodeAssist 请求失败: {} - {}", + status, + body + ); + Err(format!("loadCodeAssist 请求失败: {} - {}", status, body).into()) + } +} + +/// OAuth 成功页面 HTML +const OAUTH_SUCCESS_HTML: &str = r#" + + + + 授权成功 + + + +
+

✓ 授权成功

+

账号已添加到 ProxyCast

+ +

可以关闭此页面

+
+ +"#; + +/// OAuth 失败页面 HTML +const OAUTH_ERROR_HTML: &str = r#" + + + + 授权失败 + + + +
+

✗ 授权失败

+

ERROR_PLACEHOLDER

+

请关闭此页面后重试

+
+ +"#; + +/// OAuth 授权 URL 结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OAuthAuthUrlResult { + pub auth_url: String, + pub port: u16, + pub state: String, +} + +/// 启动 OAuth 服务器并返回授权 URL(不打开浏览器) +/// 服务器会在后台等待回调,成功后返回凭证 +pub async fn start_oauth_server_and_get_url( + skip_project_id_fetch: bool, +) -> Result< + ( + String, + impl std::future::Future>>, + ), + Box, +> { + use axum::{extract::Query, response::Html, routing::get, Router}; + use std::collections::HashMap; + use tokio::net::TcpListener; + + let 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::>(); + 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_auth_url(port, &state); + + tracing::info!( + "[Antigravity OAuth] 服务器启动在端口 {}, 授权 URL: {}", + port, + auth_url + ); + + // 构建路由 + let app = Router::new().route( + "/oauth-callback", + get(move |Query(params): Query>| { + let tx = tx.clone(); + let client = client.clone(); + let state_expected = state_clone.clone(); + let redirect_uri = redirect_uri_clone.clone(); + + 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 = 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 = 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 = 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_code_for_token(&client, code, &redirect_uri).await; + let token_data = match token_result { + Ok(data) => data, + Err(e) => { + let html = 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 = fetch_user_email(&client, access_token).await.ok().flatten(); + + // 获取项目 ID + let project_id = if skip_project_id_fetch { + tracing::info!("[Antigravity OAuth] 跳过 projectId 获取,使用随机生成的 ID"); + Some(generate_project_id()) + } else { + match fetch_project_id_for_oauth(&client, access_token).await { + Ok(Some(FetchedProjectId::HasProject(pid))) => Some(pid), + Ok(Some(FetchedProjectId::NoProject)) => { + tracing::info!("[Antigravity OAuth] projectId 为空,使用随机生成的 ID"); + Some(generate_project_id()) + } + Ok(None) => { + tracing::warn!("[Antigravity OAuth] 无法获取 projectId(字段不存在),使用随机生成的 ID"); + Some(generate_project_id()) + } + Err(e) => { + tracing::warn!("[Antigravity OAuth] 获取 projectId 失败: {}, 使用随机 ID", e); + Some(generate_project_id()) + } + } + }; + + // 构建凭证 + let now = chrono::Utc::now(); + let credentials = AntigravityCredentials { + access_token: Some(access_token.to_string()), + refresh_token, + token_type: Some("Bearer".to_string()), + expiry_date: expires_in.map(|e| (now + chrono::Duration::seconds(e)).timestamp_millis()), + expire: expires_in.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), + scope: Some(OAUTH_SCOPES.join(" ")), + last_refresh: Some(now.to_rfc3339()), + cred_type: "antigravity".to_string(), + expires_in, + timestamp: Some(now.timestamp_millis()), + enable: Some(true), + projectId: project_id, + email: email.clone(), + }; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("antigravity"); + + if let Err(e) = std::fs::create_dir_all(&creds_dir) { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("创建目录失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + + // 使用 UUID 作为文件名 + let file_name = format!("{}.json", Uuid::new_v4()); + let creds_path = creds_dir.join(&file_name); + + let creds_json = match serde_json::to_string_pretty(&credentials) { + Ok(json) => json, + Err(e) => { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("序列化失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + }; + + if let Err(e) = std::fs::write(&creds_path, &creds_json) { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("保存凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + + let creds_path_str = creds_path.to_string_lossy().to_string(); + tracing::info!("[Antigravity OAuth] 凭证已保存到: {}", creds_path_str); + + // 发送成功结果 + let result = AntigravityOAuthResult { + credentials, + creds_file_path: creds_path_str, + }; + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Ok(result)); + } + + let email_display = email.unwrap_or_else(|| "未知邮箱".to_string()); + let html = OAUTH_SUCCESS_HTML.replace("EMAIL_PLACEHOLDER", &email_display); + Html(html) + } + }), + ); + + // 创建等待回调的 Future + let wait_future = async move { + // 启动服务器 + let server = axum::serve(listener, app); + + // 同时运行服务器和等待回调结果 + tokio::select! { + result = async { + tokio::time::timeout( + std::time::Duration::from_secs(300), + rx + ).await + } => { + match result { + Ok(Ok(Ok(r))) => Ok(r), + Ok(Ok(Err(e))) => Err(e.into()), + Ok(Err(_)) => Err("OAuth 回调通道关闭".into()), + Err(_) => Err("OAuth 登录超时(5分钟)".into()), + } + } + server_result = server => { + match server_result { + Ok(_) => Err("服务器意外关闭".into()), + Err(e) => Err(format!("服务器错误: {}", e).into()), + } + } + } + }; + + Ok((auth_url, wait_future)) +} + +/// 启动 OAuth 登录流程(使用指定端口) +/// 用于配合 get_oauth_auth_url 使用 +pub async fn start_oauth_login_with_port( + port: u16, + state: String, + skip_project_id_fetch: bool, +) -> Result> { + 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()?; + + let state_clone = state.clone(); + + // 创建 channel 用于接收回调结果 + let (tx, rx) = oneshot::channel::>(); + let tx = Arc::new(tokio::sync::Mutex::new(Some(tx))); + + // 绑定到指定端口 + let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?; + + let redirect_uri = format!("http://localhost:{}/oauth-callback", port); + let redirect_uri_clone = redirect_uri.clone(); + + // 构建路由 + let app = Router::new().route( + "/oauth-callback", + get(move |Query(params): Query>| { + let tx = tx.clone(); + let client = client.clone(); + let state_expected = state_clone.clone(); + let redirect_uri = redirect_uri_clone.clone(); + + 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 = 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 = 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 = 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_code_for_token(&client, code, &redirect_uri).await; + let token_data = match token_result { + Ok(data) => data, + Err(e) => { + let html = 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 = fetch_user_email(&client, access_token).await.ok().flatten(); + + // 获取项目 ID + let project_id = if skip_project_id_fetch { + tracing::info!("[Antigravity OAuth] 跳过 projectId 获取,使用随机生成的 ID"); + Some(generate_project_id()) + } else { + match fetch_project_id_for_oauth(&client, access_token).await { + Ok(Some(FetchedProjectId::HasProject(pid))) => Some(pid), + Ok(Some(FetchedProjectId::NoProject)) => { + tracing::info!("[Antigravity OAuth] projectId 为空,使用随机生成的 ID"); + Some(generate_project_id()) + } + Ok(None) => { + tracing::warn!("[Antigravity OAuth] 无法获取 projectId(字段不存在),使用随机生成的 ID"); + Some(generate_project_id()) + } + Err(e) => { + tracing::warn!("[Antigravity OAuth] 获取 projectId 失败: {}, 使用随机 ID", e); + Some(generate_project_id()) + } + } + }; + + // 构建凭证 + let now = chrono::Utc::now(); + let credentials = AntigravityCredentials { + access_token: Some(access_token.to_string()), + refresh_token, + token_type: Some("Bearer".to_string()), + expiry_date: expires_in.map(|e| (now + chrono::Duration::seconds(e)).timestamp_millis()), + expire: expires_in.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), + scope: Some(OAUTH_SCOPES.join(" ")), + last_refresh: Some(now.to_rfc3339()), + cred_type: "antigravity".to_string(), + expires_in, + timestamp: Some(now.timestamp_millis()), + enable: Some(true), + projectId: project_id, + email: email.clone(), + }; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("antigravity"); + + if let Err(e) = std::fs::create_dir_all(&creds_dir) { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("创建目录失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + + // 使用 UUID 作为文件名 + let file_name = format!("{}.json", Uuid::new_v4()); + let creds_path = creds_dir.join(&file_name); + + let creds_json = match serde_json::to_string_pretty(&credentials) { + Ok(json) => json, + Err(e) => { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("序列化失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + }; + + if let Err(e) = std::fs::write(&creds_path, &creds_json) { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("保存凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + + let creds_path_str = creds_path.to_string_lossy().to_string(); + tracing::info!("[Antigravity OAuth] 凭证已保存到: {}", creds_path_str); + + // 发送成功结果 + let result = AntigravityOAuthResult { + credentials, + creds_file_path: creds_path_str, + }; + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Ok(result)); + } + + let email_display = email.unwrap_or_else(|| "未知邮箱".to_string()); + let html = OAUTH_SUCCESS_HTML.replace("EMAIL_PLACEHOLDER", &email_display); + Html(html) + } + }), + ); + + // 启动服务器 + let server = axum::serve(listener, app); + + // 同时运行服务器和等待回调结果 + tokio::select! { + result = async { + tokio::time::timeout( + std::time::Duration::from_secs(300), + rx + ).await + } => { + match result { + Ok(Ok(Ok(r))) => Ok(r), + Ok(Ok(Err(e))) => Err(e.into()), + Ok(Err(_)) => Err("OAuth 回调通道关闭".into()), + Err(_) => Err("OAuth 登录超时(5分钟)".into()), + } + } + server_result = server => { + match server_result { + Ok(_) => Err("服务器意外关闭".into()), + Err(e) => Err(format!("服务器错误: {}", e).into()), + } + } + } +} + +/// 启动 OAuth 登录流程 +/// 返回 (auth_url, credentials_file_path) +pub async fn start_oauth_login( + skip_project_id_fetch: bool, +) -> Result> { + 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::>(); + 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(); + + // 构建路由 + let app = Router::new().route( + "/oauth-callback", + get(move |Query(params): Query>| { + let tx = tx.clone(); + let client = client.clone(); + let state_expected = state_clone.clone(); + let redirect_uri = redirect_uri_clone.clone(); + + 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 = 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 = 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 = 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_code_for_token(&client, code, &redirect_uri).await; + let token_data = match token_result { + Ok(data) => data, + Err(e) => { + let html = 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 = fetch_user_email(&client, access_token).await.ok().flatten(); + + // 获取项目 ID + // 参考 antigravity2api-nodejs 的逻辑: + // - projectId === undefined -> 无资格(但我们改为使用随机 ID,因为很多账号都没有 projectId) + // - projectId === "" -> 有资格但无 projectId,使用随机生成的 + // - projectId 有值 -> 有资格且有 projectId + let project_id = if skip_project_id_fetch { + tracing::info!("[Antigravity OAuth] 跳过 projectId 获取,使用随机生成的 ID"); + Some(generate_project_id()) + } else { + match fetch_project_id_for_oauth(&client, access_token).await { + Ok(Some(FetchedProjectId::HasProject(pid))) => { + // 有资格且有 projectId + Some(pid) + } + Ok(Some(FetchedProjectId::NoProject)) => { + // 有资格但 projectId 为空,使用随机生成的 + tracing::info!("[Antigravity OAuth] projectId 为空,使用随机生成的 ID"); + Some(generate_project_id()) + } + Ok(None) => { + // 字段不存在,也使用随机 ID(很多账号都是这种情况) + tracing::warn!("[Antigravity OAuth] 无法获取 projectId(字段不存在),使用随机生成的 ID"); + Some(generate_project_id()) + } + Err(e) => { + tracing::warn!("[Antigravity OAuth] 获取 projectId 失败: {}, 使用随机 ID", e); + Some(generate_project_id()) + } + } + }; + + // 构建凭证 + let now = chrono::Utc::now(); + let mut credentials = AntigravityCredentials { + access_token: Some(access_token.to_string()), + refresh_token, + token_type: Some("Bearer".to_string()), + expiry_date: expires_in.map(|e| (now + chrono::Duration::seconds(e)).timestamp_millis()), + expire: expires_in.map(|e| (now + chrono::Duration::seconds(e)).to_rfc3339()), + scope: Some(OAUTH_SCOPES.join(" ")), + last_refresh: Some(now.to_rfc3339()), + cred_type: "antigravity".to_string(), + expires_in, + timestamp: Some(now.timestamp_millis()), + enable: Some(true), + projectId: project_id, + email: email.clone(), + }; + + // 保存凭证到应用数据目录 + let creds_dir = dirs::data_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("proxycast") + .join("credentials") + .join("antigravity"); + + if let Err(e) = std::fs::create_dir_all(&creds_dir) { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("创建目录失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + + // 使用 UUID 作为文件名 + let file_name = format!("{}.json", Uuid::new_v4()); + let creds_path = creds_dir.join(&file_name); + + let creds_json = match serde_json::to_string_pretty(&credentials) { + Ok(json) => json, + Err(e) => { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("序列化失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + }; + + if let Err(e) = std::fs::write(&creds_path, &creds_json) { + let html = OAUTH_ERROR_HTML.replace("ERROR_PLACEHOLDER", &format!("保存凭证失败: {}", e)); + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Err(e.to_string())); + } + return Html(html); + } + + let creds_path_str = creds_path.to_string_lossy().to_string(); + tracing::info!("[Antigravity OAuth] 凭证已保存到: {}", creds_path_str); + + // 发送成功结果 + let result = AntigravityOAuthResult { + credentials, + creds_file_path: creds_path_str, + }; + + if let Some(sender) = tx.lock().await.take() { + let _ = sender.send(Ok(result)); + } + + let email_display = email.unwrap_or_else(|| "未知邮箱".to_string()); + let html = OAUTH_SUCCESS_HTML.replace("EMAIL_PLACEHOLDER", &email_display); + Html(html) + } + }), + ); + + // 生成授权 URL + let auth_url = generate_auth_url(port, &state); + + // 打开浏览器 + tracing::info!("[Antigravity OAuth] 打开浏览器进行授权: {}", auth_url); + if let Err(e) = open::that(&auth_url) { + tracing::warn!("[Antigravity OAuth] 无法自动打开浏览器: {}", e); + } + + // 启动服务器 + let server = axum::serve(listener, app); + + // 同时运行服务器和等待回调结果 + tokio::select! { + // 等待回调结果(带超时) + result = async { + tokio::time::timeout( + std::time::Duration::from_secs(300), + rx + ).await + } => { + match result { + Ok(Ok(Ok(r))) => Ok(r), + Ok(Ok(Err(e))) => Err(e.into()), + Ok(Err(_)) => Err("OAuth 回调通道关闭".into()), + Err(_) => Err("OAuth 登录超时(5分钟)".into()), + } + } + // 服务器运行(不会主动结束,除非出错) + server_result = server => { + match server_result { + Ok(_) => Err("服务器意外关闭".into()), + Err(e) => Err(format!("服务器错误: {}", e).into()), + } + } + } +} diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 9faa967cd..82814e211 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -5,7 +5,7 @@ use crate::config::{ }; use crate::converter::anthropic_to_openai::convert_anthropic_to_openai; use crate::converter::openai_to_antigravity::{ - convert_antigravity_to_openai_response, convert_openai_to_antigravity, + convert_antigravity_to_openai_response, convert_openai_to_antigravity_with_context, }; use crate::credential::CredentialSyncService; use crate::database::dao::provider_pool::ProviderPoolDao; @@ -14,6 +14,7 @@ use crate::injection::Injector; use crate::logger::LogStore; use crate::models::anthropic::*; use crate::models::openai::*; +use crate::models::provider_pool_model::CredentialData; use crate::models::route_model::{RouteInfo, RouteListResponse}; use crate::processor::{RequestContext, RequestProcessor}; use crate::providers::antigravity::AntigravityProvider; @@ -772,6 +773,8 @@ async fn run_server( .route("/v1/chat/completions", post(chat_completions)) .route("/v1/messages", post(anthropic_messages)) .route("/v1/messages/count_tokens", post(count_tokens)) + // Gemini 原生协议路由 + .route("/v1/gemini/*path", post(gemini_generate_content)) // WebSocket 路由 .route("/v1/ws", get(ws_upgrade_handler)) .route("/ws", get(ws_upgrade_handler)) @@ -2016,6 +2019,334 @@ async fn count_tokens( .into_response() } +/// Gemini 原生协议处理 +/// 路由: POST /v1/gemini/{model}:{method} +/// 例如: /v1/gemini/gemini-3-pro-preview:generateContent +async fn gemini_generate_content( + State(state): State, + headers: HeaderMap, + Path(path): Path, + Json(request): Json, +) -> Response { + if let Err(e) = verify_api_key(&headers, &state.api_key).await { + return e.into_response(); + } + + // 解析路径: {model}:{method} + // 例如: gemini-3-pro-preview:generateContent + let parts: Vec<&str> = path.splitn(2, ':').collect(); + if parts.len() != 2 { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": { + "message": format!("无效的路径格式: {},期望格式: model:method", path) + } + })), + ) + .into_response(); + } + + let model = parts[0]; + let method = parts[1]; + + state.logs.write().await.add( + "info", + &format!( + "[GEMINI] POST /v1/gemini/{} model={} method={}", + path, model, method + ), + ); + + // 目前只支持 generateContent 方法 + if method != "generateContent" && method != "streamGenerateContent" { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": { + "message": format!("不支持的方法: {},目前只支持 generateContent", method) + } + })), + ) + .into_response(); + } + + let is_stream = method == "streamGenerateContent"; + + // 获取默认 provider + let default_provider = state.default_provider.read().await.clone(); + + // 尝试从凭证池中选择 Antigravity 凭证 + let credential = match &state.db { + Some(db) => state + .pool_service + .select_credential(db, &default_provider, Some(model)) + .ok() + .flatten(), + None => None, + }; + + let cred = match credential { + Some(c) => c, + None => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ + "error": { + "message": "没有可用的 Antigravity 凭证,请先添加凭证" + } + })), + ) + .into_response(); + } + }; + + state.logs.write().await.add( + "info", + &format!( + "[GEMINI] 使用凭证: type={} name={:?} uuid={}", + cred.provider_type, + cred.name, + &cred.uuid[..8] + ), + ); + + // 调用 Antigravity Provider + match &cred.credential { + CredentialData::AntigravityOAuth { + creds_file_path, + project_id, + } => { + let mut antigravity = AntigravityProvider::new(); + if let Err(e) = antigravity + .load_credentials_from_path(creds_file_path) + .await + { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": { + "message": format!("加载 Antigravity 凭证失败: {}", e) + } + })), + ) + .into_response(); + } + + // 检查并刷新 token + if antigravity.is_token_expiring_soon() { + if let Err(e) = antigravity.refresh_token().await { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": { + "message": format!("Token 刷新失败: {}", e) + } + })), + ) + .into_response(); + } + } + + // 设置项目 ID + if let Some(pid) = project_id { + antigravity.project_id = Some(pid.clone()); + } else if antigravity.project_id.is_none() { + // 如果凭证中没有 project_id,尝试从 API 获取或生成随机 ID + if let Err(e) = antigravity.discover_project().await { + tracing::warn!("[Antigravity] 获取项目 ID 失败: {},使用随机生成的 ID", e); + // 生成随机项目 ID + let uuid = uuid::Uuid::new_v4(); + let bytes = uuid.as_bytes(); + let adjectives = ["useful", "bright", "swift", "calm", "bold"]; + let nouns = ["fuze", "wave", "spark", "flow", "core"]; + let adj = adjectives[(bytes[0] as usize) % adjectives.len()]; + let noun = nouns[(bytes[1] as usize) % nouns.len()]; + let random_part: String = uuid.to_string()[..5].to_lowercase(); + antigravity.project_id = Some(format!("{}-{}-{}", adj, noun, random_part)); + } + } + + let proj_id = antigravity.project_id.clone().unwrap_or_else(|| { + // 最后的后备:生成随机 ID + let uuid = uuid::Uuid::new_v4(); + format!("proxycast-{}", &uuid.to_string()[..8]) + }); + + state + .logs + .write() + .await + .add("debug", &format!("[GEMINI] 使用 project_id: {}", proj_id)); + + // 构建 Antigravity 请求体 + // 直接使用用户传入的 Gemini 格式请求,只添加必要的字段 + let antigravity_request = build_gemini_native_request(&request, model, &proj_id); + + state.logs.write().await.add( + "debug", + &format!( + "[GEMINI] 请求体: {}", + serde_json::to_string(&antigravity_request).unwrap_or_default() + ), + ); + + if is_stream { + // 流式响应 - 暂不支持,返回错误 + return ( + StatusCode::NOT_IMPLEMENTED, + Json(serde_json::json!({ + "error": { + "message": "流式响应暂不支持,请使用 generateContent" + } + })), + ) + .into_response(); + } + + // 非流式响应 + match antigravity + .call_api("generateContent", &antigravity_request) + .await + { + Ok(resp) => { + state.logs.write().await.add( + "info", + &format!( + "[GEMINI] 响应成功: {}", + serde_json::to_string(&resp) + .unwrap_or_default() + .chars() + .take(200) + .collect::() + ), + ); + + // 直接返回 Gemini 格式响应 + Json(resp).into_response() + } + Err(e) => { + state + .logs + .write() + .await + .add("error", &format!("[GEMINI] 请求失败: {}", e)); + + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": { + "message": e.to_string() + } + })), + ) + .into_response() + } + } + } + _ => ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": { + "message": "Gemini 原生协议只支持 Antigravity 凭证" + } + })), + ) + .into_response(), + } +} + +/// 构建 Gemini 原生请求体 +/// 将用户传入的 Gemini 格式请求转换为 Antigravity 请求格式 +fn build_gemini_native_request( + request: &serde_json::Value, + model: &str, + project_id: &str, +) -> serde_json::Value { + use crate::converter::openai_to_antigravity::ThinkingConfig; + + // 模型名称映射 + let actual_model = match model { + "gemini-2.5-computer-use-preview-10-2025" => "rev19-uic3-1p", + "gemini-3-pro-image-preview" => "gemini-3-pro-image", + "gemini-3-pro-preview" => "gemini-3-pro-high", + "gemini-claude-sonnet-4-5" => "claude-sonnet-4-5", + "gemini-claude-sonnet-4-5-thinking" => "claude-sonnet-4-5-thinking", + _ => model, + }; + + // 是否启用思维链 + let enable_thinking = model.ends_with("-thinking") + || model == "gemini-2.5-pro" + || model.starts_with("gemini-3-pro-") + || model == "rev19-uic3-1p" + || model == "gpt-oss-120b-medium"; + + // 生成请求 ID 和会话 ID + let request_id = format!("agent-{}", uuid::Uuid::new_v4()); + let session_id = { + let uuid = uuid::Uuid::new_v4(); + let bytes = uuid.as_bytes(); + let n: u64 = u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]) % 9_000_000_000_000_000_000; + format!("-{}", n) + }; + + // 构建内部请求 + let mut inner_request = request.clone(); + + // 添加会话 ID + inner_request["sessionId"] = serde_json::json!(session_id); + + // 确保有 generationConfig + if inner_request.get("generationConfig").is_none() { + inner_request["generationConfig"] = serde_json::json!({ + "temperature": 1.0, + "maxOutputTokens": 8096, + "topP": 0.85, + "topK": 50, + "candidateCount": 1, + "stopSequences": [ + "<|user|>", + "<|bot|>", + "<|context_request|>", + "<|endoftext|>", + "<|end_of_turn|>" + ], + "thinkingConfig": { + "includeThoughts": enable_thinking, + "thinkingBudget": if enable_thinking { 1024 } else { 0 } + } + }); + } else { + // 确保有 thinkingConfig + if inner_request["generationConfig"] + .get("thinkingConfig") + .is_none() + { + inner_request["generationConfig"]["thinkingConfig"] = serde_json::json!({ + "includeThoughts": enable_thinking, + "thinkingBudget": if enable_thinking { 1024 } else { 0 } + }); + } + } + + // 删除安全设置(Antigravity 不支持) + if let Some(obj) = inner_request.as_object_mut() { + obj.remove("safetySettings"); + } + + // 构建完整的 Antigravity 请求体 + serde_json::json!({ + "project": project_id, + "requestId": request_id, + "request": inner_request, + "model": actual_model, + "userAgent": "antigravity" + }) +} + /// CodeWhisperer 响应解析结果 #[derive(Debug, Default)] struct CWParsedResponse { @@ -3016,7 +3347,7 @@ async fn chat_completions_internal(state: &AppState, request: &ChatCompletionReq } } -use crate::models::provider_pool_model::{CredentialData, ProviderCredential}; +use crate::models::provider_pool_model::ProviderCredential; /// 根据凭证调用 Provider (Anthropic 格式) async fn call_provider_anthropic( @@ -3304,9 +3635,12 @@ async fn call_provider_anthropic( tracing::warn!("[Antigravity] Failed to discover project: {}", e); } + // 获取 project_id 用于请求 + let proj_id = antigravity.project_id.clone().unwrap_or_default(); + // 先转换为 OpenAI 格式,再转换为 Antigravity 格式 let openai_request = convert_anthropic_to_openai(request); - let antigravity_request = convert_openai_to_antigravity(&openai_request); + let antigravity_request = convert_openai_to_antigravity_with_context(&openai_request, &proj_id); match antigravity .generate_content(&request.model, &antigravity_request) @@ -3810,8 +4144,11 @@ async fn call_provider_openai( tracing::warn!("[Antigravity] Failed to discover project: {}", e); } + // 获取 project_id 用于请求 + let proj_id = antigravity.project_id.clone().unwrap_or_default(); + // 转换请求格式 - let antigravity_request = convert_openai_to_antigravity(request); + let antigravity_request = convert_openai_to_antigravity_with_context(request, &proj_id); match antigravity.generate_content(&request.model, &antigravity_request).await { Ok(resp) => { @@ -4595,7 +4932,8 @@ async fn call_provider_openai_for_ws( } } CredentialData::AntigravityOAuth { - creds_file_path, .. + creds_file_path, + project_id, } => { let mut antigravity = AntigravityProvider::new(); if let Err(e) = antigravity @@ -4623,7 +4961,14 @@ async fn call_provider_openai_for_ws( return Err(e.to_string()); } } - let antigravity_request = convert_openai_to_antigravity(request); + + // 设置项目 ID + if let Some(pid) = project_id { + antigravity.project_id = Some(pid.clone()); + } + let proj_id = antigravity.project_id.clone().unwrap_or_default(); + + let antigravity_request = convert_openai_to_antigravity_with_context(request, &proj_id); match antigravity .call_api("generateContent", &antigravity_request) .await diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 23423afee..7fb7d6dde 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.14.6", + "version": "0.14.8", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/api-server/ApiServerPage.tsx b/src/components/api-server/ApiServerPage.tsx index fdf633e0a..24e0a28db 100644 --- a/src/components/api-server/ApiServerPage.tsx +++ b/src/components/api-server/ApiServerPage.tsx @@ -239,6 +239,45 @@ export function ApiServerPage() { : "http://localhost:3001"; const apiKey = config?.server.api_key || "proxycast-key"; + // 根据 Provider 类型获取测试模型 + const getTestModel = (provider: string): string => { + switch (provider) { + case "antigravity": + return "gemini-3-pro-preview"; + case "gemini": + return "gemini-2.0-flash"; + case "qwen": + return "qwen-max"; + case "openai": + return "gpt-4o"; + case "claude": + return "claude-sonnet-4-20250514"; + case "kiro": + default: + return "claude-opus-4-5-20251101"; + } + }; + + const testModel = getTestModel(defaultProvider); + + // 根据 Provider 类型获取 Gemini 测试模型 + const getGeminiTestModel = (provider: string): string => { + switch (provider) { + case "antigravity": + return "gemini-3-pro-preview"; + case "gemini": + return "gemini-2.0-flash"; + default: + return "gemini-2.0-flash"; + } + }; + + const geminiTestModel = getGeminiTestModel(defaultProvider); + + // 是否显示 Gemini 测试端点 + const showGeminiTest = + defaultProvider === "antigravity" || defaultProvider === "gemini"; + // Test endpoints const testEndpoints = [ { @@ -264,7 +303,7 @@ export function ApiServerPage() { path: "/v1/chat/completions", needsAuth: true, body: JSON.stringify({ - model: "claude-opus-4-5-20251101", + model: testModel, messages: [{ role: "user", content: "Say hi in one word" }], }), }, @@ -275,7 +314,7 @@ export function ApiServerPage() { path: "/v1/messages", needsAuth: true, body: JSON.stringify({ - model: "claude-opus-4-5-20251101", + model: testModel, max_tokens: 100, messages: [ { @@ -285,6 +324,31 @@ export function ApiServerPage() { ], }), }, + // Gemini 原生协议测试(仅在 Antigravity 或 Gemini Provider 时显示) + ...(showGeminiTest + ? [ + { + id: "gemini", + name: "Gemini Generate", + method: "POST", + path: `/v1/gemini/${geminiTestModel}:generateContent`, + needsAuth: true, + body: JSON.stringify({ + contents: [ + { + role: "user", + parts: [ + { text: "What is 2+2? Answer with just the number." }, + ], + }, + ], + generationConfig: { + maxOutputTokens: 100, + }, + }), + }, + ] + : []), ]; const runTest = async (endpoint: (typeof testEndpoints)[0]) => { diff --git a/src/components/provider-pool/AddCredentialModal.tsx b/src/components/provider-pool/AddCredentialModal.tsx index b4cb89d71..b262a8509 100644 --- a/src/components/provider-pool/AddCredentialModal.tsx +++ b/src/components/provider-pool/AddCredentialModal.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; -import { X, Key, FolderOpen } from "lucide-react"; +import { useState, useEffect } from "react"; +import { X, Key, FolderOpen, LogIn, Copy, Check, Loader2 } from "lucide-react"; import { open } from "@tauri-apps/plugin-dialog"; +import { listen } from "@tauri-apps/api/event"; import { providerPoolApi, PoolProviderType } from "@/lib/api/providerPool"; interface AddCredentialModalProps { @@ -14,7 +15,7 @@ const defaultCredsPath: Record = { kiro: "~/.aws/sso/cache/kiro-auth-token.json", gemini: "~/.gemini/oauth_creds.json", qwen: "~/.qwen/oauth_creds.json", - antigravity: "~/.antigravity/oauth_creds.json", + antigravity: "", codex: "~/.codex/oauth.json", claude_oauth: "~/.claude/oauth.json", iflow: "~/.iflow/oauth_creds.json", @@ -39,6 +40,11 @@ export function AddCredentialModal({ const [apiKey, setApiKey] = useState(""); const [baseUrl, setBaseUrl] = useState(""); + // Antigravity 添加方式: "login" | "file" + const [antigravityMode, setAntigravityMode] = useState<"login" | "file">( + "login", + ); + const isOAuth = [ "kiro", "gemini", @@ -75,7 +81,91 @@ export function AddCredentialModal({ } }; + // Antigravity OAuth 登录状态 + const [antigravityAuthUrl, setAntigravityAuthUrl] = useState( + null, + ); + const [urlCopied, setUrlCopied] = useState(false); + const [waitingForCallback, setWaitingForCallback] = useState(false); + + // 监听后端发送的授权 URL 事件 + useEffect(() => { + let unlisten: (() => void) | undefined; + + const setupListener = async () => { + unlisten = await listen<{ auth_url: string }>( + "antigravity-auth-url", + (event) => { + setAntigravityAuthUrl(event.payload.auth_url); + }, + ); + }; + + setupListener(); + + return () => { + if (unlisten) { + unlisten(); + } + }; + }, []); + + 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); + } + }; + const handleSubmit = async () => { + // Antigravity 登录模式单独处理 + if (providerType === "antigravity" && antigravityMode === "login") { + await handleAntigravityLogin(); + return; + } + setLoading(true); setError(null); @@ -151,6 +241,138 @@ export function AddCredentialModal({ } }; + // Antigravity 特殊渲染 + const renderAntigravityContent = () => ( + <> + {/* 模式选择 */} +
+ + +
+ + {antigravityMode === "login" ? ( +
+
+

+ 点击下方按钮获取授权 + URL,然后复制到浏览器(支持指纹浏览器)完成登录。 +

+

+ 授权成功后,凭证将自动保存并添加到凭证池。 +

+
+ + {/* 授权 URL 显示区域 */} + {antigravityAuthUrl && ( +
+
+
+ 授权 URL + +
+

+ {antigravityAuthUrl.slice(0, 100)}... +

+
+ + {waitingForCallback && ( +
+
+ +

+ 请复制上方 URL 到浏览器完成登录,正在等待授权回调... +

+
+
+ )} +
+ )} +
+ ) : ( + <> + {/* 文件选择 */} +
+ +
+ setCredsFilePath(e.target.value)} + placeholder="选择 accounts.json 或 oauth_creds.json..." + className="flex-1 rounded-lg border bg-background px-3 py-2 text-sm" + /> + +
+

+ 支持 antigravity2api-nodejs 的 data/accounts.json 格式 +

+
+ + {/* Project ID */} +
+ + setProjectId(e.target.value)} + placeholder="Google Cloud Project ID..." + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+ + )} + + ); + return (
@@ -180,7 +402,9 @@ export function AddCredentialModal({ />
- {isOAuth ? ( + {providerType === "antigravity" ? ( + renderAntigravityContent() + ) : isOAuth ? ( <> {/* Credential File */}
@@ -211,8 +435,6 @@ export function AddCredentialModal({ "默认路径: ~/.gemini/oauth_creds.json"} {providerType === "qwen" && "默认路径: ~/.qwen/oauth_creds.json"} - {providerType === "antigravity" && - "默认路径: ~/.antigravity/oauth_creds.json"} {providerType === "codex" && "默认路径: ~/.codex/oauth.json"} {providerType === "claude_oauth" && "默认路径: ~/.claude/oauth.json"} @@ -221,9 +443,8 @@ export function AddCredentialModal({

- {/* Gemini/Antigravity specific: Project ID */} - {(providerType === "gemini" || - providerType === "antigravity") && ( + {/* Gemini specific: Project ID */} + {providerType === "gemini" && (
diff --git a/src/lib/api/providerPool.ts b/src/lib/api/providerPool.ts index f17dc5f67..862288d8e 100644 --- a/src/lib/api/providerPool.ts +++ b/src/lib/api/providerPool.ts @@ -359,6 +359,30 @@ export const providerPoolApi = { return invoke("add_iflow_cookie_credential", { credsFilePath, name }); }, + // Antigravity OAuth 登录(打开浏览器授权) + async startAntigravityOAuthLogin( + name?: string, + skipProjectIdFetch?: boolean, + ): Promise { + return invoke("start_antigravity_oauth_login", { + name, + skipProjectIdFetch, + }); + }, + + // 获取 Antigravity OAuth 授权 URL 并等待回调(不自动打开浏览器) + // 服务器会在后台等待回调,成功后返回凭证 + // 如果需要显示 URL,错误信息会包含 AUTH_URL: 前缀 + async getAntigravityAuthUrlAndWait( + name?: string, + skipProjectIdFetch?: boolean, + ): Promise { + return invoke("get_antigravity_auth_url_and_wait", { + name, + skipProjectIdFetch, + }); + }, + // OAuth token management async refreshCredentialToken(uuid: string): Promise { return invoke("refresh_pool_credential_token", { uuid });