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.
- package/assets/whale-widget.js +151 -44
- package/lib/index.js +137 -29
- package/package.json +1 -1
package/assets/whale-widget.js
CHANGED
|
@@ -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
|
|
10
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10108
|
-
|
|
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
|
-
|
|
10120
|
-
|
|
10121
|
-
|
|
10122
|
-
|
|
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
|
-
|
|
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)
|
|
10913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12516
|
+
// 返回 Promise(v743:Codex 统计开关需要"等服务端确认后再刷新")。
|
|
12517
|
+
// configPut 自带重试与失败提示、且链尾有 catch,所以不会有 unhandled rejection。
|
|
12518
|
+
var p = configPut(configPayload(), false)
|
|
12430
12519
|
// 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
|
|
12431
12520
|
saveAnchorPos()
|
|
12432
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
1089
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|
|
1209
|
-
|
|
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
|
-
//
|
|
1264
|
-
//
|
|
1265
|
-
|
|
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 (
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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 {
|
|
1279
|
-
codexPrewarmI = setInterval(function () { try {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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.
|
|
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