diff --git a/src-tauri/icons/tray/tray-error.png b/src-tauri/icons/tray/tray-error.png index b33cbfb70..918a9e377 100644 Binary files a/src-tauri/icons/tray/tray-error.png and b/src-tauri/icons/tray/tray-error.png differ diff --git a/src-tauri/icons/tray/tray-running.png b/src-tauri/icons/tray/tray-running.png index 856fbd780..918a9e377 100644 Binary files a/src-tauri/icons/tray/tray-running.png and b/src-tauri/icons/tray/tray-running.png differ diff --git a/src-tauri/icons/tray/tray-stopped.png b/src-tauri/icons/tray/tray-stopped.png index ef7949a70..918a9e377 100644 Binary files a/src-tauri/icons/tray/tray-stopped.png and b/src-tauri/icons/tray/tray-stopped.png differ diff --git a/src-tauri/icons/tray/tray-warning.png b/src-tauri/icons/tray/tray-warning.png index 225717887..918a9e377 100644 Binary files a/src-tauri/icons/tray/tray-warning.png and b/src-tauri/icons/tray/tray-warning.png differ diff --git a/src-tauri/icons/tray/trayTemplate.png b/src-tauri/icons/tray/trayTemplate.png index 8a22bf131..918a9e377 100644 Binary files a/src-tauri/icons/tray/trayTemplate.png and b/src-tauri/icons/tray/trayTemplate.png differ diff --git a/src-tauri/icons/tray/trayTemplate@2x.png b/src-tauri/icons/tray/trayTemplate@2x.png index 0a5c853e0..666f52965 100644 Binary files a/src-tauri/icons/tray/trayTemplate@2x.png and b/src-tauri/icons/tray/trayTemplate@2x.png differ diff --git a/src-tauri/src/commands/provider_pool_cmd.rs b/src-tauri/src/commands/provider_pool_cmd.rs index 574008582..f2caa0ff4 100644 --- a/src-tauri/src/commands/provider_pool_cmd.rs +++ b/src-tauri/src/commands/provider_pool_cmd.rs @@ -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, +) -> Result { + // 复制并重命名文件到应用存储目录 + 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, +) -> Result { + // 复制并重命名文件到应用存储目录 + 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, +) -> Result { + // 复制并重命名文件到应用存储目录 + 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, +) -> Result { + // 复制并重命名文件到应用存储目录 + 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( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index daeefec0d..579e3cee3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src-tauri/src/providers/iflow.rs b/src-tauri/src/providers/iflow.rs index ebb69b7e1..c75f254be 100644 --- a/src-tauri/src/providers/iflow.rs +++ b/src-tauri/src/providers/iflow.rs @@ -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> { + 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> { + 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)?; + + 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)?; + + 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> { + 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)?; + + 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)?; + + 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, diff --git a/src-tauri/src/services/token_cache_service.rs b/src-tauri/src/services/token_cache_service.rs index 1609c967c..ccab11b5d 100644 --- a/src-tauri/src/services/token_cache_service.rs +++ b/src-tauri/src/services/token_cache_service.rs @@ -518,6 +518,7 @@ impl TokenCacheService { } /// 刷新 iFlow Cookie Token + /// 与 CLIProxyAPI 的 refreshCookieBased 对齐 async fn refresh_iflow_cookie(&self, creds_path: &str) -> Result { 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), diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 3d5b7236f..9196f9727 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -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" > -
-
- {getProviderName(cred.provider)} -
-
- {cred.has_access_token ? "Token 已加载" : "未配置"} +
+ +
+
+ {getProviderName(cred.provider)} +
+
+ {cred.has_access_token ? "Token 已加载" : "未配置"} +
} {activeTab === "prompts" && } {activeTab === "skills" && } + {activeTab === "plugins" && }
); diff --git a/src/components/prompts/PromptsPage.tsx b/src/components/prompts/PromptsPage.tsx index 55669c992..66548e42e 100644 --- a/src/components/prompts/PromptsPage.tsx +++ b/src/components/prompts/PromptsPage.tsx @@ -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", )} > + {app.label} ))} diff --git a/src/components/provider-pool/AddCredentialModal.tsx b/src/components/provider-pool/AddCredentialModal.tsx index db70df8f6..ed604fe96 100644 --- a/src/components/provider-pool/AddCredentialModal.tsx +++ b/src/components/provider-pool/AddCredentialModal.tsx @@ -17,7 +17,7 @@ const defaultCredsPath: Record = { 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"}

diff --git a/src/icons/providers/utils.ts b/src/icons/providers/utils.ts index 252bab937..741f724e7 100644 --- a/src/icons/providers/utils.ts +++ b/src/icons/providers/utils.ts @@ -11,7 +11,7 @@ const providerTypeToIcon: Record = { anthropic: "anthropic", codex: "openai", claude_oauth: "claude", - iflow: "openai", + iflow: "alibaba", amp: "amp", google: "google", alibaba: "alibaba",