dsh-whale-widget 0.1.0

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 ADDED
@@ -0,0 +1,121 @@
1
+ # DSH 小鲸鱼余额挂件(DeepSeek Balance Whale Widget)
2
+
3
+ DeepSeek Harness(DSH)Web 界面右下角的常驻余额挂件:本地小鲸鱼气泡图 + DeepSeek API 余额,每次打开界面自动启用。本项目是标准 DSH 插件包,可通过 `dsh plugin` 安装/卸载。
4
+
5
+ ## 特性
6
+
7
+ - 🐋 常驻自启:随 DSH Web 界面每次打开自动出现(标准 DSH bundle 插件)
8
+ - 💰 余额:60 秒自动刷新 + 点击手动刷新;余额变化时数字**滚动动画**;瞬时网络抖动自动沿用最近余额不报错
9
+ - 🖱️ 拖拽 + **四边四分之一吸附**(左/右/上/下,角落可组合)
10
+ - 🔄 左吸附时整体**水平镜像翻转**(文字同步反向、带动画)
11
+ - 🧸 **按压 Q 弹**玩偶效果(按压时底部坐标不变)
12
+ - 🎚️ 悬停显示大小调节按钮(0.6–1.4 倍,尺寸记忆)
13
+ - 📐 随浏览器窗口自动缩放;文字位置/字号与图片联动
14
+
15
+ ## 目录结构
16
+
17
+ ```text
18
+ dsh-whale-widget/
19
+ ├── package.json # DSH bundle 插件元数据
20
+ ├── README.md # 本文件
21
+ ├── cordis.patch.yml # 插件挂载声明
22
+ ├── lib/
23
+ │ └── index.js # 宿主侧插件本体
24
+ ├── assets/
25
+ │ └── DSniang02.png # 小鲸鱼气泡图
26
+ └── whale-widget-prompt.md # 完整规格/维护提示词
27
+ ```
28
+
29
+ ## 安装
30
+
31
+ ### 方式 A:本地开发安装(当前项目)
32
+
33
+ 在项目根目录(`DeepSeek-Balance-Whale-Widget-main`)执行:
34
+
35
+ ```powershell
36
+ dsh plugin --profile web add link:.\dsh-whale-widget
37
+ ```
38
+
39
+ 说明:
40
+
41
+ - `dsh plugin` 会把参数转发给 pnpm,并在成功后自动把 `dsh-whale-widget` 加入 `dsh.profile.bundles`
42
+ - 使用 `link:` 会在 profile 的 `node_modules` 里链接到当前源码目录,方便继续改代码
43
+ - 安装完成后重启 `dsh web`,再 F5 刷新浏览器
44
+ - **如果之后移动了源码目录**,必须重新到新的项目根目录执行一次:
45
+ ```powershell
46
+ dsh plugin --profile web add link:.\dsh-whale-widget
47
+ ```
48
+ 因为 `link:` 记录的是源目录的绝对路径;移动后旧链接会失效。若提示已存在/冲突,可先 `dsh plugin --profile web remove dsh-whale-widget` 再重新 add。
49
+
50
+ ### 方式 B:发布到 npm 后安装
51
+
52
+ 如果你把这个包发布到 npm:
53
+
54
+ ```powershell
55
+ cd dsh-whale-widget
56
+ npm publish
57
+ ```
58
+
59
+ 然后任意机器上安装:
60
+
61
+ ```powershell
62
+ dsh plugin --profile web add dsh-whale-widget
63
+ ```
64
+
65
+ ## 卸载
66
+
67
+ ```powershell
68
+ dsh plugin --profile web remove dsh-whale-widget
69
+ ```
70
+
71
+ ## 从旧手动安装升级
72
+
73
+ 如果你之前按旧方式手动安装过(复制 `whale-balance.mjs` + 改 `cordis.patch.yml`),先清理:
74
+
75
+ ```powershell
76
+ $web = "$env:USERPROFILE\.dsh\profiles\web"
77
+
78
+ Remove-Item "$web\whale-balance.mjs" -ErrorAction SilentlyContinue
79
+ Remove-Item "$web\DSniang02.png" -ErrorAction SilentlyContinue
80
+ ```
81
+
82
+ 然后编辑 `$web\cordis.patch.yml`,删除这段旧补丁:
83
+
84
+ ```yaml
85
+ - insert:
86
+ - id: whale-balance-widget
87
+ name: ./whale-balance.mjs?v=1
88
+ ```
89
+
90
+ 如果里面只有这段,直接改成:
91
+
92
+ ```yaml
93
+ []
94
+ ```
95
+
96
+ 清理后再执行上面的安装命令。
97
+
98
+ ## 验证
99
+
100
+ ```powershell
101
+ dsh --profile web --dump-config | Select-String -Pattern "whale"
102
+
103
+ curl http://127.0.0.1:3080/dsh-whale/image.png
104
+ curl http://127.0.0.1:3080/dsh-whale/balance.json
105
+ ```
106
+
107
+ - `/dsh-whale/image.png` → 200 `image/png`
108
+ - `/dsh-whale/balance.json` → 200,含 `{"ok":true,"totalBalance":...,"currency":"CNY"}`
109
+ - 浏览器 F5 后右下角出现挂件
110
+
111
+ ## 常见问题
112
+
113
+ - **挂件不出现**:确认 `dsh plugin add` 成功;`dsh --profile web --dump-config` 里能看到 `dsh-whale-widget`;重启 `dsh web` 后 F5。
114
+ - **图片不显示**:确认 `assets/DSniang02.png` 在插件包内,且没有把旧文件放在 profile 里占用了同名路由。
115
+ - **余额报「未配置 DEEPSEEK_API_KEY」**:去 DSH 配置凭据。
116
+ - **本地开发改了代码不生效**:使用 `link:` 安装时,修改源码后重启 `dsh web`(ESM 模块缓存);如果用已发布版本,需要 `npm publish` 新版本后 `dsh plugin --profile web update dsh-whale-widget`。
117
+ - **自定义图片**:必须是 1026×1026、气泡几何一致(中心 455,247、长轴 710、纵轴 430),否则按 `whale-widget-prompt.md` 调整文字定位参数。
118
+
119
+ ## 开发与维护
120
+
121
+ 完整规格、视觉参数、架构结论和生成提示词见 `whale-widget-prompt.md`。修改文字位置、颜色、动画、吸附逻辑时参考该文件。
Binary file
@@ -0,0 +1,15 @@
1
+ # ============================================================================
2
+ # dsh-whale-widget —— DSH bundle patch
3
+ # ============================================================================
4
+ # 这是插件的"挂载声明"。DSH 启动时按 bundle 层栈叠加各插件 patch,
5
+ # 本文件会把鲸鱼余额挂件插入 Web profile 的配置树。
6
+ #
7
+ # 安装方式(不再需要手工复制/手工改 profile patch):
8
+ # dsh plugin --profile web add dsh-whale-widget
9
+ # 或本地开发安装:
10
+ # dsh plugin --profile web add link:<本目录绝对路径>
11
+ # ============================================================================
12
+
13
+ - insert:
14
+ - id: dsh-whale-widget
15
+ name: dsh-whale-widget
package/lib/index.js ADDED
@@ -0,0 +1,627 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ // Package root: lib/index.js -> package root. This keeps the bundle relocatable
7
+ // when installed as a normal DSH npm plugin (node_modules or a local link).
8
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
9
+
10
+ // DSH home: used for the widget size memory file, since node_modules may be
11
+ // read-only or cleaned on update.
12
+ const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
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.
17
+ const IMAGE_CANDIDATES = [
18
+ path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
19
+ path.join(PACKAGE_ROOT, 'DSniang02.png'),
20
+ 'D:/TestBox/deepseek/DSniang02.png',
21
+ 'D:/TestBox/deepseek/skin/DSniang02.png',
22
+ ]
23
+
24
+ // Size memory file: prefer writable DSH home locations, then legacy fallbacks.
25
+ const SIZE_FILE_CANDIDATES = [
26
+ path.join(DSH_HOME, '.dshw-size.json'),
27
+ path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
28
+ 'D:/TestBox/deepseek/.dshw-size.json',
29
+ 'D:/TestBox/deepseek/skin/.dshw-size.json',
30
+ ]
31
+
32
+ const BALANCE_URL = 'https://api.deepseek.com/user/balance'
33
+ const BALANCE_TTL_MS = 25000
34
+
35
+ const JSON_HEADERS = {
36
+ 'Content-Type': 'application/json; charset=utf-8',
37
+ 'Access-Control-Allow-Origin': '*',
38
+ 'Cache-Control': 'no-store',
39
+ }
40
+
41
+ const WIDGET_JS = `(function () {
42
+ if (window.__dshWhaleWidget) return
43
+ window.__dshWhaleWidget = true
44
+
45
+ var MIN_SCALE = 0.6
46
+ var MAX_SCALE = 1.4
47
+ var STEP = 0.1
48
+ var CLICK_SQ = 9
49
+ var REFRESH_MS = 60000
50
+ var CHANGE_MS = 900
51
+ var ANIM_MS = 700
52
+ var FETCH_TIMEOUT_MS = 25000
53
+ var BALANCE_URL = '/dsh-whale/balance.json'
54
+ var SIZE_URL = '/dsh-whale/size.json'
55
+ var IMG_URL = '/dsh-whale/image.png'
56
+
57
+ var css = [
58
+ '.dshwv-root{position:fixed;right:0;bottom:0;--dshw-scale:1;--dshw-base:clamp(96px,calc(min(196px,min(100vw,100vh) * 0.22) * var(--dshw-scale)),292px);width:var(--dshw-base);height:var(--dshw-base);cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;z-index:9999;font-family:inherit;transition:left .16s ease,top .16s ease,transform .3s ease}',
59
+ '.dshwv-root.dshwv-left{transform:scaleX(-1)}',
60
+ '.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
61
+ '.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)}',
62
+ '.dshwv-img{width:var(--dshw-base);height:var(--dshw-base);display:block;pointer-events:none;-webkit-user-drag:none;user-select:none}',
63
+ '.dshwv-text{position:absolute;left:44.346%;top:25.5%;transform:translate(-50%,-50%);text-align:center;color:#536ba9;line-height:1.18;white-space:nowrap;--dshw-u:calc(var(--dshw-base) / 1026);pointer-events:none;transition:transform .3s ease}',
64
+ '.dshwv-root.dshwv-left .dshwv-text{transform:translate(-50%,-50%) scaleX(-1)}',
65
+ '.dshwv-label{font-size:calc(var(--dshw-u) * 68);font-weight:600;letter-spacing:.06em}',
66
+ '.dshwv-amount{font-size:calc(var(--dshw-u) * 119);font-weight:800;line-height:1.05}',
67
+ '.dshwv-hint{font-size:calc(var(--dshw-u) * 54);color:#9fb0d9;letter-spacing:.02em}',
68
+ '.dshwv-size{position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity .15s ease;z-index:2}',
69
+ '.dshwv-root:hover .dshwv-size{opacity:1}',
70
+ '.dshwv-size button{width:20px;height:20px;border:none;border-radius:50%;background:rgba(83,107,169,.85);color:#fff;font-size:13px;line-height:1;padding:0;cursor:pointer;display:flex;align-items:center;justify-content:center;user-select:none}',
71
+ '.dshwv-size button:hover{background:#536ba9}'
72
+ ].join('\\n')
73
+
74
+ var styleEl = document.createElement('style')
75
+ styleEl.textContent = css
76
+ document.head.appendChild(styleEl)
77
+
78
+ var root = document.createElement('div')
79
+ root.className = 'dshwv-root'
80
+
81
+ var img = document.createElement('img')
82
+ img.className = 'dshwv-img'
83
+ img.src = IMG_URL
84
+ img.alt = 'DeepSeek 余额'
85
+ img.draggable = false
86
+
87
+ var sizeBox = document.createElement('div')
88
+ sizeBox.className = 'dshwv-size'
89
+ function makeBtn(text, title, delta) {
90
+ var b = document.createElement('button')
91
+ b.type = 'button'
92
+ b.textContent = text
93
+ b.title = title
94
+ b.addEventListener('pointerdown', function (e) { e.stopPropagation() })
95
+ b.addEventListener('click', function (e) { e.stopPropagation(); adjust(delta) })
96
+ return b
97
+ }
98
+ sizeBox.appendChild(makeBtn('-', '缩小', -STEP))
99
+ sizeBox.appendChild(makeBtn('+', '放大', STEP))
100
+
101
+ var textBox = document.createElement('div')
102
+ textBox.className = 'dshwv-text'
103
+ var labelEl = document.createElement('div')
104
+ labelEl.className = 'dshwv-label'
105
+ labelEl.textContent = 'DeepSeek 余额'
106
+ var amountEl = document.createElement('div')
107
+ amountEl.className = 'dshwv-amount'
108
+ var hintEl = document.createElement('div')
109
+ hintEl.className = 'dshwv-hint'
110
+ textBox.appendChild(labelEl)
111
+ textBox.appendChild(amountEl)
112
+ textBox.appendChild(hintEl)
113
+
114
+ var body = document.createElement('div')
115
+ body.className = 'dshwv-body'
116
+ body.appendChild(img)
117
+ body.appendChild(sizeBox)
118
+ body.appendChild(textBox)
119
+ root.appendChild(body)
120
+ document.body.appendChild(root)
121
+
122
+ // Position model: the widget is ALWAYS expressed in left/top px (so edge snaps
123
+ // animate smoothly via the CSS transition on both sides — switching to
124
+ // right/auto cannot transition and flashes). The anchor info (h/v + offsets)
125
+ // lives in state and is used by settle() to recompute coordinates on window
126
+ // resize and size changes, keeping the widget glued to its anchored edge.
127
+ var state = {
128
+ scale: 1,
129
+ h: 'right',
130
+ hOff: 0,
131
+ v: 'bottom',
132
+ vOff: 0,
133
+ left: 0,
134
+ top: 0,
135
+ balance: null,
136
+ currency: null,
137
+ status: 'loading',
138
+ message: ''
139
+ }
140
+ var busy = false
141
+ var settleTimer = null
142
+ var drag = null
143
+ var shown = null
144
+ var animId = null
145
+
146
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
147
+ function viewport() {
148
+ return {
149
+ w: window.innerWidth || document.documentElement.clientWidth || 1280,
150
+ h: window.innerHeight || document.documentElement.clientHeight || 800
151
+ }
152
+ }
153
+ function fmt(balance, currency) {
154
+ var num = Number(balance)
155
+ var fixed = isFinite(num) ? num.toFixed(2) : '--'
156
+ return currency === 'CNY' ? '¥ ' + fixed : fixed + ' ' + currency
157
+ }
158
+ function animateAmount(from, to, currency, duration) {
159
+ if (animId) cancelAnimationFrame(animId)
160
+ if (from === null || !isFinite(from)) from = to
161
+ if (from === to) {
162
+ shown = to
163
+ amountEl.textContent = fmt(to, currency)
164
+ return
165
+ }
166
+ var startTime = null
167
+ function step(ts) {
168
+ if (startTime === null) startTime = ts
169
+ var t = Math.min(1, (ts - startTime) / duration)
170
+ var eased = 1 - Math.pow(1 - t, 3)
171
+ var val = from + (to - from) * eased
172
+ amountEl.textContent = fmt(val, currency)
173
+ if (t < 1) {
174
+ animId = requestAnimationFrame(step)
175
+ } else {
176
+ animId = null
177
+ shown = to
178
+ amountEl.textContent = fmt(to, currency)
179
+ }
180
+ }
181
+ animId = requestAnimationFrame(step)
182
+ }
183
+ function render() {
184
+ var amount, hint
185
+ if (state.status === 'loading') {
186
+ amount = shown !== null ? fmt(shown, state.currency) : '…'
187
+ hint = '加载中…'
188
+ } else if (state.status === 'error') {
189
+ amount = shown !== null ? fmt(shown, state.currency) : '--'
190
+ hint = state.message ? state.message.slice(0, 14) : '获取失败 · 点击重试'
191
+ } else {
192
+ amount = shown !== null ? fmt(shown, state.currency) : (state.balance !== null ? fmt(state.balance, state.currency) : '--')
193
+ hint = state.status === 'changing' ? '加载中…' : '点击刷新'
194
+ }
195
+ amountEl.textContent = amount
196
+ hintEl.textContent = hint
197
+ }
198
+ function express() {
199
+ root.style.right = 'auto'
200
+ root.style.bottom = 'auto'
201
+ root.style.left = state.left + 'px'
202
+ root.style.top = state.top + 'px'
203
+ root.classList.toggle('dshwv-left', state.h === 'left')
204
+ }
205
+ function settle() {
206
+ var vp = viewport()
207
+ var w = root.offsetWidth || root.getBoundingClientRect().width || 0
208
+ var h = root.offsetHeight || root.getBoundingClientRect().height || 0
209
+ if (drag && drag.active) {
210
+ // mid-drag resize: keep the pointer-follow position, just clamp into view
211
+ state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
212
+ state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
213
+ express()
214
+ return
215
+ }
216
+ if (state.h === 'right') {
217
+ state.left = Math.max(0, vp.w - w - state.hOff)
218
+ } else if (state.h === 'left') {
219
+ state.left = state.hOff
220
+ } else {
221
+ state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
222
+ }
223
+ if (state.v === 'bottom') {
224
+ state.top = Math.max(0, vp.h - h - state.vOff)
225
+ } else if (state.v === 'top') {
226
+ state.top = state.vOff
227
+ } else {
228
+ state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
229
+ }
230
+ express()
231
+ }
232
+ function refresh(manual) {
233
+ if (busy) return
234
+ busy = true
235
+ if (manual || state.balance === null) { state.status = 'loading'; render() }
236
+ var ctrl = null
237
+ var timer = null
238
+ try {
239
+ ctrl = new AbortController()
240
+ timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS)
241
+ } catch (err) {}
242
+ fetch(BALANCE_URL, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
243
+ .then(function (r) { return r.json() })
244
+ .then(function (data) {
245
+ if (data && data.ok) {
246
+ var nb = Number(data.totalBalance)
247
+ var nc = String(data.currency || 'CNY')
248
+ var changed = state.balance !== null && (nb !== state.balance || nc !== state.currency)
249
+ var currencyChanged = state.currency !== null && nc !== state.currency
250
+ state.balance = nb
251
+ state.currency = nc
252
+ state.message = ''
253
+ if (changed && !currencyChanged) {
254
+ if (!manual) {
255
+ state.status = 'changing'
256
+ animateAmount(shown, nb, nc, ANIM_MS)
257
+ if (settleTimer) clearTimeout(settleTimer)
258
+ settleTimer = setTimeout(function () {
259
+ settleTimer = null
260
+ if (state.status === 'changing') { state.status = 'ok'; render() }
261
+ }, CHANGE_MS)
262
+ } else {
263
+ animateAmount(shown, nb, nc, ANIM_MS)
264
+ state.status = 'ok'
265
+ render()
266
+ }
267
+ } else {
268
+ if (animId === null) shown = nb
269
+ state.status = 'ok'
270
+ render()
271
+ }
272
+ } else {
273
+ state.status = 'error'
274
+ state.message = (data && data.error) ? String(data.error) : '获取失败'
275
+ render()
276
+ }
277
+ })
278
+ .catch(function () {
279
+ state.status = 'error'
280
+ state.message = '获取失败'
281
+ render()
282
+ })
283
+ .finally(function () {
284
+ busy = false
285
+ if (timer) clearTimeout(timer)
286
+ })
287
+ }
288
+ function adjust(delta) {
289
+ var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, state.scale + delta)) * 10) / 10
290
+ state.scale = next
291
+ root.style.setProperty('--dshw-scale', String(next))
292
+ try {
293
+ fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: next }) })
294
+ } catch (err) {}
295
+ settle()
296
+ }
297
+ var SQUISH = 'scaleY(0.88) scaleX(1.05)'
298
+ function pressDown() {
299
+ body.style.transform = SQUISH
300
+ }
301
+ function pressUp() {
302
+ body.style.transform = 'scaleY(1) scaleX(1)'
303
+ }
304
+ function onPointerDown(e) {
305
+ if (e.button !== 0) return
306
+ try { root.setPointerCapture(e.pointerId) } catch (err) {}
307
+ var vp = viewport()
308
+ var rect = root.getBoundingClientRect()
309
+ drag = { active: true, startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, w: rect.width, h: rect.height, moved: false, vp: vp }
310
+ root.classList.add('dshwv-dragging')
311
+ pressDown()
312
+ }
313
+ function onPointerMove(e) {
314
+ if (!drag || !drag.active) return
315
+ var dx = e.clientX - drag.startX
316
+ var dy = e.clientY - drag.startY
317
+ if (dx * dx + dy * dy >= CLICK_SQ) drag.moved = true
318
+ // Keep the pre-drag flip orientation while dragging (state.h/v stay as they
319
+ // were); on release endDrag() recomputes the anchors and settle() flips the
320
+ // class with a smooth transition instead of reverting instantly.
321
+ state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
322
+ state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
323
+ express()
324
+ }
325
+ function endDrag(e, clickAllowed) {
326
+ if (!drag || !drag.active) return
327
+ drag.active = false
328
+ pressUp()
329
+ root.classList.remove('dshwv-dragging')
330
+ try {
331
+ if (root.hasPointerCapture && root.hasPointerCapture(e.pointerId)) root.releasePointerCapture(e.pointerId)
332
+ } catch (err) {}
333
+ if (clickAllowed && !drag.moved) { refresh(true); return }
334
+ var dx = e.clientX - drag.startX
335
+ var dy = e.clientY - drag.startY
336
+ var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
337
+ var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
338
+ var centerX = left + drag.w / 2
339
+ var centerY = top + drag.h / 2
340
+ if (centerX < drag.vp.w / 4) {
341
+ state.h = 'left'
342
+ state.hOff = 0
343
+ } else if (centerX > drag.vp.w * 3 / 4) {
344
+ state.h = 'right'
345
+ state.hOff = 0
346
+ } else {
347
+ state.h = null
348
+ state.hOff = left
349
+ }
350
+ if (centerY < drag.vp.h / 4) {
351
+ state.v = 'top'
352
+ state.vOff = 0
353
+ } else if (centerY > drag.vp.h * 3 / 4) {
354
+ state.v = 'bottom'
355
+ state.vOff = 0
356
+ } else {
357
+ state.v = null
358
+ state.vOff = top
359
+ }
360
+ state.left = left
361
+ state.top = top
362
+ settle()
363
+ }
364
+ root.addEventListener('pointerdown', onPointerDown)
365
+ root.addEventListener('pointermove', onPointerMove)
366
+ root.addEventListener('pointerup', function (e) { endDrag(e, true) })
367
+ root.addEventListener('pointercancel', function (e) { endDrag(e, false) })
368
+ window.addEventListener('resize', function () {
369
+ settle()
370
+ })
371
+
372
+ var rect0 = root.getBoundingClientRect()
373
+ state.left = rect0.left
374
+ state.top = rect0.top
375
+ express()
376
+ render()
377
+ fetch(SIZE_URL, { cache: 'no-store' })
378
+ .then(function (r) { return r.json() })
379
+ .then(function (d) {
380
+ if (d && typeof d.scale === 'number' && d.scale >= MIN_SCALE - 0.1 && d.scale <= MAX_SCALE + 0.1) {
381
+ state.scale = d.scale
382
+ root.style.setProperty('--dshw-scale', String(d.scale))
383
+ settle()
384
+ }
385
+ refresh(false)
386
+ })
387
+ .catch(function () { refresh(false) })
388
+ setInterval(function () { refresh(false) }, REFRESH_MS)
389
+ })()`
390
+
391
+ const name = 'dsh-whale-widget'
392
+ const inject = ['webServer', 'credentials']
393
+
394
+ function apply(ctx) {
395
+ let imageBytes = null
396
+ let balanceCache = null
397
+ let balanceInFlight = null
398
+
399
+ function loadImage() {
400
+ if (imageBytes) return imageBytes
401
+ for (const p of IMAGE_CANDIDATES) {
402
+ try {
403
+ const bytes = fs.readFileSync(p)
404
+ if (bytes && bytes.length > 0) {
405
+ imageBytes = bytes
406
+ return bytes
407
+ }
408
+ } catch (err) {}
409
+ }
410
+ throw new Error('whale image not found')
411
+ }
412
+
413
+ async function fetchBalance() {
414
+ let cred
415
+ try {
416
+ cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
417
+ } catch (err) {
418
+ return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
419
+ }
420
+ if (!cred) {
421
+ return { ok: false, code: 'NO_KEY', error: '未配置 DEEPSEEK_API_KEY' }
422
+ }
423
+ let lastErr = null
424
+ for (let attempt = 0; attempt < 2; attempt++) {
425
+ let res
426
+ try {
427
+ res = await fetch(BALANCE_URL, {
428
+ headers: { Authorization: 'Bearer ' + cred.value },
429
+ signal: AbortSignal.timeout(20000),
430
+ })
431
+ } catch (err) {
432
+ lastErr = err
433
+ if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
434
+ continue
435
+ }
436
+ if (!res.ok) {
437
+ lastErr = new Error('HTTP ' + res.status)
438
+ if (res.status < 500) break
439
+ if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
440
+ continue
441
+ }
442
+ let data
443
+ try {
444
+ data = await res.json()
445
+ } catch (err) {
446
+ return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
447
+ }
448
+ const info = data && Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
449
+ if (!info || info.total_balance === undefined) {
450
+ return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
451
+ }
452
+ return {
453
+ ok: true,
454
+ totalBalance: Number(info.total_balance),
455
+ currency: String(info.currency || 'CNY'),
456
+ updatedAt: new Date().toISOString(),
457
+ }
458
+ }
459
+ const transient = !(lastErr && /^HTTP 4\d\d/.test(lastErr.message))
460
+ return {
461
+ ok: false,
462
+ code: 'HTTP',
463
+ transient: transient,
464
+ error: '余额接口请求失败: ' + String((lastErr && lastErr.message) || lastErr).slice(0, 200),
465
+ }
466
+ }
467
+
468
+ function getBalance() {
469
+ const now = Date.now()
470
+ if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
471
+ return Promise.resolve(balanceCache.payload)
472
+ }
473
+ if (balanceInFlight) return balanceInFlight
474
+ balanceInFlight = fetchBalance()
475
+ .then((payload) => {
476
+ if (payload.ok) {
477
+ balanceCache = { at: now, payload }
478
+ return payload
479
+ }
480
+ if (payload.transient && balanceCache) {
481
+ // transient network/API blip: keep serving the last known balance
482
+ return { ...balanceCache.payload, stale: true, error: payload.error }
483
+ }
484
+ if (!payload.transient) console.error('[whale-balance]', payload.code, payload.error)
485
+ return payload
486
+ })
487
+ .catch((err) => ({
488
+ ok: false,
489
+ code: 'ERROR',
490
+ error: '余额服务异常: ' + String((err && err.message) || err).slice(0, 200),
491
+ }))
492
+ .finally(() => {
493
+ balanceInFlight = null
494
+ })
495
+ return balanceInFlight
496
+ }
497
+
498
+ function readSizeConfig() {
499
+ for (const p of SIZE_FILE_CANDIDATES) {
500
+ try {
501
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
502
+ if (parsed && typeof parsed.scale === 'number') return { scale: parsed.scale }
503
+ } catch (err) {}
504
+ }
505
+ return null
506
+ }
507
+
508
+ function writeSizeConfig(scale) {
509
+ const body = JSON.stringify({ scale: scale, updatedAt: new Date().toISOString() })
510
+ for (const p of SIZE_FILE_CANDIDATES) {
511
+ try {
512
+ fs.writeFileSync(p, body, 'utf8')
513
+ return { ok: true, scale: scale }
514
+ } catch (err) {}
515
+ }
516
+ return { ok: false, error: '无法持久化挂件尺寸' }
517
+ }
518
+
519
+ function readBody(req) {
520
+ return new Promise((resolve, reject) => {
521
+ const chunks = []
522
+ let size = 0
523
+ req.on('data', (c) => {
524
+ size += c.length
525
+ if (size > 8192) {
526
+ reject(new Error('body too large'))
527
+ req.destroy()
528
+ return
529
+ }
530
+ chunks.push(c)
531
+ })
532
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
533
+ req.on('error', reject)
534
+ })
535
+ }
536
+
537
+ const disposers = []
538
+
539
+ disposers.push(ctx.webServer.register({
540
+ kind: 'exact',
541
+ path: '/dsh-whale/image.png',
542
+ handler: (req, res) => {
543
+ try {
544
+ const bytes = loadImage()
545
+ res.writeHead(200, {
546
+ 'Content-Type': 'image/png',
547
+ 'Cache-Control': 'public, max-age=3600',
548
+ 'Content-Length': String(bytes.length),
549
+ })
550
+ res.end(bytes)
551
+ } catch (err) {
552
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
553
+ res.end('whale image unavailable: ' + String((err && err.message) || err))
554
+ }
555
+ },
556
+ }))
557
+
558
+ disposers.push(ctx.webServer.register({
559
+ kind: 'exact',
560
+ path: '/dsh-whale/balance.json',
561
+ handler: async (req, res) => {
562
+ try {
563
+ const payload = await getBalance()
564
+ res.writeHead(200, JSON_HEADERS)
565
+ res.end(JSON.stringify(payload))
566
+ } catch (err) {
567
+ res.writeHead(200, JSON_HEADERS)
568
+ res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200) }))
569
+ }
570
+ },
571
+ }))
572
+
573
+ disposers.push(ctx.webServer.register({
574
+ kind: 'exact',
575
+ path: '/dsh-whale/size.json',
576
+ handler: async (req, res) => {
577
+ if (req.method === 'PUT' || req.method === 'POST') {
578
+ try {
579
+ const body = await readBody(req)
580
+ const parsed = JSON.parse(body)
581
+ const scale = typeof parsed.scale === 'number' ? parsed.scale : null
582
+ if (scale === null) {
583
+ res.writeHead(400, JSON_HEADERS)
584
+ res.end(JSON.stringify({ ok: false, error: 'missing scale' }))
585
+ return
586
+ }
587
+ const result = writeSizeConfig(scale)
588
+ res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
589
+ res.end(JSON.stringify(result))
590
+ } catch (err) {
591
+ res.writeHead(400, JSON_HEADERS)
592
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
593
+ }
594
+ return
595
+ }
596
+ res.writeHead(200, JSON_HEADERS)
597
+ res.end(JSON.stringify(readSizeConfig() || {}))
598
+ },
599
+ }))
600
+
601
+ disposers.push(ctx.webServer.register({
602
+ kind: 'exact',
603
+ path: '/dsh-whale/widget.js',
604
+ handler: (req, res) => {
605
+ res.writeHead(200, {
606
+ 'Content-Type': 'application/javascript; charset=utf-8',
607
+ 'Cache-Control': 'no-store',
608
+ })
609
+ res.end(WIDGET_JS)
610
+ },
611
+ }))
612
+
613
+ disposers.push(ctx.webServer.tapIndex((html) => {
614
+ if (html.indexOf('/dsh-whale/widget.js') !== -1) return html
615
+ const tag = '<script defer src="/dsh-whale/widget.js"></script>'
616
+ if (html.indexOf('</body>') !== -1) return html.replace('</body>', tag + '</body>')
617
+ return html + tag
618
+ }))
619
+
620
+ ctx.effect(() => () => {
621
+ for (const d of disposers) {
622
+ try { d() } catch (err) {}
623
+ }
624
+ })
625
+ }
626
+
627
+ export { name, inject, apply }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "dsh-whale-widget",
3
+ "version": "0.1.0",
4
+ "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "balance",
10
+ "widget"
11
+ ],
12
+ "type": "module",
13
+ "main": "lib/index.js",
14
+ "files": [
15
+ "lib",
16
+ "assets",
17
+ "cordis.patch.yml",
18
+ "README.md"
19
+ ],
20
+ "dsh": {
21
+ "bundle": {
22
+ "patch": "./cordis.patch.yml"
23
+ }
24
+ },
25
+ "license": "MIT"
26
+ }