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

@@ -5227,6 +5227,182 @@ pub async fn test_model(
Ok(reply)
}
/// 测试模型(详细版 #Compat-1返回完整 req/resp 信息,供前端 debug 面板展示
/// 相比 test_model
/// - 不会因 400/422/429 等吞掉错误返回"连接正常",一律如实回传 status + body
/// - 返回结构化 JSONsuccess/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<String>,
) -> Result<serde_json::Value, String> {
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::<serde_json::Value>(&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::<Vec<_>>()
.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(

View File

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