mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-06-06 08:09:54 +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:
@@ -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() {
|
||||
<a href="${QTCOOL.site}" target="_blank" style="color:var(--primary);text-decoration:none;font-size:11px">${icon('external-link', 11)} ${t('assistant.qtcoolLearnMore')}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #Compat-3: 备用模型组(可折叠) -->
|
||||
<details class="ast-fallback-section" id="ast-fallback-section" ${(c.fallbackModels || []).length ? 'open' : ''} style="margin-top:14px">
|
||||
<summary style="cursor:pointer;padding:10px 14px;border-radius:var(--radius-md);background:var(--bg-secondary);border:1px solid var(--border-primary);display:flex;justify-content:space-between;align-items:center;gap:8px;list-style:none;user-select:none">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
|
||||
<span style="font-weight:600;font-size:var(--font-size-sm)">${icon('shield', 13)} ${t('assistant.fallbackModelsTitle')}</span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);white-space:nowrap" id="ast-fallback-count">${(c.fallbackModels || []).filter(f => f && f.enabled !== false).length} ${t('assistant.fallbackEnabledSuffix')}</span>
|
||||
</div>
|
||||
<span class="ast-fallback-chevron" style="color:var(--text-tertiary);font-size:12px;transition:transform 0.2s">▼</span>
|
||||
</summary>
|
||||
<div style="padding:10px 4px 4px">
|
||||
<div class="form-hint" style="margin-bottom:10px">${t('assistant.fallbackModelsDesc')}</div>
|
||||
<div id="ast-fallback-list" style="display:flex;flex-direction:column;gap:10px"></div>
|
||||
<button type="button" class="btn btn-sm btn-secondary" id="ast-fallback-add" style="margin-top:10px;width:100%">${icon('plus', 13)} ${t('assistant.fallbackAdd')}</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="ast-tab-panel" data-panel="tools">
|
||||
<div class="form-hint" style="margin-bottom:10px">${t('assistant.toolsHint')}</div>
|
||||
@@ -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 = `<div class="form-hint" style="text-align:center;padding:16px 0;color:var(--text-tertiary);font-style:italic">${t('assistant.fallbackEmpty')}</div>`
|
||||
updateFallbackCount()
|
||||
return
|
||||
}
|
||||
fallbackListEl.innerHTML = fallbackDrafts.map((fb, idx) => `
|
||||
<div class="ast-fallback-card" data-fb-idx="${idx}" style="border:1px solid var(--border-primary);border-radius:var(--radius-md);background:var(--bg-secondary);padding:10px 12px;${fb.enabled === false ? 'opacity:0.55;' : ''}">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-size:11px;color:var(--text-tertiary);font-weight:500">#${idx + 2}</span>
|
||||
<input class="form-input ast-fb-label" placeholder="${t('assistant.fallbackLabelPlaceholder')}" value="${escHtml(fb.label || '')}" style="flex:1;font-size:12px;padding:4px 8px">
|
||||
<label class="ast-switch-inline" title="${t('assistant.fallbackEnabled')}" style="display:flex;align-items:center;gap:4px;font-size:11px;color:var(--text-tertiary);cursor:pointer;white-space:nowrap">
|
||||
<input type="checkbox" class="ast-fb-enabled" ${fb.enabled !== false ? 'checked' : ''} style="margin:0">
|
||||
<span>${t('assistant.fallbackEnabled')}</span>
|
||||
</label>
|
||||
<button type="button" class="btn btn-xs btn-secondary ast-fb-remove" title="${t('assistant.fallbackRemove')}" style="padding:3px 8px;color:var(--error)">✕</button>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 120px;gap:6px;margin-bottom:6px">
|
||||
<input class="form-input ast-fb-url" placeholder="${t('assistant.fallbackBaseUrlPlaceholder')}" value="${escHtml(fb.baseUrl || '')}" style="font-size:12px;padding:4px 8px">
|
||||
<select class="form-input ast-fb-apitype" style="font-size:12px;padding:4px 8px">
|
||||
${API_TYPES.map(at => `<option value="${at.value}" ${normalizeApiType(fb.apiType) === at.value ? 'selected' : ''}>${at.label}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px">
|
||||
<input class="form-input ast-fb-key" type="password" placeholder="${t('assistant.fallbackApiKeyPlaceholder')}" value="${escHtml(fb.apiKey || '')}" style="font-size:12px;padding:4px 8px">
|
||||
<input class="form-input ast-fb-model" placeholder="${t('assistant.fallbackModelPlaceholder')}" value="${escHtml(fb.model || '')}" style="font-size:12px;padding:4px 8px">
|
||||
</div>
|
||||
</div>
|
||||
`).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 = '<span style="color:var(--text-tertiary)">' + t('assistant.testSending') + '</span>'
|
||||
|
||||
// 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 标题和欢迎页
|
||||
|
||||
Reference in New Issue
Block a user