dsh-plugin-litellm-bridge 0.0.0-stage → 2.0.2

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.
@@ -0,0 +1,1336 @@
1
+ /**
2
+ * dsh-plugin-litellm-bridge —— 客户端半(Web UI)
3
+ * ============================================================================
4
+ *
5
+ * 【必须使用 __ModuleLoader__ 格式】
6
+ * DSH 的客户端半不是普通 ESM 模块,必须包在
7
+ * `window.__ModuleLoader__.load({ id, factory })` 里,且 id 必须等于包名。
8
+ * React 由 factory 的 require('react') 提供。
9
+ *
10
+ * 【4 个 Tab】
11
+ * 1. 网关设置 填地址、测试连通性;master key 只显示「来自 .env 环境变量」
12
+ * 2. 路由配置 profile-a / profile-b × root/coder/research,
13
+ * 首选模型 + 超时 + 可拖拽排序的 fallback 列表;
14
+ * 保存 → 组装 yaml → 调网关热重载
15
+ * 3. 日志面板 拉网关调用日志,按 agent-type / profile 筛选;
16
+ * 橙色高亮触发 fallback,红色标记全链路失败;默认留最近 50 条
17
+ * 4. 当前会话 会话绑定 profile 下拉(持久化到会话变量);
18
+ * 本会话最近降级记录;一键探针测试
19
+ */
20
+
21
+ window.__ModuleLoader__.load({
22
+ id: 'dsh-plugin-litellm-bridge',
23
+ factory: (require) => {
24
+ const React = require('react');
25
+ const module = { exports: {} };
26
+ const exports = module.exports;
27
+
28
+ const h = React.createElement;
29
+ const { useState, useEffect, useCallback, useMemo, useRef } = React;
30
+
31
+ const NS = 'litellm-bridge';
32
+ const AGENT_TYPES = ['root', 'coder', 'research'];
33
+ const PROFILE_IDS = ['profile-a', 'profile-b'];
34
+
35
+ // -----------------------------------------------------------------------
36
+ // 样式(内联,避免依赖外部样式表)
37
+ // -----------------------------------------------------------------------
38
+ // -----------------------------------------------------------------------
39
+ // 设计系统 —— 使用 DSH 官方主题 token(--dsw-alias-*)
40
+ //
41
+ // 【上一版的错】硬编码 rgba(128,128,128,.28) / #ddd 这类颜色,在浅色主题下
42
+ // 边框几乎不可见、文字对比度不足,整页看起来"塌了"。
43
+ // 正确做法:全部走 cordis_inspect Theme.listTokens 给出的 token,
44
+ // 它们由主题引擎同时提供 light/dark 两套值。
45
+ // -----------------------------------------------------------------------
46
+ const T = {
47
+ bgBase: 'var(--dsw-alias-bg-base)',
48
+ bgLayer1: 'var(--dsw-alias-bg-layer-1)',
49
+ bgLayer2: 'var(--dsw-alias-bg-layer-2)',
50
+ bgOverlay: 'var(--dsw-alias-bg-overlay)',
51
+ borderL1: 'var(--dsw-alias-border-l1)',
52
+ borderL2: 'var(--dsw-alias-border-l2)',
53
+ brand: 'var(--dsw-alias-brand-primary)',
54
+ labelPrimary: 'var(--dsw-alias-label-primary)',
55
+ labelSecondary: 'var(--dsw-alias-label-secondary)',
56
+ error: 'var(--dsw-alias-state-error-primary)',
57
+ success: 'var(--dsw-alias-state-success-primary)',
58
+ warn: 'var(--dsw-alias-state-warn-primary)',
59
+ idle: 'var(--dsw-alias-state-idle-primary)',
60
+ };
61
+ // 半透明底色用 color-mix 从 token 派生,保证明暗主题都合适
62
+ const tint = (token, pct) => `color-mix(in srgb, ${token} ${pct}%, transparent)`;
63
+
64
+ const HAIRLINE = `1px solid ${T.borderL1}`;
65
+ const INPUT_H = 32;
66
+
67
+ const S = {
68
+ page: {
69
+ padding: '0', fontFamily: 'inherit', fontSize: 13,
70
+ color: T.labelPrimary, background: T.bgBase, lineHeight: 1.55,
71
+ },
72
+
73
+ // ---- 顶部标签栏 ----
74
+ tabs: {
75
+ display: 'flex', gap: 2, borderBottom: `1px solid ${T.borderL1}`,
76
+ marginBottom: 0, padding: '0 4px',
77
+ },
78
+ tab: (active) => ({
79
+ padding: '9px 14px', cursor: 'pointer', border: 'none', background: 'transparent',
80
+ color: active ? T.labelPrimary : T.labelSecondary,
81
+ borderBottom: active ? `2px solid ${T.brand}` : '2px solid transparent',
82
+ fontSize: 13, fontWeight: active ? 600 : 400, fontFamily: 'inherit',
83
+ }),
84
+
85
+ // ---- 分区(标题 + 内容缩进)----
86
+ section: { padding: '16px 4px 0' },
87
+ h2Row: {
88
+ display: 'flex', alignItems: 'center', gap: 10,
89
+ marginBottom: 12, paddingBottom: 8, borderBottom: HAIRLINE,
90
+ },
91
+ h2: { margin: 0, fontSize: 13, fontWeight: 600, color: T.labelPrimary },
92
+ sectionActions: { marginLeft: 'auto', display: 'flex', gap: 6, alignItems: 'center' },
93
+
94
+ // ---- 字段 ----
95
+ field: { marginBottom: 14 },
96
+ fieldRow: { display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 14, flexWrap: 'wrap' },
97
+ label: {
98
+ display: 'block', marginBottom: 6, fontSize: 12,
99
+ fontWeight: 500, color: T.labelSecondary,
100
+ },
101
+ hint: { fontSize: 11.5, color: T.labelSecondary, marginTop: 5, lineHeight: 1.5 },
102
+
103
+ input: {
104
+ width: '100%', boxSizing: 'border-box', height: INPUT_H, padding: '0 10px',
105
+ fontSize: 13, fontFamily: 'inherit', color: T.labelPrimary,
106
+ background: T.bgLayer1, border: `1px solid ${T.borderL2}`,
107
+ borderRadius: 6, outline: 'none',
108
+ },
109
+ inputAuto: {
110
+ height: INPUT_H, padding: '0 10px', fontSize: 13, fontFamily: 'inherit',
111
+ color: T.labelPrimary, background: T.bgLayer1,
112
+ border: `1px solid ${T.borderL2}`, borderRadius: 6, boxSizing: 'border-box',
113
+ },
114
+
115
+ // ---- 按钮 ----
116
+ btn: (kind) => ({
117
+ height: 30, padding: '0 14px', fontSize: 12.5, fontFamily: 'inherit',
118
+ cursor: 'pointer', borderRadius: 6, whiteSpace: 'nowrap',
119
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
120
+ border: kind === 'primary' ? '1px solid transparent' : `1px solid ${T.borderL2}`,
121
+ background: kind === 'primary' ? T.brand : T.bgLayer1,
122
+ color: kind === 'primary' ? '#fff' : T.labelPrimary,
123
+ fontWeight: kind === 'primary' ? 600 : 400,
124
+ }),
125
+ btnSm: {
126
+ height: 26, padding: '0 10px', fontSize: 12, fontFamily: 'inherit', cursor: 'pointer',
127
+ borderRadius: 6, border: `1px solid ${T.borderL2}`,
128
+ background: T.bgLayer1, color: T.labelSecondary,
129
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
130
+ },
131
+
132
+ // ---- 通知 ----
133
+ notice: (tone) => {
134
+ const color = tone === 'error' ? T.error : tone === 'warn' ? T.warn : tone === 'ok' ? T.success : T.labelSecondary;
135
+ return {
136
+ padding: '9px 12px', borderRadius: 6, fontSize: 12.5, marginBottom: 12,
137
+ lineHeight: 1.55, color,
138
+ background: tint(color === T.labelSecondary ? T.borderL1 : color, 10),
139
+ border: `1px solid ${tint(color === T.labelSecondary ? T.borderL2 : color, 35)}`,
140
+ };
141
+ },
142
+
143
+ // ---- 面板(用于列表容器)----
144
+ panel: {
145
+ border: `1px solid ${T.borderL1}`, borderRadius: 8,
146
+ background: T.bgLayer1, overflow: 'hidden',
147
+ },
148
+
149
+ // ---- 徽章 ----
150
+ badge: (color, bg) => ({
151
+ display: 'inline-flex', alignItems: 'center', padding: '1px 7px',
152
+ borderRadius: 10, fontSize: 11, lineHeight: '18px', whiteSpace: 'nowrap',
153
+ color: color || T.labelSecondary,
154
+ background: bg || tint(T.borderL2, 22),
155
+ }),
156
+ mono: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 11.5 },
157
+
158
+ // 状态色(字符串,供 style.color 直接用)
159
+ ok: T.success,
160
+ err: T.error,
161
+ warn: T.warn,
162
+
163
+ // ---- 日志/结果行 ----
164
+ logRow: (kind) => ({
165
+ borderLeft: `3px solid ${kind === 'fallback' ? T.warn : kind === 'failed' ? T.error : T.borderL1}`,
166
+ background: kind === 'fallback' ? tint(T.warn, 8)
167
+ : kind === 'failed' ? tint(T.error, 8) : 'transparent',
168
+ padding: '7px 10px', marginBottom: 4, borderRadius: 4, fontSize: 12,
169
+ }),
170
+
171
+ // 兼容旧引用
172
+ wrap: { fontFamily: 'inherit', fontSize: 13, color: T.labelPrimary, background: T.bgBase },
173
+ card: { marginBottom: 14 },
174
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
175
+ };
176
+
177
+
178
+ // -----------------------------------------------------------------------
179
+ // Host RPC 通道
180
+ //
181
+ // 【实测踩到的坑】最初写 connection.rpc.call('/api', 'litellm-bridge/<action>'),
182
+ // 前端会拼成 /api/litellm-bridge/<action>,实测报
183
+ // `transport for /api/litellm-bridge/models/sync HTTP 404`
184
+ // 根因不是路由写错,而是 **/api/** 是 dsh-api-gateway 的 RPC 分发领地**,
185
+ // 插件注册在它下面的路由永远匹配不到。
186
+ // 对照能正常工作的真实插件:
187
+ // dsh-connect-trae → /plugins/dsh-connect-trae/usage
188
+ // dsh-qq-plugin → /.dsh-qq-plugin
189
+ // 它们都刻意避开 /api。所以这里改成插件命名空间 + 直接 fetch
190
+ // (同源请求会自动带浏览器会话 cookie,正是 dsh-connect-trae 的做法)。
191
+ // -----------------------------------------------------------------------
192
+ const ROUTE_BASE = '/plugins/dsh-plugin-litellm-bridge';
193
+
194
+ function createApi(_ctx) {
195
+ return async function call(action, args = {}) {
196
+ try {
197
+ const res = await fetch(`${ROUTE_BASE}/${action}`, {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ credentials: 'same-origin',
201
+ body: JSON.stringify({ action, args }),
202
+ });
203
+ if (!res.ok) {
204
+ // 401/404 这类要如实说明,不能伪装成业务错误
205
+ return { ok: false, error: `HTTP ${res.status} ${res.statusText || ''}`.trim() };
206
+ }
207
+ const json = await res.json();
208
+ return json && typeof json === 'object' ? json : { ok: false, error: '响应不是 JSON 对象' };
209
+ } catch (err) {
210
+ return { ok: false, error: String(err?.message || err) };
211
+ }
212
+ };
213
+ }
214
+
215
+ // -----------------------------------------------------------------------
216
+ // 【需求7】网关状态卡:运行中 / 已停止 / 异常
217
+ //
218
+ // 为什么要有:网关是插件托管的后台子进程,用户看不见。它悄悄挂掉时,
219
+ // 表现只是「降级不生效」,极难归因。所以状态必须显式展示。
220
+ // -----------------------------------------------------------------------
221
+ function GatewayStatusCard({ api }) {
222
+ const [st, setSt] = useState(null);
223
+ const [busy, setBusy] = useState(false);
224
+
225
+ const load = useCallback(async () => {
226
+ const r = await api('gateway/lifecycle');
227
+ if (r.ok) setSt(r.data);
228
+ }, [api]);
229
+
230
+ useEffect(() => { load(); }, [load]);
231
+ // 每 8 秒轻量轮询,避免用户以为「卡住了」
232
+ useEffect(() => {
233
+ const t = setInterval(() => { void load(); }, 8000);
234
+ return () => clearInterval(t);
235
+ }, [load]);
236
+
237
+ const act = async (a) => {
238
+ setBusy(true);
239
+ await api(a);
240
+ await load();
241
+ setBusy(false);
242
+ };
243
+
244
+ const state = st?.state || 'stopped';
245
+ const tone = state === 'running' ? 'ok' : state === 'error' ? 'error' : 'warn';
246
+ const label = st?.stateLabel || (state === 'running' ? '运行中' : state === 'error' ? '异常' : '已停止');
247
+
248
+ return h('div', { style: S.panel },
249
+ h('div', { style: { padding: '10px 12px', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } },
250
+ h('span', {
251
+ style: {
252
+ width: 8, height: 8, borderRadius: '50%', display: 'inline-block',
253
+ background: tone === 'ok' ? T.success : tone === 'error' ? T.error : T.idle,
254
+ },
255
+ }),
256
+ h('span', { style: { fontWeight: 600 } }, 'LiteLLM 网关'),
257
+ h('span', { style: S.badge(tone === 'ok' ? T.success : tone === 'error' ? T.error : T.warn) }, label),
258
+ st?.owned && h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, '本插件托管'),
259
+ !st?.owned && st?.alive && h('span', { style: S.badge() }, '外部运行 · 不接管'),
260
+ h('span', { style: { marginLeft: 'auto', display: 'flex', gap: 6 } },
261
+ h('button', { style: S.btnSm, disabled: busy, onClick: () => act('gateway/start') },
262
+ busy ? '…' : '启动'),
263
+ h('button', { style: S.btnSm, disabled: busy, onClick: () => act('gateway/stop') }, '停止'),
264
+ h('button', { style: S.btnSm, disabled: busy, onClick: load }, '刷新'),
265
+ ),
266
+ ),
267
+ h('div', { style: { padding: '0 12px 10px', ...S.mono, fontSize: 11, color: T.labelSecondary, lineHeight: 1.7 } },
268
+ h('div', null, `地址:${st?.url || '—'}`),
269
+ h('div', null, `托管策略:${st?.ownership || '—'}${st?.autoStart === false ? '(autoStartGateway 已关闭)' : ''}`),
270
+ st?.lastError && h('div', { style: { color: T.error } }, `最近错误:${st.lastError}`),
271
+ !st?.lastError && st?.lastExit?.abnormal &&
272
+ h('div', { style: { color: T.error } }, `异常退出记录:code=${st.lastExit.code}`),
273
+ ),
274
+ );
275
+ }
276
+
277
+ // -----------------------------------------------------------------------
278
+ // 【需求4】一键探针:测的是「当前 profile 里从 DSH 选中的这组模型」
279
+ //
280
+ // 与旧版的差别:旧版从网关同步模型清单再全量遍历;现在只测用户实际
281
+ // 配进降级链的那几个模型 —— 这才能回答「我这套降级方案现在通不通」。
282
+ // -----------------------------------------------------------------------
283
+ function ProfileProbePanel({ api, routes, profileIds }) {
284
+ const [pid, setPid] = useState(profileIds?.[0] || 'profile-a');
285
+ const [agentType, setAgentType] = useState('root');
286
+ const [busy, setBusy] = useState(false);
287
+ const [result, setResult] = useState(null);
288
+ const [err, setErr] = useState(null);
289
+
290
+ const chainFor = useCallback((p, at) => {
291
+ const cfg = routes?.[p]?.agents?.[at];
292
+ if (!cfg) return [];
293
+ return [cfg.primary, ...(cfg.fallbacks || [])].filter(Boolean);
294
+ }, [routes]);
295
+
296
+ const chain = chainFor(pid, agentType);
297
+
298
+ const run = async () => {
299
+ setBusy(true); setResult(null); setErr(null);
300
+ const r = await api('probe/profile', { profileId: pid, agentType, timeoutMs: 60000 });
301
+ if (r.ok) setResult(r.data);
302
+ else setErr(r.error || '探针失败');
303
+ setBusy(false);
304
+ };
305
+
306
+ const latencyColor = (ms) =>
307
+ ms == null ? T.labelSecondary : ms < 5000 ? T.success : ms < 20000 ? T.warn : T.error;
308
+
309
+ return h('div', { style: S.card },
310
+ h('div', { style: S.h2Row },
311
+ h('h2', { style: S.h2 }, '一键探针'),
312
+ h('div', { style: S.sectionActions },
313
+ h('select', {
314
+ style: { ...S.inputAuto, minWidth: 110 },
315
+ value: pid, onChange: (e) => setPid(e.target.value),
316
+ }, ...(profileIds || []).map((p) => h('option', { key: p, value: p }, p))),
317
+ h('select', {
318
+ style: { ...S.inputAuto, minWidth: 110 },
319
+ value: agentType, onChange: (e) => setAgentType(e.target.value),
320
+ },
321
+ h('option', { value: 'root' }, 'root(主代理)'),
322
+ h('option', { value: 'coder' }, 'coder(子代理)'),
323
+ h('option', { value: 'research' }, 'research(子代理)'),
324
+ ),
325
+ h('button', {
326
+ style: { ...S.btn('primary'), height: 26, padding: '0 10px', fontSize: 12 },
327
+ disabled: busy || chain.length === 0,
328
+ onClick: run,
329
+ }, busy ? '测试中…' : '开始测试'),
330
+ ),
331
+ ),
332
+
333
+ h('div', { style: { ...S.hint, marginBottom: 10 } },
334
+ chain.length
335
+ ? `将按顺序测试 ${chain.length} 个模型(链路顺序即降级优先级)`
336
+ : '该链还没有选模型 —— 请先在上方「路由配置」里从 DSH 模型池选好模型'),
337
+
338
+ chain.length > 0 && h('div', { style: { marginBottom: 10 } },
339
+ chain.map((m, i) => {
340
+ const row = result?.ordered?.find((x) => x.model === m);
341
+ return h('div', {
342
+ key: `${m}-${i}`,
343
+ style: {
344
+ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 9px',
345
+ marginBottom: 3, borderRadius: 5, fontSize: 12,
346
+ background: T.bgLayer1, border: `1px solid ${T.borderL1}`,
347
+ },
348
+ },
349
+ h('span', { style: S.badge() }, String(i + 1)),
350
+ i === 0 && h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, '首选'),
351
+ h('span', { style: { ...S.mono, flex: 1, wordBreak: 'break-all' } }, m),
352
+ row && h('span', { style: { ...S.mono, fontSize: 11, color: row.ok ? T.success : T.error } },
353
+ row.ok ? '✓' : '✗'),
354
+ row && row.latencyMs != null && h('span', {
355
+ style: { ...S.mono, fontSize: 11, color: latencyColor(row.latencyMs), minWidth: 58, textAlign: 'right' },
356
+ }, `${Math.round(row.latencyMs)}ms`),
357
+ row && !row.ok && h('span', {
358
+ style: { ...S.mono, fontSize: 10.5, color: T.error, flex: '1 1 100%', marginTop: 2, wordBreak: 'break-all' },
359
+ }, String(row.error || '').slice(0, 160)),
360
+ );
361
+ }),
362
+ ),
363
+
364
+ err && h('div', { style: S.notice('error') }, err),
365
+
366
+ result && h('div', null,
367
+ h('div', { style: { ...S.row, marginBottom: 8 } },
368
+ h('span', { style: S.badge(T.success, tint(T.success, 14)) }, `通过 ${result.passed}`),
369
+ h('span', { style: S.badge(T.error, tint(T.error, 14)) }, `失败 ${result.failed}`),
370
+ h('span', { style: S.badge() }, `共 ${result.total}`),
371
+ h('span', { style: { ...S.mono, fontSize: 11, opacity: 0.5, marginLeft: 'auto' } },
372
+ `${result.profile} · ${result.agentType}`),
373
+ ),
374
+ h('div', { style: { ...S.hint } }, '失败项已写入「日志面板」。'),
375
+ ),
376
+ );
377
+ }
378
+
379
+ // -----------------------------------------------------------------------
380
+ // Tab 1:网关设置
381
+ // -----------------------------------------------------------------------
382
+ function GatewayTab({ api, state, setState }) {
383
+ const [url, setUrl] = useState(state?.gateway?.url || 'http://127.0.0.1:4000');
384
+ const [busy, setBusy] = useState(false);
385
+ const [test, setTest] = useState(null);
386
+ const [keyStatus, setKeyStatus] = useState(null);
387
+
388
+ useEffect(() => {
389
+ api('gateway/keyStatus').then((r) => { if (r.ok) setKeyStatus(r.data); });
390
+ }, [api]);
391
+
392
+ const save = async () => {
393
+ setBusy(true);
394
+ const r = await api('gateway/save', { url, timeoutMs: state?.gateway?.timeoutMs || 30000 });
395
+ if (r.ok) setState(r.data);
396
+ setBusy(false);
397
+ };
398
+
399
+ const runTest = async () => {
400
+ setBusy(true); setTest(null);
401
+ const r = await api('gateway/test', { url });
402
+ if (r.ok) setTest(r.data);
403
+ else setTest({ ok: false, steps: [{ name: '请求失败', ok: false, detail: r.error }] });
404
+ setBusy(false);
405
+ };
406
+
407
+ return h('div', null,
408
+ h('div', { style: S.card },
409
+ h('div', { style: S.field },
410
+ h('label', { style: S.label }, '网关地址'),
411
+ h('input', {
412
+ style: S.input, value: url, placeholder: 'http://127.0.0.1:4000',
413
+ onChange: (e) => setUrl(e.target.value),
414
+ }),
415
+ ),
416
+ h('div', { style: S.field },
417
+ h('label', { style: S.label }, '网关 master admin key'),
418
+ // 密钥区:绝不显示明文,只显示来源标记
419
+ h('div', {
420
+ style: { ...S.input, display: 'flex', alignItems: 'center', gap: 8, opacity: 0.9 },
421
+ },
422
+ h('span', { style: S.badge(T.success, tint(T.success, 14)) }, '来自 .env 环境变量'),
423
+ h('span', { style: { ...S.mono, opacity: 0.6 } },
424
+ keyStatus?.hasKey ? 'LITELLM_MASTER_KEY 已注入' : '未检测到(请在网关侧 .env 配置)'),
425
+ ),
426
+ ),
427
+ h('div', { style: S.row },
428
+ h('button', { style: S.btn('primary'), onClick: runTest, disabled: busy }, busy ? '测试中…' : '测试连通性'),
429
+ h('button', { style: S.btn(), onClick: save, disabled: busy }, '保存'),
430
+ ),
431
+ ),
432
+
433
+ test && h('div', { style: S.card },
434
+ h('div', { style: { marginBottom: 8, fontWeight: 600 } },
435
+ test.ok ? h('span', { style: S.ok }, `✓ 连通正常(${test.passed}/${test.total})`)
436
+ : h('span', { style: S.err }, `✗ 连通异常(${test.passed}/${test.total})`),
437
+ ),
438
+ (test.steps || []).map((s, i) => h('div', {
439
+ key: i, style: { display: 'flex', gap: 8, padding: '4px 0', alignItems: 'baseline' },
440
+ },
441
+ h('span', { style: s.ok ? S.ok : S.err }, s.ok ? '✓' : '✗'),
442
+ h('span', { style: { minWidth: 110 } }, s.name),
443
+ h('span', { style: { ...S.mono, opacity: 0.6 } }, s.path),
444
+ h('span', { style: { ...S.mono, opacity: 0.85, marginLeft: 'auto', textAlign: 'right' } }, s.detail),
445
+ )),
446
+ h('div', { style: { marginTop: 8, ...S.mono, opacity: 0.55 } },
447
+ `key 来源:${test.keySource || '—'}`),
448
+ ),
449
+
450
+ state?.statePath && h('div', { style: { ...S.mono, opacity: 0.45, marginTop: 4 } },
451
+ `配置存储:${state.statePath}`),
452
+
453
+ // 【需求7】网关状态:运行中 / 已停止 / 异常
454
+ h(GatewayStatusCard, { api }),
455
+
456
+ // 【需求4】一键探针:测当前 profile 选中的那组模型
457
+ h(ProfileProbePanel, {
458
+ api,
459
+ routes: state?.routes,
460
+ profileIds: state?.profileIds || Object.keys(state?.routes || {}),
461
+ }),
462
+ );
463
+ }
464
+
465
+ // -----------------------------------------------------------------------
466
+ // Tab 2:路由配置
467
+ //
468
+ // 【需求3】职责重新定义:
469
+ // - 新建 / 编辑 / 删除 profile
470
+ // - 对 root / coder / research 三类 Agent,从 **DSH 模型池** 选模型、
471
+ // 拖拽排序,组装 fallback 降级序列、填超时
472
+ // - 保存后下发降级顺序给 LiteLLM 网关(网关只执行调度)
473
+ // 模型下拉的数据源是 DSH【设置-模型】,不再是网关。
474
+ // -----------------------------------------------------------------------
475
+ function RouteTab({ api, state, setState }) {
476
+ const profileIds = state?.profileIds || Object.keys(state?.routes || {});
477
+ const [pid, setPid] = useState(profileIds[0] || 'profile-a');
478
+ const [draft, setDraft] = useState(null);
479
+ const [busy, setBusy] = useState(false);
480
+ const [msg, setMsg] = useState(null);
481
+ const [yaml, setYaml] = useState('');
482
+ const [pool, setPool] = useState({ models: [], providers: [], hint: '', reason: null });
483
+ const [poolBusy, setPoolBusy] = useState(false);
484
+ const [newId, setNewId] = useState('');
485
+ const [showNew, setShowNew] = useState(false);
486
+ const dragRef = useRef(null);
487
+
488
+ useEffect(() => {
489
+ if (state?.routes) setDraft(JSON.parse(JSON.stringify(state.routes)));
490
+ }, [state?.routes]);
491
+
492
+ // 【需求2】模型池来自 DSH 设置-模型
493
+ const loadPool = useCallback(async () => {
494
+ setPoolBusy(true);
495
+ const r = await api('models/pool');
496
+ if (r.ok) setPool(r.data || { models: [], providers: [], hint: '' });
497
+ else setPool({ models: [], providers: [], hint: `读取失败:${r.error}`, reason: r.error });
498
+ setPoolBusy(false);
499
+ }, [api]);
500
+
501
+ useEffect(() => { void loadPool(); }, [loadPool]);
502
+
503
+ // 当前 pid 被删除 / 不存在时回落到第一个
504
+ useEffect(() => {
505
+ if (draft && !draft[pid]) {
506
+ const first = Object.keys(draft)[0];
507
+ if (first) setPid(first);
508
+ }
509
+ }, [draft, pid]);
510
+
511
+ if (!draft) return h('div', { style: { opacity: 0.6 } }, '加载中…');
512
+ const prof = draft[pid];
513
+ if (!prof) return h('div', { style: { opacity: 0.6 } }, '没有可用的 profile');
514
+
515
+ const setAgent = (at, patch) =>
516
+ setDraft((d) => {
517
+ const next = JSON.parse(JSON.stringify(d));
518
+ next[pid].agents[at] = { ...next[pid].agents[at], ...patch };
519
+ return next;
520
+ });
521
+
522
+ // ---- 拖拽排序:fallback 列表顺序即降级优先级 ----
523
+ const onDragStart = (at, idx) => { dragRef.current = { at, idx }; };
524
+ const onDrop = (at, idx) => {
525
+ const src = dragRef.current;
526
+ if (!src || src.at !== at || src.idx === idx) return;
527
+ const list = [...prof.agents[at].fallbacks];
528
+ const [moved] = list.splice(src.idx, 1);
529
+ list.splice(idx, 0, moved);
530
+ setAgent(at, { fallbacks: list });
531
+ dragRef.current = null;
532
+ };
533
+
534
+ /** 把某个模型从「备选」提升为「首选」,原首选下沉为第一备选。 */
535
+ const promote = (at, idx) => {
536
+ const cfg = prof.agents[at];
537
+ const target = cfg.fallbacks[idx];
538
+ const rest = cfg.fallbacks.filter((_, i) => i !== idx);
539
+ setAgent(at, { primary: target, fallbacks: cfg.primary ? [cfg.primary, ...rest] : rest });
540
+ };
541
+
542
+ const saveSection = async (at) => {
543
+ setBusy(true); setMsg(null);
544
+ const r = await api('routes/saveRoute', { profileId: pid, agentType: at, patch: prof.agents[at] });
545
+ if (r.ok) {
546
+ setState((s) => ({ ...s, routes: r.data, profileIds: r.profileIds }));
547
+ setMsg({ kind: 'ok', text: `${prof.agents[at].label || at} 已保存` });
548
+ } else {
549
+ setMsg({ kind: 'err', text: r.error || '保存失败' });
550
+ }
551
+ setBusy(false);
552
+ };
553
+
554
+ /** 【需求3】保存并下发:写 config.yaml → 重启网关加载新链路 */
555
+ const applyChains = async () => {
556
+ setBusy(true); setMsg(null);
557
+ // 先保存三条链,再统一下发(顺序很重要:下发读的是已保存状态)
558
+ for (const at of AGENT_TYPES) {
559
+ const r = await api('routes/saveRoute', { profileId: pid, agentType: at, patch: prof.agents[at] });
560
+ if (r.ok) setState((s) => ({ ...s, routes: r.data, profileIds: r.profileIds }));
561
+ else { setMsg({ kind: 'err', text: `保存 ${at} 失败:${r.error}` }); setBusy(false); return; }
562
+ }
563
+ const r = await api('routes/apply', {});
564
+ if (r.ok) {
565
+ setYaml(r.data.yaml || '');
566
+ setMsg({ kind: r.data.restarted ? 'ok' : 'warn', text: r.data.message });
567
+ } else {
568
+ setYaml(r.data?.yaml || '');
569
+ setMsg({ kind: 'err', text: r.data?.error || r.error || '下发失败' });
570
+ }
571
+ setBusy(false);
572
+ };
573
+
574
+ const preview = async () => {
575
+ const r = await api('routes/preview');
576
+ if (r.ok) setYaml(r.data.yaml);
577
+ };
578
+
579
+ const createProfile = async () => {
580
+ const id = newId.trim();
581
+ if (!id) return;
582
+ setBusy(true); setMsg(null);
583
+ const r = await api('routes/createProfile', { id, label: id });
584
+ if (r.ok) {
585
+ setState((s) => ({ ...s, routes: r.data, profileIds: r.profileIds }));
586
+ setPid(id);
587
+ setNewId('');
588
+ setShowNew(false);
589
+ setMsg({ kind: 'ok', text: `已新建 profile:${id}` });
590
+ } else {
591
+ setMsg({ kind: 'err', text: r.error || '新建失败' });
592
+ }
593
+ setBusy(false);
594
+ };
595
+
596
+ const deleteProfile = async (id) => {
597
+ setBusy(true); setMsg(null);
598
+ const r = await api('routes/deleteProfile', { id });
599
+ if (r.ok) {
600
+ setState((s) => ({ ...s, routes: r.data, profileIds: r.profileIds }));
601
+ setMsg({ kind: 'ok', text: `已删除 ${id}` });
602
+ } else {
603
+ setMsg({ kind: 'err', text: r.error || '删除失败' });
604
+ }
605
+ setBusy(false);
606
+ };
607
+
608
+ // 模型池按供应商分组,供下拉与"添加"列表使用
609
+ const byProvider = useMemo(() => {
610
+ const m = new Map();
611
+ for (const x of pool.models || []) {
612
+ const key = x.provider || '未知供应商';
613
+ if (!m.has(key)) m.set(key, []);
614
+ m.get(key).push(x);
615
+ }
616
+ return [...m.entries()];
617
+ }, [pool.models]);
618
+
619
+ const allIds = (pool.models || []).map((x) => x.id);
620
+
621
+ return h('div', null,
622
+ // ---- Profile 分组:选择 / 新建 / 删除 ----
623
+ h('div', { style: S.h2Row },
624
+ h('h2', { style: S.h2 }, 'Profile 分组'),
625
+ h('span', { style: { ...S.mono, fontSize: 11, color: T.labelSecondary } },
626
+ `共 ${profileIds.length} 个`),
627
+ h('div', { style: S.sectionActions },
628
+ profileIds.map((p) => h('button', {
629
+ key: p,
630
+ style: p === pid
631
+ ? { ...S.btn('primary'), height: 26, padding: '0 10px', fontSize: 12 }
632
+ : S.btnSm,
633
+ onClick: () => setPid(p),
634
+ }, p)),
635
+ h('button', { style: S.btnSm, onClick: () => setShowNew((v) => !v) }, showNew ? '取消' : '+ 新建'),
636
+ profileIds.length > 1 && h('button', {
637
+ style: { ...S.btnSm, color: T.error, borderColor: tint(T.error, 40) },
638
+ disabled: busy,
639
+ onClick: () => deleteProfile(pid),
640
+ }, '删除当前'),
641
+ ),
642
+ ),
643
+
644
+ showNew && h('div', { style: { ...S.row, marginBottom: 14 } },
645
+ h('input', {
646
+ style: { ...S.input, flex: '1 1 200px' },
647
+ placeholder: '新 profile 名称(字母数字、连字符、下划线)',
648
+ value: newId,
649
+ onChange: (e) => setNewId(e.target.value),
650
+ onKeyDown: (e) => { if (e.key === 'Enter') void createProfile(); },
651
+ }),
652
+ h('button', { style: S.btn('primary'), disabled: busy || !newId.trim(), onClick: createProfile }, '创建'),
653
+ ),
654
+
655
+ // ---- 【需求2】模型池来源说明 ----
656
+ h('div', { style: { ...S.hint, marginBottom: 14 } },
657
+ poolBusy
658
+ ? '正在读取 DSH 模型池…'
659
+ : (pool.hint || (pool.models.length ? `可用模型 ${pool.models.length} 个` : '未读取到模型池')),
660
+ h('span', { style: { marginLeft: 8 } },
661
+ h('button', { style: { ...S.btnSm, height: 22, fontSize: 11 }, disabled: poolBusy, onClick: loadPool }, '刷新模型池')),
662
+ ),
663
+
664
+ // ---- 三条链:root / coder / research ----
665
+ AGENT_TYPES.map((at) => {
666
+ const cfg = prof.agents[at];
667
+ const isSub = at !== 'root';
668
+ const groupLabel = at === 'root' ? 'root · 主代理降级链'
669
+ : at === 'coder' ? 'coder · 子代理降级链(Coder)'
670
+ : 'research · 子代理降级链(Research)';
671
+ const chainLen = [cfg.primary, ...(cfg.fallbacks || [])].filter(Boolean).length;
672
+
673
+ return h('div', { key: at },
674
+ h('div', { style: S.h2Row },
675
+ h('h2', { style: S.h2 }, groupLabel),
676
+ isSub && h('span', { style: S.badge() }, '仅子 Agent 使用'),
677
+ h('span', { style: { ...S.mono, fontSize: 11, color: T.labelSecondary } },
678
+ chainLen ? `链长 ${chainLen}` : '未配置'),
679
+ h('div', { style: S.sectionActions },
680
+ h('button', {
681
+ style: S.btnSm,
682
+ onClick: () => setDraft((d) => {
683
+ const next = JSON.parse(JSON.stringify(d));
684
+ next[pid].agents[at] = JSON.parse(JSON.stringify(state.routes[pid].agents[at]));
685
+ return next;
686
+ }),
687
+ }, 'Discard'),
688
+ h('button', {
689
+ style: { ...S.btn('primary'), height: 26, padding: '0 10px', fontSize: 12 },
690
+ disabled: busy,
691
+ onClick: () => saveSection(at),
692
+ }, 'Save'),
693
+ ),
694
+ ),
695
+
696
+ h('div', { style: S.fieldRow },
697
+ h('div', { style: { flex: '1 1 260px' } },
698
+ h('label', { style: S.label }, '首选模型(来自 DSH 模型池)'),
699
+ h('select', {
700
+ style: S.input,
701
+ value: cfg.primary || '',
702
+ onChange: (e) => setAgent(at, { primary: e.target.value }),
703
+ },
704
+ h('option', { value: '' }, '— 请选择 —'),
705
+ byProvider.map(([prov, rows]) => h('optgroup', { key: prov, label: prov },
706
+ rows.map((m) => h('option', { key: `${prov}:${m.id}`, value: m.id },
707
+ m.name && m.name !== m.id ? `${m.id} · ${m.name}` : m.id)),
708
+ )),
709
+ ),
710
+ ),
711
+ h('div', { style: { flex: '0 0 150px' } },
712
+ h('label', { style: S.label }, '超时(毫秒)'),
713
+ h('input', {
714
+ style: S.input, type: 'number', value: cfg.timeoutMs,
715
+ onChange: (e) => setAgent(at, { timeoutMs: Number(e.target.value) }),
716
+ }),
717
+ ),
718
+ ),
719
+
720
+ // ---- fallback 链:拖拽排序 ----
721
+ h('div', { style: S.field },
722
+ h('label', { style: S.label }, 'fallback 降级序列(拖拽排序,顺序即网关执行的降级优先级)'),
723
+ (cfg.fallbacks || []).length === 0
724
+ ? h('div', { style: { ...S.mono, fontSize: 11.5, color: T.labelSecondary, padding: '5px 0' } }, '(无备选)')
725
+ : cfg.fallbacks.map((f, i) => h('div', {
726
+ key: `${f}-${i}`, draggable: true,
727
+ onDragStart: () => onDragStart(at, i),
728
+ onDragOver: (e) => e.preventDefault(),
729
+ onDrop: () => onDrop(at, i),
730
+ style: {
731
+ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 9px',
732
+ marginBottom: 3, borderRadius: 5, cursor: 'grab',
733
+ background: T.bgLayer2, border: `1px solid ${T.borderL1}`,
734
+ },
735
+ },
736
+ h('span', { style: { opacity: 0.45, ...S.mono } }, '⠿'),
737
+ h('span', { style: S.badge() }, `第 ${i + 2} 顺位`),
738
+ h('span', { style: { flex: 1, ...S.mono, wordBreak: 'break-all' } }, f),
739
+ h('button', { style: S.btnSm, onClick: () => promote(at, i), title: '提升为首选' }, '↑ 首选'),
740
+ h('button', {
741
+ style: S.btnSm,
742
+ onClick: () => setAgent(at, { fallbacks: cfg.fallbacks.filter((_, j) => j !== i) }),
743
+ }, '移除'),
744
+ )),
745
+
746
+ // 追加备选:下拉选 DSH 模型池里的模型
747
+ h('div', { style: { ...S.row, marginTop: 7 } },
748
+ h('select', {
749
+ style: { ...S.inputAuto, flex: '1 1 260px' },
750
+ value: '',
751
+ onChange: (e) => {
752
+ const v = e.target.value;
753
+ if (!v) return;
754
+ const cur = cfg.fallbacks || [];
755
+ if (v === cfg.primary || cur.includes(v)) return;
756
+ setAgent(at, { fallbacks: [...cur, v] });
757
+ e.target.value = '';
758
+ },
759
+ },
760
+ h('option', { value: '' }, '+ 添加备选模型(从 DSH 模型池选择)'),
761
+ byProvider.map(([prov, rows]) => h('optgroup', { key: prov, label: prov },
762
+ rows.filter((m) => m.id !== cfg.primary).map((m) =>
763
+ h('option', { key: `${prov}:${m.id}`, value: m.id }, m.id)),
764
+ )),
765
+ ),
766
+ ),
767
+ allIds.length === 0 && h('div', { style: { ...S.hint, color: T.warn } },
768
+ '模型池为空:请先到【设置 → 模型】里配置供应商,并用【获取可用模型】拉取模型目录。'),
769
+ ),
770
+ );
771
+ }),
772
+
773
+ // ---- 下发区 ----
774
+ h('div', { style: { ...S.row, marginTop: 16 } },
775
+ h('button', { style: S.btn('primary'), onClick: applyChains, disabled: busy },
776
+ busy ? '处理中…' : '保存并下发到网关'),
777
+ h('button', { style: S.btn(), onClick: preview, disabled: busy }, '预览 YAML'),
778
+ ),
779
+
780
+ msg && h('div', {
781
+ style: { marginTop: 10, padding: '8px 10px', borderRadius: 6, fontSize: 12.5,
782
+ color: msg.kind === 'ok' ? T.success : msg.kind === 'warn' ? T.warn : T.error,
783
+ background: tint(msg.kind === 'ok' ? T.success : msg.kind === 'warn' ? T.warn : T.error, 10),
784
+ border: `1px solid ${tint(msg.kind === 'ok' ? T.success : msg.kind === 'warn' ? T.warn : T.error, 35)}` },
785
+ }, msg.text),
786
+
787
+ yaml && h('div', { style: { marginTop: 12 } },
788
+ h('div', { style: { ...S.label, marginBottom: 5 } }, '下发给网关的 config.yaml'),
789
+ h('pre', {
790
+ style: { ...S.mono, maxHeight: 300, overflow: 'auto', padding: 10, borderRadius: 6,
791
+ background: T.bgLayer2, border: `1px solid ${T.borderL1}`, margin: 0 },
792
+ }, yaml),
793
+ ),
794
+ );
795
+ }
796
+
797
+ // -----------------------------------------------------------------------
798
+ // Tab 3:日志面板
799
+ // -----------------------------------------------------------------------
800
+ function LogsTab({ api, state }) {
801
+ const limit = state?.gateway?.logLimit || 50;
802
+ const [logs, setLogs] = useState([]);
803
+ const [busy, setBusy] = useState(false);
804
+ const [err, setErr] = useState(null);
805
+ const [fAgent, setFAgent] = useState('');
806
+ const [fProfile, setFProfile] = useState('');
807
+ const [needsDb, setNeedsDb] = useState(false);
808
+ const [localOnly, setLocalOnly] = useState(false);
809
+ /** 【需求7】网关状态随日志一起返回,面板顶部直接展示 */
810
+ const [gw, setGw] = useState(null);
811
+
812
+ const fetchLogs = useCallback(async () => {
813
+ setBusy(true); setErr(null);
814
+ const r = await api('logs/fetch', { limit });
815
+ if (r.ok) {
816
+ setLogs(r.data || []);
817
+ setNeedsDb(Boolean(r.needsDatabase));
818
+ setLocalOnly(Boolean(r.localOnly));
819
+ setGw(r.gateway || null);
820
+ } else {
821
+ setErr(r.error); setLogs([]);
822
+ }
823
+ setBusy(false);
824
+ }, [api, limit]);
825
+
826
+ useEffect(() => { fetchLogs(); }, [fetchLogs]);
827
+
828
+ const shown = useMemo(() => {
829
+ let rows = logs.slice(0, limit);
830
+ if (fAgent) rows = rows.filter((l) => l.agentType === fAgent);
831
+ if (fProfile) rows = rows.filter((l) => l.profile === fProfile);
832
+ return rows;
833
+ }, [logs, limit, fAgent, fProfile]);
834
+
835
+ const agents = useMemo(() => [...new Set(logs.map((l) => l.agentType).filter((x) => x && x !== '-'))], [logs]);
836
+ const profiles = useMemo(() => [...new Set(logs.map((l) => l.profile).filter((x) => x && x !== '-'))], [logs]);
837
+
838
+ const kindOf = (l) => (l.status === 'failure' ? 'failed' : l.fallback === 'yes' ? 'fallback' : 'normal');
839
+ const gwState = gw?.state || 'stopped';
840
+ const gwLabel = gwState === 'running' ? '运行中' : gwState === 'error' ? '异常' : '已停止';
841
+ const gwColor = gwState === 'running' ? T.success : gwState === 'error' ? T.error : T.warn;
842
+
843
+ return h('div', null,
844
+ // 【需求7】网关状态条:让用户一眼看到网关是否在跑
845
+ h('div', {
846
+ style: {
847
+ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap',
848
+ padding: '8px 12px', marginBottom: 12, borderRadius: 6,
849
+ background: tint(gwColor, 8), border: `1px solid ${tint(gwColor, 30)}`,
850
+ },
851
+ },
852
+ h('span', {
853
+ style: { width: 8, height: 8, borderRadius: '50%', background: gwColor, display: 'inline-block' },
854
+ }),
855
+ h('span', { style: { fontWeight: 600, fontSize: 12.5 } }, `网关 ${gwLabel}`),
856
+ gw?.owned && h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, '本插件托管'),
857
+ gw?.lastError && h('span', { style: { ...S.mono, fontSize: 11, color: T.error } },
858
+ String(gw.lastError).slice(0, 90)),
859
+ h('button', { style: { ...S.btnSm, marginLeft: 'auto' }, onClick: fetchLogs, disabled: busy },
860
+ busy ? '加载中…' : '刷新'),
861
+ ),
862
+
863
+ h('div', { style: { ...S.row, marginBottom: 10 } },
864
+ h('select', { style: { ...S.input, width: 'auto' }, value: fAgent, onChange: (e) => setFAgent(e.target.value) },
865
+ h('option', { value: '' }, '全部 agent-type'),
866
+ agents.map((a) => h('option', { key: a, value: a }, a)),
867
+ ),
868
+ h('select', { style: { ...S.input, width: 'auto' }, value: fProfile, onChange: (e) => setFProfile(e.target.value) },
869
+ h('option', { value: '' }, '全部 profile'),
870
+ profiles.map((p) => h('option', { key: p, value: p }, p)),
871
+ ),
872
+ h('span', { style: { marginLeft: 'auto', ...S.mono, fontSize: 11, color: T.labelSecondary } },
873
+ `显示 ${shown.length} / 最多 ${limit} 条`),
874
+ ),
875
+
876
+ h('div', { style: { ...S.row, marginBottom: 10, ...S.mono, fontSize: 11, color: T.labelSecondary } },
877
+ h('span', { style: S.badge(T.warn, tint(T.warn, 14)) }, '橙色 = 触发 fallback'),
878
+ h('span', { style: S.badge(T.error, tint(T.error, 14)) }, '红色 = 全链路失败'),
879
+ h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, 'gateway = 网关生命周期'),
880
+ ),
881
+
882
+ needsDb && h('div', { style: S.notice('warn') },
883
+ '网关调用日志需要 PostgreSQL(LiteLLM 不支持 SQLite)。当前显示插件侧记录:探针失败 + 网关生命周期事件。'),
884
+
885
+ err && h('div', { style: S.notice('error') }, `无法获取日志:${err}`),
886
+
887
+ !err && shown.length === 0 && h('div', { style: { color: T.labelSecondary, padding: 14 } },
888
+ busy ? '加载中…' : '暂无日志'),
889
+
890
+ shown.map((l) => {
891
+ const k = kindOf(l);
892
+ const isGwEvent = l.source === 'gateway';
893
+ return h('div', { key: l.id, style: S.logRow(k) },
894
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'baseline' } },
895
+ h('span', { style: { ...S.mono, color: T.labelSecondary, fontSize: 11 } },
896
+ l.time ? String(l.time).replace('T', ' ').slice(0, 19) : '—'),
897
+ isGwEvent
898
+ ? h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, 'gateway')
899
+ : h('span', { style: S.badge() }, l.agentType),
900
+ !isGwEvent && h('span', { style: S.badge() }, l.profile),
901
+ h('span', { style: { ...S.mono, fontWeight: 600 } }, l.model),
902
+ l.source === 'local' && !isGwEvent && h('span', { style: S.badge(T.brand, tint(T.brand, 14)) }, 'local'),
903
+ k === 'fallback' && h('span', { style: S.badge(T.warn, tint(T.warn, 14)) }, 'FALLBACK'),
904
+ k === 'failed' && h('span', { style: S.badge(T.error, tint(T.error, 14)) }, '失败'),
905
+ h('span', { style: { marginLeft: 'auto', ...S.mono, color: T.labelSecondary, fontSize: 11 } },
906
+ [l.latencyMs != null ? `${Math.round(l.latencyMs)}ms` : null,
907
+ l.tokens != null ? `${l.tokens} tok` : null].filter(Boolean).join(' · ')),
908
+ ),
909
+ (l.error || l.message) && h('div', { style: { ...S.mono, color: T.error, marginTop: 4, fontSize: 11 } },
910
+ l.error || l.message),
911
+ );
912
+ }),
913
+ );
914
+ }
915
+
916
+ // -----------------------------------------------------------------------
917
+ // Tab 4:当前会话
918
+ // -----------------------------------------------------------------------
919
+ function SessionTab({ api, ctx, state, setState }) {
920
+ const profileIds = state?.profileIds || Object.keys(state?.routes || {});
921
+ const [profile, setProfile] = useState('');
922
+ const [saved, setSaved] = useState(null);
923
+ const [busy, setBusy] = useState(false);
924
+ const [probe, setProbe] = useState(null);
925
+ const [drops, setDrops] = useState([]);
926
+
927
+ // 当前会话 id:尽量从宿主会话上下文取;取不到就用 URL 里的会话标记
928
+ // 当前会话 id:尽量从宿主会话上下文取;取不到就用 URL 里的会话标记。
929
+ // 【注意】依赖数组里不能放 ctx / api —— 它们是每次渲染新建的对象,
930
+ // 放进去会触发无限重渲染(表现为整页空白)。
931
+ const sessionId = useMemo(() => {
932
+ try {
933
+ const fromCtx = ctx?.session?.id || ctx?.sessionId;
934
+ if (fromCtx) return String(fromCtx);
935
+ const loc = globalThis.location;
936
+ const hay = String(loc?.hash || '') + String(loc?.search || '');
937
+ const m = hay.match(/session[=/]([A-Za-z0-9_-]+)/i);
938
+ return m ? m[1] : 'current';
939
+ } catch {
940
+ return 'current';
941
+ }
942
+ }, []);
943
+
944
+ useEffect(() => {
945
+ api('session/getProfile', { sessionId }).then((r) => {
946
+ if (r.ok) setProfile(r.data || '');
947
+ });
948
+ }, [api, sessionId]);
949
+
950
+ // 本会话最近降级记录:从日志里按 profile 过滤出 fallback 行
951
+ const loadDrops = useCallback(async () => {
952
+ const r = await api('logs/fetch', { limit: 50 });
953
+ if (r.ok) setDrops((r.data || []).filter((l) => l.fallback === 'yes' || l.status === 'failure').slice(0, 10));
954
+ }, [api]);
955
+ useEffect(() => { loadDrops(); }, [loadDrops]);
956
+
957
+ // 子代理路由覆盖状态
958
+ const [rt, setRt] = useState(null);
959
+ useEffect(() => {
960
+ api('routing/status', { sessionId }).then((r) => { if (r.ok) setRt(r.data); });
961
+ }, [api, sessionId]);
962
+
963
+ const save = async () => {
964
+ setBusy(true);
965
+ const r = await api('session/setProfile', { sessionId, profile });
966
+ setSaved(r.ok ? { kind: 'ok', text: `已绑定 ${profile || '(已清空)'},存入会话变量` } : { kind: 'err', text: r.error });
967
+ setBusy(false);
968
+ };
969
+
970
+ const runProbe = async () => {
971
+ setBusy(true); setProbe(null);
972
+ const agentType = 'root';
973
+ // 【需求4】探针测的是当前 profile 里选中的链头模型,不是写死的名字
974
+ const r = await api('probe/run', { agentType, profileId: profile || profileIds[0] });
975
+ setProbe(r.ok
976
+ ? { kind: 'ok', text: `探针通过(${r.latencyMs}ms)· 回复 ${JSON.stringify(r.data.reply)} · 上游模型 ${r.data.modelUsed}` }
977
+ : { kind: 'err', text: `探针失败:${r.error}` });
978
+ setBusy(false);
979
+ };
980
+
981
+ return h('div', null,
982
+ h('div', { style: S.card },
983
+ h('div', { style: S.field },
984
+ h('label', { style: S.label }, '本会话绑定的 Profile(降级链方案)'),
985
+ h('select', {
986
+ style: S.input, value: profile, onChange: (e) => setProfile(e.target.value),
987
+ },
988
+ h('option', { value: '' }, '(不绑定)'),
989
+ profileIds.map((p) => h('option', { key: p, value: p }, p)),
990
+ ),
991
+ h('div', { style: S.hint },
992
+ '绑定后:本会话的 root 请求按该 profile 的 root 链走;spawn 的子 Agent 按同 profile 的 coder/research 链走。'),
993
+ ),
994
+ h('div', { style: S.row },
995
+ h('button', { style: S.btn('primary'), onClick: save, disabled: busy }, '保存到会话变量'),
996
+ h('button', { style: S.btn(), onClick: runProbe, disabled: busy }, busy ? '测试中…' : '一键探针测试'),
997
+ ),
998
+ h('div', { style: { ...S.mono, color: T.labelSecondary, marginTop: 8, fontSize: 11 } },
999
+ `会话变量键:litellm-bridge/session-profile · sessionId=${sessionId}`),
1000
+ saved && h('div', {
1001
+ style: { marginTop: 9, fontSize: 12, color: saved.kind === 'ok' ? T.success : T.error },
1002
+ }, saved.text),
1003
+ probe && h('div', {
1004
+ style: { marginTop: 9, fontSize: 12, color: probe.kind === 'ok' ? T.success : T.error },
1005
+ }, probe.text),
1006
+ ),
1007
+
1008
+ rt && h('div', { style: S.card },
1009
+ h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '子代理路由覆盖'),
1010
+ (() => {
1011
+ const sp = rt.sessionProvider;
1012
+ const isNative = sp && rt.nativeProviders.some((p) => sp.toLowerCase() === p || sp.toLowerCase().startsWith(p + '-'));
1013
+ const isGw = sp && sp.toLowerCase().startsWith('litellm');
1014
+ const verdict = !sp ? { t: '未知', c: S.warn, d: '尚未观察到本会话的模型请求,暂不覆盖(fail-open)' }
1015
+ : isGw ? { t: '全部走网关', c: S.ok, d: '主 Agent 与子 Agent 均走网关链路,不做覆盖' }
1016
+ : isNative ? { t: '子代理将改道网关', c: S.warn, d: `主 Agent 保留 ${sp}(消耗积分);子 Agent 强制走 ${rt.gatewayProvider}` }
1017
+ : { t: '不覆盖', c: S.ok, d: `供应商 ${sp} 不在覆盖名单` };
1018
+ return h('div', null,
1019
+ h('div', { style: { marginBottom: 6 } },
1020
+ h('span', { style: S.badge() }, `当前供应商: ${sp || '未知'}`),
1021
+ h('span', { style: { color: verdict.c, fontWeight: 600 } }, verdict.t),
1022
+ ),
1023
+ h('div', { style: { fontSize: 12, opacity: 0.75, marginBottom: 6 } }, verdict.d),
1024
+ h('div', { style: { ...S.mono, fontSize: 11, opacity: 0.55 } },
1025
+ `覆盖名单: ${rt.nativeProviders.join(' / ')} · 目标: ${rt.gatewayProvider}`),
1026
+ h('div', { style: { ...S.mono, fontSize: 11, opacity: 0.55, marginTop: 3 } },
1027
+ `已评估 ${rt.stats.evaluated} 次 · 改道 ${rt.stats.overridden} 次 · 跳过 ${rt.stats.skipped} 次 · 降级 ${rt.stats.degraded} 次`),
1028
+ );
1029
+ })(),
1030
+ ),
1031
+
1032
+ h('div', { style: S.card },
1033
+ h('div', { style: { fontWeight: 600, marginBottom: 8 } }, '本会话最近降级记录'),
1034
+ drops.length === 0
1035
+ ? h('div', { style: { opacity: 0.5, fontSize: 12 } }, '暂无降级记录(或网关日志不含该字段)')
1036
+ : drops.map((d, i) => h('div', { key: i, style: S.logRow(d.status === 'failure' ? 'failed' : 'fallback') },
1037
+ h('span', { style: { ...S.mono } }, d.model),
1038
+ h('span', { style: { ...S.mono, opacity: 0.6, marginLeft: 8 } },
1039
+ d.fallback === 'yes' ? '触发降级' : '失败'),
1040
+ d.error && h('div', { style: { ...S.mono, fontSize: 11, color: S.err, marginTop: 3 } }, d.error),
1041
+ )),
1042
+ ),
1043
+
1044
+ h('div', { style: { ...S.mono, opacity: 0.5, fontSize: 11.5, lineHeight: 1.7 } },
1045
+ h('div', null, '联动说明:本插件读写会话 profile 变量 + 子代理路由覆盖;'),
1046
+ h('div', null, 'dsh-plugin-header-inject 读取该变量并注入 x-agent-type / x-session-profile。'),
1047
+ h('div', null, '两插件通过会话变量解耦,互不依赖。'),
1048
+ ),
1049
+ );
1050
+ }
1051
+
1052
+ /**
1053
+ * 创建**稳定引用**的 api 函数(同一个插件实例只创建一次)。
1054
+ *
1055
+ * 为什么必须要稳定:各 Tab 的 useEffect 依赖数组里都有 [api]。若 api 每次
1056
+ * 渲染都变,就会 effect→setState→重渲染→新 api→再 effect 无限循环,
1057
+ * React 表现为组件反复重挂载、页面空白。
1058
+ */
1059
+ const apiCache = new WeakMap();
1060
+ function createStableApi(ctx) {
1061
+ const hit = apiCache.get(ctx);
1062
+ if (hit) return hit;
1063
+ const fn = createApi(ctx);
1064
+ apiCache.set(ctx, fn);
1065
+ return fn;
1066
+ }
1067
+
1068
+ // -----------------------------------------------------------------------
1069
+ // 【会话级入口】标题旁的小按钮 —— 直接给当前会话选降级链(profile)
1070
+ //
1071
+ // 槽位:conversation.session.header.actions(list,scope: session)
1072
+ // ownerProps 为空;组件通过标准 props 拿到 sessionId。
1073
+ // 这是 dsh-llm-fallbacks 同款做法,replaceRisk: none,不动官方 UI。
1074
+ //
1075
+ // 为什么不做进右下角模型选择器:
1076
+ // 那个槽位是 conversation.input.model(single,shadows-shipped-ui),
1077
+ // 注册即替换掉官方选择器,得不偿失。
1078
+ // -----------------------------------------------------------------------
1079
+ function SessionProfileButton({ api, sessionId }) {
1080
+ const [open, setOpen] = useState(false);
1081
+ const [profile, setProfile] = useState('');
1082
+ const [profiles, setProfiles] = useState([]);
1083
+ const [busy, setBusy] = useState(false);
1084
+ const [msg, setMsg] = useState(null);
1085
+
1086
+ const load = useCallback(async () => {
1087
+ const [r, rs] = await Promise.all([
1088
+ api('session/getProfile', { sessionId }),
1089
+ api('routes/get'),
1090
+ ]);
1091
+ if (r.ok) setProfile(r.data || '');
1092
+ if (rs.ok) setProfiles(rs.profileIds || Object.keys(rs.data || {}));
1093
+ }, [api, sessionId]);
1094
+
1095
+ useEffect(() => { load(); }, [load]);
1096
+
1097
+ const pick = async (p) => {
1098
+ setBusy(true); setMsg(null);
1099
+ const r = await api('session/setProfile', { sessionId, profile: p });
1100
+ if (r.ok) {
1101
+ setProfile(p);
1102
+ setMsg({ ok: true, text: `已绑定 ${p || '(不绑定)'}` });
1103
+ } else {
1104
+ setMsg({ ok: false, text: r.error || '保存失败' });
1105
+ }
1106
+ setBusy(false);
1107
+ };
1108
+
1109
+ const label = profile || '未绑定';
1110
+
1111
+ return h('div', { style: { position: 'relative', display: 'inline-flex' } },
1112
+ h('button', {
1113
+ type: 'button',
1114
+ title: '本会话降级链(profile)',
1115
+ onClick: () => setOpen((v) => !v),
1116
+ style: {
1117
+ ...S.btnSm,
1118
+ height: 26, gap: 5, padding: '0 9px',
1119
+ color: profile ? T.brand : T.labelSecondary,
1120
+ borderColor: profile ? tint(T.brand, 45) : T.borderL2,
1121
+ background: profile ? tint(T.brand, 8) : T.bgLayer1,
1122
+ },
1123
+ },
1124
+ h('span', {
1125
+ style: {
1126
+ width: 6, height: 6, borderRadius: '50%',
1127
+ background: profile ? T.brand : T.idle, display: 'inline-block',
1128
+ },
1129
+ }),
1130
+ h('span', null, label),
1131
+ ),
1132
+
1133
+ open && h('div', {
1134
+ style: {
1135
+ position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 50,
1136
+ minWidth: 220, padding: 8, borderRadius: 8,
1137
+ background: T.bgOverlay, border: `1px solid ${T.borderL2}`,
1138
+ boxShadow: '0 6px 24px rgba(0,0,0,.18)',
1139
+ },
1140
+ },
1141
+ h('div', { style: { ...S.hint, marginBottom: 6, marginTop: 0 } }, '本会话降级链'),
1142
+ ...(profiles.length ? profiles : []).map((p) => h('button', {
1143
+ key: p,
1144
+ type: 'button',
1145
+ disabled: busy,
1146
+ onClick: () => pick(p),
1147
+ style: {
1148
+ display: 'flex', alignItems: 'center', gap: 8, width: '100%',
1149
+ padding: '6px 8px', marginBottom: 2, fontSize: 12.5, cursor: 'pointer',
1150
+ fontFamily: 'inherit', textAlign: 'left', borderRadius: 5,
1151
+ border: '1px solid transparent',
1152
+ background: p === profile ? tint(T.brand, 12) : 'transparent',
1153
+ color: p === profile ? T.brand : T.labelPrimary,
1154
+ },
1155
+ },
1156
+ h('span', { style: { opacity: p === profile ? 1 : 0.25 } }, '●'),
1157
+ h('span', null, p),
1158
+ )),
1159
+ h('button', {
1160
+ type: 'button',
1161
+ disabled: busy,
1162
+ onClick: () => pick(''),
1163
+ style: {
1164
+ width: '100%', padding: '6px 8px', marginTop: 2, fontSize: 12,
1165
+ cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
1166
+ borderRadius: 5, border: `1px solid ${T.borderL1}`,
1167
+ background: 'transparent', color: T.labelSecondary,
1168
+ },
1169
+ }, '清除绑定'),
1170
+ msg && h('div', {
1171
+ style: { ...S.hint, marginTop: 6, color: msg.ok ? T.success : T.error },
1172
+ }, msg.text),
1173
+ ),
1174
+ );
1175
+ }
1176
+
1177
+ // -----------------------------------------------------------------------
1178
+ // 错误边界 —— 任何一个 Tab 抛错时显示具体错误,而不是一片空白
1179
+ // (上一版「当前会话」整页空白,就是因为渲染异常被 React 静默吞掉了)
1180
+ // -----------------------------------------------------------------------
1181
+ function ErrorBox({ title, error }) {
1182
+ return h('div', { style: S.notice('error') },
1183
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, `${title} 渲染出错`),
1184
+ h('div', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 11.5, wordBreak: 'break-all' } },
1185
+ String(error?.message || error || '未知错误')),
1186
+ h('div', { style: { ...S.hint, marginTop: 6 } },
1187
+ '请把这段错误发给开发者;同时打开浏览器控制台可看到完整堆栈。'),
1188
+ );
1189
+ }
1190
+
1191
+ /** 用 try/catch 包住子渲染,异常时就地降级为错误块。 */
1192
+ function Guard({ title, children }) {
1193
+ try {
1194
+ return children();
1195
+ } catch (err) {
1196
+ return h(ErrorBox, { title, error: err });
1197
+ }
1198
+ }
1199
+
1200
+ // -----------------------------------------------------------------------
1201
+ // 主面板 —— 对齐 dsh-llm-fallbacks 的官方设置页语言:
1202
+ // 扁平页面(无卡片壳)· 顶部标签导航 · 页内分区 h2 + 每区独立 Save/Discard
1203
+ // -----------------------------------------------------------------------
1204
+ function BridgePanel({ api, ctx }) {
1205
+ const [tab, setTab] = useState('gateway');
1206
+ const [state, setState] = useState(null);
1207
+
1208
+ useEffect(() => {
1209
+ api('gateway/get').then((r) => { if (r.ok) setState(r.data); });
1210
+ }, [api]);
1211
+
1212
+ const tabs = [
1213
+ ['gateway', '网关设置'],
1214
+ ['routes', '路由配置'],
1215
+ ['logs', '日志面板'],
1216
+ ['session', '当前会话'],
1217
+ ];
1218
+
1219
+ return h('div', { style: S.page },
1220
+ // 顶部标签导航
1221
+ h('div', { style: S.tabs },
1222
+ tabs.map(([id, label]) => h('button', {
1223
+ key: id, style: S.tab(tab === id), onClick: () => setTab(id),
1224
+ }, label)),
1225
+ ),
1226
+ // 内容区:限制最大宽度 + 左右留白,避免贴边和大片空白
1227
+ h('div', { style: { padding: '16px 20px 40px', maxWidth: 860, margin: '0 auto' } },
1228
+ tab === 'gateway' && h(Guard, { title: '网关设置' }, () => h(GatewayTab, { api, state, setState })),
1229
+ tab === 'routes' && h(Guard, { title: '路由配置' }, () => h(RouteTab, { api, state, setState })),
1230
+ tab === 'logs' && h(Guard, { title: '日志面板' }, () => h(LogsTab, { api, state })),
1231
+ tab === 'session' && h(Guard, { title: '当前会话' }, () => h(SessionTab, { api, ctx, state, setState })),
1232
+ ),
1233
+ );
1234
+ }
1235
+
1236
+
1237
+ // -----------------------------------------------------------------------
1238
+ // 插件入口
1239
+ // -----------------------------------------------------------------------
1240
+ // 【只依赖 slots】
1241
+ //
1242
+ // 原来写了 ['slots', 'locale', 'connection']。问题:`locale` 与 `connection`
1243
+ // 若在组合里不存在,整个 client entry 会被判定 failed,而 boot audit 会因此
1244
+ // 拒绝启动 —— 表现就是**设置页只剩一个齿轮图标、点进去空白**。
1245
+ //
1246
+ // 对照能正常工作的 dsh-qq-plugin,它的 client inject 就只有 `['slots']`,
1247
+ // 其余能力(工作区、locale)一律用 ctx.get 可选探测。这里照做:
1248
+ // - locale:我们自己有中文文案,不需要宿主 locale 服务
1249
+ // - connection:RPC 已改为同源 fetch(/plugins/dsh-plugin-litellm-bridge),
1250
+ // 根本不需要 connection 服务
1251
+ // -----------------------------------------------------------------------
1252
+ const inject = ['slots'];
1253
+
1254
+ /** 导航项标题:优先用 locale 资源,取不到就用内置文案。 */
1255
+ function sectionLabel(ctx) {
1256
+ return () => {
1257
+ try {
1258
+ const loc = ctx?.locale;
1259
+ // 宿主的 locale 服务形态各异,probing 式取值,失败不影响标题
1260
+ const t = typeof loc?.get === 'function'
1261
+ ? loc.get('meta.title')
1262
+ : typeof loc?.text === 'function' ? loc.text('meta.title') : null;
1263
+ if (typeof t === 'string' && t.trim()) return t;
1264
+ } catch { /* 用兜底文案 */ }
1265
+ return 'LiteLLM 网关桥接';
1266
+ };
1267
+ }
1268
+
1269
+ function apply(ctx) {
1270
+ // 【关键】api 必须是稳定引用。
1271
+ // createApi(ctx) 每次渲染都会返回新函数;把它放进 useEffect 的依赖数组
1272
+ // 会导致「effect -> setState -> 重渲染 -> 新 api -> 再跑 effect」死循环,
1273
+ // 表现就是整页空白。
1274
+ const api = createStableApi(ctx);
1275
+ const Panel = () => h(BridgePanel, { api, ctx });
1276
+
1277
+ // 【必须用 ctx.effect 包住注册】
1278
+ // client 侧模板要求 apply 通过 ctx.effect 注册每个资源并返回清理函数。
1279
+ // 裸调用(不包 effect)时,某些宿主版本不会把注册项纳入生命周期,
1280
+ // 结果是「注册了但渲染不出来」。
1281
+ const register = () => {
1282
+ // ---- (1) 设置页:完整配置面板(网关 / 路由 / 日志 / 会话)----
1283
+ const disposeSection = ctx.slots.inject('settings.section', () => {
1284
+ return ctx.slots.register(
1285
+ {
1286
+ name: 'settings.section',
1287
+ id: NS,
1288
+ order: 60,
1289
+ // 【关键 · 之前漏了 label】
1290
+ // 设置页左侧导航的显示名就来自这个 label。缺了它,
1291
+ // 导航里只剩一个没有文字的齿轮图标 —— 就是用户看到的现象。
1292
+ // 写成函数是因为标题要在 locale 变更时跟着变。
1293
+ label: sectionLabel(ctx),
1294
+ },
1295
+ Panel,
1296
+ );
1297
+ });
1298
+
1299
+ // ---- (2) 会话标题旁的小按钮:直接为当前会话选降级链 ----
1300
+ // 槽位是 list + scope:session,组件通过标准 props 拿到 sessionId。
1301
+ const disposeHeader = ctx.slots.inject('conversation.session.header.actions', () => {
1302
+ return ctx.slots.register(
1303
+ {
1304
+ name: 'conversation.session.header.actions',
1305
+ id: NS,
1306
+ order: -5,
1307
+ label: () => '降级链',
1308
+ },
1309
+ (props) => h(SessionProfileButton, {
1310
+ api,
1311
+ sessionId: props?.sessionId ?? props?.session?.id ?? 'current',
1312
+ }),
1313
+ );
1314
+ });
1315
+
1316
+ // 清理:ctx.effect 要求返回 dispose
1317
+ return () => {
1318
+ try { disposeSection?.(); } catch { /* ignore */ }
1319
+ try { disposeHeader?.(); } catch { /* ignore */ }
1320
+ };
1321
+ };
1322
+
1323
+ // ctx.effect 在个别宿主版本上可能是异步语义;同步不可用时直接注册,
1324
+ // 保证「注册本身」一定发生(宁可少一层生命周期管理,也不能不显示)。
1325
+ if (typeof ctx.effect === 'function') {
1326
+ ctx.effect(register);
1327
+ } else {
1328
+ register();
1329
+ }
1330
+ }
1331
+
1332
+ exports.apply = apply;
1333
+ exports.inject = inject;
1334
+ return module.exports;
1335
+ },
1336
+ });