dsh-whale-widget 0.3.4 → 0.3.6
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 +94 -8
- package/lib/index.js +132 -31
- package/package.json +1 -1
package/assets/whale-widget.js
CHANGED
|
@@ -6,10 +6,21 @@ 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
|
+
r.querySelector('[data-composer-input]') ||
|
|
22
|
+
r.querySelector('[role="textbox"]')
|
|
23
|
+
)
|
|
13
24
|
}
|
|
14
25
|
var dshwStarted = false
|
|
15
26
|
function dshwStartOnce() {
|
|
@@ -1342,6 +1353,17 @@ var rowHide = menuRow()
|
|
|
1342
1353
|
rowHide.appendChild(menuLabel('隐藏菜单按钮'))
|
|
1343
1354
|
rowHide.appendChild(menuHideToggle)
|
|
1344
1355
|
menuBox.appendChild(rowHide)
|
|
1356
|
+
// —— Codex 本机统计开关(issue #116):关掉后宿主不再扫描 ~/.codex/sessions ——
|
|
1357
|
+
var codexStatsToggle = document.createElement('input')
|
|
1358
|
+
codexStatsToggle.type = 'checkbox'
|
|
1359
|
+
codexStatsToggle.className = 'dshwv-check'
|
|
1360
|
+
codexStatsToggle.checked = true
|
|
1361
|
+
codexStatsToggle.title = '关闭后不再读取 ~/.codex/sessions 统计本机 Codex 用量(会话日志很大时建议关闭)'
|
|
1362
|
+
codexStatsToggle.addEventListener('change', function () { setCodexStatsOn(codexStatsToggle.checked) })
|
|
1363
|
+
var rowCodexStats = menuRow()
|
|
1364
|
+
rowCodexStats.appendChild(menuLabel('Codex 本机统计'))
|
|
1365
|
+
rowCodexStats.appendChild(codexStatsToggle)
|
|
1366
|
+
menuBox.appendChild(rowCodexStats)
|
|
1345
1367
|
// —— 资源管理:集中查看/删除已导入的图片与音频(角色图/泡泡图/音频片段/音效组) ——
|
|
1346
1368
|
var rowRes = menuRow()
|
|
1347
1369
|
var resOpenBtn = document.createElement('button')
|
|
@@ -10107,6 +10129,30 @@ root.appendChild(menuBtn)
|
|
|
10107
10129
|
document.body.appendChild(root)
|
|
10108
10130
|
document.body.appendChild(menuBox)
|
|
10109
10131
|
|
|
10132
|
+
// ===== PR #105 后半:DOM 守护(SPA 切路由 / 别的插件替换 body 子树时把节点摘掉)=====
|
|
10133
|
+
// 背景:DSH 是 SPA,切到会话列表 / 设置 / 插件市场再回来、或其它客户端插件整体替换
|
|
10134
|
+
// document.body 的子树时,挂件节点会被顺带移除,而它不会自己回来(脚本只初始化一次)。
|
|
10135
|
+
// 做法:暴露 window.__dshWhaleRoot 供外部定位/调试,并用一个 MutationObserver 盯着;
|
|
10136
|
+
// 一旦发现节点已不在文档里就**把同一个节点补挂回 body**(不重建、不重新初始化,
|
|
10137
|
+
// 位置/设置/状态全部保留)。
|
|
10138
|
+
try { window.__dshWhaleRoot = root } catch (err) {}
|
|
10139
|
+
function dshwReattachRoot() {
|
|
10140
|
+
try {
|
|
10141
|
+
if (!root || root.isConnected) return
|
|
10142
|
+
document.body.appendChild(root)
|
|
10143
|
+
// 菜单是独立挂在 body 上的浮层:它也被摘掉且当前正打开时一并补回,否则菜单会"消失"
|
|
10144
|
+
if (menuBox && menuOpen && !menuBox.isConnected) document.body.appendChild(menuBox)
|
|
10145
|
+
} catch (err) {}
|
|
10146
|
+
}
|
|
10147
|
+
try {
|
|
10148
|
+
if (typeof MutationObserver === 'function') {
|
|
10149
|
+
var dshwRootGuard = new MutationObserver(function () {
|
|
10150
|
+
if (root && !root.isConnected) dshwReattachRoot()
|
|
10151
|
+
})
|
|
10152
|
+
dshwRootGuard.observe(document.documentElement, { childList: true, subtree: true })
|
|
10153
|
+
}
|
|
10154
|
+
} catch (err) {}
|
|
10155
|
+
|
|
10110
10156
|
// 泡泡内容整体与视觉中心对齐:
|
|
10111
10157
|
// 读取 SVG 主体(bshape)的包围盒,取其中点作为文字内容区的视觉中心,
|
|
10112
10158
|
// 写入 --dshw-vx/--dshw-vy(相对 .dshwv-pop 尺寸的百分比)。
|
|
@@ -10885,8 +10931,18 @@ function apiCodexDays7(c) {
|
|
|
10885
10931
|
// 列表行摘要
|
|
10886
10932
|
function apiCodexRowText(am) {
|
|
10887
10933
|
var c = am && am.codex
|
|
10888
|
-
if (!c || !c.ok)
|
|
10889
|
-
|
|
10934
|
+
if (!c || !c.ok) {
|
|
10935
|
+
// issue #116:把「被关掉 / 找不到目录 / 出错」区分开,不再是一句笼统的失败
|
|
10936
|
+
if (c && c.disabled) return 'Codex 统计已关闭'
|
|
10937
|
+
return c && c.error ? ('⚠ ' + c.error) : '无 Codex 数据'
|
|
10938
|
+
}
|
|
10939
|
+
var txt = 'Codex 今日 ' + apiFmtTokens(c.todayTokens) + ' · 近7天 ' + apiFmtTokens(apiCodexDays7(c)) + ' tokens'
|
|
10940
|
+
// 护栏的实际情况要能看见(issue #116:原报告抱怨"无开关、无报错、无提示")
|
|
10941
|
+
var notes = []
|
|
10942
|
+
if (Number(c.skipped) > 0) notes.push('已跳过 ' + c.skipped + ' 个超大日志')
|
|
10943
|
+
if (Number(c.deferred) > 0) notes.push('统计更新中')
|
|
10944
|
+
if (notes.length) txt += '(' + notes.join(' · ') + ')'
|
|
10945
|
+
return txt
|
|
10890
10946
|
}
|
|
10891
10947
|
// 第二期:订阅窗口(5h / 周)。host 已把 rate_limits 归一成 { primary, secondary, planType }
|
|
10892
10948
|
function apiCodexWinLabel(w, idx) {
|
|
@@ -12345,6 +12401,9 @@ var costBubbleActive = false
|
|
|
12345
12401
|
var scrollGapOn = false
|
|
12346
12402
|
var scrollGapPx = 17
|
|
12347
12403
|
var menuBtnHide = false // 主菜单开关:隐藏挂件菜单按钮,改为右键小鲸鱼唤出菜单
|
|
12404
|
+
// issue #116:Codex 本机统计开关(默认开)。关掉后宿主完全不扫 ~/.codex/sessions,
|
|
12405
|
+
// 适合会话日志很大的机器(超大日志会让统计扫描变得很重)。
|
|
12406
|
+
var codexStatsOn = true
|
|
12348
12407
|
// —— v734(issue #97 / #88):设置保存的「防覆盖 + 失败可见」——
|
|
12349
12408
|
// #97 根因:首次 GET 还没落地就 PUT,会把内存里的默认值整包写进服务端(重启后设置被洗成默认值)。
|
|
12350
12409
|
// #88 根因:这个 PUT 以前是 fire-and-forget,服务端 500 / {ok:false} 完全没人读。
|
|
@@ -12378,7 +12437,7 @@ function configSaveFailNotice(detail) {
|
|
|
12378
12437
|
'<br>已自动重试一次。若持续失败,请检查 DSH 数据目录是否可写。')
|
|
12379
12438
|
}
|
|
12380
12439
|
function configPayload() {
|
|
12381
|
-
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 })
|
|
12440
|
+
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 })
|
|
12382
12441
|
}
|
|
12383
12442
|
// 真正的 PUT:读响应 → 失败(网络异常 / HTTP!=200 / {ok:false})静默重试一次 → 仍失败才提示
|
|
12384
12443
|
function configPut(payload, retried) {
|
|
@@ -12508,6 +12567,14 @@ function setMenuBtnHide(v) {
|
|
|
12508
12567
|
saveConfig()
|
|
12509
12568
|
applyMenuBtnHideUI()
|
|
12510
12569
|
}
|
|
12570
|
+
// issue #116:Codex 本机统计开关。关掉后宿主不扫描 ~/.codex/sessions;
|
|
12571
|
+
// 打开/关闭都要重取一次模型列表,让 Codex 那行的统计/提示立刻跟着变。
|
|
12572
|
+
function setCodexStatsOn(v) {
|
|
12573
|
+
codexStatsOn = v !== false
|
|
12574
|
+
if (codexStatsToggle) codexStatsToggle.checked = codexStatsOn
|
|
12575
|
+
saveConfig()
|
|
12576
|
+
try { refreshModelList() } catch (err) {}
|
|
12577
|
+
}
|
|
12511
12578
|
function scaleToDisplay(s) {
|
|
12512
12579
|
return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
|
|
12513
12580
|
}
|
|
@@ -14387,7 +14454,7 @@ function onDocPointerUp(e) {
|
|
|
14387
14454
|
try { if (isWhaleHit(e)) { e.preventDefault(); e.stopPropagation() } } catch (err) {}
|
|
14388
14455
|
endDrag(e, true)
|
|
14389
14456
|
}
|
|
14390
|
-
function onDocPointerCancel(e) { endDrag(e, false) }
|
|
14457
|
+
function onDocPointerCancel(e) { endDrag(e, false, true) }
|
|
14391
14458
|
function onDocClickStopper(e) {
|
|
14392
14459
|
// 只在鲸鱼命中区域拦截 click(保持透明区 pass-through)。
|
|
14393
14460
|
// 持久注册(不随 endDrag 移除)——click 在 pointerup 之后派发,
|
|
@@ -14548,7 +14615,7 @@ document.addEventListener('pointermove', onDocPointerMoveCursor, true)
|
|
|
14548
14615
|
// 配置读回来之后还会再应用一次,这里是配置请求失败时的兜底。
|
|
14549
14616
|
try { applyMenuBtnHideUI() } catch (err) {}
|
|
14550
14617
|
|
|
14551
|
-
function endDrag(e, clickAllowed) {
|
|
14618
|
+
function endDrag(e, clickAllowed, cancelled) {
|
|
14552
14619
|
if (!drag || !drag.active) return
|
|
14553
14620
|
drag.active = false
|
|
14554
14621
|
document.removeEventListener('pointermove', onDocPointerMove, true)
|
|
@@ -14556,6 +14623,20 @@ function endDrag(e, clickAllowed) {
|
|
|
14556
14623
|
document.removeEventListener('pointercancel', onDocPointerCancel, true)
|
|
14557
14624
|
pressUp()
|
|
14558
14625
|
root.classList.remove('dshwv-dragging')
|
|
14626
|
+
// issue #79 缺陷2:pointercancel(Android 把手势判成页面滚动、或系统抢走手势时派发)的
|
|
14627
|
+
// clientX/clientY 常常是 0,而 endDrag 又是「按坐标收尾 + saveConfig() 落盘」——
|
|
14628
|
+
// 于是位移被算成"一口气拖到了 (0,0)",归边判定吃进左上角,损坏锚点被写进 localStorage。
|
|
14629
|
+
// 0.3.2 的「非法距离自愈」只治负数 / 超出视口,救不回这个**合法的 (0,0)**,所以必须在这里拦住。
|
|
14630
|
+
// 处理:取消的手势一律回到按下前的位置、并且**不落盘**(取消不该提交位置)。
|
|
14631
|
+
var noCoord = (!e || typeof e.clientX !== 'number' || typeof e.clientY !== 'number' ||
|
|
14632
|
+
!isFinite(e.clientX) || !isFinite(e.clientY))
|
|
14633
|
+
var zeroBoth = (e && e.clientX === 0 && e.clientY === 0 && drag.moved)
|
|
14634
|
+
if (cancelled || noCoord || zeroBoth) {
|
|
14635
|
+
try { state.left = drag.origLeft; state.top = drag.origTop } catch (err) {}
|
|
14636
|
+
setWidgetCursor('')
|
|
14637
|
+
settle()
|
|
14638
|
+
return
|
|
14639
|
+
}
|
|
14559
14640
|
setWidgetCursor(isWhaleHit(e) ? 'grab' : '')
|
|
14560
14641
|
if (clickAllowed && !drag.moved) {
|
|
14561
14642
|
// 长按刚唤出菜单:这次抬手不再当作点击(避免顺带弹出余额泡)
|
|
@@ -14727,6 +14808,11 @@ fetch(SIZE_URL, { cache: 'no-store' })
|
|
|
14727
14808
|
menuBtnHide = d.menuBtnHide
|
|
14728
14809
|
if (menuHideToggle) menuHideToggle.checked = menuBtnHide
|
|
14729
14810
|
}
|
|
14811
|
+
// issue #116:Codex 本机统计开关(老配置里没有这个键 → 保持默认「开」)
|
|
14812
|
+
if (d && typeof d.codexStatsOn === 'boolean') {
|
|
14813
|
+
codexStatsOn = d.codexStatsOn
|
|
14814
|
+
if (codexStatsToggle) codexStatsToggle.checked = codexStatsOn
|
|
14815
|
+
}
|
|
14730
14816
|
// 无论服务端带没带这个键都要应用一次:触屏上 ☰ 需要常显(issue #91 缺陷2),
|
|
14731
14817
|
// 而旧写法只在键存在时才调用,空配置下按钮永远是透明的。
|
|
14732
14818
|
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.6', 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,70 @@ 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 }
|
|
1316
|
+
function codexStatsOn() {
|
|
1317
|
+
try {
|
|
1318
|
+
const cfg = readSizeConfig()
|
|
1319
|
+
return !cfg || cfg.codexStatsOn !== false
|
|
1320
|
+
} catch (err) { return true }
|
|
1321
|
+
}
|
|
1322
|
+
function codexBackgroundRefresh(delayMs) {
|
|
1323
|
+
if (!codexStatsOn()) {
|
|
1324
|
+
codexSnap = { ok: false, disabled: true, error: 'Codex 统计已在设置里关闭' }
|
|
1325
|
+
codexSnapAt = Date.now()
|
|
1326
|
+
return null
|
|
1327
|
+
}
|
|
1328
|
+
if (codexScanP) return codexScanP
|
|
1329
|
+
if (delayMs > 0) {
|
|
1330
|
+
if (codexCatchupT) return null
|
|
1331
|
+
codexCatchupT = setTimeout(() => { codexCatchupT = null; codexBackgroundRefresh(0) }, delayMs)
|
|
1332
|
+
try { if (codexCatchupT.unref) codexCatchupT.unref() } catch (err) {}
|
|
1333
|
+
return null
|
|
1334
|
+
}
|
|
1335
|
+
codexScanP = codexScan()
|
|
1336
|
+
.then((s) => {
|
|
1337
|
+
codexSnap = s
|
|
1338
|
+
codexSnapAt = Date.now()
|
|
1339
|
+
// 本轮没扫完(预算用尽)→ 稍后补扫,逐步把统计补齐,而不是一次占满事件循环
|
|
1340
|
+
if (s && s.ok && s.deferred > 0) codexBackgroundRefresh(5000)
|
|
1341
|
+
})
|
|
1342
|
+
.catch((err) => {
|
|
1343
|
+
codexSnap = { ok: false, error: String((err && err.message) || err) }
|
|
1344
|
+
codexSnapAt = Date.now()
|
|
1345
|
+
})
|
|
1346
|
+
.finally(() => { codexScanP = null })
|
|
1347
|
+
return codexScanP
|
|
1348
|
+
}
|
|
1266
1349
|
function codexSummaryCached() {
|
|
1267
1350
|
const now = Date.now()
|
|
1268
|
-
if (
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1351
|
+
if (!codexSnap || now - codexSnapAt > CODEX_SNAP_TTL) codexBackgroundRefresh(0)
|
|
1352
|
+
return codexSnap
|
|
1353
|
+
}
|
|
1354
|
+
async function codexSummaryEnsured(maxWaitMs) {
|
|
1355
|
+
const stale = !codexSnap || Date.now() - codexSnapAt > CODEX_SNAP_TTL
|
|
1356
|
+
const p = codexScanP || (stale ? codexBackgroundRefresh(0) : null)
|
|
1357
|
+
if (!p) return codexSnap
|
|
1358
|
+
try { await Promise.race([p, new Promise((r) => setTimeout(r, Math.max(200, maxWaitMs || 2500)))]) } catch (err) {}
|
|
1359
|
+
return codexSnap
|
|
1360
|
+
}
|
|
1361
|
+
// 后台预热:启动后预热一次,并每 5 分钟刷新一次缓存(现在都是异步 + 有预算的,不再阻塞)。
|
|
1276
1362
|
let codexPrewarmT = null, codexPrewarmI = null
|
|
1277
1363
|
try {
|
|
1278
|
-
codexPrewarmT = setTimeout(function () { try {
|
|
1279
|
-
codexPrewarmI = setInterval(function () { try {
|
|
1364
|
+
codexPrewarmT = setTimeout(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 1500)
|
|
1365
|
+
codexPrewarmI = setInterval(function () { try { codexBackgroundRefresh(0) } catch (err) {} }, 5 * 60 * 1000)
|
|
1280
1366
|
// issue #109:这两个定时器原先不存句柄、也不进 disposers,插件树 dispose 之后
|
|
1281
1367
|
// 事件循环里仍有一个被引用的 interval → 用户退出 dsh 时进程不结束(只能 Ctrl+C)。
|
|
1282
1368
|
// 存句柄并纳入 disposers,卸载时一并清掉;unref 作为兜底(正常运行时由 HTTP 服务维持事件循环)。
|
|
@@ -1285,6 +1371,7 @@ export default {
|
|
|
1285
1371
|
disposers.push(function () {
|
|
1286
1372
|
if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null }
|
|
1287
1373
|
if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null }
|
|
1374
|
+
if (codexCatchupT !== null) { clearTimeout(codexCatchupT); codexCatchupT = null }
|
|
1288
1375
|
})
|
|
1289
1376
|
} catch (err) {}
|
|
1290
1377
|
// ===== 自定义 API 模型注册表(v655)=====
|
|
@@ -1426,7 +1513,7 @@ export default {
|
|
|
1426
1513
|
}
|
|
1427
1514
|
// Codex 模式:本地会话统计即「探活结果」,不需要网络与密钥
|
|
1428
1515
|
if (tpl.kind === 'codex') {
|
|
1429
|
-
const cs =
|
|
1516
|
+
const cs = await codexSummaryEnsured(2500)
|
|
1430
1517
|
const f = (n) => (n >= 100000000 ? (n / 100000000).toFixed(2) + '亿' : (n >= 10000 ? (n / 10000).toFixed(1).replace(/\.0$/, '') + '万' : String(Math.round(Number(n) || 0))))
|
|
1431
1518
|
if (cs && cs.ok) {
|
|
1432
1519
|
return { ok: true, detail: 'Codex 本地会话:今日 ' + f(cs.todayTokens) + ' · 本月 ' + f(cs.monthTokens) + ' · 累计 ' + f(cs.totalTokens) + ' tokens(' + cs.sessions + ' 个会话文件)' }
|
|
@@ -1923,10 +2010,12 @@ export default {
|
|
|
1923
2010
|
entry.planSupport = !!tplM.quota
|
|
1924
2011
|
// Codex 模式:不查余额、不需要密钥,直接给本地会话统计(今日/本月/累计/近7天)
|
|
1925
2012
|
if (tplM.kind === 'codex') {
|
|
1926
|
-
|
|
2013
|
+
// 这里能等:模型列表请求可以稍等一下在途扫描(最多 2.5s),但绝不触发同步扫描
|
|
2014
|
+
if (!codexStats) codexStats = await codexSummaryEnsured(2500)
|
|
1927
2015
|
entry.codex = codexStats
|
|
1928
2016
|
entry.hasKey = true
|
|
1929
|
-
entry.error = codexStats && codexStats.ok ? null
|
|
2017
|
+
entry.error = codexStats && codexStats.ok ? null
|
|
2018
|
+
: (codexStats && codexStats.disabled ? null : ((codexStats && codexStats.error) || '未找到 Codex 会话目录'))
|
|
1930
2019
|
}
|
|
1931
2020
|
// 没有余额接口的厂商(如火山方舟):余额显示「—」,只用会话事件估算
|
|
1932
2021
|
entry.balanceMode = hasBalanceApi ? 'api' : 'events'
|
|
@@ -2005,7 +2094,7 @@ export default {
|
|
|
2005
2094
|
const led = readUsageLedger()
|
|
2006
2095
|
const summary = daySummary(led, todayKey())
|
|
2007
2096
|
return {
|
|
2008
|
-
...visible, version: '0.3.
|
|
2097
|
+
...visible, version: '0.3.6', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
|
|
2009
2098
|
todayUsage: summary.amount, todayUsageCurrency: summary.currency,
|
|
2010
2099
|
usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
|
|
2011
2100
|
accounting: balanceSummary(led, todayKey()),
|
|
@@ -2056,6 +2145,9 @@ export default {
|
|
|
2056
2145
|
scrollGapOn: parsed.scrollGapOn === true,
|
|
2057
2146
|
scrollGapPx: typeof parsed.scrollGapPx === 'number' ? Math.round(parsed.scrollGapPx) : 17,
|
|
2058
2147
|
menuBtnHide: parsed.menuBtnHide === true,
|
|
2148
|
+
// issue #116:Codex 本地统计可以在设置里关掉(默认开)。关掉后宿主完全不扫
|
|
2149
|
+
// ~/.codex/sessions,也不做后台预热。
|
|
2150
|
+
codexStatsOn: parsed.codexStatsOn !== false,
|
|
2059
2151
|
}
|
|
2060
2152
|
}
|
|
2061
2153
|
} catch (err) {}
|
|
@@ -2063,7 +2155,7 @@ export default {
|
|
|
2063
2155
|
return null
|
|
2064
2156
|
}
|
|
2065
2157
|
|
|
2066
|
-
function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide) {
|
|
2158
|
+
function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, menuBtnHide, codexStatsOnArg) {
|
|
2067
2159
|
const um = normalizeUsageMode(usageMode)
|
|
2068
2160
|
const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
|
|
2069
2161
|
const bo = bubbleOn !== false
|
|
@@ -2072,6 +2164,7 @@ export default {
|
|
|
2072
2164
|
const sgo = scrollGapOn === true
|
|
2073
2165
|
const sgp = typeof scrollGapPx === 'number' && scrollGapPx > 0 ? Math.round(scrollGapPx) : 0
|
|
2074
2166
|
const mbh = menuBtnHide === true
|
|
2167
|
+
const cso = codexStatsOnArg !== false
|
|
2075
2168
|
const body = JSON.stringify({
|
|
2076
2169
|
scale: scale,
|
|
2077
2170
|
sound: sound !== false,
|
|
@@ -2085,8 +2178,10 @@ export default {
|
|
|
2085
2178
|
scrollGapOn: sgo,
|
|
2086
2179
|
scrollGapPx: sgp,
|
|
2087
2180
|
menuBtnHide: mbh,
|
|
2181
|
+
codexStatsOn: cso,
|
|
2088
2182
|
updatedAt: new Date().toISOString(),
|
|
2089
2183
|
})
|
|
2184
|
+
let lastSizeErr = null
|
|
2090
2185
|
for (const p of SIZE_FILE_CANDIDATES) {
|
|
2091
2186
|
try {
|
|
2092
2187
|
fs.writeFileSync(p, body, 'utf8')
|
|
@@ -2104,10 +2199,13 @@ export default {
|
|
|
2104
2199
|
scrollGapOn: sgo,
|
|
2105
2200
|
scrollGapPx: sgp,
|
|
2106
2201
|
menuBtnHide: mbh,
|
|
2202
|
+
codexStatsOn: cso,
|
|
2107
2203
|
}
|
|
2108
|
-
} catch (err) {}
|
|
2204
|
+
} catch (err) { lastSizeErr = err }
|
|
2109
2205
|
}
|
|
2110
|
-
|
|
2206
|
+
// #88 / #97 报告者建议:把底层原因(EPERM / EACCES / 路径问题…)带出去。
|
|
2207
|
+
// 原先无论哪个候选路径失败,返回的都是同一句固定文案,即使前端检查了响应也定位不到原因。
|
|
2208
|
+
return { ok: false, error: '无法持久化挂件尺寸' + (lastSizeErr && lastSizeErr.message ? ':' + lastSizeErr.message : '') }
|
|
2111
2209
|
}
|
|
2112
2210
|
|
|
2113
2211
|
function readBody(req) {
|
|
@@ -2509,9 +2607,12 @@ export default {
|
|
|
2509
2607
|
pickN(parsed.turnCostCloseMs, old.turnCostCloseMs, 5000),
|
|
2510
2608
|
pickB(parsed.scrollGapOn, old.scrollGapOn, false),
|
|
2511
2609
|
pickN(parsed.scrollGapPx, old.scrollGapPx, 17),
|
|
2512
|
-
pickB(parsed.menuBtnHide, old.menuBtnHide, false)
|
|
2610
|
+
pickB(parsed.menuBtnHide, old.menuBtnHide, false),
|
|
2611
|
+
pickB(parsed.codexStatsOn, old.codexStatsOn, true)
|
|
2513
2612
|
)
|
|
2514
2613
|
res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
|
|
2614
|
+
// 配置刚变(可能刚关掉/打开 Codex 统计)→ 丢掉旧快照,让下一次请求立刻反映新设置
|
|
2615
|
+
if (result.ok) { try { codexInvalidate() } catch (err) {} }
|
|
2515
2616
|
res.end(JSON.stringify(result))
|
|
2516
2617
|
} catch (err) {
|
|
2517
2618
|
res.writeHead(400, JSON_HEADERS)
|
package/package.json
CHANGED