dsh-whale-widget 0.3.17 → 0.4.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 CHANGED
@@ -4,6 +4,8 @@ import path from 'node:path'
4
4
  import dns from 'node:dns'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { createHash } from 'node:crypto'
7
+ import https from 'node:https'
8
+ import { exec } from 'node:child_process'
7
9
  import {
8
10
  preciseMoney, addMoney, sumMoney, beijingDay, dayOffset,
9
11
  observeBalance, balanceSummary, daySummary, accountingDays, reconcileBalance,
@@ -28,9 +30,71 @@ const IMAGE_CANDIDATES = [
28
30
  path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
29
31
  ]
30
32
  // 自定义角色图片存放目录(第一个可写的会被使用;roles.json 存角色元数据)
33
+ // ⭐ 2026-10-02(用户要求"并成一份"):**用户数据的候选表一律 $DSH_HOME 优先**,开发机路径降级为兜底。
34
+ // 原因:桌面客户端加载的是 strip 过的发布副本(只认 $DSH_HOME),而开发版宿主原先把开发机路径排在第一位
35
+ // ⇒ 本机上网页端与桌面端**各写各的**(实测两端同时各写一份 .dshw-usage.json / .dshw-bubble.json),
36
+ // 互相看不到对方的改动 —— 表现就是"删掉的模块又冒出来""记账数对不上"。
37
+ // ⚠️ 这个常量必须在**所有**用到它的候选表之前定义(const 有 TDZ),所以放在最前面。
38
+ // ⚠️ `_strip-dev-paths.mjs` 会把发布副本里含开发机路径的行删掉,发布版只剩前两条 $DSH_HOME ✓
39
+ const DATA_CANDIDATES_TAIL = [
40
+ ]
41
+ // v790(用户要求):菜单底部那行小字显示什么版本。
42
+ // · 发布版:显示当前版本(= package.json 的 version)
43
+ // · 开发版:显示**下一个补丁号 + beta**(例如 0.3.18 的开发版显示 0.3.19-beta),
44
+ // 因为开发版里的改动属于"将要发布的那一版"。
45
+ // ⚠️ BUILD_VERSION 与下面 payload 里两处 `version: '…'` 一起由 `_bump-version.mjs` 更新;
46
+ // ⚠️ BUILD_CHANNEL 由 `_strip-dev-paths.mjs` 在生成发布副本时改成 'release'(发布副本不带 dev 标记)。
47
+ const BUILD_VERSION = '0.4.0'
48
+ const BUILD_CHANNEL = 'release'
49
+ function versionLabelOf(version, channel) {
50
+ const s = String(version == null ? '' : version)
51
+ if (String(channel) !== 'dev') return s
52
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s)
53
+ if (!m) return s ? s + '-beta' : ''
54
+ return m[1] + '.' + m[2] + '.' + (Number(m[3]) + 1) + '-beta'
55
+ }
56
+ const VERSION_LABEL = versionLabelOf(BUILD_VERSION, BUILD_CHANNEL)
57
+ // v793(用户要求):点菜单底部的版号 → 「关于 / 自检」面板。这一层配套的敏感信息处理:
58
+ // 面板与「复制诊断信息」只允许出现**白名单字段**;任何路径都先脱敏(用户名 → %USERPROFILE%、DSH 目录 → $DSH_HOME),
59
+ // **绝不包含** API key、账号 id、账本明细、会话内容。所有字段的组装集中在 aboutPayload(),便于审计。
60
+ function maskPath(input) {
61
+ let s = String(input == null ? '' : input)
62
+ try {
63
+ // ⚠️ 顺序要紧:先替换**更长的、更具体的** DSH_HOME,再替换家目录。
64
+ // 反过来的话 `C:\Users\x\.dsh\...` 会先被压成 `%USERPROFILE%\.dsh\...`,$DSH_HOME 就再也匹配不到了(探针抓到过)。
65
+ if (DSH_HOME) {
66
+ s = s.split(DSH_HOME).join('$DSH_HOME')
67
+ s = s.split(String(DSH_HOME).replace(/\\/g, '/')).join('$DSH_HOME')
68
+ }
69
+ const home = os.homedir()
70
+ if (home) {
71
+ s = s.split(home).join('%USERPROFILE%')
72
+ s = s.split(home.replace(/\\/g, '/')).join('%USERPROFILE%')
73
+ }
74
+ // 兜底:任何盘符/家目录形态的用户名段
75
+ s = s.replace(/[A-Za-z]:\\Users\\[^\\/]+/g, '%USERPROFILE%')
76
+ s = s.replace(/\/(Users|home)\/[^\/]+/g, '%USERPROFILE%')
77
+ } catch (err) {}
78
+ return s
79
+ }
80
+ function fileFacts(file) {
81
+ try {
82
+ const st = fs.statSync(file)
83
+ return { bytes: st.size, mtime: st.mtimeMs }
84
+ } catch (err) { return null }
85
+ }
86
+ function firstExisting(list) {
87
+ for (const p of (list || [])) {
88
+ try { if (fs.existsSync(p)) return p } catch (err) {}
89
+ }
90
+ return ''
91
+ }
92
+ // ⭐ 2026-10-02(用户要求"并成一份"):**角色图库目录也 $DSH_HOME 优先**(开发机路径降级为兜底)。
31
93
  const ROLE_DIR_CANDIDATES = [
32
94
  path.join(DSH_HOME, 'whale-roles'),
33
95
  path.join(DSH_HOME, 'profiles', 'web', 'whale-roles'),
96
+ DATA_CANDIDATES_TAIL[0] + 'whale-roles/',
97
+ DATA_CANDIDATES_TAIL[1] + 'whale-roles/',
34
98
  ]
35
99
  const ROLE_INDEX_NAME = 'roles.json'
36
100
  const ROLE_DEFAULT_ID = 'default'
@@ -38,6 +102,8 @@ const ROLE_DEFAULT_ID = 'default'
38
102
  const AUDIO_DIR_CANDIDATES = [
39
103
  path.join(DSH_HOME, 'whale-audio'),
40
104
  path.join(DSH_HOME, 'profiles', 'web', 'whale-audio'),
105
+ DATA_CANDIDATES_TAIL[0] + 'whale-audio/',
106
+ DATA_CANDIDATES_TAIL[1] + 'whale-audio/',
41
107
  ]
42
108
  const AUDIO_INDEX_NAME = 'audio.json'
43
109
  // 内置预设音效组(不可删、不可改),对应 assets 里的 Ya/D 系列
@@ -57,6 +123,14 @@ const PRESET_FRAGMENTS = {
57
123
  // 任务结束音 A(随包资源):原为用户音效库里的自导入片段,现作为内置预设随包发布。
58
124
  // 显示名与用户库里同名片段一致时,前端下拉按显示名去重(内置与前端的同名条目只出现一条)。
59
125
  end_a: { id: 'end_a', name: 'A', preset: true, mime: 'audio/wav' },
126
+ // v859(用户要求):把"当前正在使用的"三个音效固化为**新的出厂默认**(随包发布,新装即此体验)
127
+ // · cost_a = 每轮消耗提示(走 taskEnd.sel)
128
+ // · ask_a = 提问提示(走 events.question.sel)
129
+ // · approve_a = 授权提示(走 events.approval.sel)
130
+ // 与 end_a 同一套做法:原为用户音效库里的自导入片段,现作为内置预设随包发布。
131
+ cost_a: { id: 'cost_a', name: '每轮消耗·默认', preset: true, mime: 'audio/wav' },
132
+ ask_a: { id: 'ask_a', name: '提问·默认', preset: true, mime: 'audio/wav' },
133
+ approve_a: { id: 'approve_a', name: '授权·默认', preset: true, mime: 'audio/wav' },
60
134
  }
61
135
  // 内置片段的音频文件:包内 assets 优先,开发环境回退到 skin 目录
