feat: add provider-specific terminal button

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_<providerId>_<pid>.json`
in the temp directory, containing the provider's API configuration.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
w0x7ce
2025-12-23 17:11:20 +08:00
parent 1586451862
commit 3dcbe313be
7 changed files with 293 additions and 0 deletions
+233
View File
@@ -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<String>, Option<String>) {
(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<bool, String> {
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())
}
+3
View File
@@ -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
+21
View File
@@ -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)}
/>
</motion.div>
@@ -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({
<BarChart3 className="h-4 w-4" />
</Button>
{onOpenTerminal && (
<Button
size="icon"
variant="ghost"
onClick={onOpenTerminal}
title={t("provider.openTerminal", "打开终端")}
className={cn(
iconButtonClass,
"hover:text-emerald-600 dark:hover:text-emerald-400",
)}
>
<Terminal className="h-4 w-4" />
</Button>
)}
<Button
size="icon"
variant="ghost"
@@ -33,6 +33,7 @@ interface ProviderCardProps {
onOpenWebsite: (url: string) => void;
onDuplicate: (provider: Provider) => void;
onTest?: (provider: Provider) => void;
onOpenTerminal?: (provider: Provider) => void;
isTesting?: boolean;
isProxyRunning: boolean;
isProxyTakeover?: boolean; // 代理接管模式(Live配置已被接管,切换为热切换)
@@ -91,6 +92,7 @@ export function ProviderCard({
onOpenWebsite,
onDuplicate,
onTest,
onOpenTerminal,
isTesting,
isProxyRunning,
isProxyTakeover = false,
@@ -339,6 +341,7 @@ export function ProviderCard({
onTest={onTest ? () => onTest(provider) : undefined}
onConfigureUsage={() => onConfigureUsage(provider)}
onDelete={() => onDelete(provider)}
onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined}
// 故障转移相关
isAutoFailoverEnabled={isAutoFailoverEnabled}
isInFailoverQueue={isInFailoverQueue}
@@ -41,6 +41,7 @@ interface ProviderListProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onCreate?: () => void;
isLoading?: boolean;
isProxyRunning?: boolean; // 代理服务运行状态
@@ -58,6 +59,7 @@ export function ProviderList({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onCreate,
isLoading = false,
isProxyRunning = false,
@@ -203,6 +205,7 @@ export function ProviderList({
onDuplicate={onDuplicate}
onConfigureUsage={onConfigureUsage}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={handleTest}
isTesting={isChecking(provider.id)}
isProxyRunning={isProxyRunning}
@@ -311,6 +314,7 @@ interface SortableProviderCardProps {
onDuplicate: (provider: Provider) => void;
onConfigureUsage?: (provider: Provider) => void;
onOpenWebsite: (url: string) => void;
onOpenTerminal?: (provider: Provider) => void;
onTest: (provider: Provider) => void;
isTesting: boolean;
isProxyRunning: boolean;
@@ -333,6 +337,7 @@ function SortableProviderCard({
onDuplicate,
onConfigureUsage,
onOpenWebsite,
onOpenTerminal,
onTest,
isTesting,
isProxyRunning,
@@ -371,6 +376,7 @@ function SortableProviderCard({
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
}
onOpenWebsite={onOpenWebsite}
onOpenTerminal={onOpenTerminal}
onTest={onTest}
isTesting={isTesting}
isProxyRunning={isProxyRunning}
+9
View File
@@ -61,4 +61,13 @@ export const providersApi = {
handler(payload);
});
},
/**
* 打开指定提供商的终端
* 任何提供商都可以打开终端,不受是否为当前激活提供商的限制
* 终端会使用该提供商特定的 API 配置,不影响全局设置
*/
async openTerminal(providerId: string, appId: AppId): Promise<boolean> {
return await invoke("open_provider_terminal", { providerId, app: appId });
},
};