diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 000000000..e5eca42a8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(npm run format:*)", + "Bash(npm run lint)", + "Bash(npx tsc:*)", + "Bash(cargo test:*)", + "Bash(cargo build:*)", + "Bash(npm run check:*)", + "Bash(npm run:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/package.json b/package.json index b36ec913e..4ff45fa2e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "proxycast", "private": true, - "version": "0.4.1", + "version": "0.4.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index fdf8011ab..79ef445d2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2789,7 +2789,7 @@ dependencies = [ [[package]] name = "proxycast" -version = "0.4.0" +version = "0.4.1" dependencies = [ "async-stream", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e8e27b300..f85661a82 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "proxycast" -version = "0.4.1" +version = "0.4.2" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1f93e6ca3..d345dcf47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,9 +5,113 @@ mod models; mod providers; mod server; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::RwLock; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Kiro, + Gemini, + Qwen, + #[serde(rename = "openai")] + OpenAI, + Claude, +} + +impl std::fmt::Display for ProviderType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ProviderType::Kiro => write!(f, "kiro"), + ProviderType::Gemini => write!(f, "gemini"), + ProviderType::Qwen => write!(f, "qwen"), + ProviderType::OpenAI => write!(f, "openai"), + ProviderType::Claude => write!(f, "claude"), + } + } +} + +impl std::str::FromStr for ProviderType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "kiro" => Ok(ProviderType::Kiro), + "gemini" => Ok(ProviderType::Gemini), + "qwen" => Ok(ProviderType::Qwen), + "openai" => Ok(ProviderType::OpenAI), + "claude" => Ok(ProviderType::Claude), + _ => Err(format!("Invalid provider: {s}")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_type_from_str() { + assert_eq!("kiro".parse::().unwrap(), ProviderType::Kiro); + assert_eq!( + "gemini".parse::().unwrap(), + ProviderType::Gemini + ); + assert_eq!("qwen".parse::().unwrap(), ProviderType::Qwen); + assert_eq!( + "openai".parse::().unwrap(), + ProviderType::OpenAI + ); + assert_eq!( + "claude".parse::().unwrap(), + ProviderType::Claude + ); + + // 测试大小写不敏感 + assert_eq!("KIRO".parse::().unwrap(), ProviderType::Kiro); + assert_eq!( + "Gemini".parse::().unwrap(), + ProviderType::Gemini + ); + + // 测试无效的 provider + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_provider_type_display() { + assert_eq!(ProviderType::Kiro.to_string(), "kiro"); + assert_eq!(ProviderType::Gemini.to_string(), "gemini"); + assert_eq!(ProviderType::Qwen.to_string(), "qwen"); + assert_eq!(ProviderType::OpenAI.to_string(), "openai"); + assert_eq!(ProviderType::Claude.to_string(), "claude"); + } + + #[test] + fn test_provider_type_serde() { + // 测试序列化 + assert_eq!( + serde_json::to_string(&ProviderType::Kiro).unwrap(), + "\"kiro\"" + ); + assert_eq!( + serde_json::to_string(&ProviderType::OpenAI).unwrap(), + "\"openai\"" + ); + + // 测试反序列化 + assert_eq!( + serde_json::from_str::("\"kiro\"").unwrap(), + ProviderType::Kiro + ); + assert_eq!( + serde_json::from_str::("\"openai\"").unwrap(), + ProviderType::OpenAI + ); + } +} + pub type AppState = Arc>; pub type LogState = Arc>; @@ -78,17 +182,15 @@ async fn set_default_provider( 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}")); - } + // 使用枚举验证 provider + let provider_type: ProviderType = provider.parse().map_err(|e: String| e)?; 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}")); + .add("info", &format!("默认 Provider 已切换为: {provider_type}")); Ok(provider) } @@ -796,9 +898,12 @@ async fn check_api_compatibility( logs: tauri::State<'_, LogState>, provider: String, ) -> Result { + // 使用枚举验证 provider + let provider_type: ProviderType = provider.parse().map_err(|e: String| e)?; + logs.write().await.add( "info", - &format!("[API检测] 开始检测 {provider} API 兼容性 (Claude Code 功能测试)..."), + &format!("[API检测] 开始检测 {provider_type} API 兼容性 (Claude Code 功能测试)..."), ); let s = state.read().await; @@ -806,17 +911,20 @@ async fn check_api_compatibility( let mut warnings: Vec = Vec::new(); // Claude Code 需要的测试项目 - let test_cases: Vec<(&str, &str)> = match provider.as_str() { - "kiro" => vec![ + let test_cases: Vec<(&str, &str)> = match provider_type { + ProviderType::Kiro => vec![ ("claude-sonnet-4-5", "basic"), // 基础对话 ("claude-sonnet-4-5", "tool_call"), // Tool Calls 支持 ], - "gemini" => vec![("gemini-2.5-flash", "basic"), ("gemini-2.5-pro", "basic")], - "qwen" => vec![ - ("qwen3-coder-plus", "basic"), - ("qwen3-coder-flash", "basic"), + ProviderType::Gemini => vec![ + ("gemini-2.5-flash", "basic"), + ("gemini-2.5-flash", "tool_call"), ], - _ => vec![], + ProviderType::Qwen => vec![ + ("qwen3-coder-plus", "basic"), + ("qwen3-coder-plus", "tool_call"), + ], + ProviderType::OpenAI | ProviderType::Claude => vec![], }; for (model, test_type) in test_cases { @@ -881,8 +989,16 @@ async fn check_api_compatibility( } }; - let result = match provider.as_str() { - "kiro" => s.kiro_provider.call_api(&test_request).await, + let result = match provider_type { + ProviderType::Kiro => s.kiro_provider.call_api(&test_request).await, + ProviderType::Gemini => { + // Gemini 暂时不支持直接 API 检测,返回未实现错误 + Err("Gemini API compatibility check not yet implemented".into()) + } + ProviderType::Qwen => { + // Qwen 暂时不支持直接 API 检测,返回未实现错误 + Err("Qwen API compatibility check not yet implemented".into()) + } _ => Err("Provider not supported for direct API check".into()), }; diff --git a/src-tauri/src/providers/kiro.rs b/src-tauri/src/providers/kiro.rs index ff959d434..f3d5309e8 100644 --- a/src-tauri/src/providers/kiro.rs +++ b/src-tauri/src/providers/kiro.rs @@ -6,6 +6,36 @@ use serde::{Deserialize, Serialize}; use std::error::Error; use std::path::PathBuf; +/// 生成设备指纹 (MAC 地址的 SHA256) +fn get_device_fingerprint() -> String { + use std::process::Command; + + // 尝试获取 MAC 地址 + let mac = if cfg!(target_os = "macos") { + Command::new("ifconfig") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .and_then(|s| { + s.lines() + .find(|l| l.contains("ether ")) + .and_then(|l| l.split_whitespace().nth(1)) + .map(|s| s.to_string()) + }) + } else { + None + }; + + let mac = mac.unwrap_or_else(|| "00:00:00:00:00:00".to_string()); + + // SHA256 hash + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + mac.hash(&mut hasher); + format!("{:016x}{:016x}", hasher.finish(), hasher.finish()) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KiroCredentials { @@ -278,6 +308,10 @@ impl KiroProvider { ); } + // 生成设备指纹用于伪装 Kiro IDE + let device_fp = get_device_fingerprint(); + let kiro_version = "0.1.25"; + let resp = self .client .post(&url) @@ -285,6 +319,18 @@ impl KiroProvider { .header("Content-Type", "application/json") .header("Accept", "application/json") .header("amz-sdk-invocation-id", uuid::Uuid::new_v4().to_string()) + .header("amz-sdk-request", "attempt=1; max=1") + .header( + "x-amz-user-agent", + format!("aws-sdk-js/1.0.7 KiroIDE-{kiro_version}-{device_fp}"), + ) + .header( + "user-agent", + format!( + "aws-sdk-js/1.0.7 ua/2.1 os/macos#14.0 lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.7 m/E KiroIDE-{kiro_version}-{device_fp}" + ), + ) + .header("x-amzn-kiro-agent-mode", "vibe") .json(&cw_request) .send() .await?; diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index eec35c7c8..d80e47adf 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -582,12 +582,15 @@ async fn anthropic_messages( .add("info", &format!("[RESP] Upstream status: {status}")); if status.is_success() { - match resp.text().await { - Ok(body) => { + match resp.bytes().await { + Ok(bytes) => { + // 使用 lossy 转换,避免无效 UTF-8 导致崩溃 + let body = String::from_utf8_lossy(&bytes).to_string(); + // 记录原始响应长度 state.logs.write().await.add( "debug", - &format!("[RESP] Raw body length: {} bytes", body.len()), + &format!("[RESP] Raw body length: {} bytes", bytes.len()), ); // 保存原始响应到文件用于调试 @@ -598,14 +601,14 @@ async fn anthropic_messages( &format!("[RESP] Raw response saved to raw_response_{request_id}.txt"), ); - // 记录响应的前500字符用于调试 - state.logs.write().await.add( - "debug", - &format!( - "[RESP] Body preview: {}", - &body.chars().take(500).collect::() - ), - ); + // 记录响应的前200字符用于调试(减少日志量) + let preview: String = + body.chars().filter(|c| !c.is_control()).take(200).collect(); + state + .logs + .write() + .await + .add("debug", &format!("[RESP] Body preview: {preview}")); let parsed = parse_cw_response(&body); @@ -679,8 +682,9 @@ async fn anthropic_messages( &format!("[RETRY] Response status: {retry_status}"), ); if retry_resp.status().is_success() { - match retry_resp.text().await { - Ok(body) => { + match retry_resp.bytes().await { + Ok(bytes) => { + let body = String::from_utf8_lossy(&bytes).to_string(); let parsed = parse_cw_response(&body); state.logs.write().await.add( "info", @@ -713,7 +717,11 @@ async fn anthropic_messages( } } } - let body = retry_resp.text().await.unwrap_or_default(); + let body = retry_resp + .bytes() + .await + .map(|b| String::from_utf8_lossy(&b).to_string()) + .unwrap_or_default(); state.logs.write().await.add( "error", &format!( @@ -918,9 +926,6 @@ fn build_anthropic_stream_response(model: &str, parsed: &CWParsedResponse) -> Re // 3. Tool use 块 for tc in &tool_calls { - let input: serde_json::Value = - serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::json!({})); - // content_block_start let block_start = serde_json::json!({ "type": "content_block_start", @@ -937,12 +942,18 @@ fn build_anthropic_stream_response(model: &str, parsed: &CWParsedResponse) -> Re )); // content_block_delta - input_json_delta + // 注意:partial_json 应该是原始 JSON 字符串,不是再次序列化的 + let partial_json = if tc.function.arguments.is_empty() { + "{}".to_string() + } else { + tc.function.arguments.clone() + }; let block_delta = serde_json::json!({ "type": "content_block_delta", "index": block_index, "delta": { "type": "input_json_delta", - "partial_json": serde_json::to_string(&input).unwrap_or_default() + "partial_json": partial_json } }); events.push(format!( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9c3268b51..f1d2ac036 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "ProxyCast", - "version": "0.4.1", + "version": "0.4.2", "identifier": "com.proxycast.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx index 84bc917d5..0a91748b6 100644 --- a/src/components/Dashboard.tsx +++ b/src/components/Dashboard.tsx @@ -76,8 +76,8 @@ export function Dashboard() { loadKiroStatus(); initTokenHash(); - const statusInterval = setInterval(fetchStatus, 2000); - const tokenInterval = setInterval(checkTokenFileChanges, 5000); + const statusInterval = setInterval(fetchStatus, 3000); + const tokenInterval = setInterval(checkTokenFileChanges, 10000); return () => { clearInterval(statusInterval); diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx index 0f259ac6f..c52191776 100644 --- a/src/components/Providers.tsx +++ b/src/components/Providers.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect } from "react"; import { Check, X, @@ -47,6 +47,8 @@ import { getDefaultProvider, setDefaultProvider, } from "@/hooks/useTauri"; +import { useProviderState } from "@/hooks/useProviderState"; +import { useFileMonitoring } from "@/hooks/useFileMonitoring"; interface Provider { id: string; @@ -98,31 +100,33 @@ 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(""); - const [kiroLastSync, setKiroLastSync] = useState(null); + // 使用 useProviderState hook 管理三个 OAuth providers + const kiro = useProviderState("kiro", { + getCredentials: getKiroCredentials, + getEnvVars: getEnvVariables, + getHash: getTokenFileHash, + checkAndReload: checkAndReloadCredentials, + reloadCredentials: reloadCredentials, + refreshToken: refreshKiroToken, + }); - // Gemini state - const [geminiStatus, setGeminiStatus] = - useState(null); - const [geminiEnvVars, setGeminiEnvVars] = useState([]); - const geminiHashRef = useRef(""); - const [geminiLastSync, setGeminiLastSync] = useState(null); + const gemini = useProviderState("gemini", { + getCredentials: getGeminiCredentials, + getEnvVars: getGeminiEnvVariables, + getHash: getGeminiTokenFileHash, + checkAndReload: checkAndReloadGeminiCredentials, + reloadCredentials: reloadGeminiCredentials, + refreshToken: refreshGeminiToken, + }); - // Qwen state - const [qwenStatus, setQwenStatus] = useState( - null, - ); - const [qwenEnvVars, setQwenEnvVars] = useState([]); - const qwenHashRef = useRef(""); - const [qwenLastSync, setQwenLastSync] = useState(null); - - // Last check time (used for display) - const [lastCheckTime, setLastCheckTime] = useState(null); + const qwen = useProviderState("qwen", { + getCredentials: getQwenCredentials, + getEnvVars: getQwenEnvVariables, + getHash: getQwenTokenFileHash, + checkAndReload: checkAndReloadQwenCredentials, + reloadCredentials: reloadQwenCredentials, + refreshToken: refreshQwenToken, + }); // OpenAI Custom state const [openaiStatus, setOpenaiStatus] = useState( @@ -151,6 +155,13 @@ export function Providers() { } | null>(null); const [copied, setCopied] = useState(null); + // 使用 useFileMonitoring hook 自动监控文件变化 + useFileMonitoring({ + kiro: { checkFn: kiro.checkForChanges, interval: 5000 }, + gemini: { checkFn: gemini.checkForChanges, interval: 5000 }, + qwen: { checkFn: qwen.checkForChanges, interval: 5000 }, + }); + useEffect(() => { const init = async () => { // Load default provider @@ -161,157 +172,62 @@ export function Providers() { console.error("Failed to get default provider:", e); } - await loadKiroStatus(); - await loadGeminiStatus(); - await loadQwenStatus(); + // 初始化加载所有 provider 状态 + await kiro.load(); + await gemini.load(); + await qwen.load(); await loadOpenAICustomStatus(); await loadClaudeCustomStatus(); - - // Get initial hashes - try { - 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(); - - // 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 loadKiroStatus = async () => { - try { - const status = await getKiroCredentials(); - setKiroStatus(status); - setKiroEnvVars(await getEnvVariables()); + // 更新 provider 列表状态 + useEffect(() => { + if (kiro.status) { setProviders((prev) => prev.map((p) => p.id === "kiro" - ? { ...p, status: status.loaded ? "connected" : "disconnected" } + ? { + ...p, + status: kiro.status?.loaded ? "connected" : "disconnected", + } : p, ), ); - } catch (e) { - console.error("Failed to load Kiro status:", e); } - }; + }, [kiro.status]); - const loadGeminiStatus = async () => { - try { - const status = await getGeminiCredentials(); - setGeminiStatus(status); - setGeminiEnvVars(await getGeminiEnvVariables()); + useEffect(() => { + if (gemini.status) { setProviders((prev) => prev.map((p) => p.id === "gemini" - ? { ...p, status: status.loaded ? "connected" : "disconnected" } + ? { + ...p, + status: gemini.status?.loaded ? "connected" : "disconnected", + } : p, ), ); - } catch (e) { - console.error("Failed to load Gemini status:", e); } - }; + }, [gemini.status]); - const loadQwenStatus = async () => { - try { - const status = await getQwenCredentials(); - setQwenStatus(status); - setQwenEnvVars(await getQwenEnvVariables()); + useEffect(() => { + if (qwen.status) { setProviders((prev) => prev.map((p) => p.id === "qwen" - ? { ...p, status: status.loaded ? "connected" : "disconnected" } + ? { + ...p, + status: qwen.status?.loaded ? "connected" : "disconnected", + } : p, ), ); - } catch (e) { - console.error("Failed to load Qwen status:", e); } - }; + }, [qwen.status]); const loadOpenAICustomStatus = async () => { try { @@ -362,51 +278,39 @@ export function Providers() { }; const handleLoadCredentials = async (provider: string) => { - setLoading(`load-${provider}`); setMessage(null); try { if (provider === "kiro") { - await reloadCredentials(); - await loadKiroStatus(); - kiroHashRef.current = await getTokenFileHash(); - setKiroLastSync(new Date()); + await kiro.reload(); + setMessage({ type: "success", text: "[Kiro] 凭证加载成功!" }); } else if (provider === "gemini") { - await reloadGeminiCredentials(); - await loadGeminiStatus(); - geminiHashRef.current = await getGeminiTokenFileHash(); - setGeminiLastSync(new Date()); + await gemini.reload(); + setMessage({ type: "success", text: "[Gemini] 凭证加载成功!" }); } else if (provider === "qwen") { - await reloadQwenCredentials(); - await loadQwenStatus(); - qwenHashRef.current = await getQwenTokenFileHash(); - setQwenLastSync(new Date()); + await qwen.reload(); + setMessage({ type: "success", text: "[Qwen] 凭证加载成功!" }); } - setMessage({ type: "success", text: `[${provider}] 凭证加载成功!` }); } catch (e: any) { setMessage({ type: "error", text: `加载失败: ${e.toString()}` }); } - setLoading(null); }; const handleRefreshToken = async (provider: string) => { - setLoading(`refresh-${provider}`); setMessage(null); try { if (provider === "kiro") { - await refreshKiroToken(); - await loadKiroStatus(); + await kiro.refresh(); + setMessage({ type: "success", text: "[Kiro] Token 刷新成功!" }); } else if (provider === "gemini") { - await refreshGeminiToken(); - await loadGeminiStatus(); + await gemini.refresh(); + setMessage({ type: "success", text: "[Gemini] Token 刷新成功!" }); } else if (provider === "qwen") { - await refreshQwenToken(); - await loadQwenStatus(); + await qwen.refresh(); + setMessage({ type: "success", text: "[Qwen] Token 刷新成功!" }); } - setMessage({ type: "success", text: `[${provider}] Token 刷新成功!` }); } catch (e: any) { setMessage({ type: "error", text: `刷新失败: ${e.toString()}` }); } - setLoading(null); }; const handleSaveOpenAIConfig = async () => { @@ -513,10 +417,14 @@ export function Providers() { const currentEnvVars = activeProvider === "kiro" - ? kiroEnvVars + ? kiro.envVars : activeProvider === "gemini" - ? geminiEnvVars - : qwenEnvVars; + ? gemini.envVars + : qwen.envVars; + + const isAnyLoading = Boolean( + kiro.loading || gemini.loading || qwen.loading || loading, + ); return (
@@ -576,13 +484,7 @@ export function Providers() { 最后同步:{" "} - {formatTime(kiroLastSync)} - - - - 最后检测:{" "} - - {formatTime(lastCheckTime)} + {formatTime(kiro.lastSync)} @@ -595,47 +497,47 @@ export function Providers() {
凭证路径: - {kiroStatus?.creds_path || + {kiro.status?.creds_path || "~/.aws/sso/cache/kiro-auth-token.json"}
区域: - {kiroStatus?.region || "未设置"} + {kiro.status?.region || "未设置"}
Access Token: - {kiroStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"} + {kiro.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
Refresh Token: - {kiroStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} + {kiro.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
@@ -659,13 +561,7 @@ export function Providers() { 最后同步:{" "} - {formatTime(geminiLastSync)} - - - - 最后检测:{" "} - - {formatTime(lastCheckTime)} + {formatTime(gemini.lastSync)} @@ -678,50 +574,50 @@ export function Providers() {
凭证路径: - {geminiStatus?.creds_path || "~/.gemini/oauth_creds.json"} + {gemini.status?.creds_path || "~/.gemini/oauth_creds.json"}
Token 有效: - {geminiStatus?.is_valid ? "✓ 有效" : "✗ 无效/过期"} + {gemini.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
Access Token: - {geminiStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"} + {gemini.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
Refresh Token: - {geminiStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} + {gemini.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
@@ -745,13 +641,7 @@ export function Providers() { 最后同步:{" "} - {formatTime(qwenLastSync)} - - - - 最后检测:{" "} - - {formatTime(lastCheckTime)} + {formatTime(qwen.lastSync)} @@ -764,50 +654,50 @@ export function Providers() {
凭证路径: - {qwenStatus?.creds_path || "~/.qwen/oauth_creds.json"} + {qwen.status?.creds_path || "~/.qwen/oauth_creds.json"}
Token 有效: - {qwenStatus?.is_valid ? "✓ 有效" : "✗ 无效/过期"} + {qwen.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
Access Token: - {qwenStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"} + {qwen.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
Refresh Token: - {qwenStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"} + {qwen.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
@@ -1027,7 +917,7 @@ export function Providers() { {defaultProvider !== provider.id && ( )} diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 41c1d8d12..f70070d7c 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -76,6 +76,7 @@ export function Settings() { setLastCheckTime(new Date()); } catch (e) { setMessage(`API 检测失败: ${e}`); + setTimeout(() => setMessage(null), 5000); } setChecking(false); }; @@ -107,7 +108,33 @@ export function Settings() { }; if (!config) { - return
加载中...
; + return ( +
+
+ + + + + 加载配置中... +
+
+ ); } return ( @@ -242,7 +269,31 @@ export function Settings() { ) : ( )} - 检测 Kiro (Claude Code) + 检测 Kiro + + +
diff --git a/src/hooks/useErrorHandler.ts b/src/hooks/useErrorHandler.ts new file mode 100644 index 000000000..cc4e0221a --- /dev/null +++ b/src/hooks/useErrorHandler.ts @@ -0,0 +1,24 @@ +import { useState, useCallback } from "react"; + +interface AppError { + message: string; + context?: string; + timestamp: Date; +} + +export function useErrorHandler() { + const [error, setError] = useState(null); + + const handleError = useCallback((e: unknown, context?: string) => { + console.error(`[${context || "Error"}]`, e); + setError({ + message: e instanceof Error ? e.message : String(e), + context, + timestamp: new Date(), + }); + }, []); + + const clearError = useCallback(() => setError(null), []); + + return { error, handleError, clearError }; +} diff --git a/src/hooks/useFileMonitoring.ts b/src/hooks/useFileMonitoring.ts new file mode 100644 index 000000000..295dcb471 --- /dev/null +++ b/src/hooks/useFileMonitoring.ts @@ -0,0 +1,39 @@ +import { useEffect, useRef, useCallback } from "react"; + +interface MonitorConfig { + checkFn: () => Promise; + interval?: number; + enabled?: boolean; +} + +export function useFileMonitoring(configs: Record) { + const intervalRef = useRef | null>(null); + + const checkAll = useCallback(async () => { + for (const [key, config] of Object.entries(configs)) { + if (config.enabled !== false) { + try { + await config.checkFn(); + } catch (e) { + console.error(`[FileMonitoring:${key}]`, e); + } + } + } + }, [configs]); + + useEffect(() => { + // 获取最小间隔 + const intervals = Object.values(configs).map((c) => c.interval || 10000); + const minInterval = Math.min(...intervals); + + intervalRef.current = setInterval(checkAll, minInterval); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, [checkAll, configs]); + + return { checkAll }; +} diff --git a/src/hooks/useProviderState.ts b/src/hooks/useProviderState.ts new file mode 100644 index 000000000..bff7567b6 --- /dev/null +++ b/src/hooks/useProviderState.ts @@ -0,0 +1,101 @@ +import { useState, useRef, useCallback } from "react"; + +export interface EnvVariable { + key: string; + value: string; + masked: string; +} + +export interface CheckResult { + changed: boolean; + new_hash: string; + reloaded: boolean; +} + +interface ProviderStateConfig { + getCredentials: () => Promise; + getEnvVars: () => Promise; + getHash: () => Promise; + checkAndReload: (hash: string) => Promise; + reloadCredentials: () => Promise; + refreshToken?: () => Promise; +} + +export function useProviderState( + providerId: string, + config: ProviderStateConfig, +) { + const [status, setStatus] = useState(null); + const [envVars, setEnvVars] = useState([]); + const [lastSync, setLastSync] = useState(null); + const [loading, setLoading] = useState(null); + const [error, setError] = useState(null); + const hashRef = useRef(""); + + const load = useCallback(async () => { + try { + const [creds, vars, hash] = await Promise.all([ + config.getCredentials(), + config.getEnvVars(), + config.getHash(), + ]); + setStatus(creds); + setEnvVars(vars); + hashRef.current = hash; + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, [config]); + + const reload = useCallback(async () => { + setLoading("reload"); + try { + await config.reloadCredentials(); + await load(); + setLastSync(new Date()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + setLoading(null); + }, [config, load]); + + const refresh = useCallback(async () => { + if (!config.refreshToken) return; + setLoading("refresh"); + try { + await config.refreshToken(); + await load(); + setLastSync(new Date()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + setLoading(null); + }, [config, load]); + + const checkForChanges = useCallback(async () => { + try { + const result = await config.checkAndReload(hashRef.current); + if (result.changed) { + hashRef.current = result.new_hash; + await load(); + setLastSync(new Date()); + } + } catch (e) { + console.error(`[${providerId}] Check failed:`, e); + } + }, [config, load, providerId]); + + return { + status, + envVars, + lastSync, + loading, + error, + load, + reload, + refresh, + checkForChanges, + clearError: () => setError(null), + }; +}