diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index da9415a..b480bc8 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -123,6 +123,7 @@ let _dashboardInitialized = false let _dashboardVersionCache = null let _dashboardStatusSummaryCache = null let _dashboardInstanceId = '' +let _dashboardLoadSeq = 0 function syncDashboardInstanceScope() { const instanceId = getActiveInstance()?.id || 'local' @@ -138,14 +139,73 @@ function versionInfoIncomplete(version) { return !version || !version.current || !version.source || version.source === 'unknown' } +function collectConfigModels(config) { + const result = [] + const providers = config?.models?.providers || {} + for (const [providerKey, provider] of Object.entries(providers)) { + for (const model of (provider?.models || [])) { + const id = typeof model === 'string' ? model : model?.id + if (id) result.push(`${providerKey}/${id}`) + } + } + return result +} + +function defaultModelNeedsNormalization(config) { + const validModels = new Set(collectConfigModels(config)) + const modelConfig = config?.agents?.defaults?.model || {} + const primary = modelConfig.primary || '' + const fallbacks = Array.isArray(modelConfig.fallbacks) ? modelConfig.fallbacks : [] + if (!validModels.size) return !!primary || fallbacks.length > 0 || Object.keys(config?.agents?.defaults?.models || {}).length > 0 + if (!validModels.has(primary)) return true + if (fallbacks.some(f => f === primary || !validModels.has(f))) return true + return Object.keys(config?.agents?.defaults?.models || {}).some(key => !validModels.has(key)) +} + +function normalizeDefaultModelConfig(config) { + const allModels = collectConfigModels(config) + const validModels = new Set(allModels) + if (!config.agents) config.agents = {} + if (!config.agents.defaults) config.agents.defaults = {} + if (!config.agents.defaults.model) config.agents.defaults.model = {} + const modelConfig = config.agents.defaults.model + if (!Array.isArray(modelConfig.fallbacks)) modelConfig.fallbacks = [] + if (!allModels.length) { + modelConfig.primary = '' + modelConfig.fallbacks = [] + config.agents.defaults.models = {} + return '' + } + if (!validModels.has(modelConfig.primary || '')) { + modelConfig.primary = modelConfig.fallbacks.find(f => validModels.has(f)) || allModels[0] + } + const seen = new Set([modelConfig.primary]) + modelConfig.fallbacks = modelConfig.fallbacks + .filter(f => validModels.has(f)) + .filter(f => { + if (seen.has(f)) return false + seen.add(f) + return true + }) + const currentMap = config.agents.defaults.models && typeof config.agents.defaults.models === 'object' && !Array.isArray(config.agents.defaults.models) ? config.agents.defaults.models : {} + const nextMap = {} + nextMap[modelConfig.primary] = currentMap[modelConfig.primary] && typeof currentMap[modelConfig.primary] === 'object' && !Array.isArray(currentMap[modelConfig.primary]) ? currentMap[modelConfig.primary] : {} + for (const fallback of modelConfig.fallbacks) { + nextMap[fallback] = currentMap[fallback] && typeof currentMap[fallback] === 'object' && !Array.isArray(currentMap[fallback]) ? currentMap[fallback] : {} + } + config.agents.defaults.models = nextMap + return modelConfig.primary +} + async function loadDashboardData(page, fullRefresh = false) { // 并发保护:如果上一次加载仍在进行,跳过本次(fullRefresh 除外) if (_loadInFlight && !fullRefresh) return + const loadSeq = ++_dashboardLoadSeq _loadInFlight = true - try { await _loadDashboardDataInner(page, fullRefresh) } finally { _loadInFlight = false } + try { await _loadDashboardDataInner(page, fullRefresh, loadSeq) } finally { if (loadSeq === _dashboardLoadSeq) _loadInFlight = false } } -async function _loadDashboardDataInner(page, fullRefresh) { +async function _loadDashboardDataInner(page, fullRefresh, loadSeq) { syncDashboardInstanceScope() // 分波加载:关键数据先渲染,次要数据后填充,减少白屏等待 // 轻量调用(读文件)每次都做;重量调用(spawn CLI/网络请求)只在首次或手动刷新时做 @@ -168,7 +228,7 @@ async function _loadDashboardDataInner(page, fullRefresh) { const [servicesRes, configRes, panelConfigRes] = await coreP const services = servicesRes.status === 'fulfilled' ? servicesRes.value : [] let version = _dashboardVersionCache || {} - const config = configRes.status === 'fulfilled' ? configRes.value : null + let config = configRes.status === 'fulfilled' ? configRes.value : null const panelConfig = panelConfigRes.status === 'fulfilled' ? panelConfigRes.value : null const gw = services.find(s => s.label === 'ai.openclaw.gateway') let agents = [] @@ -189,6 +249,7 @@ async function _loadDashboardDataInner(page, fullRefresh) { if (!config.gateway?.mode) needsPatch = true if (config.mode) needsPatch = true if (!config.tools || config.tools.profile !== 'full') needsPatch = true + if (defaultModelNeedsNormalization(config)) needsPatch = true if (needsPatch) { try { const freshConfig = await api.readOpenclawConfig() @@ -203,11 +264,19 @@ async function _loadDashboardDataInner(page, fullRefresh) { freshConfig.tools.sessions.visibility = 'all' patched = true } - if (patched) api.writeOpenclawConfig(freshConfig).catch(() => {}) + if (defaultModelNeedsNormalization(freshConfig)) { + normalizeDefaultModelConfig(freshConfig) + patched = true + } + if (patched) { + config = freshConfig + api.writeOpenclawConfig(freshConfig).catch(() => {}) + } } catch {} } } + if (loadSeq !== _dashboardLoadSeq || !page.isConnected) return renderStatCards(page, services, version, [], config, panelConfig) renderLogs(page, '') if (gw) { @@ -229,7 +298,7 @@ async function _loadDashboardDataInner(page, fullRefresh) { }) : Promise.resolve(_dashboardVersionCache || {}) versionP.then(v => { - if (!page.isConnected) return + if (loadSeq !== _dashboardLoadSeq || !page.isConnected) return version = v || {} renderStatCards(page, services, version, agents, config, panelConfig) }) @@ -247,6 +316,7 @@ async function _loadDashboardDataInner(page, fullRefresh) { // 第二波:Agent、MCP、备份 → 更新卡片 + 渲染总览 const [agentsRes, mcpRes, backupsRes, channelsRes] = await secondaryP + if (loadSeq !== _dashboardLoadSeq || !page.isConnected) return agents = agentsRes.status === 'fulfilled' ? agentsRes.value : [] const mcpConfig = mcpRes.status === 'fulfilled' ? mcpRes.value : null const backups = backupsRes.status === 'fulfilled' ? backupsRes.value : [] @@ -269,6 +339,7 @@ async function _loadDashboardDataInner(page, fullRefresh) { // 第三波:日志(最低优先级) const logs = await logsP + if (loadSeq !== _dashboardLoadSeq || !page.isConnected) return renderLogs(page, logs) _dashboardInitialized = true diff --git a/src/pages/models.js b/src/pages/models.js index 96af07d..6865375 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -89,10 +89,13 @@ async function loadConfig(page, state) { const before = JSON.stringify(state.config?.models?.providers || {}) normalizeProviderUrls(state.config) const after = JSON.stringify(state.config?.models?.providers || {}) - if (before !== after) { - console.log('[models] 自动修复了服务商 baseUrl,正在保存...') + const oldPrimary = getCurrentPrimary(state.config) + const normalizedModel = normalizeDefaultModelSelection(state.config) + if (before !== after || normalizedModel.changed) { + console.log('[models] 自动修复了模型配置,正在保存...') await api.writeOpenclawConfig(state.config) - toast(t('models.autoFixUrl'), 'info') + if (oldPrimary !== normalizedModel.primary) toast(t('models.primaryAutoSwitch', { model: normalizedModel.primary || t('models.notConfigured') }), 'info') + else if (before !== after) toast(t('models.autoFixUrl'), 'info') } renderDefaultBar(page, state) renderProviders(page, state) @@ -127,14 +130,18 @@ function getCurrentPrimary(config) { return config?.agents?.defaults?.model?.primary || '' } -function ensureDefaultModelConfig(state) { - 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 = {} - if (!Array.isArray(state.config.agents.defaults.model.fallbacks)) { - state.config.agents.defaults.model.fallbacks = [] +function ensureConfigDefaultModelConfig(config) { + if (!config.agents) config.agents = {} + if (!config.agents.defaults) config.agents.defaults = {} + if (!config.agents.defaults.model) config.agents.defaults.model = {} + if (!Array.isArray(config.agents.defaults.model.fallbacks)) { + config.agents.defaults.model.fallbacks = [] } - return state.config.agents.defaults.model + return config.agents.defaults.model +} + +function ensureDefaultModelConfig(state) { + return ensureConfigDefaultModelConfig(state.config) } function collectAllModels(config) { @@ -149,6 +156,68 @@ function collectAllModels(config) { return result } +function normalizeDefaultModelMap(config, validModels, primary, fallbacks) { + const defaults = config?.agents?.defaults + if (!defaults) return false + const current = defaults.models && typeof defaults.models === 'object' && !Array.isArray(defaults.models) ? defaults.models : {} + const next = {} + if (primary) next[primary] = current[primary] && typeof current[primary] === 'object' && !Array.isArray(current[primary]) ? current[primary] : {} + for (const f of fallbacks || []) { + if (validModels.has(f) && !next[f]) next[f] = current[f] && typeof current[f] === 'object' && !Array.isArray(current[f]) ? current[f] : {} + } + for (const [key, value] of Object.entries(current)) { + if (validModels.has(key) && !next[key]) next[key] = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + } + const changed = JSON.stringify(current) !== JSON.stringify(next) + defaults.models = next + return changed +} + +function dedupeValidFallbacks(fallbacks, validModels, primary) { + const seen = new Set() + if (primary) seen.add(primary) + return (Array.isArray(fallbacks) ? fallbacks : []) + .filter(f => validModels.has(f)) + .filter(f => { + if (seen.has(f)) return false + seen.add(f) + return true + }) +} + +function normalizeDefaultModelSelection(config) { + const allModels = collectAllModels(config) + const validModels = new Set(allModels.map(m => m.full)) + const modelConfig = ensureConfigDefaultModelConfig(config) + let changed = false + if (!allModels.length) { + if (modelConfig.primary) { + modelConfig.primary = '' + changed = true + } + if (modelConfig.fallbacks.length) { + modelConfig.fallbacks = [] + changed = true + } + changed = normalizeDefaultModelMap(config, validModels, '', []) || changed + return { changed, primary: '' } + } + let primary = modelConfig.primary || '' + if (!validModels.has(primary)) { + const fallbackPrimary = modelConfig.fallbacks.find(f => validModels.has(f)) + primary = fallbackPrimary || allModels[0].full + modelConfig.primary = primary + changed = true + } + const nextFallbacks = dedupeValidFallbacks(modelConfig.fallbacks, validModels, primary) + if (JSON.stringify(nextFallbacks) !== JSON.stringify(modelConfig.fallbacks)) { + modelConfig.fallbacks = nextFallbacks + changed = true + } + changed = normalizeDefaultModelMap(config, validModels, primary, modelConfig.fallbacks) || changed + return { changed, primary } +} + function getApiTypeLabel(apiType) { return API_TYPES.find(at => at.value === apiType)?.label || apiType || t('common.unknown') } @@ -1050,22 +1119,9 @@ function rotateFallbackChain(state, oldPrimary, newPrimary) { // 应用默认模型:primary + 其余自动成为备选 // 确保 primary 指向的模型仍然存在,不存在则自动切到第一个可用模型 function ensureValidPrimary(state) { - const primary = getCurrentPrimary(state.config) - const allModels = collectAllModels(state.config) - if (allModels.length === 0) { - // 所有模型都没了,清空 primary - if (state.config.agents?.defaults?.model) { - state.config.agents.defaults.model.primary = '' - } - return - } - const exists = allModels.some(m => m.full === primary) - if (!exists) { - // primary 指向已删除的模型,自动切到第一个 - const newPrimary = allModels[0].full - setPrimary(state, newPrimary) - toast(t('models.primaryAutoSwitch', { model: newPrimary }), 'info') - } + const current = getCurrentPrimary(state.config) + const normalized = normalizeDefaultModelSelection(state.config) + if (normalized.changed && current !== normalized.primary) toast(t('models.primaryAutoSwitch', { model: normalized.primary || t('models.notConfigured') }), 'info') } function applyDefaultModel(state) { @@ -1089,6 +1145,7 @@ function applyDefaultModel(state) { for (const m of allModels) { if (m.full !== primary) modelsMap[m.full] = {} } defaults.models = modelsMap } + normalizeDefaultModelSelection(state.config) // 注意:不再强制同步到各 agent 的 model.primary // 子 Agent 的模型覆盖是 OpenClaw 正常功能(用户可通过对话为不同 Agent 设置不同模型)