dsh-router-core 0.1.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.
Files changed (42) hide show
  1. package/README.md +151 -0
  2. package/cordis.patch.yml +9 -0
  3. package/lib/client-registry.js +3006 -0
  4. package/lib/client.js +3006 -0
  5. package/lib/index.js +1756 -0
  6. package/lib/types/credential-store.d.ts +15 -0
  7. package/lib/types/index.d.ts +6 -0
  8. package/lib/types/keys.d.ts +39 -0
  9. package/lib/types/llm/adapter.d.ts +31 -0
  10. package/lib/types/router/index.d.ts +73 -0
  11. package/lib/types/router/types.d.ts +80 -0
  12. package/lib/types/shared.d.ts +138 -0
  13. package/lib/types/supplier-config.d.ts +28 -0
  14. package/lib/types/suppliers/contract.d.ts +98 -0
  15. package/lib/types/suppliers/loader.d.ts +41 -0
  16. package/lib/types/suppliers/registry.d.ts +33 -0
  17. package/package.json +86 -0
  18. package/src/client/CombosTab.tsx +458 -0
  19. package/src/client/EndpointTab.tsx +360 -0
  20. package/src/client/Modal.tsx +46 -0
  21. package/src/client/RouterView.tsx +186 -0
  22. package/src/client/SupplierDetail.tsx +943 -0
  23. package/src/client/index.tsx +18 -0
  24. package/src/client/router.css +2096 -0
  25. package/src/client/sidebar-entry.ts +167 -0
  26. package/src/client/workspace-controller.ts +26 -0
  27. package/src/client/workspace-mount.tsx +117 -0
  28. package/src/credential-store.ts +64 -0
  29. package/src/index.ts +468 -0
  30. package/src/keys.ts +143 -0
  31. package/src/llm/adapter.ts +273 -0
  32. package/src/router/index.ts +273 -0
  33. package/src/router/types.ts +85 -0
  34. package/src/shared.ts +152 -0
  35. package/src/supplier-config.ts +144 -0
  36. package/src/suppliers/contract.ts +73 -0
  37. package/src/suppliers/loader.ts +191 -0
  38. package/src/suppliers/nvidia/plugin.ts +268 -0
  39. package/src/suppliers/opencode/plugin.ts +200 -0
  40. package/src/suppliers/openrouter/plugin.ts +282 -0
  41. package/src/suppliers/registry.ts +365 -0
  42. package/src/types.d.ts +45 -0
