dsh-whale-widget 0.1.0 → 0.2.2

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/lib/index.js CHANGED
@@ -1,627 +1,1461 @@
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 }
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/usage memory files, since node_modules may
11
+ // be 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', 'DSniang1.png'),
19
+ 'D:/TestBox/deepseek/DSniang1.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
+ // Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
33
+ const USAGE_FILE_CANDIDATES = [
34
+ path.join(DSH_HOME, '.dshw-usage.json'),
35
+ path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
36
+ 'D:/TestBox/deepseek/.dshw-usage.json',
37
+ 'D:/TestBox/deepseek/skin/.dshw-usage.json',
38
+ ]
39
+
40
+ // Sound assets: package-relative first (optional — ship Ya1/Ya2/D1/D2.mp3 in
41
+ // assets/ if you want sounds to work out of the box), legacy paths as fallback.
42
+ const SOUND_SETS = {
43
+ duck: {
44
+ press: [path.join(PACKAGE_ROOT, 'assets', 'Ya1.mp3'), 'D:/TestBox/deepseek/skin/Ya1.mp3'],
45
+ release: [path.join(PACKAGE_ROOT, 'assets', 'Ya2.mp3'), 'D:/TestBox/deepseek/skin/Ya2.mp3'],
46
+ },
47
+ fx1: {
48
+ press: [path.join(PACKAGE_ROOT, 'assets', 'D1.mp3'), 'D:/TestBox/deepseek/skin/D1.mp3'],
49
+ release: [path.join(PACKAGE_ROOT, 'assets', 'D2.mp3'), 'D:/TestBox/deepseek/skin/D2.mp3'],
50
+ },
51
+ }
52
+ function soundSetFromUrl(url) {
53
+ try {
54
+ const q = String(url || '').split('?')[1] || ''
55
+ const m = /(?:^|&)set=([^&]+)/.exec(q)
56
+ return m ? decodeURIComponent(m[1]) : ''
57
+ } catch (err) { return '' }
58
+ }
59
+ const BALANCE_URL = 'https://api.deepseek.com/user/balance'
60
+ const BALANCE_TTL_MS = 25000
61
+ // DeepSeek CNY prices per million tokens: [空闲时段价, 高峰时段价].
62
+ // 高峰时段:每日 9:00–12:00 和 14:00–18:00(北京时间)。Adjust here if DeepSeek changes pricing.
63
+ const PEAK_HOURS = [
64
+ [9, 12],
65
+ [14, 18],
66
+ ]
67
+ const BASE_PRICE = { hit: [0.05, 0.1], miss: [1.5, 3.0], out: [4.5, 9.0] }
68
+ const PRICING = {
69
+ 'deepseek-chat': BASE_PRICE,
70
+ 'deepseek-reasoner': BASE_PRICE,
71
+ 'deepseek-v4-flash': BASE_PRICE,
72
+ 'deepseek-v4-pro': BASE_PRICE,
73
+ _default: BASE_PRICE,
74
+ }
75
+ function priceFor(model) {
76
+ const m = String(model || '').toLowerCase()
77
+ for (const key of Object.keys(PRICING)) {
78
+ if (key === '_default') continue
79
+ if (m.indexOf(key) !== -1) return PRICING[key]
80
+ }
81
+ return PRICING._default
82
+ }
83
+ // bucket time is an epoch second; derive the Beijing local hour to pick peak vs off-peak price.
84
+ function isPeakTime(timeSec) {
85
+ if (!isFinite(Number(timeSec))) return false
86
+ const hour = new Date(Number(timeSec) * 1000 + 8 * 3600 * 1000).getUTCHours()
87
+ for (const [start, end] of PEAK_HOURS) {
88
+ if (hour >= start && hour < end) return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ const JSON_HEADERS = {
94
+ 'Content-Type': 'application/json; charset=utf-8',
95
+ 'Access-Control-Allow-Origin': '*',
96
+ 'Cache-Control': 'no-store',
97
+ }
98
+
99
+ const WIDGET_JS = `(function () {
100
+ if (window.__dshWhaleWidget) return
101
+ window.__dshWhaleWidget = true
102
+
103
+ var MIN_SCALE = 0.6
104
+ var MAX_SCALE = 2.5
105
+ var STEP = 0.1
106
+ var CLICK_SQ = 9
107
+ var REFRESH_MS = 60000
108
+ var CHANGE_MS = 900
109
+ var ANIM_MS = 700
110
+ var BUBBLE_MS = 5000
111
+ var FETCH_TIMEOUT_MS = 25000
112
+ var BALANCE_URL = '/dsh-whale/balance.json'
113
+ var SIZE_URL = '/dsh-whale/size.json'
114
+ var IMG_URL = '/dsh-whale/image.png?v=2'
115
+
116
+ var css = [
117
+ '.dshwv-root{position:fixed;right:0;bottom:0;--dshw-scale:1;--dshw-base:clamp(122px,calc(min(250px,min(100vw,100vh) * 0.28) * var(--dshw-scale)),625px);width:var(--dshw-base);height:var(--dshw-base);pointer-events:none;user-select:none;-webkit-user-select:none;z-index:9999;font-family:inherit;transition:left .16s ease,top .16s ease,transform .3s ease}',
118
+ '.dshwv-root.dshwv-left{transform:scaleX(-1)}',
119
+ '.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
120
+ '.dshwv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}',
121
+ '.dshwv-img{position:absolute;right:0;bottom:0;width:59.45%;height:59.45%;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none}',
122
+ '.dshwv-bubble{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1}',
123
+ '.dshwv-bubble svg{display:block;width:100%;height:100%;pointer-events:none}',
124
+ '.dshwv-bubble svg path,.dshwv-bubble svg ellipse{pointer-events:none;cursor:pointer}',
125
+ '.dshwv-bubble.dshwv-bubble-open svg path,.dshwv-bubble.dshwv-bubble-open svg ellipse{pointer-events:visiblePainted}',
126
+ '.dshwv-bubble .dshwv-bshape,.dshwv-bubble .dshwv-b1,.dshwv-bubble .dshwv-b2{opacity:0;transform:scale(.7);transform-box:fill-box;transform-origin:50% 50%;transition:opacity .2s ease,transform .2s ease}',
127
+ '.dshwv-bubble.dshwv-bubble-open .dshwv-bshape,.dshwv-bubble.dshwv-bubble-open .dshwv-b1,.dshwv-bubble.dshwv-bubble-open .dshwv-b2{opacity:1;transform:none}',
128
+ '.dshwv-bubble.dshwv-bubble-open .dshwv-b2{transition-delay:0s}',
129
+ '.dshwv-bubble.dshwv-bubble-open .dshwv-b1{transition-delay:.13s}',
130
+ '.dshwv-bubble.dshwv-bubble-open .dshwv-bshape{transition-delay:.26s}',
131
+ '.dshwv-bubble .dshwv-bshape{transition-delay:.1s}',
132
+ '.dshwv-bubble .dshwv-b1{transition-delay:.2s}',
133
+ '.dshwv-bubble .dshwv-b2{transition-delay:.3s}',
134
+ '.dshwv-text{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);text-align:center;color:#536ba9;line-height:1.15;white-space:nowrap;--dshw-u:calc(var(--dshw-base) / 1026);pointer-events:none;opacity:0;transition:opacity .16s ease,transform .3s ease}',
135
+ '.dshwv-bubble.dshwv-bubble-open .dshwv-text{opacity:1;transition:opacity .16s ease .36s,transform .3s ease}',
136
+ '.dshwv-root.dshwv-left .dshwv-text{transform:translate(-50%,-50%) scaleX(-1)}',
137
+ '.dshwv-label{font-size:calc(var(--dshw-u) * 66);font-weight:600;letter-spacing:.06em}',
138
+ '.dshwv-amount{font-size:calc(var(--dshw-u) * 128);font-weight:800;line-height:1.05}',
139
+ '.dshwv-period{font-size:calc(var(--dshw-u) * 104);font-weight:800;line-height:1.05}',
140
+ '.dshwv-wrap{white-space:normal;max-width:calc(var(--dshw-u) * 560);line-height:1.2}',
141
+ '.dshwv-hint{font-size:calc(var(--dshw-u) * 56);color:#9fb0d9;letter-spacing:.02em;margin-top:calc(var(--dshw-u) * 9)}',
142
+ '.dshwv-menu-btn{position:absolute;top:calc(40.55% + 4px);right:4px;width:26px;height:26px;border:none;border-radius:6px;background:rgba(32,49,112,.85);cursor:pointer;pointer-events:auto;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:0;z-index:2;opacity:0;transition:opacity .15s ease}',
143
+ '.dshwv-menu-btn.dshwv-menu-btn-visible{opacity:1}',
144
+ '.dshwv-menu-btn span{display:block;width:14px;height:2px;background:#fff;border-radius:1px}',
145
+ '.dshwv-menu-btn:hover{background:#203170}',
146
+ '.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}',
147
+ '.dshwv-menu.dshwv-menu-open{opacity:1;transform:scale(1) translateY(0);pointer-events:auto}',
148
+ '.dshwv-menu-row{display:flex;align-items:center;gap:8px;margin:5px 0;color:#203170;font-size:12px;white-space:nowrap}',
149
+ '.dshwv-range{flex:1;min-width:0;accent-color:#203170}',
150
+ '.dshwv-number{width:46px;border:1px solid rgba(32,49,112,.4);border-radius:6px;padding:2px 4px;font-size:12px;color:#203170;background:#fff}',
151
+ '.dshwv-sound{flex:1;border:1px solid rgba(32,49,112,.4);border-radius:6px;background:rgba(32,49,112,.08);color:#203170;font-size:12px;padding:3px 0;cursor:pointer}',
152
+ '.dshwv-sound:hover{background:rgba(32,49,112,.16)}',
153
+ '.dshwv-volpct{width:36px;text-align:right;color:#203170;font-size:12px}'
154
+ ].join('\\n')
155
+
156
+ var styleEl = document.createElement('style')
157
+ styleEl.textContent = css
158
+ document.head.appendChild(styleEl)
159
+
160
+ var root = document.createElement('div')
161
+ root.className = 'dshwv-root'
162
+
163
+ var img = document.createElement('img')
164
+ img.className = 'dshwv-img'
165
+ img.src = IMG_URL
166
+ img.alt = 'DeepSeek 余额'
167
+ img.draggable = false
168
+
169
+ var menuBtn = document.createElement('button')
170
+ menuBtn.type = 'button'
171
+ menuBtn.className = 'dshwv-menu-btn'
172
+ menuBtn.title = '菜单'
173
+ menuBtn.innerHTML = '<span></span><span></span><span></span>'
174
+ menuBtn.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu() })
175
+
176
+ var menuBox = document.createElement('div')
177
+ menuBox.className = 'dshwv-menu'
178
+ function menuLabel(text) {
179
+ var s = document.createElement('span')
180
+ s.textContent = text
181
+ return s
182
+ }
183
+ function menuRow() {
184
+ var r = document.createElement('div')
185
+ r.className = 'dshwv-menu-row'
186
+ return r
187
+ }
188
+ var scaleInput = document.createElement('input')
189
+ scaleInput.type = 'range'
190
+ scaleInput.min = String(MIN_SCALE)
191
+ scaleInput.max = String(MAX_SCALE)
192
+ scaleInput.step = '0.1'
193
+ scaleInput.className = 'dshwv-range'
194
+ scaleInput.value = '1.5'
195
+ var scaleNumber = document.createElement('input')
196
+ scaleNumber.type = 'number'
197
+ scaleNumber.min = '1'
198
+ scaleNumber.max = '20'
199
+ scaleNumber.step = '1'
200
+ scaleNumber.className = 'dshwv-number'
201
+ scaleNumber.value = '10'
202
+ scaleInput.addEventListener('pointerdown', function () { root.style.transition = 'none' })
203
+ scaleInput.addEventListener('input', function () { setScale(scaleInput.value) })
204
+ scaleInput.addEventListener('change', function () { root.style.transition = '' })
205
+ scaleNumber.addEventListener('change', function () {
206
+ var v = Math.round(Number(scaleNumber.value))
207
+ var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
208
+ setScale(s)
209
+ root.style.transition = ''
210
+ })
211
+ var soundSelect = document.createElement('select')
212
+ soundSelect.className = 'dshwv-sound'
213
+ function soundOpt(value, label) {
214
+ var o = document.createElement('option')
215
+ o.value = value
216
+ o.textContent = label
217
+ return o
218
+ }
219
+ soundSelect.appendChild(soundOpt('duck', '小黄鸭'))
220
+ soundSelect.appendChild(soundOpt('fx1', '音效1'))
221
+ soundSelect.addEventListener('change', function () { setSoundSet(soundSelect.value) })
222
+ var usageSelect = document.createElement('select')
223
+ usageSelect.className = 'dshwv-sound'
224
+ usageSelect.appendChild(soundOpt('ledger', '小鲸鱼记账 (推荐)'))
225
+ usageSelect.appendChild(soundOpt('token', '实时·令牌 (用法:去问dsh)'))
226
+ usageSelect.addEventListener('change', function () { setUsageMode(usageSelect.value) })
227
+ var row1 = menuRow()
228
+ row1.appendChild(menuLabel('大小'))
229
+ row1.appendChild(scaleInput)
230
+ row1.appendChild(scaleNumber)
231
+ var row2 = menuRow()
232
+ row2.appendChild(menuLabel('音效'))
233
+ row2.appendChild(soundSelect)
234
+ var volInput = document.createElement('input')
235
+ volInput.type = 'range'
236
+ volInput.min = '0'
237
+ volInput.max = '1'
238
+ volInput.step = '0.05'
239
+ volInput.className = 'dshwv-range'
240
+ volInput.value = '0.9'
241
+ var volPct = document.createElement('span')
242
+ volPct.className = 'dshwv-volpct'
243
+ volPct.textContent = '90%'
244
+ volInput.addEventListener('input', function () { setVol(volInput.value) })
245
+ var row3 = menuRow()
246
+ row3.appendChild(menuLabel('音量'))
247
+ row3.appendChild(volInput)
248
+ row3.appendChild(volPct)
249
+ var row4 = menuRow()
250
+ row4.appendChild(menuLabel('用量'))
251
+ row4.appendChild(usageSelect)
252
+ menuBox.appendChild(row1)
253
+ menuBox.appendChild(row2)
254
+ menuBox.appendChild(row3)
255
+ menuBox.appendChild(row4)
256
+
257
+ var textBox = document.createElement('div')
258
+ textBox.className = 'dshwv-text'
259
+ var labelEl = document.createElement('div')
260
+ labelEl.className = 'dshwv-label'
261
+ labelEl.textContent = 'DeepSeek 余额'
262
+ var amountEl = document.createElement('div')
263
+ amountEl.className = 'dshwv-amount'
264
+ var hintEl = document.createElement('div')
265
+ hintEl.className = 'dshwv-hint'
266
+ textBox.appendChild(labelEl)
267
+ textBox.appendChild(amountEl)
268
+ textBox.appendChild(hintEl)
269
+
270
+ var bubbleBox = document.createElement('div')
271
+ bubbleBox.className = 'dshwv-bubble'
272
+ bubbleBox.innerHTML = '<svg viewBox="0 0 1026 700" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg">' +
273
+ '<path class="dshwv-bshape" fill="#FFFFFF" stroke="#203170" stroke-width="18" stroke-linejoin="round" stroke-linecap="round" d="M 827 248 A 373 232 0 1 0 81 246 A 373 232 0 0 0 301 465 A 57 32 10 0 0 413 484 A 373 232 0 0 0 827 248 Z"/>' +
274
+ '<ellipse class="dshwv-b1" cx="352" cy="561" rx="37.5" ry="26" fill="#FFFFFF" stroke="#203170" stroke-width="18"/>' +
275
+ '<ellipse class="dshwv-b2" cx="442" cy="646" rx="24.5" ry="18" fill="#FFFFFF" stroke="#203170" stroke-width="18"/>' +
276
+ '</svg>'
277
+ bubbleBox.appendChild(textBox)
278
+ bubbleBox.addEventListener('click', function (e) {
279
+ e.stopPropagation()
280
+ if (!bubbleShown) return
281
+ if (bubbleRandomActive) {
282
+ // 再次点击:关闭
283
+ hideBubble()
284
+ } else {
285
+ // 首次点击:切到随机台词段(不延长总显示时长)
286
+ bubbleRandomActive = true
287
+ bubbleRandomLines = pickRandomLines()
288
+ swapBubbleContent(function () { applyBubbleLines(bubbleRandomLines) })
289
+ }
290
+ })
291
+
292
+ var body = document.createElement('div')
293
+ body.className = 'dshwv-body'
294
+ body.appendChild(img)
295
+ body.appendChild(bubbleBox)
296
+ root.appendChild(body)
297
+ root.appendChild(menuBtn)
298
+ document.body.appendChild(root)
299
+ document.body.appendChild(menuBox)
300
+
301
+ // Position model: the widget is ALWAYS expressed in left/top px (so edge snaps
302
+ // animate smoothly via the CSS transition on both sides — switching to
303
+ // right/auto cannot transition and flashes). The anchor info (h/v + offsets)
304
+ // lives in state and is used by settle() to recompute coordinates on window
305
+ // resize and size changes, keeping the widget glued to its anchored edge.
306
+ var state = {
307
+ scale: 1.5,
308
+ h: 'right',
309
+ hOff: 0,
310
+ v: 'bottom',
311
+ vOff: 0,
312
+ left: 0,
313
+ top: 0,
314
+ balance: null,
315
+ currency: null,
316
+ todayUsage: null,
317
+ isPeak: false,
318
+ status: 'loading',
319
+ message: ''
320
+ }
321
+ var busy = false
322
+ var settleTimer = null
323
+ var animDelayTimer = null
324
+ var drag = null
325
+ var shown = null
326
+ var animId = null
327
+ var bubbleShown = false
328
+ var bubbleTimer = null
329
+ var bubbleRandomActive = false
330
+ var bubbleRandomLines = null
331
+ var BUBBLE_STYLE_CLASS = { A: 'dshwv-label', B: 'dshwv-amount', P: 'dshwv-period', C: 'dshwv-hint' }
332
+ function pickOne(arr) { return arr[Math.floor(Math.random() * arr.length)] }
333
+ function singleCenter(style, text, color, wrap) { return [null, { t: text, s: style, c: color || '', w: !!wrap }, null] }
334
+ function buildGroup1() {
335
+ var peak = !!state.isPeak
336
+ return [
337
+ { t: '当前时间段为:', s: 'A', c: '' },
338
+ { t: peak ? '高峰时段' : '空闲时段', s: 'P', c: peak ? '#e0433f' : '#2fa24c' },
339
+ { t: '今日已用 ' + fmt(state.todayUsage, state.currency), s: 'C', c: '' },
340
+ ]
341
+ }
342
+ var RANDOM_GROUPS = [
343
+ { w: 20, lines: buildGroup1 },
344
+ { w: 7, lines: function () { return singleCenter('B', pickOne(['好模型... ↓', '好女孩...↓'])) } },
345
+ { w: 7, lines: function () { return singleCenter('A', pickOne(['不知道用户有什么用,先赶走吧~', '我...我...我也要挣钱吗?', '我去吃饭啦,测完叫我', '压力一只蓝色大肥鱼?!', 'DeepSleep...', '坏了...用户彻底怒了!']), '', true) } },
346
+ { w: 3, lines: function () { return singleCenter('A', pickOne(['你目录里的dsh是什么...大烧货吗...?', '恭喜你实现token自由!token全跑了!', '真当我是便宜货啊...']), '', true) } },
347
+ { w: 1, lines: function () { return [{ t: '这个', s: 'A', c: '' }, { t: '凶', s: 'B', c: '' }, { t: '是什么意思呀...', s: 'A', c: '' }] } },
348
+ { w: 1, lines: function () { return singleCenter('B', '哦鲸鲸... ') } },
349
+ ]
350
+ function pickRandomLines() {
351
+ var total = 0
352
+ for (var i = 0; i < RANDOM_GROUPS.length; i++) total += RANDOM_GROUPS[i].w
353
+ var r = Math.random() * total
354
+ for (var i = 0; i < RANDOM_GROUPS.length; i++) {
355
+ r -= RANDOM_GROUPS[i].w
356
+ if (r < 0) return RANDOM_GROUPS[i].lines()
357
+ }
358
+ return RANDOM_GROUPS[RANDOM_GROUPS.length - 1].lines()
359
+ }
360
+ function applyBubbleLines(lines) {
361
+ var els = [labelEl, amountEl, hintEl]
362
+ for (var i = 0; i < 3; i++) {
363
+ var el = els[i]
364
+ var ln = lines && lines[i]
365
+ if (ln) {
366
+ el.style.display = ''
367
+ el.className = (BUBBLE_STYLE_CLASS[ln.s] || 'dshwv-label') + (ln.w ? ' dshwv-wrap' : '')
368
+ el.textContent = ln.t
369
+ el.style.color = ln.c || ''
370
+ } else {
371
+ el.style.display = 'none'
372
+ el.textContent = ''
373
+ el.style.color = ''
374
+ }
375
+ }
376
+ }
377
+ var bubbleSwapTimer = null
378
+ var hintFadeTimer = null
379
+ var lastHintText = null
380
+ function setHint(text) {
381
+ // 「加载中…」→「今日已用」等提示行变化时做淡出淡入,其余直接替换
382
+ if (text === lastHintText) return
383
+ lastHintText = text
384
+ if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
385
+ if (!bubbleShown) {
386
+ hintEl.textContent = text
387
+ return
388
+ }
389
+ hintEl.style.transition = 'opacity .18s ease'
390
+ hintEl.style.opacity = '0'
391
+ hintFadeTimer = setTimeout(function () {
392
+ hintFadeTimer = null
393
+ hintEl.textContent = text
394
+ hintEl.style.opacity = '1'
395
+ setTimeout(function () {
396
+ hintEl.style.transition = ''
397
+ hintEl.style.opacity = ''
398
+ }, 220)
399
+ }, 190)
400
+ }
401
+ function swapBubbleContent(applyFn) {
402
+ if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
403
+ textBox.style.transition = 'opacity .18s ease'
404
+ textBox.style.opacity = '0'
405
+ bubbleSwapTimer = setTimeout(function () {
406
+ bubbleSwapTimer = null
407
+ applyFn()
408
+ textBox.style.opacity = '1'
409
+ setTimeout(function () {
410
+ textBox.style.transition = ''
411
+ textBox.style.opacity = ''
412
+ }, 220)
413
+ }, 190)
414
+ }
415
+ function restoreBubbleLines() {
416
+ if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
417
+ if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
418
+ lastHintText = null
419
+ textBox.style.transition = ''
420
+ textBox.style.opacity = ''
421
+ labelEl.style.display = ''
422
+ labelEl.className = 'dshwv-label'
423
+ labelEl.textContent = 'DeepSeek 余额'
424
+ labelEl.style.color = ''
425
+ amountEl.className = 'dshwv-amount'
426
+ amountEl.style.color = ''
427
+ hintEl.className = 'dshwv-hint'
428
+ hintEl.style.color = ''
429
+ render()
430
+ }
431
+ function showBubble() {
432
+ if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
433
+ bubbleShown = true
434
+ bubbleRandomActive = false
435
+ restoreBubbleLines()
436
+ bubbleBox.classList.add('dshwv-bubble-open')
437
+ // 默认展示当前内容;点击气泡切到随机台词段;总时长 5 秒自动关闭
438
+ bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
439
+ }
440
+ function hideBubble() {
441
+ if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
442
+ if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
443
+ if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
444
+ textBox.style.transition = ''
445
+ textBox.style.opacity = ''
446
+ hintEl.style.transition = ''
447
+ hintEl.style.opacity = ''
448
+ bubbleRandomActive = false
449
+ bubbleRandomLines = null
450
+ bubbleShown = false
451
+ bubbleBox.classList.remove('dshwv-bubble-open')
452
+ }
453
+
454
+ function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
455
+ function viewport() {
456
+ return {
457
+ w: window.innerWidth || document.documentElement.clientWidth || 1280,
458
+ h: window.innerHeight || document.documentElement.clientHeight || 800
459
+ }
460
+ }
461
+ function fmt(balance, currency) {
462
+ var num = Number(balance)
463
+ var fixed = isFinite(num) ? num.toFixed(2) : '--'
464
+ return currency === 'CNY' ? '¥ ' + fixed : fixed + ' ' + currency
465
+ }
466
+ function animateAmount(from, to, currency, duration) {
467
+ if (animId) cancelAnimationFrame(animId)
468
+ if (from === null || !isFinite(from)) from = to
469
+ if (from === to) {
470
+ shown = to
471
+ amountEl.textContent = fmt(to, currency)
472
+ return
473
+ }
474
+ var startTime = null
475
+ function step(ts) {
476
+ if (startTime === null) startTime = ts
477
+ var t = Math.min(1, (ts - startTime) / duration)
478
+ var eased = 1 - Math.pow(1 - t, 3)
479
+ var val = from + (to - from) * eased
480
+ amountEl.textContent = fmt(val, currency)
481
+ if (t < 1) {
482
+ animId = requestAnimationFrame(step)
483
+ } else {
484
+ animId = null
485
+ shown = to
486
+ amountEl.textContent = fmt(to, currency)
487
+ }
488
+ }
489
+ animId = requestAnimationFrame(step)
490
+ }
491
+ function render() {
492
+ var amount, hint
493
+ if (state.status === 'error') {
494
+ amount = shown !== null ? fmt(shown, state.currency) : '--'
495
+ hint = state.message ? state.message.slice(0, 14) : '获取失败 · 点击重试'
496
+ } else if (state.balance === null) {
497
+ amount = shown !== null ? fmt(shown, state.currency) : '…'
498
+ hint = '加载中…'
499
+ } else {
500
+ amount = shown !== null ? fmt(shown, state.currency) : fmt(state.balance, state.currency)
501
+ hint = '今日已用 ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
502
+ }
503
+ amountEl.textContent = amount
504
+ if (bubbleRandomActive && bubbleRandomLines) {
505
+ applyBubbleLines(bubbleRandomLines)
506
+ } else {
507
+ setHint(hint)
508
+ }
509
+ }
510
+ function express() {
511
+ root.style.right = 'auto'
512
+ root.style.bottom = 'auto'
513
+ root.style.left = state.left + 'px'
514
+ root.style.top = state.top + 'px'
515
+ root.classList.toggle('dshwv-left', state.h === 'left')
516
+ }
517
+ function settle() {
518
+ var vp = viewport()
519
+ var w = root.offsetWidth || root.getBoundingClientRect().width || 0
520
+ var h = root.offsetHeight || root.getBoundingClientRect().height || 0
521
+ if (drag && drag.active) {
522
+ // mid-drag resize: keep the pointer-follow position, just clamp into view
523
+ state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
524
+ state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
525
+ express()
526
+ return
527
+ }
528
+ if (state.h === 'right') {
529
+ state.left = Math.max(0, vp.w - w - state.hOff)
530
+ } else if (state.h === 'left') {
531
+ state.left = state.hOff
532
+ } else {
533
+ state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
534
+ }
535
+ if (state.v === 'bottom') {
536
+ state.top = Math.max(0, vp.h - h - state.vOff)
537
+ } else if (state.v === 'top') {
538
+ state.top = state.vOff
539
+ } else {
540
+ state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
541
+ }
542
+ express()
543
+ }
544
+ function refresh(manual) {
545
+ if (busy) return
546
+ busy = true
547
+ if (animDelayTimer) { clearTimeout(animDelayTimer); animDelayTimer = null }
548
+ if (manual || state.balance === null) { state.status = 'loading'; render() }
549
+ var ctrl = null
550
+ var timer = null
551
+ try {
552
+ ctrl = new AbortController()
553
+ timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS)
554
+ } catch (err) {}
555
+ fetch(BALANCE_URL, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
556
+ .then(function (r) { return r.json() })
557
+ .then(function (data) {
558
+ if (data && data.ok) {
559
+ var nb = Number(data.totalBalance)
560
+ var nc = String(data.currency || 'CNY')
561
+ var changed = state.balance !== null && (nb !== state.balance || nc !== state.currency)
562
+ var currencyChanged = state.currency !== null && nc !== state.currency
563
+ state.balance = nb
564
+ state.currency = nc
565
+ state.message = ''
566
+ state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
567
+ state.isPeak = !!data.isPeak
568
+ if (changed && !currencyChanged) {
569
+ if (!manual) {
570
+ showBubble()
571
+ state.status = 'changing'
572
+ // balance-change bubble: wait 0.3s after it floats out, then roll the number
573
+ if (animDelayTimer) clearTimeout(animDelayTimer)
574
+ animDelayTimer = setTimeout(function () {
575
+ animDelayTimer = null
576
+ animateAmount(shown, nb, nc, ANIM_MS)
577
+ }, 300)
578
+ if (settleTimer) clearTimeout(settleTimer)
579
+ settleTimer = setTimeout(function () {
580
+ settleTimer = null
581
+ if (state.status === 'changing') { state.status = 'ok'; render() }
582
+ }, CHANGE_MS + 300)
583
+ } else {
584
+ animateAmount(shown, nb, nc, ANIM_MS)
585
+ state.status = 'ok'
586
+ render()
587
+ }
588
+ } else {
589
+ if (animId === null) shown = nb
590
+ state.status = 'ok'
591
+ render()
592
+ }
593
+ } else {
594
+ state.status = 'error'
595
+ state.message = (data && data.error) ? String(data.error) : '获取失败'
596
+ render()
597
+ }
598
+ })
599
+ .catch(function () {
600
+ state.status = 'error'
601
+ state.message = '获取失败'
602
+ render()
603
+ })
604
+ .finally(function () {
605
+ busy = false
606
+ if (timer) clearTimeout(timer)
607
+ })
608
+ }
609
+ var soundOn = true
610
+ var soundVol = 0.9
611
+ var soundSet = 'duck'
612
+ var usageMode = 'ledger'
613
+ function saveConfig() {
614
+ try {
615
+ fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode }) })
616
+ } catch (err) {}
617
+ }
618
+ function setUsageMode(v) {
619
+ usageMode = v === 'token' ? 'token' : 'ledger'
620
+ usageSelect.value = usageMode
621
+ saveConfig()
622
+ refresh(false)
623
+ }
624
+ function scaleToDisplay(s) {
625
+ return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
626
+ }
627
+ function setScale(v) {
628
+ var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number(v))) * 10) / 10
629
+ var rect = root.getBoundingClientRect()
630
+ // fixed point: the whale's corner — bottom-right when unflipped, bottom-left
631
+ // when flipped. Growing extends the widget up-left / up-right from that
632
+ // corner; shrinking pulls it back toward the corner. The whale always hugs
633
+ // its corner while scaling.
634
+ var fx = state.h === 'left' ? rect.left : rect.right
635
+ var fy = rect.bottom
636
+ state.scale = next
637
+ root.style.setProperty('--dshw-scale', String(next))
638
+ scaleInput.value = String(next)
639
+ scaleNumber.value = String(scaleToDisplay(next))
640
+ saveConfig()
641
+ // keep the corner fixed while resizing; the position correction applies
642
+ // instantly because the caller disables the transition for the whole drag
643
+ var r2 = root.getBoundingClientRect()
644
+ var vp = viewport()
645
+ if (state.h === 'left') {
646
+ state.left = Math.min(Math.max(fx, 0), Math.max(0, vp.w - r2.width))
647
+ } else {
648
+ state.left = Math.min(Math.max(fx - r2.width, 0), Math.max(0, vp.w - r2.width))
649
+ }
650
+ state.top = Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height))
651
+ express()
652
+ }
653
+ function setVol(v) {
654
+ var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100
655
+ soundVol = next
656
+ soundOn = next > 0
657
+ volInput.value = String(next)
658
+ volPct.textContent = Math.round(next * 100) + '%'
659
+ try {
660
+ if (pressAudio) pressAudio.volume = next
661
+ if (releaseAudio) releaseAudio.volume = next
662
+ } catch (err) {}
663
+ saveConfig()
664
+ }
665
+ function setSoundSet(v) {
666
+ soundSet = v === 'fx1' ? 'fx1' : 'duck'
667
+ soundSelect.value = soundSet
668
+ applySoundSet()
669
+ saveConfig()
670
+ }
671
+ var SQUISH = 'scaleY(0.88) scaleX(1.05)'
672
+ var pressAudio = null
673
+ var releaseAudio = null
674
+ var pressing = false
675
+ var pressEnded = false
676
+ var releasePlayed = false
677
+ var releaseTimer = null
678
+ function applySoundSet() {
679
+ try {
680
+ pressAudio = new Audio('/dsh-whale/sound/press.mp3?set=' + soundSet)
681
+ pressAudio.preload = 'auto'
682
+ pressAudio.volume = soundVol
683
+ releaseAudio = new Audio('/dsh-whale/sound/release.mp3?set=' + soundSet)
684
+ releaseAudio.preload = 'auto'
685
+ releaseAudio.volume = soundVol
686
+ } catch (err) {}
687
+ }
688
+ function playPress() {
689
+ if (!pressAudio || !soundOn) return
690
+ try {
691
+ if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
692
+ if (releaseAudio) {
693
+ releaseAudio.pause()
694
+ releaseAudio.currentTime = 0
695
+ }
696
+ pressEnded = false
697
+ releasePlayed = false
698
+ pressAudio.onended = function () {
699
+ pressEnded = true
700
+ // fallback (duration unknown): click → Ya2 right after Ya1 ends
701
+ if (!pressing && !releasePlayed) playRelease()
702
+ // hold: still pressed → wait for pressUp()
703
+ }
704
+ pressAudio.currentTime = 0
705
+ var p = pressAudio.play()
706
+ if (p && typeof p.catch === 'function') p.catch(function () {})
707
+ } catch (err) {}
708
+ }
709
+ function playRelease() {
710
+ if (releasePlayed || !releaseAudio || !soundOn) return
711
+ releasePlayed = true
712
+ try {
713
+ releaseAudio.currentTime = 0
714
+ var p = releaseAudio.play()
715
+ if (p && typeof p.catch === 'function') p.catch(function () {})
716
+ } catch (err) {}
717
+ }
718
+ function pressDown() {
719
+ body.style.transform = SQUISH
720
+ pressing = true
721
+ playPress()
722
+ }
723
+ function pressUp() {
724
+ body.style.transform = 'scaleY(1) scaleX(1)'
725
+ pressing = false
726
+ if (pressEnded) {
727
+ // hold (or released after Ya1 finished) → Ya2 now
728
+ playRelease()
729
+ return
730
+ }
731
+ // click: start Ya2 in the last 100ms of Ya1's playback
732
+ var durKnown = false
733
+ var remainMs = 0
734
+ try {
735
+ var dur = pressAudio ? pressAudio.duration : 0
736
+ if (isFinite(dur) && dur > 0) {
737
+ durKnown = true
738
+ remainMs = (dur - pressAudio.currentTime) * 1000
739
+ }
740
+ } catch (err) {}
741
+ if (durKnown) {
742
+ releaseTimer = setTimeout(function () {
743
+ releaseTimer = null
744
+ playRelease()
745
+ }, Math.max(0, remainMs - 100))
746
+ }
747
+ // duration unknown → pressAudio.onended fallback plays Ya2 after Ya1 ends
748
+ }
749
+ var menuOpen = false
750
+ function toggleMenu() {
751
+ menuOpen = !menuOpen
752
+ if (menuOpen) positionMenu()
753
+ menuBox.classList.toggle('dshwv-menu-open', menuOpen)
754
+ if (menuOpen) menuBtn.classList.add('dshwv-menu-btn-visible')
755
+ }
756
+ function closeMenu() {
757
+ menuOpen = false
758
+ menuBox.classList.remove('dshwv-menu-open')
759
+ root.style.transition = ''
760
+ snapCheck()
761
+ }
762
+ function snapCheck() {
763
+ var rect = root.getBoundingClientRect()
764
+ var vp = viewport()
765
+ var w = rect.width, h = rect.height
766
+ var left = rect.left, top = rect.top
767
+ var centerX = left + w / 2
768
+ var centerY = top + h / 2
769
+ var moved = false
770
+ if (centerX < vp.w / 4) {
771
+ state.h = 'left'
772
+ state.hOff = 0
773
+ left = 0
774
+ moved = true
775
+ } else if (centerX > vp.w * 3 / 4) {
776
+ state.h = 'right'
777
+ state.hOff = 0
778
+ left = vp.w - w
779
+ moved = true
780
+ } else {
781
+ state.h = null
782
+ state.hOff = left
783
+ }
784
+ if (centerY < vp.h / 4) {
785
+ state.v = 'top'
786
+ state.vOff = 0
787
+ top = 0
788
+ moved = true
789
+ } else {
790
+ state.v = 'bottom'
791
+ state.vOff = Math.max(0, vp.h - top - h)
792
+ }
793
+ if (moved) {
794
+ state.left = left
795
+ state.top = top
796
+ settle()
797
+ }
798
+ }
799
+ function positionMenu() {
800
+ try {
801
+ var r = root.getBoundingClientRect()
802
+ var b = menuBtn.getBoundingClientRect()
803
+ var vp = viewport()
804
+ var onLeft = r.left + r.width / 2 < vp.w / 2
805
+ // the menu appears ABOVE the button, anchored to its side:
806
+ // right side → menu bottom-right aligns with the button's top-right;
807
+ // left side → menu bottom-left aligns with the button's top-left
808
+ if (onLeft) {
809
+ menuBox.style.left = b.left + 'px'
810
+ menuBox.style.right = 'auto'
811
+ menuBox.style.transformOrigin = 'bottom left'
812
+ } else {
813
+ menuBox.style.right = (vp.w - b.right) + 'px'
814
+ menuBox.style.left = 'auto'
815
+ menuBox.style.transformOrigin = 'bottom right'
816
+ }
817
+ menuBox.style.bottom = (vp.h - b.top) + 'px'
818
+ menuBox.style.top = 'auto'
819
+ } catch (err) {}
820
+ }
821
+
822
+ var hitCanvas = null
823
+ var hitReady = false
824
+ function setupHitTest() {
825
+ try {
826
+ hitCanvas = document.createElement('canvas')
827
+ hitCanvas.width = 610
828
+ hitCanvas.height = 610
829
+ var probe = new Image()
830
+ probe.onload = function () {
831
+ try {
832
+ hitCanvas.getContext('2d').drawImage(probe, 0, 0)
833
+ hitReady = true
834
+ } catch (err) {}
835
+ }
836
+ probe.onerror = function () {}
837
+ probe.src = IMG_URL
838
+ } catch (err) {}
839
+ }
840
+ function isWhaleHit(e) {
841
+ if (!hitCanvas || !hitReady) return true
842
+ try {
843
+ var r = img.getBoundingClientRect()
844
+ if (!r || r.width <= 0 || r.height <= 0) return false
845
+ var lx = (e.clientX - r.left) / r.width * 610
846
+ var ly = (e.clientY - r.top) / r.height * 610
847
+ if (lx < 0 || ly < 0 || lx >= 610 || ly >= 610) return false
848
+ if (state.h === 'left') lx = 610 - lx
849
+ var data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
850
+ return data[3] > 10
851
+ } catch (err) {
852
+ return true
853
+ }
854
+ }
855
+ function onDocPointerDown(e) {
856
+ if (e.target && e.target.closest) {
857
+ if (e.target.closest('.dshwv-bubble') || e.target.closest('.dshwv-menu') || e.target.closest('.dshwv-menu-btn')) return
858
+ }
859
+ if (menuOpen) {
860
+ closeMenu()
861
+ return
862
+ }
863
+ if (e.button !== 0 && e.pointerType === 'mouse') return
864
+ if (!isWhaleHit(e)) return
865
+ try { e.preventDefault(); e.stopPropagation() } catch (err) {}
866
+ var vp = viewport()
867
+ var rect = root.getBoundingClientRect()
868
+ 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 }
869
+ root.classList.add('dshwv-dragging')
870
+ pressDown()
871
+ setWidgetCursor('grabbing')
872
+ document.addEventListener('pointermove', onDocPointerMove, true)
873
+ document.addEventListener('pointerup', onDocPointerUp, true)
874
+ document.addEventListener('pointercancel', onDocPointerCancel, true)
875
+ document.addEventListener('click', onDocClickStopper, true)
876
+ }
877
+ function onDocPointerMove(e) {
878
+ if (!drag || !drag.active) return
879
+ var dx = e.clientX - drag.startX
880
+ var dy = e.clientY - drag.startY
881
+ if (dx * dx + dy * dy >= CLICK_SQ) drag.moved = true
882
+ // Keep the pre-drag flip orientation while dragging (state.h/v stay as they
883
+ // were); on release endDrag() recomputes the anchors and settle() flips the
884
+ // class with a smooth transition instead of reverting instantly.
885
+ state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
886
+ state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
887
+ express()
888
+ }
889
+ function onDocPointerUp(e) { endDrag(e, true) }
890
+ function onDocPointerCancel(e) { endDrag(e, false) }
891
+ function onDocClickStopper(e) {
892
+ try { e.preventDefault(); e.stopPropagation() } catch (err) {}
893
+ }
894
+ document.addEventListener('pointerdown', onDocPointerDown, true)
895
+
896
+ var widgetCursor = ''
897
+ function setWidgetCursor(v) {
898
+ if (v !== widgetCursor) {
899
+ widgetCursor = v
900
+ try { document.body.style.cursor = v } catch (err) {}
901
+ }
902
+ }
903
+ function onDocPointerMoveCursor(e) {
904
+ if (drag && drag.active) { setWidgetCursor('grabbing'); return }
905
+ var el = null
906
+ try { el = document.elementFromPoint(e.clientX, e.clientY) } catch (err) {}
907
+ if (el && el.closest && (el.closest('.dshwv-bubble') || el.closest('.dshwv-menu') || el.closest('.dshwv-menu-btn'))) {
908
+ setWidgetCursor('')
909
+ menuBtn.classList.add('dshwv-menu-btn-visible')
910
+ return
911
+ }
912
+ var over = isWhaleHit(e)
913
+ setWidgetCursor(over ? 'grab' : '')
914
+ menuBtn.classList.toggle('dshwv-menu-btn-visible', over || menuOpen)
915
+ }
916
+ document.addEventListener('pointermove', onDocPointerMoveCursor, true)
917
+
918
+ function endDrag(e, clickAllowed) {
919
+ if (!drag || !drag.active) return
920
+ drag.active = false
921
+ document.removeEventListener('pointermove', onDocPointerMove, true)
922
+ document.removeEventListener('pointerup', onDocPointerUp, true)
923
+ document.removeEventListener('pointercancel', onDocPointerCancel, true)
924
+ document.removeEventListener('click', onDocClickStopper, true)
925
+ pressUp()
926
+ root.classList.remove('dshwv-dragging')
927
+ setWidgetCursor(isWhaleHit(e) ? 'grab' : '')
928
+ if (clickAllowed && !drag.moved) { showBubble(); refresh(true); return }
929
+ var dx = e.clientX - drag.startX
930
+ var dy = e.clientY - drag.startY
931
+ var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
932
+ var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
933
+ var centerX = left + drag.w / 2
934
+ var centerY = top + drag.h / 2
935
+ if (centerX < drag.vp.w / 4) {
936
+ state.h = 'left'
937
+ state.hOff = 0
938
+ } else if (centerX > drag.vp.w * 3 / 4) {
939
+ state.h = 'right'
940
+ state.hOff = 0
941
+ } else {
942
+ state.h = null
943
+ state.hOff = left
944
+ }
945
+ if (centerY < drag.vp.h / 4) {
946
+ state.v = 'top'
947
+ state.vOff = 0
948
+ } else if (centerY > drag.vp.h * 3 / 4) {
949
+ state.v = 'bottom'
950
+ state.vOff = 0
951
+ } else {
952
+ state.v = null
953
+ state.vOff = top
954
+ }
955
+ state.left = left
956
+ state.top = top
957
+ settle()
958
+ }
959
+ window.addEventListener('resize', function () {
960
+ settle()
961
+ })
962
+
963
+ var rect0 = root.getBoundingClientRect()
964
+ state.left = rect0.left
965
+ state.top = rect0.top
966
+ express()
967
+ render()
968
+ applySoundSet()
969
+ setupHitTest()
970
+ fetch(SIZE_URL, { cache: 'no-store' })
971
+ .then(function (r) { return r.json() })
972
+ .then(function (d) {
973
+ if (d && typeof d.scale === 'number' && d.scale >= MIN_SCALE - 0.1 && d.scale <= MAX_SCALE + 0.1) {
974
+ state.scale = d.scale
975
+ root.style.setProperty('--dshw-scale', String(d.scale))
976
+ scaleInput.value = String(d.scale)
977
+ scaleNumber.value = String(scaleToDisplay(d.scale))
978
+ settle()
979
+ }
980
+ if (d && typeof d.vol === 'number') {
981
+ soundVol = d.vol
982
+ soundOn = soundVol > 0
983
+ volInput.value = String(soundVol)
984
+ volPct.textContent = Math.round(soundVol * 100) + '%'
985
+ try {
986
+ if (pressAudio) pressAudio.volume = soundVol
987
+ if (releaseAudio) releaseAudio.volume = soundVol
988
+ } catch (err) {}
989
+ }
990
+ if (d && typeof d.soundSet === 'string') {
991
+ soundSet = d.soundSet === 'fx1' ? 'fx1' : 'duck'
992
+ soundSelect.value = soundSet
993
+ applySoundSet()
994
+ }
995
+ if (d && typeof d.usageMode === 'string') {
996
+ usageMode = d.usageMode === 'token' ? 'token' : 'ledger'
997
+ usageSelect.value = usageMode
998
+ }
999
+ refresh(false)
1000
+ })
1001
+ .catch(function () { refresh(false) })
1002
+ setInterval(function () { refresh(false) }, REFRESH_MS)
1003
+ })()`
1004
+
1005
+
1006
+ const name = 'whale-balance-widget'
1007
+ const inject = ['webServer', 'credentials']
1008
+
1009
+ function apply(ctx) {
1010
+ let imageBytes = null
1011
+ let balanceCache = null
1012
+ let balanceInFlight = null
1013
+
1014
+ function loadImage() {
1015
+ if (imageBytes) return imageBytes
1016
+ for (const p of IMAGE_CANDIDATES) {
1017
+ try {
1018
+ const bytes = fs.readFileSync(p)
1019
+ if (bytes && bytes.length > 0) {
1020
+ imageBytes = bytes
1021
+ return bytes
1022
+ }
1023
+ } catch (err) {}
1024
+ }
1025
+ throw new Error('whale image not found')
1026
+ }
1027
+
1028
+ async function fetchBalance() {
1029
+ let cred
1030
+ try {
1031
+ cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
1032
+ } catch (err) {
1033
+ return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
1034
+ }
1035
+ if (!cred) {
1036
+ return { ok: false, code: 'NO_KEY', error: '未配置 DEEPSEEK_API_KEY' }
1037
+ }
1038
+ let lastErr = null
1039
+ for (let attempt = 0; attempt < 2; attempt++) {
1040
+ let res
1041
+ try {
1042
+ res = await fetch(BALANCE_URL, {
1043
+ headers: { Authorization: 'Bearer ' + cred.value },
1044
+ signal: AbortSignal.timeout(20000),
1045
+ })
1046
+ } catch (err) {
1047
+ lastErr = err
1048
+ if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
1049
+ continue
1050
+ }
1051
+ if (!res.ok) {
1052
+ lastErr = new Error('HTTP ' + res.status)
1053
+ if (res.status < 500) break
1054
+ if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
1055
+ continue
1056
+ }
1057
+ let data
1058
+ try {
1059
+ data = await res.json()
1060
+ } catch (err) {
1061
+ return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
1062
+ }
1063
+ const info = data && Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
1064
+ if (!info || info.total_balance === undefined) {
1065
+ return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
1066
+ }
1067
+ return {
1068
+ ok: true,
1069
+ totalBalance: Number(info.total_balance),
1070
+ currency: String(info.currency || 'CNY'),
1071
+ updatedAt: new Date().toISOString(),
1072
+ }
1073
+ }
1074
+ const transient = !(lastErr && /^HTTP 4\d\d/.test(lastErr.message))
1075
+ return {
1076
+ ok: false,
1077
+ code: 'HTTP',
1078
+ transient: transient,
1079
+ error: '余额接口请求失败: ' + String((lastErr && lastErr.message) || lastErr).slice(0, 200),
1080
+ }
1081
+ }
1082
+
1083
+ async function fetchUsage() {
1084
+ let cred
1085
+ try {
1086
+ cred = await ctx.credentials.resolve('DEEPSEEK_PLATFORM_TOKEN')
1087
+ } catch (err) {
1088
+ return { error: 'platform cred resolve failed' }
1089
+ }
1090
+ if (!cred) return { error: 'no platform token' }
1091
+ const token = String(cred.value).replace(/^Bearer\s+/i, '')
1092
+ try {
1093
+ const now = new Date()
1094
+ const tz = -now.getTimezoneOffset() * 60
1095
+ const start = Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
1096
+ const end = start + 86400
1097
+ const url = 'https://platform.deepseek.com/api/v0/usage/by_api_key/amount?start=' + start + '&end=' + end + '&tz=' + tz
1098
+ const res = await fetch(url, {
1099
+ headers: { Authorization: 'Bearer ' + token },
1100
+ signal: AbortSignal.timeout(15000),
1101
+ })
1102
+ if (!res.ok) return { error: 'http ' + res.status }
1103
+ const data = await res.json()
1104
+ const u = computeTodayUsage(data)
1105
+ if (u && isFinite(u.amount)) return { amount: u.amount, tokens: u.tokens }
1106
+ return { error: 'no usage' }
1107
+ } catch (err) {
1108
+ return { error: String((err && err.message) || err) }
1109
+ }
1110
+ }
1111
+
1112
+ function computeTodayUsage(data) {
1113
+ // data.data.biz_data.series[]: [{model, buckets:[{time, usage:{RESPONSE_TOKEN, PROMPT_CACHE_HIT_TOKEN, PROMPT_CACHE_MISS_TOKEN}}]}]
1114
+ let d = data
1115
+ if (d && d.data && d.data.biz_data && Array.isArray(d.data.biz_data.series)) d = d.data.biz_data
1116
+ else if (d && d.data && Array.isArray(d.data.series)) d = d.data
1117
+ const series = Array.isArray(d.series) ? d.series : null
1118
+ if (!series || series.length === 0) return null
1119
+ let cost = 0
1120
+ let tokens = 0
1121
+ let found = false
1122
+ for (const s of series) {
1123
+ if (!s || typeof s !== 'object') continue
1124
+ const p = priceFor(s.model)
1125
+ const buckets = Array.isArray(s.buckets) ? s.buckets : []
1126
+ for (const b of buckets) {
1127
+ const u = b && b.usage
1128
+ if (!u || typeof u !== 'object') continue
1129
+ const hit = Number(u.PROMPT_CACHE_HIT_TOKEN) || 0
1130
+ const miss = Number(u.PROMPT_CACHE_MISS_TOKEN) || 0
1131
+ const out = Number(u.RESPONSE_TOKEN) || 0
1132
+ if (hit + miss + out === 0) continue
1133
+ found = true
1134
+ tokens += hit + miss + out
1135
+ const pi = isPeakTime(b.time) ? 1 : 0
1136
+ cost += (hit / 1e6) * p.hit[pi] + (miss / 1e6) * p.miss[pi] + (out / 1e6) * p.out[pi]
1137
+ }
1138
+ }
1139
+ return found ? { amount: cost, tokens: tokens } : null
1140
+ }
1141
+
1142
+ function todayKey() {
1143
+ const d = new Date()
1144
+ const p = (n) => String(n).padStart(2, '0')
1145
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
1146
+ }
1147
+ function readUsageLedger() {
1148
+ for (const p of USAGE_FILE_CANDIDATES) {
1149
+ try {
1150
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
1151
+ if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
1152
+ } catch (err) {}
1153
+ }
1154
+ return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
1155
+ }
1156
+ function writeUsageLedger(led) {
1157
+ const body = JSON.stringify(led)
1158
+ for (const p of USAGE_FILE_CANDIDATES) {
1159
+ try {
1160
+ fs.writeFileSync(p, body, 'utf8')
1161
+ return true
1162
+ } catch (err) {}
1163
+ }
1164
+ return false
1165
+ }
1166
+ // 记账模式:每次观测到余额后,用余额正差值累计当天用量(跨天自动归零并归档)
1167
+ function recordLedgerUsage(currentBalance) {
1168
+ const t = todayKey()
1169
+ let led = readUsageLedger()
1170
+ if (led.date !== t) {
1171
+ if (led.date && typeof led.todayUsage === 'number') {
1172
+ led.history = led.history || {}
1173
+ led.history[led.date] = led.todayUsage
1174
+ }
1175
+ led.date = t
1176
+ led.lastBalance = currentBalance
1177
+ led.todayUsage = 0
1178
+ } else {
1179
+ const prev = typeof led.lastBalance === 'number' ? led.lastBalance : currentBalance
1180
+ if (typeof prev === 'number' && typeof currentBalance === 'number' && currentBalance < prev) {
1181
+ led.todayUsage = (typeof led.todayUsage === 'number' ? led.todayUsage : 0) + (prev - currentBalance)
1182
+ }
1183
+ led.lastBalance = currentBalance
1184
+ }
1185
+ const keys = Object.keys(led.history || {}).sort()
1186
+ while (keys.length > 30) {
1187
+ delete led.history[keys.shift()]
1188
+ }
1189
+ writeUsageLedger(led)
1190
+ return led
1191
+ }
1192
+ function normalizeUsageMode(m) {
1193
+ return m === 'token' ? 'token' : 'ledger'
1194
+ }
1195
+
1196
+ async function getBalancePayload() {
1197
+ const payload = await fetchBalance()
1198
+ if (!payload.ok) return payload
1199
+ // 无论哪种模式,都先把余额观测记入账本(自动累积「鲸鱼记账」数据)
1200
+ const led = recordLedgerUsage(Number(payload.totalBalance))
1201
+ const cfg = readSizeConfig() || {}
1202
+ const mode = normalizeUsageMode(cfg.usageMode)
1203
+ const full = { ...payload }
1204
+ full.isPeak = isPeakTime(Math.floor(Date.now() / 1000))
1205
+ if (mode === 'ledger') {
1206
+ full.todayUsage = led.todayUsage
1207
+ full.usageMode = 'ledger'
1208
+ return full
1209
+ }
1210
+ // token:尝试平台令牌实时计算
1211
+ let cred = null
1212
+ try {
1213
+ cred = await ctx.credentials.resolve('DEEPSEEK_PLATFORM_TOKEN')
1214
+ } catch (err) {}
1215
+ if (cred) {
1216
+ const u = await fetchUsage()
1217
+ if (u && u.amount !== undefined) {
1218
+ full.todayUsage = u.amount
1219
+ full.usageMode = 'token'
1220
+ return full
1221
+ }
1222
+ }
1223
+ // 无令牌或令牌失败:回落记账模式
1224
+ full.todayUsage = led.todayUsage
1225
+ full.usageMode = 'ledger'
1226
+ return full
1227
+ }
1228
+
1229
+ function getBalance() {
1230
+ const now = Date.now()
1231
+ if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
1232
+ return Promise.resolve(balanceCache.payload)
1233
+ }
1234
+ if (balanceInFlight) return balanceInFlight
1235
+ balanceInFlight = getBalancePayload()
1236
+ .then((payload) => {
1237
+ if (payload.ok) {
1238
+ balanceCache = { at: now, payload }
1239
+ return payload
1240
+ }
1241
+ if (payload.transient && balanceCache) {
1242
+ // transient network/API blip: keep serving the last known balance
1243
+ return { ...balanceCache.payload, stale: true, error: payload.error }
1244
+ }
1245
+ if (!payload.transient) console.error('[whale-balance]', payload.code, payload.error)
1246
+ return payload
1247
+ })
1248
+ .catch((err) => ({
1249
+ ok: false,
1250
+ code: 'ERROR',
1251
+ error: '余额服务异常: ' + String((err && err.message) || err).slice(0, 200),
1252
+ }))
1253
+ .finally(() => {
1254
+ balanceInFlight = null
1255
+ })
1256
+ return balanceInFlight
1257
+ }
1258
+
1259
+ function readSizeConfig() {
1260
+ for (const p of SIZE_FILE_CANDIDATES) {
1261
+ try {
1262
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
1263
+ if (parsed && typeof parsed.scale === 'number') {
1264
+ return {
1265
+ scale: parsed.scale,
1266
+ sound: parsed.sound !== false,
1267
+ vol: typeof parsed.vol === 'number' ? parsed.vol : 0.9,
1268
+ soundSet: parsed.soundSet === 'fx1' ? 'fx1' : 'duck',
1269
+ usageMode: normalizeUsageMode(parsed.usageMode),
1270
+ }
1271
+ }
1272
+ } catch (err) {}
1273
+ }
1274
+ return null
1275
+ }
1276
+
1277
+ function writeSizeConfig(scale, sound, vol, soundSet, usageMode) {
1278
+ const um = normalizeUsageMode(usageMode)
1279
+ const body = JSON.stringify({
1280
+ scale: scale,
1281
+ sound: sound !== false,
1282
+ vol: typeof vol === 'number' ? vol : 0.9,
1283
+ soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
1284
+ usageMode: um,
1285
+ updatedAt: new Date().toISOString(),
1286
+ })
1287
+ for (const p of SIZE_FILE_CANDIDATES) {
1288
+ try {
1289
+ fs.writeFileSync(p, body, 'utf8')
1290
+ return {
1291
+ ok: true,
1292
+ scale: scale,
1293
+ sound: sound !== false,
1294
+ vol: typeof vol === 'number' ? vol : 0.9,
1295
+ soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
1296
+ usageMode: um,
1297
+ }
1298
+ } catch (err) {}
1299
+ }
1300
+ return { ok: false, error: '无法持久化挂件尺寸' }
1301
+ }
1302
+
1303
+ function readBody(req) {
1304
+ return new Promise((resolve, reject) => {
1305
+ const chunks = []
1306
+ let size = 0
1307
+ req.on('data', (c) => {
1308
+ size += c.length
1309
+ if (size > 8192) {
1310
+ reject(new Error('body too large'))
1311
+ req.destroy()
1312
+ return
1313
+ }
1314
+ chunks.push(c)
1315
+ })
1316
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
1317
+ req.on('error', reject)
1318
+ })
1319
+ }
1320
+
1321
+ const disposers = []
1322
+
1323
+ disposers.push(ctx.webServer.register({
1324
+ kind: 'exact',
1325
+ path: '/dsh-whale/image.png',
1326
+ handler: (req, res) => {
1327
+ try {
1328
+ const bytes = loadImage()
1329
+ res.writeHead(200, {
1330
+ 'Content-Type': 'image/png',
1331
+ 'Cache-Control': 'no-store',
1332
+ 'Content-Length': String(bytes.length),
1333
+ })
1334
+ res.end(bytes)
1335
+ } catch (err) {
1336
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
1337
+ res.end('whale image unavailable: ' + String((err && err.message) || err))
1338
+ }
1339
+ },
1340
+ }))
1341
+
1342
+ disposers.push(ctx.webServer.register({
1343
+ kind: 'exact',
1344
+ path: '/dsh-whale/balance.json',
1345
+ handler: async (req, res) => {
1346
+ try {
1347
+ const payload = await getBalance()
1348
+ res.writeHead(200, JSON_HEADERS)
1349
+ res.end(JSON.stringify(payload))
1350
+ } catch (err) {
1351
+ res.writeHead(200, JSON_HEADERS)
1352
+ res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200) }))
1353
+ }
1354
+ },
1355
+ }))
1356
+
1357
+ disposers.push(ctx.webServer.register({
1358
+ kind: 'exact',
1359
+ path: '/dsh-whale/size.json',
1360
+ handler: async (req, res) => {
1361
+ if (req.method === 'PUT' || req.method === 'POST') {
1362
+ try {
1363
+ const body = await readBody(req)
1364
+ const parsed = JSON.parse(body)
1365
+ const scale = typeof parsed.scale === 'number' ? parsed.scale : null
1366
+ if (scale === null) {
1367
+ res.writeHead(400, JSON_HEADERS)
1368
+ res.end(JSON.stringify({ ok: false, error: 'missing scale' }))
1369
+ return
1370
+ }
1371
+ // 用量模式变化时让余额缓存失效,下次请求立即按新模式计算
1372
+ if (typeof parsed.usageMode === 'string') {
1373
+ const old = readSizeConfig()
1374
+ if (!old || normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
1375
+ balanceCache = null
1376
+ }
1377
+ }
1378
+ const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode)
1379
+ res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
1380
+ res.end(JSON.stringify(result))
1381
+ } catch (err) {
1382
+ res.writeHead(400, JSON_HEADERS)
1383
+ res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
1384
+ }
1385
+ return
1386
+ }
1387
+ res.writeHead(200, JSON_HEADERS)
1388
+ res.end(JSON.stringify(readSizeConfig() || {}))
1389
+ },
1390
+ }))
1391
+
1392
+ function loadSound(candidates) {
1393
+ for (const p of candidates) {
1394
+ try {
1395
+ const bytes = fs.readFileSync(p)
1396
+ if (bytes && bytes.length > 0) return bytes
1397
+ } catch (err) {}
1398
+ }
1399
+ return null
1400
+ }
1401
+
1402
+ function serveSound(req, res, candidates) {
1403
+ const bytes = loadSound(candidates)
1404
+ if (!bytes) {
1405
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
1406
+ res.end('sound unavailable')
1407
+ return
1408
+ }
1409
+ res.writeHead(200, {
1410
+ 'Content-Type': 'audio/mpeg',
1411
+ 'Cache-Control': 'no-store',
1412
+ 'Content-Length': String(bytes.length),
1413
+ })
1414
+ res.end(bytes)
1415
+ }
1416
+
1417
+ disposers.push(ctx.webServer.register({
1418
+ kind: 'exact',
1419
+ path: '/dsh-whale/sound/press.mp3',
1420
+ handler: (req, res) => {
1421
+ const set = SOUND_SETS[soundSetFromUrl(req.url)] || SOUND_SETS.duck
1422
+ serveSound(req, res, set.press)
1423
+ },
1424
+ }))
1425
+
1426
+ disposers.push(ctx.webServer.register({
1427
+ kind: 'exact',
1428
+ path: '/dsh-whale/sound/release.mp3',
1429
+ handler: (req, res) => {
1430
+ const set = SOUND_SETS[soundSetFromUrl(req.url)] || SOUND_SETS.duck
1431
+ serveSound(req, res, set.release)
1432
+ },
1433
+ }))
1434
+
1435
+ disposers.push(ctx.webServer.register({
1436
+ kind: 'exact',
1437
+ path: '/dsh-whale/widget.js',
1438
+ handler: (req, res) => {
1439
+ res.writeHead(200, {
1440
+ 'Content-Type': 'application/javascript; charset=utf-8',
1441
+ 'Cache-Control': 'no-store',
1442
+ })
1443
+ res.end(WIDGET_JS)
1444
+ },
1445
+ }))
1446
+
1447
+ disposers.push(ctx.webServer.tapIndex((html) => {
1448
+ if (html.indexOf('/dsh-whale/widget.js') !== -1) return html
1449
+ const tag = '<script defer src="/dsh-whale/widget.js"></script>'
1450
+ if (html.indexOf('</body>') !== -1) return html.replace('</body>', tag + '</body>')
1451
+ return html + tag
1452
+ }))
1453
+
1454
+ ctx.effect(() => () => {
1455
+ for (const d of disposers) {
1456
+ try { d() } catch (err) {}
1457
+ }
1458
+ })
1459
+ }
1460
+
1461
+ export { name, inject, apply }