From 8fe5c1041a7b079ae3f3d5cae288d4203499cb87 Mon Sep 17 00:00:00 2001 From: Calcium-Ion Date: Fri, 26 Dec 2025 22:47:24 +0800 Subject: [PATCH] feat: add Universal Provider feature (#348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Universal Provider feature - Add Universal Provider data structures and type definitions - Implement backend CRUD operations and sync functionality - Add frontend UI components (UniversalProviderPanel, Card, FormModal) - Add NewAPI icon and preset configuration - Support cross-app (Claude/Codex/Gemini) configuration sync - Add website URL field for providers - Implement real-time refresh via event notifications - Add i18n support (Chinese/English/Japanese) * feat: integrate universal provider presets into add provider dialog - Add universal provider presets (NewAPI, Custom Gateway) to preset selector - Show universal presets with Layers icon badge in preset selector - Open UniversalProviderFormModal when universal preset is clicked - Pass initialPreset to auto-fill form when opened from add dialog - Add i18n keys for addSuccess/addFailed messages - Keep separate universal provider panel for management * refactor: move universal provider management to add dialog - Remove Layers button from main navigation header - Add 'Manage' button next to universal provider presets - Open UniversalProviderPanel from within add provider dialog - Add i18n keys for 'manage' in all locales * style: display universal provider presets on separate line - Move universal provider section to a new row with border separator - Add label '统一供应商:' to clarify the section * style: unify universal provider label style with preset label - Use FormLabel component for consistent styling - Add background to 'Manage' button matching preset buttons - Update icon size and button padding for consistency * feat: add sync functionality and JSON preview for Universal Provider * fix: add missing in_failover_queue field to Provider structs After rebasing to main, the Provider struct gained a new `in_failover_queue` field. This fix adds the missing field to the three to_*_provider() methods in UniversalProvider. * refactor: redesign AddProviderDialog with tab-based layout - Add tabs to separate app-specific providers and universal providers - Move "Add Universal Provider" button from panel header to footer - Remove unused handleAdd callback and clean up imports - Update emptyHint i18n text to reference the footer button * fix: append /v1 suffix to Codex base_url in Universal Provider Codex uses OpenAI-compatible API which requires the /v1 endpoint suffix. The Universal Provider now automatically appends /v1 to base_url when generating Codex provider config if not already present. - Handle trailing slashes to avoid double slashes - Apply fix to both backend (to_codex_provider) and frontend preview * feat: auto-sync universal provider to apps on creation Previously, users had to manually click sync after adding a universal provider. Now it automatically syncs to Claude/Codex/Gemini on creation, providing a smoother user experience. --------- Co-authored-by: Jason --- src-tauri/src/commands/provider.rs | 94 +++ src-tauri/src/database/dao/mod.rs | 1 + .../src/database/dao/universal_providers.rs | 75 ++ src-tauri/src/lib.rs | 6 + src-tauri/src/provider.rs | 283 +++++++ src-tauri/src/services/provider/mod.rs | 137 ++++ src/App.tsx | 52 +- .../providers/AddProviderDialog.tsx | 156 +++- .../providers/forms/ApiKeyInput.tsx | 5 +- .../providers/forms/ProviderForm.tsx | 7 + .../forms/ProviderPresetSelector.tsx | 54 +- .../universal/UniversalProviderCard.tsx | 115 +++ .../universal/UniversalProviderFormModal.tsx | 718 ++++++++++++++++++ .../universal/UniversalProviderPanel.tsx | 288 +++++++ src/components/universal/index.ts | 3 + src/config/universalProviderPresets.ts | 131 ++++ src/i18n/locales/en.json | 46 ++ src/i18n/locales/ja.json | 45 ++ src/i18n/locales/zh.json | 46 ++ src/icons/extracted/index.ts | 3 +- src/icons/extracted/metadata.ts | 7 + src/icons/extracted/newapi.svg | 1 + src/lib/api/index.ts | 2 +- src/lib/api/providers.ts | 47 +- src/types.ts | 58 ++ 25 files changed, 2336 insertions(+), 44 deletions(-) create mode 100644 src-tauri/src/database/dao/universal_providers.rs create mode 100644 src/components/universal/UniversalProviderCard.tsx create mode 100644 src/components/universal/UniversalProviderFormModal.tsx create mode 100644 src/components/universal/UniversalProviderPanel.tsx create mode 100644 src/components/universal/index.ts create mode 100644 src/config/universalProviderPresets.ts create mode 100644 src/icons/extracted/newapi.svg diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index cce5aabd3..03157d0d6 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -229,3 +229,97 @@ pub fn update_providers_sort_order( let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) } + +// ============================================================================ +// 统一供应商(Universal Provider)命令 +// ============================================================================ + +use crate::provider::UniversalProvider; +use std::collections::HashMap; +use tauri::{AppHandle, Emitter}; + +/// 统一供应商同步完成事件的 payload +#[derive(Clone, serde::Serialize)] +pub struct UniversalProviderSyncedEvent { + /// 操作类型: "upsert" | "delete" | "sync" + pub action: String, + /// 统一供应商 ID + pub id: String, +} + +/// 发送统一供应商同步事件,通知前端刷新供应商列表 +fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { + let _ = app.emit( + "universal-provider-synced", + UniversalProviderSyncedEvent { + action: action.to_string(), + id: id.to_string(), + }, + ); +} + +/// 获取所有统一供应商 +#[tauri::command] +pub fn get_universal_providers( + state: State<'_, AppState>, +) -> Result, String> { + ProviderService::list_universal(state.inner()).map_err(|e| e.to_string()) +} + +/// 获取单个统一供应商 +#[tauri::command] +pub fn get_universal_provider( + state: State<'_, AppState>, + id: String, +) -> Result, String> { + ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string()) +} + +/// 添加或更新统一供应商 +#[tauri::command] +pub fn upsert_universal_provider( + app: AppHandle, + state: State<'_, AppState>, + provider: UniversalProvider, +) -> Result { + let id = provider.id.clone(); + let result = + ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?; + + // 发送事件通知前端刷新 + emit_universal_provider_synced(&app, "upsert", &id); + + Ok(result) +} + +/// 删除统一供应商 +#[tauri::command] +pub fn delete_universal_provider( + app: AppHandle, + state: State<'_, AppState>, + id: String, +) -> Result { + let result = + ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?; + + // 发送事件通知前端刷新 + emit_universal_provider_synced(&app, "delete", &id); + + Ok(result) +} + +/// 同步统一供应商到各应用(手动触发) +#[tauri::command] +pub fn sync_universal_provider( + app: AppHandle, + state: State<'_, AppState>, + id: String, +) -> Result { + let result = + ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?; + + // 发送事件通知前端刷新 + emit_universal_provider_synced(&app, "sync", &id); + + Ok(result) +} diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index c759c2a86..81ac6a6c1 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -10,6 +10,7 @@ pub mod proxy; pub mod settings; pub mod skills; pub mod stream_check; +pub mod universal_providers; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 导出 FailoverQueueItem 供外部使用 diff --git a/src-tauri/src/database/dao/universal_providers.rs b/src-tauri/src/database/dao/universal_providers.rs new file mode 100644 index 000000000..32c955009 --- /dev/null +++ b/src-tauri/src/database/dao/universal_providers.rs @@ -0,0 +1,75 @@ +//! 统一供应商 (Universal Provider) DAO +//! +//! 提供统一供应商的 CRUD 操作。 + +use crate::database::{lock_conn, to_json_string, Database}; +use crate::error::AppError; +use crate::provider::UniversalProvider; +use std::collections::HashMap; + +/// 统一供应商的 Settings Key +const UNIVERSAL_PROVIDERS_KEY: &str = "universal_providers"; + +impl Database { + /// 获取所有统一供应商 + pub fn get_all_universal_providers(&self) -> Result, AppError> { + let conn = lock_conn!(self.conn); + + let mut stmt = conn + .prepare("SELECT value FROM settings WHERE key = ?") + .map_err(|e| AppError::Database(e.to_string()))?; + + let result: Option = stmt + .query_row([UNIVERSAL_PROVIDERS_KEY], |row| row.get(0)) + .ok(); + + match result { + Some(json) => { + serde_json::from_str(&json) + .map_err(|e| AppError::Database(format!("解析统一供应商数据失败: {e}"))) + } + None => Ok(HashMap::new()), + } + } + + /// 获取单个统一供应商 + pub fn get_universal_provider(&self, id: &str) -> Result, AppError> { + let providers = self.get_all_universal_providers()?; + Ok(providers.get(id).cloned()) + } + + /// 保存统一供应商(添加或更新) + pub fn save_universal_provider(&self, provider: &UniversalProvider) -> Result<(), AppError> { + let mut providers = self.get_all_universal_providers()?; + providers.insert(provider.id.clone(), provider.clone()); + self.save_all_universal_providers(&providers) + } + + /// 删除统一供应商 + pub fn delete_universal_provider(&self, id: &str) -> Result { + let mut providers = self.get_all_universal_providers()?; + let existed = providers.remove(id).is_some(); + if existed { + self.save_all_universal_providers(&providers)?; + } + Ok(existed) + } + + /// 保存所有统一供应商(内部方法) + fn save_all_universal_providers( + &self, + providers: &HashMap, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + let json = to_json_string(providers)?; + + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", + [UNIVERSAL_PROVIDERS_KEY, &json], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(()) + } +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 556c326b6..c1a431fb5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -694,6 +694,12 @@ pub fn run() { commands::get_stream_check_config, commands::save_stream_check_config, commands::get_tool_versions, + // Universal Provider management + commands::get_universal_providers, + commands::get_universal_provider, + commands::upsert_universal_provider, + commands::delete_universal_provider, + commands::sync_universal_provider, ]); let app = builder diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 8c082fe68..3a4d1fa1f 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -173,3 +173,286 @@ impl ProviderManager { &self.providers } } + +// ============================================================================ +// 统一供应商(Universal Provider)- 跨应用共享配置 +// ============================================================================ + +/// 统一供应商的应用启用状态 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UniversalProviderApps { + #[serde(default)] + pub claude: bool, + #[serde(default)] + pub codex: bool, + #[serde(default)] + pub gemini: bool, +} + +/// Claude 模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ClaudeModelConfig { + /// 主模型 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Haiku 默认模型 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "haikuModel")] + pub haiku_model: Option, + /// Sonnet 默认模型 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sonnetModel")] + pub sonnet_model: Option, + /// Opus 默认模型 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "opusModel")] + pub opus_model: Option, +} + +/// Codex 模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CodexModelConfig { + /// 模型名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// 推理强度 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "reasoningEffort")] + pub reasoning_effort: Option, +} + +/// Gemini 模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct GeminiModelConfig { + /// 模型名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, +} + +/// 各应用的模型配置 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UniversalProviderModels { + #[serde(skip_serializing_if = "Option::is_none")] + pub claude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub codex: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gemini: Option, +} + +/// 统一供应商(跨应用共享配置) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UniversalProvider { + /// 唯一标识 + pub id: String, + /// 供应商名称 + pub name: String, + /// 供应商类型(如 "newapi", "custom") + #[serde(rename = "providerType")] + pub provider_type: String, + /// 应用启用状态 + pub apps: UniversalProviderApps, + /// API 基础地址 + #[serde(rename = "baseUrl")] + pub base_url: String, + /// API 密钥 + #[serde(rename = "apiKey")] + pub api_key: String, + /// 各应用的模型配置 + #[serde(default)] + pub models: UniversalProviderModels, + /// 网站链接 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "websiteUrl")] + pub website_url: Option, + /// 备注信息 + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + /// 图标名称 + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// 图标颜色 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "iconColor")] + pub icon_color: Option, + /// 元数据 + #[serde(skip_serializing_if = "Option::is_none")] + pub meta: Option, + /// 创建时间戳 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "createdAt")] + pub created_at: Option, + /// 排序索引 + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "sortIndex")] + pub sort_index: Option, +} + +impl UniversalProvider { + /// 创建新的统一供应商 + pub fn new( + id: String, + name: String, + provider_type: String, + base_url: String, + api_key: String, + ) -> Self { + Self { + id, + name, + provider_type, + apps: UniversalProviderApps::default(), + base_url, + api_key, + models: UniversalProviderModels::default(), + website_url: None, + notes: None, + icon: None, + icon_color: None, + meta: None, + created_at: Some(chrono::Utc::now().timestamp_millis()), + sort_index: None, + } + } + + /// 生成 Claude 供应商配置 + pub fn to_claude_provider(&self) -> Option { + if !self.apps.claude { + return None; + } + + let models = self.models.claude.as_ref(); + let model = models + .and_then(|m| m.model.clone()) + .unwrap_or_else(|| "claude-sonnet-4-20250514".to_string()); + let haiku = models + .and_then(|m| m.haiku_model.clone()) + .unwrap_or_else(|| model.clone()); + let sonnet = models + .and_then(|m| m.sonnet_model.clone()) + .unwrap_or_else(|| model.clone()); + let opus = models + .and_then(|m| m.opus_model.clone()) + .unwrap_or_else(|| model.clone()); + + let settings_config = serde_json::json!({ + "env": { + "ANTHROPIC_BASE_URL": self.base_url, + "ANTHROPIC_AUTH_TOKEN": self.api_key, + "ANTHROPIC_MODEL": model, + "ANTHROPIC_DEFAULT_HAIKU_MODEL": haiku, + "ANTHROPIC_DEFAULT_SONNET_MODEL": sonnet, + "ANTHROPIC_DEFAULT_OPUS_MODEL": opus, + } + }); + + Some(Provider { + id: format!("universal-claude-{}", self.id), + name: self.name.clone(), + settings_config, + website_url: self.website_url.clone(), + category: Some("aggregator".to_string()), + created_at: self.created_at, + sort_index: self.sort_index, + notes: self.notes.clone(), + meta: self.meta.clone(), + icon: self.icon.clone(), + icon_color: self.icon_color.clone(), + in_failover_queue: false, + }) + } + + /// 生成 Codex 供应商配置 + pub fn to_codex_provider(&self) -> Option { + if !self.apps.codex { + return None; + } + + let models = self.models.codex.as_ref(); + let model = models + .and_then(|m| m.model.clone()) + .unwrap_or_else(|| "gpt-4o".to_string()); + let reasoning_effort = models + .and_then(|m| m.reasoning_effort.clone()) + .unwrap_or_else(|| "high".to_string()); + + // 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API) + let codex_base_url = if self.base_url.ends_with("/v1") { + self.base_url.clone() + } else { + format!("{}/v1", self.base_url.trim_end_matches('/')) + }; + + // 生成 Codex 的 config.toml 内容 + let config_toml = format!( + r#"model_provider = "newapi" +model = "{}" +model_reasoning_effort = "{}" +disable_response_storage = true + +[model_providers.newapi] +name = "NewAPI" +base_url = "{}" +wire_api = "responses" +requires_openai_auth = true"#, + model, reasoning_effort, codex_base_url + ); + + let settings_config = serde_json::json!({ + "auth": { + "OPENAI_API_KEY": self.api_key + }, + "config": config_toml + }); + + Some(Provider { + id: format!("universal-codex-{}", self.id), + name: self.name.clone(), + settings_config, + website_url: self.website_url.clone(), + category: Some("aggregator".to_string()), + created_at: self.created_at, + sort_index: self.sort_index, + notes: self.notes.clone(), + meta: self.meta.clone(), + icon: self.icon.clone(), + icon_color: self.icon_color.clone(), + in_failover_queue: false, + }) + } + + /// 生成 Gemini 供应商配置 + pub fn to_gemini_provider(&self) -> Option { + if !self.apps.gemini { + return None; + } + + let models = self.models.gemini.as_ref(); + let model = models + .and_then(|m| m.model.clone()) + .unwrap_or_else(|| "gemini-2.5-pro".to_string()); + + let settings_config = serde_json::json!({ + "env": { + "GOOGLE_GEMINI_BASE_URL": self.base_url, + "GEMINI_API_KEY": self.api_key, + "GEMINI_MODEL": model, + } + }); + + Some(Provider { + id: format!("universal-gemini-{}", self.id), + name: self.name.clone(), + settings_config, + website_url: self.website_url.clone(), + category: Some("aggregator".to_string()), + created_at: self.created_at, + sort_index: self.sort_index, + notes: self.notes.clone(), + meta: self.meta.clone(), + icon: self.icon.clone(), + icon_color: self.icon_color.clone(), + in_failover_queue: false, + }) + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 7790ca41a..599574414 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -690,3 +690,140 @@ pub struct ProviderSortUpdate { #[serde(rename = "sortIndex")] pub sort_index: usize, } + +// ============================================================================ +// 统一供应商(Universal Provider)服务方法 +// ============================================================================ + +use crate::provider::UniversalProvider; +use std::collections::HashMap; + +impl ProviderService { + /// 获取所有统一供应商 + pub fn list_universal( + state: &AppState, + ) -> Result, AppError> { + state.db.get_all_universal_providers() + } + + /// 获取单个统一供应商 + pub fn get_universal( + state: &AppState, + id: &str, + ) -> Result, AppError> { + state.db.get_universal_provider(id) + } + + /// 添加或更新统一供应商(不自动同步,需手动调用 sync_universal_to_apps) + pub fn upsert_universal( + state: &AppState, + provider: UniversalProvider, + ) -> Result { + // 保存统一供应商 + state.db.save_universal_provider(&provider)?; + + Ok(true) + } + + /// 删除统一供应商 + pub fn delete_universal(state: &AppState, id: &str) -> Result { + // 获取统一供应商(用于删除生成的子供应商) + let provider = state.db.get_universal_provider(id)?; + + // 删除统一供应商 + state.db.delete_universal_provider(id)?; + + // 删除生成的子供应商 + if let Some(p) = provider { + if p.apps.claude { + let claude_id = format!("universal-claude-{}", id); + let _ = state.db.delete_provider("claude", &claude_id); + } + if p.apps.codex { + let codex_id = format!("universal-codex-{}", id); + let _ = state.db.delete_provider("codex", &codex_id); + } + if p.apps.gemini { + let gemini_id = format!("universal-gemini-{}", id); + let _ = state.db.delete_provider("gemini", &gemini_id); + } + } + + Ok(true) + } + + /// 同步统一供应商到各应用 + pub fn sync_universal_to_apps(state: &AppState, id: &str) -> Result { + let provider = state + .db + .get_universal_provider(id)? + .ok_or_else(|| AppError::Message(format!("统一供应商 {} 不存在", id)))?; + + // 同步到 Claude + if let Some(mut claude_provider) = provider.to_claude_provider() { + // 合并已有配置 + if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? { + let mut merged = existing.settings_config.clone(); + Self::merge_json(&mut merged, &claude_provider.settings_config); + claude_provider.settings_config = merged; + } + state.db.save_provider("claude", &claude_provider)?; + } else { + // 如果禁用了 Claude,删除对应的子供应商 + let claude_id = format!("universal-claude-{}", id); + let _ = state.db.delete_provider("claude", &claude_id); + } + + // 同步到 Codex + if let Some(mut codex_provider) = provider.to_codex_provider() { + // 合并已有配置 + if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? { + let mut merged = existing.settings_config.clone(); + Self::merge_json(&mut merged, &codex_provider.settings_config); + codex_provider.settings_config = merged; + } + state.db.save_provider("codex", &codex_provider)?; + } else { + let codex_id = format!("universal-codex-{}", id); + let _ = state.db.delete_provider("codex", &codex_id); + } + + // 同步到 Gemini + if let Some(mut gemini_provider) = provider.to_gemini_provider() { + // 合并已有配置 + if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? { + let mut merged = existing.settings_config.clone(); + Self::merge_json(&mut merged, &gemini_provider.settings_config); + gemini_provider.settings_config = merged; + } + state.db.save_provider("gemini", &gemini_provider)?; + } else { + let gemini_id = format!("universal-gemini-{}", id); + let _ = state.db.delete_provider("gemini", &gemini_id); + } + + Ok(true) + } + + /// 递归合并 JSON:base 为底,patch 覆盖同名字段 + fn merge_json(base: &mut serde_json::Value, patch: &serde_json::Value) { + use serde_json::Value; + + match (base, patch) { + (Value::Object(base_map), Value::Object(patch_map)) => { + for (k, v_patch) in patch_map { + match base_map.get_mut(k) { + Some(v_base) => Self::merge_json(v_base, v_patch), + None => { + base_map.insert(k.clone(), v_patch.clone()); + } + } + } + } + // 其它类型:直接覆盖 + (base_val, patch_val) => { + *base_val = patch_val.clone(); + } + } + } +} diff --git a/src/App.tsx b/src/App.tsx index 6dd3e37ad..a1e6f8b17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,9 +43,17 @@ import PromptPanel from "@/components/prompts/PromptPanel"; import { SkillsPage } from "@/components/skills/SkillsPage"; import { DeepLinkImportDialog } from "@/components/DeepLinkImportDialog"; import { AgentsPanel } from "@/components/agents/AgentsPanel"; +import { UniversalProviderPanel } from "@/components/universal"; import { Button } from "@/components/ui/button"; -type View = "providers" | "settings" | "prompts" | "skills" | "mcp" | "agents"; +type View = + | "providers" + | "settings" + | "prompts" + | "skills" + | "mcp" + | "agents" + | "universal"; const DRAG_BAR_HEIGHT = 28; // px const HEADER_HEIGHT = 64; // px @@ -145,6 +153,38 @@ function App() { }; }, [activeApp, refetch]); + // 监听统一供应商同步事件,刷新所有应用的供应商列表 + useEffect(() => { + let unsubscribe: (() => void) | undefined; + + const setupListener = async () => { + try { + const { listen } = await import("@tauri-apps/api/event"); + unsubscribe = await listen("universal-provider-synced", async () => { + // 统一供应商同步后刷新所有应用的供应商列表 + // 使用 invalidateQueries 使所有 providers 查询失效 + await queryClient.invalidateQueries({ queryKey: ["providers"] }); + // 同时更新托盘菜单 + try { + await providersApi.updateTrayMenu(); + } catch (error) { + console.error("[App] Failed to update tray menu", error); + } + }); + } catch (error) { + console.error( + "[App] Failed to subscribe universal-provider-synced event", + error, + ); + } + }; + + setupListener(); + return () => { + unsubscribe?.(); + }; + }, [queryClient]); + // 应用启动时检测所有应用的环境变量冲突 useEffect(() => { const checkEnvOnStartup = async () => { @@ -364,6 +404,12 @@ function App() { return ( setCurrentView("providers")} /> ); + case "universal": + return ( +
+ +
+ ); default: return (
@@ -494,6 +540,10 @@ function App() { {currentView === "skills" && t("skills.title")} {currentView === "mcp" && t("mcp.unifiedPanel.title")} {currentView === "agents" && t("agents.title")} + {currentView === "universal" && + t("universalProvider.title", { + defaultValue: "统一供应商", + })}
) : ( diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index b04dd9447..00cb0d1cd 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -1,17 +1,23 @@ -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useTranslation } from "react-i18next"; import { Plus } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; -import type { Provider, CustomEndpoint } from "@/types"; +import type { Provider, CustomEndpoint, UniversalProvider } from "@/types"; import type { AppId } from "@/lib/api"; +import { universalProvidersApi } from "@/lib/api"; import { ProviderForm, type ProviderFormValues, } from "@/components/providers/forms/ProviderForm"; +import { UniversalProviderFormModal } from "@/components/universal/UniversalProviderFormModal"; +import { UniversalProviderPanel } from "@/components/universal"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; +import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; interface AddProviderDialogProps { open: boolean; @@ -27,6 +33,46 @@ export function AddProviderDialog({ onSubmit, }: AddProviderDialogProps) { const { t } = useTranslation(); + const [activeTab, setActiveTab] = useState<"app-specific" | "universal">( + "app-specific", + ); + const [universalFormOpen, setUniversalFormOpen] = useState(false); + const [selectedUniversalPreset, setSelectedUniversalPreset] = + useState(null); + + // Handle universal provider save + const handleUniversalProviderSave = useCallback( + async (provider: UniversalProvider) => { + try { + await universalProvidersApi.upsert(provider); + toast.success( + t("universalProvider.addSuccess", { + defaultValue: "统一供应商添加成功", + }), + ); + setUniversalFormOpen(false); + setSelectedUniversalPreset(null); + onOpenChange(false); + } catch (error) { + console.error( + "[AddProviderDialog] Failed to save universal provider", + error, + ); + toast.error( + t("universalProvider.addFailed", { + defaultValue: "统一供应商添加失败", + }), + ); + } + }, + [t, onOpenChange], + ); + + // Close universal form and return to main dialog + const handleUniversalFormClose = useCallback(() => { + setUniversalFormOpen(false); + setSelectedUniversalPreset(null); + }, []); const handleSubmit = useCallback( async (values: ProviderFormValues) => { @@ -156,46 +202,86 @@ export function AddProviderDialog({ [appId, onSubmit, onOpenChange], ); - const submitLabel = - appId === "claude" - ? t("provider.addClaudeProvider") - : appId === "codex" - ? t("provider.addCodexProvider") - : t("provider.addGeminiProvider"); - - const footer = ( - <> - - - - ); + // 动态 footer:根据当前 Tab 显示不同按钮 + const footer = + activeTab === "app-specific" ? ( + <> + + + + ) : ( + <> + + + + ); return ( onOpenChange(false)} footer={footer} > - onOpenChange(false)} - showButtons={false} + setActiveTab(v as "app-specific" | "universal")} + > + + + {t(`apps.${appId}`)} {t("provider.tabProvider")} + + + {t("provider.tabUniversal")} + + + + + onOpenChange(false)} + showButtons={false} + /> + + + + + + + + {/* Universal Provider Form Modal */} + ); diff --git a/src/components/providers/forms/ApiKeyInput.tsx b/src/components/providers/forms/ApiKeyInput.tsx index 06a2982a3..784124bfe 100644 --- a/src/components/providers/forms/ApiKeyInput.tsx +++ b/src/components/providers/forms/ApiKeyInput.tsx @@ -36,10 +36,7 @@ const ApiKeyInput: React.FC = ({ return (
-
); diff --git a/src/components/universal/UniversalProviderCard.tsx b/src/components/universal/UniversalProviderCard.tsx new file mode 100644 index 000000000..b75bb2ef0 --- /dev/null +++ b/src/components/universal/UniversalProviderCard.tsx @@ -0,0 +1,115 @@ +import { useTranslation } from "react-i18next"; +import { Edit2, Trash2, RefreshCw, Globe } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ProviderIcon } from "@/components/ProviderIcon"; +import type { UniversalProvider } from "@/types"; + +interface UniversalProviderCardProps { + provider: UniversalProvider; + onEdit: (provider: UniversalProvider) => void; + onDelete: (id: string) => void; + onSync: (id: string) => void; +} + +export function UniversalProviderCard({ + provider, + onEdit, + onDelete, + onSync, +}: UniversalProviderCardProps) { + const { t } = useTranslation(); + + // 获取启用的应用列表 + const enabledApps: string[] = [ + provider.apps.claude ? "Claude" : null, + provider.apps.codex ? "Codex" : null, + provider.apps.gemini ? "Gemini" : null, + ].filter((app): app is string => app !== null); + + return ( +
+ {/* 头部:图标和名称 */} +
+
+
+ +
+
+

{provider.name}

+

+ {provider.providerType} +

+
+
+ + {/* 操作按钮 */} +
+ + + +
+
+ + {/* 配置信息 */} +
+ {/* Base URL */} +
+ + + {provider.baseUrl || "-"} + +
+ + {/* 启用的应用 */} +
+ {enabledApps.map((app) => ( + + {app} + + ))} + {enabledApps.length === 0 && ( + + {t("universalProvider.noAppsEnabled", { + defaultValue: "未启用任何应用", + })} + + )} +
+
+ + {/* 备注 */} + {provider.notes && ( +

+ {provider.notes} +

+ )} +
+ ); +} diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx new file mode 100644 index 000000000..e586092cc --- /dev/null +++ b/src/components/universal/UniversalProviderFormModal.tsx @@ -0,0 +1,718 @@ +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Eye, EyeOff, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { ProviderIcon } from "@/components/ProviderIcon"; +import JsonEditor from "@/components/JsonEditor"; +import type { UniversalProvider, UniversalProviderModels } from "@/types"; +import { + universalProviderPresets, + createUniversalProviderFromPreset, + type UniversalProviderPreset, +} from "@/config/universalProviderPresets"; + +interface UniversalProviderFormModalProps { + isOpen: boolean; + onClose: () => void; + onSave: (provider: UniversalProvider) => void; + onSaveAndSync?: (provider: UniversalProvider) => void; + editingProvider?: UniversalProvider | null; + initialPreset?: UniversalProviderPreset | null; +} + +export function UniversalProviderFormModal({ + isOpen, + onClose, + onSave, + onSaveAndSync, + editingProvider, + initialPreset, +}: UniversalProviderFormModalProps) { + const { t } = useTranslation(); + const isEditMode = !!editingProvider; + + // 表单状态 + const [selectedPreset, setSelectedPreset] = + useState(null); + const [name, setName] = useState(""); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [showApiKey, setShowApiKey] = useState(false); + const [websiteUrl, setWebsiteUrl] = useState(""); + const [notes, setNotes] = useState(""); + + // 应用启用状态 + const [claudeEnabled, setClaudeEnabled] = useState(true); + const [codexEnabled, setCodexEnabled] = useState(true); + const [geminiEnabled, setGeminiEnabled] = useState(true); + + // 模型配置 + const [models, setModels] = useState({}); + + // 保存并同步确认弹窗 + const [syncConfirmOpen, setSyncConfirmOpen] = useState(false); + const [pendingProvider, setPendingProvider] = + useState(null); + + // 初始化表单 + useEffect(() => { + if (editingProvider) { + // 编辑模式:加载现有数据 + setName(editingProvider.name); + setBaseUrl(editingProvider.baseUrl); + setApiKey(editingProvider.apiKey); + setWebsiteUrl(editingProvider.websiteUrl || ""); + setNotes(editingProvider.notes || ""); + setClaudeEnabled(editingProvider.apps.claude); + setCodexEnabled(editingProvider.apps.codex); + setGeminiEnabled(editingProvider.apps.gemini); + setModels(editingProvider.models || {}); + + // 尝试匹配预设 + const preset = universalProviderPresets.find( + (p) => p.providerType === editingProvider.providerType, + ); + setSelectedPreset(preset || null); + } else { + // 新建模式:使用传入的预设或默认选择第一个预设 + const defaultPreset = initialPreset || universalProviderPresets[0]; + setSelectedPreset(defaultPreset); + setName(defaultPreset.name); + setBaseUrl(""); + setApiKey(""); + setWebsiteUrl(defaultPreset.websiteUrl || ""); + setNotes(""); + setClaudeEnabled(defaultPreset.defaultApps.claude); + setCodexEnabled(defaultPreset.defaultApps.codex); + setGeminiEnabled(defaultPreset.defaultApps.gemini); + setModels(JSON.parse(JSON.stringify(defaultPreset.defaultModels))); + } + }, [editingProvider, initialPreset, isOpen]); + + // 选择预设 + const handlePresetSelect = useCallback( + (preset: UniversalProviderPreset) => { + setSelectedPreset(preset); + if (!isEditMode) { + setName(preset.name); + setClaudeEnabled(preset.defaultApps.claude); + setCodexEnabled(preset.defaultApps.codex); + setGeminiEnabled(preset.defaultApps.gemini); + setModels(JSON.parse(JSON.stringify(preset.defaultModels))); + } + }, + [isEditMode], + ); + + // 更新模型配置 + const updateModel = useCallback( + (app: "claude" | "codex" | "gemini", field: string, value: string) => { + setModels((prev) => ({ + ...prev, + [app]: { + ...(prev[app] || {}), + [field]: value, + }, + })); + }, + [], + ); + + // 计算 Claude 配置 JSON 预览 + const claudeConfigJson = useMemo(() => { + if (!claudeEnabled) return null; + const model = models.claude?.model || "claude-sonnet-4-20250514"; + const haiku = models.claude?.haikuModel || "claude-haiku-4-20250514"; + const sonnet = models.claude?.sonnetModel || "claude-sonnet-4-20250514"; + const opus = models.claude?.opusModel || "claude-sonnet-4-20250514"; + return { + env: { + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_AUTH_TOKEN: apiKey, + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_HAIKU_MODEL: haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: opus, + }, + }; + }, [claudeEnabled, baseUrl, apiKey, models.claude]); + + // 计算 Codex 配置 JSON 预览 + const codexConfigJson = useMemo(() => { + if (!codexEnabled) return null; + const model = models.codex?.model || "gpt-4o"; + const reasoningEffort = models.codex?.reasoningEffort || "high"; + // 确保 base_url 以 /v1 结尾(Codex 使用 OpenAI 兼容 API) + const codexBaseUrl = baseUrl.endsWith("/v1") + ? baseUrl + : `${baseUrl.replace(/\/+$/, "")}/v1`; + const configToml = `model_provider = "newapi" +model = "${model}" +model_reasoning_effort = "${reasoningEffort}" +disable_response_storage = true + +[model_providers.newapi] +name = "NewAPI" +base_url = "${codexBaseUrl}" +wire_api = "responses" +requires_openai_auth = true`; + return { + auth: { + OPENAI_API_KEY: apiKey, + }, + config: configToml, + }; + }, [codexEnabled, baseUrl, apiKey, models.codex]); + + // 计算 Gemini 配置 JSON 预览 + const geminiConfigJson = useMemo(() => { + if (!geminiEnabled) return null; + const model = models.gemini?.model || "gemini-2.5-pro"; + return { + env: { + GOOGLE_GEMINI_BASE_URL: baseUrl, + GEMINI_API_KEY: apiKey, + GEMINI_MODEL: model, + }, + }; + }, [geminiEnabled, baseUrl, apiKey, models.gemini]); + + // 提交表单 + const handleSubmit = useCallback(() => { + if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) { + return; + } + + const provider: UniversalProvider = editingProvider + ? { + ...editingProvider, + name: name.trim(), + baseUrl: baseUrl.trim(), + apiKey: apiKey.trim(), + websiteUrl: websiteUrl.trim() || undefined, + notes: notes.trim() || undefined, + apps: { + claude: claudeEnabled, + codex: codexEnabled, + gemini: geminiEnabled, + }, + models, + } + : createUniversalProviderFromPreset( + selectedPreset || universalProviderPresets[0], + crypto.randomUUID(), + baseUrl.trim(), + apiKey.trim(), + name.trim(), + ); + + // 如果是新建,更新应用启用状态和模型 + if (!editingProvider) { + provider.apps = { + claude: claudeEnabled, + codex: codexEnabled, + gemini: geminiEnabled, + }; + provider.models = models; + provider.websiteUrl = websiteUrl.trim() || undefined; + provider.notes = notes.trim() || undefined; + } + + onSave(provider); + onClose(); + }, [ + editingProvider, + name, + baseUrl, + apiKey, + websiteUrl, + notes, + claudeEnabled, + codexEnabled, + geminiEnabled, + models, + selectedPreset, + onSave, + onClose, + ]); + + // 构建 provider 对象的辅助函数 + const buildProvider = useCallback((): UniversalProvider | null => { + if (!name.trim() || !baseUrl.trim() || !apiKey.trim()) { + return null; + } + + const provider: UniversalProvider = editingProvider + ? { + ...editingProvider, + name: name.trim(), + baseUrl: baseUrl.trim(), + apiKey: apiKey.trim(), + websiteUrl: websiteUrl.trim() || undefined, + notes: notes.trim() || undefined, + apps: { + claude: claudeEnabled, + codex: codexEnabled, + gemini: geminiEnabled, + }, + models, + } + : createUniversalProviderFromPreset( + selectedPreset || universalProviderPresets[0], + crypto.randomUUID(), + baseUrl.trim(), + apiKey.trim(), + name.trim(), + ); + + // 如果是新建,更新应用启用状态和模型 + if (!editingProvider) { + provider.apps = { + claude: claudeEnabled, + codex: codexEnabled, + gemini: geminiEnabled, + }; + provider.models = models; + provider.websiteUrl = websiteUrl.trim() || undefined; + provider.notes = notes.trim() || undefined; + } + + return provider; + }, [ + editingProvider, + name, + baseUrl, + apiKey, + websiteUrl, + notes, + claudeEnabled, + codexEnabled, + geminiEnabled, + models, + selectedPreset, + ]); + + // 打开保存并同步确认弹窗 + const handleSaveAndSyncClick = useCallback(() => { + const provider = buildProvider(); + if (!provider || !onSaveAndSync) return; + + setPendingProvider(provider); + setSyncConfirmOpen(true); + }, [buildProvider, onSaveAndSync]); + + // 确认保存并同步 + const confirmSaveAndSync = useCallback(() => { + if (!pendingProvider || !onSaveAndSync) return; + + onSaveAndSync(pendingProvider); + setSyncConfirmOpen(false); + setPendingProvider(null); + onClose(); + }, [pendingProvider, onSaveAndSync, onClose]); + + const footer = ( + <> + + {isEditMode && onSaveAndSync ? ( + + ) : ( + + )} + + ); + + return ( + +
+ {/* 预设选择(仅新建模式) */} + {!isEditMode && ( +
+ +
+ {universalProviderPresets.map((preset) => ( + + ))} +
+ {selectedPreset?.description && ( +

+ {selectedPreset.description} +

+ )} +
+ )} + + {/* 基本信息 */} +
+
+ + setName(e.target.value)} + placeholder={t("universalProvider.namePlaceholder", { + defaultValue: "例如:我的 NewAPI", + })} + /> +
+ +
+ + setBaseUrl(e.target.value)} + placeholder="https://api.example.com" + /> +
+ +
+ +
+ setApiKey(e.target.value)} + placeholder="sk-..." + className="pr-10" + /> + +
+
+ +
+ + setWebsiteUrl(e.target.value)} + placeholder={t("universalProvider.websiteUrlPlaceholder", { + defaultValue: "https://example.com(可选,用于在列表中显示)", + })} + /> +
+ +
+ + setNotes(e.target.value)} + placeholder={t("universalProvider.notesPlaceholder", { + defaultValue: "可选:添加备注信息", + })} + /> +
+
+ + {/* 应用启用 */} +
+ +
+
+
+ + Claude Code +
+ +
+
+
+ + OpenAI Codex +
+ +
+
+
+ + Gemini CLI +
+ +
+
+
+ + {/* 模型配置 */} +
+ + + {/* Claude 模型 */} + {claudeEnabled && ( +
+
+ + Claude +
+
+
+ + + updateModel("claude", "model", e.target.value) + } + placeholder="claude-sonnet-4-20250514" + /> +
+
+ + + updateModel("claude", "haikuModel", e.target.value) + } + placeholder="claude-haiku-4-20250514" + /> +
+
+ + + updateModel("claude", "sonnetModel", e.target.value) + } + placeholder="claude-sonnet-4-20250514" + /> +
+
+ + + updateModel("claude", "opusModel", e.target.value) + } + placeholder="claude-sonnet-4-20250514" + /> +
+
+
+ )} + + {/* Codex 模型 */} + {codexEnabled && ( +
+
+ + Codex +
+
+
+ + + updateModel("codex", "model", e.target.value) + } + placeholder="gpt-4o" + /> +
+
+ + + updateModel("codex", "reasoningEffort", e.target.value) + } + placeholder="high" + /> +
+
+
+ )} + + {/* Gemini 模型 */} + {geminiEnabled && ( +
+
+ + Gemini +
+
+ + + updateModel("gemini", "model", e.target.value) + } + placeholder="gemini-2.5-pro" + /> +
+
+ )} +
+ + {/* 配置 JSON 预览 */} + {isEditMode && (claudeEnabled || codexEnabled || geminiEnabled) && ( +
+ +

+ {t("universalProvider.configJsonPreviewHint", { + defaultValue: + "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)", + })} +

+ + {/* Claude JSON */} + {claudeConfigJson && ( +
+
+ + Claude +
+ {}} + height={180} + /> +
+ )} + + {/* Codex JSON */} + {codexConfigJson && ( +
+
+ + Codex +
+ {}} + height={280} + /> +
+ )} + + {/* Gemini JSON */} + {geminiConfigJson && ( +
+
+ + Gemini +
+ {}} + height={140} + /> +
+ )} +
+ )} +
+ + {/* 保存并同步确认弹窗 */} + { + setSyncConfirmOpen(false); + setPendingProvider(null); + }} + /> +
+ ); +} diff --git a/src/components/universal/UniversalProviderPanel.tsx b/src/components/universal/UniversalProviderPanel.tsx new file mode 100644 index 000000000..0a81a8acc --- /dev/null +++ b/src/components/universal/UniversalProviderPanel.tsx @@ -0,0 +1,288 @@ +import { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Layers } from "lucide-react"; +import { toast } from "sonner"; +import { ConfirmDialog } from "@/components/ConfirmDialog"; +import { UniversalProviderCard } from "./UniversalProviderCard"; +import { UniversalProviderFormModal } from "./UniversalProviderFormModal"; +import { universalProvidersApi } from "@/lib/api"; +import type { UniversalProvider, UniversalProvidersMap } from "@/types"; + +export function UniversalProviderPanel() { + const { t } = useTranslation(); + + // 状态 + const [providers, setProviders] = useState({}); + const [loading, setLoading] = useState(true); + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingProvider, setEditingProvider] = + useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{ + open: boolean; + id: string; + name: string; + }>({ open: false, id: "", name: "" }); + const [syncConfirm, setSyncConfirm] = useState<{ + open: boolean; + id: string; + name: string; + }>({ open: false, id: "", name: "" }); + + // 加载数据 + const loadProviders = useCallback(async () => { + try { + setLoading(true); + const data = await universalProvidersApi.getAll(); + setProviders(data); + } catch (error) { + console.error("Failed to load universal providers:", error); + toast.error( + t("universalProvider.loadError", { + defaultValue: "加载统一供应商失败", + }), + ); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + loadProviders(); + }, [loadProviders]); + + // 添加/编辑供应商 + const handleSave = useCallback( + async (provider: UniversalProvider) => { + try { + await universalProvidersApi.upsert(provider); + + // 新建模式下自动同步到各应用 + if (!editingProvider) { + await universalProvidersApi.sync(provider.id); + } + + toast.success( + editingProvider + ? t("universalProvider.updated", { + defaultValue: "统一供应商已更新", + }) + : t("universalProvider.addedAndSynced", { + defaultValue: "统一供应商已添加并同步", + }), + ); + loadProviders(); + setEditingProvider(null); + } catch (error) { + console.error("Failed to save universal provider:", error); + toast.error( + t("universalProvider.saveError", { + defaultValue: "保存统一供应商失败", + }), + ); + } + }, + [editingProvider, loadProviders, t], + ); + + // 保存并同步供应商 + const handleSaveAndSync = useCallback( + async (provider: UniversalProvider) => { + try { + await universalProvidersApi.upsert(provider); + await universalProvidersApi.sync(provider.id); + toast.success( + t("universalProvider.savedAndSynced", { + defaultValue: "已保存并同步到所有应用", + }), + ); + loadProviders(); + setEditingProvider(null); + } catch (error) { + console.error("Failed to save and sync universal provider:", error); + toast.error( + t("universalProvider.saveAndSyncError", { + defaultValue: "保存并同步失败", + }), + ); + } + }, + [loadProviders, t], + ); + + // 删除供应商 + const handleDelete = useCallback(async () => { + if (!deleteConfirm.id) return; + + try { + await universalProvidersApi.delete(deleteConfirm.id); + toast.success( + t("universalProvider.deleted", { defaultValue: "统一供应商已删除" }), + ); + loadProviders(); + } catch (error) { + console.error("Failed to delete universal provider:", error); + toast.error( + t("universalProvider.deleteError", { + defaultValue: "删除统一供应商失败", + }), + ); + } finally { + setDeleteConfirm({ open: false, id: "", name: "" }); + } + }, [deleteConfirm.id, loadProviders, t]); + + // 同步供应商 + const handleSync = useCallback(async () => { + if (!syncConfirm.id) return; + + try { + await universalProvidersApi.sync(syncConfirm.id); + toast.success( + t("universalProvider.synced", { defaultValue: "已同步到所有应用" }), + ); + } catch (error) { + console.error("Failed to sync universal provider:", error); + toast.error( + t("universalProvider.syncError", { + defaultValue: "同步统一供应商失败", + }), + ); + } finally { + setSyncConfirm({ open: false, id: "", name: "" }); + } + }, [syncConfirm.id, t]); + + // 打开同步确认 + const handleSyncClick = useCallback( + (id: string) => { + const provider = providers[id]; + setSyncConfirm({ + open: true, + id, + name: provider?.name || id, + }); + }, + [providers], + ); + + // 打开编辑 + const handleEdit = useCallback((provider: UniversalProvider) => { + setEditingProvider(provider); + setIsFormOpen(true); + }, []); + + // 打开删除确认 + const handleDeleteClick = useCallback( + (id: string) => { + const provider = providers[id]; + setDeleteConfirm({ + open: true, + id, + name: provider?.name || id, + }); + }, + [providers], + ); + + const providerList = Object.values(providers); + + return ( +
+ {/* 头部 */} +
+ +

+ {t("universalProvider.title", { defaultValue: "统一供应商" })} +

+ + {providerList.length} + +
+ + {/* 描述 */} +

+ {t("universalProvider.description", { + defaultValue: + "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。", + })} +

+ + {/* 供应商列表 */} + {loading ? ( +
+
+
+ ) : providerList.length === 0 ? ( +
+ +

+ {t("universalProvider.empty", { + defaultValue: "还没有统一供应商", + })} +

+

+ {t("universalProvider.emptyHint", { + defaultValue: "点击下方「添加统一供应商」按钮创建一个", + })} +

+
+ ) : ( +
+ {providerList.map((provider) => ( + + ))} +
+ )} + + {/* 表单模态框 */} + { + setIsFormOpen(false); + setEditingProvider(null); + }} + onSave={handleSave} + onSaveAndSync={handleSaveAndSync} + editingProvider={editingProvider} + /> + + {/* 删除确认对话框 */} + setDeleteConfirm({ open: false, id: "", name: "" })} + /> + + {/* 同步确认对话框 */} + setSyncConfirm({ open: false, id: "", name: "" })} + /> +
+ ); +} diff --git a/src/components/universal/index.ts b/src/components/universal/index.ts new file mode 100644 index 000000000..7a09c9a9c --- /dev/null +++ b/src/components/universal/index.ts @@ -0,0 +1,3 @@ +export { UniversalProviderPanel } from "./UniversalProviderPanel"; +export { UniversalProviderCard } from "./UniversalProviderCard"; +export { UniversalProviderFormModal } from "./UniversalProviderFormModal"; diff --git a/src/config/universalProviderPresets.ts b/src/config/universalProviderPresets.ts new file mode 100644 index 000000000..5bb854338 --- /dev/null +++ b/src/config/universalProviderPresets.ts @@ -0,0 +1,131 @@ +/** + * 统一供应商(Universal Provider)预设配置 + * + * 统一供应商是跨应用共享的配置,修改后会自动同步到 Claude、Codex、Gemini 三个应用。 + * 适用于 NewAPI 等支持多种协议的 API 网关。 + */ + +import type { + UniversalProvider, + UniversalProviderApps, + UniversalProviderModels, +} from "@/types"; + +/** + * 统一供应商预设接口 + */ +export interface UniversalProviderPreset { + /** 预设名称 */ + name: string; + /** 供应商类型标识 */ + providerType: string; + /** 默认启用的应用 */ + defaultApps: UniversalProviderApps; + /** 默认模型配置 */ + defaultModels: UniversalProviderModels; + /** 网站链接 */ + websiteUrl?: string; + /** 图标名称 */ + icon?: string; + /** 图标颜色 */ + iconColor?: string; + /** 描述 */ + description?: string; + /** 是否为自定义模板(允许用户完全自定义) */ + isCustomTemplate?: boolean; +} + +/** + * NewAPI 默认模型配置 + */ +const NEWAPI_DEFAULT_MODELS: UniversalProviderModels = { + claude: { + model: "claude-sonnet-4-20250514", + haikuModel: "claude-haiku-4-20250514", + sonnetModel: "claude-sonnet-4-20250514", + opusModel: "claude-sonnet-4-20250514", + }, + codex: { + model: "gpt-4o", + reasoningEffort: "high", + }, + gemini: { + model: "gemini-2.5-pro", + }, +}; + +/** + * 统一供应商预设列表 + */ +export const universalProviderPresets: UniversalProviderPreset[] = [ + { + name: "NewAPI", + providerType: "newapi", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: NEWAPI_DEFAULT_MODELS, + websiteUrl: "https://www.newapi.pro", + icon: "newapi", + iconColor: "#00A67E", + description: + "NewAPI 是一个可自部署的 API 网关,支持 Anthropic、OpenAI、Gemini 等多种协议", + }, + { + name: "自定义网关", + providerType: "custom_gateway", + defaultApps: { + claude: true, + codex: true, + gemini: true, + }, + defaultModels: NEWAPI_DEFAULT_MODELS, + icon: "openai", + iconColor: "#6366F1", + description: "自定义配置的 API 网关", + isCustomTemplate: true, + }, +]; + +/** + * 根据预设创建统一供应商 + */ +export function createUniversalProviderFromPreset( + preset: UniversalProviderPreset, + id: string, + baseUrl: string, + apiKey: string, + customName?: string, +): UniversalProvider { + return { + id, + name: customName || preset.name, + providerType: preset.providerType, + apps: { ...preset.defaultApps }, + baseUrl, + apiKey, + models: JSON.parse(JSON.stringify(preset.defaultModels)), // Deep copy + websiteUrl: preset.websiteUrl, + icon: preset.icon, + iconColor: preset.iconColor, + createdAt: Date.now(), + }; +} + +/** + * 获取预设的显示名称(用于 UI) + */ +export function getPresetDisplayName(preset: UniversalProviderPreset): string { + return preset.name; +} + +/** + * 根据类型查找预设 + */ +export function findPresetByType( + providerType: string, +): UniversalProviderPreset | undefined { + return universalProviderPresets.find((p) => p.providerType === providerType); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34c697af5..97f6873a5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -66,6 +66,8 @@ "exitEditMode": "Exit Edit Mode" }, "provider": { + "tabProvider": "Provider", + "tabUniversal": "Universal", "noProviders": "No providers added yet", "noProvidersDescription": "Click the \"Add Provider\" button in the top right to configure your first API provider", "currentlyUsing": "Currently Using", @@ -1135,5 +1137,49 @@ "streamingFirstByte": "Streaming First Byte Timeout", "streamingIdle": "Streaming Idle Timeout", "nonStreaming": "Non-Streaming Timeout" + }, + "universalProvider": { + "title": "Universal Provider", + "description": "Universal providers manage Claude, Codex, and Gemini configurations simultaneously. Changes are automatically synced to all enabled apps.", + "add": "Add Universal Provider", + "edit": "Edit Universal Provider", + "empty": "No universal providers yet", + "emptyHint": "Click the \"Add Universal Provider\" button below to create one", + "selectPreset": "Select Preset Type", + "name": "Name", + "namePlaceholder": "e.g., My NewAPI", + "baseUrl": "API URL", + "apiKey": "API Key", + "websiteUrl": "Website URL", + "websiteUrlPlaceholder": "https://example.com (optional, displayed in the list)", + "notes": "Notes", + "notesPlaceholder": "Optional: Add notes", + "enabledApps": "Enabled Apps", + "modelConfig": "Model Configuration", + "model": "Model", + "sync": "Sync to Apps", + "synced": "Synced to all apps", + "syncError": "Sync failed", + "noAppsEnabled": "No apps enabled", + "added": "Universal provider added", + "addedAndSynced": "Universal provider added and synced", + "updated": "Universal provider updated", + "deleted": "Universal provider deleted", + "addSuccess": "Universal provider added successfully", + "addFailed": "Failed to add universal provider", + "manage": "Manage", + "loadError": "Failed to load universal providers", + "saveError": "Failed to save universal provider", + "deleteError": "Failed to delete universal provider", + "deleteConfirmTitle": "Delete Universal Provider", + "deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This will also delete its generated provider configurations in each app.", + "syncConfirmTitle": "Sync Universal Provider", + "syncConfirmDescription": "Syncing \"{{name}}\" will overwrite the associated provider configurations in Claude, Codex, and Gemini. Do you want to continue?", + "syncConfirm": "Sync", + "saveAndSync": "Save & Sync", + "savedAndSynced": "Saved and synced to all apps", + "saveAndSyncError": "Failed to save and sync", + "configJsonPreview": "Config JSON Preview", + "configJsonPreviewHint": "The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)" } } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 0a7877f39..1a27b4715 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -66,6 +66,8 @@ "exitEditMode": "編集モードを終了" }, "provider": { + "tabProvider": "プロバイダー", + "tabUniversal": "統一プロバイダー", "noProviders": "まだプロバイダーがありません", "noProvidersDescription": "右上の「プロバイダーを追加」を押して最初の API プロバイダーを登録してください", "currentlyUsing": "現在使用中", @@ -1135,5 +1137,48 @@ "streamingFirstByte": "ストリーミング初回バイトタイムアウト", "streamingIdle": "ストリーミングアイドルタイムアウト", "nonStreaming": "非ストリーミングタイムアウト" + }, + "universalProvider": { + "title": "統合プロバイダー", + "description": "統合プロバイダーは Claude、Codex、Gemini の設定を同時に管理します。変更は有効なすべてのアプリに自動的に同期されます。", + "add": "統合プロバイダーを追加", + "edit": "統合プロバイダーを編集", + "empty": "統合プロバイダーがありません", + "emptyHint": "下の「統合プロバイダーを追加」ボタンをクリックして作成してください", + "selectPreset": "プリセットタイプを選択", + "name": "名前", + "namePlaceholder": "例:私の NewAPI", + "baseUrl": "API URL", + "apiKey": "API キー", + "websiteUrl": "ウェブサイト URL", + "websiteUrlPlaceholder": "https://example.com(オプション、リストに表示されます)", + "notes": "メモ", + "notesPlaceholder": "オプション:メモを追加", + "enabledApps": "有効なアプリ", + "modelConfig": "モデル設定", + "model": "モデル", + "sync": "アプリに同期", + "synced": "すべてのアプリに同期しました", + "syncError": "同期に失敗しました", + "noAppsEnabled": "有効なアプリがありません", + "added": "統合プロバイダーを追加しました", + "updated": "統合プロバイダーを更新しました", + "deleted": "統合プロバイダーを削除しました", + "addSuccess": "統合プロバイダーを追加しました", + "addFailed": "統合プロバイダーの追加に失敗しました", + "manage": "管理", + "loadError": "統合プロバイダーの読み込みに失敗しました", + "saveError": "統合プロバイダーの保存に失敗しました", + "deleteError": "統合プロバイダーの削除に失敗しました", + "deleteConfirmTitle": "統合プロバイダーを削除", + "deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?各アプリで生成されたプロバイダー設定も削除されます。", + "syncConfirmTitle": "統合プロバイダーを同期", + "syncConfirmDescription": "「{{name}}」を同期すると、Claude、Codex、Gemini の関連プロバイダー設定が上書きされます。続行しますか?", + "syncConfirm": "同期", + "saveAndSync": "保存して同期", + "savedAndSynced": "すべてのアプリに保存・同期されました", + "saveAndSyncError": "保存と同期に失敗しました", + "configJsonPreview": "設定 JSON プレビュー", + "configJsonPreviewHint": "以下は各アプリに同期される設定内容です(表示されているフィールドのみ上書きされ、他のカスタム設定は保持されます)" } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7e0405d0c..73bf58558 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -66,6 +66,8 @@ "exitEditMode": "退出编辑模式" }, "provider": { + "tabProvider": "供应商", + "tabUniversal": "统一供应商", "noProviders": "还没有添加任何供应商", "noProvidersDescription": "点击右上角的\"添加供应商\"按钮开始配置您的第一个API供应商", "currentlyUsing": "当前使用", @@ -1135,5 +1137,49 @@ "streamingFirstByte": "流式首字节超时", "streamingIdle": "流式静默超时", "nonStreaming": "非流式超时" + }, + "universalProvider": { + "title": "统一供应商", + "description": "统一供应商可以同时管理 Claude、Codex 和 Gemini 的配置。修改后会自动同步到所有启用的应用。", + "add": "添加统一供应商", + "edit": "编辑统一供应商", + "empty": "还没有统一供应商", + "emptyHint": "点击下方「添加统一供应商」按钮创建一个", + "selectPreset": "选择预设类型", + "name": "名称", + "namePlaceholder": "例如:我的 NewAPI", + "baseUrl": "API 地址", + "apiKey": "API Key", + "websiteUrl": "官网地址", + "websiteUrlPlaceholder": "https://example.com(可选,用于在列表中显示)", + "notes": "备注", + "notesPlaceholder": "可选:添加备注信息", + "enabledApps": "启用的应用", + "modelConfig": "模型配置", + "model": "模型", + "sync": "同步到应用", + "synced": "已同步到所有应用", + "syncError": "同步失败", + "noAppsEnabled": "未启用任何应用", + "added": "统一供应商已添加", + "addedAndSynced": "统一供应商已添加并同步", + "updated": "统一供应商已更新", + "deleted": "统一供应商已删除", + "addSuccess": "统一供应商添加成功", + "addFailed": "统一供应商添加失败", + "manage": "管理", + "loadError": "加载统一供应商失败", + "saveError": "保存统一供应商失败", + "deleteError": "删除统一供应商失败", + "deleteConfirmTitle": "删除统一供应商", + "deleteConfirmDescription": "确定要删除 \"{{name}}\" 吗?这将同时删除它在各应用中生成的供应商配置。", + "syncConfirmTitle": "同步统一供应商", + "syncConfirmDescription": "同步 \"{{name}}\" 将会覆盖 Claude、Codex 和 Gemini 中关联的供应商配置。确定要继续吗?", + "syncConfirm": "同步", + "saveAndSync": "保存并同步", + "savedAndSynced": "已保存并同步到所有应用", + "saveAndSyncError": "保存并同步失败", + "configJsonPreview": "配置 JSON 预览", + "configJsonPreviewHint": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)" } } diff --git a/src/icons/extracted/index.ts b/src/icons/extracted/index.ts index c9f56c3f8..4ae7755bc 100644 --- a/src/icons/extracted/index.ts +++ b/src/icons/extracted/index.ts @@ -30,6 +30,7 @@ export const icons: Record = { midjourney: `Midjourney`, minimax: `Minimax`, mistral: `Mistral`, + newapi: `NewAPI`, notion: `Notion`, ollama: `Ollama`, openai: `OpenAI`, @@ -42,7 +43,7 @@ export const icons: Record = { vercel: `Vercel`, wenxin: `Wenxin`, xai: `Grok`, - 'xiaomimimo': `Logo`, + xiaomimimo: `Logo`, yi: `Yi`, zeroone: `01.AI`, zhipu: `Zhipu`, diff --git a/src/icons/extracted/metadata.ts b/src/icons/extracted/metadata.ts index 0224fcbfe..61390476a 100644 --- a/src/icons/extracted/metadata.ts +++ b/src/icons/extracted/metadata.ts @@ -198,6 +198,13 @@ export const iconMetadata: Record = { keywords: ["mistral"], defaultColor: "#FF7000", }, + newapi: { + name: "newapi", + displayName: "newapi", + category: "other", + keywords: [], + defaultColor: "currentColor", + }, notion: { name: "notion", displayName: "notion", diff --git a/src/icons/extracted/newapi.svg b/src/icons/extracted/newapi.svg new file mode 100644 index 000000000..a9e1261b9 --- /dev/null +++ b/src/icons/extracted/newapi.svg @@ -0,0 +1 @@ +NewAPI \ No newline at end of file diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 371faa945..71a915174 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,5 +1,5 @@ export type { AppId } from "./types"; -export { providersApi } from "./providers"; +export { providersApi, universalProvidersApi } from "./providers"; export { settingsApi } from "./settings"; export { mcpApi } from "./mcp"; export { promptsApi } from "./prompts"; diff --git a/src/lib/api/providers.ts b/src/lib/api/providers.ts index 14c8e115a..ce7df8227 100644 --- a/src/lib/api/providers.ts +++ b/src/lib/api/providers.ts @@ -1,6 +1,10 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import type { Provider } from "@/types"; +import type { + Provider, + UniversalProvider, + UniversalProvidersMap, +} from "@/types"; import type { AppId } from "./types"; export interface ProviderSortUpdate { @@ -62,3 +66,44 @@ export const providersApi = { }); }, }; + +// ============================================================================ +// 统一供应商(Universal Provider)API +// ============================================================================ + +export const universalProvidersApi = { + /** + * 获取所有统一供应商 + */ + async getAll(): Promise { + return await invoke("get_universal_providers"); + }, + + /** + * 获取单个统一供应商 + */ + async get(id: string): Promise { + return await invoke("get_universal_provider", { id }); + }, + + /** + * 添加或更新统一供应商 + */ + async upsert(provider: UniversalProvider): Promise { + return await invoke("upsert_universal_provider", { provider }); + }, + + /** + * 删除统一供应商 + */ + async delete(id: string): Promise { + return await invoke("delete_universal_provider", { id }); + }, + + /** + * 手动同步统一供应商到各应用 + */ + async sync(id: string): Promise { + return await invoke("sync_universal_provider", { id }); + }, +}; diff --git a/src/types.ts b/src/types.ts index d6e38d7a9..2761319b8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -186,3 +186,61 @@ export interface McpConfigResponse { configPath: string; servers: Record; } + +// ============================================================================ +// 统一供应商(Universal Provider)- 跨应用共享配置 +// ============================================================================ + +// 统一供应商的应用启用状态 +export interface UniversalProviderApps { + claude: boolean; + codex: boolean; + gemini: boolean; +} + +// Claude 模型配置 +export interface ClaudeModelConfig { + model?: string; + haikuModel?: string; + sonnetModel?: string; + opusModel?: string; +} + +// Codex 模型配置 +export interface CodexModelConfig { + model?: string; + reasoningEffort?: string; +} + +// Gemini 模型配置 +export interface GeminiModelConfig { + model?: string; +} + +// 各应用的模型配置 +export interface UniversalProviderModels { + claude?: ClaudeModelConfig; + codex?: CodexModelConfig; + gemini?: GeminiModelConfig; +} + +// 统一供应商(跨应用共享配置) +export interface UniversalProvider { + id: string; + name: string; + providerType: string; // "newapi" | "custom" 等 + apps: UniversalProviderApps; + baseUrl: string; + apiKey: string; + models: UniversalProviderModels; + websiteUrl?: string; + notes?: string; + icon?: string; + iconColor?: string; + meta?: ProviderMeta; + createdAt?: number; + sortIndex?: number; +} + +// 统一供应商映射(id -> UniversalProvider) +export type UniversalProvidersMap = Record;