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 +57 -9
- package/lib/i18n.js +8 -0
- package/lib/index.js +175 -35
- package/lib/provider.js +2 -2
- package/lib/proxy.js +1 -1
- package/lib/web.js +287 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# dsh-modelscope-pool
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/dsh-modelscope-pool)
|
|
3
4
|
[](LICENSE)
|
|
4
5
|
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
5
6
|
[](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
|
-
###
|
|
121
|
+
### 方式一:从 npm 安装(推荐)
|
|
120
122
|
|
|
121
123
|
```bash
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
156
|
+
装的是指向这个目录的链接,所以**目录不能删**。改完 `lib/` 里的代码需要在 App 的「引擎」页重启引擎才生效。
|
|
133
157
|
|
|
134
|
-
###
|
|
158
|
+
### 方式三:只挂到 Harness(网页版 / 桌面版)
|
|
135
159
|
|
|
136
|
-
插件本身就是一个 Cordis bundle(`package.json` 里的 `dsh.bundle.patch` 指向 `cordis.patch.yml
|
|
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 池** | 对话标签 / 侧栏 / 功能页 |
|
|
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 = '
|
|
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 =
|
|
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: '
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
caption: { $: 'data.
|
|
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('
|
|
546
|
-
{ label: t('
|
|
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('
|
|
553
|
-
{ label: t('
|
|
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
|
-
|
|
744
|
+
refresh(scope, event) {
|
|
740
745
|
const t = makeT(event?.locale)
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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':
|
|
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
|
-
|
|
867
|
-
|
|
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
|
-
|
|
885
|
+
'refresh-models'(scope, event) {
|
|
873
886
|
const t = makeT(event?.locale)
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
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
|
-
|
|
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('
|
|
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, '
|
|
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: '
|
|
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.
|
|
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",
|