dsh-usage-badge 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # dsh-usage-badge
2
2
 
3
- DeepSeek Harness 的用量徽标插件:侧边栏底部一行「今日用量」显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月的用量,并按 **DeepSeek 官方定价**和**中国法定节假日**计价。
3
+ DeepSeek Harness 的用量徽标插件:侧边栏底部一行「今日用量」显示当天的 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月 / **按年**的用量与活跃度热力图,并按 **DeepSeek 官方定价**和**中国法定节假日**计价。
4
4
 
5
5
  ![侧边栏底部的「今日用量」行与用量弹窗](assets/usage-dialog.png)
6
6
 
@@ -32,16 +32,26 @@ dsh plugin --profile web remove dsh-usage-badge
32
32
 
33
33
  弹窗有两个页签:
34
34
 
35
- - **用量** —— 四个时间区间(`24 小时` / `近 7 日` / `近 30 日` / `近 12 个月`)、按 provider 筛选、金额/请求数/Token/缓存命中率四项合计,以及一张四序列折线图(金额、token 总量、请求数、缓存命中率)。鼠标移到图上显示该点的四项数值和参考线。
35
+ - **用量** —— 五个时间区间(`24 小时` / `近 7 日` / `近 30 日` / `近 12 个月` / **`按年`**)、按 provider 筛选(**候选来自今天的 provider**,但筛选同时作用于所有区间与热力图,所以只在历史里出现过的 provider 选不到)、金额/请求数/Token/缓存命中率四项合计,以及一张四序列折线图(金额、token 总量、请求数、缓存命中率)。鼠标移到图上显示该点的四项数值和参考线。**点图例可以单独看某一条**:点掉其余三条,它就会按自己的刻度铺满整张图;有隐藏项时图例尾部出现「全部显示」。
36
36
 
37
37
  区间按**自然日 / 自然月**切分:`近 7 日` 就是最近 7 个连续自然日,没有用量的那天保留自己的空槽位,不会把窗口拉长成 8 天。
38
- - **单价配置** —— 官方定价、法定节假日日历、当前生效单价。
39
38
 
40
- ## 单价:只有一个来源
39
+ **`按年`** 选一年,看那一年的 12 个自然月与年度合计 —— 年份下拉里的选项**来自缓存里真实有数据的年份**(新的年份第一次有用量就自己出现,不用改配置、也不用维护一份列表),跨年时不用等一年。这一年是直接向 SQLite 查询的,所以**比快照窗口更早的年份也读得到**;下面的热力图也跟着变成**那一年的自然年格子**(1 月 1 日 → 12 月 31 日,53 列正好一年;当年则画到今天)。
41
40
 
42
- **就是 DeepSeek 官方定价页。** 打开「单价配置」时会实时抓取并解析它(中文页人民币 / 英文页美元),确认无误后点「应用官方价格」写入。
41
+ 折线图下面是**近一年活跃度热力图**,GitHub 贡献图那种方格:一列一周(周一起)、一行一个星期、每格一天,按当天金额分五档着色(可切 Token / 请求数)。鼠标停在格子上弹出**和折线图同一张卡片**:金额(¥)/ token 总量 / 请求数 / 缓存命中率(%)四行,标题是「日期 + 星期」(节假日带节日名,当天没用量则标「无用量」);标题右侧给出近一年的合计与「有用量的天数」。**没有用量的那天照样占着自己的浅色格子**,所以空白是真的空白。
42
+ - **单价配置** —— 官方定价、法定节假日日历、**当前生效单价(覆盖行与兜底行都能在面板里增删改)**。
43
43
 
44
- 面板里**没有任何要手填的字段** —— 汇率、币种、倍率都不需要配置(官方中文页就是人民币,徽标也始终是人民币)。想给网关上的其他模型单独定价,或者加自定义节假日,直接改下面那个 `pricing.json` 即可,改完即时生效。
44
+ ## 单价:官方定价 + 你自己的覆盖行
45
+
46
+ **官方单价来自 DeepSeek 官方定价页。** 打开「单价配置」时抓取一次并解析**中文页**(人民币)—— 每个页面只抓这一次,切走再切回来不会重新联网,要更新点「重新获取」;表下面那行写着这份列表是什么时候抓的,超过 10 分钟会标出「几小时前」。确认无误后点「应用官方价格」写入 —— 它会覆盖官方页面点名的那些同名行,**其余行原样不动**。(键名匹配不区分大小写,但写入时用官方页面的拼写;所以你手写的大写变体如 `DeepSeek-Flash` 不会被合并,而是和官方行并存、在表里都看得到。)
47
+
48
+ 官方页面没点名的模型(网关上的 `gpt-5.6-*`、`glm-5.3-flash` 之类),就在「当前生效单价」那张表里自己维护:**新增一行 / 编辑 / 删除**都在面板里点,每行可以写键、四个单价、倍率,以及这一行的**峰谷规则**(跟随表级 / 不参与峰谷 / 自定义)。表里会显示每一行当前怎么参与峰谷,所以「某一行有没有被乘峰谷」不用去翻 JSON。等价于直接改下面那个 `pricing.json`,两边是同一份文件。
49
+
50
+ **币种就是人民币**,面板里没有币种选项(徽标、合计、官方单价全是人民币,也没有汇率要配);另外两个开关——**前缀回退**和**未匹配模型是否计价**——在面板里点一下就能改(见下)。三个区块的说明性文字都收进了标题旁的 **ⓘ 图标**里,鼠标停上去才展开 —— 面板本身只留数字和你按得到的按钮。
51
+
52
+ 价格表的键不区分大小写,四种写法:`模型名` / `provider|模型名` / `provider|*` / `*|模型名`。**官方生成的 `deepseek-flash` 这一行会自己兜住 `deepseek-flash-preview` / `deepseek-flash-opencode` 这类带后缀的变体**,不用你再加一行 —— 只要前缀后面是分隔符(`- _ . : / @`)就算命中,所以 `gpt-4` 不会吞掉 `gpt-4o`。想关掉这个回退,在面板里点一下**「关闭前缀回退」**就行(等价写法 `"modelPrefixFallback": false`);也可以用 `deepseek-flash-*` 这样的显式家族行自己划定范围(精确行永远优先于前缀行)。
53
+
54
+ **一行都没匹配上的模型**,默认按 `default`(兜底行)计价。兜底行的编辑/删除也在面板里:**表里有 `default` 行时它列在表中**(带编辑、删除);没有时表格下方是一行提示写明现在用的是内置模板的哪个费率,并给一个「添加兜底行」按钮。删掉之后表回到内置模板、面板会写明「当前 default 来自内置模板」,而**下次点「应用官方价格」会按官方列表第一条重新生成**(第一条就是它最便宜的那档 `deepseek-flash`;官方页面第一条最便宜这件事有测试钉着)。你自己写过的兜底行不会被 `应用官方价格` 覆盖 —— 它只补「没有」这件事。不想给未知模型估价,就在面板里点一下**「改为不计价」**:它们的 token 与请求数照常统计,金额记 0,而且**用量面板会列出具体是哪些模型**,所以那个 0 不会被误读成免费(等价写法是 `"priceUnmatchedModels": false`)。
45
55
 
46
56
  官方页面改版导致解析失败时,面板会报错并保留原价格,不会静默算错。想核对线上页面是否还能解析:`node test/official-pricing.live.mjs`。
47
57
 
@@ -49,7 +59,9 @@ dsh plugin --profile web remove dsh-usage-badge
49
59
 
50
60
  官方规则:**周一至周五(不含中国法定节假日)9:00-12:00、14:00-18:00 是高峰时段**,其余时段 —— 包括周末和法定节假日**全天** —— 都按空闲价。
51
61
 
52
- 插件内置了中国法定节假日日历(2025–2026,取自国务院办公厅的放假安排通知),所以落在工作日上的节假日不会被按峰价计费。
62
+ 插件内置了中国法定节假日日历(2025–2026,取自国务院办公厅的放假安排通知)。**但它是价格表里 `holidays.source` 这个开关**:写 `"cn"`(或点一次「应用官方价格」,它会自动打开)才算数,缺省时工作日节假日会被当成普通工作日按峰价计费 —— 面板在未启用时会这么写明。
63
+
64
+ **峰谷对价格表里的每一行都生效**,包括你后来手写加的行 —— 不用每行都写一遍 `timeOfUse`。想让某一行完全不参与峰谷(比如网关那边已经是固定折后价),在行编辑器里把「峰谷」选成**不参与峰谷**;想让它按别的倍率算,选**自定义**并写一整套 `timeOfUse`(写全:行内规则是**整体替换**表级规则的,漏了 `peakMultiplier` 就等于那一行不加价)。**周末**用 `days` 逐行控制,编辑器给的是 周一至周五 / 周末 / 每天 三档,数组写法(`[6,7]`)要手写 JSON;**法定节假日**用 `"honorHolidays": false` 逐行关掉(有些网关在国庆照样按自己的峰时收费),这个勾选框在峰谷选成「自定义」后出现。
53
65
 
54
66
  **周末永远按空闲价**,即使国务院把它调休成上班日也一样:官方原文是把「周末」整体划作空闲时段的,调休后的周日仍然是周日。
55
67
 
@@ -57,16 +69,21 @@ dsh plugin --profile web remove dsh-usage-badge
57
69
 
58
70
  ```
59
71
  ~/.dsh/storages/usage-badge/
60
- ├── pricing.json 价格表(「应用官方价格」写的就是它)
61
- └── cache.json 会话日志的折叠缓存(可以随时删,会自动重建)
72
+ ├── pricing.json 价格表(「应用官方价格」写的就是它)
73
+ └── cache.sqlite 会话日志的折叠缓存(可以随时删,会自动重建)
62
74
  ```