@@ -0,0 +1,360 @@
1
+ /**
2
+ * 端点与密钥 — 布局交互贴近 9router endpoint 页(无隧道/Tailscale)。
3
+ * API Endpoint 卡片 + API Keys 卡片(Require API key + flex 行 key 列表 +
4
+ * Create/暂停确认/Created 弹窗)。
5
+ */
6
+ import { useCallback, useEffect, useRef, useState } from 'react'
7
+ import {
8
+ ROUTER_API_BASE,
9
+ type RouterKey,
10
+ type RouterKeysResponse,
11
+ type RouterSettingsResponse,
12
+ } from '../shared.ts'
13
+ import { Modal } from './Modal.tsx'
14
+
15
+ /* ---------------- SVG 图标(material 风格线条) ---------------- */
16
+
17
+ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
18
+ return (
19
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
20
+ <path d={d} />
21
+ </svg>
22
+ )
23
+ }
24
+
25
+ const I = {
26
+ api: 'M4 4h16v16H4zM9 9h6v6H9zM12 9V7M12 17v-2M9 12H7M17 12h-2',
27
+ key: 'M14 7a4 4 0 1 1-1.4 7.6L9 18H6v-3l4.4-4.4A4 4 0 0 1 14 7zM18 7h.01',
28
+ visibility: 'M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6zM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
29
+ visibilityOff: 'M4 4l16 16M10.6 5.2A10 10 0 0 1 12 5c6.5 0 10 6 10 6a17 17 0 0 1-3.3 4M6.1 6.6A17 17 0 0 0 2 12s3.5 6 10 6a9.6 9.6 0 0 0 4-.8M9.9 9.9a3 3 0 0 0 4.2 4.2',
30
+ copy: 'M9 9h11v11H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
31
+ check: 'M4 12.5l5 5L20 6.5',
32
+ add: 'M12 5v14M5 12h14',
33
+ delete: 'M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M10 11v6M14 11v6',
34
+ }
35
+
36
+ function maskKey(full: string): string {
37
+ if (!full || full.length <= 10) return full
38
+ return `${full.slice(0, 6)}${'•'.repeat(Math.min(full.length - 10, 12))}${full.slice(-4)}`
39
+ }
40
+
41
+ export function EndpointTab(): JSX.Element {
42
+ const [keys, setKeys] = useState<RouterKey[] | null>(null)
43
+ const [requireApiKey, setRequireApiKey] = useState(false)
44
+ const [showCreate, setShowCreate] = useState(false)
45
+ const [newKeyName, setNewKeyName] = useState('')
46
+ const [createdKey, setCreatedKey] = useState<string | null>(null)
47
+ const [pauseTarget, setPauseTarget] = useState<RouterKey | null>(null)
48
+ const [deleteTarget, setDeleteTarget] = useState<RouterKey | null>(null)
49
+ const [visibleKeys, setVisibleKeys] = useState<Set<string>>(() => new Set())
50
+ const [copiedKeyId, setCopiedKeyId] = useState<string | null>(null)
51
+ const [error, setError] = useState('')
52
+ const [busy, setBusy] = useState(false)
53
+ const mounted = useRef(true)
54
+ const endpointBase = `${window.location.origin}/v1`
55
+
56
+ useEffect(() => {
57
+ mounted.current = true
58
+ return () => { mounted.current = false }
59
+ }, [])
60
+
61
+ const load = useCallback(async (): Promise<void> => {
62
+ try {
63
+ const [keysRes, settingsRes] = await Promise.all([
64
+ fetch(`${ROUTER_API_BASE}/keys`, { cache: 'no-store' }),
65
+ fetch(`${ROUTER_API_BASE}/settings`, { cache: 'no-store' }),
66
+ ])
67
+ const keysData = await keysRes.json() as RouterKeysResponse
68
+ const settingsData = await settingsRes.json() as RouterSettingsResponse
69
+ if (mounted.current) {
70
+ if (keysData.ok && keysData.keys) setKeys(keysData.keys)
71
+ else setError(keysData.error ?? '加载密钥失败')
72
+ if (settingsData.ok && settingsData.requireApiKey !== undefined) setRequireApiKey(settingsData.requireApiKey)
73
+ }
74
+ } catch (err) {
75
+ if (mounted.current) setError((err as Error).message)
76
+ }
77
+ }, [])
78
+
79
+ useEffect(() => { void load() }, [load])
80
+
81
+ const openCreate = (): void => {
82
+ setNewKeyName('')
83
+ setError('')
84
+ setShowCreate(true)
85
+ }
86
+
87
+ const createKey = async (): Promise<void> => {
88
+ setBusy(true)
89
+ setError('')
90
+ try {
91
+ const response = await fetch(`${ROUTER_API_BASE}/keys`, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ name: newKeyName }),
95
+ cache: 'no-store',
96
+ })
97
+ const data = await response.json() as { ok: boolean; error?: string; key?: string }
98
+ if (data.ok && data.key) {
99
+ setShowCreate(false)
100
+ setNewKeyName('')
101
+ setCreatedKey(data.key)
102
+ await load()
103
+ } else {
104
+ setError(data.error ?? '创建失败')
105
+ }
106
+ } catch (err) {
107
+ setError((err as Error).message)
108
+ } finally {
109
+ setBusy(false)
110
+ }
111
+ }
112
+
113
+ const doDelete = async (id: string): Promise<void> => {
114
+ const response = await fetch(`${ROUTER_API_BASE}/keys/delete`, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ id }),
118
+ cache: 'no-store',
119
+ })
120
+ const data = await response.json() as { ok: boolean }
121
+ if (data.ok) {
122
+ setKeys(prev => prev ? prev.filter(k => k.id !== id) : prev)
123
+ setVisibleKeys(prev => { const next = new Set(prev); next.delete(id); return next })
124
+ }
125
+ setDeleteTarget(null)
126
+ }
127
+
128
+ const toggleKey = async (id: string, isActive: boolean): Promise<void> => {
129
+ const response = await fetch(`${ROUTER_API_BASE}/keys/toggle`, {
130
+ method: 'POST',
131
+ headers: { 'Content-Type': 'application/json' },
132
+ body: JSON.stringify({ id, isActive }),
133
+ cache: 'no-store',
134
+ })
135
+ const data = await response.json() as { ok: boolean }
136
+ if (data.ok) {
137
+ setKeys(prev => prev ? prev.map(k => k.id === id ? { ...k, isActive } : k) : prev)
138
+ }
139
+ }
140
+
141
+ const toggleRequire = async (value: boolean): Promise<void> => {
142
+ setRequireApiKey(value)
143
+ const response = await fetch(`${ROUTER_API_BASE}/settings`, {
144
+ method: 'PATCH',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({ requireApiKey: value }),
147
+ cache: 'no-store',
148
+ })
149
+ const data = await response.json() as { ok: boolean }
150
+ if (!data.ok) setRequireApiKey(!value)
151
+ }
152
+
153
+ const copyText = async (text: string, id: string): Promise<void> => {
154
+ try {
155
+ await navigator.clipboard.writeText(text)
156
+ setCopiedKeyId(id)
157
+ window.setTimeout(() => setCopiedKeyId(null), 1500)
158
+ } catch {
159
+ // ignore
160
+ }
161
+ }
162
+
163
+ const toggleVisible = (id: string): void => {
164
+ setVisibleKeys(prev => {
165
+ const next = new Set(prev)
166
+ if (next.has(id)) next.delete(id)
167
+ else next.add(id)
168
+ return next
169
+ })
170
+ }
171
+
172
+ // key 开关:开→关需要确认(9router 行为)
173
+ const onKeySwitch = (key: RouterKey, next: boolean): void => {
174
+ if (key.isActive && !next) setPauseTarget(key)
175
+ else void toggleKey(key.id, next)
176
+ }
177
+
178
+ return (
179
+ <div className="dshr-tabBody">
180
+ {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
181
+
182
+ {/* ---------------- API Endpoint ---------------- */}
183
+ <section className="dshr-card">
184
+ <div className="dshr-cardHead">
185
+ <span className="dshr-cardIcon"><Icon d={I.api} /></span>
186
+ <div className="dshr-cardTitle">API Endpoint</div>
187
+ </div>
188
+ <div className="dshr-endpointList">
189
+ <div className="dshr-endpointRow">
190
+ <span className="dshr-endpointLabel">Local</span>
191
+ <input className="dshr-input dshr-mono" value={endpointBase} readOnly />
192
+ <button
193
+ type="button"
194
+ className="dshr-iconBtn"
195
+ title="复制"
196
+ onClick={() => void copyText(endpointBase, '__endpoint')}
197
+ >
198
+ <Icon d={copiedKeyId === '__endpoint' ? I.check : I.copy} size={16} />
199
+ </button>
200
+ </div>
201
+ <div className="dshr-muted dshr-reason dshr-endpointNote">OpenAI 兼容。任何工具把 baseURL 指向它即可使用。鉴权由下方「Require API key」控制。</div>
202
+ </div>
203
+ </section>
204
+
205
+ {/* ---------------- API Keys ---------------- */}
206
+ <section className="dshr-card" id="require-api-key">
207
+ <div className="dshr-cardHead">
208
+ <span className="dshr-cardIcon"><Icon d={I.key} /></span>
209
+ <div className="dshr-cardTitle">API Keys</div>
210
+ <button type="button" className="dshr-primaryButton dshr-cardAction" onClick={openCreate}>
211
+ <Icon d={I.add} size={14} />
212
+ 创建 Key
213
+ </button>
214
+ </div>
215
+
216
+ {/* Require API key 行 */}
217
+ <div className="dshr-requireRow">
218
+ <div className="dshr-requireInfo">
219
+ <p className="dshr-requireTitle">Require API key</p>
220
+ <p className="dshr-requireDesc">不带有效 key 的请求将被拒绝</p>
221
+ </div>
222
+ <button
223
+ type="button"
224
+ className={`dshr-toggle ${requireApiKey ? 'dshr-toggle-on' : ''}`}
225
+ role="switch"
226
+ aria-checked={requireApiKey}
227
+ onClick={() => void toggleRequire(!requireApiKey)}
228
+ title={requireApiKey ? '关闭鉴权' : '开启鉴权'}
229
+ >
230
+ <span className="dshr-toggleKnob" />
231
+ </button>
232
+ </div>
233
+
234
+ {/* key 列表 / 空状态 */}
235
+ {keys === null
236
+ ? <div className="dshr-empty">加载中…</div>
237
+ : keys.length === 0
238
+ ? (
239
+ <div className="dshr-keyEmpty">
240
+ <span className="dshr-keyEmptyIcon"><Icon d={I.key} size={30} /></span>
241
+ <p className="dshr-keyEmptyTitle">还没有 API Key</p>
242
+ <p className="dshr-keyEmptyDesc">创建第一个 key 开始使用</p>
243
+ <button type="button" className="dshr-primaryButton" onClick={openCreate}>
244
+ <Icon d={I.add} size={14} />
245
+ 创建 Key
246
+ </button>
247
+ </div>
248
+ )
249
+ : (
250
+ <div className="dshr-keyList">
251
+ {keys.map(key => (
252
+ <div key={key.id} className={`dshr-keyRow${key.isActive ? '' : ' dshr-keyRow-paused'}`}>
253
+ <div className="dshr-keyInfo">
254
+ <div className="dshr-keyNameRow">
255
+ <p className="dshr-keyName">{key.name}</p>
256
+ <button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-keyDelete" title="删除" onClick={() => setDeleteTarget(key)}>
257
+ <Icon d={I.delete} size={14} />
258
+ </button>
259
+ </div>
260
+ <div className="dshr-keyValue">
261
+ <code className="dshr-mono">{visibleKeys.has(key.id) ? key.key : maskKey(key.key)}</code>
262
+ <button type="button" className="dshr-iconBtn dshr-iconBtn-sm" title={visibleKeys.has(key.id) ? '隐藏' : '显示'} onClick={() => toggleVisible(key.id)}>
263
+ <Icon d={visibleKeys.has(key.id) ? I.visibilityOff : I.visibility} size={14} />
264
+ </button>
265
+ <button type="button" className="dshr-iconBtn dshr-iconBtn-sm" title="复制" onClick={() => void copyText(key.key, key.id)}>
266
+ <Icon d={copiedKeyId === key.id ? I.check : I.copy} size={14} />
267
+ </button>
268
+ </div>
269
+ <p className="dshr-keyCreated">
270
+ 创建于 {new Date(key.createdAt).toLocaleDateString()}
271
+ {key.isActive === false && <span className="dshr-keyPaused"> · 已暂停</span>}
272
+ </p>
273
+ </div>
274
+ <div className="dshr-keyOps">
275
+ <button
276
+ type="button"
277
+ className={`dshr-toggle ${key.isActive ? 'dshr-toggle-on' : ''}`}
278
+ role="switch"
279
+ aria-checked={key.isActive}
280
+ title={key.isActive ? '暂停 key' : '恢复 key'}
281
+ onClick={() => onKeySwitch(key, !key.isActive)}
282
+ >
283
+ <span className="dshr-toggleKnob" />
284
+ </button>
285
+ </div>
286
+ </div>
287
+ ))}
288
+ </div>
289
+ )}
290
+ </section>
291
+
292
+ {/* ---------------- Create Key 弹窗 ---------------- */}
293
+ {showCreate && (
294
+ <Modal title="创建 API Key" onClose={() => setShowCreate(false)}>
295
+ <div className="dshr-modalForm">
296
+ <input
297
+ className="dshr-input"
298
+ placeholder="Key 名称(如 生产环境)"
299
+ value={newKeyName}
300
+ onChange={(e) => setNewKeyName(e.target.value)}
301
+ autoFocus
302
+ />
303
+ {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
304
+ <div className="dshr-modalActions">
305
+ <button type="button" className="dshr-miniButton" onClick={() => setShowCreate(false)}>取消</button>
306
+ <button type="button" className="dshr-primaryButton" onClick={() => void createKey()} disabled={busy || newKeyName.trim() === ''}>
307
+ {busy ? '创建中…' : '创建'}
308
+ </button>
309
+ </div>
310
+ </div>
311
+ </Modal>
312
+ )}
313
+
314
+ {/* ---------------- Created Key 弹窗 ---------------- */}
315
+ {createdKey !== null && (
316
+ <Modal title="API Key 已创建" onClose={() => setCreatedKey(null)}>
317
+ <div className="dshr-modalForm">
318
+ <div className="dshr-warnBox">
319
+ <p className="dshr-warnTitle">立即保存这个 key!</p>
320
+ <p className="dshr-warnDesc">这是你唯一一次看到完整 key,请妥善保管。</p>
321
+ </div>
322
+ <div className="dshr-endpointRow dshr-createdRow">
323
+ <input className="dshr-input dshr-mono" value={createdKey} readOnly />
324
+ <button type="button" className="dshr-miniButton" onClick={() => void copyText(createdKey, '__created')}>
325
+ {copiedKeyId === '__created' ? '已复制' : '复制'}
326
+ </button>
327
+ </div>
328
+ <button type="button" className="dshr-primaryButton dshr-fullButton" onClick={() => setCreatedKey(null)}>完成</button>
329
+ </div>
330
+ </Modal>
331
+ )}
332
+
333
+ {/* ---------------- 暂停确认弹窗 ---------------- */}
334
+ {pauseTarget !== null && (
335
+ <Modal title="暂停 API Key" onClose={() => setPauseTarget(null)}>
336
+ <div className="dshr-modalForm">
337
+ <p className="dshr-muted">暂停 Key「{pauseTarget.name}」?此 key 将立即失效,之后可以恢复。</p>
338
+ <div className="dshr-modalActions">
339
+ <button type="button" className="dshr-miniButton" onClick={() => setPauseTarget(null)}>取消</button>
340
+ <button type="button" className="dshr-dangerButton" onClick={() => { const t = pauseTarget; setPauseTarget(null); void toggleKey(t.id, false) }}>暂停</button>
341
+ </div>
342
+ </div>
343
+ </Modal>
344
+ )}
345
+
346
+ {/* ---------------- 删除确认弹窗 ---------------- */}
347
+ {deleteTarget !== null && (
348
+ <Modal title="删除 API Key" onClose={() => setDeleteTarget(null)}>
349
+ <div className="dshr-modalForm">
350
+ <p className="dshr-muted">确定删除 Key「{deleteTarget.name}」吗?使用它的客户端将立即失效,无法恢复。</p>
351
+ <div className="dshr-modalActions">
352
+ <button type="button" className="dshr-miniButton" onClick={() => setDeleteTarget(null)}>取消</button>
353
+ <button type="button" className="dshr-dangerButton" onClick={() => void doDelete(deleteTarget.id)}>删除</button>
354
+ </div>
355
+ </div>
356
+ </Modal>
357
+ )}
358
+ </div>
359
+ )
360
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 通用弹窗:遮罩 + 居中对话框,ESC / 点遮罩关闭。9router 风格。
3
+ */
4
+ import { useEffect } from 'react'
5
+
6
+ interface ModalProps {
7
+ title: string
8
+ onClose: () => void
9
+ children: React.ReactNode
10
+ /** 是否允许点遮罩/ESC 关闭(默认 true)。 */
11
+ dismissable?: boolean
12
+ /** 附加到 dshr-modal 的类名(如加宽)。 */
13
+ className?: string
14
+ }
15
+
16
+ export function Modal({ title, onClose, children, dismissable = true, className = '' }: ModalProps): JSX.Element {
17
+ useEffect(() => {
18
+ if (!dismissable) return
19
+ const onKey = (e: KeyboardEvent): void => {
20
+ if (e.key === 'Escape') onClose()
21
+ }
22
+ document.addEventListener('keydown', onKey)
23
+ return () => document.removeEventListener('keydown', onKey)
24
+ }, [onClose, dismissable])
25
+
26
+ return (
27
+ <div
28
+ className="dshr-modalOverlay"
29
+ onClick={(e) => {
30
+ if (dismissable && e.target === e.currentTarget) onClose()
31
+ }}
32
+ >
33
+ <div className={`dshr-modal${className ? ` ${className}` : ''}`} role="dialog" aria-modal="true" aria-label={title}>
34
+ <div className="dshr-modalHead">
35
+ <div className="dshr-modalTitle">{title}</div>
36
+ <button type="button" className="dshr-modalClose" onClick={onClose} aria-label="关闭">
37
+ <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
38
+ <path d="M4 4l8 8M12 4l-8 8" />
39
+ </svg>
40
+ </button>
41
+ </div>
42
+ <div className="dshr-modalBody">{children}</div>
43
+ </div>
44
+ </div>
45
+ )
46
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * 路由系统 (Routing System) panel — a simplified 9router.
3
+ *
4
+ * Three tabs fed by the host half (/router/api/*), mirroring 9router's
5
+ * dashboard: 供应商 (supplier cards → detail: accounts + add-account + models),
6
+ * 组合 (combo fallback chains), 端点与密钥 (endpoint URL + API keys + auth).
7
+ *
8
+ * Masthead: 返回会话 button on the LEFT, refresh on the right.
9
+ * No online/offline concept — suppliers render whatever state they have.
10
+ */
11
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
12
+ import {
13
+ ROUTER_API_BASE,
14
+ ROUTER_TITLE,
15
+ type RouterCombosResponse,
16
+ type RouterHealthResponse,
17
+ type RouterStatusResponse,
18
+ } from '../shared.ts'
19
+ import { SupplierDetail } from './SupplierDetail.tsx'
20
+ import { EndpointTab } from './EndpointTab.tsx'
21
+ import { CombosTab } from './CombosTab.tsx'
22
+
23
+ type TabId = 'suppliers' | 'combos' | 'endpoint'
24
+
25
+ interface Snapshot {
26
+ health: RouterHealthResponse | null
27
+ status: RouterStatusResponse | null
28
+ combos: RouterCombosResponse | null
29
+ }
30
+
31
+ async function getJson<T>(path: string): Promise<T> {
32
+ const response = await fetch(`${ROUTER_API_BASE}${path}`, { cache: 'no-store' })
33
+ if (!response.ok) {
34
+ throw new Error(`HTTP ${response.status}`)
35
+ }
36
+ return response.json() as Promise<T>
37
+ }
38
+
39
+ interface RouterViewProps {
40
+ /** 关闭面板、返回会话。 */
41
+ onBack: () => void
42
+ }
43
+
44
+ export function RouterView({ onBack }: RouterViewProps): JSX.Element {
45
+ const [tab, setTab] = useState<TabId>('suppliers')
46
+ const [detailSupplier, setDetailSupplier] = useState<{ id: string; name: string; icon?: string } | null>(null)
47
+ const [snapshot, setSnapshot] = useState<Snapshot>({ health: null, status: null, combos: null })
48
+ const [refreshing, setRefreshing] = useState(false)
49
+ const mounted = useRef(true)
50
+
51
+ const refresh = async (): Promise<void> => {
52
+ if (refreshing) return
53
+ setRefreshing(true)
54
+ try {
55
+ const [health, status, combos] = await Promise.all([
56
+ getJson<RouterHealthResponse>('/health').catch(() => null),
57
+ getJson<RouterStatusResponse>('/status').catch(() => null),
58
+ getJson<RouterCombosResponse>('/combos').catch(() => null),
59
+ ])
60
+ if (mounted.current) {
61
+ setSnapshot({ health, status, combos })
62
+ }
63
+ } finally {
64
+ if (mounted.current) setRefreshing(false)
65
+ }
66
+ }
67
+
68
+ useEffect(() => {
69
+ mounted.current = true
70
+ void refresh()
71
+ return () => { mounted.current = false }
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [])
74
+
75
+ const suppliers = snapshot.health?.suppliers ?? []
76
+ const accounts = snapshot.status?.accounts ?? []
77
+ const combos = snapshot.combos?.combos ?? []
78
+ const suppliersError = snapshot.status?.ok === false ? snapshot.status.error : undefined
79
+ const combosError = snapshot.combos?.ok === false ? snapshot.combos.error : undefined
80
+
81
+ /** 渲染一组供应商卡片(内置 / 插件分开)。 */
82
+ const renderSupplierGroup = (title: string, group: Array<{ id: string; name: string; icon?: string; source?: string }>): ReactNode => {
83
+ if (group.length === 0) return null
84
+ return (
85
+ <div className="dshr-supplierGroup">
86
+ <div className="dshr-supplierGroupTitle">{title}</div>
87
+ <div className="dshr-supplierGrid">
88
+ {group.map(supplier => {
89
+ const supplierAccounts = accounts.filter(a => a.supplier === supplier.id)
90
+ const healthy = supplierAccounts.filter(a => !a.cooling && !a.disabled).length
91
+ return (
92
+ <section key={supplier.id} className="dshr-supplierCard" onClick={() => setDetailSupplier(supplier)}>
93
+ <div className="dshr-supplierRow">
94
+ {supplier.icon !== undefined && (
95
+ <img className="dshr-supplierIcon" src={supplier.icon} alt="" />
96
+ )}
97
+ <div className="dshr-supplierName">{supplier.name}</div>
98
+ </div>
99
+ <div className="dshr-supplierMeta">
100
+ {supplierAccounts.length > 0 && (
101
+ <span className="dshr-muted">{supplierAccounts.length}/{healthy} 健康</span>
102
+ )}
103
+ <span className="dshr-chevron">›</span>
104
+ </div>
105
+ </section>
106
+ )
107
+ })}
108
+ </div>
109
+ </div>
110
+ )
111
+ }
112
+
113
+ return (
114
+ <div className="dshr-shell">
115
+ <header className="dshr-masthead">
116
+ <button type="button" className="dshr-backButton" onClick={onBack} title="返回会话">
117
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
118
+ <path d="M10 3 5 8l5 5" />
119
+ </svg>
120
+ <span>返回会话</span>
121
+ </button>
122
+ <div className="dshr-brand">
123
+ <div className="dshr-brandTitle">{ROUTER_TITLE}</div>
124
+ </div>
125
+ </header>
126
+
127
+ <nav className="dshr-nav">
128
+ <button type="button" aria-current={tab === 'suppliers' ? 'page' : undefined} onClick={() => setTab('suppliers')}>
129
+ 供应商 <span className="dshr-navCount">{suppliers.length}</span>
130
+ </button>
131
+ <button type="button" aria-current={tab === 'combos' ? 'page' : undefined} onClick={() => setTab('combos')}>
132
+ 组合 <span className="dshr-navCount">{combos.length}</span>
133
+ </button>
134
+ <button type="button" aria-current={tab === 'endpoint' ? 'page' : undefined} onClick={() => setTab('endpoint')}>
135
+ 端点与密钥
136
+ </button>
137
+ </nav>
138
+
139
+ <div className="dshr-content">
140
+ {/* ---------------- 供应商 ---------------- */}
141
+ {tab === 'suppliers' && (
142
+ detailSupplier !== null
143
+ ? (
144
+ <SupplierDetail
145
+ supplier={detailSupplier}
146
+ accounts={accounts}
147
+ statusLoading={snapshot.status === null}
148
+ onBack={() => setDetailSupplier(null)}
149
+ onRefresh={() => void refresh()}
150
+ />
151
+ )
152
+ : (
153
+ <div className="dshr-tabBody">
154
+ {suppliers.length === 0 && snapshot.health !== null && (
155
+ <div className="dshr-empty">暂无供应商</div>
156
+ )}
157
+ {renderSupplierGroup('内置', suppliers.filter(s => s.source !== 'external'))}
158
+ {renderSupplierGroup('插件', suppliers.filter(s => s.source === 'external'))}
159
+ {suppliersError !== undefined && (
160
+ <div className="dshr-empty">{suppliersError}</div>
161
+ )}
162
+ </div>
163
+ )
164
+ )}
165
+
166
+ {/* ---------------- 组合 ---------------- */}
167
+ {tab === 'combos' && (
168
+ snapshot.combos === null
169
+ ? <div className="dshr-tabBody"><div className="dshr-empty">加载中…</div></div>
170
+ : combosError !== undefined
171
+ ? <div className="dshr-tabBody"><div className="dshr-empty">{combosError}</div></div>
172
+ : (
173
+ <CombosTab
174
+ combos={combos}
175
+ aliases={snapshot.combos?.aliases ?? []}
176
+ onRefresh={() => void refresh()}
177
+ />
178
+ )
179
+ )}
180
+
181
+ {/* ---------------- 端点与密钥 ---------------- */}
182
+ {tab === 'endpoint' && <EndpointTab />}
183
+ </div>
184
+ </div>
185
+ )
186
+ }