balance-dsh 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,36 +1,115 @@
1
1
  # balance-dsh
2
2
 
3
- DSH 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 token/金额消耗。
3
+ DeepSeek Harness 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 token / 金额消耗。
4
4
 
5
5
  > **仅支持 DeepSeek,暂不支持其他大模型。**
6
- > 余额读的是 DeepSeek 官方账号(未登录时回退 DeepSeek API Key),费用按 DeepSeek 官方价表估算,模型识别也只认 DeepSeek 的模型名。如果你通过其他厂商或自建服务用 DSH(OpenAI、Claude、本地模型等),本插件**不会**给出正确的余额和费用:余额会显示为不可用,模型会回落按 Flash 价估算,数字仅供参考。
6
+ > 余额读的是 DeepSeek 官方账号(未登录时回退 DeepSeek API Key),费用按 DeepSeek 官方价表估算,模型识别也只认 DeepSeek 的模型名。如果你通过其他厂商或自建服务用 DeepSeek Harness(OpenAI、Claude、本地模型等),本插件**不会**给出正确的余额和费用:余额会显示为不可用,模型会回落按 Flash 价估算,数字仅供参考。
7
+
8
+ 最新版本见 [npm](https://www.npmjs.com/package/balance-dsh);装好之后也可以在插件设置卡片里直接检查并更新。
7
9
 
8
10
  ## 功能
9
11
 
10
- - **侧边栏余额行**:侧边栏展开时显示在底部(紧贴"设置"按钮上方),格式:钱包图标 + `余额` + 金额(带货币单位)+ 时段标签(高峰红 / 空闲绿)。金额按余额状态变色:充足绿色、偏低(<10)琥珀黄、为 0 显示红色"去充值"。
11
- - **收起态圆钮**:侧边栏收起时,余额显示为左下角 **60px 圆形**悬浮按钮(liquid glass 质感,圆内显示 `¥金额`;大小和水平位置可在配置界面调整)。
12
- - **自动刷新**:每 60 秒自动刷新一次(间隔可配置);每次对话完成(新回复落库)也会立即刷新;右键余额任意区域可手动刷新。
13
- - **余额与使用统计**:单击余额打开**居中弹窗**(带半透明模糊遮罩),展示账户余额、近 90 天计费 Token、估算费用、活跃天数、按模型与高峰/空闲拆分、今日/近 7 天/近 30 天、日均与峰值日,以及按日使用热力图;点遮罩 / 按 `Esc` / 点关闭按钮均可关闭,双击余额会用**系统默认浏览器**打开 DeepSeek 官方用量页。
14
- - **高峰/空闲计费**:官方高峰窗口为**工作日 09:00-12:00、14:00-18:00(北京时间)**;周末、**中国法定节假日全天**都按空闲计费,**调休上班的周末按工作日**计峰谷。节假日安排会自动从公开数据源补录(内置 2026 年表作离线兜底,无需手动更新)。消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价 ×2、空闲时段按目录价,按事件时间分桶计算。
15
- - **会话消耗行**:对话框下方实时显示当前会话消耗(`数据库图标 + 本会话消耗 + ≈¥金额`),**点击**展开明细浮层(Token 用量、缓存命中率、模型、高峰/空闲拆分、子代理消耗、网页搜索/抓取次数与费用)。
16
- - **网页搜索 / 抓取按次计费**:调用 `web_search` / `web_fetch` 时统计次数;官方 **2026-11-01 00:00(北京)起**按次收费(每千次 ¥5),此前的调用标注为「免费期内」。
17
- - **在系统浏览器查看官方用量**:双击余额、或点统计面板里的「查看官方用量」,都会用**系统默认浏览器**打开官方用量页(余额、消费金额、请求次数、Tokens);「去充值」「更多账号信息」同理。插件不会在应用内嵌浏览器或子窗口里打开网页。
18
- - **`/balance` 命令**:命令行查询余额明细和当前会话消耗。
12
+ ### 侧边栏余额行
13
+
14
+ 侧边栏展开时贴在底部,紧挨「设置」齿轮。两种样式可在配置界面切换:
15
+
16
+ - **新版(默认)**:上方一行文字信息 + 下方一条扁刻度条。
17
+ - 文字分两段:`今日 ¥21.42`(今天累计消耗)和 `空闲 剩 3h57m`(当前时段 + 距切换还剩多久,高峰为红色、空闲为绿色)。
18
+ - 刻度条整条代表**一整天**:彩色分段标出这一天里哪些时段是高峰、哪些是空闲,一个指针标出「此刻走在今天的哪个位置」,每分钟前进一格。换日或同步到新的节假日安排时,颜色分布会交叉淡入。
19
+ - **旧版**:钱包图标 + `余额` + 金额(带货币单位)的纯文字行,右侧可带时段标签。
20
+
21
+ 金额按余额状态变色:充足为主题绿、低于阈值(默认 10 元)为琥珀黄、为 0 时显示红色「去充值」。金额变化时按位滚动切换(只滚变化的数位),不会整块闪一下。
22
+
23
+ ### 收起态圆钮
24
+
25
+ 侧边栏收起时,余额显示为左下角的 **60px 圆形**悬浮按钮(liquid glass 质感,圆内显示 `¥金额`,余额耗尽时显示「充值」)。直径与距左缘距离均可调,垂直方向自动对齐聊天输入框的中心。
26
+
27
+ ### 刷新
28
+
29
+ - 默认每 **60 秒**自动刷新一次,间隔可调(5~3600 秒)。
30
+ - 对话产生新消耗时会顺带刷新一次余额。
31
+ - **右键**任意余额元素可立即刷新(有防抖,连续点击不会重复打 API)。
32
+
33
+ ### 余额与使用统计
34
+
35
+ **单击余额**打开居中弹窗(带半透明模糊遮罩),展示:
36
+
37
+ - 账户余额、近 90 天计费 Token、估算费用、活跃天数
38
+ - Token 构成与缓存命中率
39
+ - 按模型拆分(Flash / Pro)、按高峰 / 空闲拆分
40
+ - 今日 / 近 7 天 / 近 30 天、日均与峰值日
41
+ - 按日使用热力图
42
+
43
+ 点遮罩、按 `Esc`、点关闭按钮均可关闭。
44
+
45
+ ### 高峰 / 空闲计费
46
+
47
+ - 高峰时段 = **工作日 09:00-12:00、14:00-18:00(北京时间)**。
48
+ - **其余时段全部按空闲计费**:包括周末、中国法定节假日全天。**调休上班的周末也按空闲**——官方原文是「其余时段,包括周末及中国法定节假日全天均为空闲时段」,并没有给调休开例外。
49
+ - 节假日与调休安排从国务院公告(公开数据源)自动同步,内置当年表作离线兜底,**无需每年手动维护**;配置界面里能看到上次同步时间,也能手动触发立即同步。
50
+ - 消耗金额**跨时段精确计费**:高峰时段的 token 按高峰价、空闲时段按目录价,按每条消息的**事件时间**分桶计算,不会用「当前时段」笼统估算整个会话。
51
+
52
+ ### 会话消耗行
53
+
54
+ 对话框下方实时显示当前会话消耗(`数据库图标 + 本会话消耗 + ≈¥金额`)。**点击**展开明细浮层:Token 用量、缓存命中率、模型、高峰 / 空闲拆分、子代理消耗、网页搜索 / 抓取次数与费用、图片张数与估算 token。
55
+
56
+ ### 网页搜索 / 抓取按次计费
57
+
58
+ 调用 `web_search` / `web_fetch` 时统计次数。官方 **2026-11-01 00:00(北京)起**按次收费(每千次 ¥5),此前的调用标注为「免费期内」。这笔费用不属于峰谷计价,直接叠加。
59
+
60
+ ### 图片(vision)消耗
61
+
62
+ 发图时 DeepSeek 会按图片尺寸把图折算成 token,**和文字 token 一起计费**,没有单独的「图片费用」。也就是说图片消耗本来就在输入里,插件要做的是把它标出来——明细浮层里多一行:
63
+
64
+ ```
65
+ 图片 12 张 · 约 4,300 tok · 已计入输入
66
+ ```
67
+
68
+ token 数用官方文档站「图片 Token 计算器」的同一套算法估算(像素按 14 对齐 → 按 3 折成视觉网格 → 每张上限 1024),所以和官网计算器算出来的数字一致。同一张图只算一次(按附件 ID 去重,不会因为同时出现在两处事件里被重复统计)。
69
+
70
+ 要注意的是:**图一旦进入上下文,后面每一轮请求都会再把它计一遍输入**(多数能命中缓存,单价便宜很多)。所以「图片 N 张 · 约 X tok」说的是这些图本身的体积,不是它们累计产生的费用。
71
+
72
+ ### 查看官方用量
73
+
74
+ 双击余额,或点统计面板里的「查看官方用量」/「去充值」,会按三级顺序打开官方页面:
75
+
76
+ 1. **DeepSeek Harness 内置平台窗口**(首选)。这个窗口自带平台登录凭据,所以**不需要在网页里再登录一次**。
77
+ 2. 应用内侧栏浏览器(内置窗口拿不到时)。
78
+ 3. 系统默认浏览器(最后兜底)。
79
+
80
+ 余额重置 / 充值的入口就是同一个窗口里的 `top-up` 页,两个页面之间可以直接切换。
81
+
82
+ ### 设置卡片
83
+
84
+ 位置:主界面左上角 **「插件」** → 点 `balance-dsh` → 「余额与用量」卡片。包含:
85
+
86
+ - **版本与更新**:显示当前版本;点「检查更新」读取 npm 上的最新版本号(只查版本,不下载)。发现新版时原地变成「检测到新版本 X,是否更新?」——点「更新」才会下载并覆盖本机插件文件(旧文件先备份到 `~/.dsh/balance-update-backup/`),**完成后需重启 DeepSeek Harness 才生效**。
87
+ - **启动自动检查**:每次打开 DeepSeek Harness 会在启动后稍等片刻自动查一次;有新版本时左侧「余额与用量」入口旁显示 **「新版本!」** 胶囊提示。检查失败或断网时完全静默,不打扰。
88
+ - **节假日数据**:显示同步状态(已同步天数、上次同步时间、尚未公布的年份)与「立即同步」按钮。
89
+ - **侧边栏入口开关**:关掉后左侧导航不再显示「余额与用量」。关闭的那一刻会弹一个官方提示窗,告诉你从哪儿可以再打开。
19
90
 
20
- ### 使用统计口径
91
+ 整张卡片可以收起(点标题一行展开 / 折叠)。**没有保存按钮**,改动后约 0.35 秒自动保存,左下角依次显示「修改中… → 保存中… → 已保存」;点「恢复默认」立刻写回全部默认值。设置存放在你的 `.dsh` 目录下的 `balance-dsh.json`。
21
92
 
22
- **插件显示的所有金额都是本地估算,不是 DeepSeek 官方账单。** 想要准确数字请用「查看官方用量」打开官方用量页(双击余额、或点统计面板里的按钮)。
93
+ ### `/balance` 命令
94
+
95
+ 在对话里输入 `/balance` 查询余额明细和当前会话消耗。
96
+
97
+ ## 使用统计口径
98
+
99
+ **插件显示的所有金额都是本地估算,不是 DeepSeek 官方账单。** 想要准确数字请用「查看官方用量」打开官方页面。
23
100
 
24
101
  - 热力图按北京时间聚合最近 90 天持久化会话中的 `assistant/message` Token usage。
25
102
  - 费用按每条消息的**实际模型**和**峰谷时段**估算:高峰时段的 token 按高峰价、空闲时段按目录价,按事件分桶计算。
26
103
  - **峰谷归属按「请求发起时刻」判定**(取 `step/start` 事件的 `time`;拿不到时回退消息完成时刻)。**官方并未公开跨时段请求到底按发起还是按完成计费**,所以这是一个合理猜测而非官方口径;如果官方实际按响应生成时间逐段计费,一次横跨边界的请求真实费用会介于「全高峰」和「全空闲」之间,本插件两种口径都算不出这个中间值。(实测影响很小:请求平均耗时约 6 秒,长请求也只到分钟级,撞上窗口边界的概率极低。)
27
104
  - 其他已知误差来源:本地 token 数与官方计费口径可能不同(系统提示、工具调用的内部算法未公开);缓存命中与否按 usage 字段判断,官方口径可能不同;官方调价后需等插件更新价表;模型靠事件里的切换点推断,同名不同版本分不清。
28
- - 为避免历史会话过多拖慢 DSH,默认最多扫描最近 200 个会话;服务端统计和客户端展示结果均缓存 5 分钟,重复打开菜单不会重复请求。**这两个值、以及刷新间隔、各类显示开关,都能在下面的配置界面里改。**
29
- - 账户余额优先读取 **DSH 官方账号**(官方版登录后即可用,无需 API Key);未登录官方账号时回退到 API Key。两者都不可用时仍可查看本地 Token 与费用统计,只是余额会显示为不可用。
105
+ - 峰值倍率只乘一次:高峰桶乘以倍率,空闲桶原价,网页工具费不参与峰谷倍数。
106
+ - **图片 token 已包含在输入里**,插件不会再为它算一次钱(那会让金额翻倍)。明细里的「图片约 X tok」是各张图本身的体积之和,按官方计算器算法估算;图留在上下文里每轮都会重新计一遍输入,这部分体现在「缓存读取」中。
107
+ - 为避免历史会话过多拖慢 DeepSeek Harness,默认最多扫描最近 200 个会话;服务端统计与客户端展示结果均缓存 5 分钟,重复打开菜单不会重复请求。**这两个值、以及刷新间隔、各类显示开关,都能在配置界面里改。**
108
+ - 账户余额优先读取 **DeepSeek Harness 官方账号**(登录后即可用,无需 API Key);未登录时回退到 API Key。两者都不可用时仍可查看本地 Token 与费用统计,只是余额会显示为不可用。
30
109
 
31
110
  ## 安装
32
111
 
33
- 前提:已安装 DSH Desktop,且知道自己的 profile 目录(通常是 `C:\Users\<你的用户名>\.dsh\profiles\desktop\`)。
112
+ 前提:已安装 DeepSeek Harness 桌面版,且知道自己的 profile 目录(通常是 `C:\Users\<你的用户名>\.dsh\profiles\desktop\`)。
34
113
 
35
114
  ### 方式一:通过 npm 安装(推荐)
36
115
 
@@ -43,7 +122,7 @@ pnpm add balance-dsh
43
122
 
44
123
  ```powershell
45
124
  cd C:\Users\<你的用户名>\.dsh\profiles\desktop
46
- pnpm add "file:C:\path\to\balance-dsh-1.0.0.tgz"
125
+ pnpm add "file:C:\path\to\balance-dsh-<版本>.tgz"
47
126
  ```
48
127
 
49
128
  ### 方式三:从 GitHub 安装
@@ -53,7 +132,7 @@ cd C:\Users\<你的用户名>\.dsh\profiles\desktop
53
132
  dsh plugin --profile desktop add github:Rannist/balance-dsh
54
133
  ```
55
134
 
56
- > 用 npm 安装后,插件自带 `dsh.bundle.patch`(`cordis.patch.yml`),安装即自动注册,**无需手动改 profile 的 cordis.patch.yml**。只有用旧版 tarball/手动方式时才需要手动注册:
135
+ > 用 npm 安装后,插件自带 `dsh.bundle.patch`(`cordis.patch.yml`),安装即自动注册,**无需手动改 profile 的 cordis.patch.yml**。只有用旧版 tarball / 手动方式时才需要手动注册:
57
136
 
58
137
  ```yaml
59
138
  # profile 目录的 cordis.patch.yml(只有手动安装时才需要)
@@ -70,27 +149,29 @@ dsh plugin --profile desktop add github:Rannist/balance-dsh
70
149
 
71
150
  1. 环境变量 `DEEPSEEK_API_KEY`,或
72
151
  2. 在注册项的 `config.apiKey` 中设置,或
73
- 3. 使用 DSH 凭据存储中的 `DEEPSEEK_API_KEY`
152
+ 3. 使用 Harness 凭据存储中的 `DEEPSEEK_API_KEY`
74
153
 
75
154
  ### 重启
76
155
 
77
- 服务端界面改动需**重启 DSH Desktop** 后生效。
156
+ 客户端界面改动**刷新页面即生效**;服务端逻辑(刷新间隔、统计范围、API Key、计费倍率等)改动需**重启 DeepSeek Harness 桌面版**。
78
157
 
79
158
  ## 配置
80
159
 
81
160
  ### 可视化配置(推荐)
82
161
 
83
- DSH 主界面**左上角「插件」** → 点 `balance-dsh` → 详情页里的 **「余额与用量」** 卡片即可调整:
162
+ 主界面左上角 **「插件」** → 点 `balance-dsh` → 详情页里的「余额与用量」卡片。分组从上到下:
84
163
 
85
164
  | 分组 | 可调项 |
86
165
  |---|---|
166
+ | 版本与更新 | 当前版本、检查更新、就地更新 |
167
+ | 节假日数据 | 同步状态、立即同步 |
87
168
  | 余额 | 刷新间隔、低余额警示阈值、API Key |
88
- | 界面显示 | 侧边栏余额行 / 会话消耗行 / 高峰空闲标签 的显隐开关 |
169
+ | 界面显示 | 侧边栏余额行开关、余额行样式(新版 / 旧版)、侧边栏入口开关、右键刷新浪潮、会话消耗行开关、(仅旧版)高峰空闲标签开关 |
89
170
  | 收起态圆钮 | 显示开关、直径、距左缘 |
90
171
  | 用量统计 | 扫描会话数上限、统计缓存时长 |
91
172
  | 计费 | 高峰时段倍率 |
92
173
 
93
- 整张卡片可以收起(点标题一行展开 / 折叠)。**没有保存按钮,改动后约 0.35 秒自动保存并生效**,左下角会依次显示「修改中… → 保存中… → 已保存」;点「恢复默认」会立刻写回全部默认值。设置存放在你的 `.dsh` 目录下的 `balance-dsh.json`。**客户端项刷新即生效;服务端项(刷新间隔、统计范围、API Key)需重启 DSH Desktop。**
174
+ 卡片最顶部是一条固定的范围声明:「计费仅支持 DeepSeek Harness 内的 DeepSeek 官方模型与账号;其他大模型不计入金额。」
94
175
 
95
176
  ### 配置文件 / 注册项覆盖
96
177
 
@@ -112,7 +193,7 @@ DSH 主界面**左上角「插件」** → 点 `balance-dsh` → 详情页里的
112
193
  outputCnyPerMillion: 4
113
194
  ```
114
195
 
115
- > 注意:插件已自动按高峰/空闲时段计费(高峰价 = 目录价 × 2),**不要再设置 `config.peak: true`**,否则会重复翻倍。
196
+ > 注意:插件已自动按高峰 / 空闲时段计费(高峰价 = 目录价 × 倍率),**不要再设置 `config.peak: true`**,否则会重复翻倍。
116
197
 
117
198
  ## 卸载
118
199
 
@@ -125,8 +206,10 @@ pnpm remove balance-dsh
125
206
 
126
207
  ## 权限 / 数据
127
208
 
128
- - 已登录官方账号时,余额通过 **DSH 内置账号服务**读取(登录凭据由应用自身管理,插件不接触、不保存任何 token)。
209
+ - 已登录官方账号时,余额通过 **DeepSeek Harness 内置账号服务**读取(登录凭据由应用自身管理,插件不接触、不保存任何 token)。
129
210
  - 仅当回退到 API Key 时,才会向 `api.deepseek.com` 的 `/user/balance` 端点查询余额(且只在你**主动刷新**或对话完成时)。
211
+ - 检查更新只请求 `registry.npmjs.org` 上本包的版本号,不发送任何账户信息;就地更新只下载本插件自己的发布包。
212
+ - 节假日数据来自公开的国务院公告镜像(`cdn.jsdelivr.net` 上的 holiday-cn),只读取、不上报。
130
213
  - 插件不收集、不上传任何会话内容到第三方。
131
214
  - 消费统计完全基于本地会话事件(`request/header`、`assistant/message` 的 token usage)计算,不向 DeepSeek 之外的服务发送数据。
132
215
 
@@ -139,7 +222,26 @@ pnpm remove balance-dsh
139
222
  | deepseek-chat(旧) | 2 | 0.5 | 3 |
140
223
  | deepseek-reasoner(旧) | 4 | 1 | 16 |
141
224
 
142
- 价格自 **2026-09-10 12:00(北京时间)** 起生效(flash 系列由 1.5 / 0.05 / 4.5 下调);**高峰时段 = 空闲价 × 2**。调价前的历史消耗仍按当时的旧价计算,不会被重算。DeepSeek 再次调价时需同步更新 `lib/index.js` 的 `MODEL_PRICING` 与 `lib/client.js` 的 `DEFAULT_PRICING`,并把被替换的旧价移入 `LEGACY_MODEL_PRICING`。
225
+ 价格自 **2026-09-10 12:00(北京时间)** 起生效(flash 系列由 1.5 / 0.05 / 4.5 下调);**高峰时段 = 空闲价 × 倍率(默认 2)**。调价前的历史消耗仍按当时的旧价计算,不会被重算。
226
+
227
+ DeepSeek 再次调价时需同步更新 `lib/index.js` 的 `MODEL_PRICING` 与 `lib/client.js` 的 `DEFAULT_PRICING`,并把被替换的旧价移入 `LEGACY_MODEL_PRICING`。
228
+
229
+ ## 常见问题
230
+
231
+ **金额和官方页面上的不一致**
232
+ 正常。本插件算的是**本地估算**(按事件 token × 价表),官方账单还包含插件看不到的口径差异,见上面「使用统计口径」。要准确数字就点「查看官方用量」。
233
+
234
+ **为什么调休上班的周末不算高峰?**
235
+ 官方定价页原话是「其余时段,**包括周末**及中国法定节假日**全天**均为空闲时段」,没有为调休留例外。插件按这个口径实现。如果你有官方另有规定的证据,改 `lib/index.js` 的 `isPeakAt` 与 `lib/client.js` 的 `currentPeriod`(两处必须同口径)。
236
+
237
+ **设置里改了刷新间隔,怎么没立刻生效?**
238
+ 服务端项需要重启 DeepSeek Harness;纯界面项(开关、样式、圆钮尺寸)刷新页面就生效。
239
+
240
+ **点了更新,但版本号没变?**
241
+ 就地更新只是替换磁盘上的插件文件。Node 不会热替换已加载的模块,所以要**重启 DeepSeek Harness** 才会跑新代码。
242
+
243
+ **关掉了侧边栏入口,怎么找回来?**
244
+ 左上角「插件」→ `balance-dsh` → 上面那张卡片里重新打开「侧边栏显示本插件」。
143
245
 
144
246
  ## License
145
247
 
package/lib/client.js CHANGED
@@ -20,7 +20,7 @@ window.__ModuleLoader__.load({
20
20
  左缘与设置按钮严格对齐,高度/颜色一致(金额 600 加粗)。
21
21
  布局:[meta + 刻度条(竖排,居中)] [金额(最右)] */
22
22
  .balance-footer{position:relative;isolation:isolate;box-sizing:border-box;display:flex;align-items:center;justify-content:flex-start;gap:8px;width:100%;min-width:0;height:42px;margin:0;padding:0 0 0 8px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:22px;text-decoration:none;cursor:pointer;white-space:nowrap;overflow:visible;-webkit-user-select:none;user-select:none}
23
- /* 带 meta 行时整块变高:42px 装不下「一行 11px 文字 + 13px 轨道」,改成自适应 */
23
+ /* 带 meta 行时整块变高:42px 装不下「一行 11px 文字 + 13px 轨道」,改成自适应。 */
24
24
  .balance-footer--stacked{height:auto;min-height:42px;padding-top:5px;padding-bottom:5px}
25
25
  /* 整行都是热区(单击统计面板 / 双击用量页 / 右键刷新;交互挂在 .balance-stats-anchor、捕获阶段)。
26
26
  不给整行画 hover / active 底色——那圈圆角高亮框在侧边栏里太显眼,
@@ -105,12 +105,18 @@ window.__ModuleLoader__.load({
105
105
  height:auto,撑得住),而不是把胶囊顶出去。 */
106
106
  .balance-footer__timeline-meta{display:inline-flex;align-items:center;gap:6px;row-gap:3px;flex-wrap:wrap;justify-content:center;max-width:100%;font-size:11px;line-height:1;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-tertiary,#8a8a8a);margin:0}
107
107
  .balance-footer__timeline-meta-item{flex:none;white-space:nowrap}
108
- /* 「今日 ¥x」独占第一行(flex-basis:100%),于是下面那行永远是「空闲 剩 3h57m」。
109
- 不加这条时宽度够就会并成一行,用户看到的顺序就变成「今日 … 空闲 剩 …」,
110
- 得不到他要的「空闲跟剩余时间一栏」(2026-10-30 指定)。
111
- ⚠️ 必须配 text-align:center:占满 100% 之后默认是左对齐,而下面那行 group 是居中的,
112
- 两行就会错开(2026-10-30 反馈「今日消费位置不对」)。 */
108
+ /* 「今日 ¥x」独占第一行(flex-basis:100%)并居中,与下面「空闲 剩 3h57m」那一行左对齐成组。 */
113
109
  .balance-footer__timeline-meta-item--today{flex:0 0 100%;text-align:center}
110
+ /* 「今日 ¥x」数据没到时的占位:不可见但占位,高度先占好,数据到了只换文字、不动布局。
111
+ ⚠️ 不要改回条件渲染或 display:none —— 那会让 meta 行从 1 行变 2 行,
112
+ 侧栏底部整体被顶下 14px,冷启动时就是「今日余额弹出来那下卡了」(2026-10-30 反馈)。 */
113
+ .balance-footer__timeline-meta-item--pending{visibility:hidden}
114
+ /* 占位换成真实值时淡入,而不是「啪」一下闪出来。
115
+ ⚠️ 用 animation 不用 transition —— 元素刚从 visibility:hidden 显形,
116
+ 起点值不一定被浏览器算过,transition 可能整段被吞(同指针那条教训,2026-10-30)。
117
+ 类常驻但 key 不变,所以只在「占位 → 真实值」这一次播;之后金额随轮询变化不会重播。 */
118
+ .balance-footer__timeline-meta-item--in{animation:balance-today-in .3s cubic-bezier(.25,.8,.35,1) both}
119
+ @keyframes balance-today-in{from{opacity:0}to{opacity:1}}
114
120
  /* meta 的第二段(时段词 + 剩余时间)作为一个整体不拆开:折行时它整段落到
115
121
  「今日 ¥x」下面那行,于是「空闲」永远和「剩 3h57m」同行。 */
116
122
  .balance-footer__timeline-meta-group{display:inline-flex;align-items:center;gap:6px;flex:0 1 auto;min-width:0;white-space:nowrap;max-width:100%}
@@ -205,6 +211,9 @@ window.__ModuleLoader__.load({
205
211
  .balance-usage-line svg{flex:none;width:14px;height:14px}
206
212
  .balance-usage-line:hover,.balance-usage-line[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));color:var(--dsw-alias-label-secondary)}
207
213
  .balance-usage-label{text-overflow:ellipsis;min-width:0;overflow:hidden}
214
+ /* 金额那一段单独成类:算完之前显示「计算中…」,别先闪一个随后会变的数字。 */
215
+ .balance-usage-cost{white-space:nowrap;font-variant-numeric:tabular-nums}
216
+ .balance-usage-cost--pending{opacity:.72}
208
217
  /* ── 消耗明细浮层:规格照抄 DSH 原生「会话统计」面板(.HS2XBG_panel/.title/.titleRule/.details)── */
209
218
  .balance-usage-panel{box-sizing:border-box;position:fixed;z-index:1100;border:0;border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-specific-menu,var(--dsw-alias-bg-module-platform,#242526));width:max-content;min-width:min(300px,100vw - 24px);max-width:min(440px,100vw - 24px);backdrop-filter:var(--dsw-menu-backdrop-filter,none);--dsw-elevation-stroke-color:var(--dsw-alias-border-l1);box-shadow:var(--dsw-elevation-prominent,0 8px 24px rgba(0,0,0,.32));color:var(--dsw-alias-label-secondary);cursor:default;padding:16px;font-size:12px;line-height:18px;animation:balance-stats-panel-in .12s var(--ds-ease-in-out,ease-out) both}
210
219
  .balance-usage-panel__title{display:flex;justify-content:space-between;gap:16px;margin-bottom:8px;color:var(--dsw-alias-label-primary);font-weight:500}
@@ -370,8 +379,9 @@ window.__ModuleLoader__.load({
370
379
  .balance-tip-body{margin:0;font-size:13px;line-height:1.7;color:var(--dsw-alias-label-secondary)}
371
380
  /* 节假日数据行:右侧是「状态文案 + 立即同步」两段,窄屏时自动换行靠右。 */
372
381
  .balance-settings__calendar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end}
373
- .balance-settings__calendar-note{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary);text-align:right;font-variant-numeric:tabular-nums;max-width:200px}
382
+ .balance-settings__calendar-note{font-size:11.5px;line-height:17px;color:var(--dsw-alias-label-tertiary);text-align:right;font-variant-numeric:tabular-nums;max-width:100%;word-break:keep-all}
374
383
  .balance-settings__calendar-note--warn{color:var(--dsw-alias-state-warn-primary,#d97706)}
384
+ .balance-settings__calendar-note--ok{color:var(--dsw-alias-state-success-primary,#22c55e)}
375
385
  .balance-settings__calendar-note--error{color:var(--dsw-alias-state-error-primary,#e5484d)}
376
386
  .balance-tip-ok{padding:5px 14px;border:.5px solid transparent;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);font:inherit;font-size:13px;line-height:1.5;cursor:pointer}
377
387
  .balance-tip-ok:hover{opacity:.88}
@@ -1228,7 +1238,11 @@ const CN_CALENDAR_SOURCES = [
1228
1238
  }
1229
1239
 
1230
1240
  // ─── 今日消耗:随余额一起刷新(服务端 60s 缓存,只扫今天动过的会话) ──
1231
- const todayCache = { value: null, inFlight: null, at: 0 };
1241
+ // tried = 至少问过一次(不论成功失败)。用来区分「还在等」和「确实没有」:
1242
+ // 等待期间先摆一个不可见占位,让 meta 行高度从一开始就是最终高度 ——
1243
+ // 否则「今日 ¥x」这个 flex-basis:100% 的元素一插入,整行就从 1 行变 2 行,
1244
+ // 侧栏底部整体被顶下 14px,冷启动繁忙时看着就是「今日余额弹出来那下卡了」。
1245
+ const todayCache = { value: null, inFlight: null, at: 0, tried: false };
1232
1246
  const todayListeners = new Set();
1233
1247
  function notifyToday() {
1234
1248
  for (const listener of todayListeners) listener();
@@ -1251,6 +1265,12 @@ const CN_CALENDAR_SOURCES = [
1251
1265
  // 静默:今日消耗拿不到就不显示,不影响余额
1252
1266
  } finally {
1253
1267
  todayCache.inFlight = null;
1268
+ // 只在「问过但没拿到」时才需要通知:成功路径上面已经 notify 过了。
1269
+ // 这样失败时会撤掉占位,不会永久留一条空行。
1270
+ if (!todayCache.tried) {
1271
+ todayCache.tried = true;
1272
+ if (todayCache.value === null) notifyToday();
1273
+ }
1254
1274
  }
1255
1275
  return todayCache.value;
1256
1276
  })();
@@ -2373,33 +2393,34 @@ function TimelineBand(props) {
2373
2393
  metaItem(period === "peak" ? "高峰" : "空闲", " balance-footer__timeline-meta-item--" + period, "period"),
2374
2394
  ];
2375
2395
  if (span !== null) tail.push(metaItem("剩 " + formatRemain(span.remainMs), "", "remain"));
2396
+ // 「今日 ¥x」是 meta 里的第一项(flex-basis:100%,独占第一行)——
2397
+ // 数据没到之前先摆一个不可见占位,让这一行的高度从一开始就是最终高度 ——
2398
+ // 条件渲染会让整块从 1 行变 2 行,侧栏底部(含设置按钮)整体被顶下 14px,
2399
+ // 那一下重排就是「今日余额弹出来时卡住」。
2400
+ // 占位只在「还在等」时出现;确认拿不到(tried 且为 null)就整项不渲染,不留空行。
2401
+ const todayCost = today !== null && Number.isFinite(Number(today.cost)) ? Number(today.cost) : null;
2402
+ const todayPending = todayCost === null && !todayCache.tried;
2376
2403
  const metaChildren = [];
2377
- if (today !== null && Number.isFinite(Number(today.cost))) {
2378
- metaChildren.push(
2379
- jsx.jsx(
2380
- "span",
2381
- {
2382
- className: "balance-footer__timeline-meta-item balance-footer__timeline-meta-item--today",
2383
- children: "今日 ¥" + Number(today.cost).toFixed(2),
2384
- },
2385
- "today",
2386
- ),
2387
- );
2404
+ if (todayCost !== null || todayPending) {
2405
+ metaChildren.push(jsx.jsx("span", {
2406
+ className:
2407
+ "balance-footer__timeline-meta-item balance-footer__timeline-meta-item--today" +
2408
+ (todayCost === null ? " balance-footer__timeline-meta-item--pending" : " balance-footer__timeline-meta-item--in"),
2409
+ children: todayCost === null ? "今日 ¥--" : "今日 ¥" + todayCost.toFixed(2),
2410
+ }, "today"));
2388
2411
  }
2389
- metaChildren.push(
2390
- jsx.jsx("span", { className: "balance-footer__timeline-meta-group", children: tail }, "tail"),
2391
- );
2392
- const metaNode = jsx.jsx("span", {
2393
- className: "balance-footer__timeline-meta",
2394
- children: metaChildren,
2395
- });
2412
+ metaChildren.push(jsx.jsx("span", { className: "balance-footer__timeline-meta-group", children: tail }, "tail"));
2413
+ const metaNode = jsx.jsx("span", { className: "balance-footer__timeline-meta", children: metaChildren });
2396
2414
  // 新版把「meta 行 + 刻度条」当结构主体,不存在"关掉只剩金额"的形态,
2397
2415
  // 所以这里不看 showPeriodLabel;那个开关只在旧版有意义(见下面的 classic 分支)。
2398
2416
  const timelineBand = buildTimeline(timelineNow);
2399
2417
  // 刷新时不替换时段模块:刻度条保持原样,刷新反馈只落在右侧金额(半透明 + 浪潮)。
2400
- // 刻度条在 timeline 内部、meta 下方,宽度跟随侧栏(width:100%):
2401
- // [今日 ¥x / 空闲 剩 3h57m] [胶囊]
2418
+ // 刻度条在 timeline 内部、meta 下方,宽度跟随侧栏(width:100%)。
2419
+ // 容器是普通 flex(不 wrap),实际排版是两段:
2420
+ // [ 今日 ¥x] ← meta 第一行(独占、居中)
2421
+ // [空闲 剩 3h57m] [¥x • 空闲] ← meta 第二行 + 金额胶囊
2402
2422
  // [==== 刻度条(跟随侧栏宽度)====]
2423
+ // 「今日」占满左列宽度并居中,与下面「空闲 剩 3h57m」在左列内部成组。
2403
2424
  const periodNode = jsx.jsxs("span", {
2404
2425
  className: "balance-footer__timeline",
2405
2426
  children: [
@@ -2453,7 +2474,7 @@ function TimelineBand(props) {
2453
2474
  return withPanel(jsx.jsxs("div", {
2454
2475
  className: "balance-footer balance-footer--stacked",
2455
2476
  children: [
2456
- // 抹除层覆盖整块显示区(meta 行 + 时段条 + 金额):白条经过哪里,哪里才显影
2477
+ // 抹除层覆盖整块显示区(today 行 + meta 行 + 时段条 + 金额):白条经过哪里,哪里才显影
2457
2478
  waveNode,
2458
2479
  periodNode === null
2459
2480
  ? jsx.jsx("span", { className: "balance-footer__spacer" })
@@ -2588,6 +2609,14 @@ function UsageCostLine({ useProjection, sessionId }) {
2588
2609
  // 服务端分桶可能"非 null 但为空"(拿不到该会话事件),此时必须回退本地投影估算,否则金额会显示 ¥0.0000
2589
2610
  const splitUsable =
2590
2611
  split != null && ((Array.isArray(split.byModel) && split.byModel.length > 0) || (split.totalTokens ?? 0) > 0);
2612
+ // 「这个会话问过服务端没有」——没问过就还在算。
2613
+ // 本地估算此时只是临时值:它把**整段历史**按当前时段价一次性乘(高峰窗口里就是 ×2),
2614
+ // 而服务端是按每条消息实际发生的时段分桶。所以高峰时段点进旧会话必然先高后低
2615
+ // (2026-10-30 实测:先显 ¥107、随即跳到 ¥69,比值 1.55 正好是「一半历史在空闲时段」)。
2616
+ // 与其先闪一个会变的数字,不如先显示「计算中…」,拿到结果后一次到位。
2617
+ const splitAsked =
2618
+ sessionId != null && sessionId !== "" && Object.prototype.hasOwnProperty.call(splitMap, sessionId);
2619
+ const costPending = !splitAsked && usageKey > 0;
2591
2620
  const cost = splitUsable ? computeSplitCost(split, p, peakMultiplier) : computeCost(usage, periodAdjustedPricing(p, peakMultiplier));
2592
2621
  const peakTokens = splitUsable ? split.peak.inputTokens + split.peak.cacheReadTokens + split.peak.cacheWriteTokens + split.peak.outputTokens : 0;
2593
2622
  const offpeakTokens = splitUsable ? split.offpeak.inputTokens + split.offpeak.cacheReadTokens + split.offpeak.cacheWriteTokens + split.offpeak.outputTokens : 0;
@@ -2614,10 +2643,19 @@ function UsageCostLine({ useProjection, sessionId }) {
2614
2643
  ? jsx.jsx(primitives.IconDatabaseOutlineRegular, { key: "icon" })
2615
2644
  : null,
2616
2645
  jsx.jsx("span", { key: "label", className: "balance-usage-label", children: "本会话消耗" }),
2617
- jsx.jsx("span", { key: "cost", className: "balance-usage-label", children: "≈ ¥" + cost.toFixed(4) }),
2646
+ jsx.jsx("span", {
2647
+ key: "cost",
2648
+ className: "balance-usage-cost" + (costPending ? " balance-usage-cost--pending" : ""),
2649
+ children: costPending ? "计算中…" : "≈ ¥" + cost.toFixed(4),
2650
+ }),
2618
2651
  ];
2619
2652
 
2620
2653
  // ── 点击展开的明细浮层(结构与 DSH 原生「会话统计」一致:标题 + 分隔线 + dl 两列网格)──
2654
+ // 图片:官方把图片按尺寸折成 token 并入**输入**(没有单独的图片费用)。
2655
+ // 服务端返回的 usage 不做图片级拆分,所以 token 数用官网「图片 Token 计算器」同一算法估算(标「约」)。
2656
+ // ⚠️ 这部分金额**已经在**上面的输入里,这里只做说明,绝不能再加一笔(会翻倍)。
2657
+ const imageCount = Number(split?.imageCount ?? 0) || 0;
2658
+ const imageTokens = Number(split?.imageTokens ?? 0) || 0;
2621
2659
  const usageDetails = [
2622
2660
  ["缓存命中", cacheHitPercent + "%"],
2623
2661
  ["未缓存输入", formatTokens(uncachedInput) + " tok"],
@@ -2625,12 +2663,25 @@ function UsageCostLine({ useProjection, sessionId }) {
2625
2663
  ];
2626
2664
  if (cacheWrite > 0) usageDetails.push(["缓存写入", formatTokens(cacheWrite) + " tok"]);
2627
2665
  usageDetails.push(["输出", formatTokens(output) + " tok"]);
2666
+ if (imageCount > 0) {
2667
+ usageDetails.push([
2668
+ "图片",
2669
+ imageCount + " 张 · 约 " + formatTokens(imageTokens) + " tok · 已计入输入",
2670
+ ]);
2671
+ }
2628
2672
 
2629
2673
  const costDetails = [
2630
2674
  ["新增 tokens", formatTokens(newTokens) + " tok"],
2631
2675
  ["计费 tokens", formatTokens(billedTotal) + " tok"],
2632
2676
  ["模型", model || "未知(按 flash 价估算)"],
2633
2677
  ];
2678
+ // 金额口径要写明白:算完 = 服务端逐条按发生时段分桶;算完前 / 拿不到 = 本地按当前时段估算。
2679
+ // 估算会把整段历史按「此刻」的峰谷价乘一次,高峰窗口里自然偏高 —— 说清楚,别让它冒充精确值。
2680
+ if (costPending) {
2681
+ costDetails.push(["金额", "计算中(正在取服务端分时段用量)"]);
2682
+ } else if (!splitUsable) {
2683
+ costDetails.push(["金额", "按当前时段估算(服务端未返回分桶)"]);
2684
+ }
2634
2685
  if (splitUsable) {
2635
2686
  costDetails.push(
2636
2687
  ["高峰 tokens", formatTokens(peakTokens) + " tok"],
@@ -2926,19 +2977,30 @@ function UsageCostLine({ useProjection, sessionId }) {
2926
2977
  const [calendarSync, setCalendarSync] = react.useState({ kind: "idle", text: "" });
2927
2978
  const calendarBusy = calendarSync.kind === "syncing";
2928
2979
  const calendarSummary = (() => {
2929
- if (calendarSync.kind === "ok" || calendarSync.kind === "error") return calendarSync.text;
2980
+ // 同步失败:直接把服务端给的原因摆出来,别拿旧数据冒充成功。
2981
+ if (calendarSync.kind === "error") return calendarSync.text;
2982
+ // 只要有数据就一律显示同一句「已同步 N 天 · 上次同步时刻 · 待公布」——
2983
+ // **同步成功之后同样要显示时刻**(2026-10-30 反馈「只有在同步过程中才显示上次同步时间」)。
2984
+ // 旧写法让 ok 分支返回另拼的一句「已更新 2025 / 2026(72 天)(2027 待公布)」:
2985
+ // 既丢了时刻,又把年份列表塞回来,200px 宽里直接折成两行、断在「公布)」中间。
2986
+ // 现在 ok 只负责变色(--ok 绿),文案一律由这里按缓存渲染。
2930
2987
  if (calendar.fetchedAt > 0) {
2931
2988
  const at = new Date(calendar.fetchedAt);
2932
2989
  const stamp =
2933
2990
  at.getFullYear() + "-" + String(at.getMonth() + 1).padStart(2, "0") + "-" +
2934
2991
  String(at.getDate()).padStart(2, "0") + " " + String(at.getHours()).padStart(2, "0") + ":" +
2935
2992
  String(at.getMinutes()).padStart(2, "0");
2936
- const years = calendar.years.length > 0 ? calendar.years.join(" / ") : "—";
2937
2993
  // 只报日期与时刻,不报完整年份列表:那串太长会把左边的标题挤成一字一行。
2938
2994
  // 「同步了哪几年」由服务端 days 的键直接决定,用户点「立即同步」那边也能看到。
2939
- const pending = calendar.missing.length > 0 ? " · " + calendar.missing.join(" / ") + " 待公布" : "";
2995
+ // 「2027 待公布」内部用不换行空格(\\u00A0)焊死:卡片窄到 298px 时,
2996
+ // 普通空格会允许断在「2027」和「待公布」之间,把这三个字拆成两行(实测)。
2997
+ // 焊死后只可能断在前面的「 · 」处,两行就是「已同步…时刻」/「· 2027 待公布」。
2998
+ const pending = calendar.missing.length > 0
2999
+ ? " · " + calendar.missing.map(String).join("\u00A0/\u00A0") + "\u00A0待公布"
3000
+ : "";
2940
3001
  return "已同步 " + calendar.count + " 天 · " + stamp.slice(5) + pending;
2941
3002
  }
3003
+ if (calendarSync.kind === "syncing") return "正在同步…";
2942
3004
  return "尚未同步,正在用插件内置表。";
2943
3005
  })();
2944
3006
  const onSyncCalendar = async () => {
@@ -2947,14 +3009,9 @@ function UsageCostLine({ useProjection, sessionId }) {
2947
3009
  try {
2948
3010
  const data = await syncCalendar();
2949
3011
  if (data && data.ok === true) {
2950
- const years = Array.isArray(data.years) && data.years.length > 0 ? data.years.join(" / ") : "—";
2951
- const pending = Array.isArray(data.pending) && data.pending.length > 0
2952
- ? "(" + data.pending.join(" / ") + " 待公布)"
2953
- : "";
2954
- setCalendarSync({
2955
- kind: "ok",
2956
- text: "已更新 " + years + "(" + (Number(data.added) || 0) + " 天)" + pending,
2957
- });
3012
+ // 成功不另拼一句文案:syncCalendar() 内部已经 adoptCalendar(data) 刷新了缓存,
3013
+ // calendarSummary 会带着新的同步时刻自己重渲染;这里只把状态切成 ok(绿色)。
3014
+ setCalendarSync({ kind: "ok", text: "" });
2958
3015
  } else {
2959
3016
  setCalendarSync({
2960
3017
  kind: "error",
@@ -3434,9 +3491,11 @@ function UsageCostLine({ useProjection, sessionId }) {
3434
3491
  "balance-settings__calendar-note" +
3435
3492
  (calendarSync.kind === "error"
3436
3493
  ? " balance-settings__calendar-note--error"
3437
- : calendar.fetchedAt > 0
3438
- ? ""
3439
- : " balance-settings__calendar-note--warn"),
3494
+ : calendarSync.kind === "ok"
3495
+ ? " balance-settings__calendar-note--ok"
3496
+ : calendar.fetchedAt > 0
3497
+ ? ""
3498
+ : " balance-settings__calendar-note--warn"),
3440
3499
  children: calendarSummary,
3441
3500
  }),
3442
3501
  jsx.jsx("button", {
package/lib/index.js CHANGED
@@ -527,7 +527,7 @@ function formatBalance(balance) {
527
527
  // | { status:"failed" } (网络失败;官方明确“失败绝不变成 0 余额”)
528
528
  // client 是 AccountClientMetadata(version / locale / timezoneOffsetSeconds),
529
529
  // 缺字段会在平台侧组装请求头时抛错,所以三个字段都要给。
530
- const PLUGIN_VERSION = "2.0.4";
530
+ const PLUGIN_VERSION = "2.0.6";
531
531
 
532
532
  // ─── 检查更新 ───────────────────────────────────────────────────────────────
533
533
  // 只查 npm registry 的 latest 标签(一个很小的 JSON),不下载任何东西。
@@ -914,6 +914,145 @@ function webToolUsage(events) {
914
914
  };
915
915
  }
916
916
 
917
+ /** 图片 token 换算 —— **原样移植 DeepSeek 官方文档站「图片 Token 计算器」的实现**
918
+ * (文档站 chunk 6620 里的 class h,配置取 h.v41())。
919
+ *
920
+ * 官方文字只给了三条口头规则(总像素 < 544² 等比放大、更大的缩到总像素约 1300²、每张上限 1024),
921
+ * 真正的换算在这段前端代码里,而且它对宽高方向的用法并不直观 —— 所以**逐字符保真搬过来,
922
+ * 不要「读懂后重写」**:第一版做了语义化重命名,把 safeResize 的实参顺序搞反,
923
+ * 结果横图与竖图直接互换(3840×2160 给出 986,官方是 968;1080×1920 恰好反之)。
924
+ *
925
+ * 算法要点(v41 配置):patchSize=14、downsampleRatio=3、maxNToken=1024、minPixels=544²。
926
+ * 像素先按 patchSize 向上取整对齐,再按 downsampleRatio 折成视觉网格,
927
+ * token = 网格B × (网格A + 1) + 2;超预算时用解析解求比例并迭代收敛(最多 10 轮)。
928
+ *
929
+ * 校验方式:把 chunk 原文求值当 oracle,与移植版逐尺寸比对 —— 定向 23 组 + 随机 3000 组全等。
930
+ * ⚠️ 只用于**展示**:DeepSeek 已把图片折成 token 计入 usage.inputTokens,
931
+ * 这里再乘价格就是重复计费(金额会翻倍)。 */
932
+ const ImageTokenCalculator = (() => {
933
+ const l = (e, t) => Math.floor(e / t),
934
+ c = (e, t) => Math.floor((e + t - 1) / t),
935
+ r = (e) => Math.trunc(e);
936
+ class ImageTokenCalculator {
937
+ constructor(e, t, s, n, i, a, o) { this.patchSize = e; this.downsampleRatio = t; this.maxNToken = s; this.isNLayout = n; this.compressPadTo = null == i ? null : i; this.maxWhRatio = null == a ? null : a; this.minPixels = o; }
938
+ static v4() { return new ImageTokenCalculator(14, 3, 384, !0, 4, 8, 147456); }
939
+ static v41() { return new ImageTokenCalculator(14, 3, 1024, !1, null, null, 295936); }
940
+ worstPad() { return this.isNLayout && null != this.compressPadTo ? this.compressPadTo - 1 : 0; }
941
+ actualPad(e) { return this.isNLayout && null != this.compressPadTo ? null == e ? this.compressPadTo - 1 : (this.compressPadTo - (e + 1) % this.compressPadTo) % this.compressPadTo : 0; }
942
+ calcNumTokens(e, t) { let s = e * (t + 1) + 2; return this.isNLayout && (e % 2 == 1 && (s += t + 1), s += c(e, 2) * (t + 1) % 2 * 2), s; }
943
+ solveResizeRatio(e, t, s) {
944
+ const n = e / t, i = Math.sqrt((s - 2) / n + .25) - .5, a = i * n;
945
+ let o, h;
946
+ if (i < 1) {
947
+ const e2 = 1;
948
+ let t2 = l(s - 2, e2 + 1);
949
+ this.isNLayout && t2 % 2 == 1 && (t2 -= 1), h = e2 * this.patchSize * this.downsampleRatio, o = t2 * this.patchSize * this.downsampleRatio;
950
+ } else if (a < (this.isNLayout ? 2 : 1)) {
951
+ const e2 = this.isNLayout ? 2 : 1, t2 = l(s - 2, e2) - 1;
952
+ if (!(t2 > 1)) throw new Error("assertion failed: max_w > 1");
953
+ h = t2 * this.patchSize * this.downsampleRatio, o = e2 * this.patchSize * this.downsampleRatio;
954
+ } else {
955
+ const s2 = r(i);
956
+ let n2 = r(a);
957
+ this.isNLayout && n2 % 2 == 1 && (n2 -= 1);
958
+ const l2 = s2 * this.patchSize * this.downsampleRatio / t, c2 = n2 * this.patchSize * this.downsampleRatio / e, u = Math.min(l2, c2);
959
+ h = r(t * u / this.patchSize) * this.patchSize, o = r(e * u / this.patchSize) * this.patchSize;
960
+ }
961
+ const u2 = c(l(o, this.patchSize), this.downsampleRatio), d = c(l(h, this.patchSize), this.downsampleRatio);
962
+ return { nLlmH: u2, nLlmW: d, bestHeight: o, bestWidth: h, numTokens: this.calcNumTokens(u2, d) };
963
+ }
964
+ safeResize(e, t, s, n) {
965
+ const i = c(l(s, this.patchSize), this.downsampleRatio), a = c(l(n, this.patchSize), this.downsampleRatio), o = this.calcNumTokens(i, a), r2 = this.worstPad(), h = this.maxNToken - r2;
966
+ let u = { nLlmH: i, nLlmW: a, bestHeight: s, bestWidth: n, numTokens: o };
967
+ if (u.numTokens > h) {
968
+ if (u = this.solveResizeRatio(e, t, h), this.isNLayout) {
969
+ let s2 = h;
970
+ for (; u.numTokens > h;) s2 -= 1, u = this.solveResizeRatio(e, t, s2);
971
+ }
972
+ if (!(u.numTokens <= h)) throw new Error("assertion failed: result.num_tokens <= max_n_token");
973
+ }
974
+ return u.numTokens += r2, u;
975
+ }
976
+ calcResizeInner(e, t, s) {
977
+ null != this.maxWhRatio && e > t * this.maxWhRatio && (e = t * this.maxWhRatio);
978
+ const n = e * t;
979
+ if (n < this.minPixels && n > 0) {
980
+ const s2 = Math.sqrt(this.minPixels / n);
981
+ e = r(e * s2), t = r(t * s2);
982
+ }
983
+ const i = c(e, this.patchSize) * this.patchSize, a = c(t, this.patchSize) * this.patchSize, o = this.worstPad(), l2 = this.actualPad(s), h = this.safeResize(t, e, a, i);
984
+ return h.numTokens -= o - l2, h;
985
+ }
986
+ calcResize(e, t, s) {
987
+ void 0 === s && (s = null);
988
+ let n = this.calcResizeInner(e, t, s);
989
+ for (let o = 1; o < 10; o++) {
990
+ const e2 = this.calcResizeInner(n.bestWidth, n.bestHeight, s);
991
+ if (e2.nLlmH === n.nLlmH && e2.nLlmW === n.nLlmW && e2.bestHeight === n.bestHeight && e2.bestWidth === n.bestWidth && e2.numTokens === n.numTokens) return n;
992
+ n = e2;
993
+ }
994
+ throw new Error("calc_resize did not converge after 10 iterations, last result: " + JSON.stringify(n));
995
+ }
996
+ calcTokenLen(e, t, s) { return void 0 === s && (s = null), this.calcResize(e, t, s).numTokens; }
997
+ }
998
+ return ImageTokenCalculator;
999
+ })();
1000
+
1001
+ const IMAGE_TOKEN_CALC = ImageTokenCalculator.v41();
1002
+
1003
+ /** 单张图片的估算 token 数;尺寸非法时返回 0(官方计算器此时会报「无法计算」)。 */
1004
+ function estimateImageTokens(width, height) {
1005
+ const w = Number(width);
1006
+ const h = Number(height);
1007
+ if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return 0;
1008
+ try {
1009
+ return IMAGE_TOKEN_CALC.calcTokenLen(w, h, null);
1010
+ } catch {
1011
+ return 0;
1012
+ }
1013
+ }
1014
+
1015
+ /** 统计会话里的图片:张数 + 估算 token。
1016
+ * ⚠️ 两条铁律:
1017
+ * ① **只用于展示,绝不再计入金额** —— DeepSeek 已把图片折成 token 塞进 usage.inputTokens,
1018
+ * 这里只是把它从 input 里「标出来」。再算一次钱就是重复计费(金额会翻倍)。
1019
+ * ② **必须按 attachmentId 去重** —— 同一张图会在 `agent/inbox/spliced` 与 `user/message`
1020
+ * 里各记一次(实测 443 次出现只有 273 张唯一图),不去重就会多算近一倍。
1021
+ * 只遍历可能携带图片的事件类型,避免把整棵事件树递归一遍(stats 要扫 200 个会话)。 */
1022
+ const IMAGE_HOST_EVENTS = new Set(["user/message", "agent/inbox/spliced", "tool/result", "developer/message"]);
1023
+
1024
+ function imageUsage(events) {
1025
+ const seen = new Set();
1026
+ let imageCount = 0;
1027
+ let imageTokens = 0;
1028
+ const visit = (node) => {
1029
+ if (node === null || typeof node !== "object") return;
1030
+ if (Array.isArray(node)) {
1031
+ for (const item of node) visit(item);
1032
+ return;
1033
+ }
1034
+ if (node.type === "image") {
1035
+ const attachment = node.attachment ?? {};
1036
+ const id = attachment.attachmentId ?? node.attachmentId ?? `${attachment.width}x${attachment.height}:${attachment.bytes ?? 0}`;
1037
+ if (!seen.has(id)) {
1038
+ seen.add(id);
1039
+ imageCount += 1;
1040
+ imageTokens += estimateImageTokens(attachment.width, attachment.height);
1041
+ }
1042
+ return;
1043
+ }
1044
+ for (const key of Object.keys(node)) {
1045
+ const value = node[key];
1046
+ if (value !== null && typeof value === "object") visit(value);
1047
+ }
1048
+ };
1049
+ for (const event of events ?? []) {
1050
+ if (!IMAGE_HOST_EVENTS.has(event?.type)) continue;
1051
+ visit(event.data);
1052
+ }
1053
+ return { imageCount, imageTokens };
1054
+ }
1055
+
917
1056
  /** 从事件数组汇总 usage(按模型分段 + 跨时段分桶)。
918
1057
  * 供 live 会话与持久化(子代理)会话复用。返回按模型拆分的 byModel,及跨模型聚合字段。 */
919
1058
  function sumEventsUsage(events, config, fallbackModel = "") {
@@ -922,6 +1061,8 @@ function sumEventsUsage(events, config, fallbackModel = "") {
922
1061
  const fallback = fallbackModel !== "" ? fallbackModel : "deepseek-v4-flash";
923
1062
  // 网页搜索 / 抓取:次数 + 按次计费金额(2026-11-01 起才收费)
924
1063
  const webTools = webToolUsage(events);
1064
+ // 图片:张数 + 估算 token(**仅展示**,token 已含在 inputTokens 里,不再计费)
1065
+ const images = imageUsage(events);
925
1066
  const latestByStep = new Map();
926
1067
  for (const event of events ?? []) {
927
1068
  if (event.type !== "assistant/message") continue;
@@ -982,6 +1123,7 @@ function sumEventsUsage(events, config, fallbackModel = "") {
982
1123
  offpeak,
983
1124
  byModel: modelList,
984
1125
  ...webTools,
1126
+ ...images,
985
1127
  };
986
1128
  }
987
1129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "balance-dsh",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "在 DeepSeek Harness 中显示 DeepSeek 账户余额和当前会话 token/费用消耗(含高峰/空闲峰谷计费)。仅支持 DeepSeek 官方模型与账号,暂不支持其他大模型。",
5
5
  "author": "Rannist",
6
6
  "type": "module",