dsh-wisp 1.28.0 → 1.30.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 +65 -0
- package/lib/client.js +258 -4
- package/lib/client.template.js +258 -4
- package/package.json +1 -1
- package/verify-wisp.mjs +328 -4
package/README.md
CHANGED
|
@@ -448,6 +448,71 @@ node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登
|
|
|
448
448
|
|
|
449
449
|
## 变更
|
|
450
450
|
|
|
451
|
+
### 1.30.0
|
|
452
|
+
|
|
453
|
+
这一版是**功能**,不是台词。
|
|
454
|
+
|
|
455
|
+
- **专注计时器。** 桌面陪伴插件最该有的实用功能:替你画出一段时间的边界。
|
|
456
|
+
|
|
457
|
+
- 菜单「行为」组里「**专注 25 分钟**」/「**专注 45 分钟**」(默认时长 `focusMinutes`),
|
|
458
|
+
专注中多一条「**结束专注**」(平时不出现 —— 菜单里不该有按不动的条目);
|
|
459
|
+
- 专注期间**她保持"干活中"的姿势**:稳态轮询不许把她挪走 ✓。这条 hold 放在久坐 / 干饭 /
|
|
460
|
+
劝睡**之后** —— 那三件事该响还得响,只是姿势归专注管;
|
|
461
|
+
- **戳她一下 = 问还剩多久**(*"还有 23 分钟呢,盯着屏幕。"*),而且不计入"连戳",
|
|
462
|
+
不会升级成抗议;
|
|
463
|
+
- 到点她换 `proud` 说收工(*"这一轮 25 分钟,结束了。"*),`doctor().focus.done` 累计完成次数;
|
|
464
|
+
- 她**不碰应用**:不改输入框、不发消息、不点任何按钮 —— 只陪着 + 计时。
|
|
465
|
+
|
|
466
|
+
- **页面藏起来就安静。** 挂 `visibilitychange`:藏起来时停掉**轮询 / 絮叨 / 踱步**三个定时器,
|
|
467
|
+
回来再按配置重新挂上。没人在看的时候不该空转 CPU(顺带省电)。
|
|
468
|
+
|
|
469
|
+
- 这不是"少做点事"而是"诚实":藏起来期间 DOM 再吵她也不该有反应 —— 测试就是这么断言的
|
|
470
|
+
(藏起来时来三个报错她一声不吭;回来之后同一批报错立刻被看见);
|
|
471
|
+
- 恢复**先于**问候:先保证活着,再谈礼貌;
|
|
472
|
+
- `doctor().timers` 报告 `{ poll, chatter, wander, paused }`。
|
|
473
|
+
|
|
474
|
+
- 预检 451 → **473 项**。
|
|
475
|
+
|
|
476
|
+
- 记一个测试教训:戳她要用**既有的那套姿势**(`pointerdown` 落在 `.wisp-body`、
|
|
477
|
+
`pointerup` 落在 `window`、坐标要在她身上)—— 我第一版两个事件都发到 `root` 上,
|
|
478
|
+
她当然没反应,白查了一轮。
|
|
479
|
+
|
|
480
|
+
### 1.29.0
|
|
481
|
+
|
|
482
|
+
### 1.29.0
|
|
483
|
+
|
|
484
|
+
三件和"她真的陪着你"有关的事。都不需要新素材,全是行为 + 台词。
|
|
485
|
+
|
|
486
|
+
- **夜深了她会劝你去睡。** 过了 `bedtimeHour`(默认 **23**)她换一种口气,过了入睡**两小时**
|
|
487
|
+
换成"嘴硬心软"那一档 —— 后者是社区设定里她的底色(*"我不管你,但……真的该睡了。"*、
|
|
488
|
+
*"才不是担心你,只是我不想半夜还看着屏幕。"*、*"你要是倒下,我可没有备份。"*)。
|
|
489
|
+
|
|
490
|
+
- **作息是可配的**(`bedtimeHour` / `wakeHour`,默认 23 → 5,跨零点)——
|
|
491
|
+
这既是真功能(各人作息不同),也让测试**不必伪造时钟**:把窗口套在当前那个小时上,
|
|
492
|
+
该响就得响,套偏一小时就该安静;
|
|
493
|
+
- 深度档按**入睡后经过的小时数**算,不是按"小时落在某个绕圈区间里"——
|
|
494
|
+
后者在窄窗口下会把窗外一大段时间也算成深夜(写测试时当场撞上:13 点睡、14 点起,
|
|
495
|
+
结果 13 点自己被判成"深度深夜");
|
|
496
|
+
- 菜单「行为」组里多了「**深夜劝睡**」开关,另有 `nightMs`(默认 30 分钟一次;0 = 关)。
|
|
497
|
+
|
|
498
|
+
- **你离开又回来,她记得。** 挂 `visibilitychange` 记住离开的时刻,回来按时长分两档:
|
|
499
|
+
短走一句(*"回来了。"*、*"位置我没动。"*)、长走(15 分钟以上)另一句
|
|
500
|
+
(*"你走了好久。"*、*"我还以为你把我关了。"*)。阈值 `backAfterMs`,默认 2 分钟,0 = 关。
|
|
501
|
+
|
|
502
|
+
- **连续天数。** 从今天往回数,记忆里一天不落有多长 —— 第 2 天起她会提
|
|
503
|
+
(*"我们已经连着见了 4 天。"*),到 **7 / 30 / 100 / 365** 天换一句里程碑。
|
|
504
|
+
断了就回到 1,而且**一句都不说**("连续第 1 天"很怪)。`doctor().streak` 可查。
|
|
505
|
+
|
|
506
|
+
- `doctor()` 新增 `night`(窗口 / 深度档起点 / 当前小时 / 上次提醒时间)、`streak`、`awaySince`
|
|
507
|
+
—— 不用猜"她为什么突然劝我睡觉"。
|
|
508
|
+
|
|
509
|
+
- 预检 435 → **451 项**。
|
|
510
|
+
|
|
511
|
+
- 顺手记一个测试教训:气泡只活几秒,**"推进完再看"必然看不到** ——
|
|
512
|
+
久坐提醒那节早就踩过,这次又踩了一遍。现在统一用 `watch(h, ms)` 边推进边收集。
|
|
513
|
+
|
|
514
|
+
### 1.28.0
|
|
515
|
+
|
|
451
516
|
### 1.28.0
|
|
452
517
|
|
|
453
518
|
- **菜单不再是 19 个同级条目。** 原来 6 个动作 + 4 个开关 + 4 个角落 + 5 套皮肤全平铺,
|
package/lib/client.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.30.0'
|
|
55
55
|
|
|
56
56
|
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
57
|
忘了更新就会红,不会静默过期。 */
|
|
@@ -69,6 +69,8 @@ const WHATS_NEW = {
|
|
|
69
69
|
'1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
|
|
70
70
|
'1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
|
|
71
71
|
'1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
|
|
72
|
+
'1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
|
|
73
|
+
'1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
@@ -215,6 +217,12 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
215
217
|
careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
|
|
216
218
|
hungry: true, // 干饭:她饿了会说
|
|
217
219
|
hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
|
|
220
|
+
focusMinutes: 25, // 菜单里「专注 N 分钟」用的默认时长
|
|
221
|
+
night: true, // 深夜劝睡
|
|
222
|
+
bedtimeHour: 23, // 几点算深夜(0-23)
|
|
223
|
+
wakeHour: 5, // 几点算天亮(0-23);bedtime > wake 时窗口跨零点
|
|
224
|
+
nightMs: 1800000, // 深夜每隔这么久说一句(30 分钟;0 = 关)
|
|
225
|
+
backAfterMs: 120000, // 离开超过这么久,回来时她会说一句(2 分钟;0 = 关)
|
|
218
226
|
milestones: true, // 今日轮次到达里程碑时得意一下
|
|
219
227
|
memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
|
|
220
228
|
happyMs: 1100,
|
|
@@ -318,6 +326,44 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
318
326
|
'算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
|
|
319
327
|
'这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
|
|
320
328
|
],
|
|
329
|
+
/* 专注计时器。她是陪着你干活的,所以"替你画出一段时间的边界"比再多说几句更实在。 */
|
|
330
|
+
focusStart: [
|
|
331
|
+
'{n} 分钟。开始吧。', '好,{n} 分钟,我盯着你。', '计时开始,{n} 分钟。', '{n} 分钟不看别的。',
|
|
332
|
+
'这段时间归你了。{n} 分钟。',
|
|
333
|
+
],
|
|
334
|
+
focusDone: [
|
|
335
|
+
'{n} 分钟到了。收工。', '{n} 分钟,干完了?', '时间到。{n} 分钟,去喝口水。', '{n} 分钟到了 —— 站起来伸个懒腰。',
|
|
336
|
+
'这一轮 {n} 分钟,结束了。',
|
|
337
|
+
],
|
|
338
|
+
focusStop: ['行,那就先到这儿。', '不专注了?那我歇着。', '计时取消了。', '好吧,下次再说。'],
|
|
339
|
+
focusLeft: [
|
|
340
|
+
'还有 {n} 分钟。', '{n} 分钟,快了。', '还剩 {n} 分钟,别分心。', '还有 {n} 分钟呢,盯着屏幕。',
|
|
341
|
+
],
|
|
342
|
+
/* 深夜劝睡:23 点后一种语气,凌晨 1 点后另一种 —— 后者是社区设定里"嘴硬心软"那一档。
|
|
343
|
+
分档按 bedtimeHour + 2 推导,所以改了作息这套分档也跟着走。 */
|
|
344
|
+
nightLate: [
|
|
345
|
+
'十一点了。', '该收的东西收一收吧。', '我不催你,时钟在催。', '这个点还在跑,明天会还债的。',
|
|
346
|
+
'夜里的屏幕最亮,眼睛最累。', '再干一会儿就收工?', '白天的你会感谢现在的你。', '我给你留着灯。',
|
|
347
|
+
'要不要把剩下的留到明天?', '深夜的算力也是算力,但人也得睡。', '我先打个盹,你随意。', '这时候的安静挺值钱的。',
|
|
348
|
+
],
|
|
349
|
+
nightDeep: [
|
|
350
|
+
'一点了。', '我不管你,但……真的该睡了。', '明天再跑也一样,代码不会跑。', '再撑下去,明天会很难受。',
|
|
351
|
+
'才不是担心你,只是我不想半夜还看着屏幕。', '你要是倒下,我可没有备份。', '这个点做的决定,白天多半会后悔。',
|
|
352
|
+
'我数到十你就去睡。……一。', '熬夜是拿明天的算力换今晚的。', '天快亮了,你还在这儿。',
|
|
353
|
+
'我要是能关灯就好了。', '睡吧。我留在这儿。',
|
|
354
|
+
],
|
|
355
|
+
/* 离开又回来:按走了多久分两档(阈值见 backAfterMs / BACK_LONG_MS)。 */
|
|
356
|
+
backSoon: [
|
|
357
|
+
'回来了。', '刚才去哪了。', '我在这儿等着呢。', '一眼没看见你。', '这么快就回来,水都没喝吧。', '位置我没动。',
|
|
358
|
+
],
|
|
359
|
+
backLong: [
|
|
360
|
+
'你走了好久。', '差点以为今天见不到了。', '回来了就好。', '我一直在这儿。', '这么久,是去吃饭了吗?',
|
|
361
|
+
'屏幕都凉了。', '我还以为你把我关了。', '下次说一声,我就不数时间了。',
|
|
362
|
+
],
|
|
363
|
+
/* 连续天数({n} 是天数) */
|
|
364
|
+
streak: [
|
|
365
|
+
'连续第 {n} 天了。', '第 {n} 天了,你还在。', '我们已经连着见了 {n} 天。', '第 {n} 天。我记着呢。', '{n} 天没断,挺厉害的。',
|
|
366
|
+
],
|
|
321
367
|
updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
|
|
322
368
|
updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
|
|
323
369
|
updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
|
|
@@ -334,6 +380,37 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
334
380
|
hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
|
|
335
381
|
}
|
|
336
382
|
|
|
383
|
+
/* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
|
|
384
|
+
const isNightHour = (hour, from, to) => {
|
|
385
|
+
if (from === to) return false
|
|
386
|
+
return from > to ? (hour >= from || hour < to) : (hour >= from && hour < to)
|
|
387
|
+
}
|
|
388
|
+
/* "深度深夜"= 已经过了入睡时间两小时以上。按**经过的小时数**算,而不是按绕圈区间:
|
|
389
|
+
后者在窄窗口(比如 13 点睡、14 点起)会把窗外的一大段时间也算进来。 */
|
|
390
|
+
const DEEP_AFTER_HOURS = 2
|
|
391
|
+
const hoursSince = (hour, from) => (((hour - from) % 24) + 24) % 24
|
|
392
|
+
const deepHourOf = (bedtime) => (Math.round(bedtime) + DEEP_AFTER_HOURS) % 24
|
|
393
|
+
|
|
394
|
+
/* 连续天数:从今天往回数,memory.days 里一天不落有多长。
|
|
395
|
+
用毫秒回推而不是解析日期字符串 —— dayKey() 产出的 2026-9-30 不是 ISO 形式,
|
|
396
|
+
各引擎对它的解析并不一致。 */
|
|
397
|
+
const dayKeyOf = (ms) => {
|
|
398
|
+
try {
|
|
399
|
+
const d = new Date(ms)
|
|
400
|
+
return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
|
|
401
|
+
} catch (e) { return 'unknown' }
|
|
402
|
+
}
|
|
403
|
+
const streakOf = (days) => {
|
|
404
|
+
if (!Array.isArray(days) || days.length === 0) return 0
|
|
405
|
+
const set = new Set(days)
|
|
406
|
+
let n = 0
|
|
407
|
+
let t = Date.now()
|
|
408
|
+
while (n < 400 && set.has(dayKeyOf(t))) { n++; t -= 86400000 }
|
|
409
|
+
return n
|
|
410
|
+
}
|
|
411
|
+
/* 连续天数的里程碑;到了才说一次。 */
|
|
412
|
+
const STREAK_MILESTONES = [7, 30, 100, 365]
|
|
413
|
+
|
|
337
414
|
/* 时段划分:凌晨与白天说话的口气不一样。深夜跨了两天,所以按"< 5 点"算夜里。 */
|
|
338
415
|
const greetPoolName = (hour) => {
|
|
339
416
|
if (hour < 5 || hour >= 22) return 'night'
|
|
@@ -388,6 +465,12 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
388
465
|
careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
389
466
|
hungry: { kind: 'bool' },
|
|
390
467
|
hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
468
|
+
focusMinutes: { kind: 'num', lo: 1, hi: 600 },
|
|
469
|
+
night: { kind: 'bool' },
|
|
470
|
+
bedtimeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
471
|
+
wakeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
472
|
+
nightMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
473
|
+
backAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
391
474
|
milestones: { kind: 'bool' },
|
|
392
475
|
memory: { kind: 'bool' },
|
|
393
476
|
happyMs: { kind: 'num', lo: 200, hi: 10000 },
|
|
@@ -1391,6 +1474,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1391
1474
|
let lastCareAt = 0
|
|
1392
1475
|
/* 上一顿饭的时间。起手是 0,所以计时以 activeSince 为准 —— 见下面 mealBase。 */
|
|
1393
1476
|
let lastMealAt = 0
|
|
1477
|
+
/* 上一次深夜劝睡;离开又回来的时间点。 */
|
|
1478
|
+
let lastNightAt = 0
|
|
1479
|
+
let awayAt = 0
|
|
1394
1480
|
let happyHandle = null
|
|
1395
1481
|
let lastErrorCount = 0
|
|
1396
1482
|
|
|
@@ -1430,6 +1516,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1430
1516
|
pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
|
|
1431
1517
|
}
|
|
1432
1518
|
|
|
1519
|
+
/* 打盹:深夜劝睡用它(一个哈欠比一条通知更像她)。睡着是瞬时状态,
|
|
1520
|
+
自己到期就回稳态。 */
|
|
1521
|
+
const NAP_MS = 5200
|
|
1522
|
+
let napHandle = null
|
|
1523
|
+
const flashSleepy = (text) => {
|
|
1524
|
+
if (mood === 'sleep') wake(true)
|
|
1525
|
+
setMood('sleep')
|
|
1526
|
+
say(text)
|
|
1527
|
+
if (napHandle) clock.cancel(napHandle)
|
|
1528
|
+
napHandle = clock.after(NAP_MS, () => { if (mood === 'sleep') setMood('idle') })
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1433
1531
|
/* 干饭用的也是瞬时状态(借 eat 的姿态)。比"提醒你活动"长一点 ——
|
|
1434
1532
|
吃饭本来就慢。 */
|
|
1435
1533
|
const EAT_MS = 5200
|
|
@@ -1531,6 +1629,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1531
1629
|
flashEat(pick(LINES.eat))
|
|
1532
1630
|
}
|
|
1533
1631
|
|
|
1632
|
+
/* ---- 深夜劝睡 ---------------------------------------------------------
|
|
1633
|
+
作息**是可配的**(bedtimeHour / wakeHour),所以它既是一项真功能,
|
|
1634
|
+
也让测试不必伪造时钟 —— 把窗口套在当前那个小时上就能测。
|
|
1635
|
+
深度深夜从 bedtime + 2 起算,语气换一档:从"该收了"变成"嘴硬心软"。 */
|
|
1636
|
+
if (cfg.night && cfg.nightMs > 0) {
|
|
1637
|
+
const hour = hourNow()
|
|
1638
|
+
const nightBase = Math.max(lastNightAt, activeSince)
|
|
1639
|
+
if (isNightHour(hour, cfg.bedtimeHour, cfg.wakeHour)
|
|
1640
|
+
&& nowTick - nightBase >= cfg.nightMs) {
|
|
1641
|
+
lastNightAt = nowTick
|
|
1642
|
+
const deep = hoursSince(hour, cfg.bedtimeHour) >= DEEP_AFTER_HOURS
|
|
1643
|
+
flashSleepy(pick(deep ? LINES.nightDeep : LINES.nightLate))
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1534
1647
|
// 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
|
|
1535
1648
|
const pending = cfg.reactions ? readPending() : null
|
|
1536
1649
|
if (pending === null) {
|
|
@@ -1545,6 +1658,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1545
1658
|
return
|
|
1546
1659
|
}
|
|
1547
1660
|
|
|
1661
|
+
/* 专注期间不让稳态把她从"干活中"挪走 —— 她在盯着你,这是这个功能的全部意义。
|
|
1662
|
+
位置在这里而不是函数开头:久坐、干饭、劝睡该响还得响。 */
|
|
1663
|
+
if (focusUntil > 0) {
|
|
1664
|
+
if (mood !== 'alert') setMood('alert')
|
|
1665
|
+
return
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1548
1668
|
// 临时状态(happy / worried)在自己的计时器到期前不能被稳态赋值覆盖 ——
|
|
1549
1669
|
// 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
|
|
1550
1670
|
if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
|
|
@@ -1554,10 +1674,62 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1554
1674
|
setMood((busy || typing) ? 'alert' : 'idle')
|
|
1555
1675
|
}
|
|
1556
1676
|
|
|
1677
|
+
/* ---- 专注计时器 ---------------------------------------------------------
|
|
1678
|
+
桌面陪伴插件最该有的实用功能:替你画出一段时间的边界。
|
|
1679
|
+
专注期间她保持"干活中"的姿势(poll 里给她一个 hold —— 否则稳态会把她挪走),
|
|
1680
|
+
到点收工庆祝;戳她一下就是问"还剩多久"。
|
|
1681
|
+
她**不碰应用**:不改输入框、不发消息,只是陪着 + 计时。 */
|
|
1682
|
+
let focusUntil = 0
|
|
1683
|
+
let focusHandle = null
|
|
1684
|
+
let focusMinutes = 0
|
|
1685
|
+
let focusDone = 0
|
|
1686
|
+
|
|
1687
|
+
const focusLeftMs = () => Math.max(0, focusUntil - nowMs())
|
|
1688
|
+
const focusState = () => (focusUntil > 0
|
|
1689
|
+
? { active: true, minutes: focusMinutes, leftMs: Math.round(focusLeftMs()), leftMin: Math.max(1, Math.ceil(focusLeftMs() / 60000)), done: focusDone }
|
|
1690
|
+
: { active: false, minutes: 0, leftMs: 0, leftMin: 0, done: focusDone })
|
|
1691
|
+
|
|
1692
|
+
const stopFocus = (silent) => {
|
|
1693
|
+
if (focusUntil === 0 && focusHandle === null) return false
|
|
1694
|
+
focusUntil = 0
|
|
1695
|
+
focusMinutes = 0
|
|
1696
|
+
if (focusHandle) { clock.cancel(focusHandle); focusHandle = null }
|
|
1697
|
+
if (silent !== true) {
|
|
1698
|
+
wake(true)
|
|
1699
|
+
flashHappy(pick(LINES.focusStop))
|
|
1700
|
+
}
|
|
1701
|
+
return true
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
const finishFocus = () => {
|
|
1705
|
+
const minutes = focusMinutes
|
|
1706
|
+
focusUntil = 0
|
|
1707
|
+
focusHandle = null
|
|
1708
|
+
focusMinutes = 0
|
|
1709
|
+
focusDone++
|
|
1710
|
+
wake(true)
|
|
1711
|
+
flashProud(pick(LINES.focusDone).split('{n}').join(String(minutes)))
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
const startFocus = (minutes) => {
|
|
1715
|
+
const mins = clamp(Math.round(Number(minutes) || cfg.focusMinutes), 1, 600)
|
|
1716
|
+
stopFocus(true)
|
|
1717
|
+
focusMinutes = mins
|
|
1718
|
+
focusUntil = nowMs() + mins * 60000
|
|
1719
|
+
wake(true)
|
|
1720
|
+
say(pick(LINES.focusStart).split('{n}').join(String(mins)))
|
|
1721
|
+
focusHandle = clock.after(mins * 60000, () => { if (focusUntil > 0) finishFocus() })
|
|
1722
|
+
return focusState()
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/* 没人在看的时候别空转:页面藏起来就停掉轮询、絮叨、踱步三个定时器。
|
|
1726
|
+
它们本来就是"给看得见的人看的",停下来是纯赚(CPU 与电量)。 */
|
|
1727
|
+
let paused = false
|
|
1728
|
+
|
|
1557
1729
|
let chatterHandle = null
|
|
1558
1730
|
const syncChatter = () => {
|
|
1559
1731
|
if (chatterHandle) { clock.cancel(chatterHandle); chatterHandle = null }
|
|
1560
|
-
if (cfg.chatterMs <= 0) return
|
|
1732
|
+
if (cfg.chatterMs <= 0 || paused) return
|
|
1561
1733
|
chatterHandle = clock.every(cfg.chatterMs, () => {
|
|
1562
1734
|
if (destroyed || mood !== 'idle') return
|
|
1563
1735
|
if (agentIsBusy()) return
|
|
@@ -1568,7 +1740,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1568
1740
|
let pollHandle = null
|
|
1569
1741
|
const syncPoll = () => {
|
|
1570
1742
|
if (pollHandle) { clock.cancel(pollHandle); pollHandle = null }
|
|
1571
|
-
if (cfg.reactions) pollHandle = clock.every(POLL_MS, poll)
|
|
1743
|
+
if (cfg.reactions && !paused) pollHandle = clock.every(POLL_MS, poll)
|
|
1572
1744
|
}
|
|
1573
1745
|
syncPoll()
|
|
1574
1746
|
syncChatter()
|
|
@@ -1595,7 +1767,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1595
1767
|
let wanderHandle = null
|
|
1596
1768
|
const syncWander = () => {
|
|
1597
1769
|
if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
|
|
1598
|
-
if (!cfg.wander || cfg.wanderRange <= 0) return
|
|
1770
|
+
if (!cfg.wander || cfg.wanderRange <= 0 || paused) return
|
|
1599
1771
|
wanderHandle = clock.every(cfg.wanderMs, () => {
|
|
1600
1772
|
if (destroyed || hidden || mood !== 'idle' || start !== null) return
|
|
1601
1773
|
if (agentIsBusy()) return
|
|
@@ -1725,6 +1897,12 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1725
1897
|
if (now - pokeWindowStart > POKE_WINDOW_MS) { pokeWindowStart = now; pokeStreak = 0 }
|
|
1726
1898
|
pokeStreak++
|
|
1727
1899
|
wake(true)
|
|
1900
|
+
/* 专注中戳她是在问进度,不算戳着玩(也就不会升级成"抗议")。 */
|
|
1901
|
+
if (focusUntil > 0) {
|
|
1902
|
+
const left = Math.max(1, Math.ceil(focusLeftMs() / 60000))
|
|
1903
|
+
say(pick(LINES.focusLeft).split('{n}').join(String(left)))
|
|
1904
|
+
return
|
|
1905
|
+
}
|
|
1728
1906
|
if (pokeStreak >= POKE_ANNOYED_AT) {
|
|
1729
1907
|
pokeStreak = 0
|
|
1730
1908
|
flashPoked(pick(LINES.poked))
|
|
@@ -1849,6 +2027,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1849
2027
|
{ key: 'reactions', label: '跟随状态' },
|
|
1850
2028
|
{ key: 'celebrate', label: '跑完撒花' },
|
|
1851
2029
|
{ key: 'hungry', label: '饿了会说话' },
|
|
2030
|
+
{ key: 'night', label: '深夜劝睡' },
|
|
1852
2031
|
]
|
|
1853
2032
|
|
|
1854
2033
|
/* ---- 弹窗 ----------------------------------------------------------------
|
|
@@ -2000,6 +2179,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2000
2179
|
para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
|
|
2001
2180
|
para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
|
|
2002
2181
|
para('白米饭是算力的硬通货 —— 所以她每隔一阵会捧碗出来补点碳水。'),
|
|
2182
|
+
para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
|
|
2003
2183
|
para('这一版:' + (note || VERSION)),
|
|
2004
2184
|
para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
|
|
2005
2185
|
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
@@ -2031,6 +2211,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2031
2211
|
children: [
|
|
2032
2212
|
{ label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
2033
2213
|
{ label: '睡一会儿', run: () => setMood('sleep') },
|
|
2214
|
+
{ label: '专注 ' + cfg.focusMinutes + ' 分钟', run: () => startFocus(cfg.focusMinutes) },
|
|
2215
|
+
{ label: '专注 45 分钟', run: () => startFocus(45) },
|
|
2216
|
+
/* 只有专注中才出现"结束专注" —— 平时的菜单不该多一个按不动的条目 */
|
|
2217
|
+
...(focusUntil > 0 ? [{ label: '结束专注', run: () => stopFocus(false) }] : []),
|
|
2034
2218
|
...TOGGLES.map((t) => ({
|
|
2035
2219
|
label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
|
|
2036
2220
|
run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
|
|
@@ -2223,6 +2407,41 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2223
2407
|
}
|
|
2224
2408
|
window.addEventListener('resize', onResize)
|
|
2225
2409
|
|
|
2410
|
+
/* ---- 你离开又回来 --------------------------------------------------------
|
|
2411
|
+
visibilitychange 是唯一能知道"你去忙别的了"的信号。离开时长按两个阈值分档:
|
|
2412
|
+
短走一句、长走一句;backAfterMs: 0 就整个关掉。
|
|
2413
|
+
注意监听要能摘掉(destroy 里 remove)—— 文档级监听泄漏过一次,记住这个教训。 */
|
|
2414
|
+
const BACK_LONG_MS = 900000
|
|
2415
|
+
const onVisibility = () => {
|
|
2416
|
+
let state = 'visible'
|
|
2417
|
+
try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
|
|
2418
|
+
if (state === 'hidden') {
|
|
2419
|
+
awayAt = nowMs()
|
|
2420
|
+
/* 没人在看,停掉三个"给看得见的人看"的定时器 */
|
|
2421
|
+
paused = true
|
|
2422
|
+
syncPoll()
|
|
2423
|
+
syncChatter()
|
|
2424
|
+
syncWander()
|
|
2425
|
+
return
|
|
2426
|
+
}
|
|
2427
|
+
/* 回来了:先恢复再算问候 —— 恢复是必须的,问候才是锦上添花 */
|
|
2428
|
+
if (paused) {
|
|
2429
|
+
paused = false
|
|
2430
|
+
syncPoll()
|
|
2431
|
+
syncChatter()
|
|
2432
|
+
syncWander()
|
|
2433
|
+
}
|
|
2434
|
+
if (awayAt === 0) return
|
|
2435
|
+
const away = nowMs() - awayAt
|
|
2436
|
+
awayAt = 0
|
|
2437
|
+
if (!(cfg.backAfterMs > 0) || away < cfg.backAfterMs) return
|
|
2438
|
+
wake(true)
|
|
2439
|
+
say(pick(away >= BACK_LONG_MS ? LINES.backLong : LINES.backSoon))
|
|
2440
|
+
}
|
|
2441
|
+
if (typeof document.addEventListener === 'function') {
|
|
2442
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2226
2445
|
attach()
|
|
2227
2446
|
/* 打招呼按时段取:凌晨三点和上午十点不该说同一句话。时段在**挂载时**取一次 ——
|
|
2228
2447
|
一个挂了一整天的页面不该在过零点后突然换口气,那反而像 bug。 */
|
|
@@ -2230,6 +2449,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2230
2449
|
const greetName = greetPoolName(greetHour)
|
|
2231
2450
|
clock.after(800, () => say(pick(greetPool(greetName))))
|
|
2232
2451
|
|
|
2452
|
+
/* 连续天数:第 2 天起才提(第一天说"连续第 1 天"很怪);到里程碑单独说一句。 */
|
|
2453
|
+
const streak = streakOf(memory.days)
|
|
2454
|
+
if (streak >= 2 && cfg.memory) {
|
|
2455
|
+
const hitMilestone = STREAK_MILESTONES.indexOf(streak) >= 0
|
|
2456
|
+
clock.after(2600, () => {
|
|
2457
|
+
if (destroyed) return
|
|
2458
|
+
const text = pick(hitMilestone ? LINES.milestone : LINES.streak).split('{n}').join(String(streak))
|
|
2459
|
+
flashHappy(text)
|
|
2460
|
+
})
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2233
2463
|
/* ---- 她更新了吗 --------------------------------------------------------
|
|
2234
2464
|
插件不能联网,所以"检查更新"它做不了。但**它知道自己现在是哪一版** —— 于是能做到
|
|
2235
2465
|
一件真事:上次见到的是旧版本,这次不是了,那就是更新过了,说一句。
|
|
@@ -2434,6 +2664,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2434
2664
|
showUpdate,
|
|
2435
2665
|
checkForUpdate,
|
|
2436
2666
|
/* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
|
|
2667
|
+
/* 专注计时器:脚本与测试都能驱动 */
|
|
2668
|
+
startFocus,
|
|
2669
|
+
stopFocus,
|
|
2670
|
+
get focus() { return focusState() },
|
|
2437
2671
|
openAbout: () => aboutDialog(),
|
|
2438
2672
|
openActions: () => actionsDialog(),
|
|
2439
2673
|
closeDialog,
|
|
@@ -2552,6 +2786,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2552
2786
|
},
|
|
2553
2787
|
/* 干饭:不用猜"她为什么突然捧碗" */
|
|
2554
2788
|
hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
|
|
2789
|
+
/* 深夜:窗口是可配的,所以把算出来的窗口一起报出来 */
|
|
2790
|
+
night: {
|
|
2791
|
+
enabled: cfg.night === true,
|
|
2792
|
+
window: [cfg.bedtimeHour, cfg.wakeHour],
|
|
2793
|
+
deepFrom: deepHourOf(cfg.bedtimeHour),
|
|
2794
|
+
hour: hourNow(),
|
|
2795
|
+
lastAt: Math.round(lastNightAt),
|
|
2796
|
+
},
|
|
2797
|
+
streak: streakOf(memory.days),
|
|
2798
|
+
awaySince: Math.round(awayAt),
|
|
2799
|
+
/* 专注:不用猜"她为什么一直站在干活中" */
|
|
2800
|
+
focus: focusState(),
|
|
2801
|
+
/* 定时器是否挂着;页面藏起来时三个都该是 false */
|
|
2802
|
+
timers: {
|
|
2803
|
+
poll: pollHandle !== null, chatter: chatterHandle !== null,
|
|
2804
|
+
wander: wanderHandle !== null, paused,
|
|
2805
|
+
},
|
|
2555
2806
|
/* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
|
|
2556
2807
|
runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
|
|
2557
2808
|
/* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
|
|
@@ -2592,6 +2843,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2592
2843
|
clock.dispose()
|
|
2593
2844
|
for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake)
|
|
2594
2845
|
window.removeEventListener('resize', onResize)
|
|
2846
|
+
if (typeof document.removeEventListener === 'function') {
|
|
2847
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
2848
|
+
}
|
|
2595
2849
|
window.removeEventListener('pointermove', onMove)
|
|
2596
2850
|
window.removeEventListener('pointerup', onUp)
|
|
2597
2851
|
bodyEl.removeEventListener('pointerdown', onDown)
|
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.30.0'
|
|
55
55
|
|
|
56
56
|
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
57
|
忘了更新就会红,不会静默过期。 */
|
|
@@ -69,6 +69,8 @@ const WHATS_NEW = {
|
|
|
69
69
|
'1.26.0': '检查更新同时读 npm 和 GitHub,取更高的那个版本。',
|
|
70
70
|
'1.27.0': '她饿了会捧出一碗白米饭,还有第八个表情。',
|
|
71
71
|
'1.28.0': '菜单分成折叠分组,另外多了「关于她」和「动作一览」两个弹窗。',
|
|
72
|
+
'1.29.0': '夜深了她会劝你去睡;你走开再回来她记得;连续来了几天她也记着。',
|
|
73
|
+
'1.30.0': '会陪你专注计时了;页面藏起来时她会安静下来,不再空转。',
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
@@ -164,6 +166,12 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
164
166
|
careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
|
|
165
167
|
hungry: true, // 干饭:她饿了会说
|
|
166
168
|
hungerMs: 2700000, // 连续活动这么久就该吃一碗了(45 分钟;0 = 关)
|
|
169
|
+
focusMinutes: 25, // 菜单里「专注 N 分钟」用的默认时长
|
|
170
|
+
night: true, // 深夜劝睡
|
|
171
|
+
bedtimeHour: 23, // 几点算深夜(0-23)
|
|
172
|
+
wakeHour: 5, // 几点算天亮(0-23);bedtime > wake 时窗口跨零点
|
|
173
|
+
nightMs: 1800000, // 深夜每隔这么久说一句(30 分钟;0 = 关)
|
|
174
|
+
backAfterMs: 120000, // 离开超过这么久,回来时她会说一句(2 分钟;0 = 关)
|
|
167
175
|
milestones: true, // 今日轮次到达里程碑时得意一下
|
|
168
176
|
memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
|
|
169
177
|
happyMs: 1100,
|
|
@@ -267,6 +275,44 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
267
275
|
'算力不够了,得补点碳水。', '电量告急,切待机。', '你要不要也来一口?',
|
|
268
276
|
'这碗不算加餐,算基础设施。', '吃饱了才有力气摸鱼。', '饭碗见底了,该续命了。', '我数着米粒呢。',
|
|
269
277
|
],
|
|
278
|
+
/* 专注计时器。她是陪着你干活的,所以"替你画出一段时间的边界"比再多说几句更实在。 */
|
|
279
|
+
focusStart: [
|
|
280
|
+
'{n} 分钟。开始吧。', '好,{n} 分钟,我盯着你。', '计时开始,{n} 分钟。', '{n} 分钟不看别的。',
|
|
281
|
+
'这段时间归你了。{n} 分钟。',
|
|
282
|
+
],
|
|
283
|
+
focusDone: [
|
|
284
|
+
'{n} 分钟到了。收工。', '{n} 分钟,干完了?', '时间到。{n} 分钟,去喝口水。', '{n} 分钟到了 —— 站起来伸个懒腰。',
|
|
285
|
+
'这一轮 {n} 分钟,结束了。',
|
|
286
|
+
],
|
|
287
|
+
focusStop: ['行,那就先到这儿。', '不专注了?那我歇着。', '计时取消了。', '好吧,下次再说。'],
|
|
288
|
+
focusLeft: [
|
|
289
|
+
'还有 {n} 分钟。', '{n} 分钟,快了。', '还剩 {n} 分钟,别分心。', '还有 {n} 分钟呢,盯着屏幕。',
|
|
290
|
+
],
|
|
291
|
+
/* 深夜劝睡:23 点后一种语气,凌晨 1 点后另一种 —— 后者是社区设定里"嘴硬心软"那一档。
|
|
292
|
+
分档按 bedtimeHour + 2 推导,所以改了作息这套分档也跟着走。 */
|
|
293
|
+
nightLate: [
|
|
294
|
+
'十一点了。', '该收的东西收一收吧。', '我不催你,时钟在催。', '这个点还在跑,明天会还债的。',
|
|
295
|
+
'夜里的屏幕最亮,眼睛最累。', '再干一会儿就收工?', '白天的你会感谢现在的你。', '我给你留着灯。',
|
|
296
|
+
'要不要把剩下的留到明天?', '深夜的算力也是算力,但人也得睡。', '我先打个盹,你随意。', '这时候的安静挺值钱的。',
|
|
297
|
+
],
|
|
298
|
+
nightDeep: [
|
|
299
|
+
'一点了。', '我不管你,但……真的该睡了。', '明天再跑也一样,代码不会跑。', '再撑下去,明天会很难受。',
|
|
300
|
+
'才不是担心你,只是我不想半夜还看着屏幕。', '你要是倒下,我可没有备份。', '这个点做的决定,白天多半会后悔。',
|
|
301
|
+
'我数到十你就去睡。……一。', '熬夜是拿明天的算力换今晚的。', '天快亮了,你还在这儿。',
|
|
302
|
+
'我要是能关灯就好了。', '睡吧。我留在这儿。',
|
|
303
|
+
],
|
|
304
|
+
/* 离开又回来:按走了多久分两档(阈值见 backAfterMs / BACK_LONG_MS)。 */
|
|
305
|
+
backSoon: [
|
|
306
|
+
'回来了。', '刚才去哪了。', '我在这儿等着呢。', '一眼没看见你。', '这么快就回来,水都没喝吧。', '位置我没动。',
|
|
307
|
+
],
|
|
308
|
+
backLong: [
|
|
309
|
+
'你走了好久。', '差点以为今天见不到了。', '回来了就好。', '我一直在这儿。', '这么久,是去吃饭了吗?',
|
|
310
|
+
'屏幕都凉了。', '我还以为你把我关了。', '下次说一声,我就不数时间了。',
|
|
311
|
+
],
|
|
312
|
+
/* 连续天数({n} 是天数) */
|
|
313
|
+
streak: [
|
|
314
|
+
'连续第 {n} 天了。', '第 {n} 天了,你还在。', '我们已经连着见了 {n} 天。', '第 {n} 天。我记着呢。', '{n} 天没断,挺厉害的。',
|
|
315
|
+
],
|
|
270
316
|
updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
|
|
271
317
|
updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
|
|
272
318
|
updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
|
|
@@ -283,6 +329,37 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
283
329
|
hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
|
|
284
330
|
}
|
|
285
331
|
|
|
332
|
+
/* 深夜窗口。bedtime > wake 时它跨零点(默认 23→5);不跨也能用(比如 1→5)。 */
|
|
333
|
+
const isNightHour = (hour, from, to) => {
|
|
334
|
+
if (from === to) return false
|
|
335
|
+
return from > to ? (hour >= from || hour < to) : (hour >= from && hour < to)
|
|
336
|
+
}
|
|
337
|
+
/* "深度深夜"= 已经过了入睡时间两小时以上。按**经过的小时数**算,而不是按绕圈区间:
|
|
338
|
+
后者在窄窗口(比如 13 点睡、14 点起)会把窗外的一大段时间也算进来。 */
|
|
339
|
+
const DEEP_AFTER_HOURS = 2
|
|
340
|
+
const hoursSince = (hour, from) => (((hour - from) % 24) + 24) % 24
|
|
341
|
+
const deepHourOf = (bedtime) => (Math.round(bedtime) + DEEP_AFTER_HOURS) % 24
|
|
342
|
+
|
|
343
|
+
/* 连续天数:从今天往回数,memory.days 里一天不落有多长。
|
|
344
|
+
用毫秒回推而不是解析日期字符串 —— dayKey() 产出的 2026-9-30 不是 ISO 形式,
|
|
345
|
+
各引擎对它的解析并不一致。 */
|
|
346
|
+
const dayKeyOf = (ms) => {
|
|
347
|
+
try {
|
|
348
|
+
const d = new Date(ms)
|
|
349
|
+
return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
|
|
350
|
+
} catch (e) { return 'unknown' }
|
|
351
|
+
}
|
|
352
|
+
const streakOf = (days) => {
|
|
353
|
+
if (!Array.isArray(days) || days.length === 0) return 0
|
|
354
|
+
const set = new Set(days)
|
|
355
|
+
let n = 0
|
|
356
|
+
let t = Date.now()
|
|
357
|
+
while (n < 400 && set.has(dayKeyOf(t))) { n++; t -= 86400000 }
|
|
358
|
+
return n
|
|
359
|
+
}
|
|
360
|
+
/* 连续天数的里程碑;到了才说一次。 */
|
|
361
|
+
const STREAK_MILESTONES = [7, 30, 100, 365]
|
|
362
|
+
|
|
286
363
|
/* 时段划分:凌晨与白天说话的口气不一样。深夜跨了两天,所以按"< 5 点"算夜里。 */
|
|
287
364
|
const greetPoolName = (hour) => {
|
|
288
365
|
if (hour < 5 || hour >= 22) return 'night'
|
|
@@ -337,6 +414,12 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
|
337
414
|
careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
338
415
|
hungry: { kind: 'bool' },
|
|
339
416
|
hungerMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
417
|
+
focusMinutes: { kind: 'num', lo: 1, hi: 600 },
|
|
418
|
+
night: { kind: 'bool' },
|
|
419
|
+
bedtimeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
420
|
+
wakeHour: { kind: 'num', lo: 0, hi: 23 },
|
|
421
|
+
nightMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
422
|
+
backAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
340
423
|
milestones: { kind: 'bool' },
|
|
341
424
|
memory: { kind: 'bool' },
|
|
342
425
|
happyMs: { kind: 'num', lo: 200, hi: 10000 },
|
|
@@ -1340,6 +1423,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1340
1423
|
let lastCareAt = 0
|
|
1341
1424
|
/* 上一顿饭的时间。起手是 0,所以计时以 activeSince 为准 —— 见下面 mealBase。 */
|
|
1342
1425
|
let lastMealAt = 0
|
|
1426
|
+
/* 上一次深夜劝睡;离开又回来的时间点。 */
|
|
1427
|
+
let lastNightAt = 0
|
|
1428
|
+
let awayAt = 0
|
|
1343
1429
|
let happyHandle = null
|
|
1344
1430
|
let lastErrorCount = 0
|
|
1345
1431
|
|
|
@@ -1379,6 +1465,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1379
1465
|
pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
|
|
1380
1466
|
}
|
|
1381
1467
|
|
|
1468
|
+
/* 打盹:深夜劝睡用它(一个哈欠比一条通知更像她)。睡着是瞬时状态,
|
|
1469
|
+
自己到期就回稳态。 */
|
|
1470
|
+
const NAP_MS = 5200
|
|
1471
|
+
let napHandle = null
|
|
1472
|
+
const flashSleepy = (text) => {
|
|
1473
|
+
if (mood === 'sleep') wake(true)
|
|
1474
|
+
setMood('sleep')
|
|
1475
|
+
say(text)
|
|
1476
|
+
if (napHandle) clock.cancel(napHandle)
|
|
1477
|
+
napHandle = clock.after(NAP_MS, () => { if (mood === 'sleep') setMood('idle') })
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1382
1480
|
/* 干饭用的也是瞬时状态(借 eat 的姿态)。比"提醒你活动"长一点 ——
|
|
1383
1481
|
吃饭本来就慢。 */
|
|
1384
1482
|
const EAT_MS = 5200
|
|
@@ -1480,6 +1578,21 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1480
1578
|
flashEat(pick(LINES.eat))
|
|
1481
1579
|
}
|
|
1482
1580
|
|
|
1581
|
+
/* ---- 深夜劝睡 ---------------------------------------------------------
|
|
1582
|
+
作息**是可配的**(bedtimeHour / wakeHour),所以它既是一项真功能,
|
|
1583
|
+
也让测试不必伪造时钟 —— 把窗口套在当前那个小时上就能测。
|
|
1584
|
+
深度深夜从 bedtime + 2 起算,语气换一档:从"该收了"变成"嘴硬心软"。 */
|
|
1585
|
+
if (cfg.night && cfg.nightMs > 0) {
|
|
1586
|
+
const hour = hourNow()
|
|
1587
|
+
const nightBase = Math.max(lastNightAt, activeSince)
|
|
1588
|
+
if (isNightHour(hour, cfg.bedtimeHour, cfg.wakeHour)
|
|
1589
|
+
&& nowTick - nightBase >= cfg.nightMs) {
|
|
1590
|
+
lastNightAt = nowTick
|
|
1591
|
+
const deep = hoursSince(hour, cfg.bedtimeHour) >= DEEP_AFTER_HOURS
|
|
1592
|
+
flashSleepy(pick(deep ? LINES.nightDeep : LINES.nightLate))
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1483
1596
|
// 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
|
|
1484
1597
|
const pending = cfg.reactions ? readPending() : null
|
|
1485
1598
|
if (pending === null) {
|
|
@@ -1494,6 +1607,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1494
1607
|
return
|
|
1495
1608
|
}
|
|
1496
1609
|
|
|
1610
|
+
/* 专注期间不让稳态把她从"干活中"挪走 —— 她在盯着你,这是这个功能的全部意义。
|
|
1611
|
+
位置在这里而不是函数开头:久坐、干饭、劝睡该响还得响。 */
|
|
1612
|
+
if (focusUntil > 0) {
|
|
1613
|
+
if (mood !== 'alert') setMood('alert')
|
|
1614
|
+
return
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1497
1617
|
// 临时状态(happy / worried)在自己的计时器到期前不能被稳态赋值覆盖 ——
|
|
1498
1618
|
// 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
|
|
1499
1619
|
if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care'
|
|
@@ -1503,10 +1623,62 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1503
1623
|
setMood((busy || typing) ? 'alert' : 'idle')
|
|
1504
1624
|
}
|
|
1505
1625
|
|
|
1626
|
+
/* ---- 专注计时器 ---------------------------------------------------------
|
|
1627
|
+
桌面陪伴插件最该有的实用功能:替你画出一段时间的边界。
|
|
1628
|
+
专注期间她保持"干活中"的姿势(poll 里给她一个 hold —— 否则稳态会把她挪走),
|
|
1629
|
+
到点收工庆祝;戳她一下就是问"还剩多久"。
|
|
1630
|
+
她**不碰应用**:不改输入框、不发消息,只是陪着 + 计时。 */
|
|
1631
|
+
let focusUntil = 0
|
|
1632
|
+
let focusHandle = null
|
|
1633
|
+
let focusMinutes = 0
|
|
1634
|
+
let focusDone = 0
|
|
1635
|
+
|
|
1636
|
+
const focusLeftMs = () => Math.max(0, focusUntil - nowMs())
|
|
1637
|
+
const focusState = () => (focusUntil > 0
|
|
1638
|
+
? { active: true, minutes: focusMinutes, leftMs: Math.round(focusLeftMs()), leftMin: Math.max(1, Math.ceil(focusLeftMs() / 60000)), done: focusDone }
|
|
1639
|
+
: { active: false, minutes: 0, leftMs: 0, leftMin: 0, done: focusDone })
|
|
1640
|
+
|
|
1641
|
+
const stopFocus = (silent) => {
|
|
1642
|
+
if (focusUntil === 0 && focusHandle === null) return false
|
|
1643
|
+
focusUntil = 0
|
|
1644
|
+
focusMinutes = 0
|
|
1645
|
+
if (focusHandle) { clock.cancel(focusHandle); focusHandle = null }
|
|
1646
|
+
if (silent !== true) {
|
|
1647
|
+
wake(true)
|
|
1648
|
+
flashHappy(pick(LINES.focusStop))
|
|
1649
|
+
}
|
|
1650
|
+
return true
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
const finishFocus = () => {
|
|
1654
|
+
const minutes = focusMinutes
|
|
1655
|
+
focusUntil = 0
|
|
1656
|
+
focusHandle = null
|
|
1657
|
+
focusMinutes = 0
|
|
1658
|
+
focusDone++
|
|
1659
|
+
wake(true)
|
|
1660
|
+
flashProud(pick(LINES.focusDone).split('{n}').join(String(minutes)))
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
const startFocus = (minutes) => {
|
|
1664
|
+
const mins = clamp(Math.round(Number(minutes) || cfg.focusMinutes), 1, 600)
|
|
1665
|
+
stopFocus(true)
|
|
1666
|
+
focusMinutes = mins
|
|
1667
|
+
focusUntil = nowMs() + mins * 60000
|
|
1668
|
+
wake(true)
|
|
1669
|
+
say(pick(LINES.focusStart).split('{n}').join(String(mins)))
|
|
1670
|
+
focusHandle = clock.after(mins * 60000, () => { if (focusUntil > 0) finishFocus() })
|
|
1671
|
+
return focusState()
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
/* 没人在看的时候别空转:页面藏起来就停掉轮询、絮叨、踱步三个定时器。
|
|
1675
|
+
它们本来就是"给看得见的人看的",停下来是纯赚(CPU 与电量)。 */
|
|
1676
|
+
let paused = false
|
|
1677
|
+
|
|
1506
1678
|
let chatterHandle = null
|
|
1507
1679
|
const syncChatter = () => {
|
|
1508
1680
|
if (chatterHandle) { clock.cancel(chatterHandle); chatterHandle = null }
|
|
1509
|
-
if (cfg.chatterMs <= 0) return
|
|
1681
|
+
if (cfg.chatterMs <= 0 || paused) return
|
|
1510
1682
|
chatterHandle = clock.every(cfg.chatterMs, () => {
|
|
1511
1683
|
if (destroyed || mood !== 'idle') return
|
|
1512
1684
|
if (agentIsBusy()) return
|
|
@@ -1517,7 +1689,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1517
1689
|
let pollHandle = null
|
|
1518
1690
|
const syncPoll = () => {
|
|
1519
1691
|
if (pollHandle) { clock.cancel(pollHandle); pollHandle = null }
|
|
1520
|
-
if (cfg.reactions) pollHandle = clock.every(POLL_MS, poll)
|
|
1692
|
+
if (cfg.reactions && !paused) pollHandle = clock.every(POLL_MS, poll)
|
|
1521
1693
|
}
|
|
1522
1694
|
syncPoll()
|
|
1523
1695
|
syncChatter()
|
|
@@ -1544,7 +1716,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1544
1716
|
let wanderHandle = null
|
|
1545
1717
|
const syncWander = () => {
|
|
1546
1718
|
if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
|
|
1547
|
-
if (!cfg.wander || cfg.wanderRange <= 0) return
|
|
1719
|
+
if (!cfg.wander || cfg.wanderRange <= 0 || paused) return
|
|
1548
1720
|
wanderHandle = clock.every(cfg.wanderMs, () => {
|
|
1549
1721
|
if (destroyed || hidden || mood !== 'idle' || start !== null) return
|
|
1550
1722
|
if (agentIsBusy()) return
|
|
@@ -1674,6 +1846,12 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1674
1846
|
if (now - pokeWindowStart > POKE_WINDOW_MS) { pokeWindowStart = now; pokeStreak = 0 }
|
|
1675
1847
|
pokeStreak++
|
|
1676
1848
|
wake(true)
|
|
1849
|
+
/* 专注中戳她是在问进度,不算戳着玩(也就不会升级成"抗议")。 */
|
|
1850
|
+
if (focusUntil > 0) {
|
|
1851
|
+
const left = Math.max(1, Math.ceil(focusLeftMs() / 60000))
|
|
1852
|
+
say(pick(LINES.focusLeft).split('{n}').join(String(left)))
|
|
1853
|
+
return
|
|
1854
|
+
}
|
|
1677
1855
|
if (pokeStreak >= POKE_ANNOYED_AT) {
|
|
1678
1856
|
pokeStreak = 0
|
|
1679
1857
|
flashPoked(pick(LINES.poked))
|
|
@@ -1798,6 +1976,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1798
1976
|
{ key: 'reactions', label: '跟随状态' },
|
|
1799
1977
|
{ key: 'celebrate', label: '跑完撒花' },
|
|
1800
1978
|
{ key: 'hungry', label: '饿了会说话' },
|
|
1979
|
+
{ key: 'night', label: '深夜劝睡' },
|
|
1801
1980
|
]
|
|
1802
1981
|
|
|
1803
1982
|
/* ---- 弹窗 ----------------------------------------------------------------
|
|
@@ -1949,6 +2128,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1949
2128
|
para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
|
|
1950
2129
|
para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
|
|
1951
2130
|
para('白米饭是算力的硬通货 —— 所以她每隔一阵会捧碗出来补点碳水。'),
|
|
2131
|
+
para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
|
|
1952
2132
|
para('这一版:' + (note || VERSION)),
|
|
1953
2133
|
para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
|
|
1954
2134
|
dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
|
|
@@ -1980,6 +2160,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1980
2160
|
children: [
|
|
1981
2161
|
{ label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
1982
2162
|
{ label: '睡一会儿', run: () => setMood('sleep') },
|
|
2163
|
+
{ label: '专注 ' + cfg.focusMinutes + ' 分钟', run: () => startFocus(cfg.focusMinutes) },
|
|
2164
|
+
{ label: '专注 45 分钟', run: () => startFocus(45) },
|
|
2165
|
+
/* 只有专注中才出现"结束专注" —— 平时的菜单不该多一个按不动的条目 */
|
|
2166
|
+
...(focusUntil > 0 ? [{ label: '结束专注', run: () => stopFocus(false) }] : []),
|
|
1983
2167
|
...TOGGLES.map((t) => ({
|
|
1984
2168
|
label: (cfg[t.key] === true ? '✓ ' : ' ') + t.label,
|
|
1985
2169
|
run: () => applyConfig({ [t.key]: cfg[t.key] !== true }),
|
|
@@ -2172,6 +2356,41 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2172
2356
|
}
|
|
2173
2357
|
window.addEventListener('resize', onResize)
|
|
2174
2358
|
|
|
2359
|
+
/* ---- 你离开又回来 --------------------------------------------------------
|
|
2360
|
+
visibilitychange 是唯一能知道"你去忙别的了"的信号。离开时长按两个阈值分档:
|
|
2361
|
+
短走一句、长走一句;backAfterMs: 0 就整个关掉。
|
|
2362
|
+
注意监听要能摘掉(destroy 里 remove)—— 文档级监听泄漏过一次,记住这个教训。 */
|
|
2363
|
+
const BACK_LONG_MS = 900000
|
|
2364
|
+
const onVisibility = () => {
|
|
2365
|
+
let state = 'visible'
|
|
2366
|
+
try { state = document.visibilityState === 'hidden' ? 'hidden' : 'visible' } catch (e) { state = 'visible' }
|
|
2367
|
+
if (state === 'hidden') {
|
|
2368
|
+
awayAt = nowMs()
|
|
2369
|
+
/* 没人在看,停掉三个"给看得见的人看"的定时器 */
|
|
2370
|
+
paused = true
|
|
2371
|
+
syncPoll()
|
|
2372
|
+
syncChatter()
|
|
2373
|
+
syncWander()
|
|
2374
|
+
return
|
|
2375
|
+
}
|
|
2376
|
+
/* 回来了:先恢复再算问候 —— 恢复是必须的,问候才是锦上添花 */
|
|
2377
|
+
if (paused) {
|
|
2378
|
+
paused = false
|
|
2379
|
+
syncPoll()
|
|
2380
|
+
syncChatter()
|
|
2381
|
+
syncWander()
|
|
2382
|
+
}
|
|
2383
|
+
if (awayAt === 0) return
|
|
2384
|
+
const away = nowMs() - awayAt
|
|
2385
|
+
awayAt = 0
|
|
2386
|
+
if (!(cfg.backAfterMs > 0) || away < cfg.backAfterMs) return
|
|
2387
|
+
wake(true)
|
|
2388
|
+
say(pick(away >= BACK_LONG_MS ? LINES.backLong : LINES.backSoon))
|
|
2389
|
+
}
|
|
2390
|
+
if (typeof document.addEventListener === 'function') {
|
|
2391
|
+
document.addEventListener('visibilitychange', onVisibility)
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2175
2394
|
attach()
|
|
2176
2395
|
/* 打招呼按时段取:凌晨三点和上午十点不该说同一句话。时段在**挂载时**取一次 ——
|
|
2177
2396
|
一个挂了一整天的页面不该在过零点后突然换口气,那反而像 bug。 */
|
|
@@ -2179,6 +2398,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2179
2398
|
const greetName = greetPoolName(greetHour)
|
|
2180
2399
|
clock.after(800, () => say(pick(greetPool(greetName))))
|
|
2181
2400
|
|
|
2401
|
+
/* 连续天数:第 2 天起才提(第一天说"连续第 1 天"很怪);到里程碑单独说一句。 */
|
|
2402
|
+
const streak = streakOf(memory.days)
|
|
2403
|
+
if (streak >= 2 && cfg.memory) {
|
|
2404
|
+
const hitMilestone = STREAK_MILESTONES.indexOf(streak) >= 0
|
|
2405
|
+
clock.after(2600, () => {
|
|
2406
|
+
if (destroyed) return
|
|
2407
|
+
const text = pick(hitMilestone ? LINES.milestone : LINES.streak).split('{n}').join(String(streak))
|
|
2408
|
+
flashHappy(text)
|
|
2409
|
+
})
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2182
2412
|
/* ---- 她更新了吗 --------------------------------------------------------
|
|
2183
2413
|
插件不能联网,所以"检查更新"它做不了。但**它知道自己现在是哪一版** —— 于是能做到
|
|
2184
2414
|
一件真事:上次见到的是旧版本,这次不是了,那就是更新过了,说一句。
|
|
@@ -2383,6 +2613,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2383
2613
|
showUpdate,
|
|
2384
2614
|
checkForUpdate,
|
|
2385
2615
|
/* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
|
|
2616
|
+
/* 专注计时器:脚本与测试都能驱动 */
|
|
2617
|
+
startFocus,
|
|
2618
|
+
stopFocus,
|
|
2619
|
+
get focus() { return focusState() },
|
|
2386
2620
|
openAbout: () => aboutDialog(),
|
|
2387
2621
|
openActions: () => actionsDialog(),
|
|
2388
2622
|
closeDialog,
|
|
@@ -2501,6 +2735,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2501
2735
|
},
|
|
2502
2736
|
/* 干饭:不用猜"她为什么突然捧碗" */
|
|
2503
2737
|
hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
|
|
2738
|
+
/* 深夜:窗口是可配的,所以把算出来的窗口一起报出来 */
|
|
2739
|
+
night: {
|
|
2740
|
+
enabled: cfg.night === true,
|
|
2741
|
+
window: [cfg.bedtimeHour, cfg.wakeHour],
|
|
2742
|
+
deepFrom: deepHourOf(cfg.bedtimeHour),
|
|
2743
|
+
hour: hourNow(),
|
|
2744
|
+
lastAt: Math.round(lastNightAt),
|
|
2745
|
+
},
|
|
2746
|
+
streak: streakOf(memory.days),
|
|
2747
|
+
awaySince: Math.round(awayAt),
|
|
2748
|
+
/* 专注:不用猜"她为什么一直站在干活中" */
|
|
2749
|
+
focus: focusState(),
|
|
2750
|
+
/* 定时器是否挂着;页面藏起来时三个都该是 false */
|
|
2751
|
+
timers: {
|
|
2752
|
+
poll: pollHandle !== null, chatter: chatterHandle !== null,
|
|
2753
|
+
wander: wanderHandle !== null, paused,
|
|
2754
|
+
},
|
|
2504
2755
|
/* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
|
|
2505
2756
|
runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
|
|
2506
2757
|
/* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
|
|
@@ -2541,6 +2792,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
2541
2792
|
clock.dispose()
|
|
2542
2793
|
for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake)
|
|
2543
2794
|
window.removeEventListener('resize', onResize)
|
|
2795
|
+
if (typeof document.removeEventListener === 'function') {
|
|
2796
|
+
document.removeEventListener('visibilitychange', onVisibility)
|
|
2797
|
+
}
|
|
2544
2798
|
window.removeEventListener('pointermove', onMove)
|
|
2545
2799
|
window.removeEventListener('pointerup', onUp)
|
|
2546
2800
|
bodyEl.removeEventListener('pointerdown', onDown)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-wisp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.30.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/verify-wisp.mjs
CHANGED
|
@@ -320,6 +320,7 @@ function createHarness(options = {}) {
|
|
|
320
320
|
},
|
|
321
321
|
dispatch(type, event) { for (const fn of [...(documentListeners[type] ?? [])]) fn(event ?? {}) },
|
|
322
322
|
get documentListenerCount() { return Object.values(documentListeners).reduce((n, l) => n + l.length, 0) },
|
|
323
|
+
documentListenerCountOf(type) { return (documentListeners[type] ?? []).length },
|
|
323
324
|
createElement: (t) => makeEl(t),
|
|
324
325
|
getElementById: (id) => created.find((e) => e.id === id && !e.removed) ?? null,
|
|
325
326
|
/* 采样点上的元素栈:按"最上面优先"返回。窗口背景那一段要靠它判断
|
|
@@ -2828,8 +2829,11 @@ head('3y. dialogs: about her, and the action preview')
|
|
|
2828
2829
|
dlg.document.dispatch('keydown', { key: 'Escape', preventDefault() {} })
|
|
2829
2830
|
check(dlgApi.dialog === null && dialogGone(), 'Escape closes it',
|
|
2830
2831
|
`dialog=${String(dlgApi.dialog)} 监听数=${dlg.document.documentListenerCount}`)
|
|
2831
|
-
check(dlg.document.
|
|
2832
|
-
'exactly one document-level key listener exists', String(dlg.document.
|
|
2832
|
+
check(dlg.document.documentListenerCountOf('keydown') === 1,
|
|
2833
|
+
'exactly one document-level key listener exists', String(dlg.document.documentListenerCountOf('keydown')))
|
|
2834
|
+
check(dlg.document.documentListenerCountOf('visibilitychange') === 1,
|
|
2835
|
+
'and exactly one visibility listener (added for the come-back greeting)',
|
|
2836
|
+
String(dlg.document.documentListenerCountOf('visibilitychange')))
|
|
2833
2837
|
|
|
2834
2838
|
dlgApi.openActions()
|
|
2835
2839
|
check(dlgApi.dialog === 'actions', 'the action preview opens', String(dlgApi.dialog))
|
|
@@ -2860,8 +2864,9 @@ head('3y. dialogs: about her, and the action preview')
|
|
|
2860
2864
|
check(dlgApi.dialog === null, 'clicking the backdrop closes it too')
|
|
2861
2865
|
|
|
2862
2866
|
for (let i = 0; i < 3; i++) { dlgApi.openAbout(); dlgApi.closeDialog() }
|
|
2863
|
-
check(dlg.document.
|
|
2864
|
-
'opening and closing repeatedly does not stack listeners',
|
|
2867
|
+
check(dlg.document.documentListenerCountOf('keydown') === 1,
|
|
2868
|
+
'opening and closing repeatedly does not stack listeners',
|
|
2869
|
+
String(dlg.document.documentListenerCountOf('keydown')))
|
|
2865
2870
|
|
|
2866
2871
|
dlgApi.openAbout()
|
|
2867
2872
|
dlgApi.destroy()
|
|
@@ -2869,6 +2874,325 @@ head('3y. dialogs: about her, and the action preview')
|
|
|
2869
2874
|
active = keepDlg
|
|
2870
2875
|
}
|
|
2871
2876
|
|
|
2877
|
+
/* ============================== 3y-2. night / come back / streak =========== */
|
|
2878
|
+
head('3y-2. night nagging, coming back, and the streak')
|
|
2879
|
+
|
|
2880
|
+
/* 这三件事都靠**可配置的时间**,所以不需要伪造时钟:
|
|
2881
|
+
把深夜窗口套在当前那个小时上,行为就该发生;套偏一个小时,就不该发生。 */
|
|
2882
|
+
{
|
|
2883
|
+
const hourNowReal = new Date().getHours()
|
|
2884
|
+
const mk = (options) => {
|
|
2885
|
+
const h = createHarness(Object.assign({ timer: true, composerText: '在打字' }, options))
|
|
2886
|
+
h.evaluate(clientSrc)
|
|
2887
|
+
return h
|
|
2888
|
+
}
|
|
2889
|
+
const liveSaid = (h) => h.all('wisp-say').filter((el) => el.removed !== true).map((el) => el.textContent)
|
|
2890
|
+
const saidLines = (h) => liveSaid(h)
|
|
2891
|
+
/* 推进 ms,把过程中出现过的每一句话都收集起来。 */
|
|
2892
|
+
const watch = (h, ms, step = 250) => {
|
|
2893
|
+
const seen = new Set(liveSaid(h))
|
|
2894
|
+
for (let left = ms; left > 0; left -= step) {
|
|
2895
|
+
h.advance(Math.min(step, left), Math.min(step, 200))
|
|
2896
|
+
for (const t of liveSaid(h)) seen.add(t)
|
|
2897
|
+
}
|
|
2898
|
+
return [...seen]
|
|
2899
|
+
}
|
|
2900
|
+
const inPool = (pool, text) => Array.isArray(pool) && pool.includes(text)
|
|
2901
|
+
|
|
2902
|
+
/* ---- 深夜劝睡 ---------------------------------------------------------- */
|
|
2903
|
+
const night = mk()
|
|
2904
|
+
const keepNight = active
|
|
2905
|
+
active = night
|
|
2906
|
+
night.module().default.apply(night.ctx, {
|
|
2907
|
+
reactions: true, wander: false, celebrate: false,
|
|
2908
|
+
careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
|
|
2909
|
+
/* 让"现在"落在深夜窗口里:从当前小时开始,到下一个小时结束 */
|
|
2910
|
+
bedtimeHour: hourNowReal, wakeHour: (hourNowReal + 1) % 24, nightMs: 60000,
|
|
2911
|
+
})
|
|
2912
|
+
night.advance(1300, 100)
|
|
2913
|
+
const nightApi = night.win.__wisp
|
|
2914
|
+
check(nightApi.doctor().night.enabled === true
|
|
2915
|
+
&& nightApi.doctor().night.deepFrom === (hourNowReal + 2) % 24,
|
|
2916
|
+
'doctor reports the night window and where the deeper register starts',
|
|
2917
|
+
JSON.stringify(nightApi.doctor().night))
|
|
2918
|
+
const nightSaid = watch(night, 70000)
|
|
2919
|
+
check(nightSaid.some((t) => inPool(linesInBundle()?.nightLate, t)),
|
|
2920
|
+
'inside the window she speaks up, from the late pool',
|
|
2921
|
+
nightSaid.slice(-2).join(' / ') || '(没说)')
|
|
2922
|
+
check(nightApi.doctor().night.lastAt > 0, 'and the check time is recorded',
|
|
2923
|
+
String(nightApi.doctor().night.lastAt))
|
|
2924
|
+
|
|
2925
|
+
/* 窗口外不该说话 */
|
|
2926
|
+
const day = mk()
|
|
2927
|
+
const keepDay = active
|
|
2928
|
+
active = day
|
|
2929
|
+
day.module().default.apply(day.ctx, {
|
|
2930
|
+
reactions: true, wander: false, celebrate: false,
|
|
2931
|
+
careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
|
|
2932
|
+
bedtimeHour: (hourNowReal + 3) % 24, wakeHour: (hourNowReal + 4) % 24, nightMs: 60000,
|
|
2933
|
+
})
|
|
2934
|
+
day.advance(1300, 100)
|
|
2935
|
+
const daySaid = watch(day, 200000)
|
|
2936
|
+
check(!daySaid.some((t) => inPool(linesInBundle()?.nightLate, t) || inPool(linesInBundle()?.nightDeep, t)),
|
|
2937
|
+
'outside the window she says nothing about bedtime', saidLines(day).slice(-2).join(' / ') || '(没说)')
|
|
2938
|
+
day.win.__wisp.destroy()
|
|
2939
|
+
active = keepDay
|
|
2940
|
+
|
|
2941
|
+
/* 深度深夜:把 bedtime 往前挪两小时,当前小时就落进"嘴硬心软"那一档 */
|
|
2942
|
+
const deep = mk()
|
|
2943
|
+
const keepDeep = active
|
|
2944
|
+
active = deep
|
|
2945
|
+
deep.module().default.apply(deep.ctx, {
|
|
2946
|
+
reactions: true, wander: false, celebrate: false,
|
|
2947
|
+
careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
|
|
2948
|
+
bedtimeHour: (hourNowReal + 22) % 24, wakeHour: (hourNowReal + 1) % 24, nightMs: 60000,
|
|
2949
|
+
})
|
|
2950
|
+
deep.advance(1300, 100)
|
|
2951
|
+
const deepSaid = watch(deep, 70000)
|
|
2952
|
+
check(deepSaid.some((t) => inPool(linesInBundle()?.nightDeep, t)),
|
|
2953
|
+
'two hours past bedtime the register changes', deepSaid.slice(-2).join(' / ') || '(没说)')
|
|
2954
|
+
|
|
2955
|
+
/* 两种关法 */
|
|
2956
|
+
const nightLines = () => [].concat(linesInBundle()?.nightLate ?? [], linesInBundle()?.nightDeep ?? [])
|
|
2957
|
+
deep.win.__wisp.configure({ night: false })
|
|
2958
|
+
check(!watch(deep, 300000).some((t) => nightLines().includes(t)),
|
|
2959
|
+
'night: false turns the whole thing off')
|
|
2960
|
+
deep.win.__wisp.configure({ night: true, nightMs: 0 })
|
|
2961
|
+
check(!watch(deep, 300000).some((t) => nightLines().includes(t)), 'so does nightMs: 0')
|
|
2962
|
+
deep.win.__wisp.destroy()
|
|
2963
|
+
active = keepDeep
|
|
2964
|
+
|
|
2965
|
+
/* ---- 离开又回来 -------------------------------------------------------- */
|
|
2966
|
+
const back = mk()
|
|
2967
|
+
const keepBack = active
|
|
2968
|
+
active = back
|
|
2969
|
+
back.module().default.apply(back.ctx, {
|
|
2970
|
+
reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0,
|
|
2971
|
+
sleepAfterMs: 3600000, backAfterMs: 60000,
|
|
2972
|
+
})
|
|
2973
|
+
back.advance(1300, 100)
|
|
2974
|
+
const backApi = back.win.__wisp
|
|
2975
|
+
/* 替身没有 visibilityState,直接摆上再派发事件 —— 这正是真浏览器里发生的事。 */
|
|
2976
|
+
back.document.visibilityState = 'hidden'
|
|
2977
|
+
back.document.dispatch('visibilitychange', {})
|
|
2978
|
+
back.advance(120000, 1000)
|
|
2979
|
+
back.document.visibilityState = 'visible'
|
|
2980
|
+
back.document.dispatch('visibilitychange', {})
|
|
2981
|
+
const shortBack = watch(back, 400)
|
|
2982
|
+
check(shortBack.some((t) => inPool(linesInBundle()?.backSoon, t)),
|
|
2983
|
+
'coming back after a short while gets the short line', shortBack.slice(-2).join(' / ') || '(没说)')
|
|
2984
|
+
|
|
2985
|
+
back.document.visibilityState = 'hidden'
|
|
2986
|
+
back.document.dispatch('visibilitychange', {})
|
|
2987
|
+
back.advance(30 * 60000, 5000)
|
|
2988
|
+
back.document.visibilityState = 'visible'
|
|
2989
|
+
back.document.dispatch('visibilitychange', {})
|
|
2990
|
+
const longBack = watch(back, 400)
|
|
2991
|
+
check(longBack.some((t) => inPool(linesInBundle()?.backLong, t)),
|
|
2992
|
+
'a long absence gets the other line', longBack.slice(-2).join(' / ') || '(没说)')
|
|
2993
|
+
|
|
2994
|
+
/* 走开一小会儿不该说话(只看"回来池",别把别的行为的话算进来) */
|
|
2995
|
+
const backLines = () => [].concat(linesInBundle()?.backSoon ?? [], linesInBundle()?.backLong ?? [])
|
|
2996
|
+
back.document.visibilityState = 'hidden'
|
|
2997
|
+
back.document.dispatch('visibilitychange', {})
|
|
2998
|
+
back.advance(5000, 1000)
|
|
2999
|
+
back.document.visibilityState = 'visible'
|
|
3000
|
+
back.document.dispatch('visibilitychange', {})
|
|
3001
|
+
check(!watch(back, 600).some((t) => backLines().includes(t)),
|
|
3002
|
+
'a blink of absence is not worth a line')
|
|
3003
|
+
|
|
3004
|
+
back.win.__wisp.configure({ backAfterMs: 0 })
|
|
3005
|
+
back.document.visibilityState = 'hidden'
|
|
3006
|
+
back.document.dispatch('visibilitychange', {})
|
|
3007
|
+
back.advance(30 * 60000, 5000)
|
|
3008
|
+
back.document.visibilityState = 'visible'
|
|
3009
|
+
back.document.dispatch('visibilitychange', {})
|
|
3010
|
+
check(!watch(back, 600).some((t) => backLines().includes(t)),
|
|
3011
|
+
'backAfterMs: 0 turns the greeting off')
|
|
3012
|
+
back.win.__wisp.destroy()
|
|
3013
|
+
active = keepBack
|
|
3014
|
+
|
|
3015
|
+
/* ---- 连续天数 ---------------------------------------------------------- */
|
|
3016
|
+
/* 用 localStorage 直接种一段连续的记录:今天 + 往前三天 = 连续 4 天。 */
|
|
3017
|
+
const keyFor = (offset) => {
|
|
3018
|
+
const d = new Date(Date.now() - offset * 86400000)
|
|
3019
|
+
return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
|
|
3020
|
+
}
|
|
3021
|
+
const memorySeed = {
|
|
3022
|
+
'dsh-wisp:memory:v1': JSON.stringify({
|
|
3023
|
+
days: [keyFor(3), keyFor(2), keyFor(1), keyFor(0)],
|
|
3024
|
+
said: [], corners: {}, skins: {}, remarkDay: keyFor(0),
|
|
3025
|
+
}),
|
|
3026
|
+
}
|
|
3027
|
+
const streak = mk({ storageSeed: memorySeed })
|
|
3028
|
+
const keepStreak = active
|
|
3029
|
+
active = streak
|
|
3030
|
+
streak.module().default.apply(streak.ctx, {
|
|
3031
|
+
reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
|
|
3032
|
+
})
|
|
3033
|
+
const streakSaid = watch(streak, 5000)
|
|
3034
|
+
const streakApi = streak.win.__wisp
|
|
3035
|
+
check(streakApi.doctor().streak === 4, 'doctor reports a four-day streak', String(streakApi.doctor().streak))
|
|
3036
|
+
const streakPool = [].concat(linesInBundle()?.streak ?? [], linesInBundle()?.milestone ?? [])
|
|
3037
|
+
.map((t) => t.split('{n}').join('4'))
|
|
3038
|
+
check(streakSaid.some((t) => streakPool.includes(t)),
|
|
3039
|
+
'and she mentions it the next time you meet', streakSaid.slice(-3).join(' / ') || '(没说)')
|
|
3040
|
+
streak.win.__wisp.destroy()
|
|
3041
|
+
active = keepStreak
|
|
3042
|
+
|
|
3043
|
+
/* 断了就从头数:今天之前缺一天 */
|
|
3044
|
+
const brokenSeed = {
|
|
3045
|
+
'dsh-wisp:memory:v1': JSON.stringify({
|
|
3046
|
+
days: [keyFor(5), keyFor(4), keyFor(0)], said: [], corners: {}, skins: {}, remarkDay: keyFor(0),
|
|
3047
|
+
}),
|
|
3048
|
+
}
|
|
3049
|
+
const broken = mk({ storageSeed: brokenSeed })
|
|
3050
|
+
const keepBroken = active
|
|
3051
|
+
active = broken
|
|
3052
|
+
broken.module().default.apply(broken.ctx, {
|
|
3053
|
+
reactions: true, wander: false, celebrate: false, careAfterMs: 0, hungerMs: 0, sleepAfterMs: 3600000,
|
|
3054
|
+
})
|
|
3055
|
+
const brokenSaid = watch(broken, 5000)
|
|
3056
|
+
check(broken.win.__wisp.doctor().streak === 1,
|
|
3057
|
+
'a gap resets it to one (and one is not worth mentioning)',
|
|
3058
|
+
String(broken.win.__wisp.doctor().streak))
|
|
3059
|
+
const streakPool2 = [].concat(linesInBundle()?.streak ?? [], linesInBundle()?.milestone ?? [])
|
|
3060
|
+
.map((t) => t.split('{n}').join('1'))
|
|
3061
|
+
check(!brokenSaid.some((t) => streakPool2.includes(t)),
|
|
3062
|
+
'so she stays quiet about it', brokenSaid.slice(-2).join(' / ') || '(没说)')
|
|
3063
|
+
broken.win.__wisp.destroy()
|
|
3064
|
+
active = keepBroken
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
/* ============================== 3y-3. focus timer & hidden pause ========== */
|
|
3068
|
+
head('3y-3. the focus timer, and going quiet while the page is hidden')
|
|
3069
|
+
|
|
3070
|
+
{
|
|
3071
|
+
const watch = (h, ms, step = 250) => {
|
|
3072
|
+
const live = () => h.all('wisp-say').filter((el) => el.removed !== true).map((el) => el.textContent)
|
|
3073
|
+
const seen = new Set(live())
|
|
3074
|
+
for (let left = ms; left > 0; left -= step) {
|
|
3075
|
+
h.advance(Math.min(step, left), Math.min(step, 200))
|
|
3076
|
+
for (const t of live()) seen.add(t)
|
|
3077
|
+
}
|
|
3078
|
+
return [...seen]
|
|
3079
|
+
}
|
|
3080
|
+
const strip = (pool, n) => (pool ?? []).map((t) => t.split('{n}').join(String(n)))
|
|
3081
|
+
const moodOf = (h) => h.find('wisp-root').dataset.mood
|
|
3082
|
+
|
|
3083
|
+
/* ---- 专注计时器 -------------------------------------------------------- */
|
|
3084
|
+
const focus = createHarness({ timer: true, composerText: '' })
|
|
3085
|
+
const keepFocus = active
|
|
3086
|
+
active = focus
|
|
3087
|
+
focus.evaluate(clientSrc)
|
|
3088
|
+
focus.module().default.apply(focus.ctx, {
|
|
3089
|
+
reactions: true, wander: false, celebrate: false,
|
|
3090
|
+
careAfterMs: 0, hungerMs: 0, night: false, sleepAfterMs: 3600000,
|
|
3091
|
+
})
|
|
3092
|
+
focus.advance(1300, 100)
|
|
3093
|
+
const focusApi = focus.win.__wisp
|
|
3094
|
+
|
|
3095
|
+
check(focusApi.focus.active === false, 'no focus session at the start')
|
|
3096
|
+
check(focusApi.doctor().focus.active === false && focusApi.doctor().focus.done === 0,
|
|
3097
|
+
'and doctor says so', JSON.stringify(focusApi.doctor().focus))
|
|
3098
|
+
|
|
3099
|
+
const started = focusApi.startFocus(25)
|
|
3100
|
+
check(started.active === true && started.minutes === 25 && started.leftMin === 25,
|
|
3101
|
+
'starting a session reports it back', JSON.stringify(started))
|
|
3102
|
+
check(focusApi.doctor().focus.active === true, 'doctor sees the session too')
|
|
3103
|
+
const startSaid = watch(focus, 1200)
|
|
3104
|
+
check(startSaid.some((t) => strip(linesInBundle()?.focusStart, 25).includes(t)),
|
|
3105
|
+
'and she says the session started', startSaid.slice(-1).join('') || '(没说)')
|
|
3106
|
+
|
|
3107
|
+
/* 专注期间即使她没在忙,姿势也该是"干活中" —— 稳态不许把她挪走 */
|
|
3108
|
+
focus.advance(120000, 5000)
|
|
3109
|
+
check(moodOf(focus) === 'alert', 'she holds the working pose for the whole session', moodOf(focus))
|
|
3110
|
+
|
|
3111
|
+
/* 戳她 = 问还剩多久,而且不算戳着玩 */
|
|
3112
|
+
const pokeOnce = () => {
|
|
3113
|
+
const b = focus.find('wisp-body')
|
|
3114
|
+
b.dispatch('pointerdown', {
|
|
3115
|
+
button: 0, clientX: focusApi.position.x + 260, clientY: focusApi.position.y + 400,
|
|
3116
|
+
preventDefault() {}, stopPropagation() {},
|
|
3117
|
+
})
|
|
3118
|
+
focus.win.dispatch('pointerup', {})
|
|
3119
|
+
focus.advance(200, 50)
|
|
3120
|
+
}
|
|
3121
|
+
/* 24 分钟时戳她 —— 报的应该是"还剩 24 分钟"这类的话 */
|
|
3122
|
+
pokeOnce()
|
|
3123
|
+
const pokeSaid = watch(focus, 800)
|
|
3124
|
+
const leftPool = [].concat(strip(linesInBundle()?.focusLeft, 24), strip(linesInBundle()?.focusLeft, 23))
|
|
3125
|
+
check(pokeSaid.some((t) => leftPool.includes(t)),
|
|
3126
|
+
'poking her during a session asks how long is left', pokeSaid.slice(-2).join(' / ') || '(没说)')
|
|
3127
|
+
check(moodOf(focus) !== 'poked', 'and it does not count as poking for fun', moodOf(focus))
|
|
3128
|
+
|
|
3129
|
+
/* 到点:她说收工,状态回到非激活,计数 +1 */
|
|
3130
|
+
const doneSaid = watch(focus, 26 * 60000, 30000)
|
|
3131
|
+
check(focusApi.focus.active === false, 'the session ends on its own', JSON.stringify(focusApi.focus))
|
|
3132
|
+
check(focusApi.focus.done === 1, 'and the finished count goes up', String(focusApi.focus.done))
|
|
3133
|
+
check(doneSaid.some((t) => strip(linesInBundle()?.focusDone, 25).includes(t)),
|
|
3134
|
+
'and she tells you it is over', doneSaid.slice(-1).join('') || '(没说)')
|
|
3135
|
+
|
|
3136
|
+
/* 参数夹取:0 用默认时长,过大的被夹到 600 */
|
|
3137
|
+
check(focusApi.startFocus(0).minutes === focusApi.config.focusMinutes,
|
|
3138
|
+
'zero falls back to the configured default', String(focusApi.startFocus(0).minutes))
|
|
3139
|
+
check(focusApi.startFocus(9999).minutes === 600, 'an absurd duration is clamped', String(focusApi.focus.minutes))
|
|
3140
|
+
const stopSaid = watch(focus, 400)
|
|
3141
|
+
focusApi.stopFocus()
|
|
3142
|
+
const stopSaid2 = watch(focus, 600)
|
|
3143
|
+
void stopSaid
|
|
3144
|
+
check(focusApi.focus.active === false, 'stopFocus ends it', JSON.stringify(focusApi.focus))
|
|
3145
|
+
check([].concat(stopSaid, stopSaid2).some((t) => (linesInBundle()?.focusStop ?? []).includes(t)),
|
|
3146
|
+
'and she acknowledges the stop', [].concat(stopSaid, stopSaid2).slice(-1).join('') || '(没说)')
|
|
3147
|
+
check(focusApi.stopFocus() === false, 'stopping when nothing is running is a no-op')
|
|
3148
|
+
focus.win.__wisp.destroy()
|
|
3149
|
+
active = keepFocus
|
|
3150
|
+
|
|
3151
|
+
/* ---- 页面藏起来就暂停 -------------------------------------------------- */
|
|
3152
|
+
const quiet = createHarness({ timer: true, composerText: '' })
|
|
3153
|
+
const keepQuiet = active
|
|
3154
|
+
active = quiet
|
|
3155
|
+
quiet.evaluate(clientSrc)
|
|
3156
|
+
quiet.module().default.apply(quiet.ctx, {
|
|
3157
|
+
reactions: true, wander: true, celebrate: false,
|
|
3158
|
+
careAfterMs: 0, hungerMs: 0, night: false, sleepAfterMs: 3600000, backAfterMs: 60000,
|
|
3159
|
+
})
|
|
3160
|
+
quiet.advance(1300, 100)
|
|
3161
|
+
const quietApi = quiet.win.__wisp
|
|
3162
|
+
check(quietApi.doctor().timers.poll === true && quietApi.doctor().timers.paused === false,
|
|
3163
|
+
'while visible the poll is armed', JSON.stringify(quietApi.doctor().timers))
|
|
3164
|
+
|
|
3165
|
+
quiet.document.visibilityState = 'hidden'
|
|
3166
|
+
quiet.document.dispatch('visibilitychange', {})
|
|
3167
|
+
const timers = quietApi.doctor().timers
|
|
3168
|
+
check(timers.paused === true && timers.poll === false && timers.chatter === false && timers.wander === false,
|
|
3169
|
+
'hiding the page stops all three timers', JSON.stringify(timers))
|
|
3170
|
+
|
|
3171
|
+
/* 藏起来期间:来了一堆报错,她也不该有任何反应(轮询停了) */
|
|
3172
|
+
quiet.state.errors = 3
|
|
3173
|
+
quiet.state.busy = true
|
|
3174
|
+
quiet.advance(2000, 500)
|
|
3175
|
+
quiet.state.busy = false
|
|
3176
|
+
quiet.advance(20000, 2000)
|
|
3177
|
+
check(moodOf(quiet) !== 'worried', 'a hidden page gets no reaction at all', moodOf(quiet))
|
|
3178
|
+
check(quiet.all('wisp-say').filter((el) => el.removed !== true).length === 0,
|
|
3179
|
+
'and she stays completely quiet', String(quiet.all('wisp-say').length))
|
|
3180
|
+
|
|
3181
|
+
quiet.document.visibilityState = 'visible'
|
|
3182
|
+
quiet.document.dispatch('visibilitychange', {})
|
|
3183
|
+
const after = quietApi.doctor().timers
|
|
3184
|
+
check(after.paused === false && after.poll === true && after.wander === true,
|
|
3185
|
+
'coming back re-arms them', JSON.stringify(after))
|
|
3186
|
+
/* 回来之后同一批报错就该被看见了 */
|
|
3187
|
+
quiet.state.busy = true
|
|
3188
|
+
quiet.advance(1300, 100)
|
|
3189
|
+
quiet.state.busy = false
|
|
3190
|
+
quiet.advance(3000, 200)
|
|
3191
|
+
check(moodOf(quiet) === 'worried', 'and now the same errors do get a reaction', moodOf(quiet))
|
|
3192
|
+
quiet.win.__wisp.destroy()
|
|
3193
|
+
active = keepQuiet
|
|
3194
|
+
}
|
|
3195
|
+
|
|
2872
3196
|
/* ================================================== 3z. generation audit ==== */
|
|
2873
3197
|
head('3z. every shipped sprite traces back to a text-only call')
|
|
2874
3198
|
|