dsh-whale-widget 0.2.2 → 0.2.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 CHANGED
@@ -1,6 +1,8 @@
1
1
  # DSH 小鲸鱼余额挂件(DeepSeek Balance Whale Widget)
2
2
 
3
- DeepSeek Harness(DSH)Web 界面右下角的常驻余额挂件:小鲸鱼气泡图 + DeepSeek API 余额 + 今日已用,每次打开界面自动启用。本项目是标准 DSH 插件包,可通过 `dsh plugin` 安装/卸载。
3
+ ![DSH 小鲸鱼余额挂件](assets/DSH2.png)
4
+
5
+ DeepSeek Harness(DSH)Web 界面右下角的常驻余额挂件:小鲸鱼气泡图 + DeepSeek API 余额 + 今日已用 + 每轮对话消耗统计,每次打开界面自动启用。本项目是标准 DSH 插件包,可通过 `dsh plugin` 安装/卸载。
4
6
 
5
7
  ## 特性
6
8
 
@@ -9,12 +11,15 @@ DeepSeek Harness(DSH)Web 界面右下角的常驻余额挂件:小鲸鱼气
9
11
  - 📊 **今日已用**:两种模式任选(见下),显示今日消耗金额
10
12
  - **小鲸鱼记账(推荐,免令牌)**:不需要任何会话令牌,鲸鱼娘每次观测余额后用余额差值自动记账(`.dshw-usage.json`,跨天自动归零归档)
11
13
  - **实时·令牌**:填入平台会话令牌后直接调用平台用量接口,按**峰谷定价**(空闲 9:00–12:00 与 14:00–18:00 之外 / 高峰 9–12 与 14–18 点)实时换算今日已用
14
+ - 💬 **每轮对话消耗统计**:监听本机会话事件,每轮对话结束后弹出本轮消耗金额(精确 usage,非估算)
15
+ - 菜单可开关「每轮对话后自动显示消耗金额」;「自动关闭时间」可自定义秒数(填 0 表示不自动关闭)
16
+ - 消耗金额泡泡显示期间,余额变动不弹普通泡泡
12
17
  - 🖱️ **拖拽 + 四边四分之一吸附**(左/右/上/下,角落可组合)
13
18
  - 🔄 左吸附时整体**水平镜像翻转**(文字同步反向、带动画)
14
19
  - 🧸 **按压 Q 弹**玩偶效果(按压时底部坐标不变)
15
- - 🎚️ **汉堡菜单**(悬停鲸鱼右上角出现):大小滑块(0.6–2.5 倍,尺寸记忆)、音效切换(小黄鸭 / 音效1)、音量调节、用量模式选择
20
+ - 🎚️ **汉堡菜单**(悬停鲸鱼右上角出现):大小滑块(0.6–2.5 倍)、音效切换(小黄鸭 / 音效1)、音量调节、用量模式、峰谷提示文案(默认 / 梁文峰谷 / !?强强?!)、气泡开关、每轮消耗开关与自动关闭时间
16
21
  - 🔊 **音效**:按压/松手音效(可选包内 mp3,缺失时静默降级)
17
- - 💬 **随机台词**:点击气泡切换随机台词段(6 组加权随机,含峰谷提示/今日已用/卖萌吐槽),再点一次关闭;气泡总显示 5 秒自动收起
22
+ - 💬 **随机台词**:点击气泡切换随机台词段(加权随机,含峰谷提示/今日已用/gif 动图/卖萌吐槽),再点一次关闭;气泡总显示 5 秒自动收起
18
23
  - 📐 随浏览器窗口自动缩放;文字位置/字号与图片联动
19
24
 
20
25
  ## 目录结构
@@ -27,7 +32,10 @@ dsh-whale-widget/
27
32
  ├── lib/
28
33
  │ └── index.js # 宿主侧插件本体
29
34
  ├── assets/
35
+ │ ├── DSH2.png # README 顶部展示图
30
36
  │ ├── DSniang1.png # 小鲸鱼本体(cut-out,气泡由代码绘制)
37
+ │ ├── DSniang02.png # 备用整图(兼容旧版手动安装路径)
38
+ │ ├── rua.gif # 随机台词 gif(可选)
31
39
  │ ├── Ya1.mp3 / Ya2.mp3 # 小黄鸭音效(可选)
32
40
  │ └── D1.mp3 / D2.mp3 # 音效1(可选)
33
41
  └── whale-widget-prompt.md # 完整规格/维护提示词
@@ -69,6 +77,12 @@ npm publish
69
77
  dsh plugin --profile web add dsh-whale-widget
70
78
  ```
71
79
 
80
+ ### 关于令牌(安装后必读)
81
+
82
+ > **默认不需要任何令牌。** 安装后只需配置 `DEEPSEEK_API_KEY`(拉取余额必需),「今日已用」会自动使用默认的**小鲸鱼记账**模式(余额差值本地记账),开箱即用。
83
+ >
84
+ > 「实时·令牌」模式用到的 `DEEPSEEK_PLATFORM_TOKEN`(DeepSeek 平台网页会话令牌)是**可选的**,仅在你想要更精确的实时用量换算时才需要配置。获取方式见下方「用量模式使用教程」。
85
+
72
86
  ## 卸载
73
87
 
74
88
  ```powershell
@@ -104,11 +118,48 @@ Remove-Item "$web\DSniang02.png" -ErrorAction SilentlyContinue
104
118
 
105
119
  清理后再执行上面的安装命令。
106
120
 
