refactor(assistant): 备用模型 UI 重设计 - 厂商预设快捷添加 + 极简列表

## 用户反馈

"晴辰助手的备用模型配置,很复杂,很麻烦" —— 旧 UI 每个备用要填
6 个字段(label / baseUrl / apiKey / model / apiType / enabled),
每张卡 3 行 6 输入框 ~200px 高,添加流程比配主模型还复杂。

## 重设计原则

备用模型本质是"主模型挂了用啥兜底",应该是**选一个**而不是**重新配一个**。
复用 PROVIDER_PRESETS 里已有的 16 个厂商预设,一键预填 baseUrl / apiType。

## 新 UI 结构

```
┌─ 备用模型 (已启用 2 个) ────────────────────── [▼] ─┐
│ 主模型失败时按顺序切换到备用(401/403 除外)         │
│                                                      │
│ 📌 主模型(当前)  gpt-4o-mini     gpt.qt.cool      │  ← 只读
│ ⋮⋮ #2 [晴辰云] claude-haiku · api.anthropic   编辑 × │  ← 紧凑一行
│ ⋮⋮ #3 qwen3-30b · localhost:8000               编辑 × │
│                                                      │
│ 选择服务商快速添加:                                 │
│ [★晴辰云][OpenAI][Anthropic][DeepSeek][Google][Ollama]│
│ [📋 从主模型复制] [+ 自定义/自建] [更多服务商…]      │
└──────────────────────────────────────────────────────┘
```

## 关键变化

| 维度              | 旧                           | 新                                    |
|-------------------|------------------------------|---------------------------------------|
| 每行高度          | ~200px 卡片                  | ~36px 紧凑,点编辑才展开              |
| 添加方式          | 空白卡 6 字段填              | 点厂商 → 只填 apiKey + 选 model       |
| label 字段        | 用户手填                     | 去掉,自动用 model 显示               |
| enabled 开关      | 显式               | 去掉(删除即停用)+ 迁移旧禁用条目    |
| 主模型可见        | 无                           | 列表顶部显示完整调用链 (📌 主模型行) |
| 排序              | 隐式按数组顺序               | 显式 HTML5 drag-drop 拖拽手柄        |
| baseUrl/apiType   | 始终暴露                     | 折叠到"高级选项"(选 preset 后不用碰)|
| 快捷"从主模型复制"| 无                           | 有(解决"备用和主只想换个模型"场景)  |

## 实现细节

- 复用 `src/lib/model-presets.js` 的 `PROVIDER_PRESETS` 16 个厂商
- 主按钮区展示 6 个最常用(qtcool / openai / anthropic / deepseek
  / google / ollama),其余点"更多服务商…"展开
- 点厂商 → push draft 对象(含临时字段 `_editing`/`_brandLabel`),
  默认展开编辑态,autofocus 到 apiKey
- 保存时 .map 只挑 5 个字段(自动 strip 临时字段 + 不再写 enabled)
- 迁移:保存时过滤 `enabled === false` 的老条目,用户下次看到的
  都是启用状态(避免 UI 不暴露 enabled 导致的"隐形禁用"困惑)
- 主模型只读行实时跟随表单 `#ast-baseurl` / `#ast-model` 变化
- 每行折叠态的 model / hostname 在编辑态输入时实时更新(不重渲染
  避免输入框失焦)
- HTML5 drag-drop 拖拽排序(dragstart/dragover/drop),无第三方库

## i18n

新增 10 个翻译 key(至少覆盖 zh-CN / en / zh-TW / ja / ko / vi):
- fallbackPrimaryRow / fallbackPickProviderHint
- fallbackAddCopyPrimary / fallbackAddCustom / fallbackMoreProviders
- fallbackEditAdvanced / fallbackHideAdvanced / fallbackShowAdvanced
- fallbackUnnamedModel / fallbackPickProviderTitle

## 验证

