@arcaneorion/dsh-model-channel-manager 0.3.1 → 0.3.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js CHANGED
@@ -11,6 +11,24 @@ window.__ModuleLoader__.load({
11
11
  const { createElement: el, useState, useEffect, useRef, useSyncExternalStore } = require('react')
12
12
  let apiRef = null
13
13
  const savedKeys = {}
14
+ // 使用随机安全整数 nonce:host 的 lastTestHandledNonce/lastHandledNonce 是 number。
15
+ // UUID 字符串虽被新 host 接受,但旧 host(滚动升级/未重启窗口)只校验 number,
16
+ // 会把请求静默忽略,client 最终提示「host 未处理」。随机 53-bit 整数同时避免
17
+ // Date.now()%1e9 同毫秒碰撞,并保持与旧 host 的 wire schema 兼容。
18
+ let nonceFallbackCounter = 0
19
+ const createNonce = () => {
20
+ if (typeof crypto !== 'undefined' && crypto && typeof crypto.getRandomValues === 'function') {
21
+ const words = new Uint32Array(2)
22
+ crypto.getRandomValues(words)
23
+ const n = (words[0] & 0x1fffff) * 0x100000000 + words[1]
24
+ return n === 0 ? 1 : n
25
+ }
26
+ // 降级路径:旧写法 Date.now()*1000+counter ≈ 1.8e18 > MAX_SAFE_INTEGER,
27
+ // 同毫秒内可能撞值(且 strict consume-once 去重下撞值 = 测试被静默丢弃)。
28
+ nonceFallbackCounter = (nonceFallbackCounter + 1) % 1000
29
+ const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER)
30
+ return n === 0 ? nonceFallbackCounter + 1 : n
31
+ }
14
32
 
15
33
  // dsh 0.2:客户端远程面由 connection.api 改为 ctx.remote;参数改为位置参数,结果统一为
16
34
  // RemoteResult。本适配层把两者还原成 0.1 的调用形状(对象入参 + {result:{ok,value}})。
@@ -19,11 +37,6 @@ window.__ModuleLoader__.load({
19
37
  const NS_MCM = 'model-channel-manager'
20
38
  const makeLegacyApi = (remote) => {
21
39
  const wrap = (res) => ({ result: res || { ok: false } });
22
- const readMcm = async () => {
23
- const res = await remote.settings.describe()
24
- if (!res || res.ok !== true) return null
25
- return ((res.value && res.value.namespaces) || []).find((n) => n && n.ns === NS_MCM) || null
26
- }
27
40
  return {
28
41
  settings: {
29
42
  describe: async () => {
@@ -38,49 +51,38 @@ window.__ModuleLoader__.load({
38
51
  { ns: 'model-channel-health', value: value.health || {}, user: (user && user.health) || {}, revision: (mcm && mcm.revision) || 0 },
39
52
  ] } } }
40
53
  },
41
- // 旧签名:update({ ns, patch })
54
+ // 旧签名:update({ ns, patch, expectedRevision })——0.3.2 起透传 revision
55
+ // (宿主 settings.update 第三参;冲突抛 settings/conflict,由调用方提示重载)
42
56
  update: async (args) => {
43
57
  const ns = (args && args.ns) || NS_MCM
44
58
  const patch = (args && args.patch) || {}
45
- if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, undefined))
46
- const row = await readMcm()
47
- const health = (row && row.value && row.value.health) || {}
48
- return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) }, undefined))
59
+ const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
60
+ if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, expected))
61
+ // 0.3.12:health 子树改成「叶写」(path-ops),不再 describe 快照 + 整树回写。
62
+ // 旧写法在 read→write 的两次 RPC 之间有窗口:宿主刚写入的终态会被旧快照覆盖
63
+ // (线上事故:testResults[nonce].status 被改回 running,finishedAt/code/error 却保留,
64
+ // 客户端永远等不到终态 → 66s 后误报「host 未处理」)。
65
+ const ops = Object.entries(patch).map(([k, v]) => ({ op: 'set', path: ['health', k], value: v }))
66
+ if (ops.length === 0) return { result: { ok: true, value: null } }
67
+ return wrap(await remote.settings.mutate(NS_MCM, ops, expected))
49
68
  },
