From b00c457c2b32e52dce88c8ea59c5b13874956448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Mon, 20 Apr 2026 13:31:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(assistant):=20=E5=A4=87=E7=94=A8?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=20UI=20=E9=87=8D=E8=AE=BE=E8=AE=A1=20-=20?= =?UTF-8?q?=E5=8E=82=E5=95=86=E9=A2=84=E8=AE=BE=E5=BF=AB=E6=8D=B7=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20+=20=E6=9E=81=E7=AE=80=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 用户反馈 "晴辰助手的备用模型配置,很复杂,很麻烦" —— 旧 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) - 用户反馈:"很复杂,很麻烦,整体重新设计下!" --- src/locales/modules/assistant.js | 10 ++ src/pages/assistant.js | 236 ++++++++++++++++++++++++------- 2 files changed, 198 insertions(+), 48 deletions(-) diff --git a/src/locales/modules/assistant.js b/src/locales/modules/assistant.js index 8d98e76..7527a18 100644 --- a/src/locales/modules/assistant.js +++ b/src/locales/modules/assistant.js @@ -163,6 +163,16 @@ export default { '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'), + 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'), 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)'), diff --git a/src/pages/assistant.js b/src/pages/assistant.js index 52c0f99..36bcf71 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -3082,7 +3082,7 @@ function showSettings() { - +
@@ -3093,8 +3093,20 @@ function showSettings() {
${t('assistant.fallbackModelsDesc')}
-
- + +
+ 📌 + ${t('assistant.fallbackPrimaryRow')} + + +
+ +
+ +
+
${t('assistant.fallbackPickProviderHint')}
+
+
@@ -3224,76 +3236,201 @@ function showSettings() { const renderFallbackList = () => { if (!fallbackListEl) return if (fallbackDrafts.length === 0) { - fallbackListEl.innerHTML = `
${t('assistant.fallbackEmpty')}
` + fallbackListEl.innerHTML = `
${t('assistant.fallbackEmpty')}
` updateFallbackCount() return } - fallbackListEl.innerHTML = fallbackDrafts.map((fb, idx) => ` -
-
- #${idx + 2} - - - + fallbackListEl.innerHTML = fallbackDrafts.map((fb, idx) => { + const expanded = fb._editing === true || (!fb.baseUrl && !fb.model) + const modelText = fb.model || t('assistant.fallbackUnnamedModel') + const hostText = fb.baseUrl ? hostOf(fb.baseUrl) : '' + const brandText = fb._brandLabel ? `${escHtml(fb._brandLabel)}` : '' + return ` +
+
+ ⋮⋮ + #${idx + 2} +
+ ${brandText} + ${escHtml(modelText)} + ${hostText ? `· ${escHtml(hostText)}` : ''} +
+ +
-
- - -
-
- - +
+
+ + +
+
at.value === normalizeApiType(fb.apiType)) ? 'open' : ''} style="margin-top:4px"> + ▸ ${t('assistant.fallbackShowAdvanced')} +
+ + +
+
- `).join('') + `}).join('') - // 绑定每张卡片的事件 - fallbackListEl.querySelectorAll('.ast-fallback-card').forEach(card => { - const idx = parseInt(card.dataset.fbIdx, 10) + // 绑定事件 + fallbackListEl.querySelectorAll('.ast-fallback-row').forEach(row => { + const idx = parseInt(row.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, + baseUrl: (row.querySelector('.ast-fb-url')?.value || fallbackDrafts[idx].baseUrl || '').trim(), + apiKey: (row.querySelector('.ast-fb-key')?.value || '').trim(), + model: (row.querySelector('.ast-fb-model')?.value || '').trim(), + apiType: normalizeApiType(row.querySelector('.ast-fb-apitype')?.value || fallbackDrafts[idx].apiType), } - // 仅更新透明度和计数,不重渲染(避免输入框丢焦点) - card.style.opacity = fallbackDrafts[idx].enabled === false ? '0.55' : '1' 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('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) 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() } - renderFallbackList() - overlay.querySelector('#ast-fallback-add')?.addEventListener('click', () => { - // 默认从主模型推断 apiType,便于快速配同类备用 - const mainApi = overlay.querySelector('#ast-apitype')?.value || _config.apiType || 'openai-completions' + // 添加一个厂商预设备用(点击快捷按钮触发) + const addFallbackFromPreset = (preset) => { fallbackDrafts.push({ - label: '', - baseUrl: '', + baseUrl: preset.baseUrl, apiKey: '', model: '', - apiType: normalizeApiType(mainApi), - enabled: true, + 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 => ` + + `).join('') + const extraBtnHtml = ` + + + ${!showAllPresets ? `` : ''} + ` + 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({ + baseUrl: '', + apiKey: '', + model: '', + apiType: normalizeApiType(mainApi), + _editing: true, + }) + 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 切换 overlay.querySelectorAll('.ast-tab').forEach(tab => { @@ -3988,16 +4125,19 @@ function showSettings() { } // 知识库 _config.knowledgeFiles = kbFiles - // #Compat-3: 备用模型组(过滤掉空卡片) + // #Compat-3: 备用模型组(过滤空卡 + 迁移清理老的禁用条目) + // 新 UI 不再暴露 enabled 开关(删除即停用),保存时: + // - 过滤掉空卡(baseUrl 或 model 为空) + // - 过滤掉老数据里 enabled===false 的条目(隐式迁移,用户以后看到的都是启用状态) + // - strip 掉 _editing / _brandLabel 等临时字段(只挑 5 个字段 map 出来) _config.fallbackModels = fallbackDrafts - .filter(f => f && f.baseUrl && f.model) + .filter(f => f && f.baseUrl && f.model && f.enabled !== false) .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()