@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/README.md +107 -4
- package/package.json +10 -2
- package/src/client.js +187 -68
- package/src/health-store.js +39 -13
- package/src/index.js +430 -110
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 })——
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
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
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
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 (
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
|
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
|
|
1375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
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
|
-
}).
|
|
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
|
-
|
|
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
|
)
|
package/src/health-store.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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;
|