dsh-modelscope-pool 0.8.0 → 0.9.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 +45 -4
- package/lib/i18n.js +231 -0
- package/lib/index.js +330 -201
- package/lib/provider.js +10 -4
- package/lib/proxy.js +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
- [安装](#安装)
|
|
20
20
|
- [配置](#配置)
|
|
21
21
|
- [界面](#界面)
|
|
22
|
+
- [多语言](#多语言)
|
|
22
23
|
- [HTTP 接口](#http-接口)
|
|
23
24
|
- [接入其它客户端](#接入其它客户端)
|
|
24
25
|
- [常见问题](#常见问题)
|
|
@@ -62,6 +63,17 @@
|
|
|
62
63
|
|
|
63
64
|
同一个模型只显示一行;请求时按当前 Key 所属站点自动换成那边的真实 id。版本号(`.1`、`-0813`、`-1017`)是模型身份的一部分,**原样保留**。
|
|
64
65
|
|
|
66
|
+
### 界面跟随系统语言
|
|
67
|
+
|
|
68
|
+
面板、按钮、提示、状态条、模型页里的站点后缀都跟随 App 的语言:
|
|
69
|
+
|
|
70
|
+
| 语言 | 站点叫法 |
|
|
71
|
+
|---|---|
|
|
72
|
+
| 中文 | 国际版 / 国内版 |
|
|
73
|
+
| 其他语言 | International / China |
|
|
74
|
+
|
|
75
|
+
中文以外的语言不会出现「Domestic」「Mainland」这类别扭说法。存盘的 `keys.json`、`cooldowns.json` 里仍然是语言无关的常量,换语言不会动到数据。
|
|
76
|
+
|
|
65
77
|
### 额度保护
|
|
66
78
|
|
|
67
79
|
- 剩余魔粒(ModelScope 的额度单位)**低于 2** 时,Key 自动停用到次日 00:01,避免无谓的 `429`。
|
|
@@ -165,7 +177,7 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
|
|
|
165
177
|
|
|
166
178
|
| 面板 | 位置 | 内容 |
|
|
167
179
|
|---|---|---|
|
|
168
|
-
| **魔搭 Key 池** | 对话标签 / 侧栏 / 功能页 | 可用 / 冷却 /
|
|
180
|
+
| **魔搭 Key 池** | 对话标签 / 侧栏 / 功能页 | 可用 / 冷却 / 魔粒三个数字、两张扇形图(Key 状态分布 + 魔粒用量)、Key 列表(分页)、刷新额度、测试全部、全部恢复、添加 Key |
|
|
169
181
|
| **Key 详情** | 侧栏 / 功能页 | 点列表任意一行进来:完整令牌、站点、额度明细、状态、失败原因,以及禁用 / 停用 / 恢复 / 删除(删除需连点两次确认) |
|
|
170
182
|
| **魔搭模型** | 对话标签 / 侧栏 / 功能页 | 按「国际版 / 国内版 / 双站」分组的模型列表,点一下设为默认,底部可重新拉取 |
|
|
171
183
|
|
|
@@ -173,6 +185,29 @@ dsh plugin --profile web add /path/to/dsh-modelscope-pool
|
|
|
173
185
|
|
|
174
186
|
---
|
|
175
187
|
|
|
188
|
+
## 多语言
|
|
189
|
+
|
|
190
|
+
界面文案跟随系统语言,判定顺序:
|
|
191
|
+
|
|
192
|
+
1. App 传下来的 `locale`(面板视图、数据推送、按钮回调都带);
|
|
193
|
+
2. 没有时用引擎进程的 `Intl.DateTimeFormat().resolvedOptions().locale`;
|
|
194
|
+
3. 再没有就用 `LANG` 环境变量;
|
|
195
|
+
4. 都没有则按中文显示。
|
|
196
|
+
|
|
197
|
+
要加一门语言,只改 `lib/i18n.js`:`S` 里每一条都是 `[中文, 英文]`,加第三列即可(`makeT` 里的 `idx` 也要跟着扩展)。
|
|
198
|
+
|
|
199
|
+
## 魔粒用量怎么算
|
|
200
|
+
|
|
201
|
+
魔搭按「魔粒」(magicubes)计费,每个 Key 的免费额度是 **250 魔粒**。面板上的第二张扇形图就是按这个估的:
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
总魔粒 = Key 数量 × 250
|
|
205
|
+
剩余 = 各 Key 剩余之和(还没查到的 Key 按满额 250 计)
|
|
206
|
+
已用 = 总魔粒 − 剩余
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
> 这是**估算**,不是官方账单。还没刷新过额度的 Key 会被当成满额,所以「已用」只会偏小、不会虚高。要准确数字就点「刷新额度」,或直接看每行 Key 的剩余魔粒。
|
|
210
|
+
|
|
176
211
|
## HTTP 接口
|
|
177
212
|
|
|
178
213
|
全部为 OpenAI 兼容格式。
|
|
@@ -296,8 +331,14 @@ node "$DSH_HOME/skills/dsh-go-plugin/kit/bin/check.mjs" . --load
|
|
|
296
331
|
|
|
297
332
|
---
|
|
298
333
|
|
|
299
|
-
##
|
|
334
|
+
## 声明
|
|
300
335
|
|
|
301
|
-
|
|
336
|
+
- 本项目与**魔搭(ModelScope)官方无关**,不是官方客户端,也未获官方认可或背书。
|
|
337
|
+
- 只是跑在本机的一个客户端侧聚合工具:所有请求仍由你自己的 Key 直接发往魔搭官方接口,作者不经手、不中转任何流量。
|
|
338
|
+
- 「魔粒」额度、站点归属、模型列表都以魔搭官方返回为准;本项目里的 250 魔粒/Key 是**估算值**,仅用于界面展示,不构成任何额度承诺。
|
|
339
|
+
- 请自行阅读并遵守 [ModelScope 服务条款](https://modelscope.cn) 与各模型自己的许可条款。因违反条款、超额调用或令牌泄露造成的后果由使用者自负。
|
|
340
|
+
- 软件按 MIT 许可「按原样」提供,不附带任何明示或默示担保。
|
|
341
|
+
|
|
342
|
+
## License
|
|
302
343
|
|
|
303
|
-
|
|
344
|
+
[MIT](LICENSE) © 2026 Yizutt
|
package/lib/i18n.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 界面多语言:跟随 App 的语言(`caps.locale` / `event.locale`),缺省按系统语言判断。
|
|
3
|
+
*
|
|
4
|
+
* 约定:中文用「国际版 / 国内版」,其他语言用「International / China」。
|
|
5
|
+
* 站点名、失败原因这类存盘的字符串保持中文常量,显示时再翻译,
|
|
6
|
+
* 这样 keys.json / cooldowns.json 的数据不受语言影响。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const S = {
|
|
10
|
+
// 面板标题
|
|
11
|
+
poolTitle: ['魔搭 Key 池', 'ModelScope keys'],
|
|
12
|
+
keyPanelTitle: ['Key 详情', 'Key detail'],
|
|
13
|
+
modelsPanelTitle: ['魔搭模型', 'ModelScope models'],
|
|
14
|
+
|
|
15
|
+
// 概览
|
|
16
|
+
metricReady: ['可用', 'Ready'],
|
|
17
|
+
metricCooling: ['冷却', 'Cooling'],
|
|
18
|
+
metricMagicubes: ['魔粒', 'Magicubes'],
|
|
19
|
+
|
|
20
|
+
// 按钮
|
|
21
|
+
btnPrev: ['上一页', 'Prev'],
|
|
22
|
+
btnNext: ['下一页', 'Next'],
|
|
23
|
+
btnRefresh: ['刷新额度', 'Refresh quota'],
|
|
24
|
+
btnTestAll: ['测试全部', 'Test all'],
|
|
25
|
+
btnRestoreAll: ['全部恢复', 'Restore all'],
|
|
26
|
+
btnDisable: ['禁用', 'Disable'],
|
|
27
|
+
btnStop: ['停用', 'Pause'],
|
|
28
|
+
btnRestore: ['恢复', 'Restore'],
|
|
29
|
+
btnDelete: ['删除', 'Delete'],
|
|
30
|
+
btnBack: ['返回 Key 列表', 'Back to keys'],
|
|
31
|
+
btnReloadModels: ['重新拉取模型', 'Reload models'],
|
|
32
|
+
formSubmit: ['添加', 'Add'],
|
|
33
|
+
fieldLabel: ['添加 Key', 'Add key'],
|
|
34
|
+
fieldPlaceholder: ['粘贴 ms- 开头的令牌,可多行', 'Paste ms- tokens, one per line'],
|
|
35
|
+
|
|
36
|
+
// 提示
|
|
37
|
+
hintNoKeys: [
|
|
38
|
+
'还没有 Key:在魔搭复制令牌(ms- 开头)后点输入框上方的「添加 Key」,也可以在下面直接填。',
|
|
39
|
+
'No keys yet. Copy a token that starts with ms- on ModelScope, then tap "Add key" above the composer — or paste it below.',
|
|
40
|
+
],
|
|
41
|
+
hintCooling: [
|
|
42
|
+
'无感切换已开:剩余魔粒低于 2 的 Key 已自动停用到次日 00:01,避免撞 429。到点自动恢复。',
|
|
43
|
+
'Silent rotation is on. Keys with fewer than 2 magicubes are paused until 00:01 next day to avoid 429s, and resume automatically.',
|
|
44
|
+
],
|
|
45
|
+
hintModel: [
|
|
46
|
+
'无感切换已开:模型始终是 {model},Key 遇到限流、额度不足、没权限会自动换下一个;魔粒低于 2 的 Key 会提前停用。',
|
|
47
|
+
'Silent rotation is on. The model stays {model}; on rate limits, exhausted quota or missing permission the gateway moves to the next key. Keys under 2 magicubes are paused early.',
|
|
48
|
+
],
|
|
49
|
+
hintIdle: [
|
|
50
|
+
'点「刷新额度」看剩余魔粒;点下面的模型可以设为默认。',
|
|
51
|
+
'Tap "Refresh quota" to check remaining magicubes; tap a model below to set it as default.',
|
|
52
|
+
],
|
|
53
|
+
routed: [
|
|
54
|
+
'已记下 {n} 个「Key×模型」的失败组合(5 分钟内自动跳过)。',
|
|
55
|
+
'{n} key/model failures remembered and skipped for 5 minutes.',
|
|
56
|
+
],
|
|
57
|
+
hintKeyDetail: [
|
|
58
|
+
'「禁用」= 一直停到点恢复;「停用」= 停到北京时间次日 00:01;「删除」会把令牌从本机文件里去掉。',
|
|
59
|
+
'"Disable" keeps the key off until you restore it; "Pause" stops it until 00:01 next day (Beijing time); "Delete" removes the token from this device.',
|
|
60
|
+
],
|
|
61
|
+
hintKeyFallback: [
|
|
62
|
+
'回到「魔搭 Key 池」面板,点列表里任意一行就会选中它。',
|
|
63
|
+
'Go back to the key list and tap a row to select it.',
|
|
64
|
+
],
|
|
65
|
+
|
|
66
|
+
// 扇形图
|
|
67
|
+
bucketReady: ['可用', 'Ready'],
|
|
68
|
+
bucketLow: ['额度不足', 'Out of quota'],
|
|
69
|
+
bucketInvalid: ['令牌无效', 'Invalid token'],
|
|
70
|
+
bucketOther: ['其他冷却', 'Cooling (other)'],
|
|
71
|
+
unitKey: ['个', ''],
|
|
72
|
+
captionNoKeys: ['还没有 Key', 'No keys yet'],
|
|
73
|
+
shareTitle: ['Key 状态分布', 'Key status'],
|
|
74
|
+
magicLeft: ['剩余', 'Remaining'],
|
|
75
|
+
magicUsed: ['已用', 'Spent'],
|
|
76
|
+
magicCaption: [
|
|
77
|
+
'共 {total} 魔粒({keys} 个 Key × {per});剩余 {left},已用 {used}。未查到的 Key 按满额计。',
|
|
78
|
+
'{total} magicubes total ({keys} keys × {per}); {left} left, {used} spent. Keys not checked yet count as full.',
|
|
79
|
+
],
|
|
80
|
+
magicCaptionEmpty: ['还没有 Key,估不出总魔粒', 'No keys yet, so the magicube total is unknown'],
|
|
81
|
+
captionTotal: [
|
|
82
|
+
'共 {total} 个 Key:可用 {ok},额度不足 {low},其余冷却中',
|
|
83
|
+
'{total} keys: {ok} ready, {low} out of quota, the rest cooling down',
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
// Key 列表
|
|
87
|
+
keyTitleEmpty: ['Key 池(还没有 Key)', 'Key pool (empty)'],
|
|
88
|
+
keyTitlePage: [
|
|
89
|
+
'Key 池 {total} 个 · 第 {page}/{pages} 页(点一行看详情)',
|
|
90
|
+
'{total} keys · page {page}/{pages} (tap a row for detail)',
|
|
91
|
+
],
|
|
92
|
+
keyPager: ['第 {page}/{pages} 页 · 每页 {size} 个', 'Page {page}/{pages} · {size} per page'],
|
|
93
|
+
usedTimes: ['用过 {n} 次', 'used {n}×'],
|
|
94
|
+
quotaLeft: ['额度 {n} 魔粒', '{n} magicubes'],
|
|
95
|
+
quotaUnknown: ['额度未知({error})', 'quota unknown ({error})'],
|
|
96
|
+
quotaUnchecked: ['额度未查', 'quota not checked'],
|
|
97
|
+
selectedMark: ['(已选中)', ' (selected)'],
|
|
98
|
+
failTimes: ['失败 {n} 次', '{n} failed'],
|
|
99
|
+
stateReady: ['可用', 'Ready'],
|
|
100
|
+
stateDisabled: ['已禁用', 'Disabled'],
|
|
101
|
+
coolingUntil: ['冷却至 {time}', 'cooling until {time}'],
|
|
102
|
+
coolingLeft: ['冷却 {time}', 'cooling {time}'],
|
|
103
|
+
|
|
104
|
+
// Key 详情
|
|
105
|
+
focusEmpty: ['还没选中 Key(回「Key 列表」点一行)', 'No key selected — tap a row in the key list'],
|
|
106
|
+
rowToken: ['完整令牌', 'Full token'],
|
|
107
|
+
rowSite: ['站点', 'Site'],
|
|
108
|
+
rowQuota: ['额度', 'Quota'],
|
|
109
|
+
rowState: ['状态', 'State'],
|
|
110
|
+
rowFails: ['失败', 'Failures'],
|
|
111
|
+
quotaDetail: ['{n} 魔粒(总额 {total},冻结 {frozen})', '{n} magicubes (total {total}, frozen {frozen})'],
|
|
112
|
+
quotaFailed: ['查询失败:{error}', 'lookup failed: {error}'],
|
|
113
|
+
quotaNever: ['还没查过', 'not checked yet'],
|
|
114
|
+
stateManual: ['已禁用(点「恢复」放开)', 'Disabled — tap "Restore" to re-enable'],
|
|
115
|
+
failsDetail: ['{n} 次 · {error}{used}', '{n}× · {error}{used}'],
|
|
116
|
+
usedSuffix: [' · 用过 {n} 次', ' · used {n}×'],
|
|
117
|
+
none: ['无', 'none'],
|
|
118
|
+
|
|
119
|
+
// 模型列表
|
|
120
|
+
modelsTitleEmpty: ['模型(还没读到,点下面「重新拉取模型」)', 'Models (not loaded — tap "Reload models")'],
|
|
121
|
+
modelsTitleCount: ['{n} 个模型 · 当前 {current}', '{n} models · current {current}'],
|
|
122
|
+
modelNotPicked: ['未选', 'none'],
|
|
123
|
+
groupCount: ['{n} 个模型', '{n} models'],
|
|
124
|
+
groupBoth: ['国际版 + 国内版都有', 'On both sites'],
|
|
125
|
+
groupUnknown: ['站点未知', 'Unknown site'],
|
|
126
|
+
currentDefault: ['当前默认', 'current default'],
|
|
127
|
+
trailingCurrent: ['当前', 'Current'],
|
|
128
|
+
trailingUse: ['使用', 'Use'],
|
|
129
|
+
|
|
130
|
+
// 操作反馈
|
|
131
|
+
toastQuotaUpdated: ['额度已更新', 'Quota refreshed'],
|
|
132
|
+
toastModelSet: ['默认模型:{model}', 'Default model: {model}'],
|
|
133
|
+
toastModelBad: ['模型名不可用', 'That model name is not usable'],
|
|
134
|
+
toastNoKeys: ['池里还没有 Key', 'The pool has no keys'],
|
|
135
|
+
toastTestDone: ['测试完成:{parts}', 'Test done: {parts}'],
|
|
136
|
+
partOk: ['{ok}/{total} 个 Key 可用', '{ok}/{total} keys ready'],
|
|
137
|
+
partShort: ['{n} 个额度不足', '{n} out of quota'],
|
|
138
|
+
partInvalid: ['{n} 个令牌无效', '{n} invalid'],
|
|
139
|
+
toastRestored: ['已把全部 Key 恢复为可用', 'All keys restored'],
|
|
140
|
+
toastNoPick: ['先在上面点一行选中一个 Key', 'Select a key from the list first'],
|
|
141
|
+
toastNoKeyId: ['没认出这个 Key', 'Could not identify that key'],
|
|
142
|
+
toastDisabled: ['{id} 已禁用,点「恢复」才放开', '{id} disabled — tap "Restore" to re-enable'],
|
|
143
|
+
toastStopped: ['{id} 停用到北京时间次日 00:01', '{id} paused until 00:01 next day (Beijing time)'],
|
|
144
|
+
toastRestoredOne: ['{id} 已恢复可用', '{id} is ready again'],
|
|
145
|
+
toastDeleteArm: ['再点一次「删除」确认删掉 {id}', 'Tap "Delete" once more to remove {id}'],
|
|
146
|
+
toastDeleted: ['{id} 已从本机删除', '{id} removed from this device'],
|
|
147
|
+
toastFirstPage: ['已经是第一页了', 'Already on the first page'],
|
|
148
|
+
toastLastPage: ['已经是最后一页了', 'Already on the last page'],
|
|
149
|
+
toastModelsReloaded: ['模型列表已重新拉取', 'Model list reloaded'],
|
|
150
|
+
toastNoClipboard: ['剪贴板里没有文字', 'Nothing to read in the clipboard'],
|
|
151
|
+
toastNoInput: ['没读到 Key', 'No key found in the input'],
|
|
152
|
+
toastNotMs: ['这不像魔搭令牌(应以 ms- 开头)', 'That does not look like a ModelScope token (should start with ms-)'],
|
|
153
|
+
toastNoNew: ['没有新增 Key', 'No new keys added'],
|
|
154
|
+
|
|
155
|
+
// 添加结果
|
|
156
|
+
addDone: ['新增 {n} 个', '{n} added'],
|
|
157
|
+
addIntl: ['国际版 {n}', 'International {n}'],
|
|
158
|
+
addCn: ['国内版 {n}', 'China {n}'],
|
|
159
|
+
addLow: ['{n} 个额度不足已停用', '{n} out of quota, paused'],
|
|
160
|
+
addInvalid: ['{n} 个令牌无效', '{n} invalid'],
|
|
161
|
+
addSkipped: ['{n} 个重复跳过', '{n} duplicates skipped'],
|
|
162
|
+
joinArrow: ['|', ' · '],
|
|
163
|
+
|
|
164
|
+
// 状态条
|
|
165
|
+
stripEmpty: ['魔搭 未配置', 'ModelScope · none'],
|
|
166
|
+
stripText: ['魔搭 {ok}/{total} · {quota}', 'ModelScope {ok}/{total} · {quota}'],
|
|
167
|
+
stripQuota: ['{n} 魔粒', '{n} left'],
|
|
168
|
+
stripQuotaUnknown: ['额度?', 'quota?'],
|
|
169
|
+
|
|
170
|
+
// 站点与原因
|
|
171
|
+
regionAi: ['国际版', 'International'],
|
|
172
|
+
regionCn: ['国内版', 'China'],
|
|
173
|
+
regionUnknown: ['版本未知', 'Unknown site'],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const REASONS = {
|
|
177
|
+
额度不足: ['额度不足', 'Out of quota'],
|
|
178
|
+
额度用尽: ['额度用尽', 'Quota exhausted'],
|
|
179
|
+
令牌无效: ['令牌无效', 'Invalid token'],
|
|
180
|
+
被限流: ['被限流', 'Rate limited'],
|
|
181
|
+
模型名不可用: ['模型名不可用', 'Model unavailable'],
|
|
182
|
+
上游超时: ['上游超时', 'Upstream timeout'],
|
|
183
|
+
读取请求失败: ['读取请求失败', 'Request failed'],
|
|
184
|
+
冷却中: ['冷却中', 'Cooling down'],
|
|
185
|
+
手动禁用: ['手动禁用', 'Disabled manually'],
|
|
186
|
+
手动停用: ['手动停用', 'Paused manually'],
|
|
187
|
+
其他冷却: ['其他冷却', 'Cooling (other)'],
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 系统语言判断:App 没给 locale 时的兜底。 */
|
|
191
|
+
export function detectLang() {
|
|
192
|
+
try {
|
|
193
|
+
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
|
194
|
+
if (locale) return String(locale).toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
195
|
+
} catch {}
|
|
196
|
+
const env = String(process.env.LC_ALL ?? process.env.LC_MESSAGES ?? process.env.LANG ?? '')
|
|
197
|
+
if (env) return env.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
198
|
+
return 'zh'
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `zh-Hans-CN` / `en-US` / 空 → `'zh'` | `'en'`。 */
|
|
202
|
+
export function pickLang(locale) {
|
|
203
|
+
const raw = String(locale ?? '').trim()
|
|
204
|
+
if (!raw) return detectLang()
|
|
205
|
+
return raw.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 造一个翻译函数:`t('key', { n: 3 })`,另带 `t.lang`、`t.region(r)`、`t.reason(r)`。
|
|
210
|
+
*/
|
|
211
|
+
export function makeT(locale) {
|
|
212
|
+
const lang = pickLang(locale)
|
|
213
|
+
const idx = lang === 'en' ? 1 : 0
|
|
214
|
+
const t = (key, vars) => {
|
|
215
|
+
const row = S[key]
|
|
216
|
+
let text = row ? row[idx] : key
|
|
217
|
+
if (vars) {
|
|
218
|
+
for (const k of Object.keys(vars)) text = text.split(`{${k}}`).join(String(vars[k]))
|
|
219
|
+
}
|
|
220
|
+
return text
|
|
221
|
+
}
|
|
222
|
+
t.lang = lang
|
|
223
|
+
t.region = (region) =>
|
|
224
|
+
region === 'ai' ? t('regionAi') : region === 'cn' ? t('regionCn') : t('regionUnknown')
|
|
225
|
+
t.reason = (reason) => {
|
|
226
|
+
const text = String(reason ?? '')
|
|
227
|
+
const row = REASONS[text]
|
|
228
|
+
return row ? row[idx] : text
|
|
229
|
+
}
|
|
230
|
+
return t
|
|
231
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -11,8 +11,9 @@ import { registerPoolProvider, isChatModel } from './provider.js'
|
|
|
11
11
|
import { createProxy, quotaLike } from './proxy.js'
|
|
12
12
|
import { createQuotaBook, REGIONS } from './quota.js'
|
|
13
13
|
import { createRouter } from './route.js'
|
|
14
|
+
import { makeT } from './i18n.js'
|
|
14
15
|
|
|
15
|
-
export const name = '
|
|
16
|
+
export const name = 'local-modelscope-pool'
|
|
16
17
|
|
|
17
18
|
// 「模型」页只有在这个插件的设置命名空间存在时才显示「魔搭 Key 池」这一行,
|
|
18
19
|
// 而命名空间来自这里导出的 Config schema,键就是 loader 里的条目 id。
|
|
@@ -268,23 +269,26 @@ export function apply(ctx) {
|
|
|
268
269
|
const pickedKey = () => (keyFocus && pool?.keys.some((k) => k.id === keyFocus) ? keyFocus : '')
|
|
269
270
|
|
|
270
271
|
|
|
271
|
-
|
|
272
|
-
const modelRowOf = (entry, current) => {
|
|
272
|
+
const modelRowOf = (entry, current, t) => {
|
|
273
273
|
const id = String(entry?.id ?? '')
|
|
274
274
|
const isCurrent = id === current
|
|
275
275
|
const regions = entry?.regions ?? []
|
|
276
|
-
const where =
|
|
276
|
+
const where =
|
|
277
|
+
regions.length > 1
|
|
278
|
+
? `${t('regionAi')} + ${t('regionCn')}`
|
|
279
|
+
: t.region(regions[0])
|
|
277
280
|
return {
|
|
278
281
|
title: entry?.name || id,
|
|
279
|
-
subtitle: isCurrent ?
|
|
280
|
-
trailing: isCurrent ? '
|
|
282
|
+
subtitle: isCurrent ? `${t('currentDefault')} · ${where}` : where,
|
|
283
|
+
trailing: isCurrent ? t('trailingCurrent') : t('trailingUse'),
|
|
281
284
|
icon: isCurrent ? 'check' : 'model',
|
|
282
285
|
tone: isCurrent ? 'good' : undefined,
|
|
283
286
|
action: { id: 'pick-model', args: { model: id } },
|
|
284
287
|
}
|
|
285
288
|
}
|
|
286
289
|
|
|
287
|
-
function buildData() {
|
|
290
|
+
function buildData(locale) {
|
|
291
|
+
const t = makeT(locale)
|
|
288
292
|
const s = pool ? pool.status() : { total: 0, available: 0, cooling: 0, keys: [], stats: { requests: 0 } }
|
|
289
293
|
const ids = s.keys.map((k) => k.id)
|
|
290
294
|
const q = quota ? quota.sum(ids) : { available: 0, known: 0 }
|
|
@@ -293,34 +297,73 @@ export function apply(ctx) {
|
|
|
293
297
|
const currentModel = modelEntry?.id ?? model
|
|
294
298
|
const models = (catalog?.entries ?? []).filter((entry) => isChatModel(entry.id))
|
|
295
299
|
const routing = router.snapshot()
|
|
296
|
-
const routed =
|
|
300
|
+
const routed =
|
|
301
|
+
routing.blockedPairs > 0 ? ` ${t('routed', { n: routing.blockedPairs })}` : ''
|
|
297
302
|
const hint =
|
|
298
303
|
s.total === 0
|
|
299
|
-
? '
|
|
304
|
+
? t('hintNoKeys')
|
|
300
305
|
: s.cooling > 0
|
|
301
|
-
?
|
|
306
|
+
? `${t('hintCooling')}${routed}`
|
|
302
307
|
: model
|
|
303
|
-
?
|
|
304
|
-
:
|
|
308
|
+
? `${t('hintModel', { model })}${routed}`
|
|
309
|
+
: `${t('hintIdle')}${routed}`
|
|
310
|
+
|
|
305
311
|
// 扇形图:Key 池的状态分布(可用 / 额度不足 / 令牌无效 / 其他冷却)
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
312
|
+
const order = [
|
|
313
|
+
['ready', t('bucketReady'), (k) => k.state === 'ok'],
|
|
314
|
+
[
|
|
315
|
+
'low',
|
|
316
|
+
t('bucketLow'),
|
|
317
|
+
(k) => k.reason === '额度不足' || k.reason === '额度用尽',
|
|
318
|
+
],
|
|
319
|
+
['invalid', t('bucketInvalid'), (k) => k.reason === '令牌无效'],
|
|
320
|
+
[
|
|
321
|
+
'other',
|
|
322
|
+
t('bucketOther'),
|
|
323
|
+
(k) => k.state !== 'ok' && !['额度不足', '额度用尽', '令牌无效'].includes(k.reason),
|
|
324
|
+
],
|
|
325
|
+
]
|
|
326
|
+
const counts = order.map(([key, label, test]) => ({
|
|
327
|
+
key,
|
|
328
|
+
label,
|
|
329
|
+
value: s.keys.filter(test).length,
|
|
330
|
+
}))
|
|
331
|
+
const live = counts.filter((row) => row.value > 0)
|
|
332
|
+
const lowCount = counts.find((row) => row.key === 'low')?.value ?? 0
|
|
333
|
+
const readyCount = counts.find((row) => row.key === 'ready')?.value ?? 0
|
|
315
334
|
const share = {
|
|
316
335
|
kind: 'donut',
|
|
317
|
-
categories:
|
|
318
|
-
series: [{ name: 'Key', values:
|
|
319
|
-
unit: '
|
|
336
|
+
categories: live.map((row) => row.label),
|
|
337
|
+
series: [{ name: 'Key', values: live.map((row) => row.value) }],
|
|
338
|
+
unit: t('unitKey'),
|
|
339
|
+
caption:
|
|
340
|
+
s.total === 0
|
|
341
|
+
? t('captionNoKeys')
|
|
342
|
+
: t('captionTotal', { total: s.total, ok: readyCount, low: lowCount }),
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 魔粒估算:每个 Key 按满额 250 计,剩余取各 Key 剩余之和(没查到的按满额算,不凭空记「已用」)。
|
|
346
|
+
const MAGIC_PER_KEY = 250
|
|
347
|
+
const magicTotal = s.total * MAGIC_PER_KEY
|
|
348
|
+
const magicUnknown = Math.max(0, s.total - q.known)
|
|
349
|
+
const magicLeft = q.known > 0 ? q.available + magicUnknown * MAGIC_PER_KEY : magicTotal
|
|
350
|
+
const magicUsed = Math.max(0, magicTotal - magicLeft)
|
|
351
|
+
const magic = {
|
|
352
|
+
total: magicTotal,
|
|
353
|
+
left: magicLeft,
|
|
354
|
+
used: magicUsed,
|
|
320
355
|
caption:
|
|
321
356
|
s.total === 0
|
|
322
|
-
? '
|
|
323
|
-
:
|
|
357
|
+
? t('magicCaptionEmpty')
|
|
358
|
+
: t('magicCaption', {
|
|
359
|
+
total: magicTotal,
|
|
360
|
+
keys: s.total,
|
|
361
|
+
per: MAGIC_PER_KEY,
|
|
362
|
+
left: magicLeft,
|
|
363
|
+
used: magicUsed,
|
|
364
|
+
}),
|
|
365
|
+
categories: [t('magicLeft'), t('magicUsed')],
|
|
366
|
+
series: [{ name: 'Magicubes', values: [magicLeft, magicUsed] }],
|
|
324
367
|
}
|
|
325
368
|
|
|
326
369
|
const pageCount = Math.max(1, Math.ceil(s.total / KEYS_PER_PAGE))
|
|
@@ -336,84 +379,123 @@ export function apply(ctx) {
|
|
|
336
379
|
? {
|
|
337
380
|
title: `${focusRow.id} · ${focusRow.masked}`,
|
|
338
381
|
rows: [
|
|
339
|
-
['
|
|
340
|
-
['
|
|
382
|
+
[t('rowToken'), focusRaw?.key ?? '—'],
|
|
383
|
+
[t('rowSite'), t.region(focusRow.region)],
|
|
341
384
|
[
|
|
342
|
-
'
|
|
385
|
+
t('rowQuota'),
|
|
343
386
|
focusState?.ok
|
|
344
|
-
?
|
|
387
|
+
? t('quotaDetail', {
|
|
388
|
+
n: focusState.available,
|
|
389
|
+
total: focusState.total,
|
|
390
|
+
frozen: focusState.frozen,
|
|
391
|
+
})
|
|
345
392
|
: focusState
|
|
346
|
-
?
|
|
347
|
-
: '
|
|
393
|
+
? t('quotaFailed', { error: focusState.error })
|
|
394
|
+
: t('quotaNever'),
|
|
348
395
|
],
|
|
349
396
|
[
|
|
350
|
-
'
|
|
397
|
+
t('rowState'),
|
|
351
398
|
focusRow.state === 'ok'
|
|
352
|
-
? '
|
|
399
|
+
? t('stateReady')
|
|
353
400
|
: focusRow.manual
|
|
354
|
-
?
|
|
355
|
-
: `${focusRow.reason || '
|
|
401
|
+
? t('stateManual')
|
|
402
|
+
: `${t.reason(focusRow.reason) || t('hintKeyFallback')}${
|
|
403
|
+
focusRow.untilAt ? ` · ${t('coolingUntil', { time: fmtCN(focusRow.untilAt) })}` : ''
|
|
404
|
+
}`,
|
|
405
|
+
],
|
|
406
|
+
[
|
|
407
|
+
t('rowFails'),
|
|
408
|
+
t('failsDetail', {
|
|
409
|
+
n: focusRow.fails,
|
|
410
|
+
error: focusRow.lastError || t('none'),
|
|
411
|
+
used: focusRow.used ? t('usedSuffix', { n: focusRow.used }) : '',
|
|
412
|
+
}),
|
|
356
413
|
],
|
|
357
|
-
['失败', `${focusRow.fails} 次 · ${focusRow.lastError || '无'}${focusRow.used ? ` · 用过 ${focusRow.used} 次` : ''}`],
|
|
358
414
|
],
|
|
359
|
-
hint: '
|
|
415
|
+
hint: t('hintKeyDetail'),
|
|
360
416
|
}
|
|
361
|
-
: { title: '
|
|
417
|
+
: { title: t('focusEmpty'), rows: [], hint: '' }
|
|
362
418
|
|
|
363
419
|
// 模型列表按站点分组:国际版 → 国内版 → 双站,名字用短名。
|
|
364
420
|
const modelRows = []
|
|
365
|
-
const
|
|
366
|
-
for (const region of modelOrder) {
|
|
421
|
+
for (const region of ['ai', 'cn']) {
|
|
367
422
|
const group = models.filter((entry) => entry.regions?.length === 1 && entry.regions[0] === region)
|
|
368
423
|
if (group.length === 0) continue
|
|
369
|
-
modelRows.push({
|
|
370
|
-
|
|
424
|
+
modelRows.push({
|
|
425
|
+
title: t.region(region),
|
|
426
|
+
icon: 'globe',
|
|
427
|
+
tone: 'info',
|
|
428
|
+
subtitle: t('groupCount', { n: group.length }),
|
|
429
|
+
})
|
|
430
|
+
for (const entry of group) modelRows.push(modelRowOf(entry, currentModel, t))
|
|
371
431
|
}
|
|
372
432
|
const both = models.filter((entry) => (entry.regions?.length ?? 0) > 1)
|
|
373
433
|
if (both.length > 0) {
|
|
374
|
-
modelRows.push({
|
|
375
|
-
|
|
434
|
+
modelRows.push({
|
|
435
|
+
title: t('groupBoth'),
|
|
436
|
+
icon: 'globe',
|
|
437
|
+
tone: 'info',
|
|
438
|
+
subtitle: t('groupCount', { n: both.length }),
|
|
439
|
+
})
|
|
440
|
+
for (const entry of both) modelRows.push(modelRowOf(entry, currentModel, t))
|
|
376
441
|
}
|
|
377
442
|
const unknownRegion = models.filter((entry) => (entry.regions?.length ?? 0) === 0)
|
|
378
443
|
if (unknownRegion.length > 0) {
|
|
379
|
-
modelRows.push({
|
|
380
|
-
|
|
444
|
+
modelRows.push({
|
|
445
|
+
title: t('groupUnknown'),
|
|
446
|
+
icon: 'globe',
|
|
447
|
+
tone: 'info',
|
|
448
|
+
subtitle: t('groupCount', { n: unknownRegion.length }),
|
|
449
|
+
})
|
|
450
|
+
for (const entry of unknownRegion) modelRows.push(modelRowOf(entry, currentModel, t))
|
|
381
451
|
}
|
|
382
452
|
|
|
383
453
|
return {
|
|
384
454
|
available: s.available,
|
|
385
455
|
cooling: s.cooling,
|
|
386
456
|
share,
|
|
457
|
+
shareTitle: t('shareTitle'),
|
|
387
458
|
magicubes: q.known > 0 ? String(q.available) : '—',
|
|
459
|
+
magic,
|
|
388
460
|
endpoint: endpoint(),
|
|
389
461
|
hint,
|
|
390
462
|
modelTitle:
|
|
391
463
|
models.length === 0
|
|
392
|
-
? '
|
|
393
|
-
:
|
|
464
|
+
? t('modelsTitleEmpty')
|
|
465
|
+
: t('modelsTitleCount', {
|
|
466
|
+
n: models.length,
|
|
467
|
+
current: modelEntry?.name ?? model ?? t('modelNotPicked'),
|
|
468
|
+
}),
|
|
394
469
|
keyTitle:
|
|
395
470
|
s.total === 0
|
|
396
|
-
? '
|
|
397
|
-
:
|
|
398
|
-
keyPager:
|
|
471
|
+
? t('keyTitleEmpty')
|
|
472
|
+
: t('keyTitlePage', { total: s.total, page: pageNo, pages: pageCount }),
|
|
473
|
+
keyPager:
|
|
474
|
+
s.total > KEYS_PER_PAGE
|
|
475
|
+
? t('keyPager', { page: pageNo, pages: pageCount, size: KEYS_PER_PAGE })
|
|
476
|
+
: '',
|
|
399
477
|
keys: pageKeys.map((k) => {
|
|
400
478
|
const state = quota?.cached(k.id)
|
|
401
|
-
const used =
|
|
402
|
-
const money = state?.ok
|
|
403
|
-
|
|
479
|
+
const used = t('usedTimes', { n: k.used })
|
|
480
|
+
const money = state?.ok
|
|
481
|
+
? t('quotaLeft', { n: state.available })
|
|
482
|
+
: state && !state.ok
|
|
483
|
+
? t('quotaUnknown', { error: String(state.error).slice(0, 24) })
|
|
484
|
+
: t('quotaUnchecked')
|
|
485
|
+
const region = t.region(k.region)
|
|
404
486
|
return {
|
|
405
|
-
title: `${k.id} · ${k.masked}${k.id === keyFocus ? '
|
|
487
|
+
title: `${k.id} · ${k.masked}${k.id === keyFocus ? t('selectedMark') : ''}`,
|
|
406
488
|
subtitle: k.reason
|
|
407
|
-
? `${k.reason}
|
|
489
|
+
? `${t.reason(k.reason)}(${t('failTimes', { n: k.fails })})· ${money} · ${region}`
|
|
408
490
|
: `${money} · ${region} · ${used}`,
|
|
409
491
|
trailing:
|
|
410
492
|
k.state === 'ok'
|
|
411
|
-
? '
|
|
493
|
+
? t('stateReady')
|
|
412
494
|
: k.manual
|
|
413
|
-
? '
|
|
495
|
+
? t('stateDisabled')
|
|
414
496
|
: k.untilAt
|
|
415
|
-
?
|
|
416
|
-
:
|
|
497
|
+
? t('coolingUntil', { time: fmtCN(k.untilAt) })
|
|
498
|
+
: t('coolingLeft', { time: fmtLeft(k.leftMs) }),
|
|
417
499
|
icon: k.state === 'ok' ? 'cloud' : k.manual ? 'remove' : 'time',
|
|
418
500
|
tone: k.state === 'ok' ? 'good' : k.manual ? 'bad' : 'warn',
|
|
419
501
|
action: { id: 'key-open', args: { id: k.id } },
|
|
@@ -423,110 +505,138 @@ export function apply(ctx) {
|
|
|
423
505
|
models: modelRows,
|
|
424
506
|
}
|
|
425
507
|
}
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
type: '
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
508
|
+
const viewPool = (scope, caps) => {
|
|
509
|
+
const t = makeT(caps?.locale)
|
|
510
|
+
return {
|
|
511
|
+
v: 2,
|
|
512
|
+
type: 'stack',
|
|
513
|
+
title: t('poolTitle'),
|
|
514
|
+
accent: 'teal',
|
|
515
|
+
children: [
|
|
516
|
+
{
|
|
517
|
+
type: 'row',
|
|
518
|
+
children: [
|
|
519
|
+
{ type: 'metric', label: t('metricReady'), value: { $: 'data.available' }, tone: 'good' },
|
|
520
|
+
{ type: 'metric', label: t('metricCooling'), value: { $: 'data.cooling' }, tone: 'warn' },
|
|
521
|
+
{ type: 'metric', label: t('metricMagicubes'), value: { $: 'data.magicubes' }, tone: 'info' },
|
|
522
|
+
],
|
|
523
|
+
},
|
|
524
|
+
{
|
|
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' },
|
|
538
|
+
},
|
|
539
|
+
{ type: 'callout', tone: 'info', text: { $: 'data.hint' } },
|
|
540
|
+
{ type: 'text', text: { $: 'data.keyTitle' }, style: { size: 'title', weight: 'bold' } },
|
|
541
|
+
{ type: 'list', items: { $: 'data.keys' } },
|
|
542
|
+
{
|
|
543
|
+
type: 'actions',
|
|
544
|
+
items: [
|
|
545
|
+
{ label: t('btnPrev'), icon: 'collapse', action: { id: 'key-prev' } },
|
|
546
|
+
{ label: t('btnNext'), icon: 'expand', action: { id: 'key-next' } },
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
type: 'actions',
|
|
551
|
+
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' } },
|
|
555
|
+
],
|
|
556
|
+
},
|
|
557
|
+
{ type: 'text', text: { $: 'data.focusTitle' }, tone: 'muted' },
|
|
558
|
+
{
|
|
559
|
+
type: 'form',
|
|
560
|
+
submitLabel: t('formSubmit'),
|
|
561
|
+
submit: 'add-form',
|
|
562
|
+
children: [
|
|
563
|
+
{
|
|
564
|
+
type: 'textField',
|
|
565
|
+
label: t('fieldLabel'),
|
|
566
|
+
bind: 'data.newKey',
|
|
567
|
+
placeholder: t('fieldPlaceholder'),
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
}
|
|
474
573
|
}
|
|
475
574
|
|
|
476
|
-
const viewKey = {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
type: '
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
575
|
+
const viewKey = (scope, caps) => {
|
|
576
|
+
const t = makeT(caps?.locale)
|
|
577
|
+
return {
|
|
578
|
+
v: 2,
|
|
579
|
+
type: 'stack',
|
|
580
|
+
title: t('keyPanelTitle'),
|
|
581
|
+
children: [
|
|
582
|
+
{ type: 'text', text: { $: 'data.title' }, style: { size: 'title', weight: 'bold' } },
|
|
583
|
+
{ type: 'keyValue', items: { $: 'data.rows' } },
|
|
584
|
+
{
|
|
585
|
+
type: 'actions',
|
|
586
|
+
items: [
|
|
587
|
+
{ label: t('btnDisable'), icon: 'remove', action: { id: 'key-disable' } },
|
|
588
|
+
{ label: t('btnStop'), icon: 'clock', action: { id: 'key-stop' } },
|
|
589
|
+
{ label: t('btnRestore'), icon: 'play', action: { id: 'key-restore' } },
|
|
590
|
+
{ label: t('btnDelete'), icon: 'delete', action: { id: 'key-delete' } },
|
|
591
|
+
],
|
|
592
|
+
},
|
|
593
|
+
{ type: 'callout', tone: 'warn', text: { $: 'data.hint' } },
|
|
594
|
+
{ type: 'actions', items: [{ label: t('btnBack'), icon: 'list', action: { id: 'key-back' } }] },
|
|
595
|
+
],
|
|
596
|
+
}
|
|
495
597
|
}
|
|
496
598
|
|
|
497
|
-
const viewModels = {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
599
|
+
const viewModels = (scope, caps) => {
|
|
600
|
+
const t = makeT(caps?.locale)
|
|
601
|
+
return {
|
|
602
|
+
v: 2,
|
|
603
|
+
type: 'stack',
|
|
604
|
+
title: t('modelsPanelTitle'),
|
|
605
|
+
children: [
|
|
606
|
+
{ type: 'text', text: { $: 'data.modelTitle' }, style: { size: 'title', weight: 'bold' } },
|
|
607
|
+
{ type: 'list', items: { $: 'data.models' } },
|
|
608
|
+
{ type: 'actions', items: [{ label: t('btnReloadModels'), icon: 'refresh', action: { id: 'refresh-models' } }] },
|
|
609
|
+
],
|
|
610
|
+
}
|
|
506
611
|
}
|
|
507
612
|
|
|
508
|
-
const buildPoolData = () => {
|
|
509
|
-
const d = buildData()
|
|
613
|
+
const buildPoolData = (locale) => {
|
|
614
|
+
const d = buildData(locale)
|
|
510
615
|
return {
|
|
511
616
|
available: d.available,
|
|
512
617
|
cooling: d.cooling,
|
|
513
618
|
magicubes: d.magicubes,
|
|
514
619
|
share: d.share,
|
|
620
|
+
shareTitle: d.shareTitle,
|
|
621
|
+
magic: d.magic,
|
|
515
622
|
hint: d.hint,
|
|
516
623
|
keyTitle: d.keyTitle,
|
|
517
624
|
keys: d.keys,
|
|
518
625
|
focusTitle: d.focus.title,
|
|
519
626
|
}
|
|
520
627
|
}
|
|
521
|
-
const buildKeyData = () => {
|
|
522
|
-
const d = buildData()
|
|
523
|
-
return {
|
|
628
|
+
const buildKeyData = (locale) => {
|
|
629
|
+
const d = buildData(locale)
|
|
630
|
+
return {
|
|
631
|
+
title: d.focus.title,
|
|
632
|
+
rows: d.focus.rows,
|
|
633
|
+
hint: d.focus.hint || makeT(locale)('hintKeyFallback'),
|
|
634
|
+
}
|
|
524
635
|
}
|
|
525
|
-
const buildModelsData = () => {
|
|
526
|
-
const d = buildData()
|
|
636
|
+
const buildModelsData = (locale) => {
|
|
637
|
+
const d = buildData(locale)
|
|
527
638
|
return { modelTitle: d.modelTitle, models: d.models }
|
|
528
639
|
}
|
|
529
|
-
|
|
530
640
|
/** 从粘贴的内容里抠出所有令牌:一行一个也行,逗号/空格混着也行。 */
|
|
531
641
|
const parseKeys = (raw) =>
|
|
532
642
|
String(raw ?? '')
|
|
@@ -535,11 +645,12 @@ export function apply(ctx) {
|
|
|
535
645
|
.filter((t) => /^ms-[A-Za-z0-9._-]{8,}$/.test(t))
|
|
536
646
|
.filter((t, i, arr) => arr.indexOf(t) === i)
|
|
537
647
|
|
|
538
|
-
const addKey = async (raw) => {
|
|
648
|
+
const addKey = async (raw, locale) => {
|
|
649
|
+
const t = makeT(locale)
|
|
539
650
|
const text = String(raw ?? '').trim()
|
|
540
|
-
if (!text) return { toast: '
|
|
651
|
+
if (!text) return { toast: t('toastNoInput') }
|
|
541
652
|
const tokens = parseKeys(text)
|
|
542
|
-
if (tokens.length === 0) return { toast: '
|
|
653
|
+
if (tokens.length === 0) return { toast: t('toastNotMs') }
|
|
543
654
|
|
|
544
655
|
const added = []
|
|
545
656
|
const skipped = []
|
|
@@ -548,9 +659,9 @@ export function apply(ctx) {
|
|
|
548
659
|
if (result.ok) added.push({ id: result.id, token })
|
|
549
660
|
else skipped.push(result.error)
|
|
550
661
|
}
|
|
551
|
-
if (added.length === 0) return { toast: skipped[0] ?? '
|
|
662
|
+
if (added.length === 0) return { toast: skipped[0] ?? t('toastNoNew') }
|
|
552
663
|
|
|
553
|
-
//
|
|
664
|
+
// 加进来就逐个问额度接口:既能认出站点,也能当场发现令牌无效或额度不够。
|
|
554
665
|
const regions = { ai: 0, cn: 0 }
|
|
555
666
|
let invalid = 0
|
|
556
667
|
let low = 0
|
|
@@ -558,7 +669,7 @@ export function apply(ctx) {
|
|
|
558
669
|
const state = await quota.refreshOne(id, token, { force: true })
|
|
559
670
|
if (!state?.ok) {
|
|
560
671
|
invalid++
|
|
561
|
-
pool.markFailure(id, '
|
|
672
|
+
pool.markFailure(id, '\u4ee4\u724c\u65e0\u6548', String(state?.error ?? '').slice(0, 80))
|
|
562
673
|
continue
|
|
563
674
|
}
|
|
564
675
|
if (state.region) {
|
|
@@ -571,13 +682,13 @@ export function apply(ctx) {
|
|
|
571
682
|
refreshPanel()
|
|
572
683
|
refreshStrip()
|
|
573
684
|
|
|
574
|
-
const bits = [
|
|
575
|
-
if (regions.ai > 0) bits.push(
|
|
576
|
-
if (regions.cn > 0) bits.push(
|
|
577
|
-
if (low > 0) bits.push(
|
|
578
|
-
if (invalid > 0) bits.push(
|
|
579
|
-
if (skipped.length > 0) bits.push(
|
|
580
|
-
return { toast: bits.join('
|
|
685
|
+
const bits = [t('addDone', { n: added.length })]
|
|
686
|
+
if (regions.ai > 0) bits.push(t('addIntl', { n: regions.ai }))
|
|
687
|
+
if (regions.cn > 0) bits.push(t('addCn', { n: regions.cn }))
|
|
688
|
+
if (low > 0) bits.push(t('addLow', { n: low }))
|
|
689
|
+
if (invalid > 0) bits.push(t('addInvalid', { n: invalid }))
|
|
690
|
+
if (skipped.length > 0) bits.push(t('addSkipped', { n: skipped.length }))
|
|
691
|
+
return { toast: bits.join(t('joinArrow')) }
|
|
581
692
|
}
|
|
582
693
|
|
|
583
694
|
ctx.inject(['dshGo'], (dshCtx) => {
|
|
@@ -595,14 +706,14 @@ export function apply(ctx) {
|
|
|
595
706
|
} catch {}
|
|
596
707
|
}
|
|
597
708
|
|
|
598
|
-
|
|
599
|
-
const panelData = (build) => (scope, emit) => {
|
|
709
|
+
/** 面板数据推流的通用外壳:内容变了才重绘,面板一关就停。 */
|
|
710
|
+
const panelData = (build) => (scope, emit, caps) => {
|
|
600
711
|
let stopped = false
|
|
601
712
|
let lastJson = ''
|
|
602
713
|
const tick = () => {
|
|
603
714
|
if (stopped) return
|
|
604
715
|
try {
|
|
605
|
-
const payload = build()
|
|
716
|
+
const payload = build(caps?.locale)
|
|
606
717
|
const json = JSON.stringify(payload)
|
|
607
718
|
if (json === lastJson) return // 没有变化就不重绘,界面元素保持稳定
|
|
608
719
|
lastJson = json
|
|
@@ -625,18 +736,23 @@ export function apply(ctx) {
|
|
|
625
736
|
}
|
|
626
737
|
|
|
627
738
|
const panelActions = {
|
|
628
|
-
async refresh() {
|
|
739
|
+
async refresh(scope, event) {
|
|
740
|
+
const t = makeT(event?.locale)
|
|
629
741
|
await Promise.all([refreshQuota(true), refreshCatalog(true)])
|
|
630
|
-
return { toast: '
|
|
742
|
+
return { toast: t('toastQuotaUpdated'), refresh: true }
|
|
631
743
|
},
|
|
632
744
|
async 'pick-model'(scope, event) {
|
|
745
|
+
const t = makeT(event?.locale)
|
|
633
746
|
const model = String(event?.value?.model ?? '')
|
|
634
747
|
const ok = await selectModel(model)
|
|
635
|
-
return ok
|
|
748
|
+
return ok
|
|
749
|
+
? { toast: t('toastModelSet', { model }), refresh: true }
|
|
750
|
+
: { toast: t('toastModelBad') }
|
|
636
751
|
},
|
|
637
|
-
async 'test-all'() {
|
|
752
|
+
async 'test-all'(scope, event) {
|
|
753
|
+
const t = makeT(event?.locale)
|
|
638
754
|
const keys = pool ? [...pool.keys] : []
|
|
639
|
-
if (keys.length === 0) return { toast: '
|
|
755
|
+
if (keys.length === 0) return { toast: t('toastNoKeys') }
|
|
640
756
|
// 注意不能用 /v1/models 验 Key:那个接口是公开的,什么令牌都返回 200。
|
|
641
757
|
// 真正会校验令牌的是额度接口,而且查额度不扣魔粒。
|
|
642
758
|
const rows = await quota.refresh(
|
|
@@ -661,71 +777,79 @@ export function apply(ctx) {
|
|
|
661
777
|
const ok = rows.filter((r) => r.state?.ok && r.state.available >= QUOTA_FLOOR).length
|
|
662
778
|
const invalid = rows.filter((r) => !r.state?.ok).length
|
|
663
779
|
const short = rows.length - ok - invalid
|
|
664
|
-
const parts = [
|
|
665
|
-
if (short > 0) parts.push(
|
|
666
|
-
if (invalid > 0) parts.push(
|
|
667
|
-
return { toast:
|
|
780
|
+
const parts = [t('partOk', { ok, total: keys.length })]
|
|
781
|
+
if (short > 0) parts.push(t('partShort', { n: short }))
|
|
782
|
+
if (invalid > 0) parts.push(t('partInvalid', { n: invalid }))
|
|
783
|
+
return { toast: t('toastTestDone', { parts: parts.join(' · ') }) }
|
|
668
784
|
},
|
|
669
|
-
'restore-all': async () => {
|
|
785
|
+
'restore-all': async (scope, event) => {
|
|
786
|
+
const t = makeT(event?.locale)
|
|
670
787
|
await pool.restoreAll()
|
|
671
788
|
router.clearAll()
|
|
672
789
|
refreshPanel()
|
|
673
790
|
refreshStrip()
|
|
674
|
-
return { toast: '
|
|
791
|
+
return { toast: t('toastRestored') }
|
|
675
792
|
},
|
|
676
793
|
// 单 Key 控制:先点列表里的某一行选中,再用下面四个按钮。
|
|
677
794
|
'key-open'(scope, event) {
|
|
795
|
+
const t = makeT(event?.locale)
|
|
678
796
|
const id = String(event?.value?.id ?? '')
|
|
679
|
-
if (!id) return { toast: '
|
|
797
|
+
if (!id) return { toast: t('toastNoKeyId') }
|
|
680
798
|
keyFocus = id
|
|
681
799
|
deleteArmed = ''
|
|
682
800
|
return { open: 'panel:key', refresh: true }
|
|
683
801
|
},
|
|
684
|
-
'key-prev'() {
|
|
685
|
-
|
|
802
|
+
'key-prev'(scope, event) {
|
|
803
|
+
const t = makeT(event?.locale)
|
|
804
|
+
if (keyPage <= 0) return { toast: t('toastFirstPage') }
|
|
686
805
|
keyPage--
|
|
687
806
|
return { refresh: true }
|
|
688
807
|
},
|
|
689
|
-
'key-next'() {
|
|
808
|
+
'key-next'(scope, event) {
|
|
809
|
+
const t = makeT(event?.locale)
|
|
690
810
|
const pages = Math.max(1, Math.ceil((pool?.keys.length ?? 0) / KEYS_PER_PAGE))
|
|
691
|
-
if (keyPage >= pages - 1) return { toast: '
|
|
811
|
+
if (keyPage >= pages - 1) return { toast: t('toastLastPage') }
|
|
692
812
|
keyPage++
|
|
693
813
|
return { refresh: true }
|
|
694
814
|
},
|
|
695
|
-
'key-disable'() {
|
|
815
|
+
'key-disable'(scope, event) {
|
|
816
|
+
const t = makeT(event?.locale)
|
|
696
817
|
const id = pickedKey()
|
|
697
|
-
if (!id) return { toast: '
|
|
818
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
698
819
|
pool.disable(id, '手动禁用')
|
|
699
820
|
router.clearKey?.(id)
|
|
700
821
|
refreshPanel()
|
|
701
822
|
refreshStrip()
|
|
702
|
-
return { toast:
|
|
823
|
+
return { toast: t('toastDisabled', { id }), refresh: true }
|
|
703
824
|
},
|
|
704
|
-
'key-stop'() {
|
|
825
|
+
'key-stop'(scope, event) {
|
|
826
|
+
const t = makeT(event?.locale)
|
|
705
827
|
const id = pickedKey()
|
|
706
|
-
if (!id) return { toast: '
|
|
828
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
707
829
|
pool.stop(id, '手动停用', nextQuotaResetAt())
|
|
708
830
|
router.clearKey?.(id)
|
|
709
831
|
refreshPanel()
|
|
710
832
|
refreshStrip()
|
|
711
|
-
return { toast:
|
|
833
|
+
return { toast: t('toastStopped', { id }), refresh: true }
|
|
712
834
|
},
|
|
713
|
-
'key-restore'() {
|
|
835
|
+
'key-restore'(scope, event) {
|
|
836
|
+
const t = makeT(event?.locale)
|
|
714
837
|
const id = pickedKey()
|
|
715
|
-
if (!id) return { toast: '
|
|
838
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
716
839
|
pool.restore(id)
|
|
717
840
|
router.clearKey?.(id)
|
|
718
841
|
refreshPanel()
|
|
719
842
|
refreshStrip()
|
|
720
|
-
return { toast:
|
|
843
|
+
return { toast: t('toastRestoredOne', { id }), refresh: true }
|
|
721
844
|
},
|
|
722
|
-
'key-delete'() {
|
|
845
|
+
'key-delete'(scope, event) {
|
|
846
|
+
const t = makeT(event?.locale)
|
|
723
847
|
const id = pickedKey()
|
|
724
|
-
if (!id) return { toast: '
|
|
848
|
+
if (!id) return { toast: t('toastNoPick') }
|
|
725
849
|
// 删令牌不可撤销,要求连点两次。
|
|
726
850
|
if (deleteArmed !== id) {
|
|
727
851
|
deleteArmed = id
|
|
728
|
-
return { toast:
|
|
852
|
+
return { toast: t('toastDeleteArm', { id }), refresh: true }
|
|
729
853
|
}
|
|
730
854
|
deleteArmed = ''
|
|
731
855
|
void pool.remove(id).then(() => {
|
|
@@ -734,22 +858,22 @@ export function apply(ctx) {
|
|
|
734
858
|
refreshPanel()
|
|
735
859
|
refreshStrip()
|
|
736
860
|
})
|
|
737
|
-
return { toast:
|
|
861
|
+
return { toast: t('toastDeleted', { id }), refresh: true }
|
|
738
862
|
},
|
|
739
863
|
'add-form': async (scope, event) => {
|
|
740
864
|
const value = event?.value
|
|
741
865
|
const raw = typeof value === 'string' ? value : value?.newKey ?? value?.value ?? ''
|
|
742
|
-
const result = await addKey(raw)
|
|
866
|
+
const result = await addKey(raw, event?.locale)
|
|
743
867
|
return { ...result, refresh: true }
|
|
744
868
|
},
|
|
745
869
|
'key-back'() {
|
|
746
870
|
return { open: 'panel:pool' }
|
|
747
871
|
},
|
|
748
|
-
async 'refresh-models'() {
|
|
749
|
-
|
|
872
|
+
async 'refresh-models'(scope, event) {
|
|
873
|
+
const t = makeT(event?.locale)
|
|
750
874
|
await refreshCatalog(true)
|
|
751
875
|
refreshPanel()
|
|
752
|
-
return { toast: '
|
|
876
|
+
return { toast: t('toastModelsReloaded'), refresh: true }
|
|
753
877
|
},
|
|
754
878
|
}
|
|
755
879
|
|
|
@@ -758,7 +882,8 @@ export function apply(ctx) {
|
|
|
758
882
|
dshGo.panel('models', { view: viewModels, data: panelData(buildModelsData), actions: panelActions })
|
|
759
883
|
|
|
760
884
|
dshGo.strip('pool-strip', {
|
|
761
|
-
data(scope, emit) {
|
|
885
|
+
data(scope, emit, caps) {
|
|
886
|
+
const t = makeT(caps?.locale)
|
|
762
887
|
let stop = () => {}
|
|
763
888
|
let lastText = ''
|
|
764
889
|
const tick = () => {
|
|
@@ -767,9 +892,13 @@ export function apply(ctx) {
|
|
|
767
892
|
const q = quota ? quota.sum(ids) : { available: 0, known: 0 }
|
|
768
893
|
const payload =
|
|
769
894
|
s.total === 0
|
|
770
|
-
? { text: '
|
|
895
|
+
? { text: t('stripEmpty'), tone: 'warn' }
|
|
771
896
|
: {
|
|
772
|
-
text:
|
|
897
|
+
text: t('stripText', {
|
|
898
|
+
ok: s.available,
|
|
899
|
+
total: s.total,
|
|
900
|
+
quota: q.known > 0 ? t('stripQuota', { n: q.available }) : t('stripQuotaUnknown'),
|
|
901
|
+
}),
|
|
773
902
|
tone: s.available > 0 ? 'good' : 'bad',
|
|
774
903
|
}
|
|
775
904
|
const json = JSON.stringify(payload)
|
|
@@ -789,10 +918,10 @@ export function apply(ctx) {
|
|
|
789
918
|
},
|
|
790
919
|
})
|
|
791
920
|
|
|
792
|
-
dshGo.on('addkey', async (scope) => {
|
|
921
|
+
dshGo.on('addkey', async (scope, event) => {
|
|
793
922
|
const text = String(scope.clipboard ?? '').trim()
|
|
794
|
-
if (!text) return { toast: '
|
|
795
|
-
const result = await addKey(text)
|
|
923
|
+
if (!text) return { toast: makeT(event?.locale)('toastNoClipboard') }
|
|
924
|
+
const result = await addKey(text, event?.locale)
|
|
796
925
|
return { ...result, refresh: 'pool' }
|
|
797
926
|
})
|
|
798
927
|
|
|
@@ -859,7 +988,7 @@ export function apply(ctx) {
|
|
|
859
988
|
})()
|
|
860
989
|
const ok = await registerPoolProvider(llmCtx, {
|
|
861
990
|
providerId: 'modelscope-pool',
|
|
862
|
-
displayName: '
|
|
991
|
+
displayName: makeT()('poolTitle'),
|
|
863
992
|
entryId,
|
|
864
993
|
getPort: () => proxy?.port ?? 0,
|
|
865
994
|
getKey: () => config.apiKey,
|
package/lib/provider.js
CHANGED
|
@@ -5,6 +5,8 @@ import { createRequire } from 'node:module'
|
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
|
|
8
|
+
import { detectLang } from './i18n.js'
|
|
9
|
+
|
|
8
10
|
const HOST_ROOTS = ['/opt/dsh', process.cwd()]
|
|
9
11
|
const DEFAULT_CONTEXT_WINDOW = 131072
|
|
10
12
|
const IMAGE_BUDGETS = {
|
|
@@ -58,7 +60,7 @@ const INERT_AUTH = {
|
|
|
58
60
|
return []
|
|
59
61
|
},
|
|
60
62
|
async modify() {
|
|
61
|
-
throw new Error('
|
|
63
|
+
throw new Error('local-modelscope-pool has no pi-ai credential lifecycle')
|
|
62
64
|
},
|
|
63
65
|
async delete() {},
|
|
64
66
|
},
|
|
@@ -75,7 +77,11 @@ const INERT_AUTH = {
|
|
|
75
77
|
* @param options.getEntries 带版本/短名的模型条目(优先于 getModels)
|
|
76
78
|
* @param options.entryId 本插件在 loader 里的条目 id
|
|
77
79
|
*/
|
|
78
|
-
|
|
80
|
+
// 模型页里的站点后缀跟随系统语言:中文用「国际版 / 国内版」,其他语言用「International / China」。
|
|
81
|
+
const REGION_TAG = () =>
|
|
82
|
+
detectLang() === 'en'
|
|
83
|
+
? { ai: 'International', cn: 'China' }
|
|
84
|
+
: { ai: '国际版', cn: '国内版' }
|
|
79
85
|
|
|
80
86
|
/** 模型页里显示的名字:短名 + 版本,例如「DeepSeek-V4-Flash-1017 · 国际版」。 */
|
|
81
87
|
const displayNameOf = (entry) => {
|
|
@@ -83,7 +89,7 @@ const displayNameOf = (entry) => {
|
|
|
83
89
|
const regions = Array.isArray(entry?.regions) ? entry.regions : []
|
|
84
90
|
// 两个站点都有就不加后缀,避免模型页里一堆「· 双站」。
|
|
85
91
|
if (regions.length === 0 || regions.length > 1) return base
|
|
86
|
-
const tag = REGION_TAG[regions[0]] ?? ''
|
|
92
|
+
const tag = REGION_TAG()[regions[0]] ?? ''
|
|
87
93
|
return tag ? `${base} · ${tag}` : base
|
|
88
94
|
}
|
|
89
95
|
|
|
@@ -170,7 +176,7 @@ export async function registerPoolProvider(ctx, { providerId, displayName, getPo
|
|
|
170
176
|
provider: providerId,
|
|
171
177
|
displayName,
|
|
172
178
|
streamIdleTimeoutMs: 180000,
|
|
173
|
-
retryPolicy: resolveRetryPolicy(undefined, '
|
|
179
|
+
retryPolicy: resolveRetryPolicy(undefined, 'local-modelscope-pool retryPolicy'),
|
|
174
180
|
configuredMaxTokens: new Map(),
|
|
175
181
|
modelErrors: new Map(),
|
|
176
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: 'local-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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-modelscope-pool",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "魔搭多 Key 聚合网关:无感路由(模型不变,Key 遇限流/额度不足/无权限/不存在自动换下一个);自动识别国际版/国内版令牌;单个 Key 可禁用/停用/恢复/删除/查看;全部密钥分页浏览;模型实际拉取国际版+国内版合并成简化名、按站点分组;额度低于 2 停用到次日 00:01、多 Key 轮换、限流临时失败冷却 5 分钟,并注册为 DSH 模型供应商",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Yizutt",
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/Yizutt/dsh-modelscope-pool.git"
|
|
10
10
|
},
|
|
11
|
+
"homepage": "https://github.com/Yizutt/dsh-modelscope-pool",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Yizutt/dsh-modelscope-pool/issues"
|
|
14
|
+
},
|
|
11
15
|
"keywords": [
|
|
12
16
|
"dsh",
|
|
13
17
|
"dsh-go",
|