dsh-modelscope-pool 0.9.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/README.md CHANGED
@@ -20,6 +20,7 @@
20
20
  - [配置](#配置)
21
21
  - [界面](#界面)
22
22
  - [多语言](#多语言)
23
+ - [网页版与桌面版](#网页版与桌面版)
23
24
  - [HTTP 接口](#http-接口)
24
25
  - [接入其它客户端](#接入其它客户端)
25
26
  - [常见问题](#常见问题)
@@ -177,7 +178,7 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
177
178
 
178
179
  | 面板 | 位置 | 内容 |
179
180
  |---|---|---|
180
- | **魔搭 Key 池** | 对话标签 / 侧栏 / 功能页 | 可用 / 冷却 / 魔粒三个数字、两张扇形图(Key 状态分布 + 魔粒用量)、Key 列表(分页)、刷新额度、测试全部、全部恢复、添加 Key |
181
+ | **魔搭 Key 池** | 对话标签 / 侧栏 / 功能页 | 三个数字(可用 / 冷却 / 魔粒)、一条魔粒进度条、Key 列表(每页 10 个)、刷新额度 / 测试全部 / 全部恢复、添加入口 |
181
182
  | **Key 详情** | 侧栏 / 功能页 | 点列表任意一行进来:完整令牌、站点、额度明细、状态、失败原因,以及禁用 / 停用 / 恢复 / 删除(删除需连点两次确认) |
182
183
  | **魔搭模型** | 对话标签 / 侧栏 / 功能页 | 按「国际版 / 国内版 / 双站」分组的模型列表,点一下设为默认,底部可重新拉取 |
183
184
 
@@ -185,6 +186,25 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
185
186
 
186
187
  ---
187
188
 
189
+ ## 网页版与桌面版
190
+
191
+ 原生面板只在 dsh-go 手机端出现。Harness Web / 桌面端没有这套面板,所以插件在宿主自带的 webServer 上开了一个**同源管理页**:
192
+
193
+ ```
194
+ http://127.0.0.1:3080/modelscope-pool
195
+ ```
196
+
197
+ (端口跟着你的 Harness 网页端口走,默认 3080。)
198
+
199
+ 页面上能做的事和原生面板一致:看三个数字和魔粒进度、看 Key 列表、刷新额度、测试全部、全部恢复、逐条禁用 / 停用 / 恢复 / 删除、批量添加 Key、切换默认模型。中英文跟随浏览器语言,右上角可手动切换。
200
+
201
+ 两道保护:
202
+
203
+ - **只认本机回环地址**。来自 `192.168.*`、`10.*` 等局域网地址的请求直接 403,令牌不会因为开了这个页面而被同网段的人看到。
204
+ - **沿用 Harness 自己的登录校验**。这个路径和 `/api/*` 一样要过宿主的 Host/Origin 校验与 cookie 凭据,所以请在你已经登录 DSH 网页的那个浏览器里打开。
205
+
206
+ 桌面端内嵌的就是同一套 Web 界面,地址相同。
207
+
188
208
  ## 多语言
189
209
 
190
210
  界面文案跟随系统语言,判定顺序:
@@ -198,7 +218,7 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
198
218
 
199
219
  ## 魔粒用量怎么算
200
220
 
201
- 魔搭按「魔粒」(magicubes)计费,每个 Key 的免费额度是 **250 魔粒**。面板上的第二张扇形图就是按这个估的:
221
+ 魔搭按「魔粒」(magicubes)计费,每个 Key 的免费额度是 **250 魔粒**。面板上的魔粒进度条(网页版是顶部那条横条)就是按这个估的:
202
222
 
203
223
  ```
204
224
  总魔粒 = Key 数量 × 250
package/lib/i18n.js CHANGED
@@ -71,6 +71,8 @@ const S = {
71
71
  unitKey: ['个', ''],
72
72
  captionNoKeys: ['还没有 Key', 'No keys yet'],
73
73
  shareTitle: ['Key 状态分布', 'Key status'],
74
+ magicLabel: ['魔粒用量', 'Magicube usage'],
75
+ magicBarCaption: ['剩余 {left} / 共 {total}', '{left} left of {total}'],
74
76
  magicLeft: ['剩余', 'Remaining'],
75
77
  magicUsed: ['已用', 'Spent'],
76
78
  magicCaption: [
package/lib/index.js CHANGED
@@ -12,8 +12,9 @@ import { createProxy, quotaLike } from './proxy.js'
12
12
  import { createQuotaBook, REGIONS } from './quota.js'
13
13
  import { createRouter } from './route.js'
14
14
  import { makeT } from './i18n.js'
15
+ import { registerWebAdmin, WEB_ADMIN_PATH } from './web.js'
15
16
 
16
- export const name = 'local-modelscope-pool'
17
+ export const name = 'dsh-modelscope-pool'
17
18
 
18
19
  // 「模型」页只有在这个插件的设置命名空间存在时才显示「魔搭 Key 池」这一行,
19
20
  // 而命名空间来自这里导出的 Config schema,键就是 loader 里的条目 id。
@@ -265,7 +266,7 @@ export function apply(ctx) {
265
266
  let keyFocus = ''
266
267
  let keyPage = 0
267
268
  let deleteArmed = ''
268
- const KEYS_PER_PAGE = 40
269
+ const KEYS_PER_PAGE = 10
269
270
  const pickedKey = () => (keyFocus && pool?.keys.some((k) => k.id === keyFocus) ? keyFocus : '')
270
271
 
271
272
 
@@ -362,6 +363,7 @@ const modelRowOf = (entry, current, t) => {
362
363
  left: magicLeft,
363
364
  used: magicUsed,
364
365
  }),
366
+ brief: t('magicBarCaption', { left: magicLeft, total: magicTotal }),
365
367
  categories: [t('magicLeft'), t('magicUsed')],
366
368
  series: [{ name: 'Magicubes', values: [magicLeft, magicUsed] }],
367
369
  }
@@ -505,7 +507,7 @@ const modelRowOf = (entry, current, t) => {
505
507
  models: modelRows,
506
508
  }
507
509
  }
508
- const viewPool = (scope, caps) => {
510
+ const viewPool = (scope, caps) => {
509
511
  const t = makeT(caps?.locale)
510
512
  return {
511
513
  v: 2,
@@ -522,19 +524,12 @@ const viewPool = (scope, caps) => {
522
524
  ],
523
525
  },
524
526
  {
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' },
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',
538
533
  },
539
534
  { type: 'callout', tone: 'info', text: { $: 'data.hint' } },
540
535
  { type: 'text', text: { $: 'data.keyTitle' }, style: { size: 'title', weight: 'bold' } },
@@ -542,19 +537,18 @@ const viewPool = (scope, caps) => {
542
537
  {
543
538
  type: 'actions',
544
539
  items: [
545
- { label: t('btnPrev'), icon: 'collapse', action: { id: 'key-prev' } },
546
- { label: t('btnNext'), icon: 'expand', action: { id: 'key-next' } },
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' } },
547
543
  ],
548
544
  },
549
545
  {
550
546
  type: 'actions',
551
547
  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' } },
548
+ { label: t('btnPrev'), icon: 'collapse', action: { id: 'key-prev' } },
549
+ { label: t('btnNext'), icon: 'expand', action: { id: 'key-next' } },
555
550
  ],
556
551
  },
557
- { type: 'text', text: { $: 'data.focusTitle' }, tone: 'muted' },
558
552
  {
559
553
  type: 'form',
560
554
  submitLabel: t('formSubmit'),
@@ -617,7 +611,6 @@ const viewPool = (scope, caps) => {
617
611
  cooling: d.cooling,
618
612
  magicubes: d.magicubes,
619
613
  share: d.share,
620
- shareTitle: d.shareTitle,
621
614
  magic: d.magic,
622
615
  hint: d.hint,
623
616
  keyTitle: d.keyTitle,
@@ -1037,7 +1030,133 @@ const viewPool = (scope, caps) => {
1037
1030
  })
1038
1031
 
1039
1032
  // 真正的网关只在引擎里起(检查器没有 webServer,也不会占端口)。
1040
- 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
+
1041
1160
  ready
1042
1161
  .then(async () => {
1043
1162
  proxy = createProxy({
package/lib/provider.js CHANGED
@@ -60,7 +60,7 @@ const INERT_AUTH = {
60
60
  return []
61
61
  },
62
62
  async modify() {
63
- throw new Error('local-modelscope-pool has no pi-ai credential lifecycle')
63
+ throw new Error('dsh-modelscope-pool has no pi-ai credential lifecycle')
64
64
  },
65
65
  async delete() {},
66
66
  },
@@ -176,7 +176,7 @@ export async function registerPoolProvider(ctx, { providerId, displayName, getPo
176
176
  provider: providerId,
177
177
  displayName,
178
178
  streamIdleTimeoutMs: 180000,
179
- retryPolicy: resolveRetryPolicy(undefined, 'local-modelscope-pool retryPolicy'),
179
+ retryPolicy: resolveRetryPolicy(undefined, 'dsh-modelscope-pool retryPolicy'),
180
180
  configuredMaxTokens: new Map(),
181
181
  modelErrors: new Map(),
182
182
  defaultContextWindow: DEFAULT_CONTEXT_WINDOW,
package/lib/proxy.js CHANGED
@@ -119,7 +119,7 @@ export function createProxy({ pool, config, router, logger, hooks = {} }) {
119
119
  if (req.method === 'GET' && url.pathname === '/') {
120
120
  const s = pool.status()
121
121
  return sendJson(res, 200, {
122
- service: 'local-modelscope-pool',
122
+ service: 'dsh-modelscope-pool',
123
123
  endpoint: `http://127.0.0.1:${boundPort}/v1`,
124
124
  keys: { total: s.total, available: s.available, cooling: s.cooling },
125
125
  })
package/lib/web.js ADDED
@@ -0,0 +1,287 @@
1
+ // 网页版 / 桌面版 DSH 的管理页面。
2
+ // dsh-go 手机端用原生面板,Harness Web(含桌面端内嵌的同一套界面)没有面板,
3
+ // 所以在宿主自带的 webServer 上开一个同源页面:http://127.0.0.1:<web 端口>/modelscope-pool
4
+ // 页面只读同源 JSON,不跨域;只接受本机回环地址,避免局域网里被人看到令牌。
5
+
6
+ const PAGE_PATH = '/modelscope-pool'
7
+
8
+ const HTML = [
9
+ '<!doctype html>',
10
+ '<html lang="zh-CN"><head>',
11
+ '<meta charset="utf-8">',
12
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
13
+ '<title>ModelScope key pool</title>',
14
+ '<style>',
15
+ ':root{color-scheme:light dark;--bg:#fff;--fg:#16181d;--muted:#6b7078;--line:#e3e6ea;--card:#f7f8fa;--good:#1f7a4d;--warn:#8a6100;--bad:#b3261e;--btn:#eceff3}',
16
+ '@media (prefers-color-scheme:dark){:root{--bg:#16181d;--fg:#e8eaee;--muted:#9aa0aa;--line:#2b2f36;--card:#1d2026;--good:#5fd39b;--warn:#e0b355;--bad:#ff8a80;--btn:#262b33}}',
17
+ '*{box-sizing:border-box}',
18
+ 'body{margin:0;padding:16px 14px 40px;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,system-ui,"Segoe UI",Roboto,"Noto Sans SC",sans-serif}',
19
+ 'h1{margin:0 0 2px;font-size:18px}',
20
+ '.sub{color:var(--muted);font-size:12px;margin-bottom:14px}',
21
+ '.stats{display:flex;gap:10px;margin-bottom:12px}',
22
+ '.stat{flex:1;background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px}',
23
+ '.stat b{display:block;font-size:20px;line-height:1.2}',
24
+ '.stat span{color:var(--muted);font-size:12px}',
25
+ '.bar{height:8px;border-radius:6px;background:var(--line);overflow:hidden;margin:6px 0 4px}',
26
+ '.bar i{display:block;height:100%;background:var(--good)}',
27
+ '.row{display:flex;gap:8px;flex-wrap:wrap;margin:12px 0}',
28
+ 'button{background:var(--btn);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:7px 12px;font:inherit;cursor:pointer}',
29
+ 'button:active{opacity:.7}',
30
+ 'button.pri{background:var(--fg);color:var(--bg);border-color:var(--fg)}',
31
+ 'table{width:100%;border-collapse:collapse;font-size:13px}',
32
+ 'th,td{text-align:left;padding:7px 6px;border-bottom:1px solid var(--line);vertical-align:middle}',
33
+ 'th{color:var(--muted);font-weight:500;font-size:12px}',
34
+ '.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}',
35
+ '.ok{color:var(--good)}.warn{color:var(--warn)}.bad{color:var(--bad)}',
36
+ '.muted{color:var(--muted)}',
37
+ 'td.ops{white-space:nowrap;text-align:right}',
38
+ 'td.ops button{padding:4px 8px;font-size:12px;margin-left:4px}',
39
+ 'textarea,select{width:100%;background:var(--card);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:8px;font:inherit}',
40
+ 'textarea{min-height:64px;resize:vertical}',
41
+ '.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--fg);color:var(--bg);padding:9px 16px;border-radius:20px;font-size:13px;opacity:0;transition:opacity .2s;pointer-events:none;max-width:88vw}',
42
+ '.toast.on{opacity:.95}',
43
+ 'details{margin-top:18px}summary{cursor:pointer;color:var(--muted);font-size:13px}',
44
+ '</style></head><body>',
45
+ '<h1 id="title">魔搭 Key 池</h1>',
46
+ '<div class="sub" id="sub"></div>',
47
+ '<div class="stats">',
48
+ '<div class="stat"><b id="s-ok">–</b><span id="l-ok"></span></div>',
49
+ '<div class="stat"><b id="s-cool">–</b><span id="l-cool"></span></div>',
50
+ '<div class="stat"><b id="s-magic">–</b><span id="l-magic"></span></div>',
51
+ '</div>',
52
+ '<div class="bar"><i id="bar" style="width:0"></i></div>',
53
+ '<div class="sub" id="magic-cap"></div>',
54
+ '<div class="row">',
55
+ '<button class="pri" data-act="refresh" id="b-refresh"></button>',
56
+ '<button data-act="test" id="b-test"></button>',
57
+ '<button data-act="restoreAll" id="b-restore"></button>',
58
+ '<button id="b-lang"></button>',
59
+ '</div>',
60
+ '<table><thead><tr id="head"></tr></thead><tbody id="rows"></tbody></table>',
61
+ '<div class="row"><button id="b-prev"></button><button id="b-next"></button></div>',
62
+ '<details><summary id="add-sum"></summary>',
63
+ '<div class="row"><textarea id="add-text" placeholder="ms-..."></textarea></div>',
64
+ '<div class="row"><button class="pri" id="b-add"></button></div>',
65
+ '</details>',
66
+ '<details><summary id="model-sum"></summary>',
67
+ '<div class="row"><select id="model-sel"></select></div>',
68
+ '</details>',
69
+ '<div class="toast" id="toast"></div>',
70
+ '<script>',
71
+ 'var L=null,S=null,PAGE=0,PER=20,ARM="";',
72
+ 'var DICT={',
73
+ ' zh:{title:"魔搭 Key 池",sub:"本页只在 127.0.0.1 上可用;Key 不会离开这台设备。",',
74
+ ' ok:"可用",cool:"冷却",magic:"魔粒",left:"剩余",total:"共",refresh:"刷新额度",test:"测试全部",restoreAll:"全部恢复",',
75
+ ' prev:"上一页",next:"下一页",add:"添加",addSum:"添加 Key(一行一个)",modelSum:"默认模型",',
76
+ ' id:"Key",site:"站点",state:"状态",quota:"额度",ops:"操作",',
77
+ ' disable:"禁用",stop:"停用",restore:"恢复",del:"删除",delAgain:"再点一次",',
78
+ ' ai:"国际版",cn:"国内版",none:"—",empty:"还没有 Key",',
79
+ ' copied:"已刷新",added:"已添加",saved:"已保存",failed:"操作失败",lang:"English"},',
80
+ ' en:{title:"ModelScope keys",sub:"This page only answers on 127.0.0.1; keys never leave this device.",',
81
+ ' ok:"Ready",cool:"Cooling",magic:"Magicubes",left:"left",total:"of",refresh:"Refresh quota",test:"Test all",restoreAll:"Restore all",',
82
+ ' prev:"Prev",next:"Next",add:"Add",addSum:"Add keys (one per line)",modelSum:"Default model",',
83
+ ' id:"Key",site:"Site",state:"State",quota:"Quota",ops:"Actions",',
84
+ ' disable:"Disable",stop:"Pause",restore:"Restore",del:"Delete",delAgain:"Tap again",',
85
+ ' ai:"International",cn:"China",none:"—",empty:"No keys yet",',
86
+ ' copied:"Refreshed",added:"Added",saved:"Saved",failed:"Action failed",lang:"中文"}};',
87
+ 'var RSN={"额度不足":["额度不足","Out of quota"],"额度用尽":["额度用尽","Quota exhausted"],"令牌无效":["令牌无效","Invalid token"],"被限流":["被限流","Rate limited"],"模型名不可用":["模型名不可用","Model unavailable"],"上游超时":["上游超时","Upstream timeout"],"读取请求失败":["读取请求失败","Request failed"],"冷却中":["冷却中","Cooling down"],"手动禁用":["手动禁用","Disabled"],"手动停用":["手动停用","Paused"]};',
88
+ 'function rsn(r){if(!r)return "";var x=RSN[r];return x?(L===DICT.en?x[1]:x[0]):r}',
89
+ 'function t(k){return (L&&L[k])||k}',
90
+ 'function pickLang(){try{var s=localStorage.getItem("ms-lang");if(s==="zh"||s==="en")return s}catch(e){}',
91
+ ' var n=(navigator.language||"zh").toLowerCase();return n.indexOf("zh")===0?"zh":"en"}',
92
+ 'function setLang(x){L=DICT[x];try{localStorage.setItem("ms-lang",x)}catch(e){}',
93
+ ' document.documentElement.lang=x==="zh"?"zh-CN":"en";',
94
+ ' document.querySelectorAll("[id^=b-lang]").forEach(function(b){b.textContent=L.lang});render()}',
95
+ 'function toast(m){var el=document.getElementById("toast");el.textContent=m;el.classList.add("on");',
96
+ ' clearTimeout(window.__t);window.__t=setTimeout(function(){el.classList.remove("on")},2200)}',
97
+ 'function fmt(ms){if(!ms)return "";var d=new Date(ms);function p(n){return n<10?"0"+n:""+n}',
98
+ ' return p(d.getMonth()+1)+"-"+p(d.getDate())+" "+p(d.getHours())+":"+p(d.getMinutes())}',
99
+ 'function el(tag,cls,text){var e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e}',
100
+ 'function render(){',
101
+ ' if(!S||!L)return;',
102
+ ' document.getElementById("title").textContent=L.title;',
103
+ ' document.getElementById("sub").textContent=L.sub;',
104
+ ' document.getElementById("s-ok").textContent=S.available;',
105
+ ' document.getElementById("s-cool").textContent=S.cooling;',
106
+ ' document.getElementById("s-magic").textContent=S.magic.left;',
107
+ ' document.getElementById("l-ok").textContent=L.ok;',
108
+ ' document.getElementById("l-cool").textContent=L.cool;',
109
+ ' document.getElementById("l-magic").textContent=L.magic;',
110
+ ' var pct=S.magic.total>0?Math.max(0,Math.min(100,S.magic.left/S.magic.total*100)):0;',
111
+ ' document.getElementById("bar").style.width=pct.toFixed(1)+"%";',
112
+ ' document.getElementById("magic-cap").textContent=" "+L.left+" "+S.magic.left+" / "+L.total+" "+S.magic.total;',
113
+ ' document.getElementById("b-refresh").textContent=L.refresh;',
114
+ ' document.getElementById("b-test").textContent=L.test;',
115
+ ' document.getElementById("b-restore").textContent=L.restoreAll;',
116
+ ' document.getElementById("b-prev").textContent=L.prev;',
117
+ ' document.getElementById("b-next").textContent=L.next;',
118
+ ' document.getElementById("b-add").textContent=L.add;',
119
+ ' document.getElementById("add-sum").textContent=L.addSum;',
120
+ ' document.getElementById("model-sum").textContent=L.modelSum;',
121
+ ' var head=document.getElementById("head");head.innerHTML="";',
122
+ ' [L.id,L.site,L.state,L.quota,L.ops].forEach(function(h){head.appendChild(el("th",null,h))});',
123
+ ' var body=document.getElementById("rows");body.innerHTML="";',
124
+ ' var maxPage=Math.max(0,Math.ceil(S.keys.length/PER)-1);PAGE=Math.min(PAGE,maxPage);',
125
+ ' var slice=S.keys.slice(PAGE*PER,PAGE*PER+PER);',
126
+ ' if(!slice.length){var tr=el("tr");var td=el("td","muted",L.empty);td.colSpan=5;tr.appendChild(td);body.appendChild(tr)}',
127
+ ' slice.forEach(function(k){',
128
+ ' var tr=el("tr");',
129
+ ' tr.appendChild(el("td","mono",k.id+" "+k.masked));',
130
+ ' tr.appendChild(el("td",null,k.region==="ai"?L.ai:k.region==="cn"?L.cn:L.none));',
131
+ ' var st=el("td",k.state==="ok"?"ok":k.manual?"bad":"warn");',
132
+ ' st.textContent=k.state==="ok"?L.ok:(k.manual?L.disable:rsn(k.reason)||L.cool)+(k.untilAt?" "+fmt(k.untilAt):"");',
133
+ ' tr.appendChild(st);',
134
+ ' var q=el("td",null,k.quota&&k.quota.ok?(k.quota.available+" / "+k.quota.total):L.none);',
135
+ ' tr.appendChild(q);',
136
+ ' var ops=el("td","ops");',
137
+ ' function mk(act,label,cls){var b=el("button",cls,label);b.onclick=function(){act(k.id,label)};ops.appendChild(b);return b}',
138
+ ' if(k.state==="ok"||k.manual){mk(actDisable,L.disable)} else {mk(actRestore,L.restore)}',
139
+ ' mk(actStop,L.stop);',
140
+ ' var delBtn=mk(actDelete,ARM===k.id?L.delAgain:L.del);',
141
+ ' tr.appendChild(ops);',
142
+ ' body.appendChild(tr);',
143
+ ' });',
144
+ ' var sel=document.getElementById("model-sel");sel.innerHTML="";',
145
+ ' S.models.slice(0,400).forEach(function(m){var o=el("option",null,m.name);o.value=m.id;if(m.id===S.model)o.selected=true;sel.appendChild(o)});',
146
+ '}',
147
+ 'function post(action,payload){',
148
+ ' var body=Object.assign({action:action},payload||{});',
149
+ ' return fetch("' + PAGE_PATH + '/api/action",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(body)})',
150
+ ' .then(function(r){return r.json()}).then(function(j){if(j.message)toast(j.message);return load()})',
151
+ ' .catch(function(){toast(L.failed)})',
152
+ '}',
153
+ 'function actDisable(id){post("disable",{id:id})}',
154
+ 'function actStop(id){post("stop",{id:id})}',
155
+ 'function actRestore(id){post("restore",{id:id})}',
156
+ 'function actDelete(id){if(ARM!==id){ARM=id;render();return}ARM="";post("remove",{id:id})}',
157
+ 'function load(){',
158
+ ' return fetch("' + PAGE_PATH + '/api/state").then(function(r){if(!r.ok)throw 0;return r.json()})',
159
+ ' .then(function(j){S=j;render()}).catch(function(){})',
160
+ '}',
161
+ 'document.addEventListener("click",function(e){',
162
+ ' var a=e.target&&e.target.getAttribute&&e.target.getAttribute("data-act");',
163
+ ' if(a){post(a);return}',
164
+ ' if(e.target.id==="b-prev"){if(PAGE>0){PAGE--;render()}return}',
165
+ ' if(e.target.id==="b-next"){if((PAGE+1)*PER<S.keys.length){PAGE++;render()}return}',
166
+ ' if(e.target.id==="b-add"){var v=document.getElementById("add-text").value;',
167
+ ' if(!v.trim())return;post("add",{value:v}).then(function(){document.getElementById("add-text").value=""});return}',
168
+ ' if(e.target.id==="b-lang"){setLang(L===DICT.zh?"en":"zh");return}',
169
+ '});',
170
+ 'document.addEventListener("change",function(e){',
171
+ ' if(e.target.id==="model-sel")post("setModel",{value:e.target.value});',
172
+ '});',
173
+ 'setLang(pickLang());load();setInterval(load,4000);',
174
+ '</script></body></html>',
175
+ ].join('\n')
176
+
177
+ const isLoopback = (req) => {
178
+ const ip = String(req.socket?.remoteAddress ?? '')
179
+ return ip === '::1' || ip.startsWith('127.') || ip.startsWith('::ffff:127.')
180
+ }
181
+
182
+ const send = (res, status, type, body, extra) => {
183
+ res.writeHead(status, {
184
+ 'content-type': type,
185
+ 'cache-control': 'no-store',
186
+ 'x-content-type-options': 'nosniff',
187
+ ...extra,
188
+ })
189
+ res.end(body)
190
+ }
191
+
192
+ const sendJson = (res, status, value) => send(res, status, 'application/json; charset=utf-8', JSON.stringify(value))
193
+
194
+ const readBody = (req) =>
195
+ new Promise((resolve) => {
196
+ let size = 0
197
+ const chunks = []
198
+ req.on('data', (chunk) => {
199
+ size += chunk.length
200
+ if (size > 256 * 1024) {
201
+ req.destroy()
202
+ return
203
+ }
204
+ chunks.push(chunk)
205
+ })
206
+ req.on('end', () => {
207
+ const text = Buffer.concat(chunks).toString('utf8')
208
+ let value = null
209
+ try {
210
+ value = text ? JSON.parse(text) : null
211
+ } catch {}
212
+ resolve(value)
213
+ })
214
+ req.on('error', () => resolve(null))
215
+ })
216
+
217
+ /**
218
+ * 在宿主的 webServer 上挂一个同源管理页。
219
+ * @param webServer 宿主注入的 webServer 服务
220
+ * @param options.getState () => 状态对象
221
+ * @param options.doAction (action, payload) => Promise<string> 返回一行提示
222
+ */
223
+ export function registerWebAdmin(webServer, { getState, doAction, logger }) {
224
+ const route = {
225
+ kind: 'prefix',
226
+ path: PAGE_PATH,
227
+ handler: async (req, res) => {
228
+ if (!isLoopback(req)) {
229
+ sendJson(res, 403, { error: 'loopback only' })
230
+ return
231
+ }
232
+ const url = new URL(String(req.url ?? '/'), 'http://127.0.0.1')
233
+ const rest = url.pathname.slice(PAGE_PATH.length).replace(/\/+$/, '')
234
+
235
+ if (rest === '' || rest === '/') {
236
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
237
+ sendJson(res, 405, { error: 'method not allowed' })
238
+ return
239
+ }
240
+ send(res, 200, 'text/html; charset=utf-8', req.method === 'HEAD' ? '' : HTML, {
241
+ 'x-frame-options': 'SAMEORIGIN',
242
+ 'referrer-policy': 'no-referrer',
243
+ })
244
+ return
245
+ }
246
+
247
+ if (rest === '/api/state') {
248
+ if (req.method !== 'GET') {
249
+ sendJson(res, 405, { error: 'method not allowed' })
250
+ return
251
+ }
252
+ try {
253
+ sendJson(res, 200, getState())
254
+ } catch (error) {
255
+ logger?.warn?.(`modelscope-pool: 网页状态读取失败 ${String(error?.message ?? error)}`)
256
+ sendJson(res, 500, { error: 'state failed' })
257
+ }
258
+ return
259
+ }
260
+
261
+ if (rest === '/api/action') {
262
+ if (req.method !== 'POST') {
263
+ sendJson(res, 405, { error: 'method not allowed' })
264
+ return
265
+ }
266
+ const body = await readBody(req)
267
+ if (!body || typeof body.action !== 'string') {
268
+ sendJson(res, 400, { error: 'bad request' })
269
+ return
270
+ }
271
+ try {
272
+ const message = await doAction(body.action, body)
273
+ sendJson(res, 200, { ok: true, message: message ?? '' })
274
+ } catch (error) {
275
+ logger?.warn?.(`modelscope-pool: 网页操作失败 ${String(error?.message ?? error)}`)
276
+ sendJson(res, 200, { ok: false, message: String(error?.message ?? error) })
277
+ }
278
+ return
279
+ }
280
+
281
+ sendJson(res, 404, { error: 'not found' })
282
+ },
283
+ }
284
+ return webServer.register(route)
285
+ }
286
+
287
+ export const WEB_ADMIN_PATH = PAGE_PATH
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-modelscope-pool",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "魔搭多 Key 聚合网关:无感路由(模型不变,Key 遇限流/额度不足/无权限/不存在自动换下一个);自动识别国际版/国内版令牌;单个 Key 可禁用/停用/恢复/删除/查看;全部密钥分页浏览;模型实际拉取国际版+国内版合并成简化名、按站点分组;额度低于 2 停用到次日 00:01、多 Key 轮换、限流临时失败冷却 5 分钟,并注册为 DSH 模型供应商",
5
5
  "license": "MIT",
6
6
  "author": "Yizutt",