dsh-modelscope-pool 0.8.0 → 0.10.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/lib/index.js CHANGED
@@ -11,6 +11,8 @@ import { registerPoolProvider, isChatModel } from './provider.js'
11
11
  import { createProxy, quotaLike } from './proxy.js'
12
12
  import { createQuotaBook, REGIONS } from './quota.js'
13
13
  import { createRouter } from './route.js'
14
+ import { makeT } from './i18n.js'
15
+ import { registerWebAdmin, WEB_ADMIN_PATH } from './web.js'
14
16
 
15
17
  export const name = 'dsh-modelscope-pool'
16
18
 
@@ -264,27 +266,30 @@ export function apply(ctx) {
264
266
  let keyFocus = ''
265
267
  let keyPage = 0
266
268
  let deleteArmed = ''
267
- const KEYS_PER_PAGE = 40
269
+ const KEYS_PER_PAGE = 10
268
270
  const pickedKey = () => (keyFocus && pool?.keys.some((k) => k.id === keyFocus) ? keyFocus : '')
269
271
 
270
272
 
271
- /** 模型行的显示:短名当标题,真实 id 当副标题,点了设为默认。 */
272
- const modelRowOf = (entry, current) => {
273
+ const modelRowOf = (entry, current, t) => {
273
274
  const id = String(entry?.id ?? '')
274
275
  const isCurrent = id === current
275
276
  const regions = entry?.regions ?? []
276
- const where = regions.length > 1 ? '国际版 + 国内版' : REGIONS[regions[0]]?.label ?? '站点未知'
277
+ const where =
278
+ regions.length > 1
279
+ ? `${t('regionAi')} + ${t('regionCn')}`
280
+ : t.region(regions[0])
277
281
  return {
278
282
  title: entry?.name || id,
279
- subtitle: isCurrent ? `当前默认 · ${where}` : where,
280
- trailing: isCurrent ? '当前' : '使用',
283
+ subtitle: isCurrent ? `${t('currentDefault')} · ${where}` : where,
284
+ trailing: isCurrent ? t('trailingCurrent') : t('trailingUse'),
281
285
  icon: isCurrent ? 'check' : 'model',
282
286
  tone: isCurrent ? 'good' : undefined,
283
287
  action: { id: 'pick-model', args: { model: id } },
284
288
  }
285
289
  }
286
290
 
287
- function buildData() {
291
+ function buildData(locale) {
292
+ const t = makeT(locale)
288
293
  const s = pool ? pool.status() : { total: 0, available: 0, cooling: 0, keys: [], stats: { requests: 0 } }
289
294
  const ids = s.keys.map((k) => k.id)
290
295
  const q = quota ? quota.sum(ids) : { available: 0, known: 0 }
@@ -293,34 +298,74 @@ export function apply(ctx) {
293
298
  const currentModel = modelEntry?.id ?? model
294
299
  const models = (catalog?.entries ?? []).filter((entry) => isChatModel(entry.id))
295
300
  const routing = router.snapshot()
296
- const routed = routing.blockedPairs > 0 ? `已记下 ${routing.blockedPairs} 个「Key×模型」的失败组合(5 分钟内自动跳过)。` : ''
301
+ const routed =
302
+ routing.blockedPairs > 0 ? ` ${t('routed', { n: routing.blockedPairs })}` : ''
297
303
  const hint =
298
304
  s.total === 0
299
- ? '还没有 Key:在魔搭复制令牌(ms- 开头)后点输入框上方的「添加 Key」,也可以在下面直接填。'
305
+ ? t('hintNoKeys')
300
306
  : s.cooling > 0
301
- ? `无感切换已开:剩余魔粒低于 2 的 Key 已自动停用到次日 00:01,避免撞 429。到点自动恢复。${routed}`
307
+ ? `${t('hintCooling')}${routed}`
302
308
  : model
303
- ? `无感切换已开:模型始终是 ${model},Key 遇到限流、额度不足、没权限会自动换下一个;魔粒低于 2 的 Key 会提前停用。${routed}`
304
- : `点「刷新额度」看剩余魔粒;点下面的模型可以设为默认。${routed}`
309
+ ? `${t('hintModel', { model })}${routed}`
310
+ : `${t('hintIdle')}${routed}`
311
+
305
312
  // 扇形图: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])
313
+ const order = [
314
+ ['ready', t('bucketReady'), (k) => k.state === 'ok'],
315
+ [
316
+ 'low',
317
+ t('bucketLow'),
318
+ (k) => k.reason === '额度不足' || k.reason === '额度用尽',
319
+ ],
320
+ ['invalid', t('bucketInvalid'), (k) => k.reason === '令牌无效'],
321
+ [
322
+ 'other',
323
+ t('bucketOther'),
324
+ (k) => k.state !== 'ok' && !['额度不足', '额度用尽', '令牌无效'].includes(k.reason),
325
+ ],
326
+ ]
327
+ const counts = order.map(([key, label, test]) => ({
328
+ key,
329
+ label,
330
+ value: s.keys.filter(test).length,
331
+ }))
332
+ const live = counts.filter((row) => row.value > 0)
333
+ const lowCount = counts.find((row) => row.key === 'low')?.value ?? 0
334
+ const readyCount = counts.find((row) => row.key === 'ready')?.value ?? 0
315
335
  const share = {
316
336
  kind: 'donut',
317
- categories: shareCategories,
318
- series: [{ name: 'Key', values: shareValues }],
319
- unit: '个',
337
+ categories: live.map((row) => row.label),
338
+ series: [{ name: 'Key', values: live.map((row) => row.value) }],
339
+ unit: t('unitKey'),
320
340
  caption:
321
341
  s.total === 0
322
- ? '还没有 Key'
323
- : `共 ${s.total} 个 Key:可用 ${buckets['可用']},额度不足 ${buckets['额度不足']},其余冷却中`,
342
+ ? t('captionNoKeys')
343
+ : t('captionTotal', { total: s.total, ok: readyCount, low: lowCount }),
344
+ }
345
+
346
+ // 魔粒估算:每个 Key 按满额 250 计,剩余取各 Key 剩余之和(没查到的按满额算,不凭空记「已用」)。
347
+ const MAGIC_PER_KEY = 250
348
+ const magicTotal = s.total * MAGIC_PER_KEY
349
+ const magicUnknown = Math.max(0, s.total - q.known)
350
+ const magicLeft = q.known > 0 ? q.available + magicUnknown * MAGIC_PER_KEY : magicTotal
351
+ const magicUsed = Math.max(0, magicTotal - magicLeft)
352
+ const magic = {
353
+ total: magicTotal,
354
+ left: magicLeft,
355
+ used: magicUsed,
356
+ caption:
357
+ s.total === 0
358
+ ? t('magicCaptionEmpty')
359
+ : t('magicCaption', {
360
+ total: magicTotal,
361
+ keys: s.total,
362
+ per: MAGIC_PER_KEY,
363
+ left: magicLeft,
364
+ used: magicUsed,
365
+ }),
366
+ brief: t('magicBarCaption', { left: magicLeft, total: magicTotal }),
367
+ categories: [t('magicLeft'), t('magicUsed')],
368
+ series: [{ name: 'Magicubes', values: [magicLeft, magicUsed] }],
324
369
  }
325
370
 
326
371
  const pageCount = Math.max(1, Math.ceil(s.total / KEYS_PER_PAGE))
@@ -336,84 +381,123 @@ export function apply(ctx) {
336
381
  ? {
337
382
  title: `${focusRow.id} · ${focusRow.masked}`,
338
383
  rows: [
339
- ['完整令牌', focusRaw?.key ?? '—'],
340
- ['版本', REGIONS[focusRow.region]?.label ?? '版本未知'],
384
+ [t('rowToken'), focusRaw?.key ?? '—'],
385
+ [t('rowSite'), t.region(focusRow.region)],
341
386
  [
342
- '额度',
387
+ t('rowQuota'),
343
388
  focusState?.ok
344
- ? `${focusState.available} 魔粒(总额 ${focusState.total},冻结 ${focusState.frozen})`
389
+ ? t('quotaDetail', {
390
+ n: focusState.available,
391
+ total: focusState.total,
392
+ frozen: focusState.frozen,
393
+ })
345
394
  : focusState
346
- ? `查询失败:${focusState.error}`
347
- : '还没查过',
395
+ ? t('quotaFailed', { error: focusState.error })
396
+ : t('quotaNever'),
348
397
  ],
349
398
  [
350
- '状态',
399
+ t('rowState'),
351
400
  focusRow.state === 'ok'
352
- ? '可用'
401
+ ? t('stateReady')
353
402
  : focusRow.manual
354
- ? `已禁用(点「恢复」放开)`
355
- : `${focusRow.reason || '冷却中'}${focusRow.untilAt ? ` · 冷却至 ${fmtCN(focusRow.untilAt)}` : ''}`,
403
+ ? t('stateManual')
404
+ : `${t.reason(focusRow.reason) || t('hintKeyFallback')}${
405
+ focusRow.untilAt ? ` · ${t('coolingUntil', { time: fmtCN(focusRow.untilAt) })}` : ''
406
+ }`,
407
+ ],
408
+ [
409
+ t('rowFails'),
410
+ t('failsDetail', {
411
+ n: focusRow.fails,
412
+ error: focusRow.lastError || t('none'),
413
+ used: focusRow.used ? t('usedSuffix', { n: focusRow.used }) : '',
414
+ }),
356
415
  ],
357
- ['失败', `${focusRow.fails} 次 · ${focusRow.lastError || '无'}${focusRow.used ? ` · 用过 ${focusRow.used} 次` : ''}`],
358
416
  ],
359
- hint: '「禁用」= 一直停到点恢复;「停用」= 停到北京时间次日 00:01;「删除」会把令牌从本机文件里去掉。',
417
+ hint: t('hintKeyDetail'),
360
418
  }
361
- : { title: '还没选中 Key(回「Key 列表」点一行)', rows: [], hint: '' }
419
+ : { title: t('focusEmpty'), rows: [], hint: '' }
362
420
 
363
421
  // 模型列表按站点分组:国际版 → 国内版 → 双站,名字用短名。
364
422
  const modelRows = []
365
- const modelOrder = ['ai', 'cn']
366
- for (const region of modelOrder) {
423
+ for (const region of ['ai', 'cn']) {
367
424
  const group = models.filter((entry) => entry.regions?.length === 1 && entry.regions[0] === region)
368
425
  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))
426
+ modelRows.push({
427
+ title: t.region(region),
428
+ icon: 'globe',
429
+ tone: 'info',
430
+ subtitle: t('groupCount', { n: group.length }),
431
+ })
432
+ for (const entry of group) modelRows.push(modelRowOf(entry, currentModel, t))
371
433
  }
372
434
  const both = models.filter((entry) => (entry.regions?.length ?? 0) > 1)
373
435
  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))
436
+ modelRows.push({
437
+ title: t('groupBoth'),
438
+ icon: 'globe',
439
+ tone: 'info',
440
+ subtitle: t('groupCount', { n: both.length }),
441
+ })
442
+ for (const entry of both) modelRows.push(modelRowOf(entry, currentModel, t))
376
443
  }
377
444
  const unknownRegion = models.filter((entry) => (entry.regions?.length ?? 0) === 0)
378
445
  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))
