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,943 @@
1
+ /**
2
+ * 供应商详情 — 布局交互贴近 9router 供应商详情页。
3
+ * Header(返回 + 图标 + 名称 + 链接数)+ 链接池卡片(空状态 / 行列表 + 删除)
4
+ * + 可用模型卡片(flex-wrap chips,禁用模型单独列出可恢复)。
5
+ * 加链接流程由供应商能力驱动(登录链接 / API key / 轮询登录)。
6
+ */
7
+ import { useCallback, useEffect, useRef, useState } from 'react'
8
+ import {
9
+ ROUTER_API_BASE,
10
+ type RouterAccount,
11
+ type RouterLoginResponse,
12
+ type RouterSupplierModel,
13
+ type RouterSupplierModelsResponse,
14
+ } from '../shared.ts'
15
+ import { Modal } from './Modal.tsx'
16
+
17
+ /* ---------------- SVG 图标 ---------------- */
18
+
19
+ function Icon({ d, size = 18 }: { d: string; size?: number }): JSX.Element {
20
+ return (
21
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
22
+ <path d={d} />
23
+ </svg>
24
+ )
25
+ }
26
+
27
+ const I = {
28
+ back: 'M19 12H5M12 19l-7-7 7-7',
29
+ link: 'M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7',
30
+ add: 'M12 5v14M5 12h14',
31
+ 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',
32
+ key: 'M14 7a4 4 0 1 1-1.4 7.6L9 18H6v-3l4.4-4.4A4 4 0 0 1 14 7zM18 7h.01',
33
+ checkCircle: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM8.5 12l2.5 2.5 4.5-5',
34
+ cancel: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM9 9l6 6M15 9l-6 6',
35
+ robot: 'M12 8V4M8 4h8M4 12a8 8 0 0 1 16 0v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6zM9 14h.01M15 14h.01',
36
+ test: 'M9 3h6M10 3v6.3L4.7 18a2 2 0 0 0 1.8 3h11a2 2 0 0 0 1.8-3L14 9.3V3M7.5 15h9',
37
+ copy: 'M9 9h11v11H9zM5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1',
38
+ check: 'M4 12.5l5 5L20 6.5',
39
+ edit: 'M4 20h4L19.5 8.5a2.1 2.1 0 0 0-3-3L5 17v3zM13.5 6.5l3 3',
40
+ loading: 'M12 3a9 9 0 1 0 9 9M12 3a9 9 0 0 1 9 9',
41
+ search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
42
+ }
43
+
44
+ interface SupplierDetailProps {
45
+ supplier: { id: string; name: string; capabilities?: string[]; icon?: string }
46
+ accounts: RouterAccount[]
47
+ /** 状态是否还在加载。 */
48
+ statusLoading: boolean
49
+ onBack: () => void
50
+ onRefresh: () => void
51
+ }
52
+
53
+ function untilLabel(account: RouterAccount): string {
54
+ if (!account.cooling || account.until === undefined) return ''
55
+ const remaining = new Date(account.until).getTime() - Date.now()
56
+ if (remaining <= 0) return '即将恢复'
57
+ const minutes = Math.ceil(remaining / 60000)
58
+ if (minutes < 60) return `剩余 ${minutes} 分钟`
59
+ const hours = Math.ceil(minutes / 60)
60
+ return `剩余 ${hours} 小时`
61
+ }
62
+
63
+ function statusBadge(account: RouterAccount): { text: string; cls: string } {
64
+ if (account.disabled) return { text: '已禁用', cls: 'dshr-badge-danger' }
65
+ if (account.cooling) return { text: '冷却中', cls: 'dshr-badge-warn' }
66
+ return { text: '健康', cls: 'dshr-badge-ok' }
67
+ }
68
+
69
+ export function SupplierDetail({ supplier, accounts, statusLoading, onBack, onRefresh }: SupplierDetailProps): JSX.Element {
70
+ const hasCap = (name: string): boolean => supplier.capabilities?.includes(name) ?? false
71
+ // 连接池(账号池)由「添加链接」能力控制显示——没有添加链接 = 无账号概念,不显示连接池
72
+ // 添加链接有三种:URL 回调登录(generateLoginUrl) / API key 弹窗(addApiKey) / 轮询登录(pollLogin)
73
+ const canLogin = hasCap('generateLoginUrl') || hasCap('addApiKey')
74
+ const canApiKey = hasCap('addApiKey')
75
+ const canPoll = hasCap('pollLogin')
76
+ const canPool = canLogin
77
+ // 签到是供应商特有功能(按能力受控)
78
+ const canCheckin = hasCap('checkinNow')
79
+ // 模型管理/别名是通用 UI;模型测试是必要差异化能力(所有供应商恒显示)
80
+ const canModels = true
81
+ const canEdit = true
82
+ const [showLogin, setShowLogin] = useState(false)
83
+ const [loginUrl, setLoginUrl] = useState<string | null>(null)
84
+ const [callbackUrl, setCallbackUrl] = useState('')
85
+ const [keyName, setKeyName] = useState('')
86
+ const [apiKey, setApiKey] = useState('')
87
+ const [busy, setBusy] = useState(false)
88
+ const [error, setError] = useState('')
89
+ const [removeTarget, setRemoveTarget] = useState<RouterAccount | null>(null)
90
+ const [models, setModels] = useState<RouterSupplierModel[] | null>(null)
91
+ const [alias, setAlias] = useState('')
92
+ const [modelsError, setModelsError] = useState('')
93
+ const [toast, setToast] = useState<string | null>(null)
94
+ const [toggling, setToggling] = useState(false)
95
+ const [showAddModel, setShowAddModel] = useState(false)
96
+ const [newModelId, setNewModelId] = useState('')
97
+ const [addModelError, setAddModelError] = useState('')
98
+ const [addTesting, setAddTesting] = useState(false)
99
+ const [addTestResult, setAddTestResult] = useState<{ ok: boolean; error?: string } | null>(null)
100
+ const [showAlias, setShowAlias] = useState(false)
101
+ const [aliasDraft, setAliasDraft] = useState('')
102
+ const [aliasError, setAliasError] = useState('')
103
+ const [testingIds, setTestingIds] = useState<Set<string>>(() => new Set())
104
+ const [testResults, setTestResults] = useState<Record<string, 'ok' | 'error'>>({})
105
+ const [copiedModel, setCopiedModel] = useState<string | null>(null)
106
+ const [modelQuery, setModelQuery] = useState('')
107
+ const [fetchingModels, setFetchingModels] = useState(false)
108
+ const [removeModelTarget, setRemoveModelTarget] = useState<string | null>(null)
109
+ const [poolStrategy, setPoolStrategy] = useState<'fallback' | 'round-robin'>('fallback')
110
+ const [poolOrder, setPoolOrder] = useState<string[]>([])
111
+ const [checkinRule, setCheckinRule] = useState<'all' | 'first'>('all')
112
+ const [checkingIn, setCheckingIn] = useState(false)
113
+ const [dragIndex, setDragIndex] = useState<number | null>(null)
114
+ const mounted = useRef(true)
115
+
116
+ useEffect(() => {
117
+ mounted.current = true
118
+ return () => { mounted.current = false }
119
+ }, [])
120
+
121
+ const showToast = (msg: string): void => {
122
+ setToast(msg)
123
+ window.setTimeout(() => {
124
+ if (mounted.current) setToast(null)
125
+ }, 2500)
126
+ }
127
+
128
+ const loadModels = useCallback(async (): Promise<void> => {
129
+ try {
130
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models`, { cache: 'no-store' })
131
+ const data = await response.json() as RouterSupplierModelsResponse
132
+ if (mounted.current) {
133
+ if (data.ok && data.models) {
134
+ setModels(data.models)
135
+ if (data.alias) setAlias(data.alias)
136
+ } else setModelsError(data.error ?? '加载失败')
137
+ }
138
+ } catch (err) {
139
+ if (mounted.current) setModelsError((err as Error).message)
140
+ }
141
+ }, [])
142
+
143
+ useEffect(() => { void loadModels() }, [loadModels])
144
+
145
+ const loadPoolConfig = useCallback(async (): Promise<void> => {
146
+ try {
147
+ const [strategyRes, orderRes, ruleRes] = await Promise.all([
148
+ fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/strategy`, { cache: 'no-store' }),
149
+ fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/order`, { cache: 'no-store' }),
150
+ fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/checkin/rule`, { cache: 'no-store' }),
151
+ ])
152
+ const [strategyData, orderData, ruleData] = await Promise.all([strategyRes.json(), orderRes.json(), ruleRes.json()]) as [
153
+ { ok: boolean; strategy?: 'fallback' | 'round-robin' },
154
+ { ok: boolean; order?: string[] },
155
+ { ok: boolean; rule?: 'all' | 'first' },
156
+ ]
157
+ if (mounted.current) {
158
+ if (strategyData.ok && strategyData.strategy) setPoolStrategy(strategyData.strategy)
159
+ if (orderData.ok && Array.isArray(orderData.order)) setPoolOrder(orderData.order)
160
+ if (ruleData.ok && ruleData.rule) setCheckinRule(ruleData.rule)
161
+ }
162
+ } catch {
163
+ // 连接池配置加载失败不阻断
164
+ }
165
+ }, [])
166
+
167
+ useEffect(() => { void loadPoolConfig() }, [loadPoolConfig])
168
+
169
+ const savePoolOrder = async (uids: string[]): Promise<void> => {
170
+ setPoolOrder(uids)
171
+ try {
172
+ await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/order`, {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({ uids }),
176
+ cache: 'no-store',
177
+ })
178
+ showToast('连接池顺序已保存')
179
+ } catch {
180
+ showToast('保存顺序失败')
181
+ }
182
+ }
183
+
184
+ const runCheckin = async (): Promise<void> => {
185
+ if (checkingIn) return
186
+ setCheckingIn(true)
187
+ try {
188
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/checkin`, { method: 'POST', cache: 'no-store' })
189
+ const data = await response.json() as { ok: boolean; total: number; succeeded: number; already?: number; error?: string; results?: Array<{ uid: string; ok: boolean; status?: string; message?: string }> }
190
+ if (data.ok && data.total > 0) {
191
+ // 真实语义:succeeded = 真正 claim 成功;already = 今日已签;其余 = 失败/未开放
192
+ const failed = (data.results ?? []).filter(r => r.status !== 'ok' && r.status !== 'already')
193
+ let msg = `签到完成:${data.succeeded}/${data.total} 成功`
194
+ const already = data.already ?? 0
195
+ if (already > 0) msg += ` · ${already} 今日已签`
196
+ if (failed.length > 0) {
197
+ const first = failed[0]
198
+ msg += ` · ${first?.message ?? '有失败'}`
199
+ }
200
+ showToast(msg)
201
+ } else {
202
+ showToast(data.error ?? (data.total === 0 ? '没有可签到的链接' : '签到失败'))
203
+ }
204
+ onRefresh()
205
+ } catch (err) {
206
+ showToast(`签到失败:${(err as Error).message}`)
207
+ } finally {
208
+ setCheckingIn(false)
209
+ }
210
+ }
211
+
212
+ const startLogin = async (): Promise<void> => {
213
+ // API key 供应商:弹窗直接填名字+key,不走 URL 登录
214
+ if (canApiKey) {
215
+ setShowLogin(true)
216
+ setBusy(false)
217
+ setError('')
218
+ setLoginUrl(null)
219
+ setCallbackUrl('')
220
+ setKeyName('')
221
+ setApiKey('')
222
+ return
223
+ }
224
+ setShowLogin(true)
225
+ setBusy(true)
226
+ setError('')
227
+ setLoginUrl(null)
228
+ setCallbackUrl('')
229
+ try {
230
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/login`, { method: 'POST', cache: 'no-store' })
231
+ const data = await response.json() as RouterLoginResponse
232
+ if (data.ok && data.loginUrl) setLoginUrl(data.loginUrl)
233
+ else setError(data.error ?? '生成登录链接失败')
234
+ } catch (err) {
235
+ setError((err as Error).message)
236
+ } finally {
237
+ setBusy(false)
238
+ }
239
+ }
240
+
241
+ const completeLogin = async (): Promise<void> => {
242
+ if (callbackUrl.trim() === '') return
243
+ setBusy(true)
244
+ setError('')
245
+ try {
246
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/login/callback`, {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ callbackUrl: callbackUrl.trim() }),
250
+ cache: 'no-store',
251
+ })
252
+ const data = await response.json() as { ok: boolean; error?: string; account?: { uid: string; nickname: string } }
253
+ if (data.ok && data.account) {
254
+ showToast(`已添加链接 ${data.account.nickname || data.account.uid}`)
255
+ setShowLogin(false)
256
+ setLoginUrl(null)
257
+ setCallbackUrl('')
258
+ onRefresh()
259
+ } else {
260
+ setError(data.error ?? '添加失败')
261
+ }
262
+ } catch (err) {
263
+ setError((err as Error).message)
264
+ } finally {
265
+ setBusy(false)
266
+ }
267
+ }
268
+
269
+ const addApiKey = async (): Promise<void> => {
270
+ if (apiKey.trim() === '') return
271
+ setBusy(true)
272
+ setError('')
273
+ try {
274
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/links/add`, {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({ name: keyName.trim(), apiKey: apiKey.trim() }),
278
+ cache: 'no-store',
279
+ })
280
+ const data = await response.json() as { ok: boolean; error?: string; account?: { uid: string; nickname: string } }
281
+ if (data.ok && data.account) {
282
+ showToast(`已添加链接 ${data.account.nickname || data.account.uid}`)
283
+ setShowLogin(false)
284
+ setKeyName('')
285
+ setApiKey('')
286
+ onRefresh()
287
+ } else {
288
+ setError(data.error ?? '添加失败')
289
+ }
290
+ } catch (err) {
291
+ setError((err as Error).message)
292
+ } finally {
293
+ setBusy(false)
294
+ }
295
+ }
296
+
297
+ const doRemove = async (uid: string): Promise<void> => {
298
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/links/remove`, {
299
+ method: 'POST',
300
+ headers: { 'Content-Type': 'application/json' },
301
+ body: JSON.stringify({ uid }),
302
+ cache: 'no-store',
303
+ })
304
+ const data = await response.json() as { ok: boolean }
305
+ if (data.ok) onRefresh()
306
+ setRemoveTarget(null)
307
+ }
308
+
309
+ const toggleModel = async (id: string, enabled: boolean): Promise<void> => {
310
+ setToggling(true)
311
+ try {
312
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/toggle`, {
313
+ method: 'POST',
314
+ headers: { 'Content-Type': 'application/json' },
315
+ body: JSON.stringify({ id, enabled }),
316
+ cache: 'no-store',
317
+ })
318
+ const data = await response.json() as { ok: boolean }
319
+ if (data.ok) {
320
+ setModels(prev => prev ? prev.map(m => m.id === id ? { ...m, enabled } : m) : prev)
321
+ onRefresh()
322
+ }
323
+ } finally {
324
+ setToggling(false)
325
+ }
326
+ }
327
+
328
+ const testNewModel = async (): Promise<void> => {
329
+ if (newModelId.trim() === '') return
330
+ setAddTesting(true)
331
+ setAddTestResult(null)
332
+ setAddModelError('')
333
+ try {
334
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/test`, {
335
+ method: 'POST',
336
+ headers: { 'Content-Type': 'application/json' },
337
+ body: JSON.stringify({ id: newModelId.trim() }),
338
+ cache: 'no-store',
339
+ })
340
+ const data = await response.json() as { ok: boolean; error?: string }
341
+ setAddTestResult(data.ok ? { ok: true } : { ok: false, error: data.error ?? '测试失败' })
342
+ } catch (err) {
343
+ setAddTestResult({ ok: false, error: (err as Error).message })
344
+ } finally {
345
+ setAddTesting(false)
346
+ }
347
+ }
348
+
349
+ const addCustomModel = async (): Promise<void> => {
350
+ if (newModelId.trim() === '') return
351
+ setBusy(true)
352
+ setAddModelError('')
353
+ try {
354
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/add`, {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/json' },
357
+ body: JSON.stringify({ id: newModelId.trim() }),
358
+ cache: 'no-store',
359
+ })
360
+ const data = await response.json() as { ok: boolean; error?: string }
361
+ if (data.ok) {
362
+ setShowAddModel(false)
363
+ setNewModelId('')
364
+ await loadModels()
365
+ } else {
366
+ setAddModelError(data.error ?? '添加失败')
367
+ }
368
+ } catch (err) {
369
+ setAddModelError((err as Error).message)
370
+ } finally {
371
+ setBusy(false)
372
+ }
373
+ }
374
+
375
+ const bulkModels = async (enabled: boolean): Promise<void> => {
376
+ setToggling(true)
377
+ try {
378
+ await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/bulk`, {
379
+ method: 'POST',
380
+ headers: { 'Content-Type': 'application/json' },
381
+ body: JSON.stringify({ enabled }),
382
+ cache: 'no-store',
383
+ })
384
+ await loadModels()
385
+ } finally {
386
+ setToggling(false)
387
+ }
388
+ }
389
+
390
+ const fetchModels = async (): Promise<void> => {
391
+ setFetchingModels(true)
392
+ setModelsError('')
393
+ try {
394
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/fetch`, { method: 'POST', cache: 'no-store' })
395
+ const data = await response.json() as { ok: boolean; error?: string; added?: number; removed?: number }
396
+ if (data.ok) {
397
+ await loadModels()
398
+ const added = data.added ?? 0
399
+ const removed = data.removed ?? 0
400
+ if (added === 0 && removed === 0) showToast('模型已是最新')
401
+ else showToast(`已更新模型:+${added} 新增${removed > 0 ? `,${removed} 移除` : ''}`)
402
+ } else {
403
+ setModelsError(data.error ?? '获取失败')
404
+ }
405
+ } catch (err) {
406
+ setModelsError((err as Error).message)
407
+ } finally {
408
+ setFetchingModels(false)
409
+ }
410
+ }
411
+
412
+ const removeModel = async (id: string): Promise<void> => {
413
+ setToggling(true)
414
+ try {
415
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/remove`, {
416
+ method: 'POST',
417
+ headers: { 'Content-Type': 'application/json' },
418
+ body: JSON.stringify({ id }),
419
+ cache: 'no-store',
420
+ })
421
+ const data = await response.json() as { ok: boolean; error?: string }
422
+ if (data.ok) await loadModels()
423
+ else setModelsError(data.error ?? '删除失败')
424
+ } finally {
425
+ setToggling(false)
426
+ }
427
+ }
428
+
429
+ const testModel = async (id: string): Promise<void> => {
430
+ if (testingIds.has(id)) return
431
+ setTestingIds(prev => new Set(prev).add(id))
432
+ try {
433
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/models/test`, {
434
+ method: 'POST',
435
+ headers: { 'Content-Type': 'application/json' },
436
+ body: JSON.stringify({ id }),
437
+ cache: 'no-store',
438
+ })
439
+ const data = await response.json() as { ok: boolean; error?: string }
440
+ setTestResults(prev => ({ ...prev, [id]: data.ok ? 'ok' : 'error' }))
441
+ if (!data.ok) setModelsError(data.error ?? '测试失败')
442
+ } catch (err) {
443
+ setTestResults(prev => ({ ...prev, [id]: 'error' }))
444
+ setModelsError((err as Error).message)
445
+ } finally {
446
+ setTestingIds(prev => { const n = new Set(prev); n.delete(id); return n })
447
+ }
448
+ }
449
+
450
+ const copyModel = async (id: string): Promise<void> => {
451
+ try {
452
+ await navigator.clipboard.writeText(`${alias}/${id}`)
453
+ setCopiedModel(id)
454
+ window.setTimeout(() => setCopiedModel(null), 1500)
455
+ } catch {
456
+ // ignore
457
+ }
458
+ }
459
+
460
+ const saveAlias = async (): Promise<void> => {
461
+ setBusy(true)
462
+ setAliasError('')
463
+ try {
464
+ const response = await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/alias`, {
465
+ method: 'POST',
466
+ headers: { 'Content-Type': 'application/json' },
467
+ body: JSON.stringify({ alias: aliasDraft.trim() }),
468
+ cache: 'no-store',
469
+ })
470
+ const data = await response.json() as { ok: boolean; error?: string }
471
+ if (data.ok) {
472
+ setAlias(aliasDraft.trim())
473
+ // 同时保存连接池策略 + 签到规则
474
+ try {
475
+ await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/pool/strategy`, {
476
+ method: 'POST',
477
+ headers: { 'Content-Type': 'application/json' },
478
+ body: JSON.stringify({ strategy: poolStrategy }),
479
+ cache: 'no-store',
480
+ })
481
+ } catch {
482
+ // 策略保存失败不阻断前缀保存
483
+ }
484
+ try {
485
+ await fetch(`${ROUTER_API_BASE}/suppliers/${supplier.id}/checkin/rule`, {
486
+ method: 'POST',
487
+ headers: { 'Content-Type': 'application/json' },
488
+ body: JSON.stringify({ rule: checkinRule }),
489
+ cache: 'no-store',
490
+ })
491
+ } catch {
492
+ // 签到规则保存失败不阻断前缀保存
493
+ }
494
+ setShowAlias(false)
495
+ } else {
496
+ setAliasError(data.error ?? '保存失败')
497
+ }
498
+ } catch (err) {
499
+ setAliasError((err as Error).message)
500
+ } finally {
501
+ setBusy(false)
502
+ }
503
+ }
504
+
505
+ const supplierAccounts = accounts.filter(a => a.supplier === supplier.id)
506
+ // 连接池顺序:poolOrder 排前面,未在 order 的按原顺序追加
507
+ const orderedAccounts = (() => {
508
+ const byUid = new Map(supplierAccounts.map(a => [a.uid, a]))
509
+ const out: RouterAccount[] = []
510
+ const seen = new Set<string>()
511
+ for (const uid of poolOrder) {
512
+ const a = byUid.get(uid)
513
+ if (a && !seen.has(uid)) { out.push(a); seen.add(uid) }
514
+ }
515
+ for (const a of supplierAccounts) {
516
+ if (!seen.has(a.uid)) out.push(a)
517
+ }
518
+ return out
519
+ })()
520
+ const q = modelQuery.trim().toLowerCase()
521
+ const matchQuery = (m: RouterSupplierModel): boolean => q === '' || m.id.toLowerCase().includes(q)
522
+ const enabledModels = models?.filter(m => m.enabled && matchQuery(m)) ?? []
523
+ const disabledModels = models?.filter(m => !m.enabled && matchQuery(m)) ?? []
524
+
525
+ return (
526
+ <div className="dshr-tabBody">
527
+ {/* Header(9router 风格) */}
528
+ <div className="dshr-providerHead">
529
+ <button type="button" className="dshr-backLink" onClick={onBack}>
530
+ <Icon d={I.back} size={16} />
531
+ 返回
532
+ </button>
533
+ <div className="dshr-providerTitleRow">
534
+ <div className="dshr-providerIcon">
535
+ {supplier.icon !== undefined
536
+ ? <img className="dshr-providerImg" src={supplier.icon} alt="" />
537
+ : <Icon d={I.link} size={22} />}
538
+ </div>
539
+ <div className="dshr-providerMeta">
540
+ <h1 className="dshr-providerName">{supplier.name}</h1>
541
+ <p className="dshr-providerCount">
542
+ {supplierAccounts.length} 链接
543
+ {canPool && ` · ${poolStrategy === 'round-robin' ? '轮询' : '回退'}`}
544
+ {canEdit && ` · 前缀 ${alias}`}
545
+ </p>
546
+ </div>
547
+ {canEdit && (
548
+ <button type="button" className="dshr-iconBtn dshr-providerEdit" title="编辑供应商" onClick={() => { setAliasDraft(alias); setAliasError(''); setShowAlias(true) }}>
549
+ <Icon d={I.edit} size={15} />
550
+ </button>
551
+ )}
552
+ </div>
553
+ </div>
554
+
555
+ {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
556
+
557
+ {canPool ? (
558
+ <section className="dshr-card">
559
+ <div className="dshr-cardHead">
560
+ <div className="dshr-cardTitle">链接池</div>
561
+ <div className="dshr-cardActions">
562
+ {canCheckin && (
563
+ <button type="button" className="dshr-miniButton" onClick={() => void runCheckin()} disabled={checkingIn || supplierAccounts.length === 0}>
564
+ {checkingIn ? '签到中…' : '签到'}
565
+ </button>
566
+ )}
567
+ {canLogin && (
568
+ <button type="button" className="dshr-primaryButton" onClick={() => void startLogin()} disabled={busy}>
569
+ <Icon d={I.add} size={14} />
570
+ 添加链接
571
+ </button>
572
+ )}
573
+ </div>
574
+ </div>
575
+
576
+ {statusLoading
577
+ ? <div className="dshr-empty">加载中…</div>
578
+ : supplierAccounts.length === 0
579
+ ? (
580
+ <div className="dshr-linkEmpty">
581
+ <span className="dshr-linkEmptyIcon"><Icon d={I.link} size={28} /></span>
582
+ <div className="dshr-linkEmptyText">
583
+ <p className="dshr-linkEmptyTitle">暂无链接</p>
584
+ <p className="dshr-linkEmptyDesc">添加第一个登录链接开始使用</p>
585
+ </div>
586
+ {canLogin && (
587
+ <button type="button" className="dshr-primaryButton" onClick={() => void startLogin()} disabled={busy}>
588
+ <Icon d={I.add} size={14} />
589
+ 添加链接
590
+ </button>
591
+ )}
592
+ </div>
593
+ )
594
+ : (
595
+ <div className="dshr-linkList">
596
+ {orderedAccounts.map((account, index) => {
597
+ const badge = statusBadge(account)
598
+ const coolingText = account.cooling || account.disabled
599
+ ? `${account.reason ?? ''}${untilLabel(account) !== '' ? ` · ${untilLabel(account)}` : ''}`
600
+ : ''
601
+ return (
602
+ <div
603
+ key={account.uid}
604
+ className={`dshr-linkRow${dragIndex === index ? ' dshr-linkRowDragging' : ''}`}
605
+ draggable
606
+ onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move' }}
607
+ onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }}
608
+ onDrop={(e) => {
609
+ e.preventDefault()
610
+ if (dragIndex === null || dragIndex === index) { setDragIndex(null); return }
611
+ const next = [...orderedAccounts]
612
+ const [moved] = next.splice(dragIndex, 1)
613
+ if (moved) next.splice(index, 0, moved)
614
+ setDragIndex(null)
615
+ void savePoolOrder(next.map(a => a.uid))
616
+ }}
617
+ onDragEnd={() => setDragIndex(null)}
618
+ >
619
+ <div className="dshr-linkGrip" title="拖动排序">
620
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true">
621
+ <circle cx="9" cy="6" r="1.5" /><circle cx="15" cy="6" r="1.5" />
622
+ <circle cx="9" cy="12" r="1.5" /><circle cx="15" cy="12" r="1.5" />
623
+ <circle cx="9" cy="18" r="1.5" /><circle cx="15" cy="18" r="1.5" />
624
+ </svg>
625
+ </div>
626
+ <div className="dshr-linkMain">
627
+ <div className="dshr-linkTitleLine">
628
+ <span className="dshr-linkName">{account.nickname || account.uid}</span>
629
+ <span className={`dshr-badge ${badge.cls}`}>{badge.text}</span>
630
+ </div>
631
+ <div className="dshr-linkSub">
632
+ <span className="dshr-mono dshr-linkUid">{account.uid}</span>
633
+ <span className="dshr-linkDot">·</span>
634
+ <span className="dshr-mono dshr-linkCredits">{Math.ceil(account.credits)} 积分</span>
635
+ {account.err_count !== undefined && account.err_count > 0 && (
636
+ <>
637
+ <span className="dshr-linkDot">·</span>
638
+ <span className="dshr-linkErr">连续错误 {account.err_count}</span>
639
+ </>
640
+ )}
641
+ {coolingText !== '' && <span className="dshr-reason dshr-linkCooling">{coolingText}</span>}
642
+ </div>
643
+ </div>
644
+ <div className="dshr-linkOps">
645
+ <button type="button" className="dshr-iconBtn dshr-iconBtn-sm dshr-linkDelete" title="删除链接" onClick={() => setRemoveTarget(account)}>
646
+ <Icon d={I.delete} size={15} />
647
+ </button>
648
+ </div>
649
+ </div>
650
+ )
651
+ })}
652
+ </div>
653
+ )}
654
+ </section>
655
+ ) : null}
656
+
657
+ {canModels ? (
658
+ <section className="dshr-card">
659
+ <div className="dshr-cardHead">
660
+ <div className="dshr-cardTitle">可用模型</div>
661
+ <span className="dshr-muted dshr-cardMeta dshr-cardMetaAfter">{models ? `${enabledModels.length}/${models.length} 启用` : ''}</span>
662
+ <div className="dshr-cardActions">
663
+ <button type="button" className="dshr-miniButton" onClick={() => void fetchModels()} disabled={fetchingModels}>
664
+ {fetchingModels ? '获取中…' : '获取模型'}
665
+ </button>
666
+ <button type="button" className="dshr-miniButton" onClick={() => { setAddModelError(''); setAddTestResult(null); setNewModelId(''); setShowAddModel(true) }} disabled={toggling}>
667
+ <Icon d={I.add} size={13} />
668
+ 添加模型
669
+ </button>
670
+ <button type="button" className="dshr-miniButton" onClick={() => void bulkModels(true)} disabled={toggling}>全部启用</button>
671
+ <button type="button" className="dshr-miniButton" onClick={() => void bulkModels(false)} disabled={toggling}>全部禁用</button>
672
+ </div>
673
+ </div>
674
+ <div className="dshr-modelSearch">
675
+ <span className="dshr-modelSearchIcon"><Icon d={I.search} size={13} /></span>
676
+ <input
677
+ className="dshr-input"
678
+ placeholder="搜索模型…"
679
+ value={modelQuery}
680
+ onChange={(e) => setModelQuery(e.target.value)}
681
+ />
682
+ </div>
683
+ {modelsError !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{modelsError}</span></div>}
684
+ {models === null && modelsError === ''
685
+ ? <div className="dshr-empty">加载中…</div>
686
+ : (
687
+ <div className="dshr-modelChips">
688
+ {enabledModels.map(model => {
689
+ const status = testResults[model.id]
690
+ const chipCls = status === 'ok' ? ' dshr-modelChip-ok' : status === 'error' ? ' dshr-modelChip-err' : ''
691
+ return (
692
+ <div key={model.id} className={`dshr-modelChipWrap${chipCls}`}>
693
+ <button
694
+ type="button"
695
+ className="dshr-modelChip"
696
+ title="点击禁用"
697
+ disabled={toggling}
698
+ onClick={() => void toggleModel(model.id, false)}
699
+ >
700
+ <span className="dshr-modelChipStatus">
701
+ <Icon d={status === 'ok' ? I.checkCircle : status === 'error' ? I.cancel : I.robot} size={13} />
702
+ </span>
703
+ <span className="dshr-modelChipId">{model.id}</span>
704
+ </button>
705
+ <div className="dshr-modelChipOps">
706
+ <button
707
+ type="button"
708
+ className="dshr-iconBtn dshr-iconBtn-sm"
709
+ title={testingIds.has(model.id) ? '测试中…' : '测试'}
710
+ onClick={() => void testModel(model.id)}
711
+ >
712
+ <span className={testingIds.has(model.id) ? 'dshr-spin' : undefined}>
713
+ <Icon d={testingIds.has(model.id) ? I.loading : I.test} size={13} />
714
+ </span>
715
+ </button>
716
+ <button
717
+ type="button"
718
+ className="dshr-iconBtn dshr-iconBtn-sm"
719
+ title="复制模型名"
720
+ onClick={() => void copyModel(model.id)}
721
+ >
722
+ <Icon d={copiedModel === model.id ? I.check : I.copy} size={13} />
723
+ </button>
724
+ {model.custom && (
725
+ <button
726
+ type="button"
727
+ className="dshr-iconBtn dshr-iconBtn-sm dshr-modelDelete"
728
+ title="删除自定义模型"
729
+ onClick={() => setRemoveModelTarget(model.id)}
730
+ >
731
+ <Icon d={I.delete} size={13} />
732
+ </button>
733
+ )}
734
+ </div>
735
+ </div>
736
+ )
737
+ })}
738
+ {disabledModels.length > 0 && (
739
+ <div className="dshr-disabledModels">
740
+ <p className="dshr-disabledLabel">已禁用({disabledModels.length})</p>
741
+ <div className="dshr-modelChips">
742
+ {disabledModels.map(model => (
743
+ <button
744
+ key={model.id}
745
+ type="button"
746
+ className="dshr-modelChip dshr-modelChip-off"
747
+ title="点击恢复"
748
+ disabled={toggling}
749
+ onClick={() => void toggleModel(model.id, true)}
750
+ >
751
+ <span className="dshr-modelChipId">{model.id}</span>
752
+ <span className="dshr-modelChipRestore">+ 恢复</span>
753
+ </button>
754
+ ))}
755
+ </div>
756
+ </div>
757
+ )}
758
+ </div>
759
+ )}
760
+ </section>
761
+ ) : null}
762
+
763
+ {/* ---- 添加模型弹窗 ---- */}
764
+ {showAddModel && (
765
+ <Modal title="添加模型" onClose={() => setShowAddModel(false)} dismissable={false}>
766
+ <div className="dshr-modalForm">
767
+ <p className="dshr-muted">输入模型 id,自定义模型会加入可用列表。</p>
768
+ <input
769
+ className="dshr-input"
770
+ placeholder="模型 id"
771
+ value={newModelId}
772
+ onChange={(e) => setNewModelId(e.target.value)}
773
+ autoFocus
774
+ />
775
+ {addTestResult !== null && (
776
+ <div className={addTestResult.ok ? 'dshr-alert dshr-alert-ok' : 'dshr-alert'}>
777
+ <strong>{addTestResult.ok ? '测试通过' : '测试失败'}</strong>
778
+ <span>{addTestResult.ok ? '该模型可用。' : addTestResult.error}</span>
779
+ </div>
780
+ )}
781
+ {addModelError !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{addModelError}</span></div>}
782
+ <div className="dshr-modalActions">
783
+ <button type="button" className="dshr-miniButton" onClick={() => void testNewModel()} disabled={addTesting || newModelId.trim() === ''}>
784
+ {addTesting ? '测试中…' : '测试'}
785
+ </button>
786
+ <button type="button" className="dshr-miniButton" onClick={() => setShowAddModel(false)}>取消</button>
787
+ <button type="button" className="dshr-primaryButton" onClick={() => void addCustomModel()} disabled={busy || newModelId.trim() === ''}>
788
+ {busy ? '添加中…' : '添加'}
789
+ </button>
790
+ </div>
791
+ </div>
792
+ </Modal>
793
+ )}
794
+
795
+ {/* ---- 编辑供应商弹窗(前缀 + 连接池策略 + 签到规则) ---- */}
796
+ {showAlias && (
797
+ <Modal title="编辑供应商" onClose={() => setShowAlias(false)}>
798
+ <div className="dshr-modalForm">
799
+ <>
800
+ <label className="dshr-fieldLabel">前缀</label>
801
+ <p className="dshr-muted">前缀用于模型全名(复制模型名时 = 前缀/模型id),只含字母、数字、- 和 _。</p>
802
+ <input
803
+ className="dshr-input dshr-mono"
804
+ placeholder="如 oc、or、nv"
805
+ value={aliasDraft}
806
+ onChange={(e) => setAliasDraft(e.target.value)}
807
+ />
808
+ </>
809
+ <>
810
+ <label className="dshr-fieldLabel">连接池策略</label>
811
+ <p className="dshr-muted">回退 = 按连接池顺序取第一个健康链接;轮询 = 轮流使用健康链接。</p>
812
+ <select
813
+ className="dshr-select dshr-input"
814
+ value={poolStrategy}
815
+ onChange={(e) => setPoolStrategy(e.target.value as 'fallback' | 'round-robin')}
816
+ >
817
+ <option value="fallback">回退</option>
818
+ <option value="round-robin">轮询</option>
819
+ </select>
820
+ </>
821
+ <label className="dshr-fieldLabel">签到规则</label>
822
+ <p className="dshr-muted">所有链接 = 签到每个链接;首个链接 = 只签到第一个链接。(dsh-router 通用策略)</p>
823
+ <select
824
+ className="dshr-select dshr-input"
825
+ value={checkinRule}
826
+ onChange={(e) => setCheckinRule(e.target.value as 'all' | 'first')}
827
+ >
828
+ <option value="all">所有链接</option>
829
+ <option value="first">首个链接</option>
830
+ </select>
831
+ {aliasError !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{aliasError}</span></div>}
832
+ <div className="dshr-modalActions">
833
+ <button type="button" className="dshr-miniButton" onClick={() => setShowAlias(false)}>取消</button>
834
+ <button type="button" className="dshr-primaryButton" onClick={() => void saveAlias()} disabled={busy || aliasDraft.trim() === ''}>
835
+ {busy ? '保存中…' : '保存'}
836
+ </button>
837
+ </div>
838
+ </div>
839
+ </Modal>
840
+ )}
841
+
842
+ {/* ---- 删除自定义模型确认 ---- */}
843
+ {removeModelTarget !== null && (
844
+ <Modal title="删除自定义模型" onClose={() => setRemoveModelTarget(null)}>
845
+ <div className="dshr-modalForm">
846
+ <p className="dshr-muted">确定删除自定义模型「{removeModelTarget}」吗?将从可用列表移除。</p>
847
+ <div className="dshr-modalActions">
848
+ <button type="button" className="dshr-miniButton" onClick={() => setRemoveModelTarget(null)}>取消</button>
849
+ <button type="button" className="dshr-dangerButton" onClick={() => { const id = removeModelTarget; setRemoveModelTarget(null); void removeModel(id) }}>删除</button>
850
+ </div>
851
+ </div>
852
+ </Modal>
853
+ )}
854
+
855
+ {/* ---- 添加链接弹窗(URL 登录 / API key) ---- */}
856
+ {showLogin && (
857
+ <Modal title={`添加链接 · ${supplier.name}`} onClose={() => { if (!busy) { setShowLogin(false); setLoginUrl(null); setCallbackUrl(''); setKeyName(''); setApiKey('') } }}>
858
+ {canApiKey
859
+ ? (
860
+ <div className="dshr-modalForm">
861
+ <p className="dshr-muted">填入 OpenRouter API key(https://openrouter.ai/settings/keys),可起名字区分多个 key。</p>
862
+ {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
863
+ <input
864
+ className="dshr-input"
865
+ placeholder="名字(可选,如 主key)"
866
+ value={keyName}
867
+ onChange={(e) => setKeyName(e.target.value)}
868
+ />
869
+ <input
870
+ className="dshr-input"
871
+ placeholder="sk-or-v1-..."
872
+ type="password"
873
+ value={apiKey}
874
+ onChange={(e) => setApiKey(e.target.value)}
875
+ />
876
+ <div className="dshr-modalActions">
877
+ <button type="button" className="dshr-miniButton" onClick={() => setShowLogin(false)} disabled={busy}>取消</button>
878
+ <button type="button" className="dshr-primaryButton" onClick={() => void addApiKey()} disabled={busy || apiKey.trim() === ''}>
879
+ {busy ? '验证中…' : '添加'}
880
+ </button>
881
+ </div>
882
+ </div>
883
+ )
884
+ : busy && loginUrl === null && error === ''
885
+ ? <div className="dshr-muted">正在生成登录链接…</div>
886
+ : loginUrl === null
887
+ ? <div className="dshr-empty">{error !== '' ? error : '登录链接生成失败'}</div>
888
+ : (
889
+ <div className="dshr-loginBox">
890
+ <div className="dshr-loginStep"><span className="dshr-loginNum">1</span>在浏览器打开下面链接,完成登录</div>
891
+ <a className="dshr-loginUrl" href={loginUrl} target="_blank" rel="noreferrer">{loginUrl}</a>
892
+ {canPoll
893
+ ? (
894
+ <>
895
+ <div className="dshr-loginStep"><span className="dshr-loginNum">2</span>登录成功后点「完成添加」,自动获取凭证</div>
896
+ {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
897
+ <div className="dshr-modalActions">
898
+ <button type="button" className="dshr-primaryButton" onClick={() => void completeLogin()} disabled={busy}>
899
+ {busy ? '轮询中…' : '完成添加'}
900
+ </button>
901
+ </div>
902
+ </>
903
+ )
904
+ : (
905
+ <>
906
+ <div className="dshr-loginStep"><span className="dshr-loginNum">2</span>登录成功后浏览器会跳到打不开的 127.0.0.1 地址</div>
907
+ <div className="dshr-loginStep"><span className="dshr-loginNum">3</span>复制地址栏完整链接,粘贴到下面,点「完成添加」</div>
908
+ {error !== '' && <div className="dshr-alert"><strong>出错了</strong><span>{error}</span></div>}
909
+ <div className="dshr-endpointRow">
910
+ <input
911
+ className="dshr-input"
912
+ placeholder="粘贴回调链接 http://127.0.0.1:18080/authorize?..."
913
+ value={callbackUrl}
914
+ onChange={(e) => setCallbackUrl(e.target.value)}
915
+ />
916
+ <button type="button" className="dshr-primaryButton" onClick={() => void completeLogin()} disabled={busy || callbackUrl.trim() === ''}>
917
+ {busy ? '处理中…' : '完成添加'}
918
+ </button>
919
+ </div>
920
+ </>
921
+ )}
922
+ </div>
923
+ )}
924
+ </Modal>
925
+ )}
926
+
927
+ {/* ---- 删除链接确认 ---- */}
928
+ {removeTarget !== null && (
929
+ <Modal title="删除链接" onClose={() => setRemoveTarget(null)}>
930
+ <div className="dshr-modalForm">
931
+ <p className="dshr-muted">确定删除链接「{removeTarget.nickname || removeTarget.uid}」吗?该账号将立即从路由池移除。</p>
932
+ <div className="dshr-modalActions">
933
+ <button type="button" className="dshr-miniButton" onClick={() => setRemoveTarget(null)}>取消</button>
934
+ <button type="button" className="dshr-dangerButton" onClick={() => void doRemove(removeTarget.uid)}>删除</button>
935
+ </div>
936
+ </div>
937
+ </Modal>
938
+ )}
939
+
940
+ {toast !== null && <div className="dshr-toast">{toast}</div>}
941
+ </div>
942
+ )
943
+ }