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,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
|
+
}
|