dsh-whale-widget 0.3.8 → 0.3.10

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.
@@ -93,6 +93,32 @@ window.__dshWhaleInit = true
93
93
  // 衔接时机由 RELEASE_LEAD_MS 决定(0 = 正好接上;30/50 = 轻微交叠)—— 改这个数字即可按耳朵微调,
94
94
  // 不用动任何逻辑。
95
95
  var dshwvAudioCtx = null
96
+ // v753(issue #135):**running 状态的 AudioContext 会让系统一直挂着 PreventUserIdleSystemSleep** ——
97
+ // macOS 上表现为"只要页面开着就不会空闲睡眠",而且与**有没有出声无关**(context 一 running 就持有播放流)。
98
+ // 所以空闲 DSHW_AUDIO_IDLE_MS 之后主动 suspend();下次出声前 dshwvAudio() 里的 resume() 会自动恢复。
99
+ // 为什么用 suspend() 而不是 close():close() 会把 context 彻底销毁,而**任务结束音不是手势触发的**,
100
+ // 销毁后它再也响不出来(被手势解锁过的 context 再 resume 不需要新手势,挂起是安全的)。
101
+ var DSHW_AUDIO_IDLE_MS = 60000 // 静默多久交还系统睡眠(用户选定:1 分钟)
102
+ var dshwvAudioSuspendT = null
103
+ function dshwvAudioSuspendNow() {
104
+ try {
105
+ if (dshwvAudioSuspendT) { clearTimeout(dshwvAudioSuspendT); dshwvAudioSuspendT = null }
106
+ if (dshwvAudioCtx && dshwvAudioCtx.state === 'running') dshwvAudioCtx.suspend()
107
+ } catch (err) {}
108
+ }
109
+ function dshwvAudioIdleArm() {
110
+ try {
111
+ if (dshwvAudioSuspendT) clearTimeout(dshwvAudioSuspendT)
112
+ dshwvAudioSuspendT = setTimeout(function () { dshwvAudioSuspendT = null; dshwvAudioSuspendNow() }, DSHW_AUDIO_IDLE_MS)
113
+ } catch (err) {}
114
+ }
115
+ // 音效是否被**显式**关掉(v753 的显式开关)。这里刻意用 typeof 保护:
116
+ // 本文件经常被探针按片段切出来单独 eval(例如 _v752-check 只切「音频垫片 + applySoundSet」),
117
+ // 那些沙箱里不一定声明了 soundOn —— typeof 对未声明的标识符是安全的,裸引用会直接抛
118
+ // ReferenceError 把整段沙箱打断。真机上 soundOn 一定存在,行为不受影响。
119
+ function dshwvSoundOff() {
120
+ try { return typeof soundOn !== 'undefined' && soundOn === false } catch (err) { return false }
121
+ }
96
122
  function dshwvAudio() {
97
123
  try {
98
124
  if (!dshwvAudioCtx) {
@@ -101,24 +127,47 @@ function dshwvAudio() {
101
127
  dshwvAudioCtx = new AC({ latencyHint: 'interactive' })
102
128
  }
103
129
  if (dshwvAudioCtx.state === 'suspended') { try { dshwvAudioCtx.resume() } catch (err) {} }
130
+ dshwvAudioIdleArm() // 每次(重新)进入 running 都重新计时:静默满 1 分钟就挂起
104
131
  return dshwvAudioCtx
105
132
  } catch (err) { return null }
106
133
  }
107
134
  var dshwvAudioBuffers = {} // url -> Promise<AudioBuffer>(同一片段不重复下载/解码)
108
135
  var dshwvAudioDecoded = {} // url -> AudioBuffer(解码完成后**同步可读**:起播走同步路径的关键)
136
+ // v752:音频失败**必须看得见**。原来所有 fetch/decode 失败都被 `.catch(function(){})` 吞掉,
137
+ // 于是"没声音"在控制台里一点痕迹都没有,只能靠猜。现在每个 URL 只 warn 一次,
138
+ // 并把最后一次错误留在 dshwvAudioLastErr 里。
139
+ var dshwvAudioLastErr = null
140
+ var dshwvAudioWarned = {}
141
+ function dshwvAudioWarn(url, err) {
142
+ var msg = String((err && err.message) || err || 'unknown')
143
+ dshwvAudioLastErr = { url: String(url || ''), error: msg, at: new Date().toISOString() }
144
+ if (dshwvAudioWarned[url]) return
145
+ dshwvAudioWarned[url] = 1
146
+ try { console.warn('[小鲸鱼] 音频加载/解码失败:' + url + ' → ' + msg) } catch (e) {}
147
+ }
109
148
  function nowMs() { try { return (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now() } catch (err) { return Date.now() } }
110
149
  function dshwvAudioBuffer(url) {
111
150
  if (!url) return Promise.reject(new Error('empty url'))
112
151
  if (!dshwvAudioBuffers[url]) {
113
- dshwvAudioBuffers[url] = fetch(url, { cache: 'force-cache' })
114
- .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer() })
152
+ // v752:改 no-store。原来用 force-cache —— 万一某个中间层把一次空的 204 缓存住,
153
+ // 之后每次点按都会复用它(表现就是"永久没声音,重启也没用")。我们本来就有内存解码缓存,
154
+ // 不会因此重复下载。
155
+ dshwvAudioBuffers[url] = fetch(url, { cache: 'no-store' })
156
+ .then(function (r) {
157
+ // 注意:204 也满足 r.ok(2xx),必须单独挡掉,否则 0 字节会被送进 decodeAudioData
158
+ // → 抛 EncodingError("Unable to decode audio data")→ 只剩静音。
159
+ if (r.status === 204) throw new Error('HTTP 204(无内容:该音效槽留空、或音效组未找到)')
160
+ if (!r.ok) throw new Error('HTTP ' + r.status)
161
+ return r.arrayBuffer()
162
+ })
115
163
  .then(function (raw) {
164
+ if (!raw || raw.byteLength < 100) throw new Error('音频响应只有 ' + (raw ? raw.byteLength : 0) + ' 字节(不是有效音频)')
116
165
  var c = dshwvAudio()
117
166
  if (!c) throw new Error('no audio context')
118
167
  return new Promise(function (res, rej) { c.decodeAudioData(raw, res, rej) })
119
168
  })
120
169
  .then(function (buf) { dshwvAudioDecoded[url] = buf; return buf })
121
- .catch(function (err) { delete dshwvAudioBuffers[url]; delete dshwvAudioDecoded[url]; throw err })
170
+ .catch(function (err) { delete dshwvAudioBuffers[url]; delete dshwvAudioDecoded[url]; dshwvAudioWarn(url, err); throw err })
122
171
  }
123
172
  return dshwvAudioBuffers[url]
124
173
  }
