feat(assistant): 备用模型组 failover + 测试按钮走 Rust 后端(修 status 0)

解决用户反馈的两个问题:晴辰助手设置里"测试"按钮在某些 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 卡片下方新增一个默认
折叠的 `<details>` 区块"备用模型组":
- 顶部 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 需求
This commit is contained in:
晴天
2026-04-20 03:43:43 +08:00
parent 58f5525445
commit dfb81066b4
6 changed files with 549 additions and 82 deletions

View File

@@ -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'),