50
- // 旧签名:mutate({ ns, ops })——0.2 只对真实行命名空间提供 path-ops;
51
- // 本插件自身的健康子树用合并写实现等价语义。
69
+ // 旧签名:mutate({ ns, ops })——health 子树现在也走真实 path-ops(不再本地模拟整树写)
52
70
  mutate: async (args) => {
53
71
  const ns = (args && args.ns) || NS_MCM
54
72
  const ops = (args && args.ops) || []
55
- if (ns !== 'model-channels' && ns !== 'model-channel-health' && ns !== NS_MCM) {
56
- return wrap(await remote.settings.mutate(ns, ops, undefined))
57
- }
58
- const row = await readMcm()
59
- const health = Object.assign({}, (row && row.value && row.value.health) || {})
60
- const top = {
61
- groups: (row && row.value && row.value.groups) || [],
62
- providerOrder: (row && row.value && row.value.providerOrder) || [],
63
- }
73
+ const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
74
+ if (ns === NS_MCM) return wrap(await remote.settings.mutate(NS_MCM, ops, expected))
75
+ const prefix = (ns === 'model-channels') ? [] : ['health']
76
+ const mapped = []
64
77
  for (const op of ops) {
65
78
  const path = (op && op.path) || []
66
- const root = (ns === 'model-channels') ? (path[0] === 'groups' ? top : (path[0] === 'providerOrder' ? top : health)) : health
67
79
  if (path.length === 0) continue
68
- const key = path[0]
69
- const rest = path.slice(1)
70
- if (rest.length === 0) {
71
- if (op.op === 'set') root[key] = op.value
72
- else delete root[key]
73
- } else if (rest.length === 1) {
74
- const child = Object.assign({}, root[key] || {})
75
- if (op.op === 'set') child[rest[0]] = op.value
76
- else delete child[rest[0]]
77
- root[key] = child
78
- }
80
+ // legacy 混合命名空间:groups/providerOrder 属于行顶层,其余视为 health 子键
81
+ const p = (ns === 'model-channels' && (path[0] === 'groups' || path[0] === 'providerOrder')) ? path : [...prefix, ...path]
82
+ mapped.push({ op: (op && op.op) || 'set', path: p, value: op && op.value })
79
83
  }
80
- if (ns === 'model-channels') {
81
- return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder }, undefined))
82
- }
83
- return wrap(await remote.settings.update(NS_MCM, { health }, undefined))
84
+ if (mapped.length === 0) return { result: { ok: true, value: null } }
85
+ return wrap(await remote.settings.mutate(NS_MCM, mapped, expected))
84
86
  },
85
87
  },
