v0.4.2: fix TypeScript type errors, improve isAnyLoading boolean conversion

This commit is contained in:
coso
2025-12-14 13:44:04 +08:00
parent c560e41a12
commit 3fac9f75ca
14 changed files with 570 additions and 270 deletions
+15
View File
@@ -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": []
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "proxycast",
"private": true,
"version": "0.4.1",
"version": "0.4.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2789,7 +2789,7 @@ dependencies = [
[[package]]
name = "proxycast"
version = "0.4.0"
version = "0.4.1"
dependencies = [
"async-stream",
"axum",
+1 -1
View File
@@ -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"
+131 -15
View File
@@ -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<Self, Self::Err> {
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::<ProviderType>().unwrap(), ProviderType::Kiro);
assert_eq!(
"gemini".parse::<ProviderType>().unwrap(),
ProviderType::Gemini
);
assert_eq!("qwen".parse::<ProviderType>().unwrap(), ProviderType::Qwen);
assert_eq!(
"openai".parse::<ProviderType>().unwrap(),
ProviderType::OpenAI
);
assert_eq!(
"claude".parse::<ProviderType>().unwrap(),
ProviderType::Claude
);
// 测试大小写不敏感
assert_eq!("KIRO".parse::<ProviderType>().unwrap(), ProviderType::Kiro);
assert_eq!(
"Gemini".parse::<ProviderType>().unwrap(),
ProviderType::Gemini
);
// 测试无效的 provider
assert!("invalid".parse::<ProviderType>().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::<ProviderType>("\"kiro\"").unwrap(),
ProviderType::Kiro
);
assert_eq!(
serde_json::from_str::<ProviderType>("\"openai\"").unwrap(),
ProviderType::OpenAI
);
}
}
pub type AppState = Arc<RwLock<server::ServerState>>;
pub type LogState = Arc<RwLock<logger::LogStore>>;
@@ -78,17 +182,15 @@ async fn set_default_provider(
logs: tauri::State<'_, LogState>,
provider: String,
) -> Result<String, String> {
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<ApiCompatibilityResult, String> {
// 使用枚举验证 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<String> = 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()),
};
+46
View File
@@ -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?;
+29 -18
View File
@@ -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::<String>()
),
);
// 记录响应的前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!(
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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);
+126 -229
View File
@@ -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<Provider[]>(defaultProviders);
const [activeProvider, setActiveProvider] = useState<string>("kiro");
// Kiro state
const [kiroStatus, setKiroStatus] = useState<KiroCredentialStatus | null>(
null,
);
const [kiroEnvVars, setKiroEnvVars] = useState<EnvVariable[]>([]);
const kiroHashRef = useRef<string>("");
const [kiroLastSync, setKiroLastSync] = useState<Date | null>(null);
// 使用 useProviderState hook 管理三个 OAuth providers
const kiro = useProviderState<KiroCredentialStatus>("kiro", {
getCredentials: getKiroCredentials,
getEnvVars: getEnvVariables,
getHash: getTokenFileHash,
checkAndReload: checkAndReloadCredentials,
reloadCredentials: reloadCredentials,
refreshToken: refreshKiroToken,
});
// Gemini state
const [geminiStatus, setGeminiStatus] =
useState<GeminiCredentialStatus | null>(null);
const [geminiEnvVars, setGeminiEnvVars] = useState<EnvVariable[]>([]);
const geminiHashRef = useRef<string>("");
const [geminiLastSync, setGeminiLastSync] = useState<Date | null>(null);
const gemini = useProviderState<GeminiCredentialStatus>("gemini", {
getCredentials: getGeminiCredentials,
getEnvVars: getGeminiEnvVariables,
getHash: getGeminiTokenFileHash,
checkAndReload: checkAndReloadGeminiCredentials,
reloadCredentials: reloadGeminiCredentials,
refreshToken: refreshGeminiToken,
});
// Qwen state
const [qwenStatus, setQwenStatus] = useState<QwenCredentialStatus | null>(
null,
);
const [qwenEnvVars, setQwenEnvVars] = useState<EnvVariable[]>([]);
const qwenHashRef = useRef<string>("");
const [qwenLastSync, setQwenLastSync] = useState<Date | null>(null);
// Last check time (used for display)
const [lastCheckTime, setLastCheckTime] = useState<Date | null>(null);
const qwen = useProviderState<QwenCredentialStatus>("qwen", {
getCredentials: getQwenCredentials,
getEnvVars: getQwenEnvVariables,
getHash: getQwenTokenFileHash,
checkAndReload: checkAndReloadQwenCredentials,
reloadCredentials: reloadQwenCredentials,
refreshToken: refreshQwenToken,
});
// OpenAI Custom state
const [openaiStatus, setOpenaiStatus] = useState<OpenAICustomStatus | null>(
@@ -151,6 +155,13 @@ export function Providers() {
} | null>(null);
const [copied, setCopied] = useState<string | null>(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 (
<div className="space-y-6">
@@ -576,13 +484,7 @@ export function Providers() {
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(kiroLastSync)}
</span>
</span>
<span>
最后检测:{" "}
<span className="text-foreground">
{formatTime(lastCheckTime)}
{formatTime(kiro.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
@@ -595,47 +497,47 @@ export function Providers() {
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{kiroStatus?.creds_path ||
{kiro.status?.creds_path ||
"~/.aws/sso/cache/kiro-auth-token.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">区域:</span>
<span className="ml-2">{kiroStatus?.region || "未设置"}</span>
<span className="ml-2">{kiro.status?.region || "未设置"}</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${kiroStatus?.has_access_token ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${kiro.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{kiroStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
{kiro.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${kiroStatus?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${kiro.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{kiroStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
{kiro.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("kiro")}
disabled={loading !== null}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{loading === "load-kiro" ? "加载中..." : "一键读取凭证"}
{kiro.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("kiro")}
disabled={loading !== null || !kiroStatus?.has_refresh_token}
disabled={isAnyLoading || !kiro.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${loading === "refresh-kiro" ? "animate-spin" : ""}`}
className={`h-4 w-4 ${kiro.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
@@ -659,13 +561,7 @@ export function Providers() {
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(geminiLastSync)}
</span>
</span>
<span>
最后检测:{" "}
<span className="text-foreground">
{formatTime(lastCheckTime)}
{formatTime(gemini.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
@@ -678,50 +574,50 @@ export function Providers() {
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{geminiStatus?.creds_path || "~/.gemini/oauth_creds.json"}
{gemini.status?.creds_path || "~/.gemini/oauth_creds.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">Token 有效:</span>
<span
className={`ml-2 ${geminiStatus?.is_valid ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${gemini.status?.is_valid ? "text-green-600" : "text-red-500"}`}
>
{geminiStatus?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
{gemini.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${geminiStatus?.has_access_token ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${gemini.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{geminiStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
{gemini.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${geminiStatus?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${gemini.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{geminiStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
{gemini.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("gemini")}
disabled={loading !== null}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{loading === "load-gemini" ? "加载中..." : "一键读取凭证"}
{gemini.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("gemini")}
disabled={loading !== null || !geminiStatus?.has_refresh_token}
disabled={isAnyLoading || !gemini.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${loading === "refresh-gemini" ? "animate-spin" : ""}`}
className={`h-4 w-4 ${gemini.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
@@ -745,13 +641,7 @@ export function Providers() {
<span>
最后同步:{" "}
<span className="text-foreground">
{formatTime(qwenLastSync)}
</span>
</span>
<span>
最后检测:{" "}
<span className="text-foreground">
{formatTime(lastCheckTime)}
{formatTime(qwen.lastSync)}
</span>
</span>
<span className="flex items-center gap-1">
@@ -764,50 +654,50 @@ export function Providers() {
<div>
<span className="text-muted-foreground">凭证路径:</span>
<code className="ml-2 rounded bg-muted px-2 py-0.5 text-xs break-all">
{qwenStatus?.creds_path || "~/.qwen/oauth_creds.json"}
{qwen.status?.creds_path || "~/.qwen/oauth_creds.json"}
</code>
</div>
<div>
<span className="text-muted-foreground">Token 有效:</span>
<span
className={`ml-2 ${qwenStatus?.is_valid ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${qwen.status?.is_valid ? "text-green-600" : "text-red-500"}`}
>
{qwenStatus?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
{qwen.status?.is_valid ? "✓ 有效" : "✗ 无效/过期"}
</span>
</div>
<div>
<span className="text-muted-foreground">Access Token:</span>
<span
className={`ml-2 ${qwenStatus?.has_access_token ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${qwen.status?.has_access_token ? "text-green-600" : "text-red-500"}`}
>
{qwenStatus?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
{qwen.status?.has_access_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
<div>
<span className="text-muted-foreground">Refresh Token:</span>
<span
className={`ml-2 ${qwenStatus?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
className={`ml-2 ${qwen.status?.has_refresh_token ? "text-green-600" : "text-red-500"}`}
>
{qwenStatus?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
{qwen.status?.has_refresh_token ? "✓ 已加载" : "✗ 未加载"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => handleLoadCredentials("qwen")}
disabled={loading !== null}
disabled={isAnyLoading}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
<FolderOpen className="h-4 w-4" />
{loading === "load-qwen" ? "加载中..." : "一键读取凭证"}
{qwen.loading === "reload" ? "加载中..." : "一键读取凭证"}
</button>
<button
onClick={() => handleRefreshToken("qwen")}
disabled={loading !== null || !qwenStatus?.has_refresh_token}
disabled={isAnyLoading || !qwen.status?.has_refresh_token}
className="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted disabled:opacity-50"
>
<RefreshCw
className={`h-4 w-4 ${loading === "refresh-qwen" ? "animate-spin" : ""}`}
className={`h-4 w-4 ${qwen.loading === "refresh" ? "animate-spin" : ""}`}
/>
刷新 Token
</button>
@@ -1027,7 +917,7 @@ export function Providers() {
{defaultProvider !== provider.id && (
<button
onClick={() => handleSetDefaultProvider(provider.id)}
disabled={loading !== null}
disabled={isAnyLoading}
className="rounded-lg border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50"
title="设为默认"
>
@@ -1041,12 +931,19 @@ export function Providers() {
provider.id === "qwen") && (
<button
onClick={() => handleRefreshToken(provider.id)}
disabled={loading !== null}
disabled={isAnyLoading}
className="rounded p-2 hover:bg-muted"
title="刷新 Token"
>
<RefreshCw
className={`h-4 w-4 ${loading === `refresh-${provider.id}` ? "animate-spin" : ""}`}
className={`h-4 w-4 ${
(provider.id === "kiro" && kiro.loading === "refresh") ||
(provider.id === "gemini" &&
gemini.loading === "refresh") ||
(provider.id === "qwen" && qwen.loading === "refresh")
? "animate-spin"
: ""
}`}
/>
</button>
)}
+53 -2
View File
@@ -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 <div>加载中...</div>;
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">
<svg
className="animate-spin h-8 w-8 mx-auto mb-2"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>加载配置中...</span>
</div>
</div>
);
}
return (
@@ -242,7 +269,31 @@ export function Settings() {
) : (
<Shield className="h-4 w-4" />
)}
检测 Kiro (Claude Code)
检测 Kiro
</button>
<button
onClick={() => handleCheckApiCompatibility("gemini")}
disabled={checking}
className="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Shield className="h-4 w-4" />
)}
检测 Gemini
</button>
<button
onClick={() => handleCheckApiCompatibility("qwen")}
disabled={checking}
className="flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50"
>
{checking ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Shield className="h-4 w-4" />
)}
检测 Qwen
</button>
</div>
+24
View File
@@ -0,0 +1,24 @@
import { useState, useCallback } from "react";
interface AppError {
message: string;
context?: string;
timestamp: Date;
}
export function useErrorHandler() {
const [error, setError] = useState<AppError | null>(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 };
}
+39
View File
@@ -0,0 +1,39 @@
import { useEffect, useRef, useCallback } from "react";
interface MonitorConfig {
checkFn: () => Promise<void>;
interval?: number;
enabled?: boolean;
}
export function useFileMonitoring(configs: Record<string, MonitorConfig>) {
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 };
}
+101
View File
@@ -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<T> {
getCredentials: () => Promise<T>;
getEnvVars: () => Promise<EnvVariable[]>;
getHash: () => Promise<string>;
checkAndReload: (hash: string) => Promise<CheckResult>;
reloadCredentials: () => Promise<unknown>;
refreshToken?: () => Promise<unknown>;
}
export function useProviderState<T>(
providerId: string,
config: ProviderStateConfig<T>,
) {
const [status, setStatus] = useState<T | null>(null);
const [envVars, setEnvVars] = useState<EnvVariable[]>([]);
const [lastSync, setLastSync] = useState<Date | null>(null);
const [loading, setLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const hashRef = useRef<string>("");
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),
};
}