diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 97857dc1a..7a6707a2c 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -5,6 +5,7 @@ use crate::init_status::{InitErrorPayload, SkillsMigrationPayload}; use crate::services::ProviderService; use once_cell::sync::Lazy; use regex::Regex; +use std::path::Path; use std::str::FromStr; use tauri::AppHandle; use tauri::State; @@ -96,7 +97,9 @@ pub async fn get_tool_versions() -> Result, String> { for tool in tools { // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径 - let (local_version, local_error) = { + let (local_version, local_error) = if let Some(distro) = wsl_distro_for_tool(tool) { + try_get_version_wsl(tool, &distro) + } else { // 先尝试直接执行 let direct_result = try_get_version(tool); @@ -184,7 +187,7 @@ fn try_get_version(tool: &str) -> (Option, Option) { if out.status.success() { let raw = if stdout.is_empty() { &stderr } else { &stdout }; if raw.is_empty() { - (None, Some("未安装或无法执行".to_string())) + (None, Some("not installed or not executable".to_string())) } else { (Some(extract_version(raw)), None) } @@ -193,7 +196,7 @@ fn try_get_version(tool: &str) -> (Option, Option) { ( None, Some(if err.is_empty() { - "未安装或无法执行".to_string() + "not installed or not executable".to_string() } else { err }), @@ -204,6 +207,88 @@ fn try_get_version(tool: &str) -> (Option, Option) { } } +/// 校验 WSL 发行版名称是否合法 +/// WSL 发行版名称只允许字母、数字、连字符和下划线 +#[cfg(target_os = "windows")] +fn is_valid_wsl_distro_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +#[cfg(target_os = "windows")] +fn try_get_version_wsl(tool: &str, distro: &str) -> (Option, Option) { + use std::process::Command; + + // 防御性断言:tool 只能是预定义的值 + debug_assert!( + ["claude", "codex", "gemini"].contains(&tool), + "unexpected tool name: {tool}" + ); + + // 校验 distro 名称,防止命令注入 + if !is_valid_wsl_distro_name(distro) { + return (None, Some(format!("[WSL:{distro}] invalid distro name"))); + } + + let output = Command::new("wsl.exe") + .args([ + "-d", + distro, + "--", + "sh", + "-lc", + &format!("{tool} --version"), + ]) + .creation_flags(CREATE_NO_WINDOW) + .output(); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + if out.status.success() { + let raw = if stdout.is_empty() { &stderr } else { &stdout }; + if raw.is_empty() { + ( + None, + Some(format!("[WSL:{distro}] not installed or not executable")), + ) + } else { + (Some(extract_version(raw)), None) + } + } else { + let err = if stderr.is_empty() { stdout } else { stderr }; + ( + None, + Some(format!( + "[WSL:{distro}] {}", + if err.is_empty() { + "not installed or not executable".to_string() + } else { + err + } + )), + ) + } + } + Err(e) => (None, Some(format!("[WSL:{distro}] exec failed: {e}"))), + } +} + +/// 非 Windows 平台的 WSL 版本检测存根 +/// 注意:此函数实际上不会被调用,因为 `wsl_distro_from_path` 在非 Windows 平台总是返回 None。 +/// 保留此函数是为了保持 API 一致性,防止未来重构时遗漏。 +#[cfg(not(target_os = "windows"))] +fn try_get_version_wsl(_tool: &str, _distro: &str) -> (Option, Option) { + ( + None, + Some("WSL check not supported on this platform".to_string()), + ) +} + /// 扫描常见路径查找 CLI fn scan_cli_version(tool: &str) -> (Option, Option) { use std::process::Command; @@ -299,7 +384,49 @@ fn scan_cli_version(tool: &str) -> (Option, Option) { } } - (None, Some("未安装或无法执行".to_string())) + (None, Some("not installed or not executable".to_string())) +} + +fn wsl_distro_for_tool(tool: &str) -> Option { + let override_dir = match tool { + "claude" => crate::settings::get_claude_override_dir(), + "codex" => crate::settings::get_codex_override_dir(), + "gemini" => crate::settings::get_gemini_override_dir(), + _ => None, + }?; + + wsl_distro_from_path(&override_dir) +} + +/// 从 UNC 路径中提取 WSL 发行版名称 +/// 支持 `\\wsl$\Ubuntu\...` 和 `\\wsl.localhost\Ubuntu\...` 两种格式 +#[cfg(target_os = "windows")] +fn wsl_distro_from_path(path: &Path) -> Option { + use std::path::{Component, Prefix}; + let Some(Component::Prefix(prefix)) = path.components().next() else { + return None; + }; + match prefix.kind() { + Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => { + let server_name = server.to_string_lossy(); + if server_name.eq_ignore_ascii_case("wsl$") + || server_name.eq_ignore_ascii_case("wsl.localhost") + { + let distro = share.to_string_lossy().to_string(); + if !distro.is_empty() { + return Some(distro); + } + } + None + } + _ => None, + } +} + +/// 非 Windows 平台不支持 WSL 路径解析 +#[cfg(not(target_os = "windows"))] +fn wsl_distro_from_path(_path: &Path) -> Option { + None } /// 打开指定提供商的终端 diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index f7521f7cf..900b45698 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -36,6 +36,13 @@ pub struct StreamCheckConfig { pub codex_model: String, /// Gemini 测试模型 pub gemini_model: String, + /// 检查提示词 + #[serde(default = "default_test_prompt")] + pub test_prompt: String, +} + +fn default_test_prompt() -> String { + "Who are you?".to_string() } impl Default for StreamCheckConfig { @@ -47,6 +54,7 @@ impl Default for StreamCheckConfig { claude_model: "claude-haiku-4-5-20251001".to_string(), codex_model: "gpt-5.1-codex@low".to_string(), gemini_model: "gemini-3-pro-preview".to_string(), + test_prompt: default_test_prompt(), } } } @@ -110,7 +118,7 @@ impl StreamCheckService { Ok(last_result.unwrap_or_else(|| StreamCheckResult { status: HealthStatus::Failed, success: false, - message: "检查失败".to_string(), + message: "Check failed".to_string(), response_time_ms: None, http_status: None, model_used: String::new(), @@ -130,17 +138,18 @@ impl StreamCheckService { let base_url = adapter .extract_base_url(provider) - .map_err(|e| AppError::Message(format!("提取 base_url 失败: {e}")))?; + .map_err(|e| AppError::Message(format!("Failed to extract base_url: {e}")))?; let auth = adapter .extract_auth(provider) - .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; + .ok_or_else(|| AppError::Message("API Key not found".to_string()))?; // 使用全局 HTTP 客户端(已包含代理配置) let client = crate::proxy::http_client::get(); let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); + let test_prompt = &config.test_prompt; let result = match app_type { AppType::Claude => { @@ -149,13 +158,21 @@ impl StreamCheckService { &base_url, &auth, &model_to_test, + test_prompt, request_timeout, ) .await } AppType::Codex => { - Self::check_codex_stream(&client, &base_url, &auth, &model_to_test, request_timeout) - .await + Self::check_codex_stream( + &client, + &base_url, + &auth, + &model_to_test, + test_prompt, + request_timeout, + ) + .await } AppType::Gemini => { Self::check_gemini_stream( @@ -163,6 +180,7 @@ impl StreamCheckService { &base_url, &auth, &model_to_test, + test_prompt, request_timeout, ) .await @@ -179,7 +197,7 @@ impl StreamCheckService { Ok(StreamCheckResult { status: health_status, success: true, - message: "检查成功".to_string(), + message: "Check succeeded".to_string(), response_time_ms: Some(response_time), http_status: Some(status_code), model_used: model, @@ -201,32 +219,68 @@ impl StreamCheckService { } /// Claude 流式检查 + /// + /// 严格按照 Claude CLI 真实请求格式构建请求 async fn check_claude_stream( client: &Client, base_url: &str, auth: &AuthInfo, model: &str, + test_prompt: &str, timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); + // URL 必须包含 ?beta=true 参数(某些中转服务依赖此参数验证请求来源) let url = if base.ends_with("/v1") { - format!("{base}/messages") + format!("{base}/messages?beta=true") } else { - format!("{base}/v1/messages") + format!("{base}/v1/messages?beta=true") }; let body = json!({ "model": model, "max_tokens": 1, - "messages": [{ "role": "user", "content": "hi" }], + "messages": [{ "role": "user", "content": test_prompt }], "stream": true }); + // 获取本地系统信息 + let os_name = Self::get_os_name(); + let arch_name = Self::get_arch_name(); + + // 严格按照 Claude CLI 请求格式设置 headers let response = client .post(&url) + // 认证 headers(双重认证) + .header("authorization", format!("Bearer {}", auth.api_key)) .header("x-api-key", &auth.api_key) + // Anthropic 必需 headers .header("anthropic-version", "2023-06-01") - .header("Content-Type", "application/json") + .header( + "anthropic-beta", + "claude-code-20250219,interleaved-thinking-2025-05-14", + ) + .header("anthropic-dangerous-direct-browser-access", "true") + // 内容类型 headers + .header("content-type", "application/json") + .header("accept", "application/json") + .header("accept-encoding", "identity") + .header("accept-language", "*") + // 客户端标识 headers + .header("user-agent", "claude-cli/2.1.2 (external, cli)") + .header("x-app", "cli") + // x-stainless SDK headers(动态获取本地系统信息) + .header("x-stainless-lang", "js") + .header("x-stainless-package-version", "0.70.0") + .header("x-stainless-os", os_name) + .header("x-stainless-arch", arch_name) + .header("x-stainless-runtime", "node") + .header("x-stainless-runtime-version", "v22.20.0") + .header("x-stainless-retry-count", "0") + .header("x-stainless-timeout", "600") + // 其他 headers + .header("sec-fetch-mode", "cors") + .header("connection", "keep-alive") .timeout(timeout) .json(&body) .send() @@ -245,52 +299,63 @@ impl StreamCheckService { if let Some(chunk) = stream.next().await { match chunk { Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), + Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), } } else { - Err(AppError::Message("未收到响应数据".to_string())) + Err(AppError::Message("No response data received".to_string())) } } /// Codex 流式检查 + /// + /// 严格按照 Codex CLI 真实请求格式构建请求 (Responses API) async fn check_codex_stream( client: &Client, base_url: &str, auth: &AuthInfo, model: &str, + test_prompt: &str, timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); + // Codex CLI 使用 /v1/responses 端点 (OpenAI Responses API) let url = if base.ends_with("/v1") { - format!("{base}/chat/completions") + format!("{base}/responses") } else { - format!("{base}/v1/chat/completions") + format!("{base}/v1/responses") }; // 解析模型名和推理等级 (支持 model@level 或 model#level 格式) let (actual_model, reasoning_effort) = Self::parse_model_with_effort(model); + // 获取本地系统信息 + let os_name = Self::get_os_name(); + let arch_name = Self::get_arch_name(); + + // Responses API 请求体格式 (input 必须是数组) let mut body = json!({ "model": actual_model, - "messages": [ - { "role": "system", "content": "" }, - { "role": "assistant", "content": "" }, - { "role": "user", "content": "hi" } - ], - "max_tokens": 1, - "temperature": 0, + "input": [{ "role": "user", "content": test_prompt }], "stream": true }); // 如果是推理模型,添加 reasoning_effort if let Some(effort) = reasoning_effort { - body["reasoning_effort"] = json!(effort); + body["reasoning"] = json!({ "effort": effort }); } + // 严格按照 Codex CLI 请求格式设置 headers let response = client .post(&url) - .header("Authorization", format!("Bearer {}", auth.api_key)) - .header("Content-Type", "application/json") + .header("authorization", format!("Bearer {}", auth.api_key)) + .header("content-type", "application/json") + .header("accept", "text/event-stream") + .header("accept-encoding", "identity") + .header( + "user-agent", + format!("codex_cli_rs/0.80.0 ({os_name} 15.7.2; {arch_name}) Terminal"), + ) + .header("originator", "codex_cli_rs") .timeout(timeout) .json(&body) .send() @@ -308,10 +373,10 @@ impl StreamCheckService { if let Some(chunk) = stream.next().await { match chunk { Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), + Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), } } else { - Err(AppError::Message("未收到响应数据".to_string())) + Err(AppError::Message("No response data received".to_string())) } } @@ -321,6 +386,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + test_prompt: &str, timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); @@ -328,7 +394,7 @@ impl StreamCheckService { let body = json!({ "model": model, - "messages": [{ "role": "user", "content": "hi" }], + "messages": [{ "role": "user", "content": test_prompt }], "max_tokens": 1, "temperature": 0, "stream": true @@ -355,10 +421,10 @@ impl StreamCheckService { if let Some(chunk) = stream.next().await { match chunk { Ok(_) => Ok((status, model.to_string())), - Err(e) => Err(AppError::Message(format!("读取流失败: {e}"))), + Err(e) => Err(AppError::Message(format!("Stream read failed: {e}"))), } } else { - Err(AppError::Message("未收到响应数据".to_string())) + Err(AppError::Message("No response data received".to_string())) } } @@ -373,7 +439,6 @@ impl StreamCheckService { /// 解析模型名和推理等级 (支持 model@level 或 model#level 格式) /// 返回 (实际模型名, Option<推理等级>) fn parse_model_with_effort(model: &str) -> (String, Option) { - // 查找 @ 或 # 分隔符 if let Some(pos) = model.find('@').or_else(|| model.find('#')) { let actual_model = model[..pos].to_string(); let effort = model[pos + 1..].to_string(); @@ -386,17 +451,14 @@ impl StreamCheckService { fn should_retry(msg: &str) -> bool { let lower = msg.to_lowercase(); - lower.contains("timeout") - || lower.contains("abort") - || lower.contains("中断") - || lower.contains("超时") + lower.contains("timeout") || lower.contains("abort") || lower.contains("timed out") } fn map_request_error(e: reqwest::Error) -> AppError { if e.is_timeout() { - AppError::Message("请求超时".to_string()) + AppError::Message("Request timeout".to_string()) } else if e.is_connect() { - AppError::Message(format!("连接失败: {e}")) + AppError::Message(format!("Connection failed: {e}")) } else { AppError::Message(e.to_string()) } @@ -443,6 +505,26 @@ impl StreamCheckService { .map(|m| m.as_str().trim().to_string()) .filter(|value| !value.is_empty()) } + + /// 获取操作系统名称(映射为 Claude CLI 使用的格式) + fn get_os_name() -> &'static str { + match std::env::consts::OS { + "macos" => "MacOS", + "linux" => "Linux", + "windows" => "Windows", + other => other, + } + } + + /// 获取 CPU 架构名称(映射为 Claude CLI 使用的格式) + fn get_arch_name() -> &'static str { + match std::env::consts::ARCH { + "aarch64" => "arm64", + "x86_64" => "x86_64", + "x86" => "x86", + other => other, + } + } } #[cfg(test)] @@ -467,9 +549,10 @@ mod tests { #[test] fn test_should_retry() { - assert!(StreamCheckService::should_retry("请求超时")); - assert!(StreamCheckService::should_retry("request timeout")); - assert!(!StreamCheckService::should_retry("API Key 无效")); + assert!(StreamCheckService::should_retry("Request timeout")); + assert!(StreamCheckService::should_retry("request timed out")); + assert!(StreamCheckService::should_retry("connection abort")); + assert!(!StreamCheckService::should_retry("API Key invalid")); } #[test] @@ -497,4 +580,33 @@ mod tests { assert_eq!(model, "gpt-4o-mini"); assert_eq!(effort, None); } + + #[test] + fn test_get_os_name() { + let os_name = StreamCheckService::get_os_name(); + // 确保返回非空字符串 + assert!(!os_name.is_empty()); + // 在 macOS 上应该返回 "MacOS" + #[cfg(target_os = "macos")] + assert_eq!(os_name, "MacOS"); + // 在 Linux 上应该返回 "Linux" + #[cfg(target_os = "linux")] + assert_eq!(os_name, "Linux"); + // 在 Windows 上应该返回 "Windows" + #[cfg(target_os = "windows")] + assert_eq!(os_name, "Windows"); + } + + #[test] + fn test_get_arch_name() { + let arch_name = StreamCheckService::get_arch_name(); + // 确保返回非空字符串 + assert!(!arch_name.is_empty()); + // 在 ARM64 上应该返回 "arm64" + #[cfg(target_arch = "aarch64")] + assert_eq!(arch_name, "arm64"); + // 在 x86_64 上应该返回 "x86_64" + #[cfg(target_arch = "x86_64")] + assert_eq!(arch_name, "x86_64"); + } } diff --git a/src/components/usage/ModelTestConfigPanel.tsx b/src/components/usage/ModelTestConfigPanel.tsx index 95a7e8893..699facd2c 100644 --- a/src/components/usage/ModelTestConfigPanel.tsx +++ b/src/components/usage/ModelTestConfigPanel.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Save, Loader2 } from "lucide-react"; @@ -25,6 +26,7 @@ export function ModelTestConfigPanel() { claudeModel: "claude-haiku-4-5-20251001", codexModel: "gpt-5.1-codex@low", geminiModel: "gemini-3-pro-preview", + testPrompt: "Who are you?", }); useEffect(() => { @@ -43,6 +45,7 @@ export function ModelTestConfigPanel() { claudeModel: data.claudeModel, codexModel: data.codexModel, geminiModel: data.geminiModel, + testPrompt: data.testPrompt || "Who are you?", }); } catch (e) { setError(String(e)); @@ -66,6 +69,7 @@ export function ModelTestConfigPanel() { claudeModel: config.claudeModel, codexModel: config.codexModel, geminiModel: config.geminiModel, + testPrompt: config.testPrompt || "Who are you?", }; await saveStreamCheckConfig(parsed); toast.success(t("streamCheck.configSaved"), { @@ -189,6 +193,21 @@ export function ModelTestConfigPanel() { /> + + {/* 检查提示词配置 */} +
+ +