107
- ## 凭据与用量模式
121
+ ## 用量模式使用教程
122
+
123
+ ### 必需的凭据
124
+
125
+ - **`DEEPSEEK_API_KEY`**(必需):DeepSeek API 密钥,用于拉取余额(`api.deepseek.com/user/balance`)。在 DSH 凭据服务中配置即可(`dsh` 的凭据管理界面 / `.dsh/.credentials.yaml`)。
126
+
127
+ ### 两种用量模式
128
+
129
+ 挂件的「今日已用」有两种模式,在**菜单 → 用量**中选择:
130
+
131
+ **① 小鲸鱼记账(推荐,默认)—— 完全不需要额外配置**
132
+
133
+ 鲸鱼娘自己用**余额差值**记账:每次观测到余额下降就把差值累加到当天用量,跨天自动归零归档(保留 30 天)。只要配好了 `DEEPSEEK_API_KEY` 就能用,**开箱即用**。
134
+
135
+ - 账本文件:`$DSH_HOME/.dshw-usage.json`(自动生成)
136
+ - 优点:零配置、免令牌
137
+ - 说明:依赖「观测到的余额下降」累计,若 DSH 关闭期间有消耗会漏记;要精确请用令牌模式
138
+
139
+ **② 实时·令牌(可选)—— 需要 `DEEPSEEK_PLATFORM_TOKEN`**
140
+
141
+ 鲸鱼娘直接调用 DeepSeek 平台用量接口,按**峰谷定价**实时换算今日已用,**精确到每小时的 token 用量**。
142
+
143
+ **令牌在哪获取:**
144
+ 1. 浏览器打开并登录 **https://platform.deepseek.com**
145
+ 2. 按 **F12** 打开开发者工具 → 切到 **Network(网络)** 标签
146
+ 3. 在平台页面点击「用量」或刷新页面,找到名为 `usage/by_api_key/amount` 的请求
147
+ 4. 点开该请求 → **Request Headers(请求标头)** → 复制 `Authorization` 的值(形如 `Bearer eyJ...` 的一长串)
148
+ 5. 把整段值(含 `Bearer` 前缀或只要后面的 token 部分均可)配置为 DSH 凭据 `DEEPSEEK_PLATFORM_TOKEN`:
149
+ ```powershell
150
+ # 在 DSH 凭据服务中设置,例如编辑 $env:USERPROFILE\.dsh\.credentials.yaml
151
+ # DEEPSEEK_PLATFORM_TOKEN: <你复制的令牌>
152
+ ```
153
+ 6. 重启 `dsh web`,在**菜单 → 用量**里选择「实时·令牌」
154
+
155
+ **说明:**
156
+ - ⚠️ **令牌非必需**:不配置时挂件自动使用默认的「小鲸鱼记账」模式,功能不受影响
157
+ - 该令牌是 DeepSeek **平台网页的会话令牌**(不是 `sk-` 开头的 API key),仅在登录平台网页时有效;重新登录后可能需要重新获取
158
+ - 接口不返回金额,只返回 token 分桶,挂件会按内置峰谷定价表换算成金额;定价表在 `lib/index.js` 顶部 `PRICING` 常量,DeepSeek 调价时可自行修改
159
+
160
+ ### 每轮对话消耗(无需任何凭据)
108
161
 
109
- - **余额**:需要 `DEEPSEEK_API_KEY`(在 DSH 凭据服务中配置),用于 `api.deepseek.com/user/balance`
110
- - **实时·令牌模式**:需要 `DEEPSEEK_PLATFORM_TOKEN`(平台会话令牌,从浏览器 DevTools 的用量请求 `Authorization` 头获取),用于 `platform.deepseek.com/api/v0/usage/by_api_key/amount`
111
- - **小鲸鱼记账模式(默认)**:不需要平台令牌,鲸鱼娘用余额差值自动记账,跨天自动归档
162
+ 「每轮对话消耗统计」直接监听 DSH 本机会话事件,按模型真实 usage 换算金额(与今日已用同一套峰谷定价表),**不需要** `DEEPSEEK_PLATFORM_TOKEN`。
112
163
 
113
164
  ## 验证
114
165
 
@@ -118,11 +169,13 @@ dsh --profile web --dump-config | Select-String -Pattern "whale"
118
169
  curl http://127.0.0.1:3080/dsh-whale/image.png
119
170
  curl http://127.0.0.1:3080/dsh-whale/balance.json
120
171
  curl http://127.0.0.1:3080/dsh-whale/size.json
