dsh-whale-widget 0.3.18 → 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,
@@ -36,6 +38,57 @@ const IMAGE_CANDIDATES = [
36
38
  // ⚠️ `_strip-dev-paths.mjs` 会把发布副本里含开发机路径的行删掉,发布版只剩前两条 $DSH_HOME ✓
37
39
  const DATA_CANDIDATES_TAIL = [
38
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
+ }
39
92
  // ⭐ 2026-10-02(用户要求"并成一份"):**角色图库目录也 $DSH_HOME 优先**(开发机路径降级为兜底)。
40
93
  const ROLE_DIR_CANDIDATES = [
41
94
  path.join(DSH_HOME, 'whale-roles'),
@@ -70,6 +123,14 @@ const PRESET_FRAGMENTS = {
70
123
  // 任务结束音 A(随包资源):原为用户音效库里的自导入片段,现作为内置预设随包发布。
71
124
  // 显示名与用户库里同名片段一致时,前端下拉按显示名去重(内置与前端的同名条目只出现一条)。
72
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' },
73
134
  }
74
135
  // 内置片段的音频文件:包内 assets 优先,开发环境回退到 skin 目录
75
136
  const BUILTIN_FRAGMENT_FILES = {
@@ -80,6 +141,16 @@ const BUILTIN_FRAGMENT_FILES = {
80
141
  end_a: [
81
142
  path.join(PACKAGE_ROOT, 'assets', 'task-end-a.wav'),
82
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
+ ],
83
154
  }
84
155
  // —— 自定义 API 模型(v655):非 DeepSeek 厂商的余额 / 用量 / 提醒 ——
85
156
  // 注册表落在 $DSH_HOME/.dshw-api.json;密钥走 DSH 官方凭据(ctx.credentials),本文件不存明文。
@@ -265,9 +336,12 @@ const API_TEMPLATES = {
265
336
  },
266
337
  anthropic: {
267
338
  name: 'Anthropic Claude', currency: 'USD', keyRef: 'ANTHROPIC_API_KEY', noBalanceApi: true,
268
- 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 起支持,模板已预置)',
269
342
  matchIds: ['claude'],
270
- 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',
271
345
  },
272
346
  gemini: {
273
347
  name: 'Google Gemini', currency: 'USD', keyRef: 'GEMINI_API_KEY', noBalanceApi: true,
@@ -841,9 +915,14 @@ export default {
841
915
  function finalizeTurn(sessionId) {
842
916
  const agg = turnAggs.get(sessionId)
843
917
  if (agg && agg.cost > 0) {
918
+ // v862/v862b:子智能体的会话**照常记账**(金额与用量事件都写),
919
+ // 只在「子智能体完成时不提示」开着时跳过 lastTurnSeq ⇒ 前端不会因为它响提示音。
920
+ const isSub = subagentSessions.has(String(sessionId || '')) && subagentMuteOn()
844
921
  lastTurn = { turn: agg.turn, amount: agg.cost, tokens: agg.tokens, ts: agg.lastTs }
845
- lastTurnSeq++
846
- writeTurnSeq(lastTurnSeq)
922
+ if (!isSub) {
923
+ lastTurnSeq++
924
+ writeTurnSeq(lastTurnSeq)
925
+ }
847
926
  // 按模型拆分写入用量事件(模型明细的唯一来源)
848
927
  const byModel = agg.byModel || {}
849
928
  const byModelTokens = agg.byModelTokens || {}
@@ -1007,6 +1086,44 @@ export default {
1007
1086
  } catch (err) {}
1008
1087
  }
1009
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
+
1010
1127
  // 监听所有会话的追加事件;按会话 id 分桶,turn/end 时结算该会话本轮
1011
1128
  disposers.push(ctx.on('session/event', (session, event) => {
1012
1129
  const sid = session && session.id ? session.id : 'default'
@@ -1263,15 +1380,44 @@ export default {
1263
1380
  }
1264
1381
  }
1265
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
+ }
1266
1400
  function readUsageLedger() {
1267
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
+ }
1268
1410
  try {
1269
- const parsed = JSON.parse(ledgerIo(() => fs.readFileSync(p, 'utf8')))
1411
+ const parsed = JSON.parse(raw)
1270
1412
  if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
1271
- throw new Error('账本结构异常,已停止写入以保护原记录')
1272
- } 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
+ }
1273
1419
  }
1274
- return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
1420
+ return freshUsageLedger()
1275
1421
  }
1276
1422
  function writeUsageLedger(led) {
1277
1423
  const body = JSON.stringify(led)
@@ -1402,7 +1548,7 @@ export default {
1402
1548
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
1403
1549
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
1404
1550
  return {
1405
- ok: true, version: '0.3.18', today: todayData, days7,
1551
+ ok: true, version: '0.4.0', versionLabel: VERSION_LABEL, today: todayData, days7,
1406
1552
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
1407
1553
  all: {
1408
1554
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1442,13 +1588,17 @@ export default {
1442
1588
  // 没有它就无法区分"默认 100%"与"用户设了 100%",前端 soundVolumeOf() 靠它决定优先级。
1443
1589
  // ⚠️ 与前端 soundEventDefaults().turnCost **必须逐字段一致**(探针 §⑥ 跨端一致钉住)。
1444
1590
  turnCost: { vol: 1, volSet: false, bubbleOn: true },
1445
- question: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true, lines: waitDefaultLines('question') },
1446
- 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') },
1447
1594
  }
1448
1595
  }
1449
1596
  function usageSettingsDefaults() {
1450
1597
  return { // 任务结束音:v774 起出厂默认 = **开**(作者当前用法),默认音 = 内置 A(end_a)
1451
- 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 },
1452
1602
  // v777:「等待交互提示」的交互开关(用户 2026-09-26 要求)
1453
1603
  // charClose=false(出厂默认)= 只有**点泡泡**能收起等待提示;true = **点角色**也能收起。
1454
1604
  // 两种情况下"点掉的那条挂起"都不会再被每秒轮询自动弹回(前端 waitDismissedId)。
@@ -1502,6 +1652,15 @@ export default {
1502
1652
  if (s.alert && typeof s.alert === 'object') d.alert = Object.assign({}, d.alert, s.alert)
1503
1653
  if (s.budget && typeof s.budget === 'object') d.budget = Object.assign({}, d.budget, s.budget)
1504
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 })
1505
1664
  // v761(全局音效设置):四个事件逐个合并(缺字段回落到默认,不整块替换)
1506
1665
  if (s.events && typeof s.events === 'object') {
1507
1666
  for (const k of Object.keys(d.events)) {
@@ -1537,6 +1696,12 @@ export default {
1537
1696
  if (p.turnCost && typeof p.turnCost === 'object') led.settings.turnCost = Object.assign({}, led.settings.turnCost || {}, p.turnCost)
1538
1697
  // v777:「等待交互提示」的交互开关(点角色是否也能关闭等待提示气泡)
1539
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
+ }
1540
1705
  // v761(全局音效设置):events 补丁逐事件合并(前端只发改动的那几个字段)
1541
1706
  if (p.events && typeof p.events === 'object') {
1542
1707
  led.settings.events = led.settings.events && typeof led.settings.events === 'object' ? led.settings.events : {}
@@ -1556,7 +1721,7 @@ export default {
1556
1721
  led.settings.wait = { charClose: false }
1557
1722
  // 任务结束音也属于「音效类键」:宿主重置与面板显示必须口径一致(否则面板显示与真实状态撕裂)
1558
1723
  // v774:重置目标 = 新的出厂默认(开 + 内置 A)
1559
- led.settings.taskEnd = { on: true, sel: 'frag:end_a' }
1724
+ led.settings.taskEnd = { on: true, sel: 'frag:cost_a' }
1560
1725
  } // 按模型保存提醒/预算:{ modelId, alert?, budget? }
1561
1726
  if (p.modelSettings && p.modelSettings.id) {
1562
1727
  const mid = String(p.modelSettings.id)
@@ -1854,7 +2019,13 @@ export default {
1854
2019
  let codexCatchupT = null
1855
2020
  // 配置变更(例如用户在设置里关掉 Codex 统计)后必须让快照失效,
1856
2021
  // 否则下一次请求还会命中关闭前的旧快照、开关看起来"没生效"。
1857
- 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
+ }
1858
2029
  // 开关读取加 2 秒缓存:codexBackgroundRefresh 可能被高频调用(快照过期时每次 payload
1859
2030
  // 构建都会试一次),而 readSizeConfig() 是同步文件读 —— 不能放在热路径上。
1860
2031
  // 用户改设置时 codexInvalidate() 会清掉缓存,所以开关仍然是"立刻生效"。
@@ -1871,18 +2042,17 @@ export default {
1871
2042
  } catch (err) {}
1872
2043
  return false
1873
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
+ }
1874
2052
  function codexStatsOn() {
1875
2053
  const now = Date.now()
1876
2054
  if (codexStatsOnV !== null && now - codexStatsOnAt < 2000) return codexStatsOnV
1877
- let v = true
1878
- try {
1879
- if (!hasCodexModel()) {
1880
- v = false // 没有 Codex 模型 → 默认关闭(不是"用户关掉的",见下面的提示文案)
1881
- } else {
1882
- const cfg = readSizeConfig()
1883
- v = !cfg || cfg.codexStatsOn !== false
1884
- }
1885
- } catch (err) { v = true }
2055
+ const v = codexStatsEnabledFresh()
1886
2056
  codexStatsOnV = v
1887
2057
  codexStatsOnAt = now
1888
2058
  return v
@@ -1930,21 +2100,38 @@ export default {
1930
2100
  try { await Promise.race([p, new Promise((r) => setTimeout(r, Math.max(200, maxWaitMs || 2500)))]) } catch (err) {}
1931
2101
  return codexSnap
1932
2102
  }
1933
- // 后台预热:启动后预热一次,并每 5 分钟刷新一次缓存(现在都是异步 + 有预算的,不再阻塞)。
2103
+ // 后台预热:**只在真的配了 Codex 时才挂定时器**(v792,用户要求"没设置 Codex 就完全禁用扫描")。
2104
+ // 背景:真正读盘的 `codexScan()` 早已被 `codexStatsOn()` 拦住(没 Codex 模型 ⇒ 直接返回,一行 I/O 都不做),
2105
+ // 但那两个定时器仍会照常触发、每次都去读一遍注册表判"有没有 Codex 模型"。既然用户明确要求"完全禁用",
2106
+ // 这里改成**惰性装载**:启动时没有 Codex 模型 ⇒ **连定时器都不挂**;之后用户添加/删除 Codex 模型时由
2107
+ // `codexRearmPrewarm()` 按需挂上/摘掉(见模型保存路径)。
1934
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
+ }
1935
2131
  try {
1936
- codexPrewarmT = setTimeout(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 1500)
1937
- codexPrewarmI = setInterval(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 5 * 60 * 1000)
1938
- // issue #109:这两个定时器原先不存句柄、也不进 disposers,插件树 dispose 之后
1939
- // 事件循环里仍有一个被引用的 interval → 用户退出 dsh 时进程不结束(只能 Ctrl+C)。
1940
- // 存句柄并纳入 disposers,卸载时一并清掉;unref 作为兜底(正常运行时由 HTTP 服务维持事件循环)。
1941
- try { if (codexPrewarmT && codexPrewarmT.unref) codexPrewarmT.unref() } catch (err) {}
1942
- try { if (codexPrewarmI && codexPrewarmI.unref) codexPrewarmI.unref() } catch (err) {}
1943
- disposers.push(function () {
1944
- if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null }
1945
- if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null }
1946
- if (codexCatchupT !== null) { clearTimeout(codexCatchupT); codexCatchupT = null }
1947
- })
2132
+ // 启动时按需装载:没配 Codex ⇒ 一个定时器都不建(这就是"完全禁用")
2133
+ codexRearmPrewarm(false)
2134
+ disposers.push(function () { codexDisarmPrewarm() })
1948
2135
  } catch (err) {}
1949
2136
  // ===== 自定义 API 模型注册表(v655)=====
1950
2137
  // 文件:$DSH_HOME/.dshw-api.json → { version, models:[…], usage:{ [id]:{day,dayStart,lastBalance,delta,eventCost} } }
@@ -1964,10 +2151,14 @@ export default {
1964
2151
  }
1965
2152
  function writeApiRegistry(reg) {
1966
2153
  const body = JSON.stringify(reg, null, 2)
2154
+ let ok = false
1967
2155
  for (const p of API_FILE_CANDIDATES) {
1968
- try { fs.writeFileSync(p, body, 'utf8'); return true } catch (err) {}
2156
+ try { fs.writeFileSync(p, body, 'utf8'); ok = true; break } catch (err) {}
1969
2157
  }
1970
- return false
2158
+ // v792(用户要求):注册表变了 ⇒ 重新评估要不要挂 Codex 预热。
2159
+ // 加了 Codex 模型 → 立刻挂上(并马上预热一次);删掉了 / 用户关了开关 → 摘掉定时器(连定时器都不留)。
2160
+ try { if (ok) codexRearmPrewarm(true) } catch (err) {}
2161
+ return ok
1971
2162
  }
1972
2163
  function apiTemplateOf(provider) {
1973
2164
  return API_TEMPLATES[String(provider || '')] || null
@@ -2139,11 +2330,62 @@ export default {
2139
2330
  const m = String((cfg && cfg.method) || 'GET').trim().toUpperCase()
2140
2331
  return /^[A-Z]{3,7}$/.test(m) ? m : 'GET'
2141
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
+ }
2142
2385
  async function apiFetchJson(url, auth, key, opts) {
2143
2386
  const o = opts || {}
2144
- const headers = {}
2145
- const a = String(auth == null ? 'Bearer {key}' : auth)
2146
- if (a) headers.Authorization = a.replace('{key}', key)
2387
+ // v791:头列表(默认值仍是单个 Authorization,与老行为逐字节一致)
2388
+ const headers = headersFromAuth(auth, key)
2147
2389
  // v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
2148
2390
  const u = String(url).replace('{key}', key)
2149
2391
  assertSafeApiUrl(u)
@@ -2153,7 +2395,8 @@ export default {
2153
2395
  init.method = method
2154
2396
  const raw = o.body
2155
2397
  if (raw !== undefined && raw !== null && String(raw).length) {
2156
- headers['Content-Type'] = 'application/json'
2398
+ // 用户自己在头列表里写了 Content-Type 就用他的(不覆盖)
2399
+ if (!headerNameExists(headers, 'content-type')) headers['Content-Type'] = 'application/json'
2157
2400
  init.body = bodyWithPlaceholders(raw, key, o.base, o.params)
2158
2401
  }
2159
2402
  }
@@ -2611,7 +2854,7 @@ export default {
2611
2854
  const qj = {}
2612
2855
  const pathOf = (v) => String(v || '').slice(0, 200)
2613
2856
  if (p.quota.url) qj.url = String(p.quota.url).slice(0, 500)
2614
- if (p.quota.auth !== undefined) qj.auth = String(p.quota.auth || '').slice(0, 200)
2857
+ if (p.quota.auth !== undefined) qj.auth = String(p.quota.auth || '').slice(0, 1000)
2615
2858
  const jin = p.quota.json
2616
2859
  for (const k of ['percent', 'remainPct', 'weeklyRemainPct', 'remain', 'total', 'resetAt', 'resetAtMs', 'level']) {
2617
2860
  if (jin[k]) qj[k] = pathOf(jin[k])
@@ -2639,7 +2882,8 @@ export default {
2639
2882
  // 全部为空时直接删掉 model.balance,等于"完全用模板"
2640
2883
  const balIn = stripEmptyDeep({
2641
2884
  url: String(p.balance.url || '').slice(0, 500),
2642
- auth: String(p.balance.auth || '').slice(0, 200),
2885
+ // v791:auth 现在是「头名:值」列表,比单个 Authorization 值长 ⇒ 上限 200 → 1000
2886
+ auth: String(p.balance.auth || '').slice(0, 1000),
2643
2887
  // v789(issue #190):可选的请求方法与请求体(留空 ⇒ 沿用模板/GET)
2644
2888
  method: String(p.balance.method || '').slice(0, 8).toUpperCase(),
2645
2889
  body: String(p.balance.body || '').slice(0, 4000),
@@ -2656,7 +2900,7 @@ export default {
2656
2900
  model.balance = model.balance || {}
2657
2901
  model.balance.usage = stripEmptyDeep({
2658
2902
  url: String(p.balance.usage.url).slice(0, 500),
2659
- 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),
2660
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 },
2661
2905
  })
2662
2906
  }
@@ -2869,7 +3113,7 @@ export default {
2869
3113
  const summary = daySummary(led, todayKey())
2870
3114
  const nowSec = Math.floor(Date.now() / 1000)
2871
3115
  return {
2872
- ...visible, version: '0.3.18', isPeak: isPeakTime(nowSec),
3116
+ ...visible, version: '0.4.0', versionLabel: VERSION_LABEL, isPeak: isPeakTime(nowSec),
2873
3117
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2874
3118
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2875
3119
  peakHolidays: HOLIDAY_VALLEY_LIST,
@@ -2935,6 +3179,8 @@ export default {
2935
3179
  usageMode: normalizeUsageMode(parsed.usageMode),
2936
3180
  peakMode: parsed.peakMode === 'liangwen' || parsed.peakMode === 'qiangqiang' ? parsed.peakMode : 'default',
2937
3181
  bubbleOn: parsed.bubbleOn !== false,
3182
+ // v866(用户要求):按压/松开音的**独立**开关(原来是全局 soundOn 兼着当它用)
3183
+ pressOn: parsed.pressOn !== false,
2938
3184
  turnCostOn: parsed.turnCostOn !== false,
2939
3185
  turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
2940
3186
  scrollGapOn: parsed.scrollGapOn === true,
@@ -2950,10 +3196,11 @@ export default {
2950
3196
  return null
2951
3197
  }
2952
3198
 
2953
- 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) {
2954
3200
  const um = normalizeUsageMode(usageMode)
2955
3201
  const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
2956
3202
  const bo = bubbleOn !== false
3203
+ const po = pressOnArg !== false // v866:按压/松开音独立开关(默认开)
2957
3204
  const tco = turnCostOn !== false
2958
3205
  const tcc = typeof turnCostCloseMs === 'number' ? (turnCostCloseMs > 0 ? turnCostCloseMs : 0) : 5000
2959
3206
  const sgo = scrollGapOn === true
@@ -2968,6 +3215,7 @@ export default {
2968
3215
  usageMode: um,
2969
3216
  peakMode: pm,
2970
3217
  bubbleOn: bo,
3218
+ pressOn: po,
2971
3219
  turnCostOn: tco,
2972
3220
  turnCostCloseMs: tcc,
2973
3221
  scrollGapOn: sgo,
@@ -2989,6 +3237,7 @@ export default {
2989
3237
  usageMode: um,
2990
3238
  peakMode: pm,
2991
3239
  bubbleOn: bo,
3240
+ pressOn: po,
2992
3241
  turnCostOn: tco,
2993
3242
  turnCostCloseMs: tcc,
2994
3243
  scrollGapOn: sgo,
@@ -3338,14 +3587,31 @@ export default {
3338
3587
  kind: 'exact',
3339
3588
  path: '/dsh-whale/balance.json',
3340
3589
  handler: async (req, res) => {
3590
+ const t0 = Date.now()
3341
3591
  try {
3342
3592
  const refresh = new URL(req.url || '/', 'http://localhost').searchParams.get('refresh') === '1'
3343
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) {}
3344
3606
  res.writeHead(200, JSON_HEADERS)
3345
- res.end(JSON.stringify(payload))
3607
+ // v794:成功失败都带上版本 ⇒ 客户端不必依赖"余额成功"才拿到版号
3608
+ res.end(JSON.stringify(Object.assign({}, payload, { version: BUILD_VERSION, versionLabel: VERSION_LABEL })))
3346
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) {}
3347
3613
  res.writeHead(200, JSON_HEADERS)
3348
- 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 }))
3349
3615
  }
3350
3616
  },
3351
3617
  }))
@@ -3398,6 +3664,7 @@ export default {
3398
3664
  typeof parsed.usageMode === 'string' ? parsed.usageMode : old.usageMode,
3399
3665
  typeof parsed.peakMode === 'string' ? parsed.peakMode : old.peakMode,
3400
3666
  pickB(parsed.bubbleOn, old.bubbleOn, true),
3667
+ pickB(parsed.pressOn, old.pressOn, true),
3401
3668
  pickB(parsed.turnCostOn, old.turnCostOn, true),
3402
3669
  pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
3403
3670
  pickB(parsed.scrollGapOn, old.scrollGapOn, false),
@@ -3416,7 +3683,12 @@ export default {
3416
3683
  return
3417
3684
  }
3418
3685
  res.writeHead(200, JSON_HEADERS)
3419
- 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
+ })))
3420
3692
  },
3421
3693
  }))
3422
3694
 
@@ -3434,6 +3706,290 @@ export default {
3434
3706
  }))