62
136
  const BUILTIN_FRAGMENT_FILES = {
@@ -67,12 +141,25 @@ const BUILTIN_FRAGMENT_FILES = {
67
141
  end_a: [
68
142
  path.join(PACKAGE_ROOT, 'assets', 'task-end-a.wav'),
69
143
  ],
144
+ // v859:三个新的出厂默认音效(随包 assets;开发环境回退到 skin 目录)
145
+ cost_a: [
146
+ path.join(PACKAGE_ROOT, 'assets', 'cost-default.wav'),
147
+ ],
148
+ ask_a: [
149
+ path.join(PACKAGE_ROOT, 'assets', 'ask-default.wav'),
150
+ ],
151
+ approve_a: [
152
+ path.join(PACKAGE_ROOT, 'assets', 'approve-default.wav'),
153
+ ],
70
154
  }
71
155
  // —— 自定义 API 模型(v655):非 DeepSeek 厂商的余额 / 用量 / 提醒 ——
72
156
  // 注册表落在 $DSH_HOME/.dshw-api.json;密钥走 DSH 官方凭据(ctx.credentials),本文件不存明文。
157
+ // ⭐ 2026-10-02(用户要求"并成一份"):$DSH_HOME 优先(自定义 API 模型清单也属于用户数据,原先两端各存一份)。
73
158
  const API_FILE_CANDIDATES = [
74
159
  path.join(DSH_HOME, '.dshw-api.json'),
75
160
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
161
+ DATA_CANDIDATES_TAIL[0] + '.dshw-api.json',
162
+ DATA_CANDIDATES_TAIL[1] + '.dshw-api.json',
76
163
  ]
77
164
  const API_BUILTIN_ID = 'deepseek'
78
165
  // 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
@@ -163,7 +250,17 @@ const API_TEMPLATES = {
163
250
  quota: {
164
251
  url: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
165
252
  auth: '{key}', // 智谱此接口不带 Bearer
166
- json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
253
+ // v789(issue #180/#181 修):`data.limits[]` 是扁平条目数组,`TOKENS_LIMIT` 是条目的 `type` **值**,
254
+ // 且 5h 窗口不保证排在 [0](pro 账号 [0] 是 TIME_LIMIT、max 账号 [0] 才是 TOKENS_LIMIT)
255
+ // ⇒ 一律按 type(+unit) 选条目:unit=3 = 5 小时窗口、unit=6 = 周窗口;
256
+ // 新注册/credit 制账号的 type 是 CREDIT_LIMIT ⇒ 一并兼容。
257
+ json: {
258
+ level: 'data.level',
259
+ windows: [
260
+ { key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
261
+ { key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
262
+ ],
263
+ },
167
264
  },
168
265
  probeUrl: 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
169
266
  },
@@ -239,9 +336,12 @@ const API_TEMPLATES = {
239
336
  },
240
337
  anthropic: {
241
338
  name: 'Anthropic Claude', currency: 'USD', keyRef: 'ANTHROPIC_API_KEY', noBalanceApi: true,
242
- apiNote: '官方无余额接口;用量要走 Admin API(需 admin key)或控制台。注意其探活需要 x-api-key + anthropic-version 两个请求头,本挂件暂不支持 → 不提供探活',
339
+ // v791:请求头支持「分号分隔列表」后,探活这条路终于能走了 —— Anthropic 要求 x-api-key + anthropic-version 两个头。
340
+ // 官方**没有余额接口**(余额/用量只能去控制台或 Admin API),所以这里只提供探活与模型列表。
341
+ apiNote: '官方无余额接口(余额/用量见控制台,或按会话 token 估算)。探活走 /v1/models:需要 x-api-key + anthropic-version 两个请求头(v791 起支持,模板已预置)',
243
342
  matchIds: ['claude'],
244
- balance: { url: '', auth: 'Bearer {key}', json: { remaining: '' } },
343
+ balance: { url: '', auth: 'x-api-key:{key};anthropic-version:2023-06-01', json: { remaining: '' } },
344
+ probeUrl: 'https://api.anthropic.com/v1/models',
245
345
  },
246
346
  gemini: {
247
347
  name: 'Google Gemini', currency: 'USD', keyRef: 'GEMINI_API_KEY', noBalanceApi: true,
@@ -346,7 +446,14 @@ const API_TEMPLATES = {
346
446
  quota: {
347
447
  url: 'https://api.z.ai/api/monitor/usage/quota/limit',
348
448
  auth: '{key}', // 与国内站一样不带 Bearer
349
- json: { percent: 'data.limits[0].TOKENS_LIMIT.percentage', resetAt: 'data.limits[0].nextResetTime', level: 'data.level' },
449
+ // v789:与国内站同一套 `data.limits[]` 结构(见 zhipu_glm_coding 的注释),一并按 type+unit 选条目
450
+ json: {
451
+ level: 'data.level',
452
+ windows: [
453
+ { key: 'rolling', label: '5h', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 3 }, percent: 'percentage', resetAt: 'nextResetTime' },
454
+ { key: 'weekly', label: '周', pick: { list: 'data.limits', types: ['TOKENS_LIMIT', 'CREDIT_LIMIT'], unit: 6 }, percent: 'percentage', resetAt: 'nextResetTime' },
455
+ ],
456
+ },
350
457
  },
351
458
  probeUrl: 'https://api.z.ai/api/monitor/usage/quota/limit',
352
459
  },
@@ -365,20 +472,28 @@ const API_TEMPLATES = {
365
472
  },
366
473
  }
367
474
 
475
+ // ⭐ 2026-10-02(用户要求"并成一份"):这些**用户数据文件一律 $DSH_HOME 优先**,开发机路径降级为只读兜底。
476
+ // 原因见上方 AUDIO_DIR_CANDIDATES 的说明(桌面端只认 $DSH_HOME,两端原先各写各的)。
368
477
  // Size memory file: prefer writable DSH home locations, then legacy fallbacks.
369
478
  const SIZE_FILE_CANDIDATES = [
370
479
  path.join(DSH_HOME, '.dshw-size.json'),
371
480
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
481
+ DATA_CANDIDATES_TAIL[0] + '.dshw-size.json',
482
+ DATA_CANDIDATES_TAIL[1] + '.dshw-size.json',
372
483
  ]
373
484
  // Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
374
485
  const USAGE_FILE_CANDIDATES = [
375
486
  path.join(DSH_HOME, '.dshw-usage.json'),
376
487
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
488
+ DATA_CANDIDATES_TAIL[0] + '.dshw-usage.json',
489
+ DATA_CANDIDATES_TAIL[1] + '.dshw-usage.json',
377
490
  ]
378
491
  // 每轮消耗 seq 持久化文件:避免插件热重载后 seq 归零导致页面把新轮次误判为旧轮次吞掉
379
492
  const TURN_FILE_CANDIDATES = [
380
493
  path.join(DSH_HOME, '.dshw-turn.json'),
381
494
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-turn.json'),
495
+ DATA_CANDIDATES_TAIL[0] + '.dshw-turn.json',
496
+ DATA_CANDIDATES_TAIL[1] + '.dshw-turn.json',
382
497
  ]
383
498
  // Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/),
384
499
  // legacy absolute paths as fallback.
@@ -387,6 +502,8 @@ const BUBBLE_FILE_CANDIDATES = [
387
502
  // 否则清完这个数组会变成空的:泡泡配置读不了也存不了(自定义泡泡整体失效)。
388
503
  path.join(DSH_HOME, '.dshw-bubble.json'),
389
504
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-bubble.json'),
505
+ DATA_CANDIDATES_TAIL[0] + '.dshw-bubble.json',
506
+ DATA_CANDIDATES_TAIL[1] + '.dshw-bubble.json',
390
507
  ]
391
508
  // 泡泡图库(独立于角色图库):图片文件目录 + 索引
392
509
  const BUBBLE_IMG_DIR_CANDIDATES = [
@@ -394,6 +511,8 @@ const BUBBLE_IMG_DIR_CANDIDATES = [
394
511
  // 空数组会让图库上传直接报错。
395
512
  path.join(DSH_HOME, 'whale-bubble-imgs'),
396
513
  path.join(DSH_HOME, 'profiles', 'web', 'whale-bubble-imgs'),
514
+ DATA_CANDIDATES_TAIL[0] + 'whale-bubble-imgs/',
515
+ DATA_CANDIDATES_TAIL[1] + 'whale-bubble-imgs/',
397
516
  ]
398
517
  const BUBBLE_IMG_INDEX_NAME = 'bubble-imgs.json'
399
518
  // 内置默认泡泡图(语义 id → assets 文件):面板常驻可选;全新安装无用户图库时
@@ -430,6 +549,8 @@ function soundSetFromUrl(url) {
430
549
  }
431
550
  const BALANCE_URL = 'https://api.deepseek.com/user/balance'
432
551
  const BALANCE_TTL_MS = 25000
552
+ // v781:账号登录路的兜底超时(毫秒)。DSH 账号服务自身默认 30s,太长会把前端 FETCH_TIMEOUT_MS(25s) 先耗掉。
553
+ const ACCOUNT_FALLBACK_MS = 5000
433
554
  const RUA_GIF_CANDIDATES = [
434
555
  path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
435
556
  ]
@@ -794,9 +915,14 @@ export default {
794
915
  function finalizeTurn(sessionId) {
795
916
  const agg = turnAggs.get(sessionId)
796
917
  if (agg && agg.cost > 0) {
918
+ // v862/v862b:子智能体的会话**照常记账**(金额与用量事件都写),
919
+ // 只在「子智能体完成时不提示」开着时跳过 lastTurnSeq ⇒ 前端不会因为它响提示音。
920
+ const isSub = subagentSessions.has(String(sessionId || '')) && subagentMuteOn()
797
921
  lastTurn = { turn: agg.turn, amount: agg.cost, tokens: agg.tokens, ts: agg.lastTs }
798
- lastTurnSeq++
799
- writeTurnSeq(lastTurnSeq)
922
+ if (!isSub) {
923
+ lastTurnSeq++
924
+ writeTurnSeq(lastTurnSeq)
925
+ }
800
926
  // 按模型拆分写入用量事件(模型明细的唯一来源)
801
927
  const byModel = agg.byModel || {}
802
928
  const byModelTokens = agg.byModelTokens || {}
@@ -960,6 +1086,44 @@ export default {
960
1086
  } catch (err) {}
961
1087
  }
962
1088
 
1089
+ // v862(用户实测确认:跑一次子智能体,last-turn 的 seq 会 +1 ⇒ 前端跟着响"任务结束音")——
1090
+ // 机制:finalizeTurn(sessionId) 对**每个 session 的 turn/end** 都推进 lastTurnSeq,
1091
+ // 子智能体跑完自己的 turn 同样让 seq 变大 ⇒ 响。实测:seq 362 →(子智能体跑完)→ 363。
1092
+ // 区分依据(DSH Host Event 契约,已用 Inspector 核对):
1093
+ // 'subagent/start'(info: SubagentRunInfo) info = { runId, provider, id: SessionId, local }
1094
+ // 'subagent/end'(info: SubagentRunEndInfo) info = { runId, provider, id, local, stopReason, … }
1095
+ // ⇒ 子会话 id = info.id。
1096
+ // v862b(用户要求):做成**设置项**「子智能体完成时不提示」(键 = settings.subagent.mute,默认 true)。
1097
+ let subagentMuteCache = { at: 0, val: true }
1098
+ function subagentMuteOn() {
1099
+ try {
1100
+ const now = Date.now()
1101
+ if (now - subagentMuteCache.at < 2000) return subagentMuteCache.val
1102
+ const s = readUsageSettings() || {}
1103
+ const sg = (s && typeof s.subagent === 'object' && s.subagent) ? s.subagent : {}
1104
+ subagentMuteCache = { at: now, val: sg.mute !== false } // 缺省 = 不提示
1105
+ } catch (err) {
1106
+ subagentMuteCache = { at: Date.now(), val: true }
1107
+ }
1108
+ return subagentMuteCache.val
1109
+ }
1110
+ const subagentSessions = new Set()
1111
+ const SUBAGENT_ID_MAX = 500
1112
+ function rememberSubagentSession(info) {
1113
+ try {
1114
+ const id = info && info.id ? String(info.id) : ''
1115
+ if (!id || subagentSessions.has(id)) return
1116
+ if (subagentSessions.size >= SUBAGENT_ID_MAX) {
1117
+ const keep = Array.from(subagentSessions).slice(-Math.floor(SUBAGENT_ID_MAX / 2))
1118
+ subagentSessions.clear()
1119
+ for (const k of keep) subagentSessions.add(k)
1120
+ }
1121
+ subagentSessions.add(id)
1122
+ } catch (err) {}
1123
+ }
1124
+ try { disposers.push(ctx.on('subagent/start', (info) => rememberSubagentSession(info))) } catch (err) {}
1125
+ try { disposers.push(ctx.on('subagent/end', (info) => rememberSubagentSession(info))) } catch (err) {}
1126
+
963
1127
  // 监听所有会话的追加事件;按会话 id 分桶,turn/end 时结算该会话本轮
964
1128
  disposers.push(ctx.on('session/event', (session, event) => {
965
1129
  const sid = session && session.id ? session.id : 'default'
@@ -1017,10 +1181,13 @@ export default {
1017
1181
  // ⚠️ 必须用 `ctx.get('deepseekAccount')` 读**可选服务**,**绝不能写进 inject** —— 老宿主根本没有这个服务,
1018
1182
  // 一旦进 inject,整个插件会永远等不到服务而**完全不 apply**(比没有这个功能严重得多)。
1019
1183
  // ⚠️ 与 API key 路径**互不干扰**(API key 永远优先;DSH 自己的注释也是 "Neither route falls back to the other")。
1184
+ // v781:账号余额路的失败原因(只用于错误文案,不参与任何判定)。fetchAccountBalance 每次开头清空。
1185
+ let accountDiag = ''
1020
1186
  async function fetchAccountBalance() {
1021
1187
  let account = null
1188
+ accountDiag = ''
1022
1189
  try { account = typeof ctx.get === 'function' ? ctx.get('deepseekAccount') : null } catch (err) { account = null }
1023
- if (!account || typeof account.getBalance !== 'function') return null
1190
+ if (!account || typeof account.getBalance !== 'function') { accountDiag = '当前 DSH 没有账号服务'; return null }
1024
1191
  // 账户标识:优先用账号服务给的稳定 id(不同账号 → 不同账本 scope);取不到就退回固定标识。
1025
1192
  // 一律 sha256[:24] 十六进制 —— accounting.mjs 校验 scope 必须匹配 /^[a-zA-Z0-9_-]{1,80}$/
1026
1193
  // (带冒号这种可读写法会直接抛「账户标识无效」,见 issue #157 报告人踩的坑 1)。
@@ -1036,25 +1203,52 @@ export default {
1036
1203
  locale: String(process.env.DSH_LOCALE || '') || 'zh_CN',
1037
1204
  timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60, // 东为正的整秒
1038
1205
  })
1039
- } catch (err) { return null }
1206
+ } catch (err) {
1207
+ accountDiag = '账号余额接口调用失败: ' + String((err && err.message) || err).slice(0, 120)
1208
+ return null
1209
+ }
1040
1210
  // 未登录 / 没有 grant / 平台失败 → 一律返回 null,让调用方退回原来的文案(**不缓存错误**)
1041
- if (!result || result.status !== 'ready' || !Array.isArray(result.value) || result.value.length === 0) return null
1211
+ // v781:失败时记下**原因**(accountDiag),供 fetchBalance 把两条路的原因一起报给界面 ——
1212
+ // 以前只有一个笼统的"账号余额不可用",用户/支持方分不清"没登录"和"接口失败"。
1213
+ if (!result) { accountDiag = '未登录 DeepSeek 账号(没有账号凭据)'; return null }
1214
+ if (result.status !== 'ready') { accountDiag = '账号余额接口未就绪(' + String(result.status || 'unknown') + ')'; return null }
1042
1215
  const accNum = (v) => { const n = Number(v); return Number.isFinite(n) ? n : null }
1043
- const wallets = result.value.filter((w) => w && accNum(w.balance) !== null)
1044
- if (wallets.length === 0) return null
1045
- const currency = wallets.some((w) => String(w.currency || '').toUpperCase() === 'CNY')
1046
- ? 'CNY'
1047
- : String((wallets[0] && wallets[0].currency) || 'CNY').toUpperCase()
1048
- const sumOf = (list) => (Array.isArray(list) ? list : [])
1049
- .filter((w) => w && String(w.currency || 'CNY').toUpperCase() === currency && accNum(w.balance) !== null)
1216
+ // v781:**"充值钱包为空、只有赠金"必须算成功** —— 这是"只登录客户端读不到余额"的主因。
1217
+ // 平台把钱包拆成 `normal_wallets`(充值)与 `bonus_wallets`(赠金);新用户/没充过值的用户
1218
+ // 常常**只有赠金**(甚至 normal_wallets 是空数组)⇒ 旧代码要求 `result.value` 非空,
1219
+ // 直接把这类账号判成"没有余额钱包",界面就只显示"读取不到余额"。
1220
+ // 判据对齐 DSH 自己的账号卡片(dsh-client-ui-settings-account):
1221
+ // `[...balance.value, ...balance.bonusWallets].some(w => new Big(w.balance).gt(0))`
1222
+ // —— 两个钱包一起看,不再单看充值钱包。
1223
+ const normRaw = Array.isArray(result.value) ? result.value : []
1224
+ const bonusRaw = Array.isArray(result.bonusWallets) ? result.bonusWallets : []
1225
+ const usable = (list) => list.filter((w) => w && accNum(w.balance) !== null)
1226
+ const normWallets = usable(normRaw)
1227
+ const bonusWallets = usable(bonusRaw)
1228
+ const allWallets = normWallets.concat(bonusWallets)
1229
+ if (allWallets.length === 0) {
1230
+ // 两边都没有可用钱包 = 真的没有数据(未激活/账号没有任何钱包),不是"余额 0"
1231
+ accountDiag = normRaw.length || bonusRaw.length ? '钱包字段不可用' : '账号没有余额钱包'
1232
+ return null
1233
+ }
1234
+ // 币种:优先「有余额的那个钱包」的币种(CNY 优先),其次任何 CNY 钱包,最后第一个
1235
+ const curOf = (w) => String((w && w.currency) || 'CNY').toUpperCase()
1236
+ const nonzero = allWallets.filter((w) => Number(w.balance) > 0)
1237
+ const curPick = nonzero.find((w) => curOf(w) === 'CNY') || nonzero[0]
1238
+ || allWallets.find((w) => curOf(w) === 'CNY') || allWallets[0]
1239
+ const currency = curOf(curPick)
1240
+ const sumOf = (list) => list
1241
+ .filter((w) => w && curOf(w) === currency && accNum(w.balance) !== null)
1050
1242
  .reduce((s, w) => s + Number(w.balance), 0)
1051
- const recharge = sumOf(result.value)
1052
- const bonus = sumOf(result.bonusWallets)
1243
+ const recharge = sumOf(normWallets)
1244
+ const bonus = sumOf(bonusWallets)
1245
+ accountDiag = ''
1053
1246
  return {
1054
1247
  ok: true,
1055
1248
  // 充值 + 赠金:与 API key 路径的 `total_balance` 同口径(那个字段本身也是两者相加),
1056
1249
  // 否则"按余额差"推算的今日已用会偏小(issue #157 报告人的坑 2)。
1057
1250
  totalBalance: Number((recharge + bonus).toFixed(6)),
1251
+ // v781:两个分量单独下发,供「赠金 / 账户余额」两个自定义模块使用(见 SPEC §25)
1058
1252
  rechargeBalance: Number(recharge.toFixed(6)),
1059
1253
  bonusBalance: Number(bonus.toFixed(6)),
1060
1254
  currency,
@@ -1064,30 +1258,64 @@ export default {
1064
1258
  }
1065
1259
  }
1066
1260
 
1261
+ // v781:账号路的兜底(**两条路都失败才报错,并把两条路的原因都带上**)。
1262
+ // 为什么需要它:旧实现只在"完全没有 API key"时才试账号路 ⇒ 曾经配过 key(过期/写错/被删)
1263
+ // 但现在只用客户端登录的用户,余额**永远读不到**("只登录客户端读不到余额"报告里的另一大类)。
1264
+ // ⚠️ 超时上限:账号路走 DSH 自己的服务(默认 30s)⇒ 我们用 ACCOUNT_FALLBACK_MS 截断,
1265
+ // 保证"API key 两次 8s + 退避"之后仍在**前端的 25s** 之内回来,不让界面先 abort。
1266
+ async function accountFallback(keyErr) {
1267
+ let acc = null
1268
+ try {
1269
+ acc = await Promise.race([
1270
+ fetchAccountBalance(),
1271
+ new Promise((r) => setTimeout(() => { accountDiag = '账号余额请求超时'; r(null) }, ACCOUNT_FALLBACK_MS)),
1272
+ ])
1273
+ } catch (err) {
1274
+ acc = null
1275
+ accountDiag = accountDiag || ('账号余额异常: ' + String((err && err.message) || err).slice(0, 120))
1276
+ }
1277
+ if (acc) return acc
1278
+ const keyPart = keyErr
1279
+ ? ('API key 路:' + String((keyErr.error || keyErr.code || '失败')).slice(0, 160))
1280
+ : '未配置 DEEPSEEK_API_KEY'
1281
+ return {
1282
+ ok: false,
1283
+ code: keyErr ? 'BOTH_FAILED' : 'NO_KEY',
1284
+ error: keyPart + ';账号登录路:' + (accountDiag || '拿不到余额'),
1285
+ }
1286
+ }
1067
1287
  async function fetchBalance() {
1068
1288
  // v739(用户反馈「每次新实例的第一次余额请求都失败」):
1069
1289
  // ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
1070
1290
  // ② 单次超时从 20s 收到 8s,保证「2 次尝试 + 退避」明显短于前端 FETCH_TIMEOUT_MS(25s) ——
1071
1291
  // 否则前端先 abort,用户看到的永远是「第一次失败」,只能干等 60 秒后的下一轮。
1072
1292
  let cred = null
1293
+ let credErr = null
1073
1294
  for (let attempt = 0; attempt < 2; attempt++) {
1074
1295
  try {
1075
1296
  cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
1297
+ credErr = null
1076
1298
  break
1077
1299
  } catch (err) {
1300
+ credErr = err
1078
1301
  if (attempt === 0) { await new Promise((r) => setTimeout(r, 700)); continue }
1079
- return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
1302
+ // v781:凭据服务报错时**也要试账号路**(以前直接 return,登录态用户就被这条挡住)
1303
+ return await accountFallback({ code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) })
1080
1304
  }
1081
1305
  }
1082
1306
  if (!cred) {
1083
1307
  // v759(issue #157):没有 API key 时退回 DSH 账号登录态;两条路互不干扰,API key 始终第一优先。
1084
- const accountPayload = await fetchAccountBalance()
1085
- if (accountPayload) return accountPayload
1086
- return {
1087
- ok: false, code: 'NO_KEY',
1088
- error: '未配置 DEEPSEEK_API_KEY,且账号余额不可用(未登录 DeepSeek 账号,或该账号没有余额钱包)',
1089
- }
1308
+ return await accountFallback(null)
1090
1309
  }
1310
+ const apiFail = await fetchApiKeyBalance(cred)
1311
+ if (apiFail.ok) return apiFail
1312
+ // v781:key 路**任何**失败都再试一次账号路(401/结构异常/超时都算)
1313
+ void credErr
1314
+ return await accountFallback(apiFail)
1315
+ }
1316
+
1317
+ // API key 路(原 fetchBalance 的主体,v781 抽出来以便"失败后回退账号路")
1318
+ async function fetchApiKeyBalance(cred) {
1091
1319
  let lastErr = null
1092
1320
  for (let attempt = 0; attempt < 2; attempt++) {
1093
1321
  let res
@@ -1117,9 +1345,15 @@ export default {
1117
1345
  if (!info || info.total_balance === undefined || !Number.isFinite(Number(info.total_balance))) {
1118
1346
  return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
1119
1347
  }
1348
+ const numOr = (x) => { const n = Number(x); return Number.isFinite(n) ? n : null }
1120
1349
  return {
1121
1350
  ok: true,
1122
1351
  totalBalance: Number(info.total_balance),
1352
+ // v781:官方 /user/balance 除 total_balance 外还给了 granted_balance(**未过期**赠金)与
1353
+ // topped_up_balance(充值)。取不到就是 null(界面显示 —,**不臆造 0** —— 0 会被记账
1354
+ // 当成"余额真的见底",那是另一种语义)。两个分量供自定义模块里的「赠金 / 账户余额」使用。
1355
+ bonusBalance: numOr(info.granted_balance),
1356
+ rechargeBalance: numOr(info.topped_up_balance),
1123
1357
  accountTag: createHash('sha256').update(String(cred.value)).digest('hex').slice(0, 24),
1124
1358
  currency: String(info.currency || 'CNY'),
1125
1359
  balanceSource: 'apikey',
@@ -1146,15 +1380,44 @@ export default {
1146
1380
  }
1147
1381
  }
1148
1382
  }
1383
+ // v860(issue #232 / #225):账本文件损坏时**不再抛错锁死**。
1384
+ // 旧行为:JSON 解析失败或结构不对就 throw ⇒ 一个坏文件让"读不了、写不了、不自愈",
1385
+ // 余额/今日已用/额度/音效设置一起失效,只能用户手删文件。
1386
+ // 新行为:把坏文件**改名备份**(.corrupt-<时间戳>.bak,数据不丢)→ 用默认结构继续跑,
1387
+ // 插件立刻恢复可用;控制台留一条 message 方便排查。
1388
+ function freshUsageLedger() {
1389
+ return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
1390
+ }
1391
+ function ledgerBackupCorrupt(p, why) {
1392
+ try {
1393
+ const bak = p + '.corrupt-' + Date.now() + '.bak'
1394
+ ledgerIo(() => fs.renameSync(p, bak))
1395
+ console.error('[whale-ledger] 账本损坏(' + why + ')→ 已备份为 ' + bak + ',并用默认结构重建(原文件未删除)')
1396
+ } catch (err) {
1397
+ console.error('[whale-ledger] 账本损坏(' + why + ')且备份失败:', (err && err.code) || (err && err.message))
1398
+ }
1399
+ }
1149
1400
  function readUsageLedger() {
1150
1401
  for (const p of USAGE_FILE_CANDIDATES) {
1402
+ let raw = null
1403
+ try {
1404
+ raw = ledgerIo(() => fs.readFileSync(p, 'utf8'))
1405
+ } catch (err) {
1406
+ if (err.code === 'ENOENT') continue // 该候选路径没有文件:试下一个(正常情况)
1407
+ ledgerBackupCorrupt(p, (err && err.code) || 'read-error')
1408
+ return freshUsageLedger()
1409
+ }
1151
1410
  try {
1152
- const parsed = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
1411
+ const parsed = JSON.parse(raw)
1153
1412
  if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
1154
- throw new Error('账本结构异常,已停止写入以保护原记录')
1155
- } catch (err) { if (err.code !== 'ENOENT') throw err }
1413
+ ledgerBackupCorrupt(p, 'bad-structure')
1414
+ return freshUsageLedger()
1415
+ } catch (err) {
1416
+ ledgerBackupCorrupt(p, 'bad-json')
1417
+ return freshUsageLedger()
1418
+ }
1156
1419
  }
1157
- return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
1420
+ return freshUsageLedger()
1158
1421
  }
1159
1422
  function writeUsageLedger(led) {
1160
1423
  const body = JSON.stringify(led)
@@ -1285,7 +1548,7 @@ export default {
1285
1548
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
1286
1549
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
1287
1550
  return {
1288
- ok: true, version: '0.3.17', today: todayData, days7,
1551
+ ok: true, version: '0.4.0', versionLabel: VERSION_LABEL, today: todayData, days7,
1289
1552
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
1290
1553
  all: {
1291
1554
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1325,13 +1588,17 @@ export default {
1325
1588
  // 没有它就无法区分"默认 100%"与"用户设了 100%",前端 soundVolumeOf() 靠它决定优先级。
1326
1589
  // ⚠️ 与前端 soundEventDefaults().turnCost **必须逐字段一致**(探针 §⑥ 跨端一致钉住)。
1327
1590
  turnCost: { vol: 1, volSet: false, bubbleOn: true },
1328
- question: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('question') },
1329
- approval: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('approval') },
1591
+ // v859:提问/授权的出厂默认音效改为随包内置的 ask_a / approve_a(= 当前正在用的那两个)
1592
+ question: { on: true, soundOn: false, sel: 'frag:ask_a', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('question') },
1593
+ approval: { on: true, soundOn: false, sel: 'frag:approve_a', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('approval') },
1330
1594
  }
1331
1595
  }
1332
1596
  function usageSettingsDefaults() {
1333
1597
  return { // 任务结束音:v774 起出厂默认 = **开**(作者当前用法),默认音 = 内置 A(end_a)
1334
- taskEnd: { on: true, sel: 'frag:end_a' },
1598
+ // v859:任务结束/每轮消耗音的出厂默认改为随包内置的 cost_a(= 当前正在用的那一个)
1599
+ taskEnd: { on: true, sel: 'frag:cost_a' },
1600
+ // v863:子智能体完成是否提示(默认 true = 不提示;键 = settings.subagent.mute)
1601
+ subagent: { mute: true },
1335
1602
  // v777:「等待交互提示」的交互开关(用户 2026-09-26 要求)
1336
1603
  // charClose=false(出厂默认)= 只有**点泡泡**能收起等待提示;true = **点角色**也能收起。
1337
1604
  // 两种情况下"点掉的那条挂起"都不会再被每秒轮询自动弹回(前端 waitDismissedId)。
@@ -1385,6 +1652,15 @@ export default {
1385
1652
  if (s.alert && typeof s.alert === 'object') d.alert = Object.assign({}, d.alert, s.alert)
1386
1653
  if (s.budget && typeof s.budget === 'object') d.budget = Object.assign({}, d.budget, s.budget)
1387
1654
  if (s.turnCost && typeof s.turnCost === 'object') d.turnCost = Object.assign({}, d.turnCost, s.turnCost)
1655
+ // v812(用户报"点按角色关闭提示气泡不会被保存"):这一项**写进去了却读不回来** ——
1656
+ // 写侧一直有(writeUsageSettings 的 `p.wait` 分支,磁盘上确实存着 {"charClose":true}),
1657
+ // 但读侧漏了 ⇒ GET 永远下发默认 false ⇒ 面板显示与运行期行为都被"打回默认",看着就像没保存。
1658
+ // ⚠️ 凡是 writeUsageSettings 支持的键,这里都必须有对应的读回分支(本轮核对过:
1659
+ // taskEnd / alert / budget / turnCost / wait / events / models —— 当时只漏了 wait)。
1660
+ if (s.wait && typeof s.wait === 'object') d.wait = Object.assign({}, d.wait, { charClose: !!s.wait.charClose })
1661
+ // v863:子智能体完成是否提示(默认 true = 不提示)。写侧有分支、读侧也必须合并,
1662
+ // 否则刷新后前端读回的是默认值("保存了但不生效"的经典半截活)。
1663
+ if (s.subagent && typeof s.subagent === 'object') d.subagent = Object.assign({}, d.subagent, { mute: s.subagent.mute !== false })
1388
1664
  // v761(全局音效设置):四个事件逐个合并(缺字段回落到默认,不整块替换)
1389
1665
  if (s.events && typeof s.events === 'object') {
1390
1666
  for (const k of Object.keys(d.events)) {
@@ -1420,6 +1696,12 @@ export default {
1420
1696
  if (p.turnCost && typeof p.turnCost === 'object') led.settings.turnCost = Object.assign({}, led.settings.turnCost || {}, p.turnCost)
1421
1697
  // v777:「等待交互提示」的交互开关(点角色是否也能关闭等待提示气泡)
1422
1698
  if (p.wait && typeof p.wait === 'object') led.settings.wait = Object.assign({}, led.settings.wait || {}, p.wait)
1699
+ // v863(用户报"取消勾选保存后重开仍是勾选"):**写侧原来没有 subagent 分支** ⇒
1700
+ // 前端 PUT 的 { subagent: { mute:false } } 被静默丢掉,重开只能读到默认值(true=不提示)。
1701
+ // 这里只放行 mute 一个键(与其它分支一样,白名单式合并)。
1702
+ if (p.subagent && typeof p.subagent === 'object') {
1703
+ led.settings.subagent = Object.assign({}, led.settings.subagent || {}, { mute: p.subagent.mute !== false })
1704
+ }
1423
1705
  // v761(全局音效设置):events 补丁逐事件合并(前端只发改动的那几个字段)
1424
1706
  if (p.events && typeof p.events === 'object') {
1425
1707
  led.settings.events = led.settings.events && typeof led.settings.events === 'object' ? led.settings.events : {}
@@ -1439,7 +1721,7 @@ export default {
1439
1721
  led.settings.wait = { charClose: false }
1440
1722
  // 任务结束音也属于「音效类键」:宿主重置与面板显示必须口径一致(否则面板显示与真实状态撕裂)
1441
1723
  // v774:重置目标 = 新的出厂默认(开 + 内置 A)
1442
- led.settings.taskEnd = { on: true, sel: 'frag:end_a' }
1724
+ led.settings.taskEnd = { on: true, sel: 'frag:cost_a' }
1443
1725
  } // 按模型保存提醒/预算:{ modelId, alert?, budget? }
1444
1726
  if (p.modelSettings && p.modelSettings.id) {
1445
1727
  const mid = String(p.modelSettings.id)
@@ -1737,7 +2019,13 @@ export default {
1737
2019
  let codexCatchupT = null
1738
2020
  // 配置变更(例如用户在设置里关掉 Codex 统计)后必须让快照失效,
1739
2021
  // 否则下一次请求还会命中关闭前的旧快照、开关看起来"没生效"。
1740
- function codexInvalidate() { codexSnap = null; codexSnapAt = 0; codexStatsOnV = null; codexStatsOnAt = 0 }
2022
+ function codexInvalidate() {
2023
+ codexSnap = null; codexSnapAt = 0; codexStatsOnV = null; codexStatsOnAt = 0
2024
+ // v792:设置/注册表一变,**立刻**按新状态装载或摘掉预热定时器
2025
+ // (开关打开 ⇒ 马上挂上并预热;关掉 / 删掉 Codex 模型 ⇒ 一个定时器都不留)。
2026
+ // try/catch 是必要的:初始化期若在 `let codexPrewarmT` 求值之前走到这里,会撞 const/let 的 TDZ。
2027
+ try { codexRearmPrewarm(true) } catch (err) {}
2028
+ }
1741
2029
  // 开关读取加 2 秒缓存:codexBackgroundRefresh 可能被高频调用(快照过期时每次 payload
1742
2030
  // 构建都会试一次),而 readSizeConfig() 是同步文件读 —— 不能放在热路径上。
1743
2031
  // 用户改设置时 codexInvalidate() 会清掉缓存,所以开关仍然是"立刻生效"。
@@ -1754,18 +2042,17 @@ export default {
1754
2042
  } catch (err) {}
1755
2043
  return false
1756
2044
  }
2045
+ function codexStatsEnabledFresh() {
2046
+ try {
2047
+ if (!hasCodexModel()) return false // 没有 Codex 模型 → 默认关闭(不是"用户关掉的",见下面的提示文案)
2048
+ const cfg = readSizeConfig()
2049
+ return !cfg || cfg.codexStatsOn !== false
2050
+ } catch (err) { return false }
2051
+ }
1757
2052
  function codexStatsOn() {
1758
2053
  const now = Date.now()
1759
2054
  if (codexStatsOnV !== null && now - codexStatsOnAt < 2000) return codexStatsOnV
1760
- let v = true
1761
- try {
1762
- if (!hasCodexModel()) {
1763
- v = false // 没有 Codex 模型 → 默认关闭(不是"用户关掉的",见下面的提示文案)
1764
- } else {
1765
- const cfg = readSizeConfig()
1766
- v = !cfg || cfg.codexStatsOn !== false
1767
- }
1768
- } catch (err) { v = true }
2055
+ const v = codexStatsEnabledFresh()
1769
2056
  codexStatsOnV = v
1770
2057
  codexStatsOnAt = now
1771
2058
  return v
@@ -1813,21 +2100,38 @@ export default {
1813
2100
  try { await Promise.race([p, new Promise((r) => setTimeout(r, Math.max(200, maxWaitMs || 2500)))]) } catch (err) {}
1814
2101
  return codexSnap
1815
2102
  }
1816
- // 后台预热:启动后预热一次,并每 5 分钟刷新一次缓存(现在都是异步 + 有预算的,不再阻塞)。
2103
+ // 后台预热:**只在真的配了 Codex 时才挂定时器**(v792,用户要求"没设置 Codex 就完全禁用扫描")。
2104
+ // 背景:真正读盘的 `codexScan()` 早已被 `codexStatsOn()` 拦住(没 Codex 模型 ⇒ 直接返回,一行 I/O 都不做),
2105
+ // 但那两个定时器仍会照常触发、每次都去读一遍注册表判"有没有 Codex 模型"。既然用户明确要求"完全禁用",
2106
+ // 这里改成**惰性装载**:启动时没有 Codex 模型 ⇒ **连定时器都不挂**;之后用户添加/删除 Codex 模型时由
2107
+ // `codexRearmPrewarm()` 按需挂上/摘掉(见模型保存路径)。
1817
2108
  let codexPrewarmT = null, codexPrewarmI = null
2109
+ function codexPrewarmArmed() { return codexPrewarmT !== null || codexPrewarmI !== null }
2110
+ function codexDisarmPrewarm() {
2111
+ try { if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null } } catch (err) {}
2112
+ try { if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null } } catch (err) {}
2113
+ try { if (codexCatchupT !== null) { clearTimeout(codexCatchupT); codexCatchupT = null } } catch (err) {}
2114
+ }
2115
+ function codexRearmPrewarm(immediate) {
2116
+ // ⚠️ 这里用**不带 memo** 的 fresh 判断:装载/改设置都要立刻生效,
2117
+ // 也不能把结果写进 `codexStatsOnV` 那份 2 秒记忆(否则刚改完设置会被旧值挡住,v792 踩过)。
2118
+ const on = codexStatsEnabledFresh()
2119
+ if (!on) { codexDisarmPrewarm(); return false }
2120
+ // 已经挂着就什么都不做(避免每次保存模型都触发一次扫描;5 分钟轮询 + 按需 codexSummaryEnsured 已够)
2121
+ if (codexPrewarmArmed()) return true
2122
+ try {
2123
+ codexPrewarmT = setTimeout(function () { codexPrewarmT = null; try { codexBackgroundRefresh(0) } catch (err) {} }, immediate ? 0 : 1500)
2124
+ codexPrewarmI = setInterval(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 5 * 60 * 1000)
2125
+ // issue #109:句柄必须存下并进 disposers,否则插件 dispose 后 interval 仍在引用事件循环(退出 dsh 卡住)。
2126
+ try { if (codexPrewarmT && codexPrewarmT.unref) codexPrewarmT.unref() } catch (err) {}
2127
+ try { if (codexPrewarmI && codexPrewarmI.unref) codexPrewarmI.unref() } catch (err) {}
2128
+ } catch (err) {}
2129
+ return true
2130
+ }
1818
2131
  try {
1819
- codexPrewarmT = setTimeout(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 1500)
1820
- codexPrewarmI = setInterval(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 5 * 60 * 1000)
1821
- // issue #109:这两个定时器原先不存句柄、也不进 disposers,插件树 dispose 之后
1822
- // 事件循环里仍有一个被引用的 interval → 用户退出 dsh 时进程不结束(只能 Ctrl+C)。
1823
- // 存句柄并纳入 disposers,卸载时一并清掉;unref 作为兜底(正常运行时由 HTTP 服务维持事件循环)。
1824
- try { if (codexPrewarmT && codexPrewarmT.unref) codexPrewarmT.unref() } catch (err) {}
1825
- try { if (codexPrewarmI && codexPrewarmI.unref) codexPrewarmI.unref() } catch (err) {}
1826
- disposers.push(function () {
1827
- if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null }
1828
- if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null }
1829
- if (codexCatchupT !== null) { clearTimeout(codexCatchupT); codexCatchupT = null }
1830
- })
2132
+ // 启动时按需装载:没配 Codex ⇒ 一个定时器都不建(这就是"完全禁用")
2133
+ codexRearmPrewarm(false)
2134
+ disposers.push(function () { codexDisarmPrewarm() })
1831
2135
  } catch (err) {}
1832
2136
  // ===== 自定义 API 模型注册表(v655)=====
1833
2137
  // 文件:$DSH_HOME/.dshw-api.json → { version, models:[…], usage:{ [id]:{day,dayStart,lastBalance,delta,eventCost} } }
@@ -1847,10 +2151,14 @@ export default {
1847
2151
  }
1848
2152
  function writeApiRegistry(reg) {
1849
2153
  const body = JSON.stringify(reg, null, 2)
2154
+ let ok = false
1850
2155
  for (const p of API_FILE_CANDIDATES) {
1851
- try { fs.writeFileSync(p, body, 'utf8'); return true } catch (err) {}
2156
+ try { fs.writeFileSync(p, body, 'utf8'); ok = true; break } catch (err) {}
1852
2157
  }
1853
- return false
2158
+ // v792(用户要求):注册表变了 ⇒ 重新评估要不要挂 Codex 预热。
2159
+ // 加了 Codex 模型 → 立刻挂上(并马上预热一次);删掉了 / 用户关了开关 → 摘掉定时器(连定时器都不留)。
2160
+ try { if (ok) codexRearmPrewarm(true) } catch (err) {}
2161
+ return ok
1854
2162
  }
1855
2163
  function apiTemplateOf(provider) {
1856
2164
  return API_TEMPLATES[String(provider || '')] || null
@@ -1999,14 +2307,100 @@ export default {
1999
2307
  }
2000
2308
  return { key, code: '', error: '' }
2001
2309
  }
2002
- async function apiFetchJson(url, auth, key) {
2003
- const headers = {}
2004
- const a = String(auth == null ? 'Bearer {key}' : auth)
2005
- if (a) headers.Authorization = a.replace('{key}', key)
2310
+ // v789(issue #190):余额/额度接口不再局限于 GET —— 模板或模型条目可声明 method / body。
2311
+ // ① method 默认 GET;非 GET 时带 `Content-Type: application/json` 并把 body 发出去;
2312
+ // ② body 支持占位符:`{key}`(凭据)、`{base}`(Base URL)、以及 `model.params` 里的任意键(如 `{uuid}`)
2313
+ // —— 这样"POST /v1/enterprise/account {"uuid":"…"}" 这类自建网关不需要再额外挂一层转换服务;
2314
+ // ③ 安全边界不变:body 只来自配置(不接受请求方传入),目的地仍走 credentialMayGoTo() 白名单,
2315
+ // URL 仍过 assertSafeApiUrl()。
2316
+ function bodyWithPlaceholders(body, key, base, params) {
2317
+ let s = String(body == null ? '' : body)
2318
+ s = s.split('{key}').join(String(key == null ? '' : key))
2319
+ s = s.split('{base}').join(String(base == null ? '' : base))
2320
+ if (params && typeof params === 'object') {
2321
+ for (const k of Object.keys(params)) {
2322
+ const v = params[k]
2323
+ if (v === undefined || v === null || typeof v === 'object') continue
2324
+ s = s.split('{' + k + '}').join(String(v))
2325
+ }
2326
+ }
2327
+ return s
2328
+ }
2329
+ function httpMethodOf(cfg) {
2330
+ const m = String((cfg && cfg.method) || 'GET').trim().toUpperCase()
2331
+ return /^[A-Z]{3,7}$/.test(m) ? m : 'GET'
2332
+ }
2333
+ // v791(用户要求 + 安全加固):请求头支持「英文分号分隔的 头名:值 列表」。
2334
+ // 语义:`auth` 从"Authorization 头的值"升级为**请求头列表**,例如
2335
+ // x-api-key:{key};anthropic-version:2023-06-01
2336
+ // 向后兼容:**不带冒号**的写法(`Bearer {key}` / `{key}`)仍按 **Authorization 头**处理
2337
+ // ⇒ 30 个内置模板与所有老用户配置一个字都不用改。
2338
+ // ⚠️ 安全(这是本能力最容易出事的地方,逐条设防):
2339
+ // ① 头名必须匹配 RFC7230 的 token 字符集(杜绝 `X: a` 这类含空格/控制字符的名字);
2340
+ // ② **禁** Host / Content-Length / Connection / Transfer-Encoding / TE / Upgrade / Cookie /
2341
+ // Proxy-* / Keep-Alive —— 这些能绕过宿主的 Host 信任栅栏(`DSHW_TRUSTED_HOSTS`)、
2342
+ // 或制造请求走私;被忽略的头会在日志里提示一次(不静默);
2343
+ // ③ 头值去掉 CR/LF(防头注入)并限长;单次最多 10 个头;整串限长 2000;
2344
+ // ④ `{key}` 只在**值**里替换(头名不参与),且密钥永不回显/打日志。
2345
+ const HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
2346
+ const HEADER_DENY = new Set(['host', 'content-length', 'connection', 'transfer-encoding', 'te', 'upgrade',
2347
+ 'cookie', 'proxy-authorization', 'proxy-connection', 'keep-alive'])
2348
+ const HEADER_MAX_COUNT = 10
2349
+ const HEADER_NAME_MAX = 64
2350
+ const HEADER_VALUE_MAX = 1000
2351
+ const HEADER_RAW_MAX = 2000
2352
+ const headerWarned = new Set()
2353
+ function headerWarnOnce(name) {
2354
+ const k = String(name)
2355
+ if (headerWarned.has(k)) return
2356
+ headerWarned.add(k)
2357
+ try { console.warn('[dsh-whale] 已忽略请求头「' + k + '」(安全策略:非法头名或禁止的头)') } catch (err) {}
2358
+ }
2359
+ function headersFromAuth(auth, key) {
2360
+ const out = {}
2361
+ let raw = String(auth == null ? 'Bearer {key}' : auth)
2362
+ if (raw.length > HEADER_RAW_MAX) raw = raw.slice(0, HEADER_RAW_MAX)
2363
+ let count = 0
2364
+ for (const seg of raw.split(';')) {
2365
+ const s = seg.trim()
2366
+ if (!s) continue
2367
+ const i = s.indexOf(':')
2368
+ if (i === 0) continue // 「:xxx」没有头名 → 跳过
2369
+ const name = (i > 0 ? s.slice(0, i) : 'Authorization').trim()
2370
+ let value = (i > 0 ? s.slice(i + 1) : s).trim()
2371
+ if (!name || !value) continue
2372
+ if (name.length > HEADER_NAME_MAX || !HEADER_NAME_RE.test(name)) { headerWarnOnce(name); continue }
2373
+ if (HEADER_DENY.has(name.toLowerCase())) { headerWarnOnce(name); continue }
2374
+ if (!Object.prototype.hasOwnProperty.call(out, name) && count >= HEADER_MAX_COUNT) continue
2375
+ value = value.replace(/[\r\n]+/g, ' ').slice(0, HEADER_VALUE_MAX).replace('{key}', String(key == null ? '' : key))
2376
+ out[name] = value
2377
+ count++
2378
+ }
2379
+ return out
2380
+ }
2381
+ function headerNameExists(headers, lowerName) {
2382
+ for (const k of Object.keys(headers)) if (k.toLowerCase() === lowerName) return true
2383
+ return false
2384
+ }
2385
+ async function apiFetchJson(url, auth, key, opts) {
2386
+ const o = opts || {}
2387
+ // v791:头列表(默认值仍是单个 Authorization,与老行为逐字节一致)
2388
+ const headers = headersFromAuth(auth, key)
2006
2389
  // v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
2007
2390
  const u = String(url).replace('{key}', key)
2008
2391
  assertSafeApiUrl(u)
2009
- const res = await fetch(u, { headers, signal: AbortSignal.timeout(15000) })
2392
+ const init = { headers, signal: AbortSignal.timeout(15000) }
2393
+ const method = httpMethodOf(o)
2394
+ if (method !== 'GET') {
2395
+ init.method = method
2396
+ const raw = o.body
2397
+ if (raw !== undefined && raw !== null && String(raw).length) {
2398
+ // 用户自己在头列表里写了 Content-Type 就用他的(不覆盖)
2399
+ if (!headerNameExists(headers, 'content-type')) headers['Content-Type'] = 'application/json'
2400
+ init.body = bodyWithPlaceholders(raw, key, o.base, o.params)
2401
+ }
2402
+ }
2403
+ const res = await fetch(u, init)
2010
2404
  if (!res.ok) throw new Error('HTTP ' + res.status)
2011
2405
  return await res.json()
2012
2406
  }
@@ -2042,14 +2436,17 @@ export default {
2042
2436
  }
2043
2437
  const b = mergeNonEmpty(tpl.balance, model.balance) // v724:模型侧空串不再覆盖模板
2044
2438
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
2045
- const url = String(tpl.probeUrl || b.url || (base ? base + '/v1/models' : '')).replace('{base}', base)
2439
+ // v789(issue #190):声明了 POST 等非 GET 方法时,测试就直接打**用户配置的余额接口**(带同一个 body)——
2440
+ // 否则会拿 GET 去打一个只支持 POST 的地址、误报"测试失败"(probeUrl 多是 models 列表,body 不通用)。
2441
+ const nonGet = httpMethodOf(b) !== 'GET'
2442
+ const url = String(nonGet ? (b.url || tpl.probeUrl || '') : (tpl.probeUrl || b.url || (base ? base + '/v1/models' : ''))).replace('{base}', base)
2046
2443
  if (!url) return { ok: false, error: '没有可测试的接口(请填余额接口或 Base URL)' }
2047
2444
  // v761(安全修复 S1):只在目的地可信时才带上凭据
2048
2445
  const k = await keyForDestination(model, tpl, url)
2049
2446
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2050
2447
  const key = k.key
2051
2448
  try {
2052
- const data = await apiFetchJson(url, b.auth, key)
2449
+ const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
2053
2450
  const bizErr = apiBusinessError(data)
2054
2451
  if (bizErr) return { ok: false, error: '测试失败: ' + bizErr }
2055
2452
  let detail = 'HTTP 200'
@@ -2060,14 +2457,41 @@ export default {
2060
2457
  return { ok: false, error: '测试失败: ' + String((err && err.message) || err).slice(0, 140) }
2061
2458
  }
2062
2459
  }
2460
+ // v789(issue #180/#181):额度接口里"多窗口 + 数组顺序不保证"的通用取数。
2461
+ // 智谱 `GET /api/monitor/usage/quota/limit` 返回的 `data.limits[]` 是**扁平条目数组**:
2462
+ // `TOKENS_LIMIT` 是每条的 `type` 字段值(不是 `limits[0]` 的子对象),5h 窗口不保证排在 `[0]`
2463
+ // (实测:pro 账号 `limits[0]` 是 TIME_LIMIT,max 账号 `limits[0]` 是 TOKENS_LIMIT ⇒ 按位置取数必然出错过),
2464
+ // 而且新注册/credit 制账号把 `type` 改成了 `CREDIT_LIMIT`。
2465
+ // 所以模板可以声明 `pick: { list, type/types, unit, number }`:先选中**那一条**,再把它的字段当作取数根。
2466
+ function pickQuotaEntry(data, pick) {
2467
+ try {
2468
+ if (!pick || !pick.list) return null
2469
+ const arr = pickJsonPath(data, pick.list)
2470
+ if (!Array.isArray(arr)) return null
2471
+ const types = []
2472
+ if (pick.type) types.push(String(pick.type))
2473
+ if (Array.isArray(pick.types)) for (const t of pick.types) types.push(String(t))
2474
+ const unit = (pick.unit === undefined || pick.unit === null) ? null : Number(pick.unit)
2475
+ const number = (pick.number === undefined || pick.number === null) ? null : Number(pick.number)
2476
+ for (const it of arr) {
2477
+ if (!it || typeof it !== 'object') continue
2478
+ if (types.length && types.indexOf(String(it.type)) < 0) continue
2479
+ if (unit !== null && Number(it.unit) !== unit) continue
2480
+ if (number !== null && Number(it.number) !== number) continue
2481
+ return it
2482
+ }
2483
+ return null
2484
+ } catch (err) { return null }
2485
+ }
2063
2486
  // 订阅额度(Coding Plan)解析:各厂商字段不同,统一归一成「已用% + 重置时间 + 档位」
2064
- // zhipu: data.limits[0].TOKENS_LIMIT.percentage(已用%)+ nextResetTime
2487
+ // zhipu: data.limits[] 里 type=TOKENS_LIMIT/CREDIT_LIMIT + unit=3(5h)/6(周) 的条目(v789 起按 pick 选)
2065
2488
  // kimi: usage.remaining / usage.limit(剩余/总量)
2066
2489
  // minimax: model_remains[0].current_interval_remaining_percent(剩余%)+ end_time(ms)
2067
2490
  async function fetchModelQuota(model) {
2068
2491
  if (!model) return { ok: false, error: '模型不存在' }
2069
2492
  const tpl = apiTemplateOf(model.provider) || {}
2070
- const q = tpl.quota
2493
+ // v789(issue #180 建议 3):模型条目也能覆盖额度配置 —— 厂商接口变动时用户可自救(不留空即覆盖)
2494
+ const q = mergeNonEmpty(tpl.quota, model.quota)
2071
2495
  if (!q || !q.url) return { ok: false, code: 'NO_QUOTA', error: '该厂商没有订阅额度接口' }
2072
2496
  const base = String(model.baseUrl || '').replace(/\/+$/, '')
2073
2497
  const url = String(q.url).replace('{base}', base)
@@ -2076,31 +2500,33 @@ export default {
2076
2500
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2077
2501
  const key = k.key
2078
2502
  try {
2079
- const data = await apiFetchJson(url, q.auth, key)
2503
+ const data = await apiFetchJson(url, q.auth, key, { method: q.method, body: q.body, base: base, params: model.params })
2080
2504
  const bizErr = apiBusinessError(data)
2081
2505
  if (bizErr) return { ok: false, code: 'BIZ', error: bizErr }
2082
2506
  const j = q.json || {}
2083
2507
  const num = (v) => { const n = Number(v); return isFinite(n) ? n : null }
2508
+ // v789:若模板声明了顶层 pick(单窗口 + 数组筛选),先把取数根换成选中的那条
2509
+ const jroot = j.pick ? (pickQuotaEntry(data, j.pick) || {}) : data
2084
2510
  let usedPct = null, remainPct = null, resetAt = null, level = '', weeklyUsedPct = null
2085
- if (j.percent) { const p = num(pickJsonPath(data, j.percent)); if (p !== null) usedPct = p }
2511
+ if (j.percent) { const p = num(pickJsonPath(jroot, j.percent)); if (p !== null) usedPct = p }
2086
2512
  if (j.remainPct) {
2087
- const p = num(pickJsonPath(data, j.remainPct))
2513
+ const p = num(pickJsonPath(jroot, j.remainPct))
2088
2514
  if (p !== null) { remainPct = p; if (usedPct === null) usedPct = Math.max(0, 100 - p) }
2089
2515
  }
2090
2516
  if (j.weeklyRemainPct) {
2091
- const p = num(pickJsonPath(data, j.weeklyRemainPct))
2517
+ const p = num(pickJsonPath(jroot, j.weeklyRemainPct))
2092
2518
  if (p !== null) weeklyUsedPct = Math.max(0, 100 - p)
2093
2519
  }
2094
2520
  if (j.remain && j.total) {
2095
- const r0 = num(pickJsonPath(data, j.remain))
2096
- const t0 = num(pickJsonPath(data, j.total))
2521
+ const r0 = num(pickJsonPath(jroot, j.remain))
2522
+ const t0 = num(pickJsonPath(jroot, j.total))
2097
2523
  if (r0 !== null && t0) {
2098
2524
  remainPct = Math.max(0, Math.min(100, r0 / t0 * 100))
2099
2525
  usedPct = Math.max(0, 100 - remainPct)
2100
2526
  }
2101
2527
  }
2102
- if (j.resetAt) resetAt = pickJsonPath(data, j.resetAt)
2103
- if (j.resetAtMs) { const ms = num(pickJsonPath(data, j.resetAtMs)); if (ms !== null) resetAt = ms }
2528
+ if (j.resetAt) resetAt = pickJsonPath(jroot, j.resetAt)
2529
+ if (j.resetAtMs) { const ms = num(pickJsonPath(jroot, j.resetAtMs)); if (ms !== null) resetAt = ms }
2104
2530
  if (j.level) level = String(pickJsonPath(data, j.level) || '')
2105
2531
  // v0.3.1:多窗口额度 —— 有些订阅额度接口一次返回多个窗口(如 OpenCode Go 的
2106
2532
  // rolling / weekly / monthly),每个窗口各有「已用% + 重置时间」。模板用
@@ -2111,11 +2537,13 @@ export default {
2111
2537
  const list = []
2112
2538
  for (const w of j.windows) {
2113
2539
  if (!w) continue
2114
- let wp = w.percent ? num(pickJsonPath(data, w.percent)) : null
2540
+ // v789:每个窗口可以各自 pick 一条(智谱 5h=unit3 / 周=unit6),选中后以该条为取数根
2541
+ const wroot = w.pick ? (pickQuotaEntry(data, w.pick) || {}) : data
2542
+ let wp = w.percent ? num(pickJsonPath(wroot, w.percent)) : null
2115
2543
  if (wp !== null) wp = Math.max(0, Math.min(100, wp))
2116
2544
  let wr = null
2117
2545
  if (w.resetAt) {
2118
- const rv = pickJsonPath(data, w.resetAt)
2546
+ const rv = pickJsonPath(wroot, w.resetAt)
2119
2547
  if (rv !== undefined && rv !== null && rv !== '') wr = rv
2120
2548
  }
2121
2549
  if (wp === null && wr === null) continue
@@ -2168,7 +2596,8 @@ export default {
2168
2596
  if (!k.key) return { ok: false, code: k.code, error: k.error }
2169
2597
  const key = k.key
2170
2598
  try {
2171
- const data = await apiFetchJson(url, b.auth, key)
2599
+ // v789(issue #190):把 method / body / params 一并交给取数层(默认 GET,与以前完全一致)
2600
+ const data = await apiFetchJson(url, b.auth, key, { method: b.method, body: b.body, base: base, params: model.params })
2172
2601
  const j = b.json || {}
2173
2602
  let remaining = null
2174
2603
  let total = null
@@ -2407,12 +2836,57 @@ export default {
2407
2836
  if (p.allowCustomHost === true) model.allowCustomHost = true
2408
2837
  else if (p.allowCustomHost === false) delete model.allowCustomHost
2409
2838
  if (p.baseUrl !== undefined) model.baseUrl = String(p.baseUrl || '').slice(0, 300)
2839
+ // v789(issue #190):请求体里可用的 `{占位符}`(如 {uuid})—— 只允许标量,键名受限
2840
+ if (p.params && typeof p.params === 'object') {
2841
+ const pr = {}
2842
+ for (const k of Object.keys(p.params)) {
2843
+ if (!/^[A-Za-z_][A-Za-z0-9_]{0,30}$/.test(k)) continue
2844
+ const v = p.params[k]
2845
+ if (v === undefined || v === null || typeof v === 'object') continue
2846
+ pr[k] = String(v).slice(0, 300)
2847
+ }
2848
+ if (Object.keys(pr).length) model.params = pr
2849
+ else delete model.params
2850
+ }
2851
+ if (p.quota && typeof p.quota === 'object' && p.quota.json && typeof p.quota.json === 'object') {
2852
+ // v789(issue #180 建议 3):允许模型条目覆盖额度配置(厂商接口变动时用户可自救)。
2853
+ // 只收 JSON-safe 的形状,且路径字符串限长 —— 与模板同构,不做二次校验的滥用面。
2854
+ const qj = {}
2855
+ const pathOf = (v) => String(v || '').slice(0, 200)
2856
+ if (p.quota.url) qj.url = String(p.quota.url).slice(0, 500)
2857
+ if (p.quota.auth !== undefined) qj.auth = String(p.quota.auth || '').slice(0, 1000)
2858
+ const jin = p.quota.json
2859
+ for (const k of ['percent', 'remainPct', 'weeklyRemainPct', 'remain', 'total', 'resetAt', 'resetAtMs', 'level']) {
2860
+ if (jin[k]) qj[k] = pathOf(jin[k])
2861
+ }
2862
+ if (Array.isArray(jin.windows)) {
2863
+ qj.windows = jin.windows.slice(0, 6).map((w) => {
2864
+ const o = { key: String((w && w.key) || '').slice(0, 20), label: String((w && w.label) || '').slice(0, 20) }
2865
+ if (w && w.percent) o.percent = pathOf(w.percent)
2866
+ if (w && w.resetAt) o.resetAt = pathOf(w.resetAt)
2867
+ if (w && w.pick && typeof w.pick === 'object') {
2868
+ const pk = { list: pathOf(w.pick.list) }
2869
+ if (w.pick.type) pk.type = String(w.pick.type).slice(0, 40)
2870
+ if (Array.isArray(w.pick.types)) pk.types = w.pick.types.slice(0, 4).map((t) => String(t).slice(0, 40))
2871
+ if (w.pick.unit !== undefined) pk.unit = Number(w.pick.unit)
2872
+ if (w.pick.number !== undefined) pk.number = Number(w.pick.number)
2873
+ o.pick = pk
2874
+ }
2875
+ return o
2876
+ })
2877
+ }
2878
+ if (qj.url && (Object.keys(qj).length > 1)) model.quota = qj
2879
+ }
2410
2880
  if (p.balance && typeof p.balance === 'object') {
2411
2881
  // v724:空字段不写入(stripEmptyDeep)→ 模板默认值继续生效;
2412
2882
  // 全部为空时直接删掉 model.balance,等于"完全用模板"
2413
2883
  const balIn = stripEmptyDeep({
2414
2884
  url: String(p.balance.url || '').slice(0, 500),
2415
- auth: String(p.balance.auth || '').slice(0, 200),
2885
+ // v791:auth 现在是「头名:值」列表,比单个 Authorization 值长 ⇒ 上限 200 → 1000
2886
+ auth: String(p.balance.auth || '').slice(0, 1000),
2887
+ // v789(issue #190):可选的请求方法与请求体(留空 ⇒ 沿用模板/GET)
2888
+ method: String(p.balance.method || '').slice(0, 8).toUpperCase(),
2889
+ body: String(p.balance.body || '').slice(0, 4000),
2416
2890
  json: {
2417
2891
  remaining: String((p.balance.json && p.balance.json.remaining) || '').slice(0, 200),
2418
2892
  total: String((p.balance.json && p.balance.json.total) || '').slice(0, 200),
@@ -2426,7 +2900,7 @@ export default {
2426
2900
  model.balance = model.balance || {}
2427
2901
  model.balance.usage = stripEmptyDeep({
2428
2902
  url: String(p.balance.usage.url).slice(0, 500),
2429
- auth: String(p.balance.usage.auth == null ? '' : p.balance.usage.auth).slice(0, 200),
2903
+ auth: String(p.balance.usage.auth == null ? '' : p.balance.usage.auth).slice(0, 1000),
2430
2904
  json: { used: String((p.balance.usage.json && p.balance.usage.json.used) || '').slice(0, 200), scale: isFinite(Number(p.balance.usage.json && p.balance.usage.json.scale)) ? Number(p.balance.usage.json.scale) : undefined },
2431
2905
  })
2432
2906
  }
@@ -2513,6 +2987,10 @@ export default {
2513
2987
  // entry.balance 是数值,不能当描述用(否则面板保存会用空值覆盖描述)
2514
2988
  const tplE = apiTemplateOf(m.provider) || {}
2515
2989
  entry.balanceDesc = mergeNonEmpty(tplE.balance, m.balance) // v724:空串不覆盖模板
2990
+ // v789(issue #189):把当前的凭据放行标志也下发 —— 前端勾选框要能**显示现状**,
2991
+ // 否则"看不到 → 保存时显式传 false"会把用户手改文件开的口子又关掉(反而更糟)。
2992
+ entry.allowCustomHost = m.allowCustomHost === true
2993
+ entry.params = (m.params && typeof m.params === 'object') ? m.params : null
2516
2994
  if (m.id === API_BUILTIN_ID) {
2517
2995
  entry.balanceMode = 'api'
2518
2996
  entry.hasBalanceApi = true
@@ -2635,7 +3113,7 @@ export default {
2635
3113
  const summary = daySummary(led, todayKey())
2636
3114
  const nowSec = Math.floor(Date.now() / 1000)
2637
3115
  return {
2638
- ...visible, version: '0.3.17', isPeak: isPeakTime(nowSec),
3116
+ ...visible, version: '0.4.0', versionLabel: VERSION_LABEL, isPeak: isPeakTime(nowSec),
2639
3117
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2640
3118
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2641
3119
  peakHolidays: HOLIDAY_VALLEY_LIST,
@@ -2701,6 +3179,8 @@ export default {
2701
3179
  usageMode: normalizeUsageMode(parsed.usageMode),
2702
3180
  peakMode: parsed.peakMode === 'liangwen' || parsed.peakMode === 'qiangqiang' ? parsed.peakMode : 'default',
2703
3181
  bubbleOn: parsed.bubbleOn !== false,
3182
+ // v866(用户要求):按压/松开音的**独立**开关(原来是全局 soundOn 兼着当它用)
3183
+ pressOn: parsed.pressOn !== false,
2704
3184
  turnCostOn: parsed.turnCostOn !== false,
2705
3185
  turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
2706
3186
  scrollGapOn: parsed.scrollGapOn === true,
@@ -2716,10 +3196,11 @@ export default {
2716
3196
  return null
2717
3197
  }
2718
3198
 
2719
- function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide, codexStatsOnArg) {
3199
+ function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, pressOnArg, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide, codexStatsOnArg) {
2720
3200
  const um = normalizeUsageMode(usageMode)
2721
3201
  const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
2722
3202
  const bo = bubbleOn !== false
3203
+ const po = pressOnArg !== false // v866:按压/松开音独立开关(默认开)
2723
3204
  const tco = turnCostOn !== false
2724
3205
  const tcc = typeof turnCostCloseMs === 'number' ? (turnCostCloseMs > 0 ? turnCostCloseMs : 0) : 5000
2725
3206
  const sgo = scrollGapOn === true
@@ -2734,6 +3215,7 @@ export default {
2734
3215
  usageMode: um,
2735
3216
  peakMode: pm,
2736
3217
  bubbleOn: bo,
3218
+ pressOn: po,
2737
3219
  turnCostOn: tco,
2738
3220
  turnCostCloseMs: tcc,
2739
3221
  scrollGapOn: sgo,
@@ -2755,6 +3237,7 @@ export default {
2755
3237
  usageMode: um,
2756
3238
  peakMode: pm,
2757
3239
  bubbleOn: bo,
3240
+ pressOn: po,
2758
3241
  turnCostOn: tco,
2759
3242
  turnCostCloseMs: tcc,
2760
3243
  scrollGapOn: sgo,
@@ -3104,14 +3587,31 @@ export default {
3104
3587
  kind: 'exact',
3105
3588
  path: '/dsh-whale/balance.json',
3106
3589
  handler: async (req, res) => {
3590
+ const t0 = Date.now()
3107
3591
  try {
3108
3592
  const refresh = new URL(req.url || '/', 'http://localhost').searchParams.get('refresh') === '1'
3109
3593
  const payload = await getBalance(refresh)
3594
+ // v793:给「关于/自检」面板留一条最近的取数结果(只记状态与耗时,不记任何凭据)
3595
+ try {
3596
+ balanceLast = {
3597
+ at: Date.now(),
3598
+ ms: Date.now() - t0,
3599
+ ok: !!payload.ok,
3600
+ code: String(payload.code || ''),
3601
+ error: payload.ok ? '' : String(payload.error || '').slice(0, 160),
3602
+ hasBalance: payload.totalBalance !== null && payload.totalBalance !== undefined,
3603
+ currency: String(payload.currency || ''),
3604
+ }
3605
+ } catch (err) {}
3110
3606
  res.writeHead(200, JSON_HEADERS)
3111
- res.end(JSON.stringify(payload))
3607
+ // v794:成功失败都带上版本 ⇒ 客户端不必依赖"余额成功"才拿到版号
3608
+ res.end(JSON.stringify(Object.assign({}, payload, { version: BUILD_VERSION, versionLabel: VERSION_LABEL })))
3112
3609
  } catch (err) {
3610
+ try {
3611
+ balanceLast = { at: Date.now(), ms: Date.now() - t0, ok: false, code: 'THROW', error: String((err && err.message) || err).slice(0, 160), hasBalance: false, currency: '' }
3612
+ } catch (e2) {}
3113
3613
  res.writeHead(200, JSON_HEADERS)
3114
- res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200) }))
3614
+ res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200), version: BUILD_VERSION, versionLabel: VERSION_LABEL }))
3115
3615
  }
3116
3616
  },
3117
3617
  }))
@@ -3164,6 +3664,7 @@ export default {
3164
3664
  typeof parsed.usageMode === 'string' ? parsed.usageMode : old.usageMode,
3165
3665
  typeof parsed.peakMode === 'string' ? parsed.peakMode : old.peakMode,
3166
3666
  pickB(parsed.bubbleOn, old.bubbleOn, true),
3667
+ pickB(parsed.pressOn, old.pressOn, true),
3167
3668
  pickB(parsed.turnCostOn, old.turnCostOn, true),
3168
3669
  pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
3169
3670
  pickB(parsed.scrollGapOn, old.scrollGapOn, false),
@@ -3182,7 +3683,12 @@ export default {
3182
3683
  return
3183
3684
  }
3184
3685
  res.writeHead(200, JSON_HEADERS)
3185
- res.end(JSON.stringify(readSizeConfig() || {}))
3686
+ // v794:GET 也带上版本(客户端启动就会读这份配置 ⇒ 菜单底部那行版本小字**不必等余额请求**,
3687
+ // 余额首次请求失败时也不会一直空着 —— 这是用户报的"刚启动时版号消失")
3688
+ res.end(JSON.stringify(Object.assign({}, readSizeConfig() || {}, {
3689
+ version: BUILD_VERSION,
3690
+ versionLabel: VERSION_LABEL,
3691
+ })))
3186
3692
  },
3187
3693
  }))
3188
3694
 
@@ -3200,6 +3706,290 @@ export default {
3200
3706
  }))
3201
3707
  },
3202
3708
  }))
3709
+ // ===== v793(用户要求):点版号 → 「关于 / 自检」面板 =====
3710
+ // 数据来源全部是宿主已有状态;路径一律脱敏;**点开才请求,不轮询**。
3711
+ let balanceLast = null // { at, ok, code, error, ms, hasBalance, keyRef, keyConfigured }
3712
+ function aboutPayload(req) {
3713
+ const client = clientKindOf(req)
3714
+ const hostFile = (() => { try { return fileURLToPath(import.meta.url) } catch (err) { return '' } })()
3715
+ const widgetFile = (() => {
3716
+ for (const p of WIDGET_FILE_CANDIDATES) { try { if (fs.existsSync(p)) return p } catch (err) {} }
3717
+ return ''
3718
+ })()
3719
+ const dataFiles = [
3720
+ ['账本(记账)', USAGE_FILE_CANDIDATES],
3721
+ ['模型注册表', API_FILE_CANDIDATES],
3722
+ ['泡泡配置', BUBBLE_FILE_CANDIDATES],
3723
+ ['外观/设置', SIZE_FILE_CANDIDATES],
3724
+ ['轮次序号', TURN_FILE_CANDIDATES],
3725
+ ['Codex 缓存', [path.join(DSH_HOME, '.dshw-codex.json')]],
3726
+ ].map(([label, list]) => {
3727
+ const p = firstExisting(list)
3728
+ const f = p ? fileFacts(p) : null
3729
+ return {
3730
+ label: label,
3731
+ found: !!p,
3732
+ path: p ? maskPath(p) : '',
3733
+ candidates: (list || []).length,
3734
+ bytes: f ? f.bytes : null,
3735
+ mtime: f ? f.mtime : null,
3736
+ }
3737
+ })
3738
+ // 记账概览(只给统计,不给明细)
3739
+ let ledger = null
3740
+ try {
3741
+ const led = readUsageLedger()
3742
+ const today = todayKey()
3743
+ const sum = daySummary(led, today)
3744
+ let days = 0
3745
+ try { days = accountingDays(led).length } catch (err) { days = Object.keys(led.history || {}).length }
3746
+ ledger = {
3747
+ days: days,
3748
+ todayPartial: !!(sum && sum.partialDay),
3749
+ todaySource: (sum && sum.source) || '',
3750
+ lastObservedAt: (sum && sum.lastObservedAt) || null,
3751
+ needsReview: !!(sum && sum.needsReview),
3752
+ }
3753
+ } catch (err) { ledger = { error: String((err && err.message) || err).slice(0, 120) } }
3754
+ // Codex(v792 起:没配就完全禁用)
3755
+ let codex = { enabled: false, reason: '' }
3756
+ try {
3757
+ codex = {
3758
+ enabled: codexStatsOn(),
3759
+ reason: codexStatsOn() ? '' : codexDisabledReason(),
3760
+ snapAt: codexSnapAt || null,
3761
+ ok: !!(codexSnap && codexSnap.ok),
3762
+ files: (codexSnap && codexSnap.sessions) != null ? codexSnap.sessions : null,
3763
+ skipped: (codexSnap && codexSnap.skipped) != null ? codexSnap.skipped : null,
3764
+ deferred: (codexSnap && codexSnap.deferred) != null ? codexSnap.deferred : null,
3765
+ }
3766
+ } catch (err) {}
3767
+ return {
3768
+ ok: true,
3769
+ plugin: {
3770
+ name: 'dsh-whale-widget',
3771
+ version: BUILD_VERSION,
3772
+ channel: BUILD_CHANNEL,
3773
+ label: VERSION_LABEL,
3774
+ hostVersionLiterals: [...String(aboutPayloadSrc()).matchAll(/version: '([\d.]+)'/g)].map((m) => m[1]),
3775
+ },
3776
+ build: {
3777
+ // v796:`found` 必须给(v795 前端简化后靠它判断"未找到",缺了就会把宿主/前端文件误显示成"未找到")
3778
+ host: { found: !!hostFile, path: maskPath(hostFile), bytes: fileFacts(hostFile) ? fileFacts(hostFile).bytes : null, mtime: fileFacts(hostFile) ? fileFacts(hostFile).mtime : null },
3779
+ widget: { found: !!widgetFile, path: maskPath(widgetFile), bytes: fileFacts(widgetFile) ? fileFacts(widgetFile).bytes : null, mtime: fileFacts(widgetFile) ? fileFacts(widgetFile).mtime : null },
3780
+ // v819(自检发现):这里原来写的是 `indexOf('<开发机目录名>')` —— 检测"开发版路径"的代码自己带了
3781
+ // 那个字面量 ⇒ 发布包被 `tools/ci-audit.mjs` 判为"含开发机路径"(自己成了自己要检测的东西)。
3782
+ // 改成**结构性判定**:宿主源码里有没有写死的绝对盘符路径('D:/…' / "C:\\…")。
3783
+ // 开发版有 15+ 处(assets 兜底路径),strip 后的发布副本 0 处 ⇒ 判定更准,也不再出现那个字面量。
3784
+ hostHasDevPaths: (() => {
3785
+ try {
3786
+ const src = fs.readFileSync(hostFile, 'utf8')
3787
+ return /["'][A-Za-z]:[\\/]/.test(src)
3788
+ } catch (err) { return null }
3789
+ })(),
3790
+ },
3791
+ runtime: {
3792
+ platform: process.platform,
3793
+ arch: process.arch,
3794
+ node: process.version,
3795
+ dsh: dshVersionOf(),
3796
+ uptimeSec: Math.round(process.uptime()),
3797
+ dataHome: maskPath(DSH_HOME),
3798
+ // profile 名(`profiles/web`、`profiles/desktop`…):用来一眼看出"这份是哪个 profile 起来"的
3799
+ profile: (() => { try { const m = /profiles[\\/]([^\\/]+)/.exec(hostFile); return m ? m[1] : '' } catch (err) { return '' } })(),
3800
+ },
3801
+ client: client,
3802
+ data: dataFiles,
3803
+ ledger: ledger,
3804
+ balance: balanceLast,
3805
+ codex: codex,
3806
+ }
3807
+ }
3808
+ // 只用于读取源码里的两处上报版本字面量(判断"三处是否一致");不读敏感内容。
3809
+ function aboutPayloadSrc() { try { return fs.readFileSync(fileURLToPath(import.meta.url), 'utf8') } catch (err) { return '' } }
3810
+ // v796:DSH 版本**真的去读**,而不是只看环境变量(用户反馈"DSH 版本读取不到 (?)")。
3811
+ // 顺序:① 环境变量(部分启动方式会带)② 从 argv/execPath 往上找 `@deepseek-ai/dsh` 的 package.json
3812
+ // (npx 启动时 argv 就在 `_npx/<hash>/node_modules/@deepseek-ai/dsh/...` 里)③ 读不到就老实回 'unknown'。
3813
+ // 全程 try/catch + 缓存(每个目录最多往上 8 层),绝不抛错。
3814
+ let dshVersionCache = null
3815
+ function dshVersionOf() {
3816
+ if (dshVersionCache !== null) return dshVersionCache
3817
+ let v = ''
3818
+ try { v = String(process.env.DSH_CLIENT_VERSION || process.env.DSH_VERSION || '') } catch (err) {}
3819
+ if (!v) {
3820
+ try {
3821
+ const roots = [process.argv[1], process.argv[0], process.execPath]
3822
+ for (const r of roots) {
3823
+ if (!r) continue
3824
+ let dir = path.dirname(String(r))
3825
+ for (let up = 0; up < 8; up++) {
3826
+ try {
3827
+ const pj = path.join(dir, 'package.json')
3828
+ if (fs.existsSync(pj)) {
3829
+ const j = JSON.parse(fs.readFileSync(pj, 'utf8'))
3830
+ const nm = String((j && j.name) || '')
3831
+ if (nm === '@deepseek-ai/dsh' || nm === 'dsh' || nm === 'dsh-cli') {
3832
+ v = String((j && j.version) || '')
3833
+ if (v) break
3834
+ }
3835
+ }
3836
+ } catch (err) {}
3837
+ const parent = path.dirname(dir)
3838
+ if (parent === dir) break
3839
+ dir = parent
3840
+ }
3841
+ if (v) break
3842
+ }
3843
+ } catch (err) {}
3844
+ }
3845
+ dshVersionCache = v || 'unknown'
3846
+ return dshVersionCache
3847
+ }
3848
+ // v795(用户反馈):客户端类型**看请求头的 UA**。前端用 `navigator.windowControlsOverlay` 判会误判 ——
3849
+ // Chrome 桌面浏览器也有这个 API,于是 `npx @deepseek-ai/dsh web` 起来也被认成"官方桌面客户端"。
3850
+ // 官方桌面客户端是 Electron ⇒ UA 里必有 `Electron/`。判定只回 kind(不回完整 UA,避免带出多余指纹)。
3851
+ function clientKindOf(req) {
3852
+ try {
3853
+ const ua = String((req && req.headers && (req.headers['user-agent'] || req.headers['User-Agent'])) || '')
3854
+ const electron = /electron/i.test(ua)
3855
+ return { kind: electron ? 'desktop' : 'web', via: electron ? 'ua-electron' : 'ua-browser' }
3856
+ } catch (err) { return { kind: 'unknown', via: 'error' } }
3857
+ }
3858
+ disposers.push(registerRoute({
3859
+ kind: 'exact',
3860
+ path: '/dsh-whale/about.json',
3861
+ handler: (req, res) => {
3862
+ try {
3863
+ res.writeHead(200, JSON_HEADERS)
3864
+ res.end(JSON.stringify(aboutPayload(req)))
3865
+ } catch (err) {
3866
+ res.writeHead(200, JSON_HEADERS)
3867
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err).slice(0, 160) }))
3868
+ }
3869
+ },
3870
+ }))
3871
+ // 检查更新:**点了才联网**。⚠️ 实测教训(用户报的 "The operation was aborted due to timeout" 就是它):
3872
+ // ① 本机 Node 的 `fetch`(undici) 打 registry 会超时(npmmirror 6015ms / npmjs 6013ms 全挂),
3873
+ // 而 `https` 模块同一时刻 244ms / 891ms 就回来了 ⇒ **这里必须用 https,而不是 fetch**;
3874
+ // ② 顺序上**镜像优先**;v802 再补两个 CDN 源(jsdelivr / unpkg 直接返回 package.json,同样带 version),
3875
+ // 并把**所有失败源的原因一起报出来** —— 用户实测只看到最后一个源(`npmjs:超时`),看不出镜像也失败了。
3876
+ // v802:跟随重定向(unpkg 的 `@latest` 会 302 到具体版本路径;实测就是这么被误判成失败的),最多 3 跳。
3877
+ function httpsGetJson(url, timeoutMs, hop) {
3878
+ return new Promise((resolve, reject) => {
3879
+ let done = false
3880
+ const finish = (err, val) => { if (done) return; done = true; if (err) reject(err); else resolve(val) }
3881
+ try {
3882
+ const req = https.get(url, { timeout: timeoutMs, headers: { 'user-agent': 'dsh-whale-widget', accept: 'application/json' } }, (res) => {
3883
+ const code = res.statusCode
3884
+ const loc = res.headers && res.headers.location
3885
+ if (code >= 300 && code < 400 && loc && (hop || 0) < 3) {
3886
+ try { res.resume() } catch (e) {}
3887
+ let next = ''
3888
+ try { next = new URL(loc, url).toString() } catch (e) { next = '' }
3889
+ if (next) { httpsGetJson(next, timeoutMs, (hop || 0) + 1).then((v) => finish(null, v), (e) => finish(e)); return }
3890
+ }
3891
+ if (code !== 200) { try { res.resume() } catch (e) {} finish(new Error('HTTP ' + code)); return }
3892
+ let body = ''
3893
+ res.setEncoding('utf8')
3894
+ res.on('data', (c) => {
3895
+ body += c
3896
+ if (body.length > 200000) { try { req.destroy() } catch (e) {} finish(new Error('响应过大')) }
3897
+ })
3898
+ res.on('end', () => { try { finish(null, JSON.parse(body)) } catch (e) { finish(new Error('响应不是 JSON')) } })
3899
+ })
3900
+ req.on('timeout', () => { try { req.destroy() } catch (e) {} finish(new Error('超时')) })
3901
+ req.on('error', (e) => finish(e))
3902
+ } catch (err) { finish(err) }
3903
+ })
3904
+ }
3905
+ // v803:npm CLI 兜底源 —— 走 npm 自己的 registry/代理配置,在受限网络里往往比裸 https 更能出去
3906
+ // (实测本机 `npm view dsh-whale-widget version` 611~776ms 就回来了)。
3907
+ // ⚠️ 必须用**异步** exec:同步 spawnSync 会卡住宿主事件循环,把模型流一起拖住。
3908
+ function npmCliVersion(timeoutMs) {
3909
+ return new Promise((resolve, reject) => {
3910
+ try {
3911
+ exec('npm view dsh-whale-widget version', { timeout: timeoutMs, windowsHide: true }, (err, stdout) => {
3912
+ if (err) { reject(new Error('npm 退出异常')); return }
3913
+ const lines = String(stdout || '').trim().split(/\r?\n/).filter((x) => x.trim())
3914
+ const v = lines.length ? lines[lines.length - 1].trim().replace(/^v/, '') : ''
3915
+ if (!/^\d+\.\d+\.\d+/.test(v)) { reject(new Error('npm 输出里没有版本')); return }
3916
+ resolve(v)
3917
+ })
3918
+ } catch (err) { reject(err) }
3919
+ })
3920
+ }
3921
+ const UPDATE_SOURCES = [
3922
+ { name: 'npmmirror', url: 'https://registry.npmmirror.com/dsh-whale-widget/latest' },
3923
+ { name: 'jsdelivr', url: 'https://cdn.jsdelivr.net/npm/dsh-whale-widget@latest/package.json' },
3924
+ { name: 'unpkg', url: 'https://unpkg.com/dsh-whale-widget@latest/package.json' },
3925
+ { name: 'npmjs', url: 'https://registry.npmjs.org/dsh-whale-widget/latest' },
3926
+ { name: 'npm-cli', cli: true },
3927
+ ]
3928
+ let updateCheckCache = null
3929
+ disposers.push(registerRoute({
3930
+ kind: 'exact',
3931
+ path: '/dsh-whale/update-check.json',
3932
+ handler: async (req, res) => {
3933
+ const now = Date.now()
3934
+ try {
3935
+ if (updateCheckCache && now - updateCheckCache.at < 10 * 60 * 1000) {
3936
+ res.writeHead(200, JSON_HEADERS)
3937
+ res.end(JSON.stringify(updateCheckCache.payload))
3938
+ return
3939
+ }
3940
+ } catch (err) {}
3941
+ let payload = null
3942
+ // v802:四个源**并行**试(单个源实测很不稳:npmmirror/jsdelivr 会超时、unpkg 会 302),
3943
+ // 并行后最坏也就 5 秒(串行会是 20 秒),谁先成功用谁,全失败就把每个源的原因都列出来。
3944
+ const results = await Promise.all(UPDATE_SOURCES.map(async (src) => {
3945
+ // v803:失败得**很快**(<1.5s,多为 DNS/连接抖动)就再试一次;超时那种就不重试了(不然最坏翻倍)
3946
+ const attempt = async () => {
3947
+ const t0 = Date.now()
3948
+ if (src.cli) return { v: await npmCliVersion(8000), ms: Date.now() - t0 }
3949
+ const j = await httpsGetJson(src.url, 5000, 0)
3950
+ const latest = String((j && j.version) || '')
3951
+ if (!latest) throw new Error('返回里没有 version')
3952
+ return { v: latest, ms: Date.now() - t0 }
3953
+ }
3954
+ try {
3955
+ const a = await attempt()
3956
+ return { ok: true, name: src.name, latest: a.v }
3957
+ } catch (err1) {
3958
+ // 只在"失败得很快"(<1.5s,多为连接抖动)时重试一次;超时那种不重试,避免最坏耗时翻倍
3959
+ const fast = !(err1 && /超时/.test(String(err1.message || '')))
3960
+ if (!fast) return { ok: false, name: src.name, why: String((err1 && err1.message) || err1).slice(0, 40) }
3961
+ try {
3962
+ const a2 = await attempt()
3963
+ return { ok: true, name: src.name, latest: a2.v }
3964
+ } catch (err2) {
3965
+ return { ok: false, name: src.name, why: String((err2 && err2.message) || err2).slice(0, 40) }
3966
+ }
3967
+ }
3968
+ }))
3969
+ const hit = results.filter((r) => r.ok)[0]
3970
+ if (hit) {
3971
+ payload = {
3972
+ ok: true,
3973
+ current: BUILD_VERSION,
3974
+ channel: BUILD_CHANNEL,
3975
+ latest: hit.latest,
3976
+ hasUpdate: hit.latest !== BUILD_VERSION,
3977
+ source: hit.name,
3978
+ }
3979
+ } else {
3980
+ // 全失败 ⇒ 把**每一个源**的原因都列出来(只报最后一个会让人以为只有 npmjs 挂了)
3981
+ payload = {
3982
+ ok: false,
3983
+ current: BUILD_VERSION,
3984
+ error: results.map((r) => r.name + ':' + r.why).join(';'),
3985
+ tried: UPDATE_SOURCES.map((s) => s.name),
3986
+ }
3987
+ }
3988
+ try { updateCheckCache = { at: now, payload: payload } } catch (err) {}
3989
+ res.writeHead(200, JSON_HEADERS)
3990
+ res.end(JSON.stringify(payload))
3991
+ },
3992
+ }))
3203
3993
  disposers.push(registerRoute({
3204
3994
  kind: 'exact',
3205
3995
  path: '/dsh-whale/usage-records.json',
@@ -3605,10 +4395,10 @@ export default {
3605
4395
  },
3606
4396
  }))
3607
4397
 
3608
- disposers.push(registerRoute({
3609
- kind: 'exact',
3610
- path: '/dsh-whale/audio-fragment.wav',
3611
- handler: (req, res) => {
4398
+ // v860(issue #226):路由**不再以 .wav 结尾** —— 带后缀的 URL 会被部分环境(下载器/扩展/反代)
4399
+ // 当成"下载文件"劫持,导致试听与任务结束音无声。Content-Type 由 fragmentMime() 决定,
4400
+ // 与 URL 后缀无关 ⇒ 改成无后缀路径,同时**保留旧路径作为兼容别名**(老客户端仍能用)。
4401
+ const audioFragmentHandler = (req, res) => {
3612
4402
  try {
3613
4403
  const id = audioIdFromUrl(req.url)
3614
4404
  const bytes = loadAudioFragmentBytes(id)
@@ -3626,8 +4416,9 @@ export default {
3626
4416
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
3627
4417
  res.end('audio fragment unavailable')
3628
4418
  }
3629
- },
3630
- }))
4419
+ }
4420
+ disposers.push(registerRoute({ kind: 'exact', path: '/dsh-whale/audio-fragment', handler: audioFragmentHandler }))
4421
+ disposers.push(registerRoute({ kind: 'exact', path: '/dsh-whale/audio-fragment.wav', handler: audioFragmentHandler }))
3631
4422
 
3632
4423
  // 片段字节对应的 MIME:内置/预设片段按声明,自定义片段一律 wav
3633
4424
  function fragmentMime(fragId) {
@@ -3779,6 +4570,169 @@ export default {
3779
4570
  }
3780
4571
  return null
3781
4572
  }
4573
+ // ===== v815:自定义泡泡框 —— 宿主侧净化 =====
4574
+ // 用户可以在"泡泡框"里写 SVG 代码 ⇒ 这些片段最终会进 innerHTML ⇒ **必须白名单化**:
4575
+ // · 只放行无脚本、无外链的几何标签;
4576
+ // · 标签属性只放行几何/描边类;
4577
+ // · 直接删掉 <script>/<foreignObject>/on* 事件/href(javascript:)/style 里的 url()、expression。
4578
+ // 探针 `_v815-frame-lib-check.mjs` 带一批负样本(见 §验证),改这里必须同步那些断言。
4579
+ const FRAME_SVG_TAGS = ['path', 'ellipse', 'circle', 'rect', 'polygon', 'polyline', 'g']
4580
+ // v828:补 `r`(`<circle>` 的半径)—— 标签在白名单里却漏了属性,用户写 `<circle r="30"/>` 会被剥成画不出来
4581
+ const FRAME_SVG_ATTRS = ['d', 'points', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'width', 'height', 'fill', 'stroke',
4582
+ 'stroke-width', 'stroke-dasharray', 'stroke-linejoin', 'stroke-linecap', 'transform', 'opacity', 'fill-opacity', 'stroke-opacity']
4583
+ function frameColorOk(v, def) {
4584
+ const s = String(v == null ? '' : v).trim()
4585
+ if (/^#[0-9a-fA-F]{3}$/.test(s) || /^#[0-9a-fA-F]{6}$/.test(s)) return s
4586
+ if (/^rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\)$/.test(s)) return s
4587
+ if (s === 'transparent' || s === 'none') return s
4588
+ return def
4589
+ }
4590
+ function frameNumIn(v, def, min, max) {
4591
+ const n = Number(v)
4592
+ let x = isFinite(n) ? n : def
4593
+ if (typeof min === 'number' && x < min) x = min
4594
+ if (typeof max === 'number' && x > max) x = max
4595
+ return Math.round(x * 1000) / 1000
4596
+ }
4597
+ function frameSvgSanitize(code) {
4598
+ let s = String(code == null ? '' : code).slice(0, 8000)
4599
+ if (!s) return ''
4600
+ // 整块危险元素直接删(含内容)
4601
+ s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|animate|set)\b[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
4602
+ s = s.replace(/<\s*(script|foreignObject|style|iframe|image|use|animate|set)\b[^>]*\/?>/gi, '')
4603
+ // 只保留白名单标签;其它标签(含自闭合)整体删除
4604
+ s = s.replace(/<\s*\/?\s*([A-Za-z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)\/?>/g, (m0, tag, attrs) => {
4605
+ const t = String(tag)
4606
+ if (FRAME_SVG_TAGS.indexOf(t) < 0) return ''
4607
+ const closing = /^<\s*\//.test(m0)
4608
+ // v823(P2):**保住自闭合的斜杠**。P0 首版一律重拼成 `<tag …>`,而内联 SVG 里 `<path>` 不是空元素
4609
+ // ⇒ HTML 解析器会把后面的兄弟塞进它里面(`<path a/><path b/>` → path a > path b,子节点不渲染)
4610
+ // ⇒ 用户写的多形状自定义 SVG 只有第一个能显示。P2 才引入"自定义 SVG 代码",所以坑到这时才暴露。
4611
+ const selfClose = /\/\s*>$/.test(m0)
4612
+ let out = '<' + (closing ? '/' : '') + t
4613
+ if (!closing) {
4614
+ const re = /([A-Za-z][\w:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g
4615
+ let m
4616
+ let kept = 0
4617
+ while ((m = re.exec(attrs))) {
4618
+ const name = String(m[1]).toLowerCase()
4619
+ const val = String(m[3] !== undefined ? m[3] : m[4])
4620
+ if (FRAME_SVG_ATTRS.indexOf(name) < 0) continue
4621
+ // 值里也不许出现 javascript:/url(/expression
4622
+ if (/javascript:|url\s*\(|expression\s*\(/i.test(val)) continue
4623
+ if (/[<>]/.test(val)) continue
4624
+ out += ' ' + name + '="' + val.replace(/"/g, '&quot;') + '"'
4625
+ kept++
4626
+ }
4627
+ void kept
4628
+ }
4629
+ return out + ((selfClose && !closing) ? '/>' : '>')
4630
+ })
4631
+ // v818(自检发现):再**清掉标签之外的所有裸文本**。为什么必须做:`<script>alert(1)`(未闭合)这类输入,
4632
+ // 块删除与标签白名单都只处理"标签",会留下裸文本 `alert(1)` —— 它在 SVG 里是惰性文本、不会执行,
4633
+ // 但把与形状无关的文字塞进 innerHTML 没有意义,也少留一条"文本被解释"的可能性。
4634
+ // 属性值里不可能含 < >(上面的属性白名单已拒),所以按 <…> 切分是安全的。
4635
+ s = s.replace(/[^<>]+(?=<|$)/g, '')
4636
+ return s
4637
+ }
4638
+ function frameSanitizeOne(raw, idx) {
4639
+ const s = raw && typeof raw === 'object' ? raw : {}
4640
+ let id = String(s.id == null ? '' : s.id).trim().slice(0, 64)
4641
+ if (!id) id = 'frame_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)
4642
+ const slotsIn = s.slots && typeof s.slots === 'object' ? s.slots : {}
4643
+ function slot(kind) {
4644
+ const d = slotsIn[kind] && typeof slotsIn[kind] === 'object' ? slotsIn[kind] : {}
4645
+ const paint = d.paint && typeof d.paint === 'object' ? d.paint : {}
4646
+ const out = {
4647
+ dx: frameNumIn(d.dx, 0, -1026, 1026),
4648
+ dy: frameNumIn(d.dy, 0, -700, 700),
4649
+ scale: frameNumIn(d.scale, 1, 0.2, 4),
4650
+ kind: d.kind === 'image' ? 'image' : 'svg',
4651
+ svgPreset: ['classic', 'roundrect', 'zigzag', 'custom'].indexOf(String(d.svgPreset)) >= 0 ? String(d.svgPreset) : 'classic',
4652
+ svgCode: frameSvgSanitize(d.svgCode),
4653
+ imgId: String(d.imgId == null ? '' : d.imgId).slice(0, 64),
4654
+ imgScale: frameNumIn(d.imgScale, 1, 0.1, 6),
4655
+ imgOpacity: frameNumIn(d.imgOpacity, 1, 0, 1),
4656
+ paint: {
4657
+ bgOn: paint.bgOn === undefined ? true : paint.bgOn !== false,
4658
+ bg: frameColorOk(paint.bg, '#FFFFFF'),
4659
+ bgAlpha: frameNumIn(paint.bgAlpha, 1, 0, 1),
4660
+ lineOn: paint.lineOn === undefined ? true : paint.lineOn !== false,
4661
+ line: frameColorOk(paint.line, '#203170'),
4662
+ lineW: frameNumIn(paint.lineW, 18, 0, 120),
4663
+ lineAlpha: frameNumIn(paint.lineAlpha, 1, 0, 1),
4664
+ dashOn: paint.dashOn === true,
4665
+ dash: /^[0-9][0-9.,\s]*$/.test(String(paint.dash == null ? '' : paint.dash).trim()) ? String(paint.dash).trim().slice(0, 40) : '0',
4666
+ cap: ['butt', 'square', 'round'].indexOf(String(paint.cap)) >= 0 ? String(paint.cap) : 'round',
4667
+ join: ['miter', 'bevel', 'round'].indexOf(String(paint.join)) >= 0 ? String(paint.join) : 'round',
4668
+ },
4669
+ }
4670
+ if (kind === 'small1' || kind === 'small2') out.on = d.on === undefined ? true : d.on !== false
4671
+ return out
4672
+ }
4673
+ const safe = s.safe && typeof s.safe === 'object' ? s.safe : {}
4674
+ const w = frameNumIn(safe.w, 646, 40, 1026)
4675
+ const h = frameNumIn(safe.h, 400, 40, 700)
4676
+ return {
4677
+ id: id,
4678
+ name: String(s.name == null ? '' : s.name).trim().slice(0, 24) || ('泡泡框' + (idx + 1)),
4679
+ builtin: s.builtin === true,
4680
+ pinned: Math.round(frameNumIn(s.pinned, 0, 0, 9999)),
4681
+ createdAt: Math.round(frameNumIn(s.createdAt, Date.now(), 0, 1e15)),
4682
+ textColor: frameColorOk(s.textColor, ''),
4683
+ safe: { x: frameNumIn(safe.x, 190, 0, 1026 - w), y: frameNumIn(safe.y, 90, 0, 700 - h), w: w, h: h },
4684
+ slots: { big: slot('big'), small1: slot('small1'), small2: slot('small2') },
4685
+ }
4686
+ }
4687
+ // 整份框库净化:内置框强制只读(内容还原由前端做;宿主这里只保证"不许被改成非内置")
4688
+ function framesSanitize(list, defId) {
4689
+ const arr = Array.isArray(list) ? list.slice(0, 50) : []
4690
+ const out = []
4691
+ for (let i = 0; i < arr.length; i++) {
4692
+ const f = frameSanitizeOne(arr[i], i)
4693
+ if (f.id === 'frame_classic') { f.builtin = true; f.name = '经典(内置)'; continue } // 内置框不进库(前端会补)
4694
+ out.push(f)
4695
+ }
4696
+ let def = String(defId == null ? '' : defId).slice(0, 64)
4697
+ if (!out.some((x) => x.id === def)) def = 'frame_classic'
4698
+ return { frames: out, defaultFrameId: def }
4699
+ }
4700
+ // items 里的"框引用 / 外观覆盖":只保留已知形状的字段(逐字段白名单)
4701
+ function itemsSanitizeFrames(items) {
4702
+ const list = Array.isArray(items) ? items : []
4703
+ const cleanOne = (obj) => {
4704
+ if (!obj || typeof obj !== 'object') return obj
4705
+ const out = obj
4706
+ if (typeof out.frameId === 'string') out.frameId = out.frameId.slice(0, 64)
4707
+ else delete out.frameId
4708
+ const ov = out.frameOverride
4709
+ if (ov && typeof ov === 'object') {
4710
+ const keep = {}
4711
+ for (const slot of ['big', 'small1', 'small2']) {
4712
+ const raw = ov[slot]
4713
+ if (!raw || typeof raw !== 'object') continue
4714
+ const p = {}
4715
+ for (const k of ['bgOn', 'bg', 'bgAlpha', 'lineOn', 'line', 'lineW', 'lineAlpha', 'dashOn', 'dash', 'cap', 'join']) {
4716
+ if (Object.prototype.hasOwnProperty.call(raw, k)) p[k] = raw[k]
4717
+ }
4718
+ // 颜色/数值仍然过一遍校验,避免脏值写进配置
4719
+ const norm = frameSanitizeOne({ slots: { [slot]: { paint: p } } }, 0).slots[slot].paint
4720
+ const only = {}
4721
+ for (const k of Object.keys(p)) if (Object.prototype.hasOwnProperty.call(norm, k)) only[k] = norm[k]
4722
+ keep[slot] = only
4723
+ }
4724
+ if (Object.keys(keep).length) out.frameOverride = keep
4725
+ else delete out.frameOverride
4726
+ } else delete out.frameOverride
4727
+ return out
4728
+ }
4729
+ for (const it of list) {
4730
+ if (!it || typeof it !== 'object') continue
4731
+ cleanOne(it)
4732
+ if (it.kind === 'choice' && Array.isArray(it.options)) for (const o of it.options) { if (o) { cleanOne(o); if (o.item) cleanOne(o.item) } }
4733
+ }
4734
+ return list
4735
+ }
3782
4736
  function writeBubbleConfig(cfg) {
3783
4737
  const body = JSON.stringify(cfg, null, 2)
3784
4738
  for (const p of BUBBLE_FILE_CANDIDATES) {
@@ -3801,7 +4755,15 @@ export default {
3801
4755
  return
3802
4756
  }
3803
4757
  // v727:tapAdvance =「点按角色推进泡泡队列」(只存布尔值,不放行其它字段)
3804
- const cfg = { v: 1, items: parsed.items, lib: parsed.lib, tapAdvance: parsed.tapAdvance === true }
4758
+ // v815:泡泡框(frames/defaultFrameId)与 items 上的框引用/外观覆盖走净化后落盘。
4759
+ // 没带 frames 的老客户端 ⇒ 不写这两个键(老配置零 churn)。
4760
+ const itemsClean = itemsSanitizeFrames(parsed.items)
4761
+ const cfg = { v: 1, items: itemsClean, lib: parsed.lib, tapAdvance: parsed.tapAdvance === true }
4762
+ if (Array.isArray(parsed.frames)) {
4763
+ const fr = framesSanitize(parsed.frames, parsed.defaultFrameId)
4764
+ cfg.frames = fr.frames
4765
+ cfg.defaultFrameId = fr.defaultFrameId
4766
+ }
3805
4767
  writeBubbleConfig(cfg)
3806
4768
  res.writeHead(200, JSON_HEADERS)
3807
4769
  res.end(JSON.stringify({ ok: true, config: cfg }))