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.
- package/README.md +151 -0
- package/cordis.patch.yml +9 -0
- package/lib/client-registry.js +3006 -0
- package/lib/client.js +3006 -0
- package/lib/index.js +1756 -0
- package/lib/types/credential-store.d.ts +15 -0
- package/lib/types/index.d.ts +6 -0
- package/lib/types/keys.d.ts +39 -0
- package/lib/types/llm/adapter.d.ts +31 -0
- package/lib/types/router/index.d.ts +73 -0
- package/lib/types/router/types.d.ts +80 -0
- package/lib/types/shared.d.ts +138 -0
- package/lib/types/supplier-config.d.ts +28 -0
- package/lib/types/suppliers/contract.d.ts +98 -0
- package/lib/types/suppliers/loader.d.ts +41 -0
- package/lib/types/suppliers/registry.d.ts +33 -0
- package/package.json +86 -0
- package/src/client/CombosTab.tsx +458 -0
- package/src/client/EndpointTab.tsx +360 -0
- package/src/client/Modal.tsx +46 -0
- package/src/client/RouterView.tsx +186 -0
- package/src/client/SupplierDetail.tsx +943 -0
- package/src/client/index.tsx +18 -0
- package/src/client/router.css +2096 -0
- package/src/client/sidebar-entry.ts +167 -0
- package/src/client/workspace-controller.ts +26 -0
- package/src/client/workspace-mount.tsx +117 -0
- package/src/credential-store.ts +64 -0
- package/src/index.ts +468 -0
- package/src/keys.ts +143 -0
- package/src/llm/adapter.ts +273 -0
- package/src/router/index.ts +273 -0
- package/src/router/types.ts +85 -0
- package/src/shared.ts +152 -0
- package/src/supplier-config.ts +144 -0
- package/src/suppliers/contract.ts +73 -0
- package/src/suppliers/loader.ts +191 -0
- package/src/suppliers/nvidia/plugin.ts +268 -0
- package/src/suppliers/opencode/plugin.ts +200 -0
- package/src/suppliers/openrouter/plugin.ts +282 -0
- package/src/suppliers/registry.ts +365 -0
- 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
|
+
}
|