mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-27 11:20:04 +08:00
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:
@@ -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<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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user