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
+2
View File
@@ -6,6 +6,7 @@ mod env;
mod import_export;
mod mcp;
mod misc;
mod model_test;
mod plugin;
mod prompt;
mod provider;
@@ -20,6 +21,7 @@ pub use env::*;
pub use import_export::*;
pub use mcp::*;
pub use misc::*;
pub use model_test::*;
pub use plugin::*;
pub use prompt::*;
pub use provider::*;
+126
View File
@@ -0,0 +1,126 @@
//! 模型测试相关命令
use crate::app_config::AppType;
use crate::error::AppError;
use crate::services::model_test::{ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService};
use crate::store::AppState;
use tauri::State;
/// 测试单个供应商的模型可用性
#[tauri::command]
pub async fn test_provider_model(
state: State<'_, AppState>,
app_type: AppType,
provider_id: String,
) -> Result<ModelTestResult, AppError> {
// 获取测试配置
let config = state.db.get_model_test_config()?;
// 获取供应商
let providers = state.db.get_all_providers(app_type.as_str())?;
let provider = providers
.get(&provider_id)
.ok_or_else(|| AppError::Message(format!("供应商 {} 不存在", provider_id)))?;
// 执行测试
let result = ModelTestService::test_provider(&app_type, provider, &config).await?;
// 记录日志
let _ = state.db.save_model_test_log(
&provider_id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
Ok(result)
}
/// 批量测试所有供应商
#[tauri::command]
pub async fn test_all_providers_model(
state: State<'_, AppState>,
app_type: AppType,
proxy_targets_only: bool,
) -> Result<Vec<(String, ModelTestResult)>, AppError> {
let config = state.db.get_model_test_config()?;
let providers = state.db.get_all_providers(app_type.as_str())?;
let mut results = Vec::new();
for (id, provider) in providers {
// 如果只测试代理目标,跳过非代理目标
if proxy_targets_only && !provider.is_proxy_target.unwrap_or(false) {
continue;
}
match ModelTestService::test_provider(&app_type, &provider, &config).await {
Ok(result) => {
// 记录日志
let _ = state.db.save_model_test_log(
&id,
&provider.name,
app_type.as_str(),
&result.model_used,
&config.test_prompt,
&result,
);
results.push((id, result));
}
Err(e) => {
let error_result = ModelTestResult {
success: false,
message: e.to_string(),
response_time_ms: None,
http_status: None,
model_used: String::new(),
tested_at: chrono::Utc::now().timestamp(),
};
results.push((id, error_result));
}
}
}
Ok(results)
}
/// 获取模型测试配置
#[tauri::command]
pub fn get_model_test_config(state: State<'_, AppState>) -> Result<ModelTestConfig, AppError> {
state.db.get_model_test_config()
}
/// 保存模型测试配置
#[tauri::command]
pub fn save_model_test_config(
state: State<'_, AppState>,
config: ModelTestConfig,
) -> Result<(), AppError> {
state.db.save_model_test_config(&config)
}
/// 获取模型测试日志
#[tauri::command]
pub fn get_model_test_logs(
state: State<'_, AppState>,
app_type: Option<String>,
provider_id: Option<String>,
limit: Option<u32>,
) -> Result<Vec<ModelTestLog>, AppError> {
state.db.get_model_test_logs(
app_type.as_deref(),
provider_id.as_deref(),
limit.unwrap_or(50),
)
}
/// 清理旧的测试日志
#[tauri::command]
pub fn cleanup_model_test_logs(
state: State<'_, AppState>,
keep_count: Option<u32>,
) -> Result<u64, AppError> {
state.db.cleanup_model_test_logs(keep_count.unwrap_or(100))
}
+33
View File
@@ -303,6 +303,39 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// 14. Model Test Logs 表 (模型测试日志,独立于代理使用统计)
conn.execute(
"CREATE TABLE IF NOT EXISTS model_test_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
provider_name TEXT NOT NULL,
app_type TEXT NOT NULL,
model TEXT NOT NULL,
prompt TEXT NOT NULL,
success INTEGER NOT NULL,
message TEXT NOT NULL,
response_time_ms INTEGER,
http_status INTEGER,
tested_at INTEGER NOT NULL
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_provider
ON model_test_logs(provider_id, app_type)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_model_test_logs_tested_at
ON model_test_logs(tested_at DESC)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
+7
View File
@@ -661,6 +661,13 @@ pub fn run() {
commands::update_model_pricing,
commands::delete_model_pricing,
commands::check_provider_limits,
// Model testing
commands::test_provider_model,
commands::test_all_providers_model,
commands::get_model_test_config,
commands::save_model_test_config,
commands::get_model_test_logs,
commands::cleanup_model_test_logs,
]);
let app = builder
+3
View File
@@ -2,6 +2,7 @@ pub mod config;
pub mod env_checker;
pub mod env_manager;
pub mod mcp;
pub mod model_test;
pub mod prompt;
pub mod provider;
pub mod proxy;
@@ -11,6 +12,8 @@ pub mod usage_stats;
pub use config::ConfigService;
pub use mcp::McpService;
#[allow(unused_imports)]
pub use model_test::{ModelTestConfig, ModelTestLog, ModelTestResult, ModelTestService};
pub use prompt::PromptService;
pub use provider::{ProviderService, ProviderSortUpdate};
pub use proxy::ProxyService;
+481
View File
@@ -0,0 +1,481 @@
//! 模型测试服务
//!
//! 提供独立的模型可用性测试功能,复用现有 Provider 适配器逻辑,
//! 但不影响正常代理数据流程。测试结果记录到独立的日志表。
use crate::app_config::AppType;
use crate::database::Database;
use crate::error::AppError;
use crate::provider::Provider;
use crate::proxy::providers::{get_adapter, AuthInfo, ProviderAdapter};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::{Duration, Instant};
/// 模型测试配置
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestConfig {
/// 默认测试模型(Claude
pub claude_model: String,
/// 默认测试模型(Codex/OpenAI
pub codex_model: String,
/// 默认测试模型(Gemini
pub gemini_model: String,
/// 测试提示词
pub test_prompt: String,
/// 超时时间(秒)
pub timeout_secs: u64,
}
impl Default for ModelTestConfig {
fn default() -> Self {
Self {
claude_model: "claude-haiku-4-5-20251001".to_string(),
codex_model: "gpt-5.1-low".to_string(),
gemini_model: "gemini-3-pro-low".to_string(),
test_prompt: "ping".to_string(),
timeout_secs: 15,
}
}
}
/// 模型测试结果
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestResult {
pub success: bool,
pub message: String,
pub response_time_ms: Option<u64>,
pub http_status: Option<u16>,
pub model_used: String,
pub tested_at: i64,
}
/// 模型测试日志记录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelTestLog {
pub id: i64,
pub provider_id: String,
pub provider_name: String,
pub app_type: String,
pub model: String,
pub prompt: String,
pub success: bool,
pub message: String,
pub response_time_ms: Option<i64>,
pub http_status: Option<i64>,
pub tested_at: i64,
}
/// 模型测试服务
pub struct ModelTestService;
impl ModelTestService {
/// 测试单个供应商的模型可用性
pub async fn test_provider(
app_type: &AppType,
provider: &Provider,
config: &ModelTestConfig,
) -> Result<ModelTestResult, AppError> {
let start = Instant::now();
let adapter = get_adapter(app_type);
// 构建 HTTP 客户端(独立于代理服务)
let client = Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}")))?;
// 根据 AppType 选择测试模型
let model = match app_type {
AppType::Claude => &config.claude_model,
AppType::Codex => &config.codex_model,
AppType::Gemini => &config.gemini_model,
};
let result = match app_type {
AppType::Claude => {
Self::test_claude(&client, provider, adapter.as_ref(), model, &config.test_prompt)
.await
}
AppType::Codex => {
Self::test_codex(&client, provider, adapter.as_ref(), model, &config.test_prompt)
.await
}
AppType::Gemini => {
Self::test_gemini(&client, provider, adapter.as_ref(), model, &config.test_prompt)
.await
}
};
let response_time = start.elapsed().as_millis() as u64;
let tested_at = chrono::Utc::now().timestamp();
match result {
Ok((status, msg)) => Ok(ModelTestResult {
success: true,
message: msg,
response_time_ms: Some(response_time),
http_status: Some(status),
model_used: model.clone(),
tested_at,
}),
Err(e) => Ok(ModelTestResult {
success: false,
message: e.to_string(),
response_time_ms: Some(response_time),
http_status: None,
model_used: model.clone(),
tested_at,
}),
}
}
/// 测试 Claude (Anthropic Messages API)
async fn test_claude(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter.extract_base_url(provider).map_err(|e| {
AppError::Message(format!("提取 base_url 失败: {e}"))
})?;
let auth = adapter.extract_auth(provider).ok_or_else(|| {
AppError::Message("未找到 API Key".to_string())
})?;
// 智能拼接 URL,避免重复 /v1
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{}/messages", base)
} else {
format!("{}/v1/messages", base)
};
let body = json!({
"model": model,
"max_tokens": 1,
"messages": [{
"role": "user",
"content": prompt
}]
});
let mut request = client.post(&url).json(&body);
request = Self::add_claude_auth(request, &auth);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
// 先获取文本,再尝试解析 JSON(兼容流式响应)
let text = response.text().await.unwrap_or_default();
// 尝试解析 JSON
if let Ok(data) = serde_json::from_str::<Value>(&text) {
if data.get("type").is_some() || data.get("content").is_some() || data.get("id").is_some() {
return Ok((status, "模型测试成功".to_string()));
}
}
// 即使无法解析 JSON,只要状态码是 200 就认为成功
Ok((status, "模型测试成功".to_string()))
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {}: {}", status, error_text)))
}
}
/// 测试 Codex (OpenAI Chat Completions API)
async fn test_codex(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter.extract_base_url(provider).map_err(|e| {
AppError::Message(format!("提取 base_url 失败: {e}"))
})?;
let auth = adapter.extract_auth(provider).ok_or_else(|| {
AppError::Message("未找到 API Key".to_string())
})?;
// 智能拼接 URL,避免重复 /v1
let base = base_url.trim_end_matches('/');
let url = if base.ends_with("/v1") {
format!("{}/chat/completions", base)
} else {
format!("{}/v1/chat/completions", base)
};
let body = json!({
"model": model,
"messages": [{
"role": "user",
"content": prompt
}],
"max_tokens": 1,
"stream": false
});
let request = client
.post(&url)
.header("Authorization", format!("Bearer {}", auth.api_key))
.header("Content-Type", "application/json")
.json(&body);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
// 先获取文本,再尝试解析 JSON
let text = response.text().await.unwrap_or_default();
if let Ok(data) = serde_json::from_str::<Value>(&text) {
if data.get("choices").is_some() || data.get("id").is_some() {
return Ok((status, "模型测试成功".to_string()));
}
}
// 即使无法解析 JSON,只要状态码是 200 就认为成功
Ok((status, "模型测试成功".to_string()))
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {}: {}", status, error_text)))
}
}
/// 测试 Gemini (Google Generative AI API)
async fn test_gemini(
client: &Client,
provider: &Provider,
adapter: &dyn ProviderAdapter,
model: &str,
prompt: &str,
) -> Result<(u16, String), AppError> {
let base_url = adapter.extract_base_url(provider).map_err(|e| {
AppError::Message(format!("提取 base_url 失败: {e}"))
})?;
let auth = adapter.extract_auth(provider).ok_or_else(|| {
AppError::Message("未找到 API Key".to_string())
})?;
let url = format!(
"{}/v1beta/models/{}:generateContent?key={}",
base_url.trim_end_matches('/'),
model,
auth.api_key
);
let body = json!({
"contents": [{
"parts": [{
"text": prompt
}]
}],
"generationConfig": {
"maxOutputTokens": 1
}
});
let request = client
.post(&url)
.header("Content-Type", "application/json")
.json(&body);
let response = request.send().await.map_err(|e| {
if e.is_timeout() {
AppError::Message("请求超时".to_string())
} else if e.is_connect() {
AppError::Message(format!("连接失败: {e}"))
} else {
AppError::Message(e.to_string())
}
})?;
let status = response.status().as_u16();
if response.status().is_success() {
let data: Value = response.json().await.map_err(|e| {
AppError::Message(format!("解析响应失败: {e}"))
})?;
if data.get("candidates").is_some() {
Ok((status, "模型测试成功".to_string()))
} else {
Err(AppError::Message("响应格式异常".to_string()))
}
} else {
let error_text = response.text().await.unwrap_or_default();
Err(AppError::Message(format!("HTTP {}: {}", status, error_text)))
}
}
/// 添加 Claude 认证头
fn add_claude_auth(request: reqwest::RequestBuilder, auth: &AuthInfo) -> reqwest::RequestBuilder {
request
.header("x-api-key", &auth.api_key)
.header("anthropic-version", "2023-06-01")
.header("Content-Type", "application/json")
}
}
// ===== 数据库操作 =====
impl Database {
/// 保存模型测试日志
pub fn save_model_test_log(
&self,
provider_id: &str,
provider_name: &str,
app_type: &str,
model: &str,
prompt: &str,
result: &ModelTestResult,
) -> Result<i64, AppError> {
let conn = self.conn.lock().map_err(|e| {
AppError::Database(format!("获取数据库连接失败: {e}"))
})?;
conn.execute(
"INSERT INTO model_test_logs
(provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
provider_id,
provider_name,
app_type,
model,
prompt,
result.success,
result.message,
result.response_time_ms.map(|t| t as i64),
result.http_status.map(|s| s as i64),
result.tested_at,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(conn.last_insert_rowid())
}
/// 获取模型测试日志
pub fn get_model_test_logs(
&self,
app_type: Option<&str>,
provider_id: Option<&str>,
limit: u32,
) -> Result<Vec<ModelTestLog>, AppError> {
let conn = self.conn.lock().map_err(|e| {
AppError::Database(format!("获取数据库连接失败: {e}"))
})?;
let mut sql = String::from(
"SELECT id, provider_id, provider_name, app_type, model, prompt, success, message, response_time_ms, http_status, tested_at
FROM model_test_logs WHERE 1=1"
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(at) = app_type {
sql.push_str(" AND app_type = ?");
params.push(Box::new(at.to_string()));
}
if let Some(pid) = provider_id {
sql.push_str(" AND provider_id = ?");
params.push(Box::new(pid.to_string()));
}
sql.push_str(" ORDER BY tested_at DESC LIMIT ?");
params.push(Box::new(limit as i64));
let params_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql).map_err(|e| AppError::Database(e.to_string()))?;
let logs = stmt
.query_map(params_refs.as_slice(), |row| {
Ok(ModelTestLog {
id: row.get(0)?,
provider_id: row.get(1)?,
provider_name: row.get(2)?,
app_type: row.get(3)?,
model: row.get(4)?,
prompt: row.get(5)?,
success: row.get(6)?,
message: row.get(7)?,
response_time_ms: row.get(8)?,
http_status: row.get(9)?,
tested_at: row.get(10)?,
})
})
.map_err(|e| AppError::Database(e.to_string()))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(logs)
}
/// 获取模型测试配置
pub fn get_model_test_config(&self) -> Result<ModelTestConfig, AppError> {
match self.get_setting("model_test_config")? {
Some(json) => serde_json::from_str(&json).map_err(|e| {
AppError::Message(format!("解析模型测试配置失败: {e}"))
}),
None => Ok(ModelTestConfig::default()),
}
}
/// 保存模型测试配置
pub fn save_model_test_config(&self, config: &ModelTestConfig) -> Result<(), AppError> {
let json = serde_json::to_string(config).map_err(|e| {
AppError::Message(format!("序列化模型测试配置失败: {e}"))
})?;
self.set_setting("model_test_config", &json)
}
/// 清理旧的测试日志(保留最近 N 条)
pub fn cleanup_model_test_logs(&self, keep_count: u32) -> Result<u64, AppError> {
let conn = self.conn.lock().map_err(|e| {
AppError::Database(format!("获取数据库连接失败: {e}"))
})?;
let deleted = conn.execute(
"DELETE FROM model_test_logs WHERE id NOT IN (
SELECT id FROM model_test_logs ORDER BY tested_at DESC LIMIT ?
)",
rusqlite::params![keep_count as i64],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(deleted as u64)
}
}
+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 });
}