From 689ca0840990f6bf30ca7c533c833e85e8906192 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 14 Apr 2026 17:11:13 +0800 Subject: [PATCH] feat: classify stream check errors with color-coded toasts Distinguish between "provider rejects probe" (yellow warning) and "genuinely broken" (red error) in health check results. Backend: add AppError::HttpStatus variant to carry structured HTTP status codes, populate http_status on error results, classify codes into short labels (e.g. "Auth rejected (401)"), and truncate overly long response bodies. Frontend: route 401/403/400/429/5xx to toast.warning with localized hints explaining the error may not indicate actual unusability; route 404/402/connection errors to toast.error. Add i18n keys for all three locales (zh/en/ja). Also deduplicate check_once by reusing build_stream_check_result. --- src-tauri/src/commands/stream_check.rs | 27 +++-- src-tauri/src/error.rs | 2 + src-tauri/src/services/stream_check.rs | 101 +++++++++++------- .../forms/ProviderAdvancedConfig.tsx | 7 +- src/hooks/useStreamCheck.ts | 38 +++++-- src/i18n/locales/en.json | 12 ++- src/i18n/locales/ja.json | 12 ++- src/i18n/locales/zh.json | 12 ++- 8 files changed, 145 insertions(+), 66 deletions(-) diff --git a/src-tauri/src/commands/stream_check.rs b/src-tauri/src/commands/stream_check.rs index b7d5ea0b8..be0de3f12 100644 --- a/src-tauri/src/commands/stream_check.rs +++ b/src-tauri/src/commands/stream_check.rs @@ -116,15 +116,24 @@ pub async fn stream_check_all_providers( claude_api_format_override, ) .await - .unwrap_or_else(|e| StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message: e.to_string(), - response_time_ms: None, - http_status: None, - model_used: String::new(), - tested_at: chrono::Utc::now().timestamp(), - retry_count: 0, + .unwrap_or_else(|e| { + let (http_status, message) = match &e { + crate::error::AppError::HttpStatus { status, .. } => ( + Some(*status), + StreamCheckService::classify_http_status(*status).to_string(), + ), + _ => (None, e.to_string()), + }; + StreamCheckResult { + status: HealthStatus::Failed, + success: false, + message, + response_time_ms: None, + http_status, + model_used: String::new(), + tested_at: chrono::Utc::now().timestamp(), + retry_count: 0, + } }); let _ = state diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 4c5790654..04509626a 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -44,6 +44,8 @@ pub enum AppError { McpValidation(String), #[error("{0}")] Message(String), + #[error("HTTP {status}: {body}")] + HttpStatus { status: u16, body: String }, #[error("{zh} ({en})")] Localized { key: &'static str, diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index 3d1ca197a..ef545fdf1 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -271,34 +271,11 @@ impl StreamCheckService { }; let response_time = start.elapsed().as_millis() as u64; - let tested_at = chrono::Utc::now().timestamp(); - - match result { - Ok((status_code, model)) => { - let health_status = - Self::determine_status(response_time, config.degraded_threshold_ms); - Ok(StreamCheckResult { - status: health_status, - success: true, - message: "Check succeeded".to_string(), - response_time_ms: Some(response_time), - http_status: Some(status_code), - model_used: model, - tested_at, - retry_count: 0, - }) - } - Err(e) => Ok(StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message: e.to_string(), - response_time_ms: Some(response_time), - http_status: None, - model_used: String::new(), - tested_at, - retry_count: 0, - }), - } + Ok(Self::build_stream_check_result( + result, + response_time, + config.degraded_threshold_ms, + )) } /// Claude 流式检查 @@ -475,7 +452,7 @@ impl StreamCheckService { if !response.status().is_success() { let error_text = response.text().await.unwrap_or_default(); - return Err(AppError::Message(format!("HTTP {status}: {error_text}"))); + return Err(Self::http_status_error(status, error_text)); } // 流式读取:只需首个 chunk @@ -555,7 +532,7 @@ impl StreamCheckService { if i == 0 && status == 404 && urls.len() > 1 { continue; } - return Err(AppError::Message(format!("HTTP {status}: {error_text}"))); + return Err(Self::http_status_error(status, error_text)); } let mut stream = response.bytes_stream(); @@ -630,7 +607,7 @@ impl StreamCheckService { if !response.status().is_success() { let error_text = response.text().await.unwrap_or_default(); - return Err(AppError::Message(format!("HTTP {status}: {error_text}"))); + return Err(Self::http_status_error(status, error_text)); } let mut stream = response.bytes_stream(); @@ -717,16 +694,25 @@ impl StreamCheckService { tested_at, retry_count: 0, }, - Err(e) => StreamCheckResult { - status: HealthStatus::Failed, - success: false, - message: e.to_string(), - response_time_ms: Some(response_time), - http_status: None, - model_used: String::new(), - tested_at, - retry_count: 0, - }, + Err(e) => { + let (http_status, message) = match &e { + AppError::HttpStatus { status, .. } => ( + Some(*status), + Self::classify_http_status(*status).to_string(), + ), + _ => (None, e.to_string()), + }; + StreamCheckResult { + status: HealthStatus::Failed, + success: false, + message, + response_time_ms: Some(response_time), + http_status, + model_used: String::new(), + tested_at, + retry_count: 0, + } + } } } @@ -1124,6 +1110,39 @@ impl StreamCheckService { } } + /// 构造 HTTP 状态码错误,截断过长的响应体 + fn http_status_error(status: u16, body: String) -> AppError { + let body = if body.len() > 200 { + // 安全截断:找到 200 字节内最近的 char 边界 + let mut end = 200; + while end > 0 && !body.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &body[..end]) + } else { + body + }; + AppError::HttpStatus { status, body } + } + + /// 将 HTTP 状态码映射为简短的分类标签 + pub(crate) fn classify_http_status(status: u16) -> &'static str { + match status { + 400 => "Bad request (400)", + 401 => "Auth rejected (401)", + 402 => "Payment required (402)", + 403 => "Access denied (403)", + 404 => "Not found (404)", + 429 => "Rate limited (429)", + 500 => "Internal server error (500)", + 502 => "Bad gateway (502)", + 503 => "Service unavailable (503)", + 504 => "Gateway timeout (504)", + s if (500..600).contains(&s) => "Server error", + _ => "HTTP error", + } + } + fn resolve_test_model( app_type: &AppType, provider: &Provider, diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 29a5f41f1..0a7c54d7b 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -1,11 +1,6 @@ import { useTranslation } from "react-i18next"; import { useState, useEffect } from "react"; -import { - ChevronDown, - ChevronRight, - FlaskConical, - Coins, -} from "lucide-react"; +import { ChevronDown, ChevronRight, FlaskConical, Coins } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; diff --git a/src/hooks/useStreamCheck.ts b/src/hooks/useStreamCheck.ts index 958ac7b41..c30667d76 100644 --- a/src/hooks/useStreamCheck.ts +++ b/src/hooks/useStreamCheck.ts @@ -47,13 +47,37 @@ export function useStreamCheck(appId: AppId) { // 降级状态也重置熔断器,因为至少能通信 resetCircuitBreaker.mutate({ providerId, appType: appId }); } else { - toast.error( - t("streamCheck.failed", { - providerName: providerName, - message: result.message, - defaultValue: `${providerName} 检查失败: ${result.message}`, - }), - ); + const httpStatus = result.httpStatus; + const hintKey = httpStatus + ? `streamCheck.httpHint.${httpStatus >= 500 ? "5xx" : httpStatus}` + : null; + const description = + (hintKey ? t(hintKey, { defaultValue: "" }) : "") || undefined; + + // 401/403/400 = 检查被拒(供应商可能正常);429/5xx = 临时问题 + const isProbeRejection = + httpStatus != null && + ([401, 403, 400, 429].includes(httpStatus) || httpStatus >= 500); + + if (isProbeRejection) { + toast.warning( + t("streamCheck.rejected", { + providerName: providerName, + message: result.message, + defaultValue: `${providerName} 检查被拒: ${result.message}`, + }), + { description, duration: 8000, closeButton: true }, + ); + } else { + toast.error( + t("streamCheck.failed", { + providerName: providerName, + message: result.message, + defaultValue: `${providerName} 检查失败: ${result.message}`, + }), + { description, duration: 8000, closeButton: true }, + ); + } } return result; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f25b83bc3..497b7f6da 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2129,7 +2129,17 @@ "operational": "{{providerName}} is operational ({{responseTimeMs}}ms)", "degraded": "{{providerName}} is slow ({{responseTimeMs}}ms)", "failed": "{{providerName}} check failed: {{message}}", - "error": "{{providerName}} check error: {{error}}" + "rejected": "{{providerName}} check rejected: {{message}}", + "error": "{{providerName}} check error: {{error}}", + "httpHint": { + "400": "Provider rejected request format. Health check probe may differ from actual usage.", + "401": "API key may be invalid, or provider uses OAuth auth. Check failure doesn't mean it's unusable.", + "402": "Account quota or billing issue.", + "403": "Provider blocked this request. Some verify client identity; may work fine in actual use.", + "404": "Endpoint not found. Check base URL and API path.", + "429": "Too many requests. Try again later.", + "5xx": "Provider internal error. Likely temporary." + } }, "proxyConfig": { "proxyEnabled": "Routing Master Switch", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9380eb2c0..97f3df68b 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2129,7 +2129,17 @@ "operational": "{{providerName}} は正常に動作しています ({{responseTimeMs}}ms)", "degraded": "{{providerName}} の応答が遅いです ({{responseTimeMs}}ms)", "failed": "{{providerName}} のチェックに失敗しました: {{message}}", - "error": "{{providerName}} のチェックでエラーが発生しました: {{error}}" + "rejected": "{{providerName}} のチェックが拒否されました: {{message}}", + "error": "{{providerName}} のチェックでエラーが発生しました: {{error}}", + "httpHint": { + "400": "リクエスト形式が拒否されました。ヘルスチェックの形式は実際の使用と異なる場合があります。", + "401": "APIキーが無効か、OAuthなどの認証方式を使用しています。チェック失敗は実際に使えないことを意味しません。", + "402": "アカウントの利用枠または請求の問題です。", + "403": "プロバイダーがリクエストを拒否しました。実際の使用では正常に動作する場合があります。", + "404": "エンドポイントが見つかりません。Base URLとAPIパスを確認してください。", + "429": "リクエストが多すぎます。しばらくしてからお試しください。", + "5xx": "プロバイダーの内部エラーです。一時的な問題の可能性が高いです。" + } }, "proxyConfig": { "proxyEnabled": "ルーティング総スイッチ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cb32835e1..e7022ecf8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2130,7 +2130,17 @@ "operational": "{{providerName}} 运行正常 ({{responseTimeMs}}ms)", "degraded": "{{providerName}} 响应较慢 ({{responseTimeMs}}ms)", "failed": "{{providerName}} 检查失败: {{message}}", - "error": "{{providerName}} 检查出错: {{error}}" + "rejected": "{{providerName}} 检查被拒: {{message}}", + "error": "{{providerName}} 检查出错: {{error}}", + "httpHint": { + "400": "供应商拒绝了请求格式。健康检查的探测格式可能与实际使用不同。", + "401": "API Key 可能无效,或供应商使用 OAuth 等认证方式。检查失败不代表实际不可用。", + "402": "账户配额或计费问题。", + "403": "供应商拒绝了此请求。部分供应商会校验客户端身份,检查失败但实际使用可能正常。", + "404": "接口地址不存在,请检查 Base URL 和 API 路径。", + "429": "请求频率过高,请稍后重试。", + "5xx": "供应商内部错误,通常是临时问题。" + } }, "proxyConfig": { "proxyEnabled": "路由总开关",