dsh-workbuddy2api-hub 0.0.0-stage → 1.0.7

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,1671 @@
1
+ /**
2
+ * dsh-workbuddy2api-hub —— 宿主侧插件。
3
+ *
4
+ * 职责四件事:
5
+ * 1. 连接本地 workbuddy2api-hub:`http://127.0.0.1:8788` + 面板密码(`pwd`)+ 页签(`tab`);
6
+ * 2. 用面板密码换 `X-Panel-Token`,读 hub 的 `/v1/models`、`/usage`、`/accounts`、
7
+ * `/v1/balance-summary`;
8
+ * 3. 把 hub 的模型写进 llm-pi-ai 路由配置,让 DSH 的模型选择器直接出现带倍率、
9
+ * 带上下文窗口、带思考档位的模型,并在 hub 变动后自动重写;
10
+ * 4. 对外暴露一组 HTTP 接口,供设置页卡片展示额度/用量,并提供「打开看板」深链。
11
+ *
12
+ * 设计约束(都是踩过的坑,来自参考项目 dsh-workbuddy2api-panel):
13
+ * - 不 import 任何 @deepseek-ai/* 运行时包。宿主加载失败会直接 exit 1,
14
+ * 而这些包在不同版本间的导出名会变,所以只依赖 cordis 注入的服务对象。
15
+ * - 不做构建。宿主只要求 `main` 能被 import,所以这里就是纯 ESM。
16
+ * - API Key 走 credentials 服务(与 llm-pi-ai 的 `apiKeyEnv` 同一个引用名),
17
+ * 不落盘明文;面板 Token 只存在内存,进程重启即失效重登。
18
+ * - 任何网络失败都降级成一条状态文本,绝不向外抛异常(宿主不能因为 hub 没开而崩)。
19
+ */
20
+
21
+ import { createHash } from 'node:crypto'
22
+ import { readFileSync } from 'node:fs'
23
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
24
+ import { homedir } from 'node:os'
25
+ import { dirname, join, resolve } from 'node:path'
26
+
27
+ import {
28
+ DEFAULT_BASE_URL,
29
+ DEFAULT_PWD,
30
+ DEFAULT_TAB,
31
+ TABS,
32
+ buildDashboardUrl,
33
+ buildRouteModels,
34
+ classifyFailure,
35
+ computeAlerts,
36
+ modelRate,
37
+ normalizeBaseUrl,
38
+ normalizePwd,
39
+ normalizeTab,
40
+ normalizeUsageRange,
41
+ pollIntervalMs,
42
+ reconcileModels,
43
+ retryAfterSeconds,
44
+ routeConfigMatches,
45
+ sortModels,
46
+ summarizeAccounts,
47
+ summarizeBalance,
48
+ summarizeHealth,
49
+ summarizePanelStatus,
50
+ summarizeUsage,
51
+ toModelRow,
52
+ toEpochMs,
53
+ usageQuery,
54
+ } from './hub.js'
55
+
56
+ /** cordis 插件名,需与 cordis.patch.yml 里的 `id` 一致。 */
57
+ export const name = 'workbuddy-hub'
58
+
59
+ /**
60
+ * 插件版本,随快照返回,方便在设置页确认加载的是哪一份代码。
61
+ *
62
+ * 直接从 package.json 读:参考项目里这里写死过版本号,之后包一路升版,
63
+ * 设置页却一直显示旧版本,排查时被误导过一次。
64
+ */
65
+ const PLUGIN_VERSION = (() => {
66
+ try {
67
+ const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
68
+ return typeof manifest.version === 'string' && manifest.version !== '' ? manifest.version : '0.0.0'
69
+ } catch {
70
+ return '0.0.0'
71
+ }
72
+ })()
73
+
74
+ /** hub API Key 在 credentials 里的引用名,也是 llm-pi-ai 路由的 apiKeyEnv。 */
75
+ const DEFAULT_API_KEY_ENV = 'WORKBUDDY2API_HUB_API_KEY'
76
+
77
+ /** llm-pi-ai 插件的 entry id(profile 里的插件实例标识)。 */
78
+ const DEFAULT_PROVIDER_ENTRY = 'llm-pi-ai'
79
+
80
+ /**
81
+ * 写进 llm-pi-ai 的路由键名。
82
+ *
83
+ * 刻意不用参考项目的 `workbuddy2api`,也不用本机既有的 `wb`:这两条可能已经在用户的
84
+ * cordis.patch.yml 里手写过,插件接管别人的路由键会覆盖掉用户自己的模型清单。
85
+ */
86
+ const DEFAULT_ROUTE_KEY = 'workbuddy2api-hub'
87
+
88
+ /** 状态文件默认值。注意:不含 pwd 之外的任何凭据(pwd 由配置显式提供)。 */
89
+ const DEFAULT_STATE = {
90
+ /** hub 根地址,如 http://127.0.0.1:8788。 */
91
+ baseUrl: DEFAULT_BASE_URL,
92
+ /** 面板密码。用户明确给了 `pwd=admin`,就按配置存;也允许在设置页覆盖。 */
93
+ pwd: DEFAULT_PWD,
94
+ /** 看板默认页签。 */
95
+ tab: DEFAULT_TAB,
96
+ /** 是否把 hub 模型同步写进 DSH(用户可关)。 */
97
+ sync: true,
98
+ /** 后台轮询间隔,秒。 */
99
+ intervalSeconds: 120,
100
+ /** llm-pi-ai 的 entry id 与路由键。 */
101
+ providerEntry: DEFAULT_PROVIDER_ENTRY,
102
+ routeKey: DEFAULT_ROUTE_KEY,
103
+ apiKeyEnv: DEFAULT_API_KEY_ENV,
104
+ /** 用户对每个模型的选择:{ [modelId]: { enabled, efforts } }。 */
105
+ selection: {},
106
+ lastSyncAt: 0,
107
+ lastSyncCount: 0,
108
+ lastSyncError: '',
109
+ /** 用量统计窗口:today / week / month / all(hub 的 `/usage` 只认 range)。 */
110
+ usageRange: 'today',
111
+ /** 上一轮同步的模型 id 清单,用来判断新增/下架。 */
112
+ lastModelIds: [],
113
+ /** hub 已下架、但还在宽限期内的模型:`{ [id]: { at, model } }`。 */
114
+ retired: {},
115
+ /** 下架宽限期(小时)。 */
116
+ retireHours: 24,
117
+ /** hub 新上架模型的默认处理:`select` 自动勾选 / `ignore` 不勾选。 */
118
+ newModelPolicy: 'select',
119
+ /** 告警阈值。 */
120
+ thresholds: { lowCredits: 50, unreachablePolls: 5 },
121
+ /** 告警去重:`{ [key]: 上次提示时间戳 }`。 */
122
+ dismissed: {},
123
+ /** 面板会话可用时,自动取用面板里已有的 API Key(存进 credentials,不落状态文件)。 */
124
+ autoApiKey: true,
125
+ /** 上一次生效的「显式配置指纹」,用来判断用户在 YAML 里改了地址/密码。 */
126
+ appliedConfig: '',
127
+ }
128
+
129
+ /** 单次 hub 请求的超时。本地服务 8 秒足够,超时即视为不可达。 */
130
+ const REQUEST_TIMEOUT_MS = 8000
131
+
132
+ /** 登录接口单独的超时:密码校验要跑 PBKDF2,比普通读接口慢。 */
133
+ const LOGIN_TIMEOUT_MS = 12000
134
+
135
+ /**
136
+ * 定位状态文件所在目录。
137
+ *
138
+ * 优先用配置里的 `stateDir`(测试与便携安装用得上:可以钉在任意目录),
139
+ * 其次放 profile 目录(和 cordis.patch.yml 同级),这样多 profile 之间互不干扰;
140
+ * settings 服务不可用时退回 `~/.dsh/profiles/<DSH_PROFILE|desktop>`。
141
+ * @param ctx - 宿主上下文。
142
+ * @param config - 插件配置。
143
+ * @returns 状态目录的绝对路径。
144
+ */
145
+ function stateDirOf(ctx, config) {
146
+ const override = typeof config?.stateDir === 'string' ? config.stateDir.trim() : ''
147
+ if (override !== '') return resolve(override)
148
+ try {
149
+ const documentPath = ctx.get('settings')?.documentPath
150
+ if (typeof documentPath === 'string' && documentPath !== '') return dirname(documentPath)
151
+ } catch {
152
+ // settings 还没就绪,走下面的兜底路径。
153
+ }
154
+ const profile = process.env.DSH_PROFILE
155
+ if (typeof profile === 'string' && profile !== '') return join(homedir(), '.dsh', 'profiles', profile)
156
+ return join(homedir(), '.dsh', 'profiles', 'desktop')
157
+ }
158
+
159
+ /** 等一会儿(毫秒)。 */
160
+ function sleep(ms) {
161
+ return new Promise((resolve) => {
162
+ const timer = setTimeout(resolve, ms)
163
+ timer.unref?.()
164
+ })
165
+ }
166
+
167
+ /** 把任意异常压成一行可展示的错误文本。 */
168
+ function errorText(error) {
169
+ if (error === null || error === undefined) return '未知错误'
170
+ if (error instanceof Error) return error.message
171
+ return String(error)
172
+ }
173
+
174
+ /**
175
+ * 读状态文件;文件缺失或损坏都退回默认值(插件不能因为一个坏文件就起不来)。
176
+ * @param ctx - 宿主上下文。
177
+ * @returns 状态对象。
178
+ */
179
+ async function loadState(ctx, config) {
180
+ const file = join(stateDirOf(ctx, config), 'workbuddy2api-hub.json')
181
+ try {
182
+ const text = await readFile(file, 'utf8')
183
+ const parsed = JSON.parse(text)
184
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return { ...DEFAULT_STATE }
185
+ const merged = { ...DEFAULT_STATE, ...parsed }
186
+ merged.selection = parsed.selection !== null && typeof parsed.selection === 'object' ? parsed.selection : {}
187
+ merged.thresholds = { ...DEFAULT_STATE.thresholds, ...(parsed.thresholds ?? {}) }
188
+ merged.retired = parsed.retired !== null && typeof parsed.retired === 'object' ? parsed.retired : {}
189
+ return merged
190
+ } catch {
191
+ return { ...DEFAULT_STATE }
192
+ }
193
+ }
194
+
195
+ /**
196
+ * 原子写状态文件:先写临时文件再 rename,避免轮询写入时被读到半个文件。
197
+ * @param ctx - 宿主上下文。
198
+ * @param state - 要保存的状态。
199
+ * @returns 实际写入的文本。
200
+ */
201
+ async function saveState(ctx, config, state) {
202
+ const dir = stateDirOf(ctx, config)
203
+ const file = join(dir, 'workbuddy2api-hub.json')
204
+ await mkdir(dir, { recursive: true })
205
+ const text = serializeState(state)
206
+ const temp = `${file}.${process.pid}.tmp`
207
+ await writeFile(temp, text, 'utf8')
208
+ await rename(temp, file)
209
+ return text
210
+ }
211
+
212
+ /**
213
+ * 状态对象的磁盘格式。
214
+ *
215
+ * 写盘前比较用它:内容没变就不写,省掉「每 2 分钟一次」的无效写入。
216
+ * @param state - 状态对象。
217
+ * @returns 要写进文件(以及用于比较)的字符串。
218
+ */
219
+ function serializeState(state) {
220
+ return `${JSON.stringify(state, null, 2)}\n`
221
+ }
222
+
223
+ /**
224
+ * 读一个凭据引用。
225
+ *
226
+ * credentials 服务的 `resolve` 会按「进程环境 → 凭据库」的顺序取值,
227
+ * 因此用户在设置页里填的 Key 和 `set WORKBUDDY2API_HUB_API_KEY=...` 都生效。
228
+ * @param ctx - 宿主上下文。
229
+ * @param ref - 引用名。
230
+ * @param options.waitMs - 最多等多久(DSH 刚启动时凭据服务可能还没注册)。
231
+ * @returns Key 明文,或空串。
232
+ */
233
+ async function readCredential(ctx, ref, options = {}) {
234
+ const deadline = Date.now() + Math.max(0, Number(options.waitMs) || 0)
235
+ for (;;) {
236
+ const credentials = ctx.get('credentials')
237
+ if (credentials !== undefined) {
238
+ try {
239
+ const resolved = await credentials.resolve(ref)
240
+ const value = typeof resolved === 'string' ? resolved : resolved?.value
241
+ if (typeof value === 'string' && value !== '') return value
242
+ // 服务可用、但确实没有这个 key:不用再等,直接看环境变量兜底。
243
+ break
244
+ } catch {
245
+ // 凭据库刚启动、还没 hydrate 完时 resolve 会抛:当作「还没准备好」继续等。
246
+ }
247
+ }
248
+ // DSH 刚启动时凭据服务可能还没注册(`ctx.get('credentials')` 是 undefined)。
249
+ // 这时候直接发请求会光着发出去,被 hub 401,用户看到「鉴权失败」——
250
+ // 而实际上只是还没读到。给它一点时间,别把时序问题报成配置问题。
251
+ if (Date.now() >= deadline) break
252
+ await sleep(120)
253
+ }
254
+ const fromEnv = process.env[ref]
255
+ return typeof fromEnv === 'string' ? fromEnv : ''
256
+ }
257
+
258
+ /**
259
+ * 写一个凭据引用。
260
+ * @param ctx - 宿主上下文。
261
+ * @param ref - 引用名。
262
+ * @param value - 明文。
263
+ * @returns 是否写成功(credentials 不可用时不抛异常,交给调用方决定降级)。
264
+ */
265
+ async function writeCredential(ctx, ref, value) {
266
+ const credentials = ctx.get('credentials')
267
+ if (credentials === undefined || typeof credentials.set !== 'function') return false
268
+ await credentials.set(ref, value)
269
+ return true
270
+ }
271
+
272
+ /**
273
+ * 一次 hub HTTP 调用的极薄封装。
274
+ *
275
+ * 返回 `{ status, ok, body, text, timeoutMs }`;网络错误时 `status` 为 0,
276
+ * 这样上层用同一套 {@link classifyFailure} 就能分类,不需要 try/catch 满天飞。
277
+ * @param url - 完整 URL。
278
+ * @param options - `method`、`headers`、`body`、`timeoutMs`。
279
+ * @returns 响应摘要。
280
+ */
281
+ async function requestJson(url, options = {}) {
282
+ const timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : REQUEST_TIMEOUT_MS
283
+ const controller = new AbortController()
284
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
285
+ const headers = { accept: 'application/json', ...(options.headers ?? {}) }
286
+ try {
287
+ const response = await fetch(url, {
288
+ method: options.method ?? 'GET',
289
+ headers,
290
+ body: options.body,
291
+ signal: controller.signal,
292
+ })
293
+ const text = await response.text()
294
+ let body
295
+ try {
296
+ body = JSON.parse(text)
297
+ } catch {
298
+ body = undefined
299
+ }
300
+ return { status: response.status, ok: response.ok, body, text, timeoutMs }
301
+ } catch (error) {
302
+ const aborted = error?.name === 'AbortError' || error?.name === 'TimeoutError'
303
+ return {
304
+ status: 0,
305
+ ok: false,
306
+ body: undefined,
307
+ text: aborted ? `请求超时(${timeoutMs}ms)` : errorText(error),
308
+ timeoutMs,
309
+ }
310
+ } finally {
311
+ clearTimeout(timer)
312
+ }
313
+ }
314
+
315
+ /**
316
+ * 面板会话:管 `pwd` → `X-Panel-Token` 的换取、缓存与失效重登。
317
+ *
318
+ * hub 的面板 Token 只接受 `X-Panel-Token` 头(`wb_proxy.py` 的 `_panel_token()`,
319
+ * 刻意不支持 query string),且**不能**当 `Authorization: Bearer` 用——
320
+ * `identify_key` 只认配置的 API Key。所以这里分成两套凭据:
321
+ * 面板 Token 用于管理接口,API Key 用于 `/v1` 推理接口。
322
+ */
323
+ class HubSession {
324
+ /**
325
+ * @param options - `baseUrl`、`pwd`、`timeoutMs`。
326
+ */
327
+ constructor(options = {}) {
328
+ this.baseUrl = normalizeBaseUrl(options.baseUrl) || DEFAULT_BASE_URL
329
+ this.pwd = normalizePwd(options.pwd)
330
+ this.timeoutMs = Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : REQUEST_TIMEOUT_MS
331
+ /** 面板 Token,只在内存里。 */
332
+ this.token = ''
333
+ /** 登录失败的种类(供告警使用)。 */
334
+ this.lastFailure = null
335
+ }
336
+
337
+ /** 改地址/密码后必须丢掉旧 Token,否则会拿着别的实例的 Token 去请求。 */
338
+ configure(options = {}) {
339
+ const base = normalizeBaseUrl(options.baseUrl)
340
+ const pwd = normalizePwd(options.pwd)
341
+ if (base !== '' && base !== this.baseUrl) {
342
+ this.baseUrl = base
343
+ this.token = ''
344
+ }
345
+ if (pwd !== this.pwd) {
346
+ this.pwd = pwd
347
+ this.token = ''
348
+ }
349
+ if (Number(options.timeoutMs) > 0) this.timeoutMs = Number(options.timeoutMs)
350
+ }
351
+
352
+ /** 是否已经拿到面板 Token。 */
353
+ get loggedIn() {
354
+ return this.token !== ''
355
+ }
356
+
357
+ /** 免鉴权的一次 GET,用于 `/health`、`/panel/status`。 */
358
+ async publicGet(pathname, options = {}) {
359
+ return requestJson(`${this.baseUrl}${pathname}`, { timeoutMs: options.timeoutMs ?? this.timeoutMs })
360
+ }
361
+
362
+ /**
363
+ * 用面板密码换 Token。
364
+ *
365
+ * 失败要分清三种:密码错(401,别重试,会触发限流)、被限流(429,等 Ns)、
366
+ * 连不上(status 0)。hub 的限流是 60 秒内 5 次失败,所以这里绝不自动重试登录。
367
+ * @returns `{ ok, token, failure }`。
368
+ */
369
+ async login() {
370
+ const result = await requestJson(`${this.baseUrl}/panel/login`, {
371
+ method: 'POST',
372
+ headers: { 'content-type': 'application/json' },
373
+ body: JSON.stringify({ password: this.pwd }),
374
+ timeoutMs: LOGIN_TIMEOUT_MS,
375
+ })
376
+ if (result.status === 200 && typeof result.body?.token === 'string' && result.body.token !== '') {
377
+ this.token = result.body.token
378
+ this.lastFailure = null
379
+ return { ok: true, token: this.token, usingDefaultPassword: result.body.using_default_password === true }
380
+ }
381
+ const failure = classifyFailure(result)
382
+ this.lastFailure = failure
383
+ this.token = ''
384
+ return { ok: false, failure }
385
+ }
386
+
387
+ /**
388
+ * 带面板 Token 的 GET;Token 过期(401/403)自动重登一次。
389
+ *
390
+ * 只重登一次是刻意的:密码真错了的话,第二次一定还是 401,继续试只会把
391
+ * 面板限流撞满(5 次/60 秒)。
392
+ * @param pathname - 路径 + 查询串。
393
+ * @param options - `timeoutMs`、`allowAnonymous`。
394
+ * @returns 响应摘要。
395
+ */
396
+ async authGet(pathname, options = {}) {
397
+ const headers = this.token === '' ? {} : { 'x-panel-token': this.token }
398
+ let result = await requestJson(`${this.baseUrl}${pathname}`, {
399
+ timeoutMs: options.timeoutMs ?? this.timeoutMs,
400
+ headers,
401
+ })
402
+ if (result.status === 401 || result.status === 403) {
403
+ if (options.allowAnonymous === true && this.token === '') return result
404
+ const relogin = await this.login()
405
+ if (!relogin.ok) return { ...result, status: result.status, text: relogin.failure?.message ?? result.text }
406
+ result = await requestJson(`${this.baseUrl}${pathname}`, {
407
+ timeoutMs: options.timeoutMs ?? this.timeoutMs,
408
+ headers: { 'x-panel-token': this.token },
409
+ })
410
+ }
411
+ return result
412
+ }
413
+ }
414
+
415
+ /**
416
+ * 把 hub 模型写进 llm-pi-ai 的 provider 路由。
417
+ *
418
+ * 写的是 profile patch 里的 volatile 字段(`providers` 整棵是 `z.dict(...).volatile()`),
419
+ * llm-pi-ai 监听 `loader/volatile-update` 后自行重注册模型,所以不需要重启 DSH。
420
+ *
421
+ * 关键点:`compat.supportsReasoningEffort` 必须显式置 true。pi-ai 默认按 URL
422
+ * 猜这个能力,`127.0.0.1` / 局域网 IP 会被判成「不支持 reasoning_effort」,
423
+ * 于是思考档位静默失效——hub 的 `reasoning_efforts` 就白同步了。
424
+ * @param settings - settings 服务。
425
+ * @param entryId - llm-pi-ai 的 entry id。
426
+ * @param ops - mutate 操作数组。
427
+ * @param revision - 期望的 revision。
428
+ * @returns 实际使用的 revision。
429
+ */
430
+ async function mutateWithRetry(settings, entryId, ops, revision) {
431
+ try {
432
+ await settings.mutate(entryId, ops, revision)
433
+ return revision
434
+ } catch (error) {
435
+ const text = errorText(error)
436
+ if (!/revision|conflict|stale|并发/i.test(text)) throw error
437
+ const fresh = settings.describe().find((row) => row.ns === entryId)
438
+ if (fresh === undefined || fresh.revision === revision) throw error
439
+ await settings.mutate(entryId, ops, fresh.revision)
440
+ return fresh.revision
441
+ }
442
+ }
443
+
444
+ /**
445
+ * 组装写进 llm-pi-ai 的 provider 目标配置。
446
+ * @param base - hub 根地址。
447
+ * @param apiKeyEnv - 凭据引用名。
448
+ * @param routeModels - 模型档案数组。
449
+ * @returns provider 配置对象。
450
+ */
451
+ function buildRouteTarget(base, apiKeyEnv, routeModels) {
452
+ return {
453
+ displayName: 'WorkBuddy Hub',
454
+ api: 'openai-completions',
455
+ baseURL: `${base}/v1`,
456
+ apiKeyEnv,
457
+ compat: { supportsReasoningEffort: true, thinkingFormat: 'openai' },
458
+ models: routeModels,
459
+ }
460
+ }
461
+
462
+ /**
463
+ * 同步模型到 DSH。
464
+ * @param ctx - 宿主上下文。
465
+ * @param state - 当前状态。
466
+ * @param models - hub 模型条目数组(已含宽限期里的下架模型)。
467
+ * @param baseUrl - 运行期真正连通的地址。
468
+ * @returns `{ count, at, skipped }`。
469
+ */
470
+ async function syncModelsToDsh(ctx, state, models, baseUrl) {
471
+ const settings = ctx.get('settings')
472
+ if (settings === undefined) throw new Error('settings 服务不可用,无法写入模型')
473
+ const entryId = state.providerEntry !== '' ? state.providerEntry : DEFAULT_PROVIDER_ENTRY
474
+ const routeKey = state.routeKey !== '' ? state.routeKey : DEFAULT_ROUTE_KEY
475
+ const descriptor = settings.describe().find((row) => row.ns === entryId)
476
+ if (descriptor === undefined) throw new Error(`找不到插件配置 "${entryId}"(llm-pi-ai 是否已启用?)`)
477
+ const routeModels = buildRouteModels(models, state.selection)
478
+ if (routeModels.length === 0) throw new Error('没有勾选任何模型(在设置页至少勾一个)')
479
+ // 用运行期的「当前地址」而不是 state.baseUrl:改过地址但没刷新时,
480
+ // 写错会让 DSH 的请求打到连不上的地方。
481
+ const base = normalizeBaseUrl(typeof baseUrl === 'string' && baseUrl !== '' ? baseUrl : state.baseUrl)
482
+ if (base === '') throw new Error('hub 地址无效')
483
+ const apiKeyEnv = state.apiKeyEnv !== '' ? state.apiKeyEnv : DEFAULT_API_KEY_ENV
484
+ const target = buildRouteTarget(base, apiKeyEnv, routeModels)
485
+ // 内容没变就别写:写一次会让 llm-pi-ai 重新注册整条路由的全部模型,
486
+ // 每 2 分钟白做一遍既浪费也可能打扰正在跑的会话。
487
+ const current = descriptor.value?.providers?.[routeKey]
488
+ if (routeConfigMatches(current, target)) {
489
+ return { count: routeModels.length, at: state.lastSyncAt > 0 ? state.lastSyncAt : Date.now(), skipped: true }
490
+ }
491
+ const ops = Object.entries(target).map(([key, value]) => ({
492
+ op: 'set',
493
+ path: ['providers', routeKey, key],
494
+ value,
495
+ }))
496
+ await mutateWithRetry(settings, entryId, ops, descriptor.revision)
497
+ return { count: routeModels.length, at: Date.now(), skipped: false }
498
+ }
499
+
500
+ /**
501
+ * 从已存在的 llm-pi-ai 路由里读出 hub 地址,用于首次安装时自动接管。
502
+ *
503
+ * 用户之前手写过 `providers.workbuddy2api-hub.baseURL`(或参考项目的
504
+ * `providers.workbuddy2api.baseURL`),那就是最可信的地址来源。
505
+ * @param ctx - 宿主上下文。
506
+ * @param state - 当前状态(提供 providerEntry / routeKey)。
507
+ * @returns `{ baseUrl, apiKeyEnv }`,读不到返回 null。
508
+ */
509
+ function readExistingRoute(ctx, state) {
510
+ const settings = ctx.get('settings')
511
+ if (settings === undefined || typeof settings.describe !== 'function') return null
512
+ const entryId = state.providerEntry !== '' ? state.providerEntry : DEFAULT_PROVIDER_ENTRY
513
+ const keys = [state.routeKey, DEFAULT_ROUTE_KEY, 'workbuddy2api', 'wb'].filter(
514
+ (key) => typeof key === 'string' && key !== '',
515
+ )
516
+ const descriptor = settings.describe().find((row) => row.ns === entryId)
517
+ for (const key of keys) {
518
+ const route = descriptor?.value?.providers?.[key]
519
+ if (route === null || typeof route !== 'object') continue
520
+ const baseUrl = normalizeBaseUrl(typeof route.baseURL === 'string' ? route.baseURL : '')
521
+ if (baseUrl === '') continue
522
+ return {
523
+ baseUrl,
524
+ apiKeyEnv: typeof route.apiKeyEnv === 'string' ? route.apiKeyEnv : '',
525
+ routeKey: key,
526
+ }
527
+ }
528
+ return null
529
+ }
530
+
531
+ /**
532
+ * 插件运行期状态:内存缓存 + 磁盘状态,全部操作串行化。
533
+ *
534
+ * 轮询、HTTP 请求、写盘三条路径会同时进来,用一条 promise 链保证不并发写
535
+ * 同一份状态文件、也不会两次 mutate 撞上同一个 revision。
536
+ */
537
+ class HubRuntime {
538
+ /**
539
+ * @param ctx - 宿主上下文。
540
+ * @param config - 插件配置(cordis.patch.yml 里的 config)。
541
+ */
542
+ constructor(ctx, config) {
543
+ this.ctx = ctx
544
+ this.config = config ?? {}
545
+ this.state = { ...DEFAULT_STATE }
546
+ /** hub 原始模型(含宽限期里的下架模型)。 */
547
+ this.rawModels = []
548
+ /** 打过 UI 标记的模型。 */
549
+ this.models = []
550
+ this.health = null
551
+ this.panel = null
552
+ this.balance = null
553
+ this.usage = null
554
+ /** 本周用量:徽标第三行要,与 `usage`(主窗口,默认今日)分开存。 */
555
+ this.usageWeek = null
556
+ this.accounts = null
557
+ this.modelsFetchedAt = 0
558
+ this.overviewFetchedAt = 0
559
+ this.usageFetchedAt = 0
560
+ /** 用量窗口名:`today` / `week` / `month` / `all`(hub 只认 range)。 */
561
+ this.usageRange = 'today'
562
+ this.busy = Promise.resolve()
563
+ /** 进程启动时刻:冷启动窗口内失败要快试,别退避到 4 分钟。 */
564
+ this.bootedAt = Date.now()
565
+ this.lastSyncError = ''
566
+ /** 失败种类(auth / unreachable / timeout / rate-limited / http)。 */
567
+ this.failureKind = ''
568
+ /** 连续失败次数,用来算退避间隔。 */
569
+ this.consecutiveFailures = 0
570
+ /** 被限流时建议的等待秒数(本次退避的下限)。 */
571
+ this.retryFloorSeconds = 0
572
+ /** 被 HMR 事务挡掉、等下一次 HTTP 请求补做的同步。 */
573
+ this.pendingSync = false
574
+ /** 上次真正写进磁盘的内容,用来跳过「什么都没变」的写盘。 */
575
+ this.lastPersisted = ''
576
+ /** API Key 来源:`config` / `panel` / `credentials` / ``。 */
577
+ this.apiKeySource = ''
578
+ /** 上一次写进 credentials 的值,避免每轮重复写。 */
579
+ this.persistedApiKey = ''
580
+ this.newModels = []
581
+ this.lastRemoved = []
582
+ this.disposed = false
583
+ this.session = new HubSession({ baseUrl: DEFAULT_BASE_URL, pwd: DEFAULT_PWD })
584
+ }
585
+
586
+ /**
587
+ * 写盘,内容没变就跳过。
588
+ * @returns 是否真的写了。
589
+ */
590
+ async persist() {
591
+ const text = serializeState(this.state)
592
+ if (text === this.lastPersisted) return false
593
+ await saveState(this.ctx, this.config, this.state)
594
+ this.lastPersisted = text
595
+ return true
596
+ }
597
+
598
+ /** 把操作排进串行队列。 */
599
+ run(task) {
600
+ const next = this.busy.then(task, task)
601
+ this.busy = next.then(
602
+ () => undefined,
603
+ () => undefined,
604
+ )
605
+ return next
606
+ }
607
+
608
+ /** 归一路径用的当前地址。 */
609
+ get base() {
610
+ return normalizeBaseUrl(this.state.baseUrl) || DEFAULT_BASE_URL
611
+ }
612
+
613
+ /** 面板密码。 */
614
+ get pwd() {
615
+ return normalizePwd(this.state.pwd)
616
+ }
617
+
618
+ /** 看板页签。 */
619
+ get tab() {
620
+ return normalizeTab(this.state.tab)
621
+ }
622
+
623
+ /**
624
+ * 把 cordis.patch.yml 里的显式配置合进状态。
625
+ *
626
+ * 语义(这是「只改配置就能换地址/密码」这条验收标准的实现):
627
+ * - 配置里**显式写了**的字段,其值参与指纹;
628
+ * - 指纹变了(用户在 YAML 里改过)→ 覆盖状态里的同名值;
629
+ * - 指纹没变 → 保留状态里的值,于是「在设置页里改的」不会重启后被抹掉。
630
+ * @param config - 插件配置。
631
+ */
632
+ applyConfig(config) {
633
+ const source = config !== null && typeof config === 'object' ? config : {}
634
+ const explicit = {}
635
+ const baseUrl = normalizeBaseUrl(typeof source.baseUrl === 'string' ? source.baseUrl : '')
636
+ if (baseUrl !== '') explicit.baseUrl = baseUrl
637
+ if (typeof source.pwd === 'string' && source.pwd.trim() !== '') explicit.pwd = normalizePwd(source.pwd)
638
+ if (typeof source.tab === 'string' && source.tab.trim() !== '') explicit.tab = normalizeTab(source.tab)
639
+ const fingerprint = JSON.stringify(explicit)
640
+ if (Object.keys(explicit).length > 0 && fingerprint !== this.state.appliedConfig) {
641
+ Object.assign(this.state, explicit)
642
+ this.state.appliedConfig = fingerprint
643
+ }
644
+ // 非指纹类配置每次都生效(它们是行为开关,不是「用户最后一次选择」)。
645
+ if (typeof source.sync === 'boolean') this.state.sync = source.sync
646
+ if (Number(source.intervalSeconds) >= 30) {
647
+ this.state.intervalSeconds = Math.min(Math.floor(Number(source.intervalSeconds)), 3600)
648
+ }
649
+ if (typeof source.providerEntry === 'string' && source.providerEntry !== '') {
650
+ this.state.providerEntry = source.providerEntry
651
+ }
652
+ if (typeof source.routeKey === 'string' && source.routeKey !== '') this.state.routeKey = source.routeKey
653
+ if (typeof source.apiKeyEnv === 'string' && source.apiKeyEnv !== '') this.state.apiKeyEnv = source.apiKeyEnv
654
+ if (typeof source.usageRange === 'string' && source.usageRange.trim() !== '') {
655
+ this.state.usageRange = normalizeUsageRange(source.usageRange)
656
+ }
657
+ if (typeof source.autoApiKey === 'boolean') this.state.autoApiKey = source.autoApiKey
658
+ if (source.newModelPolicy === 'select' || source.newModelPolicy === 'ignore') {
659
+ this.state.newModelPolicy = source.newModelPolicy
660
+ }
661
+ if (Number(source.retireHours) >= 0) {
662
+ this.state.retireHours = Math.min(Math.floor(Number(source.retireHours)), 168)
663
+ }
664
+ if (source.thresholds !== null && typeof source.thresholds === 'object') {
665
+ const next = { ...this.state.thresholds }
666
+ if (Number(source.thresholds.lowCredits) >= 0) next.lowCredits = Number(source.thresholds.lowCredits)
667
+ if (Number(source.thresholds.unreachablePolls) > 0) {
668
+ next.unreachablePolls = Math.floor(Number(source.thresholds.unreachablePolls))
669
+ }
670
+ this.state.thresholds = next
671
+ }
672
+ this.session.configure({ baseUrl: this.base, pwd: this.pwd })
673
+ }
674
+
675
+ /** 首次装载:读盘 + 按配置拉一次数据。 */
676
+ async start() {
677
+ this.state = await loadState(this.ctx, this.config)
678
+ this.lastPersisted = serializeState(this.state)
679
+ this.applyConfig(this.config)
680
+ // 老用户手写过路由:地址和 apiKeyEnv 都是现成的,首次安装时直接接过来。
681
+ const hasExplicitBase = typeof this.config?.baseUrl === 'string' && this.config.baseUrl.trim() !== ''
682
+ if (!hasExplicitBase && normalizeBaseUrl(this.state.baseUrl) === '') {
683
+ const existing = readExistingRoute(this.ctx, this.state)
684
+ if (existing !== null) this.state.baseUrl = existing.baseUrl
685
+ }
686
+ this.session.configure({ baseUrl: this.base, pwd: this.pwd })
687
+ await this.persist()
688
+ await this.refresh({ sync: true }).catch(() => undefined)
689
+ }
690
+
691
+ /**
692
+ * 补做一次被推迟的同步。
693
+ *
694
+ * 插件在配置变更事务里被重新加载时,`settings.mutate` 会抛
695
+ * "HMR transactions cannot be nested",而 AsyncLocalStorage 的上下文会
696
+ * 传染给这次加载里创建的所有定时器——也就是说后台轮询从此写不进配置。
697
+ * HTTP 请求处理函数跑在干净上下文里,所以把补同步挂在 `/state` 上。
698
+ * @returns 有补做就返回 true。
699
+ */
700
+ async flushPendingSync() {
701
+ if (!this.pendingSync) return false
702
+ await this.run(() => this.refresh({ sync: true }))
703
+ return true
704
+ }
705
+
706
+ /**
707
+ * 拉取 hub 数据(健康 → 面板状态 → 登录 → 模型/额度/用量/账号),必要时同步模型。
708
+ * @param options - `sync`:是否顺带写 DSH 配置;`usageRange`:用量窗口(默认 `today`)。
709
+ * @returns 摘要 `{ ok, models, synced, error }`。
710
+ */
711
+ async refresh(options = {}) {
712
+ const base = this.base
713
+ if (base === '') return this.fail('还没有配置 hub 地址')
714
+ this.session.configure({ baseUrl: base, pwd: this.pwd })
715
+ const startFailure = this.consecutiveFailures
716
+
717
+ // 1) `/health` 免鉴权:先确认「服务活着」,这一步不消耗额度、也不触发登录限流。
718
+ const healthResult = await this.session.publicGet('/health')
719
+ if (!healthResult.ok) {
720
+ this.health = null
721
+ this.panel = null
722
+ return this.fail(classifyFailure(healthResult).message, classifyFailure(healthResult).kind)
723
+ }
724
+ this.health = summarizeHealth(healthResult.body)
725
+
726
+ // 2) `/panel/status` 也免鉴权:能读到「要不要密码」「面板里有没有 API Key」。
727
+ const statusResult = await this.session.publicGet('/panel/status')
728
+ this.panel = statusResult.ok ? summarizePanelStatus(statusResult.body) : null
729
+
730
+ // 3) 面板会话。密码确实是错的(401)就不再试第二次——hub 是 5 次/60 秒限流,
731
+ // 继续硬试会把用户锁在外面。被限流(429)则记下建议等待时间。
732
+ if (this.panel === null || this.panel.passwordRequired || this.panel.passwordIsDefault) {
733
+ const login = await this.session.login()
734
+ if (!login.ok) {
735
+ const failure = login.failure ?? { kind: 'auth', message: '面板登录失败' }
736
+ if (failure.kind === 'rate-limited') {
737
+ this.retryFloorSeconds = retryAfterSeconds(login.failure?.message ?? '') || 60
738
+ }
739
+ this.health = this.health
740
+ return this.fail(failure.message, failure.kind)
741
+ }
742
+ this.retryFloorSeconds = 0
743
+ }
744
+
745
+ // 4) 模型清单。/v1/models 既接受面板 Token 也接受 API Key。
746
+ const modelsResult = await this.session.authGet('/v1/models')
747
+ if (!modelsResult.ok) {
748
+ const failure = classifyFailure(modelsResult)
749
+ return this.fail(failure.message, failure.kind)
750
+ }
751
+ const list = modelListOf(modelsResult.body)
752
+ if (list === null) {
753
+ return this.fail('hub 的 /v1/models 返回内容不是 OpenAI 模型列表(缺少 data 数组)', 'payload')
754
+ }
755
+
756
+ // 5) 额度 / 用量 / 账号:都是「锦上添花」,失败各自降级,不影响模型同步。
757
+ await this.refreshBalance()
758
+ const primaryUsage = normalizeUsageRange(options.usageRange ?? this.state.usageRange ?? 'today')
759
+ await this.refreshUsage(primaryUsage)
760
+ // 徽标第三行要「本周」,所以无论主窗口是什么都多拉一次;主窗口就是 week 时不用重复。
761
+ if (primaryUsage !== 'week') await this.refreshUsage('week')
762
+ await this.refreshAccounts()
763
+
764
+ // 6) API Key:llm-pi-ai 走 `Authorization: Bearer`,面板 Token 不能顶替。
765
+ await this.resolveApiKey()
766
+
767
+ const now = Date.now()
768
+ // 首次导入(之前没有任何模型记录)不算「新上架」。
769
+ const firstSync = !Array.isArray(this.state.lastModelIds) || this.state.lastModelIds.length === 0
770
+ const reconciled = reconcileModels(
771
+ list,
772
+ { lastIds: this.state.lastModelIds, models: this.rawModels, retired: this.state.retired },
773
+ now,
774
+ Math.max(0, Number(this.state.retireHours) || 0) * 3600000,
775
+ )
776
+ this.state.retired = reconciled.retired
777
+ this.state.lastModelIds = list.map((model) => model?.id).filter((id) => typeof id === 'string' && id !== '')
778
+ this.rawModels = reconciled.models
779
+ this.newModels = reconciled.added
780
+ this.lastRemoved = reconciled.removed
781
+ if (!firstSync && this.state.newModelPolicy === 'ignore') {
782
+ for (const id of reconciled.added) {
783
+ if (this.state.selection?.[id] !== undefined) continue
784
+ this.state.selection = { ...this.state.selection, [id]: { enabled: false } }
785
+ }
786
+ }
787
+ this.models = sortModels(this.rawModels)
788
+ this.modelsFetchedAt = now
789
+ this.lastSyncError = ''
790
+ this.failureKind = ''
791
+ this.consecutiveFailures = 0
792
+ this.retryFloorSeconds = 0
793
+
794
+ let synced = 0
795
+ if (this.state.sync !== false && options.sync !== false) {
796
+ if (this.apiKeySource === '' && this.health?.apiKeyRequired === true) {
797
+ // 没有可用 API Key 时不写这条路由:写了模型也是「一选就 401」,
798
+ // 不如让用户在设置页看到明确的提示(告警里有一条 api-key)。
799
+ this.state.lastSyncError = '还没有可用的 hub API Key,暂不写入模型(面板 Token 不能当 Bearer 用)'
800
+ this.lastSyncError = this.state.lastSyncError
801
+ } else {
802
+ try {
803
+ const result = await syncModelsToDsh(this.ctx, this.state, this.models, base)
804
+ synced = result.count
805
+ this.state.lastSyncAt = result.at
806
+ this.state.lastSyncCount = result.count
807
+ this.state.lastSyncError = ''
808
+ this.pendingSync = false
809
+ } catch (error) {
810
+ const text = errorText(error)
811
+ if (text.includes('HMR transactions cannot be nested')) {
812
+ // 宿主正在应用配置变更,这次写不进去。留给下一次 HTTP 请求补做,
813
+ // 不把这个内部错误当成用户能理解的问题展示。
814
+ this.pendingSync = true
815
+ this.state.lastSyncError = ''
816
+ this.lastSyncError = ''
817
+ } else {
818
+ this.pendingSync = false
819
+ this.state.lastSyncError = text
820
+ this.lastSyncError = text
821
+ }
822
+ }
823
+ }
824
+ } else if (this.state.sync === false) {
825
+ // 关掉自动同步也要落盘:lastModelIds / retired 是「新模型策略」和
826
+ // 「下架宽限」的基准,丢了会让重启后所有模型都被当成首次导入。
827
+ this.state.lastSyncError = ''
828
+ }
829
+ void startFailure
830
+ await this.persist()
831
+ return { ok: true, models: this.models.length, synced, error: this.lastSyncError }
832
+ }
833
+
834
+ /**
835
+ * 记录一次失败:错误同时写进内存状态和磁盘状态。
836
+ *
837
+ * 只写内存字段会让设置页读到空错误(snapshot 读的是 state.lastSyncError),
838
+ * 用户就看不到「pwd 不对」这种最需要看到的提示。
839
+ * @param message - 给用户看的错误文本。
840
+ * @param kind - 失败种类。
841
+ * @returns `{ ok: false, error }`,方便调用处直接 return。
842
+ */
843
+ async fail(message, kind = '') {
844
+ this.lastSyncError = message
845
+ this.failureKind = kind
846
+ this.state.lastSyncError = message
847
+ this.consecutiveFailures += 1
848
+ this.models = []
849
+ await this.persist()
850
+ return { ok: false, error: message, kind }
851
+ }
852
+
853
+ /** 拉额度汇总(跨账号)。 */
854
+ async refreshBalance() {
855
+ const result = await this.session.authGet('/v1/balance-summary')
856
+ if (!result.ok || result.body === undefined) return undefined
857
+ this.balance = summarizeBalance(result.body)
858
+ this.overviewFetchedAt = Date.now()
859
+ return this.balance
860
+ }
861
+
862
+ /**
863
+ * 拉用量快照。
864
+ *
865
+ * hub 的 `/usage` 只认 `range`(today/week/month/all),**不认 `hours`**:
866
+ * 传 `?hours=24` 会被静默忽略,返回的是不过滤的全量历史。所以这里按
867
+ * 窗口名请求,默认 `today`——侧边栏徽标要的就是「今日」。
868
+ */
869
+ async refreshUsage(range) {
870
+ const wanted = normalizeUsageRange(range)
871
+ const result = await this.session.authGet(usageQuery(wanted))
872
+ if (!result.ok || result.body === undefined) {
873
+ // `/usage` 是 panel route;面板没开密码时也许本来就不需要鉴权,
874
+ // 这里失败就降级,不影响模型与额度。
875
+ return undefined
876
+ }
877
+ const summary = summarizeUsage(result.body, 12)
878
+ this.usageFetchedAt = Date.now()
879
+ if (wanted === 'week') {
880
+ // 徽标第三行固定要本周,与主窗口分开存,主窗口调成 month/all 也不会把它顶掉。
881
+ this.usageWeek = summary
882
+ return summary
883
+ }
884
+ this.usage = summary
885
+ this.usageRange = wanted
886
+ return summary
887
+ }
888
+
889
+ /** 拉账号池。 */
890
+ async refreshAccounts() {
891
+ const result = await this.session.authGet('/accounts')
892
+ if (!result.ok || result.body === undefined) return undefined
893
+ this.accounts = summarizeAccounts(result.body, Date.now())
894
+ return this.accounts
895
+ }
896
+
897
+ /**
898
+ * 解析 `/v1` 推理接口要用的 API Key。
899
+ *
900
+ * 三条来源,优先级从高到低:
901
+ * 1. 配置里的 `apiKey`(写进 credentials,引用名 = `apiKeyEnv`);
902
+ * 2. credentials 里已经存在的同引用名 Key(用户以前填过);
903
+ * 3. 面板会话可用时,从 `/settings` + `/settings/reveal?id=` 取第一个启用的 Key。
904
+ * 明文只进 credentials 与本进程内存,**绝不写进状态文件**。
905
+ */
906
+ async resolveApiKey() {
907
+ const ref = this.state.apiKeyEnv !== '' ? this.state.apiKeyEnv : DEFAULT_API_KEY_ENV
908
+ if (typeof this.config?.apiKey === 'string' && this.config.apiKey.trim() !== '') {
909
+ const value = this.config.apiKey.trim()
910
+ if (value !== this.persistedApiKey) {
911
+ try {
912
+ await writeCredential(this.ctx, ref, value)
913
+ this.persistedApiKey = value
914
+ } catch (error) {
915
+ // credentials 不可用时保留在内存里,至少本次会话能跑。
916
+ void error
917
+ }
918
+ }
919
+ this.apiKeySource = 'config'
920
+ return value
921
+ }
922
+ const stored = await readCredential(this.ctx, ref, { waitMs: 1500 })
923
+ if (stored !== '') {
924
+ this.apiKeySource = 'credentials'
925
+ return stored
926
+ }
927
+ if (this.state.autoApiKey !== true || this.panel === null) {
928
+ this.apiKeySource = ''
929
+ return ''
930
+ }
931
+ const settingsResult = await this.session.authGet('/settings')
932
+ const keys = Array.isArray(settingsResult.body?.api_keys) ? settingsResult.body.api_keys : []
933
+ const candidate = keys.find((item) => item?.enabled !== false && typeof item?.id === 'string' && item.id !== '')
934
+ if (candidate === undefined) {
935
+ this.apiKeySource = ''
936
+ return ''
937
+ }
938
+ const reveal = await this.session.authGet(`/settings/reveal?id=${encodeURIComponent(candidate.id)}`)
939
+ const value = typeof reveal.body?.key === 'string' ? reveal.body.key : ''
940
+ if (value === '') {
941
+ this.apiKeySource = ''
942
+ return ''
943
+ }
944
+ if (value !== this.persistedApiKey) {
945
+ try {
946
+ await writeCredential(this.ctx, ref, value)
947
+ this.persistedApiKey = value
948
+ } catch (error) {
949
+ void error
950
+ }
951
+ }
952
+ this.apiKeySource = 'panel'
953
+ return value
954
+ }
955
+
956
+ /**
957
+ * 给模型打上展示用标记(已下架)。
958
+ * @param models - 原始模型数组。
959
+ * @returns 新数组,不修改入参。
960
+ */
961
+ decorate(models) {
962
+ return (Array.isArray(models) ? models : []).map((model) =>
963
+ model?.retired === true ? model : { ...model },
964
+ )
965
+ }
966
+
967
+ /** 当前该提示的告警。 */
968
+ alerts() {
969
+ return computeAlerts({
970
+ now: Date.now(),
971
+ lastError: this.lastSyncError || this.state.lastSyncError,
972
+ failureKind: this.failureKind,
973
+ consecutiveFailures: this.consecutiveFailures,
974
+ balance: this.balance,
975
+ accounts: this.accounts,
976
+ apiKeyPresent: this.apiKeySource !== '' || this.health?.apiKeyRequired === false,
977
+ thresholds: this.state.thresholds,
978
+ dismissed: this.state.dismissed,
979
+ })
980
+ }
981
+
982
+ /**
983
+ * 拼给前端的状态快照。
984
+ *
985
+ * 不含任何凭据明文:`pwd` 只回一个布尔,Token 与 API Key 完全不出现。
986
+ * @returns 可 JSON 化的状态对象。
987
+ */
988
+ snapshot() {
989
+ const rows = this.models.map((model) => toModelRow(model, this.state.selection))
990
+ const counts = { total: rows.length, enabled: 0, retired: 0 }
991
+ let rateSum = 0
992
+ for (const row of rows) {
993
+ if (row.enabled) counts.enabled += 1
994
+ if (row.retired === true) counts.retired += 1
995
+ rateSum += Number(row.rate) || 0
996
+ }
997
+ return {
998
+ pluginVersion: PLUGIN_VERSION,
999
+ baseUrl: this.base,
1000
+ tab: this.tab,
1001
+ tabs: [...TABS],
1002
+ pwdConfigured: this.pwd !== '',
1003
+ pwdIsDefault: this.pwd === DEFAULT_PWD,
1004
+ health: this.health,
1005
+ panel: this.panel,
1006
+ loggedIn: this.session.loggedIn,
1007
+ apiKeyEnv: this.state.apiKeyEnv,
1008
+ apiKeySource: this.apiKeySource,
1009
+ providerEntry: this.state.providerEntry,
1010
+ routeKey: this.state.routeKey,
1011
+ sync: this.state.sync !== false,
1012
+ intervalSeconds: this.state.intervalSeconds,
1013
+ counts,
1014
+ averageRate: rows.length > 0 ? Math.round((rateSum / rows.length) * 1000) / 1000 : 0,
1015
+ models: rows,
1016
+ lastSyncAt: this.state.lastSyncAt,
1017
+ lastSyncCount: this.state.lastSyncCount,
1018
+ lastSyncError: this.state.lastSyncError,
1019
+ syncPending: this.pendingSync,
1020
+ lastError: this.lastSyncError,
1021
+ failureKind: this.failureKind,
1022
+ consecutiveFailures: this.consecutiveFailures,
1023
+ modelsFetchedAt: this.modelsFetchedAt,
1024
+ overviewFetchedAt: this.overviewFetchedAt,
1025
+ usageFetchedAt: this.usageFetchedAt,
1026
+ usageRange: normalizeUsageRange(this.usageRange),
1027
+ balance: this.balance,
1028
+ usage: this.usage,
1029
+ usageWeek: this.usageWeek,
1030
+ accounts: this.accounts,
1031
+ alerts: this.alerts(),
1032
+ newModels: Array.isArray(this.newModels) ? this.newModels : [],
1033
+ retired: Object.entries(this.state.retired ?? {}).map(([id, entry]) => ({
1034
+ id,
1035
+ at: Number(entry?.at) || 0,
1036
+ expiresAt: toEpochMs(Number(entry?.at) || 0) + Math.max(0, Number(this.state.retireHours) || 0) * 3600000,
1037
+ })),
1038
+ policy: {
1039
+ newModelPolicy: this.state.newModelPolicy,
1040
+ retireHours: Number(this.state.retireHours) || 0,
1041
+ thresholds: { ...this.state.thresholds },
1042
+ autoApiKey: this.state.autoApiKey !== false,
1043
+ },
1044
+ runtime: {
1045
+ node: process.version,
1046
+ bootedAt: this.bootedAt,
1047
+ },
1048
+ }
1049
+ }
1050
+
1051
+ /**
1052
+ * 更新配置(地址 / 密码 / 页签 / 同步开关 / 间隔 / API Key)。
1053
+ * @param patch - 前端提交的字段。
1054
+ * @returns 快照。
1055
+ */
1056
+ async configure(patch) {
1057
+ let reconnect = false
1058
+ if (patch !== null && typeof patch === 'object') {
1059
+ if (typeof patch.baseUrl === 'string') {
1060
+ const next = normalizeBaseUrl(patch.baseUrl)
1061
+ if (next !== '' && next !== this.base) {
1062
+ this.state.baseUrl = next
1063
+ reconnect = true
1064
+ }
1065
+ }
1066
+ if (typeof patch.pwd === 'string' && patch.pwd.trim() !== '') {
1067
+ const next = normalizePwd(patch.pwd)
1068
+ if (next !== this.pwd) {
1069
+ this.state.pwd = next
1070
+ reconnect = true
1071
+ }
1072
+ }
1073
+ if (typeof patch.tab === 'string' && patch.tab.trim() !== '') {
1074
+ this.state.tab = normalizeTab(patch.tab, this.state.tab)
1075
+ }
1076
+ if (typeof patch.sync === 'boolean') this.state.sync = patch.sync
1077
+ if (typeof patch.usageRange === 'string' && patch.usageRange.trim() !== '') {
1078
+ this.state.usageRange = normalizeUsageRange(patch.usageRange)
1079
+ }
1080
+ if (Number.isFinite(Number(patch.intervalSeconds)) && Number(patch.intervalSeconds) >= 30) {
1081
+ this.state.intervalSeconds = Math.min(Math.floor(Number(patch.intervalSeconds)), 3600)
1082
+ }
1083
+ if (typeof patch.autoApiKey === 'boolean') this.state.autoApiKey = patch.autoApiKey
1084
+ if (typeof patch.apiKey === 'string' && patch.apiKey.trim() !== '') {
1085
+ const ref = this.state.apiKeyEnv !== '' ? this.state.apiKeyEnv : DEFAULT_API_KEY_ENV
1086
+ const value = patch.apiKey.trim()
1087
+ const written = await writeCredential(this.ctx, ref, value)
1088
+ if (!written) throw new Error('credentials 服务不可用,无法保存 API Key')
1089
+ this.persistedApiKey = value
1090
+ this.apiKeySource = 'credentials'
1091
+ reconnect = true
1092
+ }
1093
+ if (patch.policy !== null && typeof patch.policy === 'object') await this.setPolicy(patch.policy)
1094
+ }
1095
+ if (reconnect) {
1096
+ // 换地址/密码必须丢 Token,并立刻重连一次,否则用户看到的是旧状态。
1097
+ this.session.configure({ baseUrl: this.base, pwd: this.pwd })
1098
+ }
1099
+ await this.persist()
1100
+ return this.snapshot()
1101
+ }
1102
+
1103
+ /**
1104
+ * 改策略(新模型默认、宽限期、告警阈值)。
1105
+ * @param patch - 只处理出现的字段。
1106
+ * @returns 快照。
1107
+ */
1108
+ async setPolicy(patch) {
1109
+ const body = patch ?? {}
1110
+ if (body.newModelPolicy === 'select' || body.newModelPolicy === 'ignore') {
1111
+ this.state.newModelPolicy = body.newModelPolicy
1112
+ }
1113
+ if (Number(body.retireHours) >= 0) {
1114
+ this.state.retireHours = Math.min(Math.floor(Number(body.retireHours)), 168)
1115
+ }
1116
+ const thresholds = body.thresholds
1117
+ if (thresholds !== null && typeof thresholds === 'object') {
1118
+ const next = { ...this.state.thresholds }
1119
+ if (Number(thresholds.lowCredits) >= 0) next.lowCredits = Number(thresholds.lowCredits)
1120
+ if (Number(thresholds.unreachablePolls) > 0) {
1121
+ next.unreachablePolls = Math.floor(Number(thresholds.unreachablePolls))
1122
+ }
1123
+ this.state.thresholds = next
1124
+ }
1125
+ await this.persist()
1126
+ return this.snapshot()
1127
+ }
1128
+
1129
+ /** 关掉一条告警,24 小时内不再提示。 */
1130
+ async dismissAlert(key) {
1131
+ if (typeof key !== 'string' || key === '') return this.snapshot()
1132
+ this.state.dismissed = { ...this.state.dismissed, [key]: Date.now() }
1133
+ await this.persist()
1134
+ return this.snapshot()
1135
+ }
1136
+
1137
+ /**
1138
+ * 勾选/取消一个模型,或裁剪它的思考档位。
1139
+ * @param id - 模型 id。
1140
+ * @param choice - `{ enabled, efforts }`。
1141
+ * @returns 快照。
1142
+ */
1143
+ async select(id, choice) {
1144
+ if (typeof id !== 'string' || id === '') return this.snapshot()
1145
+ const current = this.state.selection?.[id] ?? { enabled: true }
1146
+ const next = { ...current }
1147
+ if (typeof choice?.enabled === 'boolean') next.enabled = choice.enabled
1148
+ if (Array.isArray(choice?.efforts)) {
1149
+ next.efforts = choice.efforts.filter((level) => typeof level === 'string')
1150
+ } else if (choice?.efforts === null) {
1151
+ delete next.efforts
1152
+ }
1153
+ this.state.selection = { ...this.state.selection, [id]: next }
1154
+ await this.persist()
1155
+ if (this.state.sync !== false) await this.resync()
1156
+ return this.snapshot()
1157
+ }
1158
+
1159
+ /** 全选/全不选。 */
1160
+ async bulk(enabled) {
1161
+ const selection = { ...this.state.selection }
1162
+ for (const model of this.rawModels) {
1163
+ const id = model?.id
1164
+ if (typeof id !== 'string' || id === '') continue
1165
+ selection[id] = { ...(selection[id] ?? {}), enabled: enabled === true }
1166
+ }
1167
+ this.state.selection = selection
1168
+ await this.persist()
1169
+ if (this.state.sync !== false) await this.resync()
1170
+ return this.snapshot()
1171
+ }
1172
+
1173
+ /** 把这一轮新上架的模型全部勾上(按 `newModelPolicy: ignore` 被跳过的那些)。 */
1174
+ async enableNew() {
1175
+ const selection = { ...this.state.selection }
1176
+ for (const id of this.newModels) {
1177
+ if (typeof id !== 'string' || id === '') continue
1178
+ selection[id] = { ...(selection[id] ?? {}), enabled: true }
1179
+ }
1180
+ this.state.selection = selection
1181
+ await this.persist()
1182
+ if (this.state.sync !== false) await this.resync()
1183
+ return this.snapshot()
1184
+ }
1185
+
1186
+ /** 立即移除一个已下架的模型(不等宽限期)。 */
1187
+ async retireNow(id) {
1188
+ if (typeof id !== 'string' || id === '') return this.snapshot()
1189
+ if (this.state.retired?.[id] !== undefined) {
1190
+ const next = { ...this.state.retired }
1191
+ delete next[id]
1192
+ this.state.retired = next
1193
+ }
1194
+ this.rawModels = this.rawModels.filter((model) => model?.id !== id)
1195
+ // 同步清掉「上一轮见过」的记录,否则下一轮会把刚移除的模型当成新下架又捡回来。
1196
+ this.state.lastModelIds = (this.state.lastModelIds ?? []).filter((item) => item !== id)
1197
+ this.models = sortModels(this.rawModels)
1198
+ await this.resync()
1199
+ return this.snapshot()
1200
+ }
1201
+
1202
+ /** 按当前选择重写 DSH 配置(不重新拉 hub)。 */
1203
+ async resync() {
1204
+ if (this.models.length === 0) return this.snapshot()
1205
+ try {
1206
+ const result = await syncModelsToDsh(this.ctx, this.state, this.models, this.base)
1207
+ this.state.lastSyncAt = result.at
1208
+ this.state.lastSyncCount = result.count
1209
+ this.state.lastSyncError = ''
1210
+ this.lastSyncError = ''
1211
+ } catch (error) {
1212
+ this.state.lastSyncError = errorText(error)
1213
+ this.lastSyncError = this.state.lastSyncError
1214
+ }
1215
+ await this.persist()
1216
+ return this.snapshot()
1217
+ }
1218
+
1219
+ /**
1220
+ * 清除插件写进 llm-pi-ai 的整条路由。
1221
+ *
1222
+ * 用 unset 而不是写空对象:写空对象会在配置里留下一个空壳路由,
1223
+ * llm-pi-ai 仍然会注册它(只是没有模型),卸载后看着更糊涂。
1224
+ * @returns 新快照。
1225
+ */
1226
+ async clearConfig() {
1227
+ const settings = this.ctx.get('settings')
1228
+ const entryId = this.state.providerEntry !== '' ? this.state.providerEntry : DEFAULT_PROVIDER_ENTRY
1229
+ const routeKey = this.state.routeKey !== '' ? this.state.routeKey : DEFAULT_ROUTE_KEY
1230
+ if (settings !== undefined) {
1231
+ const descriptor = settings.describe().find((row) => row.ns === entryId)
1232
+ if (descriptor !== undefined && descriptor.value?.providers?.[routeKey] !== undefined) {
1233
+ await mutateWithRetry(settings, entryId, [{ op: 'unset', path: ['providers', routeKey] }], descriptor.revision)
1234
+ }
1235
+ }
1236
+ // 清完就停手,否则下一轮轮询又会把它写回去。
1237
+ this.state.sync = false
1238
+ this.state.lastModelIds = []
1239
+ this.state.retired = {}
1240
+ this.state.lastSyncAt = 0
1241
+ this.state.lastSyncCount = 0
1242
+ this.state.lastSyncError = ''
1243
+ this.rawModels = []
1244
+ this.models = []
1245
+ this.newModels = []
1246
+ this.balance = null
1247
+ this.usage = null
1248
+ this.usageWeek = null
1249
+ this.accounts = null
1250
+ this.health = null
1251
+ this.panel = null
1252
+ this.modelsFetchedAt = 0
1253
+ this.overviewFetchedAt = 0
1254
+ this.usageFetchedAt = 0
1255
+ this.usageRange = 'today'
1256
+ this.lastSyncError = ''
1257
+ this.failureKind = ''
1258
+ this.session.token = ''
1259
+ await this.persist()
1260
+ return this.snapshot()
1261
+ }
1262
+
1263
+ /**
1264
+ * 探测一次连接(不改变已保存的配置)。
1265
+ * @param overrides - `{ baseUrl, pwd }`。
1266
+ * @returns `{ ok, health, panel, loggedIn, failure }`。
1267
+ */
1268
+ async probe(overrides = {}) {
1269
+ const base = normalizeBaseUrl(overrides.baseUrl) || this.base
1270
+ const session = new HubSession({ baseUrl: base, pwd: normalizePwd(overrides.pwd ?? this.pwd) })
1271
+ const healthResult = await session.publicGet('/health')
1272
+ if (!healthResult.ok) {
1273
+ return { ok: false, baseUrl: base, failure: classifyFailure(healthResult) }
1274
+ }
1275
+ const health = summarizeHealth(healthResult.body)
1276
+ const statusResult = await session.publicGet('/panel/status')
1277
+ const panel = statusResult.ok ? summarizePanelStatus(statusResult.body) : null
1278
+ let loggedIn = false
1279
+ let usingDefaultPassword = false
1280
+ if (panel !== null && (panel.passwordRequired || panel.passwordIsDefault)) {
1281
+ const login = await session.login()
1282
+ if (!login.ok) {
1283
+ return { ok: false, baseUrl: base, health, panel, loggedIn: false, failure: login.failure }
1284
+ }
1285
+ loggedIn = true
1286
+ usingDefaultPassword = login.usingDefaultPassword === true
1287
+ }
1288
+ // 「连上了」和「能用」是两件事:顺手用当前会话读一次模型列表。
1289
+ // 面板密码对但 API Key 缺失时,这一步能提前暴露问题,而不是等用户选模型时 401。
1290
+ const listResult = await session.authGet('/v1/models')
1291
+ if (!listResult.ok) {
1292
+ return { ok: false, baseUrl: base, health, panel, loggedIn, usingDefaultPassword, failure: classifyFailure(listResult) }
1293
+ }
1294
+ const list = modelListOf(listResult.body)
1295
+ if (list === null) {
1296
+ return { ok: false, baseUrl: base, health, panel, loggedIn, usingDefaultPassword, failure: { kind: 'payload', message: 'hub 的 /v1/models 返回内容不是 OpenAI 模型列表(缺少 data 数组)' } }
1297
+ }
1298
+ return { ok: true, baseUrl: base, health, panel, loggedIn, usingDefaultPassword, models: list.length }
1299
+ }
1300
+
1301
+ /** 打开看板用的深链(含 pwd;只在显式点击时给出,不进快照)。 */
1302
+ dashboardUrl() {
1303
+ return buildDashboardUrl(this.base, this.pwd, this.tab)
1304
+ }
1305
+
1306
+ /** 释放资源:停掉后台轮询后调用。 */
1307
+ dispose() {
1308
+ this.disposed = true
1309
+ this.session.token = ''
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * 从 `/v1/models` 的响应里取出模型数组。
1315
+ *
1316
+ * hub 走的是 OpenAI 形状(`data`),但同类面板也有把清单放在 `models` 下的,
1317
+ * 两种都认,避免因为容器名不同就判定「版本不匹配」。
1318
+ * @param body - 已解析的响应体。
1319
+ * @returns 模型数组;形状不认识时返回 `null`。
1320
+ */
1321
+ function modelListOf(body) {
1322
+ if (Array.isArray(body?.data)) return body.data
1323
+ if (Array.isArray(body?.models)) return body.models
1324
+ return null
1325
+ }
1326
+
1327
+ /**
1328
+ * 读请求体并解析 JSON。
1329
+ * @param request - node http 请求。
1330
+ * @param limit - 字节上限,防止被超大 body 打爆内存。
1331
+ * @returns 解析后的对象(空 body 返回 `{}`)。
1332
+ */
1333
+ async function readJsonBody(request, limit = 64 * 1024) {
1334
+ const chunks = []
1335
+ let size = 0
1336
+ for await (const chunk of request) {
1337
+ size += chunk.length
1338
+ if (size > limit) throw new Error('请求体过大')
1339
+ chunks.push(chunk)
1340
+ }
1341
+ if (size === 0) return {}
1342
+ const text = Buffer.concat(chunks).toString('utf8')
1343
+ const parsed = JSON.parse(text)
1344
+ return parsed !== null && typeof parsed === 'object' ? parsed : {}
1345
+ }
1346
+
1347
+ /** 写 JSON 响应。 */
1348
+ function sendJson(response, status, payload) {
1349
+ const text = JSON.stringify(payload)
1350
+ response.writeHead(status, {
1351
+ 'content-type': 'application/json; charset=utf-8',
1352
+ 'cache-control': 'no-store',
1353
+ })
1354
+ response.end(text)
1355
+ }
1356
+
1357
+ /**
1358
+ * 请求是否来自本机。
1359
+ *
1360
+ * 宿主的 `/api/*` 有会话鉴权,但 `webServer.register` 注册的路由不在那层中间件
1361
+ * 之后(插件路由天然是公开的)。本插件只提供只读数据 + 几个会发起网络请求的
1362
+ * 接口(探测/测试),所以对非本机来源直接关掉写接口,避免把宿主变成 SSRF 跳板。
1363
+ * @param request - node http 请求。
1364
+ * @returns 是否为本机回环地址。
1365
+ */
1366
+ function isLoopback(request) {
1367
+ const address = request.socket?.remoteAddress ?? ''
1368
+ return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
1369
+ }
1370
+
1371
+ /**
1372
+ * 写请求是否来自「同一个页面」。
1373
+ *
1374
+ * 只判回环地址是拦不住浏览器里其它网站的:同机页面向 `127.0.0.1` 发
1375
+ * `fetch(..., {mode:'no-cors', headers:{'content-type':'text/plain'}})` 属于
1376
+ * 简单请求,浏览器不发预检,`remoteAddress` 也是 127.0.0.1 —— 于是任何网页
1377
+ * 都能把 hub 地址改成攻击者的服务器,插件下一轮就会把密码发过去。
1378
+ *
1379
+ * 三道闸一起用:
1380
+ * 1. `Sec-Fetch-Site` 只允许 `same-origin` / `none`(现代浏览器都会带);
1381
+ * 2. 有 `Origin` 就必须和 `Host` 一致(同源页面 POST 也会带 Origin);
1382
+ * 3. 写接口只收 `application/json`(跨站发这个类型会触发预检,而我们不返回 CORS 头)。
1383
+ * 非浏览器客户端(curl、测试、脚本)三者都可以没有,靠回环地址这一层兜住。
1384
+ * @param request - node http 请求。
1385
+ * @returns 是否可信。
1386
+ */
1387
+ function isSameOriginWrite(request) {
1388
+ const site = String(request.headers['sec-fetch-site'] ?? '')
1389
+ if (site !== '' && site !== 'same-origin' && site !== 'none') return false
1390
+ const origin = String(request.headers.origin ?? '')
1391
+ if (origin === '') return true
1392
+ let parsed
1393
+ try {
1394
+ parsed = new URL(origin)
1395
+ } catch {
1396
+ return false
1397
+ }
1398
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
1399
+ const host = String(request.headers.host ?? '')
1400
+ return host !== '' && parsed.host === host
1401
+ }
1402
+
1403
+ /**
1404
+ * 写请求的 content-type 是否可接受。
1405
+ * @param request - node http 请求。
1406
+ * @returns 是否可接受。
1407
+ */
1408
+ function hasJsonContentType(request) {
1409
+ const type = String(request.headers['content-type'] ?? '').toLowerCase()
1410
+ return type === '' || type.startsWith('application/json')
1411
+ }
1412
+
1413
+ /**
1414
+ * 插件入口。
1415
+ * @param ctx - 宿主上下文。
1416
+ * @param config - 插件配置(cordis.patch.yml 里的 config)。
1417
+ */
1418
+ export function apply(ctx, config) {
1419
+ ctx.inject(['webServer'], (webCtx) => {
1420
+ const runtime = new HubRuntime(webCtx, config)
1421
+
1422
+ webCtx.effect(() => {
1423
+ runtime.start().catch((error) => {
1424
+ // 启动失败只记录,不抛:宿主不会因为 hub 没开而崩。
1425
+ runtime.lastSyncError = errorText(error)
1426
+ runtime.failureKind = 'startup'
1427
+ })
1428
+ return () => runtime.dispose()
1429
+ }, 'workbuddy-hub: startup')
1430
+
1431
+ // 后台轮询:hub 模型/额度变了,DSH 里的模型列表跟着变。
1432
+ // 用递归 setTimeout 而不是 setInterval:间隔在用户改设置后能立刻生效,
1433
+ // 而且上一轮还没跑完时不会叠加请求。
1434
+ webCtx.effect(() => {
1435
+ let timer
1436
+ let stopped = false
1437
+ const tick = () => {
1438
+ if (stopped) return
1439
+ // 连续失败时指数退避,别在 hub 掉线时每 2 分钟硬撞。
1440
+ // 冷启动窗口内失败(刚装、刚改地址、重启后凭据还没就绪)要快试,
1441
+ // 否则指数退避会让一次瞬时 401 挂满 4 分钟才自愈。
1442
+ // 被限流(429)时抬到 hub 建议的等待时间以上。
1443
+ const coldStart = Date.now() - runtime.bootedAt < 60000
1444
+ const backedOff = pollIntervalMs(runtime.state.intervalSeconds, runtime.consecutiveFailures, {
1445
+ floorSeconds: runtime.retryFloorSeconds,
1446
+ })
1447
+ const interval = coldStart && runtime.consecutiveFailures > 0 ? Math.min(15000, backedOff) : backedOff
1448
+ timer = setTimeout(() => {
1449
+ if (stopped) return
1450
+ runtime
1451
+ .run(() => runtime.refresh({ sync: true }))
1452
+ .catch(() => undefined)
1453
+ .finally(() => tick())
1454
+ }, interval)
1455
+ timer.unref?.()
1456
+ }
1457
+ tick()
1458
+ return () => {
1459
+ stopped = true
1460
+ clearTimeout(timer)
1461
+ }
1462
+ }, 'workbuddy-hub: polling')
1463
+
1464
+ webCtx.effect(
1465
+ () =>
1466
+ webCtx.webServer.register({
1467
+ kind: 'prefix',
1468
+ path: '/workbuddy2api-hub/api',
1469
+ // 把 promise 返回出去:宿主不使用它,但测试可以直接 await 一次请求结束。
1470
+ handler: (request, response) =>
1471
+ handle(runtime, request, response).catch((error) => {
1472
+ sendJson(response, 500, { ok: false, error: errorText(error) })
1473
+ }),
1474
+ }),
1475
+ 'workbuddy-hub: routes',
1476
+ )
1477
+ })
1478
+ }
1479
+
1480
+ /**
1481
+ * 路由分发。所有写操作都排进 runtime 的串行队列,避免并发改配置。
1482
+ * @param ctx - 宿主上下文。
1483
+ * @param runtime - 运行期实例。
1484
+ * @param request - node http 请求。
1485
+ * @param response - node http 响应。
1486
+ */
1487
+ async function handle(runtime, request, response) {
1488
+ const url = new URL(request.url ?? '/', 'http://localhost')
1489
+ const route = url.pathname.replace(/^\/workbuddy2api-hub\/api/, '') || '/'
1490
+ const method = (request.method ?? 'GET').toUpperCase()
1491
+
1492
+ if (method !== 'GET') {
1493
+ if (!isLoopback(request)) {
1494
+ sendJson(response, 403, { ok: false, error: '只允许本机发起写操作' })
1495
+ return
1496
+ }
1497
+ if (!isSameOriginWrite(request)) {
1498
+ sendJson(response, 403, { ok: false, error: '拒绝跨站请求:写接口只接受同源页面调用' })
1499
+ return
1500
+ }
1501
+ if (!hasJsonContentType(request)) {
1502
+ sendJson(response, 415, { ok: false, error: '写接口只接受 application/json' })
1503
+ return
1504
+ }
1505
+ }
1506
+
1507
+ if (method === 'GET' && route === '/state') {
1508
+ // 前端每 15 秒轮询一次,正好用来补做被 HMR 事务挡掉的同步。
1509
+ await runtime.flushPendingSync()
1510
+ const text = JSON.stringify({ ok: true, ...runtime.snapshot() })
1511
+ // 完整快照约 10~30KB,而 15 秒一轮里绝大多数时候什么都没变。
1512
+ // 用 ETag + 304 把「没变」的那一轮压成一个空响应。
1513
+ const etag = `"${createHash('sha1').update(text).digest('hex').slice(0, 16)}"`
1514
+ if (String(request.headers['if-none-match'] ?? '') === etag) {
1515
+ response.writeHead(304, { etag, 'cache-control': 'no-cache' })
1516
+ response.end()
1517
+ return
1518
+ }
1519
+ response.writeHead(200, {
1520
+ 'content-type': 'application/json; charset=utf-8',
1521
+ 'cache-control': 'no-cache',
1522
+ etag,
1523
+ })
1524
+ response.end(text)
1525
+ return
1526
+ }
1527
+
1528
+ if (method === 'GET' && route === '/dashboard-url') {
1529
+ // 这个接口回包含 pwd 的深链,所以单独一个接口、且只给本机:
1530
+ // 放进 /state 会让密码每 15 秒跟着快照走一遍(还会进 ETag)。
1531
+ if (!isLoopback(request)) {
1532
+ sendJson(response, 403, { ok: false, error: '只允许本机读取看板链接' })
1533
+ return
1534
+ }
1535
+ sendJson(response, 200, { ok: true, url: runtime.dashboardUrl(), tab: runtime.tab })
1536
+ return
1537
+ }
1538
+
1539
+ if (method === 'GET' && route === '/usage') {
1540
+ // `range=today|week|month|all`——hub 只认 range,不认 hours。
1541
+ const range = normalizeUsageRange(url.searchParams.get('range'))
1542
+ await runtime.run(() => runtime.refreshUsage(range))
1543
+ const usage = range === 'week' ? runtime.usageWeek : runtime.usage
1544
+ sendJson(response, 200, {
1545
+ ok: usage !== null,
1546
+ fetchedAt: runtime.usageFetchedAt,
1547
+ range,
1548
+ usage,
1549
+ })
1550
+ return
1551
+ }
1552
+
1553
+ if (method === 'GET' && route === '/accounts') {
1554
+ await runtime.run(() => runtime.refreshAccounts())
1555
+ sendJson(response, 200, {
1556
+ ok: runtime.accounts !== null,
1557
+ accounts: runtime.accounts,
1558
+ balance: runtime.balance,
1559
+ })
1560
+ return
1561
+ }
1562
+
1563
+ if (method === 'POST' && route === '/config') {
1564
+ const body = await readJsonBody(request)
1565
+ const snapshot = await runtime.run(async () => {
1566
+ await runtime.configure(body)
1567
+ // 同步之后再取快照,否则前端拿到的是刷新前的旧状态(看不到本次同步结果)。
1568
+ if (body.refresh !== false) await runtime.refresh({ sync: true })
1569
+ return runtime.snapshot()
1570
+ })
1571
+ sendJson(response, 200, { ok: true, ...snapshot })
1572
+ return
1573
+ }
1574
+
1575
+ if (method === 'POST' && route === '/selection') {
1576
+ const body = await readJsonBody(request)
1577
+ const snapshot = await runtime.run(() => runtime.select(body.id, body))
1578
+ sendJson(response, 200, { ok: true, ...snapshot })
1579
+ return
1580
+ }
1581
+
1582
+ if (method === 'POST' && route === '/bulk') {
1583
+ const body = await readJsonBody(request)
1584
+ const snapshot = await runtime.run(() => runtime.bulk(body.enabled === true))
1585
+ sendJson(response, 200, { ok: true, ...snapshot })
1586
+ return
1587
+ }
1588
+
1589
+ if (method === 'POST' && route === '/enable-new') {
1590
+ const snapshot = await runtime.run(() => runtime.enableNew())
1591
+ sendJson(response, 200, { ok: true, ...snapshot })
1592
+ return
1593
+ }
1594
+
1595
+ if (method === 'POST' && route === '/refresh') {
1596
+ const body = await readJsonBody(request)
1597
+ const result = await runtime.run(() => runtime.refresh({ sync: body.sync !== false, hours: body.hours }))
1598
+ sendJson(response, 200, { ok: result.ok === true, result, ...runtime.snapshot() })
1599
+ return
1600
+ }
1601
+
1602
+ if (method === 'POST' && route === '/policy') {
1603
+ const body = await readJsonBody(request)
1604
+ const snapshot = await runtime.run(async () => {
1605
+ await runtime.setPolicy(body)
1606
+ return runtime.snapshot()
1607
+ })
1608
+ sendJson(response, 200, { ok: true, ...snapshot })
1609
+ return
1610
+ }
1611
+
1612
+ if (method === 'POST' && route === '/dismiss') {
1613
+ const body = await readJsonBody(request)
1614
+ const snapshot = await runtime.run(() => runtime.dismissAlert(body.key))
1615
+ sendJson(response, 200, { ok: true, ...snapshot })
1616
+ return
1617
+ }
1618
+
1619
+ if (method === 'POST' && route === '/retire') {
1620
+ const body = await readJsonBody(request)
1621
+ const snapshot = await runtime.run(() => runtime.retireNow(body.id))
1622
+ sendJson(response, 200, { ok: true, ...snapshot })
1623
+ return
1624
+ }
1625
+
1626
+ if (method === 'POST' && route === '/clear') {
1627
+ const body = await readJsonBody(request)
1628
+ if (body.confirm !== true) {
1629
+ sendJson(response, 400, { ok: false, error: '需要 confirm: true' })
1630
+ return
1631
+ }
1632
+ const snapshot = await runtime.run(() => runtime.clearConfig())
1633
+ sendJson(response, 200, { ok: true, ...snapshot })
1634
+ return
1635
+ }
1636
+
1637
+ if (method === 'POST' && route === '/test') {
1638
+ const body = await readJsonBody(request)
1639
+ const result = await runtime.run(() => runtime.probe(body))
1640
+ sendJson(response, 200, { ok: result.ok === true, ...result })
1641
+ return
1642
+ }
1643
+
1644
+ sendJson(response, 404, { ok: false, error: `未知接口 ${method} ${route}` })
1645
+ }
1646
+
1647
+ /** 供测试与文档使用的常量。 */
1648
+ export const constants = {
1649
+ DEFAULT_BASE_URL,
1650
+ DEFAULT_PWD,
1651
+ DEFAULT_TAB,
1652
+ DEFAULT_API_KEY_ENV,
1653
+ DEFAULT_PROVIDER_ENTRY,
1654
+ DEFAULT_ROUTE_KEY,
1655
+ PLUGIN_VERSION,
1656
+ TABS,
1657
+ REQUEST_TIMEOUT_MS,
1658
+ LOGIN_TIMEOUT_MS,
1659
+ DEFAULT_STATE,
1660
+ buildRouteTarget,
1661
+ HubSession,
1662
+ HubRuntime,
1663
+ // 测试用:把路由器单独拿出来,可以在不起 cordis 的情况下请求它。
1664
+ handle,
1665
+ normalizeBaseUrl,
1666
+ normalizePwd,
1667
+ normalizeTab,
1668
+ classifyFailure,
1669
+ retryAfterSeconds,
1670
+ modelRate,
1671
+ }