dsh-account-pool 0.1.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/lib/index.js ADDED
@@ -0,0 +1,1142 @@
1
+ /**
2
+ * dsh-account-pool — Host 半边入口。
3
+ *
4
+ * 把多个 WorkBuddy 账号接入 DSH 的模型选择器,并在账号之间自动切换。
5
+ *
6
+ * 数据流:
7
+ *
8
+ * DSH LLM seam
9
+ * → pi-ai adapter(本插件注册的 provider 路由)
10
+ * → loopback shim(127.0.0.1:随机端口,本插件自带)
11
+ * ├─ 选号器:决定这次用哪个账号(三因子加权 + 冷却 + 熔断 + 会话粘性)
12
+ * └─ 失败自动换号重试
13
+ * → copilot.tencent.com / www.workbuddy.ai
14
+ *
15
+ * 两个区域各一套完整栈(凭据库 / 选号器 / shim):
16
+ * workbuddy 国内版
17
+ * workbuddy-global 国际版
18
+ * 两边账号互不覆盖,可同时在线,不同会话各选一边。
19
+ *
20
+ * 不依赖任何外部网关——「网关」角色由本插件的 shim 承担。
21
+ */
22
+
23
+ import { createProvider } from '@earendil-works/pi-ai'
24
+ import { randomBytes } from 'node:crypto'
25
+ import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
26
+ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
27
+ import { resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
28
+ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
29
+ import { dirname, join } from 'node:path'
30
+ import z from '@deepseek-ai/schemastery'
31
+
32
+ import { openStore, clearStore, startDeviceLogin, pollDeviceLogin, parseAuthFile } from './accounts.js'
33
+ import { readFile, readdir, stat } from 'node:fs/promises'
34
+ import { fetchModels, fetchCredits } from './upstream.js'
35
+ import { AccountPicker } from './selector.js'
36
+ import { createShim } from './shim.js'
37
+ import { UsageRecorder } from './usage.js'
38
+ import { runDailyTasks, fetchStreak, fetchBuddy, fetchLottery, travelStatus as fetchTravelStatus } from './tasks.js'
39
+ import { openTraeStore, accountIdOf as accountIdOfTrae } from './trae-accounts.js'
40
+ import { parseTraeStorage } from './trae-storage.js'
41
+ import {
42
+ chatStream as traeChatStream,
43
+ prepareChatBody as traePrepareBody,
44
+ isRetryableWithAnotherAccount as traeRetryable,
45
+ fetchModels as traeFetchModels,
46
+ runDailyTasks as traeRunDailyTasks,
47
+ readStatus as traeReadStatus,
48
+ fetchCredits as traeFetchCredits,
49
+ } from './trae-upstream.js'
50
+
51
+ export const name = 'account-pool'
52
+
53
+ /** 声明硬依赖:没有 llm 服务就没法注册 provider,没有 settings 就没法挂发现器。 */
54
+ export const inject = ['llm', 'settings']
55
+
56
+ /** 区域 → provider 路由 id。 */
57
+ export const REGION_PROVIDERS = {
58
+ cn: 'workbuddy',
59
+ global: 'workbuddy-global',
60
+ // Trae 是完全不同的上游(字节 TRAE SOLO),照旧当成一个「区域」接入:
61
+ // 选号、重试、用量、shim 全部复用,只有上游适配器不同。
62
+ trae: 'trae',
63
+ }
64
+
65
+ /** 区域 → 在 DSH 模型选择器里显示的名字。 */
66
+ const REGION_DISPLAY_NAMES = {
67
+ cn: 'WorkBuddy',
68
+ global: 'WorkBuddy Global',
69
+ trae: 'Trae',
70
+ }
71
+
72
+ /** 所有合法的区域标识。 */
73
+ const REGION_IDS = Object.keys(REGION_PROVIDERS)
74
+
75
+ /**
76
+ * 把外部传来的 region 归一化成合法值,非法值退回 'cn'。
77
+ *
78
+ * 抽出来是因为原来 8 个路由各写一遍 `x === 'global' ? 'global' : 'cn'`,
79
+ * 加 trae 时漏改一处,Trae 面板就会静默显示成 WorkBuddy 的账号
80
+ * ——这种「重复代码 + 静默降级」的组合最容易出隐蔽 bug。
81
+ */
82
+ function normalizeRegion(value) {
83
+ const text = String(value ?? '')
84
+ return REGION_IDS.includes(text) ? text : 'cn'
85
+ }
86
+
87
+ /**
88
+ * 本插件自己的 settings 命名空间。
89
+ *
90
+ * 必须是自己的名字,不能借用官方的 `llm-pi-ai`——那个命名空间归
91
+ * dsh-llm-pi-ai 所有,而且 dsh-llm 的 registerModelDiscovery 按命名空间
92
+ * 索引,重名会直接抛 DUPLICATE_DISCOVERY 把官方功能搞坏。
93
+ *
94
+ * 这一节同时承担两个职责:
95
+ * 1. 让设置页的「模型」界面能对这个 provider 做模型发现
96
+ * (否则会报 "pi-ai ships no catalog for provider ...");
97
+ * 2. 存住上次拉到的模型目录,供发现器在账号不可用时兜底。
98
+ */
99
+ const SETTINGS_NS = 'account-pool'
100
+
101
+ /** 设置命名空间的结构:每个区域一份模型目录快照。 */
102
+ const Config = z.object({
103
+ // 各区域最近一次成功拉取的模型目录,用于发现器兜底
104
+ catalog: z.dict(z.array(z.object({
105
+ id: z.string().required(),
106
+ name: z.string(),
107
+ contextWindow: z.number().step(1).min(1),
108
+ maxTokens: z.number().step(1).min(1),
109
+ multimodal: z.boolean(),
110
+ }))).default({}),
111
+ })
112
+
113
+ /** 流式读取空闲上限。 */
114
+ const STREAM_IDLE_TIMEOUT_MS = 300_000
115
+
116
+ /** pi-ai 图片预算(对齐 dsh-llm-pi-ai 的默认值)。 */
117
+ const REQUEST_IMAGE_BUDGETS = {
118
+ maxRequestImageBytes: 20_971_520,
119
+ requestImagePixelBudget: 4_194_304,
120
+ requestImageMaxBytes: 1_048_576,
121
+ }
122
+
123
+ /**
124
+ * 惰性的 pi-ai 认证面。
125
+ * 本插件路由只认 shim 的进程内 secret(由 resolveApiKey 提供),
126
+ * 所以 pi-ai 自己的凭据发现必须一问三不知——否则它会从环境变量里
127
+ * 找到不相干的 key 拿来用。
128
+ */
129
+ const INERT_AUTH = {
130
+ credentials: {
131
+ async read() { return undefined },
132
+ async list() { return [] },
133
+ async modify() { throw new Error('account-pool: 该路由没有 pi-ai 凭据生命周期') },
134
+ async delete() {},
135
+ },
136
+ authContext: {
137
+ async env() { return undefined },
138
+ async fileExists() { return false },
139
+ },
140
+ }
141
+
142
+ /** 订阅制额度无法按 token 计价,一律报 0(不代表免费)。 */
143
+ const NO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
144
+
145
+ /** DSH 的思考档位阶梯。 */
146
+ const THINKING_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max']
147
+
148
+ /**
149
+ * 把上游声明的推理档位翻译成 DSH 的思考档位映射。
150
+ * 上游没声明的档位必须显式置 null,否则 DSH 会以为可以选择。
151
+ */
152
+ function thinkingLevelMapOf(info) {
153
+ const supported = info.reasoning?.supportedEfforts?.filter(e => THINKING_LEVELS.includes(e))
154
+ if (supported === undefined || supported.length === 0) return undefined
155
+ const map = {}
156
+ for (const level of THINKING_LEVELS) {
157
+ map[level] = supported.includes(level) ? level : null
158
+ }
159
+ // 只有上游明确允许「不思考」时才开放 off 档。
160
+ if (info.reasoning?.canDisableThinking !== true) map.off = null
161
+ return map
162
+ }
163
+
164
+ /** 展示名带积分倍率,与 WorkBuddy 自己的选择器一致:`GLM-5.3 · x0.79`。 */
165
+ function displayNameOf(info) {
166
+ return info.creditMultiplier === undefined
167
+ ? info.name
168
+ : `${info.name} · x${info.creditMultiplier.toFixed(2)}`
169
+ }
170
+
171
+ /** 构造一个 pi-ai 模型描述符,指向本插件的 shim。 */
172
+ function toPiModel(info, baseUrl, providerId) {
173
+ const thinkingLevelMap = thinkingLevelMapOf(info)
174
+ return {
175
+ id: info.id,
176
+ name: displayNameOf(info),
177
+ api: 'openai-completions',
178
+ provider: providerId,
179
+ baseUrl,
180
+ // 上游说支持图片才给 image;否则 DSH 会先把图片拦掉。
181
+ input: info.multimodal === true ? ['text', 'image'] : ['text'],
182
+ cost: NO_COST,
183
+ contextWindow: info.contextWindow,
184
+ maxTokens: info.maxTokens,
185
+ reasoning: thinkingLevelMap !== undefined,
186
+ ...(thinkingLevelMap === undefined ? {} : { thinkingLevelMap }),
187
+ compat: { supportsReasoningEffort: thinkingLevelMap !== undefined },
188
+ }
189
+ }
190
+
191
+ /**
192
+ * 为一个区域装配完整的一套:store → picker → shim → adapter。
193
+ *
194
+ * 顺序很关键:必须先起 shim 拿到端口,才能构造带 baseUrl 的模型描述符。
195
+ */
196
+ async function createRegionStack({ region, dshHome, config, logger, ctx }) {
197
+ const providerId = REGION_PROVIDERS[region]
198
+ // Trae 是独立上游:凭据格式、刷新端点、对话协议都不同,
199
+ // 所以用自己的一份 store 与适配器;其余(选号/重试/用量/shim)完全复用。
200
+ const isTrae = region === 'trae'
201
+ const store = isTrae ? openTraeStore(dshHome) : openStore(dshHome, region)
202
+ /** 上游适配器:shim 通过它屏蔽两家协议的差异。 */
203
+ const upstreamAdapter = isTrae
204
+ ? { chatStream: traeChatStream, prepareBody: traePrepareBody, retryable: traeRetryable }
205
+ : undefined
206
+ /** 拉模型目录:两家端点不同。 */
207
+ const fetchCatalog = isTrae ? traeFetchModels : fetchModels
208
+ /** 查积分:两家端点/认证方式都不同。漏分流会让 Trae 的 token
209
+ * 打到 WorkBuddy 的接口上——401,积分列永远显示「—」。 */
210
+ const fetchRegionCredits = isTrae ? traeFetchCredits : fetchCredits
211
+ /** 每日任务:Trae 只有签到与积分。 */
212
+ const runTasks = isTrae ? traeRunDailyTasks : runDailyTasks
213
+ // 用量记录器按区域分文件,与凭据分离。
214
+ const usage = new UsageRecorder({
215
+ file: join(dshHome, `.account-pool.usage.${region}.json`),
216
+ // 插件改名前的用量文件:只读回落,避免历史用量"归零"
217
+ legacyFiles: [
218
+ join(dshHome, `.workbuddy-pool.usage.${region}.json`),
219
+ join(dirname(dshHome), `.workbuddy-pool.usage.${region}.json`),
220
+ ],
221
+ })
222
+ await usage.load()
223
+ const picker = new AccountPicker({
224
+ softCooldownMs: (config.softCooldownSeconds ?? 600) * 1000,
225
+ breakerThreshold: config.breakerThreshold ?? 3,
226
+ })
227
+
228
+ /** 本区域最近一次拉到的模型目录。 */
229
+ let catalog = []
230
+
231
+ const shim = await createShim({
232
+ logger,
233
+ picker,
234
+ usage,
235
+ adapter: upstreamAdapter,
236
+ // 每次请求现读账号 + 现取可用 token,保证用最新凭证。
237
+ listAccounts: async () => {
238
+ const list = await store.list()
239
+ const out = []
240
+ for (const account of list) {
241
+ try {
242
+ out.push({ id: account.id, credential: await store.usable(account.id) })
243
+ } catch (error) {
244
+ logger.warn(`账号 ${account.id} 取凭据失败:${error?.message ?? error}`)
245
+ }
246
+ }
247
+ return out
248
+ },
249
+ })
250
+
251
+ const buildModels = () => catalog.map(info => toPiModel(info, `${shim.baseUrl()}/v1`, providerId))
252
+
253
+ const base = createProvider({
254
+ id: providerId,
255
+ name: REGION_DISPLAY_NAMES[region],
256
+ auth: {
257
+ apiKey: {
258
+ name: 'WorkBuddy loopback shim secret',
259
+ async resolve({ credential }) {
260
+ const key = credential?.key
261
+ return key === undefined || key.length === 0
262
+ ? undefined
263
+ : { auth: { apiKey: key }, source: 'WorkBuddy' }
264
+ },
265
+ },
266
+ },
267
+ models: buildModels(),
268
+ api: openAICompletionsApi(),
269
+ })
270
+
271
+ // getModels 走实时读取:目录刷新后不必重建 provider。
272
+ const provider = { ...base, getModels: () => buildModels() }
273
+
274
+ const profile = {
275
+ provider: providerId,
276
+ displayName: REGION_DISPLAY_NAMES[region],
277
+ streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
278
+ retryPolicy: resolveRetryPolicy(undefined, 'account-pool retryPolicy'),
279
+ configuredMaxTokens: new Map(),
280
+ modelErrors: new Map(),
281
+ ...REQUEST_IMAGE_BUDGETS,
282
+ piProvider: provider,
283
+ }
284
+
285
+ let profiles = new Map([[providerId, profile]])
286
+ const adapter = new PiAiAdapter({
287
+ profiles: () => profiles,
288
+ auth: INERT_AUTH,
289
+ // pi-ai 把这个值当 apiKey 发成 Bearer,shim 校验它;
290
+ // 真实 WorkBuddy token 由 shim 自己从 store 取,不经过 pi-ai。
291
+ resolveApiKey: async () => shim.token(),
292
+ ...(ctx.get('attachments') === undefined ? {} : { resolveAttachments: () => ctx.get('attachments') }),
293
+ })
294
+
295
+ /**
296
+ * 积分缓存:积分变化很慢(签到/消耗才动),而设置页会频繁刷新,
297
+ * 所以缓存 60 秒,避免每次刷新都打上游计费接口。
298
+ */
299
+ let creditsCache = { at: 0, value: null }
300
+
301
+ /** 查各账号积分(带缓存);单个账号失败不影响其他账号。 */
302
+ const loadCredits = async (force = false) => {
303
+ const now = Date.now()
304
+ if (!force && creditsCache.value !== null && now - creditsCache.at < 60_000) {
305
+ return creditsCache.value
306
+ }
307
+ const out = {}
308
+ for (const account of await store.list()) {
309
+ try {
310
+ const credential = await store.usable(account.id)
311
+ out[account.id] = await fetchRegionCredits(credential)
312
+ // 顺手把总积分喂给选号器,让加权用上真实余额。
313
+ picker.setCredits(account.id, out[account.id].total)
314
+ } catch (error) {
315
+ logger.warn(`账号 ${account.id} 查询积分失败:${error?.message ?? error}`)
316
+ }
317
+ }
318
+ creditsCache = { at: now, value: out }
319
+ return out
320
+ }
321
+
322
+ /** 拉一次模型目录;没有账号就清空。 */
323
+ const refreshCatalog = async () => {
324
+ const list = await store.list()
325
+ if (list.length === 0) {
326
+ catalog = []
327
+ return []
328
+ }
329
+ // 用第一个可用账号拉目录——目录是账号级而非全局的。
330
+ const credential = await store.usable(list[0].id)
331
+ catalog = await fetchCatalog(credential)
332
+ return catalog
333
+ }
334
+
335
+ return {
336
+ region,
337
+ providerId,
338
+ isTrae,
339
+ runTasks,
340
+ // 只读状态:Trae 用 readStatus,WorkBuddy 用现有的那组查询
341
+ readStatus: isTrae ? traeReadStatus : undefined,
342
+ store,
343
+ picker,
344
+ shim,
345
+ adapter,
346
+ usage,
347
+ refreshCatalog,
348
+ loadCredits,
349
+ getCatalog: () => catalog,
350
+ }
351
+ }
352
+
353
+ /**
354
+ * 插件入口。
355
+ *
356
+ * @param {import('@deepseek-ai/cordis').Context} ctx
357
+ * @param {object} config cordis.patch.yml 提供的配置
358
+ */
359
+ export function apply(ctx, config = {}) {
360
+ const regions = Array.isArray(config.regions) && config.regions.length > 0
361
+ ? config.regions.filter(r => r === 'cn' || r === 'global' || r === 'trae')
362
+ : ['cn']
363
+ const logger = config.logger ?? {
364
+ info: (...args) => console.log('[account-pool]', ...args),
365
+ warn: (...args) => console.warn('[account-pool]', ...args),
366
+ error: (...args) => console.error('[account-pool]', ...args),
367
+ }
368
+ // 用官方的 resolveDshHome:它认识 DSH_HOME、HOME 与平台默认值。
369
+ // 之前用 process.env.DSH_HOME ?? process.cwd() 是错的——宿主进程里
370
+ // DSH_HOME 常常没设,cwd 又不是 .dsh,导致凭证落到别处、读不回来。
371
+ const dshHome = resolveDshHome()
372
+
373
+ /** @type {Map<string, object>} providerId → stack */
374
+ const stacks = new Map()
375
+ let stopped = false
376
+
377
+ // ---- shim 与 adapter 装配 ----
378
+ // 先起 shim(拿端口),再构造 adapter,最后注册路由。
379
+ const assembly = Promise.all(regions.map(async region => {
380
+ const stack = await createRegionStack({ region, dshHome, config, logger, ctx })
381
+ if (stopped) {
382
+ await stack.shim.close()
383
+ return undefined
384
+ }
385
+ stacks.set(stack.providerId, stack)
386
+ // 起完再异步拉目录,不拖慢插件加载。
387
+ void stack.refreshCatalog()
388
+ .then(models => logger.info(`区域 ${region} 就绪,${models.length} 个模型`))
389
+ .catch(error => logger.warn(`区域 ${region} 拉目录失败:${error?.message ?? error}`))
390
+ return stack
391
+ }))
392
+
393
+ /** 供 web 路由与工具使用的「等到装配完成」。 */
394
+ const ready = assembly.then(list => list.filter(s => s !== undefined))
395
+
396
+ // ---- 注册 settings 命名空间 + 模型发现器 ----
397
+ //
398
+ // 设置页的「模型」界面会遍历可配置 provider 并调用模型发现。我们的
399
+ // provider 是运行时注册的、没有配置式 baseURL,若不注册发现器,
400
+ // 界面就会报 "pi-ai ships no catalog for provider ..."。
401
+ //
402
+ // 发现顺序:先用在线账号实时拉(拿得到最新清单),失败则回落到
403
+ // 上次保存的目录快照,都没有就返回空数组——让界面显示「暂无模型」
404
+ // 而不是抛错。
405
+ ctx.effect(() => {
406
+ let scope
407
+ try {
408
+ scope = ctx.settings.register(SETTINGS_NS, Config, { applies: 'live' })
409
+ } catch (error) {
410
+ logger.error(`注册 settings 命名空间 ${SETTINGS_NS} 失败:${error?.message ?? error}`)
411
+ return
412
+ }
413
+
414
+ const releaseDiscovery = ctx.llm.registerModelDiscovery(SETTINGS_NS, async (request) => {
415
+ const list = await ready
416
+ const stack = list.find(s => s.providerId === request.provider)
417
+ if (stack === undefined) return []
418
+
419
+ // 先试实时拉取:能拿到就顺手回写快照,供下次离线兜底。
420
+ try {
421
+ const models = await stack.refreshCatalog()
422
+ if (models.length > 0) {
423
+ persistCatalog(scope, stack.region, models)
424
+ return models.map(toDiscoveredModel)
425
+ }
426
+ } catch (error) {
427
+ logger.warn(`发现 ${request.provider} 模型失败,改用缓存:${error?.message ?? error}`)
428
+ }
429
+
430
+ // 实时拉不到(没账号 / 网络不通)→ 用上次的快照。
431
+ const saved = scope.get()?.catalog?.[stack.region]
432
+ return Array.isArray(saved) ? saved.map(toDiscoveredModel) : []
433
+ })
434
+
435
+ // 命名空间与发现器都跟着本 effect 的生命周期自动注销,无需手动 dispose。
436
+ return () => {
437
+ releaseDiscovery()
438
+ }
439
+ })
440
+
441
+ // ---- 注册 provider 路由 ----
442
+ ctx.effect(() => {
443
+ let releases = []
444
+ void ready.then(list => {
445
+ if (stopped) return
446
+ // 单个区域注册失败不能拖垮另一个区域。
447
+ for (const stack of list) {
448
+ try {
449
+ releases.push(ctx.llm.registerAdapter([stack.providerId], stack.adapter))
450
+ } catch (error) {
451
+ logger.error(`注册 provider ${stack.providerId} 失败:${error?.message ?? error}`)
452
+ }
453
+ }
454
+ try {
455
+ ctx.llm.registerConfigurableProviders(list.map(stack => ({
456
+ provider: stack.providerId,
457
+ displayName: REGION_DISPLAY_NAMES[stack.region],
458
+ settingsNs: SETTINGS_NS,
459
+ settingsPath: [],
460
+ declared: false,
461
+ })))
462
+ } catch (error) {
463
+ logger.warn(`登记可配置 provider 失败:${error?.message ?? error}`)
464
+ }
465
+ })
466
+ return () => {
467
+ for (const release of releases) release()
468
+ releases = []
469
+ }
470
+ })
471
+
472
+ // ---- 卸载清理:关 shim、落盘用量、注销 adapter ----
473
+ ctx.effect(() => () => {
474
+ stopped = true
475
+ for (const stack of stacks.values()) {
476
+ void stack.shim.close()
477
+ // 用量数据要落盘,否则重启丢统计
478
+ void stack.usage.close()
479
+ }
480
+ stacks.clear()
481
+ })
482
+
483
+ // 粘性绑定定期清理,防止 Map 无限增长。
484
+ ctx.effect(() => {
485
+ const timer = setInterval(() => {
486
+ for (const stack of stacks.values()) stack.picker.sweepSticky()
487
+ }, 5 * 60_000)
488
+ // unref:定时器不该拖住宿主进程退出(保活定时器同样处理)。
489
+ timer.unref?.()
490
+ return () => clearInterval(timer)
491
+ })
492
+
493
+ // ---- 凭证保活:避免闲置太久导致到期 ----
494
+ //
495
+ // 只在「有请求」时刷新的话,闲置超过 access token 有效期就只剩 refresh
496
+ // token 能救;再久一点连 refresh token 也过期,账号彻底作废。
497
+ // 这里每 6 小时检查一次,把剩余不足 7 天的账号提前续上。
498
+ ctx.effect(() => {
499
+ const KEEPALIVE_INTERVAL_MS = 6 * 3600_000
500
+ const runKeepAlive = async () => {
501
+ for (const [providerId, stack] of stacks) {
502
+ try {
503
+ const result = await stack.store.keepAlive(7)
504
+ if (result.refreshed > 0 || result.failed > 0) {
505
+ logger.info(`保活 ${providerId}:续期 ${result.refreshed},失败 ${result.failed},跳过 ${result.skipped}`)
506
+ }
507
+ } catch (error) {
508
+ logger.warn(`保活 ${providerId} 异常:${error?.message ?? error}`)
509
+ }
510
+ }
511
+ }
512
+ // 启动后先跑一次(延迟 30 秒,避免和插件装配、拉目录抢资源)
513
+ const initial = setTimeout(() => { void runKeepAlive() }, 30_000)
514
+ initial.unref?.()
515
+ const timer = setInterval(() => { void runKeepAlive() }, KEEPALIVE_INTERVAL_MS)
516
+ timer.unref?.()
517
+ return () => {
518
+ clearTimeout(initial)
519
+ clearInterval(timer)
520
+ }
521
+ })
522
+
523
+ // ---- 同源 web 路由:设置页靠它读状态、拉二维码、加账号 ----
524
+ ctx.inject(['webServer'], (webCtx) => {
525
+ const logger2 = logger
526
+ /**
527
+ * 判断这次请求是不是「从这台机器本身上」发起的。
528
+ *
529
+ * 用途:Trae 回调写死 127.0.0.1(浏览器所在机器)。只有当用户
530
+ * 就在这台机器上用浏览器时,自动回调才有意义。
531
+ *
532
+ * 判据是请求的 Host 是否为回环地址——用户用 `127.0.0.1:3081`
533
+ * 打开页面时 Host 就是回环;用 NAS 的 IP 打开时是内网 IP。
534
+ *
535
+ * 注意:DSH 前面可能挂透明代理(把 Host 改写成 127.0.0.1),
536
+ * 那种情况下这个判断会失效并倾向于「自动可用」。那时如果实际
537
+ * 收不到回调,用户仍可点对话框里的「改用手动粘贴」兜底。
538
+ */
539
+ const effectiveHost = (req) => {
540
+ // 优先看 x-forwarded-host:DSH 前面可能挂透明代理(本部署就是),
541
+ // 它会把 host 改写成 127.0.0.1:<port>,只看 host 会一律误判成「本机」。
542
+ // 原始 Host 被代理保存在 x-forwarded-host 里。
543
+ const forwarded = req.headers['x-forwarded-host']
544
+ return String(
545
+ (Array.isArray(forwarded) ? forwarded[0] : forwarded)
546
+ ?? req.headers.host
547
+ ?? '',
548
+ )
549
+ }
550
+
551
+ const isSameMachineRequest = (req) => {
552
+ const host = effectiveHost(req)
553
+ const hostname = host.startsWith('[')
554
+ ? host.slice(0, host.indexOf(']') + 1)
555
+ : host.split(':')[0]
556
+ return hostname === 'localhost' || hostname === '127.0.0.1'
557
+ || hostname === '[::1]' || hostname === '::1'
558
+ }
559
+
560
+ /**
561
+ * 请求来源校验:**只放行同源请求**。
562
+ *
563
+ * 目的:防止别的网站(CSRF)偷偷调用这些接口去动你的账号。
564
+ *
565
+ * 早期实现只认 `127.0.0.1`,这在「DSH 装在服务器、用局域网地址访问」
566
+ * 的部署下会把用户自己的所有请求都拒掉(用 `192.0.2.10:3080` 这类
567
+ * 非回环地址打开时,接口会全部 403)。
568
+ *
569
+ * 正确判据是「Origin 与本次请求的 Host 是否一致」——
570
+ * 用户自己打开的那个页面发起的请求天然同源;而 evil.com 发的
571
+ * 跨源请求 Host 对不上,会被拒。
572
+ *
573
+ * 没有 Origin 头时不拒:那是非浏览器客户端(curl / 本地工具),
574
+ * 不存在 CSRF 场景。
575
+ */
576
+ const loopbackOrigin = (req) => {
577
+ const origin = req.headers.origin
578
+ if (origin === undefined || origin === '') return true
579
+
580
+ let from
581
+ try {
582
+ from = new URL(origin)
583
+ } catch {
584
+ return false
585
+ }
586
+ // 非 http(s) 来源一律拒(file:、null 等)
587
+ if (from.protocol !== 'http:' && from.protocol !== 'https:') return false
588
+
589
+ // 回环地址始终放行:本机工具、SSH 隧道等场景
590
+ const loopback = ['localhost', '127.0.0.1', '[::1]', '::1']
591
+ if (loopback.includes(from.hostname)) return true
592
+
593
+ // 同源判定:Origin 的 host:port 要和请求的 Host 一致
594
+ const host = String(req.headers.host ?? '')
595
+ if (host === '') return false
596
+ return from.host === host
597
+ }
598
+ /** 错误信息脱敏后再给浏览器,避免把 token 带出去。 */
599
+ const safeMessage = (error) => String(error?.message ?? error)
600
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, '[redacted token]')
601
+ .slice(0, 500)
602
+
603
+ const json = (res, status, body) => {
604
+ const payload = JSON.stringify(body)
605
+ res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) })
606
+ res.end(payload)
607
+ }
608
+
609
+ const readBody = async (req) => {
610
+ const chunks = []
611
+ for await (const chunk of req) chunks.push(chunk)
612
+ if (chunks.length === 0) return {}
613
+ try {
614
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
615
+ } catch {
616
+ return {}
617
+ }
618
+ }
619
+
620
+ /** /account-pool/state —— 一次拿全:账号列表 + 各账号健康状态 + 目录大小。 */
621
+ const disposeState = webCtx.webServer.register({
622
+ kind: 'exact',
623
+ path: '/account-pool/state',
624
+ handler: async (req, res) => {
625
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
626
+ try {
627
+ const list = await ready
628
+ const out = {}
629
+ for (const stack of list) {
630
+ const snapshot = stack.picker.snapshot()
631
+ out[stack.region] = {
632
+ provider: stack.providerId,
633
+ displayName: REGION_DISPLAY_NAMES[stack.region],
634
+ models: stack.getCatalog().length,
635
+ accounts: (await (async () => {
636
+ const listed = await stack.store.list()
637
+ const credits = await stack.loadCredits()
638
+ return listed.map(account => ({
639
+ id: account.id,
640
+ nickname: account.nickname,
641
+ uid: account.uid,
642
+ domain: account.domain,
643
+ expiresAtMs: account.expiresAtMs,
644
+ ...snapshot[account.id],
645
+ cooling: snapshot[account.id]?.cooling ?? false,
646
+ // 积分:剩余 + 总量 + 套餐明细(界面展示用)。
647
+ // size 供悬停提示展示「总量 / 已用 / 剩余」构成。
648
+ credits: credits[account.id]?.total,
649
+ creditsSize: credits[account.id]?.size,
650
+ creditPackages: credits[account.id]?.packages?.slice(0, 5) ?? [],
651
+ }))
652
+ })()),
653
+ }
654
+ }
655
+ json(res, 200, { ok: true, regions: out })
656
+ } catch (error) {
657
+ json(res, 500, { ok: false, error: safeMessage(error) })
658
+ }
659
+ },
660
+ })
661
+
662
+ /** /account-pool/login/start —— 申请授权链接。 */
663
+ const disposeLoginStart = webCtx.webServer.register({
664
+ kind: 'exact',
665
+ path: '/account-pool/login/start',
666
+ handler: async (req, res) => {
667
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
668
+ try {
669
+ const body = req.method === 'POST' ? await readBody(req) : {}
670
+ const region = normalizeRegion(body.region)
671
+ const { state, authUrl } = await startDeviceLogin(region)
672
+ json(res, 200, { ok: true, state, authUrl, region })
673
+ } catch (error) {
674
+ json(res, 500, { ok: false, error: safeMessage(error) })
675
+ }
676
+ },
677
+ })
678
+
679
+ /** /account-pool/login/poll —— 轮询登录是否完成,完成则落盘。 */
680
+ const disposeLoginPoll = webCtx.webServer.register({
681
+ kind: 'exact',
682
+ path: '/account-pool/login/poll',
683
+ handler: async (req, res) => {
684
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
685
+ try {
686
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
687
+ const region = normalizeRegion(url.searchParams.get('region'))
688
+ const state = url.searchParams.get('state') ?? ''
689
+ const list = await ready
690
+ const stack = list.find(s => s.region === region)
691
+ if (stack === undefined) { json(res, 503, { ok: false, error: '该区域尚未初始化' }); return }
692
+
693
+ const result = await pollDeviceLogin(region, state)
694
+ if (result.pending) { json(res, 200, { ok: true, pending: true }); return }
695
+
696
+ await stack.store.save(result.credential)
697
+ // 新账号进来后立刻刷目录,让模型马上可选。
698
+ await stack.refreshCatalog().catch(error => logger2.warn(`刷新目录失败:${error?.message ?? error}`))
699
+ json(res, 200, {
700
+ ok: true,
701
+ pending: false,
702
+ account: { id: result.credential.id, nickname: result.credential.nickname ?? result.credential.uid },
703
+ })
704
+ } catch (error) {
705
+ json(res, 500, { ok: false, error: safeMessage(error) })
706
+ }
707
+ },
708
+ })
709
+
710
+ /**
711
+ * /account-pool/trae/import —— 导入 Trae 桌面端凭证。
712
+ *
713
+ * 为什么要导入文件而不是网页登录:Trae 授权页把 token fetch 投递到
714
+ * `127.0.0.1:18080`。DSH 在 NAS、浏览器在另一台电脑时,那个 127.0.0.1
715
+ * 指用户自己的电脑——请求被拒,页面报「网络错误」;又因为是 fetch
716
+ * 而非导航,地址栏不会出现带 token 的链接,连复制都拿不到。
717
+ *
718
+ * 所以改为:用户在 Windows/macOS 上登录 Trae,把 storage.json 内容
719
+ * 粘过来(或给出服务器上的文件路径)。解密不依赖本机硬件。
720
+ */
721
+ const disposeTraeImport = webCtx.webServer.register({
722
+ kind: 'exact',
723
+ path: '/account-pool/trae/import',
724
+ handler: async (req, res) => {
725
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
726
+ try {
727
+ const body = await readBody(req)
728
+
729
+ // 两条来源:直接粘贴内容,或给服务器上的路径
730
+ let content = String(body.content ?? '').trim()
731
+ const filePath = String(body.path ?? '').trim()
732
+ if (content === '' && filePath !== '') {
733
+ try {
734
+ content = (await readFile(filePath, 'utf8')).trim()
735
+ } catch (error) {
736
+ json(res, 400, { ok: false, error: `读取文件失败:${safeMessage(error)}` }); return
737
+ }
738
+ }
739
+ if (content === '') { json(res, 400, { ok: false, error: '请粘贴 storage.json 内容,或给出文件路径' }); return }
740
+
741
+ let parsed
742
+ try {
743
+ parsed = parseTraeStorage(content)
744
+ } catch (error) {
745
+ json(res, 400, { ok: false, error: safeMessage(error) }); return
746
+ }
747
+
748
+ const list = await ready
749
+ const stack = list.find(s => s.region === 'trae')
750
+ if (stack === undefined) { json(res, 503, { ok: false, error: 'Trae 区域未启用(检查 config.regions)' }); return }
751
+
752
+ // 归一化成插件内部的凭证结构
753
+ const credential = {
754
+ id: accountIdOfTrae(parsed.userId !== '' ? parsed.userId : parsed.accountName),
755
+ uid: parsed.userId,
756
+ nickname: parsed.accountName !== '' ? parsed.accountName : parsed.userId,
757
+ accessToken: parsed.accessToken,
758
+ refreshToken: parsed.refreshToken,
759
+ expiresAtMs: parsed.expiresAtMs,
760
+ region: parsed.region,
761
+ machineId: parsed.machineId,
762
+ deviceId: parsed.deviceId,
763
+ }
764
+ await stack.store.save(credential)
765
+ await stack.refreshCatalog().catch(error => logger.warn(`Trae 刷新目录失败:${error?.message ?? error}`))
766
+
767
+ json(res, 200, {
768
+ ok: true,
769
+ account: { id: credential.id, nickname: credential.nickname },
770
+ // 顺带回报一些信息,便于用户确认导入的是哪个账号
771
+ region: parsed.region,
772
+ hasRefreshToken: parsed.refreshToken !== '',
773
+ expiresAtMs: parsed.expiresAtMs,
774
+ })
775
+ } catch (error) {
776
+ json(res, 500, { ok: false, error: safeMessage(error) })
777
+ }
778
+ },
779
+ })
780
+
781
+ /** /account-pool/login/import —— 从 auth 文件导入账号(粘贴内容或给路径)。 */
782
+ const disposeImport = webCtx.webServer.register({
783
+ kind: 'exact',
784
+ path: '/account-pool/login/import',
785
+ handler: async (req, res) => {
786
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
787
+ try {
788
+ const body = await readBody(req)
789
+ const region = normalizeRegion(body.region)
790
+ const list = await ready
791
+ const stack = list.find(s => s.region === region)
792
+ if (stack === undefined) { json(res, 503, { ok: false, error: '该区域尚未初始化' }); return }
793
+
794
+ // 两种来源:content(粘贴的 JSON)或 path(服务器上的文件/目录)
795
+ let texts = []
796
+ if (typeof body.content === 'string' && body.content.trim() !== '') {
797
+ texts.push({ source: '粘贴内容', text: body.content })
798
+ } else if (typeof body.path === 'string' && body.path.trim() !== '') {
799
+ const path = body.path.trim()
800
+ // 注意变量名:这里叫 info,不能用 stat——那会遮蔽从 fs 导入的
801
+ // stat 函数,导致 await stat(path) 调用到未初始化的局部变量。
802
+ let info
803
+ try {
804
+ info = await stat(path)
805
+ } catch {
806
+ json(res, 400, { ok: false, error: `路径不存在或无法访问:${path}` })
807
+ return
808
+ }
809
+ if (info.isDirectory()) {
810
+ // 目录:把所有 *.json 都当候选,逐个试
811
+ const entries = await readdir(path)
812
+ for (const name of entries.filter(n => n.endsWith('.json')).sort()) {
813
+ try {
814
+ texts.push({ source: name, text: await readFile(join(path, name), 'utf8') })
815
+ } catch {
816
+ // 单个文件读不了就跳过
817
+ }
818
+ }
819
+ if (texts.length === 0) {
820
+ json(res, 400, { ok: false, error: `目录里没有 .json 文件:${path}` })
821
+ return
822
+ }
823
+ } else {
824
+ texts.push({ source: path.split('/').pop() || path, text: await readFile(path, 'utf8') })
825
+ }
826
+ } else {
827
+ json(res, 400, { ok: false, error: '请提供 content(粘贴 JSON)或 path(文件/目录路径)' })
828
+ return
829
+ }
830
+
831
+ // 逐个解析并落盘;失败的收集起来如实回报,不假装成功。
832
+ const imported = []
833
+ const failed = []
834
+ for (const { source, text } of texts) {
835
+ try {
836
+ const credential = parseAuthFile(text, source)
837
+ // 区域不匹配的跳过:国际版凭证进国内版池子只会一直失败。
838
+ if (credential.region !== region) {
839
+ failed.push({ source, error: `属于 ${credential.region} 区域,与当前 ${region} 不符` })
840
+ continue
841
+ }
842
+ await stack.store.save(credential)
843
+ imported.push({
844
+ id: credential.id,
845
+ nickname: credential.nickname ?? credential.uid ?? credential.id.slice(0, 8),
846
+ })
847
+ } catch (error) {
848
+ failed.push({ source, error: error?.message ?? String(error) })
849
+ }
850
+ }
851
+
852
+ // 有新账号就刷新目录与积分
853
+ if (imported.length > 0) {
854
+ await stack.refreshCatalog().catch(error => logger.warn(`导入后刷新目录失败:${error?.message ?? error}`))
855
+ }
856
+
857
+ json(res, 200, { ok: true, imported, failed })
858
+ } catch (error) {
859
+ json(res, 500, { ok: false, error: safeMessage(error) })
860
+ }
861
+ },
862
+ })
863
+
864
+ /** /account-pool/remove —— 移除账号。 */
865
+ const disposeRemove = webCtx.webServer.register({
866
+ kind: 'exact',
867
+ path: '/account-pool/remove',
868
+ handler: async (req, res) => {
869
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
870
+ try {
871
+ const body = await readBody(req)
872
+ const region = normalizeRegion(body.region)
873
+ const list = await ready
874
+ const stack = list.find(s => s.region === region)
875
+ if (stack === undefined) { json(res, 503, { ok: false, error: '该区域尚未初始化' }); return }
876
+ const removed = await stack.store.remove(String(body.accountId ?? ''))
877
+ await stack.refreshCatalog().catch(() => {})
878
+ json(res, 200, { ok: true, removed })
879
+ } catch (error) {
880
+ json(res, 500, { ok: false, error: safeMessage(error) })
881
+ }
882
+ },
883
+ })
884
+
885
+ /** /account-pool/revive —— 手动解冻账号。 */
886
+ const disposeRevive = webCtx.webServer.register({
887
+ kind: 'exact',
888
+ path: '/account-pool/revive',
889
+ handler: async (req, res) => {
890
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
891
+ try {
892
+ const body = await readBody(req)
893
+ const region = normalizeRegion(body.region)
894
+ const list = await ready
895
+ const stack = list.find(s => s.region === region)
896
+ if (stack === undefined) { json(res, 503, { ok: false, error: '该区域尚未初始化' }); return }
897
+ stack.picker.revive(String(body.accountId ?? ''))
898
+ json(res, 200, { ok: true })
899
+ } catch (error) {
900
+ json(res, 500, { ok: false, error: safeMessage(error) })
901
+ }
902
+ },
903
+ })
904
+
905
+ /** /account-pool/tasks/daily —— 对指定区域的全部账号执行每日任务。 */
906
+ const disposeTasksDaily = webCtx.webServer.register({
907
+ kind: 'exact',
908
+ path: '/account-pool/tasks/daily',
909
+ handler: async (req, res) => {
910
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
911
+ try {
912
+ const body = await readBody(req)
913
+ const region = normalizeRegion(body.region)
914
+ const list = await ready
915
+ const stack = list.find(s => s.region === region)
916
+ if (stack === undefined) { json(res, 503, { ok: false, error: '该区域尚未初始化' }); return }
917
+
918
+ const accounts = await stack.store.list()
919
+ if (accounts.length === 0) { json(res, 200, { ok: true, results: [] }); return }
920
+
921
+ // 账号间串行:上游对短时间内的密集请求有风控,串行更稳。
922
+ const results = []
923
+ for (const account of accounts) {
924
+ try {
925
+ const credential = await stack.store.usable(account.id)
926
+ // 任务因上游而异:Trae 只有签到+积分,WorkBuddy 还有旅行/兑换/抽奖
927
+ results.push(await stack.runTasks(credential))
928
+ } catch (error) {
929
+ results.push({
930
+ accountId: account.id,
931
+ nickname: account.nickname,
932
+ error: safeMessage(error),
933
+ })
934
+ }
935
+ }
936
+
937
+ // 任务跑完刷新积分与目录
938
+ await stack.loadCredits(true).catch(() => {})
939
+ json(res, 200, { ok: true, results })
940
+ } catch (error) {
941
+ json(res, 500, { ok: false, error: safeMessage(error) })
942
+ }
943
+ },
944
+ })
945
+
946
+ /** /account-pool/tasks/status —— 只读:各账号的连登/猫/旅行状态。 */
947
+ const disposeTasksStatus = webCtx.webServer.register({
948
+ kind: 'exact',
949
+ path: '/account-pool/tasks/status',
950
+ handler: async (req, res) => {
951
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
952
+ try {
953
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
954
+ // 三个区域都要认:之前只认 global/cn,传 trae 会被静默当成 cn,
955
+ // 导致 Trae 面板显示 WorkBuddy 的账号。
956
+ const region = normalizeRegion(url.searchParams.get('region'))
957
+ const list = await ready
958
+ const stack = list.find(s => s.region === region)
959
+ if (stack === undefined) { json(res, 503, { ok: false, error: '该区域尚未初始化' }); return }
960
+
961
+ const out = []
962
+ for (const account of await stack.store.list()) {
963
+ try {
964
+ const credential = await stack.store.usable(account.id)
965
+
966
+ // Trae 是另一套任务体系,且**有只读的签到状态接口**,
967
+ // 所以它连签到都不用靠「上次执行结果」猜。
968
+ if (stack.readStatus !== undefined) {
969
+ const status = await stack.readStatus(credential)
970
+ out.push({
971
+ accountId: account.id,
972
+ nickname: account.nickname ?? credential.uid,
973
+ checkin: status.checkin,
974
+ credits: status.credits,
975
+ creditsSize: status.creditsSize,
976
+ ...(status.checkinError === undefined ? {} : { checkinError: status.checkinError }),
977
+ ...(status.creditsError === undefined ? {} : { creditsError: status.creditsError }),
978
+ })
979
+ continue
980
+ }
981
+
982
+ // WorkBuddy:全部是只读查询,不签到、不派出、不兑换。
983
+ // 签到没有只读状态接口,所以这里不查它——由前端显示上次执行结果。
984
+ const [streak, buddy, travelNow, lottery] = await Promise.all([
985
+ fetchStreak(credential),
986
+ fetchBuddy(credential),
987
+ fetchTravelStatus(credential),
988
+ fetchLottery(credential),
989
+ ])
990
+ out.push({
991
+ accountId: account.id,
992
+ nickname: account.nickname ?? credential.uid,
993
+ streak,
994
+ buddy,
995
+ travel: travelNow,
996
+ lottery,
997
+ ...(credential.expiresAtMs === undefined ? {} : { expiresAtMs: credential.expiresAtMs }),
998
+ })
999
+ } catch (error) {
1000
+ out.push({ accountId: account.id, nickname: account.nickname, error: safeMessage(error) })
1001
+ }
1002
+ }
1003
+ json(res, 200, { ok: true, accounts: out })
1004
+ } catch (error) {
1005
+ json(res, 500, { ok: false, error: safeMessage(error) })
1006
+ }
1007
+ },
1008
+ })
1009
+
1010
+ /** /account-pool/usage —— 用量统计(小时/日桶聚合视图)。 */
1011
+ const disposeUsage = webCtx.webServer.register({
1012
+ kind: 'exact',
1013
+ path: '/account-pool/usage',
1014
+ handler: async (req, res) => {
1015
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
1016
+ try {
1017
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1')
1018
+ const hours = Number(url.searchParams.get('hours')) || 72
1019
+ const list = await ready
1020
+ const out = {}
1021
+ for (const stack of list) {
1022
+ out[stack.region] = stack.usage.snapshot(hours)
1023
+ }
1024
+ json(res, 200, { ok: true, regions: out })
1025
+ } catch (error) {
1026
+ json(res, 500, { ok: false, error: safeMessage(error) })
1027
+ }
1028
+ },
1029
+ })
1030
+
1031
+ /** /account-pool/credits —— 强制刷新积分(绕过缓存)。 */
1032
+ const disposeCredits = webCtx.webServer.register({
1033
+ kind: 'exact',
1034
+ path: '/account-pool/credits',
1035
+ handler: async (req, res) => {
1036
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
1037
+ try {
1038
+ const list = await ready
1039
+ for (const stack of list) await stack.loadCredits(true)
1040
+ json(res, 200, { ok: true })
1041
+ } catch (error) {
1042
+ json(res, 500, { ok: false, error: safeMessage(error) })
1043
+ }
1044
+ },
1045
+ })
1046
+
1047
+ /** /account-pool/refresh —— 重新拉模型目录。 */
1048
+ const disposeRefresh = webCtx.webServer.register({
1049
+ kind: 'exact',
1050
+ path: '/account-pool/refresh',
1051
+ handler: async (req, res) => {
1052
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
1053
+ try {
1054
+ const body = await readBody(req)
1055
+ const region = normalizeRegion(body.region)
1056
+ const list = await ready
1057
+ const stack = list.find(s => s.region === region)
1058
+ if (stack === undefined) { json(res, 503, { ok: false, error: '该区域尚未初始化' }); return }
1059
+ const models = await stack.refreshCatalog()
1060
+ json(res, 200, { ok: true, models: models.length })
1061
+ } catch (error) {
1062
+ json(res, 500, { ok: false, error: safeMessage(error) })
1063
+ }
1064
+ },
1065
+ })
1066
+
1067
+ /** /account-pool/logout —— 清空某区域全部账号。 */
1068
+ const disposeLogout = webCtx.webServer.register({
1069
+ kind: 'exact',
1070
+ path: '/account-pool/logout',
1071
+ handler: async (req, res) => {
1072
+ if (!loopbackOrigin(req)) { json(res, 403, { ok: false, error: '仅允许本机访问' }); return }
1073
+ try {
1074
+ const body = await readBody(req)
1075
+ const region = normalizeRegion(body.region)
1076
+ await clearStore(dshHome, region)
1077
+ const list = await ready
1078
+ const stack = list.find(s => s.region === region)
1079
+ if (stack !== undefined) await stack.refreshCatalog().catch(() => {})
1080
+ json(res, 200, { ok: true })
1081
+ } catch (error) {
1082
+ json(res, 500, { ok: false, error: safeMessage(error) })
1083
+ }
1084
+ },
1085
+ })
1086
+
1087
+ webCtx.effect(() => () => {
1088
+ disposeState()
1089
+ disposeLoginStart()
1090
+ disposeLoginPoll()
1091
+ disposeImport()
1092
+ disposeTraeImport()
1093
+ disposeRemove()
1094
+ disposeRevive()
1095
+ disposeTasksDaily()
1096
+ disposeTasksStatus()
1097
+ disposeUsage()
1098
+ disposeCredits()
1099
+ disposeRefresh()
1100
+ disposeLogout()
1101
+ })
1102
+ })
1103
+ }
1104
+
1105
+ /**
1106
+ * 内部模型描述 → DSH 模型发现接口要的形状。
1107
+ * 只带发现接口认识的字段。
1108
+ */
1109
+ function toDiscoveredModel(info) {
1110
+ return {
1111
+ id: info.id,
1112
+ name: displayNameOf(info),
1113
+ contextWindow: info.contextWindow,
1114
+ maxTokens: info.maxTokens,
1115
+ inputModalities: info.multimodal === true ? ['text', 'image'] : ['text'],
1116
+ }
1117
+ }
1118
+
1119
+ /**
1120
+ * 把刚拉到的目录写进 settings 快照,供下次离线时兜底。
1121
+ * 写失败不影响本次返回——快照只是优化,不是必需品。
1122
+ */
1123
+ function persistCatalog(scope, region, models) {
1124
+ try {
1125
+ const current = scope.get()?.catalog ?? {}
1126
+ const next = {
1127
+ ...current,
1128
+ [region]: models.map(m => ({
1129
+ id: m.id,
1130
+ name: m.name,
1131
+ contextWindow: m.contextWindow,
1132
+ maxTokens: m.maxTokens,
1133
+ multimodal: m.multimodal === true,
1134
+ })),
1135
+ }
1136
+ // update 可能返回 Promise,也可能同步;两种都容忍。
1137
+ const result = scope.update({ catalog: next })
1138
+ if (result && typeof result.catch === 'function') result.catch(() => {})
1139
+ } catch {
1140
+ // 忽略:快照写入失败不该影响模型发现本身
1141
+ }
1142
+ }