446
+ modelRows.push({
447
+ title: t('groupUnknown'),
448
+ icon: 'globe',
449
+ tone: 'info',
450
+ subtitle: t('groupCount', { n: unknownRegion.length }),
451
+ })
452
+ for (const entry of unknownRegion) modelRows.push(modelRowOf(entry, currentModel, t))
381
453
  }
382
454
 
383
455
  return {
384
456
  available: s.available,
385
457
  cooling: s.cooling,
386
458
  share,
459
+ shareTitle: t('shareTitle'),
387
460
  magicubes: q.known > 0 ? String(q.available) : '—',
461
+ magic,
388
462
  endpoint: endpoint(),
389
463
  hint,
390
464
  modelTitle:
391
465
  models.length === 0
392
- ? '模型(还没读到,点下面「重新拉取模型」)'
393
- : `${models.length} 个模型 · 当前 ${modelEntry?.name ?? (model || '未选')}`,
466
+ ? t('modelsTitleEmpty')
467
+ : t('modelsTitleCount', {
468
+ n: models.length,
469
+ current: modelEntry?.name ?? model ?? t('modelNotPicked'),
470
+ }),
394
471
  keyTitle:
395
472
  s.total === 0
396
- ? 'Key 池(还没有 Key)'
397
- : `Key 池 ${s.total} 个 · 第 ${pageNo}/${pageCount} 页(点一行看详情)`,
398
- keyPager: s.total > KEYS_PER_PAGE ? `第 ${pageNo}/${pageCount} 页 · 每页 ${KEYS_PER_PAGE} 个` : '',
473
+ ? t('keyTitleEmpty')
474
+ : t('keyTitlePage', { total: s.total, page: pageNo, pages: pageCount }),
475
+ keyPager:
476
+ s.total > KEYS_PER_PAGE
477
+ ? t('keyPager', { page: pageNo, pages: pageCount, size: KEYS_PER_PAGE })
478
+ : '',
399
479
  keys: pageKeys.map((k) => {
400
480
  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 ?? '版本未知'
481
+ const used = t('usedTimes', { n: k.used })
482
+ const money = state?.ok
483
+ ? t('quotaLeft', { n: state.available })
484
+ : state && !state.ok
485
+ ? t('quotaUnknown', { error: String(state.error).slice(0, 24) })
486
+ : t('quotaUnchecked')
487
+ const region = t.region(k.region)
404
488
  return {
405
- title: `${k.id} · ${k.masked}${k.id === keyFocus ? '(已选中)' : ''}`,
489
+ title: `${k.id} · ${k.masked}${k.id === keyFocus ? t('selectedMark') : ''}`,
406
490
  subtitle: k.reason
407
- ? `${k.reason}(失败 ${k.fails} 次)· ${money} · ${region}`
491
+ ? `${t.reason(k.reason)}(${t('failTimes', { n: k.fails })})· ${money} · ${region}`
408
492
  : `${money} · ${region} · ${used}`,
409
493
  trailing:
410
494
  k.state === 'ok'
411
- ? '可用'
495
+ ? t('stateReady')
412
496
  : k.manual
413
- ? '已禁用'
497
+ ? t('stateDisabled')
414
498
  : k.untilAt
415
- ? `冷却至 ${fmtCN(k.untilAt)}`
416
- : `冷却 ${fmtLeft(k.leftMs)}`,
499
+ ? t('coolingUntil', { time: fmtCN(k.untilAt) })
500
+ : t('coolingLeft', { time: fmtLeft(k.leftMs) }),
417
501
  icon: k.state === 'ok' ? 'cloud' : k.manual ? 'remove' : 'time',
418
502
  tone: k.state === 'ok' ? 'good' : k.manual ? 'bad' : 'warn',
419
503
  action: { id: 'key-open', args: { id: k.id } },
@@ -423,110 +507,129 @@ export function apply(ctx) {
423
507
  models: modelRows,
424
508
  }
425
509
  }
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
- ],
510
+ const viewPool = (scope, caps) => {
511
+ const t = makeT(caps?.locale)
512
+ return {
513
+ v: 2,
514
+ type: 'stack',
515
+ title: t('poolTitle'),
516
+ accent: 'teal',
517
+ children: [
518
+ {
519
+ type: 'row',
520
+ children: [
521
+ { type: 'metric', label: t('metricReady'), value: { $: 'data.available' }, tone: 'good' },
522
+ { type: 'metric', label: t('metricCooling'), value: { $: 'data.cooling' }, tone: 'warn' },
523
+ { type: 'metric', label: t('metricMagicubes'), value: { $: 'data.magicubes' }, tone: 'info' },
524
+ ],
525
+ },
526
+ {
527
+ type: 'progress',
528
+ label: t('magicLabel'),
529
+ value: { $: 'data.magic.left' },
530
+ max: { $: 'data.magic.total' },
531
+ caption: { $: 'data.magic.brief' },
532
+ tone: 'good',
533
+ },
534
+ { type: 'callout', tone: 'info', text: { $: 'data.hint' } },
535
+ { type: 'text', text: { $: 'data.keyTitle' }, style: { size: 'title', weight: 'bold' } },
536
+ { type: 'list', items: { $: 'data.keys' } },
537
+ {
538
+ type: 'actions',
539
+ items: [
540
+ { label: t('btnRefresh'), icon: 'refresh', action: { id: 'refresh' } },
541
+ { label: t('btnTestAll'), icon: 'bolt', action: { id: 'test-all' } },
542
+ { label: t('btnRestoreAll'), icon: 'play', action: { id: 'restore-all' } },
543
+ ],
544
+ },
545
+ {
546
+ type: 'actions',
547
+ items: [
548
+ { label: t('btnPrev'), icon: 'collapse', action: { id: 'key-prev' } },
549
+ { label: t('btnNext'), icon: 'expand', action: { id: 'key-next' } },
550
+ ],
551
+ },
552
+ {
553
+ type: 'form',
554
+ submitLabel: t('formSubmit'),
555
+ submit: 'add-form',
556
+ children: [
557
+ {
558
+ type: 'textField',
559
+ label: t('fieldLabel'),
560
+ bind: 'data.newKey',
561
+ placeholder: t('fieldPlaceholder'),
562
+ },
563
+ ],
564
+ },
565
+ ],
566
+ }
474
567
  }
475
568
 
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
- ],
569
+ const viewKey = (scope, caps) => {
570
+ const t = makeT(caps?.locale)
571
+ return {
572
+ v: 2,
573
+ type: 'stack',
574
+ title: t('keyPanelTitle'),
575
+ children: [
576
+ { type: 'text', text: { $: 'data.title' }, style: { size: 'title', weight: 'bold' } },
577
+ { type: 'keyValue', items: { $: 'data.rows' } },
578
+ {
579
+ type: 'actions',
580
+ items: [
581
+ { label: t('btnDisable'), icon: 'remove', action: { id: 'key-disable' } },
582
+ { label: t('btnStop'), icon: 'clock', action: { id: 'key-stop' } },
583
+ { label: t('btnRestore'), icon: 'play', action: { id: 'key-restore' } },
584
+ { label: t('btnDelete'), icon: 'delete', action: { id: 'key-delete' } },
585
+ ],
586
+ },
587
+ { type: 'callout', tone: 'warn', text: { $: 'data.hint' } },
588
+ { type: 'actions', items: [{ label: t('btnBack'), icon: 'list', action: { id: 'key-back' } }] },
589
+ ],
590
+ }
495
591
  }
496
592
 
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
- ],
593
+ const viewModels = (scope, caps) => {
594
+ const t = makeT(caps?.locale)
595
+ return {
596
+ v: 2,
597
+ type: 'stack',
598
+ title: t('modelsPanelTitle'),
599
+ children: [
600
+ { type: 'text', text: { $: 'data.modelTitle' }, style: { size: 'title', weight: 'bold' } },
601
+ { type: 'list', items: { $: 'data.models' } },
602
+ { type: 'actions', items: [{ label: t('btnReloadModels'), icon: 'refresh', action: { id: 'refresh-models' } }] },
603
+ ],
604
+ }
506
605
  }
