dsh-whale-widget 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,13 +4,28 @@
4
4
 
5
5
  DeepSeek Harness(DSH)Web 界面右下角的常驻挂件:小鲸鱼气泡图 + DeepSeek API 余额 + 今日已用 + 每轮对话消耗,并且**泡泡内容可以完全自定义**(点击序列、模块化排版、并列加权出泡、随机语句/随机图片)。标准 DSH bundle 插件,`dsh plugin` 一键安装,无需任何会话令牌。
6
6
 
7
+ ## 两条分支怎么选
8
+
9
+ 本仓库有两条**互不兼容**的产品线,按你要挂在哪里选一条:
10
+
11
+ | 你要挂在哪 | 用哪条分支 | 安装方式 |
12
+ |---|---|---|
13
+ | **DSH Web 界面右下角**(就是这个 README 描述的插件) | `main`(默认分支) | `dsh plugin --profile web add dsh-whale-widget`(推荐,装 npm 已发布版);也可以从本仓库装 `dsh plugin --profile web add github:MeteorNOX/DeepSeek-Balance-Whale-Widget`,但那样装的是 **main 当前状态、不跟随已发布版本** |
14
+ | **Codex 桌面应用**(跟随 Codex 窗口、无独立网页) | [`For-Codex`](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/tree/For-Codex) | 该分支的 `api-balance-whale`:解压到 `%USERPROFILE%\plugins\api-balance-whale`,再按分支内的 [安装说明](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/blob/For-Codex/docs/INSTALL-AND-ROLLBACK-0.2.0.md) 注册计划任务(也可以直接交给 Codex 自己装喵~) |
15
+
16
+ ⚠️ **两条分支互不兼容**:`For-Codex` 的插件不能用 `dsh plugin … add` 装进 DSH 网页;本主分支的插件也不能在 Codex 桌面里运行。上表第一行是本仓库默认分支(`main`)的能力,第二行是另一个分支的能力。
17
+
18
+ ⚠️ 同一分支的两种安装方式(npm 名 / `github:`)**二选一、别混用** —— 它们是同一个包名,先后安装会并存冲突;已经是 `github:` 装的,要换 npm 名请先 `dsh plugin --profile web remove dsh-whale-widget`。
19
+
20
+ > 两条产品线**各自独立维护**(无共享代码/无跨分支依赖):`main` = DSH Web 插件,`For-Codex` = Codex 桌面端口,各自的版本线、发布方式与测试互不影响。
21
+
7
22
  ## 特性
8
23
 
9
24
  ### 记账与显示
10
25
 
11
26
  - 🐋 **常驻自启**:随 DSH Web 界面每次打开自动出现(标准 DSH bundle 插件)
12
27
  - 💰 **余额**:60 秒自动刷新 + 点击鲸鱼手动刷新;余额变化时数字**滚动动画**;瞬时网络抖动自动沿用最近余额不报错
13
- - 📊 **今日已用(小鲸鱼记账)**:不需要任何令牌。余额**下降**按观测累计为消费,余额**上升**(充值 / 赠金)单独记录、不会冲掉已有消费;检测到余额增加会提示「待核对余额调整」,可在「小鲸鱼记账 → 充值 / 余额校正」按实际累计到账金额与非调用扣减校正(逐轮明细保留 90 天或最多 2 万条、逐日归档保留 365 天,超期数据归档到 `.dshw-usage-archive.json`);金额按 8 位小数记账、显示保留两位
28
+ - 📊 **今日已用(小鲸鱼记账)**:不需要任何令牌。余额**下降**按观测累计为消费,余额**上升**(充值 / 赠金)单独记录、不会冲掉已有消费;检测到余额增加会提示「待核对余额调整」,可在「小鲸鱼记账 → **DeepSeek(内置)→ 设置 余额校正**」按实际累计到账金额与非调用扣减校正 —— **「已观测消费」与「余额校正」都是 DeepSeek 账户口径**(由该 API key 的余额观测得出,不含其它厂商;「本机模型费用」才是所有模型的本地估算)(逐轮明细保留 90 天或最多 2 万条、逐日归档保留 365 天,超期数据归档到 `.dshw-usage-archive.json`);金额按 8 位小数记账、显示保留两位
14
29
  - 💬 **每轮对话消耗**:监听 DSH 本机会话事件,按模型**真实 usage**(input / cache / output / reasoning tokens)换算金额,每轮结束弹出消耗泡泡;可开关、自动关闭秒数可设(0 = 不自动关闭),**泡泡内容可自定义**(模块化,金额用 `{cost}` 引用),入口:菜单 → 每轮消耗提示 → 「自定义提示」
15
30
  - ⛰️ **峰谷定价**:工作日高峰 9:00–12:00、14:00–18:00(北京时间),其余空闲;2026-08-23 起**周末全天谷价**。峰谷模块支持状态文字、倒计时、"梁文峰谷 / !?强强?! / 简洁"等多种样式
16
31
  - 📒 **用量记录窗口**:今日模型消费、近 7 天、全部记录;模型占比条;按日期展开逐条明细(含时间与金额);支持按日期或**模型名搜索**;模型名带友好标注(`deepseek-flash` → `DeepSeek-V4.1-Flash`,旧名标注"同 V4.1 Flash")
@@ -341,7 +356,7 @@ curl http://127.0.0.1:3080/dsh-whale/audio.json
341
356
  - **图片/音效不显示、没声音**:确认插件包内 `assets/` 完整(`DSniang1.png`、`*.mp3`、`minecraft-exp-orb.wav` 等);缺失时相关功能静默降级。
342
357
  - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 凭据里配置。
343
358
  - **今日已用显示 `--`**:先等一次成功的余额观测;统计从该观测时刻开始,起点之前的消费不在此区间内。
344
- - **今日已用与官网有差异**:先核对同一账户、同一币种与同一统计区间;若有充值或其它余额调整,用「小鲸鱼记账 → 充值 / 余额校正」填写实际到账金额。余额接口只返回余额快照、不提供充值流水,充值与消费发生在同一次刷新间隔时需要实际到账金额才能校正。
359
+ - **今日已用与官网有差异**:先核对同一账户、同一币种与同一统计区间;若有充值或其它余额调整,用「小鲸鱼记账 → DeepSeek(内置)→ 设置 余额校正」填写实际到账金额。余额接口只返回余额快照、不提供充值流水,充值与消费发生在同一次刷新间隔时需要实际到账金额才能校正。
345
360
  - **充值后消费数字没变**:这是预期行为——充值不会增加消费;数字上方会出现「待核对余额调整」,提示你补充本统计区间的累计到账金额。
346
361
  - **每轮消耗不显示**:确认菜单里「每轮对话后自动显示消耗金额」已勾选;一轮对话要完整结束(`turn/end`)才结算。余额变化泡泡与消耗泡泡抢层时,提醒会退化为居中卡片。
347
362
  - **每轮消耗泡泡的内容**:菜单 → 每轮消耗提示 → 「自定义提示」里编辑(模块化,金额用 `{cost}`);这里同时能设自动关闭秒数与任务结束音效。
