/** * 模型配置页面 * 服务商管理 + 模型增删改查 + 主模型选择 */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { showModal, showConfirm } from '../components/modal.js' // API 接口类型选项 const API_TYPES = [ { value: 'openai-completions', label: 'OpenAI 兼容 (最常用)' }, { value: 'anthropic', label: 'Anthropic 原生' }, { value: 'openai-responses', label: 'OpenAI Responses' }, { value: 'google-gemini', label: 'Google Gemini' }, ] // 服务商快捷预设 const PROVIDER_PRESETS = [ { key: 'openai', label: 'OpenAI 官方', baseUrl: 'https://api.openai.com/v1', api: 'openai-completions' }, { key: 'anthropic', label: 'Anthropic 官方', baseUrl: 'https://api.anthropic.com', api: 'anthropic' }, { key: 'deepseek', label: 'DeepSeek', baseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' }, { key: 'google', label: 'Google Gemini', baseUrl: 'https://generativelanguage.googleapis.com/v1beta', api: 'google-gemini' }, ] // 常用模型预设(按服务商分组) const MODEL_PRESETS = { openai: [ { id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 }, { id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000 }, { id: 'o3-mini', name: 'o3 Mini', contextWindow: 200000, reasoning: true }, ], anthropic: [ { id: 'claude-sonnet-4-5-20250514', name: 'Claude Sonnet 4.5', contextWindow: 200000 }, { id: 'claude-haiku-3-5-20241022', name: 'Claude Haiku 3.5', contextWindow: 200000 }, ], deepseek: [ { id: 'deepseek-chat', name: 'DeepSeek V3', contextWindow: 64000 }, { id: 'deepseek-reasoner', name: 'DeepSeek R1', contextWindow: 64000, reasoning: true }, ], google: [ { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', contextWindow: 1000000, reasoning: true }, { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', contextWindow: 1000000 }, ], } export async function render() { const page = document.createElement('div') page.className = 'page' page.innerHTML = `
服务商是模型的来源(如 OpenAI、DeepSeek 等)。每个服务商下可添加多个模型。 标记为「主模型」的将优先使用,其余作为备选自动切换。配置修改后自动保存。
` const state = { config: null, search: '', undoStack: [] } // 非阻塞:先返回 DOM,后台加载数据 loadConfig(page, state) bindTopActions(page, state) // 搜索框实时过滤 page.querySelector('#model-search').oninput = (e) => { state.search = e.target.value.trim().toLowerCase() renderProviders(page, state) } return page } async function loadConfig(page, state) { const listEl = page.querySelector('#providers-list') try { state.config = await api.readOpenclawConfig() renderDefaultBar(page, state) renderProviders(page, state) } catch (e) { listEl.innerHTML = '
加载配置失败: ' + e + '
' toast('加载配置失败: ' + e, 'error') } } function getCurrentPrimary(config) { return config?.agents?.defaults?.model?.primary || '' } function collectAllModels(config) { const result = [] const providers = config?.models?.providers || {} for (const [pk, pv] of Object.entries(providers)) { for (const m of (pv.models || [])) { const id = typeof m === 'string' ? m : m.id if (id) result.push({ provider: pk, modelId: id, full: `${pk}/${id}` }) } } return result } function getApiTypeLabel(apiType) { return API_TYPES.find(t => t.value === apiType)?.label || apiType || '未知' } // 渲染当前主模型状态栏 function renderDefaultBar(page, state) { const bar = page.querySelector('#default-model-bar') const primary = getCurrentPrimary(state.config) const allModels = collectAllModels(state.config) const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full) bar.innerHTML = `
当前生效配置
主模型: ${primary || '未配置'}
备选模型: ${fallbacks.length ? fallbacks.join(', ') : '无'}
主模型不可用时,系统会自动切换到备选模型
` } // 排序模型列表 function sortModels(models, sortBy) { if (!sortBy || sortBy === 'default') return models const sorted = [...models] switch (sortBy) { case 'name-asc': sorted.sort((a, b) => { const nameA = (a.name || a.id || '').toLowerCase() const nameB = (b.name || b.id || '').toLowerCase() return nameA.localeCompare(nameB) }) break case 'name-desc': sorted.sort((a, b) => { const nameA = (a.name || a.id || '').toLowerCase() const nameB = (b.name || b.id || '').toLowerCase() return nameB.localeCompare(nameA) }) break case 'latency-asc': sorted.sort((a, b) => { const latA = a.latency ?? Infinity const latB = b.latency ?? Infinity return latA - latB }) break case 'latency-desc': sorted.sort((a, b) => { const latA = a.latency ?? -1 const latB = b.latency ?? -1 return latB - latA }) break case 'context-asc': sorted.sort((a, b) => { const ctxA = a.contextWindow ?? 0 const ctxB = b.contextWindow ?? 0 return ctxA - ctxB }) break case 'context-desc': sorted.sort((a, b) => { const ctxA = a.contextWindow ?? 0 const ctxB = b.contextWindow ?? 0 return ctxB - ctxA }) break } return sorted } // 渲染服务商列表(渲染完后直接绑定事件) function renderProviders(page, state) { const listEl = page.querySelector('#providers-list') const providers = state.config?.models?.providers || {} const keys = Object.keys(providers) const primary = getCurrentPrimary(state.config) const search = state.search || '' const sortBy = state.sortBy || 'default' if (!keys.length) { listEl.innerHTML = `
暂无服务商,点击「+ 添加服务商」开始配置
` return } listEl.innerHTML = keys.map(key => { const p = providers[key] const models = p.models || [] const filtered = search ? models.filter((m) => { const id = (typeof m === 'string' ? m : m.id).toLowerCase() const name = (m.name || '').toLowerCase() return id.includes(search) || name.includes(search) }) : models const sorted = sortModels(filtered, sortBy) const hiddenCount = models.length - sorted.length return `
${key} ${getApiTypeLabel(p.api)} · ${models.length} 个模型
${models.length >= 2 ? `
排序:
` : ''}
${renderModelCards(key, sorted, primary, search)} ${hiddenCount > 0 ? `
已隐藏 ${hiddenCount} 个不匹配的模型
` : ''}
` }).join('') // innerHTML 完成后,直接给每个按钮绑定 onclick bindProviderButtons(listEl, page, state) } // 渲染模型卡片(支持搜索高亮和批量选择 checkbox) function renderModelCards(providerKey, models, primary, search) { if (!models.length) { return '
暂无模型,点击「+ 模型」添加
' } return models.map((m) => { const id = typeof m === 'string' ? m : m.id const name = m.name || id const full = `${providerKey}/${id}` const isPrimary = full === primary const borderColor = isPrimary ? 'var(--success)' : 'var(--border-primary)' const bgColor = isPrimary ? 'var(--success-muted)' : 'var(--bg-tertiary)' const meta = [] if (name !== id) meta.push(name) if (m.contextWindow) meta.push((m.contextWindow / 1000) + 'K 上下文') // 测试状态标签:成功显示耗时,失败显示不可用 let latencyTag = '' if (m.testStatus === 'fail') { latencyTag = `不可用` } else if (m.latency != null) { const color = m.latency < 3000 ? 'success' : m.latency < 8000 ? 'warning' : 'error' const bg = color === 'success' ? 'var(--success-muted)' : color === 'warning' ? 'var(--warning-muted, #fef3c7)' : 'var(--error-muted, #fee2e2)' const fg = color === 'success' ? 'var(--success)' : color === 'warning' ? 'var(--warning, #d97706)' : 'var(--error)' latencyTag = `${(m.latency / 1000).toFixed(1)}s` } const testTime = m.lastTestAt ? formatTestTime(m.lastTestAt) : '' if (testTime) meta.push(testTime) return `
⋮⋮
${id} ${isPrimary ? '主模型' : ''} ${m.reasoning ? '推理' : ''} ${latencyTag}
${meta.join(' · ') || ''}
${!isPrimary ? '' : ''}
` }).join('') } // 格式化测试时间为相对时间 function formatTestTime(ts) { const diff = Date.now() - ts if (diff < 60000) return '刚刚测试' if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前测试` if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前测试` return `${Math.floor(diff / 86400000)} 天前测试` } // 根据 model-id 找到原始 index function findModelIdx(provider, modelId) { return (provider.models || []).findIndex(m => (typeof m === 'string' ? m : m.id) === modelId) } // ===== 自动保存 + 撤销机制 ===== // 保存快照到撤销栈(变更前调用) function pushUndo(state) { state.undoStack.push(JSON.parse(JSON.stringify(state.config))) if (state.undoStack.length > 20) state.undoStack.shift() } // 撤销上一步 async function undo(page, state) { if (!state.undoStack.length) return state.config = state.undoStack.pop() renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) await doAutoSave(state) toast('已撤销', 'info') } // 自动保存(防抖 300ms) let _saveTimer = null let _batchTestAbort = null // 批量测试终止控制器 export function cleanup() { clearTimeout(_saveTimer) _saveTimer = null if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null } } function autoSave(state) { clearTimeout(_saveTimer) _saveTimer = setTimeout(() => doAutoSave(state), 300) } async function doAutoSave(state) { try { const primary = getCurrentPrimary(state.config) if (primary) applyDefaultModel(state) await api.writeOpenclawConfig(state.config) // 提示用户需要重启 Gateway const restartBtn = document.createElement('button') restartBtn.className = 'btn btn-sm btn-primary' restartBtn.textContent = '立即重启' restartBtn.style.marginLeft = '8px' restartBtn.onclick = async () => { try { toast('正在重启 Gateway...', 'info') await api.restartGateway() toast('Gateway 重启成功', 'success') } catch (e) { toast('重启失败: ' + e.message, 'error') } } toast('配置已保存,需要重启 Gateway 生效', 'warning', { action: restartBtn }) } catch (e) { toast('自动保存失败: ' + e, 'error') } } // 更新撤销按钮状态 function updateUndoBtn(page, state) { const btn = page.querySelector('#btn-undo') if (!btn) return const n = state.undoStack.length btn.disabled = !n btn.textContent = n ? `↩ 撤销 (${n})` : '↩ 撤销' } // 渲染完成后,直接给每个 [data-action] 按钮绑定 onclick function bindProviderButtons(listEl, page, state) { // 绑定排序下拉框 listEl.querySelectorAll('select[data-action="sort-models"]').forEach(select => { select.onchange = (e) => { const val = e.target.value const section = select.closest('[data-provider]') if (!section) return const providerKey = section.dataset.provider const provider = state.config.models.providers[providerKey] if (val === 'default') { state.sortBy = 'default' renderProviders(page, state) } else { // 将排序固化到底层数据并保存 pushUndo(state) provider.models = sortModels(provider.models, val) // 恢复下拉框显示 "默认顺序",因为新顺序已经变成了默认顺序 state.sortBy = 'default' renderProviders(page, state) autoSave(state) toast('排序已保存', 'success') } } }) // 绑定拖拽排序 (Drag & Drop) listEl.querySelectorAll('.provider-models').forEach(container => { let dragged = null container.addEventListener('dragstart', e => { dragged = e.target.closest('.model-card') if (dragged) { dragged.style.opacity = '0.5' e.dataTransfer.effectAllowed = 'move' } }) container.addEventListener('dragend', e => { if (dragged) { dragged.style.opacity = '1' dragged = null } }) container.addEventListener('dragover', e => { e.preventDefault() const targetCard = e.target.closest('.model-card') if (dragged && targetCard && dragged !== targetCard) { const bounding = targetCard.getBoundingClientRect() const offset = bounding.y + bounding.height / 2 if (e.clientY > offset) { targetCard.after(dragged) } else { targetCard.before(dragged) } } }) container.addEventListener('drop', e => { e.preventDefault() if (!dragged) return const section = container.closest('[data-provider]') if (!section) return const providerKey = section.dataset.provider const provider = state.config.models.providers[providerKey] if (!provider) return // 获取新的顺序 const newOrderIds = [...container.querySelectorAll('.model-card')].map(c => c.dataset.modelId) pushUndo(state) const oldModels = [...provider.models] provider.models = newOrderIds.map(id => oldModels.find(m => (typeof m === 'string' ? m : m.id) === id)) // 更新状态不重新渲染以保持列表稳定 autoSave(state) }) }) // 绑定按钮 listEl.querySelectorAll('button[data-action], input[data-action]').forEach(btn => { const action = btn.dataset.action const section = btn.closest('[data-provider]') if (!section) return const providerKey = section.dataset.provider const provider = state.config.models.providers[providerKey] if (!provider) return const card = btn.closest('.model-card') // checkbox 改变时不需要阻止冒泡,由 handleAction 内部处理 if (btn.type === 'checkbox') { btn.onchange = (e) => { handleAction(action, btn, card, section, providerKey, provider, page, state) } } else { btn.onclick = (e) => { e.stopPropagation() handleAction(action, btn, card, section, providerKey, provider, page, state) } } }) } // 统一处理按钮动作 async function handleAction(action, btn, card, section, providerKey, provider, page, state) { switch (action) { case 'edit-provider': editProvider(page, state, providerKey) break case 'add-model': addModel(page, state, providerKey) break case 'fetch-models': fetchRemoteModels(btn, page, state, providerKey) break case 'delete-provider': { const yes = await showConfirm(`确定删除「${providerKey}」及其所有模型?`) if (!yes) return pushUndo(state) delete state.config.models.providers[providerKey] renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) toast(`已删除 ${providerKey}`, 'info') break } case 'select-all': handleSelectAll(section) break case 'batch-delete': handleBatchDelete(section, page, state, providerKey) break case 'batch-test': handleBatchTest(section, state, providerKey) break case 'delete-model': { if (!card) return const modelId = card.dataset.modelId const yes = await showConfirm(`确定删除模型「${modelId}」?`) if (!yes) return pushUndo(state) const idx = findModelIdx(provider, modelId) if (idx >= 0) provider.models.splice(idx, 1) renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) toast(`已删除 ${modelId}`, 'info') break } case 'edit-model': { if (!card) return const idx = findModelIdx(provider, card.dataset.modelId) if (idx >= 0) editModel(page, state, providerKey, idx) break } case 'set-primary': { if (!card) return pushUndo(state) setPrimary(state, card.dataset.full) renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) toast('已设为主模型', 'success') break } case 'test-model': { if (!card) return const idx = findModelIdx(provider, card.dataset.modelId) if (idx >= 0) testModel(btn, state, providerKey, idx) break } } } // 设置主模型(仅修改 state,不写入文件) function setPrimary(state, full) { if (!state.config.agents) state.config.agents = {} if (!state.config.agents.defaults) state.config.agents.defaults = {} if (!state.config.agents.defaults.model) state.config.agents.defaults.model = {} state.config.agents.defaults.model.primary = full } // 应用默认模型:primary + 其余自动成为备选 function applyDefaultModel(state) { const primary = getCurrentPrimary(state.config) const allModels = collectAllModels(state.config) const fallbacks = allModels.filter(m => m.full !== primary).map(m => m.full) const defaults = state.config.agents.defaults defaults.model.primary = primary defaults.model.fallbacks = fallbacks const modelsMap = {} modelsMap[primary] = {} for (const fb of fallbacks) modelsMap[fb] = {} defaults.models = modelsMap } // 顶部按钮事件 function bindTopActions(page, state) { page.querySelector('#btn-add-provider').onclick = () => addProvider(page, state) page.querySelector('#btn-undo').onclick = () => undo(page, state) } // 添加服务商(带预设快捷选择) function addProvider(page, state) { // 构建预设按钮 HTML const presetsHtml = PROVIDER_PRESETS.map(p => `` ).join('') const overlay = document.createElement('div') overlay.className = 'modal-overlay' overlay.innerHTML = ` ` document.body.appendChild(overlay) // 预设按钮点击自动填充 overlay.querySelectorAll('.preset-btn').forEach(btn => { btn.onclick = () => { const preset = PROVIDER_PRESETS.find(p => p.key === btn.dataset.preset) if (!preset) return overlay.querySelector('[data-name="key"]').value = preset.key overlay.querySelector('[data-name="baseUrl"]').value = preset.baseUrl overlay.querySelector('[data-name="api"]').value = preset.api // 高亮选中的预设 overlay.querySelectorAll('.preset-btn').forEach(b => b.style.opacity = '0.5') btn.style.opacity = '1' } }) overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() overlay.querySelector('[data-action="confirm"]').onclick = () => { const key = overlay.querySelector('[data-name="key"]').value.trim() const baseUrl = overlay.querySelector('[data-name="baseUrl"]').value.trim() const apiKey = overlay.querySelector('[data-name="apiKey"]').value.trim() const apiType = overlay.querySelector('[data-name="api"]').value if (!key) { toast('请填写服务商名称', 'warning'); return } pushUndo(state) if (!state.config.models) state.config.models = { mode: 'replace', providers: {} } if (!state.config.models.providers) state.config.models.providers = {} state.config.models.providers[key] = { baseUrl: baseUrl || '', apiKey: apiKey || '', api: apiType, models: [], } overlay.remove() renderProviders(page, state) updateUndoBtn(page, state) autoSave(state) toast(`已添加服务商: ${key}`, 'success') } overlay.querySelector('[data-name="key"]')?.focus() } // 编辑服务商 function editProvider(page, state, providerKey) { const p = state.config.models.providers[providerKey] showModal({ title: `编辑服务商: ${providerKey}`, fields: [ { name: 'baseUrl', label: '接口地址', value: p.baseUrl || '', hint: '模型服务的 API 地址,通常以 /v1 结尾' }, { name: 'apiKey', label: '密钥 (API Key)', value: p.apiKey || '', hint: '修改后自动保存生效' }, { name: 'api', label: '接口类型', type: 'select', value: p.api || 'openai-completions', options: API_TYPES, hint: '大多数中转站选「OpenAI 兼容」即可', }, ], onConfirm: ({ baseUrl, apiKey, api: apiType }) => { pushUndo(state) p.baseUrl = baseUrl p.apiKey = apiKey p.api = apiType renderProviders(page, state) updateUndoBtn(page, state) autoSave(state) toast('服务商已更新', 'success') }, }) } // 添加模型(带预设快捷选择) function addModel(page, state, providerKey) { const presets = MODEL_PRESETS[providerKey] || [] const existingIds = (state.config.models.providers[providerKey].models || []) .map(m => typeof m === 'string' ? m : m.id) // 过滤掉已添加的模型 const available = presets.filter(p => !existingIds.includes(p.id)) const fields = [ { name: 'id', label: '模型 ID', placeholder: '如 gpt-4o', hint: '必须与服务商支持的模型名一致' }, { name: 'name', label: '显示名称(选填)', placeholder: '如 GPT-4o', hint: '方便识别的友好名称' }, { name: 'contextWindow', label: '上下文长度(选填)', placeholder: '如 128000', hint: '模型支持的最大 Token 数' }, { name: 'reasoning', label: '这是推理模型(如 o3、R1、QwQ 等)', type: 'checkbox', value: false, hint: '推理模型会使用特殊的调用方式' }, ] if (available.length) { // 有预设可用,构建自定义弹窗 const overlay = document.createElement('div') overlay.className = 'modal-overlay' const presetBtns = available.map(p => `` ).join('') overlay.innerHTML = ` ` document.body.appendChild(overlay) bindModalEvents(overlay, fields, (vals) => { pushUndo(state) doAddModel(state, providerKey, vals) renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) }) // 预设按钮:点击直接添加 overlay.querySelectorAll('.preset-btn').forEach(btn => { btn.onclick = () => { const preset = available.find(p => p.id === btn.dataset.mid) if (!preset) return pushUndo(state) const model = { ...preset, input: ['text', 'image'] } state.config.models.providers[providerKey].models.push(model) overlay.remove() renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) toast(`已添加模型: ${preset.name}`, 'success') } }) } else { // 无预设,直接弹普通 modal showModal({ title: `添加模型到 ${providerKey}`, fields, onConfirm: (vals) => { pushUndo(state) doAddModel(state, providerKey, vals) renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) }, }) } } // 构建表单字段 HTML(用于自定义弹窗) function buildFieldsHtml(fields) { return fields.map(f => { if (f.type === 'checkbox') { return `
${f.hint ? `
${f.hint}
` : ''}
` } return `
${f.hint ? `
${f.hint}
` : ''}
` }).join('') } // 绑定自定义弹窗的通用事件 function bindModalEvents(overlay, fields, onConfirm) { overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() overlay.querySelector('[data-action="confirm"]').onclick = () => { const result = {} overlay.querySelectorAll('[data-name]').forEach(el => { result[el.dataset.name] = el.type === 'checkbox' ? el.checked : el.value }) overlay.remove() onConfirm(result) } } // 实际添加模型到 state function doAddModel(state, providerKey, vals) { if (!vals.id) { toast('请填写模型 ID', 'warning'); return } const model = { id: vals.id.trim(), name: vals.name?.trim() || vals.id.trim(), reasoning: !!vals.reasoning, input: ['text', 'image'], } if (vals.contextWindow) model.contextWindow = parseInt(vals.contextWindow) || 0 state.config.models.providers[providerKey].models.push(model) toast(`已添加模型: ${model.name}`, 'success') } // 编辑模型 function editModel(page, state, providerKey, idx) { const m = state.config.models.providers[providerKey].models[idx] showModal({ title: `编辑模型: ${m.id}`, fields: [ { name: 'id', label: '模型 ID', value: m.id || '', hint: '必须与服务商支持的模型名一致' }, { name: 'name', label: '显示名称', value: m.name || '', hint: '方便识别的友好名称' }, { name: 'contextWindow', label: '上下文长度', value: String(m.contextWindow || ''), hint: '模型支持的最大 Token 数' }, { name: 'reasoning', label: '这是推理模型', type: 'checkbox', value: !!m.reasoning, hint: '推理模型会使用特殊的调用方式' }, ], onConfirm: (vals) => { if (!vals.id) return pushUndo(state) m.id = vals.id.trim() m.name = vals.name?.trim() || vals.id.trim() m.reasoning = !!vals.reasoning if (vals.contextWindow) m.contextWindow = parseInt(vals.contextWindow) || 0 renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) toast('模型已更新', 'success') }, }) } // 全选/取消全选 function handleSelectAll(section) { const boxes = section.querySelectorAll('.model-checkbox') const allChecked = [...boxes].every(cb => cb.checked) boxes.forEach(cb => { cb.checked = !allChecked }) // 更新批量删除按钮状态 const batchDelBtn = section.querySelector('[data-action="batch-delete"]') if (batchDelBtn) batchDelBtn.disabled = allChecked } // 批量删除选中的模型 async function handleBatchDelete(section, page, state, providerKey) { const checked = [...section.querySelectorAll('.model-checkbox:checked')] if (!checked.length) { toast('请先勾选要删除的模型', 'warning'); return } const ids = checked.map(cb => cb.dataset.modelId) const yes = await showConfirm(`确定删除选中的 ${ids.length} 个模型?\n${ids.join(', ')}`) if (!yes) return pushUndo(state) const provider = state.config.models.providers[providerKey] provider.models = (provider.models || []).filter(m => { const mid = typeof m === 'string' ? m : m.id return !ids.includes(mid) }) renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) toast(`已删除 ${ids.length} 个模型`, 'info') } // 批量测试:勾选的模型,没勾选则测试全部(记录耗时和状态) async function handleBatchTest(section, state, providerKey) { // 如果正在测试,点击则终止 if (_batchTestAbort) { _batchTestAbort.abort = true toast('正在终止批量测试...', 'warning') return } const provider = state.config.models.providers[providerKey] const checked = [...section.querySelectorAll('.model-checkbox:checked')] const ids = checked.length ? checked.map(cb => cb.dataset.modelId) : (provider.models || []).map(m => typeof m === 'string' ? m : m.id) if (!ids.length) { toast('没有可测试的模型', 'warning'); return } const batchBtn = section.querySelector('[data-action="batch-test"]') const ctrl = { abort: false } _batchTestAbort = ctrl if (batchBtn) { batchBtn.textContent = '终止测试' batchBtn.classList.remove('btn-secondary') batchBtn.classList.add('btn-danger') } const page = section.closest('.page') let ok = 0, fail = 0 for (const modelId of ids) { if (ctrl.abort) break const model = (provider.models || []).find(m => (typeof m === 'string' ? m : m.id) === modelId) // 标记当前正在测试的卡片 const card = section.querySelector(`.model-card[data-model-id="${modelId}"]`) if (card) card.style.outline = '2px solid var(--accent)' const start = Date.now() try { await api.testModel(provider.baseUrl, provider.apiKey || '', modelId) const elapsed = Date.now() - start if (model && typeof model === 'object') { model.latency = elapsed model.lastTestAt = Date.now() model.testStatus = 'ok' delete model.testError } ok++ } catch (e) { const elapsed = Date.now() - start if (model && typeof model === 'object') { model.latency = null model.lastTestAt = Date.now() model.testStatus = 'fail' model.testError = String(e).slice(0, 100) } fail++ } // 每测完一个实时刷新卡片 if (page) { renderProviders(page, state) renderDefaultBar(page, state) } // 进度 toast const status = model?.testStatus === 'ok' ? '✓' : '✗' const latStr = model?.latency != null ? ` ${(model.latency / 1000).toFixed(1)}s` : '' toast(`${status} ${modelId}${latStr} (${ok + fail}/${ids.length})`, model?.testStatus === 'ok' ? 'success' : 'error') } // 恢复按钮 _batchTestAbort = null // 重新查找按钮(renderProviders 后 DOM 已更新) const newSection = page?.querySelector(`[data-provider="${providerKey}"]`) const newBtn = newSection?.querySelector('[data-action="batch-test"]') if (newBtn) { newBtn.textContent = '批量测试' newBtn.classList.remove('btn-danger') newBtn.classList.add('btn-secondary') } const aborted = ctrl.abort autoSave(state) if (aborted) { toast(`批量测试已终止:${ok} 成功,${fail} 失败,${ids.length - ok - fail} 跳过`, 'warning') } else { toast(`批量测试完成:${ok} 成功,${fail} 失败`, ok === ids.length ? 'success' : 'warning') } } // 从服务商远程获取模型列表 async function fetchRemoteModels(btn, page, state, providerKey) { const provider = state.config.models.providers[providerKey] btn.disabled = true btn.textContent = '获取中...' try { const remoteIds = await api.listRemoteModels(provider.baseUrl, provider.apiKey || '') btn.disabled = false btn.textContent = '获取列表' // 标记已添加的模型 const existingIds = (provider.models || []).map(m => typeof m === 'string' ? m : m.id) // 弹窗展示可选模型列表 const overlay = document.createElement('div') overlay.className = 'modal-overlay' overlay.innerHTML = ` ` document.body.appendChild(overlay) const listEl = overlay.querySelector('#remote-model-list') const filterInput = overlay.querySelector('#remote-filter') const countEl = overlay.querySelector('#remote-selected-count') function renderRemoteList(filter) { const filtered = filter ? remoteIds.filter(id => id.toLowerCase().includes(filter.toLowerCase())) : remoteIds listEl.innerHTML = filtered.map(id => { const exists = existingIds.includes(id) return ` ` }).join('') updateCount() } function updateCount() { const n = listEl.querySelectorAll('.remote-cb:checked').length countEl.textContent = `已选 ${n} 个` } renderRemoteList('') filterInput.oninput = () => renderRemoteList(filterInput.value.trim()) listEl.addEventListener('change', updateCount) overlay.querySelector('#remote-toggle-all').onclick = () => { const cbs = listEl.querySelectorAll('.remote-cb:not(:disabled)') const allChecked = [...cbs].every(cb => cb.checked) cbs.forEach(cb => { cb.checked = !allChecked }) updateCount() } overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove() }) overlay.querySelector('[data-action="cancel"]').onclick = () => overlay.remove() overlay.querySelector('[data-action="confirm"]').onclick = () => { const selected = [...listEl.querySelectorAll('.remote-cb:checked')].map(cb => cb.dataset.id) if (!selected.length) { toast('请至少选择一个模型', 'warning'); return } pushUndo(state) for (const id of selected) { provider.models.push({ id, input: ['text', 'image'] }) } overlay.remove() renderProviders(page, state) renderDefaultBar(page, state) updateUndoBtn(page, state) autoSave(state) toast(`已添加 ${selected.length} 个模型`, 'success') } filterInput.focus() } catch (e) { btn.disabled = false btn.textContent = '获取列表' toast(`获取模型列表失败: ${e}`, 'error') } } // 测试模型连通性(记录耗时和状态) async function testModel(btn, state, providerKey, idx) { const provider = state.config.models.providers[providerKey] const model = provider.models[idx] const modelId = typeof model === 'string' ? model : model.id btn.disabled = true const origText = btn.textContent btn.textContent = '测试中...' const start = Date.now() try { const reply = await api.testModel(provider.baseUrl, provider.apiKey || '', modelId) const elapsed = Date.now() - start // 记录到模型对象 if (typeof model === 'object') { model.latency = elapsed model.lastTestAt = Date.now() model.testStatus = 'ok' delete model.testError } toast(`${modelId} 连通正常 (${(elapsed / 1000).toFixed(1)}s): "${reply.slice(0, 50)}"`, 'success') } catch (e) { const elapsed = Date.now() - start if (typeof model === 'object') { model.latency = null model.lastTestAt = Date.now() model.testStatus = 'fail' model.testError = String(e).slice(0, 100) } toast(`${modelId} 不可用 (${(elapsed / 1000).toFixed(1)}s): ${e}`, 'error') } finally { btn.disabled = false btn.textContent = origText // 刷新卡片显示最新状态 const page = btn.closest('.page') if (page) { renderProviders(page, state) renderDefaultBar(page, state) } // 持久化测试结果 autoSave(state) } }