dsh-whale-widget 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/assets/whale-widget.js +193 -40
- package/lib/index.js +53 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
|
|
5
5
|
DeepSeek Harness(DSH)Web 界面右下角的常驻挂件:小鲸鱼气泡图 + DeepSeek API 余额 + 今日已用 + 每轮对话消耗,并且**泡泡内容可以完全自定义**(点击序列、模块化排版、并列加权出泡、随机语句/随机图片)。标准 DSH bundle 插件,`dsh plugin` 一键安装,无需任何会话令牌。
|
|
6
6
|
|
|
7
|
+
## 两条分支怎么选
|
|
8
|
+
|
|
9
|
+
本仓库有两条**互不兼容**的产品线,按你要挂在哪里选一条:
|
|
10
|
+
|
|
11
|
+
| 你要挂在哪 | 用哪条分支 | 安装方式 |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| **DSH Web 界面右下角**(就是这个 README 描述的插件) | `main`(默认分支) | `dsh plugin --profile web add dsh-whale-widget`(推荐,装 npm 已发布版);也可以从本仓库装 `dsh plugin --profile web add github:MeteorNOX/DeepSeek-Balance-Whale-Widget`,但那样装的是 **main 当前状态、不跟随已发布版本** |
|
|
14
|
+
| **Codex 桌面应用**(跟随 Codex 窗口、无独立网页) | [`For-Codex`](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/tree/For-Codex) | 该分支的 `api-balance-whale`:解压到 `%USERPROFILE%\plugins\api-balance-whale`,再按分支内的 [安装说明](https://github.com/MeteorNOX/DeepSeek-Balance-Whale-Widget/blob/For-Codex/docs/INSTALL-AND-ROLLBACK-0.2.0.md) 注册计划任务(也可以直接交给 Codex 自己装喵~) |
|
|
15
|
+
|
|
16
|
+
⚠️ **两条分支互不兼容**:`For-Codex` 的插件不能用 `dsh plugin … add` 装进 DSH 网页;本主分支的插件也不能在 Codex 桌面里运行。上表第一行是本仓库默认分支(`main`)的能力,第二行是另一个分支的能力。
|
|
17
|
+
|
|
18
|
+
⚠️ 同一分支的两种安装方式(npm 名 / `github:`)**二选一、别混用** —— 它们是同一个包名,先后安装会并存冲突;已经是 `github:` 装的,要换 npm 名请先 `dsh plugin --profile web remove dsh-whale-widget`。
|
|
19
|
+
|
|
20
|
+
> 两条产品线**各自独立维护**(无共享代码/无跨分支依赖):`main` = DSH Web 插件,`For-Codex` = Codex 桌面端口,各自的版本线、发布方式与测试互不影响。
|
|
21
|
+
|
|
7
22
|
## 特性
|
|
8
23
|
|
|
9
24
|
### 记账与显示
|
package/assets/whale-widget.js
CHANGED
|
@@ -11,38 +11,145 @@ window.__dshWhaleWidget = true
|
|
|
11
11
|
function dshwIsChatRoot(r) {
|
|
12
12
|
return !!(r && (r.querySelector('textarea') || r.querySelector('[contenteditable="true"]')))
|
|
13
13
|
}
|
|
14
|
-
var
|
|
14
|
+
var dshwStarted = false
|
|
15
|
+
function dshwStartOnce() {
|
|
16
|
+
if (dshwStarted) return
|
|
17
|
+
dshwStarted = true
|
|
18
|
+
try { dshwInit() } catch (err) {}
|
|
19
|
+
}
|
|
20
|
+
// 是否已到主聊天界面;是则启动(只启动一次,之后由 dshwInit 内部标记去重)
|
|
21
|
+
var dshwLastCheck = 0
|
|
22
|
+
function dshwTryStart(force) {
|
|
23
|
+
if (dshwStarted) return true
|
|
24
|
+
var now = Date.now()
|
|
25
|
+
// 连续 DOM 变化时合并检查,避免每次 mutation 都 querySelector
|
|
26
|
+
if (!force && now - dshwLastCheck < 200) return false
|
|
27
|
+
dshwLastCheck = now
|
|
28
|
+
try {
|
|
29
|
+
if (dshwIsChatRoot(document.getElementById('root'))) { dshwStartOnce(); return true }
|
|
30
|
+
} catch (err) {}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
15
33
|
try {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
var
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return
|
|
34
|
+
if (!dshwIsChatRoot(document.getElementById('root'))) {
|
|
35
|
+
// 尚未渲染:MutationObserver 无限等待(v739 去掉原来的「5 秒死线」)。
|
|
36
|
+
// 原来 500ms × 10 次就永久放弃,而 window.__dshWhaleWidget 是一次性闸门 ——
|
|
37
|
+
// 慢启动机器、或页面最小化时定时器被浏览器节流,都会让"第一次没赶上"变成
|
|
38
|
+
// "这次会话永远不出现"(用户反馈 / issue #102 第 2 条)。
|
|
39
|
+
// 约束不变:检测到 composer 之前一行 DOM 都不碰、不注册任何全局监听。
|
|
40
|
+
var dshwObserver = null
|
|
41
|
+
try {
|
|
42
|
+
if (typeof MutationObserver === 'function') {
|
|
43
|
+
dshwObserver = new MutationObserver(function () {
|
|
44
|
+
if (dshwTryStart()) { try { dshwObserver.disconnect() } catch (err) {} }
|
|
45
|
+
})
|
|
46
|
+
dshwObserver.observe(document.documentElement || document.body, { childList: true, subtree: true })
|
|
30
47
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
48
|
+
} catch (err) {}
|
|
49
|
+
// 兜底:observer 不可用时低频轮询继续等(不设上限,找到即停)
|
|
50
|
+
var dshwFallbackPoll = setInterval(function () {
|
|
51
|
+
if (dshwTryStart(true)) {
|
|
52
|
+
clearInterval(dshwFallbackPoll)
|
|
53
|
+
try { if (dshwObserver) dshwObserver.disconnect() } catch (err) {}
|
|
34
54
|
}
|
|
35
|
-
},
|
|
55
|
+
}, 2000)
|
|
36
56
|
}
|
|
37
57
|
} catch (err) {}
|
|
38
|
-
if (!dshwEnabled) {
|
|
39
|
-
// 非主界面(或等待超时):不初始化挂件
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
58
|
function dshwInit() {
|
|
43
59
|
if (window.__dshWhaleInit) return
|
|
44
60
|
window.__dshWhaleInit = true
|
|
45
61
|
|
|
62
|
+
// ===== v739:音效改用 Web Audio 播放(不再用 <audio> / HTMLAudioElement)=====
|
|
63
|
+
// 为什么改:HTMLAudioElement 会被 macOS 注册进系统「正在播放」,带 Touch Bar 的机器上
|
|
64
|
+
// 每次按小鲸鱼都会弹出音频播放条并持续动画 → 明显卡顿(用户反馈)。AudioBufferSourceNode
|
|
65
|
+
// 不经过媒体元素,系统媒体控件不会出现,也没有那层额外的解码/合成开销。
|
|
66
|
+
// 兼容做法:给一个与 HTMLAudioElement 常用表面一致的 shim(play/pause/currentTime/volume/
|
|
67
|
+
// onended/preload/src),这样既有的播放、音量、重播、结束回调逻辑一行都不用改。
|
|
68
|
+
var dshwvAudioCtx = null
|
|
69
|
+
function dshwvAudio() {
|
|
70
|
+
try {
|
|
71
|
+
if (!dshwvAudioCtx) dshwvAudioCtx = new (window.AudioContext || window.webkitAudioContext)()
|
|
72
|
+
if (dshwvAudioCtx.state === 'suspended') { try { dshwvAudioCtx.resume() } catch (err) {} }
|
|
73
|
+
return dshwvAudioCtx
|
|
74
|
+
} catch (err) { return null }
|
|
75
|
+
}
|
|
76
|
+
var dshwvAudioBuffers = {} // url -> Promise<AudioBuffer>(解码结果缓存,同一片段不重复下载/解码)
|
|
77
|
+
function dshwvAudioBuffer(url) {
|
|
78
|
+
if (!url) return Promise.reject(new Error('empty url'))
|
|
79
|
+
if (!dshwvAudioBuffers[url]) {
|
|
80
|
+
dshwvAudioBuffers[url] = fetch(url, { cache: 'force-cache' })
|
|
81
|
+
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.arrayBuffer() })
|
|
82
|
+
.then(function (raw) {
|
|
83
|
+
var c = dshwvAudio()
|
|
84
|
+
if (!c) throw new Error('no audio context')
|
|
85
|
+
return new Promise(function (res, rej) { c.decodeAudioData(raw, res, rej) })
|
|
86
|
+
})
|
|
87
|
+
.catch(function (err) { delete dshwvAudioBuffers[url]; throw err })
|
|
88
|
+
}
|
|
89
|
+
return dshwvAudioBuffers[url]
|
|
90
|
+
}
|
|
91
|
+
function dshwvSoundStop(el) {
|
|
92
|
+
el._token = (el._token || 0) + 1
|
|
93
|
+
var node = el._node
|
|
94
|
+
el._node = null
|
|
95
|
+
if (node) {
|
|
96
|
+
try { node.onended = null } catch (err) {}
|
|
97
|
+
try { node.stop() } catch (err) {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function dshwvSound(url) {
|
|
101
|
+
var el = { preload: 'auto', volume: 1, onended: null, loop: false, _url: String(url || ''), _node: null, _gain: null, _offset: 0, _token: 0 }
|
|
102
|
+
Object.defineProperty(el, 'src', {
|
|
103
|
+
get: function () { return el._url },
|
|
104
|
+
set: function (v) { dshwvSoundStop(el); el._url = String(v || ''); el._offset = 0 },
|
|
105
|
+
})
|
|
106
|
+
Object.defineProperty(el, 'currentTime', {
|
|
107
|
+
get: function () { return el._offset },
|
|
108
|
+
// 既有逻辑用「currentTime = 0」表示重播 → 这里顺手停掉正在播的那一份
|
|
109
|
+
set: function (v) { el._offset = Number(v) || 0; dshwvSoundStop(el) },
|
|
110
|
+
})
|
|
111
|
+
el.play = function () {
|
|
112
|
+
var c = dshwvAudio()
|
|
113
|
+
if (!c || !el._url) return Promise.resolve()
|
|
114
|
+
try {
|
|
115
|
+
if (!el._gain) { el._gain = c.createGain(); el._gain.connect(c.destination) }
|
|
116
|
+
el._gain.gain.value = Math.max(0, Math.min(1, Number(el.volume) || 0))
|
|
117
|
+
} catch (err) { return Promise.resolve() }
|
|
118
|
+
var token = (el._token = (el._token || 0) + 1)
|
|
119
|
+
var url = el._url
|
|
120
|
+
dshwvAudioBuffer(url).then(function (buf) {
|
|
121
|
+
if (token !== el._token) return // 期间被重播/暂停/换源 → 丢弃这次
|
|
122
|
+
try {
|
|
123
|
+
var src = c.createBufferSource()
|
|
124
|
+
src.buffer = buf
|
|
125
|
+
src.connect(el._gain)
|
|
126
|
+
src.onended = function () {
|
|
127
|
+
if (el._node !== src) return
|
|
128
|
+
el._node = null
|
|
129
|
+
if (typeof el.onended === 'function') { try { el.onended() } catch (err) {} }
|
|
130
|
+
}
|
|
131
|
+
el._node = src
|
|
132
|
+
var dur = Math.max(0.001, buf.duration)
|
|
133
|
+
src.start(0, Math.max(0, el._offset) % dur)
|
|
134
|
+
} catch (err) {}
|
|
135
|
+
}).catch(function () {})
|
|
136
|
+
return Promise.resolve()
|
|
137
|
+
}
|
|
138
|
+
el.pause = function () { dshwvSoundStop(el) }
|
|
139
|
+
return el
|
|
140
|
+
}
|
|
141
|
+
// 自动播放策略:AudioContext 初始是 suspended,要有一次用户手势才能出声。
|
|
142
|
+
// 任务结束音不是手势触发的,所以先挂一次性解锁(首次点击/按键后移除)。
|
|
143
|
+
try {
|
|
144
|
+
var dshwvAudioUnlock = function () {
|
|
145
|
+
dshwvAudio()
|
|
146
|
+
try { document.removeEventListener('pointerdown', dshwvAudioUnlock, true) } catch (err) {}
|
|
147
|
+
try { document.removeEventListener('keydown', dshwvAudioUnlock, true) } catch (err) {}
|
|
148
|
+
}
|
|
149
|
+
document.addEventListener('pointerdown', dshwvAudioUnlock, true)
|
|
150
|
+
document.addEventListener('keydown', dshwvAudioUnlock, true)
|
|
151
|
+
} catch (err) {}
|
|
152
|
+
|
|
46
153
|
var MIN_SCALE = 0.6
|
|
47
154
|
var MAX_SCALE = 2.5
|
|
48
155
|
var STEP = 0.1
|
|
@@ -564,6 +671,11 @@ var css = [
|
|
|
564
671
|
].join('\n')
|
|
565
672
|
|
|
566
673
|
var styleEl = document.createElement('style')
|
|
674
|
+
// PR #114:不带 data-plugin 的 <style> 会被 DSH 客户端模块系统 claimStyles 认领到
|
|
675
|
+
// 「当前正在物化的那个插件」名下,之后该插件热重载/失效时 removeOwnedStyles 会把它
|
|
676
|
+
// 一起删掉。样式一没,挂件 20 多个 dshwv-* 节点就从 position:fixed 掉回文档流堆在
|
|
677
|
+
// 页面底部(页面被撑到几千像素高)。打上自己的名字后就不会被任何人认领/删除。
|
|
678
|
+
styleEl.setAttribute('data-plugin', 'dsh-whale-widget')
|
|
567
679
|
styleEl.textContent = css
|
|
568
680
|
document.head.appendChild(styleEl)
|
|
569
681
|
|
|
@@ -1006,7 +1118,7 @@ function playTaskEndSound() {
|
|
|
1006
1118
|
url = '/dsh-whale/sound/' + (parts[2] === 'release' ? 'release' : 'press') + '.mp3?set=' + parts[1]
|
|
1007
1119
|
}
|
|
1008
1120
|
if (!url) return
|
|
1009
|
-
var a =
|
|
1121
|
+
var a = dshwvSound(url)
|
|
1010
1122
|
try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
|
|
1011
1123
|
a.play().catch(function () {})
|
|
1012
1124
|
} catch (err) {}
|
|
@@ -1025,7 +1137,7 @@ function playTaskEndGroupClick(groupId) {
|
|
|
1025
1137
|
// 按压留空:无按下音,直接播松开(模拟按下即松开的完整点按);松开留空:只播按压
|
|
1026
1138
|
if (pressEmpty) {
|
|
1027
1139
|
if (!releaseEmpty) {
|
|
1028
|
-
var relOnly =
|
|
1140
|
+
var relOnly = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
|
|
1029
1141
|
try { relOnly.volume = vol } catch (err) {}
|
|
1030
1142
|
relOnly.currentTime = 0
|
|
1031
1143
|
var pr = relOnly.play()
|
|
@@ -1033,7 +1145,7 @@ function playTaskEndGroupClick(groupId) {
|
|
|
1033
1145
|
}
|
|
1034
1146
|
return
|
|
1035
1147
|
}
|
|
1036
|
-
var press =
|
|
1148
|
+
var press = dshwvSound('/dsh-whale/sound/press.mp3?set=' + encodeURIComponent(groupId))
|
|
1037
1149
|
try { press.volume = vol } catch (err) {}
|
|
1038
1150
|
if (releaseEmpty) {
|
|
1039
1151
|
press.currentTime = 0
|
|
@@ -1041,7 +1153,7 @@ function playTaskEndGroupClick(groupId) {
|
|
|
1041
1153
|
if (pp && pp.catch) pp.catch(function () {})
|
|
1042
1154
|
return
|
|
1043
1155
|
}
|
|
1044
|
-
var release =
|
|
1156
|
+
var release = dshwvSound('/dsh-whale/sound/release.mp3?set=' + encodeURIComponent(groupId))
|
|
1045
1157
|
try { release.volume = vol } catch (err) {}
|
|
1046
1158
|
var relPlayed = false
|
|
1047
1159
|
function playRel() {
|
|
@@ -1809,6 +1921,9 @@ function usageAlertBudgetEditor(key, onSave) {
|
|
|
1809
1921
|
// 提醒编辑期间:窗口内可能弹出的各类全屏遮罩(确认/裁剪/音频/快照/用量)统一置顶,杜绝层级错位
|
|
1810
1922
|
var remindZStyle = document.createElement('style')
|
|
1811
1923
|
remindZStyle.id = 'dshw-remind-overlay-z'
|
|
1924
|
+
// 同上(PR #114):本文本表同样必须自带 data-plugin,否则会被别的客户端插件
|
|
1925
|
+
// 热重载时顺带删掉,提醒编辑期的遮罩层级就失效了。
|
|
1926
|
+
remindZStyle.setAttribute('data-plugin', 'dsh-whale-widget')
|
|
1812
1927
|
// 提醒编辑期间:窗口内可能弹出的全屏遮罩(确认/裁剪/音频/快照)统一置顶,杜绝层级错位。
|
|
1813
1928
|
// 注意:不要把 .dshwv-usage-mask 放进来——「模型子菜单/模型设置」用的是这个类(29000),
|
|
1814
1929
|
// 一提权就会反盖到提醒编辑器(30000)上面。
|
|
@@ -3484,7 +3599,7 @@ function resPlayFragment(fid) {
|
|
|
3484
3599
|
try {
|
|
3485
3600
|
if (!fid) return
|
|
3486
3601
|
if (resAudEl) { try { resAudEl.pause() } catch (err) {} resAudEl = null }
|
|
3487
|
-
var a =
|
|
3602
|
+
var a = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(fid))
|
|
3488
3603
|
try { a.volume = Number(soundVol) || 0.9 } catch (err) {}
|
|
3489
3604
|
a.onended = function () { resAudEl = null }
|
|
3490
3605
|
resAudEl = a
|
|
@@ -12136,6 +12251,16 @@ function artCenterAt(left, top, w, h, flipped) {
|
|
|
12136
12251
|
var cy = top + h - iw / 2
|
|
12137
12252
|
return { cx: cx, cy: cy }
|
|
12138
12253
|
}
|
|
12254
|
+
// v739(用户反馈「每次新实例的第一次余额请求都失败」):冷启动时凭据服务可能还没就绪,
|
|
12255
|
+
// 首次 DNS+TLS 也最慢;而客户端 25s 超时会先于宿主的两段重试结束 —— 结果是第一次必失败、
|
|
12256
|
+
// 只能干等 60 秒后的下一轮。这里失败后快速重试两次(1.5s / 3s),成功即重置计数。
|
|
12257
|
+
var balanceRetryLeft = 2
|
|
12258
|
+
function balanceRetryLater() {
|
|
12259
|
+
if (balanceRetryLeft <= 0) return
|
|
12260
|
+
var delay = balanceRetryLeft === 2 ? 1500 : 3000
|
|
12261
|
+
balanceRetryLeft--
|
|
12262
|
+
setTimeout(function () { try { refresh(false) } catch (err) {} }, delay)
|
|
12263
|
+
}
|
|
12139
12264
|
function refresh(manual) {
|
|
12140
12265
|
if (busy) return
|
|
12141
12266
|
busy = true
|
|
@@ -12158,6 +12283,7 @@ function refresh(manual) {
|
|
|
12158
12283
|
state.balance = nb
|
|
12159
12284
|
state.currency = nc
|
|
12160
12285
|
state.message = ''
|
|
12286
|
+
balanceRetryLeft = 2
|
|
12161
12287
|
state.todayUsage = data.todayUsage !== undefined ? data.todayUsage : null
|
|
12162
12288
|
state.todayUsageCurrency = data.todayUsageCurrency || data.currency || 'CNY'
|
|
12163
12289
|
state.usageLabel = data.usageLabel || '本地估算'
|
|
@@ -12193,12 +12319,14 @@ function refresh(manual) {
|
|
|
12193
12319
|
state.status = 'error'
|
|
12194
12320
|
state.message = (data && data.error) ? String(data.error) : '获取失败'
|
|
12195
12321
|
render()
|
|
12322
|
+
balanceRetryLater()
|
|
12196
12323
|
}
|
|
12197
12324
|
})
|
|
12198
12325
|
.catch(function () {
|
|
12199
12326
|
state.status = 'error'
|
|
12200
12327
|
state.message = '获取失败'
|
|
12201
12328
|
render()
|
|
12329
|
+
balanceRetryLater()
|
|
12202
12330
|
})
|
|
12203
12331
|
.finally(function () {
|
|
12204
12332
|
busy = false
|
|
@@ -12350,10 +12478,28 @@ function setScrollGapPx(v) {
|
|
|
12350
12478
|
saveConfig()
|
|
12351
12479
|
settle()
|
|
12352
12480
|
}
|
|
12481
|
+
// issue #91 缺陷2:触屏设备上没有任何进菜单的路径 —— ☰ 按钮默认 opacity:0,只由
|
|
12482
|
+
// pointermove 命中鲸鱼时才加 dshwv-menu-btn-visible,而触摸端没有 hover;长按唤出又只在
|
|
12483
|
+
// 「隐藏菜单按钮」开启时挂计时(默认关闭)。这里判定「主输入是否为无 hover 的触摸」:
|
|
12484
|
+
// 只用 (hover: none),或 (pointer: coarse) + 有触点。触屏笔记本(主输入是鼠标)不受影响。
|
|
12485
|
+
function dshwvTouchUI() {
|
|
12486
|
+
try {
|
|
12487
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
12488
|
+
if (window.matchMedia('(hover: none)').matches) return true
|
|
12489
|
+
if (window.matchMedia('(pointer: coarse)').matches && (navigator.maxTouchPoints || 0) > 0) return true
|
|
12490
|
+
}
|
|
12491
|
+
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
|
12492
|
+
return (navigator.maxTouchPoints || 0) > 0 && ('ontouchstart' in window)
|
|
12493
|
+
}
|
|
12494
|
+
} catch (err) {}
|
|
12495
|
+
return false
|
|
12496
|
+
}
|
|
12353
12497
|
function applyMenuBtnHideUI() {
|
|
12354
12498
|
try {
|
|
12355
12499
|
menuBtn.classList.toggle('dshwv-menu-btn-hidden', menuBtnHide)
|
|
12356
12500
|
if (menuBtnHide) menuBtn.classList.remove('dshwv-menu-btn-visible')
|
|
12501
|
+
// 触屏:没有 hover 可以显形 → 常显(仍受上面的 hidden 开关控制)
|
|
12502
|
+
else if (dshwvTouchUI()) menuBtn.classList.add('dshwv-menu-btn-visible')
|
|
12357
12503
|
} catch (err) {}
|
|
12358
12504
|
}
|
|
12359
12505
|
function setMenuBtnHide(v) {
|
|
@@ -12438,12 +12584,12 @@ function applySoundSet() {
|
|
|
12438
12584
|
var pEmpty = audioGroupSlotEmpty(soundSet, 'press')
|
|
12439
12585
|
var rEmpty = audioGroupSlotEmpty(soundSet, 'release')
|
|
12440
12586
|
if (pEmpty) { pressAudio = null } else {
|
|
12441
|
-
pressAudio =
|
|
12587
|
+
pressAudio = dshwvSound('/dsh-whale/sound/press.mp3?set=' + soundSet)
|
|
12442
12588
|
pressAudio.preload = 'auto'
|
|
12443
12589
|
pressAudio.volume = soundVol
|
|
12444
12590
|
}
|
|
12445
12591
|
if (rEmpty) { releaseAudio = null } else {
|
|
12446
|
-
releaseAudio =
|
|
12592
|
+
releaseAudio = dshwvSound('/dsh-whale/sound/release.mp3?set=' + soundSet)
|
|
12447
12593
|
releaseAudio.preload = 'auto'
|
|
12448
12594
|
releaseAudio.volume = soundVol
|
|
12449
12595
|
}
|
|
@@ -13477,12 +13623,12 @@ function audioEditPreviewEnsure(force) {
|
|
|
13477
13623
|
if (audioEditPreviewRelease) { audioEditPreviewRelease.pause(); audioEditPreviewRelease = null }
|
|
13478
13624
|
} catch (err) {}
|
|
13479
13625
|
if (pressId) {
|
|
13480
|
-
audioEditPreviewEl =
|
|
13626
|
+
audioEditPreviewEl = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(pressId))
|
|
13481
13627
|
audioEditPreviewEl.preload = 'auto'
|
|
13482
13628
|
audioEditPreviewEl.volume = soundVol
|
|
13483
13629
|
}
|
|
13484
13630
|
if (releaseId) {
|
|
13485
|
-
audioEditPreviewRelease =
|
|
13631
|
+
audioEditPreviewRelease = dshwvSound('/dsh-whale/audio-fragment.wav?id=' + encodeURIComponent(releaseId))
|
|
13486
13632
|
audioEditPreviewRelease.preload = 'auto'
|
|
13487
13633
|
audioEditPreviewRelease.volume = soundVol
|
|
13488
13634
|
}
|
|
@@ -14334,8 +14480,10 @@ function onDocTouchStart(e) {
|
|
|
14334
14480
|
touchDrag = { id: t.identifier }
|
|
14335
14481
|
touchStartPt = { x: t.clientX, y: t.clientY }
|
|
14336
14482
|
touchNowPt = { x: t.clientX, y: t.clientY }
|
|
14337
|
-
//
|
|
14338
|
-
|
|
14483
|
+
// 长按唤出菜单:原本只在「隐藏菜单按钮」开启且菜单未打开时挂计时(位移超标即取消)。
|
|
14484
|
+
// 触屏上默认配置(按钮永远不显形)就完全没有进菜单的路径 → 无 hover 的设备一律允许长按
|
|
14485
|
+
// 唤出(issue #91 缺陷2);桌面端行为不变。
|
|
14486
|
+
if ((menuBtnHide || dshwvTouchUI()) && !menuOpen && (Date.now() - menuClosedAt > 600)) {
|
|
14339
14487
|
cancelTouchLongPress()
|
|
14340
14488
|
touchLongPressTimer = setTimeout(fireTouchLongPressMenu, TOUCH_LONG_PRESS_MS)
|
|
14341
14489
|
}
|
|
@@ -14391,9 +14539,14 @@ function onDocPointerMoveCursor(e) {
|
|
|
14391
14539
|
}
|
|
14392
14540
|
var over = isWhaleHit(e)
|
|
14393
14541
|
setWidgetCursor(over ? 'grab' : '')
|
|
14394
|
-
|
|
14542
|
+
// 触屏上必须把 dshwvTouchUI() 也当作"该显示":拖动鲸鱼时 pointermove 的 over 为 false,
|
|
14543
|
+
// 否则这一次 toggle 会把常显状态撤掉(issue #91 缺陷2 修完又被自己抹掉)。
|
|
14544
|
+
if (!menuBtnHide) menuBtn.classList.toggle('dshwv-menu-btn-visible', over || menuOpen || dshwvTouchUI())
|
|
14395
14545
|
}
|
|
14396
14546
|
document.addEventListener('pointermove', onDocPointerMoveCursor, true)
|
|
14547
|
+
// 启动即应用一次菜单按钮可见性:触屏上 ☰ 常显(issue #91 缺陷2)。
|
|
14548
|
+
// 配置读回来之后还会再应用一次,这里是配置请求失败时的兜底。
|
|
14549
|
+
try { applyMenuBtnHideUI() } catch (err) {}
|
|
14397
14550
|
|
|
14398
14551
|
function endDrag(e, clickAllowed) {
|
|
14399
14552
|
if (!drag || !drag.active) return
|
|
@@ -14573,8 +14726,10 @@ fetch(SIZE_URL, { cache: 'no-store' })
|
|
|
14573
14726
|
if (d && typeof d.menuBtnHide === 'boolean') {
|
|
14574
14727
|
menuBtnHide = d.menuBtnHide
|
|
14575
14728
|
if (menuHideToggle) menuHideToggle.checked = menuBtnHide
|
|
14576
|
-
applyMenuBtnHideUI()
|
|
14577
14729
|
}
|
|
14730
|
+
// 无论服务端带没带这个键都要应用一次:触屏上 ☰ 需要常显(issue #91 缺陷2),
|
|
14731
|
+
// 而旧写法只在键存在时才调用,空配置下按钮永远是透明的。
|
|
14732
|
+
applyMenuBtnHideUI()
|
|
14578
14733
|
// 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离。
|
|
14579
14734
|
// 仅认 v:2 净距离格式;旧格式(含避让距离)废弃,挂件保持默认右下角吸附。
|
|
14580
14735
|
// issue #102:这里原与 applyAnchorPos() 各写了一份恢复逻辑(两份都只做下限夹紧),
|
|
@@ -14644,8 +14799,6 @@ function pollLastTurn() {
|
|
|
14644
14799
|
}
|
|
14645
14800
|
setInterval(pollLastTurn, 1000)
|
|
14646
14801
|
}
|
|
14647
|
-
//
|
|
14648
|
-
|
|
14649
|
-
try { dshwInit() } catch (err) {}
|
|
14650
|
-
}
|
|
14802
|
+
// 主界面检测通过(或稍后由 MutationObserver 检测到)后执行挂件初始化;非主界面不启动
|
|
14803
|
+
try { dshwTryStart(true) } catch (err) {}
|
|
14651
14804
|
})()
|
package/lib/index.js
CHANGED
|
@@ -716,11 +716,19 @@ export default {
|
|
|
716
716
|
}
|
|
717
717
|
|
|
718
718
|
async function fetchBalance() {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
719
|
+
// v739(用户反馈「每次新实例的第一次余额请求都失败」):
|
|
720
|
+
// ① 冷启动时凭据服务可能尚未就绪(resolve 抛错),这与「确实没配置」不同 → 短延迟重试一次;
|
|
721
|
+
// ② 单次超时从 20s 收到 8s,保证「2 次尝试 + 退避」明显短于前端 FETCH_TIMEOUT_MS(25s) ——
|
|
722
|
+
// 否则前端先 abort,用户看到的永远是「第一次失败」,只能干等 60 秒后的下一轮。
|
|
723
|
+
let cred = null
|
|
724
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
725
|
+
try {
|
|
726
|
+
cred = await ctx.credentials.resolve('DEEPSEEK_API_KEY')
|
|
727
|
+
break
|
|
728
|
+
} catch (err) {
|
|
729
|
+
if (attempt === 0) { await new Promise((r) => setTimeout(r, 700)); continue }
|
|
730
|
+
return { ok: false, code: 'NO_KEY', error: '凭据读取失败: ' + String((err && err.message) || err).slice(0, 160) }
|
|
731
|
+
}
|
|
724
732
|
}
|
|
725
733
|
if (!cred) {
|
|
726
734
|
return { ok: false, code: 'NO_KEY', error: '未配置 DEEPSEEK_API_KEY' }
|
|
@@ -731,7 +739,7 @@ export default {
|
|
|
731
739
|
try {
|
|
732
740
|
res = await fetch(BALANCE_URL, {
|
|
733
741
|
headers: { Authorization: 'Bearer ' + cred.value },
|
|
734
|
-
signal: AbortSignal.timeout(
|
|
742
|
+
signal: AbortSignal.timeout(8000),
|
|
735
743
|
})
|
|
736
744
|
} catch (err) {
|
|
737
745
|
lastErr = err
|
|
@@ -921,7 +929,7 @@ export default {
|
|
|
921
929
|
const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
|
|
922
930
|
for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
|
|
923
931
|
return {
|
|
924
|
-
ok: true, version: '0.3.
|
|
932
|
+
ok: true, version: '0.3.4', today: todayData, days7,
|
|
925
933
|
total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
|
|
926
934
|
all: {
|
|
927
935
|
days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
|
|
@@ -1265,9 +1273,19 @@ export default {
|
|
|
1265
1273
|
// 后台预热:codexSummary 首次要同步读全部 rollout 日志(可能几十 MB),
|
|
1266
1274
|
// 放在请求路径里会阻塞事件循环。这里在插件启动后预热一次,并每 5 分钟刷新一次缓存,
|
|
1267
1275
|
// 请求进来时通常直接命中 codexSummaryCached 的 5 秒记忆。
|
|
1276
|
+
let codexPrewarmT = null, codexPrewarmI = null
|
|
1268
1277
|
try {
|
|
1269
|
-
setTimeout(function () { try { codexSummaryCached() } catch (err) {} }, 1500)
|
|
1270
|
-
setInterval(function () { try { codexSummaryCached() } catch (err) {} }, 5 * 60 * 1000)
|
|
1278
|
+
codexPrewarmT = setTimeout(function () { try { codexSummaryCached() } catch (err) {} }, 1500)
|
|
1279
|
+
codexPrewarmI = setInterval(function () { try { codexSummaryCached() } catch (err) {} }, 5 * 60 * 1000)
|
|
1280
|
+
// issue #109:这两个定时器原先不存句柄、也不进 disposers,插件树 dispose 之后
|
|
1281
|
+
// 事件循环里仍有一个被引用的 interval → 用户退出 dsh 时进程不结束(只能 Ctrl+C)。
|
|
1282
|
+
// 存句柄并纳入 disposers,卸载时一并清掉;unref 作为兜底(正常运行时由 HTTP 服务维持事件循环)。
|
|
1283
|
+
try { if (codexPrewarmT && codexPrewarmT.unref) codexPrewarmT.unref() } catch (err) {}
|
|
1284
|
+
try { if (codexPrewarmI && codexPrewarmI.unref) codexPrewarmI.unref() } catch (err) {}
|
|
1285
|
+
disposers.push(function () {
|
|
1286
|
+
if (codexPrewarmT !== null) { clearTimeout(codexPrewarmT); codexPrewarmT = null }
|
|
1287
|
+
if (codexPrewarmI !== null) { clearInterval(codexPrewarmI); codexPrewarmI = null }
|
|
1288
|
+
})
|
|
1271
1289
|
} catch (err) {}
|
|
1272
1290
|
// ===== 自定义 API 模型注册表(v655)=====
|
|
1273
1291
|
// 文件:$DSH_HOME/.dshw-api.json → { version, models:[…], usage:{ [id]:{day,dayStart,lastBalance,delta,eventCost} } }
|
|
@@ -1353,12 +1371,34 @@ export default {
|
|
|
1353
1371
|
}
|
|
1354
1372
|
return out
|
|
1355
1373
|
}
|
|
1374
|
+
// PR #104(收窄版):只放行 http/https,并挡掉云厂商元数据与 link-local 地址。
|
|
1375
|
+
// 关键取舍:**不拦回环与内网** —— 我们内置的 ollama 模板就推荐用户填
|
|
1376
|
+
// http://127.0.0.1:11434/v1(本地 Ollama / LM Studio),自建网关(New API 之类,
|
|
1377
|
+
// 见 issue #53)也跑在本机;拦掉它们会把正当用法一起打死。接口地址由用户自己在面板/
|
|
1378
|
+
// 注册表里填写,且所有 /dsh-whale/* 路由都在受信任栅栏之后 —— 真正要挡的是
|
|
1379
|
+
// 「被诱导去读云元数据把凭据带出来」,而不是「访问本机服务」。
|
|
1380
|
+
// decimal / octal / hex 形式的 IPv4 已由 WHATWG URL 规范化成点分十进制,这里只按规范化结果判定。
|
|
1381
|
+
function assertSafeApiUrl(u) {
|
|
1382
|
+
let parsed
|
|
1383
|
+
try { parsed = new URL(u) } catch { throw new Error('无效的接口地址') }
|
|
1384
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error('不支持的协议: ' + parsed.protocol)
|
|
1385
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
1386
|
+
if (host === 'metadata.google.internal' || host === 'metadata.goog' || host === '100.100.100.200') throw new Error('禁止访问云元数据地址')
|
|
1387
|
+
const m = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
1388
|
+
if (m) {
|
|
1389
|
+
const [a, b] = [Number(m[1]), Number(m[2])]
|
|
1390
|
+
if (a === 169 && b === 254) throw new Error('禁止访问 link-local 地址')
|
|
1391
|
+
if (a === 0) throw new Error('禁止访问该地址')
|
|
1392
|
+
}
|
|
1393
|
+
if (/^fe[89ab][0-9a-f]:/i.test(host)) throw new Error('禁止访问 link-local 地址')
|
|
1394
|
+
}
|
|
1356
1395
|
async function apiFetchJson(url, auth, key) {
|
|
1357
1396
|
const headers = {}
|
|
1358
1397
|
const a = String(auth == null ? 'Bearer {key}' : auth)
|
|
1359
1398
|
if (a) headers.Authorization = a.replace('{key}', key)
|
|
1360
1399
|
// v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
|
|
1361
1400
|
const u = String(url).replace('{key}', key)
|
|
1401
|
+
assertSafeApiUrl(u)
|
|
1362
1402
|
const res = await fetch(u, { headers, signal: AbortSignal.timeout(15000) })
|
|
1363
1403
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1364
1404
|
return await res.json()
|
|
@@ -1965,7 +2005,7 @@ export default {
|
|
|
1965
2005
|
const led = readUsageLedger()
|
|
1966
2006
|
const summary = daySummary(led, todayKey())
|
|
1967
2007
|
return {
|
|
1968
|
-
...visible, version: '0.3.
|
|
2008
|
+
...visible, version: '0.3.4', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
|
|
1969
2009
|
todayUsage: summary.amount, todayUsageCurrency: summary.currency,
|
|
1970
2010
|
usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
|
|
1971
2011
|
accounting: balanceSummary(led, todayKey()),
|
|
@@ -2596,7 +2636,7 @@ export default {
|
|
|
2596
2636
|
const r = apiDeleteModel(parsed.id)
|
|
2597
2637
|
const list = await apiModelsPayload()
|
|
2598
2638
|
res.writeHead(r.ok ? 200 : 400, JSON_HEADERS)
|
|
2599
|
-
res.end(JSON.stringify(
|
|
2639
|
+
res.end(JSON.stringify({ ...r, models: list.models }))
|
|
2600
2640
|
return
|
|
2601
2641
|
}
|
|
2602
2642
|
if (action === 'probe') {
|
|
@@ -2632,10 +2672,10 @@ export default {
|
|
|
2632
2672
|
}
|
|
2633
2673
|
// 注意:keyValue 在提交体上(不在 model 里),必须合并进去,
|
|
2634
2674
|
// 否则前端面板里填的 API key 会被丢弃(密钥永远存不进去)
|
|
2635
|
-
const r = await apiSaveModel(
|
|
2675
|
+
const r = await apiSaveModel({ ...(parsed.model || parsed), keyValue: parsed.keyValue })
|
|
2636
2676
|
const list2 = await apiModelsPayload()
|
|
2637
2677
|
res.writeHead(r.ok ? 200 : 400, JSON_HEADERS)
|
|
2638
|
-
res.end(JSON.stringify(
|
|
2678
|
+
res.end(JSON.stringify({ ...r, models: list2.models }))
|
|
2639
2679
|
return
|
|
2640
2680
|
}
|
|
2641
2681
|
const payload = await apiModelsPayload()
|
package/package.json
CHANGED