86
88
  credentials: {
@@ -564,18 +566,26 @@ window.__ModuleLoader__.load({
564
566
  const pollTest = (nonce, provider, model, attempt = 0) => {
565
567
  if (!apiRef) return
566
568
  const key = provider + '::' + model
567
- // host 侧测试超时 60s,约 55 次 × 1.2s ≈ 66s 后放弃,避免结果被覆盖时无限轮询
568
- const giveUp = () => setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 可能未处理该请求(host 半更新后需重启 DSH)' } }))
569
+ // host 侧测试超时 60s,约 55 次 × 1.2s ≈ 66s 后放弃,避免结果被覆盖时无限轮询。
570
+ // 0.3.12:放弃时区分「host 没写结果」与「host 还在跑」,不再一律甩锅「未处理/需重启」。
571
+ const giveUp = (e) => setTestStates((s) => Object.assign({}, s, { [key]: (e && e.status === 'running')
572
+ ? { status: 'error', code: 'POLL_TIMEOUT_RUNNING', error: '测试仍在执行(约 66 秒未返回结果):host 已收到请求,可能是渠道慢或上游挂起;稍后可在健康统计里核对' }
573
+ : { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 未写入结果(可能 host 未重启到新版,或结果写入失败)' } }))
569
574
  apiRef.settings.describe({}).then((resp) => {
570
575
  const r = resp && resp.result ? resp.result : resp
571
576
  const d = r && r.value !== undefined ? r.value : r
572
577
  const ns = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
573
578
  const tr = (ns && ns.value && ns.value.testResults) || {}
574
579
  const e = tr[nonce]
575
- if (e && (e.status === 'ok' || e.status === 'error')) {
576
- setTestStates((s) => Object.assign({}, s, { [key]: e }))
580
+ // 终态判定不能只看 status:整树快照回写曾把 status 改回 running 而保留
581
+ // finishedAt/code/error(0.3.11 事故)。finishedAt 写入后不会被抹掉,把它也当终态,
582
+ // 并按 ok 归一 status —— 这样「僵尸条目」也能显示真实的上游错误而不是假超时。
583
+ const terminal = !!(e && (e.status === 'ok' || e.status === 'error' || e.finishedAt))
584
+ if (terminal) {
585
+ const norm = (e.status === 'ok' || e.status === 'error') ? e : Object.assign({}, e, { status: e.ok === true ? 'ok' : 'error' })
586
+ setTestStates((s) => Object.assign({}, s, { [key]: norm }))
577
587
  } else if (attempt >= 55) {
578
- giveUp()
588
+ giveUp(e)
579
589
  } else {
580
590
  setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1200)
581
591
  }
@@ -589,13 +599,18 @@ window.__ModuleLoader__.load({
589
599
  if (!apiRef) return
590
600
  const key = provider + '::' + model
591
601
  setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
592
- const nonce = Date.now() % 1000000000
602
+ // F03:Date.now()%1e9 同毫秒可碰撞(碰撞 = 两次测试互相认领/覆盖结果),
603
+ // 换 UUID;host 侧 testRequest.nonce 消费方是 !== 比较,字符串/数字都行
604
+ const nonce = createNonce()
593
605
  const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
594
606
  const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
595
607
  apiRef.settings.update({
596
608
  ns: 'model-channel-health',
597
609
  patch: { testRequest: { nonce, provider, model, prompt, maxTokens } }
598
- }).then(() => {
610
+ }).then((resp) => {
611
+ // F19:RPC resolve ≠ 业务成功——检查信封(旧实现把 ok:false 当「已触发」)
612
+ const r = resp && resp.result ? resp.result : resp
613
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'test request rejected')
599
614
  pollTest(nonce, provider, model)
600
615
  }).catch((e) => {
601
616
  setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', error: String((e && e.message) || e) } }))
@@ -704,7 +719,7 @@ window.__ModuleLoader__.load({
704
719
  el('span', {
705
720
  title: '拖动此手柄调整供应商顺序(保存后生效)',
706
721
  draggable: true,
707
- onDragStart: (e) => { e.stopPropagation(); setDragIdx(idx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)) } catch (_e) {} },
722
+ onDragStart: (e) => { e.stopPropagation(); setDragIdx(realIdx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(realIdx)) } catch (_e) {} },
708
723
  onDragEnd: () => { setDragIdx(null); setOverIdx(null) },
709
724
  onClick: (e) => e.stopPropagation(),
710
725
  style: { cursor: 'grab', color: 'var(--dsw-alias-label-tertiary)', fontSize: 14, padding: '0 6px', userSelect: 'none' }
@@ -931,6 +946,8 @@ window.__ModuleLoader__.load({
931
946
  const providers = props._providers || {}
932
947
  const [speedState, setSpeedState] = useState({})
933
948
  const [expanded, setExpanded] = useState({})
949
+ // 顶层 notice 的写入通道(ModelConfigView 传入;测速失败要上浮显示)
950
+ const setNotice = props._setNotice || (() => {})
934
951
 
935
952
  const addGroup = () => props.setChannelsDraft((d) => {
936
953
  const base = d || []
@@ -965,12 +982,19 @@ window.__ModuleLoader__.load({
965
982
 
966
983
  const speedtest = (gid) => {
967
984
  if (!apiRef) return
968
- const nonce = Date.now() % 1000000000
985
+ // F03/F19 同 fireTest:UUID 防碰撞 + 信封检查(ok:false 不再当「已触发」)
986
+ const nonce = createNonce()
969
987
  setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
970
- apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then(() => {
988
+ apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then((resp) => {
989
+ const r = resp && resp.result ? resp.result : resp
990
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'speed request rejected')
971
991
  setSpeedState((s) => Object.assign({}, s, { [gid]: 'sent' }))
972
992
  setTimeout(() => setSpeedState((s) => Object.assign({}, s, { [gid]: null })), 3000)
973
- }).catch(() => setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' })))
993
+ }).catch((e) => {
994
+ setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' }))
995
+ setNotice('测速触发失败: ' + String((e && e.message) || e))
996
+ setTimeout(() => setNotice(null), 5000)
997
+ })
974
998
  }
975
999
 
976
1000
  if (groups.length === 0) return el('div', { className: 'mcm-empty' }, '暂无轮询组,点击下方按钮创建', el('div', { style: { marginTop: 12 } }, btn('+新增轮询组', addGroup, 'primary')))
@@ -1059,7 +1083,9 @@ window.__ModuleLoader__.load({
1059
1083
  function HealthPanel(props) {
1060
1084
  const health = props.health
1061
1085
  const providers = props._providers || {}
1062
- const [windowMode, setWindowMode] = useState('30m') // '30m' | '24h' | '7d'
1086
+ // 默认 7d:digest 是窗口聚合,小窗口在长时间无请求时会合法显示 0,
1087
+ // 用户容易误认为「数据丢了」。先默认展示历史,再可切 30m/24h 看实时/近期。
1088
+ const [windowMode, setWindowMode] = useState('7d') // '30m' | '24h' | '7d'
1063
1089
 
1064
1090
  if (!health) return el('div', { className: 'mcm-empty' }, '健康统计数据准备中…')
1065
1091
 
@@ -1107,6 +1133,11 @@ window.__ModuleLoader__.load({
1107
1133
  let totalTokIn = 0
1108
1134
  let totalTokOut = 0
1109
1135
  let totalTokCache = 0
1136
+ // allEvents 必须在两个分支前声明:旧 host 兼容路径(digest 缺失)会在
1137
+ // else 块里赋值它(供顶部指标卡用);若在此处之后才 let 声明,
1138
+ // 赋值撞未声明变量 → ReferenceError 白屏(host 重启首次加载、digest
1139
+ // 尚未生成时正是此路径)。这是 0.3.1 引入双路径时的遗漏。
1140
+ let allEvents = []
1110
1141
 
1111
1142
  if (digestRows !== null) {
1112
1143
  // ---------- digest 投影路径 ----------
@@ -1214,8 +1245,7 @@ window.__ModuleLoader__.load({
1214
1245
  }
1215
1246
 
1216
1247
  // 顶部指标卡:digest 路径用聚合值求和,旧路径用原始事件计数
1217
- // (allEvents 提升到函数级:旧 host 回落路径的 else 块在此作用域外定义它)
1218
- let allEvents = []
1248
+ // (allEvents 已在上方分支前声明)
1219
1249
  let totalRequests = 0
1220
1250
  let totalSuccess = 0
1221
1251
  let ttftWeightedSum = 0
@@ -1368,11 +1398,23 @@ window.__ModuleLoader__.load({
1368
1398
  const [notice, setNotice] = useState(null)
1369
1399
  const [saving, setSaving] = useState(false)
1370
1400
  const [tab, setTab] = useState('config')
1401
+ // ready 门(审计 F04):首次 describe 完成前保存不可用——旧实现
1402
+ // channelsDraft || [] 把「还没读到」当「空」,加载前点保存会把轮询组清空
1403
+ const [ready, setReady] = useState(false)
1371
1404
 
1372
1405
  const renameProviderInChannels = (oldId, newId) => {
1406
+ // F17(审计 C05):改名要同步全部引用——主 candidates + 每个 preset 的
1407
+ // candidates。宿主优先使用 activePreset.candidates,漏改它 = 删旧 provider
1408
+ // 后组仍访问不存在的 ID。
1409
+ const renameCands = (cands) => (cands || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
1373
1410
  setChannelsDraft((d) => (d || []).map((g) => {
1374
- const cands = (g.candidates || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
1375
- return Object.assign({}, g, { candidates: cands })
1411
+ const next = Object.assign({}, g, { candidates: renameCands(g.candidates) })
1412
+ if (Array.isArray(g.presets)) {
1413
+ next.presets = g.presets.map((p) => (p && Array.isArray(p.candidates))
1414
+ ? Object.assign({}, p, { candidates: renameCands(p.candidates) })
1415
+ : p)
1416
+ }
1417
+ return next
1376
1418
  }))
1377
1419
  }
1378
1420
  const [channels, setChannels] = useState(null)
@@ -1412,7 +1454,9 @@ window.__ModuleLoader__.load({
1412
1454
  const providers = applyProviderOrder((ns && ns.value && ns.value.providers) || {}, savedOrder)
1413
1455
  // user 层 = 仅用户手工声明的路由(用于真正删除与重排序的 mutate 依据)
1414
1456
  const userProviders = (ns && ns.user && ns.user.providers) || {}
1415
- setState({ providers, userProviders, providerOrder: savedOrder || [] })
1457
+ // revision 记录(审计 F05):保存时作为 expectedRevision 传给宿主,
1458
+ // 别处(另一个标签页/host 侧)改过配置则宿主拒绝,防止旧快照覆盖
1459
+ setState({ providers, userProviders, providerOrder: savedOrder || [], nsRevisions: { piAi: (ns && ns.revision) || 0, channels: (cn && cn.revision) || 0 } })
1416
1460
  setDraft((prev) => prev || clone(providers))
1417
1461
  const ch = (cn && cn.value && cn.value.groups) || []
1418
1462
  setChannels(ch)
@@ -1424,7 +1468,8 @@ window.__ModuleLoader__.load({
1424
1468
  setHealth(hv.digest || (hv.digestAt !== undefined)
1425
1469
  ? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
1426
1470
  : (hn ? { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} } : null))
1427
- }).catch((e) => setNotice('加载失败: ' + String(e)))
1471
+ setReady(true) // describe 成功:保存解禁(F04 的门)
1472
+ }).catch((e) => { setNotice('加载失败: ' + String(e)); setReady(false) })
1428
1473
  }
1429
1474
 
1430
1475
  useEffect(() => {
@@ -1453,6 +1498,36 @@ window.__ModuleLoader__.load({
1453
1498
 
1454
1499
  const save = () => {
1455
1500
  if (!apiRef) return
1501
+ // F04 门:describe 未完成时 draft/channelsDraft 都是 null——旧实现
1502
+ // (channelsDraft || []) 把「还没读到」当「空」,这里直接拦截而不是兜底空数组
1503
+ if (!ready) {
1504
+ setNotice('配置尚未加载完成,请稍候再保存')
1505
+ setTimeout(() => setNotice(null), 4000)
1506
+ return
1507
+ }
1508
+ // F16 预检:非法/重复组 ID、空候选直接拒绝提交(旧实现保存后 host 静默丢弃,
1509
+ // 界面仍显示「已保存」——H08:3 组保存实际只注册 1 条路由)
1510
+ const groupsToSave = channelsDraft || []
1511
+ const problems = []
1512
+ const seenIds = new Set()
1513
+ for (const g of groupsToSave) {
1514
+ const gid = (g && g.id) || ''
1515
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(gid)) {
1516
+ problems.push('组「' + (gid || '(未命名)') + '」ID 非法(仅小写字母/数字/连字符,字母或数字开头)')
1517
+ } else if (seenIds.has(gid)) {
1518
+ problems.push('组 ID「' + gid + '」重复')
1519
+ } else {
1520
+ seenIds.add(gid)
1521
+ }
1522
+ const candCount = (g && Array.isArray(g.candidates)) ? g.candidates.filter((c) => c && c.provider && c.model).length : 0
1523
+ if (candCount === 0 && gid)
1524
+ problems.push('组「' + gid + '」没有可用候选(候选需同时选择 Provider 和模型)')
1525
+ }
1526
+ if (problems.length > 0) {
1527
+ setNotice('保存被拒绝:' + problems[0] + (problems.length > 1 ? '(共 ' + problems.length + ' 个问题)' : ''))
1528
+ setTimeout(() => setNotice(null), 8000)
1529
+ return
1530
+ }
1456
1531
  setSaving(true)
1457
1532
  setNotice(null)
1458
1533
  // Compat 全量透传:rc.2 PiAiCompatProfile 的 20 个字段全部真实生效——此前
@@ -1494,6 +1569,10 @@ window.__ModuleLoader__.load({
1494
1569
  if (Array.isArray(cleaned.input) && cleaned.input.length > 0) cleaned.input = cleaned.input.slice()
1495
1570
  else delete cleaned.input
1496
1571
  if (cleaned.reasoningEfforts != null && typeof cleaned.reasoningEfforts === 'object' && Object.keys(cleaned.reasoningEfforts).length > 0) cleaned.reasoningEfforts = Object.assign({}, cleaned.reasoningEfforts)
1572
+ else if (cleaned.reasoningEfforts === false) {
1573
+ // F07(审计 C02):false = 显式关闭推理(继承 catalog 默认的对立面)。
1574
+ // 删掉它 = 静默恢复继承,语义被改变。原样保留。
1575
+ }
1497
1576
  else delete cleaned.reasoningEfforts
1498
1577
  const mc = cleanCompat(cleaned.compat)
1499
1578
  if (mc) cleaned.compat = mc
@@ -1501,7 +1580,12 @@ window.__ModuleLoader__.load({
1501
1580
  return cleaned
1502
1581
  })
1503
1582
  const pObj = Object.assign({}, pVal)
1504
- pObj.api = pVal.api || 'openai-completions'
1583
+ // F07(审计 C08):api 缺省 = 继承 catalog 协议;强塞 openai-completions
1584
+ // 会把「继承」变成「显式指定」,materialize 时显式 api 优先于 catalog——
1585
+ // 协议被静默切换。UI 下拉仍显示默认值(渲染默认与持久化默认分离),
1586
+ // 但保存不写显式值。新增供应商时 addGroup 路径已显式给 api。
1587
+ if (typeof pVal.api === 'string' && pVal.api) pObj.api = pVal.api
1588
+ else delete pObj.api
1505
1589
  pObj.models = cleanModels
1506
1590
  if (!(pVal.baseURL && String(pVal.baseURL).trim())) delete pObj.baseURL
1507
1591
  else pObj.baseURL = String(pVal.baseURL).trim()
@@ -1561,9 +1645,14 @@ window.__ModuleLoader__.load({
1561
1645
  for (const k of allKeys) ops.push({ op: 'unset', path: ['providers', k] })
1562
1646
  for (const k of keys) ops.push({ op: 'set', path: ['providers', k], value: cleanProviders[k] })
1563
1647
  if (ops.length === 0) return Promise.resolve()
1564
- return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops }).then((resp) => {
1648
+ // F05:带 revision 保存——llm-pi-ai 是独立 settings 行,冲突由宿主以
1649
+ // settings/conflict 拒绝;提示里给「刷新后重试」指引而不是静默覆盖
1650
+ return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops, expectedRevision: (state && state.nsRevisions && state.nsRevisions.piAi) || undefined }).then((resp) => {
1565
1651
  const r = resp && resp.result ? resp.result : resp
1566
- if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed')
1652
+ if (r && r.ok === false) {
1653
+ const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
1654
+ throw new Error(conflict ? 'llm-pi-ai 配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed'))
1655
+ }
1567
1656
  return r
1568
1657
  })
1569
1658
  })() : Promise.resolve()
@@ -1572,22 +1661,46 @@ window.__ModuleLoader__.load({
1572
1661
  const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
1573
1662
  const p2 = apiRef.settings.update({
1574
1663
  ns: 'model-channels',
1664
+ expectedRevision: (state && state.nsRevisions && state.nsRevisions.channels) || undefined,
1575
1665
  // 命名单一身份:写入时统一 virtualModel.name = 组 id——旧的独立呈现名
1576
1666
  // (如遗留的 group-1)在下一次保存时自动归一,无需迁移
1577
1667
  patch: Object.assign({ groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) })) }, orderPatch)
1578
1668
  }).then((resp) => {
1579
1669
  const r = resp && resp.result ? resp.result : resp
1580
- if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'model-channels save failed')
1670
+ if (r && r.ok === false) {
1671
+ const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
1672
+ throw new Error(conflict ? '轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'model-channels save failed'))
1673
+ }
1581
1674
  return r
1582
1675
  })
1583
- Promise.all([p1, p2]).then(() => {
1584
- setNotice('已全部保存(即时生效)')
1585
- // 同步草稿与状态为刚保存的清洗版本(含默认请求头合并结果)
1586
- setDraft(clone(cleanProviders))
1587
- setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
1588
- setTimeout(() => setNotice(null), 3000)
1676
+ // F06(审计 C10,0.3.8):两域用 allSettled 而非 all——半成功不再「整单失败」
1677
+ // 掩盖已提交的那半。各自报告,失败域给出明确指引;部分成功时提示里列明
1678
+ // 哪域成功哪域失败,用户知道实际落盘了什么(宿主无跨命名空间事务,
1679
+ // 这是审计建议的务实路线:可观察的分域成败 + 冲突重试)。
1680
+ const settleOf = (label, p) => p.then(() => ({ label, ok: true }), (e) => ({ label, ok: false, error: String((e && e.message) || e) }))
1681
+ Promise.allSettled([settleOf('提供商配置', p1), settleOf('轮询组配置', p2)]).then((results) => {
1682
+ const done = results.map((r) => r.value)
1683
+ const failed = done.filter((d) => d && !d.ok)
1684
+ const okCount = done.length - failed.length
1685
+ if (failed.length === 0) {
1686
+ setNotice('已全部保存(即时生效)')
1687
+ }
1688
+ else if (okCount > 0) {
1689
+ // 部分成功:明确告知哪域已落盘、哪域失败(旧实现统一「保存失败」,
1690
+ // 用户不知道 providers 其实已经提交了)
1691
+ setNotice('部分保存:' + done.filter((d) => d && d.ok).map((d) => d.label).join('、') + ' 已生效;' + failed.map((d) => d.label).join('、') + ' 失败——' + failed[0].error)
1692
+ }
1693
+ else {
1694
+ setNotice('保存失败: ' + failed[0].error)
1695
+ }
1696
+ // 两域都成功才同步本地镜像(半成功时以 refresh 拉回真实状态为准)
1697
+ if (failed.length === 0) {
1698
+ setDraft(clone(cleanProviders))
1699
+ setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
1700
+ }
1701
+ setTimeout(() => setNotice(null), failed.length > 0 ? 8000 : 3000)
1589
1702
  refresh()
1590
- }).catch((e) => setNotice('保存失败: ' + String((e && e.message) || e))).finally(() => setSaving(false))
1703
+ }).finally(() => setSaving(false))
1591
1704
  }
1592
1705
 
1593
1706
  return el('div', { className: 'mcm-root' },
@@ -1600,12 +1713,18 @@ window.__ModuleLoader__.load({
1600
1713
  el('div', { className: 'mcm-actions' },
1601
1714
  notice ? el('span', { style: { fontSize: 12, color: notice.includes('失败') ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' } }, notice) : null,
1602
1715
  btn('刷新', refresh),
1603
- btn(saving ? '保存中…' : '保存全部变更', save, 'primary')
1716
+ el('button', {
1717
+ className: 'mcm-btn primary',
1718
+ // F04:未就绪/保存中禁用;disabled 态样式弱化
1719
+ disabled: !ready || saving,
1720
+ style: (!ready || saving) ? { opacity: 0.55, cursor: 'not-allowed' } : undefined,
1721
+ onClick: save
1722
+ }, !ready ? '加载中…' : saving ? '保存中…' : '保存全部变更')
1604
1723
  )
1605
1724
  ),
1606
1725
  el('div', { className: 'mcm-body' },
1607
1726
  tab === 'config' ? el(ModelConfigPanel, { _state: state, _draft: draft, _setDraft: setDraft, _setNotice: setNotice, _renameProviderInChannels: renameProviderInChannels }) :
1608
- tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})) }) :
1727
+ tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})), _setNotice: setNotice }) :
1609
1728
  el(HealthPanel, { health, _providers: (draft || (state ? state.providers : {})) })
1610
1729
  )
1611
1730
  )
@@ -67,6 +67,9 @@ const healthDomainSpec = defineDomain({
67
67
  },
68
68
  });
69
69
 
70
+ /** 事件去重键:同一笔在 settings 与 domain 之间合并时不重复计。 */
71
+ const eventKey = (e) => `${e.ts}|${e.provider}|${e.model}`;
72
+
70
73
  /**
71
74
  * HealthStore:封装 domain 读写 + 保留策略 + 聚合。
72
75
  * 非 Service(宿主插件自有生命周期),由 apply() 创建并 effect 持有 close。
@@ -102,14 +105,21 @@ export class HealthStore {
102
105
  if (domain) await domain.close().catch(() => {});
103
106
  }
104
107
 
108
+ /** 把一次事件表写入排入类内写链:并发调用逐个重读当前值,不交错、不互相覆盖。
109
+ * 返回本次写入的真实结果(失败会 reject,调用方可记录/重试);链本身用吞错版本续接。
110
+ */
111
+ enqueueEvent(run) {
112
+ const result = this.eventChain.then(run, run);
113
+ this.eventChain = result.catch(() => { });
114
+ return result;
115
+ }
116
+
105
117
  /** 追加一条事件到渠道桶,同时执行窗口过期 + 条数截断。
106
- * 并发安全:桶初始化(put)与追加(update)都排队到类内写链,逐个重读当前值,
107
- * 并发 appendEvent 不交错、不互相覆盖(等价 KvTable.update 的原子语义;
108
- * 桶不存在时 KvTable.update 报 missing-key,故初始化先行)。
118
+ * 桶不存在时先 put 初始化(KvTable.update 对缺失键报 missing-key)。
109
119
  */
110
120
  appendEvent(provider, rec) {
111
121
  if (this.events === null) return Promise.resolve();
112
- this.eventChain = this.eventChain.then(async () => {
122
+ return this.enqueueEvent(async () => {
113
123
  const cutoff = Date.now() - WINDOW_MS;
114
124
  const cur = this.events.get(provider);
115
125
  if (cur === undefined) {
@@ -119,8 +129,7 @@ export class HealthStore {
119
129
  await this.events.update(provider, (c) => ({
120
130
  events: [...c.events.filter((e) => e.ts >= cutoff), rec].slice(-PER_KEY_LIMIT),
121
131
  }));
122
- }).catch(() => { }); // 链不断:失败吞掉(调用方已有 warn),后续追加继续
123
- return this.eventChain;
132
+ });
124
133
  }
125
134
 
126
135
  /** 整组测速结果替换写入。 */
@@ -146,18 +155,35 @@ export class HealthStore {
146
155
  }
147
156
 
148
157
  /**
149
- * 存量迁移:settings health 子树的 records/speedResults 一次性搬入 domain。
150
- * 迁移以「桶里有数据即跳过」防重放;完成后由调用方写迁移标记。
151
- * 并发安全:逐桶判断改走原子链(迁移进行中 recordHealth 并发创建的新桶不会被旧数据覆盖)。
158
+ * 存量迁移:把 settings health 子树的 records/speedResults 并入 domain。
159
+ *
160
+ * 与早期实现的关键差别:**桶已存在时合并去重,而不是整桶跳过**。
161
+ * 原因是 0.3.11 修掉的 schema 缺陷(nonce 类型漂移 → health 整树被宽松兜底
162
+ * 清空)期间,迁移标记可能已被误写、而 settings 里的旧流水从未真正导入;
163
+ * 此时若沿用「桶已存在即跳过」再清理 settings,旧账会被直接删除。
164
+ * 合并保证不丢数据,且幂等(同 ts+provider+model 视为同一笔)。
165
+ * 测速结果是「最新快照」语义,仍以 domain 已有行为准。
152
166
  */
153
167
  async migrateFrom(records, speedResults) {
154
168
  if (this.events === null) return false;
155
169
  const cutoff = Date.now() - WINDOW_MS;
156
170
  for (const [provider, list] of Object.entries(records || {})) {
157
- if (!Array.isArray(list) || list.length === 0) continue;
158
- if (this.events.get(provider) !== undefined) continue; // 已有桶:domain 是权威,不回搬
159
- const events = list.filter((e) => e && e.ts >= cutoff).slice(-PER_KEY_LIMIT);
160
- if (events.length > 0) await this.events.put(provider, { events });
171
+ const incoming = (Array.isArray(list) ? list : [])
172
+ .filter((e) => e && Number.isFinite(e.ts) && e.ts >= cutoff);
173
+ if (incoming.length === 0) continue;
174
+ await this.enqueueEvent(async () => {
175
+ const existing = (this.events.get(provider) || {}).events || [];
176
+ const seen = new Set(existing.map(eventKey));
177
+ const merged = existing.slice();
178
+ for (const e of incoming) {
179
+ const k = eventKey(e);
180
+ if (seen.has(k)) continue;
181
+ seen.add(k);
182
+ merged.push(e);
183
+ }
184
+ merged.sort((a, b) => (a.ts || 0) - (b.ts || 0));
185
+ await this.events.put(provider, { events: merged.slice(-PER_KEY_LIMIT) });
186
+ });
161
187
  }
162
188
  for (const [groupId, rows] of Object.entries(speedResults || {})) {
163
189
  if (!Array.isArray(rows) || rows.length === 0) continue;