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 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 池** | 对话标签 / 侧栏 / 功能页 | 可用 / 冷却 / 魔粒三个数字、Key 状态扇形图、Key 列表(分页)、刷新额度、测试全部、全部恢复、添加 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
- ## License
334
+ ## 声明
300
335
 
301
- [MIT](LICENSE) © 2026 Yizutt
336
+ - 本项目与**魔搭(ModelScope)官方无关**,不是官方客户端,也未获官方认可或背书。
337
+ - 只是跑在本机的一个客户端侧聚合工具:所有请求仍由你自己的 Key 直接发往魔搭官方接口,作者不经手、不中转任何流量。
338
+ - 「魔粒」额度、站点归属、模型列表都以魔搭官方返回为准;本项目里的 250 魔粒/Key 是**估算值**,仅用于界面展示,不构成任何额度承诺。
339
+ - 请自行阅读并遵守 [ModelScope 服务条款](https://modelscope.cn) 与各模型自己的许可条款。因违反条款、超额调用或令牌泄露造成的后果由使用者自负。
340
+ - 软件按 MIT 许可「按原样」提供,不附带任何明示或默示担保。
341
+
342
+ ## License
302
343
 
303
- 本项目与 ModelScope(魔搭)官方无关,只是本机的客户端侧聚合工具。使用前请自行阅读并遵守 [ModelScope 服务条款](https://modelscope.cn)。
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 = 'dsh-modelscope-pool'
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
- /** 模型行的显示:短名当标题,真实 id 当副标题,点了设为默认。 */
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 = regions.length > 1 ? '国际版 + 国内版' : REGIONS[regions[0]]?.label ?? '站点未知'
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 ? `当前默认 · ${where}` : where,
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 = routing.blockedPairs > 0 ? `已记下 ${routing.blockedPairs} 个「Key×模型」的失败组合(5 分钟内自动跳过)。` : ''
300
+ const routed =
301
+ routing.blockedPairs > 0 ? ` ${t('routed', { n: routing.blockedPairs })}` : ''
297
302
  const hint =
298
303
  s.total === 0
299
- ? '还没有 Key:在魔搭复制令牌(ms- 开头)后点输入框上方的「添加 Key」,也可以在下面直接填。'
304
+ ? t('hintNoKeys')
300
305
  : s.cooling > 0
301
- ? `无感切换已开:剩余魔粒低于 2 的 Key 已自动停用到次日 00:01,避免撞 429。到点自动恢复。${routed}`
306
+ ? `${t('hintCooling')}${routed}`
302
307
  : model
303
- ? `无感切换已开:模型始终是 ${model},Key 遇到限流、额度不足、没权限会自动换下一个;魔粒低于 2 的 Key 会提前停用。${routed}`
304
- : `点「刷新额度」看剩余魔粒;点下面的模型可以设为默认。${routed}`
308
+ ? `${t('hintModel', { model })}${routed}`
309
+ : `${t('hintIdle')}${routed}`
310
+
305
311
  // 扇形图:Key 池的状态分布(可用 / 额度不足 / 令牌无效 / 其他冷却)
306
- const buckets = { 可用: 0, 额度不足: 0, 令牌无效: 0, '其他冷却': 0 }
307
- for (const k of s.keys) {
308
- if (k.state === 'ok') buckets['可用']++
309
- else if (k.reason === '额度不足' || k.reason === '额度用尽') buckets['额度不足']++
310
- else if (k.reason === '令牌无效') buckets['令牌无效']++
311
- else buckets['其他冷却']++
312
- }
313
- const shareCategories = Object.keys(buckets).filter((name) => buckets[name] > 0)
314
- const shareValues = shareCategories.map((name) => buckets[name])
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: shareCategories,
318
- series: [{ name: 'Key', values: shareValues }],
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
- ? '还没有 Key'
323
- : `共 ${s.total} 个 Key:可用 ${buckets['可用']},额度不足 ${buckets['额度不足']},其余冷却中`,
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
- ['完整令牌', focusRaw?.key ?? '—'],
340
- ['版本', REGIONS[focusRow.region]?.label ?? '版本未知'],
382
+ [t('rowToken'), focusRaw?.key ?? '—'],
383
+ [t('rowSite'), t.region(focusRow.region)],
341
384
  [
342
- '额度',
385
+ t('rowQuota'),
343
386
  focusState?.ok
344
- ? `${focusState.available} 魔粒(总额 ${focusState.total},冻结 ${focusState.frozen})`
387
+ ? t('quotaDetail', {
388
+ n: focusState.available,
389
+ total: focusState.total,
390
+ frozen: focusState.frozen,
391
+ })
345
392
  : focusState
346
- ? `查询失败:${focusState.error}`
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 || '冷却中'}${focusRow.untilAt ? ` · 冷却至 ${fmtCN(focusRow.untilAt)}` : ''}`,
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: '「禁用」= 一直停到点恢复;「停用」= 停到北京时间次日 00:01;「删除」会把令牌从本机文件里去掉。',
415
+ hint: t('hintKeyDetail'),
360
416
  }
361
- : { title: '还没选中 Key(回「Key 列表」点一行)', rows: [], hint: '' }
417
+ : { title: t('focusEmpty'), rows: [], hint: '' }
362
418
 
363
419
  // 模型列表按站点分组:国际版 → 国内版 → 双站,名字用短名。
364
420
  const modelRows = []
365
- const modelOrder = ['ai', 'cn']
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({ title: REGIONS[region]?.label ?? region, icon: 'globe', tone: 'info', subtitle: `${group.length} 个模型` })
370
- for (const entry of group) modelRows.push(modelRowOf(entry, currentModel))
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({ title: '国际版 + 国内版都有', icon: 'globe', tone: 'info', subtitle: `${both.length} 个模型` })
375
- for (const entry of both) modelRows.push(modelRowOf(entry, currentModel))
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({ title: '站点未知', icon: 'globe', tone: 'info', subtitle: `${unknownRegion.length} 个模型` })
380
- for (const entry of unknownRegion) modelRows.push(modelRowOf(entry, currentModel))
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
- : `${models.length} 个模型 · 当前 ${modelEntry?.name ?? (model || '未选')}`,
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
- ? 'Key 池(还没有 Key)'
397
- : `Key 池 ${s.total} 个 · 第 ${pageNo}/${pageCount} 页(点一行看详情)`,
398
- keyPager: s.total > KEYS_PER_PAGE ? `第 ${pageNo}/${pageCount} 页 · 每页 ${KEYS_PER_PAGE} 个` : '',
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 = `用过 ${k.used} 次`
402
- const money = state?.ok ? `额度 ${state.available} 魔粒` : state && !state.ok ? `额度未知(${state.error.slice(0, 24)})` : '额度未查'
403
- const region = REGIONS[k.region]?.label ?? '版本未知'
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}(失败 ${k.fails} 次)· ${money} · ${region}`
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
- ? `冷却至 ${fmtCN(k.untilAt)}`
416
- : `冷却 ${fmtLeft(k.leftMs)}`,
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
- const viewPool = {
428
- v: 2,
429
- type: 'stack',
430
- title: '魔搭 Key 池',
431
- accent: 'teal',
432
- children: [
433
- {
434
- type: 'row',
435
- children: [
436
- { type: 'metric', label: '可用', value: { $: 'data.available' }, tone: 'good' },
437
- { type: 'metric', label: '冷却', value: { $: 'data.cooling' }, tone: 'warn' },
438
- { type: 'metric', label: '魔粒', value: { $: 'data.magicubes' }, tone: 'info' },
439
- ],
440
- },
441
- {
442
- type: 'chart',
443
- kind: 'donut',
444
- categories: { $: 'data.share.categories' },
445
- series: { $: 'data.share.series' },
446
- caption: { $: 'data.share.caption' },
447
- },
448
- { type: 'callout', tone: 'info', text: { $: 'data.hint' } },
449
- { type: 'text', text: { $: 'data.keyTitle' }, style: { size: 'title', weight: 'bold' } },
450
- { type: 'list', items: { $: 'data.keys' } },
451
- {
452
- type: 'actions',
453
- items: [
454
- { label: '上一页', icon: 'collapse', action: { id: 'key-prev' } },
455
- { label: '下一页', icon: 'expand', action: { id: 'key-next' } },
456
- ],
457
- },
458
- {
459
- type: 'actions',
460
- items: [
461
- { label: '刷新额度', icon: 'refresh', action: { id: 'refresh' } },
462
- { label: '测试全部', icon: 'bolt', action: { id: 'test-all' } },
463
- { label: '全部恢复', icon: 'play', action: { id: 'restore-all' } },
464
- ],
465
- },
466
- { type: 'text', text: { $: 'data.focusTitle' }, tone: 'muted' },
467
- {
468
- type: 'form',
469
- submitLabel: '添加',
470
- submit: 'add-form',
471
- children: [{ type: 'textField', label: '添加 Key', bind: 'data.newKey', placeholder: '粘贴 ms- 开头的令牌,可多行' }],
472
- },
473
- ],
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
- v: 2,
478
- type: 'stack',
479
- title: 'Key 详情',
480
- children: [
481
- { type: 'text', text: { $: 'data.title' }, style: { size: 'title', weight: 'bold' } },
482
- { type: 'keyValue', items: { $: 'data.rows' } },
483
- {
484
- type: 'actions',
485
- items: [
486
- { label: '禁用', icon: 'remove', action: { id: 'key-disable' } },
487
- { label: '停用', icon: 'clock', action: { id: 'key-stop' } },
488
- { label: '恢复', icon: 'play', action: { id: 'key-restore' } },
489
- { label: '删除', icon: 'delete', action: { id: 'key-delete' } },
490
- ],
491
- },
492
- { type: 'callout', tone: 'warn', text: { $: 'data.hint' } },
493
- { type: 'actions', items: [{ label: '返回 Key 列表', icon: 'list', action: { id: 'key-back' } }] },
494
- ],
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
- v: 2,
499
- type: 'stack',
500
- title: '魔搭模型',
501
- children: [
502
- { type: 'text', text: { $: 'data.modelTitle' }, style: { size: 'title', weight: 'bold' } },
503
- { type: 'list', items: { $: 'data.models' } },
504
- { type: 'actions', items: [{ label: '重新拉取模型', icon: 'refresh', action: { id: 'refresh-models' } }] },
505
- ],
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 { title: d.focus.title, rows: d.focus.rows, hint: d.focus.hint || '回到「魔搭 Key 池」面板,点列表里任意一行就会选中它。' }
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: '没读到 Key' }
651
+ if (!text) return { toast: t('toastNoInput') }
541
652
  const tokens = parseKeys(text)
542
- if (tokens.length === 0) return { toast: '这不像魔搭令牌(应以 ms- 开头)' }
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] ?? '没有新增 Key' }
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, '令牌无效', String(state?.error ?? '').slice(0, 80))
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 = [`新增 ${added.length} 个`]
575
- if (regions.ai > 0) bits.push(`国际版 ${regions.ai}`)
576
- if (regions.cn > 0) bits.push(`国内版 ${regions.cn}`)
577
- if (low > 0) bits.push(`${low} 个额度不足已停用`)
578
- if (invalid > 0) bits.push(`${invalid} 个令牌无效`)
579
- if (skipped.length > 0) bits.push(`${skipped.length} 个重复跳过`)
580
- return { toast: bits.join('|') }
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: '额度已更新', refresh: true }
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 ? { toast: `默认模型:${model}`, refresh: true } : { toast: '模型名不可用' }
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: '池里还没有 Key' }
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 = [`${ok}/${keys.length} 个 Key 可用`]
665
- if (short > 0) parts.push(`${short} 个额度不足`)
666
- if (invalid > 0) parts.push(`${invalid} 个令牌无效`)
667
- return { toast: `测试完成:${parts.join(',')}` }
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: '已把全部 Key 恢复为可用' }
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: '没认出这个 Key' }
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
- if (keyPage <= 0) return { toast: '已经是第一页了' }
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: '先在上面点一行选中一个 Key' }
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: `${id} 已禁用,点「恢复」才放开`, refresh: true }
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: '先在上面点一行选中一个 Key' }
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: `${id} 停用到北京时间次日 00:01`, refresh: true }
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: '先在上面点一行选中一个 Key' }
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: `${id} 已恢复可用`, refresh: true }
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: '先在上面点一行选中一个 Key' }
848
+ if (!id) return { toast: t('toastNoPick') }
725
849
  // 删令牌不可撤销,要求连点两次。
726
850
  if (deleteArmed !== id) {
727
851
  deleteArmed = id
728
- return { toast: `再点一次「删除」确认删掉 ${id}`, refresh: true }
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: `${id} 已从本机删除`, refresh: true }
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
- keyPage = keyPage
872
+ async 'refresh-models'(scope, event) {
873
+ const t = makeT(event?.locale)
750
874
  await refreshCatalog(true)
751
875
  refreshPanel()
752
- return { toast: '模型列表已重新拉取', refresh: true }
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: '魔搭 未配置', tone: 'warn' }
895
+ ? { text: t('stripEmpty'), tone: 'warn' }
771
896
  : {
772
- text: `魔搭 ${s.available}/${s.total} · ${q.known > 0 ? `${q.available} 魔粒` : '额度?'}`,
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: '魔搭 Key 池',
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('dsh-modelscope-pool has no pi-ai credential lifecycle')
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
- const REGION_TAG = { ai: '国际版', cn: '国内版' }
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, 'dsh-modelscope-pool retryPolicy'),
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: 'dsh-modelscope-pool',
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.8.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",