From a64e588cf8033c2c686412e394c16a23a5320431 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Thu, 4 Dec 2025 00:53:25 +0800 Subject: [PATCH] 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 --- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/model_test.rs | 126 +++++ src-tauri/src/database/schema.rs | 33 ++ src-tauri/src/lib.rs | 7 + src-tauri/src/services/mod.rs | 3 + src-tauri/src/services/model_test.rs | 481 ++++++++++++++++++ src/components/providers/ProviderActions.tsx | 32 +- src/components/providers/ProviderCard.tsx | 6 + src/components/providers/ProviderList.tsx | 16 + src/components/settings/SettingsPage.tsx | 4 + src/components/usage/ModelTestConfigPanel.tsx | 223 ++++++++ src/hooks/useModelTest.ts | 63 +++ src/lib/api/model-test.ts | 87 ++++ 13 files changed, 1082 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/model_test.rs create mode 100644 src-tauri/src/services/model_test.rs create mode 100644 src/components/usage/ModelTestConfigPanel.tsx create mode 100644 src/hooks/useModelTest.ts create mode 100644 src/lib/api/model-test.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 739300016..cd8018b8b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -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::*; diff --git a/src-tauri/src/commands/model_test.rs b/src-tauri/src/commands/model_test.rs new file mode 100644 index 000000000..04b50bb9f --- /dev/null +++ b/src-tauri/src/commands/model_test.rs @@ -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 { + // 获取测试配置 + 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, 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 { + 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, + provider_id: Option, + limit: Option, +) -> Result, 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, +) -> Result { + state.db.cleanup_model_test_logs(keep_count.unwrap_or(100)) +} diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index b4acb4c6c..1086ff0fe 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -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(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d46a75d84..af735ecdb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ae13066e0..2b3e7bcc6 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -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; diff --git a/src-tauri/src/services/model_test.rs b/src-tauri/src/services/model_test.rs new file mode 100644 index 000000000..1a5eefe75 --- /dev/null +++ b/src-tauri/src/services/model_test.rs @@ -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, + pub http_status: Option, + 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, + pub http_status: Option, + pub tested_at: i64, +} + +/// 模型测试服务 +pub struct ModelTestService; + +impl ModelTestService { + /// 测试单个供应商的模型可用性 + pub async fn test_provider( + app_type: &AppType, + provider: &Provider, + config: &ModelTestConfig, + ) -> Result { + 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::(&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::(&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 { + 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, 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> = 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::, _>>() + .map_err(|e| AppError::Database(e.to_string()))?; + + Ok(logs) + } + + /// 获取模型测试配置 + pub fn get_model_test_config(&self) -> Result { + 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 { + 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) + } +} diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index 18df7bf03..a0a8dec5d 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -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({ + {onTest && ( + + )} + + + + )} + + ); +} diff --git a/src/hooks/useModelTest.ts b/src/hooks/useModelTest.ts new file mode 100644 index 000000000..c049e061c --- /dev/null +++ b/src/hooks/useModelTest.ts @@ -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>(new Set()); + + const testProvider = useCallback( + async (providerId: string, providerName: string): Promise => { + 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 }; +} diff --git a/src/lib/api/model-test.ts b/src/lib/api/model-test.ts new file mode 100644 index 000000000..64edb7f35 --- /dev/null +++ b/src/lib/api/model-test.ts @@ -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 { + return invoke("test_provider_model", { appType, providerId }); +} + +/** + * 批量测试所有供应商 + */ +export async function testAllProvidersModel( + appType: AppId, + proxyTargetsOnly: boolean = false, +): Promise> { + return invoke("test_all_providers_model", { appType, proxyTargetsOnly }); +} + +/** + * 获取模型测试配置 + */ +export async function getModelTestConfig(): Promise { + return invoke("get_model_test_config"); +} + +/** + * 保存模型测试配置 + */ +export async function saveModelTestConfig( + config: ModelTestConfig, +): Promise { + return invoke("save_model_test_config", { config }); +} + +/** + * 获取模型测试日志 + */ +export async function getModelTestLogs( + appType?: string, + providerId?: string, + limit?: number, +): Promise { + return invoke("get_model_test_logs", { appType, providerId, limit }); +} + +/** + * 清理旧的测试日志 + */ +export async function cleanupModelTestLogs(keepCount?: number): Promise { + return invoke("cleanup_model_test_logs", { keepCount }); +}