mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 08:14:33 +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:
@@ -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::*;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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