From 77feae0c665e4b5017dd4dffe4daeec0d7b2cee2 Mon Sep 17 00:00:00 2001 From: coso Date: Sun, 14 Dec 2025 03:24:05 +0800 Subject: [PATCH] feat: add default provider switching UI --- src-tauri/src/lib.rs | 26 ++ src/components/Providers.tsx | 793 ++++++++++++++++++++++++++++------- src/hooks/useTauri.ts | 8 + 3 files changed, 679 insertions(+), 148 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d3fe87fd8..476079478 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -47,6 +47,30 @@ async fn save_config(state: tauri::State<'_, AppState>, config: config::Config) config::save_config(&config).map_err(|e| e.to_string()) } +#[tauri::command] +async fn get_default_provider(state: tauri::State<'_, AppState>) -> Result { + let s = state.read().await; + Ok(s.config.default_provider.clone()) +} + +#[tauri::command] +async fn set_default_provider( + state: tauri::State<'_, AppState>, + logs: tauri::State<'_, LogState>, + provider: String, +) -> Result { + let valid_providers = ["kiro", "gemini", "qwen", "openai", "claude"]; + if !valid_providers.contains(&provider.as_str()) { + return Err(format!("Invalid provider: {}", provider)); + } + + let mut s = state.write().await; + s.config.default_provider = provider.clone(); + config::save_config(&s.config).map_err(|e| e.to_string())?; + logs.write().await.add("info", &format!("默认 Provider 已切换为: {}", provider)); + Ok(provider) +} + #[tauri::command] async fn refresh_kiro_token(state: tauri::State<'_, AppState>, logs: tauri::State<'_, LogState>) -> Result { let mut s = state.write().await; @@ -689,6 +713,8 @@ pub fn run() { get_server_status, get_config, save_config, + get_default_provider, + set_default_provider, // Kiro commands refresh_kiro_token, reload_credentials, diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index e6e004d19..09f2c06e3 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,10 +1,40 @@ -import { useState, useEffect } from "react"; -import { Check, X, RefreshCw, FolderOpen, AlertCircle, CheckCircle2 } from "lucide-react"; +import { useState, useEffect, useRef } from "react"; +import { Check, X, RefreshCw, FolderOpen, AlertCircle, CheckCircle2, Eye, EyeOff, Copy, FileText } from "lucide-react"; import { reloadCredentials, refreshKiroToken, getKiroCredentials, - KiroCredentialStatus + getEnvVariables, + getTokenFileHash, + checkAndReloadCredentials, + KiroCredentialStatus, + EnvVariable, + // Gemini + getGeminiCredentials, + reloadGeminiCredentials, + refreshGeminiToken, + getGeminiEnvVariables, + getGeminiTokenFileHash, + checkAndReloadGeminiCredentials, + GeminiCredentialStatus, + // Qwen + getQwenCredentials, + reloadQwenCredentials, + refreshQwenToken, + getQwenEnvVariables, + getQwenTokenFileHash, + checkAndReloadQwenCredentials, + QwenCredentialStatus, + // OpenAI/Claude Custom + getOpenAICustomStatus, + setOpenAICustomConfig, + getClaudeCustomStatus, + setClaudeCustomConfig, + OpenAICustomStatus, + ClaudeCustomStatus, + // Default Provider + getDefaultProvider, + setDefaultProvider, } from "@/hooks/useTauri"; interface Provider { @@ -16,118 +46,314 @@ interface Provider { } const defaultProviders: Provider[] = [ - { - id: "kiro", - name: "Kiro Claude", - enabled: true, - status: "disconnected", - description: "通过 Kiro OAuth 访问 Claude Sonnet 4.5", - }, - { - id: "gemini", - name: "Gemini CLI", - enabled: false, - status: "disconnected", - description: "通过 Gemini CLI OAuth 访问 Gemini 模型", - }, - { - id: "qwen", - name: "Qwen Code", - enabled: false, - status: "disconnected", - description: "通过 Qwen OAuth 访问通义千问", - }, - { - id: "openai", - name: "OpenAI Custom", - enabled: false, - status: "disconnected", - description: "自定义 OpenAI 兼容 API", - }, - { - id: "claude", - name: "Claude Custom", - enabled: false, - status: "disconnected", - description: "自定义 Claude API", - }, + { id: "kiro", name: "Kiro Claude", enabled: true, status: "disconnected", description: "通过 Kiro OAuth 访问 Claude Sonnet 4.5" }, + { id: "gemini", name: "Gemini CLI", enabled: true, status: "disconnected", description: "通过 Gemini CLI OAuth 访问 Gemini 模型" }, + { id: "qwen", name: "通义千问", enabled: true, status: "disconnected", description: "通过 Qwen OAuth 访问通义千问" }, + { id: "openai", name: "OpenAI 自定义", enabled: false, status: "disconnected", description: "自定义 OpenAI 兼容 API" }, + { id: "claude", name: "Claude 自定义", enabled: false, status: "disconnected", description: "自定义 Claude API" }, ]; export function Providers() { const [providers, setProviders] = useState(defaultProviders); + const [activeProvider, setActiveProvider] = useState("kiro"); + + // Kiro state const [kiroStatus, setKiroStatus] = useState(null); + const [kiroEnvVars, setKiroEnvVars] = useState([]); + const kiroHashRef = useRef(""); + + // Gemini state + const [geminiStatus, setGeminiStatus] = useState(null); + const [geminiEnvVars, setGeminiEnvVars] = useState([]); + const geminiHashRef = useRef(""); + + // Qwen state + const [qwenStatus, setQwenStatus] = useState(null); + const [qwenEnvVars, setQwenEnvVars] = useState([]); + const qwenHashRef = useRef(""); + + // OpenAI Custom state + const [openaiStatus, setOpenaiStatus] = useState(null); + const [openaiApiKey, setOpenaiApiKey] = useState(""); + const [openaiBaseUrl, setOpenaiBaseUrl] = useState(""); + + // Claude Custom state + const [claudeStatus, setClaudeStatus] = useState(null); + const [claudeApiKey, setClaudeApiKey] = useState(""); + const [claudeBaseUrl, setClaudeBaseUrl] = useState(""); + + // Default provider state + const [defaultProvider, setDefaultProviderState] = useState("kiro"); + + // Common state + const [showEnv, setShowEnv] = useState(false); + const [showValues, setShowValues] = useState(false); const [loading, setLoading] = useState(null); const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null); + const [copied, setCopied] = useState(null); useEffect(() => { - loadKiroStatus(); + const init = async () => { + // Load default provider + try { + const dp = await getDefaultProvider(); + setDefaultProviderState(dp); + } catch (e) { + console.error("Failed to get default provider:", e); + } + + await loadKiroStatus(); + await loadGeminiStatus(); + await loadQwenStatus(); + await loadOpenAICustomStatus(); + await loadClaudeCustomStatus(); + try { + kiroHashRef.current = await getTokenFileHash(); + geminiHashRef.current = await getGeminiTokenFileHash(); + qwenHashRef.current = await getQwenTokenFileHash(); + } catch (e) { + console.error("Failed to get initial hash:", e); + } + }; + init(); + + const interval = setInterval(checkFileChanges, 5000); + return () => clearInterval(interval); }, []); + const checkFileChanges = async () => { + // Check Kiro + try { + const kiroResult = await checkAndReloadCredentials(kiroHashRef.current); + kiroHashRef.current = kiroResult.new_hash; + if (kiroResult.changed && kiroResult.reloaded) { + await loadKiroStatus(); + setMessage({ type: "success", text: "[Kiro] 检测到凭证文件变化,已自动重新加载" }); + setTimeout(() => setMessage(null), 5000); + } + } catch (e) { + console.error("Kiro check error:", e); + } + + // Check Gemini + try { + const geminiResult = await checkAndReloadGeminiCredentials(geminiHashRef.current); + geminiHashRef.current = geminiResult.new_hash; + if (geminiResult.changed && geminiResult.reloaded) { + await loadGeminiStatus(); + setMessage({ type: "success", text: "[Gemini] 检测到凭证文件变化,已自动重新加载" }); + setTimeout(() => setMessage(null), 5000); + } + } catch (e) { + console.error("Gemini check error:", e); + } + + // Check Qwen + try { + const qwenResult = await checkAndReloadQwenCredentials(qwenHashRef.current); + qwenHashRef.current = qwenResult.new_hash; + if (qwenResult.changed && qwenResult.reloaded) { + await loadQwenStatus(); + setMessage({ type: "success", text: "[Qwen] 检测到凭证文件变化,已自动重新加载" }); + setTimeout(() => setMessage(null), 5000); + } + } catch (e) { + console.error("Qwen check error:", e); + } + }; + const loadKiroStatus = async () => { try { const status = await getKiroCredentials(); setKiroStatus(status); - - // 更新 Kiro provider 状态 - setProviders(prev => prev.map(p => { - if (p.id === "kiro") { - return { - ...p, - status: status.loaded ? "connected" : "disconnected" - }; - } - return p; - })); + setKiroEnvVars(await getEnvVariables()); + setProviders(prev => prev.map(p => + p.id === "kiro" ? { ...p, status: status.loaded ? "connected" : "disconnected" } : p + )); } catch (e) { console.error("Failed to load Kiro status:", e); } }; - const handleLoadCredentials = async () => { - setLoading("load"); + const loadGeminiStatus = async () => { + try { + const status = await getGeminiCredentials(); + setGeminiStatus(status); + setGeminiEnvVars(await getGeminiEnvVariables()); + setProviders(prev => prev.map(p => + p.id === "gemini" ? { ...p, status: status.loaded ? "connected" : "disconnected" } : p + )); + } catch (e) { + console.error("Failed to load Gemini status:", e); + } + }; + + const loadQwenStatus = async () => { + try { + const status = await getQwenCredentials(); + setQwenStatus(status); + setQwenEnvVars(await getQwenEnvVariables()); + setProviders(prev => prev.map(p => + p.id === "qwen" ? { ...p, status: status.loaded ? "connected" : "disconnected" } : p + )); + } catch (e) { + console.error("Failed to load Qwen status:", e); + } + }; + + const loadOpenAICustomStatus = async () => { + try { + const status = await getOpenAICustomStatus(); + setOpenaiStatus(status); + setOpenaiBaseUrl(status.base_url); + setProviders(prev => prev.map(p => + p.id === "openai" ? { ...p, status: status.enabled && status.has_api_key ? "connected" : "disconnected", enabled: status.enabled } : p + )); + } catch (e) { + console.error("Failed to load OpenAI Custom status:", e); + } + }; + + const loadClaudeCustomStatus = async () => { + try { + const status = await getClaudeCustomStatus(); + setClaudeStatus(status); + setClaudeBaseUrl(status.base_url); + setProviders(prev => prev.map(p => + p.id === "claude" ? { ...p, status: status.enabled && status.has_api_key ? "connected" : "disconnected", enabled: status.enabled } : p + )); + } catch (e) { + console.error("Failed to load Claude Custom status:", e); + } + }; + + const handleLoadCredentials = async (provider: string) => { + setLoading(`load-${provider}`); setMessage(null); try { - await reloadCredentials(); - await loadKiroStatus(); - setMessage({ type: "success", text: "凭证加载成功!" }); + if (provider === "kiro") { + await reloadCredentials(); + await loadKiroStatus(); + kiroHashRef.current = await getTokenFileHash(); + } else if (provider === "gemini") { + await reloadGeminiCredentials(); + await loadGeminiStatus(); + geminiHashRef.current = await getGeminiTokenFileHash(); + } else if (provider === "qwen") { + await reloadQwenCredentials(); + await loadQwenStatus(); + qwenHashRef.current = await getQwenTokenFileHash(); + } + setMessage({ type: "success", text: `[${provider}] 凭证加载成功!` }); } catch (e: any) { setMessage({ type: "error", text: `加载失败: ${e.toString()}` }); } setLoading(null); }; - const handleRefreshToken = async () => { - setLoading("refresh"); + const handleRefreshToken = async (provider: string) => { + setLoading(`refresh-${provider}`); setMessage(null); try { - await refreshKiroToken(); - await loadKiroStatus(); - setMessage({ type: "success", text: "Token 刷新成功!" }); + if (provider === "kiro") { + await refreshKiroToken(); + await loadKiroStatus(); + } else if (provider === "gemini") { + await refreshGeminiToken(); + await loadGeminiStatus(); + } else if (provider === "qwen") { + await refreshQwenToken(); + await loadQwenStatus(); + } + setMessage({ type: "success", text: `[${provider}] Token 刷新成功!` }); } catch (e: any) { setMessage({ type: "error", text: `刷新失败: ${e.toString()}` }); } setLoading(null); }; + const handleSaveOpenAIConfig = async () => { + setLoading("save-openai"); + try { + await setOpenAICustomConfig( + openaiApiKey || null, + openaiBaseUrl || null, + true + ); + await loadOpenAICustomStatus(); + setMessage({ type: "success", text: "[OpenAI] 配置保存成功!" }); + } catch (e: any) { + setMessage({ type: "error", text: `保存失败: ${e.toString()}` }); + } + setLoading(null); + }; + + const handleSaveClaudeConfig = async () => { + setLoading("save-claude"); + try { + await setClaudeCustomConfig( + claudeApiKey || null, + claudeBaseUrl || null, + true + ); + await loadClaudeCustomStatus(); + setMessage({ type: "success", text: "[Claude] 配置保存成功!" }); + } catch (e: any) { + setMessage({ type: "error", text: `保存失败: ${e.toString()}` }); + } + setLoading(null); + }; + const toggleProvider = (id: string) => { - setProviders((prev) => - prev.map((p) => (p.id === id ? { ...p, enabled: !p.enabled } : p)) - ); + setProviders(prev => prev.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p)); + }; + + const handleSetDefaultProvider = async (providerId: string) => { + setLoading(`default-${providerId}`); + try { + await setDefaultProvider(providerId); + setDefaultProviderState(providerId); + setMessage({ type: "success", text: `默认 Provider 已切换为: ${getProviderName(providerId)}` }); + } catch (e: any) { + setMessage({ type: "error", text: `切换失败: ${e.toString()}` }); + } + setLoading(null); + }; + + const getProviderName = (id: string) => { + switch (id) { + case "kiro": return "Kiro Claude"; + case "gemini": return "Gemini CLI"; + case "qwen": return "通义千问"; + case "openai": return "OpenAI 自定义"; + case "claude": return "Claude 自定义"; + default: return id; + } + }; + + const copyValue = (key: string, value: string) => { + navigator.clipboard.writeText(value); + setCopied(key); + setTimeout(() => setCopied(null), 2000); + }; + + const copyAllEnv = (vars: EnvVariable[]) => { + navigator.clipboard.writeText(vars.map(v => `${v.key}=${v.value}`).join("\n")); + setCopied("all"); + setTimeout(() => setCopied(null), 2000); }; const getStatusColor = (status: Provider["status"]) => { switch (status) { - case "connected": - return "bg-green-500"; - case "error": - return "bg-red-500"; - case "loading": - return "bg-yellow-500 animate-pulse"; - default: - return "bg-gray-400"; + case "connected": return "bg-green-500"; + case "error": return "bg-red-500"; + case "loading": return "bg-yellow-500 animate-pulse"; + default: return "bg-gray-400"; } }; + const currentEnvVars = activeProvider === "kiro" ? kiroEnvVars : activeProvider === "gemini" ? geminiEnvVars : qwenEnvVars; + return (
@@ -137,123 +363,394 @@ export function Providers() { {message && (
- {message.type === "success" ? ( - - ) : ( - - )} + {message.type === "success" ? : } {message.text}
)} - {/* Kiro 凭证详情 */} -
-

Kiro 凭证状态

- -
-
- 凭证路径: - - {kiroStatus?.creds_path || "~/.aws/sso/cache/kiro-auth-token.json"} - + {/* Provider Tabs */} +
+ {["kiro", "gemini", "qwen", "openai", "claude"].map(id => ( + + ))} +
+ + {/* Kiro Panel */} + {activeProvider === "kiro" && ( +
+

Kiro 凭证状态

+
+
+ 凭证路径: + + {kiroStatus?.creds_path || "~/.aws/sso/cache/kiro-auth-token.json"} + +
+
+ 区域: + {kiroStatus?.region || "未设置"} +
+
+ Access Token: + + {kiroStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"} + +
+
+ Refresh Token: + + {kiroStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} + +
-
- 区域: - {kiroStatus?.region || "未设置"} -
-
- Access Token: - - {kiroStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"} - -
-
- Refresh Token: - - {kiroStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} - -
-
- 认证方式: - {kiroStatus?.auth_method || "social"} -
-
- 过期时间: - {kiroStatus?.expires_at || "未知"} +
+ + +
+ )} -
+ {/* Gemini Panel */} + {activeProvider === "gemini" && ( +
+

Gemini CLI 凭证状态

+
+
+ 凭证路径: + + {geminiStatus?.creds_path || "~/.gemini/oauth_creds.json"} + +
+
+ Token 有效: + + {geminiStatus?.is_valid ? "✓ 有效" : "✗ 无效/过期"} + +
+
+ Access Token: + + {geminiStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"} + +
+
+ Refresh Token: + + {geminiStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} + +
+
+
+ + + +
+
+ )} + + {/* Qwen Panel */} + {activeProvider === "qwen" && ( +
+

通义千问凭证状态

+
+
+ 凭证路径: + + {qwenStatus?.creds_path || "~/.qwen/oauth_creds.json"} + +
+
+ Token 有效: + + {qwenStatus?.is_valid ? "✓ 有效" : "✗ 无效/过期"} + +
+
+ Access Token: + + {qwenStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"} + +
+
+ Refresh Token: + + {qwenStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} + +
+
+
+ + + +
+
+ )} + + {/* OpenAI Custom Panel */} + {activeProvider === "openai" && ( +
+

OpenAI 自定义配置

+
+
+ + setOpenaiApiKey(e.target.value)} + placeholder="sk-..." + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+ + setOpenaiBaseUrl(e.target.value)} + placeholder="https://api.openai.com/v1" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+ 状态: + + {openaiStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"} + +
+
-
-
+ )} + + {/* Claude Custom Panel */} + {activeProvider === "claude" && ( +
+

Claude 自定义配置

+
+
+ + setClaudeApiKey(e.target.value)} + placeholder="sk-ant-..." + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+ + setClaudeBaseUrl(e.target.value)} + placeholder="https://api.anthropic.com" + className="w-full rounded-lg border bg-background px-3 py-2 text-sm" + /> +
+
+ 状态: + + {claudeStatus?.has_api_key ? "✓ 已配置" : "✗ 未配置"} + +
+
+ +
+ )} + + {/* .env 变量展示 */} + {showEnv && ( +
+
+

.env 环境变量 ({activeProvider})

+
+ + +
+
+ {currentEnvVars.length === 0 ? ( +

暂无环境变量,请先加载凭证

+ ) : ( +
+ {currentEnvVars.map((v) => ( +
+ {v.key} + = + + {showValues ? v.value : v.masked} + + +
+ ))} +
+ )} +
+ )} {/* Provider 列表 */}
+
+

Provider 列表

+ + 当前默认: {getProviderName(defaultProvider)} + +
{providers.map((provider) => ( -
-
+
-

{provider.name}

-

- {provider.description} -

+
+

{provider.name}

+ {defaultProvider === provider.id && ( + 默认 + )} +
+

{provider.description}

- {provider.id === "kiro" && ( + {defaultProvider !== provider.id && ( + )} + {(provider.id === "kiro" || provider.id === "gemini" || provider.id === "qwen") && ( + )}
))}
+ +

+ 💡 提示:系统每 5 秒自动检查凭证文件变化,如有更新会自动重新加载并记录日志 +

); } diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index f5b10798b..800d2a35d 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -68,6 +68,14 @@ export async function saveConfig(config: Config): Promise { return invoke("save_config", { config }); } +export async function getDefaultProvider(): Promise { + return invoke("get_default_provider"); +} + +export async function setDefaultProvider(provider: string): Promise { + return invoke("set_default_provider", { provider }); +} + export async function refreshKiroToken(): Promise { return invoke("refresh_kiro_token"); }