@arcaneorion/dsh-model-channel-manager 0.3.0 → 0.3.8

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
@@ -38,22 +38,25 @@ window.__ModuleLoader__.load({
38
38
  { ns: 'model-channel-health', value: value.health || {}, user: (user && user.health) || {}, revision: (mcm && mcm.revision) || 0 },
39
39
  ] } } }
40
40
  },
41
- // 旧签名:update({ ns, patch })
41
+ // 旧签名:update({ ns, patch, expectedRevision })——0.3.2 起透传 revision
42
+ // (宿主 settings.update 第三参;冲突抛 settings/conflict,由调用方提示重载)
42
43
  update: async (args) => {
43
44
  const ns = (args && args.ns) || NS_MCM
44
45
  const patch = (args && args.patch) || {}
45
- if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, undefined))
46
+ const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
47
+ if (ns === 'model-channels') return wrap(await remote.settings.update(NS_MCM, patch, expected))
46
48
  const row = await readMcm()
47
49
  const health = (row && row.value && row.value.health) || {}
48
- return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) }, undefined))
50
+ return wrap(await remote.settings.update(NS_MCM, { health: Object.assign({}, health, patch) }, expected))
49
51
  },
50
52
  // 旧签名:mutate({ ns, ops })——0.2 只对真实行命名空间提供 path-ops;
51
53
  // 本插件自身的健康子树用合并写实现等价语义。
52
54
  mutate: async (args) => {
53
55
  const ns = (args && args.ns) || NS_MCM
54
56
  const ops = (args && args.ops) || []
57
+ const expected = (args && args.expectedRevision !== undefined) ? args.expectedRevision : undefined
55
58
  if (ns !== 'model-channels' && ns !== 'model-channel-health' && ns !== NS_MCM) {
56
- return wrap(await remote.settings.mutate(ns, ops, undefined))
59
+ return wrap(await remote.settings.mutate(ns, ops, expected))
57
60
  }
58
61
  const row = await readMcm()
59
62
  const health = Object.assign({}, (row && row.value && row.value.health) || {})
@@ -78,9 +81,9 @@ window.__ModuleLoader__.load({
78
81
  }
79
82
  }
80
83
  if (ns === 'model-channels') {
81
- return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder }, undefined))
84
+ return wrap(await remote.settings.update(NS_MCM, { groups: top.groups, providerOrder: top.providerOrder }, expected))
82
85
  }
83
- return wrap(await remote.settings.update(NS_MCM, { health }, undefined))
86
+ return wrap(await remote.settings.update(NS_MCM, { health }, expected))
84
87
  },
85
88
  },