- npm run build 通过
- assistant chunk 156.24KB → 162.02KB(gzip +1.5KB),合理
- 向后兼容:已存的 fallbackModels 数据(含 label/enabled 字段)
  可正常读取和显示,保存时会"隐式迁移"(去掉 enabled 字段,禁用
  条目被清理)

## 相关

- #Compat-3 系列(备用模型 failover)
- 用户反馈:"很复杂,很麻烦,整体重新设计下!"
This commit is contained in:
晴天
2026-04-20 13:31:16 +08:00
parent 7c63438c0e
commit b00c457c2b
2 changed files with 198 additions and 48 deletions

View File

@@ -163,6 +163,16 @@ export default {
'Noch keine Fallback-Modelle, klicken Sie unten auf Hinzufügen', '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'), 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'),
fallbackPrimaryRow: _('主模型(当前)', 'Primary Model (current)', '主模型(當前)', 'メインモデル(現在)', '기본 모델 (현재)', 'Mô hình chính (hiện tại)'),
fallbackPickProviderHint: _('选择服务商快速添加:', 'Pick a provider to quickly add:', '選擇服務商快速新增:', 'プロバイダーを選択して素早く追加:', '서비스 제공자를 선택하여 빠르게 추가:', 'Chọn nhà cung cấp để thêm nhanh:'),
fallbackAddCopyPrimary: _('从主模型复制', 'Copy from Primary', '從主模型複製', 'メインモデルからコピー', '기본 모델에서 복사', 'Sao chép từ mô hình chính'),
fallbackAddCustom: _('自定义 / 自建', 'Custom / Self-hosted', '自訂 / 自建', 'カスタム / セルフホスト', '사용자 정의 / 자체 호스팅', 'Tùy chỉnh / Tự host'),
fallbackMoreProviders: _('更多服务商…', 'More providers…', '更多服務商…', 'その他のプロバイダー…', '더 많은 제공자…', 'Nhiều nhà cung cấp hơn…'),
fallbackEditAdvanced: _('编辑', 'Edit', '編輯', '編集', '편집', 'Chỉnh sửa'),
fallbackHideAdvanced: _('收起', 'Collapse', '收合', '折りたたむ', '접기', 'Thu gọn'),
fallbackShowAdvanced: _('高级选项Base URL / API 类型)', 'Advanced (Base URL / API type)', '進階選項Base URL / API 類型)', '詳細Base URL / API タイプ)', '고급 (Base URL / API 유형)', 'Nâng cao (Base URL / Loại API)'),
fallbackUnnamedModel: _('未选模型', 'No model', '未選模型', 'モデル未選択', '모델 미선택', 'Chưa chọn mô hình'),
fallbackPickProviderTitle: _('所有服务商', 'All Providers', '所有服務商', 'すべてのプロバイダー', '모든 제공자', 'Tất cả nhà cung cấp'),
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'), 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'), 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)'), 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)'),

View File

