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:
YoVinchen
2025-12-04 00:53:25 +08:00
parent ab08b906ad
commit a64e588cf8
13 changed files with 1082 additions and 1 deletions
+31 -1
View File
@@ -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)}
/>
+16
View File
@@ -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={{
+4
View File
@@ -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>
);
}
+63
View File
@@ -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 };
}
+87
View File
@@ -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 });
}