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.
@@ -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 输入区。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
+ 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) return c && c.error ? ('⚠ ' + c.error) : '无 Codex 数据'
10889
- return 'Codex 今日 ' + apiFmtTokens(c.todayTokens) + ' · 近7天 ' + apiFmtTokens(apiCodexDays7(c)) + ' tokens'
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.4', today: todayData, days7,
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
- // 解析单个会话文件 → { 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,70 @@ 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 }
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 (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 秒记忆。
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 { codexSummaryCached() } catch (err) {} }, 1500)
1279
- codexPrewarmI = setInterval(function () { try { codexSummaryCached() } catch (err) {} }, 5 * 60 * 1000)
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 = codexSummaryCached()
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
- if (!codexStats) codexStats = codexSummaryCached()
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 : ((codexStats && codexStats.error) || '未找到 Codex 会话目录')
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.4', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
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
- return { ok: false, error: '无法持久化挂件尺寸' }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",