dsh-modelscope-pool 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +343 -2
- package/cordis.patch.yml +4 -0
- package/lib/catalog.js +105 -0
- package/lib/i18n.js +231 -0
- package/lib/index.js +1094 -0
- package/lib/pool.js +336 -0
- package/lib/provider.js +233 -0
- package/lib/proxy.js +426 -0
- package/lib/quota.js +131 -0
- package/lib/route.js +104 -0
- package/package.json +111 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,1094 @@
|
|
|
1
|
+
// dsh-go 原生插件:魔搭(ModelScope)多 Key 聚合网关。
|
|
2
|
+
// 界面只用于看状态和点按,Key 只存在本机文件里,不出现在视图、日志或响应头里。
|
|
3
|
+
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import { homedir } from 'node:os'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
import { createCatalog } from './catalog.js'
|
|
9
|
+
import { KeyPool, maskKey, nextQuotaResetAt } from './pool.js'
|
|
10
|
+
import { registerPoolProvider, isChatModel } from './provider.js'
|
|
11
|
+
import { createProxy, quotaLike } from './proxy.js'
|
|
12
|
+
import { createQuotaBook, REGIONS } from './quota.js'
|
|
13
|
+
import { createRouter } from './route.js'
|
|
14
|
+
import { makeT } from './i18n.js'
|
|
15
|
+
|
|
16
|
+
export const name = 'local-modelscope-pool'
|
|
17
|
+
|
|
18
|
+
// 「模型」页只有在这个插件的设置命名空间存在时才显示「魔搭 Key 池」这一行,
|
|
19
|
+
// 而命名空间来自这里导出的 Config schema,键就是 loader 里的条目 id。
|
|
20
|
+
const HOST_ROOTS = [join(homeDir(), 'profiles', 'web'), '/opt/dsh']
|
|
21
|
+
const z = (() => {
|
|
22
|
+
for (const root of HOST_ROOTS) {
|
|
23
|
+
try {
|
|
24
|
+
const load = createRequire(join(root, 'noop.cjs'))
|
|
25
|
+
return load('@deepseek-ai/schemastery')
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
return undefined
|
|
29
|
+
})()
|
|
30
|
+
|
|
31
|
+
const asVolatile = (schema) => (typeof schema?.volatile === 'function' ? schema.volatile() : schema)
|
|
32
|
+
|
|
33
|
+
// 设置服务只把 volatile 字段投影成可编辑表单;没有 volatile 字段的命名空间会被整条跳过,
|
|
34
|
+
// 「模型」页也就看不到这个供应商。
|
|
35
|
+
export const Config = z
|
|
36
|
+
? z.object({
|
|
37
|
+
note: asVolatile(
|
|
38
|
+
z
|
|
39
|
+
.string()
|
|
40
|
+
.default('')
|
|
41
|
+
.description('这里的模型来自本机多 Key 网关;Key、额度与默认模型在对话详情 ›「插件」里的「魔搭 Key 池」面板管理。'),
|
|
42
|
+
),
|
|
43
|
+
})
|
|
44
|
+
: undefined
|
|
45
|
+
|
|
46
|
+
const DEFAULTS = {
|
|
47
|
+
port: 8899,
|
|
48
|
+
cooldownMs: 5 * 60_000,
|
|
49
|
+
timeoutMs: 120_000,
|
|
50
|
+
upstream: 'https://api-inference.modelscope.ai/v1',
|
|
51
|
+
// 备用线路:主线路连不上时自动换,客户端无感。
|
|
52
|
+
upstreams: [],
|
|
53
|
+
apiKey: '',
|
|
54
|
+
model: '',
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function homeDir() {
|
|
58
|
+
return process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function loadConfig(dir) {
|
|
62
|
+
const file = join(dir, 'config.json')
|
|
63
|
+
let config = { ...DEFAULTS }
|
|
64
|
+
try {
|
|
65
|
+
const raw = JSON.parse(await readFile(file, 'utf8'))
|
|
66
|
+
if (raw && typeof raw === 'object') config = { ...config, ...raw }
|
|
67
|
+
} catch {
|
|
68
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
69
|
+
await writeFile(file, JSON.stringify(DEFAULTS, null, 2) + '\n', { mode: 0o600 }).catch(() => {})
|
|
70
|
+
}
|
|
71
|
+
config.port = Number(config.port) || DEFAULTS.port
|
|
72
|
+
config.upstreams = Array.isArray(config.upstreams)
|
|
73
|
+
? config.upstreams.map((u) => String(u ?? '').trim()).filter((u) => /^https?:\/\//.test(u)).slice(0, 4)
|
|
74
|
+
: []
|
|
75
|
+
config.cooldownMs = Math.max(30_000, Number(config.cooldownMs) || DEFAULTS.cooldownMs)
|
|
76
|
+
config.timeoutMs = Math.max(5_000, Number(config.timeoutMs) || DEFAULTS.timeoutMs)
|
|
77
|
+
return config
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function saveConfig(dir, config) {
|
|
81
|
+
try {
|
|
82
|
+
const body = JSON.stringify(
|
|
83
|
+
{
|
|
84
|
+
port: config.port,
|
|
85
|
+
cooldownMs: config.cooldownMs,
|
|
86
|
+
timeoutMs: config.timeoutMs,
|
|
87
|
+
upstream: config.upstream,
|
|
88
|
+
upstreams: config.upstreams,
|
|
89
|
+
apiKey: config.apiKey,
|
|
90
|
+
model: config.model ?? '',
|
|
91
|
+
},
|
|
92
|
+
null,
|
|
93
|
+
2,
|
|
94
|
+
)
|
|
95
|
+
await writeFile(join(dir, 'config.json'), body + '\n', { mode: 0o600 })
|
|
96
|
+
await chmod(join(dir, 'config.json'), 0o600).catch(() => {})
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return String(error?.message ?? error)
|
|
99
|
+
}
|
|
100
|
+
return ''
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const fmtLeft = (ms) => {
|
|
104
|
+
const total = Math.max(0, Math.ceil(ms / 1000))
|
|
105
|
+
if (total >= 3600) return `${Math.floor(total / 3600)} 小时 ${Math.floor((total % 3600) / 60)} 分`
|
|
106
|
+
const m = Math.floor(total / 60)
|
|
107
|
+
const s = total % 60
|
|
108
|
+
return `${m}:${String(s).padStart(2, '0')}`
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 北京时间 HH:MM,用来显示「冷却至 00:01」这种按天恢复的 Key。 */
|
|
112
|
+
const fmtCN = (ms) => {
|
|
113
|
+
if (!ms) return ''
|
|
114
|
+
const d = new Date(Number(ms) + 8 * 3600_000)
|
|
115
|
+
return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function apply(ctx) {
|
|
119
|
+
// 诊断:cordis 是否真的把本模块的 Config 挂在 fiber.runtime 上(设置命名空间靠它)。
|
|
120
|
+
try {
|
|
121
|
+
const fiber = ctx?.fiber
|
|
122
|
+
const rt = fiber?.runtime
|
|
123
|
+
globalThis.__poolRuntimeProbe = {
|
|
124
|
+
hasFiber: Boolean(fiber),
|
|
125
|
+
hasRuntime: Boolean(rt),
|
|
126
|
+
runtimeKeys: rt ? Object.keys(rt) : null,
|
|
127
|
+
runtimeConfig: typeof rt?.Config,
|
|
128
|
+
callbackConfig: typeof rt?.callback?.Config,
|
|
129
|
+
hasToJSON: typeof rt?.Config?.toJSON,
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
132
|
+
const dir = join(homeDir(), 'modelscope-pool')
|
|
133
|
+
const logger = ctx.logger
|
|
134
|
+
let pool
|
|
135
|
+
let proxy
|
|
136
|
+
let config = { ...DEFAULTS }
|
|
137
|
+
let quota
|
|
138
|
+
let catalog
|
|
139
|
+
let providerState = { registered: false, models: 0, error: '' }
|
|
140
|
+
let markStarted = () => {}
|
|
141
|
+
const started = new Promise((resolve) => {
|
|
142
|
+
markStarted = resolve
|
|
143
|
+
})
|
|
144
|
+
let refreshPanel = () => {}
|
|
145
|
+
let refreshStrip = () => {}
|
|
146
|
+
// Key 级无感路由:模型不变,只换 Key。
|
|
147
|
+
const router = createRouter({ logger })
|
|
148
|
+
|
|
149
|
+
const ready = (async () => {
|
|
150
|
+
config = await loadConfig(dir)
|
|
151
|
+
pool = new KeyPool({ dir, cooldownMs: config.cooldownMs, logger })
|
|
152
|
+
await pool.init()
|
|
153
|
+
quota = createQuotaBook({ logger })
|
|
154
|
+
catalog = createCatalog({ regions: REGIONS })
|
|
155
|
+
})()
|
|
156
|
+
|
|
157
|
+
/** 魔粒低于这个数就别再用了:一次 deepseek 请求就要 1~2 个魔粒,剩 0/1 必然 429。 */
|
|
158
|
+
const QUOTA_FLOOR = 2
|
|
159
|
+
/** 正在复查额度的 Key,避免同一个 Key 被多次 429 时重复排队。 */
|
|
160
|
+
const requeueing = new Map()
|
|
161
|
+
|
|
162
|
+
/** 按额度落地 Key 状态:够用就恢复,低于下限就停到次日 00:01。 */
|
|
163
|
+
const applyQuota = (id, state) => {
|
|
164
|
+
if (!pool || !state?.ok) return
|
|
165
|
+
const entry = pool.keys.find((k) => k.id === id)
|
|
166
|
+
const st = entry && pool.states.get(id)
|
|
167
|
+
if (!st) return
|
|
168
|
+
if (state.available < QUOTA_FLOOR) {
|
|
169
|
+
const untilAt = nextQuotaResetAt()
|
|
170
|
+
if (st.until !== untilAt || st.lastReason !== '额度不足') {
|
|
171
|
+
pool.markFailure(id, '额度不足', `剩余 ${state.available} 魔粒`, untilAt)
|
|
172
|
+
}
|
|
173
|
+
} else if (st.lastReason === '额度不足' || st.lastReason === '额度用尽') {
|
|
174
|
+
// 额度又回来了(充值 / 跨天 / 之前是误判),放回池子。
|
|
175
|
+
pool.restore(id)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const sweepQuotaFloor = () => {
|
|
180
|
+
if (!pool || !quota) return
|
|
181
|
+
for (const k of pool.keys) {
|
|
182
|
+
const state = quota.cached(k.id)
|
|
183
|
+
if (state?.ok) applyQuota(k.id, state)
|
|
184
|
+
}
|
|
185
|
+
refreshPanel()
|
|
186
|
+
refreshStrip()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 某个 Key 出现 429 后:一直重查它的额度,查到为止(约 5 分钟窗口)。 */
|
|
190
|
+
const requeueQuota = (id, secret) => {
|
|
191
|
+
if (!quota || !secret) return Promise.resolve()
|
|
192
|
+
if (requeueing.has(id)) return requeueing.get(id)
|
|
193
|
+
const job = (async () => {
|
|
194
|
+
const delays = [0, 5_000, 10_000, 20_000, 40_000, 60_000, 90_000, 120_000]
|
|
195
|
+
for (const wait of delays) {
|
|
196
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait))
|
|
197
|
+
const state = await quota.refreshOne(id, secret, { force: true })
|
|
198
|
+
if (state?.ok) {
|
|
199
|
+
if (state.region) await pool.setRegion(id, state.region)
|
|
200
|
+
applyQuota(id, state)
|
|
201
|
+
const st = pool?.states.get(id)
|
|
202
|
+
if (!st || st.until === 0) {
|
|
203
|
+
logger?.info?.(`modelscope-pool: key ${id} 额度已查明(剩余 ${state.available} 魔粒)`)
|
|
204
|
+
refreshPanel()
|
|
205
|
+
refreshStrip()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
// 查到了但确实不够:按额度停用,不必再重试。
|
|
209
|
+
logger?.warn?.(`modelscope-pool: key ${id} 额度不足(剩余 ${state.available} 魔粒),停用到次日 00:01`)
|
|
210
|
+
refreshPanel()
|
|
211
|
+
refreshStrip()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
logger?.warn?.(`modelscope-pool: key ${id} 额度一直查不出来,维持冷却`)
|
|
216
|
+
})().finally(() => requeueing.delete(id))
|
|
217
|
+
requeueing.set(id, job)
|
|
218
|
+
return job
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* 认出的版本写回 keys.json(只写变化的,避免每次刷新都落盘)。
|
|
223
|
+
* 版本判定就是「哪个站点的额度接口认这个令牌」,顺便把额度也查了。
|
|
224
|
+
*/
|
|
225
|
+
const persistRegions = async (ids) => {
|
|
226
|
+
if (!pool || !quota) return
|
|
227
|
+
for (const id of ids ?? pool.keys.map((k) => k.id)) {
|
|
228
|
+
const state = quota.cached(id)
|
|
229
|
+
const region = state?.region
|
|
230
|
+
if (region) await pool.setRegion(id, region)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** 额度/模型目录的后台刷新:不阻塞界面,也不用每次重绘都联网。 */
|
|
235
|
+
const refreshQuota = (force = false) => {
|
|
236
|
+
if (!pool || !quota) return Promise.resolve()
|
|
237
|
+
const entries = pool.keys.map((k) => ({ id: k.id, key: k.key, region: k.region }))
|
|
238
|
+
if (entries.length === 0) return Promise.resolve()
|
|
239
|
+
return quota
|
|
240
|
+
.refresh(entries, { force })
|
|
241
|
+
.then(() => persistRegions())
|
|
242
|
+
.then(() => {
|
|
243
|
+
sweepQuotaFloor()
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
const refreshCatalog = (force = false) => {
|
|
247
|
+
if (!pool || !catalog) return Promise.resolve()
|
|
248
|
+
return catalog.list({ force }).then(() => {
|
|
249
|
+
refreshPanel()
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
const selectModel = async (model) => {
|
|
253
|
+
const next = String(model ?? '').trim()
|
|
254
|
+
if (!next || next.length > 120) return false
|
|
255
|
+
config.model = next
|
|
256
|
+
await saveConfig(dir, config)
|
|
257
|
+
refreshPanel()
|
|
258
|
+
refreshStrip()
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const endpoint = () => (proxy?.port ? `http://127.0.0.1:${proxy.port}/v1` : '网关未启动')
|
|
263
|
+
|
|
264
|
+
// 面板上的「选中某个 Key / 翻页」是纯前端状态,放在闭包里,刷新视图时保留。
|
|
265
|
+
let keyFocus = ''
|
|
266
|
+
let keyPage = 0
|
|
267
|
+
let deleteArmed = ''
|
|
268
|
+
const KEYS_PER_PAGE = 40
|
|
269
|
+
const pickedKey = () => (keyFocus && pool?.keys.some((k) => k.id === keyFocus) ? keyFocus : '')
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
const modelRowOf = (entry, current, t) => {
|
|
273
|
+
const id = String(entry?.id ?? '')
|
|
274
|
+
const isCurrent = id === current
|
|
275
|
+
const regions = entry?.regions ?? []
|
|
276
|
+
const where =
|
|
277
|
+
regions.length > 1
|
|
278
|
+
? `${t('regionAi')} + ${t('regionCn')}`
|
|
279
|
+
: t.region(regions[0])
|
|
280
|
+
return {
|
|
281
|
+
title: entry?.name || id,
|
|
282
|
+
subtitle: isCurrent ? `${t('currentDefault')} · ${where}` : where,
|
|
283
|
+
trailing: isCurrent ? t('trailingCurrent') : t('trailingUse'),
|
|
284
|
+
icon: isCurrent ? 'check' : 'model',
|
|
285
|
+
tone: isCurrent ? 'good' : undefined,
|
|
286
|
+
action: { id: 'pick-model', args: { model: id } },
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function buildData(locale) {
|
|
291
|
+
const t = makeT(locale)
|
|
292
|
+
const s = pool ? pool.status() : { total: 0, available: 0, cooling: 0, keys: [], stats: { requests: 0 } }
|
|
293
|
+
const ids = s.keys.map((k) => k.id)
|
|
294
|
+
const q = quota ? quota.sum(ids) : { available: 0, known: 0 }
|
|
295
|
+
const model = String(config.model ?? '')
|
|
296
|
+
const modelEntry = catalog?.resolve(model)
|
|
297
|
+
const currentModel = modelEntry?.id ?? model
|
|
298
|
+
const models = (catalog?.entries ?? []).filter((entry) => isChatModel(entry.id))
|
|
299
|
+
const routing = router.snapshot()
|
|
300
|
+
const routed =
|
|
301
|
+
routing.blockedPairs > 0 ? ` ${t('routed', { n: routing.blockedPairs })}` : ''
|
|
302
|
+
const hint =
|
|
303
|
+
s.total === 0
|
|
304
|
+
? t('hintNoKeys')
|
|
305
|
+
: s.cooling > 0
|
|
306
|
+
? `${t('hintCooling')}${routed}`
|
|
307
|
+
: model
|
|
308
|
+
? `${t('hintModel', { model })}${routed}`
|
|
309
|
+
: `${t('hintIdle')}${routed}`
|
|
310
|
+
|
|
311
|
+
// 扇形图:Key 池的状态分布(可用 / 额度不足 / 令牌无效 / 其他冷却)
|
|
312
|
+
const order = [
|
|
313
|
+
['ready', t('bucketReady'), (k) => k.state === 'ok'],
|
|
314
|
+
[
|
|
315
|
+
'low',
|
|
316
|
+
t('bucketLow'),
|
|
317
|
+
(k) => k.reason === '额度不足' || k.reason === '额度用尽',
|
|
318
|
+
],
|
|
319
|
+
['invalid', t('bucketInvalid'), (k) => k.reason === '令牌无效'],
|
|
320
|
+
[
|
|
321
|
+
'other',
|
|
322
|
+
t('bucketOther'),
|
|
323
|
+
(k) => k.state !== 'ok' && !['额度不足', '额度用尽', '令牌无效'].includes(k.reason),
|
|
324
|
+
],
|
|
325
|
+
]
|
|
326
|
+
const counts = order.map(([key, label, test]) => ({
|
|
327
|
+
key,
|
|
328
|
+
label,
|
|
329
|
+
value: s.keys.filter(test).length,
|
|
330
|
+
}))
|
|
331
|
+
const live = counts.filter((row) => row.value > 0)
|
|
332
|
+
const lowCount = counts.find((row) => row.key === 'low')?.value ?? 0
|
|
333
|
+
const readyCount = counts.find((row) => row.key === 'ready')?.value ?? 0
|
|
334
|
+
const share = {
|
|
335
|
+
kind: 'donut',
|
|
336
|
+
categories: live.map((row) => row.label),
|
|
337
|
+
series: [{ name: 'Key', values: live.map((row) => row.value) }],
|
|
338
|
+
unit: t('unitKey'),
|
|
339
|
+
caption:
|
|
340
|
+
s.total === 0
|
|
341
|
+
? t('captionNoKeys')
|
|
342
|
+
: t('captionTotal', { total: s.total, ok: readyCount, low: lowCount }),
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 魔粒估算:每个 Key 按满额 250 计,剩余取各 Key 剩余之和(没查到的按满额算,不凭空记「已用」)。
|
|
346
|
+
const MAGIC_PER_KEY = 250
|
|
347
|
+
const magicTotal = s.total * MAGIC_PER_KEY
|
|
348
|
+
const magicUnknown = Math.max(0, s.total - q.known)
|
|
349
|
+
const magicLeft = q.known > 0 ? q.available + magicUnknown * MAGIC_PER_KEY : magicTotal
|
|
350
|
+
const magicUsed = Math.max(0, magicTotal - magicLeft)
|
|
351
|
+
const magic = {
|
|
352
|
+
total: magicTotal,
|
|
353
|
+
left: magicLeft,
|
|
354
|
+
used: magicUsed,
|
|
355
|
+
caption:
|
|
356
|
+
s.total === 0
|
|
357
|
+
? t('magicCaptionEmpty')
|
|
358
|
+
: t('magicCaption', {
|
|
359
|
+
total: magicTotal,
|
|
360
|
+
keys: s.total,
|
|
361
|
+
per: MAGIC_PER_KEY,
|
|
362
|
+
left: magicLeft,
|
|
363
|
+
used: magicUsed,
|
|
364
|
+
}),
|
|
365
|
+
categories: [t('magicLeft'), t('magicUsed')],
|
|
366
|
+
series: [{ name: 'Magicubes', values: [magicLeft, magicUsed] }],
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const pageCount = Math.max(1, Math.ceil(s.total / KEYS_PER_PAGE))
|
|
370
|
+
keyPage = Math.min(Math.max(0, keyPage), pageCount - 1)
|
|
371
|
+
const pageNo = keyPage + 1
|
|
372
|
+
const pageKeys = s.keys.slice(keyPage * KEYS_PER_PAGE, keyPage * KEYS_PER_PAGE + KEYS_PER_PAGE)
|
|
373
|
+
|
|
374
|
+
// 单个 Key 的详情(完整令牌只在本机面板显示,日志/响应头里永远只有掩码)。
|
|
375
|
+
const focusRow = keyFocus ? s.keys.find((k) => k.id === keyFocus) : undefined
|
|
376
|
+
const focusRaw = keyFocus ? pool.keys.find((k) => k.id === keyFocus) : undefined
|
|
377
|
+
const focusState = focusRow ? quota?.cached(focusRow.id) : undefined
|
|
378
|
+
const focusData = focusRow
|
|
379
|
+
? {
|
|
380
|
+
title: `${focusRow.id} · ${focusRow.masked}`,
|
|
381
|
+
rows: [
|
|
382
|
+
[t('rowToken'), focusRaw?.key ?? '—'],
|
|
383
|
+
[t('rowSite'), t.region(focusRow.region)],
|
|
384
|
+
[
|
|
385
|
+
t('rowQuota'),
|
|
386
|
+
focusState?.ok
|
|
387
|
+
? t('quotaDetail', {
|
|
388
|
+
n: focusState.available,
|
|
389
|
+
total: focusState.total,
|
|
390
|
+
frozen: focusState.frozen,
|
|
391
|
+
})
|
|
392
|
+
: focusState
|
|
393
|
+
? t('quotaFailed', { error: focusState.error })
|
|
394
|
+
: t('quotaNever'),
|
|
395
|
+
],
|
|
396
|
+
[
|
|
397
|
+
t('rowState'),
|
|
398
|
+
focusRow.state === 'ok'
|
|
399
|
+
? t('stateReady')
|
|
400
|
+
: focusRow.manual
|
|
401
|
+
? t('stateManual')
|
|
402
|
+
: `${t.reason(focusRow.reason) || t('hintKeyFallback')}${
|
|
403
|
+
focusRow.untilAt ? ` · ${t('coolingUntil', { time: fmtCN(focusRow.untilAt) })}` : ''
|
|
404
|
+
}`,
|
|
405
|
+
],
|
|
406
|
+
[
|
|
407
|
+
t('rowFails'),
|
|
408
|
+
t('failsDetail', {
|
|
409
|
+
n: focusRow.fails,
|
|
410
|
+
error: focusRow.lastError || t('none'),
|
|
411
|
+
used: focusRow.used ? t('usedSuffix', { n: focusRow.used }) : '',
|
|
412
|
+
}),
|
|
413
|
+
],
|
|
414
|
+
],
|
|
415
|
+
hint: t('hintKeyDetail'),
|
|
416
|
+
}
|
|
417
|
+
: { title: t('focusEmpty'), rows: [], hint: '' }
|
|
418
|
+
|
|
419
|
+
// 模型列表按站点分组:国际版 → 国内版 → 双站,名字用短名。
|
|
420
|
+
const modelRows = []
|
|
421
|
+
for (const region of ['ai', 'cn']) {
|
|
422
|
+
const group = models.filter((entry) => entry.regions?.length === 1 && entry.regions[0] === region)
|
|
423
|
+
if (group.length === 0) continue
|
|
424
|
+
modelRows.push({
|
|
425
|
+
title: t.region(region),
|
|
426
|
+
icon: 'globe',
|
|
427
|
+
tone: 'info',
|
|
428
|
+
subtitle: t('groupCount', { n: group.length }),
|
|
429
|
+
})
|
|
430
|
+
for (const entry of group) modelRows.push(modelRowOf(entry, currentModel, t))
|
|
431
|
+
}
|
|
432
|
+
const both = models.filter((entry) => (entry.regions?.length ?? 0) > 1)
|
|
433
|
+
if (both.length > 0) {
|
|
434
|
+
modelRows.push({
|
|
435
|
+
title: t('groupBoth'),
|
|
436
|
+
icon: 'globe',
|
|
437
|
+
tone: 'info',
|
|
438
|
+
subtitle: t('groupCount', { n: both.length }),
|
|
439
|
+
})
|
|
440
|
+
for (const entry of both) modelRows.push(modelRowOf(entry, currentModel, t))
|
|
441
|
+
}
|
|
442
|
+
const unknownRegion = models.filter((entry) => (entry.regions?.length ?? 0) === 0)
|
|
443
|
+
if (unknownRegion.length > 0) {
|
|
444
|
+
modelRows.push({
|
|
445
|
+
title: t('groupUnknown'),
|
|
446
|
+
icon: 'globe',
|
|
447
|
+
tone: 'info',
|
|
448
|
+
subtitle: t('groupCount', { n: unknownRegion.length }),
|
|
449
|
+
})
|
|
450
|
+
for (const entry of unknownRegion) modelRows.push(modelRowOf(entry, currentModel, t))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return {
|
|
454
|
+
available: s.available,
|
|
455
|
+
cooling: s.cooling,
|
|
456
|
+
share,
|
|
457
|
+
shareTitle: t('shareTitle'),
|
|
458
|
+
magicubes: q.known > 0 ? String(q.available) : '—',
|
|
459
|
+
magic,
|
|
460
|
+
endpoint: endpoint(),
|
|
461
|
+
hint,
|
|
462
|
+
modelTitle:
|
|
463
|
+
models.length === 0
|
|
464
|
+
? t('modelsTitleEmpty')
|
|
465
|
+
: t('modelsTitleCount', {
|
|
466
|
+
n: models.length,
|
|
467
|
+
current: modelEntry?.name ?? model ?? t('modelNotPicked'),
|
|
468
|
+
}),
|
|
469
|
+
keyTitle:
|
|
470
|
+
s.total === 0
|
|
471
|
+
? t('keyTitleEmpty')
|
|
472
|
+
: t('keyTitlePage', { total: s.total, page: pageNo, pages: pageCount }),
|
|
473
|
+
keyPager:
|
|
474
|
+
s.total > KEYS_PER_PAGE
|
|
475
|
+
? t('keyPager', { page: pageNo, pages: pageCount, size: KEYS_PER_PAGE })
|
|
476
|
+
: '',
|
|
477
|
+
keys: pageKeys.map((k) => {
|
|
478
|
+
const state = quota?.cached(k.id)
|
|
479
|
+
const used = t('usedTimes', { n: k.used })
|
|
480
|
+
const money = state?.ok
|
|
481
|
+
? t('quotaLeft', { n: state.available })
|
|
482
|
+
: state && !state.ok
|
|
483
|
+
? t('quotaUnknown', { error: String(state.error).slice(0, 24) })
|
|
484
|
+
: t('quotaUnchecked')
|
|
485
|
+
const region = t.region(k.region)
|
|
486
|
+
return {
|
|
487
|
+
title: `${k.id} · ${k.masked}${k.id === keyFocus ? t('selectedMark') : ''}`,
|
|
488
|
+
subtitle: k.reason
|
|
489
|
+
? `${t.reason(k.reason)}(${t('failTimes', { n: k.fails })})· ${money} · ${region}`
|
|
490
|
+
: `${money} · ${region} · ${used}`,
|
|
491
|
+
trailing:
|
|
492
|
+
k.state === 'ok'
|
|
493
|
+
? t('stateReady')
|
|
494
|
+
: k.manual
|
|
495
|
+
? t('stateDisabled')
|
|
496
|
+
: k.untilAt
|
|
497
|
+
? t('coolingUntil', { time: fmtCN(k.untilAt) })
|
|
498
|
+
: t('coolingLeft', { time: fmtLeft(k.leftMs) }),
|
|
499
|
+
icon: k.state === 'ok' ? 'cloud' : k.manual ? 'remove' : 'time',
|
|
500
|
+
tone: k.state === 'ok' ? 'good' : k.manual ? 'bad' : 'warn',
|
|
501
|
+
action: { id: 'key-open', args: { id: k.id } },
|
|
502
|
+
}
|
|
503
|
+
}),
|
|
504
|
+
focus: focusData,
|
|
505
|
+
models: modelRows,
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const viewPool = (scope, caps) => {
|
|
509
|
+
const t = makeT(caps?.locale)
|
|
510
|
+
return {
|
|
511
|
+
v: 2,
|
|
512
|
+
type: 'stack',
|
|
513
|
+
title: t('poolTitle'),
|
|
514
|
+
accent: 'teal',
|
|
515
|
+
children: [
|
|
516
|
+
{
|
|
517
|
+
type: 'row',
|
|
518
|
+
children: [
|
|
519
|
+
{ type: 'metric', label: t('metricReady'), value: { $: 'data.available' }, tone: 'good' },
|
|
520
|
+
{ type: 'metric', label: t('metricCooling'), value: { $: 'data.cooling' }, tone: 'warn' },
|
|
521
|
+
{ type: 'metric', label: t('metricMagicubes'), value: { $: 'data.magicubes' }, tone: 'info' },
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
type: 'chart',
|
|
526
|
+
kind: 'donut',
|
|
527
|
+
categories: { $: 'data.share.categories' },
|
|
528
|
+
series: { $: 'data.share.series' },
|
|
529
|
+
caption: { $: 'data.share.caption' },
|
|
530
|
+
},
|
|
531
|
+
{ type: 'text', text: { $: 'data.shareTitle' }, style: { size: 'title', weight: 'bold' } },
|
|
532
|
+
{
|
|
533
|
+
type: 'chart',
|
|
534
|
+
kind: 'donut',
|
|
535
|
+
categories: { $: 'data.magic.categories' },
|
|
536
|
+
series: { $: 'data.magic.series' },
|
|
537
|
+
caption: { $: 'data.magic.caption' },
|
|
538
|
+
},
|
|
539
|
+
{ type: 'callout', tone: 'info', text: { $: 'data.hint' } },
|
|
540
|
+
{ type: 'text', text: { $: 'data.keyTitle' }, style: { size: 'title', weight: 'bold' } },
|
|
541
|
+
{ type: 'list', items: { $: 'data.keys' } },
|
|
542
|
+
{
|
|
543
|
+
type: 'actions',
|
|
544
|
+
items: [
|
|
545
|
+
{ label: t('btnPrev'), icon: 'collapse', action: { id: 'key-prev' } },
|
|
546
|
+
{ label: t('btnNext'), icon: 'expand', action: { id: 'key-next' } },
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
type: 'actions',
|
|
551
|
+
items: [
|
|
552
|
+
{ label: t('btnRefresh'), icon: 'refresh', action: { id: 'refresh' } },
|
|
553
|
+
{ label: t('btnTestAll'), icon: 'bolt', action: { id: 'test-all' } },
|
|
554
|
+
{ label: t('btnRestoreAll'), icon: 'play', action: { id: 'restore-all' } },
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
{ type: 'text', text: { $: 'data.focusTitle' }, tone: 'muted' },
|
|
558
|
+
{
|
|
559
|
+
type: 'form',
|
|
560
|
+
submitLabel: t('formSubmit'),
|
|
561
|
+
submit: 'add-form',
|
|
562
|
+
children: [
|
|
563
|
+
{
|
|
564
|
+
type: 'textField',
|
|
565
|
+
label: t('fieldLabel'),
|
|
566
|
+
bind: 'data.newKey',
|
|
567
|
+
placeholder: t('fieldPlaceholder'),
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const viewKey = (scope, caps) => {
|
|
576
|
+
const t = makeT(caps?.locale)
|
|
577
|
+
return {
|
|
578
|
+
v: 2,
|
|
579
|
+
type: 'stack',
|
|
580
|
+
title: t('keyPanelTitle'),
|
|
581
|
+
children: [
|
|
582
|
+
{ type: 'text', text: { $: 'data.title' }, style: { size: 'title', weight: 'bold' } },
|
|
583
|
+
{ type: 'keyValue', items: { $: 'data.rows' } },
|
|
584
|
+
{
|
|
585
|
+
type: 'actions',
|
|
586
|
+
items: [
|
|
587
|
+
{ label: t('btnDisable'), icon: 'remove', action: { id: 'key-disable' } },
|
|
588
|
+
{ label: t('btnStop'), icon: 'clock', action: { id: 'key-stop' } },
|
|
589
|
+
{ label: t('btnRestore'), icon: 'play', action: { id: 'key-restore' } },
|
|
590
|
+
{ label: t('btnDelete'), icon: 'delete', action: { id: 'key-delete' } },
|
|
591
|
+
],
|
|
592
|
+
},
|
|
593
|
+
{ type: 'callout', tone: 'warn', text: { $: 'data.hint' } },
|
|
594
|
+
{ type: 'actions', items: [{ label: t('btnBack'), icon: 'list', action: { id: 'key-back' } }] },
|
|
595
|
+
],
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const viewModels = (scope, caps) => {
|
|
600
|
+
const t = makeT(caps?.locale)
|
|
601
|
+
return {
|
|
602
|
+
v: 2,
|
|
603
|
+
type: 'stack',
|
|
604
|
+
title: t('modelsPanelTitle'),
|
|
605
|
+
children: [
|
|
606
|
+
{ type: 'text', text: { $: 'data.modelTitle' }, style: { size: 'title', weight: 'bold' } },
|
|
607
|
+
{ type: 'list', items: { $: 'data.models' } },
|
|
608
|
+
{ type: 'actions', items: [{ label: t('btnReloadModels'), icon: 'refresh', action: { id: 'refresh-models' } }] },
|
|
609
|
+
],
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const buildPoolData = (locale) => {
|
|
614
|
+
const d = buildData(locale)
|
|
615
|
+
return {
|
|
616
|
+
available: d.available,
|
|
617
|
+
cooling: d.cooling,
|
|
618
|
+
magicubes: d.magicubes,
|
|
619
|
+
share: d.share,
|
|
620
|
+
shareTitle: d.shareTitle,
|
|
621
|
+
magic: d.magic,
|
|
622
|
+
hint: d.hint,
|
|
623
|
+
keyTitle: d.keyTitle,
|
|
624
|
+
keys: d.keys,
|
|
625
|
+
focusTitle: d.focus.title,
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const buildKeyData = (locale) => {
|
|
629
|
+
const d = buildData(locale)
|
|
630
|
+
return {
|
|
631
|
+
title: d.focus.title,
|
|
632
|
+
rows: d.focus.rows,
|
|
633
|
+
hint: d.focus.hint || makeT(locale)('hintKeyFallback'),
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
const buildModelsData = (locale) => {
|
|
637
|
+
const d = buildData(locale)
|
|
638
|
+
return { modelTitle: d.modelTitle, models: d.models }
|
|
639
|
+
}
|
|
640
|
+
/** 从粘贴的内容里抠出所有令牌:一行一个也行,逗号/空格混着也行。 */
|
|
641
|
+
const parseKeys = (raw) =>
|
|
642
|
+
String(raw ?? '')
|
|
643
|
+
.split(/[\s,;、,;]+/)
|
|
644
|
+
.map((t) => t.trim())
|
|
645
|
+
.filter((t) => /^ms-[A-Za-z0-9._-]{8,}$/.test(t))
|
|
646
|
+
.filter((t, i, arr) => arr.indexOf(t) === i)
|
|
647
|
+
|
|
648
|
+
const addKey = async (raw, locale) => {
|
|
649
|
+
const t = makeT(locale)
|
|
650
|
+
const text = String(raw ?? '').trim()
|
|
651
|
+
if (!text) return { toast: t('toastNoInput') }
|
|
652
|
+
const tokens = parseKeys(text)
|
|
653
|
+
if (tokens.length === 0) return { toast: t('toastNotMs') }
|
|
654
|
+
|
|
655
|
+
const added = []
|
|
656
|
+
const skipped = []
|
|
657
|
+
for (const token of tokens) {
|
|
658
|
+
const result = await pool.add(token)
|
|
659
|
+
if (result.ok) added.push({ id: result.id, token })
|
|
660
|
+
else skipped.push(result.error)
|
|
661
|
+
}
|
|
662
|
+
if (added.length === 0) return { toast: skipped[0] ?? t('toastNoNew') }
|
|
663
|
+
|
|
664
|
+
// 加进来就逐个问额度接口:既能认出站点,也能当场发现令牌无效或额度不够。
|
|
665
|
+
const regions = { ai: 0, cn: 0 }
|
|
666
|
+
let invalid = 0
|
|
667
|
+
let low = 0
|
|
668
|
+
for (const { id, token } of added) {
|
|
669
|
+
const state = await quota.refreshOne(id, token, { force: true })
|
|
670
|
+
if (!state?.ok) {
|
|
671
|
+
invalid++
|
|
672
|
+
pool.markFailure(id, '\u4ee4\u724c\u65e0\u6548', String(state?.error ?? '').slice(0, 80))
|
|
673
|
+
continue
|
|
674
|
+
}
|
|
675
|
+
if (state.region) {
|
|
676
|
+
regions[state.region]++
|
|
677
|
+
await pool.setRegion(id, state.region)
|
|
678
|
+
}
|
|
679
|
+
applyQuota(id, state)
|
|
680
|
+
if (state.available < QUOTA_FLOOR) low++
|
|
681
|
+
}
|
|
682
|
+
refreshPanel()
|
|
683
|
+
refreshStrip()
|
|
684
|
+
|
|
685
|
+
const bits = [t('addDone', { n: added.length })]
|
|
686
|
+
if (regions.ai > 0) bits.push(t('addIntl', { n: regions.ai }))
|
|
687
|
+
if (regions.cn > 0) bits.push(t('addCn', { n: regions.cn }))
|
|
688
|
+
if (low > 0) bits.push(t('addLow', { n: low }))
|
|
689
|
+
if (invalid > 0) bits.push(t('addInvalid', { n: invalid }))
|
|
690
|
+
if (skipped.length > 0) bits.push(t('addSkipped', { n: skipped.length }))
|
|
691
|
+
return { toast: bits.join(t('joinArrow')) }
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
ctx.inject(['dshGo'], (dshCtx) => {
|
|
695
|
+
const dshGo = dshCtx.dshGo
|
|
696
|
+
refreshPanel = () => {
|
|
697
|
+
for (const id of ['pool', 'key', 'models']) {
|
|
698
|
+
try {
|
|
699
|
+
dshGo.refresh(id)
|
|
700
|
+
} catch {}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
refreshStrip = () => {
|
|
704
|
+
try {
|
|
705
|
+
dshGo.refresh('pool-strip')
|
|
706
|
+
} catch {}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** 面板数据推流的通用外壳:内容变了才重绘,面板一关就停。 */
|
|
710
|
+
const panelData = (build) => (scope, emit, caps) => {
|
|
711
|
+
let stopped = false
|
|
712
|
+
let lastJson = ''
|
|
713
|
+
const tick = () => {
|
|
714
|
+
if (stopped) return
|
|
715
|
+
try {
|
|
716
|
+
const payload = build(caps?.locale)
|
|
717
|
+
const json = JSON.stringify(payload)
|
|
718
|
+
if (json === lastJson) return // 没有变化就不重绘,界面元素保持稳定
|
|
719
|
+
lastJson = json
|
|
720
|
+
emit(payload)
|
|
721
|
+
} catch {}
|
|
722
|
+
}
|
|
723
|
+
ready
|
|
724
|
+
.then(() => {
|
|
725
|
+
tick()
|
|
726
|
+
const timer = setInterval(tick, 1500)
|
|
727
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
728
|
+
stop = () => clearInterval(timer)
|
|
729
|
+
})
|
|
730
|
+
.catch(() => {})
|
|
731
|
+
let stop = () => {}
|
|
732
|
+
return () => {
|
|
733
|
+
stopped = true
|
|
734
|
+
stop()
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const panelActions = {
|
|
739
|
+
async refresh(scope, event) {
|
|
740
|
+
const t = makeT(event?.locale)
|
|
741
|
+
await Promise.all([refreshQuota(true), refreshCatalog(true)])
|
|
742
|
+
return { toast: t('toastQuotaUpdated'), refresh: true }
|
|
743
|
+
},
|
|
744
|
+
async 'pick-model'(scope, event) {
|
|
745
|
+
const t = makeT(event?.locale)
|
|
746
|
+
const model = String(event?.value?.model ?? '')
|
|
747
|
+
const ok = await selectModel(model)
|
|
748
|
+
return ok
|
|
749
|
+
? { toast: t('toastModelSet', { model }), refresh: true }
|
|
750
|
+
: { toast: t('toastModelBad') }
|
|
751
|
+
},
|
|
752
|
+
async 'test-all'(scope, event) {
|
|
753
|
+
const t = makeT(event?.locale)
|
|
754
|
+
const keys = pool ? [...pool.keys] : []
|
|
755
|
+
if (keys.length === 0) return { toast: t('toastNoKeys') }
|
|
756
|
+
// 注意不能用 /v1/models 验 Key:那个接口是公开的,什么令牌都返回 200。
|
|
757
|
+
// 真正会校验令牌的是额度接口,而且查额度不扣魔粒。
|
|
758
|
+
const rows = await quota.refresh(
|
|
759
|
+
keys.map((k) => ({ id: k.id, key: k.key, region: k.region })),
|
|
760
|
+
{ force: true },
|
|
761
|
+
)
|
|
762
|
+
for (const [i, row] of rows.entries()) {
|
|
763
|
+
const k = keys[i]
|
|
764
|
+
if (row.state?.region) await pool.setRegion(k.id, row.state.region)
|
|
765
|
+
if (!row.state?.ok) {
|
|
766
|
+
pool.markFailure(k.id, '令牌无效', String(row.state?.error ?? '').slice(0, 80))
|
|
767
|
+
continue
|
|
768
|
+
}
|
|
769
|
+
if (row.state.available < QUOTA_FLOOR) {
|
|
770
|
+
pool.markFailure(k.id, '额度不足', `剩余 ${row.state.available} 魔粒`, nextQuotaResetAt())
|
|
771
|
+
continue
|
|
772
|
+
}
|
|
773
|
+
pool.restore(k.id)
|
|
774
|
+
}
|
|
775
|
+
refreshPanel()
|
|
776
|
+
refreshStrip()
|
|
777
|
+
const ok = rows.filter((r) => r.state?.ok && r.state.available >= QUOTA_FLOOR).length
|
|
778
|
+
const invalid = rows.filter((r) => !r.state?.ok).length
|
|
779
|
+
const short = rows.length - ok - invalid
|
|
780
|
+
const parts = [t('partOk', { ok, total: keys.length })]
|
|
781
|
+
if (short > 0) parts.push(t('partShort', { n: short }))
|
|
782
|
+
if (invalid > 0) parts.push(t('partInvalid', { n: invalid }))
|
|
783
|
+
return { toast: t('toastTestDone', { parts: parts.join(' · ') }) }
|
|
784
|
+
},
|
|
785
|
+
'restore-all': async (scope, event) => {
|
|
786
|
+
const t = makeT(event?.locale)
|
|
787
|
+
await pool.restoreAll()
|
|
788
|
+
router.clearAll()
|
|
789
|
+
refreshPanel()
|
|
790
|
+
refreshStrip()
|
|
791
|
+
return { toast: t('toastRestored') }
|
|
792
|
+
},
|
|
793
|
+
// 单 Key 控制:先点列表里的某一行选中,再用下面四个按钮。
|
|
794
|
+
'key-open'(scope, event) {
|
|
795
|
+
const t = makeT(event?.locale)
|
|
796
|
+
const id = String(event?.value?.id ?? '')
|
|
797
|
+
if (!id) return { toast: t('toastNoKeyId') }
|
|
798
|
+
keyFocus = id
|
|
799
|
+
deleteArmed = ''
|
|
800
|
+
return { open: 'panel:key', refresh: true }
|
|
801
|
+
},
|
|
802
|
+
'key-prev'(scope, event) {
|
|
803
|
+
const t = makeT(event?.locale)
|
|
804
|
+
if (keyPage <= 0) return { toast: t('toastFirstPage') }
|
|
805
|
+
keyPage--
|
|
806
|
+
return { refresh: true }
|
|
807
|
+
},
|
|
808
|
+
'key-next'(scope, event) {
|
|
809
|
+
const t = makeT(event?.locale)
|
|
810
|
+
const pages = Math.max(1, Math.ceil((pool?.keys.length ?? 0) / KEYS_PER_PAGE))
|
|
811
|
+
if (keyPage >= pages - 1) return { toast: t('toastLastPage') }
|
|
812
|
+
keyPage++
|
|
813
|
+
return { refresh: true }
|
|
814
|
+
},
|
|
815
|
+
'key-disable'(scope, event) {
|
|
816
|
+
const t = makeT(event?.locale)
|
|
817
|
+
const id = pickedKey()
|
|
818
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
819
|
+
pool.disable(id, '手动禁用')
|
|
820
|
+
router.clearKey?.(id)
|
|
821
|
+
refreshPanel()
|
|
822
|
+
refreshStrip()
|
|
823
|
+
return { toast: t('toastDisabled', { id }), refresh: true }
|
|
824
|
+
},
|
|
825
|
+
'key-stop'(scope, event) {
|
|
826
|
+
const t = makeT(event?.locale)
|
|
827
|
+
const id = pickedKey()
|
|
828
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
829
|
+
pool.stop(id, '手动停用', nextQuotaResetAt())
|
|
830
|
+
router.clearKey?.(id)
|
|
831
|
+
refreshPanel()
|
|
832
|
+
refreshStrip()
|
|
833
|
+
return { toast: t('toastStopped', { id }), refresh: true }
|
|
834
|
+
},
|
|
835
|
+
'key-restore'(scope, event) {
|
|
836
|
+
const t = makeT(event?.locale)
|
|
837
|
+
const id = pickedKey()
|
|
838
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
839
|
+
pool.restore(id)
|
|
840
|
+
router.clearKey?.(id)
|
|
841
|
+
refreshPanel()
|
|
842
|
+
refreshStrip()
|
|
843
|
+
return { toast: t('toastRestoredOne', { id }), refresh: true }
|
|
844
|
+
},
|
|
845
|
+
'key-delete'(scope, event) {
|
|
846
|
+
const t = makeT(event?.locale)
|
|
847
|
+
const id = pickedKey()
|
|
848
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
849
|
+
// 删令牌不可撤销,要求连点两次。
|
|
850
|
+
if (deleteArmed !== id) {
|
|
851
|
+
deleteArmed = id
|
|
852
|
+
return { toast: t('toastDeleteArm', { id }), refresh: true }
|
|
853
|
+
}
|
|
854
|
+
deleteArmed = ''
|
|
855
|
+
void pool.remove(id).then(() => {
|
|
856
|
+
quota?.forget?.(id)
|
|
857
|
+
keyFocus = ''
|
|
858
|
+
refreshPanel()
|
|
859
|
+
refreshStrip()
|
|
860
|
+
})
|
|
861
|
+
return { toast: t('toastDeleted', { id }), refresh: true }
|
|
862
|
+
},
|
|
863
|
+
'add-form': async (scope, event) => {
|
|
864
|
+
const value = event?.value
|
|
865
|
+
const raw = typeof value === 'string' ? value : value?.newKey ?? value?.value ?? ''
|
|
866
|
+
const result = await addKey(raw, event?.locale)
|
|
867
|
+
return { ...result, refresh: true }
|
|
868
|
+
},
|
|
869
|
+
'key-back'() {
|
|
870
|
+
return { open: 'panel:pool' }
|
|
871
|
+
},
|
|
872
|
+
async 'refresh-models'(scope, event) {
|
|
873
|
+
const t = makeT(event?.locale)
|
|
874
|
+
await refreshCatalog(true)
|
|
875
|
+
refreshPanel()
|
|
876
|
+
return { toast: t('toastModelsReloaded'), refresh: true }
|
|
877
|
+
},
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
dshGo.panel('pool', { view: viewPool, data: panelData(buildPoolData), actions: panelActions })
|
|
881
|
+
dshGo.panel('key', { view: viewKey, data: panelData(buildKeyData), actions: panelActions })
|
|
882
|
+
dshGo.panel('models', { view: viewModels, data: panelData(buildModelsData), actions: panelActions })
|
|
883
|
+
|
|
884
|
+
dshGo.strip('pool-strip', {
|
|
885
|
+
data(scope, emit, caps) {
|
|
886
|
+
const t = makeT(caps?.locale)
|
|
887
|
+
let stop = () => {}
|
|
888
|
+
let lastText = ''
|
|
889
|
+
const tick = () => {
|
|
890
|
+
const s = pool ? pool.status() : { total: 0, available: 0 }
|
|
891
|
+
const ids = (s.keys ?? []).map((k) => k.id)
|
|
892
|
+
const q = quota ? quota.sum(ids) : { available: 0, known: 0 }
|
|
893
|
+
const payload =
|
|
894
|
+
s.total === 0
|
|
895
|
+
? { text: t('stripEmpty'), tone: 'warn' }
|
|
896
|
+
: {
|
|
897
|
+
text: t('stripText', {
|
|
898
|
+
ok: s.available,
|
|
899
|
+
total: s.total,
|
|
900
|
+
quota: q.known > 0 ? t('stripQuota', { n: q.available }) : t('stripQuotaUnknown'),
|
|
901
|
+
}),
|
|
902
|
+
tone: s.available > 0 ? 'good' : 'bad',
|
|
903
|
+
}
|
|
904
|
+
const json = JSON.stringify(payload)
|
|
905
|
+
if (json === lastText) return
|
|
906
|
+
lastText = json
|
|
907
|
+
emit(payload)
|
|
908
|
+
}
|
|
909
|
+
ready
|
|
910
|
+
.then(() => {
|
|
911
|
+
tick()
|
|
912
|
+
const timer = setInterval(tick, 5000)
|
|
913
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
914
|
+
stop = () => clearInterval(timer)
|
|
915
|
+
})
|
|
916
|
+
.catch(() => {})
|
|
917
|
+
return () => stop()
|
|
918
|
+
},
|
|
919
|
+
})
|
|
920
|
+
|
|
921
|
+
dshGo.on('addkey', async (scope, event) => {
|
|
922
|
+
const text = String(scope.clipboard ?? '').trim()
|
|
923
|
+
if (!text) return { toast: makeT(event?.locale)('toastNoClipboard') }
|
|
924
|
+
const result = await addKey(text, event?.locale)
|
|
925
|
+
return { ...result, refresh: 'pool' }
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
dshGo.on('open-pool', () => ({ open: 'panel:pool' }))
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
// 插件卸载时必须收尾:停掉心跳、关掉网关,否则热重载会留下占着端口的僵尸实例。
|
|
932
|
+
const cleanups = []
|
|
933
|
+
const onDispose = (fn) => {
|
|
934
|
+
cleanups.push(fn)
|
|
935
|
+
try {
|
|
936
|
+
ctx.effect?.(() => () => {
|
|
937
|
+
try {
|
|
938
|
+
fn()
|
|
939
|
+
} catch {}
|
|
940
|
+
})
|
|
941
|
+
} catch {}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
ready
|
|
945
|
+
.then(() => {
|
|
946
|
+
const stopWatch = pool.startWatch(() => {
|
|
947
|
+
refreshPanel()
|
|
948
|
+
refreshStrip()
|
|
949
|
+
})
|
|
950
|
+
onDispose(stopWatch)
|
|
951
|
+
refreshQuota(true).then(() => sweepQuotaFloor()).catch(() => {})
|
|
952
|
+
refreshCatalog(true).catch(() => {})
|
|
953
|
+
const quotaTimer = setInterval(() => {
|
|
954
|
+
refreshQuota(false).catch(() => {})
|
|
955
|
+
}, 300_000)
|
|
956
|
+
const catalogTimer = setInterval(() => {
|
|
957
|
+
refreshCatalog(false).catch(() => {})
|
|
958
|
+
}, 300_000)
|
|
959
|
+
if (typeof quotaTimer.unref === 'function') quotaTimer.unref()
|
|
960
|
+
if (typeof catalogTimer.unref === 'function') catalogTimer.unref()
|
|
961
|
+
const floorTimer = setInterval(() => sweepQuotaFloor(), 30_000)
|
|
962
|
+
if (typeof floorTimer.unref === 'function') floorTimer.unref()
|
|
963
|
+
onDispose(() => {
|
|
964
|
+
clearInterval(quotaTimer)
|
|
965
|
+
clearInterval(catalogTimer)
|
|
966
|
+
clearInterval(floorTimer)
|
|
967
|
+
})
|
|
968
|
+
})
|
|
969
|
+
.catch((error) => logger?.warn?.(`modelscope-pool: 初始化失败 ${String(error?.message ?? error)}`))
|
|
970
|
+
|
|
971
|
+
try {
|
|
972
|
+
ctx.on?.('dispose', () => {
|
|
973
|
+
for (const fn of cleanups.splice(0)) {
|
|
974
|
+
try {
|
|
975
|
+
fn()
|
|
976
|
+
} catch {}
|
|
977
|
+
}
|
|
978
|
+
})
|
|
979
|
+
} catch {}
|
|
980
|
+
|
|
981
|
+
// 注册成 DSH 的模型供应商:模型页直接列出池里的模型,选中就能对话。
|
|
982
|
+
ctx.inject(['llm'], (llmCtx) => {
|
|
983
|
+
started
|
|
984
|
+
.then(async () => {
|
|
985
|
+
const entryId = (() => {
|
|
986
|
+
const id = llmCtx?.fiber?.entry?.options?.id
|
|
987
|
+
return typeof id === 'string' && id.length > 0 ? id : name
|
|
988
|
+
})()
|
|
989
|
+
const ok = await registerPoolProvider(llmCtx, {
|
|
990
|
+
providerId: 'modelscope-pool',
|
|
991
|
+
displayName: makeT()('poolTitle'),
|
|
992
|
+
entryId,
|
|
993
|
+
getPort: () => proxy?.port ?? 0,
|
|
994
|
+
getKey: () => config.apiKey,
|
|
995
|
+
getModels: () => catalog?.ids ?? [],
|
|
996
|
+
getEntries: () => catalog?.entries ?? [],
|
|
997
|
+
logger,
|
|
998
|
+
})
|
|
999
|
+
providerState = {
|
|
1000
|
+
registered: ok === 'declared' ? true : ok,
|
|
1001
|
+
declared: ok === 'declared',
|
|
1002
|
+
error: ok ? '' : (globalThis.__poolProviderError ?? '宿主缺少 llm 服务或 pi-ai 不可用'),
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
globalThis.__poolEntry = {
|
|
1006
|
+
entryId,
|
|
1007
|
+
runtimeConfig: typeof llmCtx?.fiber?.runtime?.Config,
|
|
1008
|
+
namespaces: (() => {
|
|
1009
|
+
try {
|
|
1010
|
+
return (llmCtx.get?.('settings')?.describe?.() ?? []).map((row) => row.ns)
|
|
1011
|
+
} catch {
|
|
1012
|
+
return null
|
|
1013
|
+
}
|
|
1014
|
+
})(),
|
|
1015
|
+
}
|
|
1016
|
+
globalThis.__poolDirectory = {
|
|
1017
|
+
declared: llmCtx.llm.listConfigurableProviders?.() ?? null,
|
|
1018
|
+
routes: llmCtx.llm.listProviders?.() ?? null,
|
|
1019
|
+
}
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
globalThis.__poolEntry = {
|
|
1022
|
+
entryId,
|
|
1023
|
+
runtimeConfig: typeof llmCtx?.fiber?.runtime?.Config,
|
|
1024
|
+
namespaces: (() => {
|
|
1025
|
+
try {
|
|
1026
|
+
return (llmCtx.get?.('settings')?.describe?.() ?? []).map((row) => row.ns)
|
|
1027
|
+
} catch {
|
|
1028
|
+
return null
|
|
1029
|
+
}
|
|
1030
|
+
})(),
|
|
1031
|
+
}
|
|
1032
|
+
globalThis.__poolDirectory = { error: String(error?.message ?? error) }
|
|
1033
|
+
}
|
|
1034
|
+
if (ok) logger?.info?.('modelscope-pool: 已注册模型供应商「魔搭 Key 池」')
|
|
1035
|
+
})
|
|
1036
|
+
.catch((error) => logger?.warn?.(`modelscope-pool: 供应商注册失败 ${String(error?.message ?? error)}`))
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
// 真正的网关只在引擎里起(检查器没有 webServer,也不会占端口)。
|
|
1040
|
+
ctx.inject(['webServer'], () => {
|
|
1041
|
+
ready
|
|
1042
|
+
.then(async () => {
|
|
1043
|
+
proxy = createProxy({
|
|
1044
|
+
pool,
|
|
1045
|
+
config,
|
|
1046
|
+
router,
|
|
1047
|
+
logger,
|
|
1048
|
+
hooks: {
|
|
1049
|
+
provider: () => ({
|
|
1050
|
+
...providerState,
|
|
1051
|
+
directory: globalThis.__poolDirectory ?? null,
|
|
1052
|
+
entry: globalThis.__poolEntry ?? null,
|
|
1053
|
+
probe: globalThis.__poolRuntimeProbe ?? null,
|
|
1054
|
+
models: (catalog?.ids ?? []).filter((id) => isChatModel(id)).length,
|
|
1055
|
+
endpoint: endpoint(),
|
|
1056
|
+
provider: 'modelscope-pool',
|
|
1057
|
+
routing: router.snapshot(),
|
|
1058
|
+
}),
|
|
1059
|
+
quotaSuspect: (id, secret) => requeueQuota(id, secret),
|
|
1060
|
+
selected: () => String(config.model ?? ''),
|
|
1061
|
+
setModel: (model) => selectModel(model),
|
|
1062
|
+
models: async (force) => catalog.list({ force }),
|
|
1063
|
+
resolveModel: (name) => catalog?.resolve(name)?.id ?? '',
|
|
1064
|
+
modelFor: (name, region) => catalog?.modelFor(name, region) ?? '',
|
|
1065
|
+
balance: async (force) => {
|
|
1066
|
+
const entries = pool.keys.map((k) => ({ id: k.id, key: k.key, region: k.region }))
|
|
1067
|
+
const rows = await quota.refresh(entries, { force })
|
|
1068
|
+
return rows.map((row) => ({
|
|
1069
|
+
id: row.id,
|
|
1070
|
+
ok: Boolean(row.state?.ok),
|
|
1071
|
+
available: row.state?.ok ? row.state.available : 0,
|
|
1072
|
+
total: row.state?.ok ? row.state.total : 0,
|
|
1073
|
+
frozen: row.state?.ok ? row.state.frozen : 0,
|
|
1074
|
+
error: row.state?.ok ? undefined : row.state?.error,
|
|
1075
|
+
}))
|
|
1076
|
+
},
|
|
1077
|
+
},
|
|
1078
|
+
})
|
|
1079
|
+
const port = await proxy.listen()
|
|
1080
|
+
markStarted(port)
|
|
1081
|
+
logger?.info?.(`modelscope-pool: 本地网关 http://127.0.0.1:${port}/v1(${pool.keys.length} 个 Key)`)
|
|
1082
|
+
refreshPanel()
|
|
1083
|
+
refreshStrip()
|
|
1084
|
+
})
|
|
1085
|
+
.catch((error) => {
|
|
1086
|
+
markStarted(0)
|
|
1087
|
+
logger?.warn?.(`modelscope-pool: 网关启动失败 ${String(error?.message ?? error)}`)
|
|
1088
|
+
})
|
|
1089
|
+
onDispose(() => {
|
|
1090
|
+
proxy?.close().catch(() => {})
|
|
1091
|
+
proxy = undefined
|
|
1092
|
+
})
|
|
1093
|
+
})
|
|
1094
|
+
}
|