@@ -126,6 +175,9 @@ function dshwvAudioBuffer(url) {
126
175
  // 不预热 → 第一次点按要现 fetch+decodeAudioData,表现就是"按下音慢半拍、中间衔接发飘"。
127
176
  // URL 级缓存保证同一片段只解一次;失败静默吞掉(真正播放时还会自己重试一次)。
128
177
  function dshwvWarm(urls) {
178
+ // v753(issue #135):音效关掉时**连预热都不做** —— 只跳过下面那句 dshwvAudio() 是不够的,
179
+ // 因为预解码走到 dshwvAudioBuffer() 里还会再调一次 dshwvAudio(),context 照样被建起来。
180
+ if (dshwvSoundOff()) return
129
181
  try { dshwvAudio() } catch (err) {}
130
182
  for (var i = 0; i < (urls || []).length; i++) {
131
183
  var u = urls[i]
@@ -187,9 +239,15 @@ function dshwvSound(url) {
187
239
  el._playingSince = nowMs() + delay * 1000
188
240
  var dur = Math.max(0.001, buf.duration)
189
241
  src.start(delay > 0 ? c.currentTime + delay : 0, Math.max(0, el._offset) % dur)
190
- } catch (err) {}
242
+ } catch (err) {
243
+ // v749:起播抛异常(例如 AudioContext 已关闭、offset 越界)以前被完全吞掉 → 表现为"没声音但无任何报错"
244
+ dshwvAudioWarn(el && el._url, err)
245
+ }
191
246
  }
192
247
  function startSound(delaySec) {
248
+ // v753(issue #135):音效开关关掉 = 全静音(含编辑器里的试听),也**不碰** AudioContext ——
249
+ // 否则一次试听就会把 context 转成 running、系统断言照挂。
250
+ if (dshwvSoundOff()) return Promise.resolve()
193
251
  var c = dshwvAudio()
194
252
  if (!c || !el._url) return Promise.resolve()
195
253
  var token = (el._token = (el._token || 0) + 1)
@@ -197,27 +255,47 @@ function dshwvSound(url) {
197
255
  // ④ 同步起播:缓冲区已预热(dshwvWarm)过 → 直接在当前任务里 start(),不再等 Promise
198
256
  var cached = dshwvAudioDecoded[el._url]
199
257
  if (cached) { beginWithBuffer(c, cached, delay); return Promise.resolve() }
200
- dshwvAudioBuffer(el._url).then(function (buf) {
258
+ var tryUrl = el._url
259
+ dshwvAudioBuffer(tryUrl).then(function (buf) {
201
260
  if (token !== el._token) return // 期间被重播/暂停/换源 → 丢弃这次
202
261
  beginWithBuffer(c, buf, delay)
203
- }).catch(function () {})
262
+ }).catch(function () {
263
+ // v752:首选路由取不到音频时,自动换另一条路由重试一次(两条互为备胎)。
264
+ // 只在失败路径上跑,不影响正常点按的同步起播时序。
265
+ if (!el._alt || el._url !== tryUrl || token !== el._token) return
266
+ var alt = el._alt
267
+ el._alt = '' // 只回退一次,避免两条路由来回打转
268
+ el._url = alt
269
+ try { dshwvWarm([alt]) } catch (e) {} // 后台预热,下次点按就能走同步路径
270
+ try { startSound(delay) } catch (e) {}
271
+ })
204
272
  return Promise.resolve()
205
273
  }
206
274
  el.play = function () { return startSound(0) }
207
275
  el.playAt = function (delaySec) { return startSound(delaySec) }
208
276
  el.pause = function () { dshwvSoundStop(el) }
277
+ // v752:备用路由(首选失败时自动切过去);由 applySoundSet/playTaskEndGroupClick 填
278
+ el._alt = ''
209
279
  return el
210
280
  }
281
+
211
282
  // 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声;任务结束音不是手势触发的,
212
283
  // 所以挂一次性解锁(首次点击/按键后移除)。
213
284
  try {
214
285
  var dshwvAudioUnlock = function () {
286
+ // v753(issue #135):音效关掉时**不预解锁** —— 否则一次普通点击就会把 context 转成 running,
287
+ // 断言照挂。注意这里**不摘监听**:之后重新打开开关,下一次点击仍能完成解锁。
288
+ if (dshwvSoundOff()) return
215
289
  dshwvAudio()
216
290
  try { document.removeEventListener('pointerdown', dshwvAudioUnlock, true) } catch (err) {}
217
291
  try { document.removeEventListener('keydown', dshwvAudioUnlock, true) } catch (err) {}
218
292
  }
219
293
  document.addEventListener('pointerdown', dshwvAudioUnlock, true)
220
294
  document.addEventListener('keydown', dshwvAudioUnlock, true)
295
+ // v753(issue #135):页面被隐藏(切标签 / 最小化)时立刻挂起,直接覆盖"开着过夜"这个场景
296
+ document.addEventListener('visibilitychange', function () {
297
+ try { if (document.hidden) dshwvAudioSuspendNow() } catch (err) {}
298
+ })
221
299
  } catch (err) {}
222
300
 
223
301
  var MIN_SCALE = 0.6
@@ -245,10 +323,29 @@ var css = [
245
323
  '.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
246
324
  '.dshwv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}',
247
325
  '.dshwv-img{position:absolute;right:0;bottom:0;width:59.45%;height:59.45%;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none;object-fit:contain;object-position:right bottom}',
326
+ // v751(PR #119):光标不再写 document.body.style.cursor —— cursor 是可继承属性,写 <body> 会让 Blink
327
+ // 失效**整棵文档树**的样式;而它在点击链路上按下/抬手各写一次,紧接着 isWhaleHit() 的
328
+ // getBoundingClientRect() 与泡泡行测量的 getComputedStyle()/scrollWidth 会强制刷新样式+布局,
329
+ // 于是"整页样式重算"被算进了这一次点击(长会话里表现为桌宠点了卡一下、松手音延迟、跑马灯不动)。
330
+ // 现在光标由挂件自己的类承担:命中鲸鱼不透明像素时让 .dshwv-img 接过指针并显示 grab / grabbing。
331
+ // 鲸鱼区域本来就在吞指针事件(onDocPointerDown 的 isWhaleHit),这不是新增拦截;
332
+ // 新增影响的只有滚轮,由 onWhaleWheel 转交给指针下方真正可滚动的容器。
333
+ '.dshwv-root.dshwv-cursor-grab .dshwv-img{pointer-events:auto;cursor:grab}',
334
+ '.dshwv-root.dshwv-cursor-grabbing .dshwv-img{pointer-events:auto;cursor:grabbing}',
335
+ // 拖动期间握点相对挂件固定,让整个盒接过指针以保持 grabbing(类由按下时加、endDrag 摘)
336
+ '.dshwv-root.dshwv-dragging .dshwv-body{pointer-events:auto;cursor:grabbing}',
248
337
  '.dshwv-pop{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1;--dshw-u:calc(var(--dshw-base) / 1026)}',
249
338
  // 纵深防御:泡泡容器必须透明,形状由内部 SVG 绘制;用 !important 压掉外部
250
339
  // 插件“类名子串匹配”选择器(如 aqua 的 [class*=bubble])注入的玻璃/边框样式
251
340
  'html .dshwv-pop, html .dshwv-pop svg{background:transparent !important;border:0 !important;border-radius:0 !important;backdrop-filter:none !important;-webkit-backdrop-filter:none !important}',
341
+ // 纵深防御(issue #133):宿主皮肤/主题会按**几何特征**(position:fixed + z-index≥10 +
342
+ // 尺寸≥120×80 + 非原生弹窗角色)把挂件方块误判成"独立插件窗",然后注入
343
+ // backdrop-filter / 背景 / 边框 / 伪元素毛玻璃,把用户壁纸糊掉(实测 375×375 方块里是雾面)。
344
+ // 挂件是透明精灵图,根节点上任何"画底"的通道都不该存在,所以这里全部钉死为透明 ——
345
+ // 我们自己的 .dshwv-root 本来就只有 left/top/width/height/transform,没有背景/边框/阴影/filter,
346
+ // 也没有用 ::before/::after,所以这是零行为变化的加固。
347
+ 'html .dshwv-root,body .dshwv-root{background:transparent !important;background-image:none !important;border:0 !important;outline:0 !important;box-shadow:none !important;backdrop-filter:none !important;-webkit-backdrop-filter:none !important;filter:none !important}',
348
+ 'html .dshwv-root::before,html .dshwv-root::after{content:none !important;background:transparent !important;background-image:none !important;box-shadow:none !important;backdrop-filter:none !important;-webkit-backdrop-filter:none !important}',
252
349
  '.dshwv-pop svg{display:block;width:100%;height:100%;pointer-events:none}',
253
350
  '.dshwv-pop svg path,.dshwv-pop svg ellipse{pointer-events:none;cursor:pointer}',
254
351
  '.dshwv-pop.dshwv-pop-open svg path,.dshwv-pop.dshwv-pop-open svg ellipse{pointer-events:visiblePainted}',
@@ -1213,14 +1310,19 @@ function playTaskEndSound() {
1213
1310
  if (!usageSet || !usageSet.taskEnd || !usageSet.taskEnd.on || soundOn === false) return
1214
1311
  var sel = usageSet.taskEnd.sel || taskEndSel.value || ''
1215
1312
  var url = ''
1313
+ var altUrl = ''
1216
1314
  if (sel.indexOf('grp:') === 0) { playTaskEndGroupClick(sel.slice(4)); return }
1217
1315
  if (sel.indexOf('frag:') === 0) url = '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(sel.slice(5))
1218
1316
  else if (sel.indexOf('preset:') === 0) {
1317
+ // v752:预设片段也改走片段路由(与本体按压同一套选择逻辑 + 备用路由)
1219
1318
  var parts = sel.split(':')
1220
- url = '/dsh-whale/sound/' + (parts[2] === 'release' ? 'release' : 'press') + '.mp3?set=' + parts[1]
1319
+ var su = soundSlotUrls(parts[2] === 'release' ? 'release' : 'press', parts[1])
1320
+ url = su.url
1321
+ altUrl = su.alt || ''
1221
1322
  }
1222
1323
  if (!url) return
1223
1324
  var a = dshwvSound(url)
1325
+ if (altUrl) a._alt = altUrl
1224
1326
  try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
1225
1327
  a.play().catch(function () {})
1226
1328
  } catch (err) {}
@@ -1236,11 +1338,17 @@ function playTaskEndGroupClick(groupId) {
1236
1338
  var releaseEmpty = !!(g && g.release === '')
1237
1339
  if (pressEmpty && releaseEmpty) return
1238
1340
  var vol = Number(soundVol) || 0.9
1341
+ // v752:任务结束音同样改走片段路由(与本体按压同一套 URL 选择 + 备用路由逻辑)——
1342
+ // 老路由 /dsh-whale/sound/*.mp3?set=… 在部分环境会被本机那层东西拦成空的 204,
1343
+ // 不改的话"点按有声、但每轮结束音没声"会变成同一个问题的另一半。
1344
+ var pu = soundSlotUrls('press', groupId)
1345
+ var ru = soundSlotUrls('release', groupId)
1239
1346
  // 按压留空:无按下音,直接播松开(模拟按下即松开的完整点按);松开留空:只播按压
1240
1347
  if (pressEmpty) {
1241
- if (!releaseEmpty) {
1242
- dshwvWarm(['/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId)]) // v745:先预热
1243
- var relOnly = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1348
+ if (!releaseEmpty && ru.url) {
1349
+ dshwvWarm([ru.url]) // v745:先预热
1350
+ var relOnly = dshwvSound(ru.url)
1351
+ relOnly._alt = ru.alt || ''
1244
1352
  try { relOnly.volume = vol } catch (err) {}
1245
1353
  relOnly.currentTime = 0
1246
1354
  var pr = relOnly.play()
@@ -1248,20 +1356,20 @@ function playTaskEndGroupClick(groupId) {
1248
1356
  }
1249
1357
  return
1250
1358
  }
1251
- var press = dshwvSound('/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId))
1359
+ if (!pu.url) return
1360
+ var press = dshwvSound(pu.url)
1361
+ press._alt = pu.alt || ''
1252
1362
  try { press.volume = vol } catch (err) {}
1253
1363
  // v745:菜单里的"点一下试听"同样先预热解码,否则第一次听有明显延迟
1254
- dshwvWarm([
1255
- '/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId),
1256
- releaseEmpty ? '' : '/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId),
1257
- ])
1258
- if (releaseEmpty) {
1364
+ dshwvWarm([pu.url, releaseEmpty ? '' : ru.url])
1365
+ if (releaseEmpty || !ru.url) {
1259
1366
  press.currentTime = 0
1260
1367
  var pp = press.play()
1261
1368
  if (pp && pp.catch) pp.catch(function () {})
1262
1369
  return
1263
1370
  }
1264
- var release = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1371
+ var release = dshwvSound(ru.url)
1372
+ release._alt = ru.alt || ''
1265
1373
  try { release.volume = vol } catch (err) {}
1266
1374
  var relPlayed = false
1267
1375
  function playRel() {
@@ -1335,8 +1443,17 @@ var volPct = document.createElement('span')
1335
1443
  volPct.className = 'dshwv-volpct'
1336
1444
  volPct.textContent = '90%'
1337
1445
  volInput.addEventListener('input', function () { setVol(volInput.value) })
1446
+ // v753(issue #135):显式「音效开关」,放在「音量」右边。滑块是 flex:1、开关是 flex:0 0 auto(16px),
1447
+ // 所以「滑块 + 开关」合起来仍占原来滑块那一条宽度(缩掉的正好是开关与行间距的宽度),行总宽不变。
1448
+ var soundToggle = document.createElement('input')
1449
+ soundToggle.type = 'checkbox'
1450
+ soundToggle.className = 'dshwv-check'
1451
+ soundToggle.checked = true
1452
+ soundToggle.title = '音效总开关:关掉后不出声(含音效组试听),并立刻挂起音频上下文、把系统睡眠交还给你'
1453
+ soundToggle.addEventListener('change', function () { setSoundOn(soundToggle.checked) })
1338
1454
  var row3 = menuRow()
1339
1455
  row3.appendChild(menuLabel('音量'))
1456
+ row3.appendChild(soundToggle)
1340
1457
  row3.appendChild(volInput)
1341
1458
  row3.appendChild(volPct)
1342
1459
  var row6 = menuRow()
@@ -1450,17 +1567,21 @@ var rowHide = menuRow()
1450
1567
  rowHide.appendChild(menuLabel('隐藏菜单按钮'))
1451
1568
  rowHide.appendChild(menuHideToggle)
1452
1569
  menuBox.appendChild(rowHide)
1453
- // —— Codex 本机统计开关(issue #116):关掉后宿主不再扫描 ~/.codex/sessions ——
1454
- var codexStatsToggle = document.createElement('input')
1455
- codexStatsToggle.type = 'checkbox'
1456
- codexStatsToggle.className = 'dshwv-check'
1457
- codexStatsToggle.checked = true
1458
- codexStatsToggle.title = '关闭后不再读取 ~/.codex/sessions 统计本机 Codex 用量(会话日志很大时建议关闭)'
1459
- codexStatsToggle.addEventListener('change', function () { setCodexStatsOn(codexStatsToggle.checked) })
1460
- var rowCodexStats = menuRow()
1461
- rowCodexStats.appendChild(menuLabel('Codex 本机统计'))
1462
- rowCodexStats.appendChild(codexStatsToggle)
1463
- menuBox.appendChild(rowCodexStats)
1570
+ // —— Codex 本机统计开关(issue #116):v748 起**从主菜单挪进「Codex 模型的设置子菜单」** ——
1571
+ // 主菜单不再有这一项(它对没配 Codex 模型的用户没有意义)。宿主侧同样按"有没有 Codex 模型"兜底:
1572
+ // 没配 → 默认关闭、完全不扫 ~/.codex/sessions。这里只保留一个"当前挂载的那个复选框"的引用,
1573
+ // 供 setCodexStatsOn() 与配置读回时同步勾选状态(菜单是每次重建的,所以元素现建现用)。
1574
+ var codexStatsToggle = null
1575
+ function codexStatsCheckbox() {
1576
+ var el = document.createElement('input')
1577
+ el.type = 'checkbox'
1578
+ el.className = 'dshwv-check'
1579
+ el.checked = codexStatsOn !== false
1580
+ el.title = '关闭后不再读取 ~/.codex/sessions 统计本机 Codex 用量(会话日志很大时建议关闭)'
1581
+ el.addEventListener('change', function () { setCodexStatsOn(el.checked) })
1582
+ codexStatsToggle = el
1583
+ return el
1584
+ }
1464
1585
  // —— 资源管理:集中查看/删除已导入的图片与音频(角色图/泡泡图/音频片段/音效组) ——
1465
1586
  var rowRes = menuRow()
1466
1587
  var resOpenBtn = document.createElement('button')
@@ -2712,12 +2833,26 @@ function openApiModelPanel(modelId) {
2712
2833
  } catch (err) {}
2713
2834
  }
2714
2835
  var apiModelMaskEl = null
2836
+ // v748:Codex 模型的设置菜单打开时,记下"当前是哪个模型"和"用量行元素",
2837
+ // 这样拨动「Codex 本机统计」开关、等宿主确认后可以**原地**刷新那一行,不必关窗重开。
2838
+ var apiModelMenuId = null
2839
+ var codexUsageTextEl = null
2840
+ function refreshOpenCodexRow() {
2841
+ try {
2842
+ if (!codexUsageTextEl || !apiModelMenuId) return
2843
+ var t = apiCodexDetailText(apiModelMenuId)
2844
+ codexUsageTextEl.textContent = t
2845
+ codexUsageTextEl.title = t
2846
+ } catch (err) {}
2847
+ }
2715
2848
  function closeApiModelPanel() {
2716
2849
  // v744:必须走 dshwBodyDetach —— 这个遮罩是登记在案的 body 节点,
2717
2850
  // 若用 parentNode.removeChild 直接摘掉,DOM 守护会认为"被别的插件摘走了"并把它补挂回来,
2718
2851
  // 表现就是**点「取消」关不掉这个窗口**(0.3.7 引入的回归)。
2719
2852
  try { if (apiModelMaskEl) dshwBodyDetach(apiModelMaskEl) } catch (err) {}
2720
2853
  apiModelMaskEl = null
2854
+ apiModelMenuId = null
2855
+ codexUsageTextEl = null
2721
2856
  }
2722
2857
  // 列宽受限 + 悬浮滚动:内容超出列宽时,鼠标移上去文字自动横向滚动,移开回到起点。
2723
2858
  // 需要 .dshwv-marq(外层 overflow:hidden)+ 内层 inline-block span 配合。
@@ -3079,8 +3214,26 @@ function openApiModelMenu(modelId) {
3079
3214
  }, function () { openModelAlertBudget(modelId, 'budget', function () { openApiModelMenu(modelId) }) })
3080
3215
  rowOf('额度(订阅/资源包)', function () { return apiQuotaSummary(modelId) },
3081
3216
  function () { openModelQuotaEditor(modelId) })
3082
- // Codex 模式:只读展示本地会话统计(机器级)
3083
- if (apiCodexOf(modelId) && apiCodexOf(modelId).ok) {
3217
+ // Codex 模式:开关(v748 从主菜单挪到这里)+ 只读展示本地会话统计(机器级)
3218
+ // 注意:开关按「是不是 Codex 模型」(apiCodexOf 非空)显示,**不能**按 .ok 判断 ——
3219
+ // 关掉时 .ok 就是 false,那样开关会自己消失、再也没法打开。
3220
+ if (apiCodexOf(modelId)) {
3221
+ var ct = document.createElement('div')
3222
+ ct.className = 'dshwv-audiorow'
3223
+ var ctl = document.createElement('span')
3224
+ ctl.textContent = 'Codex 本机统计'
3225
+ ctl.style.flex = '0 0 auto'
3226
+ ct.appendChild(ctl)
3227
+ var cth = document.createElement('span')
3228
+ cth.className = 'dshwv-usage-hint'
3229
+ cth.style.flex = '1'
3230
+ cth.style.textAlign = 'right'
3231
+ cth.style.paddingRight = '6px'
3232
+ cth.textContent = '读取 ~/.codex/sessions'
3233
+ ct.appendChild(cth)
3234
+ ct.appendChild(codexStatsCheckbox())
3235
+ card.appendChild(ct)
3236
+ // 用量行:关闭/出错时同样显示(文字就是原因),开着时显示今日/近7天/累计
3084
3237
  var cr = document.createElement('div')
3085
3238
  cr.className = 'dshwv-audiorow'
3086
3239
  var cl = document.createElement('span')
@@ -3099,6 +3252,9 @@ function openApiModelMenu(modelId) {
3099
3252
  ci.title = ci.textContent
3100
3253
  cr.appendChild(ci)
3101
3254
  card.appendChild(cr)
3255
+ // 记下来:开关切完拿到服务端确认后,原地刷新这一行(不用关窗重开)
3256
+ codexUsageTextEl = ci
3257
+ apiModelMenuId = modelId
3102
3258
  }
3103
3259
  // 厂商订阅额度(kind='quota'):只读展示,来自厂商接口
3104
3260
  if (apiPlanSupport(modelId)) {
@@ -8531,12 +8687,22 @@ function visibleTopZ() {
8531
8687
  var top = 20500
8532
8688
  // 注意:这里必须列全 —— 少一个浮层,dshwLayerUp/下拉/提示就会低估"当前最高层"而被盖住。
8533
8689
  // toast 刻意不列入(它是永远的最顶层,不该让别的层去追它)。
8534
- var cand = [
8535
- bubbleMask, bubbleItemMask, moduleMask, moduleNamePromptMask,
8536
- cropMask, gifMask, audioCropMask, audioEditMask, resMaskEl,
8537
- confirmMask, snapMask, usageMask, usageMoreMask,
8538
- qeditEl, dshwvTplHelpEl, dshwvHintEl,
8539
- apiModelMaskEl, accountingMask, window.__dshwRemindMask
8690
+ // v750(issue #131 的教训):每个候选写成**独立取值函数**并各自 try/catch。
8691
+ // 0.3.8 曾在这里写了一个并不存在的 `usageMask` —— 数组字面量在**构造时**就抛 ReferenceError,
8692
+ // 于是 visibleTopZ() 每次调用都失败、又被调用方的空 catch 吞掉:自绘下拉的层级停在下拉样式表里的
8693
+ // 60(被父窗口整层盖住,表现为"点了没反应"),dshwLayerUp() 也一起静默失效。
8694
+ // 现在单个名字写错最多只少算那一层,不会让整条层级链失效;`_z-audit-check.mjs` 会核对
8695
+ // 候选表里的每个标识符都真的声明过(防止同样的错再犯)。
8696
+ var getters = [
8697
+ function () { return bubbleMask }, function () { return bubbleItemMask },
8698
+ function () { return moduleMask }, function () { return moduleNamePromptMask },
8699
+ function () { return cropMask }, function () { return gifMask },
8700
+ function () { return audioCropMask }, function () { return audioEditMask },
8701
+ function () { return resMaskEl }, function () { return confirmMask },
8702
+ function () { return snapMask }, function () { return usageMoreMask },
8703
+ function () { return qeditEl }, function () { return dshwvTplHelpEl },
8704
+ function () { return dshwvHintEl }, function () { return apiModelMaskEl },
8705
+ function () { return accountingMask }, function () { return window.__dshwRemindMask }
8540
8706
  ]
8541
8707
  function eff(el) {
8542
8708
  try {
@@ -8551,8 +8717,9 @@ function visibleTopZ() {
8551
8717
  return isFinite(n) ? n : 0
8552
8718
  } catch (err) { return 0 }
8553
8719
  }
8554
- for (var i = 0; i < cand.length; i++) {
8555
- var n = eff(cand[i])
8720
+ for (var i = 0; i < getters.length; i++) {
8721
+ var n = 0
8722
+ try { n = eff(getters[i]()) } catch (err) { n = 0 }
8556
8723
  if (n > top) top = n
8557
8724
  }
8558
8725
  return top
@@ -8561,7 +8728,9 @@ function visibleTopZ() {
8561
8728
  // 用在裁剪 / GIF / 音频裁剪 / 模块编辑器这些**既可能从主菜单(10000)打开、也可能从资源管理(20300)、
8562
8729
  // 泡泡编辑器(20500)、模型设置(29000)里打开**的窗口上 —— 固定层号在后者场景会被父窗口盖住。
8563
8730
  function dshwLayerUp(el, base) {
8564
- try { if (el && el.style) el.style.zIndex = String(Math.max(base, Math.round(visibleTopZ()) + 10)) } catch (err) {}
8731
+ var z = base
8732
+ try { z = Math.max(base, Math.round(visibleTopZ()) + 10) } catch (err) { z = base }
8733
+ try { if (el && el.style) el.style.zIndex = String(z) } catch (err) {}
8565
8734
  return el
8566
8735
  }
8567
8736
  // 打开主要编辑器前清理可能残留的临时层级(提醒会话遗留的 moduleMask/qedit 提升与样式)
@@ -8609,7 +8778,10 @@ function dshwDropOpen(menuEl, anchorEl) {
8609
8778
  menuEl.style.left = Math.round(left) + 'px'
8610
8779
  menuEl.style.top = Math.round(r.bottom + 2) + 'px'
8611
8780
  // 下拉层级:高于当前所有可见弹窗/窗口(兜底不低于 26010)
8612
- var vTop = visibleTopZ()
8781
+ // v750(issue #131):visibleTopZ() 单独 try/catch —— 算不出最高层也必须把层级抬上去,
8782
+ // 并且打一条 warn(原先异常被外层空 catch 吞掉,表现为"下拉点了没反应"且毫无线索)
8783
+ var vTop = 20500
8784
+ try { vTop = visibleTopZ() } catch (err) { try { console.warn('[dsh-whale] visibleTopZ 失败,下拉改用兜底层级:', err) } catch (e2) {} }
8613
8785
  menuEl.style.zIndex = String(Math.max(26010, Math.round(vTop) + 10))
8614
8786
  } catch (err) {}
8615
8787
  }
@@ -10335,7 +10507,12 @@ function measureBubbleCenter() {
10335
10507
  dshwCenterX = cx
10336
10508
  dshwCenterY = cy
10337
10509
  try {
10338
- var s = document.documentElement.style
10510
+ // v751(PR #119):写到挂件自己的 root 上,**不写 document.documentElement**。
10511
+ // 未注册的自定义属性写在 <html> 上会让 Blink 保守失效整棵子树样式(PR 实测 12k 节点会话
10512
+ // 一次约 160ms),而本函数在初始化、rAF、load 以及每次窗口 resize 都会跑。
10513
+ // 消费这两个变量的 .dshwv-text/.dshwv-gif 都在 root 内(继承即可);
10514
+ // 编辑器预览在 root 之外,所以在 bubblePreviewInto() 里补写了两行。
10515
+ var s = root.style
10339
10516
  s.setProperty('--dshw-vx', cx + '%')
10340
10517
  s.setProperty('--dshw-vy', cy + '%')
10341
10518
  } catch (err) {}
@@ -12102,6 +12279,12 @@ function bubblePreviewInto(container, mods, widthPx) {
12102
12279
  container.style.transform = 'none'
12103
12280
  container.style.transformOrigin = ''
12104
12281
  container.style.setProperty('--dshw-u', (W / 1026) + 'px')
12282
+ // v751(PR #119):预览节点在挂件 root 之外,拿不到 root 上的 --dshw-vx/--dshw-vy(以前写在 <html> 上才吃得到),
12283
+ // 这里补一份,保证编辑器里的预览与真实泡泡同排版。
12284
+ try {
12285
+ container.style.setProperty('--dshw-vx', dshwCenterX + '%')
12286
+ container.style.setProperty('--dshw-vy', dshwCenterY + '%')
12287
+ } catch (err) {}
12105
12288
  // 视觉右移 10px:用左右 margin 的非对称(右侧少让),避免溢出撑出横向滚动条
12106
12289
  var halfGap = Math.max(0, (hostW - W) / 2)
12107
12290
  var shiftR = Math.min(10, Math.max(0, Math.round(halfGap)))
@@ -12589,8 +12772,10 @@ var costBubbleActive = false
12589
12772
  var scrollGapOn = false
12590
12773
  var scrollGapPx = 17
12591
12774
  var menuBtnHide = false // 主菜单开关:隐藏挂件菜单按钮,改为右键小鲸鱼唤出菜单
12592
- // issue #116:Codex 本机统计开关(默认开)。关掉后宿主完全不扫 ~/.codex/sessions,
12593
- // 适合会话日志很大的机器(超大日志会让统计扫描变得很重)。
12775
+ // issue #116:Codex 本机统计开关。关掉后宿主完全不扫 ~/.codex/sessions(适合会话日志很大的机器)。
12776
+ // v748:默认值仍是「开」,但**宿主会先看有没有 Codex 模型** —— 没配就一律按关闭处理,
12777
+ // 所以这里保持默认 true 是安全的(不会让没配 Codex 的用户被扫盘),也不会因为一次普通保存
12778
+ // 就把老用户的开关写成 false。开关 UI 现只在 Codex 模型的设置子菜单里(见 codexStatsCheckbox)。
12594
12779
  var codexStatsOn = true
12595
12780
  // —— v734(issue #97 / #88):设置保存的「防覆盖 + 失败可见」——
12596
12781
  // #97 根因:首次 GET 还没落地就 PUT,会把内存里的默认值整包写进服务端(重启后设置被洗成默认值)。
@@ -12764,7 +12949,14 @@ function setMenuBtnHide(v) {
12764
12949
  function setCodexStatsOn(v) {
12765
12950
  codexStatsOn = v !== false
12766
12951
  if (codexStatsToggle) codexStatsToggle.checked = codexStatsOn
12767
- var after = function () { try { refreshModelList() } catch (err) {} }
12952
+ var after = function () {
12953
+ // v748:这里原来调的是 openApiModelPanel 内部的局部函数 refreshModelList(),
12954
+ // 在顶层作用域里根本不存在 → 异常被 try/catch 吞掉,"重取模型列表"其实从没发生过。
12955
+ // 现在按别处同一套做法刷新:模型列表 + (若开着)记账子界面,再原地刷新 Codex 用量行
12956
+ // (用量行的文字来自刚取回的模型列表,所以要等回调,不能立刻读旧缓存)
12957
+ try { loadApiModels(refreshOpenCodexRow, true) } catch (err) { refreshOpenCodexRow() }
12958
+ try { if (usagePanelOpen && usageSet !== null) rebuildUsageSubShell() } catch (err) {}
12959
+ }
12768
12960
  var p = null
12769
12961
  try { p = saveConfig() } catch (err) {}
12770
12962
  // 等服务端确认落盘后再重取模型列表:否则可能读到旧配置,那一行会先显示上一次的状态再跳变
@@ -12813,7 +13005,9 @@ function setScale(v) {
12813
13005
  function setVol(v) {
12814
13006
  var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100
12815
13007
  soundVol = next
12816
- soundOn = next > 0
13008
+ // v753(issue #135):**不再由音量派生 soundOn**。原来"拉到 0 就等于关音效"是个没写明的隐含行为,
13009
+ // 既让想临时静音的人被迫牺牲原来的音量值,也让"音效开关"这件事在界面上无迹可寻。
13010
+ // 现在开关自己说了算(见 setSoundOn),音量只表示音量。
12817
13011
  volInput.value = String(next)
12818
13012
  volPct.textContent = Math.round(next * 100) + '%'
12819
13013
  try {
@@ -12822,6 +13016,22 @@ function setVol(v) {
12822
13016
  } catch (err) {}
12823
13017
  saveConfig()
12824
13018
  }
13019
+ // v753(issue #135):显式音效总开关(菜单「音量」右边那个勾选框)。
13020
+ // 它同时管三件事:① 出声与否(播放路径都看 soundOn);② 关掉时**立刻挂起** AudioContext,
13021
+ // 把系统睡眠交还给用户,而不是等 1 分钟空闲;③ 重新打开时立刻预热,恢复"点按即响"的跟手度。
13022
+ function setSoundOn(v) {
13023
+ soundOn = v !== false
13024
+ try { soundToggle.checked = soundOn } catch (err) {}
13025
+ if (!soundOn) {
13026
+ dshwvAudioSuspendNow()
13027
+ } else {
13028
+ try { applySoundSet() } catch (err) {}
13029
+ }
13030
+ try {
13031
+ var p = saveConfig()
13032
+ if (p && typeof p.then === 'function') p.catch(function () {})
13033
+ } catch (err) {}
13034
+ }
12825
13035
  function setSoundSet(v) {
12826
13036
  // 支持预设组(duck/fx1)和自定义组 id
12827
13037
  soundSet = typeof v === 'string' && v ? v : 'duck'
@@ -12836,6 +13046,28 @@ var pressing = false
12836
13046
  var pressEnded = false
12837
13047
  var releasePlayed = false
12838
13048
  // v745:不再需要 releaseTimer —— 点按时由 playReleaseAt() 在**音频线程**排期(见 pressUp)
13049
+ // —— v752:本体按压/松开音改走「片段路由」——
13050
+ // 起因:issue 里那台机器上 /dsh-whale/sound/press.mp3?set=… 会被本机的一层东西(代理/安全软件)
13051
+ // 拦成**空的 204**(响应头里没有 Date、还多出 pragma: no-cache,不是本进程发出的),
13052
+ // 0 字节送进 decodeAudioData 就抛 EncodingError → 只剩静音;
13053
+ // 而同一台机器上 /dsh-whale/audio-fragment.wav?id=<片段> 是 200 且能解码。
13054
+ // 两边的字节是**同一个文件**(预设 ya1→assets/Ya1.mp3;自定义组→它引用的那个片段),
13055
+ // 所以音色、时长、衔接时序都不变;同时少一层"组→片段"的宿主侧间接。
13056
+ // 返回 { url, alt, empty }:url=首选(片段路由)、alt=兜底(老的声音组路由,失败时自动切换)、
13057
+ // empty=true 表示该槽显式留空('' = 该事件静音,按设计不出声)。
13058
+ function soundSlotUrls(slot, groupId) {
13059
+ var gid = String(groupId || soundSet || 'duck')
13060
+ if (audioGroupSlotEmpty(gid, slot)) return { url: '', alt: '', empty: true }
13061
+ var frag = ''
13062
+ for (var i = 0; i < audioGroups.length; i++) {
13063
+ var g = audioGroups[i]
13064
+ if (g && g.id === gid) { frag = String(g[slot] || ''); break }
13065
+ }
13066
+ var legacy = '/dsh-whale/sound/' + (slot === 'press' ? 'press' : 'release') + '.mp3?set=' + encodeURIComponent(gid)
13067
+ // 组信息还没到(audio.json 未返回)或找不到组 → 与旧行为完全一致,走老路由
13068
+ if (!frag) return { url: legacy, alt: '', empty: false }
13069
+ return { url: '/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(frag), alt: legacy, empty: false }
13070
+ }
12839
13071
  function applySoundSet() {
12840
13072
  try {
12841
13073
  // v729:切音效组 / 开关音效时把本轮播放状态一并复位,
@@ -12843,24 +13075,25 @@ function applySoundSet() {
12843
13075
  pressEnded = false
12844
13076
  releasePlayed = false
12845
13077
  // 槽位显式留空(该事件静音)时,对应音频元素置空;playPress/playRelease 已判空
12846
- var pEmpty = audioGroupSlotEmpty(soundSet, 'press')
12847
- var rEmpty = audioGroupSlotEmpty(soundSet, 'release')
12848
- if (pEmpty) { pressAudio = null } else {
12849
- pressAudio = dshwvSound('/dsh-whale/sound/press.mp3?set=' + soundSet)
13078
+ var pu = soundSlotUrls('press')
13079
+ var ru = soundSlotUrls('release')
13080
+ if (pu.empty) { pressAudio = null } else {
13081
+ pressAudio = dshwvSound(pu.url)
13082
+ pressAudio._alt = pu.alt || ''
12850
13083
  pressAudio.preload = 'auto'
12851
13084
  pressAudio.volume = soundVol
12852
13085
  }
12853
- if (rEmpty) { releaseAudio = null } else {
12854
- releaseAudio = dshwvSound('/dsh-whale/sound/release.mp3?set=' + soundSet)
13086
+ if (ru.empty) { releaseAudio = null } else {
13087
+ releaseAudio = dshwvSound(ru.url)
13088
+ releaseAudio._alt = ru.alt || ''
12855
13089
  releaseAudio.preload = 'auto'
12856
13090
  releaseAudio.volume = soundVol
12857
13091
  }
12858
13092
  // v745:把这组的按压/松开音**预取+预解码**(Web Audio 下 preload='auto' 不解码)。
12859
13093
  // 预热过之后,起播走 dshwvSound 的同步路径(pointerdown 同一任务里 start),手感才贴手。
12860
- dshwvWarm([
12861
- pEmpty ? '' : '/dsh-whale/sound/press.mp3?set=' + soundSet,
12862
- rEmpty ? '' : '/dsh-whale/sound/release.mp3?set=' + soundSet,
12863
- ])
13094
+ // ⚠️ 不变式:这里预热的 URL 必须与上面建元素用的 URL **逐字符相同**(同一个变量,不再各写一遍字面量)——
13095
+ // 一旦"预热 A、播放 B",点按就会退回异步解码路径,手感立刻变钝(0.3.3 那次"变钝"的根因)。
13096
+ dshwvWarm([pu.empty ? '' : pu.url, ru.empty ? '' : ru.url])
12864
13097
  } catch (err) {}
12865
13098
  }
12866
13099
  function playPress() {
@@ -14383,6 +14616,12 @@ function hideAudioCrop() {
14383
14616
  audioCropBuffer = null
14384
14617
  audioCropTarget = null
14385
14618
  audioCropFileBase = ''
14619
+ // v753(issue #135):裁剪面板自己的 AudioContext 关闭面板就销毁 —— 它不复用,
14620
+ // 留着同样是"一条一直开着的系统音频流"(重新打开面板时导入音频会重新创建,见上面的 if (!audioCropCtx))。
14621
+ try {
14622
+ if (audioCropCtx) audioCropCtx.close()
14623
+ } catch (err) {}
14624
+ audioCropCtx = null
14386
14625
  try { audioCropName.value = '' } catch (err) {}
14387
14626
  updateAudioCropOkState()
14388
14627
  }
@@ -14825,13 +15064,61 @@ document.addEventListener('touchstart', onDocTouchStart, { capture: true, passiv
14825
15064
 
14826
15065
  var widgetCursor = ''
14827
15066
  function setWidgetCursor(v) {
14828
- if (v !== widgetCursor) {
14829
- widgetCursor = v
14830
- try { document.body.style.cursor = v } catch (err) {}
14831
- }
15067
+ if (v === widgetCursor) return
15068
+ widgetCursor = v
15069
+ // v751(PR #119):光标不写 document.body.style.cursor —— cursor 是可继承属性,写 <body> 会让 Blink
15070
+ // 失效整棵文档树的样式;而本函数在点击链路上按下/抬手各写一次,紧接着 isWhaleHit() 的
15071
+ // getBoundingClientRect() 与泡泡行测量的 getComputedStyle()/scrollWidth 会强制刷新样式+布局 ——
15072
+ // 整页重算就被算进了点击里。实测(见 PR #119):5.7k 节点会话约 35ms/次、13.4k 节点约 179ms/次,
15073
+ // 而同样一次写入只落在挂件自己子树上约 1.3ms。所以改成切换挂件自己的类,光标由 CSS 承担。
15074
+ // 两个类必须互斥切换(只 add 不摘旧类会让光标一直停在 grabbing)。
15075
+ try {
15076
+ root.classList.toggle('dshwv-cursor-grab', v === 'grab')
15077
+ root.classList.toggle('dshwv-cursor-grabbing', v === 'grabbing')
15078
+ } catch (err) {}
15079
+ }
15080
+ // 鲸鱼图接过指针期间(见上面的 dshwv-cursor-* 类),滚轮要转交给「指针下方真正可滚动的容器」,
15081
+ // 否则桌宠会变成一块滚不动的实心区域。只做一次临时让位取元素,全程只写挂件自己的内联样式,
15082
+ // 不碰页面级样式;找不到可滚动祖先时什么都不做(此时浏览器仍按默认把滚动交给页面滚动容器)。
15083
+ function onWhaleWheel(e) {
15084
+ try {
15085
+ if (!widgetCursor || !e || !e.target) return
15086
+ var hit = e.target
15087
+ if (hit !== img && !(root && root.contains && root.contains(hit))) return
15088
+ var prev = hit.style ? hit.style.pointerEvents : ''
15089
+ var under = null
15090
+ try {
15091
+ if (hit.style) hit.style.pointerEvents = 'none' // 临时让开,取出指针下方真正的页面元素
15092
+ under = document.elementFromPoint(e.clientX, e.clientY)
15093
+ } catch (err) {}
15094
+ try { if (hit.style) hit.style.pointerEvents = prev } catch (err) {}
15095
+ var sc = under
15096
+ for (var hop = 0; sc && sc !== document.body && sc !== document.documentElement && hop < 12; hop++) {
15097
+ var st = null
15098
+ try { st = window.getComputedStyle(sc) } catch (err) {}
15099
+ if (st && (st.overflowY === 'auto' || st.overflowY === 'scroll' || st.overflowY === 'overlay') &&
15100
+ sc.scrollHeight > sc.clientHeight + 1) break
15101
+ sc = sc.parentElement
15102
+ }
15103
+ if (sc && sc !== document.body && sc !== document.documentElement) {
15104
+ var k = (e.deltaMode === 1) ? 16 : 1 // 1 = 行模式,按 16px 折算
15105
+ if (e.deltaY) sc.scrollTop += e.deltaY * k
15106
+ if (e.deltaX) sc.scrollLeft += e.deltaX * k
15107
+ e.preventDefault()
15108
+ }
15109
+ } catch (err) {}
14832
15110
  }
15111
+ try { root.addEventListener('wheel', onWhaleWheel, { passive: false }) } catch (err) {}
14833
15112
  function onDocPointerMoveCursor(e) {
14834
- if (drag && drag.active) { setWidgetCursor('grabbing'); return }
15113
+ if (drag && drag.active) {
15114
+ // v751(PR #119):按钮已经松开却还在"拖动中" = 这一次 pointerup 丢了(例如松手时指针在窗口外):
15115
+ // 不能继续强推 grabbing(光标会一直卡在"抓紧"),顺手补一次 endDrag 收尾,避免挂件继续黏着鼠标。
15116
+ // 鼠标/触摸/笔拖动期间 e.buttons 都是 1,buttons===0 只可能意味着真的松手了。
15117
+ // endDrag 内部还有 drag.moved 判定,所以这里传 clickAllowed=true 不会误触发点按。
15118
+ if (!e.buttons) { try { endDrag(e, true) } catch (err) {} ; return }
15119
+ setWidgetCursor('grabbing')
15120
+ return
15121
+ }
14835
15122
  var el = null
14836
15123
  try { el = document.elementFromPoint(e.clientX, e.clientY) } catch (err) {}
14837
15124
  if (el && el.closest && (el.closest('.dshwv-pop') || el.closest('.dshwv-menu') || el.closest('.dshwv-menu-btn') || el.closest('.dshwv-rolelist') || el.closest('.dshwv-cropmask') || el.closest('.dshwv-confirmmask') || el.closest('.dshwv-audiolist') || el.closest('.dshwv-audiomask') || el.closest('.dshwv-snapmask') || el.closest('.dshwv-bubmask') || el.closest('.dshwv-qedit') || el.closest('.dshwv-usagepanel') || el.closest('.dshwv-usage-mask') || el.closest('.dshwv-resmask') || el.closest('.dshwv-custmenu') || el.closest('.dshwv-custbtn'))) {
@@ -14994,7 +15281,8 @@ fetch(SIZE_URL, { cache: 'no-store' })
14994
15281
  }
14995
15282
  if (d && typeof d.vol === 'number') {
14996
15283
  soundVol = d.vol
14997
- soundOn = soundVol > 0
15284
+ // v753(issue #135):旧配置没有 sound 字段 → 沿用"音量>0 即开"的老语义;有新字段就以它为准
15285
+ if (typeof d.sound !== 'boolean') soundOn = soundVol > 0
14998
15286
  volInput.value = String(soundVol)
14999
15287
  volPct.textContent = Math.round(soundVol * 100) + '%'
15000
15288
  try {
@@ -15002,6 +15290,11 @@ fetch(SIZE_URL, { cache: 'no-store' })
15002
15290
  if (releaseAudio) releaseAudio.volume = soundVol
15003
15291
  } catch (err) {}
15004
15292
  }
15293
+ // v753:音效总开关(configPayload 一直在写 sound 字段,但以前加载时被"音量>0"覆盖掉了 → 白存)
15294
+ if (d && typeof d.sound === 'boolean') {
15295
+ soundOn = d.sound
15296
+ try { soundToggle.checked = soundOn } catch (err) {}
15297
+ }
15005
15298
  if (d && typeof d.soundSet === 'string' && d.soundSet) {
15006
15299
  // 支持预设组(duck/fx1)和自定义组 id;loadAudio() 会校验自定义组是否存在
15007
15300
  soundSet = d.soundSet
package/lib/index.js CHANGED
@@ -985,7 +985,7 @@ export default {
985
985
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
986
986
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
987
987
  return {
988
- ok: true, version: '0.3.8', today: todayData, days7,
988
+ ok: true, version: '0.3.10', today: todayData, days7,
989
989
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
990
990
  all: {
991
991
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1373,22 +1373,42 @@ export default {
1373
1373
  // 构建都会试一次),而 readSizeConfig() 是同步文件读 —— 不能放在热路径上。
1374
1374
  // 用户改设置时 codexInvalidate() 会清掉缓存,所以开关仍然是"立刻生效"。
1375
1375
  let codexStatsOnV = null, codexStatsOnAt = 0
1376
+ // v748:**没配置 Codex 模型时「本机统计」默认不启用** —— 完全不去读 ~/.codex/sessions
1377
+ // (用户没加 Codex 模型时那些日志与他无关,白扫一遍既费磁盘又占事件循环)。
1378
+ // 判定按模板 kind === 'codex'(与模型列表里"Codex 用量"那一行的判定同源)。
1379
+ function hasCodexModel() {
1380
+ try {
1381
+ for (const m of readApiRegistry().models) {
1382
+ const tpl = apiTemplateOf(m && m.provider)
1383
+ if (tpl && tpl.kind === 'codex') return true
1384
+ }
1385
+ } catch (err) {}
1386
+ return false
1387
+ }
1376
1388
  function codexStatsOn() {
1377
1389
  const now = Date.now()
1378
1390
  if (codexStatsOnV !== null && now - codexStatsOnAt < 2000) return codexStatsOnV
1379
1391
  let v = true
1380
1392
  try {
1381
- const cfg = readSizeConfig()
1382
- v = !cfg || cfg.codexStatsOn !== false
1393
+ if (!hasCodexModel()) {
1394
+ v = false // 没有 Codex 模型 → 默认关闭(不是"用户关掉的",见下面的提示文案)
1395
+ } else {
1396
+ const cfg = readSizeConfig()
1397
+ v = !cfg || cfg.codexStatsOn !== false
1398
+ }
1383
1399
  } catch (err) { v = true }
1384
1400
  codexStatsOnV = v
1385
1401
  codexStatsOnAt = now
1386
1402
  return v
1387
1403
  }
1404
+ // 关闭原因的文案:区分「用户自己关的」与「压根没配 Codex 模型(默认关)」
1405
+ function codexDisabledReason() {
1406
+ try { return hasCodexModel() ? 'Codex 统计已在设置里关闭' : '未添加 Codex 模型,本机统计默认关闭' } catch (err) { return 'Codex 统计已关闭' }
1407
+ }
1388
1408
  function codexBackgroundRefresh(delayMs) {
1389
1409
  if (codexScanP) return codexScanP // 已在扫:先返回,避免重复读配置/重复排队
1390
1410
  if (!codexStatsOn()) {
1391
- codexSnap = { ok: false, disabled: true, error: 'Codex 统计已在设置里关闭' }
1411
+ codexSnap = { ok: false, disabled: true, error: codexDisabledReason() }
1392
1412
  codexSnapAt = Date.now()
1393
1413
  return null
1394
1414
  }
@@ -1894,6 +1914,8 @@ export default {
1894
1914
  }
1895
1915
  if (changed) writeBubbleConfig(cfg)
1896
1916
  } catch (err) {}
1917
+ // v748:删掉的可能是最后一个 Codex 模型 → 立即重算「本机统计」是否启用(含清掉 2s 开关缓存)
1918
+ try { codexInvalidate() } catch (err) {}
1897
1919
  return { ok: true, removedModules: removed }
1898
1920
  }
1899
1921
  // 新增/更新模型;密钥(keyValue)若提供则写入 DSH 官方凭据
@@ -2003,6 +2025,8 @@ export default {
2003
2025
  writeApiRegistry(reg)
2004
2026
  // 改完单价立即失效缓存:否则 refreshCustomPrices 的 10 秒节流会让新价最坏 10 秒后才生效
2005
2027
  customPriceAt = 0
2028
+ // v748:新增/编辑模型可能改变"有没有 Codex 模型",让 Codex 统计开关与快照立刻重算
2029
+ try { codexInvalidate() } catch (err) {}
2006
2030
  let keySaved = false
2007
2031
  if (p.keyValue !== undefined && String(p.keyValue).length) {
2008
2032
  try {
@@ -2161,7 +2185,7 @@ export default {
2161
2185
  const summary = daySummary(led, todayKey())
2162
2186
  const nowSec = Math.floor(Date.now() / 1000)
2163
2187
  return {
2164
- ...visible, version: '0.3.8', isPeak: isPeakTime(nowSec),
2188
+ ...visible, version: '0.3.10', isPeak: isPeakTime(nowSec),
2165
2189
  // 峰谷切换点与节假日清单:前端倒计时要与宿主同源(否则法定节假日会算错切换点)
2166
2190
  peakNextChangeAt: nextPeakChangeAt(nowSec),
2167
2191
  peakHolidays: HOLIDAY_VALLEY_LIST,
@@ -3143,7 +3167,9 @@ export default {
3143
3167
  function serveSound(req, res, candidates) {
3144
3168
  const bytes = loadSound(candidates)
3145
3169
  if (!bytes) {
3146
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
3170
+ // v752:404 也带 no-store。原来只有 200 分支有缓存头,失败的响应可能被浏览器/中间层
3171
+ // 缓存住,一旦缓存就会变成"永久没声音、刷新也没用"。
3172
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
3147
3173
  res.end('sound unavailable')
3148
3174
  return
3149
3175
  }
@@ -3164,7 +3190,7 @@ export default {
3164
3190
  const setName = soundSetFromUrl(req.url)
3165
3191
  if (setName && !SOUND_SETS[setName]) {
3166
3192
  const fragId = groupFragmentId(setName, 'press')
3167
- if (fragId === '') { res.writeHead(204); res.end(); return } // 留空槽:该事件静音
3193
+ if (fragId === '') { res.writeHead(204, { 'Cache-Control': 'no-store' }); res.end(); return } // 留空槽:该事件静音(v752:补 no-store,空响应不允许被缓存)
3168
3194
  const bytes = loadAudioFragmentBytes(fragId)
3169
3195
  if (bytes) {
3170
3196
  res.writeHead(200, {
@@ -3188,7 +3214,7 @@ export default {
3188
3214
  const setName = soundSetFromUrl(req.url)
3189
3215
  if (setName && !SOUND_SETS[setName]) {
3190
3216
  const fragId = groupFragmentId(setName, 'release')
3191
- if (fragId === '') { res.writeHead(204); res.end(); return } // 留空槽:该事件静音
3217
+ if (fragId === '') { res.writeHead(204, { 'Cache-Control': 'no-store' }); res.end(); return } // 留空槽:该事件静音(v752:补 no-store,空响应不允许被缓存)
3192
3218
  const bytes = loadAudioFragmentBytes(fragId)
3193
3219
  if (bytes) {
3194
3220
  res.writeHead(200, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",