mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
v0.14.8: Antigravity OAuth 支持复制授权 URL 到指纹浏览器
- 新增 Antigravity OAuth 授权 URL 复制功能,支持多账号场景 - 用户可以复制授权 URL 到指纹浏览器完成登录 - 后端启动服务器后通过 Tauri 事件发送授权 URL - 服务器在后台等待回调(5分钟超时) - 添加 Gemini 原生协议支持路由
This commit is contained in:
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.14.6",
|
||||
"version": "0.14.8",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Generated
+1
-1
@@ -3367,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.14.5"
|
||||
version = "0.14.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<ProviderPoolService>);
|
||||
@@ -1132,3 +1132,120 @@ pub struct MigrationResultResponse {
|
||||
/// 错误信息列表
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// 获取 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<String>,
|
||||
skip_project_id_fetch: Option<bool>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
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<String>,
|
||||
skip_project_id_fetch: Option<bool>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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<String>,
|
||||
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<String>,
|
||||
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<GeminiFunctionDeclaration>,
|
||||
}
|
||||
@@ -69,12 +77,23 @@ pub struct GeminiGenerationConfig {
|
||||
pub top_k: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop_sequences: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub candidate_count: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_config: Option<ThinkingConfig>,
|
||||
}
|
||||
|
||||
/// 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<GeminiContent>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_instruction: Option<GeminiContent>,
|
||||
@@ -82,10 +101,59 @@ pub struct AntigravityRequestBody {
|
||||
pub generation_config: Option<GeminiGenerationConfig>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<GeminiTool>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_config: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 生成随机请求 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<GeminiContent> = Vec::new();
|
||||
let mut system_instruction: Option<GeminiContent> = 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<serde_json::Value>) -> Option<serde_json::Value> {
|
||||
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<String, serde_json::Value> = 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<GeminiPart> {
|
||||
let mut parts = Vec::new();
|
||||
@@ -234,14 +414,14 @@ fn convert_user_content(msg: &ChatMessage) -> Vec<GeminiPart> {
|
||||
}
|
||||
|
||||
/// 转换助手消息内容
|
||||
fn convert_assistant_content(msg: &ChatMessage) -> Vec<GeminiPart> {
|
||||
fn convert_assistant_content(msg: &ChatMessage, _contents: &[GeminiContent]) -> Vec<GeminiPart> {
|
||||
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<GeminiPart> {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+351
-6
@@ -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<AppState>,
|
||||
headers: HeaderMap,
|
||||
Path(path): Path<String>,
|
||||
Json(request): Json<serde_json::Value>,
|
||||
) -> 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::<String>()
|
||||
),
|
||||
);
|
||||
|
||||
// 直接返回 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]) => {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string | null>(
|
||||
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 = () => (
|
||||
<>
|
||||
{/* 模式选择 */}
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
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">
|
||||
@@ -180,7 +402,9 @@ export function AddCredentialModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOAuth ? (
|
||||
{providerType === "antigravity" ? (
|
||||
renderAntigravityContent()
|
||||
) : isOAuth ? (
|
||||
<>
|
||||
{/* Credential File */}
|
||||
<div>
|
||||
@@ -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({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Gemini/Antigravity specific: Project ID */}
|
||||
{(providerType === "gemini" ||
|
||||
providerType === "antigravity") && (
|
||||
{/* Gemini specific: Project ID */}
|
||||
{providerType === "gemini" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium">
|
||||
Project ID (可选)
|
||||
@@ -296,13 +517,26 @@ export function AddCredentialModal({
|
||||
>
|
||||
取消
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -359,6 +359,30 @@ export const providerPoolApi = {
|
||||
return invoke("add_iflow_cookie_credential", { credsFilePath, name });
|
||||
},
|
||||
|
||||
// Antigravity OAuth 登录(打开浏览器授权)
|
||||
async startAntigravityOAuthLogin(
|
||||
name?: string,
|
||||
skipProjectIdFetch?: boolean,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("start_antigravity_oauth_login", {
|
||||
name,
|
||||
skipProjectIdFetch,
|
||||
});
|
||||
},
|
||||
|
||||
// 获取 Antigravity OAuth 授权 URL 并等待回调(不自动打开浏览器)
|
||||
// 服务器会在后台等待回调,成功后返回凭证
|
||||
// 如果需要显示 URL,错误信息会包含 AUTH_URL: 前缀
|
||||
async getAntigravityAuthUrlAndWait(
|
||||
name?: string,
|
||||
skipProjectIdFetch?: boolean,
|
||||
): Promise<ProviderCredential> {
|
||||
return invoke("get_antigravity_auth_url_and_wait", {
|
||||
name,
|
||||
skipProjectIdFetch,
|
||||
});
|
||||
},
|
||||
|
||||
// OAuth token management
|
||||
async refreshCredentialToken(uuid: string): Promise<string> {
|
||||
return invoke("refresh_pool_credential_token", { uuid });
|
||||
|
||||
Reference in New Issue
Block a user