From 3dcbe313bed2085ab64e00f22727d69a28934274 Mon Sep 17 00:00:00 2001 From: w0x7ce Date: Tue, 23 Dec 2025 17:11:20 +0800 Subject: [PATCH 1/5] feat: add provider-specific terminal button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a terminal button next to each provider card that opens a new terminal window with that provider's specific API configuration. This allows using different providers independently without changing the global setting. Changes: - Backend: Add `open_provider_terminal` command that extracts provider config and creates a temporary claude settings file - Frontend: Add terminal button to provider cards with proper callback propagation through component hierarchy - Support macOS (Terminal.app), Linux (gnome-terminal, konsole, etc.), and Windows (cmd) Each provider gets a unique config file named `claude__.json` in the temp directory, containing the provider's API configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src-tauri/src/commands/misc.rs | 233 +++++++++++++++++++ src-tauri/src/lib.rs | 3 + src/App.tsx | 21 ++ src/components/providers/ProviderActions.tsx | 18 ++ src/components/providers/ProviderCard.tsx | 3 + src/components/providers/ProviderList.tsx | 6 + src/lib/api/providers.ts | 9 + 7 files changed, 293 insertions(+) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 15e88145f..122e9b1bc 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1,8 +1,12 @@ #![allow(non_snake_case)] +use crate::app_config::AppType; use crate::init_status::InitErrorPayload; +use crate::services::ProviderService; use tauri::AppHandle; +use tauri::State; use tauri_plugin_opener::OpenerExt; +use std::str::FromStr; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; @@ -282,3 +286,232 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { (None, Some("未安装或无法执行".to_string())) } + +/// 打开指定提供商的终端 +/// +/// 根据提供商配置的环境变量启动一个带有该提供商特定设置的终端 +/// 无需检查是否为当前激活的提供商,任何提供商都可以打开终端 +#[allow(non_snake_case)] +#[tauri::command] +pub async fn open_provider_terminal( + state: State<'_, crate::store::AppState>, + app: String, + #[allow(non_snake_case)] providerId: String, +) -> Result { + let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; + + // 获取提供商配置 + let providers = ProviderService::list(state.inner(), app_type.clone()) + .map_err(|e| format!("获取提供商列表失败: {e}"))?; + + let provider = providers.get(&providerId) + .ok_or_else(|| format!("提供商 {providerId} 不存在"))?; + + // 从提供商配置中提取环境变量 + let config = &provider.settings_config; + let env_vars = extract_env_vars_from_config(config, &app_type); + + // 根据平台启动终端,传入提供商ID用于生成唯一的配置文件名 + launch_terminal_with_env(env_vars, &providerId).map_err(|e| format!("启动终端失败: {e}"))?; + + Ok(true) +} + +/// 从提供商配置中提取环境变量 +fn extract_env_vars_from_config( + config: &serde_json::Value, + app_type: &AppType, +) -> Vec<(String, String)> { + let mut env_vars = Vec::new(); + + if let Some(obj) = config.as_object() { + // Claude 使用 env 字段 + if let Some(env) = obj.get("env").and_then(|v| v.as_object()) { + for (key, value) in env { + if let Some(str_val) = value.as_str() { + env_vars.push((key.clone(), str_val.to_string())); + } + } + } + + // Codex 使用 auth 字段 + if let Some(auth) = obj.get("auth").and_then(|v| v.as_str()) { + match app_type { + AppType::Codex => { + env_vars.push(("OPENAI_API_KEY".to_string(), auth.to_string())); + } + _ => {} + } + } + + // Gemini 使用 API_KEY + if let Some(api_key) = obj.get("api_key").and_then(|v| v.as_str()) { + match app_type { + AppType::Gemini => { + env_vars.push(("GOOGLE_API_KEY".to_string(), api_key.to_string())); + } + _ => {} + } + } + + // 提取 base_url(如果存在) + if let Some(env) = obj.get("env").and_then(|v| v.as_object()) { + if let Some(base_url) = env.get("ANTHROPIC_BASE_URL").or_else(|| env.get("GOOGLE_GEMINI_BASE_URL")) { + if let Some(url_str) = base_url.as_str() { + match app_type { + AppType::Claude => { + env_vars.push(("ANTHROPIC_BASE_URL".to_string(), url_str.to_string())); + } + AppType::Gemini => { + env_vars.push(("GOOGLE_GEMINI_BASE_URL".to_string(), url_str.to_string())); + } + _ => {} + } + } + } + } + } + + env_vars +} + +/// 创建临时配置文件并启动 claude 终端 +/// 使用 --settings 参数传入提供商特定的 API 配置 +fn launch_terminal_with_env(env_vars: Vec<(String, String)>, provider_id: &str) -> Result<(), String> { + use std::process::Command; + + // 创建临时配置文件,使用提供商ID和进程ID确保唯一性 + let temp_dir = std::env::temp_dir(); + let config_file = temp_dir.join(format!("claude_{}_{}.json", provider_id, std::process::id())); + + // 构建 claude 配置 JSON 格式 + let mut config_obj = serde_json::Map::new(); + let mut env_obj = serde_json::Map::new(); + + for (key, value) in &env_vars { + env_obj.insert(key.clone(), serde_json::Value::String(value.clone())); + } + + config_obj.insert("env".to_string(), serde_json::Value::Object(env_obj)); + + let config_json = serde_json::to_string_pretty(&config_obj) + .map_err(|e| format!("序列化配置失败: {e}"))?; + + // 写入临时配置文件 + std::fs::write(&config_file, config_json) + .map_err(|e| format!("写入配置文件失败: {e}"))?; + + // 转义配置文件路径用于 shell + let config_path_escaped = config_file.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('$', "\\$") + .replace(' ', "\\ "); + + #[cfg(target_os = "macos")] + { + // macOS: 使用 Terminal.app 启动 claude + let mut terminal_cmd = Command::new("osascript"); + terminal_cmd.arg("-e"); + + let config_file_for_cleanup = config_file.clone(); + let script = format!( + r#"tell application "Terminal" + activate + do script "echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; exit" + end tell"#, + config_path_escaped, config_path_escaped + ); + + terminal_cmd.arg(&script); + + terminal_cmd + .spawn() + .map_err(|e| format!("启动 macOS 终端失败: {e}"))?; + + return Ok(()); + } + + #[cfg(target_os = "linux")] + { + // Linux: 尝试使用常见终端 + let terminals = [ + "gnome-terminal", "konsole", "xfce4-terminal", + "mate-terminal", "lxterminal", "alacritty", "kitty", + ]; + + let mut last_error = String::from("未找到可用的终端"); + + for terminal in terminals { + // 检查终端是否存在 + if Command::new("which").arg(terminal).output().is_err() { + continue; + } + + let result = Command::new(terminal) + .arg("--") + .arg("sh") + .arg("-c") + .arg(&format!( + "echo 'Using provider-specific claude config:' && echo '{}' && claude --settings '{}'; $SHELL", + config_path_escaped, config_path_escaped + )) + .spawn(); + + match result { + Ok(_) => { + return Ok(()); + } + Err(e) => { + last_error = format!("启动 {} 失败: {}", terminal, e); + continue; + } + } + } + + // 如果所有终端都失败,清理配置文件 + let _ = std::fs::remove_file(&config_file); + return Err(last_error); + } + + #[cfg(target_os = "windows")] + { + use std::io::Write; + + // Windows: 创建临时批处理文件 + let bat_file = temp_dir.join(format!("cc_switch_claude_{}.bat", std::process::id())); + + let mut content = String::from("@echo off\n"); + content.push_str(&format!("echo Using provider-specific claude config:\n")); + content.push_str(&format!("echo {}\n", config_file.to_string_lossy().to_string().replace('&', "^&"))); + content.push_str(&format!("claude --settings \"{}\"\n", config_file.to_string_lossy().to_string().replace('&', "^&"))); + content.push_str("if errorlevel 1 (\n"); + content.push_str(" echo.\n"); + content.push_str(" echo Press any key to close...\n"); + content.push_str(" pause >nul\n"); + content.push_str(")\n"); + + std::fs::write(&bat_file, content) + .map_err(|e| format!("写入批处理文件失败: {e}"))?; + + // 启动新的 cmd 窗口执行批处理文件 + Command::new("cmd") + .args(["/C", "start", "cmd", "/C", &bat_file.to_string_lossy().to_string()]) + .creation_flags(CREATE_NO_WINDOW) + .spawn() + .map_err(|e| format!("启动 Windows 终端失败: {e}"))?; + + return Ok(()); + } + + // 这个代码在所有支持的平台上都不可达,因为前面的平台特定块都已经返回了 + // 使用 cfg 和 allow 来避免编译器警告和错误 + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[allow(unreachable_code)] + { + Ok(()) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + Err("不支持的操作系统".to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27110f225..fbef7aa95 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ mod tray; mod usage_script; pub use app_config::{AppType, McpApps, McpServer, MultiAppConfig}; +pub use commands::open_provider_terminal; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; @@ -689,6 +690,8 @@ pub fn run() { commands::get_stream_check_config, commands::save_stream_check_config, commands::get_tool_versions, + // Provider terminal + commands::open_provider_terminal, ]); let app = builder diff --git a/src/App.tsx b/src/App.tsx index 40db2fa2c..ff5764260 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -286,6 +286,26 @@ function App() { await addProvider(duplicatedProvider); }; + // 打开提供商终端 + const handleOpenTerminal = async (provider: Provider) => { + try { + await providersApi.openTerminal(provider.id, activeApp); + toast.success( + t("provider.terminalOpened", { + defaultValue: "终端已打开", + }), + ); + } catch (error) { + console.error("[App] Failed to open terminal", error); + const errorMessage = extractErrorMessage(error); + toast.error( + t("provider.terminalOpenFailed", { + defaultValue: "打开终端失败", + }) + (errorMessage ? `: ${errorMessage}` : ""), + ); + } + }; + // 导入配置成功后刷新 const handleImportSuccess = async () => { try { @@ -378,6 +398,7 @@ function App() { onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} + onOpenTerminal={handleOpenTerminal} onCreate={() => setIsAddOpen(true)} /> diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index e3047e187..e508167ac 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -6,6 +6,7 @@ import { Loader2, Play, Plus, + Terminal, TestTube2, Trash2, } from "lucide-react"; @@ -23,6 +24,7 @@ interface ProviderActionsProps { onTest?: () => void; onConfigureUsage: () => void; onDelete: () => void; + onOpenTerminal?: () => void; // 故障转移相关 isAutoFailoverEnabled?: boolean; isInFailoverQueue?: boolean; @@ -39,6 +41,7 @@ export function ProviderActions({ onTest, onConfigureUsage, onDelete, + onOpenTerminal, // 故障转移相关 isAutoFailoverEnabled = false, isInFailoverQueue = false, @@ -171,6 +174,21 @@ export function ProviderActions({ + {onOpenTerminal && ( + + )} +