507
606
 
508
- const buildPoolData = () => {
509
- const d = buildData()
607
+ const buildPoolData = (locale) => {
608
+ const d = buildData(locale)
510
609
  return {
511
610
  available: d.available,
512
611
  cooling: d.cooling,
513
612
  magicubes: d.magicubes,
514
613
  share: d.share,
614
+ magic: d.magic,
515
615
  hint: d.hint,
516
616
  keyTitle: d.keyTitle,
517
617
  keys: d.keys,
518
618
  focusTitle: d.focus.title,
519
619
  }
520
620
  }
521
- const buildKeyData = () => {
522
- const d = buildData()
523
- return { title: d.focus.title, rows: d.focus.rows, hint: d.focus.hint || '回到「魔搭 Key 池」面板,点列表里任意一行就会选中它。' }
621
+ const buildKeyData = (locale) => {
622
+ const d = buildData(locale)
623
+ return {
624
+ title: d.focus.title,
625
+ rows: d.focus.rows,
626
+ hint: d.focus.hint || makeT(locale)('hintKeyFallback'),
627
+ }
524
628
  }
525
- const buildModelsData = () => {
526
- const d = buildData()
629
+ const buildModelsData = (locale) => {
630
+ const d = buildData(locale)
527
631
  return { modelTitle: d.modelTitle, models: d.models }
528
632
  }
529
-
530
633
  /** 从粘贴的内容里抠出所有令牌:一行一个也行,逗号/空格混着也行。 */
531
634
  const parseKeys = (raw) =>
532
635
  String(raw ?? '')
@@ -535,11 +638,12 @@ export function apply(ctx) {
535
638
  .filter((t) => /^ms-[A-Za-z0-9._-]{8,}$/.test(t))
536
639
  .filter((t, i, arr) => arr.indexOf(t) === i)
537
640
 
538
- const addKey = async (raw) => {
641
+ const addKey = async (raw, locale) => {
642
+ const t = makeT(locale)
539
643
  const text = String(raw ?? '').trim()
540
- if (!text) return { toast: '没读到 Key' }
644
+ if (!text) return { toast: t('toastNoInput') }
541
645
  const tokens = parseKeys(text)
542
- if (tokens.length === 0) return { toast: '这不像魔搭令牌(应以 ms- 开头)' }
646
+ if (tokens.length === 0) return { toast: t('toastNotMs') }
543
647
 
544
648
  const added = []
545
649
  const skipped = []
@@ -548,9 +652,9 @@ export function apply(ctx) {
548
652
  if (result.ok) added.push({ id: result.id, token })
549
653
  else skipped.push(result.error)
550
654
  }
551
- if (added.length === 0) return { toast: skipped[0] ?? '没有新增 Key' }
655
+ if (added.length === 0) return { toast: skipped[0] ?? t('toastNoNew') }
552
656
 
553
- // 加进来就逐个问额度接口:既能认出国际版/大陆版,也能当场发现令牌无效或额度不够。
657
+ // 加进来就逐个问额度接口:既能认出站点,也能当场发现令牌无效或额度不够。
554
658
  const regions = { ai: 0, cn: 0 }
555
659
  let invalid = 0
556
660
  let low = 0
@@ -558,7 +662,7 @@ export function apply(ctx) {
558
662
  const state = await quota.refreshOne(id, token, { force: true })
559
663
  if (!state?.ok) {
560
664
  invalid++
561
- pool.markFailure(id, '令牌无效', String(state?.error ?? '').slice(0, 80))
665
+ pool.markFailure(id, '\u4ee4\u724c\u65e0\u6548', String(state?.error ?? '').slice(0, 80))
562
666
  continue
563
667
  }
564
668
  if (state.region) {
@@ -571,13 +675,13 @@ export function apply(ctx) {
571
675
  refreshPanel()
572
676
  refreshStrip()
573
677
 
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('|') }
678
+ const bits = [t('addDone', { n: added.length })]
679
+ if (regions.ai > 0) bits.push(t('addIntl', { n: regions.ai }))
680
+ if (regions.cn > 0) bits.push(t('addCn', { n: regions.cn }))
681
+ if (low > 0) bits.push(t('addLow', { n: low }))
682
+ if (invalid > 0) bits.push(t('addInvalid', { n: invalid }))
683
+ if (skipped.length > 0) bits.push(t('addSkipped', { n: skipped.length }))
684
+ return { toast: bits.join(t('joinArrow')) }
581
685
  }
582
686
 
583
687
  ctx.inject(['dshGo'], (dshCtx) => {
@@ -595,14 +699,14 @@ export function apply(ctx) {
595
699
  } catch {}
596
700
  }
597
701
 
598
- /** 面板数据推流的通用外壳:内容变了才重绘,面板一关就停。 */
599
- const panelData = (build) => (scope, emit) => {
702
+ /** 面板数据推流的通用外壳:内容变了才重绘,面板一关就停。 */
703
+ const panelData = (build) => (scope, emit, caps) => {
600
704
  let stopped = false
601
705
  let lastJson = ''
602
706
  const tick = () => {
603
707
  if (stopped) return
604
708
  try {
605
- const payload = build()
709
+ const payload = build(caps?.locale)
606
710
  const json = JSON.stringify(payload)
607
711
  if (json === lastJson) return // 没有变化就不重绘,界面元素保持稳定
608
712
  lastJson = json
@@ -625,18 +729,23 @@ export function apply(ctx) {
625
729
  }
626
730
 
627
731
  const panelActions = {
628
- async refresh() {
732
+ async refresh(scope, event) {
733
+ const t = makeT(event?.locale)
629
734
  await Promise.all([refreshQuota(true), refreshCatalog(true)])
630
- return { toast: '额度已更新', refresh: true }
735
+ return { toast: t('toastQuotaUpdated'), refresh: true }
631
736
  },
632
737
  async 'pick-model'(scope, event) {
738
+ const t = makeT(event?.locale)
633
739
  const model = String(event?.value?.model ?? '')
634
740
  const ok = await selectModel(model)
635
- return ok ? { toast: `默认模型:${model}`, refresh: true } : { toast: '模型名不可用' }
741
+ return ok
742
+ ? { toast: t('toastModelSet', { model }), refresh: true }
743
+ : { toast: t('toastModelBad') }
636
744
  },
637
- async 'test-all'() {
745
+ async 'test-all'(scope, event) {
746
+ const t = makeT(event?.locale)
638
747
  const keys = pool ? [...pool.keys] : []
639
- if (keys.length === 0) return { toast: '池里还没有 Key' }
748
+ if (keys.length === 0) return { toast: t('toastNoKeys') }
640
749
  // 注意不能用 /v1/models 验 Key:那个接口是公开的,什么令牌都返回 200。
641
750
  // 真正会校验令牌的是额度接口,而且查额度不扣魔粒。
642
751
  const rows = await quota.refresh(
@@ -661,71 +770,79 @@ export function apply(ctx) {
661
770
  const ok = rows.filter((r) => r.state?.ok && r.state.available >= QUOTA_FLOOR).length
662
771
  const invalid = rows.filter((r) => !r.state?.ok).length
663
772
  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(',')}` }
773
+ const parts = [t('partOk', { ok, total: keys.length })]
774
+ if (short > 0) parts.push(t('partShort', { n: short }))
775
+ if (invalid > 0) parts.push(t('partInvalid', { n: invalid }))
776
+ return { toast: t('toastTestDone', { parts: parts.join(' · ') }) }
668
777
  },
669
- 'restore-all': async () => {
778
+ 'restore-all': async (scope, event) => {
779
+ const t = makeT(event?.locale)
670
780
  await pool.restoreAll()
671
781
  router.clearAll()
672
782
  refreshPanel()
673
783
  refreshStrip()
674
- return { toast: '已把全部 Key 恢复为可用' }
784
+ return { toast: t('toastRestored') }
675
785
  },
676
786
  // 单 Key 控制:先点列表里的某一行选中,再用下面四个按钮。
677
787
  'key-open'(scope, event) {
788
+ const t = makeT(event?.locale)
678
789
  const id = String(event?.value?.id ?? '')
679
- if (!id) return { toast: '没认出这个 Key' }
790
+ if (!id) return { toast: t('toastNoKeyId') }
680
791
  keyFocus = id
681
792
  deleteArmed = ''
682
793
  return { open: 'panel:key', refresh: true }
683
794
  },
684
- 'key-prev'() {
685
- if (keyPage <= 0) return { toast: '已经是第一页了' }
795
+ 'key-prev'(scope, event) {
796
+ const t = makeT(event?.locale)
797
+ if (keyPage <= 0) return { toast: t('toastFirstPage') }
686
798
  keyPage--
687
799
  return { refresh: true }
688
800
  },
689
- 'key-next'() {
801
+ 'key-next'(scope, event) {
802
+ const t = makeT(event?.locale)
690
803
  const pages = Math.max(1, Math.ceil((pool?.keys.length ?? 0) / KEYS_PER_PAGE))
691
- if (keyPage >= pages - 1) return { toast: '已经是最后一页了' }
804
+ if (keyPage >= pages - 1) return { toast: t('toastLastPage') }
692
805
  keyPage++
693
806
  return { refresh: true }
694
807
  },
695
- 'key-disable'() {
808
+ 'key-disable'(scope, event) {
809
+ const t = makeT(event?.locale)
696
810
  const id = pickedKey()
697
- if (!id) return { toast: '先在上面点一行选中一个 Key' }
811
+ if (!id) return { toast: t('toastNoPick') }
698
812
  pool.disable(id, '手动禁用')
699
813
  router.clearKey?.(id)
700
814
  refreshPanel()
701
815
  refreshStrip()
702
- return { toast: `${id} 已禁用,点「恢复」才放开`, refresh: true }
816
+ return { toast: t('toastDisabled', { id }), refresh: true }
703
817
  },
704
- 'key-stop'() {
818
+ 'key-stop'(scope, event) {
819
+ const t = makeT(event?.locale)
705
820
  const id = pickedKey()
706
- if (!id) return { toast: '先在上面点一行选中一个 Key' }
821
+ if (!id) return { toast: t('toastNoPick') }
707
822
  pool.stop(id, '手动停用', nextQuotaResetAt())
708
823
  router.clearKey?.(id)
709
824
  refreshPanel()
710
825
  refreshStrip()
711
- return { toast: `${id} 停用到北京时间次日 00:01`, refresh: true }
826
+ return { toast: t('toastStopped', { id }), refresh: true }
712
827
  },
713
- 'key-restore'() {
828
+ 'key-restore'(scope, event) {
829
+ const t = makeT(event?.locale)
714
830
  const id = pickedKey()
715
- if (!id) return { toast: '先在上面点一行选中一个 Key' }
831
+ if (!id) return { toast: t('toastNoPick') }
716
832
  pool.restore(id)
717
833
  router.clearKey?.(id)
718
834
  refreshPanel()
719
835
  refreshStrip()
720
- return { toast: `${id} 已恢复可用`, refresh: true }
836
+ return { toast: t('toastRestoredOne', { id }), refresh: true }
721
837
  },
722
- 'key-delete'() {
838
+ 'key-delete'(scope, event) {
839
+ const t = makeT(event?.locale)
723
840
  const id = pickedKey()
724
- if (!id) return { toast: '先在上面点一行选中一个 Key' }
841
+ if (!id) return { toast: t('toastNoPick') }
725
842
  // 删令牌不可撤销,要求连点两次。
726
843
  if (deleteArmed !== id) {
727
844
  deleteArmed = id
728
- return { toast: `再点一次「删除」确认删掉 ${id}`, refresh: true }
845
+ return { toast: t('toastDeleteArm', { id }), refresh: true }
729
846
  }
730
847
  deleteArmed = ''
731
848
  void pool.remove(id).then(() => {
@@ -734,22 +851,22 @@ export function apply(ctx) {
734
851
  refreshPanel()
735
852
  refreshStrip()
736
853
  })
737
- return { toast: `${id} 已从本机删除`, refresh: true }
854
+ return { toast: t('toastDeleted', { id }), refresh: true }
738
855
  },
739
856
  'add-form': async (scope, event) => {
740
857
  const value = event?.value
741
858
  const raw = typeof value === 'string' ? value : value?.newKey ?? value?.value ?? ''
742
- const result = await addKey(raw)
859
+ const result = await addKey(raw, event?.locale)
743
860
  return { ...result, refresh: true }
744
861
  },
745
862
  'key-back'() {
746
863
  return { open: 'panel:pool' }
747
864
  },
748
- async 'refresh-models'() {
749
- keyPage = keyPage
865
+ async 'refresh-models'(scope, event) {
866
+ const t = makeT(event?.locale)
750
867
  await refreshCatalog(true)
751
868
  refreshPanel()
752
- return { toast: '模型列表已重新拉取', refresh: true }
869
+ return { toast: t('toastModelsReloaded'), refresh: true }
753
870
  },
754
871
  }
755
872
 
@@ -758,7 +875,8 @@ export function apply(ctx) {
758
875
  dshGo.panel('models', { view: viewModels, data: panelData(buildModelsData), actions: panelActions })
759
876
 
760
877
  dshGo.strip('pool-strip', {
761
- data(scope, emit) {
878
+ data(scope, emit, caps) {
879
+ const t = makeT(caps?.locale)
762
880
  let stop = () => {}
763
881
  let lastText = ''
764
882
  const tick = () => {
@@ -767,9 +885,13 @@ export function apply(ctx) {
767
885
  const q = quota ? quota.sum(ids) : { available: 0, known: 0 }
768
886
  const payload =
769
887
  s.total === 0
770
- ? { text: '魔搭 未配置', tone: 'warn' }
888
+ ? { text: t('stripEmpty'), tone: 'warn' }
771
889
  : {
772
- text: `魔搭 ${s.available}/${s.total} · ${q.known > 0 ? `${q.available} 魔粒` : '额度?'}`,
890
+ text: t('stripText', {
891
+ ok: s.available,
892
+ total: s.total,
893
+ quota: q.known > 0 ? t('stripQuota', { n: q.available }) : t('stripQuotaUnknown'),
894
+ }),
773
895
  tone: s.available > 0 ? 'good' : 'bad',
774
896
  }
775
897
  const json = JSON.stringify(payload)
@@ -789,10 +911,10 @@ export function apply(ctx) {
789
911
  },
790
912
  })
791
913
 
792
- dshGo.on('addkey', async (scope) => {
914
+ dshGo.on('addkey', async (scope, event) => {
793
915
  const text = String(scope.clipboard ?? '').trim()
794
- if (!text) return { toast: '剪贴板里没有文字' }
795
- const result = await addKey(text)
916
+ if (!text) return { toast: makeT(event?.locale)('toastNoClipboard') }
917
+ const result = await addKey(text, event?.locale)
796
918
  return { ...result, refresh: 'pool' }
797
919
  })
798
920
 
@@ -859,7 +981,7 @@ export function apply(ctx) {
859
981
  })()
860
982
  const ok = await registerPoolProvider(llmCtx, {
861
983
  providerId: 'modelscope-pool',
862
- displayName: '魔搭 Key 池',
984
+ displayName: makeT()('poolTitle'),
863
985
  entryId,
864
986
  getPort: () => proxy?.port ?? 0,
865
987
  getKey: () => config.apiKey,
@@ -908,7 +1030,133 @@ export function apply(ctx) {
908
1030
  })
909
1031
 
910
1032
  // 真正的网关只在引擎里起(检查器没有 webServer,也不会占端口)。
911
- ctx.inject(['webServer'], () => {
1033
+ // 网页版 / 桌面版:原生面板只在 dsh-go 里出现,这里补一个同源管理页。
1034
+ ctx.inject(['webServer'], (webCtx) => {
1035
+ const webServer = webCtx?.webServer
1036
+ if (webServer && typeof webServer.register === 'function') {
1037
+ const getState = () => {
1038
+ const status = pool ? pool.status() : { total: 0, available: 0, cooling: 0, keys: [] }
1039
+ const ids = status.keys.map((k) => k.id)
1040
+ const sum = quota ? quota.sum(ids) : { available: 0, known: 0 }
1041
+ const per = 250
1042
+ const total = status.total * per
1043
+ const unknown = Math.max(0, status.total - sum.known)
1044
+ const left = sum.known > 0 ? sum.available + unknown * per : total
1045
+ return {
1046
+ total: status.total,
1047
+ available: status.available,
1048
+ cooling: status.cooling,
1049
+ magic: { total, left, used: Math.max(0, total - left) },
1050
+ model: String(config.model ?? ''),
1051
+ models: (catalog?.entries ?? [])
1052
+ .filter((entry) => isChatModel(entry.id))
1053
+ .map((entry) => ({ id: entry.id, name: entry.name || entry.id, regions: entry.regions ?? [] })),
1054
+ keys: status.keys.map((k) => {
1055
+ const state = quota?.cached(k.id)
1056
+ return {
1057
+ id: k.id,
1058
+ masked: k.masked,
1059
+ region: k.region,
1060
+ state: k.state,
1061
+ reason: k.reason ?? '',
1062
+ manual: Boolean(k.manual),
1063
+ untilAt: k.untilAt ?? 0,
1064
+ used: k.used ?? 0,
1065
+ quota: state?.ok
1066
+ ? { ok: true, available: state.available, total: state.total, frozen: state.frozen }
1067
+ : { ok: false, available: 0, total: 0, frozen: 0, error: state?.error ?? '' },
1068
+ }
1069
+ }),
1070
+ }
1071
+ }
1072
+
1073
+ const doAction = async (action, payload) => {
1074
+ const id = String(payload?.id ?? '')
1075
+ const after = () => {
1076
+ refreshPanel()
1077
+ refreshStrip()
1078
+ }
1079
+ switch (action) {
1080
+ case 'refresh':
1081
+ await refreshQuota(true)
1082
+ await refreshCatalog(true)
1083
+ after()
1084
+ return makeT()('toastQuotaUpdated')
1085
+ case 'test': {
1086
+ const keys = pool ? [...pool.keys] : []
1087
+ if (keys.length === 0) return makeT()('toastNoKeys')
1088
+ const rows = await quota.refresh(
1089
+ keys.map((k) => ({ id: k.id, key: k.key, region: k.region })),
1090
+ { force: true },
1091
+ )
1092
+ for (const [i, row] of rows.entries()) {
1093
+ const key = keys[i]
1094
+ if (row.state?.region) await pool.setRegion(key.id, row.state.region)
1095
+ if (!row.state?.ok) {
1096
+ pool.markFailure(key.id, '令牌无效', String(row.state?.error ?? '').slice(0, 80))
1097
+ continue
1098
+ }
1099
+ if (row.state.available < QUOTA_FLOOR) {
1100
+ pool.markFailure(key.id, '额度不足', `剩余 ${row.state.available} 魔粒`, nextQuotaResetAt())
1101
+ continue
1102
+ }
1103
+ pool.restore(key.id)
1104
+ }
1105
+ after()
1106
+ const t = makeT()
1107
+ const ready = rows.filter((r) => r.state?.ok && r.state.available >= QUOTA_FLOOR).length
1108
+ return t('toastTestDone', { parts: t('partOk', { ok: ready, total: keys.length }) })
1109
+ }
1110
+ case 'restoreAll':
1111
+ await pool.restoreAll()
1112
+ router.clearAll()
1113
+ after()
1114
+ return makeT()('toastRestored')
1115
+ case 'disable':
1116
+ if (!id) return makeT()('toastNoKeyId')
1117
+ pool.disable(id, '手动禁用')
1118
+ router.clearKey?.(id)
1119
+ after()
1120
+ return makeT()('toastDisabled', { id })
1121
+ case 'stop':
1122
+ if (!id) return makeT()('toastNoKeyId')
1123
+ pool.stop(id, '手动停用', nextQuotaResetAt())
1124
+ router.clearKey?.(id)
1125
+ after()
1126
+ return makeT()('toastStopped', { id })
1127
+ case 'restore':
1128
+ if (!id) return makeT()('toastNoKeyId')
1129
+ pool.restore(id)
1130
+ router.clearKey?.(id)
1131
+ after()
1132
+ return makeT()('toastRestoredOne', { id })
1133
+ case 'remove':
1134
+ if (!id) return makeT()('toastNoKeyId')
1135
+ await pool.remove(id)
1136
+ quota?.forget?.(id)
1137
+ after()
1138
+ return makeT()('toastDeleted', { id })
1139
+ case 'add': {
1140
+ const result = await addKey(String(payload?.value ?? ''), undefined)
1141
+ return result?.toast ?? makeT()('toastNoNew')
1142
+ }
1143
+ case 'setModel': {
1144
+ const ok = await selectModel(String(payload?.value ?? ''))
1145
+ return ok ? makeT()('toastModelSet', { model: payload.value }) : makeT()('toastModelBad')
1146
+ }
1147
+ default:
1148
+ throw new Error(`unknown action ${action}`)
1149
+ }
1150
+ }
1151
+
1152
+ try {
1153
+ ctx.effect(() => registerWebAdmin(webServer, { getState, doAction, logger }))
1154
+ logger?.info?.(`modelscope-pool: 网页/桌面端管理页 ${WEB_ADMIN_PATH}`)
1155
+ } catch (error) {
1156
+ logger?.warn?.(`modelscope-pool: 管理页注册失败 ${String(error?.message ?? error)}`)
1157
+ }
1158
+ }
1159
+
912
1160
  ready
913
1161
  .then(async () => {
914
1162
  proxy = createProxy({