From 16e4f505e63b1366304a04896794badb34798ed6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 17 May 2026 11:04:14 +0000 Subject: [PATCH] fix(dashboard): preserve agents.defaults.models entries during model self-heal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard normalization rebuilt defaults.models from only primary+fallbacks, dropping valid per-model overrides for other configured models (unlike the models page normalizeDefaultModelMap). Extract shared pure helpers and merge orphan valid keys after rebuilding the chain. Co-authored-by: 晴天 <1186258278@users.noreply.github.com> --- src/lib/agent-default-model-normalize.js | 74 +++++++++++++++++++++ src/pages/dashboard.js | 62 ++--------------- tests/agent-default-model-normalize.test.js | 56 ++++++++++++++++ 3 files changed, 134 insertions(+), 58 deletions(-) create mode 100644 src/lib/agent-default-model-normalize.js create mode 100644 tests/agent-default-model-normalize.test.js diff --git a/src/lib/agent-default-model-normalize.js b/src/lib/agent-default-model-normalize.js new file mode 100644 index 0000000..54e6a40 --- /dev/null +++ b/src/lib/agent-default-model-normalize.js @@ -0,0 +1,74 @@ +/** + * Pure helpers for repairing agents.defaults.model + agents.defaults.models + * when loading the dashboard. Kept in a standalone module so unit tests can + * cover normalization without pulling in the full dashboard page. + */ + +export 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 +} + +export 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)) +} + +/** + * Align primary / fallbacks with configured providers and rebuild defaults.models. + * Must preserve per-model blocks for any still-valid model id not on the chain + * (same rule as models.js normalizeDefaultModelMap); otherwise dashboard + * self-heal drops unrelated overrides when it only meant to fix a bad primary. + */ +export 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] : {} + } + for (const [key, value] of Object.entries(currentMap)) { + if (validModels.has(key) && !nextMap[key]) { + nextMap[key] = value && typeof value === 'object' && !Array.isArray(value) ? value : {} + } + } + config.agents.defaults.models = nextMap + return modelConfig.primary +} diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index b480bc8..8094b26 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -11,6 +11,10 @@ import { t } from '../lib/i18n.js' import { wsClient } from '../lib/ws-client.js' import { attachCliConflictBanner } from '../components/cli-conflict-banner.js' import { icon } from '../lib/icons.js' +import { + defaultModelNeedsNormalization, + normalizeDefaultModelConfig, +} from '../lib/agent-default-model-normalize.js' let _unsubGw = null let _loadInFlight = false @@ -139,64 +143,6 @@ 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 diff --git a/tests/agent-default-model-normalize.test.js b/tests/agent-default-model-normalize.test.js new file mode 100644 index 0000000..6e7055b --- /dev/null +++ b/tests/agent-default-model-normalize.test.js @@ -0,0 +1,56 @@ +/** + * 运行:node --test tests/agent-default-model-normalize.test.js + */ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { normalizeDefaultModelConfig } from '../src/lib/agent-default-model-normalize.js' + +test('normalizeDefaultModelConfig keeps valid per-model blocks off the fallback chain', () => { + const config = { + models: { + providers: { + openai: { models: [{ id: 'gpt-4' }, { id: 'gpt-4o-mini' }] }, + anthropic: { models: [{ id: 'claude-3-5-sonnet' }] }, + }, + }, + agents: { + defaults: { + model: { + primary: 'openai/deleted-model', + fallbacks: [], + }, + models: { + 'openai/deleted-model': { temperature: 0.1 }, + 'anthropic/claude-3-5-sonnet': { temperature: 0.7 }, + }, + }, + }, + } + normalizeDefaultModelConfig(config) + assert.equal(config.agents.defaults.model.primary, 'openai/gpt-4') + assert.deepEqual(config.agents.defaults.models['anthropic/claude-3-5-sonnet'], { temperature: 0.7 }) + assert.equal(config.agents.defaults.models['openai/deleted-model'], undefined) +}) + +test('normalizeDefaultModelConfig still strips invalid model keys', () => { + const config = { + models: { + providers: { + openai: { models: [{ id: 'gpt-4' }] }, + }, + }, + agents: { + defaults: { + model: { primary: 'openai/gpt-4', fallbacks: [] }, + models: { + 'openai/gpt-4': {}, + 'ghost/missing': { temperature: 1 }, + }, + }, + }, + } + normalizeDefaultModelConfig(config) + assert.equal(config.agents.defaults.models['ghost/missing'], undefined) + assert.ok(config.agents.defaults.models['openai/gpt-4']) +})