@arcaneorion/dsh-model-channel-manager 0.3.14 → 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/src/client.js CHANGED
@@ -1,10 +1,4 @@
1
- /** @arcaneorion/dsh-model-channel-manager — client 半(现代化设计版)。
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 = (res) => ({ result: res || { ok: false } });
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 !== true) return wrap(res)
45
- const namespaces = (res.value && res.value.namespaces) || []
46
- const mcm = namespaces.find((n) => n && n.ns === NS_MCM)
47
- const value = (mcm && mcm.value) || {}
48
- const user = (mcm && mcm.user) || {}
49
- return { result: { ok: true, value: { namespaces: [...namespaces,
50
- { ns: 'model-channels', value: { groups: value.groups || [], providerOrder: value.providerOrder || [], effortMemory: value.effortMemory || {} }, user: { groups: user.groups, providerOrder: user.providerOrder }, revision: (mcm && mcm.revision) || 0 },
51
- { ns: 'model-channel-health', value: value.health || {}, user: (user && user.health) || {}, revision: (mcm && mcm.revision) || 0 },
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' }, 'HTTP 200 OK') : el('span', { className: 'mcm-badge error' }, res.code || 'ERROR')
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 pollTest = (nonce, provider, model, attempt = 0) => {
567
- if (!apiRef) return
568
- const key = provider + '::' + model
569
- // host 侧测试超时 45s(0.3.13 从 60s 下调,给终态写入留出余量),
570
- // 约 55 次 × 1.2s ≈ 66s 后放弃。放弃时按现场分三种提示,不再一律甩锅「未处理」。
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 = (gid) => {
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 === 'sent' ? el('span', { className: 'mcm-badge success' }, '已触发') : null,
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
- // 默认 7d:digest 是窗口聚合,小窗口在长时间无请求时会合法显示 0,
1091
- // 用户容易误认为「数据丢了」。先默认展示历史,再可切 30m/24h 看实时/近期。
1092
- const [windowMode, setWindowMode] = useState('7d') // '30m' | '24h' | '7d'
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
- // allEvents 必须在两个分支前声明:旧 host 兼容路径(digest 缺失)会在
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) * a.success
1185
- a.latSum = (row.latAvg || 0) * a.success
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
- } else {
1198
- // ---------- 旧 host 原始流水路径(兼容) ----------
1199
- const recsMap = health.records || {}
1200
- const rawEvents = Object.values(recsMap).flat()
1201
- // 时间窗口过滤(赋给函数级 allEvents,供顶部指标卡使用)
1202
- allEvents = rawEvents.filter((e) => (e.ts || 0) >= windowCutoff)
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.success > 0 && m.ttftSum > 0 ? (m.ttftSum / m.success / 1000).toFixed(2) + 's' : '—',
1304
- avgLat: m.success > 0 && m.latSum > 0 ? (m.latSum / m.success / 1000).toFixed(2) + 's' : '—',
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', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 } },
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('div', { className: 'mcm-nav-item' + (windowMode === '30m' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('30m') }, '🟢 近 30 分钟 (实时探针)'),
1323
- el('div', { className: 'mcm-nav-item' + (windowMode === '24h' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('24h') }, '🟡 近 24 小时'),
1324
- el('div', { className: 'mcm-nav-item' + (windowMode === '7d' ? ' active' : ''), style: { padding: '4px 12px', fontSize: 11 }, onClick: () => setWindowMode('7d') }, '🔵 近 7 天全量')
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, '前台 5s 自动刷新流')
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
- digestRows !== null && windowMode !== '7d' ? '窗口内活跃渠道的 7 天累计(聚合口径)' : '真实上游交互捕获')
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
- // 带 revision 的保存 + 「自噪声冲突」自动重试(0.3.14):
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
- const rev = desc && typeof desc.revision === 'number' ? desc.revision : undefined
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
- // 远端未变:属于插件自身健康写入造成的 revision 噪声 → 取新 revision 重试
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
- // 健康页签激活时才 5s 静默轮询(仅拉健康命名空间),切走即停,避免后台常驻全量 describe
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
- return () => clearInterval(timer)
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('div', { className: 'mcm-nav-item' + (tab === 'config' ? ' active' : ''), onClick: () => setTab('config') }, '提供商与模型'),
1769
- el('div', { className: 'mcm-nav-item' + (tab === 'roundrobin' ? ' active' : ''), onClick: () => setTab('roundrobin') }, '故障转移轮询组'),
1770
- el('div', { className: 'mcm-nav-item' + (tab === 'health' ? ' active' : ''), onClick: () => setTab('health') }, '全局健康统计')
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('刷新', refresh),
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 || (state ? state.providers : {})) })
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
  })