mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-25 17:54:10 +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:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user