From 9d2dc8438ebd67a0d40d3c74ccbfa1501325bfe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Thu, 21 May 2026 19:06:38 +0800 Subject: [PATCH] fix: apply pending correctness fixes Cherry-pick the still-relevant fixes from recent draft PRs without pulling in stale release/docs changes: - serialize dashboard data loads to avoid concurrent config self-heal writes - preserve valid per-model default blocks during dashboard model self-heal - pass structured humanizeError results directly to toast for model import scan failures - align frontend kernel isLatest with suffix-aware recommended version ordering Verification: - node --test tests/*.test.js - npm run build --- src/lib/kernel.js | 44 +++++++++++++++++++++++++++++++++++- src/pages/dashboard.js | 12 ++++++---- src/pages/models.js | 2 +- tests/humanize-error.test.js | 17 ++++++++++++++ tests/kernel.test.js | 27 ++++++++++++++-------- 5 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 tests/humanize-error.test.js diff --git a/src/lib/kernel.js b/src/lib/kernel.js index bc0d845..b8e457c 100644 --- a/src/lib/kernel.js +++ b/src/lib/kernel.js @@ -60,6 +60,48 @@ export function versionGte(a, b) { return true } +function baseVersionStr(v) { + const s = String(v || '') + const i = s.indexOf('-') + return i === -1 ? s : s.slice(0, i) +} + +function hasVersionSuffix(v) { + return String(v || '').includes('-') +} + +function allNumericParts(ver) { + return String(ver || '') + .split(/[^\d]+/) + .filter(Boolean) + .map(n => parseInt(n, 10)) + .filter(n => !Number.isNaN(n)) +} + +function compareLex(a, b) { + if (!a?.length || !b?.length) return 0 + const len = Math.max(a.length, b.length) + for (let i = 0; i < len; i++) { + const ai = a[i] || 0 + const bi = b[i] || 0 + if (ai < bi) return -1 + if (ai > bi) return 1 + } + return 0 +} + +export function recommendedIsNewer(recommended, current) { + const r = parseVersion(baseVersionStr(recommended)) + const c = parseVersion(baseVersionStr(current)) + if (!r || !c) return false + const baseCmp = compareLex(r, c) + if (baseCmp !== 0) return baseCmp > 0 + if (hasVersionSuffix(recommended) && hasVersionSuffix(current)) { + return compareLex(allNumericParts(recommended), allNumericParts(current)) > 0 + } + return false +} + /** * 检测版本是否属于汉化版 */ @@ -100,7 +142,7 @@ export function buildSnapshot(engineId, version) { target, floor, aboveFloor: !!version && versionGte(version, floor), - isLatest: !!version && !!target && versionGte(version, target), + isLatest: !!version && !!target && !recommendedIsNewer(target, version), features, versionLabel: version ? `${versionBase}${variant === 'chinese' ? ' 汉化' : ''}` diff --git a/src/pages/dashboard.js b/src/pages/dashboard.js index b480bc8..81b48cb 100644 --- a/src/pages/dashboard.js +++ b/src/pages/dashboard.js @@ -13,7 +13,7 @@ import { attachCliConflictBanner } from '../components/cli-conflict-banner.js' import { icon } from '../lib/icons.js' let _unsubGw = null -let _loadInFlight = false +let _dashboardLoadChain = Promise.resolve() let _lastGwChangeLoad = 0 let _detachCliConflict = null @@ -193,16 +193,20 @@ function normalizeDefaultModelConfig(config) { 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 } async function loadDashboardData(page, fullRefresh = false) { // 并发保护:如果上一次加载仍在进行,跳过本次(fullRefresh 除外) - if (_loadInFlight && !fullRefresh) return const loadSeq = ++_dashboardLoadSeq - _loadInFlight = true - try { await _loadDashboardDataInner(page, fullRefresh, loadSeq) } finally { if (loadSeq === _dashboardLoadSeq) _loadInFlight = false } + _dashboardLoadChain = _dashboardLoadChain.catch(() => {}).then(() => _loadDashboardDataInner(page, fullRefresh, loadSeq)) + return _dashboardLoadChain } async function _loadDashboardDataInner(page, fullRefresh, loadSeq) { diff --git a/src/pages/models.js b/src/pages/models.js index f96b17f..724fa9c 100644 --- a/src/pages/models.js +++ b/src/pages/models.js @@ -1308,7 +1308,7 @@ async function importClientConfigs(page, state) { const result = await api.scanModelClientConfigs() candidates = Array.isArray(result?.candidates) ? result.candidates : [] } catch (e) { - toast(`${t('models.importScanFailed')}: ${humanizeError(e)}`, 'error') + toast(humanizeError(e, t('models.importScanFailed')), 'error') return } finally { if (btn) { btn.disabled = false; btn.textContent = oldText || t('models.importClientConfigs') } diff --git a/tests/humanize-error.test.js b/tests/humanize-error.test.js new file mode 100644 index 0000000..d681be4 --- /dev/null +++ b/tests/humanize-error.test.js @@ -0,0 +1,17 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { humanizeError, humanizeErrorText } from '../src/lib/humanize-error.js' + +test('humanizeError must not be coerced with String() or template literals', () => { + const h = humanizeError(new Error('ECONNREFUSED 127.0.0.1:443'), 'Import scan failed') + assert.equal(typeof h, 'object') + assert.equal(String(h), '[object Object]') + assert.ok(h.message) +}) + +test('humanizeErrorText is safe for plain-string contexts', () => { + const line = humanizeErrorText(new Error('ENOENT no such file'), 'Import scan failed') + assert.match(line, /Import scan failed/) + assert.doesNotMatch(line, /\[object Object\]/) +}) diff --git a/tests/kernel.test.js b/tests/kernel.test.js index 6f174be..450cef5 100644 --- a/tests/kernel.test.js +++ b/tests/kernel.test.js @@ -9,8 +9,8 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { parseVersion, versionGte, buildSnapshot } from '../src/lib/kernel.js' -import { FEATURE_CATALOG, KERNEL_FLOOR } from '../src/lib/feature-catalog.js' +import { parseVersion, versionGte, buildSnapshot, recommendedIsNewer } from '../src/lib/kernel.js' +import { FEATURE_CATALOG, KERNEL_FLOOR, KERNEL_TARGET } from '../src/lib/feature-catalog.js' // ============================================================================ // parseVersion @@ -160,14 +160,21 @@ test('buildSnapshot edge case: version slightly below 5.6 feature requirement', }) test('buildSnapshot.isLatest works against KERNEL_TARGET', () => { - const at_target = buildSnapshot('openclaw', '2026.5.19') - const at_chinese_target = buildSnapshot('openclaw', '2026.5.18-zh.1') - const above_target = buildSnapshot('openclaw', '2026.6.0') - const below_target = buildSnapshot('openclaw', '2026.5.18') - assert.equal(at_target.isLatest, true) - assert.equal(at_chinese_target.isLatest, true) - assert.equal(above_target.isLatest, true) - assert.equal(below_target.isLatest, false) + const atOfficial = buildSnapshot('openclaw', KERNEL_TARGET.openclaw.official) + const atChinese = buildSnapshot('openclaw', KERNEL_TARGET.openclaw.chinese) + const aboveTarget = buildSnapshot('openclaw', '2026.6.0') + const belowTarget = buildSnapshot('openclaw', '2026.5.18') + assert.equal(atOfficial.isLatest, true) + assert.equal(atChinese.isLatest, true) + assert.equal(aboveTarget.isLatest, true) + assert.equal(belowTarget.isLatest, false) +}) + +test('recommendedIsNewer matches suffix patch ordering', () => { + assert.equal(recommendedIsNewer('2026.5.18-zh.2', '2026.5.18-zh.1'), true) + assert.equal(recommendedIsNewer('2026.5.18-zh.1', '2026.5.18-zh.2'), false) + assert.equal(recommendedIsNewer('2026.5.18-zh.1', '2026.5.18-zh.1'), false) + assert.equal(recommendedIsNewer('2026.5.19', '2026.5.18-zh.9'), true) }) // ============================================================================