dsh-whale-widget 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,10 +6,30 @@ window.__dshWhaleWidget = true
6
6
  // 挂件脚本通过 tapIndex 注入 DSH 的每一个 index 页面(含插件市场等 SPA 视图)。
7
7
  // 市场页用 ReactDOM.createPortal 渲染到 document.body,若挂件在此初始化,
8
8
  // 会在 body 插入节点并注册全局捕获拦截,干扰 React 渲染树(removeChild 报错、页面空白)。
9
- // 主聊天界面的特征:composer 输入区。DSH 新版输入框为 contenteditable div,旧版为 textarea,
10
- // 两种都算主界面;检测到才继续,否则不碰 DOM、不注册监听。
9
+ // 主聊天界面的特征:composer 输入区。三种形态都算主界面:
10
+ // (a) 旧版 textarea
11
+ // (b) 旧版 contenteditable 可编辑 div
12
+ // (c) **DSH 0.1.6-alpha.1 起的新版**:<div contenteditable="false" role="textbox"
13
+ // aria-multiline="true" data-composer-input="true">(编辑由 Lexical 接管,所以
14
+ // contenteditable 反而是 false —— 只认前两种会让挂件在新版 DSH 上**完全不初始化**,
15
+ // 见 issue #123)。检测到才继续,否则不碰 DOM、不注册监听。
11
16
  function dshwIsChatRoot(r) {
12
- return !!(r && (r.querySelector('textarea') || r.querySelector('[contenteditable="true"]')))
17
+ if (!r || !r.querySelector) return false
18
+ return !!(
19
+ r.querySelector('textarea') ||
20
+ r.querySelector('[contenteditable="true"]') ||
21
+ // DSH 0.1.6-alpha.1 起的新版 composer:data-composer-input 在整个 DSH 前端产物里
22
+ // **只出现在对话组件**(v742 时实测:1515 个前端文件里仅 dsh-client-ui-conversation 命中),
23
+ // 所以它是最精确的判据。
24
+ r.querySelector('[data-composer-input]') ||
25
+ // 同一层的 composer 容器属性(data-composer-seat / -card 也各只有一个出处),
26
+ // 万一将来输入框本身的属性改名,容器还在就仍能识别。
27
+ r.querySelector('[data-composer-seat],[data-composer-card]') ||
28
+ // 通用兜底:多行 textbox。注意**不要**用裸的 [role="textbox"] —— pdf.js 的批注编辑器
29
+ // (dsh-client-ui-sidebar-documentpreview)也会设 role=textbox,用它会把判定放宽到
30
+ // 非输入框的元素;加上 aria-multiline 至少排除掉单行输入框。
31
+ r.querySelector('[role="textbox"][aria-multiline="true"]')
32
+ )
13
33
  }
14
34
  var dshwStarted = false
