dsh-whale-widget 0.2.6 → 0.2.7

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.
Files changed (3) hide show
  1. package/README.md +31 -15
  2. package/lib/index.js +168 -58
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -43,38 +43,54 @@ dsh-whale-widget/
43
43
 
44
44
  ## 安装
45
45
 
46
- ### 方式 A:本地开发安装(当前项目)
46
+ ### 方式 A:本地安装(推荐,从当前仓库)
47
47
 
48
- 在项目根目录(`DeepSeek-Balance-Whale-Widget-main`)执行:
48
+ 在**仓库根目录**(`DeepSeek-Balance-Whale-Widget`,即 `package.json` 所在目录)执行:
49
49
 
50
50
  ```powershell
51
- dsh plugin --profile web add link:.\dsh-whale-widget
51
+ dsh plugin --profile web add link:.
52
52
  ```
53
53
 
54
54
  说明:
55
55
 
56
56
  - `dsh plugin` 会把参数转发给 pnpm,并在成功后自动把 `dsh-whale-widget` 加入 `dsh.profile.bundles`
57
- - 使用 `link:` 会在 profile 的 `node_modules` 里链接到当前源码目录,方便继续改代码
58
- - 安装完成后重启 `dsh web`,再 F5 刷新浏览器
59
- - **如果之后移动了源码目录**,必须重新到新的项目根目录执行一次:
57
+ - **`link:.` 表示链接当前目录**(仓库根目录本身就是插件包)。如果你复制了仓库到别处,用绝对路径:
60
58
  ```powershell
61
- dsh plugin --profile web add link:.\dsh-whale-widget
59
+ dsh plugin --profile web add link:D:\你的路径\DeepSeek-Balance-Whale-Widget
62
60
  ```
63
- 因为 `link:` 记录的是源目录的绝对路径;移动后旧链接会失效。若提示已存在/冲突,可先 `dsh plugin --profile web remove dsh-whale-widget` 再重新 add。
61
+ - ⚠️ 不要用 `link:.\dsh-whale-widget`——仓库里**没有** `dsh-whale-widget/` 子目录,这样会安装成普通依赖而非插件,重启后挂件不出现
62
+ - 安装完成后重启 `dsh web`,再 F5 刷新浏览器
63
+ - **如果之后移动了源码目录**,必须重新执行一次 `dsh plugin --profile web add link:.<新路径>`。若提示已存在/冲突,先 `dsh plugin --profile web remove dsh-whale-widget` 再重新 add
64
64
 
65
65
  ### 方式 B:发布到 npm 后安装
66
66
 
67
- 如果你把这个包发布到 npm:
68
-
69
67
  ```powershell
70
- cd dsh-whale-widget
71
- npm publish
68
+ dsh plugin --profile web add dsh-whale-widget
72
69
  ```
73
70
 
74
- 然后任意机器上安装:
71
+ ### 给 AI 的安装说明(用 dsh 辅助安装时,直接复制给 AI)
75
72
 