63
75
 
64
- 插件只**读**DSH 的会话日志(`~/.dsh/sessions`),不改动它们;`jsonl` 和 `jsonl.zstd` 都支持。
76
+ 缓存用的是 **Node 内建的 SQLite**(`node:sqlite`,随运行时一起提供,不需要装任何东西):一次保存只写「变化的那几天」,历史留在库里,内存里只装最近一年多。代价跟**那个会话**的大小有关、跟库里存了多少年无关 —— 实测(同一个会话只有一天变化时):1 天的会话 0.12 ms、30 天 0.82 ms、400 天 4.98 ms;旧的单文档缓存每存一次都要重写全部历史(两年历史时约 19 ms,且随年数线性增长)。常驻内存同样不随年数增长:实测两年历史 +6 MB(旧缓存 +14 MB)。运行时不提供 `node:sqlite` 时自动退回旧的 `cache.json` 单文档格式;从旧版本升级时会把 `cache.json` **改名**为 `cache.json.migrated` 保留下来(不删),并在启动日志里写明 —— 那个文件之后**再也不会被读**,确认新版本没问题后可以随手删掉(它只是万一要退回旧版本时用的副本)。全新安装时没有 `cache.json` 也完全正常:直接建一个空的 `cache.sqlite`,从会话日志折一遍。
77
+
78
+ `cache.sqlite` 万一读不出来(写坏了、是别的程序的文件、备份只拷了一半),插件会把它**改名成 `cache.sqlite.corrupt-<时间戳>`** 并新建一个能用的,而不是从此退化到慢的那条路 —— 原始文件保留着可以事后检查。**但如果它只是写不进去**(只读属性、ACL 变了、盘满了、另一个宿主正锁着它),那就**按只读使用、原样不动**:为了一个权限问题把几年的历史挪走,是比慢得多的错误。如果它其实是**更新版本**写的(你降级了插件),同样原样放着、只按只读使用,绝不覆盖 —— 字节级不变,有测试钉住。
79
+
80
+ 插件只**读**DSH 的会话日志(`~/.dsh/sessions`),不改动它们;`jsonl` 和 `jsonl.zstd` 都支持。**日志被删掉后,那段历史靠缓存继续计入**(缓存也丢了才会消失)—— 面板的 ⓘ 里写明了当前用的是哪种缓存。
65
81
 
66
82
  ## 已知限制
67
83
 
68
84
  - **首次启动要冷扫一遍全部会话日志**(200 多份实测约 10 秒;分片让出事件循环,界面不会卡住)。之后走增量,几乎瞬时。
69
- - **历史依赖日志**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。
85
+ - **历史依赖日志**:日志被删掉后靠缓存继续计入;缓存也丢了,那段历史就没了。(日志不是被删掉而是**变短/还原成更早的备份**时,那个会话的历史会跟着变少 —— 折叠按当前文件内容重建该会话。)
86
+ - **日粒度区间仍只覆盖最近一年**:`近 7 日` / `近 30 日` / `近 12 个月` 与它们下面的滚动热力图都基于快照的最近 400 天。要看更早的日子,用 **`按年`**:那一档的月度合计、年度总额与**自然年热力图**都是直接查缓存库的,任何一年都在。
70
87
  - **只统计 DSH 记进日志的调用**:某个东西直接请求外部 API 且不向 DSH 上报 usage,这部分统计不到。
71
88
  - **数字是估算**:token 口径和官方账单的列数不同,**对账请以金额为准**。
72
89
 
@@ -74,7 +91,7 @@ dsh plugin --profile web remove dsh-usage-badge
74
91
 
75
92
  ```sh
76
93
  npm install # 只为测试装 react / react-dom
77
- npm test # 5 个套件:包契约、定价、节假日、官方定价解析、浏览器半渲染
94
+ npm test # 6 个套件:包契约、定价、节假日、官方定价解析、缓存存储、浏览器半渲染
78
95
  ```
79
96
 
80
97
  改了东西怎么生效:
@@ -84,6 +101,8 @@ npm test # 5 个套件:包契约、定价、节假日、官方定价解
84
101
  | `lib/client.js`(界面、图表) | **刷新页面**即可 |
85
102
  | `lib/index.js` 等宿主半 | **重启客户端** |
86
103
 
104
+ 宿主半和界面半各带一个版本号(`HOST_API_VERSION` / `REQUIRED_HOST_API`),对不上时面板顶部会直接说明该「重启客户端」还是该「刷新页面」—— 不用再靠「按钮点了没反应」去猜:宿主半装载在进程启动时,只刷新页面拿到的是新界面配旧宿主,而旧宿主不认识的新写入会返回 200 并什么都不做。
105
+
87
106
  更详细的东西(实现取舍、官方政策原文、路由清单、配置字段语义、当年的踩坑记录)在 [docs/design-notes.md](docs/design-notes.md)。
88
107
 
89
108
  ## License
@@ -5,13 +5,13 @@
5
5
 
6
6
  # dsh-usage-badge
7
7
 
8
- DeepSeek Harness 的**用量徽标插件**:侧边栏底部(设置按钮上方)一行「今日用量」显示当日 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月用量,按 provider 筛选,并按 **DeepSeek 官方定价**与**中国法定节假日日历**计价。
8
+ DeepSeek Harness 的**用量徽标插件**:侧边栏底部(设置按钮上方)一行「今日用量」显示当日 token 总量与估算费用,点开可看 24 小时 / 近 7 日 / 近 30 日 / 近 12 个月用量与近一年的活跃度热力图,按 provider 筛选,并按 **DeepSeek 官方定价**与**中国法定节假日日历**计价。
9
9
 
