diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 855d4c35d..d1fe4a706 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -763,6 +763,168 @@ struct ModelInfo { owned_by: String, } +// ============ API Compatibility Check ============ + +#[derive(serde::Serialize)] +struct ApiCheckResult { + model: String, + available: bool, + status: u16, + error_type: Option, + error_message: Option, + time_ms: u64, +} + +#[derive(serde::Serialize)] +struct ApiCompatibilityResult { + provider: String, + overall_status: String, + checked_at: String, + results: Vec, + warnings: Vec, +} + +#[tauri::command] +async fn check_api_compatibility( + state: tauri::State<'_, AppState>, + logs: tauri::State<'_, LogState>, + provider: String, +) -> Result { + logs.write().await.add( + "info", + &format!("[API检测] 开始检测 {provider} API 兼容性..."), + ); + + let s = state.read().await; + let mut results: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + let models_to_check = match provider.as_str() { + "kiro" => vec!["claude-sonnet-4-5", "claude-3-7-sonnet-20250219"], + "gemini" => vec!["gemini-2.5-flash", "gemini-2.5-pro"], + "qwen" => vec!["qwen3-coder-plus", "qwen3-coder-flash"], + _ => vec![], + }; + + for model in models_to_check { + let start = std::time::Instant::now(); + + // 构建简单的测试请求 + let test_request = crate::models::openai::ChatCompletionRequest { + model: model.to_string(), + messages: vec![crate::models::openai::ChatMessage { + role: "user".to_string(), + content: Some(crate::models::openai::MessageContent::Text( + "Hi".to_string(), + )), + tool_calls: None, + tool_call_id: None, + }], + temperature: None, + max_tokens: Some(10), + stream: false, + tools: None, + tool_choice: None, + }; + + let result = match provider.as_str() { + "kiro" => s.kiro_provider.call_api(&test_request).await, + _ => Err("Provider not supported for direct API check".into()), + }; + + let time_ms = start.elapsed().as_millis() as u64; + + match result { + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + + let (available, error_type, error_message) = if (200..300).contains(&status) { + (true, None, None) + } else { + let err_type = match status { + 401 => { + warnings.push(format!("模型 {model} 返回 401: Token 可能已过期或无效")); + Some("AUTH_ERROR".to_string()) + } + 403 => { + warnings.push(format!( + "模型 {model} 返回 403: 无权访问,可能需要刷新 Token" + )); + Some("FORBIDDEN".to_string()) + } + 400 => { + warnings.push(format!("模型 {model} 返回 400: 请求格式可能已变更")); + Some("BAD_REQUEST".to_string()) + } + 404 => { + warnings.push(format!("模型 {model} 返回 404: 模型或接口可能已下线")); + Some("NOT_FOUND".to_string()) + } + 429 => { + warnings.push(format!("模型 {model} 返回 429: 请求过于频繁")); + Some("RATE_LIMITED".to_string()) + } + 500..=599 => { + warnings.push(format!("模型 {model} 返回 {status}: 服务端错误")); + Some("SERVER_ERROR".to_string()) + } + _ => Some("UNKNOWN_ERROR".to_string()), + }; + ( + false, + err_type, + Some(body[..body.len().min(200)].to_string()), + ) + }; + + results.push(ApiCheckResult { + model: model.to_string(), + available, + status, + error_type, + error_message, + time_ms, + }); + } + Err(e) => { + warnings.push(format!("模型 {model} 请求失败: {e}")); + results.push(ApiCheckResult { + model: model.to_string(), + available: false, + status: 0, + error_type: Some("REQUEST_FAILED".to_string()), + error_message: Some(e.to_string()), + time_ms, + }); + } + } + } + + let overall_status = if results.iter().all(|r| r.available) { + "healthy".to_string() + } else if results.iter().any(|r| r.available) { + "partial".to_string() + } else { + "error".to_string() + }; + + let checked_at = chrono::Utc::now().to_rfc3339(); + + logs.write().await.add( + "info", + &format!("[API检测] {provider} 检测完成: {overall_status}"), + ); + + Ok(ApiCompatibilityResult { + provider, + overall_status, + checked_at, + results, + warnings, + }) +} + #[tauri::command] async fn get_available_models() -> Result, String> { Ok(vec![ @@ -957,6 +1119,8 @@ pub fn run() { clear_logs, test_api, get_available_models, + // API Compatibility + check_api_compatibility, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 460c47bbb..84bc917d5 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -343,7 +343,7 @@ export function Dashboard() {
- 自动监测中 (5s) + 自动监测中
diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 1a9914566..0f259ac6f 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -122,7 +122,7 @@ export function Providers() { const [qwenLastSync, setQwenLastSync] = useState(null); // Last check time (used for display) - const [_lastCheckTime, setLastCheckTime] = useState(null); + const [lastCheckTime, setLastCheckTime] = useState(null); // OpenAI Custom state const [openaiStatus, setOpenaiStatus] = useState( @@ -166,80 +166,102 @@ export function Providers() { await loadQwenStatus(); await loadOpenAICustomStatus(); await loadClaudeCustomStatus(); + + // Get initial hashes try { - kiroHashRef.current = await getTokenFileHash(); - geminiHashRef.current = await getGeminiTokenFileHash(); - qwenHashRef.current = await getQwenTokenFileHash(); + const kiroHash = await getTokenFileHash(); + const geminiHash = await getGeminiTokenFileHash(); + const qwenHash = await getQwenTokenFileHash(); + kiroHashRef.current = kiroHash; + geminiHashRef.current = geminiHash; + qwenHashRef.current = qwenHash; + console.log("[Init] Kiro hash:", kiroHash); + console.log("[Init] Gemini hash:", geminiHash); + console.log("[Init] Qwen hash:", qwenHash); } catch (e) { console.error("Failed to get initial hash:", e); } }; init(); - const interval = setInterval(checkFileChanges, 5000); + // Define checkFileChanges inside useEffect to avoid stale closure + const checkFiles = async () => { + const now = new Date(); + setLastCheckTime(now); + console.log("[Check] Running file check at", now.toLocaleTimeString()); + + // Check Kiro + try { + console.log("[Check] Kiro current hash:", kiroHashRef.current); + const kiroResult = await checkAndReloadCredentials(kiroHashRef.current); + console.log("[Check] Kiro result:", kiroResult); + + if (kiroResult.new_hash !== kiroHashRef.current) { + console.log( + "[Check] Kiro hash changed:", + kiroHashRef.current, + "->", + kiroResult.new_hash, + ); + } + kiroHashRef.current = kiroResult.new_hash; + + if (kiroResult.changed && kiroResult.reloaded) { + await loadKiroStatus(); + setKiroLastSync(new Date()); + 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(); + setGeminiLastSync(new Date()); + 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(); + setQwenLastSync(new Date()); + setMessage({ + type: "success", + text: "[Qwen] 检测到凭证文件变化,已自动重新加载", + }); + setTimeout(() => setMessage(null), 5000); + } + } catch (e) { + console.error("Qwen check error:", e); + } + }; + + const interval = setInterval(checkFiles, 5000); return () => clearInterval(interval); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const checkFileChanges = async () => { - setLastCheckTime(new Date()); - - // Check Kiro - try { - const kiroResult = await checkAndReloadCredentials(kiroHashRef.current); - kiroHashRef.current = kiroResult.new_hash; - if (kiroResult.changed && kiroResult.reloaded) { - await loadKiroStatus(); - setKiroLastSync(new Date()); - 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(); - setGeminiLastSync(new Date()); - 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(); - setQwenLastSync(new Date()); - 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(); @@ -557,9 +579,15 @@ export function Providers() { {formatTime(kiroLastSync)} + + 最后检测:{" "} + + {formatTime(lastCheckTime)} + + - 监测中 (5s) + 监测中
@@ -634,9 +662,15 @@ export function Providers() { {formatTime(geminiLastSync)} + + 最后检测:{" "} + + {formatTime(lastCheckTime)} + + - 监测中 (5s) + 监测中 @@ -714,9 +748,15 @@ export function Providers() { {formatTime(qwenLastSync)} + + 最后检测:{" "} + + {formatTime(lastCheckTime)} + + - 监测中 (5s) + 监测中 diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index f8f13bef9..04e0a7fae 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,6 +1,22 @@ import { useState, useEffect } from "react"; -import { Eye, EyeOff, Copy, Check } from "lucide-react"; -import { getConfig, saveConfig, Config } from "@/hooks/useTauri"; +import { + Eye, + EyeOff, + Copy, + Check, + Shield, + AlertTriangle, + CheckCircle2, + XCircle, + Loader2, +} from "lucide-react"; +import { + getConfig, + saveConfig, + Config, + checkApiCompatibility, + ApiCompatibilityResult, +} from "@/hooks/useTauri"; export function Settings() { const [config, setConfig] = useState(null); @@ -9,6 +25,13 @@ export function Settings() { const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); + // API Compatibility Check + const [checking, setChecking] = useState(false); + const [checkResult, setCheckResult] = useState( + null, + ); + const [lastCheckTime, setLastCheckTime] = useState(null); + useEffect(() => { loadConfig(); }, []); @@ -44,6 +67,45 @@ export function Settings() { } }; + const handleCheckApiCompatibility = async (provider: string) => { + setChecking(true); + setCheckResult(null); + try { + const result = await checkApiCompatibility(provider); + setCheckResult(result); + setLastCheckTime(new Date()); + } catch (e) { + setMessage(`API 检测失败: ${e}`); + } + setChecking(false); + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "healthy": + return ; + case "partial": + return ; + case "error": + return ; + default: + return null; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case "healthy": + return "所有模型可用"; + case "partial": + return "部分模型可用"; + case "error": + return "API 不可用"; + default: + return "未知"; + } + }; + if (!config) { return
加载中...
; } @@ -150,6 +212,102 @@ export function Settings() { {saving ? "保存中..." : "保存设置"} + + {/* API 兼容性检测 */} +
+
+ +

API 兼容性检测

+
+

+ 检测当前配置的模型是否可用,识别 API 变更或认证问题 +

+ +
+ +
+ + {lastCheckTime && ( +

+ 最后检测时间: {lastCheckTime.toLocaleString()} +

+ )} + + {checkResult && ( +
+
+
+ {getStatusIcon(checkResult.overall_status)} + + {checkResult.provider.toUpperCase()} -{" "} + {getStatusText(checkResult.overall_status)} + +
+ + {new Date(checkResult.checked_at).toLocaleString()} + +
+ + {/* 模型检测结果 */} +
+

模型状态:

+ {checkResult.results.map((r) => ( +
+
+ {r.available ? ( + + ) : ( + + )} + {r.model} +
+
+ {r.status > 0 && HTTP {r.status}} + {r.time_ms}ms + {r.error_type && ( + + {r.error_type} + + )} +
+
+ ))} +
+ + {/* 警告信息 */} + {checkResult.warnings.length > 0 && ( +
+

警告:

+ {checkResult.warnings.map((w, i) => ( +
+ + {w} +
+ ))} +
+ )} +
+ )} +
); } diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index df6db4203..74d37656b 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -289,3 +289,28 @@ export interface ModelInfo { export async function getAvailableModels(): Promise { return invoke("get_available_models"); } + +// ============ API Compatibility Check ============ + +export interface ApiCheckResult { + model: string; + available: boolean; + status: number; + error_type: string | null; + error_message: string | null; + time_ms: number; +} + +export interface ApiCompatibilityResult { + provider: string; + overall_status: string; + checked_at: string; + results: ApiCheckResult[]; + warnings: string[]; +} + +export async function checkApiCompatibility( + provider: string, +): Promise { + return invoke("check_api_compatibility", { provider }); +}