76
- ```powershell
77
- dsh plugin --profile web add dsh-whale-widget
73
+ 如果你希望让另一个 DSH / AI 助手帮你安装,把下面这段发给它即可:
74
+
75
+ ```
76
+ 请帮我安装插件 dsh-whale-widget,来源是 GitHub 仓库 MeteorNOX/DeepSeek-Balance-Whale-Widget。
77
+
78
+ 步骤:
79
+ 1. 确保 pnpm 可用(没有就先:npm install -g pnpm)
80
+ 2. 在 Web profile 安装:
81
+ dsh plugin --profile web add dsh-whale-widget
82
+ 如果要从本地仓库链接安装(例如本地克隆的仓库根目录),则用:
83
+ dsh plugin --profile web add link:.<仓库绝对路径>
84
+ (注意:仓库根目录就是插件包,不要写成 link:.\dsh-whale-widget 这种带子目录的路径)
85
+ 3. 如果报 pnpm 阻止构建脚本(allowBuilds 相关),在 C:\Users\<用户名>\.dsh\profiles\web\pnpm-workspace.yaml 的 allowBuilds 下加对应的包 key,然后重跑
86
+ 4. 重启 dsh web,然后 F5 刷新浏览器
87
+
88
+ 安装后验证:
89
+ - dsh --profile web --dump-config 应该能看到 dsh-whale-widget 在 bundles 里
90
+ - curl http://127.0.0.1:3080/dsh-whale/balance.json 应返回 200 JSON(含 totalBalance)
91
+ - curl http://127.0.0.1:3080/dsh-whale/widget.js 应返回 200 JS
92
+
93
+ 另外请检查 DSH 凭据里是否配置了 DEEPSEEK_API_KEY(没有就提示用户配置;DEEPSEEK_PLATFORM_TOKEN 可选,不配也能用默认的记账模式)。
78
94
  ```
79
95
 
80
96
  ### 关于令牌(安装后必读)
package/lib/index.js CHANGED
@@ -69,11 +69,14 @@ const PEAK_HOURS = [
69
69
  [14, 18],
70
70
  ]
71
71
  const BASE_PRICE = { hit: [0.05, 0.1], miss: [1.5, 3.0], out: [4.5, 9.0] }
72
+ // deepseek-v4-pro 为 flash 的 3 倍价(官方 2026-08-17 生效);vision-exp 与 flash 同价
73
+ const PRO_PRICE = { hit: [0.15, 0.3], miss: [4.5, 9.0], out: [13.5, 27.0] }
72
74
  const PRICING = {
75
+ 'deepseek-v4-flash-vision-exp': BASE_PRICE,
76
+ 'deepseek-v4-flash': BASE_PRICE,
77
+ 'deepseek-v4-pro': PRO_PRICE,
73
78
  'deepseek-chat': BASE_PRICE,
74
79
  'deepseek-reasoner': BASE_PRICE,
75
- 'deepseek-v4-flash': BASE_PRICE,
76
- 'deepseek-v4-pro': BASE_PRICE,
77
80
  _default: BASE_PRICE,
78
81
  }
79
82
  function priceFor(model) {
@@ -151,16 +154,17 @@ var css = [
151
154
  '.dshwv-menu-btn.dshwv-menu-btn-visible{opacity:1}',
152
155
  '.dshwv-menu-btn span{display:block;width:14px;height:2px;background:#fff;border-radius:1px}',
153
156
  '.dshwv-menu-btn:hover{background:#203170}',
154
- '.dshwv-menu{position:fixed;min-width:172px;background:rgba(255,255,255,.92);border:1px solid rgba(32,49,112,.35);border-radius:10px;padding:10px 12px;opacity:0;transform:scale(.92) translateY(-4px);transform-origin:top right;transition:opacity .18s ease,transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:none;z-index:10000;box-shadow:0 6px 18px rgba(0,0,0,.18);color-scheme:light}',
157
+ '.dshwv-menu{position:fixed;min-width:196px;background:rgba(255,255,255,.92);border:1px solid rgba(32,49,112,.35);border-radius:10px;padding:10px 12px;opacity:0;transform:scale(.92) translateY(-4px);transform-origin:top right;transition:opacity .18s ease,transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:none;z-index:10000;box-shadow:0 6px 18px rgba(0,0,0,.18);color-scheme:light}',
155
158
  '.dshwv-menu.dshwv-menu-open{opacity:1;transform:scale(1) translateY(0);pointer-events:auto}',
156
159
  '.dshwv-menu-row{display:flex;align-items:center;gap:8px;margin:5px 0;color:#203170;font-size:12px;white-space:nowrap}',
157
160
  '.dshwv-range{flex:1;min-width:0;accent-color:#203170}',
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}',
161
+ '.dshwv-number{width:44px;border:1px solid rgba(32,49,112,.4);border-radius:6px;padding:2px 4px;font-size:12px;color:#203170;background:#fff;box-sizing:border-box}',
162
+ '.dshwv-number:disabled{opacity:.4;background:rgba(32,49,112,.06);cursor:not-allowed}',
159
163
  '.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}',
160
164
  '.dshwv-sound:hover{background:rgba(32,49,112,.16)}',
161
165
  '.dshwv-check{width:16px;height:16px;accent-color:#203170;cursor:pointer;flex:0 0 auto}',
162
166
  '.dshwv-menu-sep{height:1px;background:rgba(32,49,112,.25);margin:6px 0}',
163
- '.dshwv-volpct{width:36px;text-align:right;color:#203170;font-size:12px}'
167
+ '.dshwv-volpct{width:44px;text-align:right;color:#203170;font-size:12px}'
164
168
  ].join('\\n')
165
169
 
166
170
  var styleEl = document.createElement('style')
@@ -212,6 +216,13 @@ scaleNumber.value = '10'
212
216
  scaleInput.addEventListener('pointerdown', function () { root.style.transition = 'none' })
213
217
  scaleInput.addEventListener('input', function () { setScale(scaleInput.value) })
214
218
  scaleInput.addEventListener('change', function () { root.style.transition = '' })
219
+ scaleNumber.addEventListener('focus', function () { root.style.transition = 'none' })
220
+ scaleNumber.addEventListener('blur', function () { root.style.transition = '' })
221
+ scaleNumber.addEventListener('input', function () {
222
+ var v = Math.round(Number(scaleNumber.value))
223
+ var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
224
+ setScale(s)
225
+ })
215
226
  scaleNumber.addEventListener('change', function () {
216
227
  var v = Math.round(Number(scaleNumber.value))
217
228
  var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
@@ -258,8 +269,26 @@ turnCostCloseInput.min = '0'
258
269
  turnCostCloseInput.step = '1'
259
270
  turnCostCloseInput.className = 'dshwv-number'
260
271
  turnCostCloseInput.value = '5'
272
+ turnCostCloseInput.disabled = false // 跟随「每轮消耗提示」开关
261
273
  turnCostCloseInput.title = '填 0 表示不自动关闭,需手动点击关闭'
274
+ turnCostCloseInput.addEventListener('input', function () { setTurnCostClose(turnCostCloseInput.value) })
262
275
  turnCostCloseInput.addEventListener('change', function () { setTurnCostClose(turnCostCloseInput.value) })
276
+ var scrollGapToggle = document.createElement('input')
277
+ scrollGapToggle.type = 'checkbox'
278
+ scrollGapToggle.className = 'dshwv-check'
279
+ scrollGapToggle.checked = false
280
+ scrollGapToggle.title = '开启后挂件右侧按设定像素避开滚动条;关闭则贴边(盖住滚动条)'
281
+ scrollGapToggle.addEventListener('change', function () { setScrollGapOn(scrollGapToggle.checked) })
282
+ var scrollGapInput = document.createElement('input')
283
+ scrollGapInput.type = 'number'
284
+ scrollGapInput.min = '0'
285
+ scrollGapInput.step = '1'
286
+ scrollGapInput.className = 'dshwv-number'
287
+ scrollGapInput.value = '17'
288
+ scrollGapInput.disabled = true // 默认避让关 → 宽度不可修改,勾选后启用
289
+ scrollGapInput.title = '避让滚动条的像素宽度,填 0 表示贴边'
290
+ scrollGapInput.addEventListener('input', function () { setScrollGapPx(scrollGapInput.value) })
291
+ scrollGapInput.addEventListener('change', function () { setScrollGapPx(scrollGapInput.value) })
263
292
  var row1 = menuRow()
264
293
  row1.appendChild(menuLabel('大小'))
265
294
  row1.appendChild(scaleInput)
@@ -294,21 +323,26 @@ row6.appendChild(bubbleToggle)
294
323
  var menuSep1 = document.createElement('div')
295
324
  menuSep1.className = 'dshwv-menu-sep'
296
325
  var row7 = menuRow()
297
- row7.appendChild(menuLabel('每轮消耗'))
326
+ row7.appendChild(menuLabel('每轮消耗提示'))
298
327
  row7.appendChild(turnCostToggle)
299
- var row8 = menuRow()
300
- row8.appendChild(menuLabel('自动关闭时间'))
301
- row8.appendChild(turnCostCloseInput)
302
- row8.appendChild(menuLabel('秒'))
328
+ row7.appendChild(menuLabel('自动关闭'))
329
+ row7.appendChild(turnCostCloseInput)
330
+ row7.appendChild(menuLabel('秒'))
331
+ var row9 = menuRow()
332
+ row9.appendChild(menuLabel('避让滚动条'))
333
+ row9.appendChild(scrollGapToggle)
334
+ row9.appendChild(menuLabel('宽度'))
335
+ row9.appendChild(scrollGapInput)
336
+ row9.appendChild(menuLabel('px'))
303
337
  menuBox.appendChild(row1)
304
338
  menuBox.appendChild(row2)
305
339
  menuBox.appendChild(row3)
306
340
  menuBox.appendChild(row4)
307
341
  menuBox.appendChild(row5)
308
342
  menuBox.appendChild(row6)
309
- menuBox.appendChild(menuSep1)
310
343
  menuBox.appendChild(row7)
311
- menuBox.appendChild(row8)
344
+ menuBox.appendChild(menuSep1)
345
+ menuBox.appendChild(row9)
312
346
 
313
347
  var textBox = document.createElement('div')
314
348
  textBox.className = 'dshwv-text'
@@ -613,9 +647,10 @@ function viewport() {
613
647
  }
614
648
  }
615
649
  function rightGap() {
616
- var sw = window.innerWidth - document.documentElement.clientWidth
617
- if (sw < 8) sw = 10 // 悬浮滚动条测量为0时兜底
618
- return sw + 2 // 滚动条宽度 + 2px
650
+ // 开关关闭:贴边(不避让滚动条)
651
+ if (!scrollGapOn) return 0
652
+ // 开启:用用户填写的像素;填 0 也贴边
653
+ return scrollGapPx > 0 ? scrollGapPx : 0
619
654
  }
620
655
  function fmt(balance, currency) {
621
656
  var num = Number(balance)
@@ -694,8 +729,7 @@ function settle() {
694
729
  state.left = state.hOff
695
730
  } else {
696
731
  state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
697
- }
698
- if (state.v === 'bottom') {
732
+ } if (state.v === 'bottom') {
699
733
  state.top = Math.max(0, vp.h - h - state.vOff)
700
734
  } else if (state.v === 'top') {
701
735
  state.top = state.vOff
@@ -778,9 +812,13 @@ var bubbleOn = true
778
812
  var turnCostOn = true
779
813
  var turnCostCloseMs = 5000
780
814
  var costBubbleActive = false
815
+ var scrollGapOn = false
816
+ var scrollGapPx = 17
781
817
  function saveConfig() {
782
818
  try {
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 }) })
819
+ 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, scrollGapOn: scrollGapOn, scrollGapPx: scrollGapPx }) })
820
+ // 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
821
+ // v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
784
822
  var vp = viewport()
785
823
  var w = root.offsetWidth || root.getBoundingClientRect().width || 0
786
824
  var h = root.offsetHeight || root.getBoundingClientRect().height || 0
@@ -788,9 +826,13 @@ function saveConfig() {
788
826
  var rightDist = vp.w - state.left - w
789
827
  var topDist = state.top
790
828
  var bottomDist = vp.h - state.top - h
829
+ var hAnchor = leftDist <= rightDist ? 'left' : 'right'
830
+ var hDistRaw = Math.round(Math.min(leftDist, rightDist))
831
+ var hDist = hAnchor === 'right' && scrollGapOn ? Math.max(0, hDistRaw - rightGap()) : hDistRaw
791
832
  localStorage.setItem('dshw-pos', JSON.stringify({
792
- hAnchor: leftDist <= rightDist ? 'left' : 'right',
793
- hDist: Math.round(Math.min(leftDist, rightDist)),
833
+ v: 2,
834
+ hAnchor: hAnchor,
835
+ hDist: hDist,
794
836
  vAnchor: topDist <= bottomDist ? 'top' : 'bottom',
795
837
  vDist: Math.round(Math.min(topDist, bottomDist))
796
838
  }))
@@ -817,20 +859,41 @@ function setBubbleOn(v) {
817
859
  function setTurnCostOn(v) {
818
860
  turnCostOn = !!v
819
861
  turnCostToggle.checked = turnCostOn
862
+ turnCostCloseInput.disabled = !turnCostOn
820
863
  saveConfig()
821
864
  if (!turnCostOn) hideCostBubble()
822
865
  }
823
866
  function setTurnCostClose(v) {
867
+ if (!turnCostOn) return
824
868
  var n = Math.max(0, Math.round(Number(v) || 0))
825
869
  turnCostCloseMs = n * 1000
826
870
  turnCostCloseInput.value = String(n)
827
871
  saveConfig()
828
872
  }
873
+ function setScrollGapOn(v) {
874
+ scrollGapOn = !!v
875
+ scrollGapToggle.checked = scrollGapOn
876
+ scrollGapInput.disabled = !scrollGapOn
877
+ saveConfig()
878
+ settle()
879
+ }
880
+ function setScrollGapPx(v) {
881
+ if (!scrollGapOn) return
882
+ var n = Math.max(0, Math.round(Number(v) || 0))
883
+ scrollGapPx = n
884
+ scrollGapInput.value = String(n)
885
+ saveConfig()
886
+ settle()
887
+ }
829
888
  function scaleToDisplay(s) {
830
889
  return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
831
890
  }
832
891
  function setScale(v) {
833
892
  var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number(v))) * 10) / 10
893
+ // 缩放测量需要 left/top 立即到位:临时禁用过渡(滚轮/数字框路径没有
894
+ // 滑块 pointerdown 的 transition:none,否则 r2 测的是过渡起点导致错锚点)
895
+ var prevTrans = root.style.transition
896
+ root.style.transition = 'none'
834
897
  var rect = root.getBoundingClientRect()
835
898
  // fixed point: the whale's corner — bottom-right when unflipped, bottom-left
836
899
  // when flipped. Growing extends the widget up-left / up-right from that
@@ -854,6 +917,12 @@ function setScale(v) {
854
917
  }
855
918
  state.top = Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height))
856
919
  express()
920
+ // 恢复过渡必须延迟到下一帧:本帧 left/top 已在 none 下设置并提交,
921
+ // 立即恢复会让浏览器对「刚改过的 left/top」重新评估并播放过渡动画
922
+ // (翻转时叠加 transform .3s 更明显,表现为抽搐)。
923
+ requestAnimationFrame(function () {
924
+ root.style.transition = prevTrans
925
+ })
857
926
  }
858
927
  function setVol(v) {
859
928
  var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100
@@ -1079,7 +1148,6 @@ function onDocPointerDown(e) {
1079
1148
  document.addEventListener('pointermove', onDocPointerMove, true)
1080
1149
  document.addEventListener('pointerup', onDocPointerUp, true)
1081
1150
  document.addEventListener('pointercancel', onDocPointerCancel, true)
1082
- document.addEventListener('click', onDocClickStopper, true)
1083
1151
  }
1084
1152
  function onDocPointerMove(e) {
1085
1153
  if (!drag || !drag.active) return
@@ -1093,12 +1161,21 @@ function onDocPointerMove(e) {
1093
1161
  state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
1094
1162
  express()
1095
1163
  }
1096
- function onDocPointerUp(e) { endDrag(e, true) }
1164
+ function onDocPointerUp(e) {
1165
+ // 拦截鲸鱼区域内的 pointerup:防止下方元素(如文件行)监听 pointerup 穿透误触发
1166
+ try { if (isWhaleHit(e)) { e.preventDefault(); e.stopPropagation() } } catch (err) {}
1167
+ endDrag(e, true)
1168
+ }
1097
1169
  function onDocPointerCancel(e) { endDrag(e, false) }
1098
1170
  function onDocClickStopper(e) {
1171
+ // 只在鲸鱼命中区域拦截 click(保持透明区 pass-through)。
1172
+ // 持久注册(不随 endDrag 移除)——click 在 pointerup 之后派发,
1173
+ // 若在 endDrag 移除会导致 click 穿透到下方元素(如误打开文件)。
1174
+ if (!isWhaleHit(e)) return
1099
1175
  try { e.preventDefault(); e.stopPropagation() } catch (err) {}
1100
1176
  }
1101
1177
  document.addEventListener('pointerdown', onDocPointerDown, true)
1178
+ document.addEventListener('click', onDocClickStopper, true)
1102
1179
 
1103
1180
  var widgetCursor = ''
1104
1181
  function setWidgetCursor(v) {
@@ -1128,7 +1205,6 @@ function endDrag(e, clickAllowed) {
1128
1205
  document.removeEventListener('pointermove', onDocPointerMove, true)
1129
1206
  document.removeEventListener('pointerup', onDocPointerUp, true)
1130
1207
  document.removeEventListener('pointercancel', onDocPointerCancel, true)
1131
- document.removeEventListener('click', onDocClickStopper, true)
1132
1208
  pressUp()
1133
1209
  root.classList.remove('dshwv-dragging')
1134
1210
  setWidgetCursor(isWhaleHit(e) ? 'grab' : '')
@@ -1168,17 +1244,21 @@ function endDrag(e, clickAllowed) {
1168
1244
  function applyAnchorPos() {
1169
1245
  try {
1170
1246
  var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
1171
- if (!a || (a.hAnchor !== 'left' && a.hAnchor !== 'right') || typeof a.hDist !== 'number' ||
1247
+ if (!a || a.v !== 2 || (a.hAnchor !== 'left' && a.hAnchor !== 'right') || typeof a.hDist !== 'number' ||
1172
1248
  (a.vAnchor !== 'top' && a.vAnchor !== 'bottom') || typeof a.vDist !== 'number') return false
1173
1249
  var vp = viewport()
1174
1250
  var w = root.offsetWidth || root.getBoundingClientRect().width || 0
1175
1251
  var h = root.offsetHeight || root.getBoundingClientRect().height || 0
1176
- var l = a.hAnchor === 'left' ? a.hDist : vp.w - a.hDist - w
1252
+ // 与加载恢复一致:锚点存净距离,右锚点按当前避让开关叠加
1253
+ var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
1254
+ var l = a.hAnchor === 'left' ? a.hDist : vp.w - effectiveRightDist - w
1177
1255
  var t = a.vAnchor === 'top' ? a.vDist : vp.h - a.vDist - h
1178
1256
  state.left = clamp(l, 0, Math.max(0, vp.w - w))
1179
1257
  state.top = clamp(t, 0, Math.max(0, vp.h - h))
1180
- state.h = null
1181
- state.v = null
1258
+ state.h = a.hAnchor
1259
+ state.hOff = 0
1260
+ state.v = a.vAnchor
1261
+ state.vOff = 0
1182
1262
  express()
1183
1263
  return true
1184
1264
  } catch (err) { return false }
@@ -1235,25 +1315,43 @@ fetch(SIZE_URL, { cache: 'no-store' })
1235
1315
  if (d && typeof d.turnCostOn === 'boolean') {
1236
1316
  turnCostOn = d.turnCostOn
1237
1317
  turnCostToggle.checked = turnCostOn
1318
+ turnCostCloseInput.disabled = !turnCostOn
1238
1319
  }
1239
1320
  if (d && typeof d.turnCostCloseMs === 'number') {
1240
1321
  turnCostCloseMs = d.turnCostCloseMs > 0 ? d.turnCostCloseMs : 0
1241
1322
  turnCostCloseInput.value = String(Math.round(turnCostCloseMs / 1000))
1242
1323
  }
1243
- // 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离
1324
+ if (d && typeof d.scrollGapOn === 'boolean') {
1325
+ scrollGapOn = d.scrollGapOn
1326
+ scrollGapToggle.checked = scrollGapOn
1327
+ scrollGapInput.disabled = !scrollGapOn
1328
+ }
1329
+ if (d && typeof d.scrollGapPx === 'number') {
1330
+ scrollGapPx = d.scrollGapPx > 0 ? Math.round(d.scrollGapPx) : 0
1331
+ scrollGapInput.value = String(scrollGapPx)
1332
+ }
1333
+ // 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离。
1334
+ // 仅认 v:2 净距离格式;旧格式(含避让距离)废弃,挂件保持默认右下角吸附。
1335
+ // 恢复时还原吸附状态(hAnchor/vAnchor → state.h/v),避免挂件变自由位置
1336
+ // 导致避让调节不实时(settle 自由分支只 clamp 不重算位置)。
1244
1337
  try {
1245
1338
  var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
1246
- if (a && (a.hAnchor === 'left' || a.hAnchor === 'right') && typeof a.hDist === 'number' &&
1339
+ if (a && a.v === 2 && (a.hAnchor === 'left' || a.hAnchor === 'right') && typeof a.hDist === 'number' &&
1247
1340
  (a.vAnchor === 'top' || a.vAnchor === 'bottom') && typeof a.vDist === 'number') {
1248
1341
  var vpA = viewport()
1249
1342
  var wA = root.offsetWidth || root.getBoundingClientRect().width || 0
1250
1343
  var hA = root.offsetHeight || root.getBoundingClientRect().height || 0
1251
- var lA = a.hAnchor === 'left' ? a.hDist : vpA.w - a.hDist - wA
1344
+ // 锚点存的是净距离:右锚点按当前避让开关叠加避让距离
1345
+ var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
1346
+ var lA = a.hAnchor === 'left' ? a.hDist : vpA.w - effectiveRightDist - wA
1252
1347
  var tA = a.vAnchor === 'top' ? a.vDist : vpA.h - a.vDist - hA
1253
1348
  state.left = clamp(lA, 0, Math.max(0, vpA.w - wA))
1254
1349
  state.top = clamp(tA, 0, Math.max(0, vpA.h - hA))
1255
- state.h = null // 恢复为自由位置,避免 settle() 重新吸附覆盖
1256
- state.v = null
1350
+ // 按锚点还原吸附状态(贴边锚点 吸附;自由位锚点 自由)
1351
+ state.h = a.hAnchor
1352
+ state.hOff = 0
1353
+ state.v = a.vAnchor
1354
+ state.vOff = 0
1257
1355
  settle()
1258
1356
  }
1259
1357
  } catch (err) {}
@@ -1300,59 +1398,64 @@ function apply(ctx) {
1300
1398
  let balanceCache = null
1301
1399
  let balanceInFlight = null
1302
1400
  let gifBytes = null
1303
- // 每轮对话消耗统计:turn -> 聚合中的成本/分词;完成后写入 lastTurn
1304
- let turnAgg = null // { turn, cost, tokens, lastTs }
1401
+ // 每轮对话消耗统计:按 (session.id, turn) 分桶聚合,完成后写入 lastTurn
1402
+ // Map 分桶避免主会话与子代理(spawn/fork)并行时串账。
1403
+ let turnAggs = new Map() // sessionId -> { turn, cost, tokens, lastTs }
1305
1404
  let lastTurn = null // { turn, amount, tokens, ts }
1306
1405
  let lastTurnSeq = 0
1307
1406
  const disposers = []
1308
1407
 
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 }
1408
+ function finalizeTurn(sessionId) {
1409
+ const agg = turnAggs.get(sessionId)
1410
+ if (agg && agg.cost > 0) {
1411
+ lastTurn = { turn: agg.turn, amount: agg.cost, tokens: agg.tokens, ts: agg.lastTs }
1315
1412
  lastTurnSeq++
1316
1413
  }
1317
- turnAgg = null
1414
+ turnAggs.delete(sessionId)
1318
1415
  }
1319
- // 监听会话事件流:assistant/message 携带每步真实 usage,按 turn 聚合;
1320
- // turn/end 时结算本轮并写入 lastTurn
1321
- function handleSessionEvent(event) {
1416
+ // 监听会话事件流:assistant/message 携带每步真实 usage,按 (session,turn) 聚合;
1417
+ // turn/end 时结算该会话本轮并写入 lastTurn
1418
+ function handleSessionEvent(sessionId, event) {
1322
1419
  try {
1323
1420
  const type = event && event.type
1324
1421
  const d = event && event.data
1325
1422
  if (!d || typeof d !== 'object') return
1326
1423
  if (type === 'turn/end') {
1327
- // 一轮对话结束:结算并清空聚合
1328
- finalizeTurn()
1424
+ finalizeTurn(sessionId)
1329
1425
  return
1330
1426
  }
1331
1427
  if (type !== 'assistant/message') return
1332
1428
  const turn = Number(d.turn)
1333
1429
  const usage = d.usage
1334
1430
  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() }
1431
+ let agg = turnAggs.get(sessionId)
1432
+ if (!agg || agg.turn !== turn) {
1433
+ if (agg) finalizeTurn(sessionId)
1434
+ agg = { turn, cost: 0, tokens: 0, lastTs: Date.now() }
1435
+ turnAggs.set(sessionId, agg)
1338
1436
  }
1339
1437
  const input = Number(usage.inputTokens) || 0
1340
1438
  const cache = Number(usage.cacheReadTokens) || 0
1341
1439
  const output = Number(usage.outputTokens) || 0
1342
1440
  const reasoning = Number(usage.reasoningTokens) || 0
1343
- turnAgg.tokens += input + cache + output + reasoning
1441
+ agg.tokens += input + cache + output + reasoning
1344
1442
  // 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位)
1345
1443
  const model = d.message && d.message.source ? d.message.source.model : ''
1346
1444
  const p = priceFor(model)
1347
1445
  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()
1446
+ agg.cost += (cache / 1e6) * p.hit[off] + (input / 1e6) * p.miss[off] + ((output + reasoning) / 1e6) * p.out[off]
1447
+ agg.lastTs = Date.now()
1350
1448
  } catch (err) {}
1351
1449
  }
1352
1450
 
1353
- // 监听所有会话的追加事件;turn/end 时结算本轮
1451
+ // 监听所有会话的追加事件;按会话 id 分桶,turn/end 时结算该会话本轮
1354
1452
  disposers.push(ctx.on('session/event', (session, event) => {
1355
- handleSessionEvent(event)
1453
+ const sid = session && session.id ? session.id : 'default'
1454
+ handleSessionEvent(sid, event)
1455
+ }))
1456
+ // 会话销毁时清理残留聚合,避免内存泄漏
1457
+ disposers.push(ctx.on('session/disposed', (session) => {
1458
+ if (session && session.id) turnAggs.delete(session.id)
1356
1459
  }))
1357
1460
 
1358
1461
  function loadGif() {
@@ -1640,6 +1743,8 @@ function apply(ctx) {
1640
1743
  bubbleOn: parsed.bubbleOn !== false,
1641
1744
  turnCostOn: parsed.turnCostOn !== false,
1642
1745
  turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
1746
+ scrollGapOn: parsed.scrollGapOn === true,
1747
+ scrollGapPx: typeof parsed.scrollGapPx === 'number' ? Math.round(parsed.scrollGapPx) : 17,
1643
1748
  }
1644
1749
  }
1645
1750
  } catch (err) {}
@@ -1647,13 +1752,14 @@ function apply(ctx) {
1647
1752
  return null
1648
1753
  }
1649
1754
 
1650
- function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs) {
1755
+ function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx) {
1651
1756
  const um = normalizeUsageMode(usageMode)
1652
1757
  const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
1653
1758
  const bo = bubbleOn !== false
1654
1759
  const tco = turnCostOn !== false
1655
- // 0 是合法值(不自动关闭),只有缺失/非法时才回落默认 5
1656
- const tcc = typeof turnCostCloseMs === 'number' && turnCostCloseMs >= 0 ? turnCostCloseMs : 5000
1760
+ const tcc = typeof turnCostCloseMs === 'number' ? (turnCostCloseMs > 0 ? turnCostCloseMs : 0) : 5000
1761
+ const sgo = scrollGapOn === true
1762
+ const sgp = typeof scrollGapPx === 'number' && scrollGapPx > 0 ? Math.round(scrollGapPx) : 0
1657
1763
  const body = JSON.stringify({
1658
1764
  scale: scale,
1659
1765
  sound: sound !== false,
@@ -1664,6 +1770,8 @@ function apply(ctx) {
1664
1770
  bubbleOn: bo,
1665
1771
  turnCostOn: tco,
1666
1772
  turnCostCloseMs: tcc,
1773
+ scrollGapOn: sgo,
1774
+ scrollGapPx: sgp,
1667
1775
  updatedAt: new Date().toISOString(),
1668
1776
  })
1669
1777
  for (const p of SIZE_FILE_CANDIDATES) {
@@ -1680,6 +1788,8 @@ function apply(ctx) {
1680
1788
  bubbleOn: bo,
1681
1789
  turnCostOn: tco,
1682
1790
  turnCostCloseMs: tcc,
1791
+ scrollGapOn: sgo,
1792
+ scrollGapPx: sgp,
1683
1793
  }
1684
1794
  } catch (err) {}
1685
1795
  }
@@ -1791,7 +1901,7 @@ function apply(ctx) {
1791
1901
  balanceCache = null
1792
1902
  }
1793
1903
  }
1794
- const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs)
1904
+ const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs, parsed.scrollGapOn, parsed.scrollGapPx)
1795
1905
  res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
1796
1906
  res.end(JSON.stringify(result))
1797
1907
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-whale-widget",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件(含今日已用、峰谷定价、随机台词、音效与每轮消耗统计)",
5
5
  "keywords": [
6
6
  "dsh",