mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
feat(model-test): add provider model availability testing
Implement standalone model testing feature to verify provider API connectivity: - Add ModelTestService for Claude/Codex/Gemini endpoint testing - Create model_test_logs table for test result persistence - Add test button to ProviderCard with loading state - Include ModelTestConfigPanel for customizing test parameters
This commit is contained in:
@@ -1,22 +1,35 @@
|
||||
import { BarChart3, Check, Copy, Edit, Play, Trash2 } from "lucide-react";
|
||||
import {
|
||||
BarChart3,
|
||||
Check,
|
||||
Copy,
|
||||
Edit,
|
||||
Loader2,
|
||||
Play,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ProviderActionsProps {
|
||||
isCurrent: boolean;
|
||||
isTesting?: boolean;
|
||||
onSwitch: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onTest?: () => void;
|
||||
onConfigureUsage: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ProviderActions({
|
||||
isCurrent,
|
||||
isTesting,
|
||||
onSwitch,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onTest,
|
||||
onConfigureUsage,
|
||||
onDelete,
|
||||
}: ProviderActionsProps) {
|
||||
@@ -70,6 +83,23 @@ export function ProviderActions({
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{onTest && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
title={t("modelTest.testProvider", "测试模型")}
|
||||
className={iconButtonClass}
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
|
||||
@@ -30,6 +30,8 @@ interface ProviderCardProps {
|
||||
onConfigureUsage: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onTest?: (provider: Provider) => void;
|
||||
isTesting?: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
isProxyRunning: boolean;
|
||||
dragHandleProps?: DragHandleProps;
|
||||
@@ -80,6 +82,8 @@ export function ProviderCard({
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onDuplicate,
|
||||
onTest,
|
||||
isTesting,
|
||||
onSetProxyTarget,
|
||||
isProxyRunning,
|
||||
dragHandleProps,
|
||||
@@ -244,9 +248,11 @@ export function ProviderCard({
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1.5 opacity-0 pointer-events-none group-hover:opacity-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-focus-within:pointer-events-auto transition-all duration-200 translate-x-2 group-hover:translate-x-0 group-focus-within:translate-x-0">
|
||||
<ProviderActions
|
||||
isCurrent={isCurrent}
|
||||
isTesting={isTesting}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
onDuplicate={() => onDuplicate(provider)}
|
||||
onTest={onTest ? () => onTest(provider) : undefined}
|
||||
onConfigureUsage={() => onConfigureUsage(provider)}
|
||||
onDelete={() => onDelete(provider)}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { Provider } from "@/types";
|
||||
import type { AppId } from "@/lib/api";
|
||||
import { useDragSort } from "@/hooks/useDragSort";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { useModelTest } from "@/hooks/useModelTest";
|
||||
import { ProviderCard } from "@/components/providers/ProviderCard";
|
||||
import { ProviderEmptyState } from "@/components/providers/ProviderEmptyState";
|
||||
|
||||
@@ -50,6 +51,13 @@ export function ProviderList({
|
||||
// 获取代理服务运行状态
|
||||
const { isRunning: isProxyRunning } = useProxyStatus();
|
||||
|
||||
// 模型测试
|
||||
const { testProvider, isTesting } = useModelTest(appId);
|
||||
|
||||
const handleTest = (provider: Provider) => {
|
||||
testProvider(provider.id, provider.name);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -93,6 +101,8 @@ export function ProviderList({
|
||||
onDuplicate={onDuplicate}
|
||||
onConfigureUsage={onConfigureUsage}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onTest={handleTest}
|
||||
isTesting={isTesting(provider.id)}
|
||||
onSetProxyTarget={onSetProxyTarget}
|
||||
isProxyRunning={isProxyRunning}
|
||||
/>
|
||||
@@ -113,6 +123,8 @@ interface SortableProviderCardProps {
|
||||
onDuplicate: (provider: Provider) => void;
|
||||
onConfigureUsage?: (provider: Provider) => void;
|
||||
onOpenWebsite: (url: string) => void;
|
||||
onTest: (provider: Provider) => void;
|
||||
isTesting: boolean;
|
||||
onSetProxyTarget: (provider: Provider) => void;
|
||||
isProxyRunning: boolean;
|
||||
}
|
||||
@@ -127,6 +139,8 @@ function SortableProviderCard({
|
||||
onDuplicate,
|
||||
onConfigureUsage,
|
||||
onOpenWebsite,
|
||||
onTest,
|
||||
isTesting,
|
||||
onSetProxyTarget,
|
||||
isProxyRunning,
|
||||
}: SortableProviderCardProps) {
|
||||
@@ -158,6 +172,8 @@ function SortableProviderCard({
|
||||
onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined
|
||||
}
|
||||
onOpenWebsite={onOpenWebsite}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
onSetProxyTarget={onSetProxyTarget}
|
||||
isProxyRunning={isProxyRunning}
|
||||
dragHandleProps={{
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ImportExportSection } from "@/components/settings/ImportExportSection";
|
||||
import { AboutSection } from "@/components/settings/AboutSection";
|
||||
import { ProxyPanel } from "@/components/proxy";
|
||||
import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel";
|
||||
import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel";
|
||||
import { UsageDashboard } from "@/components/usage/UsageDashboard";
|
||||
import { useSettings } from "@/hooks/useSettings";
|
||||
import { useImportExport } from "@/hooks/useImportExport";
|
||||
@@ -218,6 +219,9 @@ export function SettingsPage({
|
||||
{/* 模型定价配置 */}
|
||||
<PricingConfigPanel />
|
||||
|
||||
{/* 模型测试配置 */}
|
||||
<ModelTestConfigPanel />
|
||||
|
||||
<ImportExportSection
|
||||
status={importStatus}
|
||||
selectedFile={selectedFile}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ChevronDown, ChevronRight, Save, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
getModelTestConfig,
|
||||
saveModelTestConfig,
|
||||
type ModelTestConfig,
|
||||
} from "@/lib/api/model-test";
|
||||
|
||||
export function ModelTestConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<ModelTestConfig>({
|
||||
claudeModel: "claude-haiku-4-5-20251001",
|
||||
codexModel: "gpt-5.1-low",
|
||||
geminiModel: "gemini-3-pro-low",
|
||||
testPrompt: "ping",
|
||||
timeoutSecs: 15,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await getModelTestConfig();
|
||||
setConfig(data);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
setIsSaving(true);
|
||||
await saveModelTestConfig(config);
|
||||
toast.success(t("modelTest.configSaved", "模型测试配置已保存"));
|
||||
} catch (e) {
|
||||
toast.error(t("modelTest.configSaveFailed", "保存失败") + ": " + String(e));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="border-none bg-transparent p-0 shadow-none">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<CardTitle className="text-base">
|
||||
{t("modelTest.configTitle", "模型测试配置")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-none bg-transparent shadow-none">
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<div>
|
||||
<CardTitle className="text-base">
|
||||
{t("modelTest.configTitle", "模型测试配置")}
|
||||
</CardTitle>
|
||||
{!isExpanded && (
|
||||
<CardDescription className="mt-1">
|
||||
{t(
|
||||
"modelTest.configDesc",
|
||||
"配置模型测试使用的默认模型和提示词",
|
||||
)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{isExpanded && (
|
||||
<CardContent className="space-y-4">
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="claudeModel">
|
||||
{t("modelTest.claudeModel", "Claude 测试模型")}
|
||||
</Label>
|
||||
<Input
|
||||
id="claudeModel"
|
||||
value={config.claudeModel}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, claudeModel: e.target.value })
|
||||
}
|
||||
placeholder="claude-haiku-4-5-20251001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codexModel">
|
||||
{t("modelTest.codexModel", "Codex 测试模型")}
|
||||
</Label>
|
||||
<Input
|
||||
id="codexModel"
|
||||
value={config.codexModel}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, codexModel: e.target.value })
|
||||
}
|
||||
placeholder="gpt-5.1-low"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="geminiModel">
|
||||
{t("modelTest.geminiModel", "Gemini 测试模型")}
|
||||
</Label>
|
||||
<Input
|
||||
id="geminiModel"
|
||||
value={config.geminiModel}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, geminiModel: e.target.value })
|
||||
}
|
||||
placeholder="gemini-3-pro-low"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testPrompt">
|
||||
{t("modelTest.testPrompt", "测试提示词")}
|
||||
</Label>
|
||||
<Input
|
||||
id="testPrompt"
|
||||
value={config.testPrompt}
|
||||
onChange={(e) =>
|
||||
setConfig({ ...config, testPrompt: e.target.value })
|
||||
}
|
||||
placeholder="ping"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"modelTest.testPromptHint",
|
||||
"发送给模型的测试消息,建议使用简短内容以减少 token 消耗",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timeoutSecs">
|
||||
{t("modelTest.timeout", "超时时间(秒)")}
|
||||
</Label>
|
||||
<Input
|
||||
id="timeoutSecs"
|
||||
type="number"
|
||||
min={5}
|
||||
max={60}
|
||||
value={config.timeoutSecs}
|
||||
onChange={(e) =>
|
||||
setConfig({
|
||||
...config,
|
||||
timeoutSecs: parseInt(e.target.value) || 15,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("common.saving", "保存中...")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{t("common.save", "保存")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { testProviderModel, type ModelTestResult } from "@/lib/api/model-test";
|
||||
import type { AppId } from "@/lib/api";
|
||||
|
||||
export function useModelTest(appId: AppId) {
|
||||
const { t } = useTranslation();
|
||||
const [testingIds, setTestingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const testProvider = useCallback(
|
||||
async (providerId: string, providerName: string): Promise<ModelTestResult | null> => {
|
||||
setTestingIds((prev) => new Set(prev).add(providerId));
|
||||
|
||||
try {
|
||||
const result = await testProviderModel(appId, providerId);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("modelTest.success", {
|
||||
name: providerName,
|
||||
time: result.responseTimeMs,
|
||||
defaultValue: `${providerName} 测试成功 (${result.responseTimeMs}ms)`,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("modelTest.failed", {
|
||||
name: providerName,
|
||||
error: result.message,
|
||||
defaultValue: `${providerName} 测试失败: ${result.message}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("modelTest.error", {
|
||||
name: providerName,
|
||||
error: String(e),
|
||||
defaultValue: `${providerName} 测试出错: ${String(e)}`,
|
||||
}),
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setTestingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(providerId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[appId, t],
|
||||
);
|
||||
|
||||
const isTesting = useCallback(
|
||||
(providerId: string) => testingIds.has(providerId),
|
||||
[testingIds],
|
||||
);
|
||||
|
||||
return { testProvider, isTesting };
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { AppId } from "./types";
|
||||
|
||||
export interface ModelTestConfig {
|
||||
claudeModel: string;
|
||||
codexModel: string;
|
||||
geminiModel: string;
|
||||
testPrompt: string;
|
||||
timeoutSecs: number;
|
||||
}
|
||||
|
||||
export interface ModelTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
responseTimeMs?: number;
|
||||
httpStatus?: number;
|
||||
modelUsed: string;
|
||||
testedAt: number;
|
||||
}
|
||||
|
||||
export interface ModelTestLog {
|
||||
id: number;
|
||||
providerId: string;
|
||||
providerName: string;
|
||||
appType: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
responseTimeMs?: number;
|
||||
httpStatus?: number;
|
||||
testedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个供应商的模型可用性
|
||||
*/
|
||||
export async function testProviderModel(
|
||||
appType: AppId,
|
||||
providerId: string,
|
||||
): Promise<ModelTestResult> {
|
||||
return invoke("test_provider_model", { appType, providerId });
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量测试所有供应商
|
||||
*/
|
||||
export async function testAllProvidersModel(
|
||||
appType: AppId,
|
||||
proxyTargetsOnly: boolean = false,
|
||||
): Promise<Array<[string, ModelTestResult]>> {
|
||||
return invoke("test_all_providers_model", { appType, proxyTargetsOnly });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型测试配置
|
||||
*/
|
||||
export async function getModelTestConfig(): Promise<ModelTestConfig> {
|
||||
return invoke("get_model_test_config");
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模型测试配置
|
||||
*/
|
||||
export async function saveModelTestConfig(
|
||||
config: ModelTestConfig,
|
||||
): Promise<void> {
|
||||
return invoke("save_model_test_config", { config });
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模型测试日志
|
||||
*/
|
||||
export async function getModelTestLogs(
|
||||
appType?: string,
|
||||
providerId?: string,
|
||||
limit?: number,
|
||||
): Promise<ModelTestLog[]> {
|
||||
return invoke("get_model_test_logs", { appType, providerId, limit });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧的测试日志
|
||||
*/
|
||||
export async function cleanupModelTestLogs(keepCount?: number): Promise<number> {
|
||||
return invoke("cleanup_model_test_logs", { keepCount });
|
||||
}
|
||||
Reference in New Issue
Block a user