15
35
  function dshwStartOnce() {
@@ -679,6 +699,38 @@ styleEl.setAttribute('data-plugin', 'dsh-whale-widget')
679
699
  styleEl.textContent = css
680
700
  document.head.appendChild(styleEl)
681
701
 
702
+ // ===== v743:body 挂载登记器(DOM 守护的基础设施,见下面 dshwReattachRoot)=====
703
+ // 挂件会把 30 多个节点挂到 document.body 上(主节点、菜单、各种遮罩/面板、隐藏的 file input…)。
704
+ // 早先的守护只补挂 root + menuBox:SPA 切路由或别的插件整体替换 body 子树后,其余节点会变成
705
+ // "存在但不在文档里"的孤儿 —— 界面看起来恢复了,可一旦点开对应功能就静默失效。
706
+ // 所以这里统一登记:所有挂到 body 的节点都走 dshwBodyAppend(),守护时逐个补挂;
707
+ // 主动移除(目前只有一处)走 dshwBodyDetach(),避免被守护逻辑"复活"。
708
+ var dshwBodyNodes = []
709
+ function dshwBodyAppend(el) {
710
+ try {
711
+ if (!el) return el
712
+ // 注意:这里必须是**原始**的 document.body.appendChild —— 不能走 dshwBodyAppend 自己
713
+ // (v743 批量改写时曾误替换成自我递归,被 try/catch 吞掉后表现为"登记了但从未挂上")
714
+ document.body.appendChild(el)
715
+ if (dshwBodyNodes.indexOf(el) < 0) dshwBodyNodes.push(el)
716
+ } catch (err) {}
717
+ return el
718
+ }
719
+ function dshwBodyDetach(el) {
720
+ try {
721
+ var i = dshwBodyNodes.indexOf(el)
722
+ if (i >= 0) dshwBodyNodes.splice(i, 1)
723
+ if (el && el.parentNode) el.parentNode.removeChild(el)
724
+ } catch (err) {}
725
+ }
726
+ // 兼容性:老 WebView 可能没有 Element.isConnected(Chrome 51+ 才有)。
727
+ // 若直接 `!el.isConnected`,在那种环境里会恒为 true → 每个 DOM 变更批次都会重复 appendChild。
728
+ function dshwConnected(el) {
729
+ if (!el) return false
730
+ try { if (typeof el.isConnected === 'boolean') return el.isConnected } catch (err) {}
731
+ try { return document.documentElement.contains(el) } catch (err) { return true }
732
+ }
733
+
682
734
  var root = document.createElement('div')
683
735
  root.className = 'dshwv-root'
684
736
 
@@ -763,7 +815,7 @@ audioImportBtn.textContent = '导入'
763
815
  audioImportBtn.title = '新建/编辑音效组'
764
816
  audioGroupBtn.addEventListener('click', function (e) { e.stopPropagation(); toggleAudioGroupPanel() })
765
817
  audioImportBtn.addEventListener('click', function (e) { e.stopPropagation(); openAudioGroupEditor(null) })
766
- document.body.appendChild(audioGroupPanel)
818
+ dshwBodyAppend(audioGroupPanel)
767
819
  function soundOpt(value, label) {
768
820
  var o = document.createElement('option')
769
821
  o.value = value
@@ -899,7 +951,7 @@ function dshwCustSel(sel, opts) {
899
951
  dshwCustSelClose()
900
952
  fill()
901
953
  sync()
902
- if (menu.parentNode !== document.body) document.body.appendChild(menu)
954
+ if (menu.parentNode !== document.body) dshwBodyAppend(menu)
903
955
  dshwDropOpen(menu, btn)
904
956
  // 可选底部参照元素:展开高度不超过该元素的上缘(内部滚动),用于
905
957
  // 主菜单内“任务结束音效”等下拉,避免列表盖住底部「小鲸鱼记账」按钮
@@ -1342,6 +1394,17 @@ var rowHide = menuRow()
1342
1394
  rowHide.appendChild(menuLabel('隐藏菜单按钮'))
1343
1395
  rowHide.appendChild(menuHideToggle)
1344
1396
  menuBox.appendChild(rowHide)
1397
+ // —— Codex 本机统计开关(issue #116):关掉后宿主不再扫描 ~/.codex/sessions ——
1398
+ var codexStatsToggle = document.createElement('input')
1399
+ codexStatsToggle.type = 'checkbox'
1400
+ codexStatsToggle.className = 'dshwv-check'
1401
+ codexStatsToggle.checked = true
1402
+ codexStatsToggle.title = '关闭后不再读取 ~/.codex/sessions 统计本机 Codex 用量(会话日志很大时建议关闭)'
1403
+ codexStatsToggle.addEventListener('change', function () { setCodexStatsOn(codexStatsToggle.checked) })
1404
+ var rowCodexStats = menuRow()
1405
+ rowCodexStats.appendChild(menuLabel('Codex 本机统计'))
1406
+ rowCodexStats.appendChild(codexStatsToggle)
1407
+ menuBox.appendChild(rowCodexStats)
1345
1408
  // —— 资源管理:集中查看/删除已导入的图片与音频(角色图/泡泡图/音频片段/音效组) ——
1346
1409
  var rowRes = menuRow()
1347
1410
  var resOpenBtn = document.createElement('button')
@@ -1969,7 +2032,7 @@ function usageAlertBudgetEditor(key, onSave) {
1969
2032
  mask.appendChild(card)
1970
2033
  mask.addEventListener('click', function (e) { if (e.target === mask) cleanup() })
1971
2034
  window.__dshwRemindMask = mask
1972
- document.body.appendChild(mask)
2035
+ dshwBodyAppend(mask)
1973
2036
  renderBubblePal()
1974
2037
  renderBubblePv()
1975
2038
  } catch (err) {}
@@ -2541,7 +2604,7 @@ function openApiModelPanel(modelId) {
2541
2604
  } catch (err) {}
2542
2605
  mask.appendChild(card)
2543
2606
  mask.addEventListener('click', function (e) { if (e.target === mask) closeApiModelPanel() })
2544
- document.body.appendChild(mask)
2607
+ dshwBodyAppend(mask)
2545
2608
  apiModelMaskEl = mask
2546
2609
  // 模板切换:把模板默认值填进各字段(仅新增态)
2547
2610
  if (isNew) {
@@ -2892,7 +2955,7 @@ function openModelQuotaEditor(modelId) {
2892
2955
  card.appendChild(btns)
2893
2956
  mask.appendChild(card)
2894
2957
  mask.addEventListener('click', function (e) { if (e.target === mask) closeApiModelPanel() })
2895
- document.body.appendChild(mask)
2958
+ dshwBodyAppend(mask)
2896
2959
  apiModelMaskEl = mask
2897
2960
  } catch (err) {}
2898
2961
  }
@@ -3080,7 +3143,7 @@ function openApiModelMenu(modelId) {
3080
3143
  card.appendChild(btns)
3081
3144
  mask.appendChild(card)
3082
3145
  mask.addEventListener('click', function (e) { if (e.target === mask) closeApiModelPanel() })
3083
- document.body.appendChild(mask)
3146
+ dshwBodyAppend(mask)
3084
3147
  apiModelMaskEl = mask
3085
3148
  } catch (err) {}
3086
3149
  }
@@ -3238,7 +3301,8 @@ function openBalanceAdjustment(modelId) {
3238
3301
  function close() {
3239
3302
  if (busy) return
3240
3303
  document.removeEventListener('keydown', keyHandler)
3241
- mask.remove()
3304
+ // v743:主动移除要走 dshwBodyDetach —— 否则它已被登记,DOM 守护会把这个刚关掉的窗口"复活"
3305
+ dshwBodyDetach(mask)
3242
3306
  accountingMask = null
3243
3307
  // 关掉校正窗口后回到同一个设置菜单,并把焦点放回「校正」按钮
3244
3308
  openApiModelMenu(modelId)
@@ -3271,7 +3335,7 @@ function openBalanceAdjustment(modelId) {
3271
3335
  card.appendChild(actions)
3272
3336
  mask.appendChild(card)
3273
3337
  mask.addEventListener('click', function (e) { if (e.target === mask) close() })
3274
- document.body.appendChild(mask)
3338
+ dshwBodyAppend(mask)
3275
3339
  accountingMask = mask
3276
3340
  document.addEventListener('keydown', keyHandler)
3277
3341
  var rows = []
@@ -3467,7 +3531,7 @@ var usageMoreCard = document.createElement('div')
3467
3531
  usageMoreCard.className = 'dshwv-usage-card'
3468
3532
  usageMoreMask.appendChild(usageMoreCard)
3469
3533
  usageMoreMask.addEventListener('click', function (e) { if (e.target === usageMoreMask) closeUsageRecordsWindow() })
3470
- document.body.appendChild(usageMoreMask)
3534
+ dshwBodyAppend(usageMoreMask)
3471
3535
  function openUsageRecordsWindow() {
3472
3536
  usageMoreCard.innerHTML = '<div style="padding:10px;color:#203170">加载中…</div>'
3473
3537
  usageMoreMask.style.display = 'flex'
@@ -3490,7 +3554,7 @@ function resMaskOpen() {
3490
3554
  resCardEl.className = 'dshwv-usage-card dshwv-rescard'
3491
3555
  resMaskEl.appendChild(resCardEl)
3492
3556
  resMaskEl.addEventListener('click', function (e) { if (e.target === resMaskEl) resManagerClose() })
3493
- document.body.appendChild(resMaskEl)
3557
+ dshwBodyAppend(resMaskEl)
3494
3558
  }
3495
3559
  resManagerRender()
3496
3560
  resMaskEl.style.display = 'flex'
@@ -4090,7 +4154,7 @@ function usagePopupCard(title, content, below, amount) {
4090
4154
  card.appendChild(btns)
4091
4155
  mask.appendChild(card)
4092
4156
  mask.addEventListener('click', function (e) { if (e.target === mask) { try { document.body.removeChild(mask) } catch (err) {} } })
4093
- document.body.appendChild(mask)
4157
+ dshwBodyAppend(mask)
4094
4158
  } catch (err) {}
4095
4159
  }
4096
4160
  var USAGE_PALETTE = ['#203170', '#e0433f', '#2fa24c', '#b060c8', '#e89a2e', '#3aa6c8', '#d06a8a', '#7a8b2f', '#6a6ad0', '#c84a8a']
@@ -4595,7 +4659,7 @@ function fillUsageRecordsWindow(d) {
4595
4659
  renderGroups('')
4596
4660
  })
4597
4661
  }
4598
- document.body.appendChild(rolePanel)
4662
+ dshwBodyAppend(rolePanel)
4599
4663
 
4600
4664
  // —— 吸附与翻转自定义弹窗 ——
4601
4665
  // 预览方框内五条可拖线:左/右/上/下四条吸附区边界 + 红色翻转线(竖中线位置)。
@@ -5012,7 +5076,7 @@ snapBtns.appendChild(snapBtn('重置', 'dshwv-snapbtn-no', resetSnapEdit))
5012
5076
  snapBtns.appendChild(snapBtn('确认', 'dshwv-snapbtn-ok', function () { closeSnapModal(true) }))
5013
5077
  snapCard.appendChild(snapBtns)
5014
5078
  snapMask.appendChild(snapCard)
5015
- document.body.appendChild(snapMask)
5079
+ dshwBodyAppend(snapMask)
5016
5080
 
5017
5081
  // —— 自定义泡泡:主编辑窗口(点击队列) + 单泡模块编辑 + 模块编辑 ——
5018
5082
  var bubbleMask = null
@@ -6868,7 +6932,7 @@ function qeditEnsure() {
6868
6932
  qeditEl = document.createElement('div')
6869
6933
  qeditEl.className = 'dshwv-qedit'
6870
6934
  qeditEl.style.display = 'none'
6871
- document.body.appendChild(qeditEl)
6935
+ dshwBodyAppend(qeditEl)
6872
6936
  if (!window.__dshwQeditBound) {
6873
6937
  window.__dshwQeditBound = true
6874
6938
  document.addEventListener('pointerdown', function (e) {
@@ -8190,7 +8254,7 @@ bubbleBtns.appendChild(bubbleBtn('重置', 'dshwv-bubbtn-no', bubbleEditorReset)
8190
8254
  bubbleBtns.appendChild(bubbleBtn('保存', 'dshwv-bubbtn-ok', bubbleEditorSave))
8191
8255
  bubbleCard.appendChild(bubbleBtns)
8192
8256
  bubbleMask.appendChild(bubbleCard)
8193
- document.body.appendChild(bubbleMask)
8257
+ dshwBodyAppend(bubbleMask)
8194
8258
 
8195
8259
  // ===== W2 单泡编辑窗口 =====
8196
8260
  bubbleItemMask = document.createElement('div')
@@ -8274,7 +8338,7 @@ bubbleItemBtns.appendChild(bubbleBtn('恢复默认', 'dshwv-bubbtn-no', bubbleIt
8274
8338
  bubbleItemBtns.appendChild(bubbleBtn('保存', 'dshwv-bubbtn-ok', bubbleItemSave))
8275
8339
  bubbleItemCard.appendChild(bubbleItemBtns)
8276
8340
  bubbleItemMask.appendChild(bubbleItemCard)
8277
- document.body.appendChild(bubbleItemMask)
8341
+ dshwBodyAppend(bubbleItemMask)
8278
8342
 
8279
8343
  // ===== W3 模块编辑器(文本/随机/图片 + 样式) =====
8280
8344
  var moduleMask = null
@@ -8410,7 +8474,7 @@ function whaleZClean() {
8410
8474
  // 菜单宽度与触发按钮保持一致(原样式 min-width:100% 在 body 下会按视口撑满,须归零)
8411
8475
  function dshwDropOpen(menuEl, anchorEl) {
8412
8476
  try {
8413
- if (menuEl.parentNode !== document.body) document.body.appendChild(menuEl)
8477
+ if (menuEl.parentNode !== document.body) dshwBodyAppend(menuEl)
8414
8478
  menuEl.style.position = 'fixed'
8415
8479
  menuEl.style.minWidth = '0px'
8416
8480
  menuEl.style.left = '0px'
@@ -9461,7 +9525,7 @@ moduleBtns.appendChild(saveAsBtn)
9461
9525
  moduleBtns.appendChild(bubbleBtn('保存', 'dshwv-bubbtn-ok', function () { closeModuleEditor(true) }))
9462
9526
  moduleCard.appendChild(moduleBtns)
9463
9527
  moduleMask.appendChild(moduleCard)
9464
- document.body.appendChild(moduleMask)
9528
+ dshwBodyAppend(moduleMask)
9465
9529
  // 存为可选模块:名称输入弹窗
9466
9530
  var moduleNamePromptMask = document.createElement('div')
9467
9531
  moduleNamePromptMask.className = 'dshwv-confirmmask'
@@ -9494,7 +9558,7 @@ moduleNameBtns.appendChild(moduleNameCancel)
9494
9558
  moduleNameBtns.appendChild(moduleNameOk)
9495
9559
  moduleNamePromptCard.appendChild(moduleNameBtns)
9496
9560
  moduleNamePromptMask.appendChild(moduleNamePromptCard)
9497
- document.body.appendChild(moduleNamePromptMask)
9561
+ dshwBodyAppend(moduleNamePromptMask)
9498
9562
  // 回车保存/关闭
9499
9563
  moduleNameInput.addEventListener('keydown', function (e) {
9500
9564
  try {
@@ -9607,7 +9671,7 @@ cropCard.appendChild(cropZoomWrap)
9607
9671
  cropCard.appendChild(cropAngleWrap)
9608
9672
  cropCard.appendChild(cropBtns)
9609
9673
  cropMask.appendChild(cropCard)
9610
- document.body.appendChild(cropMask)
9674
+ dshwBodyAppend(cropMask)
9611
9675
  cropBox.addEventListener('pointerdown', onCropDown)
9612
9676
  cropBox.addEventListener('pointermove', onCropMove)
9613
9677
  cropBox.addEventListener('pointerup', onCropUp)
@@ -9707,7 +9771,7 @@ gifCard.appendChild(gifHint)
9707
9771
  gifCard.appendChild(gifNameInput)
9708
9772
  gifCard.appendChild(gifBtns)
9709
9773
  gifMask.appendChild(gifCard)
9710
- document.body.appendChild(gifMask)
9774
+ dshwBodyAppend(gifMask)
9711
9775
  gifCancelBtn.addEventListener('click', hideGifRoleModal)
9712
9776
  gifOkBtn.addEventListener('click', confirmGifRole)
9713
9777
  var gifRoleDataUrl = null
@@ -9788,7 +9852,7 @@ confirmBtns.appendChild(confirmYesBtn)
9788
9852
  confirmCard.appendChild(confirmText)
9789
9853
  confirmCard.appendChild(confirmBtns)
9790
9854
  confirmMask.appendChild(confirmCard)
9791
- document.body.appendChild(confirmMask)
9855
+ dshwBodyAppend(confirmMask)
9792
9856
  confirmNoBtn.addEventListener('click', hideConfirm)
9793
9857
  confirmYesBtn.addEventListener('click', function () {
9794
9858
  var cb = confirmCb
@@ -9880,7 +9944,7 @@ audioEditCard.appendChild(audioEditPressRow)
9880
9944
  audioEditCard.appendChild(audioEditReleaseRow)
9881
9945
  audioEditCard.appendChild(audioEditBtns)
9882
9946
  audioEditMask.appendChild(audioEditCard)
9883
- document.body.appendChild(audioEditMask)
9947
+ dshwBodyAppend(audioEditMask)
9884
9948
  audioEditCancel.addEventListener('click', hideAudioEditor)
9885
9949
  audioEditPlay.addEventListener('pointerdown', function (e) { e.stopPropagation(); audioEditPreviewDown() })
9886
9950
  audioEditPlay.addEventListener('pointerup', function (e) { e.stopPropagation(); audioEditPreviewUp() })
@@ -10033,7 +10097,7 @@ audioCropCard.appendChild(audioCropTime)
10033
10097
  audioCropCard.appendChild(audioCropNameRow)
10034
10098
  audioCropCard.appendChild(audioCropBtns)
10035
10099
  audioCropMask.appendChild(audioCropCard)
10036
- document.body.appendChild(audioCropMask)
10100
+ dshwBodyAppend(audioCropMask)
10037
10101
  audioCropCancel.addEventListener('click', hideAudioCrop)
10038
10102
  audioCropOk.addEventListener('click', confirmAudioCrop)
10039
10103
  audioCropPlay.addEventListener('click', previewAudioCrop)
@@ -10104,8 +10168,8 @@ body.appendChild(img)
10104
10168
  body.appendChild(bubbleBox)
10105
10169
  root.appendChild(body)
10106
10170
  root.appendChild(menuBtn)
10107
- document.body.appendChild(root)
10108
- document.body.appendChild(menuBox)
10171
+ dshwBodyAppend(root)
10172
+ dshwBodyAppend(menuBox)
10109
10173
 
10110
10174
  // ===== PR #105 后半:DOM 守护(SPA 切路由 / 别的插件替换 body 子树时把节点摘掉)=====
10111
10175
  // 背景:DSH 是 SPA,切到会话列表 / 设置 / 插件市场再回来、或其它客户端插件整体替换
@@ -10113,19 +10177,29 @@ document.body.appendChild(menuBox)
10113
10177
  // 做法:暴露 window.__dshWhaleRoot 供外部定位/调试,并用一个 MutationObserver 盯着;
10114
10178
  // 一旦发现节点已不在文档里就**把同一个节点补挂回 body**(不重建、不重新初始化,
10115
10179
  // 位置/设置/状态全部保留)。
10180
+ // v743:补挂范围从 root+menuBox 扩到**全部登记过的 body 节点**(见 dshwBodyNodes)——
10181
+ // 否则被整体替换后,遮罩/面板/隐藏 file input 会变成孤儿,功能静默失效;
10182
+ // 另外 root 还在、只有个别节点被摘掉的"局部移除"也要能自愈,所以做了节流的全量核对。
10116
10183
  try { window.__dshWhaleRoot = root } catch (err) {}
10117
10184
  function dshwReattachRoot() {
10118
10185
  try {
10119
- if (!root || root.isConnected) return
10120
- document.body.appendChild(root)
10121
- // 菜单是独立挂在 body 上的浮层:它也被摘掉且当前正打开时一并补回,否则菜单会"消失"
10122
- if (menuBox && menuOpen && !menuBox.isConnected) document.body.appendChild(menuBox)
10186
+ for (var i = 0; i < dshwBodyNodes.length; i++) {
10187
+ var el = dshwBodyNodes[i]
10188
+ if (el && !dshwConnected(el)) dshwBodyAppend(el)
10189
+ }
10123
10190
  } catch (err) {}
10124
10191
  }
10125
10192
  try {
10126
10193
  if (typeof MutationObserver === 'function') {
10194
+ var dshwGuardLastFull = 0
10127
10195
  var dshwRootGuard = new MutationObserver(function () {
10128
- if (root && !root.isConnected) dshwReattachRoot()
10196
+ try {
10197
+ if (!root) return
10198
+ if (!dshwConnected(root)) { dshwReattachRoot(); dshwGuardLastFull = Date.now(); return }
10199
+ // root 正常时也定期全量核对一次(最多 1.5 秒一次):覆盖"只有个别浮层被摘掉"的情况
10200
+ var now = Date.now()
10201
+ if (now - dshwGuardLastFull > 1500) { dshwGuardLastFull = now; dshwReattachRoot() }
10202
+ } catch (err) {}
10129
10203
  })
10130
10204
  dshwRootGuard.observe(document.documentElement, { childList: true, subtree: true })
10131
10205
  }
@@ -10909,8 +10983,18 @@ function apiCodexDays7(c) {
10909
10983
  // 列表行摘要
10910
10984
  function apiCodexRowText(am) {
10911
10985
  var c = am && am.codex
10912
- if (!c || !c.ok) return c && c.error ? ('⚠ ' + c.error) : '无 Codex 数据'
10913
- return 'Codex 今日 ' + apiFmtTokens(c.todayTokens) + ' · 近7天 ' + apiFmtTokens(apiCodexDays7(c)) + ' tokens'
10986
+ if (!c || !c.ok) {
10987
+ // issue #116:把「被关掉 / 找不到目录 / 出错」区分开,不再是一句笼统的失败
10988
+ if (c && c.disabled) return 'Codex 统计已关闭'
10989
+ return c && c.error ? ('⚠ ' + c.error) : '无 Codex 数据'
10990
+ }
10991
+ var txt = 'Codex 今日 ' + apiFmtTokens(c.todayTokens) + ' · 近7天 ' + apiFmtTokens(apiCodexDays7(c)) + ' tokens'
10992
+ // 护栏的实际情况要能看见(issue #116:原报告抱怨"无开关、无报错、无提示")
10993
+ var notes = []
10994
+ if (Number(c.skipped) > 0) notes.push('已跳过 ' + c.skipped + ' 个超大日志')
10995
+ if (Number(c.deferred) > 0) notes.push('统计更新中')
10996
+ if (notes.length) txt += '(' + notes.join(' · ') + ')'
10997
+ return txt
10914
10998
  }
10915
10999
  // 第二期:订阅窗口(5h / 周)。host 已把 rate_limits 归一成 { primary, secondary, planType }
10916
11000
  function apiCodexWinLabel(w, idx) {
@@ -11204,7 +11288,7 @@ function bubbleTplHelpToggle(m, anchor) {
11204
11288
  if (!dshwvTplHelpEl) {
11205
11289
  dshwvTplHelpEl = document.createElement('div')
11206
11290
  dshwvTplHelpEl.className = 'dshwv-tplhelp'
11207
- document.body.appendChild(dshwvTplHelpEl)
11291
+ dshwBodyAppend(dshwvTplHelpEl)
11208
11292
  document.addEventListener('pointerdown', function (e) {
11209
11293
  if (!dshwvTplHelpEl || dshwvTplHelpEl.style.display === 'none') return
11210
11294
  try {
@@ -11248,7 +11332,7 @@ function dshwvHintEnsure() {
11248
11332
  dshwvHintEl = document.createElement('div')
11249
11333
  dshwvHintEl.className = 'dshwv-tplhelp dshwv-hintbox'
11250
11334
  dshwvHintEl.style.display = 'none'
11251
- document.body.appendChild(dshwvHintEl)
11335
+ dshwBodyAppend(dshwvHintEl)
11252
11336
  document.addEventListener('pointerdown', function (e) {
11253
11337
  if (!dshwvHintEl || dshwvHintEl.style.display === 'none') return
11254
11338
  try {
@@ -12369,6 +12453,9 @@ var costBubbleActive = false
12369
12453
  var scrollGapOn = false
12370
12454
  var scrollGapPx = 17
12371
12455
  var menuBtnHide = false // 主菜单开关:隐藏挂件菜单按钮,改为右键小鲸鱼唤出菜单
12456
+ // issue #116:Codex 本机统计开关(默认开)。关掉后宿主完全不扫 ~/.codex/sessions,
12457
+ // 适合会话日志很大的机器(超大日志会让统计扫描变得很重)。
12458
+ var codexStatsOn = true
12372
12459
  // —— v734(issue #97 / #88):设置保存的「防覆盖 + 失败可见」——
12373
12460
  // #97 根因:首次 GET 还没落地就 PUT,会把内存里的默认值整包写进服务端(重启后设置被洗成默认值)。
12374
12461
  // #88 根因:这个 PUT 以前是 fire-and-forget,服务端 500 / {ok:false} 完全没人读。
@@ -12385,7 +12472,7 @@ function dshwvToast(msg) {
12385
12472
  'max-width:min(560px,calc(100vw - 32px));box-sizing:border-box;padding:10px 14px;border-radius:10px;' +
12386
12473
  'background:#8a1f1f;color:#fff;font-size:13px;line-height:1.6;box-shadow:0 6px 20px rgba(0,0,0,.28);' +
12387
12474
  'pointer-events:none;text-align:center'
12388
- document.body.appendChild(el)
12475
+ dshwBodyAppend(el)
12389
12476
  dshwvToastEl = el
12390
12477
  }
12391
12478
  dshwvToastEl.innerHTML = msg
@@ -12402,7 +12489,7 @@ function configSaveFailNotice(detail) {
12402
12489
  '<br>已自动重试一次。若持续失败,请检查 DSH 数据目录是否可写。')
12403
12490
  }
12404
12491
  function configPayload() {
12405
- return JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode, peakMode: peakMode, bubbleOn: bubbleOn, turnCostOn: turnCostOn, turnCostCloseMs: turnCostCloseMs, scrollGapOn: scrollGapOn, scrollGapPx: scrollGapPx, menuBtnHide: menuBtnHide })
12492
+ return JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode, peakMode: peakMode, bubbleOn: bubbleOn, turnCostOn: turnCostOn, turnCostCloseMs: turnCostCloseMs, scrollGapOn: scrollGapOn, scrollGapPx: scrollGapPx, menuBtnHide: menuBtnHide, codexStatsOn: codexStatsOn })
12406
12493
  }
12407
12494
  // 真正的 PUT:读响应 → 失败(网络异常 / HTTP!=200 / {ok:false})静默重试一次 → 仍失败才提示
12408
12495
  function configPut(payload, retried) {
@@ -12424,12 +12511,15 @@ function configPut(payload, retried) {
12424
12511
  }
12425
12512
  function saveConfig() {
12426
12513
  // issue #97:加载完成前只记待办,绝不 PUT(否则把默认值整包写进服务端)
12427
- if (!configLoaded) { configSavePending = true; return }
12514
+ if (!configLoaded) { configSavePending = true; return null }
12428
12515
  try {
12429
- configPut(configPayload(), false)
12516
+ // 返回 Promise(v743:Codex 统计开关需要"等服务端确认后再刷新")。
12517
+ // configPut 自带重试与失败提示、且链尾有 catch,所以不会有 unhandled rejection。
12518
+ var p = configPut(configPayload(), false)
12430
12519
  // 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
12431
12520
  saveAnchorPos()
12432
- } catch (err) {}
12521
+ return p
12522
+ } catch (err) { return null }
12433
12523
  }
12434
12524
  // 单独抽出:只写 localStorage 锚点(不碰尺寸设置);applyAnchorPos / settle 自愈时也要用。
12435
12525
  // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
@@ -12532,6 +12622,18 @@ function setMenuBtnHide(v) {
12532
12622
  saveConfig()
12533
12623
  applyMenuBtnHideUI()
12534
12624
  }
12625
+ // issue #116:Codex 本机统计开关。关掉后宿主不扫描 ~/.codex/sessions;
12626
+ // 打开/关闭都要重取一次模型列表,让 Codex 那行的统计/提示立刻跟着变。
12627
+ function setCodexStatsOn(v) {
12628
+ codexStatsOn = v !== false
12629
+ if (codexStatsToggle) codexStatsToggle.checked = codexStatsOn
12630
+ var after = function () { try { refreshModelList() } catch (err) {} }
12631
+ var p = null
12632
+ try { p = saveConfig() } catch (err) {}
12633
+ // 等服务端确认落盘后再重取模型列表:否则可能读到旧配置,那一行会先显示上一次的状态再跳变
12634
+ if (p && typeof p.then === 'function') p.then(after, after)
12635
+ else after()
12636
+ }
12535
12637
  function scaleToDisplay(s) {
12536
12638
  return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
12537
12639
  }
@@ -13776,7 +13878,7 @@ var audioCropFileInput = document.createElement('input')
13776
13878
  audioCropFileInput.type = 'file'
13777
13879
  audioCropFileInput.accept = 'audio/*'
13778
13880
  audioCropFileInput.style.display = 'none'
13779
- document.body.appendChild(audioCropFileInput)
13881
+ dshwBodyAppend(audioCropFileInput)
13780
13882
  var audioCropTarget = null // 'press' | 'release'
13781
13883
  var audioCropCtx = null // AudioContext
13782
13884
  var audioCropBuffer = null // AudioBuffer
@@ -14765,6 +14867,11 @@ fetch(SIZE_URL, { cache: 'no-store' })
14765
14867
  menuBtnHide = d.menuBtnHide
14766
14868
  if (menuHideToggle) menuHideToggle.checked = menuBtnHide
14767
14869
  }
14870
+ // issue #116:Codex 本机统计开关(老配置里没有这个键 → 保持默认「开」)
14871
+ if (d && typeof d.codexStatsOn === 'boolean') {
14872
+ codexStatsOn = d.codexStatsOn
14873
+ if (codexStatsToggle) codexStatsToggle.checked = codexStatsOn
14874
+ }
14768
14875
  // 无论服务端带没带这个键都要应用一次:触屏上 ☰ 需要常显(issue #91 缺陷2),
14769
14876
  // 而旧写法只在键存在时才调用,空配置下按钮永远是透明的。
14770
14877
  applyMenuBtnHideUI()
package/lib/index.js CHANGED
@@ -929,7 +929,7 @@ export default {
929
929
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
930
930
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
931
931
  return {
932
- ok: true, version: '0.3.5', today: todayData, days7,
932
+ ok: true, version: '0.3.7', today: todayData, days7,
933
933
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
934
934
  all: {
935
935
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1085,14 +1085,14 @@ export default {
1085
1085
  walk(path.join(root, 'archived_sessions'), 0)
1086
1086
  return out
1087
1087
  }
1088
- // 解析单个会话文件 → { day: { model: {in,cached,cwrite,out,reason,total,turns} } }
1089
- function parseCodexFile(file) {
1088
+ // 解析单个会话文件的**文本** → { days, rl, rlTs }
1089
+ // issue #116:读取改由调用方异步完成(先判大小、读完让出事件循环),这里只做纯解析。
1090
+ function parseCodexFileText(text) {
1090
1091
  const days = {}
1091
1092
  let model = 'codex'
1092
1093
  let prevTotal = null
1093
1094
  let lastRl = null, rlTs = 0
1094
- let text = ''
1095
- try { text = fs.readFileSync(file, 'utf8') } catch (err) { return { days, rl: null, rlTs: 0 } }
1095
+ if (typeof text !== 'string' || !text) return { days, rl: null, rlTs: 0 }
1096
1096
  const bump = (day, mk, v) => {
1097
1097
  days[day] = days[day] || {}
1098
1098
  const cur = days[day][mk] || { in: 0, cached: 0, cwrite: 0, out: 0, reason: 0, total: 0, turns: 0 }
@@ -1194,21 +1194,57 @@ export default {
1194
1194
  }
1195
1195
  }
1196
1196
  // 汇总(带增量缓存:只有变化的文件才重新解析)
1197
- function codexSummary() {
1197
+ // ===== issue #116:Codex 本地统计的护栏 =====
1198
+ // 旧实现是「启动 1.5s + 每 5 分钟」在事件循环上做**同步**全量扫描:递归遍历
1199
+ // ~/.codex/sessions,对每个过期文件 readFileSync + split('\n') + 逐行 JSON.parse。
1200
+ // 会话攒多之后,一轮扫描能冻结事件循环数分钟(整个 dsh web 不响应、满核 CPU),
1201
+ // 而且单个超过 V8 字符串上限(约 512MB)的 rollout 会抛 ERR_STRING_TOO_LONG ——
1202
+ // 由于是在写缓存之前就抛出,那个文件每轮都会被重读,size/mtime 又一直在变,永久复发。
1203
+ // 现在三条护栏:
1204
+ // ① 单文件上限:超过 CODEX_MAX_FILE_BYTES 直接跳过(结果记进缓存,不再重读),
1205
+ // 并在汇总里报 skipped / skippedBytes,界面上能看出"有文件被跳过";
1206
+ // ② 单轮预算:一轮最多读 CODEX_BUDGET_FILES 个 / CODEX_BUDGET_BYTES 字节,超了就
1207
+ // 带 deferred 出结果,本轮立刻返回、稍后再刷新 —— 绝不长时间占住事件循环;
1208
+ // ③ 全程异步(fs.promises)+ 每个文件处理后让出一次事件循环(await setImmediate)。
1209
+ const CODEX_MAX_FILE_BYTES = 32 * 1024 * 1024
1210
+ const CODEX_BUDGET_FILES = 400
1211
+ const CODEX_BUDGET_BYTES = 96 * 1024 * 1024
1212
+ const CODEX_SNAP_TTL = 60 * 1000
1213
+ const yieldLoop = () => new Promise((r) => setImmediate(r))
1214
+
1215
+ // 汇总(带增量缓存:只有变化的文件才重新解析)。异步、受预算约束。
1216
+ async function codexScan() {
1198
1217
  const home = codexHome()
1199
1218
  if (!home) return { ok: false, error: '未找到 Codex 目录($CODEX_HOME 或 ~/.codex)' }
1200
1219
  const cache = readCodexCache()
1201
1220
  const files = listCodexSessionFiles(home)
1202
1221
  const keep = {}
1203
- let changed = 0
1222
+ let changed = 0, skipped = 0, skippedBytes = 0, deferred = 0, readBytes = 0, parsedCount = 0
1204
1223
  for (const f of files) {
1205
1224
  let st = null
1206
- try { st = fs.statSync(f) } catch (err) { continue }
1225
+ try { st = await fs.promises.stat(f) } catch (err) { continue }
1207
1226
  const prev = cache.files[f]
1208
- if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs && prev.days) { keep[f] = prev; continue }
1209
- const parsed = parseCodexFile(f)
1227
+ if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs && prev.days) {
1228
+ keep[f] = prev
1229
+ if (prev.skip === 'too-big') { skipped++; skippedBytes += Number(st.size) || 0 }
1230
+ continue
1231
+ }
1232
+ // ① 单文件上限:跳过超大文件(errno 不再产生,也不再每轮重读)
1233
+ if (st.size > CODEX_MAX_FILE_BYTES) {
1234
+ keep[f] = { size: st.size, mtimeMs: st.mtimeMs, days: {}, rl: null, rlTs: 0, skip: 'too-big' }
1235
+ skipped++; skippedBytes += st.size; changed++
1236
+ continue
1237
+ }
1238
+ // ② 单轮预算:本轮读够了就停,剩下的留到下一次刷新
1239
+ if (readBytes + st.size > CODEX_BUDGET_BYTES || parsedCount >= CODEX_BUDGET_FILES) { deferred++; continue }
1240
+ let text = ''
1241
+ try { text = await fs.promises.readFile(f, 'utf8') } catch (err) { text = '' }
1242
+ readBytes += st.size
1243
+ const parsed = parseCodexFileText(text)
1210
1244
  keep[f] = { size: st.size, mtimeMs: st.mtimeMs, days: parsed.days, rl: parsed.rl || null, rlTs: parsed.rlTs || 0 }
1211
1245
  changed++
1246
+ parsedCount++
1247
+ await yieldLoop() // ③ 每个文件让出一次,HTTP 请求不会被整轮扫描堵住
1212
1248
  }
1213
1249
  if (changed > 0 || Object.keys(cache.files).length !== Object.keys(keep).length) {
1214
1250
  writeCodexCache({ version: 1, files: keep, builtAt: Date.now() })
@@ -1251,6 +1287,9 @@ export default {
1251
1287
  }
1252
1288
  return {
1253
1289
  ok: true, home, sessions: files.length, changed,
1290
+ // issue #116:把护栏的实际情况报出来,界面/日志能看出"有文件被跳过或本轮没扫完"
1291
+ skipped, skippedBytes, deferred, readBytes,
1292
+ maxFileBytes: CODEX_MAX_FILE_BYTES,
1254
1293
  todayTokens, monthTokens, totalTokens,
1255
1294
  outTokens, reasonTokens, cachedTokens,
1256
1295
  days7, byModel,
@@ -1260,23 +1299,80 @@ export default {
1260
1299
  rateLimitsTs: bestRlTs > 0 ? bestRlTs : 0,
1261
1300
  }
1262
1301
  }
1263
- // codexSummary 的短 TTL 记忆:payload 循环 + 额度计算 + 探活会在同一次请求里反复问同一份数据,
1264
- // 每次都同步 stat/读文件会白白阻塞事件循环(会话文件可能有几十 MB)
1265
- let codexMemo = null, codexMemoAt = 0
1302
+ // ===== 汇总快照(issue #116)=====
1303
+ // 关键变化:**任何调用方都不会再触发同步扫描**。
1304
+ // · codexSummaryCached():同步、永不阻塞 —— 直接返回上一次快照;快照过期时
1305
+ // 「顺手发起」一次后台刷新(不等待),所以同步调用点(额度计算)也一样安全;
1306
+ // · codexSummaryEnsured(ms):需要尽量新的地方(探活 / 模型列表)用,等在途扫描,
1307
+ // 最多等 ms 毫秒,超时就用当前快照,绝不无限等;
1308
+ // · 同一时刻只允许一次扫描在跑(去重),预算用完时安排一次稍后的补扫。
1309
+ let codexSnap = null
1310
+ let codexSnapAt = 0
1311
+ let codexScanP = null
1312
+ let codexCatchupT = null
1313
+ // 配置变更(例如用户在设置里关掉 Codex 统计)后必须让快照失效,
1314
+ // 否则下一次请求还会命中关闭前的旧快照、开关看起来"没生效"。
1315
+ function codexInvalidate() { codexSnap = null; codexSnapAt = 0; codexStatsOnV = null; codexStatsOnAt = 0 }
1316
+ // 开关读取加 2 秒缓存:codexBackgroundRefresh 可能被高频调用(快照过期时每次 payload
1317
+ // 构建都会试一次),而 readSizeConfig() 是同步文件读 —— 不能放在热路径上。
1318
+ // 用户改设置时 codexInvalidate() 会清掉缓存,所以开关仍然是"立刻生效"。
1319
+ let codexStatsOnV = null, codexStatsOnAt = 0
1320
+ function codexStatsOn() {
1321
+ const now = Date.now()
1322
+ if (codexStatsOnV !== null && now - codexStatsOnAt < 2000) return codexStatsOnV
1323
+ let v = true
1324
+ try {
1325
+ const cfg = readSizeConfig()
1326
+ v = !cfg || cfg.codexStatsOn !== false
1327
+ } catch (err) { v = true }
1328
+ codexStatsOnV = v
1329
+ codexStatsOnAt = now
1330
+ return v
1331
+ }
1332
+ function codexBackgroundRefresh(delayMs) {
1333
+ if (codexScanP) return codexScanP // 已在扫:先返回,避免重复读配置/重复排队
1334
+ if (!codexStatsOn()) {
1335
+ codexSnap = { ok: false, disabled: true, error: 'Codex 统计已在设置里关闭' }
1336
+ codexSnapAt = Date.now()
1337
+ return null
1338
+ }
1339
+ if (delayMs > 0) {
1340
+ if (codexCatchupT) return null
1341
+ codexCatchupT = setTimeout(() => { codexCatchupT = null; codexBackgroundRefresh(0) }, delayMs)
1342
+ try { if (codexCatchupT.unref) codexCatchupT.unref() } catch (err) {}
1343
+ return null
1344
+ }
1345
+ codexScanP = codexScan()
1346
+ .then((s) => {
1347
+ codexSnap = s
1348
+ codexSnapAt = Date.now()
1349
+ // 本轮没扫完(预算用尽)→ 稍后补扫,逐步把统计补齐,而不是一次占满事件循环
1350
+ if (s && s.ok && s.deferred > 0) codexBackgroundRefresh(5000)
1351
+ })
1352
+ .catch((err) => {
1353
+ codexSnap = { ok: false, error: String((err && err.message) || err) }
1354
+ codexSnapAt = Date.now()
1355
+ })
1356
+ .finally(() => { codexScanP = null })
1357
+ return codexScanP
1358
+ }
1266
1359
  function codexSummaryCached() {
1267
1360
  const now = Date.now()
1268
- if (codexMemo && now - codexMemoAt < 5000) return codexMemo
1269
- codexMemo = codexSummary()
1270
- codexMemoAt = now
1271
- return codexMemo
1272
- }
1273
- // 后台预热:codexSummary 首次要同步读全部 rollout 日志(可能几十 MB),
1274
- // 放在请求路径里会阻塞事件循环。这里在插件启动后预热一次,并每 5 分钟刷新一次缓存,
1275
- // 请求进来时通常直接命中 codexSummaryCached 5 秒记忆。
1361
+ if (!codexSnap || now - codexSnapAt > CODEX_SNAP_TTL) codexBackgroundRefresh(0)
1362
+ return codexSnap
1363
+ }
1364
+ async function codexSummaryEnsured(maxWaitMs) {
1365
+ const stale = !codexSnap || Date.now() - codexSnapAt > CODEX_SNAP_TTL
1366
+ const p = codexScanP || (stale ? codexBackgroundRefresh(0) : null)
1367
+ if (!p) return codexSnap
1368
+ try { await Promise.race([p, new Promise((r) => setTimeout(r, Math.max(200, maxWaitMs || 2500)))]) } catch (err) {}
1369
+ return codexSnap
1370
+ }
1371
+ // 后台预热:启动后预热一次,并每 5 分钟刷新一次缓存(现在都是异步 + 有预算的,不再阻塞)。
1276
1372
  let codexPrewarmT = null, codexPrewarmI = null
1277
1373
  try {
1278
- codexPrewarmT = setTimeout(function () { try { codexSummaryCached() } catch (err) {} }, 1500)
1279
- codexPrewarmI = setInterval(function () { try { codexSummaryCached() } catch (err) {} }, 5 * 60 * 1000)
1374
+ codexPrewarmT = setTimeout(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 1500)
1375
+ codexPrewarmI = setInterval(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 5 * 60 * 1000)
1280
1376
  // issue #109:这两个定时器原先不存句柄、也不进 disposers,插件树 dispose 之后
1281
1377
  // 事件循环里仍有一个被引用的 interval → 用户退出 dsh 时进程不结束(只能 Ctrl+C)。
1282
1378
  // 存句柄并纳入 disposers,卸载时一并清掉;unref 作为兜底(正常运行时由 HTTP 服务维持事件循环)。
@@ -1285,6 +1381,7 @@ export default {
1285
1381
  disposers.push(function () {
1286
1382
  if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null }
1287
1383
  if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null }
1384
+ if (codexCatchupT !== null) { clearTimeout(codexCatchupT); codexCatchupT = null }
1288
1385
  })
1289
1386
  } catch (err) {}
1290
1387
  // ===== 自定义 API 模型注册表(v655)=====
@@ -1426,7 +1523,7 @@ export default {
1426
1523
  }
1427
1524
  // Codex 模式:本地会话统计即「探活结果」,不需要网络与密钥
1428
1525
  if (tpl.kind === 'codex') {
1429
- const cs = codexSummaryCached()
1526
+ const cs = await codexSummaryEnsured(2500)
1430
1527
  const f = (n) => (n >= 100000000 ? (n / 100000000).toFixed(2) + '亿' : (n >= 10000 ? (n / 10000).toFixed(1).replace(/\.0$/, '') + '万' : String(Math.round(Number(n) || 0))))
1431
1528
  if (cs && cs.ok) {
1432
1529
  return { ok: true, detail: 'Codex 本地会话:今日 ' + f(cs.todayTokens) + ' · 本月 ' + f(cs.monthTokens) + ' · 累计 ' + f(cs.totalTokens) + ' tokens(' + cs.sessions + ' 个会话文件)' }
@@ -1923,10 +2020,12 @@ export default {
1923
2020
  entry.planSupport = !!tplM.quota
1924
2021
  // Codex 模式:不查余额、不需要密钥,直接给本地会话统计(今日/本月/累计/近7天)
1925
2022
  if (tplM.kind === 'codex') {
1926
- if (!codexStats) codexStats = codexSummaryCached()
2023
+ // 这里能等:模型列表请求可以稍等一下在途扫描(最多 2.5s),但绝不触发同步扫描
2024
+ if (!codexStats) codexStats = await codexSummaryEnsured(2500)
1927
2025
  entry.codex = codexStats
1928
2026
  entry.hasKey = true
1929
- entry.error = codexStats && codexStats.ok ? null : ((codexStats && codexStats.error) || '未找到 Codex 会话目录')
2027
+ entry.error = codexStats && codexStats.ok ? null
2028
+ : (codexStats && codexStats.disabled ? null : ((codexStats && codexStats.error) || '未找到 Codex 会话目录'))
1930
2029
  }
1931
2030
  // 没有余额接口的厂商(如火山方舟):余额显示「—」,只用会话事件估算
1932
2031
  entry.balanceMode = hasBalanceApi ? 'api' : 'events'
@@ -2005,7 +2104,7 @@ export default {
2005
2104
  const led = readUsageLedger()
2006
2105
  const summary = daySummary(led, todayKey())
2007
2106
  return {
2008
- ...visible, version: '0.3.5', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
2107
+ ...visible, version: '0.3.7', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
2009
2108
  todayUsage: summary.amount, todayUsageCurrency: summary.currency,
2010
2109
  usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
2011
2110
  accounting: balanceSummary(led, todayKey()),
@@ -2056,6 +2155,9 @@ export default {
2056
2155
  scrollGapOn: parsed.scrollGapOn === true,
2057
2156
  scrollGapPx: typeof parsed.scrollGapPx === 'number' ? Math.round(parsed.scrollGapPx) : 17,
2058
2157
  menuBtnHide: parsed.menuBtnHide === true,
2158
+ // issue #116:Codex 本地统计可以在设置里关掉(默认开)。关掉后宿主完全不扫
2159
+ // ~/.codex/sessions,也不做后台预热。
2160
+ codexStatsOn: parsed.codexStatsOn !== false,
2059
2161
  }
2060
2162
  }
2061
2163
  } catch (err) {}
@@ -2063,7 +2165,7 @@ export default {
2063
2165
  return null
2064
2166
  }
2065
2167
 
2066
- function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide) {
2168
+ function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide, codexStatsOnArg) {
2067
2169
  const um = normalizeUsageMode(usageMode)
2068
2170
  const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
2069
2171
  const bo = bubbleOn !== false
@@ -2072,6 +2174,7 @@ export default {
2072
2174
  const sgo = scrollGapOn === true
2073
2175
  const sgp = typeof scrollGapPx === 'number' && scrollGapPx > 0 ? Math.round(scrollGapPx) : 0
2074
2176
  const mbh = menuBtnHide === true
2177
+ const cso = codexStatsOnArg !== false
2075
2178
  const body = JSON.stringify({
2076
2179
  scale: scale,
2077
2180
  sound: sound !== false,
@@ -2085,6 +2188,7 @@ export default {
2085
2188
  scrollGapOn: sgo,
2086
2189
  scrollGapPx: sgp,
2087
2190
  menuBtnHide: mbh,
2191
+ codexStatsOn: cso,
2088
2192
  updatedAt: new Date().toISOString(),
2089
2193
  })
2090
2194
  let lastSizeErr = null
@@ -2105,6 +2209,7 @@ export default {
2105
2209
  scrollGapOn: sgo,
2106
2210
  scrollGapPx: sgp,
2107
2211
  menuBtnHide: mbh,
2212
+ codexStatsOn: cso,
2108
2213
  }
2109
2214
  } catch (err) { lastSizeErr = err }
2110
2215
  }
@@ -2512,9 +2617,12 @@ export default {
2512
2617
  pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
2513
2618
  pickB(parsed.scrollGapOn, old.scrollGapOn, false),
2514
2619
  pickN(parsed.scrollGapPx, old.scrollGapPx, 17),
2515
- pickB(parsed.menuBtnHide, old.menuBtnHide, false)
2620
+ pickB(parsed.menuBtnHide, old.menuBtnHide, false),
2621
+ pickB(parsed.codexStatsOn, old.codexStatsOn, true)
2516
2622
  )
2517
2623
  res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
2624
+ // 配置刚变(可能刚关掉/打开 Codex 统计)→ 丢掉旧快照,让下一次请求立刻反映新设置
2625
+ if (result.ok) { try { codexInvalidate() } catch (err) {} }
2518
2626
  res.end(JSON.stringify(result))
2519
2627
  } catch (err) {
2520
2628
  res.writeHead(400, JSON_HEADERS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",