import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Package root: lib/index.js -> package root. Keeps the bundle relocatable
// when installed as a normal DSH npm plugin (node_modules or a local link).
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
// DSH home: used for the widget size/usage memory files, since node_modules may
// be read-only or cleaned on update.
const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
// Whale image: package-relative first, legacy absolute paths as fallback.
const IMAGE_CANDIDATES = [
path.join(PACKAGE_ROOT, 'assets', 'DSniang1.png'),
path.join(PACKAGE_ROOT, 'assets', 'DSniang02.png'),
'D:/TestBox/deepseek/DSniang1.png',
'D:/TestBox/deepseek/DSniang02.png',
'D:/TestBox/deepseek/skin/DSniang02.png',
]
// Size memory file: prefer writable DSH home locations, then legacy fallbacks.
const SIZE_FILE_CANDIDATES = [
path.join(DSH_HOME, '.dshw-size.json'),
path.join(DSH_HOME, 'profiles', 'web', '.dshw-size.json'),
'D:/TestBox/deepseek/.dshw-size.json',
'D:/TestBox/deepseek/skin/.dshw-size.json',
]
// Usage ledger file (小鲸鱼记账 mode): same policy as the size file.
const USAGE_FILE_CANDIDATES = [
path.join(DSH_HOME, '.dshw-usage.json'),
path.join(DSH_HOME, 'profiles', 'web', '.dshw-usage.json'),
'D:/TestBox/deepseek/.dshw-usage.json',
'D:/TestBox/deepseek/skin/.dshw-usage.json',
]
// Sound assets: package-relative first (ship Ya1/Ya2/D1/D2.mp3 in assets/ for
// sounds out of the box), legacy paths as fallback.
const SOUND_SETS = {
duck: {
press: [path.join(PACKAGE_ROOT, 'assets', 'Ya1.mp3'), 'D:/TestBox/deepseek/skin/Ya1.mp3'],
release: [path.join(PACKAGE_ROOT, 'assets', 'Ya2.mp3'), 'D:/TestBox/deepseek/skin/Ya2.mp3'],
},
fx1: {
press: [path.join(PACKAGE_ROOT, 'assets', 'D1.mp3'), 'D:/TestBox/deepseek/skin/D1.mp3'],
release: [path.join(PACKAGE_ROOT, 'assets', 'D2.mp3'), 'D:/TestBox/deepseek/skin/D2.mp3'],
},
}
function soundSetFromUrl(url) {
try {
const q = String(url || '').split('?')[1] || ''
const m = /(?:^|&)set=([^&]+)/.exec(q)
return m ? decodeURIComponent(m[1]) : ''
} catch (err) { return '' }
}
const BALANCE_URL = 'https://api.deepseek.com/user/balance'
const BALANCE_TTL_MS = 25000
const RUA_GIF_CANDIDATES = [
path.join(PACKAGE_ROOT, 'assets', 'rua.gif'),
'D:/TestBox/deepseek/skin/rua.gif',
'D:/TestBox/deepseek/rua.gif',
]
// 反应 GIF 素材(蓝色大肥鱼表情包,用户自选放入 assets/reactions/)
const ANGRY_GIF_CANDIDATES = [
path.join(PACKAGE_ROOT, 'assets', 'reactions', 'angry.gif'),
]
const PAT_GIF_CANDIDATES = [
path.join(PACKAGE_ROOT, 'assets', 'reactions', 'pat.gif'),
]
const IDLE_GIF_CANDIDATES = [
path.join(PACKAGE_ROOT, 'assets', 'reactions', 'idle.gif'),
]
// DeepSeek CNY prices per million tokens: [空闲时段价, 高峰时段价].
// 高峰时段:每日 9:00–12:00 和 14:00–18:00(北京时间)。Adjust here if DeepSeek changes pricing.
const PEAK_HOURS = [
[9, 12],
[14, 18],
]
const BASE_PRICE = { hit: [0.05, 0.1], miss: [1.5, 3.0], out: [4.5, 9.0] }
// deepseek-v4-pro 为 flash 的 3 倍价(官方 2026-08-17 生效);vision-exp 与 flash 同价
const PRO_PRICE = { hit: [0.15, 0.3], miss: [4.5, 9.0], out: [13.5, 27.0] }
const PRICING = {
'deepseek-v4-flash-vision-exp': BASE_PRICE,
'deepseek-v4-flash': BASE_PRICE,
'deepseek-v4-pro': PRO_PRICE,
'deepseek-chat': BASE_PRICE,
'deepseek-reasoner': BASE_PRICE,
_default: BASE_PRICE,
}
function priceFor(model) {
const m = String(model || '').toLowerCase()
for (const key of Object.keys(PRICING)) {
if (key === '_default') continue
if (m.indexOf(key) !== -1) return PRICING[key]
}
return PRICING._default
}
// bucket time is an epoch second; derive the Beijing local hour to pick peak vs off-peak price.
function isPeakTime(timeSec) {
if (!isFinite(Number(timeSec))) return false
const hour = new Date(Number(timeSec) * 1000 + 8 * 3600 * 1000).getUTCHours()
for (const [start, end] of PEAK_HOURS) {
if (hour >= start && hour < end) return true
}
return false
}
const JSON_HEADERS = {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'no-store',
}
const WIDGET_JS = `(function () {
if (window.__dshWhaleWidget) return
window.__dshWhaleWidget = true
var MIN_SCALE = 0.6
var MAX_SCALE = 2.5
var STEP = 0.1
var CLICK_SQ = 9
var REFRESH_MS = 60000
var CHANGE_MS = 900
var ANIM_MS = 700
var BUBBLE_MS = 5000
var FETCH_TIMEOUT_MS = 25000
var BALANCE_URL = '/dsh-whale/balance.json'
var SIZE_URL = '/dsh-whale/size.json'
var IMG_URL = '/dsh-whale/image.png?v=2'
// 挂件双形象:'dsniang'(默认,静态立绘+Q弹)| 'dafeiyu'(大肥鱼表情包形象,
// 本体为 GIF 动画,摸头/生气切换对应反应图)。键与美化设置面板共享。
var SKIN_STORAGE_KEY = 'dsh-beautify:whaleSkin'
var IDLE_GIF_URL = '/dsh-whale/reactions/idle.gif'
var ANGRY_GIF_URL = '/dsh-whale/reactions/angry.gif'
var PAT_GIF_URL = '/dsh-whale/reactions/pat.gif'
var REACTION_FALLBACK_MS = 3000
var MIN_REACTION_MS = 800
var SEQ_TAIL_MS = 300
var angrySeqToken = null
function readWhaleSkin() {
try { return localStorage.getItem(SKIN_STORAGE_KEY) === 'dafeiyu' ? 'dafeiyu' : 'dsniang' } catch (err) { return 'dsniang' }
}
function baseImgUrl() { return readWhaleSkin() === 'dafeiyu' ? IDLE_GIF_URL : IMG_URL }
var GIF_URL = '/dsh-whale/rua.gif'
var css = [
'.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}',
'.dshwv-root.dshwv-left{transform:scaleX(-1)}',
'.dshwv-root.dshwv-dragging{cursor:grabbing;transition:none}',
'.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)}',
'.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}',
'.dshwv-bubble{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1;--dshw-u:calc(var(--dshw-base) / 1026)}',
'.dshwv-bubble svg{display:block;width:100%;height:100%;pointer-events:none}',
'.dshwv-bubble svg path,.dshwv-bubble svg ellipse{pointer-events:none;cursor:pointer}',
'.dshwv-bubble.dshwv-bubble-open svg path,.dshwv-bubble.dshwv-bubble-open svg ellipse{pointer-events:visiblePainted}',
'.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}',
'.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}',
'.dshwv-gif{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);max-width:calc(var(--dshw-u) * 560);max-height:calc(var(--dshw-u) * 400);display:none;opacity:0;transition:opacity .2s ease;pointer-events:none;-webkit-user-drag:none;user-select:none;object-fit:contain}',
'.dshwv-root.dshwv-left .dshwv-gif{transform:translate(-50%,-50%) scaleX(-1)}',
'.dshwv-bubble.dshwv-bubble-open .dshwv-gif{opacity:1}',
'.dshwv-bubble.dshwv-bubble-open .dshwv-b2{transition-delay:0s}',
'.dshwv-bubble.dshwv-bubble-open .dshwv-b1{transition-delay:.13s}',
'.dshwv-bubble.dshwv-bubble-open .dshwv-bshape{transition-delay:.26s}',
'.dshwv-bubble .dshwv-bshape{transition-delay:.1s}',
'.dshwv-bubble .dshwv-b1{transition-delay:.2s}',
'.dshwv-bubble .dshwv-b2{transition-delay:.3s}',
'.dshwv-text{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);text-align:center;color:#536ba9;line-height:1.15;white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .16s ease,transform .3s ease}',
'.dshwv-bubble.dshwv-bubble-open .dshwv-text{opacity:1;transition:opacity .16s ease .36s,transform .3s ease}',
'.dshwv-root.dshwv-left .dshwv-text{transform:translate(-50%,-50%) scaleX(-1)}',
'.dshwv-label{font-size:calc(var(--dshw-u) * 66);font-weight:600;letter-spacing:.06em}',
'.dshwv-amount{font-size:calc(var(--dshw-u) * 128);font-weight:800;line-height:1.05}',
'.dshwv-period{font-size:calc(var(--dshw-u) * 104);font-weight:800;line-height:1.05}',
'.dshwv-wrap{white-space:normal;max-width:calc(var(--dshw-u) * 560);line-height:1.2}',
'.dshwv-hint{font-size:calc(var(--dshw-u) * 56);color:#9fb0d9;letter-spacing:.02em;margin-top:calc(var(--dshw-u) * 9);min-height:calc(var(--dshw-u) * 64);line-height:1.15}',
'.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}',
'.dshwv-menu-btn.dshwv-menu-btn-visible{opacity:1}',
'.dshwv-menu-btn span{display:block;width:14px;height:2px;background:#fff;border-radius:1px}',
'.dshwv-menu-btn:hover{background:#203170}',
'.dshwv-menu{position:fixed;min-width:196px;background:rgba(255,255,255,.86);backdrop-filter:blur(14px) saturate(1.5);-webkit-backdrop-filter:blur(14px) saturate(1.5);border:1px solid rgba(32,49,112,.22);border-radius:14px;padding:12px 14px;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 12px 32px rgba(32,49,112,.20),0 2px 8px rgba(32,49,112,.10);color-scheme:light}',
'.dshwv-menu.dshwv-menu-open{opacity:1;transform:scale(1) translateY(0);pointer-events:auto}',
'.dshwv-menu-row{display:flex;align-items:center;gap:8px;margin:4px -6px;padding:3px 6px;border-radius:8px;color:#203170;font-size:12px;white-space:nowrap;transition:background .15s ease}',
'.dshwv-menu-row:hover{background:rgba(32,49,112,.07)}',
'.dshwv-range{flex:1;min-width:0;accent-color:#203170}',
'.dshwv-number{width:44px;border:1px solid rgba(32,49,112,.35);border-radius:8px;padding:2px 4px;font-size:12px;color:#203170;background:#fff;box-sizing:border-box;transition:border-color .15s ease,background .15s ease}',
'.dshwv-number:focus{outline:none;border-color:rgba(32,49,112,.7)}',
'.dshwv-number:disabled{opacity:.4;background:rgba(32,49,112,.06);cursor:not-allowed}',
'.dshwv-sound{flex:1;border:1px solid rgba(32,49,112,.35);border-radius:8px;background:rgba(32,49,112,.08);color:#203170;font-size:12px;padding:3px 0;cursor:pointer;transition:background .15s ease,border-color .15s ease}',
'.dshwv-sound:hover{background:rgba(32,49,112,.16)}',
'.dshwv-check{width:16px;height:16px;accent-color:#203170;cursor:pointer;flex:0 0 auto}',
'.dshwv-menu-sep{height:1px;background:linear-gradient(90deg,transparent,rgba(32,49,112,.28),transparent);margin:7px 0}',
'.dshwv-volpct{width:44px;text-align:right;color:#203170;font-size:12px;font-variant-numeric:tabular-nums}',
'.dshwv-baby{position:fixed;display:block;pointer-events:auto;cursor:grab;-webkit-user-drag:none;user-select:none;opacity:0;transform:scale(.82);transition:opacity .3s ease,transform .45s cubic-bezier(.34,1.56,.64,1);image-rendering:-webkit-optimize-contrast;z-index:9998}',
'.dshwv-baby.dshwv-baby-in{opacity:1;transform:scale(1)}',
'.dshwv-baby.dshwv-baby-dragging{cursor:grabbing;transition:none}',
'.dshwv-baby-speech{position:fixed;z-index:10001;background:rgba(255,255,255,.92);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);color:#203170;padding:5px 12px;border-radius:14px;border:1px solid rgba(32,49,112,.18);font-size:13px;font-weight:600;line-height:1.2;box-shadow:0 6px 18px rgba(32,49,112,.16);pointer-events:none;transform:translateY(6px) scale(.82);opacity:0;transition:opacity .2s ease,transform .32s cubic-bezier(.34,1.56,.64,1);white-space:nowrap}',
'.dshwv-baby-speech.dshwv-baby-speech-in{opacity:1;transform:translateY(0) scale(1)}',
'.dshwv-baby-speech.dshwv-baby-speech-out{opacity:0;transform:translateY(-4px) scale(.94)}',
'@keyframes dshwv-pet{0%{transform:scale(1)}35%{transform:scale(1.1,.9)}55%{transform:scale(.94,1.06)}75%{transform:scale(1.04,.97)}100%{transform:scale(1)}}',
'.dshwv-img.dshwv-petting{animation:dshwv-pet .45s cubic-bezier(.34,1.56,.64,1)}',
'.dshwv-baby.dshwv-baby-petting{animation:dshwv-pet .45s cubic-bezier(.34,1.56,.64,1);transform-origin:50% 100%}'
].join('\\n')
var styleEl = document.createElement('style')
styleEl.textContent = css
document.head.appendChild(styleEl)
var root = document.createElement('div')
root.className = 'dshwv-root'
var img = document.createElement('img')
img.className = 'dshwv-img'
img.src = baseImgUrl()
// 美化设置面板切换挂件形象时实时跟随(同页签直接监听自定义事件,跨页签走 storage)
try {
window.addEventListener('storage', function (e) {
if (e.key === SKIN_STORAGE_KEY && !angrySeqToken) img.src = baseImgUrl()
})
window.addEventListener('dshw-skin-change', function () {
if (!angrySeqToken) img.src = baseImgUrl()
})
} catch (err) {}
img.alt = 'DeepSeek 余额'
img.draggable = false
var menuBtn = document.createElement('button')
menuBtn.type = 'button'
menuBtn.className = 'dshwv-menu-btn'
menuBtn.title = '菜单'
menuBtn.innerHTML = ''
menuBtn.addEventListener('click', function (e) { e.stopPropagation(); toggleMenu() })
var menuBox = document.createElement('div')
menuBox.className = 'dshwv-menu'
function menuLabel(text) {
var s = document.createElement('span')
s.textContent = text
return s
}
function menuRow() {
var r = document.createElement('div')
r.className = 'dshwv-menu-row'
return r
}
var scaleInput = document.createElement('input')
scaleInput.type = 'range'
scaleInput.min = String(MIN_SCALE)
scaleInput.max = String(MAX_SCALE)
scaleInput.step = '0.1'
scaleInput.className = 'dshwv-range'
scaleInput.value = '1.5'
var scaleNumber = document.createElement('input')
scaleNumber.type = 'number'
scaleNumber.min = '1'
scaleNumber.max = '20'
scaleNumber.step = '1'
scaleNumber.className = 'dshwv-number'
scaleNumber.value = '10'
scaleInput.addEventListener('pointerdown', function () { root.style.transition = 'none' })
scaleInput.addEventListener('input', function () { setScale(scaleInput.value) })
scaleInput.addEventListener('change', function () { root.style.transition = '' })
scaleNumber.addEventListener('focus', function () { root.style.transition = 'none' })
scaleNumber.addEventListener('blur', function () { root.style.transition = '' })
scaleNumber.addEventListener('input', function () {
var v = Math.round(Number(scaleNumber.value))
var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
setScale(s)
})
scaleNumber.addEventListener('change', function () {
var v = Math.round(Number(scaleNumber.value))
var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
setScale(s)
root.style.transition = ''
})
var soundSelect = document.createElement('select')
soundSelect.className = 'dshwv-sound'
function soundOpt(value, label) {
var o = document.createElement('option')
o.value = value
o.textContent = label
return o
}
soundSelect.appendChild(soundOpt('duck', '小黄鸭'))
soundSelect.appendChild(soundOpt('fx1', '音效1'))
soundSelect.addEventListener('change', function () { setSoundSet(soundSelect.value) })
// 子代理声音独立开关(不影响主鲸鱼声音)
var babySoundBtn = document.createElement('button')
babySoundBtn.className = 'dshwv-sound'
babySoundBtn.textContent = '子代理声音:开'
babySoundBtn.addEventListener('click', function () { setBabySound(!babySound) })
var usageSelect = document.createElement('select')
usageSelect.className = 'dshwv-sound'
usageSelect.appendChild(soundOpt('ledger', '小鲸鱼记账 (推荐)'))
usageSelect.appendChild(soundOpt('token', '实时·令牌 (用法:去问dsh)'))
usageSelect.addEventListener('change', function () { setUsageMode(usageSelect.value) })
var peakSelect = document.createElement('select')
peakSelect.className = 'dshwv-sound'
peakSelect.appendChild(soundOpt('default', '默认'))
peakSelect.appendChild(soundOpt('liangwen', '梁文峰谷'))
peakSelect.appendChild(soundOpt('qiangqiang', '!?强强?!'))
peakSelect.addEventListener('change', function () { setPeakMode(peakSelect.value) })
var bubbleToggle = document.createElement('input')
bubbleToggle.type = 'checkbox'
bubbleToggle.className = 'dshwv-check'
bubbleToggle.checked = true
bubbleToggle.title = '开启/关闭思考气泡'
bubbleToggle.addEventListener('change', function () { setBubbleOn(bubbleToggle.checked) })
var turnCostToggle = document.createElement('input')
turnCostToggle.type = 'checkbox'
turnCostToggle.className = 'dshwv-check'
turnCostToggle.checked = true
turnCostToggle.title = '每轮对话结束后自动显示本轮消耗金额'
turnCostToggle.addEventListener('change', function () { setTurnCostOn(turnCostToggle.checked) })
var turnCostCloseInput = document.createElement('input')
turnCostCloseInput.type = 'number'
turnCostCloseInput.min = '0'
turnCostCloseInput.step = '1'
turnCostCloseInput.className = 'dshwv-number'
turnCostCloseInput.value = '5'
turnCostCloseInput.disabled = false // 跟随「每轮消耗提示」开关
turnCostCloseInput.title = '填 0 表示不自动关闭,需手动点击关闭'
turnCostCloseInput.addEventListener('input', function () { setTurnCostClose(turnCostCloseInput.value) })
turnCostCloseInput.addEventListener('change', function () { setTurnCostClose(turnCostCloseInput.value) })
var scrollGapToggle = document.createElement('input')
scrollGapToggle.type = 'checkbox'
scrollGapToggle.className = 'dshwv-check'
scrollGapToggle.checked = false
scrollGapToggle.title = '开启后挂件右侧按设定像素避开滚动条;关闭则贴边(盖住滚动条)'
scrollGapToggle.addEventListener('change', function () { setScrollGapOn(scrollGapToggle.checked) })
var scrollGapInput = document.createElement('input')
scrollGapInput.type = 'number'
scrollGapInput.min = '0'
scrollGapInput.step = '1'
scrollGapInput.className = 'dshwv-number'
scrollGapInput.value = '17'
scrollGapInput.disabled = true // 默认避让关 → 宽度不可修改,勾选后启用
scrollGapInput.title = '避让滚动条的像素宽度,填 0 表示贴边'
scrollGapInput.addEventListener('input', function () { setScrollGapPx(scrollGapInput.value) })
scrollGapInput.addEventListener('change', function () { setScrollGapPx(scrollGapInput.value) })
var row1 = menuRow()
row1.appendChild(menuLabel('大小'))
row1.appendChild(scaleInput)
row1.appendChild(scaleNumber)
var row2 = menuRow()
row2.appendChild(menuLabel('音效'))
row2.appendChild(soundSelect)
var rowBaby = menuRow()
rowBaby.appendChild(menuLabel('子代理'))
rowBaby.appendChild(babySoundBtn)
var volInput = document.createElement('input')
volInput.type = 'range'
volInput.min = '0'
volInput.max = '1'
volInput.step = '0.05'
volInput.className = 'dshwv-range'
volInput.value = '0.9'
var volPct = document.createElement('span')
volPct.className = 'dshwv-volpct'
volPct.textContent = '90%'
volInput.addEventListener('input', function () { setVol(volInput.value) })
var row3 = menuRow()
row3.appendChild(menuLabel('音量'))
row3.appendChild(volInput)
row3.appendChild(volPct)
var row4 = menuRow()
row4.appendChild(menuLabel('用量'))
row4.appendChild(usageSelect)
var row5 = menuRow()
row5.appendChild(menuLabel('峰谷'))
row5.appendChild(peakSelect)
var row6 = menuRow()
row6.appendChild(menuLabel('气泡'))
row6.appendChild(bubbleToggle)
var menuSep1 = document.createElement('div')
menuSep1.className = 'dshwv-menu-sep'
var row7 = menuRow()
row7.appendChild(menuLabel('每轮消耗提示'))
row7.appendChild(turnCostToggle)
row7.appendChild(menuLabel('自动关闭'))
row7.appendChild(turnCostCloseInput)
row7.appendChild(menuLabel('秒'))
var row9 = menuRow()
row9.appendChild(menuLabel('避让滚动条'))
row9.appendChild(scrollGapToggle)
row9.appendChild(menuLabel('宽度'))
row9.appendChild(scrollGapInput)
row9.appendChild(menuLabel('px'))
menuBox.appendChild(row1)
menuBox.appendChild(row2)
menuBox.appendChild(rowBaby)
menuBox.appendChild(row3)
menuBox.appendChild(row4)
menuBox.appendChild(row5)
menuBox.appendChild(row6)
menuBox.appendChild(row7)
menuBox.appendChild(menuSep1)
menuBox.appendChild(row9)
var textBox = document.createElement('div')
textBox.className = 'dshwv-text'
var labelEl = document.createElement('div')
labelEl.className = 'dshwv-label'
labelEl.textContent = 'DeepSeek 余额'
var amountEl = document.createElement('div')
amountEl.className = 'dshwv-amount'
var hintEl = document.createElement('div')
hintEl.className = 'dshwv-hint'
textBox.appendChild(labelEl)
textBox.appendChild(amountEl)
textBox.appendChild(hintEl)
var bubbleBox = document.createElement('div')
bubbleBox.className = 'dshwv-bubble'
bubbleBox.innerHTML = ''
var gifEl = document.createElement('img')
gifEl.className = 'dshwv-gif'
gifEl.src = GIF_URL
gifEl.alt = ''
gifEl.draggable = false
bubbleBox.appendChild(gifEl)
var gifFailed = false
gifEl.onerror = function () { gifFailed = true }
bubbleBox.appendChild(textBox)
bubbleBox.addEventListener('click', function (e) {
e.stopPropagation()
if (!bubbleShown) return
if (costBubbleActive) {
// 消耗金额泡泡:点击关闭(确认)
hideCostBubble()
return
}
if (bubbleRandomActive) {
// 再次点击:关闭
hideBubble()
} else {
// 首次点击:切到随机台词段,并重置自动关闭计时——
// 保证第二段台词有完整停留时间(否则第 4 秒点击只看到 0.5 秒)
bubbleRandomActive = true
bubbleRandomLines = pickRandomLines()
swapBubbleContent(function () { applyBubbleLines(bubbleRandomLines) })
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
}
})
var body = document.createElement('div')
body.className = 'dshwv-body'
body.appendChild(img)
body.appendChild(bubbleBox)
root.appendChild(body)
root.appendChild(menuBtn)
document.body.appendChild(root)
document.body.appendChild(menuBox)
// Position model: the widget is ALWAYS expressed in left/top px (so edge snaps
// animate smoothly via the CSS transition on both sides — switching to
// right/auto cannot transition and flashes). The anchor info (h/v + offsets)
// lives in state and is used by settle() to recompute coordinates on window
// resize and size changes, keeping the widget glued to its anchored edge.
var state = {
scale: 1.5,
h: 'right',
hOff: 0,
v: 'bottom',
vOff: 0,
left: 0,
top: 0,
balance: null,
currency: null,
todayUsage: null,
isPeak: false,
status: 'loading',
message: ''
}
var busy = false
var settleTimer = null
var animDelayTimer = null
var drag = null
var shown = null
var animId = null
var bubbleShown = false
var bubbleTimer = null
var bubbleRandomActive = false
var bubbleRandomLines = null
var BUBBLE_STYLE_CLASS = { A: 'dshwv-label', B: 'dshwv-amount', P: 'dshwv-period', C: 'dshwv-hint' }
function pickOne(arr) { return arr[Math.floor(Math.random() * arr.length)] }
function singleCenter(style, text, color, wrap) { return [null, { t: text, s: style, c: color || '', w: !!wrap }, null] }
function buildGroup1() {
var peak = !!state.isPeak
var offText = '空闲时段'
var peakText = '高峰时段'
if (peakMode === 'liangwen') {
offText = '梁文谷'
peakText = '梁文峰'
} else if (peakMode === 'qiangqiang') {
offText = '!?谷谷?!'
peakText = '!?峰峰?!'
}
return [
{ t: '当前时间段为:', s: 'A', c: '' },
{ t: peak ? peakText : offText, s: 'P', c: peak ? '#e0433f' : '#2fa24c' },
{ t: '今日已用 ' + fmt(state.todayUsage, state.currency), s: 'C', c: '' },
]
}
var RANDOM_GROUPS = [
{ w: 45, lines: buildGroup1 },
{ w: 7, lines: function () { return singleCenter('B', pickOne(['好模型... ↓', '好女孩...↓'])) } },
{ w: 7, lines: function () { return singleCenter('A', pickOne(['不知道用户有什么用,先赶走吧~', '我...我...我也要挣钱吗?', '我去吃饭啦,测完叫我', '压力一只蓝色大肥鱼?!', 'DeepSleep...', '坏了...用户彻底怒了!']), '', true) } },
{ w: 10, lines: function () { return { gif: true } } },
{ w: 3, lines: function () { return singleCenter('A', pickOne(['你目录里的dsh是什么...大烧货吗...?', '恭喜你实现token自由!token全跑了!', '真当我是便宜货啊...']), '', true) } },
{ w: 1, lines: function () { return singleCenter('B', '哦鲸鲸... ') } },
]
function pickRandomLines() {
var total = 0
for (var i = 0; i < RANDOM_GROUPS.length; i++) total += RANDOM_GROUPS[i].w
var r = Math.random() * total
for (var i = 0; i < RANDOM_GROUPS.length; i++) {
r -= RANDOM_GROUPS[i].w
if (r < 0) return RANDOM_GROUPS[i].lines()
}
return RANDOM_GROUPS[RANDOM_GROUPS.length - 1].lines()
}
function applyBubbleLines(lines) {
if (lines && lines.gif) {
// gif 台词组:只显示 gif,隐藏三行文字(display 必须显式覆盖 CSS 的 none)。
// lines.hint 可选:gif 下方保留一行说明文字(生气气话等)。
if (gifFailed) {
// gif 加载失败/路由缺失:降级为文字台词,避免空白白色气泡
lines = singleCenter('A', pickOne(['gif 加载失败了...', '今天没有动图给你看~', '呜呜 动图不见了...']), '', true)
} else {
if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
gifEl.style.display = 'block'
gifEl.style.opacity = ''
labelEl.style.display = 'none'
amountEl.style.display = 'none'
if (lines.hint) {
hintEl.style.display = ''
hintEl.className = 'dshwv-label'
hintEl.textContent = lines.hint
hintEl.style.color = lines.hintColor || ''
} else {
hintEl.style.display = 'none'
hintEl.textContent = ''
hintEl.style.color = ''
}
return
}
}
if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
gifEl.style.display = 'none'
gifEl.style.opacity = ''
var els = [labelEl, amountEl, hintEl]
for (var i = 0; i < 3; i++) {
var el = els[i]
var ln = lines && lines[i]
if (ln) {
el.style.display = ''
el.className = (BUBBLE_STYLE_CLASS[ln.s] || 'dshwv-label') + (ln.w ? ' dshwv-wrap' : '')
el.textContent = ln.t
el.style.color = ln.c || ''
} else {
el.style.display = 'none'
el.textContent = ''
el.style.color = ''
}
}
}
var bubbleSwapTimer = null
var hintFadeTimer = null
var gifFadeTimer = null
var lastHintText = null
// ---- 连戳生气状态机(阈值与衰减对齐 pet-whale 的 pokeStreak 设计) ----
var POKE_SULK_AT = 6 // 戳到第 6 下:生气(播放 GIF + 气话)
var POKE_DECAY_MS = 2600 // 停手这么久,连戳计数清零
var SULK_MS = 4200 // 闹脾气持续时长,期间再戳只会换气话
var petStreak = 0 // 当前连戳次数
var petDecayTimer = 0 // 衰减计时器
var petSulking = false // 闹脾气中标志
var petSulkTimer = 0 // 闹脾气到期计时器
function bumpPetStreak() {
petStreak += 1
if (petDecayTimer !== 0) { clearTimeout(petDecayTimer); petDecayTimer = 0 }
petDecayTimer = setTimeout(function () { petDecayTimer = 0; petStreak = 0 }, POKE_DECAY_MS)
}
function clearPetSulk() {
if (petSulkTimer !== 0) { clearTimeout(petSulkTimer); petSulkTimer = 0 }
petSulking = false
}
function triggerSulk() {
clearPetSulk()
petSulking = true
if (costBubbleActive) { if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null } costBubbleActive = false }
if (readWhaleSkin() === 'dafeiyu') playAngryGif(function () {})
showAngryBubble()
petSulkTimer = setTimeout(function () { petSulkTimer = 0; clearPetSulk(); petStreak = 0 }, SULK_MS)
}
// 生气台词气泡:走 showBubble() 的完整开框流程保证聊天框正常,再把内容换成气话
function showAngryBubble() {
if (!bubbleOn) return
showBubble()
bubbleRandomActive = true
bubbleRandomLines = { gif: true, hint: pickOne(ANGRY_LINES), hintColor: '#e0433f' }
swapBubbleContent(function () { applyBubbleLines(bubbleRandomLines) })
}
function setHint(text) {
// 首次/恢复(lastHintText===null)时直接写文本,不做淡出淡入——否则
// 气泡打开或按压重开时会先淡出再淡入,造成「消失一下又出现」。
// 只有气泡打开期间的内容变化(加载中→今日已用)才走动画。
if (text === lastHintText) return
var first = lastHintText === null
lastHintText = text
if (first || !bubbleShown) {
hintEl.textContent = text
return
}
hintEl.style.transition = 'opacity .18s ease'
hintEl.style.opacity = '0'
hintFadeTimer = setTimeout(function () {
hintFadeTimer = null
hintEl.textContent = text
hintEl.style.opacity = '1'
setTimeout(function () {
hintEl.style.transition = ''
hintEl.style.opacity = ''
}, 220)
}, 190)
}
function swapBubbleContent(applyFn) {
if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
textBox.style.transition = 'opacity .18s ease'
textBox.style.opacity = '0'
bubbleSwapTimer = setTimeout(function () {
bubbleSwapTimer = null
applyFn()
textBox.style.opacity = '1'
setTimeout(function () {
textBox.style.transition = ''
textBox.style.opacity = ''
}, 220)
}, 190)
}
function restoreBubbleLines() {
if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
lastHintText = null
textBox.style.transition = ''
textBox.style.opacity = ''
gifEl.style.display = 'none'
gifEl.style.opacity = ''
labelEl.style.display = ''
labelEl.className = 'dshwv-label'
labelEl.textContent = 'DeepSeek 余额'
labelEl.style.color = ''
amountEl.style.display = ''
amountEl.className = 'dshwv-amount'
amountEl.style.color = ''
hintEl.style.display = ''
hintEl.className = 'dshwv-hint'
hintEl.style.color = ''
render()
}
function showBubble() {
if (!bubbleOn) return
// 消耗金额泡泡显示期间,余额变动不再弹出普通泡泡
if (costBubbleActive) return
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
bubbleShown = true
bubbleRandomActive = false
restoreBubbleLines()
bubbleBox.classList.add('dshwv-bubble-open')
// 默认展示当前内容;点击气泡切到随机台词段;总时长 5 秒自动关闭
bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
}
function hideBubble() {
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
textBox.style.transition = ''
textBox.style.opacity = ''
hintEl.style.transition = ''
hintEl.style.opacity = ''
bubbleRandomActive = false
bubbleRandomLines = null
bubbleShown = false
// 只销毁 gif 显示;三行文字保持现状让气泡自然淡出——不能在关闭瞬间
// 恢复成余额内容(否则随机台词界面会闪现余额)。文字恢复交给下次
// showBubble() 的 restoreBubbleLines()(那时气泡隐藏,恢复过程不可见)。
bubbleBox.classList.remove('dshwv-bubble-open')
// gif 靠 CSS opacity 过渡淡出;display:none 会跳过过渡,须等淡出完成再隐藏
gifFadeTimer = setTimeout(function () {
gifFadeTimer = null
gifEl.style.display = 'none'
}, 240)
}
// —— 每轮对话消耗金额泡泡 ——
var costBubbleTimer = null
function showCostBubble(amount) {
if (!bubbleOn || !turnCostOn) return
if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
if (gifFadeTimer) { clearTimeout(gifFadeTimer); gifFadeTimer = null }
// 挂起中的台词切换也要取消:否则点气泡后 190ms 内一轮对话结束,
// 迟到的 applyFn 会用随机台词(甚至 gif)覆盖刚写好的消耗金额。
if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
costBubbleActive = true
bubbleRandomActive = false
bubbleShown = true
lastHintText = null
// 样式:第一行 A(标签),第二行 B(红色金额),居中两行
gifEl.style.display = 'none'
gifEl.style.opacity = ''
labelEl.style.display = ''
labelEl.className = 'dshwv-label'
labelEl.textContent = '上一轮对话消耗:'
labelEl.style.color = ''
amountEl.style.display = ''
amountEl.className = 'dshwv-amount'
amountEl.textContent = '¥ ' + (isFinite(amount) ? Number(amount).toFixed(2) : '--')
amountEl.style.color = '#e0433f'
hintEl.style.display = 'none'
hintEl.textContent = ''
hintEl.style.color = ''
textBox.style.transition = ''
textBox.style.opacity = ''
bubbleBox.classList.add('dshwv-bubble-open')
if (turnCostCloseMs > 0) {
costBubbleTimer = setTimeout(hideCostBubble, turnCostCloseMs)
}
}
function hideCostBubble() {
if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
costBubbleActive = false
hideBubble()
}
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
function viewport() {
return {
w: window.innerWidth || document.documentElement.clientWidth || 1280,
h: window.innerHeight || document.documentElement.clientHeight || 800
}
}
function rightGap() {
// 开关关闭:贴边(不避让滚动条)
if (!scrollGapOn) return 0
// 开启:用用户填写的像素;填 0 也贴边
return scrollGapPx > 0 ? scrollGapPx : 0
}
function fmt(balance, currency) {
var num = Number(balance)
var fixed = isFinite(num) ? num.toFixed(2) : '--'
return currency === 'CNY' ? '¥ ' + fixed : fixed + ' ' + currency
}
function animateAmount(from, to, currency, duration) {
// 消耗金额泡泡显示期间,余额数字滚动不触碰金额行
if (costBubbleActive) return
if (animId) cancelAnimationFrame(animId)
if (from === null || !isFinite(from)) from = to
if (from === to) {
shown = to
amountEl.textContent = fmt(to, currency)
return
}
var startTime = null
function step(ts) {
if (startTime === null) startTime = ts
var t = Math.min(1, (ts - startTime) / duration)
var eased = 1 - Math.pow(1 - t, 3)
var val = from + (to - from) * eased
amountEl.textContent = fmt(val, currency)
if (t < 1) {
animId = requestAnimationFrame(step)
} else {
animId = null
shown = to
amountEl.textContent = fmt(to, currency)
}
}
animId = requestAnimationFrame(step)
}
function render() {
// 消耗金额泡泡显示期间,余额渲染不覆盖其内容(金额行/标题行/提示行)
if (costBubbleActive) return
var amount, hint
if (state.status === 'error') {
amount = shown !== null ? fmt(shown, state.currency) : '--'
hint = state.message ? state.message.slice(0, 14) : '获取失败 · 点击重试'
} else if (state.balance === null) {
amount = shown !== null ? fmt(shown, state.currency) : '…'
hint = '加载中…'
} else {
amount = shown !== null ? fmt(shown, state.currency) : fmt(state.balance, state.currency)
hint = '今日已用 ' + (state.todayUsage !== null && state.todayUsage !== undefined ? fmt(state.todayUsage, state.currency) : '--')
}
amountEl.textContent = amount
if (bubbleRandomActive && bubbleRandomLines) {
applyBubbleLines(bubbleRandomLines)
} else {
setHint(hint)
}
}
function express() {
root.style.right = 'auto'
root.style.bottom = 'auto'
root.style.left = state.left + 'px'
root.style.top = state.top + 'px'
root.classList.toggle('dshwv-left', state.h === 'left')
// 主鲸鱼移动/翻转时让子代理小鲸鱼跟着重排;但**拖动中**不重排——
// 否则每 move 一次小鲸鱼就跳一次。松手后 endDrag → settle() → express() 重排归位。
if (!drag || !drag.active) {
layoutBabies()
}
}
function settle() {
var vp = viewport()
var w = root.offsetWidth || root.getBoundingClientRect().width || 0
var h = root.offsetHeight || root.getBoundingClientRect().height || 0
if (drag && drag.active) {
// mid-drag resize: keep the pointer-follow position, just clamp into view
state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
express()
return
}
if (state.h === 'right') {
state.left = Math.max(0, vp.w - w - state.hOff - rightGap())
} else if (state.h === 'left') {
state.left = state.hOff
} else {
state.left = clamp(state.left, 0, Math.max(0, vp.w - w - rightGap()))
} if (state.v === 'bottom') {
state.top = Math.max(0, vp.h - h - state.vOff)
} else if (state.v === 'top') {
state.top = state.vOff
} else {
state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
}
express()
}
function refresh(manual) {
if (busy) return
busy = true
if (animDelayTimer) { clearTimeout(animDelayTimer); animDelayTimer = null }
if (manual || state.balance === null) { state.status = 'loading'; render() }
var ctrl = null
var timer = null
try {
ctrl = new AbortController()
timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS)
} catch (err) {}
fetch(BALANCE_URL, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
.then(function (r) { return r.json() })
.then(function (data) {
if (data && data.ok) {
var nb = Number(data.totalBalance)
var nc = String(data.currency || 'CNY')
var changed = state.balance !== null && (nb !== state.balance || nc !== state.currency)
var currencyChanged = state.currency !== null && nc !== state.currency
state.balance = nb
state.currency = nc
state.message = ''
state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
state.isPeak = !!data.isPeak
if (changed && !currencyChanged) {
if (!manual) {
showBubble()
state.status = 'changing'
// balance-change bubble: wait 0.3s after it floats out, then roll the number
if (animDelayTimer) clearTimeout(animDelayTimer)
animDelayTimer = setTimeout(function () {
animDelayTimer = null
animateAmount(shown, nb, nc, ANIM_MS)
}, 300)
if (settleTimer) clearTimeout(settleTimer)
settleTimer = setTimeout(function () {
settleTimer = null
if (state.status === 'changing') { state.status = 'ok'; render() }
}, CHANGE_MS + 300)
} else {
animateAmount(shown, nb, nc, ANIM_MS)
state.status = 'ok'
render()
}
} else {
if (animId === null) shown = nb
state.status = 'ok'
render()
}
} else {
state.status = 'error'
state.message = (data && data.error) ? String(data.error) : '获取失败'
render()
}
})
.catch(function () {
state.status = 'error'
state.message = '获取失败'
render()
})
.finally(function () {
busy = false
if (timer) clearTimeout(timer)
})
}
var soundOn = true
var soundVol = 0.9
var soundSet = 'duck'
var usageMode = 'ledger'
var peakMode = 'default'
var bubbleOn = true
var turnCostOn = true
var turnCostCloseMs = 5000
var costBubbleActive = false
var scrollGapOn = false
var scrollGapPx = 17
// 子代理小鲸鱼声音独立开关(报到/离开叫声);主鲸鱼声音由 soundOn/soundVol 控制。
var babySound = true
function saveConfig() {
try {
fetch(SIZE_URL, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, usageMode: usageMode, peakMode: peakMode, bubbleOn: bubbleOn, turnCostOn: turnCostOn, turnCostCloseMs: turnCostCloseMs, scrollGapOn: scrollGapOn, scrollGapPx: scrollGapPx, babySound: babySound }) }).catch(function () {})
// Persistence is best-effort: the server may be restarting, and every
// volume-slider input event fires this — an unhandled rejection per event
// would flood the console. In-memory state stays authoritative.
// 锚点位置记忆:记录相对边框的离边距离,窗口 resize 后保持(localStorage)。
// v:2 = 净距离格式(剥离避让距离),v:1 旧格式含避让距离,恢复时废弃旧格式。
var vp = viewport()
var w = root.offsetWidth || root.getBoundingClientRect().width || 0
var h = root.offsetHeight || root.getBoundingClientRect().height || 0
var leftDist = state.left
var rightDist = vp.w - state.left - w
var topDist = state.top
var bottomDist = vp.h - state.top - h
var hAnchor = leftDist <= rightDist ? 'left' : 'right'
var hDistRaw = Math.round(Math.min(leftDist, rightDist))
var hDist = hAnchor === 'right' && scrollGapOn ? Math.max(0, hDistRaw - rightGap()) : hDistRaw
localStorage.setItem('dshw-pos', JSON.stringify({
v: 2,
hAnchor: hAnchor,
hDist: hDist,
vAnchor: topDist <= bottomDist ? 'top' : 'bottom',
vDist: Math.round(Math.min(topDist, bottomDist))
}))
} catch (err) {}
}
function setBabySound(v) {
babySound = !!v
babySoundBtn.textContent = '子代理声音:' + (babySound ? '开' : '关')
saveConfig()
}
function setUsageMode(v) {
usageMode = v === 'token' ? 'token' : 'ledger'
usageSelect.value = usageMode
saveConfig()
refresh(false)
}
function setPeakMode(v) {
peakMode = v === 'liangwen' || v === 'qiangqiang' ? v : 'default'
peakSelect.value = peakMode
saveConfig()
}
function setBubbleOn(v) {
bubbleOn = !!v
bubbleToggle.checked = bubbleOn
saveConfig()
// 必须走 hideCostBubble:残留的 costBubbleActive 会让 render()/showBubble() 永久早退
if (!bubbleOn) hideCostBubble()
}
function setTurnCostOn(v) {
turnCostOn = !!v
turnCostToggle.checked = turnCostOn
turnCostCloseInput.disabled = !turnCostOn
saveConfig()
if (!turnCostOn) hideCostBubble()
}
function setTurnCostClose(v) {
if (!turnCostOn) return
var n = Math.max(0, Math.round(Number(v) || 0))
turnCostCloseMs = n * 1000
turnCostCloseInput.value = String(n)
saveConfig()
}
function setScrollGapOn(v) {
scrollGapOn = !!v
scrollGapToggle.checked = scrollGapOn
scrollGapInput.disabled = !scrollGapOn
saveConfig()
settle()
}
function setScrollGapPx(v) {
if (!scrollGapOn) return
var n = Math.max(0, Math.round(Number(v) || 0))
scrollGapPx = n
scrollGapInput.value = String(n)
saveConfig()
settle()
}
function scaleToDisplay(s) {
return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
}
function setScale(v) {
var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number(v))) * 10) / 10
// 缩放测量需要 left/top 立即到位:临时禁用过渡(滚轮/数字框路径没有
// 滑块 pointerdown 的 transition:none,否则 r2 测的是过渡起点导致错锚点)
var prevTrans = root.style.transition
root.style.transition = 'none'
var rect = root.getBoundingClientRect()
// fixed point: the whale's corner — bottom-right when unflipped, bottom-left
// when flipped. Growing extends the widget up-left / up-right from that
// corner; shrinking pulls it back toward the corner. The whale always hugs
// its corner while scaling.
var fx = state.h === 'left' ? rect.left : rect.right
var fy = rect.bottom
state.scale = next
root.style.setProperty('--dshw-scale', String(next))
scaleInput.value = String(next)
scaleNumber.value = String(scaleToDisplay(next))
saveConfig()
// keep the corner fixed while resizing; the position correction applies
// instantly because the caller disables the transition for the whole drag
var r2 = root.getBoundingClientRect()
var vp = viewport()
if (state.h === 'left') {
state.left = Math.min(Math.max(fx, 0), Math.max(0, vp.w - r2.width))
} else {
state.left = Math.min(Math.max(fx - r2.width, 0), Math.max(0, vp.w - r2.width))
}
state.top = Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height))
express()
// 恢复过渡必须延迟到下一帧:本帧 left/top 已在 none 下设置并提交,
// 立即恢复会让浏览器对「刚改过的 left/top」重新评估并播放过渡动画
// (翻转时叠加 transform .3s 更明显,表现为抽搐)。
requestAnimationFrame(function () {
root.style.transition = prevTrans
})
}
function setVol(v) {
var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100
soundVol = next
soundOn = next > 0
volInput.value = String(next)
volPct.textContent = Math.round(next * 100) + '%'
try {
if (pressAudio) pressAudio.volume = next
if (releaseAudio) releaseAudio.volume = next
} catch (err) {}
saveConfig()
}
function setSoundSet(v) {
soundSet = v === 'fx1' ? 'fx1' : 'duck'
soundSelect.value = soundSet
applySoundSet()
saveConfig()
}
var SQUISH = 'scaleY(0.88) scaleX(1.05)'
var pressAudio = null
var releaseAudio = null
var pressing = false
var pressEnded = false
var releasePlayed = false
var releaseTimer = null
function applySoundSet() {
try {
pressAudio = new Audio('/dsh-whale/sound/press.mp3?set=' + soundSet)
pressAudio.preload = 'auto'
pressAudio.volume = soundVol
releaseAudio = new Audio('/dsh-whale/sound/release.mp3?set=' + soundSet)
releaseAudio.preload = 'auto'
releaseAudio.volume = soundVol
babyCryAudio = null
babyCryLeaveAudio = null
} catch (err) {}
}
var babyCryAudio = null
var babyCryLeaveAudio = null
// 子代理报到叫声(press 音效),受「子代理声音」独立开关控制。
function playBabyCry() {
if (!soundOn || !babySound) return
try {
if (!babyCryAudio) {
babyCryAudio = new Audio('/dsh-whale/sound/press.mp3?set=' + soundSet)
babyCryAudio.volume = soundVol
}
babyCryAudio.currentTime = 0
var p = babyCryAudio.play()
if (p && typeof p.catch === 'function') p.catch(function () {})
} catch (err) {}
}
// 子代理离开叫声(release 音效,和报到的 press 区分开)。
function playBabyCryLeaving() {
if (!soundOn || !babySound) return
try {
if (!babyCryLeaveAudio) {
babyCryLeaveAudio = new Audio('/dsh-whale/sound/release.mp3?set=' + soundSet)
babyCryLeaveAudio.volume = soundVol
}
babyCryLeaveAudio.currentTime = 0
var p = babyCryLeaveAudio.play()
if (p && typeof p.catch === 'function') p.catch(function () {})
} catch (err) {}
}
function playPress() {
if (!pressAudio || !soundOn) return
try {
if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
if (releaseAudio) {
releaseAudio.pause()
releaseAudio.currentTime = 0
}
pressEnded = false
releasePlayed = false
pressAudio.onended = function () {
pressEnded = true
// fallback (duration unknown): click → Ya2 right after Ya1 ends
if (!pressing && !releasePlayed) playRelease()
// hold: still pressed → wait for pressUp()
}
pressAudio.currentTime = 0
var p = pressAudio.play()
if (p && typeof p.catch === 'function') p.catch(function () {})
} catch (err) {}
}
function playRelease() {
if (releasePlayed || !releaseAudio || !soundOn) return
releasePlayed = true
try {
releaseAudio.currentTime = 0
var p = releaseAudio.play()
if (p && typeof p.catch === 'function') p.catch(function () {})
} catch (err) {}
}
// 点击回弹工具:给目标元素加弹跳动画 class(480ms 后移除),
// 强制 reflow 保证连续点击也能重新触发。
// 解析 GIF 总时长:累加每帧 Graphic Control Extension 延迟(移植自大肥鱼桌宠)。
// 结构不可读或无帧延迟时返回 fallbackMs。
function parseGifDurationMs(buf, fallbackMs) {
try {
if (!buf || buf.byteLength < 14) return fallbackMs
var dv = new DataView(buf)
if (dv.getUint8(0) !== 0x47 || dv.getUint8(1) !== 0x49 || dv.getUint8(2) !== 0x46) return fallbackMs
var packed = dv.getUint8(10)
var pos = 13
if (packed & 0x80) pos += 3 * (2 << (packed & 0x07))
var totalMs = 0
while (pos + 1 <= buf.byteLength) {
var b = dv.getUint8(pos)
if (b === 0x3b) break
if (b === 0x21) {
var label = dv.getUint8(pos + 1)
pos += 2
if (label === 0xf9) {
var size = dv.getUint8(pos)
if (size !== 4 || pos + size + 2 > buf.byteLength) return fallbackMs
totalMs += dv.getUint16(pos + 1, true) * 10
pos += size + 1
continue
}
while (pos < buf.byteLength) {
var sz = dv.getUint8(pos); pos += 1
if (sz === 0) break
pos += sz
}
continue
}
if (b === 0x2c) {
if (pos + 10 > buf.byteLength) return fallbackMs
var flags = dv.getUint8(pos + 9)
pos += 10
if (flags & 0x80) pos += 3 * (2 << (flags & 0x07))
if (pos >= buf.byteLength) return fallbackMs
var lzwMin = dv.getUint8(pos); pos += 1
if (lzwMin > 11) return fallbackMs
while (pos < buf.byteLength) {
var bsz = dv.getUint8(pos); pos += 1
if (bsz === 0) break
pos += bsz
}
continue
}
return fallbackMs
}
return totalMs > 0 ? totalMs : fallbackMs
} catch (err) { return fallbackMs }
}
// 播放反应 GIF 一遍后回到当前形象的默认图;GIF 缺失/加载失败时 done(false)。
function playReactionGif(url, done) {
try {
if (angrySeqToken) angrySeqToken.cancelled = true
var token = { cancelled: false }
angrySeqToken = token
fetch(url, { cache: 'force-cache' }).then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status)
return res.arrayBuffer()
}).then(function (buf) {
if (token.cancelled) { if (done) done(false); return }
var dur = parseGifDurationMs(buf, REACTION_FALLBACK_MS)
var blobUrl = URL.createObjectURL(new Blob([buf], { type: 'image/gif' }))
img.src = blobUrl
setTimeout(function () {
try { URL.revokeObjectURL(blobUrl) } catch (err) {}
if (token.cancelled) { if (done) done(false); return }
img.src = baseImgUrl()
angrySeqToken = null
if (done) done(true)
}, Math.max(dur, MIN_REACTION_MS) + SEQ_TAIL_MS)
}).catch(function () {
angrySeqToken = null
if (done) done(false)
})
} catch (err) {
angrySeqToken = null
if (done) done(false)
}
}
function playAngryGif(done) { playReactionGif(ANGRY_GIF_URL, done) }
function playPatGif(done) { playReactionGif(PAT_GIF_URL, done) }
function petBounce(el) {
if (!el) return
try {
el.classList.remove('dshwv-petting', 'dshwv-baby-petting')
void el.offsetWidth
el.classList.add(el.classList.contains('dshwv-baby') ? 'dshwv-baby-petting' : 'dshwv-petting')
setTimeout(function () {
try { el.classList.remove('dshwv-petting', 'dshwv-baby-petting') } catch (err) {}
}, 480)
} catch (err) {}
}
// 摸头生气:PET_WINDOW_MS 内按压满 PET_ANGRY_COUNT 次触发一次,ANGRY_COOLDOWN_MS 内不再累计
var petStamps = []
var angryUntil = 0
var PET_WINDOW_MS = 10000
var PET_ANGRY_COUNT = 8
var ANGRY_COOLDOWN_MS = 15000
var ANGRY_LINES = [
'别摸啦!头要秃了!',
'哼!生气了!不理你了!',
'再摸就要收费了!一次一条鱼!',
'摸鱼摸鱼...你是在摸我吗!',
'呜...再摸我真的要生气了!',
]
// 摸头计数:滑动窗口内按压达到阈值 → getAngry()。冷却期内不累计。
function notePet() {
var now = Date.now()
if (now < angryUntil) return
while (petStamps.length && now - petStamps[0] > PET_WINDOW_MS) petStamps.shift()
petStamps.push(now)
if (petStamps.length >= PET_ANGRY_COUNT) {
petStamps.length = 0
getAngry()
}
}
// 生气:左右抖动 + 气泡弹出气话。台词走 bubbleRandomLines 承载,
// 复用「再点气泡关闭」与 render() 刷新时不覆盖随机台词的既有路径;
// 气泡关闭后 hideBubble 清态,下次 showBubble 自然恢复余额显示。
// 气泡总开关关闭时只抖动不说话;消耗泡泡显示中触发生气则先清掉它
// (用户主动交互优先,否则两套泡泡状态互踩:点击关闭/计时器互相截断)。
function getAngry() {
angryUntil = Date.now() + ANGRY_COOLDOWN_MS
if (costBubbleActive) {
if (costBubbleTimer) { clearTimeout(costBubbleTimer); costBubbleTimer = null }
costBubbleActive = false
}
if (readWhaleSkin() === 'dafeiyu') playAngryGif(function () {})
showAngryBubble()
}
function pressDown() {
body.style.transform = SQUISH
pressing = true
playPress()
notePet()
}
function pressUp() {
body.style.transform = 'scaleY(1) scaleX(1)'
pressing = false
if (pressEnded) {
// hold (or released after Ya1 finished) → Ya2 now
playRelease()
return
}
// click: start Ya2 in the last 100ms of Ya1's playback
var durKnown = false
var remainMs = 0
try {
var dur = pressAudio ? pressAudio.duration : 0
if (isFinite(dur) && dur > 0) {
durKnown = true
remainMs = (dur - pressAudio.currentTime) * 1000
}
} catch (err) {}
if (durKnown) {
releaseTimer = setTimeout(function () {
releaseTimer = null
playRelease()
}, Math.max(0, remainMs - 100))
}
// duration unknown → pressAudio.onended fallback plays Ya2 after Ya1 ends
}
var menuOpen = false
function toggleMenu() {
menuOpen = !menuOpen
if (menuOpen) positionMenu()
menuBox.classList.toggle('dshwv-menu-open', menuOpen)
if (menuOpen) menuBtn.classList.add('dshwv-menu-btn-visible')
}
function closeMenu() {
menuOpen = false
menuBox.classList.remove('dshwv-menu-open')
root.style.transition = ''
snapCheck()
}
function snapCheck() {
var rect = root.getBoundingClientRect()
var vp = viewport()
var w = rect.width, h = rect.height
var left = rect.left, top = rect.top
var centerX = left + w / 2
var centerY = top + h / 2
var moved = false
// 吸附阈值 = 自身大小:离墙 ≤ 自身半宽时贴墙(小东西紧贴就吸,大东西半宽内才吸)
var snap = Math.max(w, h)
if (centerX <= snap) {
state.h = 'left'
state.hOff = 0
left = 0
moved = true
} else if (centerX >= vp.w - snap) {
state.h = 'right'
state.hOff = 0
left = vp.w - w - rightGap()
moved = true
} else {
state.h = null
state.hOff = left
}
if (centerY <= snap) {
state.v = 'top'
state.vOff = 0
top = 0
moved = true
} else {
state.v = 'bottom'
state.vOff = Math.max(0, vp.h - top - h)
}
if (moved) {
state.left = left
state.top = top
settle()
}
}
function positionMenu() {
try {
var r = root.getBoundingClientRect()
var b = menuBtn.getBoundingClientRect()
var vp = viewport()
var onLeft = r.left + r.width / 2 < vp.w / 2
// the menu appears ABOVE the button, anchored to its side:
// right side → menu bottom-right aligns with the button's top-right;
// left side → menu bottom-left aligns with the button's top-left
if (onLeft) {
menuBox.style.left = b.left + 'px'
menuBox.style.right = 'auto'
menuBox.style.transformOrigin = 'bottom left'
} else {
menuBox.style.right = (vp.w - b.right) + 'px'
menuBox.style.left = 'auto'
menuBox.style.transformOrigin = 'bottom right'
}
menuBox.style.bottom = (vp.h - b.top) + 'px'
menuBox.style.top = 'auto'
} catch (err) {}
}
var hitCanvas = null
var hitReady = false
function setupHitTest() {
try {
hitCanvas = document.createElement('canvas')
hitCanvas.width = 610
hitCanvas.height = 610
var probe = new Image()
probe.onload = function () {
try {
// 拉伸到 610×610 与 isWhaleHit 的坐标映射对齐;不指定尺寸会按原图大小绘制,
// 回退到非 610×610 素材(如 DSniang02.png)时命中区域会错位
hitCanvas.getContext('2d').drawImage(probe, 0, 0, 610, 610)
hitReady = true
} catch (err) {}
}
probe.onerror = function () {}
probe.src = IMG_URL
} catch (err) {}
}
function isWhaleHit(e) {
if (!hitCanvas || !hitReady) return true
try {
var r = img.getBoundingClientRect()
if (!r || r.width <= 0 || r.height <= 0) return false
var lx = (e.clientX - r.left) / r.width * 610
var ly = (e.clientY - r.top) / r.height * 610
if (lx < 0 || ly < 0 || lx >= 610 || ly >= 610) return false
if (state.h === 'left') lx = 610 - lx
var data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
return data[3] > 10
} catch (err) {
return true
}
}
function onDocPointerDown(e) {
if (e.target && e.target.closest) {
if (e.target.closest('.dshwv-bubble') || e.target.closest('.dshwv-menu') || e.target.closest('.dshwv-menu-btn')) return
}
if (menuOpen) {
closeMenu()
return
}
if (e.button !== 0 && e.pointerType === 'mouse') return
if (!isWhaleHit(e)) return
try { e.preventDefault(); e.stopPropagation() } catch (err) {}
var vp = viewport()
var rect = root.getBoundingClientRect()
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 }
root.classList.add('dshwv-dragging')
pressDown()
// 点击回弹:按下瞬间主鲸鱼弹一下(q 弹),配合 pressDown 的压扁动画
petBounce(img)
setWidgetCursor('grabbing')
document.addEventListener('pointermove', onDocPointerMove, true)
document.addEventListener('pointerup', onDocPointerUp, true)
document.addEventListener('pointercancel', onDocPointerCancel, true)
}
function onDocPointerMove(e) {
if (!drag || !drag.active) return
var dx = e.clientX - drag.startX
var dy = e.clientY - drag.startY
if (dx * dx + dy * dy >= CLICK_SQ) drag.moved = true
// Keep the pre-drag flip orientation while dragging (state.h/v stay as they
// were); on release endDrag() recomputes the anchors and settle() flips the
// class with a smooth transition instead of reverting instantly.
state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
express()
}
function onDocPointerUp(e) {
// 拦截鲸鱼区域内的 pointerup:防止下方元素(如文件行)监听 pointerup 穿透误触发
try { if (isWhaleHit(e)) { e.preventDefault(); e.stopPropagation() } } catch (err) {}
endDrag(e, true)
}
function onDocPointerCancel(e) { endDrag(e, false) }
function onDocClickStopper(e) {
// 只在鲸鱼命中区域拦截 click(保持透明区 pass-through)。
// 持久注册(不随 endDrag 移除)——click 在 pointerup 之后派发,
// 若在 endDrag 移除会导致 click 穿透到下方元素(如误打开文件)。
if (!isWhaleHit(e)) return
try { e.preventDefault(); e.stopPropagation() } catch (err) {}
}
document.addEventListener('pointerdown', onDocPointerDown, true)
document.addEventListener('click', onDocClickStopper, true)
var widgetCursor = ''
function setWidgetCursor(v) {
if (v !== widgetCursor) {
widgetCursor = v
try { document.body.style.cursor = v } catch (err) {}
}
}
function onDocPointerMoveCursor(e) {
if (drag && drag.active) { setWidgetCursor('grabbing'); return }
if (babyDrag && babyDrag.active) { setWidgetCursor('grabbing'); return }
if (isBabyHit(e)) {
// 摸到子代理小鲸鱼:系统手型(摸头标记由独立监听负责)
setWidgetCursor('grab')
menuBtn.classList.remove('dshwv-menu-btn-visible')
return
}
var el = null
try { el = document.elementFromPoint(e.clientX, e.clientY) } catch (err) {}
if (el && el.closest && (el.closest('.dshwv-bubble') || el.closest('.dshwv-menu') || el.closest('.dshwv-menu-btn'))) {
setWidgetCursor('')
menuBtn.classList.add('dshwv-menu-btn-visible')
return
}
var over = isWhaleHit(e)
setWidgetCursor(over ? 'grab' : '')
menuBtn.classList.toggle('dshwv-menu-btn-visible', over || menuOpen)
}
document.addEventListener('pointermove', onDocPointerMoveCursor, true)
function endDrag(e, clickAllowed) {
if (!drag || !drag.active) return
drag.active = false
document.removeEventListener('pointermove', onDocPointerMove, true)
document.removeEventListener('pointerup', onDocPointerUp, true)
document.removeEventListener('pointercancel', onDocPointerCancel, true)
pressUp()
root.classList.remove('dshwv-dragging')
setWidgetCursor(isWhaleHit(e) ? 'grab' : '')
if (clickAllowed && !drag.moved) {
bumpPetStreak()
if (petSulking) {
// 闹脾气期间再戳只是火上浇油:换一句新气话,不重播 GIF
if (bubbleOn) {
bubbleRandomActive = true
bubbleRandomLines = { gif: true, hint: pickOne(ANGRY_LINES), hintColor: '#e0433f' }
swapBubbleContent(function () { applyBubbleLines(bubbleRandomLines) })
if (bubbleTimer) clearTimeout(bubbleTimer)
bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
}
return
}
if (petStreak >= POKE_SULK_AT) {
triggerSulk()
return
}
// 正常摸头:本体播脸红爱心 GIF(素材缺失则跳过),气泡照常
if (readWhaleSkin() === 'dafeiyu') playPatGif(function () {})
showBubble(); refresh(true); return
}
var dx = e.clientX - drag.startX
var dy = e.clientY - drag.startY
var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
var centerX = left + drag.w / 2
var centerY = top + drag.h / 2
// 吸附阈值 = 自身大小(与 snapCheck 一致的手感)
var snap = Math.max(drag.w, drag.h)
if (centerX <= snap) {
state.h = 'left'
state.hOff = 0
} else if (centerX >= drag.vp.w - snap) {
state.h = 'right'
state.hOff = 0
} else {
state.h = null
state.hOff = left
}
if (centerY <= snap) {
state.v = 'top'
state.vOff = 0
} else if (centerY >= drag.vp.h - snap) {
state.v = 'bottom'
state.vOff = 0
} else {
state.v = null
state.vOff = top
}
state.left = left
state.top = top
settle()
// 拖拽结束立即保存锚点位置(否则刷新/关闭后位置回退到上次改菜单时)
saveConfig()
}
// 窗口尺寸变化时:自由位置的鲸鱼按相对边框锚点重算(保持离边距离,窗口恢复原状即回原位);
// 贴边吸附的鲸鱼走 settle()(保持贴边)
function applyAnchorPos() {
try {
var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
if (!a || a.v !== 2 || (a.hAnchor !== 'left' && a.hAnchor !== 'right') || typeof a.hDist !== 'number' ||
(a.vAnchor !== 'top' && a.vAnchor !== 'bottom') || typeof a.vDist !== 'number') return false
var vp = viewport()
var w = root.offsetWidth || root.getBoundingClientRect().width || 0
var h = root.offsetHeight || root.getBoundingClientRect().height || 0
// 与加载恢复一致:锚点存净距离,右锚点按当前避让开关叠加
var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
var l = a.hAnchor === 'left' ? a.hDist : vp.w - effectiveRightDist - w
var t = a.vAnchor === 'top' ? a.vDist : vp.h - a.vDist - h
state.left = clamp(l, 0, Math.max(0, vp.w - w))
state.top = clamp(t, 0, Math.max(0, vp.h - h))
state.h = a.hAnchor
state.hOff = 0
state.v = a.vAnchor
state.vOff = 0
express()
return true
} catch (err) { return false }
}
window.addEventListener('resize', function () {
if (state.h === null && state.v === null && applyAnchorPos()) return
settle()
})
var rect0 = root.getBoundingClientRect()
state.left = rect0.left
state.top = rect0.top
express()
render()
applySoundSet()
setupHitTest()
fetch(SIZE_URL, { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (d) {
if (d && typeof d.scale === 'number' && d.scale >= MIN_SCALE - 0.1 && d.scale <= MAX_SCALE + 0.1) {
state.scale = d.scale
root.style.setProperty('--dshw-scale', String(d.scale))
scaleInput.value = String(d.scale)
scaleNumber.value = String(scaleToDisplay(d.scale))
settle()
}
if (d && typeof d.vol === 'number') {
soundVol = d.vol
soundOn = soundVol > 0
volInput.value = String(soundVol)
volPct.textContent = Math.round(soundVol * 100) + '%'
try {
if (pressAudio) pressAudio.volume = soundVol
if (releaseAudio) releaseAudio.volume = soundVol
} catch (err) {}
}
if (d && typeof d.soundSet === 'string') {
soundSet = d.soundSet === 'fx1' ? 'fx1' : 'duck'
soundSelect.value = soundSet
applySoundSet()
}
if (d && typeof d.babySound === 'boolean') {
babySound = d.babySound
babySoundBtn.textContent = '子代理声音:' + (babySound ? '开' : '关')
}
if (d && typeof d.usageMode === 'string') {
usageMode = d.usageMode === 'token' ? 'token' : 'ledger'
usageSelect.value = usageMode
}
if (d && typeof d.peakMode === 'string') {
peakMode = d.peakMode === 'liangwen' || d.peakMode === 'qiangqiang' ? d.peakMode : 'default'
peakSelect.value = peakMode
}
if (d && typeof d.bubbleOn === 'boolean') {
bubbleOn = d.bubbleOn
bubbleToggle.checked = bubbleOn
}
if (d && typeof d.turnCostOn === 'boolean') {
turnCostOn = d.turnCostOn
turnCostToggle.checked = turnCostOn
turnCostCloseInput.disabled = !turnCostOn
}
if (d && typeof d.turnCostCloseMs === 'number') {
turnCostCloseMs = d.turnCostCloseMs > 0 ? d.turnCostCloseMs : 0
turnCostCloseInput.value = String(Math.round(turnCostCloseMs / 1000))
}
if (d && typeof d.scrollGapOn === 'boolean') {
scrollGapOn = d.scrollGapOn
scrollGapToggle.checked = scrollGapOn
scrollGapInput.disabled = !scrollGapOn
}
if (d && typeof d.scrollGapPx === 'number') {
scrollGapPx = d.scrollGapPx > 0 ? Math.round(d.scrollGapPx) : 0
scrollGapInput.value = String(scrollGapPx)
}
// 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离。
// 仅认 v:2 净距离格式;旧格式(含避让距离)废弃,挂件保持默认右下角吸附。
// 恢复时还原吸附状态(hAnchor/vAnchor → state.h/v),避免挂件变自由位置
// 导致避让调节不实时(settle 自由分支只 clamp 不重算位置)。
try {
var a = JSON.parse(localStorage.getItem('dshw-pos') || 'null')
if (a && a.v === 2 && (a.hAnchor === 'left' || a.hAnchor === 'right') && typeof a.hDist === 'number' &&
(a.vAnchor === 'top' || a.vAnchor === 'bottom') && typeof a.vDist === 'number') {
var vpA = viewport()
var wA = root.offsetWidth || root.getBoundingClientRect().width || 0
var hA = root.offsetHeight || root.getBoundingClientRect().height || 0
// 锚点存的是净距离:右锚点按当前避让开关叠加避让距离
var effectiveRightDist = a.hAnchor === 'right' ? a.hDist + (scrollGapOn ? rightGap() : 0) : a.hDist
var lA = a.hAnchor === 'left' ? a.hDist : vpA.w - effectiveRightDist - wA
var tA = a.vAnchor === 'top' ? a.vDist : vpA.h - a.vDist - hA
state.left = clamp(lA, 0, Math.max(0, vpA.w - wA))
state.top = clamp(tA, 0, Math.max(0, vpA.h - hA))
// 按锚点还原吸附状态(贴边锚点 → 吸附;自由位锚点 → 自由)
state.h = a.hAnchor
state.hOff = 0
state.v = a.vAnchor
state.vOff = 0
settle()
}
} catch (err) {}
refresh(false)
})
.catch(function () { refresh(false) })
setInterval(function () { refresh(false) }, REFRESH_MS)
// —— 每轮对话消耗检测:轮询 last-turn.json,出现新 seq 时弹消耗金额泡泡 ——
var LAST_TURN_URL = '/dsh-whale/last-turn.json'
var lastCostSeq = 0
var lastCostAligned = false
function pollLastTurn() {
try {
fetch(LAST_TURN_URL, { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (d) {
if (!d || !d.ok || typeof d.seq !== 'number') return
if (!lastCostAligned) {
// 首次拿到数据:只对齐 seq,不弹旧轮次
lastCostSeq = d.seq
lastCostAligned = true
return
}
if (d.seq > lastCostSeq) {
lastCostSeq = d.seq
if (d.turn !== null && d.amount !== null) {
showCostBubble(Number(d.amount))
}
}
})
.catch(function () {})
} catch (err) {}
}
setInterval(pollLastTurn, 1000)
// 摸头标记:指针停留在小鲸鱼图形上时,在 上打 data-dsh-whale-hover,
// 鲸鱼光标据此把当前状态切为「链接手型」(摸头 = 手)。像素检测只在小鲸鱼
// 矩形范围内触发,平时只做矩形快速排除。
var lastWhaleHit = false
// 指针是否在某只子代理小鲸鱼的矩形内(独立个体,直接用坐标判断,
// 不依赖 elementFromPoint——元素命中受 z-index/透明区域影响,rect 更稳)。
// 返回命中的小鲸鱼元素(null = 没摸到),供摸头回弹动画定位目标。
function isBabyHit(e) {
var found = null
if (!babyWhales) return null
babyWhales.forEach(function (el) {
if (found || !el) return
try {
var r = el.getBoundingClientRect()
if (r && r.width > 0 && r.height > 0 &&
e.clientX >= r.left && e.clientX <= r.right &&
e.clientY >= r.top && e.clientY <= r.bottom) found = el
} catch (err) {}
})
return found
}
document.addEventListener('pointermove', function (e) {
var hit = isWhaleHit(e) || isBabyHit(e) !== null
if (hit === lastWhaleHit) return
lastWhaleHit = hit
try {
if (hit) document.documentElement.setAttribute('data-dsh-whale-hover', '')
else document.documentElement.removeAttribute('data-dsh-whale-hover')
} catch (err) {}
}, { passive: true })
var babyWhales = new Map()
var babySeq = 0
// 被用户手动拖动过的子代理小鲸鱼:保持手动位置,不再参与自动排列。
var babyManual = new Set()
// 碰撞箱 = 角色视觉主体(脸+身体),不算两侧头发飘逸、呆毛/衣领下方
// 阴影。DSniang1.png 女仆头像有效内容主要集中在中央脸+衣领(约 50% 宽)
// + 头两侧飘出的头发;裁切到中央约 50%×60% 才是"看上去的角色大小"。
function collideBox(l, t, w, h) {
var cutL = 0.25, cutR = 0.25, cutT = 0.15, cutB = 0.25
return {
l: l + w * cutL,
t: t + h * cutT,
r: l + w * (1 - cutR),
b: t + h * (1 - cutB),
}
}
// 碰撞箱 = 角色实体区域(collideBox),挤压时沿最小穿透方向推开
// (物理分离),不是卡住不能动。障碍 = 主鲸鱼 + 其他小鲸鱼(忽略自身)。
// 每次迭代处理一个重叠,最多 8 轮,最后钳制在屏幕内。
// 坐标系纪律:入参/返回值/位移全部是**图片矩形**坐标;碰撞箱只用于
// 相交测试(此前把碰撞箱原点当返回值,小鲸鱼每次拖动/重排都向左上漂移)。
function pushOut(l, t, w, h, ignoreId) {
try {
var l0 = l
var t0 = t
var obstacles = []
var rr = root.getBoundingClientRect()
if (rr && rr.width > 0 && rr.height > 0) {
var mb = collideBox(rr.left, rr.top, rr.width, rr.height)
obstacles.push({ l: mb.l, t: mb.t, r: mb.r, b: mb.b })
}
babyWhales.forEach(function (b, id) {
if (id === ignoreId || !b) return
try {
var r = b.getBoundingClientRect()
if (r && r.width > 0 && r.height > 0) {
var bb = collideBox(r.left, r.top, r.width, r.height)
obstacles.push({ l: bb.l, t: bb.t, r: bb.r, b: bb.b })
}
} catch (err) {}
})
var mine = collideBox(l0, t0, w, h)
var ml = mine.l
var mt = mine.t
var mw = mine.r - mine.l
var mh = mine.b - mine.t
var pad = 4
var vp = viewport()
for (var pass = 0; pass < 8; pass++) {
var o = null
for (var i = 0; i < obstacles.length; i++) {
var ob = obstacles[i]
if (ml < ob.r - pad && ml + mw > ob.l + pad && mt < ob.b - pad && mt + mh > ob.t + pad) {
o = ob
break
}
}
if (!o) break
// 沿最小穿透方向推出(推开而非卡死)。按距离排序后逐个尝试,
// 取第一个 clamp 到屏幕内后仍能实际移动的方向——最近的墙若被
// 屏幕边界挡住(如主鲸鱼贴边),就滑到次近方向,保证必然分离。
var dLeft = ml + mw - o.l + pad
var dRight = o.r - ml + pad
var dUp = mt + mh - o.t + pad
var dDown = o.b - mt + pad
var options = [
{ dl: -dLeft, dt: 0, d: dLeft },
{ dl: dRight, dt: 0, d: dRight },
{ dl: 0, dt: -dUp, d: dUp },
{ dl: 0, dt: dDown, d: dDown },
].sort(function (a, b) { return a.d - b.d })
var applied = false
for (var k = 0; k < options.length; k++) {
// 位移应用回图片坐标并 clamp(防推出后图片出屏);碰撞箱跟随平移
var nl = clamp(l0 + options[k].dl, 0, Math.max(0, vp.w - w))
var nt = clamp(t0 + options[k].dt, 0, Math.max(0, vp.h - h))
if (nl !== l0 || nt !== t0) {
ml += nl - l0
mt += nt - t0
l0 = nl
t0 = nt
applied = true
break
}
}
if (!applied) break // 四个方向都被屏幕挡住(理论不可能),防死循环
}
l0 = clamp(l0, 0, Math.max(0, vp.w - w))
t0 = clamp(t0, 0, Math.max(0, vp.h - h))
return { left: Math.round(l0), top: Math.round(t0) }
} catch (err) {
return { left: l, top: t }
}
}
// 默认位置:紧贴主鲸鱼图片底部水平排成一排(依次向右),最右一只
// 紧贴主鲸鱼左侧不重叠。这样多只小鲸鱼默认不相互碰撞、不遮挡 dsh
// 聊天框/输入区(贴在屏幕底部,远离中央对话区),也不盖主鲸鱼
// 自己的余额气泡(在主鲸鱼图片内)。手动拖走的保持用户位置但不
// 侵入主鲸鱼(pushOut 推出)。
function layoutBabies() {
var map = babyWhales
// var 提升:初始化早期(babyWhales 赋值前)express() 可能先调到这里
if (!map || map.size === 0) return
var list = Array.from(map.keys())
var count = list.length
var r = root.getBoundingClientRect()
if (!r || r.width <= 0) return
var vp = viewport()
var size = Math.round(clamp(r.width * 0.30, 64, 128))
var gap = 10
// 默认:紧贴主鲸鱼底部水平排
var y = clamp(r.bottom - size, 6, Math.max(6, vp.h - size - 6))
var autoIndex = 0
list.forEach(function (id) {
var el = babyWhales.get(id)
if (!el) return
// 手动拖走的小鲸鱼保持用户放置的位置,但主鲸鱼移动后不能侵入它
if (babyManual.has(id)) {
var curL = parseFloat(el.style.left) || 0
var curT = parseFloat(el.style.top) || 0
var cw = parseFloat(el.style.width) || size
var ch = parseFloat(el.style.height) || size
var pushed = pushOut(curL, curT, cw, ch, id)
if (pushed.left !== curL || pushed.top !== curT) {
el.style.left = pushed.left + 'px'
el.style.top = pushed.top + 'px'
}
return
}
el.style.width = size + 'px'
el.style.height = size + 'px'
// 默认:固定从**左侧边栏(260px)右缘**开始向右排,紧贴主鲸鱼底部——
// 不跟着主鲸鱼左缘跑(主鲸鱼在屏幕左边时小鲸鱼也不会钻进侧边栏)。
var baseX = 268
var x = baseX + autoIndex * (size + gap)
x = clamp(x, baseX, Math.max(baseX, vp.w - size - 6))
el.style.left = Math.round(x) + 'px'
el.style.top = Math.round(y) + 'px'
autoIndex += 1
})
}
// 一只子代理小鲸鱼入场:完整的独立小鲸鱼(不是小图标),直接挂到 body。
function spawnBabyWhale(id) {
if (babyWhales.has(id)) return
var b = document.createElement('img')
b.className = 'dshwv-baby'
b.src = IMG_URL
b.alt = ''
b.draggable = false
document.body.appendChild(b)
babyWhales.set(id, b)
layoutBabies()
// 每只小鲸鱼自己报到:头顶冒小气泡(独立个体自己报数)
showBabySpeech(b, '报到!')
requestAnimationFrame(function () {
requestAnimationFrame(function () {
b.classList.add('dshwv-baby-in')
})
})
}
// 小鲸鱼头顶小气泡:弹出 → 停留 → 淡出移除。位置跟随小鲸鱼顶部中心。
function showBabySpeech(babyEl, text) {
try {
var b = document.createElement('div')
b.className = 'dshwv-baby-speech'
b.textContent = text
document.body.appendChild(b)
function place() {
try {
var r = babyEl.getBoundingClientRect()
var bw = b.offsetWidth
var bh = b.offsetHeight
b.style.left = Math.round(r.left + r.width / 2 - bw / 2) + 'px'
b.style.top = Math.round(r.top - bh - 6) + 'px'
} catch (err) {}
}
place()
setTimeout(place, 40)
requestAnimationFrame(function () { b.classList.add('dshwv-baby-speech-in') })
setTimeout(function () { b.classList.add('dshwv-baby-speech-out') }, 1100)
setTimeout(function () {
try { b.remove() } catch (err) {}
}, 1500)
} catch (err) {}
}
// 一只小小鲸鱼退场(对应的子代理会话结束),随后重排剩余个体。
function removeBabyWhale(id) {
var el = babyWhales.get(id)
if (!el) return
babyWhales.delete(id)
babyManual.delete(id)
el.classList.remove('dshwv-baby-in')
// 离开也叫一声(release 音效,退场动画刚开始时)
setTimeout(function () { playBabyCryLeaving() }, 120)
setTimeout(function () {
try { el.remove() } catch (err) {}
}, 360)
layoutBabies()
}
// 与子代理生命周期 1:1 同步:出现的一起报到(逐个错开 + 叫声),消失的一起离开。
// 孵化定时器的守卫读模块级最新集合,而不是本次事件的 alive 快照——
// 否则快速起停( 5) {
babyDrag.moved = true
}
if (!babyDrag.moved) return
var vp = viewport()
var w = el.offsetWidth || el.getBoundingClientRect().width || 0
var h = el.offsetHeight || el.getBoundingClientRect().height || 0
var nx = clamp(babyDrag.origLeft + e.clientX - babyDrag.startX, 0, Math.max(0, vp.w - w))
var ny = clamp(babyDrag.origTop + e.clientY - babyDrag.startY, 0, Math.max(0, vp.h - h))
// 挤压推开:拖动中实时与主鲸鱼/其他小鲸鱼分离(沿最小穿透方向滑开)
var pushed = pushOut(nx, ny, w, h, babyDrag.id)
el.style.left = pushed.left + 'px'
el.style.top = pushed.top + 'px'
}
function onBabyPointerUp() { endBabyDrag() }
// 两个矩形是否重叠(pad 为最小安全间距,模拟"碰撞体积")。
function rectsOverlap(a, b, pad) {
pad = pad || 0
return a.left < b.right - pad && a.right > b.left + pad &&
a.top < b.bottom - pad && a.bottom > b.top + pad
}
function endBabyDrag() {
if (!babyDrag) return
var el = babyWhales.get(babyDrag.id)
if (el) {
el.classList.remove('dshwv-baby-dragging')
var vp = viewport()
var w = el.offsetWidth || el.getBoundingClientRect().width || 0
var h = el.offsetHeight || el.getBoundingClientRect().height || 0
var l = parseFloat(el.style.left) || 0
var t = parseFloat(el.style.top) || 0
// 只是摸一下(没拖动):位置原样保留,不锁定、不吸附——避免"点一下
// 小鲸鱼就被吸到角落"的误操作。
if (babyDrag.moved) {
// 手动放置后保持位置,不再被自动排列拉回
babyManual.add(babyDrag.id)
// 吸附阈值 = 自身大小:离墙 ≤ 自身半宽时贴墙
var cx = l + w / 2
var cy = t + h / 2
var snap = Math.max(w, h)
if (cx <= snap) l = 0
else if (cx >= vp.w - snap) l = Math.max(0, vp.w - w)
if (cy <= snap) t = 0
else if (cy >= vp.h - snap) t = Math.max(0, vp.h - h)
// 挤压推开:松手时再分离一次,确保不压着主鲸鱼或其他小鲸鱼
var pushed = pushOut(l, t, w, h, babyDrag.id)
l = pushed.left
t = pushed.top
}
el.style.left = Math.round(l) + 'px'
el.style.top = Math.round(t) + 'px'
}
document.removeEventListener('pointermove', onBabyPointerMove, true)
document.removeEventListener('pointerup', onBabyPointerUp, true)
document.removeEventListener('pointercancel', onBabyPointerUp, true)
babyDrag = null
setWidgetCursor('')
// 和主鲸鱼一样:松开也叫一声(release 音效,走「子代理声音」独立开关)
playBabyCryLeaving()
}
document.addEventListener('pointerdown', onBabyPointerDown, true)
window.addEventListener('dshw:subagents', function (e) {
try {
var detail = e && e.detail
// 优先使用子代理 id 列表;旧事件只带 count 时退化为占位 id。
var ids = detail && Array.isArray(detail.ids)
? detail.ids.filter(function (x) { return typeof x === 'string' })
: Array.from({ length: detail && typeof detail.count === 'number' ? detail.count : 0 }, function (_x, i) { return 's' + i })
var prevCount = lastBabyCount
lastBabyCount = ids.length
syncBabyWhales(ids)
// 报数改由每只小鲸鱼自己冒泡(spawnBabyWhale → showBabySpeech),
// 不再让主鲸鱼的气泡统一播报
} catch (err) {}
})
})()`
const name = 'whale-balance-widget'
const inject = ['webServer', 'credentials']
function apply(ctx) {
let imageBytes = null
let balanceCache = null
let balanceInFlight = null
let gifBytes = null
let angryGifBytes = null
let patGifBytes = null
let idleGifBytes = null
// 每轮对话消耗统计:按 (session.id, turn) 分桶聚合,完成后写入 lastTurn。
// 用 Map 分桶避免主会话与子代理(spawn/fork)并行时串账。
let turnAggs = new Map() // sessionId -> { turn, cost, tokens, lastTs }
let lastTurn = null // { turn, amount, tokens, ts }
let lastTurnSeq = 0
const disposers = []
function finalizeTurn(sessionId) {
const agg = turnAggs.get(sessionId)
if (agg && agg.cost > 0) {
lastTurn = { turn: agg.turn, amount: agg.cost, tokens: agg.tokens, ts: agg.lastTs }
lastTurnSeq++
}
turnAggs.delete(sessionId)
}
// 监听会话事件流:assistant/message 携带每步真实 usage,按 (session,turn) 聚合;
// turn/end 时结算该会话本轮并写入 lastTurn
function handleSessionEvent(sessionId, event) {
try {
const type = event && event.type
const d = event && event.data
if (!d || typeof d !== 'object') return
if (type === 'turn/end') {
finalizeTurn(sessionId)
return
}
if (type !== 'assistant/message') return
const turn = Number(d.turn)
const usage = d.usage
if (!usage || typeof usage !== 'object' || !isFinite(turn)) return
let agg = turnAggs.get(sessionId)
if (!agg || agg.turn !== turn) {
if (agg) finalizeTurn(sessionId)
agg = { turn, cost: 0, tokens: 0, lastTs: Date.now() }
turnAggs.set(sessionId, agg)
}
const input = Number(usage.inputTokens) || 0
const cache = Number(usage.cacheReadTokens) || 0
const output = Number(usage.outputTokens) || 0
const reasoning = Number(usage.reasoningTokens) || 0
agg.tokens += input + cache + output + reasoning
// 定价换算(CNY/百万 token;缓存命中=输入价,其余按各自档位)
const model = d.message && d.message.source ? d.message.source.model : ''
const p = priceFor(model)
const off = isPeakTime(Math.floor(Date.now() / 1000)) ? 1 : 0
agg.cost += (cache / 1e6) * p.hit[off] + (input / 1e6) * p.miss[off] + ((output + reasoning) / 1e6) * p.out[off]
agg.lastTs = Date.now()
} catch (err) {}
}
// 监听所有会话的追加事件;按会话 id 分桶,turn/end 时结算该会话本轮
disposers.push(ctx.on('session/event', (session, event) => {
const sid = session && session.id ? session.id : 'default'
handleSessionEvent(sid, event)
}))
// 会话销毁时清理残留聚合,避免内存泄漏
disposers.push(ctx.on('session/disposed', (session) => {
if (session && session.id) turnAggs.delete(session.id)
}))
function loadGif() {
if (gifBytes) return gifBytes
for (const p of RUA_GIF_CANDIDATES) {
try {
const bytes = fs.readFileSync(p)
if (bytes && bytes.length > 0) {
gifBytes = bytes
return bytes
}
} catch (err) {}
}
throw new Error('rua gif not found')
}
function loadAngryGif() {
if (angryGifBytes) return angryGifBytes
for (const p of ANGRY_GIF_CANDIDATES) {
try {
const bytes = fs.readFileSync(p)
if (bytes && bytes.length > 0) {
angryGifBytes = bytes
return bytes
}
} catch (err) {}
}
throw new Error('angry gif not found')
}
function loadPatGif() {
if (patGifBytes) return patGifBytes
for (const p of PAT_GIF_CANDIDATES) {
try {
const bytes = fs.readFileSync(p)
if (bytes && bytes.length > 0) {
patGifBytes = bytes
return bytes
}
} catch (err) {}
}
throw new Error('pat gif not found')
}
function loadIdleGif() {
if (idleGifBytes) return idleGifBytes
for (const p of IDLE_GIF_CANDIDATES) {
try {
const bytes = fs.readFileSync(p)
if (bytes && bytes.length > 0) {
idleGifBytes = bytes
return bytes
}
} catch (err) {}
}
throw new Error('idle gif not found')
}
function loadImage() {
if (imageBytes) return imageBytes
for (const p of IMAGE_CANDIDATES) {
try {
const bytes = fs.readFileSync(p)
if (bytes && bytes.length > 0) {
imageBytes = bytes
return bytes
}
} catch (err) {}
}
throw new Error('whale image not found')
}
function pickBalanceInfo(infos) {
if (!Array.isArray(infos) || infos.length === 0) return null
const num = (x) => (x && x.total_balance !== undefined ? Number(x.total_balance) : NaN)
return (
infos.find((x) => x && x.currency === 'CNY' && num(x) > 0) ||
infos.find((x) => num(x) > 0) ||
infos.find((x) => x && x.currency === 'CNY') ||
infos[0]
)
}
async function fetchBalance() {
let cred
try {
cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
} catch (err) {
return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
}
if (!cred) {
return { ok: false, code: 'NO_KEY', error: '未配置 DEEPSEEK_API_KEY' }
}
let lastErr = null
for (let attempt = 0; attempt < 2; attempt++) {
let res
try {
res = await fetch(BALANCE_URL, {
headers: { Authorization: 'Bearer ' + cred.value },
signal: AbortSignal.timeout(20000),
})
} catch (err) {
lastErr = err
if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
continue
}
if (!res.ok) {
lastErr = new Error('HTTP ' + res.status)
if (res.status < 500) break
if (attempt === 0) await new Promise((r) => setTimeout(r, 500))
continue
}
let data
try {
data = await res.json()
} catch (err) {
return { ok: false, code: 'PARSE', error: '余额接口返回不是合法 JSON' }
}
const info = pickBalanceInfo(data && data.balance_infos)
if (!info || info.total_balance === undefined) {
return { ok: false, code: 'SHAPE', error: '余额接口返回结构异常' }
}
return {
ok: true,
totalBalance: Number(info.total_balance),
currency: String(info.currency || 'CNY'),
updatedAt: new Date().toISOString(),
}
}
const transient = !(lastErr && /^HTTP 4\d\d/.test(lastErr.message))
return {
ok: false,
code: 'HTTP',
transient: transient,
error: '余额接口请求失败: ' + String((lastErr && lastErr.message) || lastErr).slice(0, 200),
}
}
async function fetchUsage() {
let cred
try {
cred = await ctx.credentials.resolve('DEEPSEEK_PLATFORM_TOKEN')
} catch (err) {
return { error: 'platform cred resolve failed' }
}
if (!cred) return { error: 'no platform token' }
const token = String(cred.value).replace(/^Bearer\s+/i, '')
try {
const now = new Date()
const tz = -now.getTimezoneOffset() * 60
const start = Math.floor(new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000)
const end = start + 86400
const url = 'https://platform.deepseek.com/api/v0/usage/by_api_key/amount?start=' + start + '&end=' + end + '&tz=' + tz
const res = await fetch(url, {
headers: { Authorization: 'Bearer ' + token },
signal: AbortSignal.timeout(15000),
})
if (!res.ok) return { error: 'http ' + res.status }
const data = await res.json()
const u = computeTodayUsage(data)
if (u && isFinite(u.amount)) return { amount: u.amount, tokens: u.tokens }
return { error: 'no usage' }
} catch (err) {
return { error: String((err && err.message) || err) }
}
}
function computeTodayUsage(data) {
// data.data.biz_data.series[]: [{model, buckets:[{time, usage:{RESPONSE_TOKEN, PROMPT_CACHE_HIT_TOKEN, PROMPT_CACHE_MISS_TOKEN}}]}]
let d = data
if (d && d.data && d.data.biz_data && Array.isArray(d.data.biz_data.series)) d = d.data.biz_data
else if (d && d.data && Array.isArray(d.data.series)) d = d.data
const series = Array.isArray(d.series) ? d.series : null
if (!series || series.length === 0) return null
let cost = 0
let tokens = 0
let found = false
for (const s of series) {
if (!s || typeof s !== 'object') continue
const p = priceFor(s.model)
const buckets = Array.isArray(s.buckets) ? s.buckets : []
for (const b of buckets) {
const u = b && b.usage
if (!u || typeof u !== 'object') continue
const hit = Number(u.PROMPT_CACHE_HIT_TOKEN) || 0
const miss = Number(u.PROMPT_CACHE_MISS_TOKEN) || 0
const out = Number(u.RESPONSE_TOKEN) || 0
if (hit + miss + out === 0) continue
found = true
tokens += hit + miss + out
const pi = isPeakTime(b.time) ? 1 : 0
cost += (hit / 1e6) * p.hit[pi] + (miss / 1e6) * p.miss[pi] + (out / 1e6) * p.out[pi]
}
}
return found ? { amount: cost, tokens: tokens } : null
}
function todayKey() {
const d = new Date()
const p = (n) => String(n).padStart(2, '0')
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
}
function readUsageLedger() {
for (const p of USAGE_FILE_CANDIDATES) {
try {
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
if (parsed && typeof parsed === 'object' && typeof parsed.date === 'string') return parsed
} catch (err) {}
}
return { date: todayKey(), lastBalance: null, todayUsage: 0, history: {} }
}
function writeUsageLedger(led) {
const body = JSON.stringify(led)
for (const p of USAGE_FILE_CANDIDATES) {
try {
fs.writeFileSync(p, body, 'utf8')
return true
} catch (err) {}
}
return false
}
// 记账模式:每次观测到余额后,用余额正差值累计当天用量(跨天自动归零并归档)
function recordLedgerUsage(currentBalance) {
const t = todayKey()
let led = readUsageLedger()
if (led.date !== t) {
if (led.date && typeof led.todayUsage === 'number') {
led.history = led.history || {}
led.history[led.date] = led.todayUsage
}
led.date = t
led.lastBalance = currentBalance
led.todayUsage = 0
} else {
const prev = typeof led.lastBalance === 'number' ? led.lastBalance : currentBalance
if (typeof prev === 'number' && typeof currentBalance === 'number' && currentBalance < prev) {
led.todayUsage = (typeof led.todayUsage === 'number' ? led.todayUsage : 0) + (prev - currentBalance)
}
led.lastBalance = currentBalance
}
const keys = Object.keys(led.history || {}).sort()
while (keys.length > 30) {
delete led.history[keys.shift()]
}
writeUsageLedger(led)
return led
}
function normalizeUsageMode(m) {
return m === 'token' ? 'token' : 'ledger'
}
async function getBalancePayload() {
const payload = await fetchBalance()
if (!payload.ok) return payload
// 无论哪种模式,都先把余额观测记入账本(自动累积「鲸鱼记账」数据)
const led = recordLedgerUsage(Number(payload.totalBalance))
const cfg = readSizeConfig() || {}
const mode = normalizeUsageMode(cfg.usageMode)
const full = { ...payload }
full.isPeak = isPeakTime(Math.floor(Date.now() / 1000))
if (mode === 'ledger') {
full.todayUsage = led.todayUsage
full.usageMode = 'ledger'
return full
}
// token:尝试平台令牌实时计算
let cred = null
try {
cred = await ctx.credentials.resolve('DEEPSEEK_PLATFORM_TOKEN')
} catch (err) {}
if (cred) {
const u = await fetchUsage()
if (u && u.amount !== undefined) {
full.todayUsage = u.amount
full.usageMode = 'token'
return full
}
}
// 无令牌或令牌失败:回落记账模式
full.todayUsage = led.todayUsage
full.usageMode = 'ledger'
return full
}
function getBalance() {
const now = Date.now()
if (balanceCache && now - balanceCache.at < BALANCE_TTL_MS) {
return Promise.resolve(balanceCache.payload)
}
if (balanceInFlight) return balanceInFlight
balanceInFlight = getBalancePayload()
.then((payload) => {
if (payload.ok) {
balanceCache = { at: now, payload }
return payload
}
if (payload.transient && balanceCache) {
// transient network/API blip: keep serving the last known balance
return { ...balanceCache.payload, stale: true, error: payload.error }
}
if (!payload.transient) console.error('[whale-balance]', payload.code, payload.error)
return payload
})
.catch((err) => ({
ok: false,
code: 'ERROR',
error: '余额服务异常: ' + String((err && err.message) || err).slice(0, 200),
}))
.finally(() => {
balanceInFlight = null
})
return balanceInFlight
}
function readSizeConfig() {
for (const p of SIZE_FILE_CANDIDATES) {
try {
const parsed = JSON.parse(fs.readFileSync(p, 'utf8'))
if (parsed && typeof parsed.scale === 'number') {
return {
scale: parsed.scale,
sound: parsed.sound !== false,
vol: typeof parsed.vol === 'number' ? parsed.vol : 0.9,
soundSet: parsed.soundSet === 'fx1' ? 'fx1' : 'duck',
usageMode: normalizeUsageMode(parsed.usageMode),
peakMode: parsed.peakMode === 'liangwen' || parsed.peakMode === 'qiangqiang' ? parsed.peakMode : 'default',
bubbleOn: parsed.bubbleOn !== false,
turnCostOn: parsed.turnCostOn !== false,
turnCostCloseMs: typeof parsed.turnCostCloseMs === 'number' ? parsed.turnCostCloseMs : 5000,
scrollGapOn: parsed.scrollGapOn === true,
scrollGapPx: typeof parsed.scrollGapPx === 'number' ? Math.round(parsed.scrollGapPx) : 17,
babySound: parsed.babySound !== false,
}
}
} catch (err) {}
}
return null
}
function writeSizeConfig(scale, sound, vol, soundSet, usageMode, peakMode, bubbleOn, turnCostOn, turnCostCloseMs, scrollGapOn, scrollGapPx, babySound) {
const um = normalizeUsageMode(usageMode)
const pm = peakMode === 'liangwen' || peakMode === 'qiangqiang' ? peakMode : 'default'
const bo = bubbleOn !== false
const tco = turnCostOn !== false
const tcc = typeof turnCostCloseMs === 'number' ? (turnCostCloseMs > 0 ? turnCostCloseMs : 0) : 5000
const sgo = scrollGapOn === true
const sgp = typeof scrollGapPx === 'number' && scrollGapPx > 0 ? Math.round(scrollGapPx) : 0
const body = JSON.stringify({
scale: scale,
sound: sound !== false,
vol: typeof vol === 'number' ? vol : 0.9,
soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
usageMode: um,
peakMode: pm,
bubbleOn: bo,
turnCostOn: tco,
turnCostCloseMs: tcc,
scrollGapOn: sgo,
scrollGapPx: sgp,
babySound: babySound !== false,
updatedAt: new Date().toISOString(),
})
for (const p of SIZE_FILE_CANDIDATES) {
try {
fs.writeFileSync(p, body, 'utf8')
return {
ok: true,
scale: scale,
sound: sound !== false,
vol: typeof vol === 'number' ? vol : 0.9,
soundSet: soundSet === 'fx1' ? 'fx1' : 'duck',
usageMode: um,
peakMode: pm,
bubbleOn: bo,
turnCostOn: tco,
turnCostCloseMs: tcc,
scrollGapOn: sgo,
scrollGapPx: sgp,
babySound: babySound !== false,
}
} catch (err) {}
}
return { ok: false, error: '无法持久化挂件尺寸' }
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = []
let size = 0
req.on('data', (c) => {
size += c.length
if (size > 8192) {
reject(new Error('body too large'))
req.destroy()
return
}
chunks.push(c)
})
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
req.on('error', reject)
})
}
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/image.png',
handler: (req, res) => {
try {
const bytes = loadImage()
res.writeHead(200, {
'Content-Type': 'image/png',
'Cache-Control': 'no-store',
'Content-Length': String(bytes.length),
})
res.end(bytes)
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('whale image unavailable: ' + String((err && err.message) || err))
}
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/rua.gif',
handler: (req, res) => {
try {
const bytes = loadGif()
res.writeHead(200, {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store',
'Content-Length': String(bytes.length),
})
res.end(bytes)
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('rua gif unavailable: ' + String((err && err.message) || err))
}
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/reactions/angry.gif',
handler: (req, res) => {
try {
const bytes = loadAngryGif()
res.writeHead(200, {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store',
'Content-Length': String(bytes.length),
})
res.end(bytes)
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('angry gif unavailable: ' + String((err && err.message) || err))
}
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/reactions/pat.gif',
handler: (req, res) => {
try {
const bytes = loadPatGif()
res.writeHead(200, {
'Content-Type': 'image/gif',
'Cache-Control': 'no-store',
'Content-Length': String(bytes.length),
})
res.end(bytes)
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('pat gif unavailable: ' + String((err && err.message) || err))
}
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/reactions/idle.gif',
handler: (req, res) => {
try {
const bytes = loadIdleGif()
res.writeHead(200, {
'Content-Type': 'image/gif',
'Cache-Control': 'no-cache',
'Content-Length': String(bytes.length),
})
res.end(bytes)
} catch (err) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('idle gif unavailable: ' + String((err && err.message) || err))
}
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/balance.json',
handler: async (req, res) => {
try {
const payload = await getBalance()
res.writeHead(200, JSON_HEADERS)
res.end(JSON.stringify(payload))
} catch (err) {
res.writeHead(200, JSON_HEADERS)
res.end(JSON.stringify({ ok: false, code: 'ERROR', error: String((err && err.message) || err).slice(0, 200) }))
}
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/last-turn.json',
handler: (req, res) => {
// 返回最近一轮已完成的对话消耗;seq 递增供前端判断「新的一轮」
const payload = lastTurn
? { ok: true, seq: lastTurnSeq, turn: lastTurn.turn, amount: lastTurn.amount, tokens: lastTurn.tokens, ts: lastTurn.ts }
: { ok: true, seq: 0, turn: null, amount: null, tokens: null, ts: null }
res.writeHead(200, JSON_HEADERS)
res.end(JSON.stringify(payload))
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/size.json',
handler: async (req, res) => {
if (req.method === 'PUT' || req.method === 'POST') {
try {
const body = await readBody(req)
const parsed = JSON.parse(body)
const scale = typeof parsed.scale === 'number' ? parsed.scale : null
if (scale === null) {
res.writeHead(400, JSON_HEADERS)
res.end(JSON.stringify({ ok: false, error: 'missing scale' }))
return
}
// 用量模式变化时让余额缓存失效,下次请求立即按新模式计算
if (typeof parsed.usageMode === 'string') {
const old = readSizeConfig()
if (!old || normalizeUsageMode(old.usageMode) !== normalizeUsageMode(parsed.usageMode)) {
balanceCache = null
}
}
const result = writeSizeConfig(scale, parsed.sound !== false, parsed.vol, parsed.soundSet, parsed.usageMode, parsed.peakMode, parsed.bubbleOn, parsed.turnCostOn, parsed.turnCostCloseMs, parsed.scrollGapOn, parsed.scrollGapPx, parsed.babySound !== false)
res.writeHead(result.ok ? 200 : 500, JSON_HEADERS)
res.end(JSON.stringify(result))
} catch (err) {
res.writeHead(400, JSON_HEADERS)
res.end(JSON.stringify({ ok: false, error: String((err && err.message) || err) }))
}
return
}
res.writeHead(200, JSON_HEADERS)
res.end(JSON.stringify(readSizeConfig() || {}))
},
}))
function loadSound(candidates) {
for (const p of candidates) {
try {
const bytes = fs.readFileSync(p)
if (bytes && bytes.length > 0) return bytes
} catch (err) {}
}
return null
}
function serveSound(req, res, candidates) {
const bytes = loadSound(candidates)
if (!bytes) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
res.end('sound unavailable')
return
}
res.writeHead(200, {
'Content-Type': 'audio/mpeg',
'Cache-Control': 'no-store',
'Content-Length': String(bytes.length),
})
res.end(bytes)
}
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/sound/press.mp3',
handler: (req, res) => {
const set = SOUND_SETS[soundSetFromUrl(req.url)] || SOUND_SETS.duck
serveSound(req, res, set.press)
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/sound/release.mp3',
handler: (req, res) => {
const set = SOUND_SETS[soundSetFromUrl(req.url)] || SOUND_SETS.duck
serveSound(req, res, set.release)
},
}))
disposers.push(ctx.webServer.register({
kind: 'exact',
path: '/dsh-whale/widget.js',
handler: (req, res) => {
res.writeHead(200, {
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store',
})
res.end(WIDGET_JS)
},
}))
disposers.push(ctx.webServer.tapIndex((html) => {
if (html.indexOf('/dsh-whale/widget.js') !== -1) return html
const tag = ''
if (html.indexOf('') !== -1) return html.replace('