dsh-modelscope-pool 0.9.0 → 0.10.1

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
@@ -1,5 +1,6 @@
1
1
  # dsh-modelscope-pool
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/dsh-modelscope-pool.svg)](https://www.npmjs.com/package/dsh-modelscope-pool)
3
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
5
  [![dsh](https://img.shields.io/badge/dsh-%3E%3D0.2.1--rc.2-4D6BFE.svg)](https://github.com/deepseek-ai/deepseek-harness)
5
6
  [![Node](https://img.shields.io/badge/node-%3E%3D20-5FA04E.svg)](https://nodejs.org/)
@@ -20,6 +21,7 @@
20
21
  - [配置](#配置)
21
22
  - [界面](#界面)
22
23
  - [多语言](#多语言)
24
+ - [网页版与桌面版](#网页版与桌面版)
23
25
  - [HTTP 接口](#http-接口)
24
26
  - [接入其它客户端](#接入其它客户端)
25
27
  - [常见问题](#常见问题)
@@ -116,29 +118,53 @@
116
118
  - Node.js ≥ 20
117
119
  - 一个魔搭账号和它的 API Key(`ms-` 开头),在 <https://modelscope.cn/my/myaccesstoken> 或 <https://modelscope.ai> 生成
118
120
 
119
- ### 作为 dsh-go 原生插件安装
121
+ ### 方式一:从 npm 安装(推荐)
120
122
 
121
123
  ```bash
122
- # 1. 把仓库放进 dsh-go 的本地插件目录
123
- # 目录名必须叫 dsh-modelscope-pool(与 package.json 的 name 一致)
124
+ dsh plugin --profile web add dsh-modelscope-pool@0.10.0
125
+ ```
126
+
127
+ 一条命令搞定。装好后重启 App(或让插件重新加载),网关会在 `127.0.0.1:8899` 起来。
128
+
129
+ 升级就是换版本号重装:
130
+
131
+ ```bash
132
+ dsh plugin --profile web add dsh-modelscope-pool@<新版本>
133
+ ```
134
+
135
+ > 建议写死精确版本。npm 上其他包常见的 `latest`、`^` 范围在这里没有好处——插件会监听端口、读写本机文件,版本变化值得你自己看一眼。
136
+
137
+ 只想要包本身、不需要 dsh 帮你装:
138
+
139
+ ```bash
140
+ npm i dsh-modelscope-pool
141
+ ```
142
+
143
+ > 每个版本的改动见 [Releases](https://github.com/Yizutt/dsh-modelscope-pool/releases)。
144
+
145
+ ### 方式二:从源码安装(要改代码时用)
146
+
147
+ ```bash
148
+ # 目录名要和 package.json 的 name 一致
124
149
  mkdir -p "$DSH_HOME/dsh-go/plugins"
125
150
  cd "$DSH_HOME/dsh-go/plugins"
126
151
  git clone https://github.com/Yizutt/dsh-modelscope-pool.git dsh-modelscope-pool
127
152
 
128
- # 2. 装进当前 profile
129
153
  dsh plugin --profile web add "$DSH_HOME/dsh-go/plugins/dsh-modelscope-pool"
130
154
  ```
131
155
 
132
- 装好后重启 App,或让插件重新加载。网关会在 `127.0.0.1:8899` 起来。
156
+ 装的是指向这个目录的链接,所以**目录不能删**。改完 `lib/` 里的代码需要在 App 的「引擎」页重启引擎才生效。
133
157
 
134
- ### 只想用网关、不装 App
158
+ ### 方式三:只挂到 Harness(网页版 / 桌面版)
135
159
 
136
- 插件本身就是一个 Cordis bundle(`package.json` 里的 `dsh.bundle.patch` 指向 `cordis.patch.yml`),所以也可以直接挂到 Harness 的 profile 里:
160
+ 插件本身就是一个 Cordis bundle(`package.json` 里的 `dsh.bundle.patch` 指向 `cordis.patch.yml`),所以不装 dsh-go 也能用:
137
161
 
138
162
  ```bash
139
163
  dsh plugin --profile web add /path/to/dsh-modelscope-pool
140
164
  ```
141
165
 
166
+ 网页版的管理页在 `http://127.0.0.1:3080/modelscope-pool`,见[网页版与桌面版](#网页版与桌面版)。
167
+
142
168
  ---
143
169
 
144
170
  ## 配置
@@ -177,7 +203,7 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
177
203
 
178
204
  | 面板 | 位置 | 内容 |
179
205
  |---|---|---|
180
- | **魔搭 Key 池** | 对话标签 / 侧栏 / 功能页 | 可用 / 冷却 / 魔粒三个数字、两张扇形图(Key 状态分布 + 魔粒用量)、Key 列表(分页)、刷新额度、测试全部、全部恢复、添加 Key |
206
+ | **魔搭 Key 池** | 对话标签 / 侧栏 / 功能页 | 三个数字(可用 / 冷却 / 魔粒)、一条魔粒进度条、Key 列表(每页 10 个)、刷新额度 / 测试全部 / 全部恢复、添加入口 |
181
207
  | **Key 详情** | 侧栏 / 功能页 | 点列表任意一行进来:完整令牌、站点、额度明细、状态、失败原因,以及禁用 / 停用 / 恢复 / 删除(删除需连点两次确认) |
182
208
  | **魔搭模型** | 对话标签 / 侧栏 / 功能页 | 按「国际版 / 国内版 / 双站」分组的模型列表,点一下设为默认,底部可重新拉取 |
183
209
 
@@ -185,6 +211,25 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
185
211
 
186
212
  ---
187
213
 
214
+ ## 网页版与桌面版
215
+
216
+ 原生面板只在 dsh-go 手机端出现。Harness Web / 桌面端没有这套面板,所以插件在宿主自带的 webServer 上开了一个**同源管理页**:
217
+
218
+ ```
219
+ http://127.0.0.1:3080/modelscope-pool
220
+ ```
221
+
222
+ (端口跟着你的 Harness 网页端口走,默认 3080。)
223
+
224
+ 页面上能做的事和原生面板一致:看三个数字和魔粒进度、看 Key 列表、刷新额度、测试全部、全部恢复、逐条禁用 / 停用 / 恢复 / 删除、批量添加 Key、切换默认模型。中英文跟随浏览器语言,右上角可手动切换。
225
+
226
+ 两道保护:
227
+
228
+ - **只认本机回环地址**。来自 `192.168.*`、`10.*` 等局域网地址的请求直接 403,令牌不会因为开了这个页面而被同网段的人看到。
229
+ - **沿用 Harness 自己的登录校验**。这个路径和 `/api/*` 一样要过宿主的 Host/Origin 校验与 cookie 凭据,所以请在你已经登录 DSH 网页的那个浏览器里打开。
230
+
231
+ 桌面端内嵌的就是同一套 Web 界面,地址相同。
232
+
188
233
  ## 多语言
189
234
 
190
235
  界面文案跟随系统语言,判定顺序:
@@ -198,7 +243,7 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
198
243
 
199
244
  ## 魔粒用量怎么算
200
245
 
201
- 魔搭按「魔粒」(magicubes)计费,每个 Key 的免费额度是 **250 魔粒**。面板上的第二张扇形图就是按这个估的:
246
+ 魔搭按「魔粒」(magicubes)计费,每个 Key 的免费额度是 **250 魔粒**。面板上的魔粒进度条(网页版是顶部那条横条)就是按这个估的:
202
247
 
203
248
  ```
204
249
  总魔粒 = Key 数量 × 250
@@ -288,6 +333,9 @@ dsh-go 里更进一步:这个插件会把自己注册成一个叫 **魔搭 Key
288
333
  **Key 一直不自动恢复?**
289
334
  看看是不是被「手动禁用」了(`manual` 标记)。手动禁用不会被自动恢复流程放开,需要点「恢复」。
290
335
 
336
+ **点「刷新额度」提示「魔搭 Key 池没有响应」?**
337
+ App 要求点按在 3 秒内返回,而刷新额度要联网逐个查所有 Key 的额度,早就超过 3 秒了。0.10.1 起这几个慢操作(刷新额度 / 测试全部 / 重新拉取模型 / 添加 Key)改成先回一个「正在刷新额度」的等待态,结果异步回来,最多 60 秒。如果你还在 0.10.0 或更早,升级即可。
338
+
291
339
  **端口被占用?**
292
340
  改 `config.json` 的 `port`,同时把客户端和供应商配置里的地址一起改掉。
293
341
 
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: [
@@ -127,6 +129,12 @@ const S = {
127
129
  trailingCurrent: ['当前', 'Current'],
128
130
  trailingUse: ['使用', 'Use'],
129
131
 
132
+ // 慢操作:点按后先回 pending,App 会显示等待
133
+ pendingRefresh: ['正在刷新额度', 'Refreshing quota'],
134
+ pendingTest: ['正在测试全部', 'Testing all'],
135
+ pendingModels: ['正在拉取模型', 'Loading models'],
136
+ pendingAdd: ['正在校验 Key', 'Checking keys'],
137
+
130
138
  // 操作反馈
131
139
  toastQuotaUpdated: ['额度已更新', 'Quota refreshed'],
132
140
  toastModelSet: ['默认模型:{model}', 'Default model: {model}'],
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,
@@ -735,11 +728,26 @@ const viewPool = (scope, caps) => {
735
728
  }
736
729
  }
737
730
 
731
+ // 点按必须在 3 秒内返回,否则 App 会提示「没有响应」。
732
+ // 刷新额度 / 测试全部 / 拉模型 / 校验 Key 都要联网,所以先回 pending,结果走延后效果。
733
+ const defer = (label, work) => ({
734
+ pending: { label, timeoutMs: 60_000 },
735
+ result: async () => {
736
+ const value = await work()
737
+ refreshPanel()
738
+ refreshStrip()
739
+ return value
740
+ },
741
+ })
742
+
738
743
  const panelActions = {
739
- async refresh(scope, event) {
744
+ refresh(scope, event) {
740
745
  const t = makeT(event?.locale)
741
- await Promise.all([refreshQuota(true), refreshCatalog(true)])
742
- return { toast: t('toastQuotaUpdated'), refresh: true }
746
+ return defer(t('pendingRefresh'), async () => {
747
+ await refreshQuota(true)
748
+ await refreshCatalog(true)
749
+ return { toast: t('toastQuotaUpdated') }
750
+ })
743
751
  },
744
752
  async 'pick-model'(scope, event) {
745
753
  const t = makeT(event?.locale)
@@ -749,12 +757,13 @@ const viewPool = (scope, caps) => {
749
757
  ? { toast: t('toastModelSet', { model }), refresh: true }
750
758
  : { toast: t('toastModelBad') }
751
759
  },
752
- async 'test-all'(scope, event) {
760
+ 'test-all'(scope, event) {
753
761
  const t = makeT(event?.locale)
754
762
  const keys = pool ? [...pool.keys] : []
755
763
  if (keys.length === 0) return { toast: t('toastNoKeys') }
756
764
  // 注意不能用 /v1/models 验 Key:那个接口是公开的,什么令牌都返回 200。
757
765
  // 真正会校验令牌的是额度接口,而且查额度不扣魔粒。
766
+ return defer(t('pendingTest'), async () => {
758
767
  const rows = await quota.refresh(
759
768
  keys.map((k) => ({ id: k.id, key: k.key, region: k.region })),
760
769
  { force: true },
@@ -781,6 +790,7 @@ const viewPool = (scope, caps) => {
781
790
  if (short > 0) parts.push(t('partShort', { n: short }))
782
791
  if (invalid > 0) parts.push(t('partInvalid', { n: invalid }))
783
792
  return { toast: t('toastTestDone', { parts: parts.join(' · ') }) }
793
+ })
784
794
  },
785
795
  'restore-all': async (scope, event) => {
786
796
  const t = makeT(event?.locale)
@@ -860,20 +870,24 @@ const viewPool = (scope, caps) => {
860
870
  })
861
871
  return { toast: t('toastDeleted', { id }), refresh: true }
862
872
  },
863
- 'add-form': async (scope, event) => {
873
+ 'add-form': (scope, event) => {
874
+ const t = makeT(event?.locale)
864
875
  const value = event?.value
865
876
  const raw = typeof value === 'string' ? value : value?.newKey ?? value?.value ?? ''
866
- const result = await addKey(raw, event?.locale)
867
- return { ...result, refresh: true }
877
+ return defer(t('pendingAdd'), async () => {
878
+ const result = await addKey(raw, event?.locale)
879
+ return { toast: result?.toast ?? t('toastNoNew') }
880
+ })
868
881
  },
869
882
  'key-back'() {
870
883
  return { open: 'panel:pool' }
871
884
  },
872
- async 'refresh-models'(scope, event) {
885
+ 'refresh-models'(scope, event) {
873
886
  const t = makeT(event?.locale)
874
- await refreshCatalog(true)
875
- refreshPanel()
876
- return { toast: t('toastModelsReloaded'), refresh: true }
887
+ return defer(t('pendingModels'), async () => {
888
+ await refreshCatalog(true)
889
+ return { toast: t('toastModelsReloaded') }
890
+ })
877
891
  },
878
892
  }
879
893
 
@@ -1037,7 +1051,133 @@ const viewPool = (scope, caps) => {
1037
1051
  })
1038
1052
 
1039
1053
  // 真正的网关只在引擎里起(检查器没有 webServer,也不会占端口)。
1040
- ctx.inject(['webServer'], () => {
1054
+ // 网页版 / 桌面版:原生面板只在 dsh-go 里出现,这里补一个同源管理页。
1055
+ ctx.inject(['webServer'], (webCtx) => {
1056
+ const webServer = webCtx?.webServer
1057
+ if (webServer && typeof webServer.register === 'function') {
1058
+ const getState = () => {
1059
+ const status = pool ? pool.status() : { total: 0, available: 0, cooling: 0, keys: [] }
1060
+ const ids = status.keys.map((k) => k.id)
1061
+ const sum = quota ? quota.sum(ids) : { available: 0, known: 0 }
1062
+ const per = 250
1063
+ const total = status.total * per
1064
+ const unknown = Math.max(0, status.total - sum.known)
1065
+ const left = sum.known > 0 ? sum.available + unknown * per : total
1066
+ return {
1067
+ total: status.total,
1068
+ available: status.available,
1069
+ cooling: status.cooling,
1070
+ magic: { total, left, used: Math.max(0, total - left) },
1071
+ model: String(config.model ?? ''),
1072
+ models: (catalog?.entries ?? [])
1073
+ .filter((entry) => isChatModel(entry.id))
1074
+ .map((entry) => ({ id: entry.id, name: entry.name || entry.id, regions: entry.regions ?? [] })),
1075
+ keys: status.keys.map((k) => {
1076
+ const state = quota?.cached(k.id)
1077
+ return {
1078
+ id: k.id,
1079
+ masked: k.masked,
1080
+ region: k.region,
1081
+ state: k.state,
1082
+ reason: k.reason ?? '',
1083
+ manual: Boolean(k.manual),
1084
+ untilAt: k.untilAt ?? 0,
1085
+ used: k.used ?? 0,
1086
+ quota: state?.ok
1087
+ ? { ok: true, available: state.available, total: state.total, frozen: state.frozen }
1088
+ : { ok: false, available: 0, total: 0, frozen: 0, error: state?.error ?? '' },
1089
+ }
1090
+ }),
1091
+ }
1092
+ }
1093
+
1094
+ const doAction = async (action, payload) => {
1095
+ const id = String(payload?.id ?? '')
1096
+ const after = () => {
1097
+ refreshPanel()
1098
+ refreshStrip()
1099
+ }
1100
+ switch (action) {
1101
+ case 'refresh':
1102
+ await refreshQuota(true)
1103
+ await refreshCatalog(true)
1104
+ after()
1105
+ return makeT()('toastQuotaUpdated')
1106
+ case 'test': {
1107
+ const keys = pool ? [...pool.keys] : []
1108
+ if (keys.length === 0) return makeT()('toastNoKeys')
1109
+ const rows = await quota.refresh(
1110
+ keys.map((k) => ({ id: k.id, key: k.key, region: k.region })),
1111
+ { force: true },
1112
+ )
1113
+ for (const [i, row] of rows.entries()) {
1114
+ const key = keys[i]
1115
+ if (row.state?.region) await pool.setRegion(key.id, row.state.region)
1116
+ if (!row.state?.ok) {
1117
+ pool.markFailure(key.id, '令牌无效', String(row.state?.error ?? '').slice(0, 80))
1118
+ continue
1119
+ }
1120
+ if (row.state.available < QUOTA_FLOOR) {
1121
+ pool.markFailure(key.id, '额度不足', `剩余 ${row.state.available} 魔粒`, nextQuotaResetAt())
1122
+ continue
1123
+ }
1124
+ pool.restore(key.id)
1125
+ }
1126
+ after()
1127
+ const t = makeT()
1128
+ const ready = rows.filter((r) => r.state?.ok && r.state.available >= QUOTA_FLOOR).length
1129
+ return t('toastTestDone', { parts: t('partOk', { ok: ready, total: keys.length }) })
1130
+ }
1131
+ case 'restoreAll':
1132
+ await pool.restoreAll()
1133
+ router.clearAll()
1134
+ after()
1135
+ return makeT()('toastRestored')
1136
+ case 'disable':
1137
+ if (!id) return makeT()('toastNoKeyId')
1138
+ pool.disable(id, '手动禁用')
1139
+ router.clearKey?.(id)
1140
+ after()
1141
+ return makeT()('toastDisabled', { id })
1142
+ case 'stop':
1143
+ if (!id) return makeT()('toastNoKeyId')
1144
+ pool.stop(id, '手动停用', nextQuotaResetAt())
1145
+ router.clearKey?.(id)
1146
+ after()
1147
+ return makeT()('toastStopped', { id })
1148
+ case 'restore':
1149
+ if (!id) return makeT()('toastNoKeyId')
1150
+ pool.restore(id)
1151
+ router.clearKey?.(id)
1152
+ after()
1153
+ return makeT()('toastRestoredOne', { id })
1154
+ case 'remove':
1155
+ if (!id) return makeT()('toastNoKeyId')
1156
+ await pool.remove(id)
1157
+ quota?.forget?.(id)
1158
+ after()
1159
+ return makeT()('toastDeleted', { id })
1160
+ case 'add': {
1161
+ const result = await addKey(String(payload?.value ?? ''), undefined)
1162
+ return result?.toast ?? makeT()('toastNoNew')
1163
+ }
1164
+ case 'setModel': {
1165
+ const ok = await selectModel(String(payload?.value ?? ''))
1166
+ return ok ? makeT()('toastModelSet', { model: payload.value }) : makeT()('toastModelBad')
1167
+ }
1168
+ default:
1169
+ throw new Error(`unknown action ${action}`)
1170
+ }
1171
+ }
1172
+
1173
+ try {
1174
+ ctx.effect(() => registerWebAdmin(webServer, { getState, doAction, logger }))
1175
+ logger?.info?.(`modelscope-pool: 网页/桌面端管理页 ${WEB_ADMIN_PATH}`)
1176
+ } catch (error) {
1177
+ logger?.warn?.(`modelscope-pool: 管理页注册失败 ${String(error?.message ?? error)}`)
1178
+ }
1179
+ }
1180
+
1041
1181
  ready
1042
1182
  .then(async () => {
1043
1183
  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.1",
4
4
  "description": "魔搭多 Key 聚合网关:无感路由(模型不变,Key 遇限流/额度不足/无权限/不存在自动换下一个);自动识别国际版/国内版令牌;单个 Key 可禁用/停用/恢复/删除/查看;全部密钥分页浏览;模型实际拉取国际版+国内版合并成简化名、按站点分组;额度低于 2 停用到次日 00:01、多 Key 轮换、限流临时失败冷却 5 分钟,并注册为 DSH 模型供应商",
5
5
  "license": "MIT",
6
6
  "author": "Yizutt",