balance-dsh 2.0.3 → 2.0.5
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 +128 -26
- package/lib/client.js +240 -79
- package/lib/index.js +143 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,36 +1,115 @@
|
|
|
1
1
|
# balance-dsh
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DeepSeek Harness 插件:在 Web UI 中直接显示 DeepSeek 账户余额和当前会话 token / 金额消耗。
|
|
4
4
|
|
|
5
5
|
> **仅支持 DeepSeek,暂不支持其他大模型。**
|
|
6
|
-
> 余额读的是 DeepSeek 官方账号(未登录时回退 DeepSeek API Key),费用按 DeepSeek 官方价表估算,模型识别也只认 DeepSeek 的模型名。如果你通过其他厂商或自建服务用
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
29
|
-
-
|
|
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
|
-
前提:已安装
|
|
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
|
|
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. 使用
|
|
152
|
+
3. 使用 Harness 凭据存储中的 `DEEPSEEK_API_KEY`
|
|
74
153
|
|
|
75
154
|
### 重启
|
|
76
155
|
|
|
77
|
-
|
|
156
|
+
客户端界面改动**刷新页面即生效**;服务端逻辑(刷新间隔、统计范围、API Key、计费倍率等)改动需**重启 DeepSeek Harness 桌面版**。
|
|
78
157
|
|
|
79
158
|
## 配置
|
|
80
159
|
|
|
81
160
|
### 可视化配置(推荐)
|
|
82
161
|
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
>
|
|
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
|
-
- 已登录官方账号时,余额通过 **
|
|
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
|
|
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
|
@@ -111,6 +111,16 @@ window.__ModuleLoader__.load({
|
|
|
111
111
|
⚠️ 必须配 text-align:center:占满 100% 之后默认是左对齐,而下面那行 group 是居中的,
|
|
112
112
|
两行就会错开(2026-10-30 反馈「今日消费位置不对」)。 */
|
|
113
113
|
.balance-footer__timeline-meta-item--today{flex:0 0 100%;text-align:center}
|
|
114
|
+
/* 「今日 ¥x」数据没到时的占位:不可见但占位,高度先占好,数据到了只换文字、不动布局。
|
|
115
|
+
⚠️ 不要改回条件渲染或 display:none —— 那会让 meta 行从 1 行变 2 行,
|
|
116
|
+
侧栏底部整体被顶下 14px,冷启动时就是「今日余额弹出来那下卡了」(2026-10-30 反馈)。 */
|
|
117
|
+
.balance-footer__timeline-meta-item--pending{visibility:hidden}
|
|
118
|
+
/* 占位换成真实值时淡入,而不是「啪」一下闪出来。
|
|
119
|
+
⚠️ 用 animation 不用 transition —— 元素刚从 visibility:hidden 显形,
|
|
120
|
+
起点值不一定被浏览器算过,transition 可能整段被吞(同指针那条教训,2026-10-30)。
|
|
121
|
+
类常驻但 key 不变,所以只在「占位 → 真实值」这一次播;之后金额随轮询变化不会重播。 */
|
|
122
|
+
.balance-footer__timeline-meta-item--in{animation:balance-today-in .3s cubic-bezier(.25,.8,.35,1) both}
|
|
123
|
+
@keyframes balance-today-in{from{opacity:0}to{opacity:1}}
|
|
114
124
|
/* meta 的第二段(时段词 + 剩余时间)作为一个整体不拆开:折行时它整段落到
|
|
115
125
|
「今日 ¥x」下面那行,于是「空闲」永远和「剩 3h57m」同行。 */
|
|
116
126
|
.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%}
|
|
@@ -137,11 +147,27 @@ window.__ModuleLoader__.load({
|
|
|
137
147
|
.balance-footer__timeline-glow{position:absolute;left:-3px;right:-3px;top:-3px;bottom:-3px;pointer-events:none;filter:blur(3px) saturate(1.5) brightness(1.15);opacity:.45;z-index:2}
|
|
138
148
|
/* 色带本身:不透明 + 轻微提饱和,颜色是主角 */
|
|
139
149
|
.balance-footer__timeline-band{position:absolute;left:0;top:0;right:0;bottom:0;pointer-events:none;filter:saturate(1.25) brightness(1.12);z-index:1}
|
|
140
|
-
/*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
150
|
+
/* 换日时色带的交叉淡入:两条 linear-gradient 之间不可插值,只能新旧两层叠着换 opacity。
|
|
151
|
+
TimelineBand 把旧分布垫在下面,新分布挂这个类淡入盖上去,播完摘掉旧层。
|
|
152
|
+
终点 opacity 读 --balance-band-to:色带本体 1、泛光层 .45 —— 写死 1 会把泛光拉成实心。 */
|
|
153
|
+
.balance-footer__timeline-fade{animation:balance-band-fade 620ms cubic-bezier(.4,0,.25,1) both}
|
|
154
|
+
@keyframes balance-band-fade{from{opacity:0}to{opacity:var(--balance-band-to,1)}}
|
|
155
|
+
/* 指针位置由 TimelinePointer 写 inline style 的 left(3px 端内缩,见组件里的 calc)。
|
|
156
|
+
入场走 **CSS animation**(--intro 类 + balance-ptr-in),不是 transition:
|
|
157
|
+
- transition 的起点取「上一次样式计算的值」,而重启 DSH 时首屏繁忙、浏览器可能还没为
|
|
158
|
+
这个元素算过样式,那次变更就被当成首次赋值 → 直接跳到终点、一帧都不播
|
|
159
|
+
(2026-10-30 反馈「有时候重启时指针动画会吞掉」)。
|
|
160
|
+
- animation 的 keyframes 有**显式的 from/to**,元素一插入就带着动画,浏览器时间线怎么
|
|
161
|
+
排都不会漏播;首屏忙只会晚一点开始,但一定播完整。补间在浏览器动画管线里跑,
|
|
162
|
+
React 全程只渲染 2 次(挂载、摘类),不抢主线程(逐帧 setState 就是「掉帧」的元凶)。
|
|
163
|
+
- 稳态(每分钟一次落位)**不带**动画:0.069% 的变化直接跳过去,肉眼无感;顺带也
|
|
164
|
+
避开了「后台标签页暂停动画、切回来瞬移」的老问题。
|
|
165
|
+
时长与曲线与刷新白条 balance-wipe-head 完全一致(.58s cubic-bezier(.34,.7,.26,1))。 */
|
|
166
|
+
.balance-footer__timeline-pointer{position:absolute;left:0;top:-1px;bottom:-1px;width:2px;margin-left:-1px;background:var(--dsw-alias-label-primary,#fff);box-shadow:0 0 3px rgba(255,255,255,.7),0 0 2px rgba(0,0,0,.55);pointer-events:none;z-index:2}
|
|
167
|
+
.balance-footer__timeline-pointer--intro{animation:balance-ptr-in 580ms cubic-bezier(.34,.7,.26,1) both;will-change:left}
|
|
168
|
+
/* from 固定 = pct 0 的位置(0% + 3px 内缩);to 读元素上的自定义属性,即最终落点。
|
|
169
|
+
both 让动画结束后停在 to,摘掉 --intro 时 inline left 恰好同值,不会跳。 */
|
|
170
|
+
@keyframes balance-ptr-in{from{left:3px}to{left:var(--balance-ptr-to,3px)}}
|
|
145
171
|
/* ── 侧栏入口的红点 + 未读高亮 ─────────────────────────────────────────── */
|
|
146
172
|
.balance-panel-icon{position:relative;display:inline-flex;align-items:center;justify-content:center}
|
|
147
173
|
/* 未读时图标本身染成品牌色,比周围灰图标更跳 */
|
|
@@ -1212,7 +1238,11 @@ const CN_CALENDAR_SOURCES = [
|
|
|
1212
1238
|
}
|
|
1213
1239
|
|
|
1214
1240
|
// ─── 今日消耗:随余额一起刷新(服务端 60s 缓存,只扫今天动过的会话) ──
|
|
1215
|
-
|
|
1241
|
+
// tried = 至少问过一次(不论成功失败)。用来区分「还在等」和「确实没有」:
|
|
1242
|
+
// 等待期间先摆一个不可见占位,让 meta 行高度从一开始就是最终高度 ——
|
|
1243
|
+
// 否则「今日 ¥x」这个 flex-basis:100% 的元素一插入,整行就从 1 行变 2 行,
|
|
1244
|
+
// 侧栏底部整体被顶下 14px,冷启动繁忙时看着就是「今日余额弹出来那下卡了」。
|
|
1245
|
+
const todayCache = { value: null, inFlight: null, at: 0, tried: false };
|
|
1216
1246
|
const todayListeners = new Set();
|
|
1217
1247
|
function notifyToday() {
|
|
1218
1248
|
for (const listener of todayListeners) listener();
|
|
@@ -1235,6 +1265,12 @@ const CN_CALENDAR_SOURCES = [
|
|
|
1235
1265
|
// 静默:今日消耗拿不到就不显示,不影响余额
|
|
1236
1266
|
} finally {
|
|
1237
1267
|
todayCache.inFlight = null;
|
|
1268
|
+
// 只在「问过但没拿到」时才需要通知:成功路径上面已经 notify 过了。
|
|
1269
|
+
// 这样失败时会撤掉占位,不会永久留一条空行。
|
|
1270
|
+
if (!todayCache.tried) {
|
|
1271
|
+
todayCache.tried = true;
|
|
1272
|
+
if (todayCache.value === null) notifyToday();
|
|
1273
|
+
}
|
|
1238
1274
|
}
|
|
1239
1275
|
return todayCache.value;
|
|
1240
1276
|
})();
|
|
@@ -1839,6 +1875,142 @@ const CN_CALENDAR_SOURCES = [
|
|
|
1839
1875
|
}
|
|
1840
1876
|
|
|
1841
1877
|
// ─── 余额显示:单击统计,双击 DeepSeek 用量页,右键刷新 ─────────────
|
|
1878
|
+
// 指针入场动画:从刻度 0 滑到当前时刻,只在页面生命周期的第一次挂载播一次。
|
|
1879
|
+
// ⚠️ **不要用「模块加载时刻 + 时间窗口」判断**(试过,不行):client 模块在页面启动早期就加载了,
|
|
1880
|
+
// 而侧栏组件要等首屏渲染 + 会话恢复完才挂载,中间经常超过好几秒,于是动画被判定成"过期"、
|
|
1881
|
+
// 指针直接落在终点 —— 用户看到的就是「当前不是从 0 开始的」(2026-10-30 第四次现场反馈)。
|
|
1882
|
+
// 正确做法:用「是否已经播完过一次」的标记,播完才置位。挂载被中断 / StrictMode 双跑都能补播。
|
|
1883
|
+
// POINTER_INTRO_MAX_AGE_MS 只是兜底上限,防止挂载后很久才展开侧栏又莫名滑一次。
|
|
1884
|
+
const POINTER_INTRO_MAX_AGE_MS = 30000;
|
|
1885
|
+
const PLUGIN_LOADED_AT = Date.now();
|
|
1886
|
+
let pointerIntroDone = false;
|
|
1887
|
+
|
|
1888
|
+
// 时间轴指针:一根 2px 竖条,位置 = 此刻在当天的百分比。
|
|
1889
|
+
//
|
|
1890
|
+
// ⚠️ 四件事必须守住:
|
|
1891
|
+
// 1) 位置由 **React state 渲染**(inline style),不是 ref 里手写 style.left ——
|
|
1892
|
+
// 在 DSH 里 ref 拿不到 DOM,指针会永远停在 0%(2026-10-30 两次现场反馈)。
|
|
1893
|
+
// 2) **不要用 requestAnimationFrame**:DSH 渲染进程里它会被窗口可见性挂起。
|
|
1894
|
+
// 3) 它必须**独立成一个组件**:补间每拍都 setState,挂在 BalanceFooter 里会带着
|
|
1895
|
+
// 余额行、时间轴、胶囊全部重渲染 —— 一次入场动画要重渲上百次整块 footer,
|
|
1896
|
+
// 桌面版启动首屏本来就忙,那就是用户看到的「掉帧 / 初始化很卡」。
|
|
1897
|
+
// 4) 稳态**不要**跑逐帧定时器:指针每分钟才动 0.069%(约 0.14px),按分钟对齐一次就够。
|
|
1898
|
+
// 旧版稳态也跑 16ms 定时器,等于页面开着就一直 62Hz 唤醒,纯浪费。
|
|
1899
|
+
function TimelinePointer() {
|
|
1900
|
+
// 入场动画 = 一条 CSS animation,React 全程只渲染 2 次(挂载一次、摘类一次)。
|
|
1901
|
+
//
|
|
1902
|
+
// ⚠️ 三条历史教训,都别再退回去:
|
|
1903
|
+
// 1) 逐帧 setState(16ms × 37 拍)会在重启 DSH 的首屏高峰上抢主线程 → 掉帧。
|
|
1904
|
+
// 2) 用 CSS transition + 延后一拍设终点,**有时候动画会被吞**:transition 的起点取
|
|
1905
|
+
// 「上一次样式计算的值」,而首屏繁忙时浏览器可能还没为这个元素算过样式,
|
|
1906
|
+
// 那次变更就被当成首次赋值,直接跳到终点、一帧都不播(2026-10-30 反馈「有时候重启时指针动画会吞掉」)。
|
|
1907
|
+
// 3) 所以现在用 animation:keyframes 里有**显式的 from/to**,不依赖上一次计算值。
|
|
1908
|
+
// 元素一插入就带着动画,opacity/left 从 3px 补到终点,浏览器时间线怎么排都不会漏播;
|
|
1909
|
+
// 首屏忙时它只会晚一点开始,但一定播完整。
|
|
1910
|
+
const [view, setView] = react.useState(() => {
|
|
1911
|
+
const expired = Date.now() - PLUGIN_LOADED_AT > POINTER_INTRO_MAX_AGE_MS;
|
|
1912
|
+
return { pct: pointerPercent(Date.now()), intro: !pointerIntroDone && !expired };
|
|
1913
|
+
});
|
|
1914
|
+
react.useEffect(() => {
|
|
1915
|
+
const expired = Date.now() - PLUGIN_LOADED_AT > POINTER_INTRO_MAX_AGE_MS;
|
|
1916
|
+
const intro = !pointerIntroDone && !expired;
|
|
1917
|
+
// 与刷新白条(CSS 里 balance-wipe-head 的 .58s)同一个时长、同一条曲线,
|
|
1918
|
+
// 两者在同一次渲染里插入,动画时钟同起同落。
|
|
1919
|
+
const INTRO_MS = 580;
|
|
1920
|
+
const IDLE_MS = 60000;
|
|
1921
|
+
let introTimer = 0;
|
|
1922
|
+
let idleTimer = 0;
|
|
1923
|
+
const idleIn = (now) => IDLE_MS - (now % IDLE_MS) + 30;
|
|
1924
|
+
const scheduleIdle = () => {
|
|
1925
|
+
idleTimer = setTimeout(tick, idleIn(Date.now()));
|
|
1926
|
+
};
|
|
1927
|
+
function tick() {
|
|
1928
|
+
// 稳态:不带动画直接落位(每分钟才动 0.069%,跳过去看不出来),
|
|
1929
|
+
// 下一拍对齐到下一个整分钟。跨零点也走这里,不会反向滑回去。
|
|
1930
|
+
setView({ pct: pointerPercent(Date.now()), intro: false });
|
|
1931
|
+
scheduleIdle();
|
|
1932
|
+
}
|
|
1933
|
+
if (intro) {
|
|
1934
|
+
introTimer = setTimeout(() => {
|
|
1935
|
+
// 到站:置位标记(之后的重挂载不再重播),并摘掉动画类。
|
|
1936
|
+
// animation 是 both,摘类前后都在终点,不会跳。
|
|
1937
|
+
pointerIntroDone = true;
|
|
1938
|
+
setView((v) => ({ pct: v.pct, intro: false }));
|
|
1939
|
+
scheduleIdle();
|
|
1940
|
+
}, INTRO_MS + 40);
|
|
1941
|
+
} else {
|
|
1942
|
+
scheduleIdle();
|
|
1943
|
+
}
|
|
1944
|
+
return () => {
|
|
1945
|
+
clearTimeout(introTimer);
|
|
1946
|
+
clearTimeout(idleTimer);
|
|
1947
|
+
};
|
|
1948
|
+
// 依赖故意为空:只在挂载时读一次初值,之后由 CSS animation + 分钟对齐自己跑。
|
|
1949
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1950
|
+
}, []);
|
|
1951
|
+
// 刻度两端各留 3px 余量:指针 2px 宽、margin-left 让它以 left 为中心线,直接贴 0%/100%
|
|
1952
|
+
// 会有一半被父级 overflow 裁掉,看着就像指针跑到轨道外面去了。
|
|
1953
|
+
// 把 [0,100] 线性压进 [3px, 100% - 3px]:中心线 = inset + (p/100) × (宽 - 2 × inset),
|
|
1954
|
+
// 展开成 calc 就是「p% + inset px - (p/100) × 2 × inset px」。
|
|
1955
|
+
const pct = view.pct;
|
|
1956
|
+
const inset = 3;
|
|
1957
|
+
const shift = inset - (pct / 100) * 2 * inset;
|
|
1958
|
+
const pos = "calc(" + pct.toFixed(4) + "% + " + shift.toFixed(2) + "px)";
|
|
1959
|
+
return jsx.jsx("span", {
|
|
1960
|
+
className: view.intro ? "balance-footer__timeline-pointer balance-footer__timeline-pointer--intro" : "balance-footer__timeline-pointer",
|
|
1961
|
+
// left 给稳态用;--balance-ptr-to 是 keyframes 的终点(两者取值相同)。
|
|
1962
|
+
style: { left: pos, "--balance-ptr-to": pos },
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// 色带分布变化时的交叉淡入(2026-10-30 加,用户「如果第二天的时段分布不一样 时刻条里的颜色分布我需要有渐变动画」)。
|
|
1967
|
+
//
|
|
1968
|
+
// 色带是一条 linear-gradient,而 background-image 在 CSS 里 **不可插值** —— 直接换 gradient
|
|
1969
|
+
// 永远是硬切:周五 → 周六(整条从「红绿相间」变成「通体绿」)会啪一下换掉,很扎眼。
|
|
1970
|
+
// 所以做两层交叉淡入:新的那条从 opacity 0 淡到本该有的不透明度,盖住旧的那条,
|
|
1971
|
+
// 播完把旧层摘掉。视觉上就是颜色分布自己慢慢挪过去。
|
|
1972
|
+
//
|
|
1973
|
+
// 触发时机只有两个,都不会天天发生:
|
|
1974
|
+
// 1) 跨零点 —— 第二天的分布不同(工作日 ↔ 周末、节前 ↔ 假期);
|
|
1975
|
+
// 2) 节假日表刚同步完,把当天的口径纠正了。
|
|
1976
|
+
// 没有定时器:只跟着传入的 gradient 字符串变化走,一天最多一次。
|
|
1977
|
+
//
|
|
1978
|
+
// 首挂不播(seq === 0 时不挂动画类)—— 色带不该有自己的入场动画,那会让首屏又多一份动效。
|
|
1979
|
+
const BAND_FADE_MS = 620;
|
|
1980
|
+
function TimelineBand(props) {
|
|
1981
|
+
const gradient = props.gradient;
|
|
1982
|
+
const [view, setView] = react.useState(() => ({ now: gradient, prev: null, seq: 0 }));
|
|
1983
|
+
// 分布变了:把当前这条降级成 prev(留在下面),新的那条顶上来并 +1 换 key。
|
|
1984
|
+
// key 变了才是新元素,CSS animation 才会重新播。
|
|
1985
|
+
react.useEffect(() => {
|
|
1986
|
+
setView((v) => (v.now === gradient ? v : { now: gradient, prev: v.now, seq: v.seq + 1 }));
|
|
1987
|
+
}, [gradient]);
|
|
1988
|
+
// 淡入播完再摘掉旧层,防止 prev 一天天堆下去。
|
|
1989
|
+
react.useEffect(() => {
|
|
1990
|
+
if (!view.prev) return;
|
|
1991
|
+
const timer = setTimeout(() => {
|
|
1992
|
+
setView((v) => (v.prev ? { now: v.now, prev: null, seq: v.seq } : v));
|
|
1993
|
+
}, BAND_FADE_MS + 80);
|
|
1994
|
+
return () => clearTimeout(timer);
|
|
1995
|
+
}, [view.prev, view.seq]);
|
|
1996
|
+
// seq === 0 是首挂,不带动画类;之后每一层都带,靠 key 变化重新起播。
|
|
1997
|
+
const fadeCls = view.seq > 0 ? " balance-footer__timeline-fade" : "";
|
|
1998
|
+
// --balance-band-to 是 keyframes 的终点 opacity:色带本体 1、泛光层保持原本的 .45,
|
|
1999
|
+
// 否则泛光会被动画拉成实心(它静态就是半透明的)。
|
|
2000
|
+
const layer = (cls, bg, to, key) => jsx.jsx("span", {
|
|
2001
|
+
className: cls + fadeCls,
|
|
2002
|
+
style: { background: bg, "--balance-band-to": to },
|
|
2003
|
+
}, key);
|
|
2004
|
+
const layers = [];
|
|
2005
|
+
if (view.prev) {
|
|
2006
|
+
layers.push(jsx.jsx("span", { className: "balance-footer__timeline-glow", style: { background: view.prev } }, "pg"));
|
|
2007
|
+
layers.push(jsx.jsx("span", { className: "balance-footer__timeline-band", style: { background: view.prev } }, "pb"));
|
|
2008
|
+
}
|
|
2009
|
+
layers.push(layer("balance-footer__timeline-glow", view.now, "0.45", "g" + view.seq));
|
|
2010
|
+
layers.push(layer("balance-footer__timeline-band", view.now, "1", "b" + view.seq));
|
|
2011
|
+
return jsx.jsx("span", { className: "balance-footer__timeline-clip", children: layers });
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1842
2014
|
function BalanceFooter({ wide }) {
|
|
1843
2015
|
const state = useBalance();
|
|
1844
2016
|
const settings = useSettings();
|
|
@@ -1958,23 +2130,8 @@ const CN_CALENDAR_SOURCES = [
|
|
|
1958
2130
|
}
|
|
1959
2131
|
};
|
|
1960
2132
|
}, []);
|
|
1961
|
-
//
|
|
1962
|
-
//
|
|
1963
|
-
// · 按分钟 tick + 长过渡 —— 静止 59 秒再窜一下,且连续性押在浏览器动画时钟上,
|
|
1964
|
-
// 后台标签页会把过渡暂停,切回来时直接落到终点,那就是用户看到的「瞬移」;
|
|
1965
|
-
// · 每 500ms 重算一次 —— 相邻两次只差 0.0006%(0.0012px),视觉上就是匀速滑,
|
|
1966
|
-
// 不依赖过渡调度,也不会因为后台暂停而攒成一跳。
|
|
1967
|
-
// 只有 2px 的元素改 left,代价可忽略;不碰 state 就不触发组件重渲染。
|
|
1968
|
-
const pointerRef = react.useRef(null);
|
|
1969
|
-
react.useEffect(() => {
|
|
1970
|
-
const paint = () => {
|
|
1971
|
-
const el = pointerRef.current;
|
|
1972
|
-
if (el) el.style.left = pointerPercent(Date.now()).toFixed(4) + "%";
|
|
1973
|
-
};
|
|
1974
|
-
paint();
|
|
1975
|
-
const timer = setInterval(paint, 500);
|
|
1976
|
-
return () => clearInterval(timer);
|
|
1977
|
-
}, []);
|
|
2133
|
+
// 指针位置见模块作用域的 TimelinePointer —— 刻意拆成独立组件:
|
|
2134
|
+
// 补间只重渲染那一根 2px 竖条,不会带着整个余额行一起重渲染。
|
|
1978
2135
|
const clickTimer = react.useRef(null);
|
|
1979
2136
|
const closeTimer = react.useRef(null);
|
|
1980
2137
|
const anchorRef = react.useRef(null);
|
|
@@ -2237,13 +2394,21 @@ const CN_CALENDAR_SOURCES = [
|
|
|
2237
2394
|
];
|
|
2238
2395
|
if (span !== null) tail.push(metaItem("剩 " + formatRemain(span.remainMs), "", "remain"));
|
|
2239
2396
|
const metaChildren = [];
|
|
2240
|
-
|
|
2397
|
+
// 「今日 ¥x」独占一行(CSS 里 flex-basis:100%)。数据没到之前先摆一个不可见占位,
|
|
2398
|
+
// 让这一行的高度从一开始就是最终高度 —— 条件渲染会让整块从 1 行变 2 行,
|
|
2399
|
+
// 侧栏底部(含设置按钮)整体被顶下 14px,那一下重排就是「今日余额弹出来时卡住」。
|
|
2400
|
+
// 占位只在「还在等」时出现;确认拿不到(tried 且为 null)就整项不渲染,不留空行。
|
|
2401
|
+
const todayCost = today !== null && Number.isFinite(Number(today.cost)) ? Number(today.cost) : null;
|
|
2402
|
+
const todayPending = todayCost === null && !todayCache.tried;
|
|
2403
|
+
if (todayCost !== null || todayPending) {
|
|
2241
2404
|
metaChildren.push(
|
|
2242
2405
|
jsx.jsx(
|
|
2243
2406
|
"span",
|
|
2244
2407
|
{
|
|
2245
|
-
className:
|
|
2246
|
-
|
|
2408
|
+
className:
|
|
2409
|
+
"balance-footer__timeline-meta-item balance-footer__timeline-meta-item--today" +
|
|
2410
|
+
(todayCost === null ? " balance-footer__timeline-meta-item--pending" : " balance-footer__timeline-meta-item--in"),
|
|
2411
|
+
children: todayCost === null ? "今日 ¥--" : "今日 ¥" + todayCost.toFixed(2),
|
|
2247
2412
|
},
|
|
2248
2413
|
"today",
|
|
2249
2414
|
),
|
|
@@ -2270,29 +2435,12 @@ const CN_CALENDAR_SOURCES = [
|
|
|
2270
2435
|
jsx.jsxs("span", {
|
|
2271
2436
|
className: "balance-footer__timeline-track balance-footer__timeline-track--" + period,
|
|
2272
2437
|
children: [
|
|
2273
|
-
// 裁剪层让四周各留 1px
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
className: "balance-footer__timeline-glow",
|
|
2280
|
-
style: { background: timelineBand.gradient },
|
|
2281
|
-
}),
|
|
2282
|
-
// 色带本体:纯色块,不带任何文字
|
|
2283
|
-
jsx.jsx("span", {
|
|
2284
|
-
className: "balance-footer__timeline-band",
|
|
2285
|
-
style: { background: timelineBand.gradient },
|
|
2286
|
-
}),
|
|
2287
|
-
],
|
|
2288
|
-
}),
|
|
2289
|
-
// 指针:位置 = 此刻在当天的比例,随时间从左走到右
|
|
2290
|
-
jsx.jsx("span", {
|
|
2291
|
-
className: "balance-footer__timeline-pointer",
|
|
2292
|
-
ref: pointerRef,
|
|
2293
|
-
// 首帧先摆到位(rAF 还没起来),之后由 rAF 每帧接管
|
|
2294
|
-
style: { left: pointerPercent(Date.now()).toFixed(3) + "%" },
|
|
2295
|
-
}),
|
|
2438
|
+
// 裁剪层让四周各留 1px,颜色不贴边框。
|
|
2439
|
+
// 里面是泛光 + 色带两层,换日时由 TimelineBand 做交叉淡入(gradient 不可插值)。
|
|
2440
|
+
jsx.jsx(TimelineBand, { gradient: timelineBand.gradient }),
|
|
2441
|
+
// 指针:位置 = 此刻在当天的比例,随时间从左走到右。
|
|
2442
|
+
// 指针是独立组件,自己管 state 和补间,不带着余额行重渲染。
|
|
2443
|
+
jsx.jsx(TimelinePointer, {}),
|
|
2296
2444
|
],
|
|
2297
2445
|
}),
|
|
2298
2446
|
],
|
|
@@ -2498,6 +2646,11 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
2498
2646
|
];
|
|
2499
2647
|
|
|
2500
2648
|
// ── 点击展开的明细浮层(结构与 DSH 原生「会话统计」一致:标题 + 分隔线 + dl 两列网格)──
|
|
2649
|
+
// 图片:官方把图片按尺寸折成 token 并入**输入**(没有单独的图片费用)。
|
|
2650
|
+
// 服务端返回的 usage 不做图片级拆分,所以 token 数用官网「图片 Token 计算器」同一算法估算(标「约」)。
|
|
2651
|
+
// ⚠️ 这部分金额**已经在**上面的输入里,这里只做说明,绝不能再加一笔(会翻倍)。
|
|
2652
|
+
const imageCount = Number(split?.imageCount ?? 0) || 0;
|
|
2653
|
+
const imageTokens = Number(split?.imageTokens ?? 0) || 0;
|
|
2501
2654
|
const usageDetails = [
|
|
2502
2655
|
["缓存命中", cacheHitPercent + "%"],
|
|
2503
2656
|
["未缓存输入", formatTokens(uncachedInput) + " tok"],
|
|
@@ -2505,6 +2658,12 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
2505
2658
|
];
|
|
2506
2659
|
if (cacheWrite > 0) usageDetails.push(["缓存写入", formatTokens(cacheWrite) + " tok"]);
|
|
2507
2660
|
usageDetails.push(["输出", formatTokens(output) + " tok"]);
|
|
2661
|
+
if (imageCount > 0) {
|
|
2662
|
+
usageDetails.push([
|
|
2663
|
+
"图片",
|
|
2664
|
+
imageCount + " 张 · 约 " + formatTokens(imageTokens) + " tok · 已计入输入",
|
|
2665
|
+
]);
|
|
2666
|
+
}
|
|
2508
2667
|
|
|
2509
2668
|
const costDetails = [
|
|
2510
2669
|
["新增 tokens", formatTokens(newTokens) + " tok"],
|
|
@@ -3299,6 +3458,38 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
3299
3458
|
],
|
|
3300
3459
|
}),
|
|
3301
3460
|
|
|
3461
|
+
// 节假日同步紧跟在「版本与更新」下面:两者都是「后台自动补一次 + 可手动触发」,
|
|
3462
|
+
// 放一起比塞在「计费」末尾好找。数据本身仍然决定算钱口径,只是入口挪了位置。
|
|
3463
|
+
group("calendar", "节假日数据", [
|
|
3464
|
+
row(
|
|
3465
|
+
"calendar",
|
|
3466
|
+
"同步状态",
|
|
3467
|
+
"法定节假日与调休从国务院公告同步。插件每 30 天自动同步一次,这里可以手动立即更新。",
|
|
3468
|
+
jsx.jsxs("div", {
|
|
3469
|
+
className: "balance-settings__calendar",
|
|
3470
|
+
children: [
|
|
3471
|
+
jsx.jsx("span", {
|
|
3472
|
+
className:
|
|
3473
|
+
"balance-settings__calendar-note" +
|
|
3474
|
+
(calendarSync.kind === "error"
|
|
3475
|
+
? " balance-settings__calendar-note--error"
|
|
3476
|
+
: calendar.fetchedAt > 0
|
|
3477
|
+
? ""
|
|
3478
|
+
: " balance-settings__calendar-note--warn"),
|
|
3479
|
+
children: calendarSummary,
|
|
3480
|
+
}),
|
|
3481
|
+
jsx.jsx("button", {
|
|
3482
|
+
type: "button",
|
|
3483
|
+
className: "balance-settings__btn",
|
|
3484
|
+
onClick: onSyncCalendar,
|
|
3485
|
+
disabled: calendarBusy,
|
|
3486
|
+
children: calendarBusy ? "同步中…" : "立即同步",
|
|
3487
|
+
}),
|
|
3488
|
+
],
|
|
3489
|
+
}),
|
|
3490
|
+
),
|
|
3491
|
+
]),
|
|
3492
|
+
|
|
3302
3493
|
group("balance", "余额", [
|
|
3303
3494
|
numberRow("refreshIntervalSec", "余额刷新间隔", "轮询间隔;对话产生新消耗时也会顺带刷新。", 5, 3600, "秒"),
|
|
3304
3495
|
numberRow("lowBalanceThreshold", "低余额警示阈值", "余额低于该值时金额转为琥珀色。", 0, 100000, "元"),
|
|
@@ -3362,36 +3553,6 @@ function UsageCostLine({ useProjection, sessionId }) {
|
|
|
3362
3553
|
"9-12, 14-18",
|
|
3363
3554
|
),
|
|
3364
3555
|
numberRow("peakMultiplier", "高峰时段倍率", "官方为 2(高峰价 = 空闲价 × 倍率)。", 1, 10, "倍"),
|
|
3365
|
-
// 法定节假日/调休直接决定某天算高峰还是空闲(算钱和标签都靠它),
|
|
3366
|
-
// 内置表只是离线兜底。给一个手动同步按钮:即使哪天没人维护插件,
|
|
3367
|
-
// 用户自己点一下就能把下一年的国务院公告补进来。
|
|
3368
|
-
row(
|
|
3369
|
-
"calendar",
|
|
3370
|
-
"节假日数据",
|
|
3371
|
-
"法定节假日与调休从国务院公告同步。插件每 30 天自动同步一次,这里可以手动立即更新。",
|
|
3372
|
-
jsx.jsxs("div", {
|
|
3373
|
-
className: "balance-settings__calendar",
|
|
3374
|
-
children: [
|
|
3375
|
-
jsx.jsx("span", {
|
|
3376
|
-
className:
|
|
3377
|
-
"balance-settings__calendar-note" +
|
|
3378
|
-
(calendarSync.kind === "error"
|
|
3379
|
-
? " balance-settings__calendar-note--error"
|
|
3380
|
-
: calendar.fetchedAt > 0
|
|
3381
|
-
? ""
|
|
3382
|
-
: " balance-settings__calendar-note--warn"),
|
|
3383
|
-
children: calendarSummary,
|
|
3384
|
-
}),
|
|
3385
|
-
jsx.jsx("button", {
|
|
3386
|
-
type: "button",
|
|
3387
|
-
className: "balance-settings__btn",
|
|
3388
|
-
onClick: onSyncCalendar,
|
|
3389
|
-
disabled: calendarBusy,
|
|
3390
|
-
children: calendarBusy ? "同步中…" : "立即同步",
|
|
3391
|
-
}),
|
|
3392
|
-
],
|
|
3393
|
-
}),
|
|
3394
|
-
),
|
|
3395
3556
|
]),
|
|
3396
3557
|
|
|
3397
3558
|
jsx.jsxs("div", {
|
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.
|
|
530
|
+
const PLUGIN_VERSION = "2.0.5";
|
|
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
|
|