@@ -11,38 +11,145 @@ window.__dshWhaleWidget = true
11
11
  function dshwIsChatRoot(r) {
12
12
  return !!(r && (r.querySelector('textarea') || r.querySelector('[contenteditable="true"]')))
13
13
  }
14
- var dshwEnabled = false
14
+ var dshwStarted = false
15
+ function dshwStartOnce() {
16
+ if (dshwStarted) return
17
+ dshwStarted = true
18
+ try { dshwInit() } catch (err) {}
19
+ }
20
+ // 是否已到主聊天界面;是则启动(只启动一次,之后由 dshwInit 内部标记去重)
21
+ var dshwLastCheck = 0
22
+ function dshwTryStart(force) {
23
+ if (dshwStarted) return true
24
+ var now = Date.now()
25
+ // 连续 DOM 变化时合并检查,避免每次 mutation 都 querySelector
26
+ if (!force && now - dshwLastCheck < 200) return false
27
+ dshwLastCheck = now
28
+ try {
29
+ if (dshwIsChatRoot(document.getElementById('root'))) { dshwStartOnce(); return true }
30
+ } catch (err) {}
31
+ return false
32
+ }
15
33
  try {
16
- var dshwRoot = document.getElementById('root')
17
- // 初始已有 composer 主界面
18
- if (dshwIsChatRoot(dshwRoot)) {
19
- dshwEnabled = true
20
- } else {
21
- // 尚未渲染:轮询等待(主界面异步挂载),超过 5s 视为非主界面(市场/设置等)放弃
22
- var dshwPollTries = 0
23
- var dshwPoll = setInterval(function () {
24
- dshwPollTries++
25
- if (dshwIsChatRoot(document.getElementById('root'))) {
26
- clearInterval(dshwPoll)
27
- dshwEnabled = true
28
- try { dshwInit() } catch (err) {}
29
- return
34
+ if (!dshwIsChatRoot(document.getElementById('root'))) {
35
+ // 尚未渲染:MutationObserver 无限等待(v739 去掉原来的「5 秒死线」)。
36
+ // 原来 500ms × 10 次就永久放弃,而 window.__dshWhaleWidget 是一次性闸门 ——
37
+ // 慢启动机器、或页面最小化时定时器被浏览器节流,都会让"第一次没赶上"变成
38
+ // "这次会话永远不出现"(用户反馈 / issue #102 第 2 条)。
39
+ // 约束不变:检测到 composer 之前一行 DOM 都不碰、不注册任何全局监听。
40
+ var dshwObserver = null
41
+ try {
42
+ if (typeof MutationObserver === 'function') {
43
+ dshwObserver = new MutationObserver(function () {
44
+ if (dshwTryStart()) { try { dshwObserver.disconnect() } catch (err) {} }
45
+ })
46
+ dshwObserver.observe(document.documentElement || document.body, { childList: true, subtree: true })
30
47
  }
31
- if (dshwPollTries >= 10) {
32
- clearInterval(dshwPoll)
33
- // 非主界面:直接退出,不初始化
48
+ } catch (err) {}
49
+ // 兜底:observer 不可用时低频轮询继续等(不设上限,找到即停)
50
+ var dshwFallbackPoll = setInterval(function () {
51
+ if (dshwTryStart(true)) {
52
+ clearInterval(dshwFallbackPoll)
53
+ try { if (dshwObserver) dshwObserver.disconnect() } catch (err) {}
34
54
  }
35
- }, 500)
55
+ }, 2000)
36
56
  }
37
57
  } catch (err) {}
38
- if (!dshwEnabled) {
39
- // 非主界面(或等待超时):不初始化挂件
40
- return
41
- }
42
58
  function dshwInit() {
43
59
  if (window.__dshWhaleInit) return
44
60
  window.__dshWhaleInit = true
45
61
 
62
+ // ===== v739:音效改用 Web Audio 播放(不再用 <audio> / HTMLAudioElement)=====
63
+ // 为什么改:HTMLAudioElement 会被 macOS 注册进系统「正在播放」,带 Touch Bar 的机器上
64
+ // 每次按小鲸鱼都会弹出音频播放条并持续动画 → 明显卡顿(用户反馈)。AudioBufferSourceNode
65
+ // 不经过媒体元素,系统媒体控件不会出现,也没有那层额外的解码/合成开销。
66
+ // 兼容做法:给一个与 HTMLAudioElement 常用表面一致的 shim(play/pause/currentTime/volume/
67
+ // onended/preload/src),这样既有的播放、音量、重播、结束回调逻辑一行都不用改。
68
+ var dshwvAudioCtx = null
69
+ function dshwvAudio() {
70
+ try {
71
+ if (!dshwvAudioCtx) dshwvAudioCtx = new (window.AudioContext || window.webkitAudioContext)()
72
+ if (dshwvAudioCtx.state === 'suspended') { try { dshwvAudioCtx.resume() } catch (err) {} }
73
+ return dshwvAudioCtx
74
+ } catch (err) { return null }
75
+ }
76
+ var dshwvAudioBuffers = {} // url -> Promise<AudioBuffer>(解码结果缓存,同一片段不重复下载/解码)
77
+ function dshwvAudioBuffer(url) {
78
+ if (!url) return Promise.reject(new Error('empty url'))
79
+ if (!dshwvAudioBuffers[url]) {
80
+ dshwvAudioBuffers[url] = fetch(url, { cache: 'force-cache' })
81
+ .then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer() })
82
+ .then(function (raw) {
83
+ var c = dshwvAudio()
84
+ if (!c) throw new Error('no audio context')
85
+ return new Promise(function (res, rej) { c.decodeAudioData(raw, res, rej) })
86
+ })
87
+ .catch(function (err) { delete dshwvAudioBuffers[url]; throw err })
88
+ }
89
+ return dshwvAudioBuffers[url]
90
+ }
91
+ function dshwvSoundStop(el) {
92
+ el._token = (el._token || 0) + 1
93
+ var node = el._node
94
+ el._node = null
95
+ if (node) {
96
+ try { node.onended = null } catch (err) {}
97
+ try { node.stop() } catch (err) {}
98
+ }
99
+ }
100
+ function dshwvSound(url) {
101
+ var el = { preload: 'auto', volume: 1, onended: null, loop: false, _url: String(url || ''), _node: null, _gain: null, _offset: 0, _token: 0 }
102
+ Object.defineProperty(el, 'src', {
103
+ get: function () { return el._url },
104
+ set: function (v) { dshwvSoundStop(el); el._url = String(v || ''); el._offset = 0 },
105
+ })
106
+ Object.defineProperty(el, 'currentTime', {
107
+ get: function () { return el._offset },
108
+ // 既有逻辑用「currentTime = 0」表示重播 → 这里顺手停掉正在播的那一份
109
+ set: function (v) { el._offset = Number(v) || 0; dshwvSoundStop(el) },
110
+ })
111
+ el.play = function () {
112
+ var c = dshwvAudio()
113
+ if (!c || !el._url) return Promise.resolve()
114
+ try {
115
+ if (!el._gain) { el._gain = c.createGain(); el._gain.connect(c.destination) }
116
+ el._gain.gain.value = Math.max(0, Math.min(1, Number(el.volume) || 0))
117
+ } catch (err) { return Promise.resolve() }
118
+ var token = (el._token = (el._token || 0) + 1)
119
+ var url = el._url
120
+ dshwvAudioBuffer(url).then(function (buf) {
121
+ if (token !== el._token) return // 期间被重播/暂停/换源 → 丢弃这次
122
+ try {
123
+ var src = c.createBufferSource()
124
+ src.buffer = buf
125
+ src.connect(el._gain)
126
+ src.onended = function () {
127
+ if (el._node !== src) return
128
+ el._node = null
129
+ if (typeof el.onended === 'function') { try { el.onended() } catch (err) {} }
130
+ }
131
+ el._node = src
132
+ var dur = Math.max(0.001, buf.duration)
133
+ src.start(0, Math.max(0, el._offset) % dur)
134
+ } catch (err) {}
135
+ }).catch(function () {})
136
+ return Promise.resolve()
137
+ }
138
+ el.pause = function () { dshwvSoundStop(el) }
139
+ return el
140
+ }
141
+ // 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声。
142
+ // 任务结束音不是手势触发的,所以先挂一次性解锁(首次点击/按键后移除)。
143
+ try {
144
+ var dshwvAudioUnlock = function () {
145
+ dshwvAudio()
146
+ try { document.removeEventListener('pointerdown', dshwvAudioUnlock, true) } catch (err) {}
147
+ try { document.removeEventListener('keydown', dshwvAudioUnlock, true) } catch (err) {}
148
+ }
149
+ document.addEventListener('pointerdown', dshwvAudioUnlock, true)
150
+ document.addEventListener('keydown', dshwvAudioUnlock, true)
151
+ } catch (err) {}
152
+
46
153
  var MIN_SCALE = 0.6
47
154
  var MAX_SCALE = 2.5
48
155
  var STEP = 0.1
@@ -293,7 +400,7 @@ var css = [
293
400
  '.dshwv-usagepanel{position:fixed;z-index:26020;background:#fff;border:1px solid rgba(32,49,112,.35);border-radius:10px;box-shadow:0 8px 22px rgba(15,23,42,.22);padding:10px 12px;color-scheme:light;max-height:70vh;overflow-y:auto}',
294
401
  // 用量作为主菜单内的子界面
295
402
  '.dshwv-menuview{display:block}',
296
- '.dshwv-usage-sub{display:none;max-height:min(70vh,560px);overflow-y:auto;padding-right:2px;width:100%;box-sizing:border-box}',
403
+ '.dshwv-usage-sub{display:none;max-height:min(70vh,560px);overflow-y:auto;padding-right:2px;padding-bottom:12px;width:100%;box-sizing:border-box}',
297
404
  '.dshwv-usage-back{border:none;background:none;color:#203170;font-size:12px;font-weight:600;cursor:pointer;padding:0 0 2px;text-align:left;width:100%}',
298
405
  '.dshwv-usage-back:hover{color:#2f4488;text-decoration:underline}',
299
406
  '.dshwv-usagebody{display:flex;flex-direction:column;gap:2px;color:#203170;font-size:12px;min-width:0;overflow-x:hidden}',
@@ -1006,7 +1113,7 @@ function playTaskEndSound() {
1006
1113
  url = '/dsh-whale/sound/' + (parts[2] === 'release' ? 'release' : 'press') + '.mp3?set=' + parts[1]
1007
1114
  }
1008
1115
  if (!url) return
1009
- var a = new Audio(url)
1116
+ var a = dshwvSound(url)
1010
1117
  try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
1011
1118
  a.play().catch(function () {})
1012
1119
  } catch (err) {}
@@ -1025,7 +1132,7 @@ function playTaskEndGroupClick(groupId) {
1025
1132
  // 按压留空:无按下音,直接播松开(模拟按下即松开的完整点按);松开留空:只播按压
1026
1133
  if (pressEmpty) {
1027
1134
  if (!releaseEmpty) {
1028
- var relOnly = new Audio('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1135
+ var relOnly = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1029
1136
  try { relOnly.volume = vol } catch (err) {}
1030
1137
  relOnly.currentTime = 0
1031
1138
  var pr = relOnly.play()
@@ -1033,7 +1140,7 @@ function playTaskEndGroupClick(groupId) {
1033
1140
  }
1034
1141
  return
1035
1142
  }
1036
- var press = new Audio('/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId))
1143
+ var press = dshwvSound('/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId))
1037
1144
  try { press.volume = vol } catch (err) {}
1038
1145
  if (releaseEmpty) {
1039
1146
  press.currentTime = 0
@@ -1041,7 +1148,7 @@ function playTaskEndGroupClick(groupId) {
1041
1148
  if (pp && pp.catch) pp.catch(function () {})
1042
1149
  return
1043
1150
  }
1044
- var release = new Audio('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1151
+ var release = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
1045
1152
  try { release.volume = vol } catch (err) {}
1046
1153
  var relPlayed = false
1047
1154
  function playRel() {
@@ -1870,6 +1977,13 @@ function apiModelById(id) {
1870
1977
  for (var i = 0; i < apiModels.length; i++) if (apiModels[i] && apiModels[i].id === id) return apiModels[i]
1871
1978
  return null
1872
1979
  }
1980
+ // 「充值 / 余额校正」只属于固定的 DeepSeek(内置):宿主在模型条目里下发 canAdjustBalance,
1981
+ // 前端据此决定要不要在设置菜单里给出入口 —— 手动新增的同名模型、Kimi 等其它厂商都不会有,
1982
+ // 新增模板也不会自动继承(宿主还会在路由层再校验一次,见 balance-adjustments.json)。
1983
+ function apiCanAdjustBalance(model) {
1984
+ return !!(model && model.id === 'deepseek' && model.builtin === true &&
1985
+ model.provider === 'deepseek' && model.canAdjustBalance === true)
1986
+ }
1873
1987
  // 今日已用金额自带的币种(host 下发的 todayUsageCurrency):
1874
1988
  // 会话事件金额在 host 已按自定义单价折算成人民币,余额差则是厂商币种 → 显示必须按各自的币种,
1875
1989
  // 否则 USD 单价的模型会把人民币数值渲染成 $。
@@ -2800,7 +2914,7 @@ function openApiModelMenu(modelId) {
2800
2914
  else st.textContent = '余额 ' + apiFmtMoney(m.balance, m.currency) + ' · 今日已用 ' + apiFmtMoney(m.todayUsage, apiTodayCur(m))
2801
2915
  card.appendChild(st)
2802
2916
  var ms = (usageSet && usageSet.models && usageSet.models[modelId]) || {}
2803
- function rowOf(label, stateFn, onEdit) {
2917
+ function rowOf(label, stateFn, onEdit, buttonLabel) {
2804
2918
  var r = document.createElement('div')
2805
2919
  r.className = 'dshwv-audiorow'
2806
2920
  var l = document.createElement('span')
@@ -2817,8 +2931,9 @@ function openApiModelMenu(modelId) {
2817
2931
  info.style.textOverflow = 'ellipsis'
2818
2932
  info.textContent = stateFn()
2819
2933
  r.appendChild(info)
2820
- r.appendChild(apiBtn('编辑', 'dshwv-roleimport', onEdit))
2934
+ r.appendChild(apiBtn(buttonLabel || '编辑', 'dshwv-roleimport', onEdit))
2821
2935
  card.appendChild(r)
2936
+ return r
2822
2937
  }
2823
2938
  rowOf('余额预警', function () {
2824
2939
  var a = ms.alert
@@ -2875,13 +2990,15 @@ function openApiModelMenu(modelId) {
2875
2990
  card.appendChild(pr)
2876
2991
  }
2877
2992
  // 只读行(复用现有 .dshwv-audiorow / .dshwv-usage-hint,不引入新颜色与字体)
2878
- function readonlyRow(label, text) {
2993
+ function readonlyRow(label, text, hintHtml) {
2879
2994
  var r = document.createElement('div')
2880
2995
  r.className = 'dshwv-audiorow'
2881
2996
  var l = document.createElement('span')
2882
2997
  l.textContent = label
2883
2998
  l.style.flex = '0 0 auto'
2884
2999
  r.appendChild(l)
3000
+ // 需要「?」说明时,说明收进圆圈(悬停显示、点击钉住),与挂件其它说明圈同一套组件
3001
+ if (hintHtml) { try { r.appendChild(dshwvAskDot(hintHtml)) } catch (err) {} }
2885
3002
  var v = document.createElement('span')
2886
3003
  v.className = 'dshwv-usage-hint'
2887
3004
  v.style.flex = '1'
@@ -2914,6 +3031,32 @@ function openApiModelMenu(modelId) {
2914
3031
  pTxt = '未设置(沿用内置价目表)→ 在「密钥 / 接口」里填写'
2915
3032
  }
2916
3033
  readonlyRow('单价', pTxt)
3034
+ // 「已观测消费」= DeepSeek 账户口径(余额观测),只在内置项的设置里显示,与下面的「余额校正」配套。
3035
+ if (apiCanAdjustBalance(m)) {
3036
+ var acc = m.accounting || null
3037
+ var accAmt = (acc && typeof acc.amount === 'number') ? acc.amount : m.todayUsage
3038
+ var accInfo = (acc && acc.firstObservedAt)
3039
+ ? '统计起点(北京):' + accountingTime(acc.firstObservedAt) + '。起点前的消费未计入;该账户的观测包含同一个 key 在别处的消费。'
3040
+ : '尚无余额观测:先配置 DeepSeek API key 并成功刷新一次余额。'
3041
+ readonlyRow('已观测消费', ((acc && acc.label) || m.usageLabel || '已观测消费') + ' ' +
3042
+ (isFinite(Number(accAmt)) ? apiFmtMoney(accAmt, apiTodayCur(m)) : '--'), accInfo)
3043
+ // 需要用户动手的提示仍然直接显示(不藏进「?」里)
3044
+ if (acc && acc.needsReview) {
3045
+ var accWarn = document.createElement('div')
3046
+ accWarn.className = 'dshwv-usage-hint'
3047
+ accWarn.style.cssText = 'line-height:1.65;white-space:normal;margin:2px 0 4px'
3048
+ accWarn.textContent = '检测到余额增加,请用下面的「余额校正」核对本区间累计到账。'
3049
+ card.appendChild(accWarn)
3050
+ }
3051
+ }
3052
+ // 「充值 / 余额校正」入口:只给固定的 DeepSeek(内置),放在「单价」下方、沿用同一行布局与按钮样式。
3053
+ // 其它厂商、手动新增的 DeepSeek、仅改名为「DeepSeek(内置)」的模型都不会走到这里。
3054
+ if (apiCanAdjustBalance(m)) {
3055
+ var adjustmentRow = rowOf('余额校正', function () {
3056
+ return (m.accounting && m.accounting.label) || '充值与余额调整'
3057
+ }, function () { openBalanceAdjustment(modelId) }, '校正')
3058
+ adjustmentRow.lastElementChild.setAttribute('data-action', 'balance-adjustment')
3059
+ }
2917
3060
  // 币种不一致提示:今日已用按「自带币种」显示(会话事件为 CNY)。若与模型币种不同且没填汇率,
2918
3061
  // 今日预算提醒会被跳过(见 A 方案),这里给出可见的补救提示。
2919
3062
  var tuc = String((m && m.todayUsageCurrency) || '').toUpperCase()
@@ -3009,94 +3152,92 @@ function accountingTime(at) {
3009
3152
  } catch (err) { return String(at || '') }
3010
3153
  }
3011
3154
  var accountingMask = null
3012
- function openBalanceAdjustment() {
3013
- if (accountingMask) return
3014
- var previousFocus = document.activeElement
3155
+ function openBalanceAdjustment(modelId) {
3156
+ // 只有固定的 DeepSeek(内置)能打开:入口由宿主下发的 canAdjustBalance 控制,这里再挡一次
3157
+ if (!apiCanAdjustBalance(apiModelById(modelId)) || accountingMask) return
3158
+ var adjustmentUrl = '/dsh-whale/balance-adjustments.json?modelId=' + encodeURIComponent(modelId)
3159
+ closeApiModelPanel()
3160
+ // 与「额度」「余额预警 / 今日预算」等窗口共用同一套骨架与样式:
3161
+ // dshwv-usage-mask + dshwv-usage-card + dshwv-bubtitle/bubhint + apiPanelRow/apiTextInput + dshwv-bubbtns
3015
3162
  var mask = document.createElement('div')
3016
3163
  mask.className = 'dshwv-usage-mask'
3017
- mask.style.display = 'flex'
3018
- mask.style.zIndex = '2147483647'
3164
+ mask.style.zIndex = '30000'
3019
3165
  var card = document.createElement('form')
3020
3166
  card.className = 'dshwv-usage-card'
3021
- card.style.cssText = 'width:min(520px,calc(100vw - 32px));box-sizing:border-box;max-height:calc(100vh - 40px);overflow:auto;padding:22px;color:#203170;background:#fff;border-radius:18px;line-height:1.65'
3167
+ card.style.width = 'min(430px,94vw)'
3168
+ card.style.padding = '14px 16px'
3169
+ card.style.boxSizing = 'border-box'
3170
+ card.style.textAlign = 'left'
3022
3171
  card.setAttribute('role', 'dialog')
3023
3172
  card.setAttribute('aria-modal', 'true')
3024
- card.setAttribute('aria-label', '充值与余额校正')
3025
- var title = document.createElement('h2')
3026
- title.textContent = '充值与余额校正'
3027
- title.style.cssText = 'font-size:20px;margin:0 0 8px'
3028
- card.appendChild(title)
3029
- var introduction = document.createElement('p')
3173
+ card.setAttribute('aria-label', 'DeepSeek(内置)余额校正')
3174
+ // 内容可能比 82vh 高:中间这段自己滚,按钮行固定在底部(与 .dshwv-reswrap 同一做法)
3175
+ var body = document.createElement('div')
3176
+ body.style.cssText = 'min-height:0;overflow-y:auto;flex:1 1 auto'
3177
+ var title = document.createElement('div')
3178
+ title.className = 'dshwv-bubtitle'
3179
+ title.textContent = '余额校正 · DeepSeek(内置)'
3180
+ body.appendChild(title)
3181
+ var introduction = document.createElement('div')
3182
+ introduction.className = 'dshwv-bubhint'
3183
+ introduction.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left'
3030
3184
  introduction.textContent = '核对统计区间内的全部到账后,重新计算本地消费。此操作不会充值,也不会改变 DeepSeek 账户余额。'
3031
- introduction.style.cssText = 'font-size:13px;margin:0 0 14px'
3032
- card.appendChild(introduction)
3033
- function field(labelText, input) {
3034
- var label = document.createElement('label')
3035
- label.style.cssText = 'display:block;font-size:13px;margin:12px 0'
3036
- var text = document.createElement('span')
3037
- text.textContent = labelText
3038
- label.appendChild(text)
3039
- input.style.cssText = 'display:block;width:100%;box-sizing:border-box;min-height:40px;margin-top:5px;border:1px solid #b9c5e7;border-radius:8px;padding:8px;background:#fff;color:#203170;font:inherit'
3040
- label.appendChild(input)
3041
- card.appendChild(label)
3042
- return text
3043
- }
3044
- var dates = document.createElement('select')
3045
- field('记账日期(北京时间)', dates)
3185
+ body.appendChild(introduction)
3186
+ var dates = apiSelectEl([], '')
3187
+ body.appendChild(apiPanelRow('日期', dates))
3046
3188
  var interval = document.createElement('div')
3047
- interval.style.cssText = 'font-size:13px;white-space:pre-line;background:#f1f5ff;padding:12px;border-radius:10px'
3048
- card.appendChild(interval)
3049
- var credits = document.createElement('input')
3050
- credits.type = 'text'
3189
+ interval.className = 'dshwv-bubhint'
3190
+ interval.style.cssText = 'margin:0 0 8px;white-space:pre-line;line-height:1.6;text-align:left'
3191
+ body.appendChild(interval)
3192
+ var credits = apiTextInput('', '未到账请填 0')
3051
3193
  credits.inputMode = 'decimal'
3052
3194
  credits.required = true
3053
3195
  credits.autocomplete = 'off'
3054
- var creditsLabel = field('本统计区间累计到账金额', credits)
3055
- var debits = document.createElement('input')
3056
- debits.type = 'text'
3196
+ body.appendChild(apiPanelRow('累计到账', credits))
3197
+ var creditsHint = document.createElement('div')
3198
+ creditsHint.className = 'dshwv-bubhint'
3199
+ creditsHint.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left'
3200
+ body.appendChild(creditsHint)
3201
+ var debits = apiTextInput('0', '没有请填 0')
3057
3202
  debits.inputMode = 'decimal'
3058
3203
  debits.autocomplete = 'off'
3059
- var debitsLabel = field('非调用造成的余额减少(没有请填 0)', debits)
3060
- var help = document.createElement('p')
3061
- help.style.cssText = 'font-size:12px;margin:8px 0;color:#4c5e87'
3062
- help.textContent = '到账包括充值、赠金等;多次到账请填合计,不要只填最后一笔。非调用减少可包含到期赠金、余额退回等。仅填写上方统计起点之后的金额;保存会替换之前的校正值。'
3063
- card.appendChild(help)
3204
+ body.appendChild(apiPanelRow('非调用扣减', debits))
3205
+ var debitsHint = document.createElement('div')
3206
+ debitsHint.className = 'dshwv-bubhint'
3207
+ debitsHint.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left'
3208
+ body.appendChild(debitsHint)
3064
3209
  var preview = document.createElement('div')
3065
- preview.style.cssText = 'font-weight:600;font-size:15px;margin:14px 0'
3210
+ preview.className = 'dshwv-bubhint'
3211
+ preview.style.cssText = 'margin:0 0 8px;white-space:normal;line-height:1.6;text-align:left;font-weight:700;color:#203170'
3066
3212
  preview.setAttribute('aria-live', 'polite')
3067
- card.appendChild(preview)
3213
+ body.appendChild(preview)
3068
3214
  var confirmRow = document.createElement('label')
3069
- confirmRow.style.cssText = 'display:flex;gap:8px;align-items:flex-start;font-size:13px;margin:12px 0;cursor:pointer'
3215
+ confirmRow.style.cssText = 'display:flex;gap:6px;align-items:flex-start;flex:1;min-width:0;font-size:12px;color:#203170;cursor:pointer;line-height:1.5'
3070
3216
  var confirm = document.createElement('input')
3071
3217
  confirm.type = 'checkbox'
3072
- confirm.style.marginTop = '5px'
3218
+ confirm.style.marginTop = '1px'
3073
3219
  confirmRow.appendChild(confirm)
3074
3220
  confirmRow.appendChild(document.createTextNode('我已核对本统计区间的全部到账和非调用扣减'))
3075
- card.appendChild(confirmRow)
3221
+ body.appendChild(apiPanelRow('确认', confirmRow))
3076
3222
  var status = document.createElement('div')
3223
+ status.className = 'dshwv-bubhint'
3077
3224
  status.setAttribute('role', 'status')
3078
- status.style.cssText = 'font-size:13px;white-space:normal;color:#b33333;margin:8px 0'
3225
+ status.style.cssText = 'margin:0 0 6px;white-space:normal;line-height:1.6;text-align:left;color:#b33333'
3079
3226
  status.textContent = '正在刷新余额…'
3080
- card.appendChild(status)
3081
- var actions = document.createElement('div')
3082
- actions.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;justify-content:flex-end;margin-top:16px'
3083
- function button(text, callback) {
3084
- var btn = document.createElement('button')
3085
- btn.type = 'button'
3086
- btn.textContent = text
3087
- btn.className = 'dshwv-bubbtn dshwv-bubbtn-no'
3088
- btn.style.cssText = 'min-height:38px;padding:8px 12px;cursor:pointer'
3089
- if (callback) btn.addEventListener('click', callback)
3090
- actions.appendChild(btn)
3091
- return btn
3092
- }
3227
+ body.appendChild(status)
3228
+ card.appendChild(body)
3093
3229
  var busy = false
3094
3230
  function close() {
3095
3231
  if (busy) return
3096
3232
  document.removeEventListener('keydown', keyHandler)
3097
3233
  mask.remove()
3098
3234
  accountingMask = null
3099
- try { previousFocus.focus() } catch (err) {}
3235
+ // 关掉校正窗口后回到同一个设置菜单,并把焦点放回「校正」按钮
3236
+ openApiModelMenu(modelId)
3237
+ try {
3238
+ var returnButton = apiModelMaskEl && apiModelMaskEl.querySelector('[data-action="balance-adjustment"]')
3239
+ if (returnButton) returnButton.focus()
3240
+ } catch (err) {}
3100
3241
  }
3101
3242
  function keyHandler(e) {
3102
3243
  if (e.key === 'Escape') { e.preventDefault(); close() }
@@ -3109,25 +3250,17 @@ function openBalanceAdjustment() {
3109
3250
  else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
3110
3251
  }
3111
3252
  }
3112
- var reload = button('重新读取', function () { load() })
3113
- var reset = button('撤销该日校正', function () { submit('reset') })
3114
- button('取消', close)
3115
- var save = button('保存校正')
3116
- save.type = 'submit'
3117
- save.style.background = '#315de8'
3118
- save.style.color = '#fff'
3119
- save.style.border = '1px solid #315de8'
3253
+ // 按钮行与其它窗口完全一致:.dshwv-bubbtns + apiBtn 的标准按钮样式(不再自绘尺寸/配色)
3254
+ var actions = document.createElement('div')
3255
+ actions.className = 'dshwv-bubbtns'
3256
+ var reload = apiBtn('重新读取', 'dshwv-bubbtn dshwv-bubbtn-no', function () { load() })
3257
+ var reset = apiBtn('撤销该日校正', 'dshwv-bubbtn dshwv-bubbtn-no', function () { submit('reset') })
3258
+ var save = apiBtn('保存校正', 'dshwv-bubbtn dshwv-bubbtn-ok', function () { submit('save') })
3259
+ actions.appendChild(reload)
3260
+ actions.appendChild(reset)
3261
+ actions.appendChild(apiBtn('取消', 'dshwv-bubbtn dshwv-bubbtn-no', function () { close() }))
3262
+ actions.appendChild(save)
3120
3263
  card.appendChild(actions)
3121
- var scrollBody = document.createElement('div')
3122
- scrollBody.style.cssText = 'min-height:0;overflow:auto;flex:1 1 auto;padding-right:3px'
3123
- while (card.firstChild && card.firstChild !== actions) scrollBody.appendChild(card.firstChild)
3124
- card.insertBefore(scrollBody, actions)
3125
- card.style.display = 'flex'
3126
- card.style.flexDirection = 'column'
3127
- card.style.overflow = 'hidden'
3128
- actions.style.flex = '0 0 auto'
3129
- actions.style.paddingTop = '10px'
3130
- actions.style.borderTop = '1px solid #e2e8f5'
3131
3264
  mask.appendChild(card)
3132
3265
  mask.addEventListener('click', function (e) { if (e.target === mask) close() })
3133
3266
  document.body.appendChild(mask)
@@ -3141,6 +3274,8 @@ function openBalanceAdjustment() {
3141
3274
  reset.disabled = !selected || !selected.correctedAt || busy
3142
3275
  if (!selected) {
3143
3276
  interval.textContent = '尚无可校正的余额观测。请先配置 DeepSeek API key 并成功刷新余额。'
3277
+ creditsHint.textContent = ''
3278
+ debitsHint.textContent = ''
3144
3279
  return
3145
3280
  }
3146
3281
  interval.textContent = '统计起点:' + accountingTime(selected.firstObservedAt) +
@@ -3148,8 +3283,8 @@ function openBalanceAdjustment() {
3148
3283
  '\n起点余额 ' + usageMoney(selected.openingBalance, selected.currency) +
3149
3284
  ' → 当前余额 ' + usageMoney(selected.currentBalance, selected.currency) +
3150
3285
  '\n当前:' + selected.label + ' ' + usageMoney(selected.amount, selected.currency)
3151
- creditsLabel.textContent = '本统计区间累计到账金额(' + selected.currency + ',未到账请填 0'
3152
- debitsLabel.textContent = '非调用造成的余额减少(' + selected.currency + ',没有请填 0'
3286
+ creditsHint.textContent = '本统计区间累计到账金额(' + selected.currency + ',未到账请填 0):包括充值、赠金等;多次到账请填合计,不要只填最后一笔。'
3287
+ debitsHint.textContent = '非调用造成的余额减少(' + selected.currency + ',没有请填 0):到期赠金、余额退回等。仅填写统计起点之后的金额;保存会替换之前的校正值。'
3153
3288
  credits.value = selected.credits == null ? '' : String(selected.credits)
3154
3289
  debits.value = selected.otherDebits == null ? '0' : String(selected.otherDebits)
3155
3290
  confirm.checked = false
@@ -3173,7 +3308,7 @@ function openBalanceAdjustment() {
3173
3308
  reset.disabled = true
3174
3309
  reload.disabled = true
3175
3310
  status.textContent = '正在刷新余额…'
3176
- fetch('/dsh-whale/balance-adjustments.json', { cache: 'no-store' })
3311
+ fetch(adjustmentUrl, { cache: 'no-store' })
3177
3312
  .then(function (r) { return r.json() })
3178
3313
  .then(function (data) {
3179
3314
  if (!data.ok) throw new Error(data.error || '读取失败')
@@ -3198,16 +3333,29 @@ function openBalanceAdjustment() {
3198
3333
  reset.disabled = true
3199
3334
  reload.disabled = true
3200
3335
  status.textContent = '正在保存…'
3201
- fetch('/dsh-whale/balance-adjustments.json', {
3336
+ fetch(adjustmentUrl, {
3202
3337
  method: 'PUT', headers: { 'Content-Type': 'application/json' },
3203
3338
  body: JSON.stringify({
3204
- day: selected.day, revision: selected.revision, action: action,
3339
+ modelId: modelId, day: selected.day, revision: selected.revision, action: action,
3205
3340
  credits: credits.value.trim(), otherDebits: debits.value.trim() || '0', confirmed: confirm.checked
3206
3341
  })
3207
3342
  })
3208
3343
  .then(function (r) { return r.json() })
3209
3344
  .then(function (data) {
3210
3345
  if (!data.ok) throw new Error(data.error || '保存失败')
3346
+ // 保存后立刻用返回的摘要刷新该模型条目:关窗回到设置菜单时显示的就是新金额
3347
+ var model = apiModelById(modelId)
3348
+ var currentDay = new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10)
3349
+ if (apiCanAdjustBalance(model) && data.summary && data.summary.day === currentDay) {
3350
+ model.balance = data.summary.currentBalance
3351
+ model.currency = data.summary.currency
3352
+ model.todayUsage = data.summary.amount
3353
+ model.todayUsageCurrency = data.summary.currency
3354
+ model.usageSource = data.summary.source
3355
+ model.usageLabel = data.summary.label
3356
+ model.accounting = data.summary
3357
+ model.error = null
3358
+ }
3211
3359
  busy = false
3212
3360
  close()
3213
3361
  refresh(true)
@@ -3235,25 +3383,12 @@ function fillUsagePanel(d) {
3235
3383
  var today = d.today || {}
3236
3384
  var todayModels = today.models || []
3237
3385
  var hasEvToday = todayModels.length > 0
3238
- // ① 今日模型消费(独立容器+滚动);标题不带下分隔线
3239
- var tToday = uSectionTitle(today.label || '今日消费', usageMoney(today.total, today.currency))
3240
- tToday.style.borderBottom = 'none'
3241
- wrap.appendChild(tToday)
3242
- var coverage = document.createElement('div')
3243
- coverage.className = 'dshwv-usage-hint'
3244
- coverage.style.cssText = 'line-height:1.65;white-space:normal;margin:4px 0'
3245
- coverage.textContent = today.firstObservedAt
3246
- ? '统计起点(北京):' + accountingTime(today.firstObservedAt) + '。起点前的消费未计入。'
3247
- : '尚无余额观测;此处仅显示本机会话估算或旧版记录。'
3248
- if (today.needsReview) coverage.textContent += ' 检测到余额增加,请核对本区间累计到账。'
3249
- wrap.appendChild(coverage)
3250
- var adjustButton = document.createElement('button')
3251
- adjustButton.type = 'button'
3252
- adjustButton.className = 'dshwv-usage-more'
3253
- adjustButton.textContent = '充值 / 余额校正'
3254
- adjustButton.addEventListener('click', function (e) { e.stopPropagation(); openBalanceAdjustment() })
3255
- wrap.appendChild(adjustButton)
3256
- var modelTitle = uSectionTitle('本机模型费用 · 估算', usageMoney(today.modelTotal, 'CNY'))
3386
+ // ① 本机模型费用(所有模型的本地会话估算)
3387
+ // 「已观测消费」是 DeepSeek 账户口径(余额观测),已移到 小鲸鱼记账 DeepSeek(内置)→ 设置 里,
3388
+ // 与「余额校正」放在一起,避免在这里被误读成"全模型合计"。
3389
+ var modelTitle = uSectionTitle('本机模型费用', usageMoney(today.modelTotal, 'CNY'))
3390
+ // 标题与下方模型列表之间不再画分隔线(列表本身有边框,够了)
3391
+ modelTitle.style.borderBottom = 'none'
3257
3392
  modelTitle.title = '按本机 DSH 会话计算,不按账户余额比例缩放;两者覆盖范围不同。'
3258
3393
  wrap.appendChild(modelTitle)
3259
3394
  var todayBox = document.createElement('div')
@@ -3456,7 +3591,7 @@ function resPlayFragment(fid) {
3456
3591
  try {
3457
3592
  if (!fid) return
3458
3593
  if (resAudEl) { try { resAudEl.pause() } catch (err) {} resAudEl = null }
3459
- var a = new Audio('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(fid))
3594
+ var a = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(fid))
3460
3595
  try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
3461
3596
  a.onended = function () { resAudEl = null }
3462
3597
  resAudEl = a
@@ -8232,7 +8367,7 @@ var bubbleColorOpenMenu = null // 当前展开的颜色下拉(纯色/跑马灯,
8232
8367
  // —— 弹层/菜单层级统一助手(所有下拉与弹窗据此取高于当前可见层,避免互相压制) ——
8233
8368
  function visibleTopZ() {
8234
8369
  var top = 20500
8235
- var cand = [bubbleMask, bubbleItemMask, moduleMask, usageMoreMask, qeditEl, window.__dshwRemindMask]
8370
+ var cand = [bubbleMask, bubbleItemMask, moduleMask, usageMoreMask, qeditEl, window.__dshwRemindMask, apiModelMaskEl, accountingMask]
8236
8371
  function eff(el) {
8237
8372
  try {
8238
8373
  if (!el) return 0
@@ -12108,6 +12243,16 @@ function artCenterAt(left, top, w, h, flipped) {
12108
12243
  var cy = top + h - iw / 2
12109
12244
  return { cx: cx, cy: cy }
12110
12245
  }
12246
+ // v739(用户反馈「每次新实例的第一次余额请求都失败」):冷启动时凭据服务可能还没就绪,
12247
+ // 首次 DNS+TLS 也最慢;而客户端 25s 超时会先于宿主的两段重试结束 —— 结果是第一次必失败、
12248
+ // 只能干等 60 秒后的下一轮。这里失败后快速重试两次(1.5s / 3s),成功即重置计数。
12249
+ var balanceRetryLeft = 2
12250
+ function balanceRetryLater() {
12251
+ if (balanceRetryLeft <= 0) return
12252
+ var delay = balanceRetryLeft === 2 ? 1500 : 3000
12253
+ balanceRetryLeft--
12254
+ setTimeout(function () { try { refresh(false) } catch (err) {} }, delay)
12255
+ }
12111
12256
  function refresh(manual) {
12112
12257
  if (busy) return
12113
12258
  busy = true
@@ -12130,6 +12275,7 @@ function refresh(manual) {
12130
12275
  state.balance = nb
12131
12276
  state.currency = nc
12132
12277
  state.message = ''
12278
+ balanceRetryLeft = 2
12133
12279
  state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
12134
12280
  state.todayUsageCurrency = data.todayUsageCurrency || data.currency || 'CNY'
12135
12281
  state.usageLabel = data.usageLabel || '本地估算'
@@ -12165,12 +12311,14 @@ function refresh(manual) {
12165
12311
  state.status = 'error'
12166
12312
  state.message = (data && data.error) ? String(data.error) : '获取失败'
12167
12313
  render()
12314
+ balanceRetryLater()
12168
12315
  }
12169
12316
  })
12170
12317
  .catch(function () {
12171
12318
  state.status = 'error'
12172
12319
  state.message = '获取失败'
12173
12320
  render()
12321
+ balanceRetryLater()
12174
12322
  })
12175
12323
  .finally(function () {
12176
12324
  busy = false
@@ -12410,12 +12558,12 @@ function applySoundSet() {
12410
12558
  var pEmpty = audioGroupSlotEmpty(soundSet, 'press')
12411
12559
  var rEmpty = audioGroupSlotEmpty(soundSet, 'release')
12412
12560
  if (pEmpty) { pressAudio = null } else {
12413
- pressAudio = new Audio('/dsh-whale/sound/press.mp3?set=' + soundSet)
12561
+ pressAudio = dshwvSound('/dsh-whale/sound/press.mp3?set=' + soundSet)
12414
12562
  pressAudio.preload = 'auto'
12415
12563
  pressAudio.volume = soundVol
12416
12564
  }
12417
12565
  if (rEmpty) { releaseAudio = null } else {
12418
- releaseAudio = new Audio('/dsh-whale/sound/release.mp3?set=' + soundSet)
12566
+ releaseAudio = dshwvSound('/dsh-whale/sound/release.mp3?set=' + soundSet)
12419
12567
  releaseAudio.preload = 'auto'
12420
12568
  releaseAudio.volume = soundVol
12421
12569
  }
@@ -13449,12 +13597,12 @@ function audioEditPreviewEnsure(force) {
13449
13597
  if (audioEditPreviewRelease) { audioEditPreviewRelease.pause(); audioEditPreviewRelease = null }
13450
13598
  } catch (err) {}
13451
13599
  if (pressId) {
13452
- audioEditPreviewEl = new Audio('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(pressId))
13600
+ audioEditPreviewEl = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(pressId))
13453
13601
  audioEditPreviewEl.preload = 'auto'
13454
13602
  audioEditPreviewEl.volume = soundVol
13455
13603
  }
13456
13604
  if (releaseId) {
13457
- audioEditPreviewRelease = new Audio('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(releaseId))
13605
+ audioEditPreviewRelease = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(releaseId))
13458
13606
  audioEditPreviewRelease.preload = 'auto'
13459
13607
  audioEditPreviewRelease.volume = soundVol
13460
13608
  }
@@ -14616,8 +14764,6 @@ function pollLastTurn() {
14616
14764
  }
14617
14765
  setInterval(pollLastTurn, 1000)
14618
14766
  }
14619
- // 主界面检测通过后执行挂件初始化(非主界面时 dshwInit 不会执行)
14620
- if (dshwEnabled) {
14621
- try { dshwInit() } catch (err) {}
14622
- }
14767
+ // 主界面检测通过(或稍后由 MutationObserver 检测到)后执行挂件初始化;非主界面不启动
14768
+ try { dshwTryStart(true) } catch (err) {}
14623
14769
  })()
package/lib/index.js CHANGED
@@ -75,6 +75,11 @@ const API_FILE_CANDIDATES = [
75
75
  path.join(DSH_HOME, 'profiles', 'web', '.dshw-api.json'),
76
76
  ]
77
77
  const API_BUILTIN_ID = 'deepseek'
78
+ // 「充值 / 余额校正」只属于固定的 DeepSeek(内置):模型条目下发 canAdjustBalance,路由层再校验一次。
79
+ // 新增厂商模板 / 手动新增的同名模型 / Kimi 等其它厂商都不会继承这个能力。
80
+ function canAdjustBuiltinBalance(model) {
81
+ return !!(model && model.id === API_BUILTIN_ID && model.builtin === true && model.provider === 'deepseek')
82
+ }
78
83
  // 内置厂商模板:balance 描述「怎么取余额」。json 路径支持 a.b[0].c;scale 为取值后的乘数。
79
84
  // 余额接口已按官方文档 / 社区参考核对(DeepSeek、OpenRouter、Kimi、阶跃、Novita、OpenAI 兼容中转站);
80
85
  // 硅基流动的余额接口已官方下线(410),故改为「无余额接口 + /v1/models 探活」;
@@ -711,11 +716,19 @@ export default {
711
716
  }
712
717
 
713
718
  async function fetchBalance() {
714
- let cred
715
- try {
716
- cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
717
- } catch (err) {
718
- return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
719
+ // v739(用户反馈「每次新实例的第一次余额请求都失败」):
720
+ // ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
721
+ // 单次超时从 20s 收到 8s,保证「2 次尝试 + 退避」明显短于前端 FETCH_TIMEOUT_MS(25s) ——
722
+ // 否则前端先 abort,用户看到的永远是「第一次失败」,只能干等 60 秒后的下一轮。
723
+ let cred = null
724
+ for (let attempt = 0; attempt < 2; attempt++) {
725
+ try {
726
+ cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
727
+ break
728
+ } catch (err) {
729
+ if (attempt === 0) { await new Promise((r) => setTimeout(r, 700)); continue }
730
+ return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
731
+ }
719
732
  }
720
733
  if (!cred) {
721
734
  return { ok: false, code: 'NO_KEY', error: '未配置 DEEPSEEK_API_KEY' }
@@ -726,7 +739,7 @@ export default {
726
739
  try {
727
740
  res = await fetch(BALANCE_URL, {
728
741
  headers: { Authorization: 'Bearer ' + cred.value },
729
- signal: AbortSignal.timeout(20000),
742
+ signal: AbortSignal.timeout(8000),
730
743
  })
731
744
  } catch (err) {
732
745
  lastErr = err
@@ -916,7 +929,7 @@ export default {
916
929
  const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
917
930
  for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
918
931
  return {
919
- ok: true, version: '0.3.1', today: todayData, days7,
932
+ ok: true, version: '0.3.3', today: todayData, days7,
920
933
  total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
921
934
  all: {
922
935
  days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
@@ -1824,6 +1837,7 @@ export default {
1824
1837
  let entry = {
1825
1838
  id: m.id, name: m.name, provider: m.provider, currency: m.currency,
1826
1839
  keyRef: m.keyRef, builtin: !!m.builtin, baseUrl: m.baseUrl || '',
1840
+ canAdjustBalance: canAdjustBuiltinBalance(m),
1827
1841
  matchIds: m.matchIds || [], settings: settings.models && settings.models[m.id] ? settings.models[m.id] : null,
1828
1842
  price: m.price || null,
1829
1843
  // 手动额度(订阅/资源包):总量、单位、已用、重置周期;前端据此显示额度模块
@@ -1959,7 +1973,7 @@ export default {
1959
1973
  const led = readUsageLedger()
1960
1974
  const summary = daySummary(led, todayKey())
1961
1975
  return {
1962
- ...visible, version: '0.3.1', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
1976
+ ...visible, version: '0.3.3', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
1963
1977
  todayUsage: summary.amount, todayUsageCurrency: summary.currency,
1964
1978
  usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
1965
1979
  accounting: balanceSummary(led, todayKey()),
@@ -2498,9 +2512,23 @@ export default {
2498
2512
  path: '/dsh-whale/balance-adjustments.json',
2499
2513
  handler: async (req, res) => {
2500
2514
  try {
2515
+ // 先核对身份再碰余额或账本:必须是唯一且明确的 DeepSeek(内置)。
2516
+ // 新增厂商模板不会自动继承这个能力,也拒绝其它模型读写校正。
2517
+ const targetIds = new URL(req.url || '/', 'http://localhost').searchParams.getAll('modelId')
2518
+ const targetModelId = targetIds.length === 1 ? targetIds[0] : null
2519
+ if (targetModelId !== API_BUILTIN_ID || !canAdjustBuiltinBalance(apiModelById(targetModelId))) {
2520
+ const error = new Error('余额校正仅支持 DeepSeek(内置),请从该模型的设置菜单进入')
2521
+ error.status = 403
2522
+ throw error
2523
+ }
2501
2524
  if (req.method === 'PUT') {
2502
2525
  const input = JSON.parse(await readBodyMax(req, 8192))
2503
2526
  if (!input || typeof input !== 'object') throw new Error('校正内容无效')
2527
+ if (input.modelId !== targetModelId) {
2528
+ const error = new Error('校正请求的模型不匹配,仅支持 DeepSeek(内置)')
2529
+ error.status = 403
2530
+ throw error
2531
+ }
2504
2532
  if (input.day === todayKey()) {
2505
2533
  const fresh = await getBalance(true)
2506
2534
  if (!fresh.ok || fresh.stale) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义",
5
5
  "keywords": [
6
6
  "dsh",