dsh-wisp 1.26.0 → 1.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -0
- package/assets/canon/eat.webp +0 -0
- package/assets/classic/eat.webp +0 -0
- package/assets/deepsea/eat.webp +0 -0
- package/assets/night/eat.webp +0 -0
- package/assets/pajama/eat.webp +0 -0
- package/build.mjs +1 -1
- package/lib/client.js +363 -53
- package/lib/client.template.js +358 -53
- package/package.json +1 -1
- package/tools/assets.mjs +1 -1
- package/tools/smoke.mjs +80 -6
- package/verify-wisp.mjs +294 -15
package/lib/client.template.js
CHANGED
|
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
|
|
|
51
51
|
var exports = module.exports
|
|
52
52
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
53
53
|
|
|
54
|
-
const VERSION = '1.
|
|
54
|
+
const VERSION = '1.28.0'
|
|
55
55
|
|
|
56
56
|
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
57
|
忘了更新就会红,不会静默过期。 */
|
|
@@ -67,6 +67,8 @@ const WHATS_NEW = {
|
|
|
67
67
|
'1.25.0': '她真的会去查有没有新版本了。',
|
|
68
68
|
'1.25.1': '内容与 1.25.0 相同,重新发布以修好安装源。',
|
|
69
69
|
'1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
|
|
70
|
+
'1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
|
|
71
|
+
'1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
@@ -160,6 +162,8 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
160
162
|
celebrateAfterMs: 2500, // ...but only for runs that lasted this long
|
|
161
163
|
chatterMs: 0, // 0 = she stays quiet when idle
|
|
162
164
|
careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
|
|
165
|
+
hungry: true, // 干饭:她饿了会说
|
|
166
|
+
hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
|
|
163
167
|
milestones: true, // 今日轮次到达里程碑时得意一下
|
|
164
168
|
memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
|
|
165
169
|
happyMs: 1100,
|
|
@@ -255,6 +259,14 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
255
259
|
'你最近常让我穿{s}。', '{s} —— 你好像挺喜欢这套。', '又是{s},我都快忘了别的了。',
|
|
256
260
|
],
|
|
257
261
|
/* 检查更新用的三句。前两句带结果,第三句是"查不了/没查成"。 */
|
|
262
|
+
/* 干饭。社区给她的设定里,白米饭是算力的唯一硬通货 —— 这是她最像"DeepSeek娘"
|
|
263
|
+
而不是随便一只桌宠的地方。台词照这个底色写。 */
|
|
264
|
+
eat: [
|
|
265
|
+
'白米饭才是硬通货。', '先干一碗,再想复杂的事。', '冰箱里能吃的?那整台冰箱都是我的便当盒。',
|
|
266
|
+
'才不是特意帮你,只是吃饱了顺手活动一下手指。', '比起深度推理,先来一碗香喷喷的白米饭。',
|
|
267
|
+
'算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
|
|
268
|
+
'这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
|
|
269
|
+
],
|
|
258
270
|
updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
|
|
259
271
|
updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
|
|
260
272
|
updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
|
|
@@ -323,6 +335,8 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
323
335
|
celebrateAfterMs: { kind: 'num', lo: 0, hi: 600000 },
|
|
324
336
|
chatterMs: { kind: 'num', lo: 0, hi: 3600000 },
|
|
325
337
|
careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
338
|
+
hungry: { kind: 'bool' },
|
|
339
|
+
hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
326
340
|
milestones: { kind: 'bool' },
|
|
327
341
|
memory: { kind: 'bool' },
|
|
328
342
|
happyMs: { kind: 'num', lo: 200, hi: 10000 },
|
|
@@ -551,6 +565,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
551
565
|
.wisp-tab img{width:100%;height:100%;object-fit:contain;object-position:bottom center;pointer-events:none}
|
|
552
566
|
@keyframes wisp-menu{0%{opacity:0;transform:scale(.94)}100%{opacity:1;transform:scale(1)}}
|
|
553
567
|
@keyframes wisp-say{0%{opacity:0;transform:translate(-50%,-100%) translateY(6px) scale(.94)}12%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}82%{opacity:1;transform:translate(-50%,-100%) translateY(0) scale(1)}100%{opacity:0;transform:translate(-50%,-100%) translateY(-6px) scale(.98)}}
|
|
568
|
+
.wisp-menu-item[data-group]{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
|
569
|
+
.wisp-menu-caret{opacity:.55;font-size:10px}
|
|
570
|
+
.wisp-menu-sub{padding-left:10px}
|
|
571
|
+
.wisp-menu-sub .wisp-menu-item{padding-left:12px;opacity:.94}
|
|
572
|
+
.wisp-dialog-backdrop{position:absolute;inset:0;background:rgba(6,12,26,.34);pointer-events:auto;animation:wisp-menu .16s ease}
|
|
573
|
+
.wisp-dialog{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:min(340px,calc(100vw - 28px));max-height:min(440px,calc(100vh - 36px));overflow:auto;padding:14px 14px 10px;border-radius:14px;font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-primary,var(--wisp-say-fg));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));box-shadow:0 18px 50px rgba(0,0,0,.34);-webkit-backdrop-filter:blur(16px) saturate(160%);backdrop-filter:blur(16px) saturate(160%);pointer-events:auto;animation:wisp-menu .16s ease}
|
|
574
|
+
.wisp-dialog-title{font-size:13.5px;font-weight:600;margin:0 22px 8px 0}
|
|
575
|
+
.wisp-dialog p,.wisp-dialog .wisp-dialog-p{margin:0 0 8px}
|
|
576
|
+
.wisp-dialog-x{position:absolute;right:8px;top:7px;width:22px;height:22px;border:0;border-radius:6px;background:none;color:inherit;font:inherit;font-size:15px;line-height:1;cursor:pointer;opacity:.7}
|
|
577
|
+
.wisp-dialog-x:hover{opacity:1;background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
|
|
578
|
+
.wisp-dialog-foot{display:flex;justify-content:space-between;gap:10px;margin-top:8px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));font-size:11px;opacity:.72}
|
|
579
|
+
.wisp-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin:2px 0 6px}
|
|
580
|
+
.wisp-cell{display:flex;flex-direction:column;align-items:center;gap:2px;padding:4px 2px 5px;border:1px solid transparent;border-radius:9px;background:none;color:inherit;font:inherit;font-size:10.5px;cursor:pointer}
|
|
581
|
+
.wisp-cell:hover{border-color:var(--dsw-alias-border-l1,var(--wisp-say-line));background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.18))}
|
|
582
|
+
.wisp-cell img{width:100%;height:52px;object-fit:contain;object-position:bottom center;pointer-events:none}
|
|
583
|
+
.wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
|
|
554
584
|
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
|
|
555
585
|
|
|
556
586
|
const installStyles = (styles) => {
|
|
@@ -605,6 +635,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
605
635
|
return out
|
|
606
636
|
}
|
|
607
637
|
|
|
638
|
+
/* 经 window 取 Image,而不是裸的全局名:这个半包在壳里由 new Function 求值,
|
|
639
|
+
裸名解析到的是**外层的全局作用域**,那里未必有 Image —— 实测在预检环境里就是
|
|
640
|
+
`TypeError: Image is not a constructor`(点动作预览换到新精灵图键时才踩出来)。
|
|
641
|
+
这个文件读时钟、读性能也是同样的规矩:一切经 window。
|
|
642
|
+
两处用它:命中蒙版、以及换皮肤后的预热。 */
|
|
643
|
+
const imageCtor = () => {
|
|
644
|
+
if (typeof window !== 'undefined' && window && typeof window.Image === 'function') return window.Image
|
|
645
|
+
if (typeof Image === 'function') return Image
|
|
646
|
+
return null
|
|
647
|
+
}
|
|
648
|
+
|
|
608
649
|
const buildMask = (key, url) => {
|
|
609
650
|
if (masks[key]) return
|
|
610
651
|
try {
|
|
@@ -614,7 +655,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
614
655
|
if (!ctx || typeof ctx.drawImage !== 'function' || typeof ctx.getImageData !== 'function') return
|
|
615
656
|
canvas.width = MASK_W
|
|
616
657
|
canvas.height = MASK_H
|
|
617
|
-
const
|
|
658
|
+
const ImageCtor = imageCtor()
|
|
659
|
+
if (ImageCtor === null) return
|
|
660
|
+
const probe = new ImageCtor()
|
|
618
661
|
probe.onload = () => {
|
|
619
662
|
try {
|
|
620
663
|
ctx.drawImage(probe, 0, 0, MASK_W, MASK_H)
|
|
@@ -1081,7 +1124,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1081
1124
|
/* ---- mood -> sprite ----
|
|
1082
1125
|
attn 曾借用 work 那张,现在有自己的一张(抬手招你过来的姿态)。
|
|
1083
1126
|
仍然保留回退:某一套皮肤将来若没有 attn,她会退回 work,而不是变成空白。 */
|
|
1084
|
-
const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy', attn: 'attn', care: 'attn', poked: 'poked', proud: 'proud', worried: 'sleepy' }
|
|
1127
|
+
const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy', attn: 'attn', care: 'attn', poked: 'poked', proud: 'proud', worried: 'sleepy', eat: 'eat' }
|
|
1085
1128
|
const SPRITE_FALLBACK = { attn: 'work' }
|
|
1086
1129
|
let mood = 'idle'
|
|
1087
1130
|
|
|
@@ -1295,6 +1338,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1295
1338
|
let activeSince = 0
|
|
1296
1339
|
let lastActiveAt = 0
|
|
1297
1340
|
let lastCareAt = 0
|
|
1341
|
+
/* 上一顿饭的时间。起手是 0,所以计时以 activeSince 为准 —— 见下面 mealBase。 */
|
|
1342
|
+
let lastMealAt = 0
|
|
1298
1343
|
let happyHandle = null
|
|
1299
1344
|
let lastErrorCount = 0
|
|
1300
1345
|
|
|
@@ -1334,6 +1379,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1334
1379
|
pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
|
|
1335
1380
|
}
|
|
1336
1381
|
|
|
1382
|
+
/* 干饭用的也是瞬时状态(借 eat 的姿态)。比"提醒你活动"长一点 ——
|
|
1383
|
+
吃饭本来就慢。 */
|
|
1384
|
+
const EAT_MS = 5200
|
|
1385
|
+
let eatHandle = null
|
|
1386
|
+
const flashEat = (text) => {
|
|
1387
|
+
if (mood === 'sleep') wake(true)
|
|
1388
|
+
setMood('eat')
|
|
1389
|
+
say(text)
|
|
1390
|
+
if (eatHandle) clock.cancel(eatHandle)
|
|
1391
|
+
eatHandle = clock.after(EAT_MS, () => { if (mood === 'eat') setMood('idle') })
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1337
1394
|
/* 久坐提醒用的也是一个**瞬时状态**(借 attn 的姿态),理由和 poked 一样:
|
|
1338
1395
|
稳态逻辑每轮都会重算,不给它自己的定时器就会被抹掉。 */
|
|
1339
1396
|
const PROUD_MS = 2800
|
|
@@ -1410,6 +1467,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1410
1467
|
flashCare(pick(LINES.care))
|
|
1411
1468
|
}
|
|
1412
1469
|
|
|
1470
|
+
/* ---- 干饭 -------------------------------------------------------------
|
|
1471
|
+
和久坐共用同一套"还在动"的判定,但含义相反:久坐是提醒你起来,
|
|
1472
|
+
干饭是提醒她自己该补碳了。计时基准取 lastMealAt 与 activeSince 里更晚的那个 ——
|
|
1473
|
+
离开很久回来之后,不该立刻就说饿(那一段已经结束了)。 */
|
|
1474
|
+
const mealBase = Math.max(lastMealAt, activeSince)
|
|
1475
|
+
if (
|
|
1476
|
+
cfg.hungry && cfg.hungerMs > 0 && activeSince !== 0
|
|
1477
|
+
&& nowTick - mealBase >= cfg.hungerMs
|
|
1478
|
+
) {
|
|
1479
|
+
lastMealAt = nowTick
|
|
1480
|
+
flashEat(pick(LINES.eat))
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1413
1483
|
// 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
|
|
1414
1484
|
const pending = cfg.reactions ? readPending() : null
|
|
1415
1485
|
if (pending === null) {
|
|
@@ -1426,7 +1496,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1426
1496
|
|
|
1427
1497
|
// 临时状态(happy / worried)在自己的计时器到期前不能被稳态赋值覆盖 ——
|
|
1428
1498
|
// 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
|
|
1429
|
-
if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
|
|
1499
|
+
if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
|
|
1500
|
+
|| mood === 'eat' || mood === 'worried') return
|
|
1501
|
+
/* 动作预览期间也要挡住稳态赋值,否则刚摆的表情在同一轮里被算回去 */
|
|
1502
|
+
if (nowMs() < previewUntil) return
|
|
1430
1503
|
setMood((busy || typing) ? 'alert' : 'idle')
|
|
1431
1504
|
}
|
|
1432
1505
|
|
|
@@ -1682,6 +1755,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1682
1755
|
const MENU_PAD = 10
|
|
1683
1756
|
let menuEl = null
|
|
1684
1757
|
let menuOff = null
|
|
1758
|
+
/* 折叠分组要"在原地重开",所以记住这次的落点 */
|
|
1759
|
+
let menuAtX = 0
|
|
1760
|
+
let menuAtY = 0
|
|
1685
1761
|
|
|
1686
1762
|
const closeMenu = () => {
|
|
1687
1763
|
if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
|
|
@@ -1721,45 +1797,204 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1721
1797
|
{ key: 'wander', label: '自己溜达' },
|
|
1722
1798
|
{ key: 'reactions', label: '跟随状态' },
|
|
1723
1799
|
{ key: 'celebrate', label: '跑完撒花' },
|
|
1800
|
+
{ key: 'hungry', label: '饿了会说话' },
|
|
1724
1801
|
]
|
|
1725
1802
|
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1803
|
+
/* ---- 弹窗 ----------------------------------------------------------------
|
|
1804
|
+
菜单是"做事"的地方,不是"看东西"的地方:19 个同级条目,谁也找不到谁。
|
|
1805
|
+
所以顶层只留动作,"她是谁"和"她有哪些动作"放进弹窗 —— 关掉即从 DOM 移除,
|
|
1806
|
+
不常驻、不参与轮询。 */
|
|
1807
|
+
let dialogEl = null
|
|
1808
|
+
let dialogKind = null
|
|
1809
|
+
let dialogKeyOff = null
|
|
1810
|
+
let activeGroup = ''
|
|
1811
|
+
/* 预览锁:动作预览要摆出某个表情给你看,而稳态轮询每一轮都会把它算回去。
|
|
1812
|
+
用一个到期时间挡住轮询,比给每个情绪再加一个瞬时标记干净。 */
|
|
1813
|
+
let previewUntil = 0
|
|
1814
|
+
let previewHandle = null
|
|
1815
|
+
|
|
1816
|
+
const closeDialog = () => {
|
|
1817
|
+
if (dialogEl && dialogEl.parentNode) dialogEl.parentNode.removeChild(dialogEl)
|
|
1818
|
+
dialogEl = null
|
|
1819
|
+
dialogKind = null
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/* 弹窗不抢焦点,所以 Esc 只能靠一个常驻监听 —— 而且必须是**一个**:
|
|
1823
|
+
每次开弹窗都挂一个新的会越积越多,引用还拿不回来,销毁时摘不掉。 */
|
|
1824
|
+
const onDialogKey = (ev) => {
|
|
1825
|
+
if (ev.key !== 'Escape' || dialogEl === null) return
|
|
1826
|
+
ev.preventDefault()
|
|
1827
|
+
closeDialog()
|
|
1828
|
+
focusHer()
|
|
1829
|
+
}
|
|
1830
|
+
/* 文档级监听:真 document 一定有,但这里仍然判断一下 ——
|
|
1831
|
+
"环境少一个方法"不该让整个插件起不来(预检的替身就少了它)。 */
|
|
1832
|
+
if (typeof document.addEventListener === 'function') {
|
|
1833
|
+
document.addEventListener('keydown', onDialogKey, true)
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
const para = (text) => {
|
|
1837
|
+
const p = document.createElement('p')
|
|
1838
|
+
p.className = 'wisp-dialog-p'
|
|
1839
|
+
p.textContent = text
|
|
1840
|
+
return p
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const dialogFoot = (left, right) => {
|
|
1844
|
+
const row = document.createElement('div')
|
|
1845
|
+
row.className = 'wisp-dialog-foot'
|
|
1846
|
+
const a = document.createElement('span')
|
|
1847
|
+
/* 两个 span 各带类名:真 DOM 从父节点就能读到汇总文本,
|
|
1848
|
+
简化 DOM 不能 —— 带类名意味着两边都能定位到这段文字。 */
|
|
1849
|
+
a.className = 'wisp-dialog-foot-l'
|
|
1850
|
+
a.textContent = left
|
|
1851
|
+
const b = document.createElement('span')
|
|
1852
|
+
b.className = 'wisp-dialog-foot-r'
|
|
1853
|
+
b.textContent = right
|
|
1854
|
+
row.appendChild(a)
|
|
1855
|
+
row.appendChild(b)
|
|
1856
|
+
return row
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const openDialog = (kind, title, rows) => {
|
|
1860
|
+
closeMenu()
|
|
1861
|
+
closeDialog()
|
|
1862
|
+
const back = document.createElement('div')
|
|
1863
|
+
back.className = 'wisp-dialog-backdrop'
|
|
1864
|
+
back.addEventListener('pointerdown', () => { closeDialog(); focusHer() })
|
|
1865
|
+
const card = document.createElement('div')
|
|
1866
|
+
card.className = 'wisp-dialog'
|
|
1867
|
+
card.setAttribute('role', 'dialog')
|
|
1868
|
+
card.setAttribute('aria-modal', 'true')
|
|
1869
|
+
card.setAttribute('aria-label', title)
|
|
1870
|
+
card.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
1871
|
+
const head = document.createElement('div')
|
|
1872
|
+
head.className = 'wisp-dialog-title'
|
|
1873
|
+
head.textContent = title
|
|
1874
|
+
card.appendChild(head)
|
|
1875
|
+
const x = document.createElement('button')
|
|
1876
|
+
x.className = 'wisp-dialog-x'
|
|
1877
|
+
x.setAttribute('type', 'button')
|
|
1878
|
+
x.setAttribute('aria-label', '关闭')
|
|
1879
|
+
x.textContent = '×'
|
|
1880
|
+
x.addEventListener('click', (ev) => { ev.preventDefault(); closeDialog(); focusHer() })
|
|
1881
|
+
card.appendChild(x)
|
|
1882
|
+
for (let i = 0; i < rows.length; i++) card.appendChild(rows[i])
|
|
1883
|
+
back.appendChild(card)
|
|
1884
|
+
layer.appendChild(back)
|
|
1885
|
+
dialogEl = back
|
|
1886
|
+
dialogKind = kind
|
|
1887
|
+
return card
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/* 八个动作。key 是精灵图名,mood 是运行时该摆的状态 —— 两者不一样:
|
|
1891
|
+
"干活中"的图叫 work,但状态是 alert;"待机"两者都是 idle。 */
|
|
1892
|
+
const ACTION_ROWS = [
|
|
1893
|
+
{ key: 'idle', mood: 'idle', label: '待机', say: '平时就是这样。' },
|
|
1894
|
+
{ key: 'happy', mood: 'happy', label: '开心', say: '嗯,开心。' },
|
|
1895
|
+
{ key: 'sleepy', mood: 'sleep', label: '打盹', say: '困了……' },
|
|
1896
|
+
{ key: 'work', mood: 'alert', label: '干活中', say: '在跑呢,别催。' },
|
|
1897
|
+
{ key: 'attn', mood: 'attn', label: '有动静', say: '我注意到了。' },
|
|
1898
|
+
{ key: 'poked', mood: 'poked', label: '被戳', say: '干嘛。' },
|
|
1899
|
+
{ key: 'proud', mood: 'proud', label: '得意', say: '跑完了,不错吧。' },
|
|
1900
|
+
{ key: 'eat', mood: 'eat', label: '干饭', say: '先吃一碗。' },
|
|
1901
|
+
]
|
|
1902
|
+
|
|
1903
|
+
const previewMood = (key) => {
|
|
1904
|
+
const row = ACTION_ROWS.find((r) => r.key === key)
|
|
1905
|
+
if (!row) return false
|
|
1906
|
+
closeDialog()
|
|
1907
|
+
/* 被点掉的那个格子随弹窗一起消失了,焦点必须有人接 —— 否则掉进 <body>。 */
|
|
1908
|
+
focusHer()
|
|
1909
|
+
previewUntil = nowMs() + 6000
|
|
1910
|
+
setMood(row.mood)
|
|
1911
|
+
/* 预览必须**自己有到期时间**:预览的姿势可能在"稳态轮询不许覆盖"的名单里
|
|
1912
|
+
(干饭就在),光靠 previewUntil 挡住轮询的话,锁一过期她就被永久卡在那个表情上。
|
|
1913
|
+
到点主动交还稳态,之后该是什么由轮询说了算。 */
|
|
1914
|
+
if (previewHandle) clock.cancel(previewHandle)
|
|
1915
|
+
previewHandle = clock.after(6200, () => { setMood('idle') })
|
|
1916
|
+
say(row.say)
|
|
1917
|
+
return true
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const actionsDialog = () => {
|
|
1921
|
+
const grid = document.createElement('div')
|
|
1922
|
+
grid.className = 'wisp-grid'
|
|
1923
|
+
for (const row of ACTION_ROWS) {
|
|
1924
|
+
const cell = document.createElement('button')
|
|
1925
|
+
cell.className = 'wisp-cell'
|
|
1926
|
+
cell.setAttribute('type', 'button')
|
|
1927
|
+
cell.setAttribute('aria-label', '预览' + row.label)
|
|
1928
|
+
cell.dataset.mood = row.key
|
|
1929
|
+
const im = document.createElement('img')
|
|
1930
|
+
im.setAttribute('alt', '')
|
|
1931
|
+
im.src = resolveSprite(spriteOf(row.key))
|
|
1932
|
+
const cap = document.createElement('span')
|
|
1933
|
+
cap.textContent = row.label
|
|
1934
|
+
cell.appendChild(im)
|
|
1935
|
+
cell.appendChild(cap)
|
|
1936
|
+
cell.addEventListener('click', (ev) => { ev.preventDefault(); previewMood(row.key) })
|
|
1937
|
+
grid.appendChild(cell)
|
|
1759
1938
|
}
|
|
1760
|
-
return
|
|
1939
|
+
return openDialog('actions', '动作一览', [
|
|
1940
|
+
para('点一个动作,她就当场做给你看。(图都是当前皮肤 ' + skinLabel(activeSkin) + ')'),
|
|
1941
|
+
grid,
|
|
1942
|
+
dialogFoot('共 ' + ACTION_ROWS.length + ' 个动作', '按 Esc 关闭'),
|
|
1943
|
+
])
|
|
1761
1944
|
}
|
|
1762
1945
|
|
|
1946
|
+
const aboutDialog = () => {
|
|
1947
|
+
const note = WHATS_NEW[VERSION]
|
|
1948
|
+
return openDialog('about', '关于她', [
|
|
1949
|
+
para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
|
|
1950
|
+
para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
|
|
1951
|
+
para('白米饭是算力的硬通货 —— 所以她每隔一阵会捧碗出来补点碳水。'),
|
|
1952
|
+
para('这一版:' + (note || VERSION)),
|
|
1953
|
+
para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
|
|
1954
|
+
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
1955
|
+
])
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
/* 顶层只放"做什么",外观/行为/位置收进可折叠的分组 —— 19 个同级条目等于没有结构。 */
|
|
1959
|
+
const menuItems = () => [
|
|
1960
|
+
{ label: '关于她…', run: () => aboutDialog() },
|
|
1961
|
+
{ label: '动作一览…', run: () => actionsDialog() },
|
|
1962
|
+
{
|
|
1963
|
+
group: '外观',
|
|
1964
|
+
children: [
|
|
1965
|
+
...SKIN_IDS.map((id) => ({
|
|
1966
|
+
label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
|
|
1967
|
+
skin: id,
|
|
1968
|
+
run: () => {
|
|
1969
|
+
if (id === activeSkin) return
|
|
1970
|
+
applySkin(id)
|
|
1971
|
+
say('换成' + skinLabel(id) + '了。')
|
|
1972
|
+
},
|
|
1973
|
+
})),
|
|
1974
|
+
{ label: '放大一点', run: () => applyConfig({ size: cfg.size + 0.5 }) },
|
|
1975
|
+
{ label: '缩小一点', run: () => applyConfig({ size: cfg.size - 0.5 }) },
|
|
1976
|
+
],
|
|
1977
|
+
},
|
|
1978
|
+
{
|
|
1979
|
+
group: '行为',
|
|
1980
|
+
children: [
|
|
1981
|
+
{ label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
1982
|
+
{ label: '睡一会儿', run: () => setMood('sleep') },
|
|
1983
|
+
...TOGGLES.map((t) => ({
|
|
1984
|
+
label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
|
|
1985
|
+
run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
|
|
1986
|
+
})),
|
|
1987
|
+
],
|
|
1988
|
+
},
|
|
1989
|
+
{
|
|
1990
|
+
group: '位置',
|
|
1991
|
+
children: CORNERS.map((c) => ({ label: c.label, run: () => goCorner(c.label) })),
|
|
1992
|
+
},
|
|
1993
|
+
{ label: '版本与更新', run: () => showUpdate() },
|
|
1994
|
+
/* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
|
|
1995
|
+
{ label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
|
|
1996
|
+
]
|
|
1997
|
+
|
|
1763
1998
|
/* ---- 菜单的键盘驱动 ----
|
|
1764
1999
|
菜单已经声明了 role="menu",没有键盘支持的话,对屏幕阅读器来说比不声明更糟 ——
|
|
1765
2000
|
它宣布了一个你无法操作的菜单。采用 roving tabindex:只有当前项 tabindex=0,
|
|
@@ -1790,7 +2025,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1790
2025
|
}
|
|
1791
2026
|
}
|
|
1792
2027
|
|
|
1793
|
-
|
|
2028
|
+
/* keepGroup:点分组是"在原地重开",得把刚设好的分组带过去。
|
|
2029
|
+
其他入口都是新手势,一律回到收起状态 —— 所以这个重置放在这里,
|
|
2030
|
+
而不是 closeMenu 里(放那里会让展开动作把自己清掉)。 */
|
|
2031
|
+
const openMenu = (x, y, keepGroup) => {
|
|
2032
|
+
menuAtX = x
|
|
2033
|
+
menuAtY = y
|
|
2034
|
+
if (keepGroup !== true) activeGroup = ''
|
|
1794
2035
|
closeMenu()
|
|
1795
2036
|
stopGlide()
|
|
1796
2037
|
const items = menuItems()
|
|
@@ -1799,16 +2040,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1799
2040
|
menu.className = 'wisp-menu'
|
|
1800
2041
|
menu.setAttribute('role', 'menu')
|
|
1801
2042
|
menu.style.position = 'absolute'
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
if (spec.header !== undefined) {
|
|
1805
|
-
// 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
|
|
1806
|
-
const head = document.createElement('div')
|
|
1807
|
-
head.className = 'wisp-menu-head'
|
|
1808
|
-
head.textContent = spec.header
|
|
1809
|
-
menu.appendChild(head)
|
|
1810
|
-
continue
|
|
1811
|
-
}
|
|
2043
|
+
let heads = 0
|
|
2044
|
+
const addItem = (spec, sub) => {
|
|
1812
2045
|
const btn = document.createElement('button')
|
|
1813
2046
|
btn.className = 'wisp-menu-item'
|
|
1814
2047
|
btn.setAttribute('type', 'button')
|
|
@@ -1824,14 +2057,71 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1824
2057
|
})
|
|
1825
2058
|
menu.appendChild(btn)
|
|
1826
2059
|
menuButtons.push(btn)
|
|
2060
|
+
return btn
|
|
2061
|
+
}
|
|
2062
|
+
for (let i = 0; i < items.length; i++) {
|
|
2063
|
+
const spec = items[i]
|
|
2064
|
+
if (spec.header !== undefined) {
|
|
2065
|
+
// 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
|
|
2066
|
+
const head = document.createElement('div')
|
|
2067
|
+
head.className = 'wisp-menu-head'
|
|
2068
|
+
head.textContent = spec.header
|
|
2069
|
+
menu.appendChild(head)
|
|
2070
|
+
heads++
|
|
2071
|
+
continue
|
|
2072
|
+
}
|
|
2073
|
+
if (spec.group !== undefined) {
|
|
2074
|
+
/* 折叠分组:一行标题,点一下在原地展开/收起(重开同一个坐标,不是新开一个菜单)。 */
|
|
2075
|
+
const open = activeGroup === spec.group
|
|
2076
|
+
const btn = document.createElement('button')
|
|
2077
|
+
btn.className = 'wisp-menu-item'
|
|
2078
|
+
btn.setAttribute('type', 'button')
|
|
2079
|
+
btn.setAttribute('role', 'menuitem')
|
|
2080
|
+
btn.setAttribute('tabindex', '-1')
|
|
2081
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false')
|
|
2082
|
+
btn.dataset.group = spec.group
|
|
2083
|
+
btn.dataset.open = open ? 'true' : 'false'
|
|
2084
|
+
/* 文字直接写在按钮上,不拆成子 span:真 DOM 会汇总子节点的 textContent,
|
|
2085
|
+
但简化 DOM(预检的替身)不会 —— 依赖那种汇总会让分组名在替身里变成空串。 */
|
|
2086
|
+
btn.textContent = (open ? '▾ ' : '▸ ') + spec.group
|
|
2087
|
+
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2088
|
+
btn.addEventListener('click', (ev) => {
|
|
2089
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
2090
|
+
activeGroup = open ? '' : spec.group
|
|
2091
|
+
openMenu(menuAtX, menuAtY, true)
|
|
2092
|
+
})
|
|
2093
|
+
menu.appendChild(btn)
|
|
2094
|
+
menuButtons.push(btn)
|
|
2095
|
+
if (!open) continue
|
|
2096
|
+
const wrap = document.createElement('div')
|
|
2097
|
+
wrap.className = 'wisp-menu-sub'
|
|
2098
|
+
for (const child of spec.children) {
|
|
2099
|
+
const cb = document.createElement('button')
|
|
2100
|
+
cb.className = 'wisp-menu-item'
|
|
2101
|
+
cb.setAttribute('type', 'button')
|
|
2102
|
+
cb.setAttribute('role', 'menuitem')
|
|
2103
|
+
cb.setAttribute('tabindex', '-1')
|
|
2104
|
+
cb.textContent = child.label
|
|
2105
|
+
cb.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
2106
|
+
cb.addEventListener('click', (ev) => {
|
|
2107
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
2108
|
+
closeMenu()
|
|
2109
|
+
try { child.run() } catch (err) { /* 同上 */ }
|
|
2110
|
+
})
|
|
2111
|
+
wrap.appendChild(cb)
|
|
2112
|
+
menuButtons.push(cb)
|
|
2113
|
+
}
|
|
2114
|
+
menu.appendChild(wrap)
|
|
2115
|
+
continue
|
|
2116
|
+
}
|
|
2117
|
+
addItem(spec, false)
|
|
1827
2118
|
}
|
|
1828
2119
|
// 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
|
|
1829
2120
|
layer.appendChild(menu)
|
|
1830
2121
|
menuEl = menu
|
|
1831
2122
|
const w = MENU_W
|
|
1832
|
-
//
|
|
1833
|
-
|
|
1834
|
-
for (const it of items) hs += it.header !== undefined ? 22 : MENU_ITEM_H
|
|
2123
|
+
// 高度只用于夹取落点,所以按**实际渲染出来的行数**估算即可(假 DOM 里没有布局可测)
|
|
2124
|
+
const hs = MENU_PAD + heads * 22 + menuButtons.length * MENU_ITEM_H
|
|
1835
2125
|
menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
|
|
1836
2126
|
menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
|
|
1837
2127
|
|
|
@@ -1927,9 +2217,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1927
2217
|
/* warm the other sprites after the first frame so mood changes never blink */
|
|
1928
2218
|
clock.after(1500, () => {
|
|
1929
2219
|
if (destroyed) return
|
|
2220
|
+
/* 预热其余的精灵图。没有 Image 构造函数的地方(简化 DOM)就跳过,
|
|
2221
|
+
预热本来就是锦上添花,不该把整只宠物带崩 —— 这里原来硬用裸的 new Image()。 */
|
|
2222
|
+
const ImageCtor = imageCtor()
|
|
2223
|
+
if (ImageCtor === null) return
|
|
1930
2224
|
for (const key in SPRITES[activeSkin]) {
|
|
1931
2225
|
if (key === currentSprite) continue
|
|
1932
|
-
const pre = new
|
|
2226
|
+
const pre = new ImageCtor()
|
|
1933
2227
|
pre.src = resolveSprite(spriteOf(key))
|
|
1934
2228
|
}
|
|
1935
2229
|
})
|
|
@@ -2088,6 +2382,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2088
2382
|
setSkin: applySkin,
|
|
2089
2383
|
showUpdate,
|
|
2090
2384
|
checkForUpdate,
|
|
2385
|
+
/* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
|
|
2386
|
+
openAbout: () => aboutDialog(),
|
|
2387
|
+
openActions: () => actionsDialog(),
|
|
2388
|
+
closeDialog,
|
|
2389
|
+
previewMood,
|
|
2390
|
+
get dialog() { return dialogKind },
|
|
2391
|
+
get menuGroup() { return activeGroup },
|
|
2091
2392
|
get updateCheck() { return { ...updateCheck } },
|
|
2092
2393
|
get hidden() { return hidden },
|
|
2093
2394
|
hide: () => setHidden(true),
|
|
@@ -2198,6 +2499,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2198
2499
|
lastCheck: { ...updateCheck },
|
|
2199
2500
|
why: '宿主半包在 vm 沙箱里,harness 只有 defineTool/registerTool/handle,Node API 被 nodeApiTraps 挡住',
|
|
2200
2501
|
},
|
|
2502
|
+
/* 干饭:不用猜"她为什么突然捧碗" */
|
|
2503
|
+
hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
|
|
2201
2504
|
/* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
|
|
2202
2505
|
runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
|
|
2203
2506
|
/* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
|
|
@@ -2244,6 +2547,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2244
2547
|
bodyEl.removeEventListener('keydown', onBodyKey)
|
|
2245
2548
|
bodyEl.removeEventListener('contextmenu', onContext)
|
|
2246
2549
|
closeMenu()
|
|
2550
|
+
closeDialog()
|
|
2551
|
+
if (dialogKeyOff) { dialogKeyOff(); dialogKeyOff = null }
|
|
2247
2552
|
hideTab()
|
|
2248
2553
|
if (typeof offTheme === 'function') {
|
|
2249
2554
|
try { offTheme() } catch (e) { /* ignore */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wisp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
package/tools/assets.mjs
CHANGED
|
@@ -45,7 +45,7 @@ const flag = (name, fallback) => {
|
|
|
45
45
|
const at = argv.indexOf(name)
|
|
46
46
|
return at >= 0 && argv[at + 1] !== undefined ? argv[at + 1] : fallback
|
|
47
47
|
}
|
|
48
|
-
const MOODS = ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud']
|
|
48
|
+
const MOODS = ['idle', 'happy', 'sleepy', 'work', 'attn', 'poked', 'proud', 'eat']
|
|
49
49
|
const srcDir = resolve(here, flag('--from', 'masters'))
|
|
50
50
|
/* 皮肤 = assets/ 下的一个子目录。--skin 决定输出到哪一套。
|
|
51
51
|
注意 build.mjs 会把每个子目录当成一套皮肤打进包里,所以皮肤越多包越大。 */
|