dsh-whale-widget 0.2.2 → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MeteorNOX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
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` 就能用,**开箱即用**。
108
134
 
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
- - **小鲸鱼记账模式(默认)**:不需要平台令牌,鲸鱼娘用余额差值自动记账,跨天自动归档
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
+ ### 每轮对话消耗(无需任何凭据)
161
+
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` 调整几何参数。
@@ -138,3 +192,7 @@ curl http://127.0.0.1:3080/dsh-whale/size.json
138
192
  ## 开发与维护
139
193
 
140
194
  完整规格、视觉参数、架构结论和生成提示词见 `whale-widget-prompt.md`。修改文字位置、颜色、动画、吸附逻辑、台词组或定价表时参考该文件。
195
+
196
+ ## 许可证
197
+
198
+ 本项目基于 **MIT License** 开源,详见 [LICENSE](LICENSE)。
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,23 @@ 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)
339
+ var gifFailed = false
340
+ gifEl.onerror = function () { gifFailed = true }
277
341
  bubbleBox.appendChild(textBox)
278
342
  bubbleBox.addEventListener('click', function (e) {
279
343
  e.stopPropagation()
280
344
  if (!bubbleShown) return
345
+ if (costBubbleActive) {
346
+ // 消耗金额泡泡:点击关闭(确认)
347
+ hideCostBubble()
348
+ return
349
+ }
281
350
  if (bubbleRandomActive) {
282
351
  // 再次点击:关闭
283
352
  hideBubble()
@@ -333,18 +402,27 @@ function pickOne(arr) { return arr[Math.floor(Math.random() * arr.length)] }
333
402
  function singleCenter(style, text, color, wrap) { return [null, { t: text, s: style, c: color || '', w: !!wrap }, null] }
334
403
  function buildGroup1() {
335
404
  var peak = !!state.isPeak
405
+ var offText = '空闲时段'
406
+ var peakText = '高峰时段'
407
+ if (peakMode === 'liangwen') {
408
+ offText = '梁文谷'
409
+ peakText = '梁文峰'
410
+ } else if (peakMode === 'qiangqiang') {
411
+ offText = '!?谷谷?!'
412
+ peakText = '!?峰峰?!'
413
+ }
336
414
  return [
337
415
  { t: '当前时间段为:', s: 'A', c: '' },
338
- { t: peak ? '高峰时段' : '空闲时段', s: 'P', c: peak ? '#e0433f' : '#2fa24c' },
416
+ { t: peak ? peakText : offText, s: 'P', c: peak ? '#e0433f' : '#2fa24c' },
339
417
  { t: '今日已用 ' + fmt(state.todayUsage, state.currency), s: 'C', c: '' },
340
418
  ]
341
419
  }
342
420
  var RANDOM_GROUPS = [
343
- { w: 20, lines: buildGroup1 },
421
+ { w: 45, lines: buildGroup1 },
344
422
  { w: 7, lines: function () { return singleCenter('B', pickOne(['好模型... ↓', '好女孩...↓'])) } },
345
423
  { w: 7, lines: function () { return singleCenter('A', pickOne(['不知道用户有什么用,先赶走吧~', '我...我...我也要挣钱吗?', '我去吃饭啦,测完叫我', '压力一只蓝色大肥鱼?!', 'DeepSleep...', '坏了...用户彻底怒了!']), '', true) } },
424
+ { w: 10, lines: function () { return { gif: true } } },
346
425
  { 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
426
  { w: 1, lines: function () { return singleCenter('B', '哦鲸鲸... ') } },
349
427
  ]
350
428
  function pickRandomLines() {
@@ -358,6 +436,24 @@ function pickRandomLines() {
358
436
  return RANDOM_GROUPS[RANDOM_GROUPS.length - 1].lines()
359
437
  }
360
438
  function applyBubbleLines(lines) {
439
+ if (lines && lines.gif) {
440
+ // gif 台词组:只显示 gif,隐藏三行文字(display 必须显式覆盖 CSS 的 none)
441
+ if (gifFailed) {
442
+ // gif 加载失败/路由缺失:降级为文字台词,避免空白白色气泡
443
+ lines = singleCenter('A', pickOne(['gif 加载失败了...', '今天没有动图给你看~', '呜呜 动图不见了...']), '', true)
444
+ } else {
445
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
446
+ gifEl.style.display = 'block'
447
+ gifEl.style.opacity = ''
448
+ labelEl.style.display = 'none'
449
+ amountEl.style.display = 'none'
450
+ hintEl.style.display = 'none'
451
+ return
452
+ }
453
+ }
454
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
455
+ gifEl.style.display = 'none'
456
+ gifEl.style.opacity = ''
361
457
  var els = [labelEl, amountEl, hintEl]
362
458
  for (var i = 0; i < 3; i++) {
363
459
  var el = els[i]
@@ -376,13 +472,16 @@ function applyBubbleLines(lines) {
376
472
  }
377
473
  var bubbleSwapTimer = null
378
474
  var hintFadeTimer = null
475
+ var gifFadeTimer = null
379
476
  var lastHintText = null
380
477
  function setHint(text) {
381
- // 「加载中…」→「今日已用」等提示行变化时做淡出淡入,其余直接替换
478
+ // 首次/恢复(lastHintText===null)时直接写文本,不做淡出淡入——否则
479
+ // 气泡打开或按压重开时会先淡出再淡入,造成「消失一下又出现」。
480
+ // 只有气泡打开期间的内容变化(加载中→今日已用)才走动画。
382
481
  if (text === lastHintText) return
482
+ var first = lastHintText === null
383
483
  lastHintText = text
384
- if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
385
- if (!bubbleShown) {
484
+ if (first || !bubbleShown) {
386
485
  hintEl.textContent = text
387
486
  return
388
487
  }
@@ -415,21 +514,30 @@ function swapBubbleContent(applyFn) {
415
514
  function restoreBubbleLines() {
416
515
  if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
417
516
  if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
517
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
418
518
  lastHintText = null
419
519
  textBox.style.transition = ''
420
520
  textBox.style.opacity = ''
521
+ gifEl.style.display = 'none'
522
+ gifEl.style.opacity = ''
421
523
  labelEl.style.display = ''
422
524
  labelEl.className = 'dshwv-label'
423
525
  labelEl.textContent = 'DeepSeek 余额'
424
526
  labelEl.style.color = ''
527
+ amountEl.style.display = ''
425
528
  amountEl.className = 'dshwv-amount'
426
529
  amountEl.style.color = ''
530
+ hintEl.style.display = ''
427
531
  hintEl.className = 'dshwv-hint'
428
532
  hintEl.style.color = ''
429
533
  render()
430
534
  }
431
535
  function showBubble() {
536
+ if (!bubbleOn) return
537
+ // 消耗金额泡泡显示期间,余额变动不再弹出普通泡泡
538
+ if (costBubbleActive) return
432
539
  if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
540
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
433
541
  bubbleShown = true
434
542
  bubbleRandomActive = false
435
543
  restoreBubbleLines()
@@ -448,7 +556,53 @@ function hideBubble() {
448
556
  bubbleRandomActive = false
449
557
  bubbleRandomLines = null
450
558
  bubbleShown = false
559
+ // 只销毁 gif 显示;三行文字保持现状让气泡自然淡出——不能在关闭瞬间
560
+ // 恢复成余额内容(否则随机台词界面会闪现余额)。文字恢复交给下次
561
+ // showBubble() 的 restoreBubbleLines()(那时气泡隐藏,恢复过程不可见)。
451
562
  bubbleBox.classList.remove('dshwv-bubble-open')
563
+ // gif 靠 CSS opacity 过渡淡出;display:none 会跳过过渡,须等淡出完成再隐藏
564
+ gifFadeTimer = setTimeout(function () {
565
+ gifFadeTimer = null
566
+ gifEl.style.display = 'none'
567
+ }, 240)
568
+ }
569
+
570
+ // —— 每轮对话消耗金额泡泡 ——
571
+ var costBubbleTimer = null
572
+ function showCostBubble(amount) {
573
+ if (!bubbleOn || !turnCostOn) return
574
+ if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
575
+ if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
576
+ if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
577
+ costBubbleActive = true
578
+ bubbleRandomActive = false
579
+ bubbleShown = true
580
+ lastHintText = null
581
+ // 样式:第一行 A(标签),第二行 B(红色金额),居中两行
582
+ gifEl.style.display = 'none'
583
+ gifEl.style.opacity = ''
584
+ labelEl.style.display = ''
585
+ labelEl.className = 'dshwv-label'
586
+ labelEl.textContent = '上一轮对话消耗:'
587
+ labelEl.style.color = ''
588
+ amountEl.style.display = ''
589
+ amountEl.className = 'dshwv-amount'
590
+ amountEl.textContent = '¥ ' + (isFinite(amount) ? Number(amount).toFixed(2) : '--')
591
+ amountEl.style.color = '#e0433f'
592
+ hintEl.style.display = 'none'
593
+ hintEl.textContent = ''
594
+ hintEl.style.color = ''
595
+ textBox.style.transition = ''
596
+ textBox.style.opacity = ''
597
+ bubbleBox.classList.add('dshwv-bubble-open')
598
+ if (turnCostCloseMs > 0) {
599
+ costBubbleTimer = setTimeout(hideCostBubble, turnCostCloseMs)
600
+ }
601
+ }
602
+ function hideCostBubble() {
603
+ if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
604
+ costBubbleActive = false
605
+ hideBubble()
452
606
  }
453
607
 
454
608
  function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
@@ -458,12 +612,19 @@ function viewport() {
458
612
  h: window.innerHeight || document.documentElement.clientHeight || 800
459
613
  }
460
614
  }
615
+ function rightGap() {
616
+ var sw = window.innerWidth - document.documentElement.clientWidth
617
+ if (sw < 8) sw = 10 // 悬浮滚动条测量为0时兜底
618
+ return sw + 2 // 滚动条宽度 + 2px
619
+ }
461
620
  function fmt(balance, currency) {
462
621
  var num = Number(balance)
463
622
  var fixed = isFinite(num) ? num.toFixed(2) : '--'
464
623
  return currency === 'CNY' ? '¥ ' + fixed : fixed + ' ' + currency
465
624
  }
466
625
  function animateAmount(from, to, currency, duration) {
626
+ // 消耗金额泡泡显示期间,余额数字滚动不触碰金额行
627
+ if (costBubbleActive) return
467
628
  if (animId) cancelAnimationFrame(animId)
468
629
  if (from === null || !isFinite(from)) from = to
469
630
  if (from === to) {
@@ -489,6 +650,8 @@ function animateAmount(from, to, currency, duration) {
489
650
  animId = requestAnimationFrame(step)
490
651
  }
491
652
  function render() {
653
+ // 消耗金额泡泡显示期间,余额渲染不覆盖其内容(金额行/标题行/提示行)
654
+ if (costBubbleActive) return
492
655
  var amount, hint
493
656
  if (state.status === 'error') {
494
657
  amount = shown !== null ? fmt(shown, state.currency) : '--'
@@ -520,17 +683,17 @@ function settle() {
520
683
  var h = root.offsetHeight || root.getBoundingClientRect().height || 0
521
684
  if (drag && drag.active) {
522
685
  // mid-drag resize: keep the pointer-follow position, just clamp into view
523
- state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
686
+ state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
524
687
  state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
525
688
  express()
526
689
  return
527
690
  }
528
691
  if (state.h === 'right') {
529
- state.left = Math.max(0, vp.w - w - state.hOff)
692
+ state.left = Math.max(0, vp.w - w - state.hOff - rightGap())
530
693
  } else if (state.h === 'left') {
531
694
  state.left = state.hOff
532
695
  } else {
533
- state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
696
+ state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
534
697
  }
535
698
  if (state.v === 'bottom') {
536
699
  state.top = Math.max(0, vp.h - h - state.vOff)
@@ -610,9 +773,27 @@ var soundOn = true
610
773
  var soundVol = 0.9
611
774
  var soundSet = 'duck'
612
775
  var usageMode = 'ledger'
776
+ var peakMode = 'default'
777
+ var bubbleOn = true
778
+ var turnCostOn = true
779
+ var turnCostCloseMs = 5000
780
+ var costBubbleActive = false
613
781
  function saveConfig() {
614
782
  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 }) })
783
+ 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 }) })
784
+ var vp = viewport()
785
+ var w = root.offsetWidth || root.getBoundingClientRect().width || 0
786
+ var h = root.offsetHeight || root.getBoundingClientRect().height || 0
787
+ var leftDist = state.left
788
+ var rightDist = vp.w - state.left - w
789
+ var topDist = state.top
790
+ var bottomDist = vp.h - state.top - h
791
+ localStorage.setItem('dshw-pos', JSON.stringify({
792
+ hAnchor: leftDist <= rightDist ? 'left' : 'right',
793
+ hDist: Math.round(Math.min(leftDist, rightDist)),
794
+ vAnchor: topDist <= bottomDist ? 'top' : 'bottom',
795
+ vDist: Math.round(Math.min(topDist, bottomDist))
796
+ }))
616
797
  } catch (err) {}
617
798
  }
618
799
  function setUsageMode(v) {
@@ -621,6 +802,30 @@ function setUsageMode(v) {
621
802
  saveConfig()
622
803
  refresh(false)
623
804
  }
805
+ function setPeakMode(v) {
806
+ peakMode = v === 'liangwen' || v === 'qiangqiang' ? v : 'default'
807
+ peakSelect.value = peakMode
808
+ saveConfig()
809
+ }
810
+ function setBubbleOn(v) {
811
+ bubbleOn = !!v
812
+ bubbleToggle.checked = bubbleOn
813
+ saveConfig()
814
+ // 必须走 hideCostBubble:残留的 costBubbleActive 会让 render()/showBubble() 永久早退
815
+ if (!bubbleOn) hideCostBubble()
816
+ }
817
+ function setTurnCostOn(v) {
818
+ turnCostOn = !!v
819
+ turnCostToggle.checked = turnCostOn
820
+ saveConfig()
821
+ if (!turnCostOn) hideCostBubble()
822
+ }
823
+ function setTurnCostClose(v) {
824
+ var n = Math.max(0, Math.round(Number(v) || 0))
825
+ turnCostCloseMs = n * 1000
826
+ turnCostCloseInput.value = String(n)
827
+ saveConfig()
828
+ }
624
829
  function scaleToDisplay(s) {
625
830
  return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
626
831
  }
@@ -775,7 +980,7 @@ function snapCheck() {
775
980
  } else if (centerX > vp.w * 3 / 4) {
776
981
  state.h = 'right'
777
982
  state.hOff = 0
778
- left = vp.w - w
983
+ left = vp.w - w - rightGap()
779
984
  moved = true
780
985
  } else {
781
986
  state.h = null
@@ -829,7 +1034,9 @@ function setupHitTest() {
829
1034
  var probe = new Image()
830
1035
  probe.onload = function () {
831
1036
  try {
832
- hitCanvas.getContext('2d').drawImage(probe, 0, 0)
1037
+ // 拉伸到 610×610 与 isWhaleHit 的坐标映射对齐;不指定尺寸会按原图大小绘制,
1038
+ // 回退到非 610×610 素材(如 DSniang02.png)时命中区域会错位
1039
+ hitCanvas.getContext('2d').drawImage(probe, 0, 0, 610, 610)
833
1040
  hitReady = true
834
1041
  } catch (err) {}
835
1042
  }
@@ -956,7 +1163,28 @@ function endDrag(e, clickAllowed) {
956
1163
  state.top = top
957
1164
  settle()
958
1165
  }
1166
+ // 窗口尺寸变化时:自由位置的鲸鱼按相对边框锚点重算(保持离边距离,窗口恢复原状即回原位);
1167
+ // 贴边吸附的鲸鱼走 settle()(保持贴边)
1168
+ function applyAnchorPos() {
1169
+ try {
1170
+ var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
1171
+ if (!a || (a.hAnchor !== 'left' && a.hAnchor !== 'right') || typeof a.hDist !== 'number' ||
1172
+ (a.vAnchor !== 'top' && a.vAnchor !== 'bottom') || typeof a.vDist !== 'number') return false
1173
+ var vp = viewport()
1174
+ var w = root.offsetWidth || root.getBoundingClientRect().width || 0
1175
+ var h = root.offsetHeight || root.getBoundingClientRect().height || 0
1176
+ var l = a.hAnchor === 'left' ? a.hDist : vp.w - a.hDist - w
1177
+ var t = a.vAnchor === 'top' ? a.vDist : vp.h - a.vDist - h
1178
+ state.left = clamp(l, 0, Math.max(0, vp.w - w))
1179
+ state.top = clamp(t, 0, Math.max(0, vp.h - h))
1180
+ state.h = null
1181
+ state.v = null
1182
+ express()
1183
+ return true
1184
+ } catch (err) { return false }
1185
+ }
959
1186
  window.addEventListener('resize', function () {
1187
+ if (state.h === null && state.v === null && applyAnchorPos()) return
960
1188
  settle()
961
1189
  })
962
1190
 
@@ -996,10 +1224,71 @@ fetch(SIZE_URL, { cache: 'no-store' })
996
1224
  usageMode = d.usageMode === 'token' ? 'token' : 'ledger'
997
1225
  usageSelect.value = usageMode
998
1226
  }
1227
+ if (d && typeof d.peakMode === 'string') {
1228
+ peakMode = d.peakMode === 'liangwen' || d.peakMode === 'qiangqiang' ? d.peakMode : 'default'
1229
+ peakSelect.value = peakMode
1230
+ }
1231
+ if (d && typeof d.bubbleOn === 'boolean') {
1232
+ bubbleOn = d.bubbleOn
1233
+ bubbleToggle.checked = bubbleOn
1234
+ }
1235
+ if (d && typeof d.turnCostOn === 'boolean') {
1236
+ turnCostOn = d.turnCostOn
1237
+ turnCostToggle.checked = turnCostOn
1238
+ }
1239
+ if (d && typeof d.turnCostCloseMs === 'number') {
1240
+ turnCostCloseMs = d.turnCostCloseMs > 0 ? d.turnCostCloseMs : 0
1241
+ turnCostCloseInput.value = String(Math.round(turnCostCloseMs / 1000))
1242
+ }
1243
+ // 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离
1244
+ try {
1245
+ var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
1246
+ if (a && (a.hAnchor === 'left' || a.hAnchor === 'right') && typeof a.hDist === 'number' &&
1247
+ (a.vAnchor === 'top' || a.vAnchor === 'bottom') && typeof a.vDist === 'number') {
1248
+ var vpA = viewport()
1249
+ var wA = root.offsetWidth || root.getBoundingClientRect().width || 0
1250
+ var hA = root.offsetHeight || root.getBoundingClientRect().height || 0
1251
+ var lA = a.hAnchor === 'left' ? a.hDist : vpA.w - a.hDist - wA
1252
+ var tA = a.vAnchor === 'top' ? a.vDist : vpA.h - a.vDist - hA
1253
+ state.left = clamp(lA, 0, Math.max(0, vpA.w - wA))
1254
+ state.top = clamp(tA, 0, Math.max(0, vpA.h - hA))
1255
+ state.h = null // 恢复为自由位置,避免 settle() 重新吸附覆盖
1256
+ state.v = null
1257
+ settle()
1258
+ }
1259
+ } catch (err) {}
999
1260
  refresh(false)
1000
1261
  })
1001
1262
  .catch(function () { refresh(false) })
1002
1263
  setInterval(function () { refresh(false) }, REFRESH_MS)
1264
+
1265
+ // —— 每轮对话消耗检测:轮询 last-turn.json,出现新 seq 时弹消耗金额泡泡 ——
1266
+ var LAST_TURN_URL = '/dsh-whale/last-turn.json'
1267
+ var lastCostSeq = 0
1268
+ var lastCostAligned = false
1269
+ function pollLastTurn() {
1270
+ try {
1271
+ fetch(LAST_TURN_URL, { cache: 'no-store' })
1272
+ .then(function (r) { return r.json() })
1273
+ .then(function (d) {
1274
+ if (!d || !d.ok || typeof d.seq !== 'number') return
1275
+ if (!lastCostAligned) {
1276
+ // 首次拿到数据:只对齐 seq,不弹旧轮次
1277
+ lastCostSeq = d.seq
1278
+ lastCostAligned = true
1279
+ return
1280
+ }
1281
+ if (d.seq > lastCostSeq) {
1282
+ lastCostSeq = d.seq
1283
+ if (d.turn !== null && d.amount !== null) {
1284
+ showCostBubble(Number(d.amount))
1285
+ }
1286
+ }
1287
+ })
1288
+ .catch(function () {})
1289
+ } catch (err) {}
1290
+ }
1291
+ setInterval(pollLastTurn, 1000)
1003
1292
  })()`
1004
1293
 
1005
1294
 
@@ -1010,6 +1299,75 @@ function apply(ctx) {
1010
1299
  let imageBytes = null
1011
1300
  let balanceCache = null
1012
1301
  let balanceInFlight = null
1302
+ let gifBytes = null
1303
+ // 每轮对话消耗统计:turn -> 聚合中的成本/分词;完成后写入 lastTurn
1304
+ let turnAgg = null // { turn, cost, tokens, lastTs }
1305
+ let lastTurn = null // { turn, amount, tokens, ts }
1306
+ let lastTurnSeq = 0
1307
+ const disposers = []
1308
+
1309
+ function resetTurnAgg() {
1310
+ turnAgg = null
1311
+ }
1312
+ function finalizeTurn() {
1313
+ if (turnAgg && turnAgg.cost > 0) {
1314
+ lastTurn = { turn: turnAgg.turn, amount: turnAgg.cost, tokens: turnAgg.tokens, ts: turnAgg.lastTs }
1315
+ lastTurnSeq++
1316
+ }
1317
+ turnAgg = null
1318
+ }
1319
+ // 监听会话事件流:assistant/message 携带每步真实 usage,按 turn 聚合;
1320
+ // turn/end 时结算本轮并写入 lastTurn
1321
+ function handleSessionEvent(event) {
1322
+ try {
1323
+ const type = event && event.type
1324
+ const d = event && event.data
1325
+ if (!d || typeof d !== 'object') return
1326
+ if (type === 'turn/end') {
1327
+ // 一轮对话结束:结算并清空聚合
1328
+ finalizeTurn()
1329
+ return
1330
+ }
1331
+ if (type !== 'assistant/message') return
1332
+ const turn = Number(d.turn)
1333
+ const usage = d.usage
1334
+ if (!usage || typeof usage !== 'object' || !isFinite(turn)) return
1335
+ if (!turnAgg || turnAgg.turn !== turn) {
1336
+ if (turnAgg && turnAgg.turn !== turn) finalizeTurn()
1337
+ if (!turnAgg || turnAgg.turn !== turn) turnAgg = { turn, cost: 0, tokens: 0, lastTs: Date.now() }
1338
+ }
1339
+ const input = Number(usage.inputTokens) || 0
1340
+ const cache = Number(usage.cacheReadTokens) || 0
1341
+ const output = Number(usage.outputTokens) || 0
1342
+ const reasoning = Number(usage.reasoningTokens) || 0
1343
+ turnAgg.tokens += input + cache + output + reasoning
1344
+ // 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位)
1345
+ const model = d.message && d.message.source ? d.message.source.model : ''
1346
+ const p = priceFor(model)
1347
+ const off = isPeakTime(Math.floor(Date.now() / 1000)) ? 1 : 0
1348
+ turnAgg.cost += (cache / 1e6) * p.hit[off] + (input / 1e6) * p.miss[off] + ((output + reasoning) / 1e6) * p.out[off]
1349
+ turnAgg.lastTs = Date.now()
1350
+ } catch (err) {}
1351
+ }
1352
+
1353
+ // 监听所有会话的追加事件;turn/end 时结算本轮
1354
+ disposers.push(ctx.on('session/event', (session, event) => {
1355
+ handleSessionEvent(event)
1356
+ }))
1357
+
1358
+ function loadGif() {
1359
+ if (gifBytes) return gifBytes
1360
+ for (const p of RUA_GIF_CANDIDATES) {
1361
+ try {
1362
+ const bytes = fs.readFileSync(p)
1363
+ if (bytes && bytes.length > 0) {
1364
+ gifBytes = bytes
1365
+ return bytes
1366
+ }
1367
+ } catch (err) {}
1368
+ }
1369
+ throw new Error('rua gif not found')
1370
+ }
1013
1371
 
1014
1372
  function loadImage() {
1015
1373
  if (imageBytes) return imageBytes
@@ -1025,6 +1383,17 @@ function apply(ctx) {
1025
1383
  throw new Error('whale image not found')
1026
1384
  }
1027
1385
 
1386
+ function pickBalanceInfo(infos) {
1387
+ if (!Array.isArray(infos) || infos.length === 0) return null
1388
+ const num = (x) => (x && x.total_balance !== undefined ? Number(x.total_balance) : NaN)
1389
+ return (
1390
+ infos.find((x) => x && x.currency === 'CNY' && num(x) > 0) ||
1391
+ infos.find((x) => num(x) > 0) ||
1392
+ infos.find((x) => x && x.currency === 'CNY') ||
1393
+ infos[0]
1394
+ )
1395
+ }
1396
+
1028
1397
  async function fetchBalance() {
1029
1398
  let cred
1030
1399
  try {
@@ -1060,7 +1429,7 @@ function apply(ctx) {
1060
1429
  } catch (err) {
1061
1430
  return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
1062
1431
  }
1063
- const info = data && Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
1432
+ const info = pickBalanceInfo(data && data.balance_infos)
1064
1433
  if (!info || info.total_balance === undefined) {
1065
1434
  return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
1066
1435
  }
@@ -1267,6 +1636,10 @@ function apply(ctx) {
1267
1636
  vol: typeof parsed.vol === 'number' ? parsed.vol : 0.9,
1268
1637
  soundSet: parsed.soundSet === 'fx1' ? 'fx1' : 'duck',
1269
1638
  usageMode: normalizeUsageMode(parsed.usageMode),
1639
+ peakMode: parsed.peakMode === 'liangwen' || parsed.peakMode === 'qiangqiang' ? parsed.peakMode : 'default',
1640
+ bubbleOn: parsed.bubbleOn !== false,
1641
+ turnCostOn: parsed.turnCostOn !== false,
1642
+ turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
1270
1643
  }
1271
1644
  }
1272
1645
  } catch (err) {}
@@ -1274,14 +1647,23 @@ function apply(ctx) {
1274
1647
  return null
1275
1648
  }
1276
1649
 
1277
- function writeSizeConfig(scale, sound, vol, soundSet, usageMode) {
1650
+ function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs) {
1278
1651
  const um = normalizeUsageMode(usageMode)
1652
+ const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
1653
+ const bo = bubbleOn !== false
1654
+ const tco = turnCostOn !== false
1655
+ // 0 是合法值(不自动关闭),只有缺失/非法时才回落默认 5 秒
1656
+ const tcc = typeof turnCostCloseMs === 'number' && turnCostCloseMs >= 0 ? turnCostCloseMs : 5000
1279
1657
  const body = JSON.stringify({
1280
1658
  scale: scale,
1281
1659
  sound: sound !== false,
1282
1660
  vol: typeof vol === 'number' ? vol : 0.9,
1283
1661
  soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
1284
1662
  usageMode: um,
1663
+ peakMode: pm,
1664
+ bubbleOn: bo,
1665
+ turnCostOn: tco,
1666
+ turnCostCloseMs: tcc,
1285
1667
  updatedAt: new Date().toISOString(),
1286
1668
  })
1287
1669
  for (const p of SIZE_FILE_CANDIDATES) {
@@ -1294,6 +1676,10 @@ function apply(ctx) {
1294
1676
  vol: typeof vol === 'number' ? vol : 0.9,
1295
1677
  soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
1296
1678
  usageMode: um,
1679
+ peakMode: pm,
1680
+ bubbleOn: bo,
1681
+ turnCostOn: tco,
1682
+ turnCostCloseMs: tcc,
1297
1683
  }
1298
1684
  } catch (err) {}
1299
1685
  }
@@ -1318,8 +1704,6 @@ function apply(ctx) {
1318
1704
  })
1319
1705
  }
1320
1706
 
1321
- const disposers = []
1322
-
1323
1707
  disposers.push(ctx.webServer.register({
1324
1708
  kind: 'exact',
1325
1709
  path: '/dsh-whale/image.png',
@@ -1339,6 +1723,25 @@ function apply(ctx) {
1339
1723
  },
1340
1724
  }))
1341
1725
 
1726
+ disposers.push(ctx.webServer.register({
1727
+ kind: 'exact',
1728
+ path: '/dsh-whale/rua.gif',
1729
+ handler: (req, res) => {
1730
+ try {
1731
+ const bytes = loadGif()
1732
+ res.writeHead(200, {
1733
+ 'Content-Type': 'image/gif',
1734
+ 'Cache-Control': 'no-store',
1735
+ 'Content-Length': String(bytes.length),
1736
+ })
1737
+ res.end(bytes)
1738
+ } catch (err) {
1739
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
1740
+ res.end('rua gif unavailable: ' + String((err && err.message) || err))
1741
+ }
1742
+ },
1743
+ }))
1744
+
1342
1745
  disposers.push(ctx.webServer.register({
1343
1746
  kind: 'exact',
1344
1747
  path: '/dsh-whale/balance.json',
@@ -1354,6 +1757,19 @@ function apply(ctx) {
1354
1757
  },
1355
1758
  }))
1356
1759
 
1760
+ disposers.push(ctx.webServer.register({
1761
+ kind: 'exact',
1762
+ path: '/dsh-whale/last-turn.json',
1763
+ handler: (req, res) => {
1764
+ // 返回最近一轮已完成的对话消耗;seq 递增供前端判断「新的一轮」
1765
+ const payload = lastTurn
1766
+ ? { ok: true, seq: lastTurnSeq, turn: lastTurn.turn, amount: lastTurn.amount, tokens: lastTurn.tokens, ts: lastTurn.ts }
1767
+ : { ok: true, seq: 0, turn: null, amount: null, tokens: null, ts: null }
1768
+ res.writeHead(200, JSON_HEADERS)
1769
+ res.end(JSON.stringify(payload))
1770
+ },
1771
+ }))
1772
+
1357
1773
  disposers.push(ctx.webServer.register({
1358
1774
  kind: 'exact',
1359
1775
  path: '/dsh-whale/size.json',
@@ -1375,7 +1791,7 @@ function apply(ctx) {
1375
1791
  balanceCache = null
1376
1792
  }
1377
1793
  }
1378
- const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode)
1794
+ const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs)
1379
1795
  res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
1380
1796
  res.end(JSON.stringify(result))
1381
1797
  } 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.6",
4
+ "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件(含今日已用、峰谷定价、随机台词、音效与每轮消耗统计)",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",