3435
3707
  },
3436
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
+ }))
3437
3993
  disposers.push(registerRoute({
3438
3994
  kind: 'exact',
3439
3995
  path: '/dsh-whale/usage-records.json',
@@ -3839,10 +4395,10 @@ export default {
3839
4395
  },
3840
4396
  }))
3841
4397
 
3842
- disposers.push(registerRoute({
3843
- kind: 'exact',
3844
- path: '/dsh-whale/audio-fragment.wav',
3845
- handler: (req, res) => {
4398
+ // v860(issue #226):路由**不再以 .wav 结尾** —— 带后缀的 URL 会被部分环境(下载器/扩展/反代)
4399
+ // 当成"下载文件"劫持,导致试听与任务结束音无声。Content-Type 由 fragmentMime() 决定,
4400
+ // 与 URL 后缀无关 ⇒ 改成无后缀路径,同时**保留旧路径作为兼容别名**(老客户端仍能用)。
4401
+ const audioFragmentHandler = (req, res) => {
3846
4402
  try {
3847
4403
  const id = audioIdFromUrl(req.url)
3848
4404
  const bytes = loadAudioFragmentBytes(id)
@@ -3860,8 +4416,9 @@ export default {
3860
4416
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
3861
4417
  res.end('audio fragment unavailable')
3862
4418
  }
3863
- },
3864
- }))
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 }))
3865
4422
 
3866
4423
  // 片段字节对应的 MIME:内置/预设片段按声明,自定义片段一律 wav
3867
4424
  function fragmentMime(fragId) {
@@ -4013,6 +4570,169 @@ export default {
4013
4570
  }
4014
4571
  return null
4015
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
+ }
4016
4736
  function writeBubbleConfig(cfg) {
4017
4737
  const body = JSON.stringify(cfg, null, 2)
4018
4738
  for (const p of BUBBLE_FILE_CANDIDATES) {
@@ -4035,7 +4755,15 @@ export default {
4035
4755
  return
4036
4756
  }
4037
4757
  // v727:tapAdvance =「点按角色推进泡泡队列」(只存布尔值,不放行其它字段)
4038
- 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
+ }
4039
4767
  writeBubbleConfig(cfg)
4040
4768
  res.writeHead(200, JSON_HEADERS)
4041
4769
  res.end(JSON.stringify({ ok: true, config: cfg }))