From dfb81066b4dc714dabd951ce80fc9ce9e3f0444d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Mon, 20 Apr 2026 03:43:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(assistant):=20=E5=A4=87=E7=94=A8=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E7=BB=84=20failover=20+=20=E6=B5=8B=E8=AF=95=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E8=B5=B0=20Rust=20=E5=90=8E=E7=AB=AF=EF=BC=88?= =?UTF-8?q?=E4=BF=AE=20status=200=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解决用户反馈的两个问题:晴辰助手设置里"测试"按钮在某些 provider (如 gpt.qt.cool)上显示 Response Status: 0、Body 空,以及只能配 一个模型、挂了就没法用。 ## 1. 测试按钮 status 0 根因 & 修复 **根因**:Tauri 桌面端以前走 webview 的 `fetch()` 直打外部 API, 受 Chromium 网络栈限制 —— 某些 provider 的 HTTP/2 分块编码、TLS 握手、CORS 预检、或特殊响应头会被静默拒绝并抛 TypeError: Failed to fetch,前端 catch 后把 `respStatus` 写死 0、`respBody` 空。这 不是 provider 的问题,也不是 key 的问题,是 Chromium net stack 的 兼容性问题。 **修复**:新增 Rust 命令 `test_model_verbose`(基于已有的 reqwest HTTP 客户端),返回结构化 JSON `{success, status, reqUrl, reqBody, respBody, reply, error, elapsedMs, usedApi}`。 前端测试按钮无论 Tauri/Web 模式,一律调 `api.testModelVerbose()`: - Tauri → `invoke('test_model_verbose')` → 走原生 reqwest - Web → `fetch('/__api/test_model_verbose')` → 走 dev-api.js 服务端 fetch 这样绕过了 webview net stack 所有兼容性陷阱,拿到的永远是真实 HTTP status(含 401/429/5xx)和原始 body,debug 面板展示完整信息。 相比旧的 `test_model` 命令,`test_model_verbose` 不会因 400/422/429 就吞错误返回 "连接正常",而是如实回传,便于用户排查。 ## 2. 备用模型组 failover(参考 OpenClaw) **新增配置**:`_config.fallbackModels: Array<{label, baseUrl, apiKey, model, apiType, enabled}>`,存在 localStorage 里。 **callAI 改造**: - 旧的 `callAI` 改名为 `_callAIOnce`,保持不变 - 新增 `callAIWithSlot(slot, messages, onChunk)`:临时把 slot 注入 到 `_config`,调 `_callAIOnce`,finally 恢复(单线程安全,因为 `_isStreaming` 防并发) - 新的 `callAI`:`buildActiveSlots()` 收集主模型 + 启用且配置完整 的 fallback,按序尝试 - 成功 → return - `AbortError`(用户中止)→ 直抛,不 failover - 鉴权错误 401/403/`unauthorized`/`invalid api key` → 直抛,不 failover(切也白切) - 其他可重试错误(网络/超时/5xx/429/400 请求错/模型不存在)→ 在 聊天里插入 `⚠ 模型「X」失败,切换到备用「Y」` 引用块,继续下 一个 slot - 全部 slot 都失败 → 抛最后一个错误,触发既有 retry bar + circuit breaker 流程 **UI**:设置面板 API 标签页,在晴辰云 promo 卡片下方新增一个默认 折叠的 `
` 区块"备用模型组": - 顶部 summary 显示启用数量 + 折叠箭头 - 每张卡片:label / baseUrl / apiType / apiKey / model(紧凑 2 列 栅格)+ enabled 开关 + 删除按钮 - 顶部 "添加备用模型" 按钮:默认继承主模型的 apiType,减少配置项 - 编辑态用 fallbackDrafts(深拷贝),保存按钮才过滤空卡片写回 `_config.fallbackModels` - 单个 input 变化时只同步 drafts + 更新计数,不重渲染列表(保持 输入框焦点) **文件改动**: - `src-tauri/src/commands/config.rs`:+175 行 `test_model_verbose` - `src-tauri/src/lib.rs`:注册新命令 - `src/lib/tauri-api.js`:+1 行 `testModelVerbose` 封装 - `scripts/dev-api.js`:+75 行 Web 模式 test_model_verbose handler - `src/pages/assistant.js`: - `loadConfig`: 新增 `fallbackModels = []` 默认值 - `callAI` 重构为 failover loop(+80 行) - 测试按钮:移除 90 行的 webview fetch 双分支,统一调 verbose API(净减 ~60 行) - `showSettings`: 新增备用模型 UI + 事件绑定(+85 行) - 保存按钮:收集 fallbackDrafts 写回 _config - `src/locales/modules/assistant.js`:11 语言翻译(slotPrimary / failoverNotice / fallbackModelsTitle / fallbackModelsDesc / fallbackEnabledSuffix / fallbackEmpty / fallbackAdd / fallbackRemove / fallbackEnabled / placeholders) ## 验证 - `npm run build` 通过(assistant chunk 149.85 → 153.98 kB) - `cargo fmt --check` 通过 - `cargo clippy --all-targets -- -D warnings` 通过 - 向后兼容:旧用户的 `localStorage` 无 `fallbackModels` 字段, loadConfig 会初始化空数组,既有行为不变 Refs: 模型兼容性优化 + 多模型 failover 需求 --- scripts/dev-api.js | 77 ++++++++ src-tauri/src/commands/config.rs | 176 +++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/tauri-api.js | 1 + src/locales/modules/assistant.js | 62 ++++++ src/pages/assistant.js | 314 +++++++++++++++++++++++-------- 6 files changed, 549 insertions(+), 82 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 61b7a9e..f333845 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4948,6 +4948,83 @@ const handlers = { } }, + // 模型测试(详细版 #Compat-1):返回 {success, status, reqUrl, reqBody, respBody, reply, error, elapsedMs, usedApi} + async test_model_verbose({ baseUrl, apiKey, modelId, apiType = 'openai-completions' }) { + const type = ['anthropic', 'anthropic-messages'].includes(apiType) ? 'anthropic-messages' + : apiType === 'google-gemini' ? 'google-gemini' + : 'openai-completions' + let base = _normalizeBaseUrl(baseUrl) + if (type === 'anthropic-messages' && !/\/v1$/i.test(base)) base += '/v1' + const t0 = Date.now() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 30000) + + let usedApi, reqUrl, reqBody, headers, realUrl + if (type === 'anthropic-messages') { + usedApi = 'Anthropic Messages' + reqUrl = `${base}/messages` + realUrl = reqUrl + reqBody = { model: modelId, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } + headers = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' } + if (apiKey) headers['x-api-key'] = apiKey + } else if (type === 'google-gemini') { + usedApi = 'Gemini' + reqUrl = `${base}/models/${encodeURIComponent(modelId)}:generateContent?key=***` + realUrl = `${base}/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey || '')}` + reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] } + headers = { 'Content-Type': 'application/json' } + } else { + usedApi = 'Chat Completions' + reqUrl = `${base}/chat/completions` + realUrl = reqUrl + reqBody = { model: modelId, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200, stream: false } + headers = { 'Content-Type': 'application/json' } + if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` + } + + let resp + try { + resp = await fetch(realUrl, { method: 'POST', headers, body: JSON.stringify(reqBody), signal: controller.signal }) + } catch (e) { + clearTimeout(timer) + const elapsedMs = Date.now() - t0 + const error = e.name === 'AbortError' ? '请求超时 (30s)' : (e.message || String(e)) + return { success: false, status: 0, reqUrl, reqBody, respBody: '', reply: '', error, elapsedMs, usedApi } + } + clearTimeout(timer) + const elapsedMs = Date.now() - t0 + const status = resp.status + const respBody = await resp.text().catch(() => '') + + let reply = '' + try { + const v = JSON.parse(respBody) + if (Array.isArray(v.content)) { + reply = v.content.filter(b => b.type === 'text').map(b => b.text).join('') + } + if (!reply && v.candidates?.[0]?.content?.parts) { + reply = v.candidates[0].content.parts.map(p => p.text).filter(Boolean).join('') + } + if (!reply && v.choices?.[0]?.message) { + const msg = v.choices[0].message + reply = msg.content || (msg.reasoning_content ? `[reasoning] ${msg.reasoning_content}` : '') + } + if (!reply && v.output?.text) reply = v.output.text + } catch {} + + const success = resp.ok && !!reply + let error = null + if (!resp.ok) { + try { + const v = JSON.parse(respBody) + error = v.error?.message || v.message || `HTTP ${status}` + } catch { error = `HTTP ${status}` } + } else if (!reply) { + error = 'API 已响应但未解析出内容' + } + return { success, status, reqUrl, reqBody, respBody, reply, error, elapsedMs, usedApi } + }, + async list_remote_models({ baseUrl, apiKey, apiType = 'openai-completions' }) { const type = ['anthropic', 'anthropic-messages'].includes(apiType) ? 'anthropic-messages' : apiType === 'google-gemini' ? 'google-gemini' diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 425528c..11de5a9 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -5227,6 +5227,182 @@ pub async fn test_model( Ok(reply) } +/// 测试模型(详细版 #Compat-1):返回完整 req/resp 信息,供前端 debug 面板展示 +/// 相比 test_model: +/// - 不会因 400/422/429 等吞掉错误返回"连接正常",一律如实回传 status + body +/// - 返回结构化 JSON:success/status/req_url/req_body/resp_body/reply/error/elapsed_ms/used_api +/// - 前端拿到后可以直接渲染 debug 面板,无需在 webview 里走外部 fetch(规避 status 0) +#[tauri::command] +pub async fn test_model_verbose( + base_url: String, + api_key: String, + model_id: String, + api_type: Option, +) -> Result { + use std::time::Instant; + let api_type_norm = + normalize_model_api_type(api_type.as_deref().unwrap_or("openai-completions")); + let base = normalize_base_url_for_api(&base_url, api_type_norm); + let start = Instant::now(); + + let client = + crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(30), None) + .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + + let (used_api, req_url, req_body_json, req_builder) = match api_type_norm { + "anthropic-messages" => { + let url = format!("{}/messages", base); + let body = json!({ + "model": model_id, + "messages": [{"role": "user", "content": "你好,请用一句话回复"}], + "max_tokens": 200, + }); + let mut req = client + .post(&url) + .header("anthropic-version", "2023-06-01") + .json(&body); + if !api_key.is_empty() { + req = req.header("x-api-key", api_key.clone()); + } + ("Anthropic Messages", url, body, req) + } + "google-gemini" => { + let url_display = format!("{}/models/{}:generateContent?key=***", base, model_id); + let url_real = format!( + "{}/models/{}:generateContent?key={}", + base, model_id, api_key + ); + let body = json!({ + "contents": [{"role": "user", "parts": [{"text": "你好,请用一句话回复"}]}] + }); + let req = client.post(&url_real).json(&body); + ("Gemini", url_display, body, req) + } + _ => { + let url = format!("{}/chat/completions", base); + let body = json!({ + "model": model_id, + "messages": [{"role": "user", "content": "你好,请用一句话回复"}], + "max_tokens": 200, + "stream": false + }); + let mut req = client.post(&url).json(&body); + if !api_key.is_empty() { + req = req.header("Authorization", format!("Bearer {api_key}")); + } + ("Chat Completions", url, body, req) + } + }; + + let resp_result = req_builder.send().await; + let elapsed_ms = start.elapsed().as_millis() as u64; + + let resp = match resp_result { + Ok(r) => r, + Err(e) => { + let error = if e.is_timeout() { + "请求超时 (30s)".to_string() + } else if e.is_connect() { + format!("连接失败: {e}") + } else { + format!("请求失败: {e}") + }; + return Ok(json!({ + "success": false, + "status": 0, + "reqUrl": req_url, + "reqBody": req_body_json, + "respBody": "", + "reply": "", + "error": error, + "elapsedMs": elapsed_ms, + "usedApi": used_api, + })); + } + }; + + let status = resp.status(); + let status_code = status.as_u16(); + let text = resp.text().await.unwrap_or_default(); + + // 提取 reply 文本(兼容 OpenAI / Anthropic / Gemini / DashScope) + let reply = serde_json::from_str::(&text) + .ok() + .and_then(|v| { + if let Some(arr) = v.get("content").and_then(|c| c.as_array()) { + let text = arr + .iter() + .filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text")) + .filter_map(|b| b.get("text").and_then(|t| t.as_str())) + .collect::>() + .join(""); + if !text.is_empty() { + return Some(text); + } + } + if let Some(t) = v + .get("candidates") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("content")) + .and_then(|c| c.get("parts")) + .and_then(|p| p.get(0)) + .and_then(|p| p.get("text")) + .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) + { + return Some(t.to_string()); + } + if let Some(msg) = v + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + { + let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or(""); + if !content.is_empty() { + return Some(content.to_string()); + } + if let Some(rc) = msg + .get("reasoning_content") + .and_then(|c| c.as_str()) + .filter(|s| !s.is_empty()) + { + return Some(format!("[reasoning] {rc}")); + } + } + if let Some(t) = v + .get("output") + .and_then(|o| o.get("text")) + .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) + { + return Some(t.to_string()); + } + None + }) + .unwrap_or_default(); + + let success = status.is_success() && !reply.is_empty(); + let error = if !status.is_success() { + Some(extract_error_message(&text, status)) + } else if reply.is_empty() { + Some("API 已响应但未解析出内容".to_string()) + } else { + None + }; + + Ok(json!({ + "success": success, + "status": status_code, + "reqUrl": req_url, + "reqBody": req_body_json, + "respBody": text, + "reply": reply, + "error": error, + "elapsedMs": elapsed_ms, + "usedApi": used_api, + })) +} + /// 获取服务商的远程模型列表(调用 /models 接口) #[tauri::command] pub async fn list_remote_models( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e099777..a7342b5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,6 +89,7 @@ pub fn run() { config::reload_gateway, config::restart_gateway, config::test_model, + config::test_model_verbose, config::list_remote_models, config::list_openclaw_versions, config::upgrade_openclaw, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 64f02cc..ae33de9 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -217,6 +217,7 @@ export const api = { getNpmRegistry: () => cachedInvoke('get_npm_registry', {}, 30000), setNpmRegistry: (registry) => { invalidate('get_npm_registry'); return invoke('set_npm_registry', { registry }) }, testModel: (baseUrl, apiKey, modelId, apiType = null) => invoke('test_model', { baseUrl, apiKey, modelId, apiType }), + testModelVerbose: (baseUrl, apiKey, modelId, apiType = null) => invoke('test_model_verbose', { baseUrl, apiKey, modelId, apiType }), listRemoteModels: (baseUrl, apiKey, apiType = null) => invoke('list_remote_models', { baseUrl, apiKey, apiType }), // Agent 管理 diff --git a/src/locales/modules/assistant.js b/src/locales/modules/assistant.js index 4e66aa0..8d98e76 100644 --- a/src/locales/modules/assistant.js +++ b/src/locales/modules/assistant.js @@ -107,6 +107,68 @@ export default { 'Nouvelles tentatives suspendues en raison d\'erreurs répétées — vérifiez d\'abord la configuration', 'Wiederholungen aufgrund wiederholter Fehler pausiert — bitte zuerst Konfiguration prüfen', ), + // #Compat-3: 备用模型组 + slotPrimary: _('主模型', 'Primary', '主模型', 'メイン', '기본', 'Chính', 'Principal', 'Principal', 'Основной', 'Principal', 'Hauptmodell'), + failoverNotice: _( + '⚠ 模型「{from}」失败({err}),自动切换到备用:「{to}」', + '⚠ Model "{from}" failed ({err}), switching to fallback: "{to}"', + '⚠ 模型「{from}」失敗({err}),自動切換至備用:「{to}」', + '⚠ モデル「{from}」が失敗しました({err})、フォールバック「{to}」に切り替えます', + '⚠ 모델 "{from}" 실패 ({err}), 대체 모델 "{to}"로 전환', + '⚠ Mô hình "{from}" thất bại ({err}), đang chuyển sang dự phòng: "{to}"', + '⚠ El modelo "{from}" falló ({err}), cambiando a respaldo: "{to}"', + '⚠ O modelo "{from}" falhou ({err}), alternando para fallback: "{to}"', + '⚠ Модель "{from}" не удалась ({err}), переключение на резервную: "{to}"', + '⚠ Le modèle "{from}" a échoué ({err}), basculement vers la secours : "{to}"', + '⚠ Modell "{from}" fehlgeschlagen ({err}), wechsle zu Fallback: "{to}"', + ), + fallbackModelsTitle: _( + '备用模型组', + 'Fallback Models', + '備用模型組', + 'フォールバックモデル', + '대체 모델', + 'Mô hình dự phòng', + 'Modelos de respaldo', + 'Modelos de fallback', + 'Резервные модели', + 'Modèles de secours', + 'Fallback-Modelle', + ), + fallbackModelsDesc: _( + '主模型调用失败时,按顺序尝试以下备用模型(鉴权错误 401/403 不会触发切换)', + 'When the primary model fails, try these fallback models in order (auth errors 401/403 will not trigger switch)', + '主模型呼叫失敗時,依序嘗試以下備用模型(401/403 鑑權錯誤不會觸發切換)', + 'メインモデルが失敗した場合、以下のフォールバックモデルを順に試します(401/403 認証エラーは切り替え対象外)', + '기본 모델 실패 시 아래 대체 모델을 순서대로 시도합니다 (401/403 인증 오류는 전환되지 않음)', + 'Khi mô hình chính thất bại, thử các mô hình dự phòng theo thứ tự (lỗi xác thực 401/403 không kích hoạt chuyển đổi)', + 'Cuando el modelo principal falla, intenta estos modelos de respaldo en orden (errores de autenticación 401/403 no activarán el cambio)', + 'Quando o modelo principal falhar, tenta esses modelos de fallback em ordem (erros de auth 401/403 não acionam troca)', + 'При сбое основной модели последовательно пробует следующие резервные (ошибки аутентификации 401/403 не вызывают переключение)', + 'Lorsque le modèle principal échoue, essaie ces modèles de secours dans l\'ordre (les erreurs d\'authentification 401/403 ne déclenchent pas le basculement)', + 'Wenn das Hauptmodell fehlschlägt, werden folgende Fallback-Modelle der Reihe nach versucht (401/403-Auth-Fehler lösen keinen Wechsel aus)', + ), + fallbackEnabledSuffix: _('启用', 'enabled', '啟用', '有効', '활성', 'đang bật', 'activos', 'ativos', 'активно', 'activés', 'aktiviert'), + fallbackEmpty: _( + '还没有备用模型,点击下方按钮添加', + 'No fallback models yet, click the button below to add', + '還沒有備用模型,點擊下方按鈕新增', + 'まだフォールバックモデルがありません。下のボタンから追加してください', + '대체 모델이 아직 없습니다. 아래 버튼을 눌러 추가하세요', + 'Chưa có mô hình dự phòng, nhấn nút bên dưới để thêm', + 'Aún no hay modelos de respaldo, haz clic en el botón abajo para agregar', + 'Ainda não há modelos de fallback, clique no botão abaixo para adicionar', + 'Пока нет резервных моделей, нажмите кнопку ниже, чтобы добавить', + 'Aucun modèle de secours, cliquez sur le bouton ci-dessous pour ajouter', + 'Noch keine Fallback-Modelle, klicken Sie unten auf Hinzufügen', + ), + fallbackAdd: _('添加备用模型', 'Add Fallback Model', '新增備用模型', 'フォールバックモデルを追加', '대체 모델 추가', 'Thêm mô hình dự phòng', 'Agregar modelo de respaldo', 'Adicionar modelo de fallback', 'Добавить резервную модель', 'Ajouter un modèle de secours', 'Fallback-Modell hinzufügen'), + fallbackRemove: _('删除此备用模型', 'Remove this fallback model', '刪除此備用模型', 'このフォールバックモデルを削除', '이 대체 모델 삭제', 'Xóa mô hình dự phòng này', 'Eliminar este modelo de respaldo', 'Remover este modelo de fallback', 'Удалить эту резервную модель', 'Supprimer ce modèle de secours', 'Dieses Fallback-Modell entfernen'), + fallbackEnabled: _('启用', 'Enabled', '啟用', '有効', '활성', 'Bật', 'Activo', 'Ativo', 'Активно', 'Activé', 'Aktiv'), + fallbackLabelPlaceholder: _('显示名称(选填,如 DeepSeek 备用)', 'Display name (optional, e.g. DeepSeek Backup)', '顯示名稱(選填,如 DeepSeek 備用)', '表示名(任意、例:DeepSeek バックアップ)', '표시 이름(선택, 예: DeepSeek 백업)', 'Tên hiển thị (tuỳ chọn, ví dụ: DeepSeek dự phòng)', 'Nombre para mostrar (opcional)', 'Nome de exibição (opcional)', 'Отображаемое имя (необязательно)', 'Nom d\'affichage (facultatif)', 'Anzeigename (optional)'), + fallbackBaseUrlPlaceholder: _('API Base URL,如 https://api.deepseek.com/v1', 'API Base URL, e.g. https://api.deepseek.com/v1', 'API Base URL,如 https://api.deepseek.com/v1'), + fallbackApiKeyPlaceholder: _('API Key', 'API Key'), + fallbackModelPlaceholder: _('模型 ID,如 deepseek-chat', 'Model ID, e.g. deepseek-chat', '模型 ID,如 deepseek-chat'), newSession: _('新建会话', 'New Session', '新建對話', '新しいセッション', '새 세션', 'Phiên mới', 'Nueva sesión', 'Nova sessão', 'Новая сессия', 'Nouvelle session', 'Neue Sitzung'), deleteSession: _('删除会话', 'Delete Session', '刪除對話', 'セッション削除', '세션 삭제', 'Xóa phiên', 'Eliminar sesión', 'Excluir sessão', 'Удалить сессию', 'Supprimer la session', 'Sitzung löschen'), noSessions: _('暂无会话', 'No sessions', '暫無對話', 'セッションなし', '세션 없음', 'Không có phiên', 'Sin sesiones', 'Sem sessões', 'Нет сессий', 'Aucune session', 'Keine Sitzungen'), diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 20cb12f..8889222 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -1587,6 +1587,8 @@ function loadConfig() { _config.apiType = normalizeApiType(_config.apiType) if (_config.autoRounds === undefined) _config.autoRounds = 8 if (!Array.isArray(_config.knowledgeFiles)) _config.knowledgeFiles = [] + // #Compat-3 备用模型组:主模型失败时自动切换 + if (!Array.isArray(_config.fallbackModels)) _config.fallbackModels = [] return _config } @@ -1718,7 +1720,102 @@ const TIMEOUT_TOTAL = 120_000 // 总超时 120 秒 const TIMEOUT_CHUNK = 30_000 // 流式 chunk 间隔超时 30 秒 const TIMEOUT_CONNECT = 30_000 // 连接超时 30 秒 +// #Compat-3: 收集所有可用的模型槽位(主模型 + 启用的备用模型) +// 返回 [{ label, baseUrl, apiKey, model, apiType, isPrimary }, ...] +function buildActiveSlots() { + const slots = [] + // 主模型 + if (_config.baseUrl && _config.model) { + slots.push({ + label: t('assistant.slotPrimary'), + baseUrl: _config.baseUrl, + apiKey: _config.apiKey || '', + model: _config.model, + apiType: normalizeApiType(_config.apiType), + isPrimary: true, + }) + } + // 备用模型(仅保留必填字段齐全 + enabled 的) + for (const fb of (_config.fallbackModels || [])) { + if (!fb || fb.enabled === false) continue + if (!fb.baseUrl || !fb.model) continue + const apiType = normalizeApiType(fb.apiType) + if (requiresApiKey(apiType) && !fb.apiKey) continue + slots.push({ + label: fb.label || fb.model, + baseUrl: fb.baseUrl, + apiKey: fb.apiKey || '', + model: fb.model, + apiType, + isPrimary: false, + }) + } + return slots +} + +// #Compat-3: 判断错误是否应该触发 failover 切换到下一个槽位 +// - AbortError(用户手动中止)→ 不切 +// - 鉴权错误 401/403 → 不切(key 错了,切了也白切) +// - 其它(网络/超时/429/5xx/400 请求格式错误/模型不存在)→ 切 +function isFailoverableError(err) { + if (!err) return false + if (err.name === 'AbortError') return false + const msg = (err.message || '').toLowerCase() + // 鉴权错误关键字:401/403/invalid api key/unauthorized/authentication + if (/\b(401|403)\b/.test(msg)) return false + if (/unauthorized|forbidden|invalid\s+api\s*key|authentication|api\s*key\s+(invalid|missing|expired)/i.test(msg)) return false + return true +} + async function callAI(messages, onChunk) { + const slots = buildActiveSlots() + if (slots.length === 0) { + throw new Error(t('assistant.errConfigFirst')) + } + + let lastErr + for (let i = 0; i < slots.length; i++) { + const slot = slots[i] + try { + await callAIWithSlot(slot, messages, onChunk) + if (i > 0) { + console.log(`[assistant] Failover 成功:已切换到备用模型「${slot.label}」`) + } + return + } catch (err) { + lastErr = err + // 用户中止或鉴权错误:直接抛出,不 failover + if (!isFailoverableError(err)) throw err + // 最后一个槽位也失败了,抛出最终错误 + if (i >= slots.length - 1) throw err + // 还有备用:通知用户并继续 + const nextSlot = slots[i + 1] + const shortMsg = (err.message || '').slice(0, 80) + console.warn(`[assistant] 模型「${slot.label}」失败(${shortMsg}),切换到备用:${nextSlot.label}`) + onChunk(`\n\n> ${t('assistant.failoverNotice', { from: slot.label, to: nextSlot.label, err: shortMsg })}\n\n`) + } + } + throw lastErr +} + +// 用指定槽位发起一次请求(原 callAI 的内部实现;通过临时替换 _config 实现槽位隔离) +async function callAIWithSlot(slot, messages, onChunk) { + const savedConfig = _config + _config = { + ..._config, + baseUrl: slot.baseUrl, + apiKey: slot.apiKey, + model: slot.model, + apiType: slot.apiType, + } + try { + await _callAIOnce(messages, onChunk) + } finally { + _config = savedConfig + } +} + +async function _callAIOnce(messages, onChunk) { const apiType = normalizeApiType(_config.apiType) if (!_config.baseUrl || !_config.model || (requiresApiKey(apiType) && !_config.apiKey)) { throw new Error(t('assistant.errConfigFirst')) @@ -2977,6 +3074,22 @@ function showSettings() { ${icon('external-link', 11)} ${t('assistant.qtcoolLearnMore')} + + +
+ +
+ ${icon('shield', 13)} ${t('assistant.fallbackModelsTitle')} + ${(c.fallbackModels || []).filter(f => f && f.enabled !== false).length} ${t('assistant.fallbackEnabledSuffix')} +
+ +
+
+
${t('assistant.fallbackModelsDesc')}
+
+ +
+
${t('assistant.toolsHint')}
@@ -3092,6 +3205,89 @@ function showSettings() { ` document.body.appendChild(overlay) + // #Compat-3: 备用模型草稿(编辑态)——保存时才写回 _config.fallbackModels + const fallbackDrafts = JSON.parse(JSON.stringify(c.fallbackModels || [])) + const fallbackListEl = overlay.querySelector('#ast-fallback-list') + const fallbackCountEl = overlay.querySelector('#ast-fallback-count') + const updateFallbackCount = () => { + if (!fallbackCountEl) return + const n = fallbackDrafts.filter(f => f && f.enabled !== false && f.baseUrl && f.model).length + fallbackCountEl.textContent = `${n} ${t('assistant.fallbackEnabledSuffix')}` + } + const renderFallbackList = () => { + if (!fallbackListEl) return + if (fallbackDrafts.length === 0) { + fallbackListEl.innerHTML = `
${t('assistant.fallbackEmpty')}
` + updateFallbackCount() + return + } + fallbackListEl.innerHTML = fallbackDrafts.map((fb, idx) => ` +
+
+ #${idx + 2} + + + +
+
+ + +
+
+ + +
+
+ `).join('') + + // 绑定每张卡片的事件 + fallbackListEl.querySelectorAll('.ast-fallback-card').forEach(card => { + const idx = parseInt(card.dataset.fbIdx, 10) + const sync = () => { + fallbackDrafts[idx] = { + ...fallbackDrafts[idx], + label: card.querySelector('.ast-fb-label').value.trim(), + baseUrl: card.querySelector('.ast-fb-url').value.trim(), + apiKey: card.querySelector('.ast-fb-key').value.trim(), + model: card.querySelector('.ast-fb-model').value.trim(), + apiType: normalizeApiType(card.querySelector('.ast-fb-apitype').value), + enabled: card.querySelector('.ast-fb-enabled').checked, + } + // 仅更新透明度和计数,不重渲染(避免输入框丢焦点) + card.style.opacity = fallbackDrafts[idx].enabled === false ? '0.55' : '1' + updateFallbackCount() + } + card.querySelectorAll('.ast-fb-label, .ast-fb-url, .ast-fb-key, .ast-fb-model, .ast-fb-apitype, .ast-fb-enabled').forEach(el => { + el.addEventListener('input', sync) + el.addEventListener('change', sync) + }) + card.querySelector('.ast-fb-remove').onclick = () => { + fallbackDrafts.splice(idx, 1) + renderFallbackList() + } + }) + updateFallbackCount() + } + renderFallbackList() + overlay.querySelector('#ast-fallback-add')?.addEventListener('click', () => { + // 默认从主模型推断 apiType,便于快速配同类备用 + const mainApi = overlay.querySelector('#ast-apitype')?.value || _config.apiType || 'openai-completions' + fallbackDrafts.push({ + label: '', + baseUrl: '', + apiKey: '', + model: '', + apiType: normalizeApiType(mainApi), + enabled: true, + }) + renderFallbackList() + }) + // Tab 切换 overlay.querySelectorAll('.ast-tab').forEach(tab => { tab.addEventListener('click', () => { @@ -3527,91 +3723,34 @@ function showSettings() { btn.textContent = t('assistant.testing') resultEl.innerHTML = '' + t('assistant.testSending') + '' - // Web 模式下浏览器 fetch 受 CORS 限制,优先走后端代理 - if (!window.__TAURI_INTERNALS__) { - const t0 = Date.now() - try { - const reply = await api.testModel(baseUrl, apiKey, model, selApiType) - const elapsed = Date.now() - t0 - resultEl.innerHTML = buildTestResult({ success: true, elapsed, usedApi: selApiType, reqUrl: baseUrl, reqBody: {}, respStatus: 200, respBody: '', reply: reply || '(ok)' }) - } catch (err) { - const elapsed = Date.now() - t0 - resultEl.innerHTML = buildTestResult({ success: false, elapsed, usedApi: selApiType, reqUrl: baseUrl, reqBody: {}, respStatus: 0, respBody: '', error: err.message || String(err) }) - } - btn.disabled = false; btn.textContent = t('assistant.testBtn') - return - } - - const base = cleanBaseUrl(baseUrl, selApiType) - const hdrs = authHeaders(selApiType, apiKey) - const t0 = Date.now() - - let respStatus = 0, respBody = '', reply = '', usedApi = '', reqUrl = '', reqBody = {} - + // #Compat-1: 统一走 Rust reqwest(规避 webview fetch 的 status 0 / CORS 问题) + // Web 模式走 dev-api 的 /__api/test_model_verbose,Tauri 模式走 invoke('test_model_verbose') try { - if (selApiType === 'anthropic-messages') { - usedApi = 'Anthropic Messages' - reqUrl = base + '/messages' - reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } - const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) - respStatus = resp.status; respBody = await resp.text() - try { - const data = JSON.parse(respBody) - reply = data.content?.filter(b => b.type === 'text').map(b => b.text).join('') || '' - } catch {} - } else if (selApiType === 'google-gemini') { - usedApi = 'Gemini' - reqUrl = `${base}/models/${model}:generateContent?key=***` - reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] } - const realUrl = `${base}/models/${model}:generateContent?key=${apiKey}` - const resp = await fetch(realUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) - respStatus = resp.status; respBody = await resp.text() - try { - const data = JSON.parse(respBody) - reply = data.candidates?.[0]?.content?.parts?.[0]?.text || '' - } catch {} - } else { - // OpenAI: Chat Completions + Responses fallback - usedApi = 'Chat Completions' - reqUrl = base + '/chat/completions' - reqBody = { model, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } - const resp = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) - respStatus = resp.status; respBody = await resp.text() - - let fallback = false - if (!resp.ok && (respBody.includes('legacy protocol') || respBody.includes('/v1/responses') || respBody.includes('not supported'))) { - fallback = true - } - - if (!fallback) { - try { - const data = JSON.parse(respBody) - const msg = data.choices?.[0]?.message - reply = msg?.content || msg?.reasoning_content || data.choices?.[0]?.text || data.output?.text || '' - if (!msg?.content && msg?.reasoning_content) reply = '[reasoning] ' + reply - } catch {} - } - - if (fallback) { - usedApi = 'Responses' - reqUrl = base + '/responses' - reqBody = { model, input: [{ role: 'user', content: '你好,请用一句话回复' }], max_output_tokens: 200 } - try { - const resp2 = await fetch(reqUrl, { method: 'POST', headers: hdrs, body: JSON.stringify(reqBody), signal: AbortSignal.timeout(30000) }) - respStatus = resp2.status; respBody = await resp2.text() - try { const d = JSON.parse(respBody); reply = d.output_text || d.output?.[0]?.content?.[0]?.text || '' } catch {} - } catch (err2) { - resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err2.message }) - btn.disabled = false; btn.textContent = t('assistant.testBtn'); return - } - } - } + const r = await api.testModelVerbose(baseUrl, apiKey, model, selApiType) + resultEl.innerHTML = buildTestResult({ + success: !!r.success, + elapsed: r.elapsedMs || 0, + usedApi: r.usedApi || selApiType, + reqUrl: r.reqUrl || baseUrl, + reqBody: r.reqBody || {}, + respStatus: r.status ?? 0, + respBody: r.respBody || '', + reply: r.reply || '', + error: r.error || null, + }) } catch (err) { - resultEl.innerHTML = buildTestResult({ success: false, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus: 0, respBody: '', error: err.message }) - btn.disabled = false; btn.textContent = t('assistant.testBtn'); return + // Rust 命令本身失败(如 client 构造失败),或 Web 模式网络异常 + resultEl.innerHTML = buildTestResult({ + success: false, + elapsed: 0, + usedApi: selApiType, + reqUrl: baseUrl, + reqBody: {}, + respStatus: 0, + respBody: '', + error: err?.message || String(err), + }) } - - resultEl.innerHTML = buildTestResult({ success: !!reply, elapsed: Date.now() - t0, usedApi, reqUrl, reqBody, respStatus, respBody, reply }) btn.disabled = false btn.textContent = t('assistant.testBtn') } @@ -3842,6 +3981,17 @@ function showSettings() { } // 知识库 _config.knowledgeFiles = kbFiles + // #Compat-3: 备用模型组(过滤掉空卡片) + _config.fallbackModels = fallbackDrafts + .filter(f => f && f.baseUrl && f.model) + .map(f => ({ + label: f.label || f.model, + baseUrl: f.baseUrl, + apiKey: f.apiKey || '', + model: f.model, + apiType: normalizeApiType(f.apiType), + enabled: f.enabled !== false, + })) saveConfig() overlay.remove() // 更新 Header 标题和欢迎页