86
89
  credentials: {
@@ -589,13 +592,18 @@ window.__ModuleLoader__.load({
589
592
  if (!apiRef) return
590
593
  const key = provider + '::' + model
591
594
  setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
592
- const nonce = Date.now() % 1000000000
595
+ // F03:Date.now()%1e9 同毫秒可碰撞(碰撞 = 两次测试互相认领/覆盖结果),
596
+ // 换 UUID;host 侧 testRequest.nonce 消费方是 !== 比较,字符串/数字都行
597
+ const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
593
598
  const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
594
599
  const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
595
600
  apiRef.settings.update({
596
601
  ns: 'model-channel-health',
597
602
  patch: { testRequest: { nonce, provider, model, prompt, maxTokens } }
598
- }).then(() => {
603
+ }).then((resp) => {
604
+ // F19:RPC resolve ≠ 业务成功——检查信封(旧实现把 ok:false 当「已触发」)
605
+ const r = resp && resp.result ? resp.result : resp
606
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'test request rejected')
599
607
  pollTest(nonce, provider, model)
600
608
  }).catch((e) => {
601
609
  setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', error: String((e && e.message) || e) } }))
@@ -704,7 +712,7 @@ window.__ModuleLoader__.load({
704
712
  el('span', {
705
713
  title: '拖动此手柄调整供应商顺序(保存后生效)',
706
714
  draggable: true,
707
- onDragStart: (e) => { e.stopPropagation(); setDragIdx(idx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(idx)) } catch (_e) {} },
715
+ onDragStart: (e) => { e.stopPropagation(); setDragIdx(realIdx); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(realIdx)) } catch (_e) {} },
708
716
  onDragEnd: () => { setDragIdx(null); setOverIdx(null) },
709
717
  onClick: (e) => e.stopPropagation(),
710
718
  style: { cursor: 'grab', color: 'var(--dsw-alias-label-tertiary)', fontSize: 14, padding: '0 6px', userSelect: 'none' }
@@ -931,6 +939,8 @@ window.__ModuleLoader__.load({
931
939
  const providers = props._providers || {}
932
940
  const [speedState, setSpeedState] = useState({})
933
941
  const [expanded, setExpanded] = useState({})
942
+ // 顶层 notice 的写入通道(ModelConfigView 传入;测速失败要上浮显示)
943
+ const setNotice = props._setNotice || (() => {})
934
944
 
935
945
  const addGroup = () => props.setChannelsDraft((d) => {
936
946
  const base = d || []
@@ -965,12 +975,19 @@ window.__ModuleLoader__.load({
965
975
 
966
976
  const speedtest = (gid) => {
967
977
  if (!apiRef) return
968
- const nonce = Date.now() % 1000000000
978
+ // F03/F19 同 fireTest:UUID 防碰撞 + 信封检查(ok:false 不再当「已触发」)
979
+ const nonce = (crypto.randomUUID && crypto.randomUUID()) || (String(Date.now()) + '-' + Math.random().toString(36).slice(2, 10))
969
980
  setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
970
- apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then(() => {
981
+ apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then((resp) => {
982
+ const r = resp && resp.result ? resp.result : resp
983
+ if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'speed request rejected')
971
984
  setSpeedState((s) => Object.assign({}, s, { [gid]: 'sent' }))
972
985
  setTimeout(() => setSpeedState((s) => Object.assign({}, s, { [gid]: null })), 3000)
973
- }).catch(() => setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' })))
986
+ }).catch((e) => {
987
+ setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' }))
988
+ setNotice('测速触发失败: ' + String((e && e.message) || e))
989
+ setTimeout(() => setNotice(null), 5000)
990
+ })
974
991
  }
975
992
 
976
993
  if (groups.length === 0) return el('div', { className: 'mcm-empty' }, '暂无轮询组,点击下方按钮创建', el('div', { style: { marginTop: 12 } }, btn('+新增轮询组', addGroup, 'primary')))
@@ -1065,11 +1082,13 @@ window.__ModuleLoader__.load({
1065
1082
 
1066
1083
  const now = Date.now()
1067
1084
  const windowCutoff = windowMode === '30m' ? now - 30 * 60 * 1000 : windowMode === '24h' ? now - 24 * 3600 * 1000 : 0
1068
- const recsMap = health.records || {}
1069
- const rawEvents = Object.values(recsMap).flat()
1070
- // 时间窗口过滤
1071
- const allEvents = rawEvents.filter((e) => (e.ts || 0) >= windowCutoff)
1072
1085
 
1086
+ // ---------- 聚合数据源 ----------
1087
+ // 0.3.1 起优先用 host 下发的 digest 小投影(聚合已上移 host,client 不再拉原始流水)。
1088
+ // digest 是 7 天全窗口聚合;30m/24h 视图按 lastTs 近似(lastTs 在窗口内才计入),
1089
+ // 精细窗口统计属后续增强(需要 host 按窗口出多份 digest)。
1090
+ // 旧 host 兼容:无 digest 字段时走原始 records 路径(升级窗口不断供)。
1091
+ const digestRows = Array.isArray(health.digest) ? health.digest : null
1073
1092
  // 按 provider 分组
1074
1093
  const byProvider = new Map()
1075
1094
 
@@ -1092,7 +1111,10 @@ window.__ModuleLoader__.load({
1092
1111
  lastOk: null,
1093
1112
  lastCode: null,
1094
1113
  recentErrors: 0,
1095
- tokSum: 0
1114
+ tokSum: 0,
1115
+ tokIn: 0,
1116
+ tokOut: 0,
1117
+ tokCache: 0
1096
1118
  })
1097
1119
  }
1098
1120
  }
@@ -1103,57 +1125,134 @@ window.__ModuleLoader__.load({
1103
1125
  let totalTokOut = 0
1104
1126
  let totalTokCache = 0
1105
1127
 
1106
- // 累加时间窗口内的真实流水记录
1107
- for (const e of allEvents) {
1108
- if (!e || !e.provider || !e.model) continue
1109
- let pMap = byProvider.get(e.provider)
1110
- if (!pMap) { pMap = new Map(); byProvider.set(e.provider, pMap) }
1111
- let a = pMap.get(e.model)
1112
- if (!a) {
1113
- a = {
1114
- provider: e.provider,
1115
- model: e.model,
1116
- name: e.model,
1117
- total: 0,
1118
- success: 0,
1119
- fail: 0,
1120
- ttftSum: 0,
1121
- latSum: 0,
1122
- lastTs: 0,
1123
- lastOk: null,
1124
- lastCode: null,
1125
- recentErrors: 0,
1126
- tokSum: 0
1128
+ if (digestRows !== null) {
1129
+ // ---------- digest 投影路径 ----------
1130
+ for (const row of digestRows) {
1131
+ if (!row || !row.provider || !row.model) continue
1132
+ // 窗口过滤:lastTs 不在窗口内的渠道不计入该窗口(近似口径,见上注)
1133
+ if (windowCutoff > 0 && (row.lastTs || 0) < windowCutoff) continue
1134
+ let pMap = byProvider.get(row.provider)
1135
+ if (!pMap) { pMap = new Map(); byProvider.set(row.provider, pMap) }
1136
+ let a = pMap.get(row.model)
1137
+ if (!a) {
1138
+ a = {
1139
+ provider: row.provider,
1140
+ model: row.model,
1141
+ name: row.model,
1142
+ total: 0,
1143
+ success: 0,
1144
+ fail: 0,
1145
+ ttftSum: 0,
1146
+ latSum: 0,
1147
+ lastTs: 0,
1148
+ lastOk: null,
1149
+ lastCode: null,
1150
+ recentErrors: 0,
1151
+ tokSum: 0,
1152
+ tokIn: 0,
1153
+ tokOut: 0,
1154
+ tokCache: 0
1155
+ }
1156
+ pMap.set(row.model, a)
1127
1157
  }
1128
- pMap.set(e.model, a)
1129
- }
1130
- a.total++
1131
- const tokIn = (e && e.inputTokens) || 0
1132
- const tokOut = (e && e.outputTokens) || 0
1133
- const tokCache = ((e && e.cacheReadTokens) || 0) + ((e && e.cacheWriteTokens) || 0)
1134
- totalTokIn += tokIn
1135
- totalTokOut += tokOut
1136
- totalTokCache += tokCache
1137
- a.tokSum += tokIn + tokOut + tokCache
1138
- if (e.ok) {
1139
- a.success++
1140
- if (e.ttftMs != null && e.ttftMs >= 0) a.ttftSum += e.ttftMs
1141
- if (e.latencyMs != null && e.latencyMs >= 0) a.latSum += e.latencyMs
1142
- } else {
1143
- a.fail++
1144
- if (e.code) a.lastCode = e.code
1158
+ a.total = row.total || 0
1159
+ a.success = row.success || 0
1160
+ a.fail = a.total - a.success
1161
+ a.ttftSum = (row.ttftAvg || 0) * a.success
1162
+ a.latSum = (row.latAvg || 0) * a.success
1163
+ a.lastTs = row.lastTs || 0
1164
+ a.lastOk = row.lastOk
1165
+ a.lastCode = row.lastCode || null
1166
+ a.tokIn = row.tokIn || 0
1167
+ a.tokOut = row.tokOut || 0
1168
+ a.tokCache = row.tokCache || 0
1169
+ a.tokSum = a.tokIn + a.tokOut + a.tokCache
1170
+ totalTokIn += a.tokIn
1171
+ totalTokOut += a.tokOut
1172
+ totalTokCache += a.tokCache
1145
1173
  }
1146
- if ((e.ts || 0) > a.lastTs) {
1147
- a.lastTs = e.ts || 0
1148
- a.lastOk = e.ok
1174
+ } else {
1175
+ // ---------- 旧 host 原始流水路径(兼容) ----------
1176
+ const recsMap = health.records || {}
1177
+ const rawEvents = Object.values(recsMap).flat()
1178
+ // 时间窗口过滤(赋给函数级 allEvents,供顶部指标卡使用)
1179
+ allEvents = rawEvents.filter((e) => (e.ts || 0) >= windowCutoff)
1180
+ // 累加时间窗口内的真实流水记录
1181
+ for (const e of allEvents) {
1182
+ if (!e || !e.provider || !e.model) continue
1183
+ let pMap = byProvider.get(e.provider)
1184
+ if (!pMap) { pMap = new Map(); byProvider.set(e.provider, pMap) }
1185
+ let a = pMap.get(e.model)
1186
+ if (!a) {
1187
+ a = {
1188
+ provider: e.provider,
1189
+ model: e.model,
1190
+ name: e.model,
1191
+ total: 0,
1192
+ success: 0,
1193
+ fail: 0,
1194
+ ttftSum: 0,
1195
+ latSum: 0,
1196
+ lastTs: 0,
1197
+ lastOk: null,
1198
+ lastCode: null,
1199
+ recentErrors: 0,
1200
+ tokSum: 0,
1201
+ tokIn: 0,
1202
+ tokOut: 0,
1203
+ tokCache: 0
1204
+ }
1205
+ pMap.set(e.model, a)
1206
+ }
1207
+ a.total++
1208
+ const tokIn = (e && e.inputTokens) || 0
1209
+ const tokOut = (e && e.outputTokens) || 0
1210
+ const tokCache = ((e && e.cacheReadTokens) || 0) + ((e && e.cacheWriteTokens) || 0)
1211
+ totalTokIn += tokIn
1212
+ totalTokOut += tokOut
1213
+ totalTokCache += tokCache
1214
+ a.tokIn += tokIn
1215
+ a.tokOut += tokOut
1216
+ a.tokCache += tokCache
1217
+ a.tokSum += tokIn + tokOut + tokCache
1218
+ if (e.ok) {
1219
+ a.success++
1220
+ if (e.ttftMs != null && e.ttftMs >= 0) a.ttftSum += e.ttftMs
1221
+ if (e.latencyMs != null && e.latencyMs >= 0) a.latSum += e.latencyMs
1222
+ } else {
1223
+ a.fail++
1224
+ if (e.code) a.lastCode = e.code
1225
+ }
1226
+ if ((e.ts || 0) > a.lastTs) {
1227
+ a.lastTs = e.ts || 0
1228
+ a.lastOk = e.ok
1229
+ }
1149
1230
  }
1150
1231
  }
1151
1232
 
1152
- const totalRequests = allEvents.length
1153
- const totalSuccess = allEvents.filter((x) => x.ok).length
1233
+ // 顶部指标卡:digest 路径用聚合值求和,旧路径用原始事件计数
1234
+ // (allEvents 提升到函数级:旧 host 回落路径的 else 块在此作用域外定义它)
1235
+ let allEvents = []
1236
+ let totalRequests = 0
1237
+ let totalSuccess = 0
1238
+ let ttftWeightedSum = 0
1239
+ if (digestRows !== null) {
1240
+ for (const pMap of byProvider.values()) {
1241
+ for (const a of pMap.values()) {
1242
+ totalRequests += a.total
1243
+ totalSuccess += a.success
1244
+ ttftWeightedSum += a.ttftSum
1245
+ }
1246
+ }
1247
+ } else {
1248
+ totalRequests = allEvents.length
1249
+ totalSuccess = allEvents.filter((x) => x.ok).length
1250
+ for (const pMap of byProvider.values()) {
1251
+ for (const a of pMap.values()) ttftWeightedSum += a.ttftSum
1252
+ }
1253
+ }
1154
1254
  const globalRate = totalRequests > 0 ? ((totalSuccess / totalRequests) * 100).toFixed(1) + '%' : '100%'
1155
- const validTtfts = allEvents.filter((x) => x.ok && x.ttftMs != null).map((x) => x.ttftMs)
1156
- const avgGlobalTtft = validTtfts.length > 0 ? (validTtfts.reduce((a, b) => a + b, 0) / validTtfts.length / 1000).toFixed(2) + 's' : '—'
1255
+ const avgGlobalTtft = totalSuccess > 0 ? (ttftWeightedSum / totalSuccess / 1000).toFixed(2) + 's' : '—'
1157
1256
 
1158
1257
  const providerGroups = [...byProvider.entries()].map(([provName, modelMap]) => {
1159
1258
  const models = [...modelMap.values()].map((m) => {
@@ -1212,7 +1311,9 @@ window.__ModuleLoader__.load({
1212
1311
  el('div', { className: 'mcm-metric-card' },
1213
1312
  el('span', { className: 'mcm-metric-label' }, windowMode === '30m' ? '近 30 分钟请求' : windowMode === '24h' ? '近 24 小时请求' : '7 天全周期请求'),
1214
1313
  el('span', { className: 'mcm-metric-value' }, totalRequests),
1215
- el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, '真实上游交互捕获')
1314
+ // digest 聚合是 7 天窗口口径;30m/24h 只按「最近活跃」过滤渠道,数值为该渠道 7 天累计
1315
+ el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } },
1316
+ digestRows !== null && windowMode !== '7d' ? '窗口内活跃渠道的 7 天累计(聚合口径)' : '真实上游交互捕获')
1216
1317
  ),
1217
1318
  el('div', { className: 'mcm-metric-card' },
1218
1319
  el('span', { className: 'mcm-metric-label' }, '总 Token 用量'),
@@ -1284,11 +1385,23 @@ window.__ModuleLoader__.load({
1284
1385
  const [notice, setNotice] = useState(null)
1285
1386
  const [saving, setSaving] = useState(false)
1286
1387
  const [tab, setTab] = useState('config')
1388
+ // ready 门(审计 F04):首次 describe 完成前保存不可用——旧实现
1389
+ // channelsDraft || [] 把「还没读到」当「空」,加载前点保存会把轮询组清空
1390
+ const [ready, setReady] = useState(false)
1287
1391
 
1288
1392
  const renameProviderInChannels = (oldId, newId) => {
1393
+ // F17(审计 C05):改名要同步全部引用——主 candidates + 每个 preset 的
1394
+ // candidates。宿主优先使用 activePreset.candidates,漏改它 = 删旧 provider
1395
+ // 后组仍访问不存在的 ID。
1396
+ const renameCands = (cands) => (cands || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
1289
1397
  setChannelsDraft((d) => (d || []).map((g) => {
1290
- const cands = (g.candidates || []).map((c) => (c && c.provider === oldId) ? Object.assign({}, c, { provider: newId }) : c)
1291
- return Object.assign({}, g, { candidates: cands })
1398
+ const next = Object.assign({}, g, { candidates: renameCands(g.candidates) })
1399
+ if (Array.isArray(g.presets)) {
1400
+ next.presets = g.presets.map((p) => (p && Array.isArray(p.candidates))
1401
+ ? Object.assign({}, p, { candidates: renameCands(p.candidates) })
1402
+ : p)
1403
+ }
1404
+ return next
1292
1405
  }))
1293
1406
  }
1294
1407
  const [channels, setChannels] = useState(null)
@@ -1328,14 +1441,22 @@ window.__ModuleLoader__.load({
1328
1441
  const providers = applyProviderOrder((ns && ns.value && ns.value.providers) || {}, savedOrder)
1329
1442
  // user 层 = 仅用户手工声明的路由(用于真正删除与重排序的 mutate 依据)
1330
1443
  const userProviders = (ns && ns.user && ns.user.providers) || {}
1331
- setState({ providers, userProviders, providerOrder: savedOrder || [] })
1444
+ // revision 记录(审计 F05):保存时作为 expectedRevision 传给宿主,
1445
+ // 别处(另一个标签页/host 侧)改过配置则宿主拒绝,防止旧快照覆盖
1446
+ setState({ providers, userProviders, providerOrder: savedOrder || [], nsRevisions: { piAi: (ns && ns.revision) || 0, channels: (cn && cn.revision) || 0 } })
1332
1447
  setDraft((prev) => prev || clone(providers))
1333
1448
  const ch = (cn && cn.value && cn.value.groups) || []
1334
1449
  setChannels(ch)
1335
1450
  setChannelsDraft((prev) => prev || clone(ch))
1336
1451
  const hn = findNs('model-channel-health')
1337
- setHealth(hn ? { records: (hn.value && hn.value.records) || {}, speedResults: (hn.value && hn.value.speedResults) || {}, runtime: (hn.value && hn.value.runtime) || {} } : null)
1338
- }).catch((e) => setNotice('加载失败: ' + String(e)))
1452
+ // 0.3.1:健康权威数据在 host 的 storageDomain;client 只接收小投影 digest。
1453
+ // 兼容旧 host(无 digest 字段)时回落到 records 原始视图,避免升级窗口白屏。
1454
+ const hv = (hn && hn.value) || {}
1455
+ setHealth(hv.digest || (hv.digestAt !== undefined)
1456
+ ? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
1457
+ : (hn ? { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} } : null))
1458
+ setReady(true) // describe 成功:保存解禁(F04 的门)
1459
+ }).catch((e) => { setNotice('加载失败: ' + String(e)); setReady(false) })
1339
1460
  }
1340
1461
 
1341
1462
  useEffect(() => {
@@ -1350,11 +1471,10 @@ window.__ModuleLoader__.load({
1350
1471
  const d = unwrap(resp)
1351
1472
  const hn = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
1352
1473
  if (hn && hn.value) {
1353
- setHealth({
1354
- records: hn.value.records || {},
1355
- speedResults: hn.value.speedResults || {},
1356
- runtime: hn.value.runtime || {}
1357
- })
1474
+ const hv = hn.value
1475
+ setHealth(hv.digest || (hv.digestAt !== undefined)
1476
+ ? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
1477
+ : { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} })
1358
1478
  }
1359
1479
  }).catch(() => {})
1360
1480
  }
@@ -1365,6 +1485,36 @@ window.__ModuleLoader__.load({
1365
1485
 
1366
1486
  const save = () => {
1367
1487
  if (!apiRef) return
1488
+ // F04 门:describe 未完成时 draft/channelsDraft 都是 null——旧实现
1489
+ // (channelsDraft || []) 把「还没读到」当「空」,这里直接拦截而不是兜底空数组
1490
+ if (!ready) {
1491
+ setNotice('配置尚未加载完成,请稍候再保存')
1492
+ setTimeout(() => setNotice(null), 4000)
1493
+ return
1494
+ }
1495
+ // F16 预检:非法/重复组 ID、空候选直接拒绝提交(旧实现保存后 host 静默丢弃,
1496
+ // 界面仍显示「已保存」——H08:3 组保存实际只注册 1 条路由)
1497
+ const groupsToSave = channelsDraft || []
1498
+ const problems = []
1499
+ const seenIds = new Set()
1500
+ for (const g of groupsToSave) {
1501
+ const gid = (g && g.id) || ''
1502
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(gid)) {
1503
+ problems.push('组「' + (gid || '(未命名)') + '」ID 非法(仅小写字母/数字/连字符,字母或数字开头)')
1504
+ } else if (seenIds.has(gid)) {
1505
+ problems.push('组 ID「' + gid + '」重复')
1506
+ } else {
1507
+ seenIds.add(gid)
1508
+ }
1509
+ const candCount = (g && Array.isArray(g.candidates)) ? g.candidates.filter((c) => c && c.provider && c.model).length : 0
1510
+ if (candCount === 0 && gid)
1511
+ problems.push('组「' + gid + '」没有可用候选(候选需同时选择 Provider 和模型)')
1512
+ }
1513
+ if (problems.length > 0) {
1514
+ setNotice('保存被拒绝:' + problems[0] + (problems.length > 1 ? '(共 ' + problems.length + ' 个问题)' : ''))
1515
+ setTimeout(() => setNotice(null), 8000)
1516
+ return
1517
+ }
1368
1518
  setSaving(true)
1369
1519
  setNotice(null)
1370
1520
  // Compat 全量透传:rc.2 PiAiCompatProfile 的 20 个字段全部真实生效——此前
@@ -1406,6 +1556,10 @@ window.__ModuleLoader__.load({
1406
1556
  if (Array.isArray(cleaned.input) && cleaned.input.length > 0) cleaned.input = cleaned.input.slice()
1407
1557
  else delete cleaned.input
1408
1558
  if (cleaned.reasoningEfforts != null && typeof cleaned.reasoningEfforts === 'object' && Object.keys(cleaned.reasoningEfforts).length > 0) cleaned.reasoningEfforts = Object.assign({}, cleaned.reasoningEfforts)
1559
+ else if (cleaned.reasoningEfforts === false) {
1560
+ // F07(审计 C02):false = 显式关闭推理(继承 catalog 默认的对立面)。
1561
+ // 删掉它 = 静默恢复继承,语义被改变。原样保留。
1562
+ }
1409
1563
  else delete cleaned.reasoningEfforts
1410
1564
  const mc = cleanCompat(cleaned.compat)
1411
1565
  if (mc) cleaned.compat = mc
@@ -1413,7 +1567,12 @@ window.__ModuleLoader__.load({
1413
1567
  return cleaned
1414
1568
  })
1415
1569
  const pObj = Object.assign({}, pVal)
1416
- pObj.api = pVal.api || 'openai-completions'
1570
+ // F07(审计 C08):api 缺省 = 继承 catalog 协议;强塞 openai-completions
1571
+ // 会把「继承」变成「显式指定」,materialize 时显式 api 优先于 catalog——
1572
+ // 协议被静默切换。UI 下拉仍显示默认值(渲染默认与持久化默认分离),
1573
+ // 但保存不写显式值。新增供应商时 addGroup 路径已显式给 api。
1574
+ if (typeof pVal.api === 'string' && pVal.api) pObj.api = pVal.api
1575
+ else delete pObj.api
1417
1576
  pObj.models = cleanModels
1418
1577
  if (!(pVal.baseURL && String(pVal.baseURL).trim())) delete pObj.baseURL
1419
1578
  else pObj.baseURL = String(pVal.baseURL).trim()
@@ -1473,9 +1632,14 @@ window.__ModuleLoader__.load({
1473
1632
  for (const k of allKeys) ops.push({ op: 'unset', path: ['providers', k] })
1474
1633
  for (const k of keys) ops.push({ op: 'set', path: ['providers', k], value: cleanProviders[k] })
1475
1634
  if (ops.length === 0) return Promise.resolve()
1476
- return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops }).then((resp) => {
1635
+ // F05:带 revision 保存——llm-pi-ai 是独立 settings 行,冲突由宿主以
1636
+ // settings/conflict 拒绝;提示里给「刷新后重试」指引而不是静默覆盖
1637
+ return apiRef.settings.mutate({ ns: 'llm-pi-ai', ops, expectedRevision: (state && state.nsRevisions && state.nsRevisions.piAi) || undefined }).then((resp) => {
1477
1638
  const r = resp && resp.result ? resp.result : resp
1478
- if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed')
1639
+ if (r && r.ok === false) {
1640
+ const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
1641
+ throw new Error(conflict ? 'llm-pi-ai 配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'llm-pi-ai save failed'))
1642
+ }
1479
1643
  return r
1480
1644
  })
1481
1645
  })() : Promise.resolve()
@@ -1484,22 +1648,46 @@ window.__ModuleLoader__.load({
1484
1648
  const orderPatch = draft ? { providerOrder: Object.keys(cleanProviders) } : {}
1485
1649
  const p2 = apiRef.settings.update({
1486
1650
  ns: 'model-channels',
1651
+ expectedRevision: (state && state.nsRevisions && state.nsRevisions.channels) || undefined,
1487
1652
  // 命名单一身份:写入时统一 virtualModel.name = 组 id——旧的独立呈现名
1488
1653
  // (如遗留的 group-1)在下一次保存时自动归一,无需迁移
1489
1654
  patch: Object.assign({ groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) })) }, orderPatch)
1490
1655
  }).then((resp) => {
1491
1656
  const r = resp && resp.result ? resp.result : resp
1492
- if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'model-channels save failed')
1657
+ if (r && r.ok === false) {
1658
+ const conflict = r.error && (r.error.code === 'settings/conflict' || /conflict/i.test(String(r.error.message || '')))
1659
+ throw new Error(conflict ? '轮询组配置已被其他页面修改(版本冲突),请点「刷新」后重试' : ((r.error && (r.error.message || r.error)) || 'model-channels save failed'))
1660
+ }
1493
1661
  return r
1494
1662
  })
1495
- Promise.all([p1, p2]).then(() => {
1496
- setNotice('已全部保存(即时生效)')
1497
- // 同步草稿与状态为刚保存的清洗版本(含默认请求头合并结果)
1498
- setDraft(clone(cleanProviders))
1499
- setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
1500
- setTimeout(() => setNotice(null), 3000)
1663
+ // F06(审计 C10,0.3.8):两域用 allSettled 而非 all——半成功不再「整单失败」
1664
+ // 掩盖已提交的那半。各自报告,失败域给出明确指引;部分成功时提示里列明
1665
+ // 哪域成功哪域失败,用户知道实际落盘了什么(宿主无跨命名空间事务,
1666
+ // 这是审计建议的务实路线:可观察的分域成败 + 冲突重试)。
1667
+ const settleOf = (label, p) => p.then(() => ({ label, ok: true }), (e) => ({ label, ok: false, error: String((e && e.message) || e) }))
1668
+ Promise.allSettled([settleOf('提供商配置', p1), settleOf('轮询组配置', p2)]).then((results) => {
1669
+ const done = results.map((r) => r.value)
1670
+ const failed = done.filter((d) => d && !d.ok)
1671
+ const okCount = done.length - failed.length
1672
+ if (failed.length === 0) {
1673
+ setNotice('已全部保存(即时生效)')
1674
+ }
1675
+ else if (okCount > 0) {
1676
+ // 部分成功:明确告知哪域已落盘、哪域失败(旧实现统一「保存失败」,
1677
+ // 用户不知道 providers 其实已经提交了)
1678
+ setNotice('部分保存:' + done.filter((d) => d && d.ok).map((d) => d.label).join('、') + ' 已生效;' + failed.map((d) => d.label).join('、') + ' 失败——' + failed[0].error)
1679
+ }
1680
+ else {
1681
+ setNotice('保存失败: ' + failed[0].error)
1682
+ }
1683
+ // 两域都成功才同步本地镜像(半成功时以 refresh 拉回真实状态为准)
1684
+ if (failed.length === 0) {
1685
+ setDraft(clone(cleanProviders))
1686
+ setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
1687
+ }
1688
+ setTimeout(() => setNotice(null), failed.length > 0 ? 8000 : 3000)
1501
1689
  refresh()
1502
- }).catch((e) => setNotice('保存失败: ' + String((e && e.message) || e))).finally(() => setSaving(false))
1690
+ }).finally(() => setSaving(false))
1503
1691
  }
1504
1692
 
1505
1693
  return el('div', { className: 'mcm-root' },
@@ -1512,12 +1700,18 @@ window.__ModuleLoader__.load({
1512
1700
  el('div', { className: 'mcm-actions' },
1513
1701
  notice ? el('span', { style: { fontSize: 12, color: notice.includes('失败') ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' } }, notice) : null,
1514
1702
  btn('刷新', refresh),
1515
- btn(saving ? '保存中…' : '保存全部变更', save, 'primary')
1703
+ el('button', {
1704
+ className: 'mcm-btn primary',
1705
+ // F04:未就绪/保存中禁用;disabled 态样式弱化
1706
+ disabled: !ready || saving,
1707
+ style: (!ready || saving) ? { opacity: 0.55, cursor: 'not-allowed' } : undefined,
1708
+ onClick: save
1709
+ }, !ready ? '加载中…' : saving ? '保存中…' : '保存全部变更')
1516
1710
  )
1517
1711
  ),
1518
1712
  el('div', { className: 'mcm-body' },
1519
1713
  tab === 'config' ? el(ModelConfigPanel, { _state: state, _draft: draft, _setDraft: setDraft, _setNotice: setNotice, _renameProviderInChannels: renameProviderInChannels }) :
1520
- tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})) }) :
1714
+ tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})), _setNotice: setNotice }) :
1521
1715
  el(HealthPanel, { health, _providers: (draft || (state ? state.providers : {})) })
1522
1716
  )
1523
1717
  )