fix(kernel): align isLatest with suffix-aware recommended version

buildSnapshot used versionGte against KERNEL_TARGET, which only compares
the base date triplet and ignores -zh.N. Users on e.g. 2026.5.12-zh.1 were
marked latest when the panel target was 2026.5.12-zh.2, hiding the sidebar
upgrade hint and showing a false "latest" badge.

Add recommendedIsNewer mirroring Rust config.rs logic; isLatest is now
!recommendedIsNewer(target, current). Extend kernel tests accordingly.

Co-authored-by: 晴天 <1186258278@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-05-16 11:06:53 +00:00
parent 230b5e6dca
commit 3780dbadcd
2 changed files with 76 additions and 10 deletions

View File

@@ -26,7 +26,7 @@ const _listeners = []
* @property {string|null} target 当前推荐目标版本
* @property {string} floor 硬地板版本
* @property {boolean} aboveFloor 是否 >= floor
* @property {boolean} isLatest 是否 >= target
* @property {boolean} isLatest 是否已达推荐目标(含 -zh.N 等后缀小版本,与后端 recommended 语义一致)
* @property {Set<string>} features 当前启用的特性 id 集合
* @property {string} versionLabel 人类可读的版本显示,例如 "2026.5.6 汉化"
* @property {number|null} protocol 握手协商出的 Gateway WS 协议版本 (3 或 4),未握手为 null
@@ -60,6 +60,54 @@ export function versionGte(a, b) {
return true
}
/** 与 Rust `base_version` 一致:在首个 `-` 处截断 */
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('-')
}
/** 提取字符串中所有数字段,对齐 Rust `parse_version` 对整串的拆分 */
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
}
/**
* 与 `src-tauri/src/commands/config.rs` 中 `recommended_is_newer` 对齐:
* 在基础 x.y.z 相同的情况下,继续比较 `-zh.N` / `-nightly.N` 等后缀中的数字序。
*/
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 +148,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' ? ' 汉化' : ''}`

View File

@@ -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,12 +160,30 @@ 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.6')
const above_target = buildSnapshot('openclaw', '2026.6.0')
const below_target = buildSnapshot('openclaw', '2026.5.5')
assert.equal(at_target.isLatest, true)
assert.equal(above_target.isLatest, true)
assert.equal(below_target.isLatest, false)
const officialT = KERNEL_TARGET.openclaw.official
const atOfficial = buildSnapshot('openclaw', officialT)
const aboveOfficial = buildSnapshot('openclaw', '2026.6.0')
const belowOfficial = buildSnapshot('openclaw', '2026.5.5')
assert.equal(atOfficial.isLatest, true)
assert.equal(aboveOfficial.isLatest, true)
assert.equal(belowOfficial.isLatest, false)
const chin = KERNEL_TARGET.openclaw.chinese
if (chin && /-zh\.\d+/.test(chin)) {
assert.equal(buildSnapshot('openclaw', chin).isLatest, true)
const m = chin.match(/-zh\.(\d+)$/)
if (m && Number(m[1]) > 1) {
const prevZh = chin.replace(/-zh\.\d+$/, `-zh.${Number(m[1]) - 1}`)
assert.equal(buildSnapshot('openclaw', prevZh).isLatest, false)
}
}
})
test('recommendedIsNewer matches suffix patch ordering', () => {
assert.equal(recommendedIsNewer('2026.5.12-zh.2', '2026.5.12-zh.1'), true)
assert.equal(recommendedIsNewer('2026.5.12-zh.1', '2026.5.12-zh.2'), false)
assert.equal(recommendedIsNewer('2026.5.12-zh.2', '2026.5.12-zh.2'), false)
assert.equal(recommendedIsNewer('2026.5.13', '2026.5.12-zh.9'), true)
})
// ============================================================================