dsh-peak-hours 1.11.0 → 1.12.1
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/CHANGELOG.md +18 -0
- package/README.md +31 -4
- package/client.js +340 -4
- package/index.js +70 -5
- package/lib/schedule.js +120 -0
- package/lib/usage.js +386 -0
- package/lib/zstd.js +122 -0
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# 更新日志
|
|
2
2
|
|
|
3
|
+
## 1.12.1 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
### 修正
|
|
6
|
+
|
|
7
|
+
- **侧栏角标只剩一行文字、圆点和日程条消失**:根节点上的 `aria-label` 会被 `sidebar.footer.action` 的所有者当成「行文本」投影出来,整个组件子树被替换成一行纯文本。现已移除该属性(无障碍名交给可见文本 + `title`),并在源码里留下注释,避免回归。
|
|
8
|
+
|
|
9
|
+
## 1.12.0 — 2026-10-29
|
|
10
|
+
|
|
11
|
+
### 新增
|
|
12
|
+
|
|
13
|
+
- **设置页「高峰/空闲用量与费用对比」卡片**(`settings.general.item`,排在「性能与用量」之后):把本机会话日志里的每个请求按**发生时刻的北京时间**归到高峰或空闲,按模型价目表折算成钱,给出近 7 天 / 近 30 天 / 全部三个区间的「实际花费、全高峰、全空闲、省下的钱与比例、高峰占比、分模型明细」。想查「我到底因为错峰省了多少」时,不用再手算。
|
|
14
|
+
- **host 半**:`index.js` 注册 `GET /dsh-peak-hours/api/usage`,60 秒 TTL + `?fresh=1` 强制刷新 + in-flight 去重;`lib/zstd.js` 自己按帧切开多帧 zstd 会话日志(`zlib.zstdDecompressSync` 只能解首帧),`lib/usage.js` 增量扫描并按文件大小/修改时间缓存到 `~/.dsh/storages/peak-hours/cache.json`。
|
|
15
|
+
- 新增 10 个用例覆盖 host 半(窗口边界、周末/节假日、模型分类、价格公式、`turn+step` 替换、分桶、费用对比、区间过滤),测试总数 15 → 24。
|
|
16
|
+
|
|
17
|
+
### 修正
|
|
18
|
+
|
|
19
|
+
- 分模型明细里同名模型(如 `deepseek-account/deepseek-flash` 与 `deepseek-official/deepseek-flash`)不再显示成两行一模一样,第二个起会带提供商后缀(`· personal` / `· official`)。
|
|
20
|
+
|
|
3
21
|
## 1.11.0 — 2026-10-29
|
|
4
22
|
|
|
5
23
|
### 修正
|
package/README.md
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# dsh-peak-hours
|
|
2
2
|
|
|
3
|
-
一个 DeepSeek Harness
|
|
3
|
+
一个 DeepSeek Harness 插件,做两件事:
|
|
4
|
+
|
|
5
|
+
1. **侧栏角标**:现在是不是高峰计价时段;
|
|
6
|
+
2. **设置页卡片**:把你自己的用量按高峰/空闲拆开,并算出「实际花了多少」对比「全在高峰 / 全在空闲要花多少」。
|
|
4
7
|
|
|
5
8
|