10
10
  它把 [DeepSeek-Harness](https://github.com/ai-written/DeepSeek-Harness) 桌面壳里的「每日用量徽标」重做成一个独立的 DSH 插件,因此**纯 `dsh web` 与 CLI 也能用**,不再依赖 Tauri 壳。
11
11
 
12
12
  ![DeepSeek Harness 侧边栏底部的「今日用量」行与用量弹窗](assets/usage-dialog.png)
13
13
 
14
- **零依赖、零构建**:宿主半只用 Node 内建模块;浏览器半是手写的单文件 bundle(`lib/client.js` 既是源码也是产物),不需要 esbuild、不需要 `pnpm run build`。图不用图表库,四序列折线是手写 SVG。
14
+ **零依赖、零构建**:宿主半只用 Node 内建模块;浏览器半是手写的单文件 bundle(`lib/client.js` 既是源码也是产物),不需要 esbuild、不需要 `pnpm run build`。图不用图表库,四序列折线、近一年的热力图方格都是手写 SVG。
15
15
 
16
16
  ## 安装
17
17
 
@@ -51,7 +51,7 @@ dsh plugin --profile web remove dsh-usage-badge
51
51
  侧边栏底部的 **「今日用量」行**(就在「设置」上方,所以它跟设置行同一套高度、圆角、悬停与缩进;侧边栏收起时缩成 36×36 的小方块),从左到右依次是:
52
52
 
53
53
  - **「今日用量」** 标签
54
- - **峰谷圆点**:谷时灰色、峰时橙色;悬停圆点本身会显示 `峰时 ×2` / `谷时 ×1` / `国庆节 · 全天空闲`
54
+ - **峰谷圆点**:谷时灰色、峰时橙色;悬停圆点本身会显示 `峰时 ×2` / `谷时 ×1` / `国庆节 · 全天空闲`。橙色用静态色 `--dsw-static-amber-500`,**不要借 `--dsw-alias-brand-primary`** —— 那个 token 在本设计系统里是**前景墨色**(DSH 自己只用它做文字、描边、开关轨道):浅色主题解析成 `--dsw-static-neutral-bluish-1000`(`#0f1115`,近黑),深色主题解析成 near-white。借了它的后果是峰时圆点变成一个看不出含义的深色点,`峰时 ×2` 标签变成白底白字,主按钮(白字填色)在深色主题下文字直接消失 —— 三处都已改成显式颜色(`--dsw-static-amber-500` 与 `--dsw-static-deepseek-500`),并由 `test/client.render.mjs` 里一条「墨色 token 不得作白字填充背景」的断言钉住。
55
55
  - **当日 token 总量**(输入 + 缓存 + 输出)
56
56
  - **当日估算金额**
57
57
 
@@ -59,27 +59,41 @@ dsh plugin --profile web remove dsh-usage-badge
59
59
 
60
60
  **用量**
61
61
 
62
- - 四个区间页签:24 小时(今天逐小时)/ 近 7 日 / 近 30 日 / 近 12 个月
63
- - 今日有多个 provider 时出现 provider 筛选
62
+ - 五个区间页签:24 小时(今天逐小时)/ 近 7 日 / 近 30 日 / 近 12 个月 / **按年**(年份下拉的选项来自数据)
63
+ - 今日有多个 provider 时出现 provider 筛选(**候选来自今天的 provider**,但选中后同时过滤 7 日 / 30 日 / 12 个月与热力图 —— 所以只在历史里出现过的 provider 选不到,这是已知的口径)
64
64
  - 合计卡片:金额、请求数、Token 总量、缓存命中率
65
- - **四序列折线图**,与桌面壳的图表同一套配色与读法:金额(蓝,带面积填充)、token 总量(绿)、请求数(紫)、缓存命中率(橙虚线,固定 0–100 刻度),各自独立缩放、共用横轴;鼠标移上去显示该点的四项数值与竖线参考线(`mode: 'index'`,和壳里的 tooltip 行为一致)
65
+ - **四序列折线图**,与桌面壳的图表同一套配色与读法:金额(蓝,带面积填充)、token 总量(绿)、请求数(紫)、缓存命中率(橙虚线,固定 0–100 刻度),各自独立缩放、共用横轴;鼠标移上去显示该点的四项数值与竖线参考线(`mode: 'index'`,和壳里的 tooltip 行为一致)。**图例即开关**:点一下隐藏/显示该序列(隐藏后图例淡出、色块转灰),所以任意一条都能独占整张图、按自己的刻度铺满 —— 四条量级差三个数量级时,单独看才是读得出来的。三条附带规则:有隐藏项时图例尾才出现「全部显示」(平时不占位置);点到最后一条会**恢复全部而不是画一张空图**;被隐藏的金额序列连同面积填充一起消失,否则填充会落到过滤后恰好排第一的那条线上。summary 卡片(区间合计)与热力图都不跟着变 —— 前者是区间汇总,后者有自己的指标开关。
66
66
  - 近 7 日 / 近 30 日里,**法定节假日用绿色背景色带**标出(这些日子全天按空闲价)
67
+ - **活跃度热力图**(折线图下方):GitHub 贡献图那种方格 —— 一列一周(周一起)、一行一个星期、每格一天,默认按当日金额分五档,可切 Token / 请求数;悬停弹出的是**折线图那张卡片本身**(同一个 `.dub-tip`、同样的 `.dub-tip-row`、同 `SERIES` 定义与格式化),四行金额 / token 总量 / 请求数 / 缓存命中率,标题为「日期 + 星期」,节假日带节日名、没用量标「无用量」。范围有两种:常规区间下是**最近 371 天**(滚动一年),`按年` 下是**那一年的自然年**(1 月 1 日 → 12 月 31 日,当年到今天)—— 同一个 `heatGrid`,边界当参数传。五点取舍:
68
+ - **格子来自日历,不来自日志**:宿主只上报有用量的日子,照它画就会把没用的那天整格抹掉,而「空白」恰恰是这张图要说的事。所以浏览器半自己枚举日期,缺数据的那格画成最浅一档。滚动一年用快照里的 `days`(最多 400 天)就够铺满 53 周,**没有为此新增路由**;`按年` 则需要 `/usage-badge/year` 带上那一年的 `daily`(更早的年份不在快照里)。
69
+ - **五档是四分位,不是占最大值的比例**:把 `>0` 的日子按所选指标排序后取 25% / 50% / 75% 三个分位点。按最大值切档的话,只要有一天用量远超平时,其余一整年都会被压进最浅那一档。(副作用:有用量的天数 ≤ 4 时 75% 分位点就等于最大值,第 4 档取不到 —— 图例里最深那格在这种数据集下不会出现。)
70
+ - **一个格子的四项指标与折线图一个点同源**:都由 `metricsOf()` 从行数据算出来,所以同一张卡片在两处的数字不会对不上 —— 悬停卡片只额外多一行标题。
71
+ - **卡片挂在 `.dub-hm-plot` 上,不挂在整块上**:百分比是按格子坐标算的,挂在含标题与图例的块上会整体偏移标题的高度。横向沿用折线图卡片的 172px 固定宽度,所以照旧 `clamp()` 夹 86px。
72
+ - **朝哪边开是量出来的,不是按行号猜的**:热力图是面板里最后一个区块,而 `.dub-body` 是可滚动的裁切框 —— 靠下的格子如果向下开卡片,就会被这个框的下边缘切掉(这正是最初按「行号 ≥ 5」写死时踩到的坑:面板滚动位置一变,行号和边缘的关系就全错了)。所以进入格子时用 `closest('.dub-body')` 取到滚动框量上下各有多少空间:下方放得下就向下;放不下则**比一下哪边更宽裕**(只有上方更宽裕才翻上去,否则仍向下),量完再把卡片的纵向位置 `clamp` 进同一个框 —— 于是面板滚到哪儿卡片都不会被切。卡片高度按「5 行 × 17.6px + 内边距」估成 112px —— 估而不是实测,是为了少渲染一次。
67
73
 
68
- **单价配置**(全部只读 + 两个动作:**重新获取** / **应用官方价格**,没有任何要手填的字段)
74
+ **单价配置**(官方价格只读展示 + 覆盖行与兜底行都可增删改 + 四个动作:**重新获取** / **应用官方价格** / **前缀回退开关** / **未匹配模型是否计价**)
69
75
 
70
- - **官方定价**:每次打开这个面板都实时抓取 DeepSeek 官方定价页并解析(中文页人民币 / 英文页美元),列出现行单价、高峰时段与政策原文;**这是唯一的单价来源**
76
+ - **官方定价**:**每个页面生命周期抓一次**(第一次进入这个页签时,只抓中文页、人民币),列出现行单价、高峰时段与政策原文,并在表下给出获取时间与它的年龄;要更新点「重新获取」;**官方单价就是从这里来的**
71
77
  - **法定节假日日历**:内置日历的覆盖年份与天数、今天是不是法定节假日、数据来源文号
72
- - **当前生效单价**:当前 default 行与全部覆盖行,只做展示
78
+ - **当前生效单价**:**兜底行 + 全部覆盖行,都在这里维护** —— 每行有「编辑 / 删除」,区块头有「新增一行」
79
+ - **行编辑器**:一次只编辑一行(键、输入 / 缓存命中 / 缓存写入 / 输出、倍率、峰谷规则)。一行一行来而不是每格一个控件,是因为**键才是决定这行管哪些模型的东西**,它和它的规则应该能在同一处读完
80
+ - **ⓘ 提示**:三个区块的说明性文字(政策原文、数据来源、键的四种写法、两个开关在做什么、文件位置)都收进标题或那一行旁边的 `Info` 气泡里,用原生 `title`。面板本身只留数字与按钮 —— 之前每个区块下面挂三四行说明,扫一眼根本找不到要找的那个数
73
81
 
74
- > 为什么没有汇率 / 币种 / 倍率可填:官方中文页就是人民币,徽标也始终是人民币,所以这些都不需要配置。它们作为**进阶字段仍然被支持**(写在 `pricing.json` 里就生效,比如给某个网关模型加 `multiplier`),只是面板不提供入口;一旦某个覆盖行带上了非 1 的倍率,面板会**自动多出一列「倍率」**把它显示出来,避免一个改变金额的系数被藏起来。
82
+ > **兜底行只在表里真有它的时候才列出来**:有 `default` 行时它和其他行一样列在表格里,带「编辑 / 删除」;没有时表格下方是一行提示,写明现在按内置模板的哪个费率计价,并给一个「添加兜底行」按钮。(编辑器只提供四个单价 —— `multiplier` 与 `timeOfUse` 是**表级**字段,`modelMultiplier` / `modelTimeOfUse` 只遍历覆盖行,写在兜底行里不会生效,所以不提供入口,免得让人填一个不生效的字段,表单里也写明了。)删除就是 `default: null`(宿主 `mergeConfig` 认这个写法),删掉后表回到内置模板,下次「应用官方价格」又会按官方列表第一条生成回来 —— 能删掉再生成,正是它可删除的意义。
83
+ >
84
+ > 这条「只在真有它时才列出来」是**踩出来的**:最初的版本把内置模板也渲染成一行(`default(兜底 · 内置模板)`)。结果删除**成功了**却看起来像没生效 —— 那一行还在表里,只是换成了模板的费率、并且不再有删除按钮。行列表要回答的是「我的价格表里有什么」,而不是「现在生效的是什么」;后者是区块顶部的 `default: …` 注释与「未匹配的模型」那一行负责说明的。
85
+
86
+ > **为什么覆盖行是手填的**:官方页面只点名它自己列出的型号,网关上的 `gpt-5.6-terra`、`deepseek-flash-opencode` 这些它不会写。所以「官方那部分自动同步、其余部分你自己维护」是同一条线上的两段。行编辑器**只重写你交给它的那一行**:它没显示的字段(`contextMultiplier`、`currency`、以后新增的字段)原样保留并会在表单里列出来,因为一个保存按钮悄悄丢掉手写配置,比没有编辑器更糟。表里恒定显示 **倍率** 与 **峰谷** 两列 —— 这两样都会改变金额,不能只藏在 JSON 里。
87
+ >
88
+ > **币种从面板里去掉了**:徽标、合计、官方单价全是人民币,也就没有汇率要配 —— 所以行编辑器不再有币种选择,官方定价也不再提供英文页(路由仍然接受 `?source=en`,只是面板没有理由给这个选项)。`currency` / `totalCurrency` / `exchangeRate` 作为进阶字段照旧生效:某一行若在手写的 JSON 里带着 `currency`,面板会把它列进「本行另有字段原样保留」并原样写回,不会因为编辑器不认识它就丢掉。
75
89
 
76
90
  ## 官方定价
77
91
 
78
- 点开「单价配置」时会实时抓取官方定价页并解析:
92
+ 打开「单价配置」时抓取一次官方定价页并解析(每个页面生命周期一次,之后点「重新获取」)。**面板只请求中文页**(人民币);解析器本身两种页面都认,路由也仍然接受 `?source=en`,那是给直接调用路由的人和联网核对脚本用的:
79
93
 
80
94
  | 来源 | 页面 | 币种 |
81
95
  |---|---|---|
82
- | 中文页 | <https://api-docs.deepseek.com/zh-cn/quick_start/pricing> | 人民币 |
96
+ | 中文页 ← 面板用这个 | <https://api-docs.deepseek.com/zh-cn/quick_start/pricing> | 人民币 |
83
97
  | English | <https://api-docs.deepseek.com/quick_start/pricing> | 美元 |
84
98
 
85
99
  页面是 Docusaurus 服务端渲染的 HTML,无需浏览器即可读取。解析出的内容:
@@ -89,13 +103,15 @@ dsh plugin --profile web remove dsh-usage-badge
89
103
  - **政策原文**:空闲价为高峰价的一半、周末与法定节假日全天空闲
90
104
  - **已下线的旧模型名**:脚注 (1) 列出的 `deepseek-v4-flash`、`deepseek-v4-flash-vision-exp` 仍按下线前对应模型计价,会一并写入覆盖行
91
105
 
92
- **时区换算**:配置里的 `peakRanges` 是**本机小时**,而官方页面的窗口按它自己声明的时区书写,所以解析时会把窗口从来源时区换算到本机(英文页的 `01:00–04:00 / 06:00–10:00 UTC` 会换算成本机的 `09:00–12:00 / 14:00–18:00`)。面板同时显示原始窗口与本机换算后的窗口。
106
+ **时区换算**:配置里的 `peakRanges` 是**本机小时**,而官方页面的窗口按它自己声明的时区书写,所以解析时会把窗口从来源时区换算到本机(如果请求的是英文页,它的 `01:00–04:00 / 06:00–10:00 UTC` 会换算成本机的 `09:00–12:00 / 14:00–18:00`)。面板显示的是**本机换算后**的窗口,原始窗口与来源时区收在旁边的 ⓘ 里。
93
107
 
94
- **「应用官方价格」会写入什么**:把每个模型的**空闲价**写成覆盖行的单价,并把 `timeOfUse` 设为 `peakMultiplier: 2 / valleyMultiplier: 1`(等价于「空闲价是高峰价的一半」,也与桌面壳原有配置同形),同时打开节假日日历。你手写的其他覆盖行、`default` 行与未知字段都不会被动。
108
+ **「应用官方价格」会写入什么**:把每个模型的**空闲价**写成覆盖行的单价,并把 `timeOfUse` 设为 `peakMultiplier: 2 / valleyMultiplier: 1`(等价于「空闲价是高峰价的一半」,也与桌面壳原有配置同形),同时打开节假日日历。**表里没有 `default` 行时还会补一个**,取自官方列表的第一条 —— 官方页面第一条就是它最便宜的那档(`deepseek-flash`),拿它当「未知模型」的兜底比内置模板更靠谱;你自己写过的 `default` 行不会被覆盖(补的只是「没有」这件事),`applied.defaultFrom` 报告补的是哪个模型,宿主日志与面板都会写明。你手写的其他覆盖行与未知字段都不动。
95
109
 
96
110
  **抓取是脆弱的**:官方页面改版后解析会失败——此时面板显示失败原因并保留原有价格,可继续手改 JSON 兜底。`test/fixtures/` 保存了本解析器验证过的页面快照,页面一变测试就会失败;`node test/official-pricing.live.mjs` 会用真实页面与快照对比,确认线上页面仍能解析。
97
111
 
98
- **抓取频率**:每次打开面板都会请求一次;宿主机把几秒内的重复请求合并(20 秒窗口),所以反复开关面板不会反复打官网。
112
+ **抓取频率**:**每个页面生命周期只抓一次** —— 第一次进入这个页签时抓,之后切走再切回来只渲染上一次的结果(`cachedOfficial` 同时充当「要不要抓」的判断),要更新就点「重新获取」。理由是这张表一年改几次,而切页签不是新闻;代价是列表可能放着变旧,所以表格下面那行会带上「获取于 …」并在超过 10 分钟后标出「(3 小时前)」—— 静默的旧价格正是这里要避免的。
113
+
114
+ 按钮与首次抓取都带 `refresh=1`,宿主对此**跳过**下面的复用窗口,也就是真的走网络。宿主的 20 秒复用窗口只对**不带** `refresh` 的读生效,也就是「应用官方价格」那一步:它复用你刚才看到的那一份,而不是在你按下按钮时抓一份可能已经不同的页面。
99
115
 
100
116
  ## 法定节假日与峰谷判定
101
117
 
@@ -139,7 +155,7 @@ DeepSeek 官方政策原文:
139
155
  ```
140
156
  $DSH_HOME/storages/usage-badge/
141
157
  ├── pricing.json # 价格表(可手改;「应用官方价格」也写这里)
142
- └── cache.json # 会话日志的折叠缓存(可随时删,会重建)
158
+ └── cache.sqlite # 会话日志的折叠缓存(可随时删,会重建;回退时是 cache.json)
143
159
  ```
144
160
 
145
161
  > 未设置 `DSH_HOME` 时即 `~/.dsh/storages/usage-badge/`。
@@ -156,30 +172,77 @@ $DSH_HOME/storages/usage-badge/
156
172
  $DSH_HOME/storages/usage-badge/pricing.json
157
173
  ```
158
174
 
159
- **正常使用不需要碰这个文件**:面板里点「应用官方价格」就会把官方单价写进去,峰谷时段与节假日日历也一起配好。手工编辑只在进阶场景下需要(给网关模型加单价、加自定义节假日),改完即时生效。
175
+ **「应用官方价格」能覆盖官方那部分,覆盖行由你自己维护**:点一下按钮,官方页面点名的型号就写进去了,峰谷时段与节假日日历也一起配好;官方不点名的型号(网关上的其它模型)在面板的「当前生效单价」里增删改,或直接编辑这个文件,改完即时生效。
160
176
 
161
177
  文件不存在或无法解析时按内置默认模板计价(占位符不会落盘;模板可用 `GET /usage-badge/config` 查看)。字段:
162
178
 
163
179
  | 字段 | 说明 |
164
180
  |---|---|
165
- | `default` | 兜底价格行(`inputPerMillion` / `cacheReadPerMillion` / `cacheWritePerMillion` / `outputPerMillion` / `currency`) |
166
- | `overrides` | 按键覆盖,支持 `模型名`、`provider\|模型名`、`provider\|*`、`*\|模型名` 四种写法 |
167
- | `timeOfUse` | 峰谷计价,`{ enabled, days, peakMultiplier, valleyMultiplier, peakRanges }`;「应用官方价格」会按官方页面的窗口自动填好 |
181
+ | `default` | 兜底价格行(`inputPerMillion` / `cacheReadPerMillion` / `cacheWritePerMillion` / `outputPerMillion` / `currency`);面板里可编辑、可删除(`default: null`),表里没有它时「应用官方价格」会按官方列表第一条生成一个 |
182
+ | `overrides` | 按键覆盖,支持 `模型名`、`provider\|模型名`、`provider\|*`、`*\|模型名` 四种写法,以及模型名末尾带 `*` 的**前缀行** |
183
+ | `timeOfUse` | 峰谷计价,`{ enabled, days, peakMultiplier, valleyMultiplier, peakRanges, honorHolidays }`;「应用官方价格」会按官方页面的窗口自动填好 |
168
184
  | `holidays` | 节假日日历,`{ source, extra }`;`source: "cn"` 即内置中国日历 |
169
185
 
170
- 进阶字段(面板不提供入口,手写才生效):
186
+ 进阶字段(面板给了一部分入口:两个开关与 `multiplier` 都能在面板里改,其余手写才生效):
171
187
 
172
188
  | 字段 | 说明 |
173
189
  |---|---|
174
- | `exchangeRate` | 跨币种换算用的汇率,仅当价格行与合计币种不一致时才用到 |
190
+ | `exchangeRate` | 跨币种换算用的汇率,仅当价格行与合计币种不一致时才用到(面板没有入口 —— 界面里一切都是人民币) |
175
191
  | `totalCurrency` | 合计币种 `cny`(缺省)/ `usd` |
176
192
  | `multiplier` | 全局倍率(可被 override 行覆盖) |
177
193
  | `contextMultiplier` | 上下文长度档位,`{ "threshold": "128K", "multiplier": 1.5 }`,**严格大于**阈值才生效 |
194
+ | `modelPrefixFallback` | 隐式前缀回退,缺省 `true`(表里已有的行兜住自己名字后面的变体);只有显式写 `false` 才关闭,关闭后只剩精确行与显式 `*` 家族行 |
195
+ | `priceUnmatchedModels` | 一行都没匹配上的模型是否按 `default` 行计价,缺省 `true`;写 `false` 即「不计价」—— 只统计 token 与请求数,金额记 0,快照的 `unpriced` 会列出这些路由 |
178
196
 
179
197
  计价优先级:**纯模型名 > `provider\|模型名` > `provider\|*` > `*\|模型名`**,逐价格字段独立取最高优先级;键名匹配不区分大小写。每次请求的费用按该请求的**发起时刻**判定峰谷,并整体乘以模型倍率与上下文档位倍率;法定节假日覆盖 `days` 的判定(周末不因调休改变计价)。
180
198
 
199
+ ### 峰谷对每一行都生效
200
+
201
+ `timeOfUse` 是**表级**字段,不是每行都要写一遍:解析一条路由时先按上面的优先级取到价格行,再按该请求的发起时刻把峰谷倍率乘上去。所以**你后来手写加的任何一行都自动带峰谷** —— 精确行、前缀行、连 `default` 兜底的那些都一样。四个边界要知道:
202
+
203
+ - **行内可以自带 `timeOfUse`,此时它整体替换表级规则**(是替换不是合并)。所以行内规则要写全:只写 `peakRanges` 而漏 `peakMultiplier`,峰时倍率就是 `Math.max(1, Number(undefined) || 1)` = **1**,那段时间照样不加价 —— 这是最容易踩的坑(`test/pricing.verify.mjs` 有一条专门钉住它)。反过来,行内写 `{ "enabled": false }` 就能让这一行完全不参与峰谷。
204
+ - **`days` 缺省或写错时匹配所有日子**(宽松处理,为的是让旧配置行为不变),所以行内规则漏了 `days` 会在周末也按峰时计价。
205
+ - **周末本来就是逐行可设的**:`days` 写 `"all"`(周末也按峰时)、`"weekend"`、或 `[6,7]` 就行,表级写的是 `"weekday"` 也不影响。
206
+ - **节假日日历默认是全局的**:`holidays` 在文档顶层解析一次,`dayClass === 'holiday'` 时 `multiplierFor` 返回 1 —— 政策原文就是「法定节假日全天均为空闲时段」。但**规则可以拒绝这个日历**:`"honorHolidays": false`(行内或表级都行)之后,节假日那天就按 `days` 正常判定,该峰时就是峰时。这是给「国庆照样按自己峰时窗口收费」的网关用的;日历本身仍然在算,只是这条规则不采信它。
207
+
208
+ ### 前缀匹配:不写也行,写了更精确
209
+
210
+ 网关上的模型 id 常带后缀(`deepseek-flash-opencode`、`-preview`、`-20260901`、`-0731`),一个名字一行价格表维护不过来。有两条路,**默认那条不需要你写任何东西**:
211
+
212
+ **一、隐式回退(默认开)**:表里**已有的行会兜住自己名字后面的变体** —— 「应用官方价格」写出的 `deepseek-flash` 直接就能管 `deepseek-flash-preview` / `deepseek-flash-opencode`。前缀后面必须是**分隔符**(`- _ . : / @`)才算命中,所以 `deepseek-flash` 吞不掉 `deepseek-flashpreview`,`gpt-4` 也吞不掉 `gpt-4o`(后者是另一个模型、另一个价)。
213
+
214
+ 开关就在面板里:「当前生效单价」区块显示当前是开是关,并提供**「关闭 / 开启前缀回退」按钮** —— 按下即 PUT `/usage-badge/config` 写进 `pricing.json`,宿主把快照置空后立刻按新价重算(历史日期跟着变,所以按完按钮同时刷新弹窗里的用量)。这里**不做输入框是刻意的**:这块没有要填的**价格**,只有一个决定「哪个模型落到哪一行」的开关,而那正是面板必须让人看得见、改得动的东西,不该只能去文件里改。等价的手写方式是 `"modelPrefixFallback": false`(只有显式 `false` 才算关)。
215
+
216
+ **二、显式家族行**:模型名末尾的 `*` 是前缀匹配 —— `deepseek-flash-*` 覆盖所有以 `deepseek-flash-` 开头的模型,`deepseek-flash*` 连 `deepseek-flash` 本身一起覆盖。它**不受开关影响**,而且对没有分隔符的名字也生效 —— 显式写 `*` 就是在说「粘在一起的那些也算」。三种形状都支持:`前缀*`、`provider|前缀*`、`*|前缀*`。
217
+
218
+ 两条路共用一套排序,规则如下,都是为了让优先级可以一眼算出来:
219
+
220
+ 1. **精确行永远优先**。一个没有前缀可回退的价格表,解析结果与加这个功能之前**逐字节一致**(`test/pricing.verify.mjs` 里那组从桌面壳移植的用例就是这条的证明)。
221
+ 2. 前缀之间保持与精确行相同的**形状顺序**(纯模型 → `provider|模型` → `*|模型`),同形状内**长前缀优先**,所以 `deepseek-flash` 比 `deepseek` 更具体;等长时显式家族行赢过隐式回退。
222
+ 3. **显式家族行里的星号是后缀,不是分隔符**:`deepseek-flash-*` 覆盖 `deepseek-flash-preview`,但**不覆盖** `deepseek-flash` 本身 —— 要连它一起就写 `deepseek-flash*`(少一个连字符)。隐式回退没有这个问题:`deepseek-flash` 本来就是精确行,管它自己的时候走精确匹配。
223
+ 4. **光秃秃一个 `*` 键永远是死配置**:`provider|*` 里的 `*` 表示「任意模型」,这是靠精确候选键实现的;而单独一个 `*` 行从前不会匹配任何东西,现在也不会(否则它就会变成一个悄悄吞掉全表的兜底行)。前缀必须非空,正是这一点把它挡住了。
224
+
225
+ > **这是一条默认开启、会改变既有数字的规则**,所以要清楚它的边界:它只把「本来落到 `default`(没有 `default` 行时就是内置模板)的模型」挪到一条更具体的行上,绝不会改动任何已有精确行命中的模型;受影响的历史日期会在下一次折叠时按新价重算。真遇到「名字像但价不同」的模型(例如网关上的 `-free` 变体),给它写一行精确行就行 —— 精确行优先于一切前缀。
226
+ >
181
227
  > 价格表每次刷新都重新读取,改完不用重启。
182
228
 
229
+ ### 一行都没匹配上的模型:计价,还是不计价
230
+
231
+ ```json
232
+ "priceUnmatchedModels": false
233
+ ```
234
+
235
+ 缺省 `true`:没有行匹配的模型由 `default` 行兜底(表里没有 `default` 行时就是内置模板)。写成 `false` 就是**不计价** —— 那条路由只贡献 token 与请求数,金额记 0。
236
+
237
+ 四个要点:
238
+
239
+ 1. **「匹配」不要求行里写了价格字段**。只要有一行命中(精确、前缀、`provider|*`),这条路由就算有价格,缺的字段由 `default` 行补 —— 所以一条只写了 `multiplier` 的行也能让某个模型「被认领」,不至于因为没写单价就整条变成不计价。
240
+ 2. **不计价必须说出来**。¥0 和「免费」在图上是同一个数字,所以快照里带一个 `unpriced` 路由清单(provider、model、token、请求数),用量面板会把它列出来并指向这个开关。否则总额就是一个静默错误的数字 —— 正是本插件在别处极力避免的那类。
241
+ 3. **默认仍然是计价**。`default` 行的存在就是为了兜底,而不计价会让总额**低估**真实花费;这是个偏好问题,不是对错问题,所以做成开关,按一下即切换并重算历史(和历史日期一起变,所以和前缀回退共用同一个写入路径)。
242
+ 4. **顺手修掉的一处诚实性问题**:表里没有 `default` 行时,面板原先会把内置模板的费率当作「当前生效单价」展示。现在 `/usage-badge/config` 会返回 `defaultSource: 'file' | 'template'` 与 `defaultRow`(**文件里的原样**),面板据此在表格下方写明「兜底行还没有:未匹配的模型现在按内置模板计价(…)」,并给出两条出路:点「添加兜底行」自己写,或点「应用官方价格」按官方列表第一条生成 —— 那些数字不是你的选择,就不该看起来像你的选择。(旧宿主不返回这两个字段时,面板不猜:它说「未知(宿主是旧版本,重启后可见)」。)
243
+
244
+ > 换到「不计价」时说清楚代价:本机实测 26 条路由里有 16 条没有价格,占 token 的 3.2%,但按模板价粗算占**金额的 13.7%**(里面有 `ox-alpha-free` 这种名字里就写着 free 的)。要么接受总额不再包含它们,要么给它们补价格行。
245
+
183
246
  ## 架构
184
247
 
185
248
  ```
@@ -187,33 +250,95 @@ dsh-usage-badge
187
250
  ├── cordis.patch.yml # bundle 补丁:向 profile 插入一行 usage-badge
188
251
  ├── lib/
189
252
  │ ├── index.js # 宿主半:HTTP 路由 + 快照组装 + 配置读写
190
- │ ├── fold.js # 会话日志发现、zstd 解码、逐请求折叠、增量缓存
253
+ │ ├── fold.js # 会话日志发现、zstd 解码、逐请求折叠、增量刷新
254
+ │ ├── buckets.js # 桶模型:一天一个 (provider, model) 的 token / 小时 / 逐请求记录,及其 JSON 形式
255
+ │ ├── cache-store.js # 折叠历史的存储层:SQLite(默认)+ JSON 回退 + 从旧 cache.json 迁移
191
256
  │ ├── pricing.js # 价格解析与成本计算(从桌面壳移植)
192
257
  │ ├── holidays.js # 节假日日历:加载、展开、判定规则
193
258
  │ ├── holidays-cn.json # 逐年放假日与调休上班日(后者仅存档,不参与计价;含来源文号)
194
259
  │ ├── official-pricing.js # 官方定价页抓取、解析与应用
195
260
  │ └── client.js # 浏览器半:手写单文件 bundle
196
- └── test/ # 5 个回归套件 + 1 个联网核对脚本,见下
261
+ └── test/ # 6 个回归套件 + 1 个联网核对脚本,见下
197
262
  ```
198
263
 
199
264
  **数据流**:
200
265
 
201
266
  1. 宿主半在启动时折叠 `$DSH_HOME/sessions` 下所有会话日志。每个 `assistant/message` 事件里的 `usage` 是一条已完成的请求;`request/header` / `request/context` 决定它属于哪个 provider / model。按**本地日历日**与 `provider|model` 入桶,并保留**逐请求**记录——这是上下文档位、峰谷与节假日都能按每次请求算的前提。
202
- 2. 增量缓存写在自己的目录里:`$DSH_HOME/storages/usage-badge/cache.json`。只重读 size / mtime / 路径变化的日志;日志被删除时保留缓存中的历史贡献(与桌面壳一致)。
203
- 3. 浏览器半轮询 `GET /usage-badge/summary`,本地完成区间聚合与筛选;切到「单价配置」时才去抓官方定价。
267
+ 2. 折叠结果交给 `cache-store.js`:默认写进 `$DSH_HOME/storages/usage-badge/cache.sqlite`,**一次只写变化的那几天**。只重读 size / mtime / 路径变化的日志;日志被删除时那条记录再也不会被写,于是缓存里的历史贡献原样留下(与桌面壳一致)。
268
+ 3. 浏览器半轮询 `GET /usage-badge/summary`,本地完成区间聚合与筛选;切到「单价配置」时才去抓官方定价;切到「按年」时按年向 `GET /usage-badge/year` 取那一年的 12 个月(快照里没有更早的年份)。
269
+
270
+ ### 缓存为什么不是「一个 JSON 文件」
271
+
272
+ 历史是不删的,所以「每次保存重写全部历史」这件事迟早会变成主问题。实测(把真实的一天桶形状铺成两年、761 天):
273
+
274
+ | | 单文档 JSON(旧) | SQLite(现在) |
275
+ |---|---|---|
276
+ | 一次保存要写多少 | 整个文档(6 MB → 19 ms,且随历史线性增长) | **只有 payload 真的变了的那几天**(库里存多少年都无关) |
277
+ | 一次保存的实际耗时 | 19 ms(两年历史) | 1 天的会话 0.12 ms、30 天 0.82 ms、400 天 4.98 ms |
278
+ | 加载 | 全部(761 天) | 只加载窗口(431 天) |
279
+ | 常驻内存增量 | +14 MB | **+6 MB**(与历史长度无关) |
280
+ | 历史 | 全留 | 全留(超出窗口的行留在库里,只是不读进来) |
281
+
282
+ > 第二条那一列是**实测**(同一个会话只有一天变化时)。它随「那个会话有多少天」增长而不是随历史增长:写入前要把该会话已存的行读出来比对 payload,所以一个 400 天的会话仍是 ~5 ms 而不是 0 —— 早期文档里写的「~0ms」只对短会话成立。要让它也变成常数,得再存一列 payload 哈希(省掉读整份 payload),但本机实测平均每个会话只有 1 天,不值得为它做一次 schema 迁移。
283
+
284
+ 三条设计决定:
285
+
286
+ 1. **按 (会话, 天) 建行**,因为「变化」的单位就是「某个会话的某一天」。文件夹一次交一个会话,存储层写回时逐天比对,只写真的变了的那几行 —— 保存代价与**历史长度**脱钩(与那个会话的长度有关),这是当初问题里那个「两年后每次保存要 100ms 阻塞主线程」的根治办法。
287
+ 2. **加载取窗口,但从不删除**。窗口 `CACHE_WINDOW_DAYS = 430` 个**日历日**;快照的 `DAYS_KEPT = 400` 数的是「**有用量的天数**」,两者不是同一种计数(稀疏历史能回溯到 400 天以外,密集历史则停在 400 天以内)。窗口取更宽只是为了不截断快照可能想要的那天。更早的行留在库里,「按年」视图直接查;没有「把它们删掉」这回事。
288
+ 3. **用 Node 内建的 `node:sqlite`**,所以「零依赖」这条没破:不需要 npm 包、不需要外部 sqlite3 工具、用户什么都不用装。代价是缓存不再是能用文本编辑器打开的文件 —— 换来的是写入与内存都不随历史增长。运行时不提供它时(老运行时的 `--experimental-sqlite` 时代)自动退回旧的 JSON 后端,`DSH_USAGE_BADGE_CACHE=json` 也可以主动选它。
289
+
290
+ 迁移是**一次性**的:数据库为空且存在旧 `cache.json` 时导入全部会话,然后把旧文件改名成 `cache.json.migrated` **保留**(不删)并打日志。导入完成后会 checkpoint 一次,让 WAL 里的内容落回主文件。schema 版本写在 `PRAGMA user_version` 里。
291
+
292
+ `.migrated` 之后**永远不会被读**(迁移只读 `cache.json`,且数据库非空就不再迁移),它的唯一价值是万一要退回旧版本时的一份副本 —— 那时把它改名回 `cache.json` 即可。确认新版本没问题就可以删掉。
293
+
294
+ **全新安装**没有任何文件也没关系:目录按需创建、`cache.sqlite` 由 SQLite 自己建、`user_version` 为 0 时建表,然后从会话日志冷折一遍。这条路径不是特例 —— 所有回归套件挂载的临时 home 都是从一个空目录开始的。
295
+
296
+ 打开缓存时三种失败各有各的答案,而不是合并成一句「不可用」:
297
+
298
+ | 情况 | 处理 |
299
+ |---|---|
300
+ | 运行时装不了 `node:sqlite` | 退回 JSON 后端(日志写明是模块的问题) |
301
+ | 文件**不是数据库**(写坏了 / 是别的程序的文件 / 备份拷了一半) | **改名成 `cache.sqlite.corrupt-<pid>-<时间戳>-<随机>` 并新建一个**,重折一次;原始文件留着可检查。打开失败时句柄一定会先关掉,否则 Windows 上那个文件会被锁住、用户连删都删不掉 |
302
+ | 文件**写不进去**(只读属性 / ACL / 盘满 / 另一个宿主锁着) | **按只读使用、原样不动**。这是最要紧的一条:早先的实现把 `PRAGMA journal_mode = WAL` 放在读 `user_version` **之前**,于是一个只读的库会以「attempt to write a readonly database」失败、被当成损坏而挪走 —— 为一个权限问题丢掉全部历史。现在先读版本(只读操作),`busy_timeout` 也提到 `journal_mode` 之前,写不进去就退回只读打开 |
303
+ | 文件是**更新版本**写的 | 一开始就用 `{ readOnly: true }` 打开,**任何时候都不执行写型 pragma**(`journal_mode = WAL` 是持久化的,会改写文件的字节);连表都认不出来时也只是退回 JSON,绝不改名或覆盖。测试比对**字节哈希 + mtime + 目录内容**,而不只是「没写进行」 |
304
+
305
+ 判定「不是数据库」用错误码/信息(`SQLITE_NOTADB`/`SQLITE_CORRUPT`、`file is not a database`、`malformed`),**只有这一种情况才允许挪文件**;其余一律按「现在用不了」处理 —— 读不到就退回只读,再不行退回 JSON,文件不动。另有一个 `cacheKeepFile` 标记传递「文件没问题、但这个版本用不了」。
306
+
307
+ `cache.json` 侧也补齐了同样的两条:解析不了的文件**改名成 `cache.json.corrupt-…` 而不是被下一次保存覆盖**;数据库非空时旁边留着的 `cache.json` **不合并**(按天合并两代同一会话可能重复计数)但会**在日志里说明**它在被忽略、可以删。
308
+
309
+ 删掉 `cache.sqlite` 与删掉 `cache.json` 的后果完全一样:下次启动冷扫一遍日志重建;只有「日志已被删除」的那段历史会随之消失。
310
+
311
+ ### `按年` 视图:年份列表属于数据,不属于代码
312
+
313
+ 历史不删,就迟早要看更早的年。这一年视图有三条决定:
314
+
315
+ 1. **年份下拉的选项来自存储层**(`store.years()` → `SELECT DISTINCT substr(date,1,4)`),不是代码里的一份列表。所以 2027 年第一次有用量的那天它自己就出现在下拉里,跨年不需要改任何配置;反过来,插件没运行过、日志也已经不在的年份不会假装存在。列表可能出现「空档」(只有 2023 与 2026),那正是事实。
316
+ 2. **按年查库,而不是读快照**。快照只带最近 400 天(`DAYS_KEPT`)且内存里只装 430 天窗口,更早的年份根本不在进程里;`/usage-badge/year` 用一次有界查询(`date BETWEEN 'YYYY-01-01' AND 'YYYY-12-31'`)把那一年的行取回来,复用日汇总的逐请求计价折成 12 个月。这也是存储层选择「全留、只按窗口读」的直接回报 —— 没有它,这个视图就只能看到最近一年。
317
+ 3. **热力图跟着选中的那一年变成自然年格子**。原来的热力图是「最近一年」的滚动格子(371 天,不是自然年),放在一个自然年旁边会让人对不上;而 53 列本来就正好是一年,所以按年视图把边界改成 1 月 1 日 → 12 月 31 日(当年则画到今天),**同一套 `heatGrid` 同时服务两种调用**(边界是参数,不是从「今天」推出来的)。闰年且 1 月 1 日是周日时会是 54 列,所以 SVG 宽度按实际列数算,不是写死 53。
318
+
319
+ 12 个月**永远都是 12 个**(没有用量的月份保留空槽位),这样切换年份时月份轴不会跳;年度合计就是这 12 个月相加(面板的四张卡片把选中的 provider 过滤也一并算进去)。
320
+
321
+ **负载**(实测,365 天全满的合成数据):1 个 provider → 104 KB / 40 ms,4 个 → 242 KB / 138 ms,12 个 → 608 KB / 369 ms;真实用量(1 个 provider、34 天)是 15.8 KB。大小主要来自每天的 provider 拆分(provider 过滤要用),请求是**按需**的(切年份一次)。如果哪天觉得大了,正确的做法是把 provider 拆分改成按需请求,而不是砍掉过滤。
322
+
323
+ > **`daily` 是版本握手的一部分**(`HOST_API_VERSION` 为它从 5 提到 6):宿主半是旧版本时,`/year` 只有月份、没有日粒度,热力图就会是一张全空的格子 —— 那是**画出来的谎**,因为这个项目里「空白」是有含义的。所以面板对缺失的 `daily` 有两条退路:**当年**用快照自己的 `days`(那一年的日子就是快照里的那些,等价),**往年**则明说「宿主半是旧版本,重启客户端后可见」,而不是留一张空图。这个坑是实际撞上的:宿主停在只加了 `/year` 的那一版、界面已经刷新到最新,于是热力图全灰。
204
324
 
205
325
  **路由**(全部挂在 `ctx.webServer` 上):
206
326
 
207
327
  | 方法 | 路径 | 说明 |
208
328
  |---|---|---|
209
- | GET | `/usage-badge/summary` | 徽标与图表所需的全部数据(含 `calendar` 与每日 `dayClass`) |
210
- | GET | `/usage-badge/config` | 生效价格表、内置日历覆盖情况、当前节假日状态、各文件路径 |
211
- | PUT/POST | `/usage-badge/config` | 改 `exchangeRate` / `totalCurrency` 等,**仅接受 loopback 来源** |
329
+ | GET | `/usage-badge/summary` | 徽标与图表所需的全部数据(含 `calendar` 与每日 `dayClass`,以及 `years`:**缓存里有数据的年份**,面板的年份下拉直接用它) |
330
+ | GET | `/usage-badge/year?year=YYYY` | 某一年的 12 个自然月与年度合计。**直接查存储层**,所以比加载窗口更早的年份也读得到;年份不是四位数字时 400 |
331
+ | GET | `/usage-badge/config` | 生效价格表、`defaultSource`(default 行来自文件还是内置模板)与 `defaultRow`(**文件里的原样**,表格与编辑器据此显示;`effective.default` 才是模板合并后的视图)、`apiVersion`(版本握手)、内置日历覆盖情况、当前节假日状态、各文件路径 |
332
+ | PUT/POST | `/usage-badge/config` | 改 `exchangeRate` / `totalCurrency` 等标量、两个开关、**整份 `overrides`** 以及 **`default` 行**(两者都是**整体替换** —— 面板发什么,落盘就是什么,清空的字段真的会消失;`default: null` 表示删除兜底行),**仅接受 loopback 来源** |
212
333
  | GET | `/usage-badge/official-pricing?source=&refresh=1` | 抓取并解析官方定价页 |
213
334
  | POST | `/usage-badge/official-pricing/apply` | 应用官方价格,**仅接受 loopback 来源** |
214
335
 
215
336
  **为什么用 HTTP 路由而不是 Typert RPC**:第三方插件很难提供严格模式要的生成描述符,而 Typert 的 SRC 回退路径在文档里被定位为开发路径。命名路由不需要 codegen、不需要 gateway 接线,代价是路由本身不走授权层——所以两个写入口额外加了 loopback 校验。
216
337
 
338
+ **宿主半与界面半的版本握手**:宿主半在 DSH 进程启动时装载,界面半每次刷新页面都会重新读取 —— 所以「改了宿主半,只刷新了页面」是很正常的状态,而在这种状态下**一个旧宿主不认识的写入会返回 200 并什么都不做**,表现出来就是「按钮点了没反应」(删除兜底行那次就是这么被误判成 bug 的)。因此宿主在 `/config` 里报 `apiVersion`(`HOST_API_VERSION`),界面用 `REQUIRED_HOST_API` 对照:宿主旧了就说「重启客户端」,界面旧了就说「刷新页面」。`test/client.render.mjs` 有一条断言钉住两半的常量相等 —— 只改一半会被测出来。**改动任何宿主半对外行为时把这个数字加一。**
339
+
340
+ 这条告警挂在**弹窗顶部、页签之上**,而不是某一个页签里面:谁只打开「用量」也必须被告知宿主旧了(`按年` 与两个开关都依赖宿主),而它最初只长在「单价配置」里 —— 于是出现过一个真实场景:宿主停在只有 `/year` 的中间版本、界面已经刷新到最新,用量页签的热力图全灰而没有任何提示。弹窗自己按需读一次 `/config`(页面生命周期一次),失败就只是没有这条提示而已。
341
+
217
342
  **UI 挂载点**:`sidebar.footer.action` —— 侧边栏自己声明的「设置按钮旁边的底部动作」席位。用这个席位而不是浮动图层,行才会跟已发布的行对齐并共用布局:席位把侧边栏的 `wide` 状态交给条目,所以展开时是一整行、收起时是 36×36 方块,也永远不会压到别的控件上(对比 `shell.overlay`:那是整帧浮层,条目得自己定位,放不好就会盖住侧边栏内容)。
218
343
 
219
344
  ## 验证
@@ -226,31 +351,39 @@ npm test
226
351
  | 脚本 | 覆盖什么 |
227
352
  |---|---|
228
353
  | `test/manifest.verify.mjs` | 包契约:`dsh.bundle.patch` / `dsh.client` / `exports["./client"]` 是否与磁盘上的文件对得上;patch 行名与包名是否一致;宿主半**没有** `export default`(有的话 Loader 的 `unwrapExports` 会丢弃 `inject`);节假日数据文件的结构与来源完整性 |
229
- | `test/pricing.verify.mjs` | 用临时 `DSH_HOME` + 合成会话日志跑真实宿主半,断言精确金额。第一组用例**逐字移植自桌面壳的 `pricing-key-case.verify.mjs`**(同样的 fixture、同样的期望值),用来证明移植后的定价与原实现一致;另覆盖上下文档位(严格大于、`"128K"` 紧凑写法、缓存 token 计入上下文)、峰谷 `days` 规则与跨币种换算 |
354
+ | `test/pricing.verify.mjs` | 用临时 `DSH_HOME` + 合成会话日志跑真实宿主半,断言精确金额。第一组用例**逐字移植自桌面壳的 `pricing-key-case.verify.mjs`**(同样的 fixture、同样的期望值),用来证明移植后的定价与原实现一致;另覆盖上下文档位(严格大于、`"128K"` 紧凑写法、缓存 token 计入上下文)、峰谷 `days` 规则、跨币种换算、前缀行的四条优先级规则(精确行优先、长前缀优先、星号不是分隔符、裸 `*` 仍是死配置)、隐式前缀回退(官方行兜住自己的变体、分隔符边界让 `gpt-4` 吞不掉 `gpt-4o`、开关经 PUT 路由后在**同一个进程内**立刻重算),以及未匹配模型不计价的语义(哪些行算「匹配」、token 仍计入、快照列出被跳过的路由)、逐行/表级的 `honorHolidays`(既有「节假日盖过行内规则」也有「行内拒绝日历后照样收峰价」)、行编辑器的写入路径(整份 `overrides` 经 PUT 落盘、其它字段与两个开关不受影响)、**`default` 行是整体替换而非字段合并**(清空的字段真的消失、`{}` 被当成「没有这一行」)、以及**一行 `null` 覆盖行**被跳过而不是让 `/summary` 500 |
230
355
  | `test/holidays.verify.mjs` | 节假日日历的行为:工作日节假日全天空闲、**调休周末仍按空闲价**(政策原文说周末全天空闲)、旧配置里的 `holidays.workdays` 被忽略、关闭日历时回到旧的按星期行为、自定义 `extra`、以及同一个月内不同日子的分类互不干扰 |
231
- | `test/official-pricing.verify.mjs` | 解析器对**中英文两个页面快照**的逐字段断言(含旧模型别名、时区识别与换算);页面结构变化时会抛错而不是静默返回错价;`应用` 的合并语义(未知字段存活、`default` 行不动);并用**被 stub 掉的 fetch** 跑通两个路由,最后验证应用后的价格真的参与计费 |
232
- | `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签、四序列折线的条数与配色、悬停 tooltip 的装配、provider 页签、官方价格表内容、节假日徽标与日历区块、展开/收起两种行形态,以及空数据 / 稀疏 provider 两个边界不崩 |
233
- | `test/smoke.mjs` | 对着**本机真实**的 `$DSH_HOME` 跑一遍,打印徽标金额、今日 token 构成、当前峰谷档位、节假日日历状态与最近 7 天 |
356
+ | `test/official-pricing.verify.mjs` | 解析器对**中英文两个页面快照**的逐字段断言(含旧模型别名、时区识别与换算);页面结构变化时会抛错而不是静默返回错价;`应用` 的合并语义(未知字段存活、已有的 `default` 行不动、没有 `default` 行时按官方第一条补一个并报告 `defaultFrom`、页面第一条确实是最便宜那档);并用**被 stub 掉的 fetch** 跑通两个路由,最后验证应用后的价格真的参与计费(含未列出模型按生成的 default 行计价),以及兜底行的完整生命周期:删除 → 回到内置模板并把 `defaultSource` 报成 `template` → 再次应用从官方第一条生成回来 → 手改过的兜底行不会被应用覆盖 |
357
+ | `test/cache.verify.mjs` | 缓存存储层:**两个后端跑同一套契约断言**(会话游标与桶的往返、`hourly` 槽位与逐请求记录、重写会话即替换它的日期集合、`years()` 的年份列表与有界区间读),以及只有跨重启才看得见的行为 —— **删掉会话日志后历史仍在**(新挂载的宿主照旧报出同样的金额)、**没有变化的一轮刷新不写文件**、**超出加载窗口的那天留在库里只是不读进来**(换个更宽的窗口还能取到)、旧 `cache.json` **迁移一次**并把旧文件改名为 `.migrated`(且 `.migrated` 之后**不再被读**)、**读不出来的缓存文件被改名保留并新建一个**、**更新版本的库只读使用且绝不改名**、加载窗口必须大于快照窗口这条不变量,以及 `按年` 路由(三年以前那一年照样读得到、12 个月恒定、`daily` 带节假日标记与 provider 拆分、没有数据的年份是 12 个空月而不是报错、非法年份 400)。 |
358
+ | `test/client.render.mjs` | 用替身 Loader 加载浏览器 bundle,检查 slot 注册形状与 `id`,再用 `react-dom/server` 渲染两个面板,断言金额格式、区间页签(含 `按年`:下拉只列出宿主报的那几年、选中的那一年被选中、月份轴恒为 12、点数是 12 × 序列数、四张卡片是那一年的合计、这一档不画热力图、没有年份时不渲染空下拉)、四序列折线的条数与配色、图例开关(单独显示某一条时的折线条数,以及面积填充有没有跟着走)、悬停 tooltip 的装配、provider 页签、热力图的格数(371 − 今天之后的那几天)与分档、官方价格表内容、两个开关(前缀回退与未匹配模型计价:开/关两种状态各自渲染出的说明与动作、default 行的来源标注、以及「哪些模型没价格」的提示)、**覆盖行编辑器**(每行的编辑/删除动作、倍率与峰谷列恒定存在、表单用被编辑行的真实数值预填而不是空模板、自定义规则才展开规则字段、新增行不给删除按钮、**兜底行同样可编辑可删除且不提供那些不生效的字段**、**重名键与「改过的行」会被保存拦下**、**旧宿主不会被说成「兜底行还没有」**、以及「保留未显示字段」与拒绝写 NaN 的源码断言)、节假日徽标与日历区块、展开/收起两种行形态,以及空数据 / 稀疏 provider 两个边界不崩 |
359
+ | `test/smoke.mjs` | 对着**本机真实**的 `$DSH_HOME` 跑一遍,打印徽标金额、今日 token 构成、当前峰谷档位、节假日日历状态、最近 7 天,以及滚动热力图那 371 天的分档分布(有用量的天数与四档各多少天,都挤在一档说明这张图会显得很平) |
234
360
  | `test/official-pricing.live.mjs` | **联网**核对:抓真实页面并与 `test/fixtures/` 的快照逐字段比对,确认官方页面仍能解析、价格未变 |
235
361
 
236
- `test/smoke.mjs` 只读会话日志与价格表;测试脚本唯一会写的是临时目录,联网脚本不写任何文件。
362
+ `test/smoke.mjs` 只读会话日志与价格表,但它挂载的是**真实**宿主半,所以冷缓存或日志有变化时会顺带重建插件自己的缓存(`storages/usage-badge/cache.sqlite`,或回退时的 `cache.json`;都是插件自己的文件,随时可删);其余套件只写临时目录,联网脚本不写任何文件。
237
363
 
238
364
  ## 已知限制
239
365
 
240
366
  - **节假日与峰谷按本机时区判定**:官方窗口按北京时间定义,应用官方价格时已把窗口换算到本机。但**日历匹配用的是本地日期**,主机不在 UTC+8 时,跨午夜的请求可能落到错误的本地日期从而判错节假日。中国主机(UTC+8)下完全精确。如需任意时区精确,可加 `timeOfUse.zone` 选项(尚未实现)。
241
367
  - **节假日数据需要逐年更新**:内置 2025 与 2026。国务院通常在每年 11 月公布次年安排,届时需补 `lib/holidays-cn.json`;缺失年份会在启动日志与面板提示,不会静默算错。
242
368
  - **官方页面解析依赖当前页面结构**:改版后「获取官方定价」会报错(面板显示原因并保留原价格),用 `node test/official-pricing.live.mjs` 可以随时核对;价格仍可手改 JSON 兜底。
243
- - **定价数字仅供参考**:token 列是五桶合计,与官方「三列」天然对不齐(reasoning token 单独上报且不计费),与官方实时数字存在分钟级时差;**对账请以金额为准**。
369
+ - **定价数字仅供参考**:token 列是**四桶合计**(输入 / 缓存读 / 缓存写 / 输出 —— 代码里没有第五个桶,reasoning token 没有被读取),与官方「三列」天然对不齐,与官方实时数字也存在分钟级时差;**对账请以金额为准**。
244
370
  - **只统计 DSH 记进日志的调用**:统计口径是会话日志里的 `assistant/message` + `usage`;某个插件直接请求外部 API 且不向宿主上报 usage,这部分消耗统计不到。
245
371
  - **首次启动要冷扫一遍全部日志**(实测 209 份日志约 10 秒,分片让出事件循环,不会卡住宿主)。之后走增量:实测热启动 237ms、折叠 0 份。缓存文件丢失才会再次冷扫。
246
- - **历史依赖日志仍在盘上**:已删除的日志靠缓存里保留的贡献继续计入;缓存也丢了,那段历史就没了。
247
- - **不做**:余额查询、订阅额度(Coding Plan)、**手工维护单价**(单价唯一来源是官方页面,面板里没有要填的字段)、峰谷切换弹窗/系统通知、中英双语、历史回填 CLI。这些是 [`dsh-cost-meter`](https://github.com/Han-1413141/dsh-cost-meter) 的领域;本插件刻意只做徽标、图表、官方定价同步与节假日判定。
372
+ - **历史依赖日志仍在盘上**:已删除的日志靠缓存里保留的贡献继续计入;缓存也丢了,那段历史就没了。**日志不是被删掉、而是变短或还原成更早的备份**时,那个会话的历史会跟着变少 —— 折叠按当前文件内容重建该会话的桶(删除是「不再写它」,截断是「用更少的内容重写它」,两者不同)。
373
+ - **缓存只把最近 430 天装进内存**(`CACHE_WINDOW_DAYS`,比快照要的 400 天宽):更早的行留在库里,`按年` 视图能直接查它们,但**日粒度**的区间(`近 N 日`)与热力图仍只覆盖最近一年。
374
+ - **没有 `node:sqlite` 的运行时会退回旧的 JSON 缓存**:功能一样,但保存代价与常驻内存随历史长度增长(就是换 SQLite 之前的实测数字)。面板的 ⓘ 会写明当前用的是哪一种。
375
+ - **不做**:余额查询、订阅额度(Coding Plan)、峰谷切换弹窗/系统通知、中英双语、历史回填 CLI。这些是 [`dsh-cost-meter`](https://github.com/Han-1413141/dsh-cost-meter) 的领域;本插件刻意只做徽标、图表、官方定价同步、覆盖行维护与节假日判定。(**曾经**把「手工维护单价」也列在这里,理由是「单价唯一来源是官方页面」;实际上官方页面只点名它自己列的型号,网关上的其它模型总得有人定价 —— 与其让人去翻 JSON,不如把那张表做成可编辑的。)
248
376
 
249
377
  ## 与桌面壳的关系
250
378
 
251
- 桌面壳的用量功能(`src-tauri/usage/usage-sidecar.mjs` + `usage-panel.js` + Rust 胶水)与本插件**功能重叠**,口径差异只有一处:本插件会识别**法定节假日**(工作日节假日全天按空闲价),桌面壳只会按星期几判断;另外官方定价可以一键同步。周末的判定两边一致 —— 调休上班日不按工作日计价。
379
+ 桌面壳的用量功能(`src-tauri/usage/usage-sidecar.mjs` + `usage-panel.js` + Rust 胶水)与本插件**功能重叠**,但口径已经不是「只有一处」了:
380
+
381
+ 1. 本插件识别**法定节假日**(工作日节假日全天按空闲价),桌面壳只按星期几判断;周末判定两边一致 —— 调休上班日不按工作日计价。
382
+ 2. 本插件的**隐式前缀回退默认开启**:`deepseek-flash-preview` 这类带后缀的 id 会落到 `deepseek-flash` 那一行,而桌面壳只会落到 `default`。同一份日志两边金额会因此不同。
383
+ 3. 本插件可以**把未匹配的模型设为不计价**(只统计 token),桌面壳没有这个概念。
384
+ 4. 官方定价可以一键同步,且同步时会在表里没有 `default` 行时按官方列表第一条补一个。
252
385
 
253
- 两者已**不再共用价格表**:本插件读写自己的 `storages/usage-badge/pricing.json`,而桌面壳的 sidecar 固定读 `storages/usage-pricing.json`。所以同时使用时要各自维护价格表;只用本插件时,把价格表放在 `usage-badge/pricing.json` 即可(放在旧位置也能被读到,只是会在首次写入时迁移过去)。
386
+ 两者**不再写同一个文件**:本插件写自己的 `storages/usage-badge/pricing.json`,桌面壳的 sidecar 固定读 `storages/usage-pricing.json`。所以同时使用时要各自维护价格表;只用本插件时把价格表放在 `usage-badge/pricing.json` 即可 —— 旧位置的文件仍会被当**回退**读取(启动日志会写明),并在你第一次写入时迁移过去(旧文件保持原样)。
254
387
 
255
388
  - 用本插件:`dsh web` / 官方桌面客户端 / CLI 都有徽标,壳里那套约 2800 行可以删掉。
256
389
  - 用桌面壳:保留 sidecar 的故障隔离(插件跑在宿主进程内,出问题影响的是整个 DSH;sidecar 崩了只写日志),但计时口径偏松。