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