@@ -3082,7 +3082,7 @@ function showSettings() {
</div> </div>
</div> </div>
<!-- #Compat-3: 备用模型组(可折叠 --> <!-- #Compat-3: 备用模型组(重设计:极简一行 + 厂商预设快捷添加 -->
<details class="ast-fallback-section" id="ast-fallback-section" ${(c.fallbackModels || []).length ? 'open' : ''} style="margin-top:14px"> <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"> <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"> <div style="display:flex;align-items:center;gap:8px;flex:1;min-width:0">
@@ -3093,8 +3093,20 @@ function showSettings() {
</summary> </summary>
<div style="padding:10px 4px 4px"> <div style="padding:10px 4px 4px">
<div class="form-hint" style="margin-bottom:10px">${t('assistant.fallbackModelsDesc')}</div> <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 id="ast-fallback-primary-row" style="display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--bg-tertiary);border:1px dashed var(--border-primary);border-radius:var(--radius-md);margin-bottom:6px;font-size:12px">
<span style="font-size:14px">📌</span>
<span style="color:var(--text-tertiary);white-space:nowrap">${t('assistant.fallbackPrimaryRow')}</span>
<span id="ast-fallback-primary-model" style="flex:1;min-width:0;font-family:var(--font-mono);color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
<span id="ast-fallback-primary-host" style="color:var(--text-tertiary);font-size:11px;white-space:nowrap"></span>
</div>
<!-- 备用列表 -->
<div id="ast-fallback-list" style="display:flex;flex-direction:column;gap:4px"></div>
<!-- 厂商预设快捷添加区 -->
<div id="ast-fallback-add-area" style="margin-top:12px;padding-top:10px;border-top:1px dashed var(--border-primary)">
<div class="form-hint" style="margin-bottom:6px">${t('assistant.fallbackPickProviderHint')}</div>
<div id="ast-fallback-presets" style="display:flex;flex-wrap:wrap;gap:6px"></div>
</div>
</div> </div>
</details> </details>
</div> </div>
@@ -3224,76 +3236,201 @@ function showSettings() {
const renderFallbackList = () => { const renderFallbackList = () => {
if (!fallbackListEl) return if (!fallbackListEl) return
if (fallbackDrafts.length === 0) { 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>` fallbackListEl.innerHTML = `<div class="form-hint" style="text-align:center;padding:12px 0;color:var(--text-tertiary);font-style:italic">${t('assistant.fallbackEmpty')}</div>`
updateFallbackCount() updateFallbackCount()
return return
} }
fallbackListEl.innerHTML = fallbackDrafts.map((fb, idx) => ` 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;' : ''}"> const expanded = fb._editing === true || (!fb.baseUrl && !fb.model)
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> const modelText = fb.model || t('assistant.fallbackUnnamedModel')
<span style="font-size:11px;color:var(--text-tertiary);font-weight:500">#${idx + 2}</span> const hostText = fb.baseUrl ? hostOf(fb.baseUrl) : ''
<input class="form-input ast-fb-label" placeholder="${t('assistant.fallbackLabelPlaceholder')}" value="${escHtml(fb.label || '')}" style="flex:1;font-size:12px;padding:4px 8px"> const brandText = fb._brandLabel ? `<span style="color:var(--primary);font-size:10px;padding:1px 5px;border:1px solid var(--primary);border-radius:3px;margin-right:4px">${escHtml(fb._brandLabel)}</span>` : ''
<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"> return `
<input type="checkbox" class="ast-fb-enabled" ${fb.enabled !== false ? 'checked' : ''} style="margin:0"> <div class="ast-fallback-row" data-fb-idx="${idx}" draggable="true" style="border:1px solid var(--border-primary);border-radius:var(--radius-md);background:var(--bg-secondary);transition:border-color 0.15s">
<span>${t('assistant.fallbackEnabled')}</span> <div style="display:flex;align-items:center;gap:8px;padding:6px 10px;user-select:none">
</label> <span class="ast-fb-handle" style="color:var(--text-tertiary);cursor:grab;font-size:13px;line-height:1" title="${t('assistant.dragHint')}">⋮⋮</span>
<button type="button" class="btn btn-xs btn-secondary ast-fb-remove" title="${t('assistant.fallbackRemove')}" style="padding:3px 8px;color:var(--error)">✕</button> <span style="color:var(--text-tertiary);font-size:11px;font-weight:500;min-width:22px">#${idx + 2}</span>
<div style="flex:1;min-width:0;display:flex;align-items:center;gap:6px;overflow:hidden">
${brandText}
<span style="font-family:var(--font-mono);font-size:12px;color:${fb.model ? 'var(--text-primary)' : 'var(--text-tertiary)'};overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(modelText)}</span>
${hostText ? `<span style="color:var(--text-tertiary);font-size:11px;white-space:nowrap">·&nbsp;${escHtml(hostText)}</span>` : ''}
</div> </div>
<div style="display:grid;grid-template-columns:1fr 120px;gap:6px;margin-bottom:6px"> <button type="button" class="btn btn-xs btn-ghost ast-fb-toggle" style="padding:2px 8px;font-size:11px;color:var(--text-secondary)">${expanded ? t('assistant.fallbackHideAdvanced') : t('assistant.fallbackEditAdvanced')}</button>
<button type="button" class="btn btn-xs btn-ghost ast-fb-remove" title="${t('assistant.fallbackRemove')}" style="padding:2px 6px;color:var(--error);font-size:12px">✕</button>
</div>
<div class="ast-fb-edit" style="display:${expanded ? 'block' : 'none'};padding:6px 10px 10px;border-top:1px dashed var(--border-primary)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom: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>
<details class="ast-fb-advanced" ${!fb.baseUrl || !API_TYPES.some(at => at.value === normalizeApiType(fb.apiType)) ? 'open' : ''} style="margin-top:4px">
<summary style="cursor:pointer;font-size:11px;color:var(--text-tertiary);padding:2px 0;list-style:none">▸ ${t('assistant.fallbackShowAdvanced')}</summary>
<div style="display:grid;grid-template-columns:1fr 140px;gap:6px;margin-top:6px">
<input class="form-input ast-fb-url" placeholder="${t('assistant.fallbackBaseUrlPlaceholder')}" value="${escHtml(fb.baseUrl || '')}" style="font-size:12px;padding:4px 8px"> <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"> <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('')} ${API_TYPES.map(at => `<option value="${at.value}" ${normalizeApiType(fb.apiType) === at.value ? 'selected' : ''}>${at.label}</option>`).join('')}
</select> </select>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px"> </details>
<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>
</div> </div>
`).join('') `}).join('')
// 绑定每张卡片的事件 // 绑定事件
fallbackListEl.querySelectorAll('.ast-fallback-card').forEach(card => { fallbackListEl.querySelectorAll('.ast-fallback-row').forEach(row => {
const idx = parseInt(card.dataset.fbIdx, 10) const idx = parseInt(row.dataset.fbIdx, 10)
const sync = () => { const sync = () => {
fallbackDrafts[idx] = { fallbackDrafts[idx] = {
...fallbackDrafts[idx], ...fallbackDrafts[idx],
label: card.querySelector('.ast-fb-label').value.trim(), baseUrl: (row.querySelector('.ast-fb-url')?.value || fallbackDrafts[idx].baseUrl || '').trim(),
baseUrl: card.querySelector('.ast-fb-url').value.trim(), apiKey: (row.querySelector('.ast-fb-key')?.value || '').trim(),
apiKey: card.querySelector('.ast-fb-key').value.trim(), model: (row.querySelector('.ast-fb-model')?.value || '').trim(),
model: card.querySelector('.ast-fb-model').value.trim(), apiType: normalizeApiType(row.querySelector('.ast-fb-apitype')?.value || fallbackDrafts[idx].apiType),
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() updateFallbackCount()
// 实时更新折叠态显示的 model / hostname
const headerModel = row.querySelector('div > span[style*="var(--font-mono)"]')
if (headerModel) {
const m = fallbackDrafts[idx].model
headerModel.textContent = m || t('assistant.fallbackUnnamedModel')
headerModel.style.color = m ? 'var(--text-primary)' : 'var(--text-tertiary)'
} }
card.querySelectorAll('.ast-fb-label, .ast-fb-url, .ast-fb-key, .ast-fb-model, .ast-fb-apitype, .ast-fb-enabled').forEach(el => { }
row.querySelectorAll('.ast-fb-url, .ast-fb-key, .ast-fb-model, .ast-fb-apitype').forEach(el => {
el.addEventListener('input', sync) el.addEventListener('input', sync)
el.addEventListener('change', sync) el.addEventListener('change', sync)
}) })
card.querySelector('.ast-fb-remove').onclick = () => { // 展开 / 收起
row.querySelector('.ast-fb-toggle').onclick = () => {
fallbackDrafts[idx]._editing = !(fallbackDrafts[idx]._editing === true || (!fallbackDrafts[idx].baseUrl && !fallbackDrafts[idx].model))
renderFallbackList()
}
// 删除
row.querySelector('.ast-fb-remove').onclick = () => {
fallbackDrafts.splice(idx, 1) fallbackDrafts.splice(idx, 1)
renderFallbackList() renderFallbackList()
} }
// HTML5 拖拽排序
row.ondragstart = (e) => {
e.dataTransfer.setData('text/plain', String(idx))
e.dataTransfer.effectAllowed = 'move'
row.style.opacity = '0.4'
}
row.ondragend = () => { row.style.opacity = '' }
row.ondragover = (e) => { e.preventDefault(); row.style.borderColor = 'var(--primary)' }
row.ondragleave = () => { row.style.borderColor = 'var(--border-primary)' }
row.ondrop = (e) => {
e.preventDefault()
row.style.borderColor = 'var(--border-primary)'
const from = parseInt(e.dataTransfer.getData('text/plain'), 10)
if (isNaN(from) || from === idx) return
const [moved] = fallbackDrafts.splice(from, 1)
fallbackDrafts.splice(idx, 0, moved)
renderFallbackList()
}
}) })
updateFallbackCount() updateFallbackCount()
} }
renderFallbackList() // 添加一个厂商预设备用(点击快捷按钮触发)
overlay.querySelector('#ast-fallback-add')?.addEventListener('click', () => { const addFallbackFromPreset = (preset) => {
// 默认从主模型推断 apiType便于快速配同类备用 fallbackDrafts.push({
const mainApi = overlay.querySelector('#ast-apitype')?.value || _config.apiType || 'openai-completions' baseUrl: preset.baseUrl,
apiKey: '',
model: '',
apiType: normalizeApiType(preset.api),
_editing: true,
_brandLabel: preset.label,
})
renderFallbackList()
// 聚焦到刚加的 apiKey 输入
setTimeout(() => {
const last = fallbackListEl.querySelector('.ast-fallback-row:last-child .ast-fb-key')
last?.focus()
}, 30)
}
// 从主模型复制
const addFallbackFromPrimary = () => {
const baseUrl = overlay.querySelector('#ast-baseurl')?.value?.trim() || ''
const apiKey = overlay.querySelector('#ast-apikey')?.value?.trim() || ''
const model = overlay.querySelector('#ast-model')?.value?.trim() || ''
const apiType = overlay.querySelector('#ast-apitype')?.value || 'openai-completions'
fallbackDrafts.push({
baseUrl,
apiKey,
model,
apiType: normalizeApiType(apiType),
_editing: true,
})
renderFallbackList()
setTimeout(() => {
const last = fallbackListEl.querySelector('.ast-fallback-row:last-child .ast-fb-model')
last?.focus()
last?.select()
}, 30)
}
// 渲染厂商预设按钮6 个最常用 + 从主模型复制 + 自定义 + 更多)
const TOP_PRESETS = ['qtcool', 'openai', 'anthropic', 'deepseek', 'google', 'ollama']
let showAllPresets = false
const renderPresetButtons = () => {
const shown = showAllPresets
? PROVIDER_PRESETS
: PROVIDER_PRESETS.filter(p => TOP_PRESETS.includes(p.key))
const presetBtnHtml = shown.map(p => `
<button type="button" class="btn btn-xs btn-secondary ast-fb-preset-btn" data-preset-key="${p.key}" style="padding:4px 10px;font-size:11px;gap:4px">
${p.badge ? `<span style="color:var(--warning);font-size:9px;margin-right:2px">★</span>` : ''}
${escHtml(p.label)}
</button>
`).join('')
const extraBtnHtml = `
<button type="button" class="btn btn-xs btn-ghost" id="ast-fb-copy-primary" style="padding:4px 10px;font-size:11px;border:1px dashed var(--primary);color:var(--primary)">
${icon('copy', 11)} ${t('assistant.fallbackAddCopyPrimary')}
</button>
<button type="button" class="btn btn-xs btn-ghost" id="ast-fb-custom" style="padding:4px 10px;font-size:11px;border:1px dashed var(--border-primary);color:var(--text-secondary)">
${icon('plus', 11)} ${t('assistant.fallbackAddCustom')}
</button>
${!showAllPresets ? `<button type="button" class="btn btn-xs btn-ghost" id="ast-fb-more" style="padding:4px 10px;font-size:11px;color:var(--text-tertiary)">${t('assistant.fallbackMoreProviders')}</button>` : ''}
`
fallbackPresetsEl.innerHTML = presetBtnHtml + extraBtnHtml
// 绑定每个预设按钮
fallbackPresetsEl.querySelectorAll('.ast-fb-preset-btn').forEach(btn => {
btn.onclick = () => {
const preset = PROVIDER_PRESETS.find(p => p.key === btn.dataset.presetKey)
if (preset) addFallbackFromPreset(preset)
}
})
// 从主模型复制
fallbackPresetsEl.querySelector('#ast-fb-copy-primary').onclick = addFallbackFromPrimary
// 自定义(空白)
fallbackPresetsEl.querySelector('#ast-fb-custom').onclick = () => {
const mainApi = overlay.querySelector('#ast-apitype')?.value || 'openai-completions'
fallbackDrafts.push({ fallbackDrafts.push({
label: '',
baseUrl: '', baseUrl: '',
apiKey: '', apiKey: '',
model: '', model: '',
apiType: normalizeApiType(mainApi), apiType: normalizeApiType(mainApi),
enabled: true, _editing: true,
}) })
renderFallbackList() renderFallbackList()
setTimeout(() => {
const last = fallbackListEl.querySelector('.ast-fallback-row:last-child .ast-fb-key')
last?.focus()
}, 30)
}
// 更多服务商(展开全部)
fallbackPresetsEl.querySelector('#ast-fb-more')?.addEventListener('click', () => {
showAllPresets = true
renderPresetButtons()
}) })
}
// 主模型表单任一字段变更时,同步主模型只读行
;['#ast-baseurl', '#ast-model', '#ast-apikey', '#ast-apitype'].forEach(sel => {
overlay.querySelector(sel)?.addEventListener('input', renderPrimaryRow)
overlay.querySelector(sel)?.addEventListener('change', renderPrimaryRow)
})
renderPrimaryRow()
renderFallbackList()
renderPresetButtons()
// Tab 切换 // Tab 切换
overlay.querySelectorAll('.ast-tab').forEach(tab => { overlay.querySelectorAll('.ast-tab').forEach(tab => {
@@ -3988,16 +4125,19 @@ function showSettings() {
} }
// 知识库 // 知识库
_config.knowledgeFiles = kbFiles _config.knowledgeFiles = kbFiles
// #Compat-3: 备用模型组(过滤空卡 // #Compat-3: 备用模型组(过滤空卡 + 迁移清理老的禁用条目
// 新 UI 不再暴露 enabled 开关(删除即停用),保存时:
// - 过滤掉空卡baseUrl 或 model 为空)
// - 过滤掉老数据里 enabled===false 的条目(隐式迁移,用户以后看到的都是启用状态)
// - strip 掉 _editing / _brandLabel 等临时字段(只挑 5 个字段 map 出来)
_config.fallbackModels = fallbackDrafts _config.fallbackModels = fallbackDrafts
.filter(f => f && f.baseUrl && f.model) .filter(f => f && f.baseUrl && f.model && f.enabled !== false)
.map(f => ({ .map(f => ({
label: f.label || f.model, label: f.label || f.model,
baseUrl: f.baseUrl, baseUrl: f.baseUrl,
apiKey: f.apiKey || '', apiKey: f.apiKey || '',
model: f.model, model: f.model,
apiType: normalizeApiType(f.apiType), apiType: normalizeApiType(f.apiType),
enabled: f.enabled !== false,
})) }))
saveConfig() saveConfig()
overlay.remove() overlay.remove()