|
|
6
9
|
|
|
@@ -36,6 +39,24 @@
|
|
|
36
39
|
|
|
37
40
|
时间一律按北京时间(UTC+8)算,和你浏览器的时区无关。
|
|
38
41
|
|
|
42
|
+
## 用量与费用对比(设置 → 通用)
|
|
43
|
+
|
|
44
|
+
除了角标,插件还在「设置 → 通用」里加了一行 **高峰/空闲用量与费用对比**(`settings.general.item`,排在「性能与用量」后面)。它读取本机 DeepSeek Harness 的会话日志,把每个请求按它发生的**北京时间**归到高峰或空闲,然后按模型价目表折算成钱:
|
|
45
|
+
|
|
46
|
+
- **三个区间**:近 7 天 / 近 30 天 / 全部
|
|
47
|
+
- **三笔钱**:实际花费(高峰按 ×2、空闲按 ×1)、假如全部落在高峰、假如全部落在空闲
|
|
48
|
+
- **省下的钱与比例**:`¥68.49 · 35.5%` 这样
|
|
49
|
+
- **用量拆分**:高峰 token、空闲 token、合计、高峰占比,配一条橙/蓝比例条
|
|
50
|
+
- **分模型**:前四个模型各自的 token 与费用
|
|
51
|
+
|
|
52
|
+
口径与限制(重要,也是诚实的地方):
|
|
53
|
+
|
|
54
|
+
- 只统计**能对上 DeepSeek 价目表**的模型(`deepseek-flash` 按 flash 价、`deepseek-v4-pro` / reasoner 按 pro 价);其他供应商(claude、gpt、qmodel 等)只计 token、不计入费用,并在说明里单独标注
|
|
55
|
+
- 价目表同样内置在 `lib/schedule.js`(元/百万 tokens,空闲→高峰):flash 命中 0.02→0.04、未命中 1→2、输出 4→8;pro 命中 0.15→0.30、未命中 4.5→9、输出 13.5→27
|
|
56
|
+
- 同一 `turn`+`step` 的多次采样按**最后一次**算(会话流式输出会反复覆盖同一步的用量),不会重复累加
|
|
57
|
+
- 数据来自本机会话日志 `~/.dsh/sessions/<工作区>/<会话>/session.v4.jsonl.zstd`,**只在本地读、不外发**;解析结果按文件大小与修改时间缓存到 `~/.dsh/storages/peak-hours/cache.json`,第二次打开是毫秒级
|
|
58
|
+
- 用量接口也有 60 秒 TTL,卡片上的「刷新」会跳过缓存重新扫一遍
|
|
59
|
+
|
|
39
60
|
## 安装
|
|
40
61
|
|
|
41
62
|
从 npm:
|
|
@@ -61,17 +82,23 @@ dsh plugin --profile web remove dsh-peak-hours
|
|
|
61
82
|
## 兼容性
|
|
62
83
|
|
|
63
84
|
- 需要带客户端 UI 的 DeepSeek Harness Web GUI(`dsh.client` 声明为 `platform: web`)
|
|
64
|
-
-
|
|
85
|
+
- 客户端 + 一个只读的 host 半:host 半注册一条 `GET /dsh-peak-hours/api/usage`,读取本机会话日志算峰谷账,**不发任何网络请求**;除 `~/.dsh/storages/peak-hours/cache.json` 这一份解析缓存外不写文件,不需要任何配置或凭据
|
|
86
|
+
- 角标部分纯客户端,不依赖 host 半;即使 host 半取数失败,角标照常工作
|
|
65
87
|
- 颜色是固定值(高峰 `#f59e0b`、空闲 `#4d6bfe`,即 DeepSeek 品牌蓝),不跟随主题 `brand-primary`,以免换皮肤后蓝色被改成别的色
|
|
66
88
|
|
|
67
89
|
## 开发
|
|
68
90
|
|
|
69
91
|
```sh
|
|
70
|
-
npm test #
|
|
92
|
+
npm test # 24 个单元测试(node --test,零依赖)
|
|
71
93
|
npm run verify # npm pack --dry-run,检查发布清单
|
|
72
94
|
```
|
|
73
95
|
|
|
74
|
-
|
|
96
|
+
测试分两块,全部零依赖、不用起 GUI:
|
|
97
|
+
|
|
98
|
+
- `test/schedule.test.mjs`:通过 `vm` 加载 `client.js`,用最小的 React / locale / document 替身渲染角标,验证时段边界、日程条分段与宽度、游标位置、折叠态、复制与中英文 key 对齐
|
|
99
|
+
- `test/usage.test.mjs`:直接测 host 半的 `lib/schedule.js` 与 `lib/usage.js`,用合成事件验证窗口判定、节假日、模型分类、价格公式、`turn+step` 替换语义、按日+小时+模型分桶、峰谷费用对比与区间过滤
|
|
100
|
+
|
|
101
|
+
设置页卡片还有一个独立的 headless 渲染检查(真实接口数据 + 最小 hooks 替身),确认取数成功后不会渲染崩溃。
|
|
75
102
|
|
|
76
103
|
## 许可
|
|
77
104
|
|
package/client.js
CHANGED
|
@@ -334,7 +334,53 @@ window.__ModuleLoader__.load({
|
|
|
334
334
|
}
|
|
335
335
|
.dshph-rail .dshph-track { width: 26px; height: 6px; }
|
|
336
336
|
.dshph-rail .dshph-cursor { width: 1.5px; }
|
|
337
|
-
|
|
337
|
+
|
|
338
|
+
/* ---- 峰谷用量与费用卡片(设置页) ---- */
|
|
339
|
+
.dshphu-card {
|
|
340
|
+
--dshphu-peak: #f59e0b;
|
|
341
|
+
--dshphu-off: #4d6bfe;
|
|
342
|
+
display: flex; flex-direction: column; gap: 10px;
|
|
343
|
+
box-sizing: border-box; width: 100%; min-width: 0;
|
|
344
|
+
padding: 12px 14px; border-radius: 10px;
|
|
345
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, .18));
|
|
346
|
+
background: var(--dsw-alias-bg-layer-1, transparent);
|
|
347
|
+
font-family: inherit; font-size: 14px; font-size: .875rem;
|
|
348
|
+
line-height: 1.45; color: var(--dsw-alias-label-primary);
|
|
349
|
+
}
|
|
350
|
+
.dshphu-head { display: flex; align-items: flex-start; gap: 10px; min-width: 0; }
|
|
351
|
+
.dshphu-titles { flex: 1 1 auto; min-width: 0; }
|
|
352
|
+
.dshphu-title { font-weight: 600; }
|
|
353
|
+
.dshphu-desc { color: var(--dsw-alias-label-secondary); font-size: .92em; }
|
|
354
|
+
.dshphu-seg { flex: none; display: flex; gap: 4px; }
|
|
355
|
+
.dshphu-segBtn {
|
|
356
|
+
font: inherit; font-size: .92em; cursor: pointer;
|
|
357
|
+
padding: 2px 9px; border-radius: 999px;
|
|
358
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(128, 128, 128, .18));
|
|
359
|
+
background: transparent; color: var(--dsw-alias-label-secondary);
|
|
360
|
+
}
|
|
361
|
+
.dshphu-segBtn[data-on='1'] {
|
|
362
|
+
color: #fff; border-color: transparent; background: var(--dshphu-off);
|
|
363
|
+
}
|
|
364
|
+
.dshphu-kpis { display: flex; flex-wrap: wrap; gap: 14px; }
|
|
365
|
+
.dshphu-kpi { min-width: 96px; }
|
|
366
|
+
.dshphu-kpiLabel { color: var(--dsw-alias-label-secondary); font-size: .92em; }
|
|
367
|
+
.dshphu-kpiValue { font-size: 1.18em; font-weight: 600; }
|
|
368
|
+
.dshphu-kpiValue[data-tone='peak'] { color: var(--dshphu-peak); }
|
|
369
|
+
.dshphu-kpiValue[data-tone='save'] { color: var(--dshphu-off); }
|
|
370
|
+
.dshphu-bar {
|
|
371
|
+
display: flex; height: 8px; border-radius: 999px; overflow: hidden;
|
|
372
|
+
background: var(--dsw-alias-bg-layer-2);
|
|
373
|
+
}
|
|
374
|
+
.dshphu-barPeak { background: var(--dshphu-peak); }
|
|
375
|
+
.dshphu-barOff { background: var(--dshphu-off); }
|
|
376
|
+
.dshphu-line { color: var(--dsw-alias-label-secondary); font-size: .92em; }
|
|
377
|
+
.dshphu-models { display: flex; flex-direction: column; gap: 3px; }
|
|
378
|
+
.dshphu-model { display: flex; gap: 8px; align-items: baseline; font-size: .92em; }
|
|
379
|
+
.dshphu-modelName { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
380
|
+
.dshphu-modelNum { flex: none; color: var(--dsw-alias-label-secondary); }
|
|
381
|
+
.dshphu-note { color: var(--dsw-alias-label-secondary); font-size: .88em; }
|
|
382
|
+
.dshphu-err { color: var(--dsw-alias-state-error-primary, #d33); font-size: .92em; }
|
|
383
|
+
@media (prefers-reduced-motion: reduce) {
|
|
338
384
|
.dshph-root, .dshph-root * {
|
|
339
385
|
animation: none !important; transition: none !important;
|
|
340
386
|
}
|
|
@@ -388,6 +434,27 @@ window.__ModuleLoader__.load({
|
|
|
388
434
|
'copy.ok': '已复制',
|
|
389
435
|
'copy.fail': '复制失败',
|
|
390
436
|
'copy.suffix': '(北京时间)',
|
|
437
|
+
'usage.title': '峰谷用量与费用',
|
|
438
|
+
'usage.desc': '来自本地会话日志:按高峰/空闲拆分 tokens,并按峰谷价折算费用',
|
|
439
|
+
'usage.range': '统计区间',
|
|
440
|
+
'usage.range.7': '近 7 天',
|
|
441
|
+
'usage.range.30': '近 30 天',
|
|
442
|
+
'usage.range.all': '全部',
|
|
443
|
+
'usage.actual': '实际花费',
|
|
444
|
+
'usage.allPeak': '全在高峰',
|
|
445
|
+
'usage.saved': '比全高峰省',
|
|
446
|
+
'usage.peakTokens': '高峰',
|
|
447
|
+
'usage.offTokens': '空闲',
|
|
448
|
+
'usage.totalTokens': '合计',
|
|
449
|
+
'usage.peakShare': '高峰占比',
|
|
450
|
+
'usage.refresh': '重新统计',
|
|
451
|
+
'usage.refreshHint': '点击右上角区间可切换',
|
|
452
|
+
'usage.loading': '统计中…(首次需扫描本地会话日志)',
|
|
453
|
+
'usage.error': '读取失败:',
|
|
454
|
+
'usage.empty': '暂无用量记录',
|
|
455
|
+
'usage.note': '仅 deepseek 系模型按峰谷价折算;其他供应商 {tokens} tokens 计入用量、不计费',
|
|
456
|
+
'usage.meta': '扫描 {sessions} 个会话 · 活跃 {days} 天',
|
|
457
|
+
|
|
391
458
|
};
|
|
392
459
|
|
|
393
460
|
const EN = {
|
|
@@ -424,6 +491,27 @@ window.__ModuleLoader__.load({
|
|
|
424
491
|
'copy.ok': 'Copied',
|
|
425
492
|
'copy.fail': 'Copy failed',
|
|
426
493
|
'copy.suffix': '(Beijing time)',
|
|
494
|
+
'usage.title': 'Peak / off-peak usage and cost',
|
|
495
|
+
'usage.desc': 'From local session logs: tokens split by peak/off-peak, priced at the peak/off-peak rates',
|
|
496
|
+
'usage.range': 'Range',
|
|
497
|
+
'usage.range.7': '7 days',
|
|
498
|
+
'usage.range.30': '30 days',
|
|
499
|
+
'usage.range.all': 'All',
|
|
500
|
+
'usage.actual': 'Actual spend',
|
|
501
|
+
'usage.allPeak': 'All at peak',
|
|
502
|
+
'usage.saved': 'Saved vs all-peak',
|
|
503
|
+
'usage.peakTokens': 'Peak',
|
|
504
|
+
'usage.offTokens': 'Off-peak',
|
|
505
|
+
'usage.totalTokens': 'Total',
|
|
506
|
+
'usage.peakShare': 'Peak share',
|
|
507
|
+
'usage.refresh': 'Rescan',
|
|
508
|
+
'usage.refreshHint': 'Switch ranges above',
|
|
509
|
+
'usage.loading': 'Scanning… (first run reads local session logs)',
|
|
510
|
+
'usage.error': 'Failed to load: ',
|
|
511
|
+
'usage.empty': 'No usage yet',
|
|
512
|
+
'usage.note': 'Only DeepSeek models are priced here; {tokens} tokens from other providers count as usage, not money',
|
|
513
|
+
'usage.meta': '{sessions} sessions scanned · {days} active days',
|
|
514
|
+
|
|
427
515
|
};
|
|
428
516
|
|
|
429
517
|
let translate = (key) => (EN[key] !== undefined ? EN[key] : key);
|
|
@@ -495,14 +583,14 @@ window.__ModuleLoader__.load({
|
|
|
495
583
|
h('span', { key: 'cursor', className: 'dshph-cursor', style: { left: cursorLeft } }),
|
|
496
584
|
);
|
|
497
585
|
|
|
586
|
+
// 根节点**不要**放 aria-label / aria-labelledby:sidebar.footer.action 的所有者
|
|
587
|
+
// 会把这个属性当行文本投影出来,整个子树被换成一行纯文本(圆点、日程条都会消失,
|
|
588
|
+
// 1.11.0 起踩过这个坑)。无障碍名交给可见文本 + title。
|
|
498
589
|
const common = {
|
|
499
590
|
title,
|
|
500
|
-
role: 'button',
|
|
501
591
|
tabIndex: 0,
|
|
502
592
|
onClick: onCopy,
|
|
503
593
|
onKeyDown,
|
|
504
|
-
'aria-label':
|
|
505
|
-
t('label') + ': ' + stateText + ', ' + remaining + '. ' + t('tip.hint'),
|
|
506
594
|
};
|
|
507
595
|
|
|
508
596
|
if (!wide) {
|
|
@@ -538,6 +626,241 @@ window.__ModuleLoader__.load({
|
|
|
538
626
|
);
|
|
539
627
|
}
|
|
540
628
|
|
|
629
|
+
|
|
630
|
+
// ---------------------------------------------------------------------
|
|
631
|
+
// 峰谷用量与费用(设置页卡片)
|
|
632
|
+
// ---------------------------------------------------------------------
|
|
633
|
+
const USAGE_API = '/dsh-peak-hours/api/usage';
|
|
634
|
+
const RANGES = ['7', '30', 'all'];
|
|
635
|
+
let usageCache = null;
|
|
636
|
+
|
|
637
|
+
function fmtTokens(n) {
|
|
638
|
+
const v = Number(n) || 0;
|
|
639
|
+
if (v >= 1e8) return (v / 1e8).toFixed(2) + ' 亿';
|
|
640
|
+
if (v >= 1e4) return (v / 1e4).toFixed(1) + ' 万';
|
|
641
|
+
return String(Math.round(v));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function fmtMoney(n) {
|
|
645
|
+
const v = Number(n) || 0;
|
|
646
|
+
if (v > 0 && v < 0.01) return '¥' + v.toFixed(4);
|
|
647
|
+
if (v < 1) return '¥' + v.toFixed(3);
|
|
648
|
+
return '¥' + v.toFixed(2);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function fmtPct(x) {
|
|
652
|
+
return ((Number(x) || 0) * 100).toFixed(1) + '%';
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function modelParts(name) {
|
|
656
|
+
const s = String(name || '');
|
|
657
|
+
const i = s.indexOf('/');
|
|
658
|
+
return i >= 0 ? { provider: s.slice(0, i), model: s.slice(i + 1) } : { provider: '', model: s };
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** Short name; when two providers expose the same model, keep the provider tag. */
|
|
662
|
+
function shortModel(name, duplicated) {
|
|
663
|
+
const { provider, model } = modelParts(name);
|
|
664
|
+
if (!duplicated) return model;
|
|
665
|
+
const tag = provider.replace(/^deepseek-/, '').replace(/-?account$/, 'personal');
|
|
666
|
+
return tag ? model + ' · ' + tag : model;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function UsageCard() {
|
|
670
|
+
const t = translate;
|
|
671
|
+
const rangeState = React.useState('30');
|
|
672
|
+
const range = rangeState[0];
|
|
673
|
+
const setRange = rangeState[1];
|
|
674
|
+
const dataState = React.useState(usageCache);
|
|
675
|
+
const data = dataState[0];
|
|
676
|
+
const setData = dataState[1];
|
|
677
|
+
const errState = React.useState('');
|
|
678
|
+
const err = errState[0];
|
|
679
|
+
const setErr = errState[1];
|
|
680
|
+
const loadState = React.useState(!usageCache);
|
|
681
|
+
const loading = loadState[0];
|
|
682
|
+
const setLoading = loadState[1];
|
|
683
|
+
const tickState = React.useState(0);
|
|
684
|
+
const tick = tickState[0];
|
|
685
|
+
const reload = tickState[1];
|
|
686
|
+
|
|
687
|
+
React.useEffect(() => {
|
|
688
|
+
let alive = true;
|
|
689
|
+
setLoading(true);
|
|
690
|
+
fetch(USAGE_API + (tick > 0 ? '?fresh=1' : ''), { cache: 'no-store' })
|
|
691
|
+
.then((r) =>
|
|
692
|
+
r.json().then(
|
|
693
|
+
(j) => {
|
|
694
|
+
if (!r.ok) throw new Error((j && j.error) || 'HTTP ' + r.status);
|
|
695
|
+
return j;
|
|
696
|
+
},
|
|
697
|
+
() => {
|
|
698
|
+
throw new Error('HTTP ' + r.status);
|
|
699
|
+
},
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
.then((j) => {
|
|
703
|
+
if (!alive) return;
|
|
704
|
+
usageCache = j;
|
|
705
|
+
setData(j);
|
|
706
|
+
setErr('');
|
|
707
|
+
})
|
|
708
|
+
.catch((e) => {
|
|
709
|
+
if (!alive) return;
|
|
710
|
+
setErr(String((e && e.message) || e));
|
|
711
|
+
})
|
|
712
|
+
.then(() => {
|
|
713
|
+
if (alive) setLoading(false);
|
|
714
|
+
});
|
|
715
|
+
return () => {
|
|
716
|
+
alive = false;
|
|
717
|
+
};
|
|
718
|
+
}, [tick]);
|
|
719
|
+
|
|
720
|
+
const rep = data && data.ranges ? data.ranges[range] : null;
|
|
721
|
+
const seg = h(
|
|
722
|
+
'div',
|
|
723
|
+
{ className: 'dshphu-seg', role: 'group', 'aria-label': t('usage.range') },
|
|
724
|
+
...RANGES.map((key) =>
|
|
725
|
+
h(
|
|
726
|
+
'button',
|
|
727
|
+
{
|
|
728
|
+
key,
|
|
729
|
+
type: 'button',
|
|
730
|
+
className: 'dshphu-segBtn',
|
|
731
|
+
'data-on': range === key ? '1' : '0',
|
|
732
|
+
'aria-pressed': range === key ? 'true' : 'false',
|
|
733
|
+
onClick: () => setRange(key),
|
|
734
|
+
},
|
|
735
|
+
t('usage.range.' + key),
|
|
736
|
+
),
|
|
737
|
+
),
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
const head = h(
|
|
741
|
+
'div',
|
|
742
|
+
{ className: 'dshphu-head' },
|
|
743
|
+
h(
|
|
744
|
+
'div',
|
|
745
|
+
{ className: 'dshphu-titles' },
|
|
746
|
+
h('div', { className: 'dshphu-title' }, t('usage.title')),
|
|
747
|
+
h('div', { className: 'dshphu-desc' }, t('usage.desc')),
|
|
748
|
+
),
|
|
749
|
+
seg,
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
if (err) {
|
|
753
|
+
return h(
|
|
754
|
+
'div',
|
|
755
|
+
{ className: 'dshphu-card' },
|
|
756
|
+
head,
|
|
757
|
+
h('div', { className: 'dshphu-err' }, t('usage.error') + err),
|
|
758
|
+
h(
|
|
759
|
+
'div',
|
|
760
|
+
null,
|
|
761
|
+
h('button', { type: 'button', className: 'dshphu-segBtn', onClick: () => reload((n) => n + 1) }, t('usage.refresh')),
|
|
762
|
+
),
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!rep) {
|
|
767
|
+
return h(
|
|
768
|
+
'div',
|
|
769
|
+
{ className: 'dshphu-card' },
|
|
770
|
+
head,
|
|
771
|
+
h('div', { className: 'dshphu-line' }, loading ? t('usage.loading') : t('usage.empty')),
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const tokens = rep.tokens;
|
|
776
|
+
const cost = rep.cost;
|
|
777
|
+
const peakPct = Math.max(0, Math.min(1, tokens.peakShare));
|
|
778
|
+
const modelRows = rep.classByModel || [];
|
|
779
|
+
const modelNames = new Map();
|
|
780
|
+
for (const row of modelRows) {
|
|
781
|
+
const base = modelParts(row.model).model;
|
|
782
|
+
modelNames.set(base, (modelNames.get(base) || 0) + 1);
|
|
783
|
+
}
|
|
784
|
+
const models = modelRows.slice(0, 4);
|
|
785
|
+
|
|
786
|
+
return h(
|
|
787
|
+
'div',
|
|
788
|
+
{ className: 'dshphu-card' },
|
|
789
|
+
head,
|
|
790
|
+
h(
|
|
791
|
+
'div',
|
|
792
|
+
{ className: 'dshphu-kpis' },
|
|
793
|
+
h(
|
|
794
|
+
'div',
|
|
795
|
+
{ className: 'dshphu-kpi' },
|
|
796
|
+
h('div', { className: 'dshphu-kpiLabel' }, t('usage.actual')),
|
|
797
|
+
h('div', { className: 'dshphu-kpiValue' }, fmtMoney(cost.actual)),
|
|
798
|
+
),
|
|
799
|
+
h(
|
|
800
|
+
'div',
|
|
801
|
+
{ className: 'dshphu-kpi' },
|
|
802
|
+
h('div', { className: 'dshphu-kpiLabel' }, t('usage.allPeak')),
|
|
803
|
+
h('div', { className: 'dshphu-kpiValue', 'data-tone': 'peak' }, fmtMoney(cost.allPeak)),
|
|
804
|
+
),
|
|
805
|
+
h(
|
|
806
|
+
'div',
|
|
807
|
+
{ className: 'dshphu-kpi' },
|
|
808
|
+
h('div', { className: 'dshphu-kpiLabel' }, t('usage.saved')),
|
|
809
|
+
h(
|
|
810
|
+
'div',
|
|
811
|
+
{ className: 'dshphu-kpiValue', 'data-tone': 'save' },
|
|
812
|
+
fmtMoney(cost.saved) + ' · ' + fmtPct(cost.savedPct),
|
|
813
|
+
),
|
|
814
|
+
),
|
|
815
|
+
),
|
|
816
|
+
h(
|
|
817
|
+
'div',
|
|
818
|
+
{ className: 'dshphu-bar', role: 'img', 'aria-label': t('usage.peakShare') + ' ' + fmtPct(peakPct) },
|
|
819
|
+
h('span', { className: 'dshphu-barPeak', style: { width: fmtPct(peakPct) } }),
|
|
820
|
+
h('span', { className: 'dshphu-barOff', style: { width: fmtPct(1 - peakPct) } }),
|
|
821
|
+
),
|
|
822
|
+
h(
|
|
823
|
+
'div',
|
|
824
|
+
{ className: 'dshphu-line' },
|
|
825
|
+
t('usage.peakTokens') + ' ' + fmtTokens(tokens.peak.total) +
|
|
826
|
+
' · ' + t('usage.offTokens') + ' ' + fmtTokens(tokens.off.total) +
|
|
827
|
+
' · ' + t('usage.totalTokens') + ' ' + fmtTokens(tokens.total) +
|
|
828
|
+
' · ' + t('usage.peakShare') + ' ' + fmtPct(peakPct),
|
|
829
|
+
),
|
|
830
|
+
models.length
|
|
831
|
+
? h(
|
|
832
|
+
'div',
|
|
833
|
+
{ className: 'dshphu-models' },
|
|
834
|
+
...models.map((m) =>
|
|
835
|
+
h(
|
|
836
|
+
'div',
|
|
837
|
+
{ key: m.model, className: 'dshphu-model' },
|
|
838
|
+
h(
|
|
839
|
+
'span',
|
|
840
|
+
{ className: 'dshphu-modelName' },
|
|
841
|
+
shortModel(m.model, (modelNames.get(modelParts(m.model).model) || 0) > 1),
|
|
842
|
+
),
|
|
843
|
+
h(
|
|
844
|
+
'span',
|
|
845
|
+
{ className: 'dshphu-modelNum' },
|
|
846
|
+
fmtTokens(m.peak.total + m.off.total) + ' · ' + fmtMoney(m.cost || 0),
|
|
847
|
+
),
|
|
848
|
+
),
|
|
849
|
+
),
|
|
850
|
+
)
|
|
851
|
+
: null,
|
|
852
|
+
h('div', { className: 'dshphu-note' }, t('usage.note').replace('{tokens}', fmtTokens(rep.other.tokens))),
|
|
853
|
+
h(
|
|
854
|
+
'div',
|
|
855
|
+
{ className: 'dshphu-line' },
|
|
856
|
+
t('usage.meta')
|
|
857
|
+
.replace('{sessions}', String((data.stats && data.stats.sessions) || 0))
|
|
858
|
+
.replace('{days}', String(rep.activeDays || 0)) +
|
|
859
|
+
' · ' +
|
|
860
|
+
(loading ? t('usage.loading') : t('usage.refreshHint')),
|
|
861
|
+
),
|
|
862
|
+
);
|
|
863
|
+
}
|
|
541
864
|
return {
|
|
542
865
|
inject: ['slots'],
|
|
543
866
|
apply(ctx) {
|
|
@@ -572,6 +895,19 @@ window.__ModuleLoader__.load({
|
|
|
572
895
|
PeakBadge,
|
|
573
896
|
),
|
|
574
897
|
);
|
|
898
|
+
ctx.slots.inject('settings.general.item', () =>
|
|
899
|
+
ctx.slots.register(
|
|
900
|
+
{
|
|
901
|
+
name: 'settings.general.item',
|
|
902
|
+
id: 'peak-hours-usage',
|
|
903
|
+
order: 31,
|
|
904
|
+
locale: NS,
|
|
905
|
+
label: () => translate('usage.title'),
|
|
906
|
+
},
|
|
907
|
+
UsageCard,
|
|
908
|
+
),
|
|
909
|
+
);
|
|
910
|
+
|
|
575
911
|
},
|
|
576
912
|
};
|
|
577
913
|
},
|
package/index.js
CHANGED
|
@@ -1,8 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Host half of
|
|
2
|
+
* Host half of dsh-peak-hours.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Serves the peak / off-peak usage report on `/dsh-peak-hours/api/usage` so the
|
|
5
|
+
* client half (a settings card) can show tokens and money without touching the
|
|
6
|
+
* filesystem itself.
|
|
7
7
|
*/
|
|
8
|
-
|
|
8
|
+
import { buildReport } from './lib/usage.js'
|
|
9
|
+
|
|
10
|
+
export const name = 'dsh-peak-hours'
|
|
11
|
+
export const inject = ['webServer']
|
|
12
|
+
|
|
13
|
+
const API = '/dsh-peak-hours/api'
|
|
14
|
+
/** The scan walks every session log; a short TTL keeps repeated UI loads cheap. */
|
|
15
|
+
const TTL_MS = 60_000
|
|
16
|
+
|
|
17
|
+
export function apply(ctx) {
|
|
18
|
+
let payload = null
|
|
19
|
+
let payloadAt = 0
|
|
20
|
+
let inflight = null
|
|
21
|
+
|
|
22
|
+
async function getReport(fresh) {
|
|
23
|
+
const now = Date.now()
|
|
24
|
+
if (!fresh && payload && now - payloadAt < TTL_MS) return payload
|
|
25
|
+
if (inflight) return inflight
|
|
26
|
+
inflight = (async () => {
|
|
27
|
+
try {
|
|
28
|
+
const report = await buildReport({})
|
|
29
|
+
payload = report
|
|
30
|
+
payloadAt = Date.now()
|
|
31
|
+
return report
|
|
32
|
+
} finally {
|
|
33
|
+
inflight = null
|
|
34
|
+
}
|
|
35
|
+
})()
|
|
36
|
+
return inflight
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
ctx.effect(() =>
|
|
40
|
+
ctx.webServer.register({
|
|
41
|
+
kind: 'prefix',
|
|
42
|
+
path: API,
|
|
43
|
+
handler: async (req, res) => {
|
|
44
|
+
const url = req.url || '/'
|
|
45
|
+
const pathname = url.split('?')[0]
|
|
46
|
+
const fresh = /[?&]fresh=1(?:&|$)/.test(url)
|
|
47
|
+
const sub = pathname.startsWith(API) ? pathname.slice(API.length) : pathname
|
|
48
|
+
try {
|
|
49
|
+
if (sub === '/usage') {
|
|
50
|
+
const report = await getReport(fresh)
|
|
51
|
+
res.writeHead(200, {
|
|
52
|
+
'content-type': 'application/json; charset=utf-8',
|
|
53
|
+
'cache-control': 'no-store',
|
|
54
|
+
})
|
|
55
|
+
res.end(JSON.stringify(report))
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
res.writeHead(200, {
|
|
59
|
+
'content-type': 'application/json; charset=utf-8',
|
|
60
|
+
'cache-control': 'no-store',
|
|
61
|
+
})
|
|
62
|
+
res.end(JSON.stringify({ ok: true, service: name, endpoints: ['/usage', '/usage?fresh=1'] }))
|
|
63
|
+
} catch (err) {
|
|
64
|
+
res.writeHead(500, {
|
|
65
|
+
'content-type': 'application/json; charset=utf-8',
|
|
66
|
+
'cache-control': 'no-store',
|
|
67
|
+
})
|
|
68
|
+
res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
)
|
|
73
|
+
}
|
package/lib/schedule.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peak / off-peak schedule and price table (host half).
|
|
3
|
+
*
|
|
4
|
+
* Kept in sync with `client.js` by hand: the client half is a standalone
|
|
5
|
+
* `window.__ModuleLoader__` bundle and cannot import package files, so the two
|
|
6
|
+
* copies are cross-checked by `test/schedule.test.mjs` against the published
|
|
7
|
+
* rules (09:00–12:00 and 14:00–18:00 Beijing time; weekends, Chinese public
|
|
8
|
+
* holidays and make-up rest days are all off-peak; off-peak is half price).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const MINUTE = 60_000
|
|
12
|
+
export const HOUR = 60 * MINUTE
|
|
13
|
+
/** Beijing is UTC+8 with no DST: shifting the instant is exact. */
|
|
14
|
+
export const BEIJING_OFFSET_MS = 8 * 3_600_000
|
|
15
|
+
|
|
16
|
+
/** Peak windows as minutes-of-day, Beijing time. */
|
|
17
|
+
export const PEAK_WINDOWS = [
|
|
18
|
+
[540, 720], // 09:00–12:00
|
|
19
|
+
[840, 1080], // 14:00–18:00
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
/** Chinese public holidays, keyed by year → MM-DD. Extend as the calendar is announced. */
|
|
23
|
+
export const HOLIDAYS = {
|
|
24
|
+
2026: [
|
|
25
|
+
'01-01', // 元旦
|
|
26
|
+
'02-17', '02-18', '02-19', '02-20', '02-21', '02-22', '02-23', // 春节
|
|
27
|
+
'04-04', '04-05', '04-06', // 清明
|
|
28
|
+
'05-01', '05-02', '05-03', '05-04', '05-05', // 劳动节
|
|
29
|
+
'06-19', '06-20', '06-21', // 端午
|
|
30
|
+
'09-25', '09-26', '09-27', // 中秋
|
|
31
|
+
'10-01', '10-02', '10-03', '10-04', '10-05', '10-06', '10-07', // 国庆
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Beijing calendar fields of an instant. */
|
|
36
|
+
export function bjParts(ms) {
|
|
37
|
+
const d = new Date(ms + BEIJING_OFFSET_MS)
|
|
38
|
+
return {
|
|
39
|
+
y: d.getUTCFullYear(),
|
|
40
|
+
mo: String(d.getUTCMonth() + 1).padStart(2, '0'),
|
|
41
|
+
d: String(d.getUTCDate()).padStart(2, '0'),
|
|
42
|
+
h: d.getUTCHours(),
|
|
43
|
+
mi: d.getUTCMinutes(),
|
|
44
|
+
dow: d.getUTCDay(),
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function bjDayKey(ms) {
|
|
49
|
+
const p = bjParts(ms)
|
|
50
|
+
return `${p.y}-${p.mo}-${p.d}`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isHoliday(ms) {
|
|
54
|
+
const p = bjParts(ms)
|
|
55
|
+
const list = HOLIDAYS[p.y]
|
|
56
|
+
return Array.isArray(list) && list.includes(`${p.mo}-${p.d}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isWeekend(ms) {
|
|
60
|
+
const dow = bjParts(ms).dow
|
|
61
|
+
return dow === 0 || dow === 6
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whole day is off-peak (weekend or public holiday). */
|
|
65
|
+
export function isOffDay(ms) {
|
|
66
|
+
return isWeekend(ms) || isHoliday(ms)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function minuteOfDay(ms) {
|
|
70
|
+
const p = bjParts(ms)
|
|
71
|
+
return p.h * 60 + p.mi
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isPeakInstant(ms) {
|
|
75
|
+
if (isOffDay(ms)) return false
|
|
76
|
+
const m = minuteOfDay(ms)
|
|
77
|
+
return PEAK_WINDOWS.some(([a, b]) => m >= a && m < b)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Prices in CNY per million tokens.
|
|
82
|
+
* `miss` also prices cache writes (a cache write is billed as uncached input).
|
|
83
|
+
*/
|
|
84
|
+
export const PRICES = {
|
|
85
|
+
flash: {
|
|
86
|
+
peak: { hit: 0.04, miss: 2, out: 8 },
|
|
87
|
+
off: { hit: 0.02, miss: 1, out: 4 },
|
|
88
|
+
},
|
|
89
|
+
pro: {
|
|
90
|
+
peak: { hit: 0.3, miss: 9, out: 27 },
|
|
91
|
+
off: { hit: 0.15, miss: 4.5, out: 13.5 },
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Which price class a model falls into, or `null` when the model is not billed
|
|
97
|
+
* on the DeepSeek peak/off-peak table (third-party providers routed through the
|
|
98
|
+
* harness are still counted as usage, but not as money).
|
|
99
|
+
*/
|
|
100
|
+
export function classifyModel(model) {
|
|
101
|
+
const m = String(model || '').toLowerCase()
|
|
102
|
+
if (!m.includes('deepseek')) return null
|
|
103
|
+
if (m.includes('pro') || m.includes('reasoner')) return 'pro'
|
|
104
|
+
return 'flash'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** `[input, output, cacheRead, cacheWrite]` → CNY. */
|
|
108
|
+
export function bucketCost(price, b) {
|
|
109
|
+
const miss = (b[0] || 0) + (b[3] || 0)
|
|
110
|
+
const hit = b[2] || 0
|
|
111
|
+
const out = b[1] || 0
|
|
112
|
+
return (miss * price.miss + hit * price.hit + out * price.out) / 1e6
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function fmtYuan(n) {
|
|
116
|
+
const v = Number(n) || 0
|
|
117
|
+
if (v > 0 && v < 0.01) return '¥' + v.toFixed(4)
|
|
118
|
+
if (v < 1) return '¥' + v.toFixed(3)
|
|
119
|
+
return '¥' + v.toFixed(2)
|
|
120
|
+
}
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-log usage scanner: turns durable session logs into peak / off-peak
|
|
3
|
+
* token totals and money.
|
|
4
|
+
*
|
|
5
|
+
* Data source (same one token-meter keeps): `~/.dsh/sessions/<cwd>/<id>/session.v<N>.jsonl.zstd`,
|
|
6
|
+
* a multi-frame zstd stream of JSONL events. Each assistant event carries a
|
|
7
|
+
* top-level `time` (epoch ms) and `data.usage`
|
|
8
|
+
* `{ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens }`.
|
|
9
|
+
* Repeated samples for the same `turn`/`step` REPLACE the previous one, so the
|
|
10
|
+
* folder subtracts the previous sample before adding the newer one.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { decodeAllFrames } from './zstd.js'
|
|
16
|
+
import { PRICES, bjDayKey, bjParts, bucketCost, classifyModel, isPeakInstant } from './schedule.js'
|
|
17
|
+
|
|
18
|
+
const LOG_RE = /^session\.v\d+\.jsonl\.zstd$/
|
|
19
|
+
const CACHE_VERSION = 3
|
|
20
|
+
const DAY_MS = 86_400_000
|
|
21
|
+
|
|
22
|
+
export function sessionsRoot() {
|
|
23
|
+
return path.join(os.homedir(), '.dsh', 'sessions')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function cacheFile() {
|
|
27
|
+
return path.join(os.homedir(), '.dsh', 'storages', 'peak-hours', 'cache.json')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function bucketsFrom(u) {
|
|
31
|
+
return [
|
|
32
|
+
Number(u.inputTokens) || 0,
|
|
33
|
+
Number(u.outputTokens) || 0,
|
|
34
|
+
Number(u.cacheReadTokens) || 0,
|
|
35
|
+
Number(u.cacheWriteTokens) || 0,
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lastStreamUsage(stream) {
|
|
40
|
+
if (!Array.isArray(stream)) return undefined
|
|
41
|
+
for (let i = stream.length - 1; i >= 0; i--) {
|
|
42
|
+
const r = stream[i]
|
|
43
|
+
if (r && r.type === 'chunk' && r.chunk && r.chunk.type === 'usage' && r.chunk.usage != null) {
|
|
44
|
+
return r.chunk.usage
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function usageOf(ev) {
|
|
51
|
+
if (ev.data && ev.data.usage != null) return ev.data.usage
|
|
52
|
+
if (ev.type !== 'assistant/message' && ev.type !== 'assistant/attempt') return undefined
|
|
53
|
+
return lastStreamUsage(ev.data && ev.data.stream)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function modelOfEvent(ev) {
|
|
57
|
+
const s = ev && ev.data && ev.data.message && ev.data.message.source
|
|
58
|
+
if (!s || typeof s !== 'object') return ''
|
|
59
|
+
const prov = typeof s.provider === 'string' ? s.provider : ''
|
|
60
|
+
const mod = typeof s.model === 'string' ? s.model : ''
|
|
61
|
+
return prov && mod ? prov + '/' + mod : mod || prov
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fold one session log's decoded text into `map`.
|
|
66
|
+
* Key: `${dayKey}|${hour}|${model}` → `[input, output, cacheRead, cacheWrite]`.
|
|
67
|
+
* @returns {{ samples: number, parsed: number }}
|
|
68
|
+
*/
|
|
69
|
+
export function foldSessionText(text, map) {
|
|
70
|
+
let last = null
|
|
71
|
+
let samples = 0
|
|
72
|
+
let parsed = 0
|
|
73
|
+
const add = (key, b, sign) => {
|
|
74
|
+
const cur = map.get(key) || [0, 0, 0, 0]
|
|
75
|
+
cur[0] += sign * b[0]
|
|
76
|
+
cur[1] += sign * b[1]
|
|
77
|
+
cur[2] += sign * b[2]
|
|
78
|
+
cur[3] += sign * b[3]
|
|
79
|
+
map.set(key, cur)
|
|
80
|
+
}
|
|
81
|
+
for (const line of text.split('\n')) {
|
|
82
|
+
if (!line) continue
|
|
83
|
+
let ev
|
|
84
|
+
try {
|
|
85
|
+
ev = JSON.parse(line)
|
|
86
|
+
} catch {
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
parsed++
|
|
90
|
+
const t = Number(ev.time)
|
|
91
|
+
if (!(t > 0)) continue
|
|
92
|
+
const u = usageOf(ev)
|
|
93
|
+
if (u == null) continue
|
|
94
|
+
const b = bucketsFrom(u)
|
|
95
|
+
const data = ev.data || {}
|
|
96
|
+
const turn = data.turn == null ? null : data.turn
|
|
97
|
+
const step = data.step == null ? null : data.step
|
|
98
|
+
const model = modelOfEvent(ev)
|
|
99
|
+
const p = bjParts(t)
|
|
100
|
+
if (last && last.turn === turn && last.step === step && turn !== null) {
|
|
101
|
+
add(last.key, last.b, -1)
|
|
102
|
+
}
|
|
103
|
+
const key = `${p.y}-${String(p.mo).padStart(2, '0')}-${String(p.d).padStart(2, '0')}|${p.h}|${model}`
|
|
104
|
+
add(key, b, 1)
|
|
105
|
+
last = { turn, step, key, b }
|
|
106
|
+
samples++
|
|
107
|
+
}
|
|
108
|
+
return { samples, parsed }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function listSessionLogs(root) {
|
|
112
|
+
const out = []
|
|
113
|
+
let cwds
|
|
114
|
+
try {
|
|
115
|
+
cwds = fs.readdirSync(root, { withFileTypes: true })
|
|
116
|
+
} catch {
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
for (const cwd of cwds) {
|
|
120
|
+
if (!cwd.isDirectory()) continue
|
|
121
|
+
const cwdDir = path.join(root, cwd.name)
|
|
122
|
+
let sessions
|
|
123
|
+
try {
|
|
124
|
+
sessions = fs.readdirSync(cwdDir, { withFileTypes: true })
|
|
125
|
+
} catch {
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
for (const s of sessions) {
|
|
129
|
+
if (!s.isDirectory()) continue
|
|
130
|
+
const dir = path.join(cwdDir, s.name)
|
|
131
|
+
let files
|
|
132
|
+
try {
|
|
133
|
+
files = fs.readdirSync(dir)
|
|
134
|
+
} catch {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
for (const f of files) {
|
|
138
|
+
if (LOG_RE.test(f)) out.push(path.join(dir, f))
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readCache(file) {
|
|
146
|
+
try {
|
|
147
|
+
const json = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
148
|
+
if (json && json.version === CACHE_VERSION && json.files && typeof json.files === 'object') return json
|
|
149
|
+
} catch {
|
|
150
|
+
/* missing or corrupt → cold scan */
|
|
151
|
+
}
|
|
152
|
+
return { version: CACHE_VERSION, files: {} }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function writeCache(file, data) {
|
|
156
|
+
try {
|
|
157
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
158
|
+
const tmp = file + '.tmp'
|
|
159
|
+
fs.writeFileSync(tmp, JSON.stringify(data))
|
|
160
|
+
fs.renameSync(tmp, file)
|
|
161
|
+
} catch {
|
|
162
|
+
/* cache is an optimisation only */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read every session log, reusing per-file aggregates for unchanged logs.
|
|
168
|
+
* Yields to the event loop between files so a cold scan never freezes the host.
|
|
169
|
+
* @returns {Promise<{ buckets: Array<[string, number, string, number, number, number, number]>, stats: object }>}
|
|
170
|
+
*/
|
|
171
|
+
export async function scanSessions(opts = {}) {
|
|
172
|
+
const root = opts.root || sessionsRoot()
|
|
173
|
+
const cachePath = opts.cacheFile || cacheFile()
|
|
174
|
+
const started = Date.now()
|
|
175
|
+
const cache = readCache(cachePath)
|
|
176
|
+
const files = listSessionLogs(root)
|
|
177
|
+
const stats = { sessions: files.length, scanned: 0, cacheHits: 0, dropped: 0, frames: 0 }
|
|
178
|
+
const buckets = []
|
|
179
|
+
const nextCache = { version: CACHE_VERSION, files: {} }
|
|
180
|
+
const tick = () => new Promise((resolve) => setImmediate(resolve))
|
|
181
|
+
|
|
182
|
+
for (const file of files) {
|
|
183
|
+
await tick()
|
|
184
|
+
let st
|
|
185
|
+
try {
|
|
186
|
+
st = fs.statSync(file)
|
|
187
|
+
} catch {
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
const prev = cache.files[file]
|
|
191
|
+
if (prev && prev.size === st.size && prev.mtimeMs === Math.trunc(st.mtimeMs)) {
|
|
192
|
+
stats.cacheHits++
|
|
193
|
+
nextCache.files[file] = prev
|
|
194
|
+
for (const row of prev.buckets) buckets.push(row)
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
let buf
|
|
198
|
+
try {
|
|
199
|
+
buf = fs.readFileSync(file)
|
|
200
|
+
} catch {
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
const decoded = decodeAllFrames(buf)
|
|
204
|
+
const map = new Map()
|
|
205
|
+
foldSessionText(decoded.text, map)
|
|
206
|
+
stats.scanned++
|
|
207
|
+
stats.dropped += decoded.dropped
|
|
208
|
+
stats.frames += decoded.frames
|
|
209
|
+
const rows = []
|
|
210
|
+
for (const [key, b] of map) {
|
|
211
|
+
if (b[0] === 0 && b[1] === 0 && b[2] === 0 && b[3] === 0) continue
|
|
212
|
+
const [day, hour, model] = key.split('|')
|
|
213
|
+
rows.push([day, Number(hour), model, b[0], b[1], b[2], b[3]])
|
|
214
|
+
buckets.push([day, Number(hour), model, b[0], b[1], b[2], b[3]])
|
|
215
|
+
}
|
|
216
|
+
nextCache.files[file] = {
|
|
217
|
+
size: st.size,
|
|
218
|
+
mtimeMs: Math.trunc(st.mtimeMs),
|
|
219
|
+
frames: decoded.frames,
|
|
220
|
+
dropped: decoded.dropped,
|
|
221
|
+
buckets: rows,
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
writeCache(cachePath, nextCache)
|
|
226
|
+
stats.scanMs = Date.now() - started
|
|
227
|
+
return { buckets, stats }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const EMPTY = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 })
|
|
231
|
+
|
|
232
|
+
function addTo(acc, b) {
|
|
233
|
+
acc.input += b[0]
|
|
234
|
+
acc.output += b[1]
|
|
235
|
+
acc.cacheRead += b[2]
|
|
236
|
+
acc.cacheWrite += b[3]
|
|
237
|
+
acc.total += b[0] + b[1] + b[2] + b[3]
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Instant at the start of `dayKey` + `hour` in Beijing time. */
|
|
241
|
+
function instantOf(day, hour) {
|
|
242
|
+
const [y, mo, d] = day.split('-').map(Number)
|
|
243
|
+
return Date.UTC(y, mo - 1, d, hour, 0, 0) - 8 * 3_600_000
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Aggregate bucket rows into one range.
|
|
248
|
+
* @param {Array} buckets rows from `scanSessions`
|
|
249
|
+
* @param {{ days?: number|null, nowMs?: number }} opts `days = null` means all time
|
|
250
|
+
*/
|
|
251
|
+
export function aggregate(buckets, opts = {}) {
|
|
252
|
+
const nowMs = opts.nowMs || Date.now()
|
|
253
|
+
const days = opts.days == null ? null : Number(opts.days)
|
|
254
|
+
const cutoff = days == null ? null : nowMs - days * DAY_MS
|
|
255
|
+
const out = {
|
|
256
|
+
days,
|
|
257
|
+
peak: EMPTY(),
|
|
258
|
+
off: EMPTY(),
|
|
259
|
+
classes: { flash: EMPTY(), pro: EMPTY() },
|
|
260
|
+
peakClasses: { flash: EMPTY(), pro: EMPTY() },
|
|
261
|
+
offClasses: { flash: EMPTY(), pro: EMPTY() },
|
|
262
|
+
classByModel: {},
|
|
263
|
+
other: { tokens: 0, models: {} },
|
|
264
|
+
byModel: {},
|
|
265
|
+
days_seen: new Set(),
|
|
266
|
+
}
|
|
267
|
+
for (const row of buckets) {
|
|
268
|
+
const [day, hour, model, i, o, cr, cw] = row
|
|
269
|
+
const b = [i, o, cr, cw]
|
|
270
|
+
const inst = instantOf(day, hour)
|
|
271
|
+
if (cutoff != null && inst < cutoff) continue
|
|
272
|
+
const peak = isPeakInstant(inst)
|
|
273
|
+
const cls = classifyModel(model)
|
|
274
|
+
out.days_seen.add(day)
|
|
275
|
+
addTo(peak ? out.peak : out.off, b)
|
|
276
|
+
const modelTot = (out.byModel[model] ||= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0, peak: 0, off: 0 })
|
|
277
|
+
addTo(modelTot, b)
|
|
278
|
+
if (peak) modelTot.peak += i + o + cr + cw
|
|
279
|
+
else modelTot.off += i + o + cr + cw
|
|
280
|
+
if (cls) {
|
|
281
|
+
addTo(out.classes[cls], b)
|
|
282
|
+
addTo(peak ? out.peakClasses[cls] : out.offClasses[cls], b)
|
|
283
|
+
const cm = (out.classByModel[model] ||= { class: cls, peak: EMPTY(), off: EMPTY() })
|
|
284
|
+
addTo(peak ? cm.peak : cm.off, b)
|
|
285
|
+
} else {
|
|
286
|
+
out.other.tokens += i + o + cr + cw
|
|
287
|
+
out.other.models[model] = (out.other.models[model] || 0) + i + o + cr + cw
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const costActual = {}
|
|
292
|
+
const costAllPeak = {}
|
|
293
|
+
const costAllOff = {}
|
|
294
|
+
let actual = 0
|
|
295
|
+
let allPeak = 0
|
|
296
|
+
let allOff = 0
|
|
297
|
+
for (const cls of ['flash', 'pro']) {
|
|
298
|
+
const price = PRICES[cls]
|
|
299
|
+
const a = bucketCost(price.peak, toB(out.peakClasses[cls])) + bucketCost(price.off, toB(out.offClasses[cls]))
|
|
300
|
+
const p = bucketCost(price.peak, toB(out.classes[cls]))
|
|
301
|
+
const o = bucketCost(price.off, toB(out.classes[cls]))
|
|
302
|
+
costActual[cls] = a
|
|
303
|
+
costAllPeak[cls] = p
|
|
304
|
+
costAllOff[cls] = o
|
|
305
|
+
actual += a
|
|
306
|
+
allPeak += p
|
|
307
|
+
allOff += o
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const peakTotal = out.peak.total
|
|
311
|
+
const offTotal = out.off.total
|
|
312
|
+
const total = peakTotal + offTotal
|
|
313
|
+
const priced = out.classes.flash.total + out.classes.pro.total
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
days,
|
|
317
|
+
since: cutoff == null ? null : bjDayKey(cutoff),
|
|
318
|
+
activeDays: out.days_seen.size,
|
|
319
|
+
tokens: {
|
|
320
|
+
peak: out.peak,
|
|
321
|
+
off: out.off,
|
|
322
|
+
total,
|
|
323
|
+
peakShare: total > 0 ? peakTotal / total : 0,
|
|
324
|
+
offShare: total > 0 ? offTotal / total : 0,
|
|
325
|
+
priced,
|
|
326
|
+
},
|
|
327
|
+
classes: out.classes,
|
|
328
|
+
models: Object.entries(out.byModel)
|
|
329
|
+
.map(([model, v]) => ({ model, cls: classifyModel(model), ...v }))
|
|
330
|
+
.sort((a, b) => b.total - a.total),
|
|
331
|
+
classByModel: Object.entries(out.classByModel)
|
|
332
|
+
.map(([model, v]) => {
|
|
333
|
+
const price = PRICES[v.class]
|
|
334
|
+
const peakCost = bucketCost(price.peak, toB(v.peak))
|
|
335
|
+
const offCost = bucketCost(price.off, toB(v.off))
|
|
336
|
+
return {
|
|
337
|
+
model,
|
|
338
|
+
cls: v.class,
|
|
339
|
+
peak: v.peak,
|
|
340
|
+
off: v.off,
|
|
341
|
+
peakCost,
|
|
342
|
+
offCost,
|
|
343
|
+
allPeakCost: bucketCost(price.peak, toB(v.peak)) + bucketCost(price.peak, toB(v.off)),
|
|
344
|
+
cost: peakCost + offCost,
|
|
345
|
+
}
|
|
346
|
+
})
|
|
347
|
+
.sort((a, b) => b.cost - a.cost),
|
|
348
|
+
other: {
|
|
349
|
+
tokens: out.other.tokens,
|
|
350
|
+
models: Object.entries(out.other.models)
|
|
351
|
+
.map(([model, tokens]) => ({ model, tokens }))
|
|
352
|
+
.sort((a, b) => b.tokens - a.tokens),
|
|
353
|
+
},
|
|
354
|
+
cost: {
|
|
355
|
+
actual,
|
|
356
|
+
allPeak,
|
|
357
|
+
allOff,
|
|
358
|
+
byClass: costActual,
|
|
359
|
+
allPeakByClass: costAllPeak,
|
|
360
|
+
allOffByClass: costAllOff,
|
|
361
|
+
saved: allPeak - actual,
|
|
362
|
+
savedPct: allPeak > 0 ? (allPeak - actual) / allPeak : 0,
|
|
363
|
+
},
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function toB(acc) {
|
|
368
|
+
return [acc.input, acc.output, acc.cacheRead, acc.cacheWrite]
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** One payload for the client: the three ranges it offers. */
|
|
372
|
+
export async function buildReport(opts = {}) {
|
|
373
|
+
const nowMs = opts.nowMs || Date.now()
|
|
374
|
+
const { buckets, stats } = await scanSessions(opts)
|
|
375
|
+
return {
|
|
376
|
+
ok: true,
|
|
377
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
378
|
+
stats,
|
|
379
|
+
ranges: {
|
|
380
|
+
'7': aggregate(buckets, { days: 7, nowMs }),
|
|
381
|
+
'30': aggregate(buckets, { days: 30, nowMs }),
|
|
382
|
+
all: aggregate(buckets, { days: null, nowMs }),
|
|
383
|
+
},
|
|
384
|
+
cache: { file: cacheFile(), raw: false },
|
|
385
|
+
}
|
|
386
|
+
}
|
package/lib/zstd.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-frame zstd decoder.
|
|
3
|
+
*
|
|
4
|
+
* A durable session log (`session.v<N>.jsonl.zstd`) is a *sequence* of zstd
|
|
5
|
+
* frames — one frame appended per batched write. `zlib.zstdDecompressSync`
|
|
6
|
+
* decodes only the FIRST frame and silently drops the rest, so we have to walk
|
|
7
|
+
* the physical frame boundaries ourselves: parse a frame header, walk its
|
|
8
|
+
* blocks to find the frame end, then decompress exactly that slice.
|
|
9
|
+
*
|
|
10
|
+
* A torn tail (a frame still being written) is expected: we stop at the last
|
|
11
|
+
* complete frame and report how many frames were dropped, which lets callers
|
|
12
|
+
* resync on the next scan.
|
|
13
|
+
*/
|
|
14
|
+
import zlib from 'node:zlib'
|
|
15
|
+
|
|
16
|
+
const ZSTD_MAGIC = 0xfd2fb528
|
|
17
|
+
|
|
18
|
+
function isSkippableMagic(m) {
|
|
19
|
+
return m >= 0x184d2a50 && m <= 0x184d2a5f
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function findMagic(buf, from) {
|
|
23
|
+
for (let i = from; i + 4 <= buf.length; i++) {
|
|
24
|
+
if (buf.readUInt32LE(i) === ZSTD_MAGIC) return i
|
|
25
|
+
}
|
|
26
|
+
return -1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Physical end offset of the frame starting at `off` (throws on a torn frame). */
|
|
30
|
+
function parseFrameEnd(buf, off) {
|
|
31
|
+
let p = off + 4
|
|
32
|
+
const fhd = buf[p]
|
|
33
|
+
p += 1
|
|
34
|
+
const fcsFlag = fhd >> 6
|
|
35
|
+
const single = (fhd >> 5) & 1
|
|
36
|
+
const checksum = (fhd >> 2) & 1
|
|
37
|
+
const dictId = fhd & 3
|
|
38
|
+
p += [0, 1, 2, 4][dictId]
|
|
39
|
+
if (!single) p += 1 // window descriptor
|
|
40
|
+
p += fcsFlag === 0 ? (single ? 1 : 0) : fcsFlag === 1 ? 2 : fcsFlag === 2 ? 4 : 8
|
|
41
|
+
let last = false
|
|
42
|
+
let guard = 0
|
|
43
|
+
while (!last) {
|
|
44
|
+
if (p + 3 > buf.length) throw new Error('torn block header')
|
|
45
|
+
const bh = buf.readUIntLE(p, 3)
|
|
46
|
+
p += 3
|
|
47
|
+
last = (bh & 1) === 1
|
|
48
|
+
const type = (bh >>> 1) & 3
|
|
49
|
+
const size = bh >>> 3
|
|
50
|
+
if (type === 3) throw new Error('reserved block type')
|
|
51
|
+
const payload = type === 1 ? 1 : size
|
|
52
|
+
if (p + payload > buf.length) throw new Error('torn block payload')
|
|
53
|
+
p += payload
|
|
54
|
+
if (++guard > 1e7) throw new Error('runaway blocks')
|
|
55
|
+
}
|
|
56
|
+
if (checksum) {
|
|
57
|
+
if (p + 4 > buf.length) throw new Error('torn checksum')
|
|
58
|
+
p += 4
|
|
59
|
+
}
|
|
60
|
+
return p
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decode every complete frame in `buf`.
|
|
65
|
+
* @param {Buffer} buf
|
|
66
|
+
* @param {(text: string) => void} [onFrame] per-frame UTF-8 text (optional)
|
|
67
|
+
* @returns {{ text: string, frames: number, dropped: number }}
|
|
68
|
+
*/
|
|
69
|
+
export function decodeAllFrames(buf, onFrame) {
|
|
70
|
+
const parts = []
|
|
71
|
+
let off = 0
|
|
72
|
+
let frames = 0
|
|
73
|
+
let dropped = 0
|
|
74
|
+
while (off < buf.length) {
|
|
75
|
+
if (buf.length - off < 4) {
|
|
76
|
+
if (off < buf.length) dropped += 1
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
const magic = buf.readUInt32LE(off)
|
|
80
|
+
if (isSkippableMagic(magic)) {
|
|
81
|
+
// Skippable frame: 4-byte magic + 4-byte LE size + payload.
|
|
82
|
+
if (off + 8 > buf.length) {
|
|
83
|
+
dropped += 1
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
const size = buf.readUInt32LE(off + 4)
|
|
87
|
+
if (off + 8 + size > buf.length) {
|
|
88
|
+
dropped += 1
|
|
89
|
+
break
|
|
90
|
+
}
|
|
91
|
+
off += 8 + size
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (magic !== ZSTD_MAGIC) {
|
|
95
|
+
const next = findMagic(buf, off + 1)
|
|
96
|
+
if (next < 0) break
|
|
97
|
+
off = next
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
let end
|
|
101
|
+
try {
|
|
102
|
+
end = parseFrameEnd(buf, off)
|
|
103
|
+
} catch {
|
|
104
|
+
dropped += 1
|
|
105
|
+
break
|
|
106
|
+
}
|
|
107
|
+
const slice = buf.subarray(off, end)
|
|
108
|
+
let text
|
|
109
|
+
try {
|
|
110
|
+
text = zlib.zstdDecompressSync(slice).toString('utf8')
|
|
111
|
+
} catch {
|
|
112
|
+
dropped += 1
|
|
113
|
+
off = end
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
frames += 1
|
|
117
|
+
if (onFrame) onFrame(text)
|
|
118
|
+
else parts.push(text)
|
|
119
|
+
off = end
|
|
120
|
+
}
|
|
121
|
+
return { text: parts.join(''), frames, dropped }
|
|
122
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-peak-hours",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "Sidebar badge that marks the current DeepSeek peak-pricing window
|
|
5
|
+
"description": "Sidebar badge that marks the current DeepSeek peak-pricing window (24-hour schedule bar, countdown, click to copy) plus a settings card that splits your logged usage into peak / off-peak tokens and compares what it actually cost against all-peak and all-off-peak.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "run666888 <zhirunli6@gmail.com>",
|
|
8
8
|
"homepage": "https://github.com/run666888/dsh-peak-hours#readme",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"peak-hours",
|
|
28
28
|
"off-peak",
|
|
29
29
|
"pricing",
|
|
30
|
+
"cost",
|
|
31
|
+
"usage",
|
|
30
32
|
"sidebar"
|
|
31
33
|
],
|
|
32
34
|
"publishConfig": {
|
|
@@ -42,6 +44,7 @@
|
|
|
42
44
|
"files": [
|
|
43
45
|
"index.js",
|
|
44
46
|
"client.js",
|
|
47
|
+
"lib",
|
|
45
48
|
"cordis.patch.yml",
|
|
46
49
|
"locale/*.json",
|
|
47
50
|
"icon.svg",
|