dsh-whale-widget 0.3.3 → 0.3.5
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/assets/whale-widget.js +79 -6
- package/lib/index.js +44 -9
- package/package.json +1 -1
package/assets/whale-widget.js
CHANGED
|
@@ -671,6 +671,11 @@ var css = [
|
|
|
671
671
|
].join('\n')
|
|
672
672
|
|
|
673
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')
|
|
674
679
|
styleEl.textContent = css
|
|
675
680
|
document.head.appendChild(styleEl)
|
|
676
681
|
|
|
@@ -1916,6 +1921,9 @@ function usageAlertBudgetEditor(key, onSave) {
|
|
|
1916
1921
|
// 提醒编辑期间:窗口内可能弹出的各类全屏遮罩(确认/裁剪/音频/快照/用量)统一置顶,杜绝层级错位
|
|
1917
1922
|
var remindZStyle = document.createElement('style')
|
|
1918
1923
|
remindZStyle.id = 'dshw-remind-overlay-z'
|
|
1924
|
+
// 同上(PR #114):本文本表同样必须自带 data-plugin,否则会被别的客户端插件
|
|
1925
|
+
// 热重载时顺带删掉,提醒编辑期的遮罩层级就失效了。
|
|
1926
|
+
remindZStyle.setAttribute('data-plugin', 'dsh-whale-widget')
|
|
1919
1927
|
// 提醒编辑期间:窗口内可能弹出的全屏遮罩(确认/裁剪/音频/快照)统一置顶,杜绝层级错位。
|
|
1920
1928
|
// 注意:不要把 .dshwv-usage-mask 放进来——「模型子菜单/模型设置」用的是这个类(29000),
|
|
1921
1929
|
// 一提权就会反盖到提醒编辑器(30000)上面。
|
|
@@ -10099,6 +10107,30 @@ root.appendChild(menuBtn)
|
|
|
10099
10107
|
document.body.appendChild(root)
|
|
10100
10108
|
document.body.appendChild(menuBox)
|
|
10101
10109
|
|
|
10110
|
+
// ===== PR #105 后半:DOM 守护(SPA 切路由 / 别的插件替换 body 子树时把节点摘掉)=====
|
|
10111
|
+
// 背景:DSH 是 SPA,切到会话列表 / 设置 / 插件市场再回来、或其它客户端插件整体替换
|
|
10112
|
+
// document.body 的子树时,挂件节点会被顺带移除,而它不会自己回来(脚本只初始化一次)。
|
|
10113
|
+
// 做法:暴露 window.__dshWhaleRoot 供外部定位/调试,并用一个 MutationObserver 盯着;
|
|
10114
|
+
// 一旦发现节点已不在文档里就**把同一个节点补挂回 body**(不重建、不重新初始化,
|
|
10115
|
+
// 位置/设置/状态全部保留)。
|
|
10116
|
+
try { window.__dshWhaleRoot = root } catch (err) {}
|
|
10117
|
+
function dshwReattachRoot() {
|
|
10118
|
+
try {
|
|
10119
|
+
if (!root || root.isConnected) return
|
|
10120
|
+
document.body.appendChild(root)
|
|
10121
|
+
// 菜单是独立挂在 body 上的浮层:它也被摘掉且当前正打开时一并补回,否则菜单会"消失"
|
|
10122
|
+
if (menuBox && menuOpen && !menuBox.isConnected) document.body.appendChild(menuBox)
|
|
10123
|
+
} catch (err) {}
|
|
10124
|
+
}
|
|
10125
|
+
try {
|
|
10126
|
+
if (typeof MutationObserver === 'function') {
|
|
10127
|
+
var dshwRootGuard = new MutationObserver(function () {
|
|
10128
|
+
if (root && !root.isConnected) dshwReattachRoot()
|
|
10129
|
+
})
|
|
10130
|
+
dshwRootGuard.observe(document.documentElement, { childList: true, subtree: true })
|
|
10131
|
+
}
|
|
10132
|
+
} catch (err) {}
|
|
10133
|
+
|
|
10102
10134
|
// 泡泡内容整体与视觉中心对齐:
|
|
10103
10135
|
// 读取 SVG 主体(bshape)的包围盒,取其中点作为文字内容区的视觉中心,
|
|
10104
10136
|
// 写入 --dshw-vx/--dshw-vy(相对 .dshwv-pop 尺寸的百分比)。
|
|
@@ -12470,10 +12502,28 @@ function setScrollGapPx(v) {
|
|
|
12470
12502
|
saveConfig()
|
|
12471
12503
|
settle()
|
|
12472
12504
|
}
|
|
12505
|
+
// issue #91 缺陷2:触屏设备上没有任何进菜单的路径 —— ☰ 按钮默认 opacity:0,只由
|
|
12506
|
+
// pointermove 命中鲸鱼时才加 dshwv-menu-btn-visible,而触摸端没有 hover;长按唤出又只在
|
|
12507
|
+
// 「隐藏菜单按钮」开启时挂计时(默认关闭)。这里判定「主输入是否为无 hover 的触摸」:
|
|
12508
|
+
// 只用 (hover: none),或 (pointer: coarse) + 有触点。触屏笔记本(主输入是鼠标)不受影响。
|
|
12509
|
+
function dshwvTouchUI() {
|
|
12510
|
+
try {
|
|
12511
|
+
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
12512
|
+
if (window.matchMedia('(hover: none)').matches) return true
|
|
12513
|
+
if (window.matchMedia('(pointer: coarse)').matches && (navigator.maxTouchPoints || 0) > 0) return true
|
|
12514
|
+
}
|
|
12515
|
+
if (typeof window !== 'undefined' && typeof navigator !== 'undefined') {
|
|
12516
|
+
return (navigator.maxTouchPoints || 0) > 0 && ('ontouchstart' in window)
|
|
12517
|
+
}
|
|
12518
|
+
} catch (err) {}
|
|
12519
|
+
return false
|
|
12520
|
+
}
|
|
12473
12521
|
function applyMenuBtnHideUI() {
|
|
12474
12522
|
try {
|
|
12475
12523
|
menuBtn.classList.toggle('dshwv-menu-btn-hidden', menuBtnHide)
|
|
12476
12524
|
if (menuBtnHide) menuBtn.classList.remove('dshwv-menu-btn-visible')
|
|
12525
|
+
// 触屏:没有 hover 可以显形 → 常显(仍受上面的 hidden 开关控制)
|
|
12526
|
+
else if (dshwvTouchUI()) menuBtn.classList.add('dshwv-menu-btn-visible')
|
|
12477
12527
|
} catch (err) {}
|
|
12478
12528
|
}
|
|
12479
12529
|
function setMenuBtnHide(v) {
|
|
@@ -14361,7 +14411,7 @@ function onDocPointerUp(e) {
|
|
|
14361
14411
|
try { if (isWhaleHit(e)) { e.preventDefault(); e.stopPropagation() } } catch (err) {}
|
|
14362
14412
|
endDrag(e, true)
|
|
14363
14413
|
}
|
|
14364
|
-
function onDocPointerCancel(e) { endDrag(e, false) }
|
|
14414
|
+
function onDocPointerCancel(e) { endDrag(e, false, true) }
|
|
14365
14415
|
function onDocClickStopper(e) {
|
|
14366
14416
|
// 只在鲸鱼命中区域拦截 click(保持透明区 pass-through)。
|
|
14367
14417
|
// 持久注册(不随 endDrag 移除)——click 在 pointerup 之后派发,
|
|
@@ -14454,8 +14504,10 @@ function onDocTouchStart(e) {
|
|
|
14454
14504
|
touchDrag = { id: t.identifier }
|
|
14455
14505
|
touchStartPt = { x: t.clientX, y: t.clientY }
|
|
14456
14506
|
touchNowPt = { x: t.clientX, y: t.clientY }
|
|
14457
|
-
//
|
|
14458
|
-
|
|
14507
|
+
// 长按唤出菜单:原本只在「隐藏菜单按钮」开启且菜单未打开时挂计时(位移超标即取消)。
|
|
14508
|
+
// 触屏上默认配置(按钮永远不显形)就完全没有进菜单的路径 → 无 hover 的设备一律允许长按
|
|
14509
|
+
// 唤出(issue #91 缺陷2);桌面端行为不变。
|
|
14510
|
+
if ((menuBtnHide || dshwvTouchUI()) && !menuOpen && (Date.now() - menuClosedAt > 600)) {
|
|
14459
14511
|
cancelTouchLongPress()
|
|
14460
14512
|
touchLongPressTimer = setTimeout(fireTouchLongPressMenu, TOUCH_LONG_PRESS_MS)
|
|
14461
14513
|
}
|
|
@@ -14511,11 +14563,16 @@ function onDocPointerMoveCursor(e) {
|
|
|
14511
14563
|
}
|
|
14512
14564
|
var over = isWhaleHit(e)
|
|
14513
14565
|
setWidgetCursor(over ? 'grab' : '')
|
|
14514
|
-
|
|
14566
|
+
// 触屏上必须把 dshwvTouchUI() 也当作"该显示":拖动鲸鱼时 pointermove 的 over 为 false,
|
|
14567
|
+
// 否则这一次 toggle 会把常显状态撤掉(issue #91 缺陷2 修完又被自己抹掉)。
|
|
14568
|
+
if (!menuBtnHide) menuBtn.classList.toggle('dshwv-menu-btn-visible', over || menuOpen || dshwvTouchUI())
|
|
14515
14569
|
}
|
|
14516
14570
|
document.addEventListener('pointermove', onDocPointerMoveCursor, true)
|
|
14571
|
+
// 启动即应用一次菜单按钮可见性:触屏上 ☰ 常显(issue #91 缺陷2)。
|
|
14572
|
+
// 配置读回来之后还会再应用一次,这里是配置请求失败时的兜底。
|
|
14573
|
+
try { applyMenuBtnHideUI() } catch (err) {}
|
|
14517
14574
|
|
|
14518
|
-
function endDrag(e, clickAllowed) {
|
|
14575
|
+
function endDrag(e, clickAllowed, cancelled) {
|
|
14519
14576
|
if (!drag || !drag.active) return
|
|
14520
14577
|
drag.active = false
|
|
14521
14578
|
document.removeEventListener('pointermove', onDocPointerMove, true)
|
|
@@ -14523,6 +14580,20 @@ function endDrag(e, clickAllowed) {
|
|
|
14523
14580
|
document.removeEventListener('pointercancel', onDocPointerCancel, true)
|
|
14524
14581
|
pressUp()
|
|
14525
14582
|
root.classList.remove('dshwv-dragging')
|
|
14583
|
+
// issue #79 缺陷2:pointercancel(Android 把手势判成页面滚动、或系统抢走手势时派发)的
|
|
14584
|
+
// clientX/clientY 常常是 0,而 endDrag 又是「按坐标收尾 + saveConfig() 落盘」——
|
|
14585
|
+
// 于是位移被算成"一口气拖到了 (0,0)",归边判定吃进左上角,损坏锚点被写进 localStorage。
|
|
14586
|
+
// 0.3.2 的「非法距离自愈」只治负数 / 超出视口,救不回这个**合法的 (0,0)**,所以必须在这里拦住。
|
|
14587
|
+
// 处理:取消的手势一律回到按下前的位置、并且**不落盘**(取消不该提交位置)。
|
|
14588
|
+
var noCoord = (!e || typeof e.clientX !== 'number' || typeof e.clientY !== 'number' ||
|
|
14589
|
+
!isFinite(e.clientX) || !isFinite(e.clientY))
|
|
14590
|
+
var zeroBoth = (e && e.clientX === 0 && e.clientY === 0 && drag.moved)
|
|
14591
|
+
if (cancelled || noCoord || zeroBoth) {
|
|
14592
|
+
try { state.left = drag.origLeft; state.top = drag.origTop } catch (err) {}
|
|
14593
|
+
setWidgetCursor('')
|
|
14594
|
+
settle()
|
|
14595
|
+
return
|
|
14596
|
+
}
|
|
14526
14597
|
setWidgetCursor(isWhaleHit(e) ? 'grab' : '')
|
|
14527
14598
|
if (clickAllowed && !drag.moved) {
|
|
14528
14599
|
// 长按刚唤出菜单:这次抬手不再当作点击(避免顺带弹出余额泡)
|
|
@@ -14693,8 +14764,10 @@ fetch(SIZE_URL, { cache: 'no-store' })
|
|
|
14693
14764
|
if (d && typeof d.menuBtnHide === 'boolean') {
|
|
14694
14765
|
menuBtnHide = d.menuBtnHide
|
|
14695
14766
|
if (menuHideToggle) menuHideToggle.checked = menuBtnHide
|
|
14696
|
-
applyMenuBtnHideUI()
|
|
14697
14767
|
}
|
|
14768
|
+
// 无论服务端带没带这个键都要应用一次:触屏上 ☰ 需要常显(issue #91 缺陷2),
|
|
14769
|
+
// 而旧写法只在键存在时才调用,空配置下按钮永远是透明的。
|
|
14770
|
+
applyMenuBtnHideUI()
|
|
14698
14771
|
// 相对边框恢复(localStorage 锚点):窗口变化后保持离边距离。
|
|
14699
14772
|
// 仅认 v:2 净距离格式;旧格式(含避让距离)废弃,挂件保持默认右下角吸附。
|
|
14700
14773
|
// issue #102:这里原与 applyAnchorPos() 各写了一份恢复逻辑(两份都只做下限夹紧),
|
package/lib/index.js
CHANGED
|
@@ -929,7 +929,7 @@ export default {
|
|
|
929
929
|
const days = new Set([today, ...Object.keys(led.history || {}), ...accountingDays(led)])
|
|
930
930
|
for (const e of events) if (/^\d{4}-\d{2}-\d{2}$/.test(e.day)) days.add(e.day)
|
|
931
931
|
return {
|
|
932
|
-
ok: true, version: '0.3.
|
|
932
|
+
ok: true, version: '0.3.5', today: todayData, days7,
|
|
933
933
|
total7: total7ByCurrency[todayData.currency] || 0, total7Currency: todayData.currency, total7ByCurrency,
|
|
934
934
|
all: {
|
|
935
935
|
days: Array.from(days).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().reverse().map(forDay),
|
|
@@ -1273,9 +1273,19 @@ export default {
|
|
|
1273
1273
|
// 后台预热:codexSummary 首次要同步读全部 rollout 日志(可能几十 MB),
|
|
1274
1274
|
// 放在请求路径里会阻塞事件循环。这里在插件启动后预热一次,并每 5 分钟刷新一次缓存,
|
|
1275
1275
|
// 请求进来时通常直接命中 codexSummaryCached 的 5 秒记忆。
|
|
1276
|
+
let codexPrewarmT = null, codexPrewarmI = null
|
|
1276
1277
|
try {
|
|
1277
|
-
setTimeout(function () { try { codexSummaryCached() } catch (err) {} }, 1500)
|
|
1278
|
-
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
|
+
})
|
|
1279
1289
|
} catch (err) {}
|
|
1280
1290
|
// ===== 自定义 API 模型注册表(v655)=====
|
|
1281
1291
|
// 文件:$DSH_HOME/.dshw-api.json → { version, models:[…], usage:{ [id]:{day,dayStart,lastBalance,delta,eventCost} } }
|
|
@@ -1361,12 +1371,34 @@ export default {
|
|
|
1361
1371
|
}
|
|
1362
1372
|
return out
|
|
1363
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
|
+
}
|
|
1364
1395
|
async function apiFetchJson(url, auth, key) {
|
|
1365
1396
|
const headers = {}
|
|
1366
1397
|
const a = String(auth == null ? 'Bearer {key}' : auth)
|
|
1367
1398
|
if (a) headers.Authorization = a.replace('{key}', key)
|
|
1368
1399
|
// v724:URL 里也支持 {key}(如 Gemini 的 ?key=… 形式;auth 传空串时不带 Authorization 头)
|
|
1369
1400
|
const u = String(url).replace('{key}', key)
|
|
1401
|
+
assertSafeApiUrl(u)
|
|
1370
1402
|
const res = await fetch(u, { headers, signal: AbortSignal.timeout(15000) })
|
|
1371
1403
|
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
1372
1404
|
return await res.json()
|
|
@@ -1973,7 +2005,7 @@ export default {
|
|
|
1973
2005
|
const led = readUsageLedger()
|
|
1974
2006
|
const summary = daySummary(led, todayKey())
|
|
1975
2007
|
return {
|
|
1976
|
-
...visible, version: '0.3.
|
|
2008
|
+
...visible, version: '0.3.5', isPeak: isPeakTime(Math.floor(Date.now() / 1000)),
|
|
1977
2009
|
todayUsage: summary.amount, todayUsageCurrency: summary.currency,
|
|
1978
2010
|
usageSource: summary.source, usageLabel: summary.label, usageMode: 'ledger',
|
|
1979
2011
|
accounting: balanceSummary(led, todayKey()),
|
|
@@ -2055,6 +2087,7 @@ export default {
|
|
|
2055
2087
|
menuBtnHide: mbh,
|
|
2056
2088
|
updatedAt: new Date().toISOString(),
|
|
2057
2089
|
})
|
|
2090
|
+
let lastSizeErr = null
|
|
2058
2091
|
for (const p of SIZE_FILE_CANDIDATES) {
|
|
2059
2092
|
try {
|
|
2060
2093
|
fs.writeFileSync(p, body, 'utf8')
|
|
@@ -2073,9 +2106,11 @@ export default {
|
|
|
2073
2106
|
scrollGapPx: sgp,
|
|
2074
2107
|
menuBtnHide: mbh,
|
|
2075
2108
|
}
|
|
2076
|
-
} catch (err) {}
|
|
2109
|
+
} catch (err) { lastSizeErr = err }
|
|
2077
2110
|
}
|
|
2078
|
-
|
|
2111
|
+
// #88 / #97 报告者建议:把底层原因(EPERM / EACCES / 路径问题…)带出去。
|
|
2112
|
+
// 原先无论哪个候选路径失败,返回的都是同一句固定文案,即使前端检查了响应也定位不到原因。
|
|
2113
|
+
return { ok: false, error: '无法持久化挂件尺寸' + (lastSizeErr && lastSizeErr.message ? ':' + lastSizeErr.message : '') }
|
|
2079
2114
|
}
|
|
2080
2115
|
|
|
2081
2116
|
function readBody(req) {
|
|
@@ -2604,7 +2639,7 @@ export default {
|
|
|
2604
2639
|
const r = apiDeleteModel(parsed.id)
|
|
2605
2640
|
const list = await apiModelsPayload()
|
|
2606
2641
|
res.writeHead(r.ok ? 200 : 400, JSON_HEADERS)
|
|
2607
|
-
res.end(JSON.stringify(
|
|
2642
|
+
res.end(JSON.stringify({ ...r, models: list.models }))
|
|
2608
2643
|
return
|
|
2609
2644
|
}
|
|
2610
2645
|
if (action === 'probe') {
|
|
@@ -2640,10 +2675,10 @@ export default {
|
|
|
2640
2675
|
}
|
|
2641
2676
|
// 注意:keyValue 在提交体上(不在 model 里),必须合并进去,
|
|
2642
2677
|
// 否则前端面板里填的 API key 会被丢弃(密钥永远存不进去)
|
|
2643
|
-
const r = await apiSaveModel(
|
|
2678
|
+
const r = await apiSaveModel({ ...(parsed.model || parsed), keyValue: parsed.keyValue })
|
|
2644
2679
|
const list2 = await apiModelsPayload()
|
|
2645
2680
|
res.writeHead(r.ok ? 200 : 400, JSON_HEADERS)
|
|
2646
|
-
res.end(JSON.stringify(
|
|
2681
|
+
res.end(JSON.stringify({ ...r, models: list2.models }))
|
|
2647
2682
|
return
|
|
2648
2683
|
}
|
|
2649
2684
|
const payload = await apiModelsPayload()
|
package/package.json
CHANGED