@arcaneorion/dsh-model-channel-manager 0.3.15 → 0.4.0
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 +70 -343
- package/package.json +17 -4
- package/src/channel-state.js +196 -0
- package/src/client.js +126 -307
- package/src/health-store.js +11 -20
- package/src/index.js +170 -622
package/src/client.js
CHANGED
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 遵循 DSH 规范:inject ["slots","connection"],
|
|
3
|
-
* apply(ctx) 捕获 ctx.get("connection").api,组件经闭包使用。
|
|
4
|
-
* 数据通道 = api.settings / api.llm / api.credentials(公共 seam,无私有 RPC)。
|
|
5
|
-
* 会话模型选择器(座位遮蔽)已拆出为独立插件 @arcaneorion/dsh-model-selector-search,
|
|
6
|
-
* 本包只保留 conversation.view 模型配置页签。
|
|
7
|
-
*/
|
|
1
|
+
/** Model configuration, independent runtime status, and cancellable tasks. */
|
|
8
2
|
window.__ModuleLoader__.load({
|
|
9
3
|
id: '@arcaneorion/dsh-model-channel-manager',
|
|
10
4
|
factory: (require) => {
|
|
@@ -30,60 +24,29 @@ window.__ModuleLoader__.load({
|
|
|
30
24
|
return n === 0 ? nonceFallbackCounter + 1 : n
|
|
31
25
|
}
|
|
32
26
|
|
|
33
|
-
// dsh 0.2:客户端远程面由 connection.api 改为 ctx.remote;参数改为位置参数,结果统一为
|
|
34
|
-
// RemoteResult。本适配层把两者还原成 0.1 的调用形状(对象入参 + {result:{ok,value}})。
|
|
35
|
-
// 另:0.2 的 settings 命名空间恒等于「插件行 id」,本插件的两套旧命名空间
|
|
36
|
-
// (model-channels / model-channel-health)现由其实例配置的字段承载,在此合成回旧视图。
|
|
37
27
|
const NS_MCM = 'model-channel-manager'
|
|
38
|
-
const makeLegacyApi = (remote) => {
|
|
39
|
-
const wrap = (
|
|
28
|
+
const makeLegacyApi = (remote, connection) => {
|
|
29
|
+
const wrap = (result) => ({ result: result || { ok: false } })
|
|
40
30
|
return {
|
|
31
|
+
runtime: async (method, payload = {}, signal) => {
|
|
32
|
+
const result = await connection.rpc.call('/api', 'modelChannels/invoke', { args: { method, payload } }, signal)
|
|
33
|
+
if (!result || !result.ok) throw Object.assign(new Error(result?.error?.message || '健康服务不可用,请确认宿主已升级并重新加载'), { code: result?.error?.code })
|
|
34
|
+
return result.value
|
|
35
|
+
},
|
|
41
36
|
settings: {
|
|
42
37
|
describe: async () => {
|
|
43
38
|
const res = await remote.settings.describe()
|
|
44
|
-
if (!res || res.ok
|
|
45
|
-
const namespaces =
|
|
46
|
-
const mcm = namespaces.find(
|
|
47
|
-
const value =
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
] } } }
|
|
53
|
-
},
|
|
54
|
-
// 旧签名:update({ ns, patch, expectedRevision })——0.3.2 起透传 revision
|
|
55
|
-
// (宿主 settings.update 第三参;冲突抛 settings/conflict,由调用方提示重载)
|
|
56
|
-
update: async (args) => {
|
|
57
|
-
const ns = (args && args.ns) || NS_MCM
|
|
58
|
-
const patch = (args && args.patch) || {}
|
|
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))
|
|
68
|
-
},
|
|
69
|
-
// 旧签名:mutate({ ns, ops })——health 子树现在也走真实 path-ops(不再本地模拟整树写)
|
|
70
|
-
mutate: async (args) => {
|
|
71
|
-
const ns = (args && args.ns) || NS_MCM
|
|
72
|
-
const ops = (args && args.ops) || []
|
|
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 = []
|
|
77
|
-
for (const op of ops) {
|
|
78
|
-
const path = (op && op.path) || []
|
|
79
|
-
if (path.length === 0) continue
|
|
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 })
|
|
83
|
-
}
|
|
84
|
-
if (mapped.length === 0) return { result: { ok: true, value: null } }
|
|
85
|
-
return wrap(await remote.settings.mutate(NS_MCM, mapped, expected))
|
|
39
|
+
if (!res || !res.ok) return wrap(res)
|
|
40
|
+
const namespaces = res.value?.namespaces || []
|
|
41
|
+
const mcm = namespaces.find(row => row.ns === NS_MCM)
|
|
42
|
+
const value = mcm?.value || {}
|
|
43
|
+
return wrap({ ok: true, value: { namespaces: [...namespaces, {
|
|
44
|
+
ns: 'model-channels', value: { groups: value.groups || [], providerOrder: value.providerOrder || [], effortMemory: value.effortMemory || {} },
|
|
45
|
+
user: mcm?.user || {}, revision: mcm?.revision,
|
|
46
|
+
}] } })
|
|
86
47
|
},
|
|
48
|
+
update: async ({ ns, patch, expectedRevision }) => wrap(await remote.settings.update(ns === 'model-channels' ? NS_MCM : ns, patch, expectedRevision)),
|
|
49
|
+
mutate: async ({ ns, ops, expectedRevision }) => wrap(await remote.settings.mutate(ns === 'model-channels' ? NS_MCM : ns, ops, expectedRevision)),
|
|
87
50
|
},
|
|
88
51
|
credentials: {
|
|
89
52
|
// 旧签名:set({ ref, value })
|
|
@@ -112,6 +75,12 @@ window.__ModuleLoader__.load({
|
|
|
112
75
|
}
|
|
113
76
|
|
|
114
77
|
const CSS = `
|
|
78
|
+
.mcm-root button { font:inherit; }
|
|
79
|
+
.mcm-nav-item { border:0; background:transparent; }
|
|
80
|
+
.mcm-root :focus-visible { outline:2px solid var(--dsw-alias-brand-primary); outline-offset:3px; }
|
|
81
|
+
.mcm-root button:disabled { opacity:.5; cursor:wait; }
|
|
82
|
+
@media (max-width:760px) { .mcm-root .mcm-header { flex-wrap:wrap; gap:12px; padding:12px; } .mcm-root .mcm-body { padding:16px 12px; } .mcm-root .mcm-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .mcm-root .mcm-nav-item { padding:8px; } .mcm-health-toolbar { flex-wrap:wrap; gap:12px; } .mcm-health-toolbar > div:first-child { flex-wrap:wrap; } .mcm-health-toolbar .mcm-nav-item { white-space:nowrap; } .mcm-health-toolbar > div:last-child { margin-left:auto; } .mcm-root .mcm-health-grid { grid-template-columns:minmax(0,1fr); } .mcm-health-card-h { flex-wrap:wrap; } }
|
|
83
|
+
|
|
115
84
|
.mcm-root { display:flex; flex-direction:column; height:100%; overflow:hidden; font-size:13px; color:var(--dsw-alias-label-primary); background:var(--dsw-alias-bg-base); }
|
|
116
85
|
.mcm-header { display:flex; align-items:center; justify-content:space-between; padding:12px 24px; border-bottom:1px solid var(--dsw-alias-border-l1); background:var(--dsw-alias-bg-layer-1); flex-shrink:0; }
|
|
117
86
|
.mcm-nav { display:flex; background:var(--dsw-alias-bg-layer-2); padding:3px; border-radius:10px; border:1px solid var(--dsw-alias-border-l2); gap:2px; }
|
|
@@ -340,8 +309,9 @@ window.__ModuleLoader__.load({
|
|
|
340
309
|
el('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
|
|
341
310
|
el('span', { className: 'mcm-badge brand' }, win.provider),
|
|
342
311
|
el('span', { className: 'mcm-badge' }, win.model),
|
|
343
|
-
res.status === 'ok' ? el('span', { className: 'mcm-badge success' }, '
|
|
312
|
+
res.status === 'ok' ? el('span', { className: 'mcm-badge success' }, '响应正常') : el('span', { className: 'mcm-badge error' }, res.code || 'ERROR')
|
|
344
313
|
),
|
|
314
|
+
res.persistenceError ? el('div', { className: 'mcm-banner', role: 'alert' }, res.persistenceError) : null,
|
|
345
315
|
res.status === 'ok' ? el('div', { style: { background: 'var(--dsw-alias-bg-layer-2)', padding: 12, borderRadius: 8, border: '1px solid var(--dsw-alias-border-l1)', display: 'flex', flexDirection: 'column', gap: 6 } },
|
|
346
316
|
el('div', { style: { display: 'flex', gap: 16, fontSize: 12, color: 'var(--dsw-alias-state-success-primary)', fontWeight: 600 } },
|
|
347
317
|
el('span', null, 'TTFT 首字响应: ' + (res.ttftMs != null ? (res.ttftMs / 1000).toFixed(2) + 's' : '—')),
|
|
@@ -512,6 +482,37 @@ window.__ModuleLoader__.load({
|
|
|
512
482
|
}).catch((e) => setDisc({ provider: name, error: String((e && e.message) || e) }))
|
|
513
483
|
}
|
|
514
484
|
|
|
485
|
+
function useTaskRunner(setRows) {
|
|
486
|
+
const active = useRef(new Map())
|
|
487
|
+
useEffect(() => () => { for (const job of active.current.values()) job.controller.abort(); active.current.clear() }, [])
|
|
488
|
+
const start = async (kind, request, key) => {
|
|
489
|
+
if (!apiRef || active.current.has(key)) return
|
|
490
|
+
const controller = new AbortController()
|
|
491
|
+
const nonce = createNonce()
|
|
492
|
+
active.current.set(key, { controller, id: String(nonce) })
|
|
493
|
+
const update = row => { if (!controller.signal.aborted) setRows(rows => ({ ...rows, [key]: row })) }
|
|
494
|
+
update({ id: String(nonce), status: 'running' })
|
|
495
|
+
try {
|
|
496
|
+
let row = await apiRef.runtime(kind, { ...request, nonce }, AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]))
|
|
497
|
+
update(row)
|
|
498
|
+
while (row.status === 'running' && !controller.signal.aborted) {
|
|
499
|
+
await new Promise(resolve => { const done = () => { clearTimeout(timer); controller.signal.removeEventListener('abort', done); resolve() }; const timer = setTimeout(done, 800); controller.signal.addEventListener('abort', done, { once: true }) })
|
|
500
|
+
if (controller.signal.aborted) return
|
|
501
|
+
row = await apiRef.runtime('task', { id: String(nonce) }, AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]))
|
|
502
|
+
update(row)
|
|
503
|
+
}
|
|
504
|
+
} catch (error) { update({ id: String(nonce), status: 'error', code: error.code, error: error.message }) }
|
|
505
|
+
finally { active.current.delete(key) }
|
|
506
|
+
}
|
|
507
|
+
const cancel = async key => {
|
|
508
|
+
const job = active.current.get(key)
|
|
509
|
+
if (!job) return
|
|
510
|
+
try { await apiRef.runtime('cancel', { id: job.id }, AbortSignal.timeout(15000)) }
|
|
511
|
+
catch (error) { setRows(rows => ({ ...rows, [key]: { ...rows[key], error: '取消失败:' + error.message } })) }
|
|
512
|
+
}
|
|
513
|
+
return { start, cancel }
|
|
514
|
+
}
|
|
515
|
+
|
|
515
516
|
function ModelConfigPanel(props) {
|
|
516
517
|
const draft = props._draft; const setDraft = props._setDraft
|
|
517
518
|
const setNotice = props._setNotice || (() => {})
|
|
@@ -519,6 +520,7 @@ window.__ModuleLoader__.load({
|
|
|
519
520
|
const [sa, setSa] = useState({})
|
|
520
521
|
const [keyInput, setKeyInput] = useState({})
|
|
521
522
|
const [testStates, setTestStates] = useState({})
|
|
523
|
+
const testRunner = useTaskRunner(setTestStates)
|
|
522
524
|
const [detailWin, setDetailWin] = useState(null)
|
|
523
525
|
const [configWin, setConfigWin] = useState(false)
|
|
524
526
|
const [disc, setDisc] = useState(null)
|
|
@@ -563,63 +565,11 @@ window.__ModuleLoader__.load({
|
|
|
563
565
|
return {}
|
|
564
566
|
}
|
|
565
567
|
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
const giveUp = (e, settledHere) => setTestStates((s) => Object.assign({}, s, { [key]: settledHere
|
|
572
|
-
? { status: 'error', code: 'POLL_TIMEOUT_SETTLED', error: 'host 已完成本次测试但结果条目未能写入(写入失败或被覆盖):请重试,若反复出现请查看宿主日志' }
|
|
573
|
-
: (e && e.status === 'running')
|
|
574
|
-
? { status: 'error', code: 'POLL_TIMEOUT_RUNNING', error: '测试仍在执行(约 66 秒未返回结果):host 已收到请求,可能是渠道慢或上游挂起;稍后可在健康统计里核对' }
|
|
575
|
-
: { status: 'error', code: 'POLL_TIMEOUT', error: '等待测试结果超时:host 未写入结果(可能 host 未重启到新版,或结果写入失败)' } }))
|
|
576
|
-
apiRef.settings.describe({}).then((resp) => {
|
|
577
|
-
const r = resp && resp.result ? resp.result : resp
|
|
578
|
-
const d = r && r.value !== undefined ? r.value : r
|
|
579
|
-
const ns = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
|
|
580
|
-
const tr = (ns && ns.value && ns.value.testResults) || {}
|
|
581
|
-
const e = tr[nonce]
|
|
582
|
-
// 终态判定不能只看 status:整树快照回写曾把 status 改回 running 而保留
|
|
583
|
-
// finishedAt/code/error(0.3.11 事故)。finishedAt 写入后不会被抹掉,把它也当终态,
|
|
584
|
-
// 并按 ok 归一 status —— 这样「僵尸条目」也能显示真实的上游错误而不是假超时。
|
|
585
|
-
const terminal = !!(e && (e.status === 'ok' || e.status === 'error' || e.finishedAt))
|
|
586
|
-
if (terminal) {
|
|
587
|
-
const norm = (e.status === 'ok' || e.status === 'error') ? e : Object.assign({}, e, { status: e.ok === true ? 'ok' : 'error' })
|
|
588
|
-
setTestStates((s) => Object.assign({}, s, { [key]: norm }))
|
|
589
|
-
} else if (attempt >= 55) {
|
|
590
|
-
// host 已结算(lastTestHandledNonce === 自己的 nonce)但结果条目不在 → 写入失败
|
|
591
|
-
const settled = ns && ns.value ? ns.value.lastTestHandledNonce : undefined
|
|
592
|
-
giveUp(e, settled !== undefined && settled !== null && String(settled) === String(nonce))
|
|
593
|
-
} else {
|
|
594
|
-
setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1200)
|
|
595
|
-
}
|
|
596
|
-
}).catch(() => {
|
|
597
|
-
if (attempt >= 55) giveUp()
|
|
598
|
-
else setTimeout(() => pollTest(nonce, provider, model, attempt + 1), 1500)
|
|
599
|
-
})
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
const fireTest = (provider, model) => {
|
|
603
|
-
if (!apiRef) return
|
|
604
|
-
const key = provider + '::' + model
|
|
605
|
-
setTestStates((s) => Object.assign({}, s, { [key]: { status: 'running' } }))
|
|
606
|
-
// F03:Date.now()%1e9 同毫秒可碰撞(碰撞 = 两次测试互相认领/覆盖结果),
|
|
607
|
-
// 换 UUID;host 侧 testRequest.nonce 消费方是 !== 比较,字符串/数字都行
|
|
608
|
-
const nonce = createNonce()
|
|
609
|
-
const prompt = localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己'
|
|
610
|
-
const maxTokens = Number(localStorage.getItem('mcm_test_max_tokens')) || 256
|
|
611
|
-
apiRef.settings.update({
|
|
612
|
-
ns: 'model-channel-health',
|
|
613
|
-
patch: { testRequest: { nonce, provider, model, prompt, maxTokens } }
|
|
614
|
-
}).then((resp) => {
|
|
615
|
-
// F19:RPC resolve ≠ 业务成功——检查信封(旧实现把 ok:false 当「已触发」)
|
|
616
|
-
const r = resp && resp.result ? resp.result : resp
|
|
617
|
-
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'test request rejected')
|
|
618
|
-
pollTest(nonce, provider, model)
|
|
619
|
-
}).catch((e) => {
|
|
620
|
-
setTestStates((s) => Object.assign({}, s, { [key]: { status: 'error', error: String((e && e.message) || e) } }))
|
|
621
|
-
})
|
|
622
|
-
}
|
|
568
|
+
const fireTest = (provider, model) => testRunner.start('test', {
|
|
569
|
+
provider, model,
|
|
570
|
+
prompt: localStorage.getItem('mcm_test_prompt') || '用一句话介绍你自己',
|
|
571
|
+
maxTokens: Math.min(8192, Math.max(1, Number(localStorage.getItem('mcm_test_max_tokens')) || 256)),
|
|
572
|
+
}, provider + '::' + model)
|
|
623
573
|
|
|
624
574
|
const launchTest = (provider, model) => {
|
|
625
575
|
// 模型尚未保存到配置中 → 友好提示
|
|
@@ -829,7 +779,7 @@ window.__ModuleLoader__.load({
|
|
|
829
779
|
}, '✗ 异常 (详情)')
|
|
830
780
|
) : null,
|
|
831
781
|
el('div', { style: { marginLeft: 'auto', display: 'flex', gap: 6 }, onClick: (e) => e.stopPropagation() },
|
|
832
|
-
btn('⚡测试', () => launchTest(name, m.id), 'primary'),
|
|
782
|
+
ts?.status === 'running' ? btn('取消测试', () => testRunner.cancel(name + '::' + m.id)) : btn('⚡测试', () => launchTest(name, m.id), 'primary'),
|
|
833
783
|
btn('✕', () => removeModel(name, mi), 'danger')
|
|
834
784
|
)
|
|
835
785
|
),
|
|
@@ -949,6 +899,7 @@ window.__ModuleLoader__.load({
|
|
|
949
899
|
const groups = props.channelsDraft || []
|
|
950
900
|
const providers = props._providers || {}
|
|
951
901
|
const [speedState, setSpeedState] = useState({})
|
|
902
|
+
const speedRunner = useTaskRunner(setSpeedState)
|
|
952
903
|
const [expanded, setExpanded] = useState({})
|
|
953
904
|
// 顶层 notice 的写入通道(ModelConfigView 传入;测速失败要上浮显示)
|
|
954
905
|
const setNotice = props._setNotice || (() => {})
|
|
@@ -984,22 +935,7 @@ window.__ModuleLoader__.load({
|
|
|
984
935
|
return cur
|
|
985
936
|
}))
|
|
986
937
|
|
|
987
|
-
const speedtest =
|
|
988
|
-
if (!apiRef) return
|
|
989
|
-
// F03/F19 同 fireTest:UUID 防碰撞 + 信封检查(ok:false 不再当「已触发」)
|
|
990
|
-
const nonce = createNonce()
|
|
991
|
-
setSpeedState((s) => Object.assign({}, s, { [gid]: 'running' }))
|
|
992
|
-
apiRef.settings.update({ ns: 'model-channel-health', patch: { speedRequest: { group: gid, nonce } } }).then((resp) => {
|
|
993
|
-
const r = resp && resp.result ? resp.result : resp
|
|
994
|
-
if (r && r.ok === false) throw new Error((r.error && (r.error.message || r.error)) || 'speed request rejected')
|
|
995
|
-
setSpeedState((s) => Object.assign({}, s, { [gid]: 'sent' }))
|
|
996
|
-
setTimeout(() => setSpeedState((s) => Object.assign({}, s, { [gid]: null })), 3000)
|
|
997
|
-
}).catch((e) => {
|
|
998
|
-
setSpeedState((s) => Object.assign({}, s, { [gid]: 'err' }))
|
|
999
|
-
setNotice('测速触发失败: ' + String((e && e.message) || e))
|
|
1000
|
-
setTimeout(() => setNotice(null), 5000)
|
|
1001
|
-
})
|
|
1002
|
-
}
|
|
938
|
+
const speedtest = gid => speedRunner.start('speed', { group: gid }, gid)
|
|
1003
939
|
|
|
1004
940
|
if (groups.length === 0) return el('div', { className: 'mcm-empty' }, '暂无轮询组,点击下方按钮创建', el('div', { style: { marginTop: 12 } }, btn('+新增轮询组', addGroup, 'primary')))
|
|
1005
941
|
|
|
@@ -1008,7 +944,7 @@ window.__ModuleLoader__.load({
|
|
|
1008
944
|
const isOpen = !!expanded[i]
|
|
1009
945
|
const vm = g.virtualModel || {}
|
|
1010
946
|
const stCfg = g.speedTest || {}
|
|
1011
|
-
const st = speedState[g.id]
|
|
947
|
+
const st = speedState[g.id]?.status
|
|
1012
948
|
return el('div', { className: 'mcm-card', key: 'rr_card_' + i },
|
|
1013
949
|
el('div', { className: 'mcm-card-h', onClick: () => setExpanded((e) => Object.assign({}, e, { [i]: !e[i] })) },
|
|
1014
950
|
el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } }, isOpen ? '▼' : '▶'),
|
|
@@ -1016,8 +952,8 @@ window.__ModuleLoader__.load({
|
|
|
1016
952
|
el('span', { className: 'mcm-badge' }, (g.candidates || []).length + ' 候选'),
|
|
1017
953
|
el('span', { className: 'mcm-badge' }, g.strategy || 'sticky'),
|
|
1018
954
|
el('div', { style: { marginLeft: 'auto', display: 'flex', gap: 6 }, onClick: (e) => e.stopPropagation() },
|
|
1019
|
-
btn('⚡测速排序', () => speedtest(g.id), 'primary'),
|
|
1020
|
-
st === 'running' ? el('span', { className: 'mcm-badge brand' }, '测速中…') : st === '
|
|
955
|
+
st === 'running' ? btn('取消测速', () => speedRunner.cancel(g.id)) : btn('⚡测速排序', () => speedtest(g.id), 'primary'),
|
|
956
|
+
st === 'running' ? el('span', { className: 'mcm-badge brand' }, '测速中…') : st === 'ok' ? el('span', { className: 'mcm-badge success' }, '测速完成') : st === 'error' ? el('span', { className: 'mcm-badge error', title: speedState[g.id].error }, speedState[g.id].code === 'ABORTED' ? '已取消' : '测速失败') : null,
|
|
1021
957
|
btn('删', () => { if (confirm('删除轮询组 ' + g.id + '?')) props.setChannelsDraft((d) => d.filter((_, gi) => gi !== i)) }, 'danger')
|
|
1022
958
|
)
|
|
1023
959
|
),
|
|
@@ -1087,26 +1023,9 @@ window.__ModuleLoader__.load({
|
|
|
1087
1023
|
function HealthPanel(props) {
|
|
1088
1024
|
const health = props.health
|
|
1089
1025
|
const providers = props._providers || {}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
const [windowMode
|
|
1093
|
-
|
|
1094
|
-
if (!health) return el('div', { className: 'mcm-empty' }, '健康统计数据准备中…')
|
|
1095
|
-
|
|
1096
|
-
const now = Date.now()
|
|
1097
|
-
const windowCutoff = windowMode === '30m' ? now - 30 * 60 * 1000 : windowMode === '24h' ? now - 24 * 3600 * 1000 : 0
|
|
1098
|
-
|
|
1099
|
-
// ---------- 聚合数据源 ----------
|
|
1100
|
-
// 0.3.1 起优先用 host 下发的 digest 小投影(聚合已上移 host,client 不再拉原始流水)。
|
|
1101
|
-
// digest 是 7 天全窗口聚合;30m/24h 视图按 lastTs 近似(lastTs 在窗口内才计入),
|
|
1102
|
-
// 精细窗口统计属后续增强(需要 host 按窗口出多份 digest)。
|
|
1103
|
-
// 旧 host 兼容:无 digest 字段时走原始 records 路径(升级窗口不断供)。
|
|
1104
|
-
// #11(审计):digest 是空数组时不要遮蔽 settings 里仍存在的 records——
|
|
1105
|
-
// 典型场景是 domain 打开成功但迁移失败(host 保留旧流水不清理)或 host 刚重启
|
|
1106
|
-
// 尚未投影。此时回落到 records 路径,页面显示旧流水而不是全 0。
|
|
1107
|
-
const rawRecords = health.records || {}
|
|
1108
|
-
const hasRawRecords = Object.keys(rawRecords).some((k) => Array.isArray(rawRecords[k]) && rawRecords[k].length > 0)
|
|
1109
|
-
const digestRows = (Array.isArray(health.digest) && (health.digest.length > 0 || !hasRawRecords)) ? health.digest : null
|
|
1026
|
+
const [windowMode, setWindowMode] = useState('7d')
|
|
1027
|
+
if (!health) return el('div', { className: 'mcm-empty', role: 'status' }, props.healthError ? '健康服务连接失败:' + props.healthError : '健康统计数据准备中…', btn('重试', props.refreshHealth))
|
|
1028
|
+
const digestRows = health.windows?.[windowMode] || []
|
|
1110
1029
|
// 按 provider 分组
|
|
1111
1030
|
const byProvider = new Map()
|
|
1112
1031
|
|
|
@@ -1142,18 +1061,8 @@ window.__ModuleLoader__.load({
|
|
|
1142
1061
|
let totalTokIn = 0
|
|
1143
1062
|
let totalTokOut = 0
|
|
1144
1063
|
let totalTokCache = 0
|
|
1145
|
-
|
|
1146
|
-
// else 块里赋值它(供顶部指标卡用);若在此处之后才 let 声明,
|
|
1147
|
-
// 赋值撞未声明变量 → ReferenceError 白屏(host 重启首次加载、digest
|
|
1148
|
-
// 尚未生成时正是此路径)。这是 0.3.1 引入双路径时的遗漏。
|
|
1149
|
-
let allEvents = []
|
|
1150
|
-
|
|
1151
|
-
if (digestRows !== null) {
|
|
1152
|
-
// ---------- digest 投影路径 ----------
|
|
1153
|
-
for (const row of digestRows) {
|
|
1064
|
+
for (const row of digestRows) {
|
|
1154
1065
|
if (!row || !row.provider || !row.model) continue
|
|
1155
|
-
// 窗口过滤:lastTs 不在窗口内的渠道不计入该窗口(近似口径,见上注)
|
|
1156
|
-
if (windowCutoff > 0 && (row.lastTs || 0) < windowCutoff) continue
|
|
1157
1066
|
let pMap = byProvider.get(row.provider)
|
|
1158
1067
|
if (!pMap) { pMap = new Map(); byProvider.set(row.provider, pMap) }
|
|
1159
1068
|
let a = pMap.get(row.model)
|
|
@@ -1181,8 +1090,10 @@ window.__ModuleLoader__.load({
|
|
|
1181
1090
|
a.total = row.total || 0
|
|
1182
1091
|
a.success = row.success || 0
|
|
1183
1092
|
a.fail = a.total - a.success
|
|
1184
|
-
a.ttftSum = (row.ttftAvg || 0) *
|
|
1185
|
-
a.
|
|
1093
|
+
a.ttftSum = (row.ttftAvg || 0) * row.ttftCount
|
|
1094
|
+
a.ttftCount = row.ttftCount
|
|
1095
|
+
a.latCount = row.latCount
|
|
1096
|
+
a.latSum = (row.latAvg || 0) * row.latCount
|
|
1186
1097
|
a.lastTs = row.lastTs || 0
|
|
1187
1098
|
a.lastOk = row.lastOk
|
|
1188
1099
|
a.lastCode = row.lastCode || null
|
|
@@ -1194,87 +1105,12 @@ window.__ModuleLoader__.load({
|
|
|
1194
1105
|
totalTokOut += a.tokOut
|
|
1195
1106
|
totalTokCache += a.tokCache
|
|
1196
1107
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
// 累加时间窗口内的真实流水记录
|
|
1204
|
-
for (const e of allEvents) {
|
|
1205
|
-
if (!e || !e.provider || !e.model) continue
|
|
1206
|
-
let pMap = byProvider.get(e.provider)
|
|
1207
|
-
if (!pMap) { pMap = new Map(); byProvider.set(e.provider, pMap) }
|
|
1208
|
-
let a = pMap.get(e.model)
|
|
1209
|
-
if (!a) {
|
|
1210
|
-
a = {
|
|
1211
|
-
provider: e.provider,
|
|
1212
|
-
model: e.model,
|
|
1213
|
-
name: e.model,
|
|
1214
|
-
total: 0,
|
|
1215
|
-
success: 0,
|
|
1216
|
-
fail: 0,
|
|
1217
|
-
ttftSum: 0,
|
|
1218
|
-
latSum: 0,
|
|
1219
|
-
lastTs: 0,
|
|
1220
|
-
lastOk: null,
|
|
1221
|
-
lastCode: null,
|
|
1222
|
-
recentErrors: 0,
|
|
1223
|
-
tokSum: 0,
|
|
1224
|
-
tokIn: 0,
|
|
1225
|
-
tokOut: 0,
|
|
1226
|
-
tokCache: 0
|
|
1227
|
-
}
|
|
1228
|
-
pMap.set(e.model, a)
|
|
1229
|
-
}
|
|
1230
|
-
a.total++
|
|
1231
|
-
const tokIn = (e && e.inputTokens) || 0
|
|
1232
|
-
const tokOut = (e && e.outputTokens) || 0
|
|
1233
|
-
const tokCache = ((e && e.cacheReadTokens) || 0) + ((e && e.cacheWriteTokens) || 0)
|
|
1234
|
-
totalTokIn += tokIn
|
|
1235
|
-
totalTokOut += tokOut
|
|
1236
|
-
totalTokCache += tokCache
|
|
1237
|
-
a.tokIn += tokIn
|
|
1238
|
-
a.tokOut += tokOut
|
|
1239
|
-
a.tokCache += tokCache
|
|
1240
|
-
a.tokSum += tokIn + tokOut + tokCache
|
|
1241
|
-
if (e.ok) {
|
|
1242
|
-
a.success++
|
|
1243
|
-
if (e.ttftMs != null && e.ttftMs >= 0) a.ttftSum += e.ttftMs
|
|
1244
|
-
if (e.latencyMs != null && e.latencyMs >= 0) a.latSum += e.latencyMs
|
|
1245
|
-
} else {
|
|
1246
|
-
a.fail++
|
|
1247
|
-
if (e.code) a.lastCode = e.code
|
|
1248
|
-
}
|
|
1249
|
-
if ((e.ts || 0) > a.lastTs) {
|
|
1250
|
-
a.lastTs = e.ts || 0
|
|
1251
|
-
a.lastOk = e.ok
|
|
1252
|
-
}
|
|
1253
|
-
}
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
// 顶部指标卡:digest 路径用聚合值求和,旧路径用原始事件计数
|
|
1257
|
-
// (allEvents 已在上方分支前声明)
|
|
1258
|
-
let totalRequests = 0
|
|
1259
|
-
let totalSuccess = 0
|
|
1260
|
-
let ttftWeightedSum = 0
|
|
1261
|
-
if (digestRows !== null) {
|
|
1262
|
-
for (const pMap of byProvider.values()) {
|
|
1263
|
-
for (const a of pMap.values()) {
|
|
1264
|
-
totalRequests += a.total
|
|
1265
|
-
totalSuccess += a.success
|
|
1266
|
-
ttftWeightedSum += a.ttftSum
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
} else {
|
|
1270
|
-
totalRequests = allEvents.length
|
|
1271
|
-
totalSuccess = allEvents.filter((x) => x.ok).length
|
|
1272
|
-
for (const pMap of byProvider.values()) {
|
|
1273
|
-
for (const a of pMap.values()) ttftWeightedSum += a.ttftSum
|
|
1274
|
-
}
|
|
1275
|
-
}
|
|
1276
|
-
const globalRate = totalRequests > 0 ? ((totalSuccess / totalRequests) * 100).toFixed(1) + '%' : '100%'
|
|
1277
|
-
const avgGlobalTtft = totalSuccess > 0 ? (ttftWeightedSum / totalSuccess / 1000).toFixed(2) + 's' : '—'
|
|
1108
|
+
const totalRequests = digestRows.reduce((n, r) => n + r.total, 0)
|
|
1109
|
+
const totalSuccess = digestRows.reduce((n, r) => n + r.success, 0)
|
|
1110
|
+
const measured = digestRows.reduce((n, r) => n + r.ttftCount, 0)
|
|
1111
|
+
const ttftWeightedSum = digestRows.reduce((n, r) => n + (r.ttftAvg || 0) * r.ttftCount, 0)
|
|
1112
|
+
const globalRate = totalRequests > 0 ? ((totalSuccess / totalRequests) * 100).toFixed(1) + '%' : '—'
|
|
1113
|
+
const avgGlobalTtft = measured > 0 ? (ttftWeightedSum / measured / 1000).toFixed(2) + 's' : '—'
|
|
1278
1114
|
|
|
1279
1115
|
const providerGroups = [...byProvider.entries()].map(([provName, modelMap]) => {
|
|
1280
1116
|
const models = [...modelMap.values()].map((m) => {
|
|
@@ -1300,8 +1136,8 @@ window.__ModuleLoader__.load({
|
|
|
1300
1136
|
statusKind,
|
|
1301
1137
|
ratePercent: m.total > 0 ? (rate * 100).toFixed(0) + '%' : '未调用',
|
|
1302
1138
|
rateValue: rate,
|
|
1303
|
-
avgTtft: m.
|
|
1304
|
-
avgLat: m.
|
|
1139
|
+
avgTtft: m.ttftCount > 0 ? (m.ttftSum / m.ttftCount / 1000).toFixed(2) + 's' : '—',
|
|
1140
|
+
avgLat: m.latCount > 0 ? (m.latSum / m.latCount / 1000).toFixed(2) + 's' : '—',
|
|
1305
1141
|
lastTimeStr: m.lastTs > 0 ? new Date(m.lastTs).toLocaleTimeString() : '无调用记录'
|
|
1306
1142
|
}
|
|
1307
1143
|
})
|
|
@@ -1315,27 +1151,28 @@ window.__ModuleLoader__.load({
|
|
|
1315
1151
|
providerGroups.sort((a, b) => b.total - a.total)
|
|
1316
1152
|
|
|
1317
1153
|
return el('div', null,
|
|
1318
|
-
el('div', {
|
|
1154
|
+
props.healthError ? el('div', { className: 'mcm-banner', role: 'alert' }, '刷新失败,保留上次数据:' + props.healthError, btn('重试', props.refreshHealth)) : null,
|
|
1155
|
+
health.storage?.mode === 'memory' || health.storage?.error ? el('div', { className: 'mcm-banner', role: 'status' }, health.storage.error || '当前为内存模式,重启后不保留新增记录。') : null,
|
|
1156
|
+
el('div', { className: 'mcm-health-toolbar', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 } },
|
|
1319
1157
|
el('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
1320
1158
|
el('span', { style: { fontSize: 12, fontWeight: 600, color: 'var(--dsw-alias-label-secondary)' } }, '统计时间窗口:'),
|
|
1321
1159
|
el('div', { className: 'mcm-nav', style: { padding: 2 } },
|
|
1322
|
-
el('
|
|
1323
|
-
el('
|
|
1324
|
-
el('
|
|
1160
|
+
el('button', { type: 'button', className: 'mcm-nav-item' + (windowMode === '30m' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('30m') }, '🟢 近 30 分钟'),
|
|
1161
|
+
el('button', { type: 'button', className: 'mcm-nav-item' + (windowMode === '24h' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('24h') }, '🟡 近 24 小时'),
|
|
1162
|
+
el('button', { type: 'button', className: 'mcm-nav-item' + (windowMode === '7d' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('7d') }, '🔵 近 7 天')
|
|
1325
1163
|
)
|
|
1326
1164
|
),
|
|
1327
1165
|
el('div', { style: { display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--dsw-alias-state-success-primary)' } },
|
|
1328
|
-
el('span', { className: 'mcm-status-dot ok' }),
|
|
1329
|
-
el('span', null, '
|
|
1166
|
+
el('span', { className: 'mcm-status-dot ' + (props.healthError ? 'err' : 'ok') }),
|
|
1167
|
+
el('span', null, props.healthError ? '连接中断' : '更新于 ' + new Date(health.digestAt).toLocaleTimeString()), btn('刷新统计', props.refreshHealth)
|
|
1330
1168
|
)
|
|
1331
1169
|
),
|
|
1332
1170
|
el('div', { className: 'mcm-metrics-grid' },
|
|
1333
1171
|
el('div', { className: 'mcm-metric-card' },
|
|
1334
|
-
el('span', { className: 'mcm-metric-label' }, windowMode === '30m' ? '近 30 分钟请求' : windowMode === '24h' ? '近 24 小时请求' : '7
|
|
1172
|
+
el('span', { className: 'mcm-metric-label' }, windowMode === '30m' ? '近 30 分钟请求' : windowMode === '24h' ? '近 24 小时请求' : '近7天保留记录'),
|
|
1335
1173
|
el('span', { className: 'mcm-metric-value' }, totalRequests),
|
|
1336
|
-
// digest 聚合是 7 天窗口口径;30m/24h 只按「最近活跃」过滤渠道,数值为该渠道 7 天累计
|
|
1337
1174
|
el('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-tertiary)' } },
|
|
1338
|
-
|
|
1175
|
+
'按实际调用时间统计 · 每桶最多保留300条')
|
|
1339
1176
|
),
|
|
1340
1177
|
el('div', { className: 'mcm-metric-card' },
|
|
1341
1178
|
el('span', { className: 'mcm-metric-label' }, '总 Token 用量'),
|
|
@@ -1429,6 +1266,8 @@ window.__ModuleLoader__.load({
|
|
|
1429
1266
|
const [channels, setChannels] = useState(null)
|
|
1430
1267
|
const [channelsDraft, setChannelsDraft] = useState(null)
|
|
1431
1268
|
const [health, setHealth] = useState(null)
|
|
1269
|
+
const [healthError, setHealthError] = useState(null)
|
|
1270
|
+
const healthFlight = useRef(null)
|
|
1432
1271
|
|
|
1433
1272
|
const unwrap = (resp) => {
|
|
1434
1273
|
const r = resp && resp.result ? resp.result : resp
|
|
@@ -1450,28 +1289,21 @@ window.__ModuleLoader__.load({
|
|
|
1450
1289
|
const d = unwrap(resp)
|
|
1451
1290
|
return ((d && d.namespaces) || []).find((n) => n && n.ns === ns) || null
|
|
1452
1291
|
}
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1455
|
-
// 为什么必须这样:本插件的 settings 行(model-channel-manager)同时承载健康投影,
|
|
1456
|
-
// 宿主每 5s 的 digest 叶写都会改动该行的 raw config;而 describe() 的 revision
|
|
1457
|
-
// 正是「raw config 的 JSON 指纹」(dsh-settings:revision += raw !== previous.raw)。
|
|
1458
|
-
// 于是 client 手里那份 revision 在几秒内必然过期,保存被 settings/conflict 拒绝——
|
|
1459
|
-
// 提示里的「其他页面修改」其实是插件自己写的健康数据。
|
|
1460
|
-
//
|
|
1461
|
-
// 策略:① 每次写入前取最新 revision(消除过期);② 仍冲突时比较远端值与我们加载时
|
|
1462
|
-
// 的基线:没变 = 纯 revision 噪声 → 用新 revision 重试;真的变了 = 真冲突 → 报错。
|
|
1292
|
+
// Check the loaded field baseline before using a fresh revision; unrelated changes may retry.
|
|
1463
1293
|
const saveWithRevisionRetry = async ({ ns, baseline, remoteOf, write, conflictMessage, attempts = 3 }) => {
|
|
1464
1294
|
let lastError = null
|
|
1465
1295
|
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1466
1296
|
const desc = await readNsDesc(ns).catch(() => null)
|
|
1467
|
-
|
|
1297
|
+
if (!desc || typeof desc.revision !== 'number') throw new Error('无法读取配置版本,请刷新后重试')
|
|
1298
|
+
if (!sameJson(remoteOf(desc), baseline)) throw new Error(conflictMessage)
|
|
1299
|
+
const rev = desc.revision
|
|
1468
1300
|
const r = await write(rev)
|
|
1469
1301
|
if (!(r && r.ok === false)) return r
|
|
1470
1302
|
lastError = r.error
|
|
1471
1303
|
if (!isConflict(r.error)) throw new Error((r.error && (r.error.message || r.error)) || (ns + ' save failed'))
|
|
1472
1304
|
const now = await readNsDesc(ns).catch(() => null)
|
|
1473
1305
|
if (!sameJson(remoteOf(now), baseline)) throw new Error(conflictMessage)
|
|
1474
|
-
//
|
|
1306
|
+
// Only retry while the fields being edited still match the loaded baseline.
|
|
1475
1307
|
}
|
|
1476
1308
|
throw new Error(conflictMessage.replace(/,请点「刷新」后重试/, '') + '(已自动重试 ' + attempts + ' 次仍未成功)' + (lastError ? ':' + String(lastError.message || lastError) : ''))
|
|
1477
1309
|
}
|
|
@@ -1491,7 +1323,7 @@ window.__ModuleLoader__.load({
|
|
|
1491
1323
|
return out
|
|
1492
1324
|
}
|
|
1493
1325
|
|
|
1494
|
-
const refresh = () => {
|
|
1326
|
+
const refresh = (reset = false) => {
|
|
1495
1327
|
if (!apiRef) return
|
|
1496
1328
|
apiRef.settings.describe({}).then((resp) => {
|
|
1497
1329
|
const d = unwrap(resp)
|
|
@@ -1506,17 +1338,10 @@ window.__ModuleLoader__.load({
|
|
|
1506
1338
|
// revision 记录(审计 F05):保存时作为 expectedRevision 传给宿主,
|
|
1507
1339
|
// 别处(另一个标签页/host 侧)改过配置则宿主拒绝,防止旧快照覆盖
|
|
1508
1340
|
setState({ providers, userProviders, providerOrder: savedOrder || [], nsRevisions: { piAi: (ns && ns.revision) || 0, channels: (cn && cn.revision) || 0 } })
|
|
1509
|
-
setDraft((prev) => prev || clone(providers))
|
|
1341
|
+
setDraft((prev) => reset ? clone(providers) : prev || clone(providers))
|
|
1510
1342
|
const ch = (cn && cn.value && cn.value.groups) || []
|
|
1511
1343
|
setChannels(ch)
|
|
1512
|
-
setChannelsDraft((prev) => prev || clone(ch))
|
|
1513
|
-
const hn = findNs('model-channel-health')
|
|
1514
|
-
// 0.3.1:健康权威数据在 host 的 storageDomain;client 只接收小投影 digest。
|
|
1515
|
-
// 兼容旧 host(无 digest 字段)时回落到 records 原始视图,避免升级窗口白屏。
|
|
1516
|
-
const hv = (hn && hn.value) || {}
|
|
1517
|
-
setHealth(hv.digest || (hv.digestAt !== undefined)
|
|
1518
|
-
? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
|
|
1519
|
-
: (hn ? { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} } : null))
|
|
1344
|
+
setChannelsDraft((prev) => reset ? clone(ch) : prev || clone(ch))
|
|
1520
1345
|
setReady(true) // describe 成功:保存解禁(F04 的门)
|
|
1521
1346
|
}).catch((e) => { setNotice('加载失败: ' + String(e)); setReady(false) })
|
|
1522
1347
|
}
|
|
@@ -1524,25 +1349,23 @@ window.__ModuleLoader__.load({
|
|
|
1524
1349
|
useEffect(() => {
|
|
1525
1350
|
refresh()
|
|
1526
1351
|
}, [])
|
|
1527
|
-
|
|
1352
|
+
const refreshHealth = async () => {
|
|
1353
|
+
if (!apiRef || healthFlight.current) return
|
|
1354
|
+
const controller = new AbortController()
|
|
1355
|
+
healthFlight.current = controller
|
|
1356
|
+
try {
|
|
1357
|
+
const value = await apiRef.runtime('snapshot', {}, AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]))
|
|
1358
|
+
if (!controller.signal.aborted) { setHealth(value); setHealthError(null) }
|
|
1359
|
+
} catch (error) { if (!controller.signal.aborted) setHealthError(error.message) }
|
|
1360
|
+
finally { if (healthFlight.current === controller) healthFlight.current = null }
|
|
1361
|
+
}
|
|
1528
1362
|
useEffect(() => {
|
|
1529
1363
|
if (tab !== 'health') return
|
|
1530
|
-
const pull = () => {
|
|
1531
|
-
if (!apiRef) return
|
|
1532
|
-
apiRef.settings.describe({}).then((resp) => {
|
|
1533
|
-
const d = unwrap(resp)
|
|
1534
|
-
const hn = ((d && d.namespaces) || []).find((n) => n && n.ns === 'model-channel-health')
|
|
1535
|
-
if (hn && hn.value) {
|
|
1536
|
-
const hv = hn.value
|
|
1537
|
-
setHealth(hv.digest || (hv.digestAt !== undefined)
|
|
1538
|
-
? { digest: hv.digest || [], digestAt: hv.digestAt || 0, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} }
|
|
1539
|
-
: { records: hv.records || {}, speedResults: hv.speedResults || {}, runtime: hv.runtime || {} })
|
|
1540
|
-
}
|
|
1541
|
-
}).catch(() => {})
|
|
1542
|
-
}
|
|
1364
|
+
const pull = () => { if (!document.hidden) void refreshHealth() }
|
|
1543
1365
|
pull()
|
|
1544
1366
|
const timer = setInterval(pull, 5000)
|
|
1545
|
-
|
|
1367
|
+
document.addEventListener('visibilitychange', pull)
|
|
1368
|
+
return () => { clearInterval(timer); document.removeEventListener('visibilitychange', pull); healthFlight.current?.abort(); healthFlight.current = null }
|
|
1546
1369
|
}, [tab])
|
|
1547
1370
|
|
|
1548
1371
|
const save = () => {
|
|
@@ -1715,10 +1538,6 @@ window.__ModuleLoader__.load({
|
|
|
1715
1538
|
// (如遗留的 group-1)在下一次保存时自动归一,无需迁移
|
|
1716
1539
|
groups: (channelsDraft || []).map((g) => Object.assign({}, g, { virtualModel: Object.assign({}, g.virtualModel, { name: g.id }) }))
|
|
1717
1540
|
}, orderPatch)
|
|
1718
|
-
// 0.3.14:本行同时承载健康投影,宿主每 5s 的 digest 叶写会不断推高它的 revision
|
|
1719
|
-
// (revision = raw config 的 JSON 指纹)——旧实现用加载时的旧 revision,几乎必然
|
|
1720
|
-
// 撞 settings/conflict,于是出现「部分保存:轮询组配置失败……点击多次才能成功」。
|
|
1721
|
-
// 现在每次写入前取最新 revision;冲突时只有远端 groups/providerOrder 真的变了才报冲突。
|
|
1722
1541
|
const p2 = saveWithRevisionRetry({
|
|
1723
1542
|
ns: 'model-channels',
|
|
1724
1543
|
baseline: { groups: channels || [], providerOrder: (state && state.providerOrder) || [] },
|
|
@@ -1758,20 +1577,20 @@ window.__ModuleLoader__.load({
|
|
|
1758
1577
|
setState((s) => Object.assign({}, s, { providers: cleanProviders, userProviders: cleanProviders, providerOrder: Object.keys(cleanProviders) }))
|
|
1759
1578
|
}
|
|
1760
1579
|
setTimeout(() => setNotice(null), failed.length > 0 ? 8000 : 3000)
|
|
1761
|
-
refresh()
|
|
1580
|
+
if (failed.length === 0) refresh(true)
|
|
1762
1581
|
}).finally(() => setSaving(false))
|
|
1763
1582
|
}
|
|
1764
1583
|
|
|
1765
1584
|
return el('div', { className: 'mcm-root' },
|
|
1766
1585
|
el('div', { className: 'mcm-header' },
|
|
1767
1586
|
el('div', { className: 'mcm-nav' },
|
|
1768
|
-
el('
|
|
1769
|
-
el('
|
|
1770
|
-
el('
|
|
1587
|
+
el('button', { type: 'button', className: 'mcm-nav-item' + (tab === 'config' ? ' active' : ''), onClick: () => setTab('config') }, '提供商与模型'),
|
|
1588
|
+
el('button', { type: 'button', className: 'mcm-nav-item' + (tab === 'roundrobin' ? ' active' : ''), onClick: () => setTab('roundrobin') }, '故障转移轮询组'),
|
|
1589
|
+
el('button', { type: 'button', className: 'mcm-nav-item' + (tab === 'health' ? ' active' : ''), onClick: () => setTab('health') }, '全局健康统计')
|
|
1771
1590
|
),
|
|
1772
1591
|
el('div', { className: 'mcm-actions' },
|
|
1773
1592
|
notice ? el('span', { style: { fontSize: 12, color: notice.includes('失败') ? 'var(--dsw-alias-state-error-primary)' : 'var(--dsw-alias-state-success-primary)' } }, notice) : null,
|
|
1774
|
-
btn('
|
|
1593
|
+
btn('重新加载配置', () => refresh(true)),
|
|
1775
1594
|
el('button', {
|
|
1776
1595
|
className: 'mcm-btn primary',
|
|
1777
1596
|
// F04:未就绪/保存中禁用;disabled 态样式弱化
|
|
@@ -1784,7 +1603,7 @@ window.__ModuleLoader__.load({
|
|
|
1784
1603
|
el('div', { className: 'mcm-body' },
|
|
1785
1604
|
tab === 'config' ? el(ModelConfigPanel, { _state: state, _draft: draft, _setDraft: setDraft, _setNotice: setNotice, _renameProviderInChannels: renameProviderInChannels }) :
|
|
1786
1605
|
tab === 'roundrobin' ? el(RoundrobinPanel, { channels, channelsDraft, setChannelsDraft, _providers: (draft || (state ? state.providers : {})), _setNotice: setNotice }) :
|
|
1787
|
-
el(HealthPanel, { health, _providers: (draft ||
|
|
1606
|
+
el(HealthPanel, { health, healthError, refreshHealth, _providers: (draft || state?.providers || {}) })
|
|
1788
1607
|
)
|
|
1789
1608
|
)
|
|
1790
1609
|
}
|
|
@@ -1793,7 +1612,7 @@ window.__ModuleLoader__.load({
|
|
|
1793
1612
|
function apply(ctx) {
|
|
1794
1613
|
// dsh 0.2:远程调用面为 ctx.remote(connection.api 已移除)。保留 0.1 的调用形状,
|
|
1795
1614
|
// 由下面的适配层把位置参数/RemoteResult 还原成旧的 {result:{ok,value}} 与对象入参。
|
|
1796
|
-
ctx.inject(['remote'], (scope) => { apiRef = makeLegacyApi(scope.remote) })
|
|
1615
|
+
ctx.inject(['remote', 'connection'], (scope) => { apiRef = makeLegacyApi(scope.remote, scope.connection); scope.effect(() => () => { apiRef = null }) })
|
|
1797
1616
|
ctx.effect(() => {
|
|
1798
1617
|
const tag = document.createElement('style')
|
|
1799
1618
|
tag.dataset.mcmStyle = ''
|
|
@@ -1812,6 +1631,6 @@ window.__ModuleLoader__.load({
|
|
|
1812
1631
|
))
|
|
1813
1632
|
}
|
|
1814
1633
|
|
|
1815
|
-
return { name: 'model-channel-manager', inject: ['slots', 'remote', 'remote.settings', 'remote.credentials', 'remote.llm'], apply }
|
|
1634
|
+
return { name: 'model-channel-manager', inject: ['slots', 'connection', 'remote', 'remote.settings', 'remote.credentials', 'remote.llm'], apply }
|
|
1816
1635
|
},
|
|
1817
1636
|
})
|