Merge branch 'main' of https://github.com/zss823158062/proxycast
@@ -25,6 +25,7 @@
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -5346,6 +5347,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "proxycast",
|
||||
"private": true,
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.2",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -37,6 +37,7 @@
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3367,7 +3367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proxycast"
|
||||
version = "0.14.0"
|
||||
version = "0.14.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxycast"
|
||||
version = "0.14.0"
|
||||
version = "0.14.2"
|
||||
description = "AI API Proxy Desktop App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
|
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 727 B After Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 715 B After Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 715 B After Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 378 B After Width: | Height: | Size: 776 B |
|
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 1.4 KiB |
@@ -736,6 +736,98 @@ pub fn add_claude_key_credential(
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 Codex OAuth 凭证(通过文件路径)
|
||||
#[tauri::command]
|
||||
pub fn add_codex_oauth_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
creds_file_path: String,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "codex")?;
|
||||
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"codex",
|
||||
CredentialData::CodexOAuth {
|
||||
creds_file_path: stored_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 Claude OAuth 凭证(通过文件路径)
|
||||
#[tauri::command]
|
||||
pub fn add_claude_oauth_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
creds_file_path: String,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "claude_oauth")?;
|
||||
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"claude_oauth",
|
||||
CredentialData::ClaudeOAuth {
|
||||
creds_file_path: stored_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 iFlow OAuth 凭证(通过文件路径)
|
||||
#[tauri::command]
|
||||
pub fn add_iflow_oauth_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
creds_file_path: String,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "iflow")?;
|
||||
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"iflow",
|
||||
CredentialData::IFlowOAuth {
|
||||
creds_file_path: stored_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 添加 iFlow Cookie 凭证(通过文件路径)
|
||||
#[tauri::command]
|
||||
pub fn add_iflow_cookie_credential(
|
||||
db: State<'_, DbConnection>,
|
||||
pool_service: State<'_, ProviderPoolServiceState>,
|
||||
creds_file_path: String,
|
||||
name: Option<String>,
|
||||
) -> Result<ProviderCredential, String> {
|
||||
// 复制并重命名文件到应用存储目录
|
||||
let stored_file_path = copy_and_rename_credential_file(&creds_file_path, "iflow_cookie")?;
|
||||
|
||||
pool_service.0.add_credential(
|
||||
&db,
|
||||
"iflow",
|
||||
CredentialData::IFlowCookie {
|
||||
creds_file_path: stored_file_path,
|
||||
},
|
||||
name,
|
||||
Some(true),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// 刷新凭证的 OAuth Token
|
||||
#[tauri::command]
|
||||
pub async fn refresh_pool_credential_token(
|
||||
|
||||
@@ -24,6 +24,7 @@ use std::sync::Arc;
|
||||
use tauri::{Manager, Runtime};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use commands::plugin_cmd::PluginManagerState;
|
||||
use commands::provider_pool_cmd::{CredentialSyncServiceState, ProviderPoolServiceState};
|
||||
use commands::resilience_cmd::ResilienceConfigState;
|
||||
use commands::router_cmd::RouterConfigState;
|
||||
@@ -1373,6 +1374,10 @@ pub fn run() {
|
||||
// Initialize ResilienceConfigState
|
||||
let resilience_config_state = ResilienceConfigState::default();
|
||||
|
||||
// Initialize PluginManager
|
||||
let plugin_manager = plugin::PluginManager::with_defaults();
|
||||
let plugin_manager_state = PluginManagerState(Arc::new(RwLock::new(plugin_manager)));
|
||||
|
||||
// Initialize shared telemetry instances for both TelemetryState and RequestProcessor
|
||||
// This allows the frontend monitoring page to display data recorded by the request processor
|
||||
let shared_stats = Arc::new(parking_lot::RwLock::new(
|
||||
@@ -1427,6 +1432,7 @@ pub fn run() {
|
||||
.manage(router_config_state)
|
||||
.manage(resilience_config_state)
|
||||
.manage(telemetry_state)
|
||||
.manage(plugin_manager_state)
|
||||
.setup(move |app| {
|
||||
// 初始化托盘管理器
|
||||
// Requirements 1.4: 应用启动时显示停止状态图标
|
||||
@@ -1666,6 +1672,10 @@ pub fn run() {
|
||||
commands::provider_pool_cmd::add_antigravity_oauth_credential,
|
||||
commands::provider_pool_cmd::add_openai_key_credential,
|
||||
commands::provider_pool_cmd::add_claude_key_credential,
|
||||
commands::provider_pool_cmd::add_codex_oauth_credential,
|
||||
commands::provider_pool_cmd::add_claude_oauth_credential,
|
||||
commands::provider_pool_cmd::add_iflow_oauth_credential,
|
||||
commands::provider_pool_cmd::add_iflow_cookie_credential,
|
||||
commands::provider_pool_cmd::refresh_pool_credential_token,
|
||||
commands::provider_pool_cmd::get_pool_credential_oauth_status,
|
||||
commands::provider_pool_cmd::debug_kiro_credentials,
|
||||
@@ -1724,6 +1734,17 @@ pub fn run() {
|
||||
commands::tray_cmd::get_tray_state,
|
||||
commands::tray_cmd::refresh_tray_menu,
|
||||
commands::tray_cmd::refresh_tray_with_stats,
|
||||
// Plugin commands
|
||||
commands::plugin_cmd::get_plugin_status,
|
||||
commands::plugin_cmd::get_plugins,
|
||||
commands::plugin_cmd::get_plugin_info,
|
||||
commands::plugin_cmd::enable_plugin,
|
||||
commands::plugin_cmd::disable_plugin,
|
||||
commands::plugin_cmd::update_plugin_config,
|
||||
commands::plugin_cmd::get_plugin_config,
|
||||
commands::plugin_cmd::reload_plugins,
|
||||
commands::plugin_cmd::unload_plugin,
|
||||
commands::plugin_cmd::get_plugins_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -104,6 +104,16 @@ impl Default for IFlowCredentials {
|
||||
}
|
||||
}
|
||||
|
||||
/// iFlow API Key 数据
|
||||
/// 与 CLIProxyAPI 的 iFlowKeyData 对齐
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IFlowKeyData {
|
||||
pub api_key: String,
|
||||
pub expire_time: String,
|
||||
pub name: String,
|
||||
pub has_expired: bool,
|
||||
}
|
||||
|
||||
/// Enum representation of iFlow credentials for type-safe handling
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum IFlowCredentialsType {
|
||||
@@ -802,6 +812,179 @@ impl IFlowProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查 Cookie API Key 是否需要刷新(距离过期 2 天内)
|
||||
/// 与 CLIProxyAPI 的 ShouldRefreshAPIKey 对齐
|
||||
pub fn should_refresh_api_key(&self) -> bool {
|
||||
if let Some(expire_str) = &self.credentials.expire {
|
||||
// 尝试解析 "2006-01-02 15:04" 格式(iFlow 返回的格式)
|
||||
if let Ok(expire) = chrono::NaiveDateTime::parse_from_str(expire_str, "%Y-%m-%d %H:%M")
|
||||
{
|
||||
let expire_utc = expire.and_utc();
|
||||
let now = chrono::Utc::now();
|
||||
let two_days_from_now = now + chrono::Duration::hours(48);
|
||||
return expire_utc < two_days_from_now;
|
||||
}
|
||||
// 尝试解析 RFC3339 格式
|
||||
if let Ok(expire) = chrono::DateTime::parse_from_rfc3339(expire_str) {
|
||||
let now = chrono::Utc::now();
|
||||
let two_days_from_now = now + chrono::Duration::hours(48);
|
||||
return expire < two_days_from_now;
|
||||
}
|
||||
}
|
||||
// 如果没有过期时间,默认需要刷新
|
||||
true
|
||||
}
|
||||
|
||||
/// 通过 Cookie 刷新 API Key
|
||||
/// 与 CLIProxyAPI 的 RefreshAPIKey 对齐
|
||||
pub async fn refresh_api_key_with_cookie(
|
||||
&mut self,
|
||||
) -> Result<String, Box<dyn Error + Send + Sync>> {
|
||||
let cookie = self
|
||||
.credentials
|
||||
.cookies
|
||||
.as_ref()
|
||||
.ok_or_else(|| create_config_error("没有可用的 Cookie"))?
|
||||
.clone();
|
||||
|
||||
let email = self
|
||||
.credentials
|
||||
.email
|
||||
.as_ref()
|
||||
.ok_or_else(|| create_config_error("没有可用的用户标识(email)"))?
|
||||
.clone();
|
||||
|
||||
tracing::info!("[IFLOW] 正在通过 Cookie 刷新 API Key,用户: {}", email);
|
||||
|
||||
// 首先获取当前 API Key 信息(GET 请求)
|
||||
let key_info = self.fetch_api_key_info(&cookie).await?;
|
||||
|
||||
// 然后刷新 API Key(POST 请求)
|
||||
let refreshed_key = self.refresh_api_key(&cookie, &key_info.name).await?;
|
||||
|
||||
// 更新凭证
|
||||
self.credentials.api_key = Some(refreshed_key.api_key.clone());
|
||||
self.credentials.expire = Some(refreshed_key.expire_time.clone());
|
||||
self.credentials.last_refresh = Some(chrono::Utc::now().to_rfc3339());
|
||||
|
||||
// 保存凭证
|
||||
self.save_credentials().await?;
|
||||
|
||||
tracing::info!(
|
||||
"[IFLOW] Cookie API Key 刷新成功,新过期时间: {}",
|
||||
refreshed_key.expire_time
|
||||
);
|
||||
|
||||
Ok(refreshed_key.api_key)
|
||||
}
|
||||
|
||||
/// 获取 API Key 信息(GET 请求)
|
||||
async fn fetch_api_key_info(
|
||||
&self,
|
||||
cookie: &str,
|
||||
) -> Result<IFlowKeyData, Box<dyn Error + Send + Sync>> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(IFLOW_API_KEY_URL)
|
||||
.header("Cookie", cookie)
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::error!("[IFLOW] 获取 API Key 信息失败: {} - {}", status, body);
|
||||
return Err(create_auth_error(&format!(
|
||||
"获取 API Key 信息失败: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
|
||||
|
||||
if !data["success"].as_bool().unwrap_or(false) {
|
||||
let message = data["message"].as_str().unwrap_or("未知错误");
|
||||
return Err(create_auth_error(&format!(
|
||||
"获取 API Key 信息失败: {}",
|
||||
message
|
||||
)));
|
||||
}
|
||||
|
||||
let key_data = &data["data"];
|
||||
Ok(IFlowKeyData {
|
||||
api_key: key_data["apiKey"]
|
||||
.as_str()
|
||||
.or_else(|| key_data["apiKeyMask"].as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
expire_time: key_data["expireTime"].as_str().unwrap_or("").to_string(),
|
||||
name: key_data["name"].as_str().unwrap_or("").to_string(),
|
||||
has_expired: key_data["hasExpired"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// 刷新 API Key(POST 请求)
|
||||
async fn refresh_api_key(
|
||||
&self,
|
||||
cookie: &str,
|
||||
name: &str,
|
||||
) -> Result<IFlowKeyData, Box<dyn Error + Send + Sync>> {
|
||||
let body = serde_json::json!({ "name": name });
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(IFLOW_API_KEY_URL)
|
||||
.header("Cookie", cookie)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json, text/plain, */*")
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
|
||||
.header("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
|
||||
.header("Origin", "https://platform.iflow.cn")
|
||||
.header("Referer", "https://platform.iflow.cn/")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::error!("[IFLOW] 刷新 API Key 失败: {} - {}", status, body);
|
||||
return Err(create_auth_error(&format!(
|
||||
"刷新 API Key 失败: {} - {}",
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
|
||||
let data: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| Box::new(ProviderError::from(e)) as Box<dyn Error + Send + Sync>)?;
|
||||
|
||||
if !data["success"].as_bool().unwrap_or(false) {
|
||||
let message = data["message"].as_str().unwrap_or("未知错误");
|
||||
return Err(create_auth_error(&format!(
|
||||
"刷新 API Key 失败: {}",
|
||||
message
|
||||
)));
|
||||
}
|
||||
|
||||
let key_data = &data["data"];
|
||||
Ok(IFlowKeyData {
|
||||
api_key: key_data["apiKey"].as_str().unwrap_or("").to_string(),
|
||||
expire_time: key_data["expireTime"].as_str().unwrap_or("").to_string(),
|
||||
name: key_data["name"].as_str().unwrap_or("").to_string(),
|
||||
has_expired: key_data["hasExpired"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Refresh token with retry mechanism
|
||||
pub async fn refresh_token_with_retry(
|
||||
&mut self,
|
||||
|
||||
@@ -27,6 +27,35 @@ pub fn sync_to_live(
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理 Claude 配置中冲突的认证环境变量
|
||||
///
|
||||
/// Claude Code 同时检测到 ANTHROPIC_AUTH_TOKEN 和 ANTHROPIC_API_KEY 时会报警告。
|
||||
/// 此函数确保只保留一个认证变量:
|
||||
/// - 优先保留 ANTHROPIC_AUTH_TOKEN(OAuth token)
|
||||
/// - 如果只有 ANTHROPIC_API_KEY,则保留它
|
||||
fn clean_claude_auth_conflict(settings: &mut Value) {
|
||||
if let Some(env) = settings.get_mut("env").and_then(|v| v.as_object_mut()) {
|
||||
let has_auth_token = env
|
||||
.get("ANTHROPIC_AUTH_TOKEN")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
let has_api_key = env
|
||||
.get("ANTHROPIC_API_KEY")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 如果两者都存在,移除 ANTHROPIC_API_KEY(优先使用 AUTH_TOKEN)
|
||||
if has_auth_token && has_api_key {
|
||||
tracing::info!(
|
||||
"检测到 Claude 认证冲突:同时存在 ANTHROPIC_AUTH_TOKEN 和 ANTHROPIC_API_KEY,移除 ANTHROPIC_API_KEY"
|
||||
);
|
||||
env.remove("ANTHROPIC_API_KEY");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync Claude settings to ~/.claude/settings.json
|
||||
fn sync_claude_settings(
|
||||
provider: &Provider,
|
||||
@@ -69,6 +98,9 @@ fn sync_claude_settings(
|
||||
settings = provider.settings_config.clone();
|
||||
}
|
||||
|
||||
// 清理冲突的认证环境变量
|
||||
clean_claude_auth_conflict(&mut settings);
|
||||
|
||||
// Write settings
|
||||
let content = serde_json::to_string_pretty(&settings)?;
|
||||
std::fs::write(&config_path, content)?;
|
||||
|
||||
@@ -518,6 +518,7 @@ impl TokenCacheService {
|
||||
}
|
||||
|
||||
/// 刷新 iFlow Cookie Token
|
||||
/// 与 CLIProxyAPI 的 refreshCookieBased 对齐
|
||||
async fn refresh_iflow_cookie(&self, creds_path: &str) -> Result<CachedTokenInfo, String> {
|
||||
use crate::providers::iflow::IFlowProvider;
|
||||
|
||||
@@ -527,8 +528,46 @@ impl TokenCacheService {
|
||||
.await
|
||||
.map_err(|e| format!("加载 iFlow Cookie 凭证失败: {}", e))?;
|
||||
|
||||
// iFlow Cookie 凭证使用 API Key,不需要刷新 OAuth Token
|
||||
// 直接从凭证中获取 API Key
|
||||
// 检查是否需要刷新 API Key(距离过期 2 天内)
|
||||
if provider.should_refresh_api_key() {
|
||||
tracing::info!("[IFLOW] Cookie API Key 需要刷新");
|
||||
|
||||
// 通过 Cookie 刷新 API Key
|
||||
let api_key = provider
|
||||
.refresh_api_key_with_cookie()
|
||||
.await
|
||||
.map_err(|e| format!("刷新 iFlow Cookie API Key 失败: {}", e))?;
|
||||
|
||||
// 解析新的过期时间
|
||||
let expiry_time = provider
|
||||
.credentials
|
||||
.expire
|
||||
.as_ref()
|
||||
.and_then(|s| {
|
||||
// 尝试解析 "2006-01-02 15:04" 格式
|
||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M")
|
||||
.ok()
|
||||
.map(|dt| dt.and_utc())
|
||||
.or_else(|| {
|
||||
// 尝试解析 RFC3339 格式
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(30));
|
||||
|
||||
return Ok(CachedTokenInfo {
|
||||
access_token: Some(api_key),
|
||||
refresh_token: None,
|
||||
expiry_time: Some(expiry_time),
|
||||
last_refresh: Some(Utc::now()),
|
||||
refresh_error_count: 0,
|
||||
last_refresh_error: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 不需要刷新,直接返回现有的 API Key
|
||||
let api_key = provider
|
||||
.credentials
|
||||
.api_key
|
||||
@@ -540,9 +579,19 @@ impl TokenCacheService {
|
||||
.credentials
|
||||
.expire
|
||||
.as_ref()
|
||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(30)); // Cookie 通常有效期较长
|
||||
.and_then(|s| {
|
||||
// 尝试解析 "2006-01-02 15:04" 格式
|
||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M")
|
||||
.ok()
|
||||
.map(|dt| dt.and_utc())
|
||||
.or_else(|| {
|
||||
// 尝试解析 RFC3339 格式
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.ok()
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| Utc::now() + chrono::Duration::days(30));
|
||||
|
||||
Ok(CachedTokenInfo {
|
||||
access_token: Some(api_key),
|
||||
|
||||
@@ -77,17 +77,23 @@ impl TrayIcons {
|
||||
let default = Self::load_default_icon(app);
|
||||
|
||||
// 尝试加载各状态图标
|
||||
// 为了保持一致性,所有状态都使用应用主图标
|
||||
let tray_icons_dir = resource_path.join("icons").join("tray");
|
||||
info!("托盘图标目录: {:?}", tray_icons_dir);
|
||||
|
||||
let running = Self::load_png_file(&tray_icons_dir.join("tray-running.png"));
|
||||
let warning = Self::load_png_file(&tray_icons_dir.join("tray-warning.png"));
|
||||
let error = Self::load_png_file(&tray_icons_dir.join("tray-error.png"));
|
||||
let stopped = Self::load_png_file(&tray_icons_dir.join("tray-stopped.png"));
|
||||
// 检查是否有自定义托盘图标,如果没有则使用默认图标
|
||||
let running = Self::load_png_file(&tray_icons_dir.join("tray-running.png"))
|
||||
.or_else(|| Some(default.clone()));
|
||||
let warning = Self::load_png_file(&tray_icons_dir.join("tray-warning.png"))
|
||||
.or_else(|| Some(default.clone()));
|
||||
let error = Self::load_png_file(&tray_icons_dir.join("tray-error.png"))
|
||||
.or_else(|| Some(default.clone()));
|
||||
let stopped = Self::load_png_file(&tray_icons_dir.join("tray-stopped.png"))
|
||||
.or_else(|| Some(default.clone()));
|
||||
|
||||
if running.is_none() || warning.is_none() || error.is_none() || stopped.is_none() {
|
||||
warn!("部分托盘图标加载失败,将使用默认图标作为后备");
|
||||
// 尝试从内嵌资源加载
|
||||
// 如果所有托盘图标都加载失败,使用内嵌资源
|
||||
if running.is_none() && warning.is_none() && error.is_none() && stopped.is_none() {
|
||||
warn!("所有托盘图标加载失败,将使用内嵌资源");
|
||||
return Self::load_embedded();
|
||||
}
|
||||
|
||||
@@ -104,14 +110,16 @@ impl TrayIcons {
|
||||
fn load_embedded() -> Self {
|
||||
info!("从内嵌资源加载托盘图标");
|
||||
|
||||
// 使用应用主图标作为默认托盘图标
|
||||
let default = Image::from_bytes(include_bytes!("../../icons/32x32.png"))
|
||||
.expect("内嵌默认图标加载失败");
|
||||
|
||||
// 尝试加载内嵌的托盘图标
|
||||
let running = Image::from_bytes(include_bytes!("../../icons/tray/tray-running.png")).ok();
|
||||
let warning = Image::from_bytes(include_bytes!("../../icons/tray/tray-warning.png")).ok();
|
||||
let error = Image::from_bytes(include_bytes!("../../icons/tray/tray-error.png")).ok();
|
||||
let stopped = Image::from_bytes(include_bytes!("../../icons/tray/tray-stopped.png")).ok();
|
||||
// 所有状态都使用应用主图标,保持一致性
|
||||
// macOS 托盘图标建议使用单色 Template 图标,但为了跨平台一致性,使用应用图标
|
||||
let running = Image::from_bytes(include_bytes!("../../icons/32x32.png")).ok();
|
||||
let warning = Image::from_bytes(include_bytes!("../../icons/32x32.png")).ok();
|
||||
let error = Image::from_bytes(include_bytes!("../../icons/32x32.png")).ok();
|
||||
let stopped = Image::from_bytes(include_bytes!("../../icons/32x32.png")).ok();
|
||||
|
||||
Self {
|
||||
running,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ProxyCast",
|
||||
"version": "0.14.0",
|
||||
"version": "0.14.2",
|
||||
"identifier": "com.proxycast.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useAllOAuthCredentials } from "@/hooks/useOAuthCredentials";
|
||||
import { StatsOverview } from "./monitoring/StatsOverview";
|
||||
import { LogViewer } from "./monitoring/LogViewer";
|
||||
import { TokenStats } from "./monitoring/TokenStats";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
import {
|
||||
getDashboardData,
|
||||
getRequestLogs,
|
||||
@@ -376,12 +377,15 @@ function OverviewTab({
|
||||
key={cred.provider}
|
||||
className="flex items-center justify-between rounded-lg border bg-background p-3"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{getProviderName(cred.provider)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{cred.has_access_token ? "Token 已加载" : "未配置"}
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon providerType={cred.provider} size={20} />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{getProviderName(cred.provider)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{cred.has_access_token ? "Token 已加载" : "未配置"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -188,6 +188,9 @@ export function ApiServerPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadPoolOverview();
|
||||
// 定时刷新凭证池数据,以便使用次数能够更新
|
||||
const poolInterval = setInterval(loadPoolOverview, 5000);
|
||||
return () => clearInterval(poolInterval);
|
||||
}, []);
|
||||
|
||||
// 自动清除 provider 切换提示
|
||||
@@ -308,6 +311,11 @@ export function ApiServerPage() {
|
||||
httpStatus: result.status,
|
||||
},
|
||||
}));
|
||||
|
||||
// 测试成功后立即刷新凭证池数据,更新使用次数
|
||||
if (result.success) {
|
||||
await loadPoolOverview();
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : String(e);
|
||||
setTestResults((prev) => ({
|
||||
@@ -627,22 +635,12 @@ export function ApiServerPage() {
|
||||
{credentials.map((cred) => (
|
||||
<div
|
||||
key={cred.uuid}
|
||||
className={`flex items-center justify-between rounded-lg border px-3 py-2 text-sm ${
|
||||
cred.is_healthy
|
||||
? "border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950"
|
||||
: cred.is_disabled
|
||||
? "border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900"
|
||||
: "border-red-200 bg-red-50 dark:border-red-900 dark:bg-red-950"
|
||||
}`}
|
||||
className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
cred.is_healthy
|
||||
? "bg-green-500"
|
||||
: cred.is_disabled
|
||||
? "bg-gray-400"
|
||||
: "bg-red-500"
|
||||
cred.is_healthy ? "bg-green-500" : "bg-yellow-500"
|
||||
}`}
|
||||
/>
|
||||
<span>{cred.name || cred.uuid.slice(0, 8)}</span>
|
||||
|
||||
@@ -315,7 +315,7 @@ export function ProviderForm({
|
||||
return {
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
primaryModel: "claude-sonnet-4-20250514",
|
||||
primaryModel: "",
|
||||
haikuModel: "",
|
||||
sonnetModel: "",
|
||||
opusModel: "",
|
||||
@@ -328,7 +328,7 @@ export function ProviderForm({
|
||||
return {
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
primaryModel: "claude-sonnet-4-20250514",
|
||||
primaryModel: "",
|
||||
haikuModel: "",
|
||||
sonnetModel: "",
|
||||
opusModel: "",
|
||||
@@ -337,7 +337,7 @@ export function ProviderForm({
|
||||
return {
|
||||
apiKey: env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || "",
|
||||
baseUrl: env.ANTHROPIC_BASE_URL || "",
|
||||
primaryModel: env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514",
|
||||
primaryModel: env.ANTHROPIC_MODEL || "",
|
||||
haikuModel: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || "",
|
||||
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL || "",
|
||||
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL || "",
|
||||
@@ -373,9 +373,14 @@ export function ProviderForm({
|
||||
const generateJsonFromFields = useCallback(() => {
|
||||
const env: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
// 同时设置两个 API Key,支持 OpenAI 和 Anthropic 两种协议
|
||||
env.ANTHROPIC_API_KEY = apiKey;
|
||||
env.OPENAI_API_KEY = apiKey;
|
||||
}
|
||||
if (baseUrl) {
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.OPENAI_BASE_URL = baseUrl;
|
||||
}
|
||||
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
if (primaryModel) env.ANTHROPIC_MODEL = primaryModel;
|
||||
if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel;
|
||||
if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel;
|
||||
@@ -635,10 +640,43 @@ export function ProviderForm({
|
||||
const handleCredentialSelect = async (credential: CredentialDisplay) => {
|
||||
setSelectedCredentialUuid(credential.uuid);
|
||||
setName(credential.name || `${credential.provider_type} 凭证`);
|
||||
setIconColor("#22c55e");
|
||||
|
||||
// 根据凭证类型和 appType 设置配置
|
||||
// 使用 ProxyCast 代理,凭证池中的凭证通过本地代理访问
|
||||
// 判断是否为 API Key 类型凭证(非 OAuth)
|
||||
const isApiKeyType = !credential.credential_type.includes("oauth");
|
||||
|
||||
// API Key 类型凭证:直接使用凭证中的 api_key 和 base_url
|
||||
if (isApiKeyType && credential.api_key) {
|
||||
setIconColor("#22c55e");
|
||||
|
||||
if (appType === "claude") {
|
||||
setApiKey(credential.api_key);
|
||||
setBaseUrl(credential.base_url || "");
|
||||
setJsonManuallyEdited(false);
|
||||
} else if (appType === "codex") {
|
||||
setCodexAuth(
|
||||
JSON.stringify(
|
||||
{
|
||||
api_key: credential.api_key,
|
||||
api_base_url: credential.base_url || "",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} else if (appType === "gemini") {
|
||||
setGeminiEnv(
|
||||
`GEMINI_API_KEY=${credential.api_key}\nGOOGLE_GEMINI_BASE_URL=${credential.base_url || ""}\nGEMINI_MODEL=gemini-2.0-flash`,
|
||||
);
|
||||
}
|
||||
|
||||
setNotes(
|
||||
`API Key 凭证: ${credential.provider_type} - ${credential.uuid.slice(0, 8)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// OAuth 类型凭证:使用 ProxyCast 代理
|
||||
setIconColor("#3b82f6");
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const proxyApiKey = config.server.api_key || "";
|
||||
@@ -668,7 +706,7 @@ export function ProviderForm({
|
||||
}
|
||||
|
||||
setNotes(
|
||||
`使用凭证池: ${credential.provider_type} - ${credential.uuid.slice(0, 8)}`,
|
||||
`代理凭证: ${credential.provider_type} - ${credential.uuid.slice(0, 8)}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load config:", e);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { Plug, MessageSquare, Boxes } from "lucide-react";
|
||||
import { Plug, MessageSquare, Boxes, Puzzle } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { McpPage } from "../mcp/McpPage";
|
||||
import { PromptsPage } from "../prompts/PromptsPage";
|
||||
import { SkillsPage } from "../skills/SkillsPage";
|
||||
import { PluginManager } from "../plugins/PluginManager";
|
||||
|
||||
type Tab = "mcp" | "prompts" | "skills";
|
||||
type Tab = "mcp" | "prompts" | "skills" | "plugins";
|
||||
|
||||
const tabs = [
|
||||
{ id: "mcp" as Tab, label: "MCP", icon: Plug },
|
||||
{ id: "prompts" as Tab, label: "Prompts", icon: MessageSquare },
|
||||
{ id: "skills" as Tab, label: "Skills", icon: Boxes },
|
||||
{ id: "plugins" as Tab, label: "Plugins", icon: Puzzle },
|
||||
];
|
||||
|
||||
export function ExtensionsPage() {
|
||||
@@ -49,6 +51,7 @@ export function ExtensionsPage() {
|
||||
{activeTab === "mcp" && <McpPage hideHeader />}
|
||||
{activeTab === "prompts" && <PromptsPage hideHeader />}
|
||||
{activeTab === "skills" && <SkillsPage hideHeader />}
|
||||
{activeTab === "plugins" && <PluginManager />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,11 +12,22 @@ import { usePrompts } from "@/hooks/usePrompts";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { HelpTip } from "@/components/HelpTip";
|
||||
import { ProviderIcon } from "@/icons/providers";
|
||||
|
||||
const apps: { id: AppType; label: string; filename: string }[] = [
|
||||
{ id: "claude", label: "Claude Code", filename: "CLAUDE.md" },
|
||||
{ id: "codex", label: "Codex", filename: "AGENTS.md" },
|
||||
{ id: "gemini", label: "Gemini", filename: "GEMINI.md" },
|
||||
const apps: {
|
||||
id: AppType;
|
||||
label: string;
|
||||
filename: string;
|
||||
iconType: string;
|
||||
}[] = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
filename: "CLAUDE.md",
|
||||
iconType: "claude",
|
||||
},
|
||||
{ id: "codex", label: "Codex", filename: "AGENTS.md", iconType: "openai" },
|
||||
{ id: "gemini", label: "Gemini", filename: "GEMINI.md", iconType: "gemini" },
|
||||
];
|
||||
|
||||
/** Toggle Switch Component */
|
||||
@@ -212,12 +223,13 @@ export function PromptsPage({ hideHeader = false }: PromptsPageProps) {
|
||||
key={app.id}
|
||||
onClick={() => handleAppChange(app.id)}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-t-lg text-sm font-medium transition-colors",
|
||||
"flex items-center gap-2 px-4 py-2 rounded-t-lg text-sm font-medium transition-colors",
|
||||
activeApp === app.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<ProviderIcon providerType={app.iconType} size={16} />
|
||||
{app.label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -17,7 +17,7 @@ const defaultCredsPath: Record<string, string> = {
|
||||
antigravity: "~/.antigravity/oauth_creds.json",
|
||||
codex: "~/.codex/oauth.json",
|
||||
claude_oauth: "~/.claude/oauth.json",
|
||||
iflow: "~/.iflow/oauth.json",
|
||||
iflow: "~/.iflow/oauth_creds.json",
|
||||
};
|
||||
|
||||
export function AddCredentialModal({
|
||||
@@ -207,7 +207,8 @@ export function AddCredentialModal({
|
||||
{providerType === "codex" && "默认路径: ~/.codex/oauth.json"}
|
||||
{providerType === "claude_oauth" &&
|
||||
"默认路径: ~/.claude/oauth.json"}
|
||||
{providerType === "iflow" && "默认路径: ~/.iflow/oauth.json"}
|
||||
{providerType === "iflow" &&
|
||||
"默认路径: ~/.iflow/oauth_creds.json"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -287,7 +287,7 @@ export function ProviderForm({
|
||||
return {
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
primaryModel: "claude-sonnet-4-20250514",
|
||||
primaryModel: "",
|
||||
haikuModel: "",
|
||||
sonnetModel: "",
|
||||
opusModel: "",
|
||||
@@ -300,7 +300,7 @@ export function ProviderForm({
|
||||
return {
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
primaryModel: "claude-sonnet-4-20250514",
|
||||
primaryModel: "",
|
||||
haikuModel: "",
|
||||
sonnetModel: "",
|
||||
opusModel: "",
|
||||
@@ -309,7 +309,7 @@ export function ProviderForm({
|
||||
return {
|
||||
apiKey: env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN || "",
|
||||
baseUrl: env.ANTHROPIC_BASE_URL || "",
|
||||
primaryModel: env.ANTHROPIC_MODEL || "claude-sonnet-4-20250514",
|
||||
primaryModel: env.ANTHROPIC_MODEL || "",
|
||||
haikuModel: env.ANTHROPIC_DEFAULT_HAIKU_MODEL || "",
|
||||
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL || "",
|
||||
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL || "",
|
||||
@@ -345,9 +345,14 @@ export function ProviderForm({
|
||||
const generateJsonFromFields = useCallback(() => {
|
||||
const env: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
// 同时设置两个 API Key,支持 OpenAI 和 Anthropic 两种协议
|
||||
env.ANTHROPIC_API_KEY = apiKey;
|
||||
env.OPENAI_API_KEY = apiKey;
|
||||
}
|
||||
if (baseUrl) {
|
||||
env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
env.OPENAI_BASE_URL = baseUrl;
|
||||
}
|
||||
if (baseUrl) env.ANTHROPIC_BASE_URL = baseUrl;
|
||||
if (primaryModel) env.ANTHROPIC_MODEL = primaryModel;
|
||||
if (haikuModel) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = haikuModel;
|
||||
if (sonnetModel) env.ANTHROPIC_DEFAULT_SONNET_MODEL = sonnetModel;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Toaster as SonnerToaster } from "sonner";
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="top-center"
|
||||
richColors
|
||||
theme="system"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
classNames: {
|
||||
toast:
|
||||
"group rounded-md border bg-background text-foreground shadow-lg",
|
||||
title: "text-sm font-semibold",
|
||||
description: "text-sm text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { switchApi, Provider, AppType } from "@/lib/api/switch";
|
||||
|
||||
export function useSwitch(appType: AppType) {
|
||||
@@ -40,21 +41,25 @@ export function useSwitch(appType: AppType) {
|
||||
};
|
||||
await switchApi.addProvider(newProvider);
|
||||
await fetchProviders();
|
||||
toast.success("配置已添加");
|
||||
};
|
||||
|
||||
const updateProvider = async (provider: Provider) => {
|
||||
await switchApi.updateProvider(provider);
|
||||
await fetchProviders();
|
||||
toast.success("配置已更新");
|
||||
};
|
||||
|
||||
const deleteProvider = async (id: string) => {
|
||||
await switchApi.deleteProvider(appType, id);
|
||||
await fetchProviders();
|
||||
toast.success("配置已删除");
|
||||
};
|
||||
|
||||
const switchToProvider = async (id: string) => {
|
||||
await switchApi.switchProvider(appType, id);
|
||||
await fetchProviders();
|
||||
toast.success("切换成功");
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,7 +11,7 @@ const providerTypeToIcon: Record<string, string> = {
|
||||
anthropic: "anthropic",
|
||||
codex: "openai",
|
||||
claude_oauth: "claude",
|
||||
iflow: "openai",
|
||||
iflow: "alibaba",
|
||||
amp: "amp",
|
||||
google: "google",
|
||||
alibaba: "alibaba",
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { Toaster } from "./components/ui/sonner";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<Toaster />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||