172
+ curl http://127.0.0.1:3080/dsh-whale/last-turn.json
121
173
  ```
122
174
 
123
175
  - `/dsh-whale/image.png` → 200 `image/png`
124
176
  - `/dsh-whale/balance.json` → 200,含 `{"ok":true,"totalBalance":...,"currency":"CNY","todayUsage":...}`
125
- - `/dsh-whale/size.json` → GET 返回 `{scale,sound,vol,soundSet,usageMode}`;PUT 写入
177
+ - `/dsh-whale/size.json` → GET 返回配置;PUT 写入
178
+ - `/dsh-whale/last-turn.json` → 200,含最近一轮对话消耗 `{seq, turn, amount, tokens}`
126
179
  - 浏览器 F5 后右下角出现挂件
127
180
 
128
181
  ## 常见问题
@@ -131,6 +184,7 @@ curl http://127.0.0.1:3080/dsh-whale/size.json
131
184
  - **图片不显示**:确认 `assets/DSniang1.png` 在插件包内,且没有把旧文件放在 profile 里占用了同名路由。
132
185
  - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 配置凭据。
133
186
  - **今日已用显示 --**:记账模式下需要先跑一次余额观测(60 秒内自动完成);令牌模式需要配置 `DEEPSEEK_PLATFORM_TOKEN`。
187
+ - **每轮消耗不显示**:确认菜单「每轮对话后自动显示消耗金额」已勾选;一轮对话必须完整结束(turn/end)才会结算。
134
188
  - **没有声音**:确认 `assets/*.mp3` 在包内;若不想带音效文件,静默降级为无声音。
135
189
  - **本地开发改了代码不生效**:使用 `link:` 安装时,修改源码后重启 `dsh web`(ESM 模块缓存);如果用已发布版本,需要 `npm publish` 新版本后 `dsh plugin --profile web update dsh-whale-widget`。
136
190
  - **自定义图片**:气泡由代码绘制(SVG),鲸鱼本体为 cut-out PNG,放在右下角 59.45%;换图需保证透明背景 cut-out,否则按 `whale-widget-prompt.md` 调整几何参数。
Binary file
Binary file
package/assets/rua.gif ADDED
Binary file
package/lib/index.js CHANGED
@@ -3,7 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
 
6
- // Package root: lib/index.js -> package root. This keeps the bundle relocatable
6
+ // Package root: lib/index.js -> package root. Keeps the bundle relocatable
7
7
  // when installed as a normal DSH npm plugin (node_modules or a local link).
8
8
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
9
 
@@ -11,11 +11,10 @@ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)),
11
11
  // be read-only or cleaned on update.
12
12
  const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
13
13
 
14
- // Resolve the whale image relative to the package first, so the bundle works
15
- // no matter where it is installed. Legacy absolute paths remain as harmless
16
- // fallbacks for older manual installs.
14
+ // Whale image: package-relative first, legacy absolute paths as fallback.
17
15
  const IMAGE_CANDIDATES = [
18
16
  path.join(PACKAGE_ROOT, 'assets', 'DSniang1.png'),
17
+ path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
19
18
  'D:/TestBox/deepseek/DSniang1.png',
20
19
  'D:/TestBox/deepseek/DSniang02.png',
21
20
  'D:/TestBox/deepseek/skin/DSniang02.png',
@@ -37,8 +36,8 @@ const USAGE_FILE_CANDIDATES = [
37
36
  'D:/TestBox/deepseek/skin/.dshw-usage.json',
38
37
  ]
39
38
 
40
- // Sound assets: package-relative first (optional — ship Ya1/Ya2/D1/D2.mp3 in
41
- // assets/ if you want sounds to work out of the box), legacy paths as fallback.
39
+ // Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/ for
40
+ // sounds out of the box), legacy paths as fallback.
42
41
  const SOUND_SETS = {
43
42
  duck: {
44
43
  press: [path.join(PACKAGE_ROOT, 'assets', 'Ya1.mp3'), 'D:/TestBox/deepseek/skin/Ya1.mp3'],
@@ -58,6 +57,11 @@ function soundSetFromUrl(url) {
58
57
  }
59
58
  const BALANCE_URL = 'https://api.deepseek.com/user/balance'
60
59
  const BALANCE_TTL_MS = 25000
60
+ const RUA_GIF_CANDIDATES = [
61
+ path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
62
+ 'D:/TestBox/deepseek/skin/rua.gif',
63
+ 'D:/TestBox/deepseek/rua.gif',
64
+ ]
61
65
  // DeepSeek CNY prices per million tokens: [空闲时段价, 高峰时段价].
62
66
  // 高峰时段:每日 9:00–12:00 和 14:00–18:00(北京时间)。Adjust here if DeepSeek changes pricing.
63
67
  const PEAK_HOURS = [
@@ -112,6 +116,7 @@ var FETCH_TIMEOUT_MS = 25000
112
116
  var BALANCE_URL = '/dsh-whale/balance.json'
113
117
  var SIZE_URL = '/dsh-whale/size.json'
114
118
  var IMG_URL = '/dsh-whale/image.png?v=2'
119
+ var GIF_URL = '/dsh-whale/rua.gif'
115
120
 
116
121
  var css = [
117
122
  '.dshwv-root{position:fixed;right:0;bottom:0;--dshw-scale:1;--dshw-base:clamp(122px,calc(min(250px,min(100vw,100vh) * 0.28) * var(--dshw-scale)),625px);width:var(--dshw-base);height:var(--dshw-base);pointer-events:none;user-select:none;-webkit-user-select:none;z-index:9999;font-family:inherit;transition:left .16s ease,top .16s ease,transform .3s ease}',
@@ -119,26 +124,29 @@ var css = [
119
124
  '.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
120
125
  '.dshwv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}',
121
126
  '.dshwv-img{position:absolute;right:0;bottom:0;width:59.45%;height:59.45%;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none}',
122
- '.dshwv-bubble{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1}',
127
+ '.dshwv-bubble{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1;--dshw-u:calc(var(--dshw-base) / 1026)}',
123
128
  '.dshwv-bubble svg{display:block;width:100%;height:100%;pointer-events:none}',
124
129
  '.dshwv-bubble svg path,.dshwv-bubble svg ellipse{pointer-events:none;cursor:pointer}',
125
130
  '.dshwv-bubble.dshwv-bubble-open svg path,.dshwv-bubble.dshwv-bubble-open svg ellipse{pointer-events:visiblePainted}',
126
131
  '.dshwv-bubble .dshwv-bshape,.dshwv-bubble .dshwv-b1,.dshwv-bubble .dshwv-b2{opacity:0;transform:scale(.7);transform-box:fill-box;transform-origin:50% 50%;transition:opacity .2s ease,transform .2s ease}',
127
132
  '.dshwv-bubble.dshwv-bubble-open .dshwv-bshape,.dshwv-bubble.dshwv-bubble-open .dshwv-b1,.dshwv-bubble.dshwv-bubble-open .dshwv-b2{opacity:1;transform:none}',
133
+ '.dshwv-gif{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);max-width:calc(var(--dshw-u) * 560);max-height:calc(var(--dshw-u) * 400);display:none;opacity:0;transition:opacity .2s ease;pointer-events:none;-webkit-user-drag:none;user-select:none;object-fit:contain}',
134
+ '.dshwv-root.dshwv-left .dshwv-gif{transform:translate(-50%,-50%) scaleX(-1)}',
135
+ '.dshwv-bubble.dshwv-bubble-open .dshwv-gif{opacity:1}',
128
136
  '.dshwv-bubble.dshwv-bubble-open .dshwv-b2{transition-delay:0s}',
129
137
  '.dshwv-bubble.dshwv-bubble-open .dshwv-b1{transition-delay:.13s}',
130
138
  '.dshwv-bubble.dshwv-bubble-open .dshwv-bshape{transition-delay:.26s}',
131
139
  '.dshwv-bubble .dshwv-bshape{transition-delay:.1s}',
132
140
  '.dshwv-bubble .dshwv-b1{transition-delay:.2s}',
133
141
  '.dshwv-bubble .dshwv-b2{transition-delay:.3s}',
134
- '.dshwv-text{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);text-align:center;color:#536ba9;line-height:1.15;white-space:nowrap;--dshw-u:calc(var(--dshw-base) / 1026);pointer-events:none;opacity:0;transition:opacity .16s ease,transform .3s ease}',
142
+ '.dshwv-text{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);text-align:center;color:#536ba9;line-height:1.15;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .16s ease,transform .3s ease}',
135
143
  '.dshwv-bubble.dshwv-bubble-open .dshwv-text{opacity:1;transition:opacity .16s ease .36s,transform .3s ease}',
136
144
  '.dshwv-root.dshwv-left .dshwv-text{transform:translate(-50%,-50%) scaleX(-1)}',
137
145
  '.dshwv-label{font-size:calc(var(--dshw-u) * 66);font-weight:600;letter-spacing:.06em}',
138
146
  '.dshwv-amount{font-size:calc(var(--dshw-u) * 128);font-weight:800;line-height:1.05}',
139
147
  '.dshwv-period{font-size:calc(var(--dshw-u) * 104);font-weight:800;line-height:1.05}',
140
148
  '.dshwv-wrap{white-space:normal;max-width:calc(var(--dshw-u) * 560);line-height:1.2}',
141
- '.dshwv-hint{font-size:calc(var(--dshw-u) * 56);color:#9fb0d9;letter-spacing:.02em;margin-top:calc(var(--dshw-u) * 9)}',
149
+ '.dshwv-hint{font-size:calc(var(--dshw-u) * 56);color:#9fb0d9;letter-spacing:.02em;margin-top:calc(var(--dshw-u) * 9);min-height:calc(var(--dshw-u) * 64);line-height:1.15}',
142
150
  '.dshwv-menu-btn{position:absolute;top:calc(40.55% + 4px);right:4px;width:26px;height:26px;border:none;border-radius:6px;background:rgba(32,49,112,.85);cursor:pointer;pointer-events:auto;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:0;z-index:2;opacity:0;transition:opacity .15s ease}',
143
151
  '.dshwv-menu-btn.dshwv-menu-btn-visible{opacity:1}',
144
152
  '.dshwv-menu-btn span{display:block;width:14px;height:2px;background:#fff;border-radius:1px}',
@@ -150,6 +158,8 @@ var css = [
150
158
  '.dshwv-number{width:46px;border:1px solid rgba(32,49,112,.4);border-radius:6px;padding:2px 4px;font-size:12px;color:#203170;background:#fff}',
151
159
  '.dshwv-sound{flex:1;border:1px solid rgba(32,49,112,.4);border-radius:6px;background:rgba(32,49,112,.08);color:#203170;font-size:12px;padding:3px 0;cursor:pointer}',
152
160
  '.dshwv-sound:hover{background:rgba(32,49,112,.16)}',
161
+ '.dshwv-check{width:16px;height:16px;accent-color:#203170;cursor:pointer;flex:0 0 auto}',
162
+ '.dshwv-menu-sep{height:1px;background:rgba(32,49,112,.25);margin:6px 0}',
153
163
  '.dshwv-volpct{width:36px;text-align:right;color:#203170;font-size:12px}'
154
164
  ].join('\\n')
155
165
 
@@ -224,6 +234,32 @@ usageSelect.className = 'dshwv-sound'
224
234
  usageSelect.appendChild(soundOpt('ledger', '小鲸鱼记账 (推荐)'))
225
235
  usageSelect.appendChild(soundOpt('token', '实时·令牌 (用法:去问dsh)'))
226
236
  usageSelect.addEventListener('change', function () { setUsageMode(usageSelect.value) })
237
+ var peakSelect = document.createElement('select')
238
+ peakSelect.className = 'dshwv-sound'
239
+ peakSelect.appendChild(soundOpt('default', '默认'))
240
+ peakSelect.appendChild(soundOpt('liangwen', '梁文峰谷'))
241
+ peakSelect.appendChild(soundOpt('qiangqiang', '!?强强?!'))
242
+ peakSelect.addEventListener('change', function () { setPeakMode(peakSelect.value) })
243
+ var bubbleToggle = document.createElement('input')
244
+ bubbleToggle.type = 'checkbox'
245
+ bubbleToggle.className = 'dshwv-check'
246
+ bubbleToggle.checked = true
247
+ bubbleToggle.title = '开启/关闭思考气泡'
248
+ bubbleToggle.addEventListener('change', function () { setBubbleOn(bubbleToggle.checked) })
249
+ var turnCostToggle = document.createElement('input')
250
+ turnCostToggle.type = 'checkbox'
251
+ turnCostToggle.className = 'dshwv-check'
252
+ turnCostToggle.checked = true
253
+ turnCostToggle.title = '每轮对话结束后自动显示本轮消耗金额'
254
+ turnCostToggle.addEventListener('change', function () { setTurnCostOn(turnCostToggle.checked) })
255
+ var turnCostCloseInput = document.createElement('input')
256
+ turnCostCloseInput.type = 'number'
257
+ turnCostCloseInput.min = '0'
258
+ turnCostCloseInput.step = '1'
259
+ turnCostCloseInput.className = 'dshwv-number'
260
+ turnCostCloseInput.value = '5'
261
+ turnCostCloseInput.title = '填 0 表示不自动关闭,需手动点击关闭'
262
+ turnCostCloseInput.addEventListener('change', function () { setTurnCostClose(turnCostCloseInput.value) })
227
263
  var row1 = menuRow()
228
264
  row1.appendChild(menuLabel('大小'))
229
265
  row1.appendChild(scaleInput)
@@ -249,10 +285,30 @@ row3.appendChild(volPct)
249
285
  var row4 = menuRow()
250
286
  row4.appendChild(menuLabel('用量'))
251
287
  row4.appendChild(usageSelect)
288
+ var row5 = menuRow()
289
+ row5.appendChild(menuLabel('峰谷'))
290
+ row5.appendChild(peakSelect)
291
+ var row6 = menuRow()
292
+ row6.appendChild(menuLabel('气泡'))
293
+ row6.appendChild(bubbleToggle)
294
+ var menuSep1 = document.createElement('div')
295
+ menuSep1.className = 'dshwv-menu-sep'
296
+ var row7 = menuRow()
297
+ row7.appendChild(menuLabel('每轮消耗'))
298
+ row7.appendChild(turnCostToggle)
299
+ var row8 = menuRow()
300
+ row8.appendChild(menuLabel('自动关闭时间'))
301
+ row8.appendChild(turnCostCloseInput)
302
+ row8.appendChild(menuLabel('秒'))
252
303
  menuBox.appendChild(row1)
253
304
  menuBox.appendChild(row2)
254
305
  menuBox.appendChild(row3)
255
306
  menuBox.appendChild(row4)
307
+ menuBox.appendChild(row5)
308
+ menuBox.appendChild(row6)
309
+ menuBox.appendChild(menuSep1)
310
+ menuBox.appendChild(row7)
311
+ menuBox.appendChild(row8)
256
312
 
257
313
  var textBox = document.createElement('div')
258
314
  textBox.className = 'dshwv-text'
@@ -274,10 +330,21 @@ bubbleBox.innerHTML = '<svg viewBox="0 0 1026 700" preserveAspectRatio="xMidYMid
274
330
  '<ellipse class="dshwv-b1" cx="352" cy="561" rx="37.5" ry="26" fill="#FFFFFF" stroke="#203170" stroke-width="18"/>' +
275
331
  '<ellipse class="dshwv-b2" cx="442" cy="646" rx="24.5" ry="18" fill="#FFFFFF" stroke="#203170" stroke-width="18"/>' +
276
332
  '</svg>'
333
+ var gifEl = document.createElement('img')
334
+ gifEl.className = 'dshwv-gif'
335
+ gifEl.src = GIF_URL
336
+ gifEl.alt = ''
337
+ gifEl.draggable = false
338
+ bubbleBox.appendChild(gifEl)
277
339
  bubbleBox.appendChild(textBox)
278
340
  bubbleBox.addEventListener('click', function (e) {
279
341
  e.stopPropagation()
280
342
  if (!bubbleShown) return
343
+ if (costBubbleActive) {
344
+ // 消耗金额泡泡:点击关闭(确认)
345
+ hideCostBubble()
346
+ return
347
+ }
281
348
  if (bubbleRandomActive) {
282
349
  // 再次点击:关闭
283
350
  hideBubble()
@@ -333,18 +400,27 @@ function pickOne(arr) { return arr[Math.floor(Math.random() * arr.length)] }
333
400
  function singleCenter(style, text, color, wrap) { return [null, { t: text, s: style, c: color || '', w: !!wrap }, null] }
334
401
  function buildGroup1() {
335
402
  var peak = !!state.isPeak
403
+ var offText = '空闲时段'
404
+ var peakText = '高峰时段'
405
+ if (peakMode === 'liangwen') {
406
+ offText = '梁文谷'
407
+ peakText = '梁文峰'
408
+ } else if (peakMode === 'qiangqiang') {
409
+ offText = '!?谷谷?!'
410
+ peakText = '!?峰峰?!'
411
+ }
336
412
  return [
337
413
  { t: '当前时间段为:', s: 'A', c: '' },
338
- { t: peak ? '高峰时段' : '空闲时段', s: 'P', c: peak ? '#e0433f' : '#2fa24c' },
414
+ { t: peak ? peakText : offText, s: 'P', c: peak ? '#e0433f' : '#2fa24c' },
339
415
  { t: '今日已用 ' + fmt(state.todayUsage, state.currency), s: 'C', c: '' },
340
416
  ]
341
417
  }
342
418
  var RANDOM_GROUPS = [
343
- { w: 20, lines: buildGroup1 },
419
+ { w: 45, lines: buildGroup1 },
344
420
  { w: 7, lines: function () { return singleCenter('B', pickOne(['好模型... ↓', '好女孩...↓'])) } },
345
421
  { w: 7, lines: function () { return singleCenter('A', pickOne(['不知道用户有什么用,先赶走吧~', '我...我...我也要挣钱吗?', '我去吃饭啦,测完叫我', '压力一只蓝色大肥鱼?!', 'DeepSleep...', '坏了...用户彻底怒了!']), '', true) } },
422
+ { w: 10, lines: function () { return { gif: true } } },
346
423
  { w: 3, lines: function () { return singleCenter('A', pickOne(['你目录里的dsh是什么...大烧货吗...?', '恭喜你实现token自由!token全跑了!', '真当我是便宜货啊...']), '', true) } },
347
- { w: 1, lines: function () { return [{ t: '这个', s: 'A', c: '' }, { t: '凶', s: 'B', c: '' }, { t: '是什么意思呀...', s: 'A', c: '' }] } },
348
424
  { w: 1, lines: function () { return singleCenter('B', '哦鲸鲸... ') } },
349
425
  ]
350
426
  function pickRandomLines() {
@@ -358,6 +434,19 @@ function pickRandomLines() {
358
434
  return RANDOM_GROUPS[RANDOM_GROUPS.length - 1].lines()
359
435
  }
360
436
  function applyBubbleLines(lines) {
437
+ if (lines && lines.gif) {
438
+ // gif 台词组:只显示 gif,隐藏三行文字(display 必须显式覆盖 CSS 的 none)
439
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
440
+ gifEl.style.display = 'block'
441
+ gifEl.style.opacity = ''
442
+ labelEl.style.display = 'none'
443
+ amountEl.style.display = 'none'
444
+ hintEl.style.display = 'none'
445
+ return
446
+ }
447
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
448
+ gifEl.style.display = 'none'
449
+ gifEl.style.opacity = ''
361
450
  var els = [labelEl, amountEl, hintEl]
362
451
  for (var i = 0; i < 3; i++) {
363
452
  var el = els[i]
@@ -376,13 +465,16 @@ function applyBubbleLines(lines) {
376
465
  }
377
466
  var bubbleSwapTimer = null
378
467
  var hintFadeTimer = null
468
+ var gifFadeTimer = null
379
469
  var lastHintText = null
380
470
  function setHint(text) {
381
- // 「加载中…」→「今日已用」等提示行变化时做淡出淡入,其余直接替换
471
+ // 首次/恢复(lastHintText===null)时直接写文本,不做淡出淡入——否则
472
+ // 气泡打开或按压重开时会先淡出再淡入,造成「消失一下又出现」。
473
+ // 只有气泡打开期间的内容变化(加载中→今日已用)才走动画。
382
474
  if (text === lastHintText) return
475
+ var first = lastHintText === null
383
476
  lastHintText = text
384
- if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
385
- if (!bubbleShown) {
477
+ if (first || !bubbleShown) {
386
478
  hintEl.textContent = text
387
479
  return
388
480
  }
@@ -415,21 +507,30 @@ function swapBubbleContent(applyFn) {
415
507
  function restoreBubbleLines() {
416
508
  if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
417
509
  if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
510
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
418
511
  lastHintText = null
419
512
  textBox.style.transition = ''
420
513
  textBox.style.opacity = ''
514
+ gifEl.style.display = 'none'
515
+ gifEl.style.opacity = ''
421
516
  labelEl.style.display = ''
422
517
  labelEl.className = 'dshwv-label'
423
518
  labelEl.textContent = 'DeepSeek 余额'
424
519
  labelEl.style.color = ''
520
+ amountEl.style.display = ''
425
521
  amountEl.className = 'dshwv-amount'
426
522
  amountEl.style.color = ''
523
+ hintEl.style.display = ''
427
524
  hintEl.className = 'dshwv-hint'
428
525
  hintEl.style.color = ''
429
526
  render()
430
527
  }
431
528
  function showBubble() {
529
+ if (!bubbleOn) return
530
+ // 消耗金额泡泡显示期间,余额变动不再弹出普通泡泡
531
+ if (costBubbleActive) return
432
532
  if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
533
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
433
534
  bubbleShown = true
434
535
  bubbleRandomActive = false
435
536
  restoreBubbleLines()
@@ -448,7 +549,53 @@ function hideBubble() {
448
549
  bubbleRandomActive = false
449
550
  bubbleRandomLines = null
450
551
  bubbleShown = false
552
+ // 只销毁 gif 显示;三行文字保持现状让气泡自然淡出——不能在关闭瞬间
553
+ // 恢复成余额内容(否则随机台词界面会闪现余额)。文字恢复交给下次
554
+ // showBubble() 的 restoreBubbleLines()(那时气泡隐藏,恢复过程不可见)。
451
555
  bubbleBox.classList.remove('dshwv-bubble-open')
556
+ // gif 靠 CSS opacity 过渡淡出;display:none 会跳过过渡,须等淡出完成再隐藏
557
+ gifFadeTimer = setTimeout(function () {
558
+ gifFadeTimer = null
559
+ gifEl.style.display = 'none'
560
+ }, 240)
561
+ }
562
+
563
+ // —— 每轮对话消耗金额泡泡 ——
564
+ var costBubbleTimer = null
565
+ function showCostBubble(amount) {
566
+ if (!bubbleOn || !turnCostOn) return
567
+ if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
568
+ if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
569
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
570
+ costBubbleActive = true
571
+ bubbleRandomActive = false
572
+ bubbleShown = true
573
+ lastHintText = null
574
+ // 样式:第一行 A(标签),第二行 B(红色金额),居中两行
575
+ gifEl.style.display = 'none'
576
+ gifEl.style.opacity = ''
577
+ labelEl.style.display = ''
578
+ labelEl.className = 'dshwv-label'
579
+ labelEl.textContent = '上一轮对话消耗:'
580
+ labelEl.style.color = ''
581
+ amountEl.style.display = ''
582
+ amountEl.className = 'dshwv-amount'
583
+ amountEl.textContent = '¥ ' + (isFinite(amount) ? Number(amount).toFixed(2) : '--')
584
+ amountEl.style.color = '#e0433f'
585
+ hintEl.style.display = 'none'
586
+ hintEl.textContent = ''
587
+ hintEl.style.color = ''
588
+ textBox.style.transition = ''
589
+ textBox.style.opacity = ''
590
+ bubbleBox.classList.add('dshwv-bubble-open')
591
+ if (turnCostCloseMs > 0) {
592
+ costBubbleTimer = setTimeout(hideCostBubble, turnCostCloseMs)
593
+ }
594
+ }
595
+ function hideCostBubble() {
596
+ if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
597
+ costBubbleActive = false
598
+ hideBubble()
452
599
  }
453
600
 
454
601
  function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
@@ -464,6 +611,8 @@ function fmt(balance, currency) {
464
611
  return currency === 'CNY' ? '¥ ' + fixed : fixed + ' ' + currency
465
612
  }
466
613
  function animateAmount(from, to, currency, duration) {
614
+ // 消耗金额泡泡显示期间,余额数字滚动不触碰金额行
615
+ if (costBubbleActive) return
467
616
  if (animId) cancelAnimationFrame(animId)
468
617
  if (from === null || !isFinite(from)) from = to
469
618
  if (from === to) {
@@ -489,6 +638,8 @@ function animateAmount(from, to, currency, duration) {
489
638
  animId = requestAnimationFrame(step)
490
639
  }
491
640
  function render() {
641
+ // 消耗金额泡泡显示期间,余额渲染不覆盖其内容(金额行/标题行/提示行)
642
+ if (costBubbleActive) return
492
643
  var amount, hint
493
644
  if (state.status === 'error') {
494
645
  amount = shown !== null ? fmt(shown, state.currency) : '--'
@@ -610,9 +761,15 @@ var soundOn = true
610
761
  var soundVol = 0.9
611
762
  var soundSet = 'duck'
612
763
  var usageMode = 'ledger'
764
+ var peakMode = 'default'
765
+ var bubbleOn = true
766
+ var turnCostOn = true
767
+ var turnCostCloseMs = 5000
768
+ var lastCostSeq = 0
769
+ var costBubbleActive = false
613
770
  function saveConfig() {
614
771
  try {
615
- fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode }) })
772
+ fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode, peakMode: peakMode, bubbleOn: bubbleOn, turnCostOn: turnCostOn, turnCostCloseMs: turnCostCloseMs }) })
616
773
  } catch (err) {}
617
774
  }
618
775
  function setUsageMode(v) {
@@ -621,6 +778,29 @@ function setUsageMode(v) {
621
778
  saveConfig()
622
779
  refresh(false)
623
780
  }
781
+ function setPeakMode(v) {
782
+ peakMode = v === 'liangwen' || v === 'qiangqiang' ? v : 'default'
783
+ peakSelect.value = peakMode
784
+ saveConfig()
785
+ }
786
+ function setBubbleOn(v) {
787
+ bubbleOn = !!v
788
+ bubbleToggle.checked = bubbleOn
789
+ saveConfig()
790
+ if (!bubbleOn) hideBubble()
791
+ }
792
+ function setTurnCostOn(v) {
793
+ turnCostOn = !!v
794
+ turnCostToggle.checked = turnCostOn
795
+ saveConfig()
796
+ if (!turnCostOn) hideCostBubble()
797
+ }
798
+ function setTurnCostClose(v) {
799
+ var n = Math.max(0, Math.round(Number(v) || 0))
800
+ turnCostCloseMs = n * 1000
801
+ turnCostCloseInput.value = String(n)
802
+ saveConfig()
803
+ }
624
804
  function scaleToDisplay(s) {
625
805
  return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
626
806
  }
@@ -996,10 +1176,54 @@ fetch(SIZE_URL, { cache: 'no-store' })
996
1176
  usageMode = d.usageMode === 'token' ? 'token' : 'ledger'
997
1177
  usageSelect.value = usageMode
998
1178
  }
1179
+ if (d && typeof d.peakMode === 'string') {
1180
+ peakMode = d.peakMode === 'liangwen' || d.peakMode === 'qiangqiang' ? d.peakMode : 'default'
1181
+ peakSelect.value = peakMode
1182
+ }
1183
+ if (d && typeof d.bubbleOn === 'boolean') {
1184
+ bubbleOn = d.bubbleOn
1185
+ bubbleToggle.checked = bubbleOn
1186
+ }
1187
+ if (d && typeof d.turnCostOn === 'boolean') {
1188
+ turnCostOn = d.turnCostOn
1189
+ turnCostToggle.checked = turnCostOn
1190
+ }
1191
+ if (d && typeof d.turnCostCloseMs === 'number') {
1192
+ turnCostCloseMs = d.turnCostCloseMs > 0 ? d.turnCostCloseMs : 0
1193
+ turnCostCloseInput.value = String(Math.round(turnCostCloseMs / 1000))
1194
+ }
999
1195
  refresh(false)
1000
1196
  })
1001
1197
  .catch(function () { refresh(false) })
1002
1198
  setInterval(function () { refresh(false) }, REFRESH_MS)
1199
+
1200
+ // —— 每轮对话消耗检测:轮询 last-turn.json,出现新 seq 时弹消耗金额泡泡 ——
1201
+ var LAST_TURN_URL = '/dsh-whale/last-turn.json'
1202
+ var lastCostSeq = 0
1203
+ var lastCostAligned = false
1204
+ function pollLastTurn() {
1205
+ try {
1206
+ fetch(LAST_TURN_URL, { cache: 'no-store' })
1207
+ .then(function (r) { return r.json() })
1208
+ .then(function (d) {
1209
+ if (!d || !d.ok || typeof d.seq !== 'number') return
1210
+ if (!lastCostAligned) {
1211
+ // 首次拿到数据:只对齐 seq,不弹旧轮次
1212
+ lastCostSeq = d.seq
1213
+ lastCostAligned = true
1214
+ return
1215
+ }
1216
+ if (d.seq > lastCostSeq) {
1217
+ lastCostSeq = d.seq
1218
+ if (d.turn !== null && d.amount !== null) {
1219
+ showCostBubble(Number(d.amount))
1220
+ }
1221
+ }
1222
+ })
1223
+ .catch(function () {})
1224
+ } catch (err) {}
1225
+ }
1226
+ setInterval(pollLastTurn, 1000)
1003
1227
  })()`
1004
1228
 
1005
1229
 
@@ -1010,6 +1234,79 @@ function apply(ctx) {
1010
1234
  let imageBytes = null
1011
1235
  let balanceCache = null
1012
1236
  let balanceInFlight = null
1237
+ let gifBytes = null
1238
+ // 每轮对话消耗统计:turn -> 聚合中的成本/分词;完成后写入 lastTurn
1239
+ let turnAgg = null // { turn, cost, tokens, lastTs }
1240
+ let lastTurn = null // { turn, amount, tokens, ts }
1241
+ let lastTurnSeq = 0
1242
+ const disposers = []
1243
+
1244
+ function resetTurnAgg() {
1245
+ turnAgg = null
1246
+ }
1247
+ function finalizeTurn() {
1248
+ if (turnAgg && turnAgg.cost > 0) {
1249
+ lastTurn = { turn: turnAgg.turn, amount: turnAgg.cost, tokens: turnAgg.tokens, ts: turnAgg.lastTs }
1250
+ lastTurnSeq++
1251
+ }
1252
+ turnAgg = null
1253
+ }
1254
+ // 监听会话事件流:assistant/message 携带每步真实 usage,按 turn 聚合;
1255
+ // turn/end 时结算本轮并写入 lastTurn
1256
+ function handleSessionEvent(event) {
1257
+ try {
1258
+ const type = event && event.type
1259
+ const d = event && event.data
1260
+ if (!d || typeof d !== 'object') return
1261
+ if (type === 'turn/end') {
1262
+ // 一轮对话结束:结算并清空聚合
1263
+ finalizeTurn()
1264
+ return
1265
+ }
1266
+ if (type !== 'assistant/message') return
1267
+ const turn = Number(d.turn)
1268
+ const usage = d.usage
1269
+ if (!usage || typeof usage !== 'object' || !isFinite(turn)) return
1270
+ if (!turnAgg || turnAgg.turn !== turn) {
1271
+ if (turnAgg && turnAgg.turn !== turn) finalizeTurn()
1272
+ if (!turnAgg || turnAgg.turn !== turn) turnAgg = { turn, cost: 0, tokens: 0, lastTs: Date.now() }
1273
+ }
1274
+ const input = Number(usage.inputTokens) || 0
1275
+ const cache = Number(usage.cacheReadTokens) || 0
1276
+ const output = Number(usage.outputTokens) || 0
1277
+ const reasoning = Number(usage.reasoningTokens) || 0
1278
+ turnAgg.tokens += input + cache + output + reasoning
1279
+ // 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位)
1280
+ const model = d.message && d.message.source ? d.message.source.model : ''
1281
+ const p = priceFor(model)
1282
+ const off = isPeakTime(Math.floor(Date.now() / 1000)) ? 1 : 0
1283
+ turnAgg.cost += (cache / 1e6) * p.hit[off] + (input / 1e6) * p.miss[off] + ((output + reasoning) / 1e6) * p.out[off]
1284
+ turnAgg.lastTs = Date.now()
1285
+ } catch (err) {}
1286
+ }
1287
+
1288
+ // 监听所有会话的追加事件;turn/end 时结算本轮
1289
+ disposers.push(ctx.on('session/event', (session, event) => {
1290
+ // [debug] 确认事件是否到达(下个版本移除)
1291
+ if (event && (event.type === 'turn/end' || event.type === 'assistant/message')) {
1292
+ try { console.error('[whale-balance:event]', event.type, 'turn=' + (event.data && event.data.turn), 'usage=' + (event.data && event.data.usage ? 'yes' : 'no')) } catch (err) {}
1293
+ }
1294
+ handleSessionEvent(event)
1295
+ }))
1296
+
1297
+ function loadGif() {
1298
+ if (gifBytes) return gifBytes
1299
+ for (const p of RUA_GIF_CANDIDATES) {
1300
+ try {
1301
+ const bytes = fs.readFileSync(p)
1302
+ if (bytes && bytes.length > 0) {
1303
+ gifBytes = bytes
1304
+ return bytes
1305
+ }
1306
+ } catch (err) {}
1307
+ }
1308
+ throw new Error('rua gif not found')
1309
+ }
1013
1310
 
1014
1311
  function loadImage() {
1015
1312
  if (imageBytes) return imageBytes
@@ -1267,6 +1564,10 @@ function apply(ctx) {
1267
1564
  vol: typeof parsed.vol === 'number' ? parsed.vol : 0.9,
1268
1565
  soundSet: parsed.soundSet === 'fx1' ? 'fx1' : 'duck',
1269
1566
  usageMode: normalizeUsageMode(parsed.usageMode),
1567
+ peakMode: parsed.peakMode === 'liangwen' || parsed.peakMode === 'qiangqiang' ? parsed.peakMode : 'default',
1568
+ bubbleOn: parsed.bubbleOn !== false,
1569
+ turnCostOn: parsed.turnCostOn !== false,
1570
+ turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
1270
1571
  }
1271
1572
  }
1272
1573
  } catch (err) {}
@@ -1274,14 +1575,22 @@ function apply(ctx) {
1274
1575
  return null
1275
1576
  }
1276
1577
 
1277
- function writeSizeConfig(scale, sound, vol, soundSet, usageMode) {
1578
+ function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs) {
1278
1579
  const um = normalizeUsageMode(usageMode)
1580
+ const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
1581
+ const bo = bubbleOn !== false
1582
+ const tco = turnCostOn !== false
1583
+ const tcc = typeof turnCostCloseMs === 'number' && turnCostCloseMs > 0 ? turnCostCloseMs : 5000
1279
1584
  const body = JSON.stringify({
1280
1585
  scale: scale,
1281
1586
  sound: sound !== false,
1282
1587
  vol: typeof vol === 'number' ? vol : 0.9,
1283
1588
  soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
1284
1589
  usageMode: um,
1590
+ peakMode: pm,
1591
+ bubbleOn: bo,
1592
+ turnCostOn: tco,
1593
+ turnCostCloseMs: tcc,
1285
1594
  updatedAt: new Date().toISOString(),
1286
1595
  })
1287
1596
  for (const p of SIZE_FILE_CANDIDATES) {
@@ -1294,6 +1603,10 @@ function apply(ctx) {
1294
1603
  vol: typeof vol === 'number' ? vol : 0.9,
1295
1604
  soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
1296
1605
  usageMode: um,
1606
+ peakMode: pm,
1607
+ bubbleOn: bo,
1608
+ turnCostOn: tco,
1609
+ turnCostCloseMs: tcc,
1297
1610
  }
1298
1611
  } catch (err) {}
1299
1612
  }
@@ -1318,8 +1631,6 @@ function apply(ctx) {
1318
1631
  })
1319
1632
  }
1320
1633
 
1321
- const disposers = []
1322
-
1323
1634
  disposers.push(ctx.webServer.register({
1324
1635
  kind: 'exact',
1325
1636
  path: '/dsh-whale/image.png',
@@ -1339,6 +1650,25 @@ function apply(ctx) {
1339
1650
  },
1340
1651
  }))
1341
1652
 
1653
+ disposers.push(ctx.webServer.register({
1654
+ kind: 'exact',
1655
+ path: '/dsh-whale/rua.gif',
1656
+ handler: (req, res) => {
1657
+ try {
1658
+ const bytes = loadGif()
1659
+ res.writeHead(200, {
1660
+ 'Content-Type': 'image/gif',
1661
+ 'Cache-Control': 'no-store',
1662
+ 'Content-Length': String(bytes.length),
1663
+ })
1664
+ res.end(bytes)
1665
+ } catch (err) {
1666
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
1667
+ res.end('rua gif unavailable: ' + String((err && err.message) || err))
1668
+ }
1669
+ },
1670
+ }))
1671
+
1342
1672
  disposers.push(ctx.webServer.register({
1343
1673
  kind: 'exact',
1344
1674
  path: '/dsh-whale/balance.json',
@@ -1354,6 +1684,19 @@ function apply(ctx) {
1354
1684
  },
1355
1685
  }))
1356
1686
 
1687
+ disposers.push(ctx.webServer.register({
1688
+ kind: 'exact',
1689
+ path: '/dsh-whale/last-turn.json',
1690
+ handler: (req, res) => {
1691
+ // 返回最近一轮已完成的对话消耗;seq 递增供前端判断「新的一轮」
1692
+ const payload = lastTurn
1693
+ ? { ok: true, seq: lastTurnSeq, turn: lastTurn.turn, amount: lastTurn.amount, tokens: lastTurn.tokens, ts: lastTurn.ts }
1694
+ : { ok: true, seq: 0, turn: null, amount: null, tokens: null, ts: null }
1695
+ res.writeHead(200, JSON_HEADERS)
1696
+ res.end(JSON.stringify(payload))
1697
+ },
1698
+ }))
1699
+
1357
1700
  disposers.push(ctx.webServer.register({
1358
1701
  kind: 'exact',
1359
1702
  path: '/dsh-whale/size.json',
@@ -1375,7 +1718,7 @@ function apply(ctx) {
1375
1718
  balanceCache = null
1376
1719
  }
1377
1720
  }
1378
- const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode)
1721
+ const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs)
1379
1722
  res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
1380
1723
  res.end(JSON.stringify(result))
1381
1724
  } catch (err) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.2.2",
4
- "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件(含今日已用、峰谷定价、随机台词与音效)",
3
+ "version": "0.2.5",
4
+ "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件(含今日已用、峰谷定价、随机台词、音效与每轮消耗统计)",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",