dsh-whale-widget 0.3.14 → 0.3.16

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.
@@ -113,7 +113,7 @@ function dshwvAudioIdleArm() {
113
113
  } catch (err) {}
114
114
  }
115
115
  // 音效是否被**显式**关掉(v753 的显式开关)。这里刻意用 typeof 保护:
116
- // 本文件经常被探针按片段切出来单独 eval(例如 _v752-check 只切「音频垫片 + applySoundSet」),
116
+ // 本文件经常被离线自检按片段切出来单独 eval(例如只切「音频垫片 + applySoundSet」那段),
117
117
  // 那些沙箱里不一定声明了 soundOn —— typeof 对未声明的标识符是安全的,裸引用会直接抛
118
118
  // ReferenceError 把整段沙箱打断。真机上 soundOn 一定存在,行为不受影响。
119
119
  function dshwvSoundOff() {
@@ -195,6 +195,32 @@ function dshwvSoundStop(el) {
195
195
  try { node.stop() } catch (err) {}
196
196
  }
197
197
  }
198
+ // v766:**试听名单** —— 供"停止试听"用。为什么要它:`dshwvSound()` 每次都返回一个全新元素,
199
+ // 而任务结束音 / 片段 / 音效组三条试听路径都是**匿名调用**(外面拿不到句柄),播起来之后就停不掉了。
200
+ // 这里只登记 `dshwvPreviewMark > 0` 期间创建的元素 —— 也就是**只登记试听**,绝不误伤真实事件音
201
+ // (真实的任务结束音 / 提问授权提示音是用户要听的,不能在面板里被顺手掐掉)。
202
+ var dshwvPreviewEls = []
203
+ var dshwvPreviewMark = 0
204
+ function dshwvPreviewOn() { dshwvPreviewMark++ }
205
+ function dshwvPreviewOff() { dshwvPreviewMark = Math.max(0, dshwvPreviewMark - 1) }
206
+ function dshwvPreviewTrack(el) {
207
+ if (!(dshwvPreviewMark > 0)) return
208
+ try {
209
+ dshwvPreviewEls.push(el)
210
+ if (dshwvPreviewEls.length > 12) {
211
+ // 播完的(_node 已清空)不再留着,避免名单无限增长
212
+ dshwvPreviewEls = dshwvPreviewEls.filter(function (x) { return x && x._node })
213
+ }
214
+ } catch (err) {}
215
+ }
216
+ function dshwvStopPreviews() {
217
+ try {
218
+ for (var i = 0; i < dshwvPreviewEls.length; i++) {
219
+ try { dshwvSoundStop(dshwvPreviewEls[i]) } catch (err) {}
220
+ }
221
+ dshwvPreviewEls = dshwvPreviewEls.filter(function (x) { return x && x._node })
222
+ } catch (err) {}
223
+ }
198
224
  function dshwvSound(url) {
199
225
  var el = { preload: 'auto', volume: 1, onended: null, loop: false, _url: String(url || ''), _node: null, _gain: null, _offset: 0, _token: 0, _playingSince: 0 }
200
226
  Object.defineProperty(el, 'src', {
@@ -276,6 +302,7 @@ function dshwvSound(url) {
276
302
  el.pause = function () { dshwvSoundStop(el) }
277
303
  // v752:备用路由(首选失败时自动切过去);由 applySoundSet/playTaskEndGroupClick 填
278
304
  el._alt = ''
305
+ dshwvPreviewTrack(el) // v766:只在试听期间登记(见上面的试听名单)
279
306
  return el
280
307
  }
281
308
 
@@ -678,6 +705,9 @@ var css = [
678
705
  // 新增跑马灯:墨韵黑白 / 靛蓝夜曲(文字·内层文字·菜单)
679
706
  '.dshwv-trow.dshwv-rgb-ink,.dshwv-trowtx.dshwv-rgb-ink,.dshwv-qcolmenu .dshwv-rgbopt.opt-ink{background-image:linear-gradient(90deg,rgb(20,20,20),rgb(80,80,80),rgb(140,140,140),rgb(200,200,200),rgb(250,250,250),rgb(250,250,250),rgb(200,200,200),rgb(140,140,140),rgb(80,80,80),rgb(20,20,20))}',
680
707
  '.dshwv-trow.dshwv-rgb-indigo,.dshwv-trowtx.dshwv-rgb-indigo,.dshwv-qcolmenu .dshwv-rgbopt.opt-indigo{background-image:linear-gradient(90deg,rgb(32,49,112),rgb(52,76,146),rgb(74,102,180),rgb(100,126,210),rgb(130,132,224),rgb(130,132,224),rgb(100,126,210),rgb(74,102,180),rgb(52,76,146),rgb(32,49,112))}',
708
+ // v770 新增跑马灯:火红烈焰(深绯红→橙→白热芯) / 警示橙黄(高饱和橙→警示黄)
709
+ '.dshwv-trow.dshwv-rgb-blaze,.dshwv-trowtx.dshwv-rgb-blaze,.dshwv-qcolmenu .dshwv-rgbopt.opt-blaze{background-image:linear-gradient(90deg,rgb(150,15,25),rgb(200,30,30),rgb(240,70,25),rgb(255,130,30),rgb(255,200,60),rgb(255,240,170),rgb(255,200,60),rgb(255,130,30),rgb(240,70,25),rgb(200,30,30),rgb(150,15,25))}',
710
+ '.dshwv-trow.dshwv-rgb-amber,.dshwv-trowtx.dshwv-rgb-amber,.dshwv-qcolmenu .dshwv-rgbopt.opt-amber{background-image:linear-gradient(90deg,rgb(230,90,0),rgb(255,140,0),rgb(255,180,0),rgb(255,215,40),rgb(255,240,120),rgb(255,215,40),rgb(255,180,0),rgb(255,140,0),rgb(230,90,0))}',
681
711
  // 文字底色(圆角矩形底层):跑马灯底色与文字颜色/文字跑马灯不互相占用,底色铺在文字下方
682
712
  '.dshwv-trow.dshwv-bgrgb{text-shadow:none;background-size:200% auto;animation:dshwvRainbow 2.6s linear infinite}',
683
713
  '.dshwv-trow.dshwv-bgrgb-macaron{background-image:linear-gradient(90deg,rgb(255,180,200),rgb(255,205,170),rgb(255,225,165),rgb(245,240,180),rgb(190,240,210),rgb(180,230,245),rgb(190,215,250),rgb(220,200,245),rgb(240,200,230),rgb(255,180,200))}',
@@ -696,6 +726,9 @@ var css = [
696
726
  // 新增跑马灯底色:墨韵黑白 / 靛蓝夜曲
697
727
  '.dshwv-trow.dshwv-bgrgb-ink{background-image:linear-gradient(90deg,rgb(20,20,20),rgb(80,80,80),rgb(140,140,140),rgb(200,200,200),rgb(250,250,250),rgb(250,250,250),rgb(200,200,200),rgb(140,140,140),rgb(80,80,80),rgb(20,20,20))}',
698
728
  '.dshwv-trow.dshwv-bgrgb-indigo{background-image:linear-gradient(90deg,rgb(32,49,112),rgb(52,76,146),rgb(74,102,180),rgb(100,126,210),rgb(130,132,224),rgb(130,132,224),rgb(100,126,210),rgb(74,102,180),rgb(52,76,146),rgb(32,49,112))}',
729
+ // v770 新增跑马灯底色:火红烈焰 / 警示橙黄
730
+ '.dshwv-trow.dshwv-bgrgb-blaze{background-image:linear-gradient(90deg,rgb(150,15,25),rgb(200,30,30),rgb(240,70,25),rgb(255,130,30),rgb(255,200,60),rgb(255,240,170),rgb(255,200,60),rgb(255,130,30),rgb(240,70,25),rgb(200,30,30),rgb(150,15,25))}',
731
+ '.dshwv-trow.dshwv-bgrgb-amber{background-image:linear-gradient(90deg,rgb(230,90,0),rgb(255,140,0),rgb(255,180,0),rgb(255,215,40),rgb(255,240,120),rgb(255,215,40),rgb(255,180,0),rgb(255,140,0),rgb(230,90,0))}',
699
732
  // 内层文字(带底色时)也支持跑马灯文字颜色:与 .dshwv-trow 相同的 渐变裁字 规则
700
733
  '.dshwv-trowtx.dshwv-rgb{background-image:linear-gradient(90deg,rgb(255,180,200),rgb(255,205,170),rgb(255,225,165),rgb(245,240,180),rgb(190,240,210),rgb(180,230,245),rgb(190,215,250),rgb(220,200,245),rgb(240,200,230),rgb(255,180,200));background-size:200% auto;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;animation:dshwvRainbow 2.6s linear infinite;text-shadow:none}',
701
734
  '.dshwv-trowtx.dshwv-rgb-candy{background-image:linear-gradient(90deg,rgb(255,145,170),rgb(255,170,130),rgb(255,195,110),rgb(240,220,115),rgb(140,220,175),rgb(115,210,205),rgb(130,195,240),rgb(160,170,235),rgb(210,155,230),rgb(235,135,190),rgb(255,145,170))}',
@@ -1121,7 +1154,7 @@ function dshwCustSel(sel, opts) {
1121
1154
  dshwCustSelOpen = { menu: menu, btn: btn }
1122
1155
  })
1123
1156
  sync()
1124
- return { sync: sync, refresh: function () { fill(); sync() } }
1157
+ return { sync: sync, refresh: function () { fill(); sync() }, btn: btn, wrap: wrap }
1125
1158
  }
1126
1159
  // 用量:小鲸鱼记账为唯一记账方式;「用量记录」按钮打开历史用量子面板
1127
1160
  var usageRecBtn = document.createElement('button')
@@ -1165,13 +1198,21 @@ taskEndSel.addEventListener('change', function () {
1165
1198
  applyTaskEndLocal(undefined, taskEndSel.value)
1166
1199
  commitTaskEnd()
1167
1200
  })
1168
- function fillTaskEndOptions(pref) {
1169
- // 排列:置顶项恒最前(按置顶先后) → 已导入片段 → 4 个预设单音 → 音效组置底;
1170
- // 组与组可同名,按 value 去重(不按显示名);其余按 value/显示名去重
1201
+ // v761(全局音效设置面板):音效下拉的**通用填充**(任务结束音 / 提问 / 授权三处共用)。
1202
+ // 排列:置顶项恒最前(按置顶先后) → 已导入片段 → 4 个预设单音 → 音效组置底;
1203
+ // 组与组可同名,按 value 去重(不按显示名);其余按 value/显示名去重。
1204
+ // 只改 DOM 与(任务结束音原有的)兜底设置,不写任何新键 ——
1205
+ // 任务结束音(selectEl === taskEndSel)完全沿用原有语义,其它下拉找不到现值时保留原值显示。
1206
+ // v762:**删掉了原第一位的「静音」项**(value 为空串)——「不响」统一由音效行左边的 [✓] 表达
1207
+ // (提问/授权 = events.<kind>.soundOn,任务结束音 = taskEnd.on),一个开关就够了,不再有语义重叠。
1208
+ // 连带效应(正是我们要的):空/失效的 sel 不再被「静音」项接住,于是重新走到下面的兜底分支
1209
+ // (任务结束音挑默认音并**写回 usageSet**),下拉里再也不会出现空值。
1210
+ function fillSoundSelect(selectEl, currentSel) {
1211
+ if (!selectEl) return false
1212
+ var isTaskEnd = selectEl === taskEndSel
1171
1213
  // 期望值优先取“持久化设置”,避免启动时被上一轮的临时默认值带偏
1172
- var prefSel = (usageSet && usageSet.taskEnd && usageSet.taskEnd.sel) || (pref && pref.sel) || ''
1173
- var cur = taskEndSel.value || prefSel || ''
1174
- taskEndSel.innerHTML = ''
1214
+ var cur = String(currentSel == null ? '' : currentSel)
1215
+ selectEl.innerHTML = ''
1175
1216
  var seen = {}
1176
1217
  var seenLbl = {}
1177
1218
  function add(v, lab) {
@@ -1180,7 +1221,7 @@ function fillTaskEndOptions(pref) {
1180
1221
  if (seenLbl[lab]) return
1181
1222
  seen[v] = 1
1182
1223
  seenLbl[lab] = 1
1183
- taskEndSel.appendChild(soundOpt(v, lab))
1224
+ selectEl.appendChild(soundOpt(v, lab))
1184
1225
  }
1185
1226
  // 收集全部候选(片段+单音在前,组置底),返回 {v,lab,grp} 列表
1186
1227
  var grps = Array.isArray(audioGroups) ? audioGroups : []
@@ -1216,9 +1257,9 @@ function fillTaskEndOptions(pref) {
1216
1257
  var allCand = fragCand.concat(preCand, grpCand)
1217
1258
  var candByV = {}
1218
1259
  allCand.forEach(function (c) { candByV[c.v] = c })
1219
- // 置顶序列(仍存在的选项按 pin 顺序排最前)
1260
+ // 置顶序列(仍存在的选项按 pin 顺序排最前);置顶只属于任务结束音(值存 usageSet.taskEnd.pins)
1220
1261
  var pins = []
1221
- try { if (usageSet && usageSet.taskEnd && Array.isArray(usageSet.taskEnd.pins)) pins = usageSet.taskEnd.pins.slice() } catch (err) {}
1262
+ if (isTaskEnd) { try { if (usageSet && usageSet.taskEnd && Array.isArray(usageSet.taskEnd.pins)) pins = usageSet.taskEnd.pins.slice() } catch (err) {} }
1222
1263
  var ordered = []
1223
1264
  var orderedSeen = {}
1224
1265
  pins.forEach(function (pv) {
@@ -1235,48 +1276,75 @@ function fillTaskEndOptions(pref) {
1235
1276
  if (c.grp) {
1236
1277
  if (seen[c.v]) return
1237
1278
  seen[c.v] = 1
1238
- taskEndSel.appendChild(soundOpt(c.v, c.lab))
1279
+ selectEl.appendChild(soundOpt(c.v, c.lab))
1239
1280
  } else add(c.v, c.lab)
1240
1281
  })
1241
1282
  var fragN = 0
1242
- for (var fi = 0; fi < taskEndSel.options.length; fi++) if (String(taskEndSel.options[fi].value).indexOf('frag:') === 0) fragN++
1283
+ for (var fi = 0; fi < selectEl.options.length; fi++) if (String(selectEl.options[fi].value).indexOf('frag:') === 0) fragN++
1243
1284
  var found = false
1244
- for (var i = 0; i < taskEndSel.options.length; i++) if (taskEndSel.options[i].value === cur) { taskEndSel.value = cur; found = true; break }
1285
+ for (var i = 0; i < selectEl.options.length; i++) if (selectEl.options[i].value === cur) { selectEl.value = cur; found = true; break }
1245
1286
  if (!found) {
1246
- // 期望值是自导入片段,但片段列表尚未就绪(启动时音频还在加载):
1247
- // 先不落默认、不改写 usageSet,等片段就绪后的下一次 fill 再定,
1248
- // 避免把用户的片段选择悄悄覆盖成「小黄鸭·按下」并随后续保存持久化
1249
- if (cur && String(cur).indexOf('frag:') === 0 && fragN === 0) {
1250
- taskEndSel.value = ''
1251
- if (taskEndDrop) taskEndDrop.refresh()
1252
- return
1253
- }
1254
- // 期望值是音效组,但组列表尚未就绪(启动时 audio.json 还在加载):
1255
- // 同样先不落默认、不改写 usageSet,等组就绪后的下一次 fill 再定
1256
- if (cur && String(cur).indexOf('grp:') === 0 && (!Array.isArray(audioGroups) || audioGroups.length === 0)) {
1257
- taskEndSel.value = ''
1258
- if (taskEndDrop) taskEndDrop.refresh()
1259
- return
1260
- }
1261
- // 默认:优先 name==='entity',否则第一条已导入片段;都没有则选预设
1262
- var chosen = 'preset:duck:press'
1263
- for (var j = 0; j < taskEndSel.options.length; j++) {
1264
- var v = taskEndSel.options[j].value
1265
- if (v.indexOf('frag:') === 0) { chosen = v; if (String(taskEndSel.options[j].textContent || '') === 'entity') break }
1287
+ if (isTaskEnd) {
1288
+ // 期望值是自导入片段,但片段列表尚未就绪(启动时音频还在加载):
1289
+ // 先不落默认、不改写 usageSet,等片段就绪后的下一次 fill 再定,
1290
+ // 避免把用户的片段选择悄悄覆盖成「小黄鸭·按下」并随后续保存持久化。
1291
+ // 注意:这里把 value 置空是**流程需要**(fillTaskEndOptions 靠 `taskEndSel.value || prefSel`
1292
+ // 在下一轮重新取到真实值),与已删除的「静音」项无关 —— 置空后本次会显示为未选中,属预期。
1293
+ if (cur && String(cur).indexOf('frag:') === 0 && fragN === 0) {
1294
+ selectEl.value = ''
1295
+ if (taskEndDrop) taskEndDrop.refresh()
1296
+ return false
1297
+ }
1298
+ // 期望值是音效组,但组列表尚未就绪(启动时 audio.json 还在加载):
1299
+ // 同样先不落默认、不改写 usageSet,等组就绪后的下一次 fill 再定
1300
+ if (cur && String(cur).indexOf('grp:') === 0 && (!Array.isArray(audioGroups) || audioGroups.length === 0)) {
1301
+ selectEl.value = ''
1302
+ if (taskEndDrop) taskEndDrop.refresh()
1303
+ return false
1304
+ }
1305
+ // 默认:优先 name==='entity',否则第一条已导入片段;都没有则选预设
1306
+ var chosen = 'preset:duck:press'
1307
+ for (var j = 0; j < selectEl.options.length; j++) {
1308
+ var v = selectEl.options[j].value
1309
+ if (v.indexOf('frag:') === 0) { chosen = v; if (String(selectEl.options[j].textContent || '') === 'entity') break }
1310
+ }
1311
+ selectEl.value = chosen
1312
+ usageSet = usageSet || {}
1313
+ usageSet.taskEnd = usageSet.taskEnd || { on: false, sel: '' }
1314
+ usageSet.taskEnd.sel = chosen
1315
+ } else if (cur) {
1316
+ // 面板下拉:绑定的音效还没加载出来(如音频列表未就绪)——原样保留并显示,
1317
+ // 不静默改成别的音效(否则用户一保存就把自己的选择丢了)
1318
+ selectEl.appendChild(soundOpt(cur, cur + '(暂未加载)'))
1319
+ selectEl.value = cur
1320
+ } else {
1321
+ // v762:「静音」项已删除,空值不再有任何含义。面板下拉(提问/授权)走到这里表示
1322
+ // 「没有给值」,兜底选中第一项,保证下拉永远有一个**非空**值
1323
+ // (pollWaitState 的 `on && sel` 门控因此仍然成立)。
1324
+ // 任务结束音不会走到这里(上面 isTaskEnd 分支已处理过 cur 为空的情况)。
1325
+ selectEl.selectedIndex = selectEl.options.length ? 0 : -1
1266
1326
  }
1267
- taskEndSel.value = chosen
1268
- usageSet = usageSet || {}
1269
- usageSet.taskEnd = usageSet.taskEnd || { on: false, sel: '' }
1270
- usageSet.taskEnd.sel = chosen
1271
1327
  }
1272
- if (pref && pref.sel) { for (var k = 0; k < taskEndSel.options.length; k++) if (taskEndSel.options[k].value === pref.sel) taskEndSel.value = pref.sel }
1273
1328
  // 兜底:按 value 从尾向前去重,确保绝无重复项
1274
1329
  var seen2 = {}
1275
- for (var di = taskEndSel.options.length - 1; di >= 0; di--) {
1276
- var dv = taskEndSel.options[di].value
1277
- if (seen2[dv]) { try { taskEndSel.remove(di) } catch (err) {} }
1330
+ for (var di = selectEl.options.length - 1; di >= 0; di--) {
1331
+ var dv = selectEl.options[di].value
1332
+ if (seen2[dv]) { try { selectEl.remove(di) } catch (err) {} }
1278
1333
  else seen2[dv] = 1
1279
1334
  }
1335
+ try {
1336
+ if (isTaskEnd) { if (taskEndDrop) taskEndDrop.refresh() }
1337
+ else if (selectEl.__dshwDrop) selectEl.__dshwDrop.refresh()
1338
+ } catch (err) {}
1339
+ return found
1340
+ }
1341
+ // 任务结束音下拉:入口与语义不变,内部改走通用 fillSoundSelect
1342
+ // (v762 起选项里**不再有「静音」**:不响由 taskEnd.on / 面板音效行的 [✓] 表达)
1343
+ function fillTaskEndOptions(pref) {
1344
+ var prefSel = (usageSet && usageSet.taskEnd && usageSet.taskEnd.sel) || (pref && pref.sel) || ''
1345
+ var cur = taskEndSel.value || prefSel || ''
1346
+ fillSoundSelect(taskEndSel, cur)
1347
+ if (pref && pref.sel) { for (var k = 0; k < taskEndSel.options.length; k++) if (taskEndSel.options[k].value === pref.sel) taskEndSel.value = pref.sel }
1280
1348
  if (taskEndDrop) taskEndDrop.refresh()
1281
1349
  }
1282
1350
  // 片段列表变化后刷新任务结束音下拉(保留用户当前选择)
@@ -1310,6 +1378,53 @@ function taskEndTogglePin(v) {
1310
1378
  fillTaskEndOptions((usageSet && usageSet.taskEnd) || null)
1311
1379
  } catch (err) {}
1312
1380
  }
1381
+ // v761(全局音效设置):**通用事件音效播放**。
1382
+ // 任务结束音 / 提问音 / 授权音三处共用;不改写 playTaskEndSound 里那段已验证的取值逻辑
1383
+ // (frag: 片段 / preset: 预设 / grp: 音效组 + 备用路由),而是**临时替换它读取的绑定与音量再还原**。
1384
+ // v761:音量取值统一走这里 —— **绝不能用 `Number(x) || 0.9`**:0 是合法音量,
1385
+ // 但 `||` 把它当假值 ⇒ 滑块拖到 0 会变成 0.9(用户真机实测到的 bug)。
1386
+ // 这里对 0 / NaN / 越界都给出确定行为:0 就是静音,NaN 才回落默认值。
1387
+ function soundVolClamped(fallback) {
1388
+ var v = Number(soundVol)
1389
+ if (!isFinite(v)) v = (typeof fallback === "number") ? fallback : 0.9
1390
+ return Math.max(0, Math.min(1, v))
1391
+ }
1392
+ function playBindingSound(bind, ev) {
1393
+ try {
1394
+ if (!bind || !bind.on || soundOn === false) return
1395
+ var sel = bind.sel || ''
1396
+ if (!sel) return // 防御:空选择不出声(v762 起下拉里已无「静音」项,正常不会再为空)
1397
+ var savedTaskEnd = usageSet ? usageSet.taskEnd : undefined
1398
+ var savedVol = soundVol
1399
+ try {
1400
+ if (usageSet) usageSet.taskEnd = { on: true, sel: sel }
1401
+ if (ev && typeof ev.vol === 'number' && ev.vol >= 0) soundVol = ev.vol
1402
+ playTaskEndSound()
1403
+ } finally {
1404
+ if (usageSet) usageSet.taskEnd = savedTaskEnd
1405
+ soundVol = savedVol
1406
+ }
1407
+ } catch (err) {}
1408
+ }
1409
+ // v761:等待交互时的**会话名**(wait.json 每秒轮询回填)+ 超长截断(默认 12 字符 + …)
1410
+ var waitSessionName = ''
1411
+ var WAIT_SESSION_MAX = 12
1412
+ // v768:对话名的**统一取值**入口 —— 等待提示的 `{session}` 文案与「对话名」模块共用。
1413
+ // max <= 0 或非数 = 不截断;名字为空时回落「当前对话」。
1414
+ function bubbleSessionLabel(max) {
1415
+ var s = String(waitSessionName || '').trim()
1416
+ if (!s) s = '当前对话'
1417
+ var n = Number(max)
1418
+ if (!isFinite(n) || n <= 0) return s
1419
+ n = Math.max(1, Math.min(120, Math.round(n)))
1420
+ return s.length > n ? (s.slice(0, n) + '...') : s
1421
+ }
1422
+ function soundSessionLabel() { return bubbleSessionLabel(WAIT_SESSION_MAX) }
1423
+ // 对话名模块的显示文本:模块自己的「保留长度」(m.len)优先,缺省 12
1424
+ function bubbleSessionText(m) {
1425
+ m = m || {}
1426
+ return bubbleSessionLabel(m.len === undefined || m.len === null ? WAIT_SESSION_MAX : m.len)
1427
+ }
1313
1428
  function playTaskEndSound() {
1314
1429
  try {
1315
1430
  if (!usageSet || !usageSet.taskEnd || !usageSet.taskEnd.on || soundOn === false) return
@@ -1328,7 +1443,7 @@ function playTaskEndSound() {
1328
1443
  if (!url) return
1329
1444
  var a = dshwvSound(url)
1330
1445
  if (altUrl) a._alt = altUrl
1331
- try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
1446
+ try { a.volume = soundVolClamped(0.9) } catch (err) {}
1332
1447
  a.play().catch(function () {})
1333
1448
  } catch (err) {}
1334
1449
  }
@@ -1342,7 +1457,7 @@ function playTaskEndGroupClick(groupId) {
1342
1457
  var pressEmpty = !!(g && g.press === '') // 仅显式留空算静音;缺失字段(null)按旧数据回落预设
1343
1458
  var releaseEmpty = !!(g && g.release === '')
1344
1459
  if (pressEmpty && releaseEmpty) return
1345
- var vol = Number(soundVol) || 0.9
1460
+ var vol = soundVolClamped(0.9)
1346
1461
  // v752:任务结束音同样改走片段路由(与本体按压同一套 URL 选择 + 备用路由逻辑)——
1347
1462
  // 老路由 /dsh-whale/sound/*.mp3?set=… 在部分环境会被本机那层东西拦成空的 204,
1348
1463
  // 不改的话"点按有声、但每轮结束音没声"会变成同一个问题的另一半。
@@ -1433,34 +1548,40 @@ var row1 = menuRow()
1433
1548
  row1.appendChild(menuLabel('大小'))
1434
1549
  row1.appendChild(scaleInput)
1435
1550
  row1.appendChild(scaleNumber)
1436
- var row2 = menuRow()
1437
- row2.appendChild(menuLabel('音效'))
1438
- row2.appendChild(audioGroupBtn)
1439
- row2.appendChild(audioImportBtn)
1551
+ // —— 音量滑块与百分比(v761 起搬进「提示与音效设置」面板,主菜单不再占行)——
1440
1552
  var volInput = document.createElement('input')
1441
1553
  volInput.type = 'range'
1442
1554
  volInput.min = '0'
1443
1555
  volInput.max = '1'
1444
1556
  volInput.step = '0.05'
1445
1557
  volInput.className = 'dshwv-range'
1446
- volInput.value = '0.9'
1558
+ volInput.value = '1'
1447
1559
  var volPct = document.createElement('span')
1448
1560
  volPct.className = 'dshwv-volpct'
1449
- volPct.textContent = '90%'
1561
+ volPct.textContent = '100%'
1450
1562
  volInput.addEventListener('input', function () { setVol(volInput.value) })
1451
- // v753(issue #135):显式「音效开关」,放在「音量」右边。滑块是 flex:1、开关是 flex:0 0 auto(16px),
1452
- // 所以「滑块 + 开关」合起来仍占原来滑块那一条宽度(缩掉的正好是开关与行间距的宽度),行总宽不变。
1563
+ // 音效总开关(v753/issue #135 引入;v762 起只由「提示与音效设置」面板 ① 表头的同名开关驱动,
1564
+ // 主菜单「音效与提示」行不再放它 —— 但元素保留,因为 setSoundOn()/配置回填会同步它的 checked)
1453
1565
  var soundToggle = document.createElement('input')
1454
1566
  soundToggle.type = 'checkbox'
1455
1567
  soundToggle.className = 'dshwv-check'
1456
1568
  soundToggle.checked = true
1457
1569
  soundToggle.title = '音效总开关:关掉后不出声(含音效组试听),并立刻挂起音频上下文、把系统睡眠交还给你'
1458
1570
  soundToggle.addEventListener('change', function () { setSoundOn(soundToggle.checked) })
1459
- var row3 = menuRow()
1460
- row3.appendChild(menuLabel('音量'))
1461
- row3.appendChild(soundToggle)
1462
- row3.appendChild(volInput)
1463
- row3.appendChild(volPct)
1571
+ // v762:「音效与提示」行 = 标签 + 面板入口(总开关搬进面板 ① 表头,本行不再放开关)。
1572
+ // 原「音效」行里的音效组/导入、原「音量」行的滑块,以及每轮消耗/提问/授权的音效,全部收进面板。
1573
+ // ⚠️ soundToggle 这个元素**仍然创建**(setSoundOn() 与配置回填都会 `soundToggle.checked = ...`),
1574
+ // 只是不再挂到本行上。删掉它的创建会让那两处抛 ReferenceError(本项目栽过整个挂件不出现)。
1575
+ var row2 = menuRow()
1576
+ row2.appendChild(menuLabel('音效与提示'))
1577
+ var soundPanelBtn = document.createElement('button')
1578
+ soundPanelBtn.type = 'button'
1579
+ soundPanelBtn.className = 'dshwv-roleimport'
1580
+ soundPanelBtn.style.flex = '1'
1581
+ soundPanelBtn.textContent = '全局设置'
1582
+ soundPanelBtn.title = '提示与音效设置:按压音量/音效组、每轮消耗提示、提问提示、授权提示'
1583
+ soundPanelBtn.addEventListener('click', function (e) { e.stopPropagation(); openSoundSettingsPanel() })
1584
+ row2.appendChild(soundPanelBtn)
1464
1585
  var row6 = menuRow()
1465
1586
  row6.appendChild(menuLabel('气泡全局开关'))
1466
1587
  row6.appendChild(bubbleToggle)
@@ -1469,8 +1590,8 @@ var bubbleCustomBtn = document.createElement('button')
1469
1590
  bubbleCustomBtn.type = 'button'
1470
1591
  bubbleCustomBtn.className = 'dshwv-roleimport'
1471
1592
  bubbleCustomBtn.style.flex = '1'
1472
- bubbleCustomBtn.textContent = '自定义泡泡'
1473
- bubbleCustomBtn.title = '打开“自定义泡泡”设置'
1593
+ bubbleCustomBtn.textContent = '按压泡泡设置'
1594
+ bubbleCustomBtn.title = '打开“按压泡泡设置”(按压时的泡泡内容与队列)'
1474
1595
  bubbleCustomBtn.addEventListener('click', function (e) { e.stopPropagation(); openBubbleEditor() })
1475
1596
  row6.appendChild(bubbleCustomBtn)
1476
1597
  var menuSep1 = document.createElement('div')
@@ -1523,9 +1644,12 @@ roleFileInput.addEventListener('change', function () { onRoleFileChosen(roleFile
1523
1644
  menuBox.appendChild(rowRole)
1524
1645
  menuBox.appendChild(row1)
1525
1646
  menuBox.appendChild(row2)
1526
- menuBox.appendChild(row3)
1527
1647
  menuBox.appendChild(row6)
1528
- menuBox.appendChild(row7)
1648
+ // v762:「每轮消耗提示」整行**不再挂到主菜单**(菜单里已再无此行,配置入口移进「全局设置」面板)。
1649
+ // ⚠️ row7 及其内部控件(turnCostToggle / turnCostCustomBtn)的创建**必须原样保留**:
1650
+ // turnCostToggle 仍被 OPEN 时的状态同步与 setTurnCostOn() 引用,**取消挂载 ≠ 删除创建**。
1651
+ // (本文件曾在「删了一行、别处还引用 rowN」上踩过:init 抛 TypeError 被外层 try 静默吞掉,
1652
+ // 表现就是挂件整个不出现。)
1529
1653
  // 「任务结束音效」不再占主菜单(v720):控件挂在一个不插入文档的宿主上,
1530
1654
  // 「自定义提示」窗口打开时再把它搬进窗口。需要宿主是因为 dshwCustSel 初始化要求 select 已有父节点。
1531
1655
  var taskEndRowHost = document.createElement('div')
@@ -1863,19 +1987,711 @@ function buildUsageSubShell() {
1863
1987
  usagePanel.appendChild(usageMainEl)
1864
1988
  }
1865
1989
  // —— 预警/预算提醒内容编辑:直接复用 W2 模块编辑组件(可选模块入框/拖入、行内并排、拖动排序、按类型编辑、真实预览) ——
1990
+ // ===== v761(全局音效设置面板):一个窗口里调四个事件 =====
1991
+ // 按压音效 / 每轮消耗提示 / 提问音效 / 授权音效,各自的音效 + 独立音量 + 自动关闭 + 冒泡。
1992
+ // 默认值必须与宿主 whale-balance.mjs 的 soundEventsDefaults() **逐字段一致**
1993
+ // (宿主 = 新用户默认;这里 = 面板的读回兜底与「恢复默认」的目标,改一处必须同步另一处)。
1994
+ function soundEventDefaults() {
1995
+ return {
1996
+ // 按压音效(按压 + 松开):音量与音效组仍在 .dsh-size.json(vol / soundSet),这里只占位
1997
+ press: { vol: 1 },
1998
+ // ⚠️ turnCost **刻意不带** soundOn:它的「这个音效播不播」就是既有的 usageSet.taskEnd.on
1999
+ // (宿主 soundEventsDefaults() 的注释与实现完全一致)。
2000
+ // 若给它加 soundOn: true 再同步写 taskEnd.on,会把老用户的任务结束音从「默认关」变成
2001
+ // 「默认开」—— 这是不能悄悄发生的默认行为变更。
2002
+ // ⚠️ v764:turnCost **也不再带** autoClose / ttlSec —— 它们从来没有消费方,而面板 ② 区
2003
+ // 「自动关闭」的真实落点是 .dshw-size.json 的 turnCostCloseMs(见 openSoundSettingsPanel)。
2004
+ // 给 turnCost 重新长出这两个键,会让同一个设置又变回两个来源(其中一个还是假的)。
2005
+ turnCost: { vol: 1, bubbleOn: true },
2006
+ // v774:出厂默认 = 作者当前用法:提问/授权**开着**(要冒泡),但音效**不响**(soundOn: false)。
2007
+ // 要响就把音效行的 [✓] 勾上;不想冒泡就取消入口行的 [✓]。
2008
+ question: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true },
2009
+ approval: { on: true, soundOn: false, sel: 'frag:exp_orb', vol: 1, autoClose: true, ttlSec: 180, bubbleOn: true },
2010
+ }
2011
+ }
2012
+ // 读某个事件的设置:缺字段/类型不对一律回落默认(只读 usageSet,不写)
2013
+ function soundEventCfg(kind) {
2014
+ var def = soundEventDefaults()[kind] || {}
2015
+ var src = (usageSet && usageSet.events && usageSet.events[kind]) || {}
2016
+ var out = {}
2017
+ for (var k in def) {
2018
+ var v = src[k]
2019
+ if (k === 'vol') { var n = Number(v); out.vol = (isFinite(n) && n >= 0 && n <= 1) ? n : def.vol }
2020
+ else if (k === 'ttlSec') { var s = Number(v); out.ttlSec = (isFinite(s) && s >= 0) ? Math.round(s) : def.ttlSec }
2021
+ else if (k === 'sel') out.sel = (typeof v === 'string') ? v : def.sel
2022
+ else out[k] = (typeof v === 'boolean') ? v : def[k]
2023
+ }
2024
+ return out
2025
+ }
2026
+ // 事件的保存载荷(只带 schema 里的键:turnCost 没有 on/sel/autoClose/ttlSec,宿主那侧的 lines 等内容一律不动)
2027
+ function soundEventOut(cfg, withBind) {
2028
+ var o = {
2029
+ vol: Number(cfg.vol) || 0,
2030
+ bubbleOn: cfg.bubbleOn !== false,
2031
+ }
2032
+ if (withBind) {
2033
+ // v764:autoClose / ttlSec 跟着 on/sel/soundOn 一起,只属于提问/授权。turnCost 的「自动关闭」
2034
+ // 真实落点是 .dsh-size.json 的 turnCostCloseMs、**不走 events 载荷** —— 别再无条件塞进去,
2035
+ // 否则每次保存都会给 turnCost 重新写回这两个死键。
2036
+ o.autoClose = cfg.autoClose !== false
2037
+ o.ttlSec = Math.max(0, Math.round(Number(cfg.ttlSec) || 0))
2038
+ o.on = cfg.on !== false
2039
+ o.sel = (typeof cfg.sel === 'string') ? cfg.sel : ''
2040
+ // v762:音效行的「是否播这个音效」开关。**只有提问/授权**带这个键;
2041
+ // turnCost 的同类开关走既有的 taskEnd.on,不进 events.turnCost(与宿主 schema 一致)。
2042
+ o.soundOn = cfg.soundOn !== false
2043
+ }
2044
+ return o
2045
+ }
2046
+ // 提示与音效设置面板(主菜单「音效与提示」行 →「全局设置」按钮;v761 引入时叫「全局音效设置」)。
2047
+ // 保存语义与既有窗口一致:窗口期间的改动**只落内存缓冲**(sndBuf),点「保存」才一次 PUT
2048
+ // (events + taskEnd);「取消」或点遮罩关闭 = 丢弃缓冲,并把**复用自主菜单的控件**
2049
+ // (音量滑块 volInput、按压总开关、每轮消耗开关 —— 它们的监听本来就是即时提交的)还原回打开时的快照。
2050
+ // v763(真机反馈修):**「恢复默认」也必须守这条语义** —— 它只改内存缓冲与控件显示,一个字节都不落盘
2051
+ // (既不调 setVol/setSoundOn/setTurnCostOn,也不 saveUsageSettings);那几项(按压音量/音效总开关/
2052
+ // 每轮消耗开关/自动关闭秒数,存在 .dsh-size.json)的真落盘统一由 saveAll() 负责,点「取消」则由
2053
+ // cleanup(true) 回滚(自动关闭走 setTurnCostClose,收秒、落 .dsh-size.json 的 turnCostCloseMs)。
2054
+ function openSoundSettingsPanel() {
2055
+ try {
2056
+ var DEF = soundEventDefaults()
2057
+ // v764:② 区「自动关闭」的出厂默认(秒)。它**不属于** events schema —— 真实落点是 .dshw-size.json 的
2058
+ // turnCostCloseMs(宿主 readSizeConfig() / 客户端 `var turnCostCloseMs = 5000` 的兜底值是 5 秒,
2059
+ // 但面板按设计稿把「恢复默认」的目标定为 180 秒,与发布说明里「自动关闭默认 180 秒」一致)。
2060
+ // 这个常量放在面板里,不再借(已摘除的)events.turnCost.ttlSec。
2061
+ var TC_CLOSE_DEFAULT_SEC = 180
2062
+ // 打开时的快照(只覆盖复用自菜单、会即时提交的那几个控件)
2063
+ var snapVol = Number(soundVol) || 0
2064
+ var snapSoundOn = soundOn !== false
2065
+ var snapTurnCostOn = !!turnCostOn
2066
+ // v763:面板 ②区「自动关闭」的真实落点是 .dsh-size.json 的 turnCostCloseMs(= 消耗泡泡的停留时长,
2067
+ // 唯一消费点 sceneOpen('cost', …, turnCostCloseMs > 0 ? turnCostCloseMs : 0)),所以它也要进快照、
2068
+ // 也要能随「取消」还原(否则面板那行「自动关闭」就是个死控件)。
2069
+ var snapTurnCostCloseMs = Math.max(0, Math.round(Number(turnCostCloseMs) || 0))
2070
+ // 内存缓冲:窗口期间只改这里
2071
+ var sndBuf = {
2072
+ turnCost: soundEventCfg('turnCost'),
2073
+ question: soundEventCfg('question'),
2074
+ approval: soundEventCfg('approval'),
2075
+ }
2076
+ // v764:② 区「自动关闭」自己的缓冲(不再是 events.turnCost 的一部分)。初值直接取**真实值**
2077
+ // turnCostCloseMs —— 老实现显示的是 events.turnCost.ttlSec 里那个虚构的 180 秒,而消耗泡泡
2078
+ // 实际一直按 .dshw-size.json 的值关闭,那一行等于在骗人。窗口期间只改这个缓冲,
2079
+ // 真落盘仍由 saveAll() → setTurnCostClose() 负责(收「秒」)。
2080
+ var tcCloseBuf = {
2081
+ autoClose: snapTurnCostCloseMs > 0,
2082
+ ttlSec: Math.round(snapTurnCostCloseMs / 1000),
2083
+ }
2084
+ var teOld = (usageSet && usageSet.taskEnd) || {}
2085
+ var teOldSel = (typeof teOld.sel === 'string') ? teOld.sel : ''
2086
+ // 任务结束音:缓冲里存**真实选择**(taskEnd.on 的开关已由本行左侧的 [✓] 单独表达,
2087
+ // 不再像早期那样「on 为假就把下拉显示成静音」)。
2088
+ // v762:「静音」项已从下拉里删除,所以这里直接沿用已保存的 sel;空/失效值由
2089
+ // fillSoundSelect 按任务结束音原有的兜底逻辑挑一个默认音并写回 usageSet。
2090
+ var bufTaskEndSel = teOldSel
2091
+ var bufTaskEndKeep = teOldSel // 兜底:sel 万一被清空时保留原选择
2092
+ // v762:② 音效行的 [✓] = 既有的 usageSet.taskEnd.on(宿主 usageSettingsDefaults() 里
2093
+ // taskEnd 默认 { on: false } ⇒ 这里默认不勾)。**不引入 turnCost.soundOn**。
2094
+ var bufTaskEndOn = (teOld.on === true)
2095
+ // —— 窗口壳(与「提醒内容编辑器」同款卡片)——
2096
+ var mask = document.createElement('div')
2097
+ mask.className = 'dshwv-bubmask'
2098
+ // 必须高于「模型子菜单/模型设置」(29000) 才能正常操作
2099
+ mask.style.zIndex = '30000'
2100
+ var card = document.createElement('div')
2101
+ card.className = 'dshwv-bubcard'
2102
+ card.style.width = 'min(440px,calc(100vw - 16px))'
2103
+ card.style.maxHeight = '88vh'
2104
+ card.style.overflow = 'hidden auto'
2105
+ var title = document.createElement('div')
2106
+ title.className = 'dshwv-bubtitle'
2107
+ title.textContent = '提示与音效设置'
2108
+ card.appendChild(title)
2109
+ // 登记到运行时浮层表(issue #142 / CI 的 z 层审计):面板内的自绘下拉靠 visibleTopZ()
2110
+ // 抬层,不登记的话它们会算在遮罩下面(点开却看不见)
2111
+ var unregMask = null
2112
+ try { if (typeof window.dshwRegisterMask === 'function') unregMask = window.dshwRegisterMask(mask) } catch (err) {}
2113
+ // —— 面板内的小工具 ——
2114
+ // 分区表头:表头文字前一个开关(该事件总开关)。
2115
+ // v762:右侧那条分隔线(─)**去掉了**,改成上下的留白 —— 用户要求「加一些间隔距离,
2116
+ // 不加分割线了」;四个表头的 [✓] + 标题照旧保留。
2117
+ // ⚠️ .dshwv-bubsec 是多个窗口共用的小节标题类(泡泡编辑器、预览区等),所以这里只用
2118
+ // 行内样式覆盖本面板,绝不去改全局 CSS(否则会连带改掉别的窗口)。
2119
+ // v765:面板改成「四个入口行 + 展开体」的手风琴 —— 入口行常驻(开关 + 区名 + 当前值摘要 + ▸/▾),
2120
+ // 点入口行才展开该区的设置项,**可同时展开多个**;默认全部收起。
2121
+ // v767:入口行/折叠体的实现抽到模块级 `dshwvFoldEntry()`(与资源管理窗口共用),这里只做一层包装。
2122
+ // ⚠️ 四个区的控件**照旧全部创建**,只是藏在折叠体里:applyDefaults() / saveAll() 会逐个回填与读取,
2123
+ // 懒创建会让「恢复默认」静默失效(那些回填都包在 try/catch 里,出错看不出来)。
2124
+ var sndEntries = [] // [{ kind, summary, sel }],供 refreshSummaries() 更新入口行右侧的当前值
2125
+ function sndEntry(labelText, checked, onToggle, first) {
2126
+ return dshwvFoldEntry(card, labelText, {
2127
+ checked: checked,
2128
+ onToggle: onToggle,
2129
+ first: first,
2130
+ onRowClick: function () {
2131
+ // v766:点入口行(展开或收起)时,先把本面板可能开着的浮层收起来。为什么必须**显式**收:
2132
+ // · 「音效组」列表(.dshwv-audiolist)是 body 上的固定层,而全局 onDocPointerDown 见到
2133
+ // 本面板的遮罩(.dshwv-bubmask)就直接 return("面板内部交给面板自己处理")⇒ 不主动收,
2134
+ // 它就会一直飘在收起后的入口行上面(用户实测:收起设置项后已打开的下拉菜单没关)。
2135
+ // · 自绘音效下拉(.dshwv-custmenu)平时会被它自己的 document pointerdown 收掉,一起收更稳。
2136
+ // 与既有约定一致:菜单里点到别处也会 closeRolePanel() + closeAudioGroupPanel()。
2137
+ try { dshwCustSelClose() } catch (err) {}
2138
+ try { closeAudioGroupPanel() } catch (err) {}
2139
+ try { stopPanelPreview() } catch (err) {} // 正在试听的那一声也一起停(v766)
2140
+ },
2141
+ })
2142
+ }
2143
+ // 入口行右侧的「当前值摘要」:不展开也能看出这个事件现在是什么状态
2144
+ function soundNameOf(sel) {
2145
+ try {
2146
+ if (!sel || !sel.options || sel.selectedIndex < 0) return ''
2147
+ var o = sel.options[sel.selectedIndex]
2148
+ return String((o && o.textContent) || '')
2149
+ } catch (err) { return '' }
2150
+ }
2151
+ function sndPct(v) { var n = Number(v); if (!isFinite(n) || n < 0) n = 0; return Math.round(n * 100) + '%' }
2152
+ function refreshSummaries() {
2153
+ try {
2154
+ for (var i = 0; i < sndEntries.length; i++) {
2155
+ var e = sndEntries[i]
2156
+ var txt = ''
2157
+ if (e.kind === 'press') {
2158
+ if (soundOn === false) txt = '已关闭'
2159
+ else {
2160
+ var g = ''
2161
+ try { g = String(audioGroupBtnLabel.textContent || '') } catch (err) {}
2162
+ txt = (g || '默认音效组') + ' · ' + sndPct(soundVol)
2163
+ }
2164
+ } else if (e.kind === 'turnCost') {
2165
+ if (!turnCostOn) txt = '已关闭'
2166
+ else {
2167
+ var sec = Math.max(0, Math.round(Number(tcCloseBuf.ttlSec) || 0))
2168
+ var pre = (tcCloseBuf.autoClose !== false && sec > 0) ? ('自动关 ' + sec + 's') : '不自动关'
2169
+ // v768:静音时**不再显示已选的音效名**,直接告诉用户去哪儿改(用户要求的确切文案)
2170
+ txt = pre + ' · ' + (bufTaskEndOn ? (soundNameOf(e.sel) || '—') : '当前为静音,在下拉设置中修改')
2171
+ if (bufTaskEndOn && sndPct(sndBuf.turnCost.vol) !== '100%') txt += ' · ' + sndPct(sndBuf.turnCost.vol)
2172
+ }
2173
+ } else {
2174
+ var cfg = sndBuf[e.kind] || {}
2175
+ if (cfg.on === false) txt = '已关闭'
2176
+ else if (cfg.soundOn === false) txt = '当前为静音,在下拉设置中修改' // v768:静音时不显示已选音效
2177
+ else {
2178
+ txt = soundNameOf(e.sel) || '—'
2179
+ if (sndPct(cfg.vol) !== '100%') txt += ' · ' + sndPct(cfg.vol)
2180
+ }
2181
+ }
2182
+ try { if (e.summary) e.summary.textContent = txt } catch (err) {}
2183
+ }
2184
+ // v773:摘要与"置灰状态"永远一起刷(所有既有刷新点都会走到这里:开面板 / 卡内 input·change /
2185
+ // 点文档任意处 / 「恢复默认」末尾)—— 所以开关一动,相关设置立刻跟着灰/亮,不需要另挂监听。
2186
+ applySndDisabled()
2187
+ } catch (err) {}
2188
+ }
2189
+ // v773:**音效没开 ⇒ 该区"音效相关设置"置灰且不可编辑**(用户要求)。
2190
+ // · 只动**设置类**控件:音效下拉(自绘那颗按钮 + 原生 select)与音量滑块,以及它们那一行的标签;
2191
+ // · **开关自己**(入口行 [✓] / 音效行 [✓])永远保持可点 —— 它是"开回来"的唯一入口,
2192
+ // 置灰它就等于把用户锁死在外面;
2193
+ // · **试听 ▶ 也保持可用** —— 它正好用来"先听一下再决定开不开"(不是设置,是预览);
2194
+ // · 与声音无关的行(冒泡提示 / 编辑提示内容 / 自动关闭)不动:它们归"事件总开关"管,各按各的。
2195
+ // 判定条件由各区给(读的都是**实时**状态:模块级 soundOn / turnCostOn、缓冲里的 bufTaskEndOn / cfg.soundOn)。
2196
+ var sndDisableGroups = []
2197
+ function sndReg(off, items) { sndDisableGroups.push({ off: off, items: items || [] }) }
2198
+ function sndDim(el, off) {
2199
+ try { if (el && el.style) el.style.opacity = off ? '.4' : '' } catch (err) {}
2200
+ }
2201
+ function applySndDisabled() {
2202
+ for (var i = 0; i < sndDisableGroups.length; i++) {
2203
+ var g = sndDisableGroups[i]
2204
+ var off = false
2205
+ try { off = !!g.off() } catch (err) { off = false }
2206
+ for (var j = 0; j < g.items.length; j++) {
2207
+ var it = g.items[j] || {}
2208
+ try { if (it.el) it.el.disabled = off } catch (err) {}
2209
+ // 自绘下拉:原生 select 是隐藏的,看得见的是它自己那颗按钮 ⇒ 两者都要处理
2210
+ try { if (it.drop && typeof it.drop.sync === 'function') it.drop.sync() } catch (err) {}
2211
+ sndDim(it.drop && it.drop.btn ? it.drop.btn : it.el, off)
2212
+ sndDim(it.label, off)
2213
+ }
2214
+ }
2215
+ }
2216
+ // 一行:标签 + 控件(控件由调用方 append 进来,沿用主菜单的行样式)
2217
+ // v765:多一个 host 参数 —— 行挂到哪个容器(默认 card;手风琴里传该区的折叠体)
2218
+ function row(labelText, host) {
2219
+ var r = menuRow()
2220
+ if (labelText) r.appendChild(menuLabel(labelText))
2221
+ ;(host || card).appendChild(r)
2222
+ return r
2223
+ }
2224
+ // 试听按钮(沿用各窗口里的行内小图标按钮 .dshwv-bubmini)
2225
+ function playBtn(titleText, fn) {
2226
+ var b = document.createElement('button')
2227
+ b.type = 'button'
2228
+ b.className = 'dshwv-bubmini'
2229
+ b.textContent = '▶'
2230
+ b.title = titleText
2231
+ b.addEventListener('click', function (e) { e.stopPropagation(); try { fn() } catch (err) {} })
2232
+ return b
2233
+ }
2234
+ // 独立音量滑块 + 百分比(只改缓冲,点「保存」才落盘)
2235
+ function volSlider(getV, setV) {
2236
+ var inp = document.createElement('input')
2237
+ inp.type = 'range'
2238
+ inp.min = '0'
2239
+ inp.max = '1'
2240
+ inp.step = '0.05'
2241
+ inp.className = 'dshwv-range'
2242
+ inp.value = String(getV())
2243
+ var pct = document.createElement('span')
2244
+ pct.className = 'dshwv-volpct'
2245
+ pct.textContent = Math.round(getV() * 100) + '%'
2246
+ inp.addEventListener('input', function () {
2247
+ var v = Number(inp.value)
2248
+ if (!isFinite(v)) v = 0
2249
+ setV(v)
2250
+ pct.textContent = Math.round(v * 100) + '%'
2251
+ })
2252
+ return { input: inp, pct: pct }
2253
+ }
2254
+ // v766:试听包裹 —— 期间新建的音频元素会进"试听名单"(见 dshwvPreviewTrack),
2255
+ // 收起设置项时由 stopPanelPreview() 一次停掉(片段/预设/音效组三条路都覆盖)。
2256
+ function playPreview(fn) {
2257
+ try { dshwvPreviewOn() } catch (err) {}
2258
+ try { fn() } finally { try { dshwvPreviewOff() } catch (err) {} }
2259
+ }
2260
+ // v766:停掉本面板正在试听的声音。
2261
+ // · ① 的「试听按压音效」走的是**模块级** pressAudio / releaseAudio(复用本体那两个元素)⇒ 直接停;
2262
+ // · ②③④ 走 playBindingSound → playTaskEndSound(内部匿名新建元素)⇒ 靠试听名单停。
2263
+ // ⚠️ 只停试听,**不碰**真实的任务结束音 / 提问授权提示音(那是用户要听的事件音)。
2264
+ function stopPanelPreview() {
2265
+ try { if (pressAudio) dshwvSoundStop(pressAudio) } catch (err) {}
2266
+ try { if (releaseAudio) dshwvSoundStop(releaseAudio) } catch (err) {}
2267
+ try { dshwvStopPreviews() } catch (err) {}
2268
+ }
2269
+ // 自动关闭:开关 + 秒数(0 = 不自动关闭);host = 挂到哪个容器(手风琴的折叠体)
2270
+ function autoCloseRow(cfg, host) {
2271
+ var r = row('自动关闭', host)
2272
+ var ck = document.createElement('input')
2273
+ ck.type = 'checkbox'
2274
+ ck.className = 'dshwv-check'
2275
+ ck.checked = cfg.autoClose !== false
2276
+ ck.title = '开启后泡泡到时间自动关闭'
2277
+ ck.addEventListener('change', function () { cfg.autoClose = !!ck.checked })
2278
+ var num = document.createElement('input')
2279
+ num.type = 'number'
2280
+ num.min = '0'
2281
+ num.step = '1'
2282
+ num.className = 'dshwv-winput'
2283
+ num.value = String(Math.max(0, Math.round(Number(cfg.ttlSec) || 0)))
2284
+ num.title = '自动关闭秒数(0 = 不自动关闭)'
2285
+ num.addEventListener('input', function () { cfg.ttlSec = Math.max(0, Math.round(Number(num.value) || 0)) })
2286
+ num.addEventListener('change', function () {
2287
+ cfg.ttlSec = Math.max(0, Math.round(Number(num.value) || 0))
2288
+ num.value = String(cfg.ttlSec)
2289
+ })
2290
+ r.appendChild(ck)
2291
+ r.appendChild(num)
2292
+ r.appendChild(menuLabel('秒'))
2293
+ return { chk: ck, num: num }
2294
+ }
2295
+ // 冒泡提示:开关 + 编辑提示内容(editFn 为空时才置灰;提问/授权已接入各自的编辑器)
2296
+ function bubbleRow(cfg, editFn, editTip, host) {
2297
+ var r = row('冒泡提示', host)
2298
+ var ck = document.createElement('input')
2299
+ ck.type = 'checkbox'
2300
+ ck.className = 'dshwv-check'
2301
+ ck.checked = cfg.bubbleOn !== false
2302
+ ck.title = '该事件是否用泡泡提示'
2303
+ ck.addEventListener('change', function () { cfg.bubbleOn = !!ck.checked })
2304
+ var btn = document.createElement('button')
2305
+ btn.type = 'button'
2306
+ btn.className = 'dshwv-roleimport'
2307
+ btn.style.flex = '1'
2308
+ btn.textContent = '编辑提示内容'
2309
+ btn.title = editTip
2310
+ if (editFn) {
2311
+ btn.addEventListener('click', function (e) { e.stopPropagation(); try { editFn() } catch (err) {} })
2312
+ } else {
2313
+ btn.disabled = true
2314
+ btn.style.opacity = '.5'
2315
+ btn.style.cursor = 'not-allowed'
2316
+ }
2317
+ r.appendChild(ck)
2318
+ r.appendChild(btn)
2319
+ return ck
2320
+ }
2321
+ // —— ① 按压音效 ——
2322
+ // v762 行序:音效组 → 音量(用户要求把「按压音效组」挪到「按压音量」**之前**)
2323
+ var pressEnt = sndEntry('按压音效', soundOn !== false, function (b) {
2324
+ // 与音效总开关同一个状态(setSoundOn 会同步那只保留但不再挂到菜单行的开关)
2325
+ setSoundOn(b)
2326
+ }, true)
2327
+ var pressOnChk = pressEnt.chk
2328
+ sndEntries.push({ kind: 'press', summary: pressEnt.summary, sel: null })
2329
+ var rPressGrp = row('按压音效组', pressEnt.body)
2330
+ rPressGrp.appendChild(audioGroupBtn) // 复用原「音效组」按钮
2331
+ audioImportBtn.textContent = '新建音效组'
2332
+ audioImportBtn.title = '新建/编辑音效组'
2333
+ rPressGrp.appendChild(audioImportBtn)
2334
+ // 这两个弹层是 body 上的固定层(.dshwv-audiolist / .dshwv-audiomask 都低于 30000),
2335
+ // 从本面板打开时必须临时抬到面板之上,否则会被遮罩整层盖住(点了没反应)
2336
+ try {
2337
+ audioGroupBtn.addEventListener('click', function () { try { audioGroupPanel.style.zIndex = '31000' } catch (err) {} })
2338
+ audioImportBtn.addEventListener('click', function () { try { audioEditMask.style.zIndex = '31000' } catch (err) {} })
2339
+ } catch (err) {}
2340
+ var rPressVol = row('按压音量', pressEnt.body)
2341
+ rPressVol.appendChild(volInput) // 复用主菜单搬出来的滑块(input 事件已接 setVol;取消时按快照还原)
2342
+ rPressVol.appendChild(volPct)
2343
+ // 本区**只保留音量行这一个试听按钮**(音量行自己不加 ▶,避免一个模块两个入口)
2344
+ rPressVol.appendChild(playBtn('试听按压音效', function () {
2345
+ playPreview(function () {
2346
+ playPress()
2347
+ // 音效组把按压槽留空时 playPress() 只置状态不出声 → 补一次松开音,保证试听总能听到该组
2348
+ if (!pressAudio) playRelease()
2349
+ })
2350
+ }))
2351
+ // v773:① 的"音效没开"= 入口行 [✓](soundOn)没勾 ⇒ 音效组 / 按压音量 置灰不可编辑
2352
+ sndReg(function () { return soundOn === false }, [
2353
+ { el: audioGroupBtn, label: rPressGrp.firstChild },
2354
+ { el: audioImportBtn, label: null },
2355
+ { el: volInput, label: rPressVol.firstChild },
2356
+ ])
2357
+ // —— ② 每轮消耗提示 ——
2358
+ // v762 行序(与 ③④ 统一):冒泡提示 → 自动关闭 → 音效行 → 提示音量
2359
+ // (DOM 顺序 = 创建顺序,所以这里按目标顺序调用即完成重排)
2360
+ // v763:② 表头的 [✓] 留个句柄,供 applyDefaults() 同步显示(直接改 .checked 不会触发 change ⇒ 不落盘)
2361
+ var tcEnt = sndEntry('每轮消耗提示', !!turnCostOn, function (b) { setTurnCostOn(b) })
2362
+ var tcOnChk = tcEnt.chk
2363
+ var tcBub = bubbleRow(sndBuf.turnCost, function () { usageAlertBudgetEditor('cost', null) }, '编辑每轮消耗提示的泡泡内容(金额用 {cost})', tcEnt.body)
2364
+ var tcAc = autoCloseRow(tcCloseBuf, tcEnt.body)
2365
+ var rTe = row('任务结束音', tcEnt.body)
2366
+ // 音效行的 [✓] = 该事件是否播这个音效。② 直接绑**既有的** usageSet.taskEnd.on
2367
+ //(宿主 usageSettingsDefaults() 里 taskEnd 默认 { on: false } ⇒ 这里默认不勾)。
2368
+ var teSoundChk = document.createElement('input')
2369
+ teSoundChk.type = 'checkbox'
2370
+ teSoundChk.className = 'dshwv-check'
2371
+ teSoundChk.checked = bufTaskEndOn
2372
+ teSoundChk.title = '该事件是否播这个音效(关掉 = 不响;下拉里选的音效仍保留,下次勾上即用)'
2373
+ teSoundChk.addEventListener('change', function () { bufTaskEndOn = !!teSoundChk.checked })
2374
+ rTe.appendChild(teSoundChk)
2375
+ var teSel = document.createElement('select')
2376
+ teSel.className = 'dshwv-sound'
2377
+ teSel.title = '任务结束音:音效组(点按整组)/ 单个音频;播不播由左边的 [✓] 决定'
2378
+ teSel.addEventListener('change', function () { bufTaskEndSel = teSel.value })
2379
+ rTe.appendChild(teSel)
2380
+ rTe.appendChild(playBtn('试听任务结束音', function () {
2381
+ playPreview(function () { playBindingSound({ on: true, sel: bufTaskEndSel }, { vol: sndBuf.turnCost.vol }) })
2382
+ }))
2383
+ var teDrop = dshwCustSel(teSel, { scrollNames: true })
2384
+ try { teSel.__dshwDrop = teDrop } catch (err) {}
2385
+ fillSoundSelect(teSel, bufTaskEndSel)
2386
+ // 「静音」项删除后下拉不该留空:把兜底后的真实值回写缓冲(旧数据 sel 为空串时兜底成列表里的默认音)
2387
+ if (teSel.value) bufTaskEndSel = teSel.value
2388
+ var tcVol = volSlider(function () { return sndBuf.turnCost.vol }, function (v) { sndBuf.turnCost.vol = v })
2389
+ var rTcVol = row('提示音量', tcEnt.body)
2390
+ rTcVol.appendChild(tcVol.input)
2391
+ rTcVol.appendChild(tcVol.pct)
2392
+ // v773:② 的"音效没开"= 音效行 [✓] 没勾(bufTaskEndOn)**或**整个事件关着(turnCostOn)⇒
2393
+ // 音效下拉 + 提示音量置灰;只有"下拉 + 音量"灰,[✓] 与试听 ▶ 仍可用。
2394
+ // v773b:**「任务结束音」这行字不置灰**(用户要求"这三行字不用变灰")—— 行里还有可用的
2395
+ // [✓] 与 ▶,标签一起灰会让人以为整行都废了。label 传 null = 只灰控件、不动标签。
2396
+ sndReg(function () { return !turnCostOn || !bufTaskEndOn }, [
2397
+ { el: teSel, drop: teDrop, label: null },
2398
+ { el: tcVol.input, label: rTcVol.firstChild },
2399
+ ])
2400
+ sndEntries.push({ kind: 'turnCost', summary: tcEnt.summary, sel: teSel })
2401
+ // —— ③④ 提问提示 / 授权提示(结构完全相同)——
2402
+ var waitUI = {}
2403
+ function buildWait(kind, labelText, selLabel) {
2404
+ var cfg = sndBuf[kind]
2405
+ var wEnt = sndEntry(labelText, cfg.on !== false, function (b) { cfg.on = !!b })
2406
+ var onChk = wEnt.chk
2407
+ // v762 行序(与 ② 统一):冒泡提示 → 自动关闭 → 音效行 → 提示音量
2408
+ // v761(#161 C5):提问/授权的内容编辑器已接入 —— usageAlertBudgetEditor 新增了
2409
+ // 'question' / 'approval' 两个模式,内容落在 events.<kind>.lines。**不要**拿 'question'
2410
+ // 去打开预警/预算编辑器(那会改错内容,正是上一轮把它置灰的原因)。
2411
+ var bub = bubbleRow(cfg, function () { usageAlertBudgetEditor(kind, null) },
2412
+ '编辑' + (kind === 'approval' ? '授权' : '提问') + '提示的泡泡内容({session} = 当前对话名,超长自动截断)', wEnt.body)
2413
+ // v764:**不再挂「自动关闭」那一行** —— 提问/授权的提示气泡是**常驻**的(宿主给的是挂起状态,
2414
+ // 前端 whaleSysPush({kind:'wait', ttlMs: 0}) 不布自动关闭计时器),所以那行改了也没有任何消费方,
2415
+ // 属于「看起来能设、其实什么都不做」的死控件。行序因此是:冒泡提示 → 音效行 → 提示音量。
2416
+ var rSel = row(selLabel, wEnt.body)
2417
+ // 音效行的 [✓] = 是否播这个音效,键 = events.<kind>.soundOn(v762 新增,默认 true)。
2418
+ // 提问/授权本来就被表头总开关默认关着(on: false),所以默认 true 不带来任何行为变化。
2419
+ // 位置:标签之后、下拉之前。
2420
+ var soundChk = document.createElement('input')
2421
+ soundChk.type = 'checkbox'
2422
+ soundChk.className = 'dshwv-check'
2423
+ soundChk.checked = cfg.soundOn !== false
2424
+ soundChk.title = '该事件是否播这个音效(关掉 = 不响;下拉里选的音效仍保留,下次勾上即用)'
2425
+ soundChk.addEventListener('change', function () { cfg.soundOn = !!soundChk.checked })
2426
+ rSel.appendChild(soundChk)
2427
+ var sel = document.createElement('select')
2428
+ sel.className = 'dshwv-sound'
2429
+ sel.title = selLabel + ':音效组(点按整组)/ 单个音频;播不播由左边的 [✓] 决定'
2430
+ sel.addEventListener('change', function () { cfg.sel = sel.value })
2431
+ rSel.appendChild(sel)
2432
+ rSel.appendChild(playBtn('试听' + selLabel, function () {
2433
+ playPreview(function () { playBindingSound({ on: true, sel: cfg.sel }, { vol: cfg.vol }) })
2434
+ }))
2435
+ var drop = dshwCustSel(sel, { scrollNames: true })
2436
+ try { sel.__dshwDrop = drop } catch (err) {}
2437
+ fillSoundSelect(sel, cfg.sel)
2438
+ // 同 ②:「静音」项删除后下拉不该留空,把兜底后的真实值写回缓冲
2439
+ if (sel.value) cfg.sel = sel.value
2440
+ var vs = volSlider(function () { return cfg.vol }, function (v) { cfg.vol = v })
2441
+ var rVol = row('提示音量', wEnt.body)
2442
+ rVol.appendChild(vs.input)
2443
+ rVol.appendChild(vs.pct)
2444
+ // v773:③④ 的"音效没开"= 音效行 [✓](cfg.soundOn)没勾**或**该事件总开关(cfg.on)关着
2445
+ // v773b:**「提问提示音效」/「授权提示音」这两行字不置灰**(用户要求),只灰下拉与音量
2446
+ sndReg(function () { return cfg.on === false || cfg.soundOn === false }, [
2447
+ { el: sel, drop: drop, label: null },
2448
+ { el: vs.input, label: rVol.firstChild },
2449
+ ])
2450
+ sndEntries.push({ kind: kind, summary: wEnt.summary, sel: sel })
2451
+ waitUI[kind] = { on: onChk, sel: sel, drop: drop, vol: vs, bub: bub, sound: soundChk }
2452
+ }
2453
+ buildWait('question', '提问提示', '提问提示音效')
2454
+ buildWait('approval', '授权提示', '授权提示音')
2455
+ // v765:入口行右侧摘要的刷新 —— ① 打开面板时一次;② 卡片里任何 input/change 都刷新(控件都在卡内);
2456
+ // ③ 点文档任意处也刷新一次(覆盖「音效组」这种从 body 级子面板里改、不发卡内事件的路径),
2457
+ // 该监听在 cleanup 里摘掉。摘要只写 textContent,成本很低。
2458
+ function sndDocRefresh() { refreshSummaries() }
2459
+ try { document.addEventListener('click', sndDocRefresh, true) } catch (err) {}
2460
+ try { card.addEventListener('input', refreshSummaries) } catch (err) {}
2461
+ try { card.addEventListener('change', refreshSummaries) } catch (err) {}
2462
+ refreshSummaries()
2463
+ // —— 底部按钮 ——
2464
+ var closed = false
2465
+ function cleanup(restore) {
2466
+ if (closed) return
2467
+ closed = true
2468
+ try { dshwCustSelClose() } catch (err) {} // 收回可能还开着的自绘下拉(它是 body 上的固定层)
2469
+ try { closeAudioGroupPanel() } catch (err) {}
2470
+ try { stopPanelPreview() } catch (err) {} // v766:关窗时也停掉试听(别让声音跟着窗口一起"留在后台")
2471
+ if (restore) {
2472
+ // 复用自菜单的控件在输入时就已提交 → 取消时按快照回滚
2473
+ try { if (Math.abs(Number(soundVol) - snapVol) > 1e-9) setVol(snapVol) } catch (err) {}
2474
+ try { if ((soundOn !== false) !== snapSoundOn) setSoundOn(snapSoundOn) } catch (err) {}
2475
+ try { if (!!turnCostOn !== snapTurnCostOn) setTurnCostOn(snapTurnCostOn) } catch (err) {}
2476
+ // v763:④「自动关闭」(turnCostCloseMs,存在 .dsh-size.json)同款处理 —— 回滚内存并写回磁盘。
2477
+ // setTurnCostClose 收秒,且没有「值未变就跳过」的短路,所以这里自己按快照比较,没变就一个字节都不写。
2478
+ try {
2479
+ if (Math.abs(Number(turnCostCloseMs) - snapTurnCostCloseMs) > 1e-9) {
2480
+ setTurnCostClose(Math.round(snapTurnCostCloseMs / 1000))
2481
+ }
2482
+ } catch (err) {}
2483
+ }
2484
+ try { audioGroupPanel.style.zIndex = '' } catch (err) {}
2485
+ try { audioEditMask.style.zIndex = '' } catch (err) {}
2486
+ try { document.removeEventListener('click', sndDocRefresh, true) } catch (err) {}
2487
+ try { if (unregMask) unregMask() } catch (err) {}
2488
+ dshwBodyDetach(mask) // 走 detach 注销登记,否则 DOM 守护会把这个刚关掉的面板补挂回来
2489
+ }
2490
+ // 本面板会写的那四个事件的载荷(只带 schema 里的键)
2491
+ function eventsPayload() {
2492
+ return {
2493
+ // 按压音量仍在 .dsh-size.json(滑块已即时落盘),这里按宿主 schema 再记一份
2494
+ press: { vol: Number(soundVol) || 0 },
2495
+ turnCost: soundEventOut(sndBuf.turnCost, false),
2496
+ question: soundEventOut(sndBuf.question, true),
2497
+ approval: soundEventOut(sndBuf.approval, true),
2498
+ }
2499
+ }
2500
+ // 本地合并一份(与既有编辑器一致:先乐观写内存,宿主返回后以宿主为准)——
2501
+ // 逐字段合并不整块替换,免得把宿主那侧的 lines 等内容从内存里抹掉。
2502
+ function mergeEventsLocal(ev) {
2503
+ try {
2504
+ usageSet = usageSet || {}
2505
+ usageSet.events = usageSet.events || {}
2506
+ for (var ek in ev) {
2507
+ var m = {}
2508
+ for (var mk in (usageSet.events[ek] || {})) m[mk] = usageSet.events[ek][mk]
2509
+ for (var nk in ev[ek]) m[nk] = ev[ek][nk]
2510
+ usageSet.events[ek] = m
2511
+ }
2512
+ } catch (err) {}
2513
+ }
2514
+ // v763:面板 ②区「自动关闭」→ 消耗泡泡停留时长(毫秒)。为什么要这根线:
2515
+ // · v764:events.turnCost 的 autoClose / ttlSec 已**摘除**(它们从来没有消费方,只有面板自己在
2516
+ // 读写,等于同一件事有两个键);现在 ② 区那行只改面板自己的 tcCloseBuf;
2517
+ // · 真正决定消耗泡泡停多久的是 .dsh-size.json 的 turnCostCloseMs(消费点见上面 sceneOpen);
2518
+ // · 而唯一能改 turnCostCloseMs 的 turnCostCloseInput 已经不再挂到任何窗口(主菜单那行 v762 删了、
2519
+ // 本面板这轮也没搬它)⇒ 不接这根线,面板上那行「自动关闭」就是个死控件。
2520
+ // ⚠️ setTurnCostClose(v) 收的是**秒**(内部 n = round(v); turnCostCloseMs = n * 1000),
2521
+ // 并且**没有**「值未变就跳过」的短路 —— 值没变也会 saveConfig() 写一次 .dsh-size.json(幂等)。
2522
+ function closeMsFromBuf() {
2523
+ // v764:读 ② 区自己的缓冲(tcCloseBuf),不再读 events.turnCost
2524
+ var c = tcCloseBuf || {}
2525
+ var s = Number(c.ttlSec)
2526
+ if (!isFinite(s) || s < 0) s = 0
2527
+ // 不勾「自动关闭」⇒ 0(= 不自动关闭),与既有 turnCostCloseMs > 0 的判定一致
2528
+ return c.autoClose !== false ? Math.round(s) * 1000 : 0
2529
+ }
2530
+ function saveAll() {
2531
+ try {
2532
+ var ev = eventsPayload()
2533
+ // 任务结束音:沿用既有绑定键 {on, sel}。v762 起「播不播」由 ② 音效行的 [✓]
2534
+ // (= usageSet.taskEnd.on)表达,下拉只负责选**哪一个**音效(已无「静音」项)。
2535
+ usageSet = usageSet || {}
2536
+ var te = {}
2537
+ try { for (var k in (usageSet.taskEnd || {})) te[k] = usageSet.taskEnd[k] } catch (err) {}
2538
+ te.on = !!bufTaskEndOn
2539
+ te.sel = bufTaskEndSel || bufTaskEndKeep
2540
+ usageSet.taskEnd = te
2541
+ try { applyTaskEndLocal(te.on, te.sel) } catch (err) {}
2542
+ mergeEventsLocal(ev)
2543
+ saveUsageSettings({ events: ev, taskEnd: te })
2544
+ // v763:按压音量 / 音效总开关 / 每轮消耗提示开关这三项**不在**上面这次 PUT 的覆盖范围里
2545
+ //(它们存在 .dsh-size.json,走 saveConfig())⇒ 保存时必须各自按**当前内存值**提交一次,
2546
+ // 否则「恢复默认」对它们不生效(用户会觉得恢复默认对音量/开关没作用)。
2547
+ // 三个 setter 各自都有「值没变就写一遍同样的值」的幂等行为,无副作用。
2548
+ try { setVol(soundVol) } catch (err) {}
2549
+ try { setSoundOn(soundOn) } catch (err) {}
2550
+ try { setTurnCostOn(turnCostOn) } catch (err) {}
2551
+ // v763:② 的「自动关闭」同样按**当前内存值**提交 —— setTurnCostClose() 收秒、写的是
2552
+ // .dsh-size.json 的 turnCostCloseMs(消耗泡泡停留时长);0 = 不自动关闭。
2553
+ try { setTurnCostClose(Math.round(closeMsFromBuf() / 1000)) } catch (err) {}
2554
+ } catch (err) {}
2555
+ cleanup(false)
2556
+ }
2557
+ function applyDefaults() {
2558
+ try {
2559
+ // v763(真机反馈修):**本函数只改内存,绝不落盘** —— 与窗口里其它改动完全同语义。
2560
+ // 旧实现调了 setVol/setSoundOn/setTurnCostOn(这三者都立即写 .dsh-size.json),又把
2561
+ // 「取消快照」整体换成默认值、最后还 saveUsageSettings({resetEvents:true}) 立刻 PUT
2562
+ // ⇒ 点「恢复默认」再点「取消」时磁盘上早已是默认值,取消等于没撤销(用户实测的 bug)。
2563
+ // 现在:这里只改内存缓冲 + 控件显示(直接改 .value/.checked 不派发 input/change,
2564
+ // 所以碰不到那三个落盘 setter);点「保存」由 saveAll() 提交,「取消」由 cleanup(true)
2565
+ // 按**打开面板时**的快照回滚(因此下面不再重写 snapVol/snapSoundOn/snapTurnCostOn)。
2566
+ // ① 按压音量 + 按压音效总开关(都是内存标量)
2567
+ var dPressVol = Number(DEF.press.vol)
2568
+ dPressVol = isFinite(dPressVol) ? Math.round(Math.min(1, Math.max(0, dPressVol)) * 100) / 100 : 0
2569
+ soundVol = dPressVol // 与 setVol() 同一套规范化(取消时的快照比较是 1e-9,精度必须一致)
2570
+ var sndWasOff = (soundOn === false)
2571
+ soundOn = true
2572
+ try { volInput.value = String(soundVol) } catch (err) {}
2573
+ try { volPct.textContent = Math.round(soundVol * 100) + '%' } catch (err) {}
2574
+ try {
2575
+ if (pressAudio) pressAudio.volume = soundVol
2576
+ if (releaseAudio) releaseAudio.volume = soundVol
2577
+ } catch (err) {}
2578
+ // 由「关」到「开」时补上 setSoundOn(true) 的**非落盘**副作用(复位本轮播放状态 + 重新预热):
2579
+ // 否则音频上下文还停在 suspend,面板里的试听按钮会没声音(取消时 setSoundOn(快照) 会回去)。
2580
+ if (sndWasOff) { try { applySoundSet() } catch (err) {} }
2581
+ // ② 「每轮消耗提示」总开关(同样只改内存)
2582
+ turnCostOn = true
2583
+ try { tcOnChk.checked = true } catch (err) {}
2584
+ try { turnCostToggle.checked = true } catch (err) {}
2585
+ try { turnCostCloseInput.disabled = false } catch (err) {}
2586
+ sndBuf.turnCost = JSON.parse(JSON.stringify(DEF.turnCost))
2587
+ sndBuf.question = JSON.parse(JSON.stringify(DEF.question))
2588
+ sndBuf.approval = JSON.parse(JSON.stringify(DEF.approval))
2589
+ // v764:② 「自动关闭」的默认值 = 出厂值(5 秒 / 开,与 .dshw-size.json 的 turnCostCloseMs 出厂
2590
+ // 默认一致)—— 它现在住在 tcCloseBuf 里,**不再**跟着 DEF.turnCost 走。同样只改内存
2591
+ // (**不调 setTurnCostClose** ⇒ 不落盘),并把(已创建、未挂载的)秒数输入框显示同步好 ——
2592
+ // 否则之后从本面板打开「自定义提示(每轮消耗)」窗口时,那个窗口的「保存」会按陈旧的输入框值
2593
+ // 把 turnCostCloseMs 又写回去。
2594
+ tcCloseBuf.autoClose = true
2595
+ tcCloseBuf.ttlSec = TC_CLOSE_DEFAULT_SEC
2596
+ turnCostCloseMs = closeMsFromBuf()
2597
+ try { turnCostCloseInput.value = String(Math.round(turnCostCloseMs / 1000)) } catch (err) {}
2598
+ // v762:「静音」项已删除 ⇒ 默认仍是「默认已选中内置音」。
2599
+ // v774:「恢复默认」的目标 = 新的出厂默认(宿主 usageSettingsDefaults().taskEnd):
2600
+ // on = **true**、sel = 内置 A(frag:end_a)。
2601
+ bufTaskEndSel = 'frag:end_a' // = 宿主 usageSettingsDefaults().taskEnd.sel
2602
+ bufTaskEndKeep = 'frag:end_a' // = 宿主 usageSettingsDefaults().taskEnd.sel
2603
+ bufTaskEndOn = true // v774:② 音效行的 [✓] 复位(宿主 taskEnd.on 默认 true)
2604
+ // 控件回填
2605
+ try { pressOnChk.checked = true } catch (err) {}
2606
+ try { soundToggle.checked = true } catch (err) {} // v763:与 setSoundOn() 同步的那只开关(只改内存显示)
2607
+ try { teSoundChk.checked = true } catch (err) {} // v774:② 音效行 [✓] 复位(出厂默认已改成"开")
2608
+ try { tcAc.chk.checked = tcCloseBuf.autoClose !== false } catch (err) {}
2609
+ try { tcAc.num.value = String(tcCloseBuf.ttlSec) } catch (err) {}
2610
+ try { tcBub.checked = sndBuf.turnCost.bubbleOn !== false } catch (err) {}
2611
+ try {
2612
+ tcVol.input.value = String(sndBuf.turnCost.vol)
2613
+ tcVol.pct.textContent = Math.round(sndBuf.turnCost.vol * 100) + '%'
2614
+ } catch (err) {}
2615
+ fillSoundSelect(teSel, bufTaskEndSel)
2616
+ ;['question', 'approval'].forEach(function (kind) {
2617
+ var u = waitUI[kind]
2618
+ var cfg = sndBuf[kind]
2619
+ if (!u) return
2620
+ try { u.on.checked = cfg.on !== false } catch (err) {}
2621
+ try {
2622
+ u.vol.input.value = String(cfg.vol)
2623
+ u.vol.pct.textContent = Math.round(cfg.vol * 100) + '%'
2624
+ } catch (err) {}
2625
+ try { u.bub.checked = cfg.bubbleOn !== false } catch (err) {}
2626
+ // v762:③④ 音效行的 [✓] 复位(DEF 里 soundOn: true)
2627
+ try { if (u.sound) u.sound.checked = cfg.soundOn !== false } catch (err) {}
2628
+ fillSoundSelect(u.sel, cfg.sel)
2629
+ })
2630
+ // v765:入口行摘要也要跟着「恢复默认」刷新
2631
+ refreshSummaries()
2632
+ // v763:到这里结束 —— **不再写 usageSet、不调 applyTaskEndLocal、也绝不 PUT**。
2633
+ // 旧实现在这里乐观写了内存镜像(events + taskEnd)并立刻发 resetEvents;而「取消」只能
2634
+ // 回滚那三个标量 ⇒ 内存镜像停在默认值、磁盘却是旧值,表现为「取消后重新打开面板看到
2635
+ // 的还是默认值」。现在整套动作都只是缓冲:
2636
+ // · 事件类键 = sndBuf(saveAll 里由 eventsPayload() 取,保存时 mergeEventsLocal + PUT)
2637
+ // · 任务结束音 = bufTaskEndOn / bufTaskEndSel(saveAll 里写回 usageSet.taskEnd + applyTaskEndLocal)
2638
+ // · 按压音量 = soundVol(saveAll 里 setVol() 落 .dsh-size.json,并按宿主 schema 记一份 press.vol)
2639
+ // · usageSet.events 的 lines(提问/授权提示内容)不再被「恢复默认」清空(与按钮 title 一致)
2640
+ } catch (err) {}
2641
+ }
2642
+ var btns = document.createElement('div')
2643
+ btns.className = 'dshwv-bubbtns'
2644
+ var cancelBtn = document.createElement('button')
2645
+ cancelBtn.type = 'button'
2646
+ cancelBtn.className = 'dshwv-bubbtn dshwv-bubbtn-no'
2647
+ cancelBtn.textContent = '取消'
2648
+ cancelBtn.addEventListener('click', function () { cleanup(true) })
2649
+ btns.appendChild(cancelBtn)
2650
+ var resetBtn = document.createElement('button')
2651
+ resetBtn.type = 'button'
2652
+ resetBtn.className = 'dshwv-bubbtn dshwv-bubbtn-no'
2653
+ resetBtn.textContent = '恢复默认'
2654
+ resetBtn.title = '把四个事件的音量/自动关闭/冒泡/音效选择恢复为默认值(不动外观、位置、账本、角色与自定义泡泡)'
2655
+ resetBtn.addEventListener('click', function () { applyDefaults() })
2656
+ btns.appendChild(resetBtn)
2657
+ var okBtn = document.createElement('button')
2658
+ okBtn.type = 'button'
2659
+ okBtn.className = 'dshwv-bubbtn dshwv-bubbtn-ok'
2660
+ okBtn.textContent = '保存'
2661
+ okBtn.addEventListener('click', function () { saveAll() })
2662
+ btns.appendChild(okBtn)
2663
+ card.appendChild(btns)
2664
+ mask.appendChild(card)
2665
+ mask.addEventListener('click', function (e) { if (e.target === mask) cleanup(true) })
2666
+ dshwBodyAppend(mask)
2667
+ } catch (err) {}
2668
+ }
1866
2669
  function usageAlertBudgetEditor(key, onSave) {
1867
2670
  try {
1868
2671
  var isAlert = key === 'alert'
1869
2672
  // v720:第三个模式 cost —— 主菜单「每轮消耗提示」后的「自定义提示」窗口。
1870
2673
  // 内容与预警/预算共用同一套模块机制;头部不是"触发条件"而是自动关闭秒数 + 任务结束音效(从主菜单搬来)。
1871
2674
  var isCost = key === 'cost'
2675
+ // v761(#161 C5):第四/第五个模式 question / approval —— 「全局音效设置」面板里
2676
+ // 「提问音效 / 授权音效」两区的「编辑提示内容」入口。内容落在 usageSet.events.<kind>.lines
2677
+ // (宿主 waitQuestion.content / waitApproval.content 那套键),与 alert/budget 的 lines 完全分开,
2678
+ // 所以**绝不能**落到 isAlert/isBudget 分支去(那会改错内容 —— 上一轮就是因此把它置灰的)。
2679
+ var isWait = (key === 'question' || key === 'approval')
2680
+ var waitKind = isWait ? key : ''
1872
2681
  var costCommitted = false // 保存过就不再走"取消还原"
2682
+ var waitCommitted = false // v761:等待提示内容"保存过就不再走取消还原"的旗标
2683
+ var waitSnapLines = null // v761:等待提示内容打开窗口时的快照(取消/点遮罩关闭时还原缓冲)
1873
2684
  var cfg = isCost
1874
2685
  ? (((usageSet || {}).turnCost) || {})
1875
- : (((usageSet || {})[isAlert ? 'alert' : 'budget']) || {})
2686
+ : (isWait ? ((((usageSet || {}).events || {})[waitKind]) || {}) : (((usageSet || {})[isAlert ? 'alert' : 'budget']) || {}))
1876
2687
  var numDef = isAlert ? 50 : 20
1877
2688
  var numInit = isAlert ? (cfg.below != null ? cfg.below : numDef) : (cfg.amount != null ? cfg.amount : numDef)
1878
- var step = { kind: 'custom', modules: JSON.parse(JSON.stringify(isCost ? usageTurnCostLines() : usageRemindLinesOf(cfg, isAlert))) }
2689
+ // 三种自带内容各自取自己的默认模板;等待两模式取宿主 waitDefaultLines() 的逐字段一致版
2690
+ var stepLines0 = isCost ? usageTurnCostLines() : (isWait ? usageWaitLinesOf(cfg, waitKind) : usageRemindLinesOf(cfg, isAlert))
2691
+ if (isWait) {
2692
+ try { waitSnapLines = stepLines0 ? JSON.parse(JSON.stringify(stepLines0)) : [] } catch (err) { waitSnapLines = [] }
2693
+ }
2694
+ var step = { kind: 'custom', modules: JSON.parse(JSON.stringify(stepLines0)) }
1879
2695
  // 备份被临时替换的全局状态/元素/函数,关闭后还原
1880
2696
  var bkEditItems = bubbleEditItems
1881
2697
  var bkEditorSnap = bubbleEditorSnap
@@ -1905,20 +2721,25 @@ function usageAlertBudgetEditor(key, onSave) {
1905
2721
  var title = document.createElement('div')
1906
2722
  title.className = 'dshwv-bubtitle'
1907
2723
  title.textContent = isCost
1908
- ? '自定义提示(每轮消耗 · 内容 / 自动关闭 / 任务结束音效)'
1909
- : ('编辑 ' + (isAlert ? '余额预警' : '今日预算') + '提醒内容(可拖动下方模块入框)')
2724
+ ? '自定义提示(每轮消耗 · 内容)'
2725
+ : (isWait
2726
+ ? ('编辑 ' + (waitKind === 'approval' ? '授权' : '提问') + '提示内容(可拖动下方模块入框)')
2727
+ : ('编辑 ' + (isAlert ? '余额预警' : '今日预算') + '提醒内容(可拖动下方模块入框)'))
1910
2728
  card.appendChild(title)
1911
- // —— 头部:预警/预算=触发条件;cost=自动关闭 + 任务结束音效 ——
2729
+ // —— 头部:预警/预算=触发条件;cost=自动关闭 + 任务结束音效;wait=说明 + 占位符提示 ——
1912
2730
  var secCond = document.createElement('div')
1913
2731
  secCond.className = 'dshwv-bubsec dshwv-bubsec-first'
1914
2732
  secCond.textContent = isCost
1915
2733
  ? '每轮消耗提示(内容里用 {cost} 引用本轮消耗金额)'
1916
- : (isAlert ? '触发条件(余额低于该值时提醒)' : '触发条件(今日已用达到该值时提醒)')
2734
+ : (isWait
2735
+ ? ((waitKind === 'approval' ? '授权' : '提问') + '提示内容({session} = 对话名)')
2736
+ : (isAlert ? '触发条件(余额低于该值时提醒)' : '触发条件(今日已用达到该值时提醒)'))
1917
2737
  card.appendChild(secCond)
1918
2738
  // 触发条件控件(启用 + 阈值):cost 模式没有触发条件,这两个控件不创建
2739
+ // v761:wait 模式同样没有"触发条件"(挂起音效/开关/自动关闭/冒泡都在「全局音效设置」面板那一区里)
1919
2740
  var chk = null
1920
2741
  var numInp = null
1921
- if (!isCost) {
2742
+ if (!isCost && !isWait) {
1922
2743
  chk = document.createElement('input')
1923
2744
  chk.type = 'checkbox'
1924
2745
  chk.className = 'dshwv-check'
@@ -1949,32 +2770,36 @@ function usageAlertBudgetEditor(key, onSave) {
1949
2770
  return s
1950
2771
  }
1951
2772
  if (isCost) {
1952
- // 自动关闭:复用主菜单原来那个秒数输入(0 = 不自动关闭);窗口内不受菜单"提示已关闭"禁用态影响
1953
- turnCostCloseInput.disabled = false
1954
- var gAcC = segCond()
1955
- gAcC.appendChild(qLabel('自动关闭'))
1956
- gAcC.appendChild(turnCostCloseInput)
1957
- var lSecC = qLabel('秒(0=不自动关闭)')
1958
- lSecC.style.opacity = '.75'
1959
- lSecC.style.fontSize = '11px'
1960
- gAcC.appendChild(lSecC)
1961
- condBox.appendChild(gAcC)
1962
- // 任务结束音效(从主菜单搬进来;窗口期间的改动先缓冲,点「保存」才落盘)
1963
- var cBrk = document.createElement('span')
1964
- cBrk.style.flex = '1 0 100%'
1965
- cBrk.style.height = '0'
1966
- cBrk.style.margin = '0'
1967
- condBox.appendChild(cBrk)
1968
- var gTe = segCond()
1969
- gTe.appendChild(qLabel('任务结束音效'))
1970
- gTe.appendChild(taskEndToggle)
1971
- gTe.appendChild(taskEndRowHost)
1972
- condBox.appendChild(gTe)
1973
- var teHint = qLabel('每轮回复完成时播放;这两项点「保存」后才生效')
1974
- teHint.style.opacity = '.75'
1975
- teHint.style.fontSize = '11px'
1976
- teHint.style.flex = '1 0 100%'
1977
- condBox.appendChild(teHint)
2773
+ // v763(用户要求):本窗口**不再挂这三行** —— 「自动关闭 [秒数] 秒(0=不自动关闭)」、
2774
+ // 「任务结束音效 [音效名 ▾]」(含左边的 [✓])以及下面的小字提示
2775
+ // 「每轮回复完成时播放;这两项点「保存」后才生效」。它们现在都在「提示与音效设置」面板的
2776
+ // 「每轮消耗提示」区里(那一区才是这两项的正式入口),留在本窗口属于重复。
2777
+ // ⚠️ 这里**只是不 append**,元素的创建与相关缓冲/快照/取消还原一律原样保留:
2778
+ // · turnCostCloseInput:仍创建(见文件上方),仍走 turnCostCloseDefer / -Snap 那套
2779
+ // 「窗口内只改内存 → 点「保存」才 setTurnCostClose 落 .dsh-size.json → 取消按快照还原」;
2780
+ // · taskEndSel:仍创建,并**继续留在他原本的宿主 taskEndRowHost 里**(那个宿主本来就不插入
2781
+ // 文档;dshwCustSel 只要求 select 有父节点,所以自绘下拉的 refresh/fill/sync 照常工作,
2782
+ // 不依赖是否挂进文档);
2783
+ // · taskEndToggle(「每轮回复完成时播放」的 [✓]):仍创建、仍被 applyTaskEndLocal 同步,
2784
+ // 只是本窗口不再展示(面板 ② 区的 teSoundChk 是它的替代入口)。
2785
+ // 保存/取消语义不变:本窗口的「保存」照样提交缓冲里的 taskEnd 与秒数,「取消」照样按快照还原。
2786
+ turnCostCloseInput.disabled = false // 保留原有的「窗口内不受菜单禁用态影响」语义(取消路径也看它)
2787
+ } else if (isWait) {
2788
+ // v761(#161 C5):等待提示没有触发条件。这里只放说明 —— 音效 / 音量 / 自动关闭 / 冒泡开关
2789
+ // 都在「全局音效设置」面板里那一区,本窗口只管内容(改动仍遵循"保存才提交")。
2790
+ var wHint = qLabel(waitKind === 'approval'
2791
+ ? '授权挂起时显示这条提示(开关与时长在「授权音效」那一区设置)'
2792
+ : '提问挂起时显示这条提示(开关与时长在「提问音效」那一区设置)')
2793
+ wHint.style.fontSize = '11px'
2794
+ wHint.style.color = '#203170'
2795
+ wHint.style.opacity = '.85'
2796
+ condBox.appendChild(wHint)
2797
+ condBox.appendChild(document.createElement('br'))
2798
+ var wHint2 = qLabel('{session} 会替换为当前对话名,超长自动截断(读不到时显示「当前对话」)')
2799
+ wHint2.style.fontSize = '11px'
2800
+ wHint2.style.color = '#203170'
2801
+ wHint2.style.opacity = '.75'
2802
+ condBox.appendChild(wHint2)
1978
2803
  } else {
1979
2804
  // 启用提醒: 口 启用提醒 · 阈值: 余额 ≤ [数值] 元时提醒
1980
2805
  var gOn = segCond()
@@ -2018,7 +2843,8 @@ function usageAlertBudgetEditor(key, onSave) {
2018
2843
  gSec.appendChild(lSec)
2019
2844
  condBox.appendChild(gSec)
2020
2845
  }
2021
- card.appendChild(condBox)
2846
+ // v761:wait 模式 condBox 里只有说明(上面那一支已直接挂进 card),不重复挂一个空的盒子
2847
+ if (!isWait) card.appendChild(condBox)
2022
2848
  // —— 以下完全复用 W2 组件(仅换宿主元素与目标步骤) ——
2023
2849
  var secPal = document.createElement('div')
2024
2850
  secPal.className = 'dshwv-bubsec dshwv-bubsec-first dshwv-bubsec-withq'
@@ -2033,7 +2859,9 @@ function usageAlertBudgetEditor(key, onSave) {
2033
2859
  '<div>· 拖 ⠿ 手柄 = 整行排序</div>' +
2034
2860
  (isCost
2035
2861
  ? '<div style="margin-top:4px">{cost} 在触发时替换为本轮消耗金额(默认内容请保持 {cost})</div>'
2036
- : '<div style="margin-top:4px">{below} / {amount} 在触发时替换为实际数值</div>') +
2862
+ : (isWait
2863
+ ? '<div style="margin-top:4px">{session} 在挂起时替换为当前对话名(读不到显示「当前对话」,超长按 12 字符截断并追加 ...)</div>'
2864
+ : '<div style="margin-top:4px">{below} / {amount} 在触发时替换为实际数值</div>')) +
2037
2865
  '<div style="margin-top:4px;opacity:.75">手机端:长按约 0.4 秒进入拖动</div>'
2038
2866
  ), secPal.firstChild)
2039
2867
  card.appendChild(secPal)
@@ -2042,7 +2870,9 @@ function usageAlertBudgetEditor(key, onSave) {
2042
2870
  card.appendChild(bubblePalEl)
2043
2871
  var secPv = document.createElement('div')
2044
2872
  secPv.className = 'dshwv-bubsec'
2045
- secPv.textContent = isCost ? '提示内容(每轮消耗)' : '提醒内容'
2873
+ secPv.textContent = isCost
2874
+ ? '提示内容(每轮消耗)'
2875
+ : (isWait ? ('提示内容(' + (waitKind === 'approval' ? '授权' : '提问') + ')') : '提醒内容')
2046
2876
  card.appendChild(secPv)
2047
2877
  bubblePvEl = document.createElement('div')
2048
2878
  bubblePvEl.className = 'dshwv-bubpvbox'
@@ -2083,7 +2913,10 @@ function usageAlertBudgetEditor(key, onSave) {
2083
2913
  var snap = taskEndDeferSnap || { on: false, sel: '' }
2084
2914
  usageSet = usageSet || {}
2085
2915
  usageSet.taskEnd = { on: !!snap.on, sel: String(snap.sel || '') }
2086
- applyTaskEndLocal(usageSet.taskEnd.on, usageSet.taskEnd.sel)
2916
+ // v763:v762 起 taskEndSel 不再挂进任何窗口(它留在不插入文档的宿主 taskEndRowHost 里),
2917
+ // 所以这里对它的同步改成与下一行同款的 try 包裹 —— 万一自绘下拉在未挂载状态下出问题,
2918
+ // 也不能把后面「秒数还原」和 dshwBodyDetach(mask)(关窗)一起带崩(本文件踩过静默 TypeError)。
2919
+ try { applyTaskEndLocal(usageSet.taskEnd.on, usageSet.taskEnd.sel) } catch (err) {}
2087
2920
  try { fillTaskEndOptions(usageSet.taskEnd) } catch (err) {}
2088
2921
  // 秒数:窗口内没落盘,还原内存与输入框即可
2089
2922
  turnCostCloseMs = turnCostCloseDeferSnap
@@ -2095,6 +2928,14 @@ function usageAlertBudgetEditor(key, onSave) {
2095
2928
  turnCostCloseDefer = false
2096
2929
  turnCostCloseInput.disabled = !turnCostOn // 回到菜单态的禁用逻辑
2097
2930
  }
2931
+ // v761(#161 C5):wait 模式收尾 —— 没点「保存」就把缓冲还原(取消语义)。
2932
+ // 窗口期间用户拖入/删除的模块只改 step.modules(缓冲),内存里的 events.<kind>.lines 一直没动,
2933
+ // 所以这里只需把缓冲还原回打开时的快照,再把预览重画一次。
2934
+ if (isWait && !waitCommitted) {
2935
+ var wsnap = Array.isArray(waitSnapLines) ? waitSnapLines : []
2936
+ step.modules = JSON.parse(JSON.stringify(wsnap))
2937
+ try { renderBubblePv() } catch (err) {}
2938
+ }
2098
2939
  dshwBodyDetach(mask) // v744:走 detach 注销登记,否则 DOM 守护会把这个刚关掉的编辑器补挂回来
2099
2940
  bubbleEditItems = bkEditItems
2100
2941
  bubbleEditorSnap = bkEditorSnap
@@ -2122,9 +2963,16 @@ function usageAlertBudgetEditor(key, onSave) {
2122
2963
  resBtn.type = 'button'
2123
2964
  resBtn.className = 'dshwv-bubbtn dshwv-bubbtn-no'
2124
2965
  resBtn.textContent = '恢复默认'
2125
- resBtn.title = isCost ? '恢复为默认提示内容(自动关闭与任务结束音效保持不变)' : '恢复为默认提醒内容(触发条件保持不变)'
2966
+ resBtn.title = isCost
2967
+ ? '恢复为默认提示内容(自动关闭与任务结束音效保持不变)'
2968
+ : (isWait
2969
+ ? ('恢复为默认' + (waitKind === 'approval' ? '授权' : '提问') + '提示内容(音效/音量/自动关闭/冒泡开关保持不变)')
2970
+ : '恢复为默认提醒内容(触发条件保持不变)')
2126
2971
  resBtn.addEventListener('click', function () {
2127
- step.modules = JSON.parse(JSON.stringify(isCost ? usageTurnCostDefaultLines() : usageRemindDefaultLines(isAlert)))
2972
+ // v761:wait 模式的默认模板 = 宿主 waitDefaultLines() 的逐字段一致版
2973
+ step.modules = JSON.parse(JSON.stringify(isCost
2974
+ ? usageTurnCostDefaultLines()
2975
+ : (isWait ? usageWaitDefaultLines(waitKind) : usageRemindDefaultLines(isAlert))))
2128
2976
  renderBubblePv()
2129
2977
  })
2130
2978
  btns.appendChild(resBtn)
@@ -2153,6 +3001,26 @@ function usageAlertBudgetEditor(key, onSave) {
2153
3001
  if (onSave) onSave(oCost)
2154
3002
  return
2155
3003
  }
3004
+ // v761(#161 C5):等待提示内容保存 —— 落在 usageSet.events.<kind>.lines(宿主 waitQuestion.content /
3005
+ // waitApproval.content 的那套键),**只**发 { events: { <kind>: { lines } } },
3006
+ // 不碰 alert/budget/turnCost 的任何键(宿主那侧是逐键合并,其余字段原样保留)。
3007
+ if (isWait) {
3008
+ waitCommitted = true
3009
+ usageSet = usageSet || {}
3010
+ usageSet.events = usageSet.events || {}
3011
+ var prevEv = usageSet.events[waitKind] || {}
3012
+ var evNext = {}
3013
+ try { for (var pk in prevEv) evNext[pk] = prevEv[pk] } catch (err) {}
3014
+ evNext.lines = lines
3015
+ usageSet.events[waitKind] = evNext
3016
+ var patchEv = {}
3017
+ patchEv[waitKind] = { lines: lines }
3018
+ saveUsageSettings({ events: patchEv })
3019
+ var oWait = { lines: lines }
3020
+ cleanup()
3021
+ if (onSave) onSave(oWait)
3022
+ return
3023
+ }
2156
3024
  var o = { on: chk.checked, lines: lines, autoClose: acChk.checked, ttlSec: Math.max(0, Number(secInp.value) || 0) }
2157
3025
  if (isAlert) o.below = Math.max(0, Number(numInp.value) || 0)
2158
3026
  else o.amount = Math.max(0, Number(numInp.value) || 0)
@@ -2182,17 +3050,20 @@ function usageAlertBudgetEditor(key, onSave) {
2182
3050
  return el
2183
3051
  }
2184
3052
  try { if (qeditEl) qeditEl.style.zIndex = '31000' } catch (err) {}
2185
- // 包装 renderBubblePv:列表/预览刷新后,鲸鱼预览里的占位符用实际数值实时替换
2186
- // ({below} 余额阈值 / {amount} 预算阈值 / {cost} 本轮消耗金额)
3053
+ // 包装 renderBubblePv:列表/预览后,鲸鱼预览里的占位符用实际值实时替换
3054
+ // ({below} 余额阈值 / {amount} 预算阈值 / {cost} 本轮消耗金额 / {session} 当前对话名)
2187
3055
  var renderPvSuper = bkRenderPv
2188
3056
  renderBubblePv = function () {
2189
3057
  try { renderPvSuper() } catch (err) {}
2190
3058
  try {
2191
3059
  var it = bubbleEditTarget()
2192
3060
  var below = (isAlert && numInp) ? Math.max(0, Number(numInp.value) || 0) : null
2193
- var amount = (!isAlert && !isCost && numInp) ? Math.max(0, Number(numInp.value) || 0) : null
3061
+ var amount = (!isAlert && !isCost && !isWait && numInp) ? Math.max(0, Number(numInp.value) || 0) : null
2194
3062
  var cost = isCost ? '0.00' : null // 预览用示例金额(与 usageCostValue 的格式一致)
2195
- if (it && Array.isArray(it.modules) && bubblePvPrevEl) bubblePreviewInto(bubblePvPrevEl, usageAlertModsResolved(it.modules, below, amount, cost))
3063
+ // v761(#161 C5):{session} 要在这里就换成对话名(读不到显示「当前对话」,超长按 12 字符截断),
3064
+ // 这样编辑器预览与真实泡泡看到的完全一致,而不是原样显示 `{session}`。
3065
+ var sess = isWait ? soundSessionLabel() : null
3066
+ if (it && Array.isArray(it.modules) && bubblePvPrevEl) bubblePreviewInto(bubblePvPrevEl, usageAlertModsResolved(it.modules, below, amount, cost, sess))
2196
3067
  } catch (err) {}
2197
3068
  }
2198
3069
  if (numInp) {
@@ -2205,7 +3076,9 @@ function usageAlertBudgetEditor(key, onSave) {
2205
3076
  taskEndDefer = true
2206
3077
  var teCur = (usageSet && usageSet.taskEnd) || { on: false, sel: '' }
2207
3078
  taskEndDeferSnap = JSON.parse(JSON.stringify({ on: !!teCur.on, sel: String(teCur.sel || '') }))
2208
- applyTaskEndLocal(!!teCur.on, String(teCur.sel || ''))
3079
+ // v763:taskEndSel 现在不挂进本窗口了(留在不插入文档的宿主里)⇒ 这两处同步都包 try,
3080
+ // 免得自绘下拉在未挂载状态下万一抛错,把整个窗口的渲染(mask.appendChild 等)一起带崩。
3081
+ try { applyTaskEndLocal(!!teCur.on, String(teCur.sel || '')) } catch (err) {}
2209
3082
  try { fillTaskEndOptions(teCur) } catch (err) {}
2210
3083
  turnCostCloseDefer = true
2211
3084
  turnCostCloseDeferSnap = turnCostCloseMs
@@ -3763,6 +4636,100 @@ function openUsageRecordsWindow() {
3763
4636
  .catch(function () { usageMoreCard.innerHTML = '<div style="padding:10px;color:#203170">加载失败</div>' })
3764
4637
  }
3765
4638
  function closeUsageRecordsWindow() { usageMoreMask.style.display = 'none' }
4639
+ // ===== v767:通用「入口行 + 可折叠体」组件(提示与音效设置面板 / 资源管理窗口共用)=====
4640
+ // 入口行常驻:可选 [✓] 开关 + 区名 + 当前值摘要 + ▸/▾;点整行才展开它下面的折叠体,可同时展开多个。
4641
+ // 折叠用 max-height 过渡(与「自定义提示」窗口里的 advBox 同一套路),展开动画结束后置 'none' —— 之后
4642
+ // 窗口缩放换行也不会被截断;flexShrink:0 是坑 14 的规矩(flex 容器里的折叠框必须给)。
4643
+ // ⚠️ 折叠体里的控件**照旧全部创建**(只是藏起来):懒创建会让「恢复默认」这类逐个回填的逻辑静默失效。
4644
+ // instant = true 时不做过渡、直接到位(资源窗口重渲染时用它保持用户已展开的区,别闪动画)。
4645
+ function collapseSet(body, open, instant) {
4646
+ try {
4647
+ var arrow = body.__dshwArrow
4648
+ if (open) {
4649
+ body.setAttribute('data-open', '1')
4650
+ body.style.maxHeight = instant ? 'none' : (body.scrollHeight + 'px')
4651
+ body.style.opacity = '1'
4652
+ if (arrow) arrow.textContent = '▾'
4653
+ if (!instant) {
4654
+ setTimeout(function () { try { if (body.getAttribute('data-open') === '1') body.style.maxHeight = 'none' } catch (err) {} }, 260)
4655
+ }
4656
+ } else {
4657
+ body.setAttribute('data-open', '0')
4658
+ body.style.maxHeight = body.scrollHeight + 'px' // 先给起始值,才收得回去
4659
+ if (!instant) { try { void body.offsetHeight } catch (err) {} }
4660
+ body.style.maxHeight = '0px'
4661
+ body.style.opacity = '0'
4662
+ if (arrow) arrow.textContent = '▸'
4663
+ }
4664
+ } catch (err) {}
4665
+ }
4666
+ // 建一个入口行 + 它下面的折叠体,都挂进 host;返回 { row, body, summary, arrow, chk }
4667
+ // opts: { checked, onToggle, first, summary, titleTail, onRowClick(willOpen) }
4668
+ function dshwvFoldEntry(host, labelText, opts) {
4669
+ opts = opts || {}
4670
+ var rowEl = document.createElement('div')
4671
+ rowEl.className = 'dshwv-menu-row' // 与主菜单/设置面板入口行同款排版
4672
+ rowEl.style.cursor = 'pointer'
4673
+ rowEl.style.margin = opts.first ? '12px 0 2px' : '10px 0 2px'
4674
+ rowEl.style.padding = '6px 6px'
4675
+ rowEl.style.borderRadius = '6px'
4676
+ rowEl.style.background = 'rgba(32,49,112,.05)' // 常驻淡底:与折叠体里的内容行区分开
4677
+ rowEl.title = '点这一行展开 / 收起「' + labelText + '」' + String(opts.titleTail || '')
4678
+ var ck = null
4679
+ if (typeof opts.onToggle === 'function') {
4680
+ ck = document.createElement('input')
4681
+ ck.type = 'checkbox'
4682
+ ck.className = 'dshwv-check'
4683
+ ck.checked = !!opts.checked
4684
+ ck.title = labelText + '开关(只管开不开;下面的设置项不受影响)'
4685
+ // 点开关**不**展开/收起(否则想关掉该事件时会顺手把区拉开)
4686
+ ck.addEventListener('click', function (e) { try { e.stopPropagation() } catch (err) {} })
4687
+ ck.addEventListener('change', function () { try { opts.onToggle(!!ck.checked) } catch (err) {} })
4688
+ rowEl.appendChild(ck)
4689
+ }
4690
+ var tx = document.createElement('span')
4691
+ tx.textContent = labelText
4692
+ rowEl.appendChild(tx)
4693
+ var sp = document.createElement('span') // 撑开,把摘要与箭头推到右侧
4694
+ sp.style.flex = '1'
4695
+ rowEl.appendChild(sp)
4696
+ var sum = document.createElement('span')
4697
+ sum.style.fontSize = '11px'
4698
+ sum.style.color = '#203170'
4699
+ sum.style.opacity = '.7'
4700
+ sum.style.whiteSpace = 'nowrap'
4701
+ sum.style.overflow = 'hidden'
4702
+ sum.style.textOverflow = 'ellipsis'
4703
+ sum.style.maxWidth = '46%'
4704
+ sum.textContent = String(opts.summary == null ? '' : opts.summary)
4705
+ rowEl.appendChild(sum)
4706
+ var arrow = document.createElement('span')
4707
+ arrow.textContent = '▸'
4708
+ arrow.style.opacity = '.6'
4709
+ arrow.style.marginLeft = '2px'
4710
+ rowEl.appendChild(arrow)
4711
+ host.appendChild(rowEl)
4712
+ var body = document.createElement('div')
4713
+ body.style.overflow = 'hidden'
4714
+ body.style.maxHeight = '0px'
4715
+ body.style.opacity = '0'
4716
+ body.style.transition = 'max-height .24s ease, opacity .18s ease'
4717
+ body.style.flexShrink = '0'
4718
+ body.style.paddingLeft = '10px' // 轻微缩进:看得出下面这些行属于上面那个入口
4719
+ body.setAttribute('data-open', '0')
4720
+ body.__dshwArrow = arrow
4721
+ host.appendChild(body)
4722
+ rowEl.addEventListener('click', function () {
4723
+ var willOpen = body.getAttribute('data-open') !== '1'
4724
+ // onRowClick 先跑:调用方要在"切换"之前收掉自己的浮层 / 停掉试听(见坑 53)
4725
+ try { if (typeof opts.onRowClick === 'function') opts.onRowClick(willOpen) } catch (err) {}
4726
+ collapseSet(body, willOpen)
4727
+ })
4728
+ // 悬停反馈:本项目约定「只用行内样式、不动全局 CSS」,所以用两个监听器代替 :hover
4729
+ rowEl.addEventListener('mouseenter', function () { try { rowEl.style.background = 'rgba(32,49,112,.09)' } catch (err) {} })
4730
+ rowEl.addEventListener('mouseleave', function () { try { rowEl.style.background = 'rgba(32,49,112,.05)' } catch (err) {} })
4731
+ return { row: rowEl, body: body, summary: sum, arrow: arrow, chk: ck }
4732
+ }
3766
4733
  // —— 资源管理窗口:集中查看/删除已导入插件的图片与音频 ——
3767
4734
  var resMaskEl = null
3768
4735
  var resCardEl = null
@@ -3785,9 +4752,7 @@ function resMaskOpen() {
3785
4752
  function resManagerClose() {
3786
4753
  try { if (resMaskEl) resMaskEl.style.display = 'none' } catch (err) {}
3787
4754
  // 窗口消失时结束正在预览的音频,避免后台继续响
3788
- try {
3789
- if (resAudEl) { resAudEl.pause(); resAudEl = null }
3790
- } catch (err) {}
4755
+ try { resStopPreview() } catch (err) {}
3791
4756
  }
3792
4757
  function openResManager() { resMaskOpen() }
3793
4758
  function resMkTag(text, built) {
@@ -3881,12 +4846,15 @@ function resMkBtn(cls, label, disabled, fn) {
3881
4846
  return b
3882
4847
  }
3883
4848
  var resAudEl = null // 资源窗口音频片段试听元素(复用,避免并发)
4849
+ function resStopPreview() {
4850
+ try { if (resAudEl) { resAudEl.pause(); resAudEl = null } } catch (err) {}
4851
+ }
3884
4852
  function resPlayFragment(fid) {
3885
4853
  try {
3886
4854
  if (!fid) return
3887
4855
  if (resAudEl) { try { resAudEl.pause() } catch (err) {} resAudEl = null }
3888
4856
  var a = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(fid))
3889
- try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
4857
+ try { a.volume = soundVolClamped(0.9) } catch (err) {}
3890
4858
  a.onended = function () { resAudEl = null }
3891
4859
  resAudEl = a
3892
4860
  a.play().catch(function () { resAudEl = null })
@@ -4035,43 +5003,58 @@ function resDelAudioFrag(id) {
4035
5003
  } catch (err) {}
4036
5004
  })
4037
5005
  }
5006
+ // v767:资源窗口的两个分区(图片 / 音频)改成与「提示与音效设置」同一套折叠入口(`dshwvFoldEntry`)。
5007
+ // 折叠态**跨重渲染保留** —— 删一张图 / 导一段音频后本窗口会整窗重渲染,不能让用户刚展开的区自己收回去。
5008
+ var resFoldOpen = { img: false, audio: false }
5009
+ function resFoldEntry(wrap, key, labelText, summaryText, first) {
5010
+ var e = dshwvFoldEntry(wrap, labelText, {
5011
+ first: first,
5012
+ summary: summaryText,
5013
+ titleTail: '的资源',
5014
+ onRowClick: function (willOpen) {
5015
+ resFoldOpen[key] = !!willOpen
5016
+ resStopPreview() // 收起/展开时停掉正在试听的那一段(与设置面板同一约定)
5017
+ },
5018
+ })
5019
+ if (resFoldOpen[key]) collapseSet(e.body, true, true) // 直接到位不播动画:重渲染时保持用户已展开的区
5020
+ return e
5021
+ }
4038
5022
  function resRenderData(wrap, roles, bubbleImgs, audio) {
4039
5023
  try {
4040
5024
  wrap.innerHTML = ''
4041
- // —— 图片组 ——
4042
- var catImg = document.createElement('div')
4043
- catImg.className = 'dshwv-rescat'
4044
- catImg.textContent = '图片'
4045
- wrap.appendChild(catImg)
5025
+ var groups = (audio && Array.isArray(audio.groups)) ? audio.groups : []
5026
+ var fragsAll = (audio && Array.isArray(audio.fragments)) ? audio.fragments : []
5027
+ var frags = fragsAll.filter(function (f) { return f && !f.preset })
5028
+ // —— 图片(入口行 + 折叠体;摘要 = 数量,不展开也知道有多少)——
5029
+ var imgEnt = resFoldEntry(wrap, 'img', '图片', '角色 ' + roles.length + ' · 泡泡图 ' + bubbleImgs.length, true)
4046
5030
  var anyImg = false
4047
5031
  // 角色图(默认角色只读展示)
4048
5032
  roles.forEach(function (r) {
4049
5033
  anyImg = true
4050
5034
  var isDefault = r.id === 'default'
4051
5035
  var tag = resMkTag(isDefault ? '默认角色' : '自定义角色', isDefault)
4052
- wrap.appendChild(resImgRow(r.url, r.name || r.id, '', [tag, resMkDel('删除', isDefault, function () { resDelRole(r.id) })]))
5036
+ imgEnt.body.appendChild(resImgRow(r.url, r.name || r.id, '', [tag, resMkDel('删除', isDefault, function () { resDelRole(r.id) })]))
4053
5037
  })
4054
5038
  bubbleImgs.forEach(function (im) {
4055
5039
  anyImg = true
4056
5040
  var tag = resMkTag(im.builtin ? '内置图' : '泡泡图', !!im.builtin)
4057
- wrap.appendChild(resImgRow('/dsh-whale/bubble-img.png?id=' + encodeURIComponent(im.id), im.name || im.id, '', [tag, resMkDel('删除', !!im.builtin, function () { resDelBubbleImg(im.id) })]))
5041
+ imgEnt.body.appendChild(resImgRow('/dsh-whale/bubble-img.png?id=' + encodeURIComponent(im.id), im.name || im.id, '', [tag, resMkDel('删除', !!im.builtin, function () { resDelBubbleImg(im.id) })]))
4058
5042
  })
4059
5043
  if (!anyImg) {
4060
5044
  var empty = document.createElement('div')
4061
5045
  empty.className = 'dshwv-resempty'
4062
5046
  empty.textContent = '暂无自定义图片(角色/泡泡图)'
4063
- wrap.appendChild(empty)
5047
+ imgEnt.body.appendChild(empty)
4064
5048
  }
4065
- // —— 音频组 ——
4066
- var catAu = document.createElement('div')
4067
- catAu.className = 'dshwv-rescat'
4068
- catAu.textContent = '音频'
5049
+ // —— 音频(入口行 + 折叠体;「导入片段」放进折叠体第一行,免得入口行太挤)——
5050
+ var auEnt = resFoldEntry(wrap, 'audio', '音频', '音效组 ' + groups.length + ' · 片段 ' + frags.length)
5051
+ var impRow = document.createElement('div')
5052
+ impRow.className = 'dshwv-resrow'
4069
5053
  var catAuBtn = resMkBtn('dshwv-resimp', '导入片段', false, resImportAudioFragment)
4070
5054
  catAuBtn.title = '导入并裁剪一段音频到片段库(可被音效组引用)'
4071
- catAu.appendChild(catAuBtn)
4072
- wrap.appendChild(catAu)
5055
+ impRow.appendChild(catAuBtn)
5056
+ auEnt.body.appendChild(impRow)
4073
5057
  var anyAu = false
4074
- var groups = (audio && Array.isArray(audio.groups)) ? audio.groups : []
4075
5058
  groups.forEach(function (g) {
4076
5059
  anyAu = true
4077
5060
  var preset = !!g.preset
@@ -4079,22 +5062,20 @@ function resRenderData(wrap, roles, bubbleImgs, audio) {
4079
5062
  var meta = ''
4080
5063
  if (!preset && g.press && g.release) meta = '按压:' + g.press + ' 松开:' + g.release
4081
5064
  // 音效组不提供播放(按整组点按发声,需与点按动作绑定),只展示/删除
4082
- wrap.appendChild(resIconRow(preset ? '🎧' : '🎵', g.name || g.id, meta, [tag, resMkDel('删除', preset, function () { resDelAudioGroup(g.id) })]))
5065
+ auEnt.body.appendChild(resIconRow(preset ? '🎧' : '🎵', g.name || g.id, meta, [tag, resMkDel('删除', preset, function () { resDelAudioGroup(g.id) })]))
4083
5066
  })
4084
- var frags = (audio && Array.isArray(audio.fragments)) ? audio.fragments : []
4085
5067
  frags.forEach(function (f) {
4086
- if (f.preset) return
4087
5068
  anyAu = true
4088
5069
  var tag = resMkTag('音频片段', false)
4089
5070
  var play = resMkBtn('dshwv-resplay', '播放', false, function () { resPlayFragment(f.id) })
4090
5071
  play.title = '试听该音频片段'
4091
- wrap.appendChild(resIconRow('🎶', f.name || f.id, '', [tag, play, resMkDel('删除', false, function () { resDelAudioFrag(f.id) })]))
5072
+ auEnt.body.appendChild(resIconRow('🎶', f.name || f.id, '', [tag, play, resMkDel('删除', false, function () { resDelAudioFrag(f.id) })]))
4092
5073
  })
4093
5074
  if (!anyAu) {
4094
5075
  var empty2 = document.createElement('div')
4095
5076
  empty2.className = 'dshwv-resempty'
4096
5077
  empty2.textContent = '暂无自定义音频(片段/音效组)'
4097
- wrap.appendChild(empty2)
5078
+ auEnt.body.appendChild(empty2)
4098
5079
  }
4099
5080
  } catch (err) {}
4100
5081
  }
@@ -4163,12 +5144,32 @@ function usageRemindLinesOf(cfg, isAlert) {
4163
5144
  if (Array.isArray(cfg.lines) && cfg.lines.length) return cfg.lines
4164
5145
  return usageRemindDefaultLines(isAlert)
4165
5146
  }
5147
+ // —— 等待交互(提问 / 授权)提示内容(v761 / #161 C5)——
5148
+ // 默认内容必须与宿主 whale-balance.mjs 的 waitDefaultLines(kind) **逐字段一致**
5149
+ // (宿主 = 新用户默认;这里 = 编辑器「恢复默认」的目标,改一处必须同步另一处)。
5150
+ function usageWaitDefaultLines(kind) {
5151
+ // v774:出厂默认内容 = 作者当前实际使用的那一套(对话名模块「[ 对话名 ]」+ 一句提示语,都带跑马灯配色)
5152
+ var isApproval = (kind === 'approval')
5153
+ return [
5154
+ { type: 'session', size: 10, bold: true, tpl: '[ {session} ]', len: 5, rgb: 'champagne', color: '' },
5155
+ { type: 'text', text: (isApproval ? '正在等待老大授权' : '正在等待老大回答'), size: 7, bold: true, bgRgb: '', bg: '', rgb: 'indigo', color: '' },
5156
+ ]
5157
+ }
5158
+ // 等待提示内容读取:events.<kind>.lines[] 优先;缺失/空 → 默认模板
5159
+ function usageWaitLinesOf(cfg, kind) {
5160
+ cfg = cfg || {}
5161
+ if (Array.isArray(cfg.lines) && cfg.lines.length) return cfg.lines
5162
+ return usageWaitDefaultLines(kind)
5163
+ }
4166
5164
  // 占位替换:{below} 余额预警阈值 / {amount} 今日预算阈值 / {cost} 本轮消耗金额
4167
- function usageFillText(txt, below, amount, cost) {
5165
+ function usageFillText(txt, below, amount, cost, session) {
4168
5166
  return String(txt || '')
4169
5167
  .replace(/\{below\}/g, below != null ? String(below) : '')
4170
5168
  .replace(/\{amount\}/g, amount != null ? String(amount) : '')
4171
5169
  .replace(/\{cost\}/g, cost != null ? String(cost) : '')
5170
+ // v761:{session} = 当前对话名(wait.json 轮询回填;读不到显示「当前对话」;超长截断)
5171
+ .replace(/\{session\}/g, session != null ? String(session) : soundSessionLabel())
5172
+
4172
5173
  }
4173
5174
  function usageLineFontPx(level) {
4174
5175
  var n = Math.max(1, Math.min(50, Math.round(Number(level) || 7)))
@@ -4317,14 +5318,18 @@ function runApiModelAlerts() {
4317
5318
  } catch (err) {}
4318
5319
  }
4319
5320
  // 占位替换后的展示模块列表(深拷贝,不改配置)
4320
- function usageAlertModsResolved(mods, below, amount, cost) {
5321
+ // v761(#161 C5):第 5 个参数 session 原样透传给 usageFillText —— 等待交互的提示内容里有
5322
+ // `{session}`,要在这里(渲染/预览时)就换成对话名,否则真实泡泡与编辑器预览都会**原样显示 `{session}`**。
5323
+ // 不传(既有 alert / budget / cost 三处调用)时 usageFillText 会自己回落 soundSessionLabel(),
5324
+ // 因此这三处的行为逐字未变(它们的 lines 里本来就没有 {session})。
5325
+ function usageAlertModsResolved(mods, below, amount, cost, session) {
4321
5326
  var out = []
4322
5327
  try {
4323
5328
  for (var i = 0; i < mods.length; i++) {
4324
5329
  var m0 = mods[i] || {}
4325
5330
  var cp = JSON.parse(JSON.stringify(m0))
4326
5331
  var raw = String(m0.text != null ? m0.text : '')
4327
- cp.text = raw.length ? usageFillText(raw, below, amount, cost) : raw
5332
+ cp.text = raw.length ? usageFillText(raw, below, amount, cost, session) : raw
4328
5333
  out.push(cp)
4329
5334
  }
4330
5335
  } catch (err) {}
@@ -4381,6 +5386,29 @@ function usagePopupCard(title, content, below, amount) {
4381
5386
  }
4382
5387
  var USAGE_PALETTE = ['#203170', '#e0433f', '#2fa24c', '#b060c8', '#e89a2e', '#3aa6c8', '#d06a8a', '#7a8b2f', '#6a6ad0', '#c84a8a']
4383
5388
  // 统计一组天数里的模型合计(输入 days:[{models:[{model,cost}]}])
5389
+ // v776(issue #163):换/删 API key 后,宿主按密钥指纹分本记账(换 key = 换一本)。
5390
+ // 界面侧给一句说明:否则旧账本的日期看起来像"丢了"(数据其实一直在 .dshw-usage.json 里)。
5391
+ // 宿主在 daySummary() 上补了 bookCount / historyHint / source='balance-observed-other-account',
5392
+ // 这里只负责把话讲清楚 —— **不做相加**(无法判断两次是不是同一个账户)。
5393
+ function usageMultiBookNote(allDays, today) {
5394
+ try {
5395
+ var maxCount = 0, maxHint = '', otherDays = 0, todayCount = 0
5396
+ for (var i = 0; i < (allDays || []).length; i++) {
5397
+ var dx = allDays[i] || {}
5398
+ var n = Number(dx.bookCount) || 0
5399
+ if (n > maxCount) { maxCount = n; maxHint = String(dx.historyHint || '') }
5400
+ if (dx.source === 'balance-observed-other-account') otherDays++
5401
+ }
5402
+ todayCount = Number(today && today.bookCount) || 0
5403
+ if (maxCount <= 1 && !otherDays) return ''
5404
+ var parts = []
5405
+ if (maxCount > 1 || todayCount > 1) {
5406
+ parts.push('检测到 ' + Math.max(maxCount, todayCount) + ' 个记账本(换过 API key):' + (maxHint || '同一天在多个本里都有观测'))
5407
+ }
5408
+ if (otherDays > 0) parts.push('另有 ' + otherDays + ' 天来自历史记账本,已按「已观测消费 · 历史账户」显示,未与当前账户相加')
5409
+ return parts.join(';')
5410
+ } catch (err) { return '' }
5411
+ }
4384
5412
  function usageAggModels(daysArr) {
4385
5413
  var map = {}
4386
5414
  ;(daysArr || []).forEach(function (day) {
@@ -4704,6 +5732,15 @@ function fillUsageRecordsWindow(d) {
4704
5732
  sourceNote.className = 'dshwv-usage-hint'
4705
5733
  sourceNote.textContent = '按各日标注口径汇总;本机模型明细为估算,与账户消费覆盖范围不同。'
4706
5734
  ov.appendChild(sourceNote)
5735
+ // v776(issue #163):多记账本(换过 API key)时补一句说明,让旧账本的记录有入口可查
5736
+ var bookNote = usageMultiBookNote(allDays, d.today)
5737
+ if (bookNote) {
5738
+ var bn = document.createElement('div')
5739
+ bn.className = 'dshwv-usage-hint'
5740
+ bn.style.marginTop = '2px'
5741
+ bn.textContent = bookNote
5742
+ ov.appendChild(bn)
5743
+ }
4707
5744
  body.appendChild(ov)
4708
5745
  var detailBox = null
4709
5746
  // ② 统计图表(默认折叠,懒渲染)
@@ -4772,7 +5809,9 @@ function fillUsageRecordsWindow(d) {
4772
5809
  if ((dayV === undefined || dayV === 0) && day === todayKeyStr2 && d.today && isFinite(Number(d.today.total))) dayV = Number(d.today.total)
4773
5810
  if (dayV === undefined || dayV === null) dayV = evs.reduce(function (a, x) { return a + (Number(x.cost) || 0) }, 0)
4774
5811
  c.textContent = usageMoney(dayV, dayMeta[day] && dayMeta[day].currency)
4775
- name.title = (dayMeta[day] && dayMeta[day].label) || '本地估算'
5812
+ // v776(issue #163):悬停提示带上"历史记账本"说明(旧 key 的记账不再看着像丢了)
5813
+ var dayM = dayMeta[day] || {}
5814
+ name.title = (dayM.label || '本地估算') + (dayM.historyHint ? ' · ' + dayM.historyHint : '')
4776
5815
  row.appendChild(c)
4777
5816
  var chev = document.createElement('span')
4778
5817
  chev.className = 'dshwv-usage-chev'
@@ -6432,6 +7471,7 @@ function bubbleModuleSummary(m) {
6432
7471
  m = m || {}
6433
7472
  if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
6434
7473
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
7474
+ if (m.type === 'session') return '对话名' + (Number(m.len) > 0 ? '(保留 ' + Math.round(Number(m.len)) + ' 字)' : '(不截断)')
6435
7475
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
6436
7476
  if (m.type === 'plan') return bubblePlanModuleLabel(m)
6437
7477
  if (m.type === 'peak' || m.type === 'nextpeak') return bubblePeakModuleLabel(m)
@@ -6449,6 +7489,7 @@ function bubbleModuleListLabel(m) {
6449
7489
  if (m.type === 'random') return m.name || '随机语句' // 只显示模块名,不展示内部句子
6450
7490
  if (m.type === 'balance') return bubbleIsModelMod(m) ? ('余额·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '余额数值'
6451
7491
  if (m.type === 'today') return bubbleIsModelMod(m) ? ('今日已用·' + ((apiModelBalanceInfo(m.modelId) || {}).name || m.modelId)) : '今日已用'
7492
+ if (m.type === 'session') return '对话名' + (Number(m.len) > 0 ? '(保留 ' + Math.round(Number(m.len)) + ' 字)' : '(不截断)')
6452
7493
  if (m.type === 'quota') return '额度·' + ((apiModelById(m.modelId) || {}).name || m.modelId)
6453
7494
  if (m.type === 'plan') return bubblePlanModuleLabel(m)
6454
7495
  if (m.type === 'peak' || m.type === 'nextpeak') return bubblePeakModuleLabel(m)
@@ -7148,6 +8189,7 @@ var QC_SCHEMES = [
7148
8189
  ['macaron', '马卡龙'], ['candy', '糖果'], ['rouge', '酒红'], ['bamboo', '翠青'], ['aurora', '极光幻彩'],
7149
8190
  ['deepsea', '深海蓝调'], ['sunset', '落日熔金'], ['forest', '森林秘语'], ['champagne', '香槟鎏金'],
7150
8191
  ['lavender', '薰衣草梦境'], ['mint', '薄荷汽水'], ['lava', '岩浆熔岩'], ['galaxy', '银河星紫'], ['ink', '墨韵黑白'], ['indigo', '靛蓝夜曲'],
8192
+ ['blaze', '火红烈焰'], ['amber', '警示橙黄'],
7151
8193
  ]
7152
8194
  function qeditEnsure() {
7153
8195
  if (qeditEl) return qeditEl
@@ -7421,6 +8463,38 @@ function openQuickTextEditor(m, anchorBtn) {
7421
8463
  } catch (err) { qeditPlace({ left: 40, right: 360, top: 200, bottom: 300, width: 320 }, 320, false) }
7422
8464
  }
7423
8465
  // —— 特殊内容模块(balance/today/peak/nextpeak):悬浮编辑,内容与原先编辑窗口一致 ——
8466
+ // v769:对话名模块的「保留长度」行 —— 悬浮编辑器(qRow/qLabel)与整窗编辑器(.dshwv-audiorow/span)
8467
+ // **共用同一份实现**,只有外层行/标签的构造方式不同(各自的样式才一致)。
8468
+ // rowFactory() 造一行、labelFactory(text) 造标签;changed() 在输入时回调(悬浮窗用它做实时预览)。
8469
+ function sessionLenRowBuild(rowFactory, labelFactory, m, changed) {
8470
+ var r = rowFactory()
8471
+ r.appendChild(labelFactory('保留长度'))
8472
+ var inp = document.createElement('input')
8473
+ inp.type = 'number'
8474
+ inp.min = '0'
8475
+ inp.max = '120'
8476
+ inp.step = '1'
8477
+ inp.className = 'dshwv-winput'
8478
+ if (m.len === undefined || m.len === null) m.len = WAIT_SESSION_MAX
8479
+ inp.value = String(Math.max(0, Math.round(Number(m.len) || 0)))
8480
+ inp.title = '对话名超过这个长度时截断成「前N字...」;0 = 不截断'
8481
+ inp.addEventListener('input', function () {
8482
+ var n = Math.round(Number(inp.value) || 0)
8483
+ m.len = Math.max(0, Math.min(120, n))
8484
+ try { if (typeof changed === 'function') changed() } catch (err) {}
8485
+ })
8486
+ inp.addEventListener('change', function () {
8487
+ inp.value = String(Math.max(0, Math.round(Number(m.len) || 0)))
8488
+ })
8489
+ r.appendChild(inp)
8490
+ var tail = labelFactory('字,超出截断为 ...(0 = 不截断)')
8491
+ try {
8492
+ tail.style.opacity = '.75'
8493
+ tail.style.fontSize = '11px'
8494
+ } catch (err) {}
8495
+ r.appendChild(tail)
8496
+ return r
8497
+ }
7424
8498
  function openQuickModuleEditor(m, anchorBtn) {
7425
8499
  if (!m || bubbleIsImgMod(m) || m.type === 'random' || m.type === 'text') return
7426
8500
  if (qeditToggleClose(anchorBtn)) return // v723:同一按钮再点 = 关闭
@@ -7461,8 +8535,8 @@ function openQuickModuleEditor(m, anchorBtn) {
7461
8535
  var isModelBal = bubbleIsModelMod(m) && (m.type === 'balance' || m.type === 'today')
7462
8536
  var isModelQuota = bubbleIsModelMod(m) && m.type === 'quota'
7463
8537
  var isModelPlan = bubbleIsModelMod(m) && m.type === 'plan'
7464
- inp.placeholder = isModelPlan ? '例: {plan} 额度 · {plan_reset} 刷新时间' : (isModelQuota ? '例: 额度 {quota} · 剩 {quota_left}' : (isModelBal ? '例: {balance} 或 今日 {today}' : (m.type === 'balance' ? '例: {balance_ds}' : (m.type === 'today' ? '例: 今日已用 {expense_ds}' : (bubbleIsPeakCount(m) ? '例: 距空闲 {countdown}' : '例: 当前 {status}')))))
7465
- inp.title = '可用占位符(英文): ' + (isModelPlan ? '{plan} 额度 / {plan_left} 剩余 / {plan_reset} 刷新时间(多窗口时随「显示样式」所选窗口变化)' : (isModelQuota ? '{quota} / {quota_used} / {quota_left} / {quota_total} / {quota_reset}' : (isModelBal ? '{balance} / {today}' : (m.type === 'peak' || m.type === 'nextpeak' ? '{status} / {countdown}' : (m.type === 'balance' ? '{balance_ds}' : '{expense_ds}')))))
8538
+ inp.placeholder = isModelPlan ? '例: {plan} 额度 · {plan_reset} 刷新时间' : (isModelQuota ? '例: 额度 {quota} · 剩 {quota_left}' : (isModelBal ? '例: {balance} 或 今日 {today}' : (m.type === 'balance' ? '例: {balance_ds}' : (m.type === 'today' ? '例: 今日已用 {expense_ds}' : (m.type === 'session' ? '例: {session} 或 当前对话 {session}' : (bubbleIsPeakCount(m) ? '例: 距空闲 {countdown}' : '例: 当前 {status}'))))))
8539
+ inp.title = '可用占位符(英文): ' + (isModelPlan ? '{plan} 额度 / {plan_left} 剩余 / {plan_reset} 刷新时间(多窗口时随「显示样式」所选窗口变化)' : (isModelQuota ? '{quota} / {quota_used} / {quota_left} / {quota_total} / {quota_reset}' : (isModelBal ? '{balance} / {today}' : (m.type === 'peak' || m.type === 'nextpeak' ? '{status} / {countdown}' : (m.type === 'balance' ? '{balance_ds}' : (m.type === 'today' ? '{expense_ds}' : (m.type === 'session' ? '{session} 当前对话名(按「保留长度」截断)' : '{expense_ds}')))))))
7466
8540
  inp.addEventListener('input', function () { m.tpl = inp.value; changed() })
7467
8541
  r.appendChild(inp)
7468
8542
  var qb2 = document.createElement('button')
@@ -7476,6 +8550,8 @@ function openQuickModuleEditor(m, anchorBtn) {
7476
8550
  box.appendChild(r)
7477
8551
  }
7478
8552
  tplRow()
8553
+ // v769:对话名模块的「保留长度」(与整窗编辑器共用同一份实现;这里改了会实时刷新预览)
8554
+ if (m.type === 'session') box.appendChild(sessionLenRowBuild(qRow, qLabel, m, changed))
7479
8555
  // 订阅额度模块的「显示样式」= 选时间窗口(多窗口厂商,如 OpenCode Go 的 5h / 周 / 月)
7480
8556
  if (m.type === 'plan' && (apiPlanMultiWin(m.modelId) || apiPlanWinList(m.modelId))) {
7481
8557
  var wrow = qRow()
@@ -7661,6 +8737,8 @@ function renderBubblePal() {
7661
8737
  { key: 'today', label: '今日已用', pin: true, cb: function () { bubbleModuleAdd({ type: 'today', size: 1, tpl: '今日已用 {expense_ds}' }) } },
7662
8738
  { key: 'peak', label: '峰谷时段', pin: true, cb: function () { bubbleModuleAdd({ type: 'peak', size: 4, peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{status}' }) } },
7663
8739
  { key: 'nextpeak', label: '时段倒计时', pin: true, cb: function () { bubbleModuleAdd({ type: 'peak', size: 6, bold: true, peakStyle: 'count', peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{countdown}' }) } },
8740
+ // v768:对话名模块(内容 = 当前对话标题;「保留长度」可在模块编辑器里改)
8741
+ { key: 'session', label: '对话名', pin: true, cb: function () { bubbleModuleAdd({ type: 'session', size: 4, bold: true, tpl: '{session}', len: WAIT_SESSION_MAX }) } },
7664
8742
  { key: 'random', label: '随机语句', cb: function () { bubbleModuleAdd(bubbleCloneModule(bubbleDefaultSecondModules()[0])) } },
7665
8743
  { key: 'link', label: '超链接', cb: function () { bubbleModuleAdd(bubblePaletteModule('link')) } },
7666
8744
  { key: 'image', label: '图片/动图', cb: function () { bubblePickImageToAdd() } },
@@ -7878,7 +8956,8 @@ function bubblePvRowCommit(rows) {
7878
8956
  function bubbleModuleEdit(m, anchorBtn) {
7879
8957
  try {
7880
8958
  if (m && (m.type === 'text' || m.type === 'link')) { openQuickTextEditor(m, anchorBtn); return }
7881
- if (m && (m.type === 'balance' || m.type === 'today' || m.type === 'peak' || m.type === 'nextpeak')) { openQuickModuleEditor(m, anchorBtn); return }
8959
+ // v769:对话名也走悬浮编辑(像文本那样边改边看效果),不再开整窗编辑器
8960
+ if (m && (m.type === 'balance' || m.type === 'today' || m.type === 'peak' || m.type === 'nextpeak' || m.type === 'session')) { openQuickModuleEditor(m, anchorBtn); return }
7882
8961
  openModuleEditor(m, function (saved) { if (saved) renderBubblePv() })
7883
8962
  } catch (err) {}
7884
8963
  }
@@ -7980,6 +9059,7 @@ function bubblePaletteModule(key) {
7980
9059
  if (key === 'today') return { type: 'today', size: 1, tpl: '今日已用 {expense_ds}' }
7981
9060
  if (key === 'peak') return { type: 'peak', size: 4, peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{status}' }
7982
9061
  if (key === 'nextpeak') return { type: 'peak', size: 6, bold: true, peakStyle: 'count', peakColor: '#e0433f', offColor: '#2fa24c', tpl: '{countdown}' }
9062
+ if (key === 'session') return { type: 'session', size: 4, bold: true, tpl: '{session}', len: WAIT_SESSION_MAX }
7983
9063
  if (key === 'link') return { type: 'link', text: '打开链接', url: '', size: 6, color: '#2f4488' }
7984
9064
  if (key === 'randimg') return { type: 'randimg', imgs: [], imgScale: 1 }
7985
9065
  // 自定义 API 模型的余额模块(palette key = bal:<modelId>)
@@ -8869,6 +9949,8 @@ function bubbleRgbSelect(current, cb) {
8869
9949
  ['galaxy', '银河星紫'],
8870
9950
  ['ink', '墨韵黑白'],
8871
9951
  ['indigo', '靛蓝夜曲'],
9952
+ ['blaze', '火红烈焰'],
9953
+ ['amber', '警示橙黄'],
8872
9954
  ]
8873
9955
  function labelOf(v) {
8874
9956
  for (var i = 0; i < opts.length; i++) if (opts[i][0] === v) return opts[i][1]
@@ -9045,6 +10127,7 @@ function bubbleFontEditRow(getVal, setVal) {
9045
10127
  function moduleTypeName(t, m) {
9046
10128
  if (t === 'balance') return '余额数值'
9047
10129
  if (t === 'today') return '今日已用'
10130
+ if (t === 'session') return '对话名'
9048
10131
  if (t === 'peak' || t === 'nextpeak') {
9049
10132
  // 峰谷模块按显示样式给名(倒计时/简洁等);无模块对象时退回通用名
9050
10133
  if (m) return bubblePeakModuleLabel(m)
@@ -9091,6 +10174,7 @@ function renderModuleEditor() {
9091
10174
  function hintOf() {
9092
10175
  if (m.type === 'balance') return '例: {balance_ds}'
9093
10176
  if (m.type === 'today') return '例: 今日已用 {expense_ds}'
10177
+ if (m.type === 'session') return '例: {session} 或 当前对话 {session}'
9094
10178
  if (bubbleIsPeakCount(m)) return '例: 距空闲 {countdown}'
9095
10179
  return '例: 当前 {status}'
9096
10180
  }
@@ -9667,10 +10751,26 @@ function renderModuleEditor() {
9667
10751
  } else {
9668
10752
  var note = document.createElement('div')
9669
10753
  note.className = 'dshwv-bubhint'
9670
- note.textContent = '该模块为内置数值,内容自动获取,可调下方颜色/字号'
10754
+ note.textContent = m.type === 'session'
10755
+ ? '该模块自动显示**当前对话名**(占位符 {session}),可调下方颜色 / 字号'
10756
+ : '该模块为内置数值,内容自动获取,可调下方颜色/字号'
9671
10757
  moduleBodyEl.appendChild(note)
9672
10758
  // F1:整句文案模板(如「今日已用 {值}」;留空=默认)
9673
10759
  moduleTplRow()
10760
+ // v768/v769:对话名模块额外给「保留长度」(与悬浮编辑器共用同一份实现;
10761
+ // 悬浮窗改了会实时刷新预览,这里点保存才落盘)
10762
+ if (m.type === 'session') {
10763
+ moduleBodyEl.appendChild(sessionLenRowBuild(function () {
10764
+ var d = document.createElement('div')
10765
+ d.className = 'dshwv-audiorow'
10766
+ return d
10767
+ }, function (t) {
10768
+ var s = document.createElement('span')
10769
+ s.textContent = t
10770
+ s.style.flex = '0 0 auto'
10771
+ return s
10772
+ }, m, null))
10773
+ }
9674
10774
  }
9675
10775
  }
9676
10776
  // 公共样式区(图片/动图、随机语句模块不显示:随机语句样式在每句单独编辑中设置)
@@ -11048,6 +12148,10 @@ function bubbleClearAll() {
11048
12148
  try { if (settleTimer) { clearTimeout(settleTimer); settleTimer = null } } catch (err) {}
11049
12149
  }
11050
12150
  function bubbleCloseVisual() {
12151
+ // v771:泡泡一旦真的收起,等待标记必须一起复位。`waitShown` 平时由 bubbleRenderModules 按场景重算,
12152
+ // 但"整泡关闭"(hideBubble/hideCostBubble/hideWaitBubble…)不走渲染 ⇒ 不复位就会残留成 true,
12153
+ // 之后 showWaitBubble 会误判"已经在显示等待泡泡"(历史上正是这类不同步把等待泡泡卡死的)。
12154
+ waitShown = false
11051
12155
  try { bubbleBox.classList.remove('dshwv-pop-open') } catch (err) {}
11052
12156
  try { textBox.style.transition = ''; textBox.style.opacity = '' } catch (err) {}
11053
12157
  try { hintEl.style.transition = ''; hintEl.style.opacity = '' } catch (err) {}
@@ -11114,6 +12218,9 @@ function bubbleAutoClose() {
11114
12218
  bubbleTtlTimer = null
11115
12219
  if (bubbleScene && bubbleScene.kind === 'cost') { if (whaleSysSwapNext()) return; hideCostBubble(); return }
11116
12220
  if (bubbleScene && bubbleScene.kind === 'alert') { if (whaleSysSwapNext()) return; hideUsageAlertBubble(); return }
12221
+ // v761(#161 C5):等待交互泡泡是**常驻**的(sceneOpen 收到 ttl 0 不布计时器),正常永远走不到这里;
12222
+ // 这一支是防御性的(例如用户先点开等待泡泡再改设置导致 ttl 被重新布上),语义仍与其他系统泡泡一致。
12223
+ if (bubbleScene && bubbleScene.kind === 'wait') { if (whaleSysSwapNext()) return; hideWaitBubble(); return }
11117
12224
  hideBubble()
11118
12225
  }
11119
12226
  // 重置当前泡泡的留存计时(点鲸鱼给第 1 泡续时,不清内容)
@@ -11143,6 +12250,15 @@ function bubbleRenderRandom(lines) {
11143
12250
  function bubbleRenderCostMods(amount) {
11144
12251
  bubbleRenderModules(usageAlertModsResolved(usageTurnCostLines(), null, null, usageCostValue(amount)))
11145
12252
  }
12253
+ // 等待交互提示(v761 / #161 C5):内容取 events.<kind>.lines(缺失/空 → 默认模板),
12254
+ // 走与预警/预算/消耗同一条模块渲染链路。`{session}` 在这里就替换成当前对话名
12255
+ // (usageAlertModsResolved → usageFillText 的会话名回落),所以真实泡泡显示的是对话名而不是 `{session}`。
12256
+ function bubbleRenderWaitMods(kind) {
12257
+ var k = (kind === 'approval') ? 'approval' : 'question'
12258
+ var cfg = {}
12259
+ try { cfg = ((usageSet || {}).events || {})[k] || {} } catch (err) { cfg = {} }
12260
+ bubbleRenderModules(usageAlertModsResolved(usageWaitLinesOf(cfg, k), null, null, null))
12261
+ }
11146
12262
  // 并列步骤:每轮到这一步时独立按权重抽一个候选泡(不记忆上次,允许连续几轮同泡)
11147
12263
  function bubblePickChoiceStep(step) {
11148
12264
  var opts = bubbleChoiceOptions(step)
@@ -11175,8 +12291,8 @@ function bubbleShowSeqNext() {
11175
12291
  }
11176
12292
  }
11177
12293
  // ===== 模块渲染引擎(B1) =====
11178
- // 模块:{type:'text'|'balance'|'today'|'peak'|'image'|'random', text?, imgId?, color?, size?(1..8 档),
11179
- // lines?:[{t,w}] (random 自带句子列表)}
12294
+ // 模块:{type:'text'|'balance'|'today'|'peak'|'session'|'image'|'random', text?, imgId?, color?, size?(1..8 档),
12295
+ // lines?:[{t,w}] (random 自带句子列表), tpl?(占位符模板), len?(session 的「保留长度」,0=不截断)}
11180
12296
  // 字号档位 1..50,线性细分(1→40u … 50→240u,相对 --dshw-u 的倍数)
11181
12297
  function bubbleModuleFontU(level) {
11182
12298
  var n = Number(level) || 6
@@ -11563,6 +12679,9 @@ function bubbleContentTokenMap(m) {
11563
12679
  } else if (m.type === 'today') {
11564
12680
  v = (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
11565
12681
  map['expense_ds'] = v
12682
+ } else if (m.type === 'session') {
12683
+ // v768:对话名模块 —— {session} = 当前对话标题(按模块的「保留长度」截断)
12684
+ map['session'] = bubbleSessionText(m)
11566
12685
  } else if (bubbleIsPeakCount(m)) {
11567
12686
  v = bubbleCountdownText()
11568
12687
  map['countdown'] = v
@@ -11596,6 +12715,7 @@ function bubbleTplHelpItems(m) {
11596
12715
  }
11597
12716
  if (m.type === 'balance') add('balance_ds', '余额数值')
11598
12717
  else if (m.type === 'today') add('expense_ds', '今日已用金额')
12718
+ else if (m.type === 'session') add('session', '当前对话名(超过「保留长度」会截断为 前N字...)')
11599
12719
  else if (m.type === 'peak' || m.type === 'nextpeak') {
11600
12720
  if (bubbleIsPeakCount(m)) add('countdown', '距下一时段倒计时 (HH:MM:SS)')
11601
12721
  else add('status', '高峰/空闲 状态文字(随显示样式变化)')
@@ -11870,7 +12990,7 @@ function bubbleCountdownApplyStyle(el, mod, peak) {
11870
12990
  if (rgb) {
11871
12991
  var scheme = rgb === true ? 'macaron' : String(rgb || 'macaron')
11872
12992
  el.classList.add('dshwv-rgb')
11873
- if (scheme === 'candy' || scheme === 'rouge' || scheme === 'bamboo' || scheme === 'aurora' || scheme === 'deepsea' || scheme === 'sunset' || scheme === 'forest' || scheme === 'champagne' || scheme === 'lavender' || scheme === 'mint' || scheme === 'lava' || scheme === 'galaxy' || scheme === 'ink' || scheme === 'indigo') el.classList.add('dshwv-rgb-' + scheme)
12993
+ if (bubbleRgbSchemeOk(scheme)) el.classList.add('dshwv-rgb-' + scheme)
11874
12994
  } else if (col) {
11875
12995
  el.style.color = col
11876
12996
  }
@@ -11932,10 +13052,12 @@ function bubblePeakRowRegister(row, tx, mod, peakNow) {
11932
13052
  } catch (err) {}
11933
13053
  }
11934
13054
  // 跑马灯配色方案白名单(与 blockOf() / bubbleCountdownApplyStyle 用的那套一致)
13055
+ // v770:新增配色时**只改这一处**(其余调用点已改成调它,不再各自维护一串 || 判断)
11935
13056
  function bubbleRgbSchemeOk(s) {
11936
13057
  return s === 'candy' || s === 'rouge' || s === 'bamboo' || s === 'aurora' || s === 'deepsea' ||
11937
13058
  s === 'sunset' || s === 'forest' || s === 'champagne' || s === 'lavender' || s === 'mint' ||
11938
- s === 'lava' || s === 'galaxy' || s === 'ink' || s === 'indigo'
13059
+ s === 'lava' || s === 'galaxy' || s === 'ink' || s === 'indigo' ||
13060
+ s === 'blaze' || s === 'amber'
11939
13061
  }
11940
13062
  function bubblePeakRowClearClass(el, prefix) {
11941
13063
  try {
@@ -11993,6 +13115,10 @@ function bubbleRowContentOf(mod) {
11993
13115
  var tv2 = bubbleIsModelMod(mod) ? apiModelTodayText(mod.modelId) : (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.todayUsageCurrency || state.currency) : '--')
11994
13116
  return { txt: bubbleContentText(mod, '今日已用 ' + tv2), line: null }
11995
13117
  }
13118
+ if (mod.type === 'session') {
13119
+ // v768:对话名模块(自动内容 = 截断后的对话名;留空 tpl 时直接用自动内容)
13120
+ return { txt: bubbleContentText(mod, bubbleSessionText(mod)), line: null }
13121
+ }
11996
13122
  if (mod.type === 'quota') {
11997
13123
  var qi = apiQuotaInfo(mod.modelId)
11998
13124
  var qt = qi
@@ -12108,7 +13234,7 @@ function bubbleRowsTo(parentEl, mods) {
12108
13234
  // 跑马灯渐变:true 兼容旧数据=马卡龙;支持 macaron/candy/rouge/bamboo…
12109
13235
  target.classList.add('dshwv-rgb')
12110
13236
  var scheme = g === true ? 'macaron' : String(g || 'macaron')
12111
- if (scheme === 'candy' || scheme === 'rouge' || scheme === 'bamboo' || scheme === 'aurora' || scheme === 'deepsea' || scheme === 'sunset' || scheme === 'forest' || scheme === 'champagne' || scheme === 'lavender' || scheme === 'mint' || scheme === 'lava' || scheme === 'galaxy' || scheme === 'ink' || scheme === 'indigo') target.classList.add('dshwv-rgb-' + scheme)
13237
+ if (bubbleRgbSchemeOk(scheme)) target.classList.add('dshwv-rgb-' + scheme)
12112
13238
  }
12113
13239
  if (marquee) {
12114
13240
  // 文字跑马灯装在内层 span(有底色时)或行上;底色在 row 层,两者可叠加;
@@ -12127,7 +13253,7 @@ function bubbleRowsTo(parentEl, mods) {
12127
13253
  if (needBg) {
12128
13254
  if (effBgRgb) {
12129
13255
  row.classList.add('dshwv-bgrgb')
12130
- if (effBgRgb === 'candy' || effBgRgb === 'rouge' || effBgRgb === 'bamboo' || effBgRgb === 'aurora' || effBgRgb === 'deepsea' || effBgRgb === 'sunset' || effBgRgb === 'forest' || effBgRgb === 'champagne' || effBgRgb === 'lavender' || effBgRgb === 'mint' || effBgRgb === 'lava' || effBgRgb === 'galaxy' || effBgRgb === 'ink' || effBgRgb === 'indigo' || effBgRgb === 'macaron') row.classList.add('dshwv-bgrgb-' + effBgRgb)
13256
+ if (bubbleRgbSchemeOk(effBgRgb) || effBgRgb === 'macaron') row.classList.add('dshwv-bgrgb-' + effBgRgb)
12131
13257
  row.style.animationDuration = bubbleMarqueeDur()
12132
13258
  } else if (effBg) {
12133
13259
  row.style.background = effBg
@@ -12285,8 +13411,15 @@ function bubbleRowsTo(parentEl, mods) {
12285
13411
  // 当前泡泡正在显示的模块列表(供场景切换时记录;显示期间不再即时整泡重绘,
12286
13412
  // 避免用户观看时内容被刷新打断——更新统一在“泡泡消失→下一次显示”的渲染采用最新 state)
12287
13413
  var bubbleLiveMods = null
13414
+ // v761(#161 C5):当前是否正显示「等待交互」的常驻泡泡。只有真正渲染出等待内容时才置位,
13415
+ // 因此拿它做幂等判断(同名再次入队直接丢弃)不会把"排了队但还没轮到"的项误判成已显示。
13416
+ var waitShown = false
12288
13417
  function bubbleRenderModules(mods) {
12289
13418
  try {
13419
+ // v761(#161 C5):等待交互泡泡(kind:'wait')从布场景到渲染都是同步完成的,所以真正的
13420
+ // "已显示"标记放在这里取;其它场景/关闭路径会把 bubbleScene 置空或换 kind,因此
13421
+ // 该标记不可能残留成"其实没显示却以为在显示"。
13422
+ waitShown = !!(bubbleScene && bubbleScene.kind === 'wait')
12290
13423
  bubbleLiveMods = Array.isArray(mods) ? mods.slice() : null
12291
13424
  // 清掉旧模块行,隐藏老三行与 gif(它们仍保留在 DOM 供普通场景使用)
12292
13425
  gifEl.style.display = 'none'
@@ -12390,6 +13523,9 @@ function bubbleNext() {
12390
13523
  if (!bubbleShown) return
12391
13524
  if (bubbleScene && bubbleScene.kind === 'cost') { hideCostBubble(); return }
12392
13525
  if (bubbleScene && bubbleScene.kind === 'alert') { hideUsageAlertBubble(); return }
13526
+ // v761(#161 C5):等待交互泡泡必须**常驻到被回答/批准**——点它不关(点击泡泡 = 推进/收起是给
13527
+ // 手动轮的语义);避免用户误点把「正在等待你的回答」收掉、而后台其实还挂着。
13528
+ if (bubbleScene && bubbleScene.kind === 'wait') return
12393
13529
  if (bubbleRoundOn && bubbleSeqIdx < bubbleSeq.length) { bubbleShowSeqNext(); return }
12394
13530
  hideBubble()
12395
13531
  } catch (err) {}
@@ -12437,20 +13573,71 @@ var USAGE_ALERT_TTL = 6500 // 提醒泡泡停留毫秒数
12437
13573
  var whaleSysQueue = []
12438
13574
  var whaleSysItem = null // 当前正展示的 {kind, mods/amount, rank}
12439
13575
  var whaleSysTimer = null
13576
+ // 队列插入(v772 起抽成函数,供 push / 抢占退回 共用):
13577
+ // rank 小的先出(1 今日预算 / 2 余额预警 / 3 每轮消耗 / 4 等待交互);
13578
+ // **同档的「每轮消耗」后入先出** —— 新的一轮消耗插到已有消耗项之前 ⇒ 新的置顶,旧的排队;
13579
+ // 其它档位保持先入先出(预警/预算按发生顺序看更合理),等待交互项永远排在最前。
13580
+ function whaleSysQueueInsert(item) {
13581
+ var rank = Number(item.rank)
13582
+ if (!(rank >= 1)) rank = 2
13583
+ item.rank = rank
13584
+ var pos = whaleSysQueue.length
13585
+ for (var i = 0; i < whaleSysQueue.length; i++) {
13586
+ var q = whaleSysQueue[i] || {}
13587
+ var qr = Number(q.rank)
13588
+ if (!(qr >= 1)) qr = 2
13589
+ if (qr > rank || (item.kind === 'cost' && q.kind === 'cost' && qr === rank)) { pos = i; break }
13590
+ }
13591
+ whaleSysQueue.splice(pos, 0, item)
13592
+ return pos
13593
+ }
13594
+ // 展示一项(v772 抽成函数:tick / 淡切下一项 / 抢占都走这一份,避免三处各写一遍又不一致)
13595
+ function whaleSysOpenItem(item) {
13596
+ whaleSysItem = item
13597
+ if (item.kind === 'cost') {
13598
+ sceneOpen('cost', function () { bubbleRenderCostMods(item.amount) }, turnCostCloseMs > 0 ? turnCostCloseMs : 0)
13599
+ } else if (item.kind === 'wait') {
13600
+ // 等待交互:常驻(ttl 0 = sceneOpen 不布自动关闭计时器),内容在渲染时按当前设置取
13601
+ sceneOpen('wait', function () { bubbleRenderWaitMods(item.waitKind) }, 0)
13602
+ } else {
13603
+ sceneOpen('alert', function () { bubbleRenderModules(item.mods || []) }, (item && item.ttlMs != null) ? item.ttlMs : USAGE_ALERT_TTL)
13604
+ }
13605
+ }
12440
13606
  function whaleSysPush(item) {
12441
13607
  try {
12442
13608
  if (!bubbleOn || !bubbleBox || !textBox) return false
13609
+ if (!item || !item.kind) return false
13610
+ // v772:**等待交互(授权/提问)享有最高优先级,永远不会被"消耗泡泡正开着"挡住**。
13611
+ // 整轮对话都卡在等你回答/批准,它必须立刻可见;正在展示的其它系统泡泡(消耗/预警)退回队列,
13612
+ // 等挂起解除后照旧继续 —— 用户真机反馈「已有每轮消耗提示时,授权与提问不会显示」。
13613
+ // 顺带兜住历史上的一个坑:`costBubbleActive` 万一残留成 true 而 `whaleSysItem` 为空,
13614
+ // 下面的 `costBubbleActive && !whaleSysItem` 早退会把所有入队都吃掉(等待泡泡一起被丢)。
13615
+ if (item.kind === 'wait') {
13616
+ if (whaleSysItem && whaleSysItem.kind === 'wait') {
13617
+ if (whaleSysItem.waitKind === item.waitKind) return true // 已在显示同一类:内容无需重渲染
13618
+ whaleSysItem = item
13619
+ sceneOpen('wait', function () { bubbleRenderWaitMods(item.waitKind) }, 0)
13620
+ return true
13621
+ }
13622
+ if (whaleSysItem) {
13623
+ var pre = whaleSysItem
13624
+ whaleSysItem = null
13625
+ whaleSysQueueInsert(pre) // 退回队列(按 rank + 消耗 LIFO 规则就位)
13626
+ }
13627
+ whaleSysOpenItem(item)
13628
+ return true
13629
+ }
12443
13630
  // 正在展示消耗泡泡时,新提醒退化为居中卡片(避免与消耗内容抢层);排队中的则按序等
12444
13631
  if (costBubbleActive && !whaleSysItem) return false
12445
- if (!item || !item.kind) return false
12446
- var rank = Number(item.rank)
12447
- if (!(rank >= 1)) rank = 2
12448
- item.rank = rank
12449
- var pos = whaleSysQueue.length
12450
- for (var i = 0; i < whaleSysQueue.length; i++) {
12451
- if (whaleSysQueue[i].rank > rank) { pos = i; break }
12452
- }
12453
- whaleSysQueue.splice(pos, 0, item)
13632
+ // v772:**每轮消耗改后入先出** —— 新一轮消耗直接置顶,原来那条退回队列(点击时按"新→旧"回看)。
13633
+ if (item.kind === 'cost' && whaleSysItem && whaleSysItem.kind === 'cost') {
13634
+ var pre2 = whaleSysItem
13635
+ whaleSysItem = null
13636
+ whaleSysQueueInsert(pre2)
13637
+ whaleSysOpenItem(item)
13638
+ return true
13639
+ }
13640
+ whaleSysQueueInsert(item)
12454
13641
  if (whaleSysTimer) { clearTimeout(whaleSysTimer); whaleSysTimer = null }
12455
13642
  whaleSysTimer = setTimeout(whaleSysTick, 30)
12456
13643
  return true
@@ -12463,15 +13650,12 @@ function whaleSysTick() {
12463
13650
  if (whaleSysItem) return
12464
13651
  if (!whaleSysQueue.length) return
12465
13652
  // 存在尚未入队的正展示系统泡?仅在鲸鱼空闲且无我们自己队列项时取下一个
12466
- if (costBubbleActive || (bubbleScene && bubbleScene.kind === 'alert')) return
13653
+ // v761(#161 C5):waitShown 也必须算"正被占着"——等待泡泡是常驻的(whaleSysItem 会一直是它),
13654
+ // 正常走不到这里;但万一 item 被别的路径清掉而场景还停在等待内容上,也不能抢它的层。
13655
+ if (costBubbleActive || waitShown || (bubbleScene && bubbleScene.kind === 'alert')) return
12467
13656
  var item = whaleSysQueue.shift()
12468
13657
  if (!item) return
12469
- whaleSysItem = item
12470
- if (item.kind === 'cost') {
12471
- sceneOpen('cost', function () { bubbleRenderCostMods(item.amount) }, turnCostCloseMs > 0 ? turnCostCloseMs : 0)
12472
- } else {
12473
- sceneOpen('alert', function () { bubbleRenderModules(item.mods || []) }, (item && item.ttlMs != null) ? item.ttlMs : USAGE_ALERT_TTL)
12474
- }
13658
+ whaleSysOpenItem(item) // v772:展示逻辑抽成一份(tick / 淡切 / 抢占共用)
12475
13659
  } catch (err) {}
12476
13660
  }
12477
13661
  function whaleSysDone() {
@@ -12487,12 +13671,7 @@ function whaleSysSwapNext() {
12487
13671
  if (!whaleSysQueue.length || !bubbleOn || !bubbleShown) return false
12488
13672
  var item = whaleSysQueue.shift()
12489
13673
  if (!item) return false
12490
- whaleSysItem = item
12491
- if (item.kind === 'cost') {
12492
- sceneOpen('cost', function () { bubbleRenderCostMods(item.amount) }, turnCostCloseMs > 0 ? turnCostCloseMs : 0)
12493
- } else {
12494
- sceneOpen('alert', function () { bubbleRenderModules(item.mods || []) }, (item && item.ttlMs != null) ? item.ttlMs : USAGE_ALERT_TTL)
12495
- }
13674
+ whaleSysOpenItem(item) // v772:与 tick / 抢占同一份展示逻辑
12496
13675
  return true
12497
13676
  } catch (err) { return false }
12498
13677
  }
@@ -12508,7 +13687,88 @@ function hideUsageAlertBubble() {
12508
13687
  bubbleCloseVisual()
12509
13688
  whaleSysDone()
12510
13689
  }
12511
-
13690
+ // ===== v761(#161 C5):等待交互的**常驻**泡泡(提问 / 授权)=====
13691
+ // 语义与消耗(3)/预警(2)/预算(1)三档系统泡泡不同:那几档是"显示 N 秒自动关",而挂起泡泡要
13692
+ // **在挂起存在期间一直显示**,直到宿主在 wait.json 里说挂起解除了 → pollWaitState() 调 hideWaitBubble()。
13693
+ // 复核后的实现方式(改动面最小的那一版):
13694
+ // · 复用既有系统泡泡链路(whaleSysPush / whaleSysTick / whaleSysSwapNext / whaleSysDone),
13695
+ // 不另造浮层;新增第四档 rank = 4(排在消耗之后,理由见 showWaitBubble 注释);
13696
+ // · 常驻 = **ttlMs 传 0**。sceneOpen 里 `if (ttlMs > 0) bubbleTtlTimer = setTimeout(...)`,
13697
+ // 所以 0 天然"不布自动关闭计时器";bubbleResetTtl 也带 `ttlMs > 0` 守卫,不会给它补上计时器。
13698
+ // 既有 cost/alert 的取值(turnCostCloseMs / usageRemindTtlMs)完全没动。
13699
+ // · 队列项 kind = 'cost' | 'alert' | 'wait';'wait' 这一支只出现在 whaleSysTick /
13700
+ // whaleSysSwapNext / bubbleAutoClose / bubbleNext 的**新增分支**里,alert/budget/cost 路径逐字未改。
13701
+ // 幂等 + 换类型(**v771 修掉真机卡死**):等待泡泡**永远只有一条**,内容 = 宿主当前上报的那一条挂起。
13702
+ // 依据:宿主 `waitState` 也只有一个槽位(`notePendingEvent` 里每次 `waitState.pending = {...}` 覆盖),
13703
+ // 所以客户端"镜像最新一条"就与宿主同口径,不需要(也不能)排队多条常驻泡泡。
13704
+ // ⚠️ 旧实现遇到"正在显示 A 类、又来了 B 类"时:把 `whaleSysItem` 置空 → 把 A 项 `unshift` 回队首 → 再 push B。
13705
+ // 可屏幕上**仍在显示等待场景**(`waitShown` 依旧 true)⇒
13706
+ // ① `whaleSysTick` 的 `if (waitShown) return` 守卫**永远**拦住队列(B 永不上屏、A 也不换);
13707
+ // ② `whaleSysItem` 已是 null ⇒ `hideWaitBubble()` 的 `if (whaleSysItem && kind==='wait')` 分支走不到
13708
+ // ⇒ 之后宿主说"没有挂起了"也**关不掉** ⇒ 泡泡一直停在 A,直到刷新页面。
13709
+ // 真机复现:先授权、再提问(1 秒轮询很可能错过中间"无挂起"的那一瞬)⇒ 泡泡停在授权、提问泡泡不出现、
13710
+ // 对话结束也不消失。
13711
+ function showWaitBubble(kind, p) {
13712
+ try {
13713
+ if (!bubbleOn) return false // 全局「泡泡总开关」为假 → 一律不显示
13714
+ var k = (kind === 'approval') ? 'approval' : 'question'
13715
+ var cfg = waitEventCfg(k)
13716
+ // 门控:该事件总开关 + 该事件「冒泡提示」开关(任一为假都不冒泡);pollWaitState 也会先判一次
13717
+ if (cfg.on === false || cfg.bubbleOn === false) return false
13718
+ if (!bubbleBox || !textBox) return false
13719
+ var pid = (p && p.id) ? String(p.id) : ''
13720
+ // "正显示等待泡泡"看**场景**(权威)或已占位的队列项(次之)—— 二者取或,避免状态半同步时漏判
13721
+ var showingWait = !!((bubbleScene && bubbleScene.kind === 'wait') || (whaleSysItem && whaleSysItem.kind === 'wait'))
13722
+ if (showingWait) {
13723
+ // 同一类:幂等丢弃(内容一样)。只是 pendingId 变了就顺手更新,便于排查。
13724
+ if (whaleSysItem && whaleSysItem.kind === 'wait' && whaleSysItem.waitKind === k) {
13725
+ if (pid) whaleSysItem.pendingId = pid
13726
+ return false
13727
+ }
13728
+ // 不同类(或状态半同步):**原地替换** —— 不动 waitShown、不置空 whaleSysItem、不往队列塞,
13729
+ // 直接复用 sceneOpen 的内容淡切把这一条常驻泡泡换成新的挂起内容。
13730
+ whaleSysQueue = whaleSysQueue.filter(function (q) { return !(q && q.kind === 'wait') })
13731
+ whaleSysItem = { kind: 'wait', waitKind: k, rank: 4, ttlMs: 0, pendingId: pid }
13732
+ sceneOpen('wait', function () { bubbleRenderWaitMods(k) }, 0)
13733
+ return true
13734
+ }
13735
+ // 还没在显示等待泡泡(可能正在放消耗/预警,或压根没泡泡):队列里只保留"最新一条"等待项
13736
+ whaleSysQueue = whaleSysQueue.filter(function (q) { return !(q && q.kind === 'wait') })
13737
+ // rank 4:1 今日预算 / 2 余额预警 / 3 每轮消耗 / 4 等待交互。挂在消耗之后是刻意的 ——
13738
+ // 等待泡泡一旦显示就**常驻**(不会自己让位),所以必须让"来了就要看、会自己消失"的消耗泡泡先走完;
13739
+ // 否则一次消耗提醒会被常驻的等待泡泡永久挡在队里。既有 30ms 合批排序逻辑不用改。
13740
+ return whaleSysPush({ kind: 'wait', waitKind: k, rank: 4, ttlMs: 0, pendingId: pid })
13741
+ } catch (err) { return false }
13742
+ }
13743
+ // 挂起解除(被回答 / 被批准 / 切换对话):收起等待泡泡。
13744
+ // 队列里还有别的项时保持打开、淡切到下一项 —— 与 hideCostBubble / hideUsageAlertBubble 同语义,
13745
+ // 复用它们的做法:**不**把正在显示的那一项摘掉,交给 whaleSysSwapNext() 去换或去清场。
13746
+ function hideWaitBubble() {
13747
+ try {
13748
+ // v771:判断"当前是不是正显示等待泡泡"必须**同时看场景与队列项**。历史上只看 whaleSysItem,
13749
+ // 一旦它与场景不同步(showWaitBubble 换类型的旧实现就会造成)就永远走不到关闭分支 ⇒ 泡泡卡死。
13750
+ var showingWait = !!((bubbleScene && bubbleScene.kind === 'wait') || (whaleSysItem && whaleSysItem.kind === 'wait') || waitShown)
13751
+ var beforeLen = whaleSysQueue.length
13752
+ // 排队中但还没轮到的等待项作废(等待项永远只保留最新一条,见 showWaitBubble)
13753
+ whaleSysQueue = whaleSysQueue.filter(function (q) { return !(q && q.kind === 'wait') })
13754
+ if (showingWait) {
13755
+ if (whaleSysSwapNext()) return // 队列里还有别的项 → 淡切过去(waitShown 由渲染侧重算)
13756
+ waitShown = false
13757
+ whaleSysItem = null
13758
+ bubbleClearAll()
13759
+ bubbleScene = null
13760
+ bubbleShown = false
13761
+ bubbleRandomActive = false
13762
+ bubbleRandomLines = null
13763
+ bubbleLiveMods = null
13764
+ bubbleCloseVisual()
13765
+ whaleSysDone()
13766
+ return
13767
+ }
13768
+ // 没在显示等待泡泡:只清掉了排队中的等待项 → 补一次 tick,让别的项继续走
13769
+ if (whaleSysQueue.length !== beforeLen) whaleSysTick()
13770
+ } catch (err) {}
13771
+ }
12512
13772
  function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
12513
13773
  function viewport() {
12514
13774
  return {
@@ -15501,6 +16761,56 @@ try {
15501
16761
  var lastCostStored = Number(localStorage.getItem('dshw-last-seq') || 0)
15502
16762
  if (isFinite(lastCostStored) && lastCostStored >= 0) lastCostSeq = lastCostStored
15503
16763
  } catch (err) {}
16764
+ // v761(issue #161 / 全局音效设置):**等待用户交互**的轮询。
16765
+ // 并入既有的每秒周期(不新增 interval);宿主 wait.json 给出「有没有挂起 / 哪一类 / 对话名」。这里负责:
16766
+ // ① 记住对话名,供提示内容里的 {session} 占位符使用;
16767
+ // ② 挂起**首次出现**时播对应音效(提问音 / 授权音),并在开启「冒泡提示」时弹泡泡;
16768
+ // ③ 用 localStorage 记住已响过的挂起 id ⇒ **刷新页面不会为同一个未回答的提问重复响**。
16769
+ var WAIT_URL = '/dsh-whale/wait.json'
16770
+ var WAIT_SOUND_KEY = 'dshw-wait-sound'
16771
+ var waitSeenId = ''
16772
+ function waitEventCfg(kind) {
16773
+ try { return ((usageSet || {}).events || {})[kind] || {} } catch (err) { return {} }
16774
+ }
16775
+ function pollWaitState() {
16776
+ try {
16777
+ fetch(WAIT_URL, { cache: 'no-store' })
16778
+ .then(function (r) { return r.json() })
16779
+ .then(function (d) {
16780
+ if (!d || !d.ok) return
16781
+ if (typeof d.sessionName === 'string') waitSessionName = d.sessionName
16782
+ var p = d.pending || null
16783
+ if (!p || !p.kind) {
16784
+ // 挂起已解除:收起泡泡,并允许下一次挂起照常响
16785
+ waitSeenId = ''
16786
+ if (typeof hideWaitBubble === 'function') hideWaitBubble()
16787
+ return
16788
+ }
16789
+ var kind = p.kind === 'approval' ? 'approval' : 'question'
16790
+ var ev = waitEventCfg(kind)
16791
+ // v771:**每秒都把泡泡与宿主的挂起状态对齐一次**(showWaitBubble 幂等:同类直接返回 false,
16792
+ // 不会重渲染)。旧实现只在"新 id"时才调显示,于是一旦出现瞬时不同步(换类型 / 泡泡被别的
16793
+ // 场景顶掉 / 某次渲染失败),泡泡就会卡在那个状态再也回不来 —— 这正是真机那次卡死的另一半原因。
16794
+ // 现在任何不同步都会在 1 秒内自愈:该换内容就换、该收就收(收在下面 !p 分支)。
16795
+ // 门控照旧:该事件的「冒泡提示」关掉就不冒泡(与声音各自独立判)。
16796
+ if (ev.bubbleOn !== false && typeof showWaitBubble === 'function') showWaitBubble(kind, p)
16797
+ // 声音:同一个未回答的挂起只响一次(刷新页面也不重复响)
16798
+ var key = kind + ':' + String(p.id || '')
16799
+ var remembered = ''
16800
+ try { remembered = localStorage.getItem(WAIT_SOUND_KEY) || '' } catch (err) {}
16801
+ if (key === waitSeenId || key === remembered) return
16802
+ waitSeenId = key
16803
+ try { localStorage.setItem(WAIT_SOUND_KEY, key) } catch (err) {}
16804
+ var on = ev.on !== false
16805
+ var sel = ev.sel || ''
16806
+ // v762:音效行新增的「是否播这个音效」开关(events.<kind>.soundOn,缺省视为开)。
16807
+ // 门控 = 该事件总开关 on + 选了音效 sel(下拉已无「静音」,正常不为空)+ soundOn。
16808
+ // 任务结束音的播放判定不在这里,仍读既有的 usageSet.taskEnd.on(见 playTaskEndSound)。
16809
+ if (on && sel && ev.soundOn !== false) playBindingSound({ on: true, sel: sel }, ev)
16810
+ })
16811
+ .catch(function () {})
16812
+ } catch (err) {}
16813
+ }
15504
16814
  function pollLastTurn() {
15505
16815
  try {
15506
16816
  fetch(LAST_TURN_URL, { cache: 'no-store' })
@@ -15536,7 +16846,7 @@ function pollLastTurn() {
15536
16846
  .catch(function () {})
15537
16847
  } catch (err) {}
15538
16848
  }
15539
- setInterval(pollLastTurn, 1000)
16849
+ setInterval(function () { pollLastTurn(); pollWaitState() }, 1000)
15540
16850
  }
15541
16851
  // 主界面检测通过(或稍后由 MutationObserver 检测到)后执行挂件初始化;非主界面不启动
15542
16852
  try { dshwTryStart(true) } catch (err) {}