mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-14 00:43:19 +08:00
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
This commit is contained in:
@@ -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' ? ' 汉化' : ''}`
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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') }
|
||||
|
||||
17
tests/humanize-error.test.js
Normal file
17
tests/humanize-error.test.js
Normal file
@@ -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\]/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user