dsh-wisp 0.6.0 → 1.25.1
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 +869 -10
- package/assets/canon/attn.webp +0 -0
- package/assets/canon/happy.webp +0 -0
- package/assets/canon/idle.webp +0 -0
- package/assets/canon/poked.webp +0 -0
- package/assets/canon/proud.webp +0 -0
- package/assets/canon/sleepy.webp +0 -0
- package/assets/canon/work.webp +0 -0
- package/assets/classic/attn.webp +0 -0
- package/assets/classic/happy.webp +0 -0
- package/assets/classic/idle.webp +0 -0
- package/assets/classic/poked.webp +0 -0
- package/assets/classic/proud.webp +0 -0
- package/assets/classic/sleepy.webp +0 -0
- package/assets/classic/work.webp +0 -0
- package/assets/deepsea/attn.webp +0 -0
- package/assets/deepsea/happy.webp +0 -0
- package/assets/deepsea/idle.webp +0 -0
- package/assets/deepsea/poked.webp +0 -0
- package/assets/deepsea/proud.webp +0 -0
- package/assets/deepsea/sleepy.webp +0 -0
- package/assets/deepsea/work.webp +0 -0
- package/assets/night/attn.webp +0 -0
- package/assets/night/happy.webp +0 -0
- package/assets/night/idle.webp +0 -0
- package/assets/night/poked.webp +0 -0
- package/assets/night/proud.webp +0 -0
- package/assets/night/sleepy.webp +0 -0
- package/assets/night/work.webp +0 -0
- package/assets/pajama/attn.webp +0 -0
- package/assets/pajama/happy.webp +0 -0
- package/assets/pajama/idle.webp +0 -0
- package/assets/pajama/poked.webp +0 -0
- package/assets/pajama/proud.webp +0 -0
- package/assets/pajama/sleepy.webp +0 -0
- package/assets/pajama/work.webp +0 -0
- package/audit/generation-audit.jsonl +35 -0
- package/build.mjs +93 -30
- package/cordis.patch.yml +2 -0
- package/lib/client.js +1293 -41
- package/lib/client.template.js +1248 -37
- package/lib/index.js +115 -12
- package/package.json +8 -1
- package/tools/CHARACTER-PROMPT.md +221 -3
- package/tools/assets.mjs +39 -16
- package/tools/audit-log.mjs +245 -0
- package/tools/bundle-cost.mjs +105 -0
- package/tools/pack-check.mjs +78 -0
- package/tools/perf.mjs +186 -0
- package/tools/smoke.mjs +577 -0
- package/tools/tiers.mjs +104 -0
- package/verify-wisp.mjs +2681 -872
- package/assets/happy.webp +0 -0
- package/assets/happy_hi.webp +0 -0
- package/assets/happy_md.webp +0 -0
- package/assets/happy_sm.webp +0 -0
- package/assets/idle.webp +0 -0
- package/assets/idle_hi.webp +0 -0
- package/assets/idle_md.webp +0 -0
- package/assets/idle_sm.webp +0 -0
- package/assets/sleepy.webp +0 -0
- package/assets/sleepy_hi.webp +0 -0
- package/assets/sleepy_md.webp +0 -0
- package/assets/sleepy_sm.webp +0 -0
- package/assets/work.webp +0 -0
- package/assets/work_hi.webp +0 -0
- package/assets/work_md.webp +0 -0
- package/assets/work_sm.webp +0 -0
package/lib/client.template.js
CHANGED
|
@@ -43,16 +43,49 @@
|
|
|
43
43
|
========================================================================== */
|
|
44
44
|
window.__ModuleLoader__.load({
|
|
45
45
|
id: 'dsh-wisp/client',
|
|
46
|
-
|
|
46
|
+
/* 参数故意不叫 `require`:客户端半包里 `require` 是**被陷阱的全局**(一调用就抛),
|
|
47
|
+
而构建守卫对源码做正则,无法区分"被陷阱的全局"与"遮蔽它的工厂参数"。叫
|
|
48
|
+
`loadModule` 让"这是模块系统的解析面"成为代码里看得见的事实,守卫也就能保持严格。 */
|
|
49
|
+
factory: (loadModule) => {
|
|
47
50
|
var module = { exports: {} }
|
|
48
51
|
var exports = module.exports
|
|
49
52
|
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
50
53
|
|
|
51
|
-
const VERSION = '
|
|
54
|
+
const VERSION = '1.25.1'
|
|
55
|
+
|
|
56
|
+
/* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
|
|
57
|
+
忘了更新就会红,不会静默过期。 */
|
|
58
|
+
const WHATS_NEW = {
|
|
59
|
+
'1.16.0': '按社区规范换上鲸鳍耳。',
|
|
60
|
+
'1.17.0': '鳍耳缩小、加上呆毛。',
|
|
61
|
+
'1.18.0': '新增原版女仆的完整提示词。',
|
|
62
|
+
'1.20.0': '第五套皮肤「原版女仆」上线。',
|
|
63
|
+
'1.21.0': '去掉双马尾,五套皮肤统一长直发。',
|
|
64
|
+
'1.22.0': '生成审计日志:素材来源可自查。',
|
|
65
|
+
'1.23.0': '滚轮调大小、方向键挪位置、躲起来也能开菜单。',
|
|
66
|
+
'1.24.0': '她会报自己的版本,更新过会告诉你,还能把包名复制给你。',
|
|
67
|
+
'1.25.0': '她真的会去查有没有新版本了。',
|
|
68
|
+
'1.25.1': '内容与 1.25.0 相同,重新发布以修好安装源。',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
|
|
72
|
+
宿主半包跑在 vm 沙箱里,harness 只给 { defineTool, registerTool, handle },
|
|
73
|
+
沙箱里还有 nodeApiTraps() 挡着 Node API —— 没有网络、没有文件系统。
|
|
74
|
+
DSH 的插件安装器也没有以可注入服务的形式暴露出来。
|
|
75
|
+
所以这里只做三件真能做的事:报版本、说她更新了、把包名复制给你去 DSH 里装。 */
|
|
76
|
+
const SEEN_VERSION_KEY = 'dsh-wisp:seen-version:v1'
|
|
77
|
+
const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
|
|
52
78
|
|
|
53
79
|
/* ---- sprites: 256x384 WebP, transparent, inlined by build.mjs ---- */
|
|
54
80
|
const SPRITES = __SPRITES_LITERAL__
|
|
55
81
|
|
|
82
|
+
/* 皮肤显示名。没在这里登记的皮肤直接用它的目录名 —— 所以增删皮肤只需要
|
|
83
|
+
放素材 + 重新构建,不必改这一段。 */
|
|
84
|
+
const SKIN_LABELS = { deepsea: '深海女仆', canon: '原版女仆', classic: '素绘女仆', night: '宵蓝礼服', pajama: '宵眠睡衣', default: '默认' }
|
|
85
|
+
/* 皮肤 id 表放在模块级:mount 里恢复"上次选的皮肤"时就要用它,那时 mount
|
|
86
|
+
内部的 const 还没求值(写成 mount 内的 const 会踩 TDZ)。 */
|
|
87
|
+
const SKIN_IDS = Object.keys(SPRITES)
|
|
88
|
+
|
|
56
89
|
/* ---------------------------------------------------------------------
|
|
57
90
|
SPRITE URL RESOLUTION — why this is not simply `img.src = dataUri`.
|
|
58
91
|
|
|
@@ -113,6 +146,7 @@ window.__ModuleLoader__.load({
|
|
|
113
146
|
const SAY_CLEARANCE = 84 // room the bubble needs above its anchor
|
|
114
147
|
const POLL_MS = 1200 // reaction poll
|
|
115
148
|
const WAKE_THROTTLE_MS = 1000
|
|
149
|
+
const WORRIED_MS = 3500 // 出错表情持续多久
|
|
116
150
|
|
|
117
151
|
const DEFAULTS = {
|
|
118
152
|
bottom: GROUND,
|
|
@@ -124,23 +158,130 @@ window.__ModuleLoader__.load({
|
|
|
124
158
|
celebrate: true, // cheer when a run finishes
|
|
125
159
|
celebrateAfterMs: 2500, // ...but only for runs that lasted this long
|
|
126
160
|
chatterMs: 0, // 0 = she stays quiet when idle
|
|
161
|
+
careAfterMs: 5400000, // 连续活动这么久之后提醒你起来动动(90 分钟;0 = 关)
|
|
162
|
+
milestones: true, // 今日轮次到达里程碑时得意一下
|
|
163
|
+
memory: true, // 她记着你:一起多少天 / 常放哪个角 / 常穿哪套
|
|
127
164
|
happyMs: 1100,
|
|
128
165
|
wander: true, // stroll around on her own while idle
|
|
129
166
|
wanderMs: 45000, // how often she considers moving
|
|
130
167
|
wanderRange: 260, // furthest a single stroll may travel
|
|
168
|
+
skin: '', // '' = 上次选的那套;也可点名一套皮肤
|
|
169
|
+
hidden: false, // true = 挂载时就是躲起来的状态(只能强制隐藏,恢复走菜单)
|
|
170
|
+
/* 让窗口的 DWM 背景材质(Mica/Acrylic)真正露出来:清掉"铺满视口的不透明底色"。
|
|
171
|
+
桌面窗口有两种"透":
|
|
172
|
+
1) 整窗 alpha(SetLayeredWindowAttributes + LWA_ALPHA)—— 桌面不模糊地透过来,
|
|
173
|
+
但**窗内每一个像素**都被乘掉,包括她的精灵图,于是她跟着一起透;
|
|
174
|
+
2) DWM 背景材质(Mica/Acrylic)—— 只有窗口背景透,窗内内容保持不透明。
|
|
175
|
+
2 正是"壁纸可见 + 她实心",但它要求页面在背景处不画不透明像素。
|
|
176
|
+
默认关:这是一处影响整个应用的视觉改动,不该由她替别人做主。 */
|
|
177
|
+
backdrop: false,
|
|
131
178
|
}
|
|
132
179
|
|
|
180
|
+
/* ---------------------------------------------------------------------
|
|
181
|
+
台词表。
|
|
182
|
+
|
|
183
|
+
她是个"长期在场"的东西:一天点几十次、看几百轮任务。3–6 句的池子在一周内就会被
|
|
184
|
+
看穿,所以每个场景的池子都留得足够宽,且按**时段**分四套招呼语 —— 凌晨三点和上午
|
|
185
|
+
十点不该说同一句话。
|
|
186
|
+
|
|
187
|
+
语气基准:安静、干、偶尔自嘲,不谄媚也不聒噪;她和你并排坐着干活,不是客服。
|
|
188
|
+
--------------------------------------------------------------------- */
|
|
133
189
|
const LINES = {
|
|
134
|
-
hello: ['我在这儿。', '灯亮着,你忙你的。', '需要我就戳一下。'],
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
190
|
+
hello: ['我在这儿。', '灯亮着,你忙你的。', '需要我就戳一下。', '我又来了。', '还在老位置。'],
|
|
191
|
+
|
|
192
|
+
// 招呼语按时段取,见下面的 greetPoolName()
|
|
193
|
+
morning: ['早上好。', '早,今天从哪儿开始?', '醒了?我比你先到。', '早上安静,适合干活。', '早上的脑子最好用,别浪费。'],
|
|
194
|
+
afternoon: ['下午了。', '这一下午还长着呢。', '午后最容易走神,我盯着你。', '下午的光比上午暖。', '喝口水吧,还早。'],
|
|
195
|
+
evening: ['到晚上了。', '天黑了,灯打开没?', '晚上的你和白天的你不太一样。', '入夜了,剩下的慢慢来。', '这个点最容易一口气干到天亮。'],
|
|
196
|
+
night: ['这么晚还在?', '夜深了,别硬撑。', '凌晨的屏幕最亮。', '我不催你,但我会记着时间。', '夜里的话少,正好干活。'],
|
|
197
|
+
|
|
198
|
+
click: [
|
|
199
|
+
'嗯?', '在的。', '别戳啦。', '……痒。', '有事说,没事也成。', '我一直看着屏幕呢。',
|
|
200
|
+
'你再戳一下我就不客气了。', '干嘛?', '在呢在呢。', '戳我一百下也不会多出一条信息。',
|
|
201
|
+
'手闲了?', '我在这儿待着挺好的。', '有点痒,但是不讨厌。', '你想说什么就说吧。',
|
|
202
|
+
],
|
|
203
|
+
happy: [
|
|
204
|
+
'叮——', '这个我喜欢。', '再来一次?', '哦?', '难得。', '嗯,这算好事。', '记下了。',
|
|
205
|
+
'你心情不错。', '看得出来今天顺。',
|
|
206
|
+
],
|
|
207
|
+
sleep: [
|
|
208
|
+
'呼……', '我眯一会儿。', '……先睡了。', '撑不住了,抱歉。', '有事叫我,我听得见。', '你忙,我躺会儿。',
|
|
209
|
+
'眼睛闭上了,耳朵还开着。', '别看我,我睡着了。',
|
|
210
|
+
],
|
|
211
|
+
wake: [
|
|
212
|
+
'醒了醒了。', '嗯,我在。', '谁?……哦,是你。', '没睡着,只是闭着眼。', '来了。', '继续吧。',
|
|
213
|
+
'刚才梦到海了。', '一点动静就醒。',
|
|
214
|
+
],
|
|
215
|
+
done: [
|
|
216
|
+
'跑完了。', '收了。', '这一轮结束。', '去看看?', '结束了,轮到你。', '干得不错。',
|
|
217
|
+
'这轮比上轮快。', '我数着步数呢,收工。', '好了,喘口气。', '结论在上面,自己看。',
|
|
218
|
+
],
|
|
219
|
+
chatter: [
|
|
220
|
+
'……', '有点安静。', '我在数窗外的光。', '你打字的声音挺好听的。', '刚才那行删掉又打了一遍。',
|
|
221
|
+
'我不打扰你,就是想说句话。', '这个字你打错了三次。', '屏幕有点烫。', '你喝水了吗。',
|
|
222
|
+
'我在这儿看了很久了。',
|
|
223
|
+
],
|
|
224
|
+
move: [
|
|
225
|
+
'这儿也行。', '我挪个位置。', '让让,我站这儿。', '换个角度。', '这边清楚点。', '不挡你吧?',
|
|
226
|
+
'挪好了。', '新位置,一样站得住。',
|
|
227
|
+
],
|
|
228
|
+
home: [
|
|
229
|
+
'回来了。', '还是老地方。', '嗯,归位。', '兜一圈还是这儿最好。', '角落最省地方。', '站稳了。',
|
|
230
|
+
'老位置,踏实。', '就这儿吧。',
|
|
231
|
+
],
|
|
232
|
+
error: [
|
|
233
|
+
'出错了。', '好像不太顺。', '这一下没成。', '要不要看一眼报错?', '红的。去看一眼。',
|
|
234
|
+
'这次不怪你……也不一定。', '停下来了,可能需要你。', '又来了,先别急。', '报错在下面。',
|
|
235
|
+
],
|
|
236
|
+
attn: [
|
|
237
|
+
'有人在等你回答。', '这里需要你点一下。', '轮到你了。', '它停下来了,等你。', '别让它干等着。',
|
|
238
|
+
'要你拍板。', '你看一眼这里。', '这一步只有你能过。',
|
|
239
|
+
],
|
|
240
|
+
poked: [
|
|
241
|
+
'等一下。', '别戳了别戳了。', '……你干嘛。', '我在这儿又不会跑。', '手拿开。', '再戳我就躲起来了。',
|
|
242
|
+
'有点过分了啊。', '戳坏了你赔。', '我抗议。', '停。',
|
|
243
|
+
],
|
|
244
|
+
/* 久坐提醒:她是"盯着你干活"的那个,所以这件事本来就该由她说。 */
|
|
245
|
+
/* 今日轮次里程碑:台词里的 {n} 会被替换成实际轮数 */
|
|
246
|
+
/* 她记着的那些事。带 {n}/{c}/{s} 的占位符在说话时替换。 */
|
|
247
|
+
days: [
|
|
248
|
+
'我们一起 {n} 天了。', '第 {n} 天 —— 记着呢。', '{n} 天了,居然还在。', '今天是第 {n} 天。',
|
|
249
|
+
],
|
|
250
|
+
habitCorner: [
|
|
251
|
+
'你总把我放在{c}。', '又是{c} —— 你习惯那儿了。', '{c} 都快成我的位置了。',
|
|
252
|
+
],
|
|
253
|
+
habitSkin: [
|
|
254
|
+
'你最近常让我穿{s}。', '{s} —— 你好像挺喜欢这套。', '又是{s},我都快忘了别的了。',
|
|
255
|
+
],
|
|
256
|
+
/* 检查更新用的三句。前两句带结果,第三句是"查不了/没查成"。 */
|
|
257
|
+
updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
|
|
258
|
+
updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
|
|
259
|
+
updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
|
|
260
|
+
milestone: [
|
|
261
|
+
'今天第 {n} 轮了。', '{n} 轮了,还不错。', '已经 {n} 轮了,你挺能撑。',
|
|
262
|
+
'第 {n} 轮 —— 我数着呢。', '{n} 轮,进度可观。', '今天 {n} 轮,记上了。',
|
|
263
|
+
'第 {n} 轮,干得不错。', '{n} 轮了,比昨天多?',
|
|
264
|
+
],
|
|
265
|
+
care: [
|
|
266
|
+
'你已经坐很久了。', '起来走两步吧,我不跑。', '水杯空了吧。', '眼睛也要休息。',
|
|
267
|
+
'我数着时间呢。', '站起来伸个懒腰。', '再坐下去腰会抗议的。', '别一坐就是一整天。',
|
|
268
|
+
'去窗边看看。', '我在这儿等你回来。', '坐太久了,动一下。', '你不动,我就一直看着你。',
|
|
269
|
+
],
|
|
270
|
+
hint: ['右键我一下试试。', '右键我有菜单。', '拖我,或者右键我。'],
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* 时段划分:凌晨与白天说话的口气不一样。深夜跨了两天,所以按"< 5 点"算夜里。 */
|
|
274
|
+
const greetPoolName = (hour) => {
|
|
275
|
+
if (hour < 5 || hour >= 22) return 'night'
|
|
276
|
+
if (hour < 11) return 'morning'
|
|
277
|
+
if (hour < 18) return 'afternoon'
|
|
278
|
+
return 'evening'
|
|
279
|
+
}
|
|
280
|
+
const greetPool = (name) => (LINES[name] && LINES[name].length ? LINES[name] : LINES.hello)
|
|
281
|
+
const hourNow = () => {
|
|
282
|
+
try { return new Date().getHours() } catch (e) { return 12 }
|
|
143
283
|
}
|
|
284
|
+
|
|
144
285
|
const pick = (a) => a[Math.floor(Math.random() * a.length)]
|
|
145
286
|
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
|
|
146
287
|
|
|
@@ -180,6 +321,9 @@ window.__ModuleLoader__.load({
|
|
|
180
321
|
sleepAfterMs: { kind: 'num', lo: 5000, hi: 3600000 },
|
|
181
322
|
celebrateAfterMs: { kind: 'num', lo: 0, hi: 600000 },
|
|
182
323
|
chatterMs: { kind: 'num', lo: 0, hi: 3600000 },
|
|
324
|
+
careAfterMs: { kind: 'num', lo: 0, hi: 86400000 },
|
|
325
|
+
milestones: { kind: 'bool' },
|
|
326
|
+
memory: { kind: 'bool' },
|
|
183
327
|
happyMs: { kind: 'num', lo: 200, hi: 10000 },
|
|
184
328
|
wanderMs: { kind: 'num', lo: 5000, hi: 3600000 },
|
|
185
329
|
wanderRange: { kind: 'num', lo: 0, hi: 2000 },
|
|
@@ -187,11 +331,16 @@ window.__ModuleLoader__.load({
|
|
|
187
331
|
persist: { kind: 'bool' },
|
|
188
332
|
celebrate: { kind: 'bool' },
|
|
189
333
|
wander: { kind: 'bool' },
|
|
334
|
+
hidden: { kind: 'bool' },
|
|
335
|
+
backdrop: { kind: 'bool' },
|
|
336
|
+
skin: { kind: 'str' },
|
|
190
337
|
}
|
|
191
338
|
|
|
192
339
|
const coerce = (key, value, fallback) => {
|
|
193
340
|
const spec = CONFIG_SPEC[key]
|
|
194
|
-
|
|
341
|
+
if (spec.kind === 'num') return num(value, spec.lo, spec.hi, fallback)
|
|
342
|
+
if (spec.kind === 'str') return typeof value === 'string' ? value : fallback
|
|
343
|
+
return bool(value, fallback)
|
|
195
344
|
}
|
|
196
345
|
|
|
197
346
|
const normalizeConfig = (config) => {
|
|
@@ -323,6 +472,10 @@ window.__ModuleLoader__.load({
|
|
|
323
472
|
just means "no remembered position".
|
|
324
473
|
--------------------------------------------------------------------- */
|
|
325
474
|
const POSITION_KEY = 'dsh-wisp:position:v1'
|
|
475
|
+
const SKIN_KEY = 'dsh-wisp:skin:v1'
|
|
476
|
+
const HIDDEN_KEY = 'dsh-wisp:hidden:v1'
|
|
477
|
+
const PREFS_KEY = 'dsh-wisp:prefs:v1'
|
|
478
|
+
const HINT_KEY = 'dsh-wisp:hint:v1'
|
|
326
479
|
|
|
327
480
|
const readStored = (key) => {
|
|
328
481
|
try {
|
|
@@ -379,7 +532,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
379
532
|
.wisp-root[data-mood="sleep"] .wisp-img{filter:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.82) brightness(.96)}
|
|
380
533
|
@keyframes wisp-bob{0%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-9px,0)}100%{transform:translate3d(0,0,0)}}
|
|
381
534
|
@keyframes wisp-zzz{0%{opacity:0;transform:translate(0,0) scale(.6)}30%{opacity:.9}100%{opacity:0;transform:translate(14px,-30px) scale(1.15)}}
|
|
535
|
+
.wisp-root[data-mood="attn"]{--wisp-aura:rgba(255,138,120,.55)}
|
|
536
|
+
.wisp-root[data-mood="worried"]{--wisp-aura:rgba(150,170,190,.5)}
|
|
537
|
+
.wisp-root[data-mood="worried"] .wisp-img{filter:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.86)}
|
|
538
|
+
.wisp-root[data-mood="worried"] .wisp-body{animation-name:wisp-worry;animation-duration:2.4s;animation-iteration-count:3}
|
|
539
|
+
@keyframes wisp-worry{0%,100%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-5px,0)}}
|
|
540
|
+
.wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
|
|
541
|
+
@keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
|
|
382
542
|
@keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
|
|
543
|
+
.wisp-menu{position:absolute;min-width:150px;padding:5px;border-radius:11px;font-size:12px;line-height:1.2;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 10px 30px rgba(0,0,0,.28);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);pointer-events:auto;animation:wisp-menu .14s ease}
|
|
544
|
+
.wisp-menu-item{display:block;width:100%;padding:7px 10px;border:0;border-radius:7px;background:none;color:inherit;font:inherit;text-align:left;cursor:pointer;white-space:nowrap}
|
|
545
|
+
.wisp-menu-item:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.22))}
|
|
546
|
+
.wisp-menu-head{padding:7px 10px 3px;font-size:11px;opacity:.62;letter-spacing:.04em}
|
|
547
|
+
.wisp-root[data-hidden="true"]{display:none}
|
|
548
|
+
.wisp-tab{position:absolute;width:56px;height:56px;padding:0;display:flex;align-items:center;justify-content:center;overflow:hidden;border-radius:16px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));background:var(--dsw-alias-bg-overlay,var(--wisp-say-bg));box-shadow:0 6px 18px rgba(0,0,0,.22);cursor:pointer;pointer-events:auto;animation:wisp-menu .16s ease;transition:transform .16s ease}
|
|
549
|
+
.wisp-tab:hover{transform:translateY(-2px)}
|
|
550
|
+
.wisp-tab img{width:100%;height:100%;object-fit:contain;object-position:bottom center;pointer-events:none}
|
|
551
|
+
@keyframes wisp-menu{0%{opacity:0;transform:scale(.94)}100%{opacity:1;transform:scale(1)}}
|
|
383
552
|
@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)}}
|
|
384
553
|
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
|
|
385
554
|
|
|
@@ -503,6 +672,56 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
503
672
|
|
|
504
673
|
const agentIsBusy = () => !!document.querySelector(STOP_SELECTOR)
|
|
505
674
|
|
|
675
|
+
/* ---------------------------------------------------------------------
|
|
676
|
+
"在等你动手" —— 三个面板各自用 data-* key 标注,语言无关:
|
|
677
|
+
[data-question-key] Agent 提问了,等你回答
|
|
678
|
+
[data-plan-review-key] 计划待你确认
|
|
679
|
+
[data-approval-key] 工具授权待你批准
|
|
680
|
+
元素只在真的有待处理项时才渲染,所以"存在"本身就是信号;key 用来区分
|
|
681
|
+
"新的一件事" —— 同一件事不重复提醒,换了一件才再说一次。
|
|
682
|
+
--------------------------------------------------------------------- */
|
|
683
|
+
const PENDING_ATTRS = ['data-question-key', 'data-plan-review-key', 'data-approval-key']
|
|
684
|
+
const PENDING_SELECTOR = PENDING_ATTRS.map((a) => '[' + a + ']').join(',')
|
|
685
|
+
|
|
686
|
+
const readPending = () => {
|
|
687
|
+
let el = null
|
|
688
|
+
try { el = document.querySelector(PENDING_SELECTOR) } catch (e) { return null }
|
|
689
|
+
if (!el || typeof el.getAttribute !== 'function') return null
|
|
690
|
+
for (let i = 0; i < PENDING_ATTRS.length; i++) {
|
|
691
|
+
const value = el.getAttribute(PENDING_ATTRS[i])
|
|
692
|
+
if (value) return value
|
|
693
|
+
}
|
|
694
|
+
return 'pending'
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/* ---------------------------------------------------------------------
|
|
698
|
+
出错的行。DSH 给失败的工具行与命令卡都标了 data-error="true"(ui-tool 的
|
|
699
|
+
ToolRow、ui-chat 的 GenericCommandCard),所以存在性就是信号。
|
|
700
|
+
|
|
701
|
+
但这是全文档扫描,不能放进每 1.2 秒的轮询里 —— 那会把"空闲时 0 次
|
|
702
|
+
querySelectorAll"的性能结论作废。只在"一轮刚跑完"的那一刻查一次:错误
|
|
703
|
+
只会出现在那个时刻,而且一轮一次,摊到整场会话可以忽略。
|
|
704
|
+
--------------------------------------------------------------------- */
|
|
705
|
+
const ERROR_SELECTOR = '[data-error="true"]'
|
|
706
|
+
|
|
707
|
+
/* 插件在这个页面里用到的主题令牌。doctor() 会把它们逐个读出来 —— 全为空说明
|
|
708
|
+
壳的主题层没起来,亮暗跟随会退化成默认色。 */
|
|
709
|
+
const THEME_TOKENS = [
|
|
710
|
+
'--dsw-alias-label-primary',
|
|
711
|
+
'--dsw-alias-bg-overlay',
|
|
712
|
+
'--dsw-alias-border-l1',
|
|
713
|
+
'--dsw-alias-bg-layer-2',
|
|
714
|
+
'--dsw-alias-brand-primary',
|
|
715
|
+
]
|
|
716
|
+
const countErrors = () => {
|
|
717
|
+
try {
|
|
718
|
+
const found = document.querySelectorAll(ERROR_SELECTOR)
|
|
719
|
+
return found && typeof found.length === 'number' ? found.length : 0
|
|
720
|
+
} catch (e) {
|
|
721
|
+
return 0
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
506
725
|
let composerEl = null
|
|
507
726
|
let composerMisses = 0
|
|
508
727
|
|
|
@@ -528,9 +747,14 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
528
747
|
|
|
529
748
|
const mount = (config, ctx) => {
|
|
530
749
|
const cfg = normalizeConfig(config)
|
|
750
|
+
/* 配置里"显式写了 size"与"用了默认值"必须区分开:前者该压过记忆里的尺寸,
|
|
751
|
+
后者不该 —— 否则用户在菜单里调好的大小会被默认值悄悄顶掉。 */
|
|
752
|
+
const sizeIsExplicit = !!config && typeof config === 'object'
|
|
753
|
+
&& Object.prototype.hasOwnProperty.call(config, 'size') && config.size !== undefined
|
|
531
754
|
const clock = createClock(ctx)
|
|
532
755
|
const styleDispose = installStyles(ctx && typeof ctx.get === 'function' ? ctx.get('styles') : undefined)
|
|
533
756
|
|
|
757
|
+
|
|
534
758
|
const vw = () => window.innerWidth || 1400
|
|
535
759
|
const vh = () => window.innerHeight || 900
|
|
536
760
|
/* The box she OCCUPIES is the base box times the render scale, and every
|
|
@@ -539,6 +763,117 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
539
763
|
the wrong place — invisible at size 1, obvious at any other. */
|
|
540
764
|
const boxW = () => BOX_W * cfg.size
|
|
541
765
|
const boxH = () => BOX_H * cfg.size
|
|
766
|
+
|
|
767
|
+
/* ---------------------------------------------------------------------
|
|
768
|
+
让窗口的 DWM 背景材质露出来(cfg.backdrop,默认关)
|
|
769
|
+
|
|
770
|
+
"壁纸透过来"有两条完全不同的路:
|
|
771
|
+
|
|
772
|
+
1) 整窗 alpha —— SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA)。
|
|
773
|
+
桌面**不模糊**地透过来,因为它是把整窗表面乘一个系数再叠上去。
|
|
774
|
+
代价:窗内**每一个像素**都被乘掉 —— 包括她的精灵图,所以她会跟着一起透。
|
|
775
|
+
这是 `dsh-transparent` 的默认做法(alpha 默认 215)。
|
|
776
|
+
|
|
777
|
+
2) DWM 背景材质 —— DwmSetWindowAttribute(hwnd, 38, Mica|Acrylic)。
|
|
778
|
+
只有**窗口背景**是壁纸,窗内内容(文字、面板、她)保持不透明。
|
|
779
|
+
这正是"壁纸可见 + 她实心",两条要求同时成立。
|
|
780
|
+
|
|
781
|
+
2 的唯一前提:页面在背景处**不画不透明像素**。若应用自己在整个视口上铺了一层底色,
|
|
782
|
+
Mica 就被整块挡住 —— 这时把那层底色清掉即可,Mica 自然露出来。
|
|
783
|
+
|
|
784
|
+
为什么由她来做这件事:她要的是"自己不受整窗 alpha 影响",而唯一能同时满足
|
|
785
|
+
"壁纸可见"的机制就是 2;把 2 卡住的那层底色清掉,是她能自己完成的最小改动,
|
|
786
|
+
不需要去改另一个插件的任何一行。
|
|
787
|
+
--------------------------------------------------------------------- */
|
|
788
|
+
const BACKDROP_STYLE_ID = 'wisp-backdrop'
|
|
789
|
+
|
|
790
|
+
/* 只处理"铺满视口"的元素:采样点上最上面那个画底色的,且尺寸接近整个视口。
|
|
791
|
+
有尺寸下限,所以内容卡片、气泡、菜单都不会被误伤。 */
|
|
792
|
+
const findBackdropBlockers = () => {
|
|
793
|
+
const selectors = new Set()
|
|
794
|
+
const blockers = []
|
|
795
|
+
const skipped = []
|
|
796
|
+
const errors = []
|
|
797
|
+
const seenEls = new Set()
|
|
798
|
+
if (typeof document.elementsFromPoint !== 'function') return { selectors: [], blockers, skipped, errors }
|
|
799
|
+
const spots = [[0.5, 0.5], [0.2, 0.5], [0.8, 0.5], [0.5, 0.85]]
|
|
800
|
+
for (const [fx, fy] of spots) {
|
|
801
|
+
let stack = []
|
|
802
|
+
try { stack = document.elementsFromPoint(vw() * fx, vh() * fy) || [] } catch (e) { continue }
|
|
803
|
+
for (const el of stack) {
|
|
804
|
+
if (!el || el === document.documentElement || el === document.body) continue
|
|
805
|
+
if (seenEls.has(el)) continue // 同一个元素会在多个采样点上出现,只记一次
|
|
806
|
+
seenEls.add(el)
|
|
807
|
+
let bg
|
|
808
|
+
let rect
|
|
809
|
+
try {
|
|
810
|
+
bg = window.getComputedStyle(el).backgroundColor
|
|
811
|
+
rect = el.getBoundingClientRect()
|
|
812
|
+
} catch (e) { continue }
|
|
813
|
+
if (!bg || bg === 'transparent' || bg === 'rgba(0, 0, 0, 0)') continue
|
|
814
|
+
if (rect.width < vw() * 0.8 || rect.height < vh() * 0.3) continue
|
|
815
|
+
blockers.push({ background: bg, size: Math.round(rect.width) + 'x' + Math.round(rect.height) })
|
|
816
|
+
/* 类名要能安全地写进选择器:带数字开头的哈希类名(CSS 里得转义)直接跳过,
|
|
817
|
+
宁可漏掉一层也不要写出无效规则把整张样式表毁掉。 */
|
|
818
|
+
const cls = String(el.className || '').split(/\s+/).filter(Boolean)[0]
|
|
819
|
+
if (cls && /^-?[_a-zA-Z][-_a-zA-Z0-9]*$/.test(cls)) selectors.add(cls)
|
|
820
|
+
else if (cls) skipped.push(cls)
|
|
821
|
+
break // 只看每个采样点上最上面那一层
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return { selectors: [...selectors], blockers, skipped, errors }
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const removeBackdropStyle = () => {
|
|
828
|
+
try {
|
|
829
|
+
const tag = document.getElementById(BACKDROP_STYLE_ID)
|
|
830
|
+
if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
|
|
831
|
+
} catch (e) { /* ignore */ }
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const applyBackdrop = () => {
|
|
835
|
+
const report = { enabled: !!cfg.backdrop, cleared: [], blockers: [], skipped: [], errors: [] }
|
|
836
|
+
/* 关掉开关要把已经注入的规则收回 —— 否则"关掉"只是不再新增,页面上还留着上一次的改动。 */
|
|
837
|
+
if (!report.enabled) { removeBackdropStyle(); return report }
|
|
838
|
+
const found = findBackdropBlockers()
|
|
839
|
+
report.blockers = found.blockers
|
|
840
|
+
report.skipped = found.skipped
|
|
841
|
+
report.errors = found.errors
|
|
842
|
+
/* 只在开关打开时打一行:这个功能的成败取决于"页面上到底有没有挡路的底色",
|
|
843
|
+
而那件事只有页面自己知道 —— 说出来,否则"壁纸没回来"就无从判断是哪一步没成。 */
|
|
844
|
+
if (typeof console !== 'undefined' && console.log) {
|
|
845
|
+
console.log('[wisp] backdrop: ' + (found.selectors.length
|
|
846
|
+
? '清掉 ' + found.selectors.length + ' 层铺满视口的底色 ' + JSON.stringify(found.selectors)
|
|
847
|
+
+ (found.blockers[0] ? '(原色 ' + found.blockers[0].background + ')' : '')
|
|
848
|
+
: '没找到铺满视口的不透明底色 —— 挡路的另有其人,或 Mica 本身不生效')
|
|
849
|
+
+ (found.errors.length ? ';扫描报错 ' + JSON.stringify(found.errors) : ''))
|
|
850
|
+
}
|
|
851
|
+
if (found.selectors.length === 0) return report
|
|
852
|
+
const css = found.selectors
|
|
853
|
+
.map((c) => '.' + c + '{background-color:transparent !important;background-image:none !important}')
|
|
854
|
+
.join('')
|
|
855
|
+
let tag = document.getElementById(BACKDROP_STYLE_ID)
|
|
856
|
+
if (!tag) {
|
|
857
|
+
tag = document.createElement('style')
|
|
858
|
+
tag.id = BACKDROP_STYLE_ID
|
|
859
|
+
document.head.appendChild(tag)
|
|
860
|
+
}
|
|
861
|
+
tag.textContent = css
|
|
862
|
+
report.cleared = found.selectors
|
|
863
|
+
return report
|
|
864
|
+
}
|
|
865
|
+
let backdropReport = applyBackdrop()
|
|
866
|
+
|
|
867
|
+
/* 菜单里调过的大小要活过刷新 —— 位置/皮肤/隐藏都记了,尺寸没记会显得前后
|
|
868
|
+
不一致。但配置里显式写了 size 时以配置为准(那是项目级的设定,比一次
|
|
869
|
+
临时缩放更权威)。 */
|
|
870
|
+
if (!sizeIsExplicit && cfg.persist) {
|
|
871
|
+
const prefs = readStored(PREFS_KEY)
|
|
872
|
+
if (prefs && typeof prefs.size === 'number' && isFinite(prefs.size)) {
|
|
873
|
+
cfg.size = num(prefs.size, CONFIG_SPEC.size.lo, CONFIG_SPEC.size.hi, cfg.size)
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
542
877
|
const maxX = () => Math.max(0, vw() - boxW())
|
|
543
878
|
const maxY = () => Math.max(0, vh() - boxH())
|
|
544
879
|
const halfSay = () => Math.min(SAY_MAX_W / 2, Math.max(0, (vw() - 20) / 2))
|
|
@@ -580,6 +915,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
580
915
|
coords.y = clamp(vh() - cfg.bottom - boxH(), 0, maxY())
|
|
581
916
|
}
|
|
582
917
|
|
|
918
|
+
/* 皮肤:优先用配置里点名的(必须存在),否则用上次选的,再否则用第一套。
|
|
919
|
+
记忆的皮肤名可能属于一个已经不再随包发布的皮肤,所以必须校验。 */
|
|
920
|
+
const storedSkin = cfg.persist ? readStored(SKIN_KEY) : null
|
|
921
|
+
let activeSkin = SKIN_IDS[0]
|
|
922
|
+
if (typeof cfg.skin === 'string' && SKIN_IDS.indexOf(cfg.skin) >= 0) activeSkin = cfg.skin
|
|
923
|
+
else if (storedSkin && typeof storedSkin.id === 'string' && SKIN_IDS.indexOf(storedSkin.id) >= 0) activeSkin = storedSkin.id
|
|
924
|
+
|
|
583
925
|
/* The root is LAID OUT at its final size rather than transform-scaled:
|
|
584
926
|
a scaled layer is rasterized once and then stretched, so an enlarged
|
|
585
927
|
sprite goes soft, whereas a laid-out box rasterizes the 512px source at
|
|
@@ -626,8 +968,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
626
968
|
bodyEl.style.bottom = '0'
|
|
627
969
|
bodyEl.style.pointerEvents = 'auto'
|
|
628
970
|
bodyEl.setAttribute('role', 'button')
|
|
629
|
-
|
|
971
|
+
/* tabindex 是 0 而不是 -1:菜单已经支持键盘导航(↑↓/Home/End/Esc),但如果她本身
|
|
972
|
+
不可聚焦,键盘用户就**没有办法打开它** —— 声明了 role="menu" 却没有入口,与
|
|
973
|
+
"声明了 role 却按不动"是同一类问题。她挂在 body 末尾,所以 Tab 顺序上排在应用
|
|
974
|
+
内容之后,不会插队。 */
|
|
975
|
+
bodyEl.setAttribute('tabindex', '0')
|
|
630
976
|
bodyEl.setAttribute('aria-label', 'DeepSeek娘')
|
|
977
|
+
bodyEl.setAttribute('aria-haspopup', 'menu')
|
|
631
978
|
|
|
632
979
|
const shine = document.createElement('div')
|
|
633
980
|
shine.className = 'wisp-shine'
|
|
@@ -648,10 +995,20 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
648
995
|
if (typeof console !== 'undefined' && console.error) console.error('[wisp] sprite failed to load')
|
|
649
996
|
}
|
|
650
997
|
let currentSprite = 'idle'
|
|
998
|
+
/* 皮肤:一套完整的 idle/happy/sleepy/work。activeSkin 随配置与记忆恢复,
|
|
999
|
+
切换时只需要重建精灵图与命中蒙版,位置和状态都不动。 */
|
|
1000
|
+
/* 一套皮肤若不包含某个情绪(例如将来只给了四张),退到备选而不是空白 */
|
|
1001
|
+
const spriteOf = (key) => {
|
|
1002
|
+
const skin = SPRITES[activeSkin]
|
|
1003
|
+
if (skin && typeof skin[key] === 'string') return skin[key]
|
|
1004
|
+
const alt = SPRITE_FALLBACK[key]
|
|
1005
|
+
if (alt && typeof skin[alt] === 'string') return skin[alt]
|
|
1006
|
+
return skin.idle
|
|
1007
|
+
}
|
|
651
1008
|
|
|
652
1009
|
const setSprite = (key) => {
|
|
653
|
-
const url = resolveSprite(
|
|
654
|
-
buildMask(key, url)
|
|
1010
|
+
const url = resolveSprite(spriteOf(key))
|
|
1011
|
+
buildMask(activeSkin + '/' + key, url)
|
|
655
1012
|
currentSprite = key
|
|
656
1013
|
/* Cross-fade rather than swapping `src` outright: a hard swap makes every
|
|
657
1014
|
mood change read as a glitch. The outgoing sprite keeps its own layer
|
|
@@ -705,14 +1062,43 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
705
1062
|
else if (console && console.error) console.error('[wisp] document.body never appeared')
|
|
706
1063
|
return
|
|
707
1064
|
}
|
|
1065
|
+
/* 挂载前先清掉页面上任何**残留的旧层**。
|
|
1066
|
+
只靠 `window.__wisp` 那一个句柄是不够的:那个引用可能属于另一个上下文/另一次加载,
|
|
1067
|
+
于是旧层留在页面上、新层再叠一层 —— 表现就是"出现了两个她"。
|
|
1068
|
+
她只有一个,这件事不该依赖任何一本书里记着的引用。 */
|
|
1069
|
+
try {
|
|
1070
|
+
const strays = document.querySelectorAll('.wisp-layer')
|
|
1071
|
+
for (let i = 0; i < strays.length; i++) {
|
|
1072
|
+
const el = strays[i]
|
|
1073
|
+
if (el && el !== layer && el.parentNode) el.parentNode.removeChild(el)
|
|
1074
|
+
}
|
|
1075
|
+
} catch (e) { /* 清不掉也不该挡住挂载 */ }
|
|
708
1076
|
document.body.appendChild(layer)
|
|
709
1077
|
attached = true
|
|
710
1078
|
}
|
|
711
1079
|
|
|
712
|
-
/* ---- mood -> sprite ----
|
|
713
|
-
|
|
1080
|
+
/* ---- mood -> sprite ----
|
|
1081
|
+
attn 曾借用 work 那张,现在有自己的一张(抬手招你过来的姿态)。
|
|
1082
|
+
仍然保留回退:某一套皮肤将来若没有 attn,她会退回 work,而不是变成空白。 */
|
|
1083
|
+
const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy', attn: 'attn', care: 'attn', poked: 'poked', proud: 'proud', worried: 'sleepy' }
|
|
1084
|
+
const SPRITE_FALLBACK = { attn: 'work' }
|
|
714
1085
|
let mood = 'idle'
|
|
715
1086
|
|
|
1087
|
+
/* 换皮肤 = 换一整套精灵图。位置、情绪、气泡都不动,只有图与命中蒙版重建。 */
|
|
1088
|
+
const skinLabel = (id) => SKIN_LABELS[id] ?? id
|
|
1089
|
+
const applySkin = (id) => {
|
|
1090
|
+
if (destroyed || typeof id !== 'string') return false
|
|
1091
|
+
if (SKIN_IDS.indexOf(id) < 0) return false // 不认识的名字就当作没这回事
|
|
1092
|
+
const changed = id !== activeSkin
|
|
1093
|
+
/* 只有**换到不同**的一套才算一次使用:初始皮肤是直接赋值进来的,不走这个函数;
|
|
1094
|
+
setSkin 传同一个 id 也不该计数。(这里用 changed —— 别再写一个不存在的变量名。) */
|
|
1095
|
+
if (changed) bump(memory.skins, id)
|
|
1096
|
+
activeSkin = id
|
|
1097
|
+
writeStored(SKIN_KEY, { id })
|
|
1098
|
+
if (changed) setSprite(SPRITE_OF[mood])
|
|
1099
|
+
return true
|
|
1100
|
+
}
|
|
1101
|
+
|
|
716
1102
|
const setMood = (next) => {
|
|
717
1103
|
if (destroyed || mood === next) return
|
|
718
1104
|
mood = next
|
|
@@ -729,6 +1115,14 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
729
1115
|
}
|
|
730
1116
|
}
|
|
731
1117
|
|
|
1118
|
+
/* 气泡也挂在 layer 上,所以躲起来时必须单独收掉 —— 只把 root 设成
|
|
1119
|
+
display:none 的话,会留下一个悬空的气泡在屏幕上。 */
|
|
1120
|
+
const clearSay = () => {
|
|
1121
|
+
if (sayHandle) { clock.cancel(sayHandle); sayHandle = null }
|
|
1122
|
+
if (sayEl !== null && sayEl.parentNode) sayEl.remove()
|
|
1123
|
+
sayEl = null
|
|
1124
|
+
}
|
|
1125
|
+
|
|
732
1126
|
let sayHandle = null
|
|
733
1127
|
const say = (text) => {
|
|
734
1128
|
if (destroyed) return
|
|
@@ -797,10 +1191,129 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
797
1191
|
} catch (e) { offTheme = null }
|
|
798
1192
|
}
|
|
799
1193
|
|
|
1194
|
+
/* ---- 今日轮次:里程碑 --------------------------------------------------
|
|
1195
|
+
她数的是"今天跑完多少轮",所以要跨刷新记住。按天存:换了一天就归零,
|
|
1196
|
+
但**记过的里程碑也要一起存** —— 否则同一天里刷新页面会把 10、25 又说一遍。
|
|
1197
|
+
这里用真实日期(new Date())而不是插件时钟:问的是"今天",不是"这次页面开了多久"。 */
|
|
1198
|
+
const RUNS_KEY = 'dsh-wisp:runs:v1'
|
|
1199
|
+
const MILESTONES = [10, 25, 50, 100, 200, 500]
|
|
1200
|
+
const dayKey = () => {
|
|
1201
|
+
try {
|
|
1202
|
+
const d = new Date()
|
|
1203
|
+
return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate()
|
|
1204
|
+
} catch (e) { return 'unknown' }
|
|
1205
|
+
}
|
|
1206
|
+
let runsToday = (() => {
|
|
1207
|
+
const stored = readStored(RUNS_KEY)
|
|
1208
|
+
const today = dayKey()
|
|
1209
|
+
if (stored && typeof stored === 'object' && stored.day === today && typeof stored.count === 'number') {
|
|
1210
|
+
return { day: today, count: stored.count, said: Array.isArray(stored.said) ? stored.said : [] }
|
|
1211
|
+
}
|
|
1212
|
+
return { day: today, count: 0, said: [] }
|
|
1213
|
+
})()
|
|
1214
|
+
|
|
1215
|
+
/* ---- 她记着你 --------------------------------------------------------
|
|
1216
|
+
存的是"一起多少天 / 常放哪个角 / 常穿哪套",以及**已经说过哪些**。
|
|
1217
|
+
两条防噪规则是有意的:
|
|
1218
|
+
1) 同一条记忆一辈子只说一次(said 里记着);
|
|
1219
|
+
2) 一天最多说一句(remarkDay)。
|
|
1220
|
+
没有这两条,这些台词会在每次刷新时重复出现,很快就会从"她记得"变成"她唠叨"。 */
|
|
1221
|
+
const MEMORY_KEY = 'dsh-wisp:memory:v1'
|
|
1222
|
+
const MEMORY_MAX_DAYS = 90 // 别让这个记录无限长下去
|
|
1223
|
+
const DAY_MILESTONES = [2, 7, 14, 30, 100]
|
|
1224
|
+
const HABIT_MIN = 8 // 一个角落至少用过这么多次才算"习惯"
|
|
1225
|
+
const HABIT_SHARE = 0.6 // 而且要占大多数
|
|
1226
|
+
const SKIN_MIN = 6
|
|
1227
|
+
|
|
1228
|
+
const memory = (() => {
|
|
1229
|
+
const stored = readStored(MEMORY_KEY)
|
|
1230
|
+
const mem = stored && typeof stored === 'object' ? stored : {}
|
|
1231
|
+
const today = dayKey()
|
|
1232
|
+
const days = Array.isArray(mem.days) ? mem.days.filter((d) => typeof d === 'string') : []
|
|
1233
|
+
if (days.indexOf(today) < 0) days.push(today)
|
|
1234
|
+
return {
|
|
1235
|
+
days: days.slice(-MEMORY_MAX_DAYS),
|
|
1236
|
+
corners: mem.corners && typeof mem.corners === 'object' ? mem.corners : {},
|
|
1237
|
+
skins: mem.skins && typeof mem.skins === 'object' ? mem.skins : {},
|
|
1238
|
+
said: Array.isArray(mem.said) ? mem.said : [],
|
|
1239
|
+
remarkDay: typeof mem.remarkDay === 'string' ? mem.remarkDay : '',
|
|
1240
|
+
}
|
|
1241
|
+
})()
|
|
1242
|
+
const saveMemory = () => writeStored(MEMORY_KEY, memory)
|
|
1243
|
+
saveMemory() // 今天来过了,先记上
|
|
1244
|
+
|
|
1245
|
+
const bump = (bag, key) => {
|
|
1246
|
+
if (typeof key !== 'string' || key === '') return
|
|
1247
|
+
bag[key] = (typeof bag[key] === 'number' ? bag[key] : 0) + 1
|
|
1248
|
+
saveMemory()
|
|
1249
|
+
}
|
|
1250
|
+
const topOf = (bag) => {
|
|
1251
|
+
let best = null
|
|
1252
|
+
let total = 0
|
|
1253
|
+
for (const key of Object.keys(bag)) {
|
|
1254
|
+
const n = bag[key]
|
|
1255
|
+
if (typeof n !== 'number') continue
|
|
1256
|
+
total += n
|
|
1257
|
+
if (best === null || n > best.count) best = { key, count: n }
|
|
1258
|
+
}
|
|
1259
|
+
return best === null ? null : { key: best.key, count: best.count, total }
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/* 挑一句"她记着的事"。返回 null 表示今天不该说。 */
|
|
1263
|
+
const memoryRemark = () => {
|
|
1264
|
+
if (!cfg.memory) return null
|
|
1265
|
+
const today = dayKey()
|
|
1266
|
+
if (memory.remarkDay === today) return null
|
|
1267
|
+
const once = (key) => memory.said.indexOf(key) < 0
|
|
1268
|
+
const claim = (key) => { memory.said.push(key); memory.remarkDay = today; saveMemory() }
|
|
1269
|
+
|
|
1270
|
+
if (DAY_MILESTONES.indexOf(memory.days.length) >= 0 && once('days:' + memory.days.length)) {
|
|
1271
|
+
claim('days:' + memory.days.length)
|
|
1272
|
+
return { mood: 'happy', text: pick(LINES.days).split('{n}').join(String(memory.days.length)) }
|
|
1273
|
+
}
|
|
1274
|
+
const corner = topOf(memory.corners)
|
|
1275
|
+
if (corner && corner.count >= HABIT_MIN && corner.count / corner.total >= HABIT_SHARE && once('corner:' + corner.key)) {
|
|
1276
|
+
claim('corner:' + corner.key)
|
|
1277
|
+
return { mood: 'proud', text: pick(LINES.habitCorner).split('{c}').join(corner.key) }
|
|
1278
|
+
}
|
|
1279
|
+
const skin = topOf(memory.skins)
|
|
1280
|
+
if (skin && skin.count >= SKIN_MIN && skin.key !== activeSkin && once('skin:' + skin.key)) {
|
|
1281
|
+
claim('skin:' + skin.key)
|
|
1282
|
+
return { mood: 'proud', text: pick(LINES.habitSkin).split('{s}').join(skinLabel(skin.key)) }
|
|
1283
|
+
}
|
|
1284
|
+
return null
|
|
1285
|
+
}
|
|
1286
|
+
|
|
800
1287
|
/* ---- react to what the app is doing ---- */
|
|
801
1288
|
let lastBusy = false
|
|
1289
|
+
let lastPending = null
|
|
802
1290
|
let busySince = 0
|
|
1291
|
+
/* 久坐追踪:activeSince = 这一段"坐在电脑前"的起点;0 表示不在这一段里。
|
|
1292
|
+
CARE_ACTIVE_GAP_MS = 中间停顿多久算离开。 */
|
|
1293
|
+
const CARE_ACTIVE_GAP_MS = 90000
|
|
1294
|
+
let activeSince = 0
|
|
1295
|
+
let lastActiveAt = 0
|
|
1296
|
+
let lastCareAt = 0
|
|
803
1297
|
let happyHandle = null
|
|
1298
|
+
let lastErrorCount = 0
|
|
1299
|
+
|
|
1300
|
+
const flashProud = (text) => {
|
|
1301
|
+
setMood('proud')
|
|
1302
|
+
say(text)
|
|
1303
|
+
if (proudHandle) clock.cancel(proudHandle)
|
|
1304
|
+
proudHandle = clock.after(PROUD_MS, () => { if (mood === 'proud') setMood('idle') })
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/* 记一轮完成;命中里程碑时返回那个数字,否则 null */
|
|
1308
|
+
const recordRun = () => {
|
|
1309
|
+
const today = dayKey()
|
|
1310
|
+
if (runsToday.day !== today) runsToday = { day: today, count: 0, said: [] }
|
|
1311
|
+
runsToday.count++
|
|
1312
|
+
const hit = MILESTONES.find((m) => m === runsToday.count && runsToday.said.indexOf(m) < 0)
|
|
1313
|
+
if (hit !== undefined) runsToday.said.push(hit)
|
|
1314
|
+
writeStored(RUNS_KEY, { day: runsToday.day, count: runsToday.count, said: runsToday.said })
|
|
1315
|
+
return hit === undefined ? null : hit
|
|
1316
|
+
}
|
|
804
1317
|
|
|
805
1318
|
const flashHappy = (text) => {
|
|
806
1319
|
setMood('happy')
|
|
@@ -809,18 +1322,110 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
809
1322
|
happyHandle = clock.after(cfg.happyMs, () => { if (mood === 'happy') setMood('idle') })
|
|
810
1323
|
}
|
|
811
1324
|
|
|
1325
|
+
/* 连戳到一定程度就换成"抗议"。它和 happy 一样是**瞬时状态**,但必须自己一份定时器:
|
|
1326
|
+
否则同一轮询里的 idle/alert 会把它盖掉 —— worried 当初就是这么坏的。 */
|
|
1327
|
+
const POKED_MS = 1800
|
|
1328
|
+
let pokedHandle = null
|
|
1329
|
+
const flashPoked = (text) => {
|
|
1330
|
+
setMood('poked')
|
|
1331
|
+
say(text)
|
|
1332
|
+
if (pokedHandle) clock.cancel(pokedHandle)
|
|
1333
|
+
pokedHandle = clock.after(POKED_MS, () => { if (mood === 'poked') setMood('idle') })
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/* 久坐提醒用的也是一个**瞬时状态**(借 attn 的姿态),理由和 poked 一样:
|
|
1337
|
+
稳态逻辑每轮都会重算,不给它自己的定时器就会被抹掉。 */
|
|
1338
|
+
const PROUD_MS = 2800
|
|
1339
|
+
let proudHandle = null
|
|
1340
|
+
|
|
1341
|
+
const CARE_MS = 4200
|
|
1342
|
+
let careHandle = null
|
|
1343
|
+
const flashCare = (text) => {
|
|
1344
|
+
setMood('care')
|
|
1345
|
+
say(text)
|
|
1346
|
+
if (careHandle) clock.cancel(careHandle)
|
|
1347
|
+
careHandle = clock.after(CARE_MS, () => { if (mood === 'care') setMood('idle') })
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
let worriedHandle = null
|
|
1351
|
+
const flashWorried = (text) => {
|
|
1352
|
+
setMood('worried')
|
|
1353
|
+
say(text)
|
|
1354
|
+
if (worriedHandle) clock.cancel(worriedHandle)
|
|
1355
|
+
worriedHandle = clock.after(WORRIED_MS, () => { if (mood === 'worried') setMood('idle') })
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
/* 一轮刚跑完的那一刻看一次错误。同一批错误只说一次;错误清掉之后再出现新的
|
|
1359
|
+
才会再说 —— 否则每轮结束都会重复念同一句。 */
|
|
1360
|
+
const checkErrors = () => {
|
|
1361
|
+
if (!cfg.reactions) return
|
|
1362
|
+
const count = countErrors()
|
|
1363
|
+
if (count > 0 && count !== lastErrorCount) flashWorried(pick(LINES.error))
|
|
1364
|
+
lastErrorCount = count
|
|
1365
|
+
}
|
|
1366
|
+
|
|
812
1367
|
const poll = () => {
|
|
813
1368
|
if (destroyed) return
|
|
814
1369
|
let busy = false
|
|
815
1370
|
try { busy = agentIsBusy() } catch (e) { busy = false }
|
|
816
1371
|
if (busy && !lastBusy) busySince = nowMs()
|
|
817
|
-
if (!busy && lastBusy
|
|
818
|
-
|
|
1372
|
+
if (!busy && lastBusy) {
|
|
1373
|
+
/* 里程碑优先于普通庆祝:同一次跑完只该说一句,而"第 25 轮"比"跑完了"更值得说 */
|
|
1374
|
+
const hit = cfg.reactions && cfg.milestones ? recordRun() : null
|
|
1375
|
+
if (hit !== null) {
|
|
1376
|
+
flashProud(pick(LINES.milestone).split('{n}').join(String(hit)))
|
|
1377
|
+
} else if (cfg.celebrate && nowMs() - busySince >= cfg.celebrateAfterMs) {
|
|
1378
|
+
flashHappy(pick(LINES.done))
|
|
1379
|
+
}
|
|
1380
|
+
// 一轮结束 = 错误唯一会出现的时机,所以错误检查挂在这个边上而不是轮询里
|
|
1381
|
+
checkErrors()
|
|
819
1382
|
}
|
|
820
1383
|
lastBusy = busy
|
|
821
|
-
|
|
1384
|
+
|
|
1385
|
+
/* ---- 久坐追踪 ---------------------------------------------------------
|
|
1386
|
+
"连着坐了多久"没有直接信号,用"最近有没有活动"近似:跑着任务或输入框里有字
|
|
1387
|
+
就算还在动。中间短暂停一下(不到 CARE_ACTIVE_GAP_MS)不算离开 —— 人是会停下来
|
|
1388
|
+
想事情的,把那种间隙当成"离开"会让计时永远攒不满。
|
|
1389
|
+
判定放在这里(而不是稳态赋值之后):瞬时的表情可能提前 return,而计时必须每轮都走。 */
|
|
822
1390
|
let typing = false
|
|
823
1391
|
try { typing = composerHasText() } catch (e) { typing = false }
|
|
1392
|
+
const activeNow = busy || typing
|
|
1393
|
+
const nowTick = nowMs()
|
|
1394
|
+
if (activeNow) {
|
|
1395
|
+
if (activeSince === 0) activeSince = nowTick
|
|
1396
|
+
lastActiveAt = nowTick
|
|
1397
|
+
} else if (activeSince !== 0 && nowTick - lastActiveAt > CARE_ACTIVE_GAP_MS) {
|
|
1398
|
+
activeSince = 0 // 他离开了一阵,这一段结束
|
|
1399
|
+
}
|
|
1400
|
+
if (
|
|
1401
|
+
cfg.careAfterMs > 0 && activeSince !== 0
|
|
1402
|
+
&& nowTick - activeSince >= cfg.careAfterMs
|
|
1403
|
+
&& nowTick - lastCareAt >= cfg.careAfterMs
|
|
1404
|
+
) {
|
|
1405
|
+
lastCareAt = nowTick
|
|
1406
|
+
/* 提醒你的时候她自己得先醒着 —— 打盹着喊人起来活动,读起来像 bug。
|
|
1407
|
+
pending 面板那条路也是这么处理的(先 wake 再说话)。 */
|
|
1408
|
+
if (mood === 'sleep') wake(true)
|
|
1409
|
+
flashCare(pick(LINES.care))
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// 有东西在等她处理时,优先级高于一切:这是唯一"你不理它就卡住"的状态。
|
|
1413
|
+
const pending = cfg.reactions ? readPending() : null
|
|
1414
|
+
if (pending === null) {
|
|
1415
|
+
lastPending = null
|
|
1416
|
+
} else if (pending !== lastPending) {
|
|
1417
|
+
lastPending = pending
|
|
1418
|
+
if (mood === 'sleep') wake(true) // 睡着了也要叫起来
|
|
1419
|
+
say(pick(LINES.attn))
|
|
1420
|
+
}
|
|
1421
|
+
if (pending !== null) {
|
|
1422
|
+
setMood('attn')
|
|
1423
|
+
return
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
// 临时状态(happy / worried)在自己的计时器到期前不能被稳态赋值覆盖 ——
|
|
1427
|
+
// 否则刚摆出的表情在同一个 poll 周期里就被 setMood('idle') 抹掉了。
|
|
1428
|
+
if (mood === 'sleep' || mood === 'happy' || mood === 'poked' || mood === 'proud' || mood === 'care' || mood === 'worried') return
|
|
824
1429
|
setMood((busy || typing) ? 'alert' : 'idle')
|
|
825
1430
|
}
|
|
826
1431
|
|
|
@@ -867,7 +1472,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
867
1472
|
if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
|
|
868
1473
|
if (!cfg.wander || cfg.wanderRange <= 0) return
|
|
869
1474
|
wanderHandle = clock.every(cfg.wanderMs, () => {
|
|
870
|
-
if (destroyed || mood !== 'idle' || start !== null) return
|
|
1475
|
+
if (destroyed || hidden || mood !== 'idle' || start !== null) return
|
|
871
1476
|
if (agentIsBusy()) return
|
|
872
1477
|
// A random bearing with a floor on the distance, so a stroll is always
|
|
873
1478
|
// a real stroll rather than a rounding error in a random direction.
|
|
@@ -882,8 +1487,67 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
882
1487
|
}
|
|
883
1488
|
syncWander()
|
|
884
1489
|
|
|
1490
|
+
/* ---- 躲起来 / 叫回来 ----
|
|
1491
|
+
她放大到 560x840 之后会挡住会话和输入框,而"让她消失一会儿"原先只能整个
|
|
1492
|
+
禁用插件。躲起来时留一个迷你按钮(用的是当前皮肤的 idle 图,所以不需要
|
|
1493
|
+
额外素材),点一下就回来。状态记在本地,刷新后仍然躲着。 */
|
|
1494
|
+
let hidden = cfg.hidden === true || (cfg.persist && readStored(HIDDEN_KEY)?.hidden === true)
|
|
1495
|
+
root.dataset.hidden = hidden ? 'true' : 'false'
|
|
1496
|
+
let tabEl = null
|
|
1497
|
+
|
|
1498
|
+
const hideTab = () => {
|
|
1499
|
+
if (tabEl && tabEl.parentNode) tabEl.parentNode.removeChild(tabEl)
|
|
1500
|
+
tabEl = null
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
const showTab = () => {
|
|
1504
|
+
if (destroyed || tabEl !== null) return
|
|
1505
|
+
const tab = document.createElement('button')
|
|
1506
|
+
tab.className = 'wisp-tab'
|
|
1507
|
+
tab.setAttribute('type', 'button')
|
|
1508
|
+
tab.setAttribute('title', '把' + '她' + '叫回来')
|
|
1509
|
+
tab.setAttribute('aria-label', '把 DeepSeek娘 叫回来')
|
|
1510
|
+
tab.style.position = 'absolute'
|
|
1511
|
+
tab.style.right = Math.round(cfg.right) + 'px'
|
|
1512
|
+
tab.style.bottom = Math.round(cfg.bottom + 40) + 'px'
|
|
1513
|
+
const thumb = document.createElement('img')
|
|
1514
|
+
thumb.className = 'wisp-tab-thumb'
|
|
1515
|
+
thumb.setAttribute('src', resolveSprite(spriteOf('idle')))
|
|
1516
|
+
thumb.setAttribute('alt', '')
|
|
1517
|
+
thumb.setAttribute('draggable', 'false')
|
|
1518
|
+
tab.appendChild(thumb)
|
|
1519
|
+
tab.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
1520
|
+
tab.addEventListener('click', (ev) => {
|
|
1521
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
1522
|
+
setHidden(false)
|
|
1523
|
+
})
|
|
1524
|
+
/* 躲起来之后,原先只能靠那个小按钮把她叫回来 —— 换皮肤、调大小、换角落在那个状态下
|
|
1525
|
+
全都够不着。右键它直接开同一个菜单,这里就补上了。
|
|
1526
|
+
(openMenu 在下面才定义;showTab 只在挂载完成之后的用户操作里跑,所以不是 TDZ。) */
|
|
1527
|
+
tab.addEventListener('contextmenu', (ev) => {
|
|
1528
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
1529
|
+
openMenu(ev.clientX ?? 0, ev.clientY ?? 0)
|
|
1530
|
+
})
|
|
1531
|
+
layer.appendChild(tab)
|
|
1532
|
+
tabEl = tab
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const setHidden = (next) => {
|
|
1536
|
+
if (destroyed) return
|
|
1537
|
+
hidden = next === true
|
|
1538
|
+
root.dataset.hidden = hidden ? 'true' : 'false'
|
|
1539
|
+
if (hidden) { closeMenu(); hideTab(); clearSay(); showTab() }
|
|
1540
|
+
else { hideTab(); paint() }
|
|
1541
|
+
if (cfg.persist) writeStored(HIDDEN_KEY, { hidden })
|
|
1542
|
+
}
|
|
1543
|
+
|
|
885
1544
|
/* ---- drag & click ---- */
|
|
886
1545
|
let start = null, origin = null, moved = false, lastClick = 0
|
|
1546
|
+
/* 连戳计数:POKE_WINDOW_MS 内累计到 POKE_ANNOYED_AT 次就抗议一次 */
|
|
1547
|
+
const POKE_WINDOW_MS = 3000
|
|
1548
|
+
const POKE_ANNOYED_AT = 3
|
|
1549
|
+
let pokeStreak = 0
|
|
1550
|
+
let pokeWindowStart = 0
|
|
887
1551
|
|
|
888
1552
|
const rememberPosition = () => {
|
|
889
1553
|
if (!cfg.persist) return
|
|
@@ -892,7 +1556,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
892
1556
|
|
|
893
1557
|
/** Whether this pointer is over an opaque pixel of the current sprite. */
|
|
894
1558
|
const hitsBody = (e) => {
|
|
895
|
-
const mask = masks[currentSprite]
|
|
1559
|
+
const mask = masks[activeSkin + '/' + currentSprite]
|
|
896
1560
|
if (!mask) return true // no mask yet: stay grabbable rather than vanish
|
|
897
1561
|
const boxw = boxW()
|
|
898
1562
|
const boxh = boxH()
|
|
@@ -926,11 +1590,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
926
1590
|
return
|
|
927
1591
|
}
|
|
928
1592
|
if (destroyed) return
|
|
929
|
-
|
|
1593
|
+
/* 用 nowMs()(插件自己的时钟)而不是 Date.now():双击窗口与连戳窗口必须和其他
|
|
1594
|
+
有时间意义的行为共用同一个时钟,否则测试没法推进它,行为也无法在虚拟时间下复现。 */
|
|
1595
|
+
const now = nowMs()
|
|
930
1596
|
const dbl = now - lastClick < 420
|
|
931
1597
|
lastClick = now
|
|
1598
|
+
/* 连戳升级:短窗口内戳到第三次,她从"开心"转成"抗议"。
|
|
1599
|
+
计数在窗口过期后归零 —— 隔一会儿再戳一下不该被记仇。 */
|
|
1600
|
+
if (now - pokeWindowStart > POKE_WINDOW_MS) { pokeWindowStart = now; pokeStreak = 0 }
|
|
1601
|
+
pokeStreak++
|
|
932
1602
|
wake(true)
|
|
933
|
-
|
|
1603
|
+
if (pokeStreak >= POKE_ANNOYED_AT) {
|
|
1604
|
+
pokeStreak = 0
|
|
1605
|
+
flashPoked(pick(LINES.poked))
|
|
1606
|
+
} else {
|
|
1607
|
+
flashHappy(dbl ? pick(LINES.happy) : pick(LINES.click))
|
|
1608
|
+
}
|
|
934
1609
|
}
|
|
935
1610
|
const onDown = (e) => {
|
|
936
1611
|
if (e.button !== 0) return
|
|
@@ -950,21 +1625,253 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
950
1625
|
}
|
|
951
1626
|
bodyEl.addEventListener('pointerdown', onDown)
|
|
952
1627
|
|
|
1628
|
+
/* 键盘入口:Enter / Space / 菜单键 / Shift+F10 打开菜单。
|
|
1629
|
+
Space 默认会滚动页面,Enter 在表单里会提交 —— 都要拦。 */
|
|
1630
|
+
/* 方向键微调位置。
|
|
1631
|
+
在这之前,**挪动她只能靠鼠标拖** —— 键盘用户拿到焦点后除了开菜单什么也做不了。
|
|
1632
|
+
方向键是这类「可拖动物件」的通行补法(与拖动共用同一套边界夹紧与位置持久化)。
|
|
1633
|
+
Shift = 细调;默认步长 16px,因为 1px 要按十几次才看得出动。 */
|
|
1634
|
+
const NUDGE = 16
|
|
1635
|
+
const NUDGE_FINE = 4
|
|
1636
|
+
const NUDGE_KEYS = {
|
|
1637
|
+
ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1],
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
const onBodyKey = (ev) => {
|
|
1641
|
+
const nudge = NUDGE_KEYS[ev.key]
|
|
1642
|
+
if (nudge) {
|
|
1643
|
+
/* 不拦的话,方向键会同时把页面滚走 —— 那时候她看起来像是「跑掉了」。 */
|
|
1644
|
+
if (typeof ev.preventDefault === 'function') ev.preventDefault()
|
|
1645
|
+
if (typeof ev.stopPropagation === 'function') ev.stopPropagation()
|
|
1646
|
+
const step = ev.shiftKey ? NUDGE_FINE : NUDGE
|
|
1647
|
+
coords.x = clamp(coords.x + nudge[0] * step, 0, maxX())
|
|
1648
|
+
coords.y = clamp(coords.y + nudge[1] * step, 0, maxY())
|
|
1649
|
+
paint()
|
|
1650
|
+
rememberPosition()
|
|
1651
|
+
return
|
|
1652
|
+
}
|
|
1653
|
+
const opensMenu = ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar'
|
|
1654
|
+
|| ev.key === 'ContextMenu' || (ev.shiftKey && ev.key === 'F10')
|
|
1655
|
+
if (!opensMenu) return
|
|
1656
|
+
if (typeof ev.preventDefault === 'function') ev.preventDefault()
|
|
1657
|
+
if (typeof ev.stopPropagation === 'function') ev.stopPropagation()
|
|
1658
|
+
openMenu(coords.x + boxW() / 2, coords.y + Math.min(boxH() / 2, 320))
|
|
1659
|
+
}
|
|
1660
|
+
bodyEl.addEventListener('keydown', onBodyKey)
|
|
1661
|
+
|
|
1662
|
+
/* ---- Ctrl/⌘ + 滚轮 = 调大小 ----
|
|
1663
|
+
只认**带修饰键**的滚轮:她占地不小,直接吞掉普通滚轮等于在她身上把页面滚动禁掉。
|
|
1664
|
+
带修饰键是「缩放」的通行手势,此时 preventDefault 才是帮忙而不是捣乱。
|
|
1665
|
+
步长 0.25,上下限由 CONFIG_SPEC 统一夹紧(0.4–8),不在这里再写一遍。 */
|
|
1666
|
+
const WHEEL_STEP = 0.25
|
|
1667
|
+
const onWheel = (ev) => {
|
|
1668
|
+
if (!ev.ctrlKey && !ev.metaKey) return // 普通滚轮原样放行
|
|
1669
|
+
if (typeof ev.preventDefault === 'function') ev.preventDefault()
|
|
1670
|
+
if (typeof ev.stopPropagation === 'function') ev.stopPropagation()
|
|
1671
|
+
const dir = (ev.deltaY ?? 0) < 0 ? 1 : -1 // 向上 = 变大,与浏览器缩放同向
|
|
1672
|
+
applyConfig({ size: cfg.size + dir * WHEEL_STEP })
|
|
1673
|
+
}
|
|
1674
|
+
bodyEl.addEventListener('wheel', onWheel)
|
|
1675
|
+
|
|
953
1676
|
/* ---- 右键 = 叫回原位 ----
|
|
954
1677
|
她大了之后很容易被拖到看不见的地方,而"恢复位置"原先只能开 Console。
|
|
955
|
-
|
|
956
|
-
const
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
1678
|
+
现在右键她本体弹出菜单,归位只是其中一项。 */
|
|
1679
|
+
const MENU_W = 158
|
|
1680
|
+
const MENU_ITEM_H = 30
|
|
1681
|
+
const MENU_PAD = 10
|
|
1682
|
+
let menuEl = null
|
|
1683
|
+
let menuOff = null
|
|
1684
|
+
|
|
1685
|
+
const closeMenu = () => {
|
|
1686
|
+
if (menuEl && menuEl.parentNode) menuEl.parentNode.removeChild(menuEl)
|
|
1687
|
+
menuEl = null
|
|
1688
|
+
menuButtons = []
|
|
1689
|
+
menuFocusAt = -1
|
|
1690
|
+
if (menuOff) { menuOff(); menuOff = null }
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/* 四个角落。她放大到 560x840 之后,"右下角"未必就是合适的位置 —— 屏幕右边
|
|
1694
|
+
可能是侧栏,左下角未必被输入框挡住。 */
|
|
1695
|
+
const CORNERS = [
|
|
1696
|
+
{ label: '右上角', at: (w, h, bw, bh, c) => ({ x: clamp(w - c.right - bw, 0, Math.max(0, w - bw)), y: clamp(c.bottom, 0, Math.max(0, h - bh)) }) },
|
|
1697
|
+
{ label: '左上角', at: (w, h, bw, bh, c) => ({ x: clamp(c.right, 0, Math.max(0, w - bw)), y: clamp(c.bottom, 0, Math.max(0, h - bh)) }) },
|
|
1698
|
+
{ label: '左下角', at: (w, h, bw, bh, c) => ({ x: clamp(c.right, 0, Math.max(0, w - bw)), y: clamp(h - c.bottom - bh, 0, Math.max(0, h - bh)) }) },
|
|
1699
|
+
{ label: '右下角', at: (w, h, bw, bh, c) => ({ x: clamp(w - c.right - bw, 0, Math.max(0, w - bw)), y: clamp(h - c.bottom - bh, 0, Math.max(0, h - bh)) }) },
|
|
1700
|
+
]
|
|
1701
|
+
|
|
1702
|
+
const goHome = () => goCorner('右下角')
|
|
1703
|
+
/* 角落用法要计数,而 goCorner 是把人挪过去的唯一入口 —— 计数放在它里面最省事,
|
|
1704
|
+
而且只有用户会触发它(初始位置是直接算坐标的,不走这里)。 */
|
|
1705
|
+
|
|
1706
|
+
const goCorner = (label) => {
|
|
1707
|
+
const corner = CORNERS.find((c) => c.label === label) || CORNERS[CORNERS.length - 1]
|
|
1708
|
+
const spot = corner.at(vw(), vh(), boxW(), boxH(), cfg)
|
|
1709
|
+
coords.x = spot.x
|
|
1710
|
+
coords.y = spot.y
|
|
964
1711
|
paint()
|
|
965
1712
|
rememberPosition()
|
|
966
1713
|
say(pick(LINES.home))
|
|
967
1714
|
}
|
|
1715
|
+
|
|
1716
|
+
/* 菜单分四段:动作 / 行为开关 / 位置 / 皮肤。后三段各有一个小标题。
|
|
1717
|
+
行为开关原本在设置页里,但那页与菜单重叠过大被撤掉了 —— 这三个开关是面板
|
|
1718
|
+
真正的净增量,放进菜单比再建一页便宜得多(复用现有的项结构、键盘支持与关闭逻辑)。 */
|
|
1719
|
+
const TOGGLES = [
|
|
1720
|
+
{ key: 'wander', label: '自己溜达' },
|
|
1721
|
+
{ key: 'reactions', label: '跟随状态' },
|
|
1722
|
+
{ key: 'celebrate', label: '跑完撒花' },
|
|
1723
|
+
]
|
|
1724
|
+
|
|
1725
|
+
const menuItems = () => {
|
|
1726
|
+
const items = [
|
|
1727
|
+
{ label: '说句话', run: () => { wake(true); say(pick(LINES.click)) } },
|
|
1728
|
+
{ label: '放大一点', run: () => applyConfig({ size: cfg.size + 0.5 }) },
|
|
1729
|
+
{ label: '缩小一点', run: () => applyConfig({ size: cfg.size - 0.5 }) },
|
|
1730
|
+
/* 一个开关,不是一个单向动作:从迷你按钮右键打开菜单时,它必须能把她叫回来。 */
|
|
1731
|
+
{ label: hidden ? '回来吧' : '先躲起来', run: () => setHidden(!hidden) },
|
|
1732
|
+
{ label: '睡一会儿', run: () => setMood('sleep') },
|
|
1733
|
+
{ label: '版本与更新', run: () => showUpdate() },
|
|
1734
|
+
{ header: '行为' },
|
|
1735
|
+
]
|
|
1736
|
+
for (const toggle of TOGGLES) {
|
|
1737
|
+
const on = cfg[toggle.key] === true
|
|
1738
|
+
items.push({
|
|
1739
|
+
label: (on ? '✓ ' : ' ') + toggle.label,
|
|
1740
|
+
run: () => applyConfig({ [toggle.key]: !on }),
|
|
1741
|
+
})
|
|
1742
|
+
}
|
|
1743
|
+
items.push({ header: '位置' })
|
|
1744
|
+
for (const corner of CORNERS) {
|
|
1745
|
+
items.push({ label: corner.label, run: () => goCorner(corner.label) })
|
|
1746
|
+
}
|
|
1747
|
+
items.push({ header: '皮肤' })
|
|
1748
|
+
for (const id of SKIN_IDS) {
|
|
1749
|
+
items.push({
|
|
1750
|
+
label: (id === activeSkin ? '✓ ' : ' ') + skinLabel(id),
|
|
1751
|
+
skin: id,
|
|
1752
|
+
run: () => {
|
|
1753
|
+
if (id === activeSkin) return
|
|
1754
|
+
applySkin(id)
|
|
1755
|
+
say('换成' + skinLabel(id) + '了。')
|
|
1756
|
+
},
|
|
1757
|
+
})
|
|
1758
|
+
}
|
|
1759
|
+
return items
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/* ---- 菜单的键盘驱动 ----
|
|
1763
|
+
菜单已经声明了 role="menu",没有键盘支持的话,对屏幕阅读器来说比不声明更糟 ——
|
|
1764
|
+
它宣布了一个你无法操作的菜单。采用 roving tabindex:只有当前项 tabindex=0,
|
|
1765
|
+
其余 -1,焦点本身用原生 .focus()。Enter/Space 不做显式处理 —— 焦点落在真正的
|
|
1766
|
+
<button> 上,激活是浏览器原生行为,自己再拦一次会双重触发。 */
|
|
1767
|
+
let menuButtons = []
|
|
1768
|
+
let menuFocusAt = -1
|
|
1769
|
+
|
|
1770
|
+
const focusMenuItem = (at) => {
|
|
1771
|
+
if (menuButtons.length === 0) return
|
|
1772
|
+
const next = ((at % menuButtons.length) + menuButtons.length) % menuButtons.length
|
|
1773
|
+
menuFocusAt = next
|
|
1774
|
+
for (let i = 0; i < menuButtons.length; i++) {
|
|
1775
|
+
const btn = menuButtons[i]
|
|
1776
|
+
btn.tabIndex = i === next ? 0 : -1
|
|
1777
|
+
btn.setAttribute('tabindex', i === next ? '0' : '-1')
|
|
1778
|
+
btn.setAttribute('aria-selected', i === next ? 'true' : 'false')
|
|
1779
|
+
}
|
|
1780
|
+
const target = menuButtons[next]
|
|
1781
|
+
if (target && typeof target.focus === 'function') {
|
|
1782
|
+
try { target.focus() } catch (err) { /* 焦点不可用也不该让菜单崩掉 */ }
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const focusHer = () => {
|
|
1787
|
+
if (typeof bodyEl.focus === 'function') {
|
|
1788
|
+
try { bodyEl.focus() } catch (err) { /* ignore */ }
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const openMenu = (x, y) => {
|
|
1793
|
+
closeMenu()
|
|
1794
|
+
stopGlide()
|
|
1795
|
+
const items = menuItems()
|
|
1796
|
+
menuButtons = []
|
|
1797
|
+
const menu = document.createElement('div')
|
|
1798
|
+
menu.className = 'wisp-menu'
|
|
1799
|
+
menu.setAttribute('role', 'menu')
|
|
1800
|
+
menu.style.position = 'absolute'
|
|
1801
|
+
for (let i = 0; i < items.length; i++) {
|
|
1802
|
+
const spec = items[i]
|
|
1803
|
+
if (spec.header !== undefined) {
|
|
1804
|
+
// 小标题是分组标签,不是菜单项 —— 不能被聚焦,否则键盘用户会停在一个按不动的东西上
|
|
1805
|
+
const head = document.createElement('div')
|
|
1806
|
+
head.className = 'wisp-menu-head'
|
|
1807
|
+
head.textContent = spec.header
|
|
1808
|
+
menu.appendChild(head)
|
|
1809
|
+
continue
|
|
1810
|
+
}
|
|
1811
|
+
const btn = document.createElement('button')
|
|
1812
|
+
btn.className = 'wisp-menu-item'
|
|
1813
|
+
btn.setAttribute('type', 'button')
|
|
1814
|
+
btn.setAttribute('role', 'menuitem')
|
|
1815
|
+
btn.setAttribute('tabindex', '-1')
|
|
1816
|
+
btn.textContent = spec.label
|
|
1817
|
+
// 菜单自己吞掉 pointerdown,否则会被下面那个"点外面就关"的监听判成外部点击
|
|
1818
|
+
btn.addEventListener('pointerdown', (ev) => ev.stopPropagation())
|
|
1819
|
+
btn.addEventListener('click', (ev) => {
|
|
1820
|
+
ev.preventDefault(); ev.stopPropagation()
|
|
1821
|
+
closeMenu()
|
|
1822
|
+
try { spec.run() } catch (err) { /* 单个动作失败不该带塌整个菜单 */ }
|
|
1823
|
+
})
|
|
1824
|
+
menu.appendChild(btn)
|
|
1825
|
+
menuButtons.push(btn)
|
|
1826
|
+
}
|
|
1827
|
+
// 先挂上去再定位:定位要用最终尺寸,而尺寸只有渲染后才知道
|
|
1828
|
+
layer.appendChild(menu)
|
|
1829
|
+
menuEl = menu
|
|
1830
|
+
const w = MENU_W
|
|
1831
|
+
// 高度只用于夹取落点,所以按条目类型估算即可(假 DOM 里没有布局可测)
|
|
1832
|
+
let hs = MENU_PAD
|
|
1833
|
+
for (const it of items) hs += it.header !== undefined ? 22 : MENU_ITEM_H
|
|
1834
|
+
menu.style.left = Math.round(clamp(x, 8, Math.max(8, vw() - w - 8))) + 'px'
|
|
1835
|
+
menu.style.top = Math.round(clamp(y, 8, Math.max(8, vh() - hs - 8))) + 'px'
|
|
1836
|
+
|
|
1837
|
+
const onOutside = () => closeMenu()
|
|
1838
|
+
const onKey = (ev) => {
|
|
1839
|
+
if (ev.key === 'Escape') {
|
|
1840
|
+
closeMenu()
|
|
1841
|
+
focusHer() // 关掉之后焦点要回到她身上,不能掉进 <body>
|
|
1842
|
+
return
|
|
1843
|
+
}
|
|
1844
|
+
if (menuButtons.length === 0) return
|
|
1845
|
+
let at = menuFocusAt
|
|
1846
|
+
if (ev.key === 'ArrowDown') at = at < 0 ? 0 : at + 1
|
|
1847
|
+
else if (ev.key === 'ArrowUp') at = at < 0 ? menuButtons.length - 1 : at - 1
|
|
1848
|
+
else if (ev.key === 'Home') at = 0
|
|
1849
|
+
else if (ev.key === 'End') at = menuButtons.length - 1
|
|
1850
|
+
else if (ev.key === 'Tab') { closeMenu(); return } // Tab 离开 = 关掉,别把人困在菜单里
|
|
1851
|
+
else return
|
|
1852
|
+
if (typeof ev.preventDefault === 'function') ev.preventDefault()
|
|
1853
|
+
focusMenuItem(at)
|
|
1854
|
+
}
|
|
1855
|
+
// 冒泡阶段(非捕获):菜单项的 stopPropagation 才能拦住它
|
|
1856
|
+
window.addEventListener('pointerdown', onOutside)
|
|
1857
|
+
window.addEventListener('keydown', onKey, true)
|
|
1858
|
+
window.addEventListener('resize', onOutside)
|
|
1859
|
+
window.addEventListener('wheel', onOutside)
|
|
1860
|
+
menuOff = () => {
|
|
1861
|
+
window.removeEventListener('pointerdown', onOutside)
|
|
1862
|
+
window.removeEventListener('keydown', onKey, true)
|
|
1863
|
+
window.removeEventListener('resize', onOutside)
|
|
1864
|
+
window.removeEventListener('wheel', onOutside)
|
|
1865
|
+
}
|
|
1866
|
+
// 打开即聚焦第一项:键盘用户不必先 Tab 进去
|
|
1867
|
+
focusMenuItem(0)
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
const onContext = (e) => {
|
|
1871
|
+
if (!hitsBody(e)) return
|
|
1872
|
+
e.preventDefault(); e.stopPropagation()
|
|
1873
|
+
openMenu(e.clientX, e.clientY)
|
|
1874
|
+
}
|
|
968
1875
|
bodyEl.addEventListener('contextmenu', onContext)
|
|
969
1876
|
|
|
970
1877
|
const onResize = () => {
|
|
@@ -975,21 +1882,65 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
975
1882
|
window.addEventListener('resize', onResize)
|
|
976
1883
|
|
|
977
1884
|
attach()
|
|
978
|
-
|
|
1885
|
+
/* 打招呼按时段取:凌晨三点和上午十点不该说同一句话。时段在**挂载时**取一次 ——
|
|
1886
|
+
一个挂了一整天的页面不该在过零点后突然换口气,那反而像 bug。 */
|
|
1887
|
+
const greetHour = hourNow()
|
|
1888
|
+
const greetName = greetPoolName(greetHour)
|
|
1889
|
+
clock.after(800, () => say(pick(greetPool(greetName))))
|
|
1890
|
+
|
|
1891
|
+
/* ---- 她更新了吗 --------------------------------------------------------
|
|
1892
|
+
插件不能联网,所以"检查更新"它做不了。但**它知道自己现在是哪一版** —— 于是能做到
|
|
1893
|
+
一件真事:上次见到的是旧版本,这次不是了,那就是更新过了,说一句。
|
|
1894
|
+
第一次安装(没记录)不吭声:那时候她还没"更新",只是刚来。 */
|
|
1895
|
+
const seenVersion = readStored(SEEN_VERSION_KEY)
|
|
1896
|
+
if (typeof seenVersion === 'string' && seenVersion !== VERSION) {
|
|
1897
|
+
clock.after(1600, () => {
|
|
1898
|
+
if (destroyed) return
|
|
1899
|
+
flashHappy('我更新到 ' + VERSION + ' 了。')
|
|
1900
|
+
})
|
|
1901
|
+
}
|
|
1902
|
+
writeStored(SEEN_VERSION_KEY, VERSION)
|
|
1903
|
+
/* 记忆台词排在三件事之后:打招呼(0.8s)、首次右键提示(约 3.6s)。
|
|
1904
|
+
曾经和提示撞在同一秒,结果两条互相盖掉 —— 挪到 7 秒,彼此都有位置。 */
|
|
1905
|
+
if (cfg.reactions) {
|
|
1906
|
+
clock.after(7000, () => {
|
|
1907
|
+
if (destroyed) return
|
|
1908
|
+
const remark = memoryRemark()
|
|
1909
|
+
if (remark === null) return
|
|
1910
|
+
if (remark.mood === 'proud') flashProud(remark.text)
|
|
1911
|
+
else flashHappy(remark.text)
|
|
1912
|
+
})
|
|
1913
|
+
}
|
|
1914
|
+
/* 第一次见面时提一句右键 —— 菜单是主要入口,但从外观上完全看不出来。
|
|
1915
|
+
只提一次:第二次见面还念叨就成了骚扰。 */
|
|
1916
|
+
if (cfg.persist && readStored(HINT_KEY) === null) {
|
|
1917
|
+
clock.after(3600, () => {
|
|
1918
|
+
if (destroyed) return
|
|
1919
|
+
say(pick(LINES.hint))
|
|
1920
|
+
writeStored(HINT_KEY, { told: true })
|
|
1921
|
+
})
|
|
1922
|
+
}
|
|
979
1923
|
wake(true)
|
|
1924
|
+
if (hidden) showTab()
|
|
980
1925
|
|
|
981
1926
|
/* warm the other sprites after the first frame so mood changes never blink */
|
|
982
1927
|
clock.after(1500, () => {
|
|
983
1928
|
if (destroyed) return
|
|
984
|
-
for (const key in SPRITES) {
|
|
1929
|
+
for (const key in SPRITES[activeSkin]) {
|
|
985
1930
|
if (key === currentSprite) continue
|
|
986
1931
|
const pre = new Image()
|
|
987
|
-
pre.src = resolveSprite(
|
|
1932
|
+
pre.src = resolveSprite(spriteOf(key))
|
|
988
1933
|
}
|
|
989
1934
|
})
|
|
990
1935
|
|
|
991
1936
|
const applyConfig = (partial) => {
|
|
992
1937
|
mergeConfig(cfg, partial)
|
|
1938
|
+
// configure({skin: '…'}) 也要能换皮肤,否则配置与菜单两条路会不一致
|
|
1939
|
+
if (partial && typeof partial.skin === 'string' && partial.skin !== '') applySkin(partial.skin)
|
|
1940
|
+
// 尺寸也要活过刷新:位置/皮肤/隐藏都记了,只有尺寸不记会显得前后不一致
|
|
1941
|
+
if (cfg.persist && partial && typeof partial === 'object' && partial.size !== undefined) {
|
|
1942
|
+
writeStored(PREFS_KEY, { size: cfg.size })
|
|
1943
|
+
}
|
|
993
1944
|
// A bigger companion occupies a bigger box, so the stored position has
|
|
994
1945
|
// to be re-clamped or she would grow off the edge.
|
|
995
1946
|
root.style.width = Math.round(boxW()) + 'px'
|
|
@@ -1002,9 +1953,116 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1002
1953
|
syncPoll()
|
|
1003
1954
|
syncChatter()
|
|
1004
1955
|
syncWander()
|
|
1956
|
+
// 开关 backdrop 之后要立刻生效,不用刷新页面
|
|
1957
|
+
if (partial && typeof partial === 'object' && partial.backdrop !== undefined) {
|
|
1958
|
+
backdropReport = applyBackdrop()
|
|
1959
|
+
}
|
|
1005
1960
|
return api
|
|
1006
1961
|
}
|
|
1007
1962
|
|
|
1963
|
+
/* ---- 检查更新 -----------------------------------------------------------
|
|
1964
|
+
浏览器半包的 fetch 是教学陷阱,宿主半包又在 vm 沙箱里没有 Node API ——
|
|
1965
|
+
所以这条必须走**平台给的服务**:宿主半包 inject 'web',用 ctx.web.fetch 抓
|
|
1966
|
+
远端 package.json,再用 harness.handle 注册成 checkUpdate,这边用 host.call 调。
|
|
1967
|
+
|
|
1968
|
+
host 是 shell 注入的名字,不是全局:拿不到就如实说"查不了",不许抛。 */
|
|
1969
|
+
let updateCheck = { state: 'idle', at: 0 }
|
|
1970
|
+
|
|
1971
|
+
const compareVersions = (a, b) => {
|
|
1972
|
+
const parse = (v) => String(v).split('-')[0].split('.').map((n) => {
|
|
1973
|
+
const x = Number.parseInt(n, 10)
|
|
1974
|
+
return Number.isFinite(x) ? x : 0
|
|
1975
|
+
})
|
|
1976
|
+
const left = parse(a)
|
|
1977
|
+
const right = parse(b)
|
|
1978
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
1979
|
+
const l = left[i] ?? 0
|
|
1980
|
+
const r = right[i] ?? 0
|
|
1981
|
+
if (l !== r) return l < r ? -1 : 1
|
|
1982
|
+
}
|
|
1983
|
+
return 0
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
/* shell 把 host 作为闭包符号给它;typeof 对未声明名字是安全的,
|
|
1987
|
+
但如果 shell 根本没提供,这里只会得到 undefined,不会炸。 */
|
|
1988
|
+
const hostSeat = () => {
|
|
1989
|
+
try {
|
|
1990
|
+
if (typeof host !== 'undefined' && host && typeof host.call === 'function') return host
|
|
1991
|
+
} catch (e) { /* 没这个座位 */ }
|
|
1992
|
+
return null
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const checkForUpdate = () => {
|
|
1996
|
+
const seat = hostSeat()
|
|
1997
|
+
if (seat === null) {
|
|
1998
|
+
updateCheck = { state: 'unsupported', at: nowMs() }
|
|
1999
|
+
return Promise.resolve(updateCheck)
|
|
2000
|
+
}
|
|
2001
|
+
updateCheck = { state: 'checking', at: nowMs() }
|
|
2002
|
+
return Promise.resolve()
|
|
2003
|
+
.then(() => seat.call('checkUpdate', { current: VERSION }))
|
|
2004
|
+
.then((res) => {
|
|
2005
|
+
if (!res || res.ok !== true || typeof res.latest !== 'string') {
|
|
2006
|
+
updateCheck = { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
|
|
2007
|
+
} else if (compareVersions(VERSION, res.latest) < 0) {
|
|
2008
|
+
updateCheck = { state: 'available', latest: res.latest, at: nowMs() }
|
|
2009
|
+
} else {
|
|
2010
|
+
updateCheck = { state: 'current', latest: res.latest, at: nowMs() }
|
|
2011
|
+
}
|
|
2012
|
+
return updateCheck
|
|
2013
|
+
})
|
|
2014
|
+
.catch((err) => {
|
|
2015
|
+
updateCheck = {
|
|
2016
|
+
state: 'failed',
|
|
2017
|
+
reason: 'call-failed',
|
|
2018
|
+
detail: String(err && err.message ? err.message : err),
|
|
2019
|
+
at: nowMs(),
|
|
2020
|
+
}
|
|
2021
|
+
return updateCheck
|
|
2022
|
+
})
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
const updateLine = (res) => {
|
|
2026
|
+
if (res.state === 'available') return pick(LINES.updateAvailable) + res.latest
|
|
2027
|
+
if (res.state === 'current') return pick(LINES.updateCurrent)
|
|
2028
|
+
return pick(LINES.updateFailed)
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/* 把包名复制到剪贴板,便于在 DSH 的「按插件包名安装」里粘贴。
|
|
2032
|
+
没有剪贴板权限(或不是安全上下文)时**静默降级**成只说话 —— 不该因此报错。 */
|
|
2033
|
+
const copyText = (text) => {
|
|
2034
|
+
try {
|
|
2035
|
+
const nav = window.navigator
|
|
2036
|
+
const cb = nav && nav.clipboard
|
|
2037
|
+
if (cb && typeof cb.writeText === 'function') {
|
|
2038
|
+
const p = cb.writeText(text)
|
|
2039
|
+
if (p && typeof p.catch === 'function') p.catch(() => {})
|
|
2040
|
+
return true
|
|
2041
|
+
}
|
|
2042
|
+
} catch (e) { /* 降级 */ }
|
|
2043
|
+
return false
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/* 「版本与更新」:说出当前版本与这一版的要点,并把包名复制好。
|
|
2047
|
+
用户点这一项想知道的就是这两件事。 */
|
|
2048
|
+
const showUpdate = () => {
|
|
2049
|
+
const note = WHATS_NEW[VERSION]
|
|
2050
|
+
const copied = copyText('dsh-wisp')
|
|
2051
|
+
/* 一步到位:先把"我是谁、这一版做了什么"说出来,同时去查有没有新版;
|
|
2052
|
+
查回来再用一句话覆盖掉 —— 一次只冒一个气泡,所以是"说完结果就结束"。 */
|
|
2053
|
+
const base = '我是 ' + VERSION + '。'
|
|
2054
|
+
flashHappy(note ? base + note : base)
|
|
2055
|
+
const started = checkForUpdate()
|
|
2056
|
+
if (started && typeof started.then === 'function') {
|
|
2057
|
+
started.then((res) => {
|
|
2058
|
+
if (destroyed) return
|
|
2059
|
+
if (res.state === 'checking') return
|
|
2060
|
+
flashHappy(base + updateLine(res))
|
|
2061
|
+
})
|
|
2062
|
+
}
|
|
2063
|
+
return { version: VERSION, note: note || null, copied, checking: true }
|
|
2064
|
+
}
|
|
2065
|
+
|
|
1008
2066
|
const api = {
|
|
1009
2067
|
version: VERSION,
|
|
1010
2068
|
clock: clock.mode,
|
|
@@ -1019,7 +2077,155 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1019
2077
|
paint()
|
|
1020
2078
|
},
|
|
1021
2079
|
get element() { return root },
|
|
1022
|
-
get sprites() { return Object.keys(SPRITES) },
|
|
2080
|
+
get sprites() { return Object.keys(SPRITES[activeSkin]) },
|
|
2081
|
+
get skins() { return SKIN_IDS.slice() },
|
|
2082
|
+
/* 读用 skin、写用 setSkin。同一个对象字面量里 get skin() 与 skin: fn
|
|
2083
|
+
不能共存 —— 后者会静默覆盖前者,读出来就成了函数。 */
|
|
2084
|
+
get skin() { return activeSkin },
|
|
2085
|
+
setSkin: applySkin,
|
|
2086
|
+
showUpdate,
|
|
2087
|
+
checkForUpdate,
|
|
2088
|
+
get updateCheck() { return { ...updateCheck } },
|
|
2089
|
+
get hidden() { return hidden },
|
|
2090
|
+
hide: () => setHidden(true),
|
|
2091
|
+
show: () => setHidden(false),
|
|
2092
|
+
/* 读实时配置(Console 里 `__wisp.config` 是唯一能看到生效值的地方)。给的是副本。 */
|
|
2093
|
+
get config() { return Object.assign({}, cfg) },
|
|
2094
|
+
goCorner,
|
|
2095
|
+
|
|
2096
|
+
/* -----------------------------------------------------------------
|
|
2097
|
+
doctor() —— 自检:这个页面里,插件依赖的每个钩子到底能不能解析。
|
|
2098
|
+
|
|
2099
|
+
存在的理由:这些选择器是我读 DSH 源码推出来的,而**它们错了不会有任何报错** ——
|
|
2100
|
+
对应的功能只是永远不触发,看起来像"她今天没反应"。而插件跑在带鉴权的页面里,
|
|
2101
|
+
外部脚本(包括我这边的浏览器)连不上,所以自检必须由插件自己在页面里提供。
|
|
2102
|
+
|
|
2103
|
+
只读,无副作用。Console 里敲 `__wisp.doctor()` 即可。
|
|
2104
|
+
----------------------------------------------------------------- */
|
|
2105
|
+
doctor() {
|
|
2106
|
+
const probe = (selector) => {
|
|
2107
|
+
try { return document.querySelectorAll(selector).length } catch (e) { return -1 }
|
|
2108
|
+
}
|
|
2109
|
+
const composer = COMPOSER_HOOKS.map((selector) => ({ selector, matches: probe(selector) }))
|
|
2110
|
+
const pending = PENDING_ATTRS.map((attr) => ({
|
|
2111
|
+
selector: '[' + attr + ']',
|
|
2112
|
+
matches: probe('[' + attr + ']'),
|
|
2113
|
+
}))
|
|
2114
|
+
const body = document.body
|
|
2115
|
+
/* 走 window. 而不是裸全局:客户端半包可能运行在"被提供的 window ≠ 环境全局"之下,
|
|
2116
|
+
裸 getComputedStyle 会解析到别处(或不存在)。nowMs 当初就是这么坏掉的。 */
|
|
2117
|
+
const style = body && window && typeof window.getComputedStyle === 'function'
|
|
2118
|
+
? window.getComputedStyle(body)
|
|
2119
|
+
: null
|
|
2120
|
+
const tokens = THEME_TOKENS.map((token) => ({
|
|
2121
|
+
token,
|
|
2122
|
+
value: style ? style.getPropertyValue(token).trim() : '',
|
|
2123
|
+
}))
|
|
2124
|
+
|
|
2125
|
+
/* 把"数字"翻译成"哪个功能会失效",否则这份报告没人看得懂。
|
|
2126
|
+
只列**真的意味着失效**的条件:待处理面板与出错行缺席是正常状态,
|
|
2127
|
+
把它们算进 problems 只会训练人忽略这份清单。 */
|
|
2128
|
+
const problems = []
|
|
2129
|
+
if (!composer.some((h) => h.matches > 0)) {
|
|
2130
|
+
problems.push('输入框钩子一个都没匹配到 —— 若你此刻正开着会话(能看到输入框),'
|
|
2131
|
+
+ '就是钩子失效了:「你在打字时她进入待命」不会触发')
|
|
2132
|
+
}
|
|
2133
|
+
if (tokens.every((t) => t.value === '')) {
|
|
2134
|
+
problems.push('主题令牌全为空 → 亮暗跟随退化成默认色')
|
|
2135
|
+
}
|
|
2136
|
+
if (!body) problems.push('document.body 不存在')
|
|
2137
|
+
|
|
2138
|
+
const busyMatches = probe(STOP_SELECTOR)
|
|
2139
|
+
/* 忙碌钩子是唯一无法静态确认的:它的 aria-label 来自语言表,而不是源码里的字面量
|
|
2140
|
+
(其余 data-* 钩子我已在已发布的 app.asar 里逐个确认存在)。所以找不到时,把页面上
|
|
2141
|
+
实际存在的按钮 aria-label 一起报出来 —— 一次 doctor() 就能看出真正的标签是什么。 */
|
|
2142
|
+
const sampleAriaLabels = () => {
|
|
2143
|
+
try {
|
|
2144
|
+
const all = document.querySelectorAll('button[aria-label]')
|
|
2145
|
+
const out = []
|
|
2146
|
+
for (let i = 0; i < all.length && out.length < 12; i++) {
|
|
2147
|
+
const label = all[i].getAttribute('aria-label')
|
|
2148
|
+
if (label && out.indexOf(label) < 0) out.push(label)
|
|
2149
|
+
}
|
|
2150
|
+
return out
|
|
2151
|
+
} catch (e) {
|
|
2152
|
+
return []
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
return {
|
|
2157
|
+
version: VERSION,
|
|
2158
|
+
clock: clock.mode,
|
|
2159
|
+
mood,
|
|
2160
|
+
skin: activeSkin,
|
|
2161
|
+
hidden,
|
|
2162
|
+
position: { x: Math.round(coords.x), y: Math.round(coords.y) },
|
|
2163
|
+
config: Object.assign({}, cfg),
|
|
2164
|
+
hooks: {
|
|
2165
|
+
composer,
|
|
2166
|
+
busy: busyMatches === 0
|
|
2167
|
+
? { selector: STOP_SELECTOR, matches: 0, ariaLabelsOnPage: sampleAriaLabels() }
|
|
2168
|
+
: { selector: STOP_SELECTOR, matches: busyMatches },
|
|
2169
|
+
pending,
|
|
2170
|
+
error: { selector: ERROR_SELECTOR, matches: probe(ERROR_SELECTOR) },
|
|
2171
|
+
},
|
|
2172
|
+
dark: !!(body && body.hasAttribute('data-ds-dark-theme')),
|
|
2173
|
+
tokens,
|
|
2174
|
+
/* 窗口背景:Mica 能不能露出来,取决于页面有没有在背景处画不透明像素。
|
|
2175
|
+
blockers 是"每个采样点上最上面那个铺满视口的画底色的元素",
|
|
2176
|
+
cleared 是已经被清掉的那些 —— 一眼就能看出有没有找对。 */
|
|
2177
|
+
backdrop: {
|
|
2178
|
+
enabled: !!cfg.backdrop,
|
|
2179
|
+
blockers: backdropReport.blockers,
|
|
2180
|
+
cleared: backdropReport.cleared,
|
|
2181
|
+
skipped: backdropReport.skipped,
|
|
2182
|
+
errors: backdropReport.errors,
|
|
2183
|
+
},
|
|
2184
|
+
/* 页面上到底有几个她。只要不是 1 就是 bug,而这件事必须能被一眼看见 ——
|
|
2185
|
+
"出现了两个她"最早是从用户嘴里知道的,不该是这样。 */
|
|
2186
|
+
/* 更新相关的实情:当前版本、这一版做了什么、以及更新该走哪条路。
|
|
2187
|
+
注意:插件自己**没有**联网能力,这里给的是"怎么做",不是"帮你做"。 */
|
|
2188
|
+
update: {
|
|
2189
|
+
version: VERSION,
|
|
2190
|
+
whatsNew: WHATS_NEW[VERSION] ?? null,
|
|
2191
|
+
seenBefore: readStored(SEEN_VERSION_KEY),
|
|
2192
|
+
hint: UPDATE_HINT,
|
|
2193
|
+
canSelfUpdate: false,
|
|
2194
|
+
/* 上一次检查的结果(idle / checking / current / available / failed / unsupported) */
|
|
2195
|
+
lastCheck: { ...updateCheck },
|
|
2196
|
+
why: '宿主半包在 vm 沙箱里,harness 只有 defineTool/registerTool/handle,Node API 被 nodeApiTraps 挡住',
|
|
2197
|
+
},
|
|
2198
|
+
/* 今日轮次:不用猜"她为什么突然得意",数字就在这里 */
|
|
2199
|
+
runsToday: { day: runsToday.day, count: runsToday.count, said: runsToday.said.slice() },
|
|
2200
|
+
/* 她记着你:一起多少天、习惯放在哪、常穿哪套、已经说过哪些 */
|
|
2201
|
+
memory: {
|
|
2202
|
+
days: memory.days.length,
|
|
2203
|
+
topCorner: topOf(memory.corners),
|
|
2204
|
+
topSkin: topOf(memory.skins),
|
|
2205
|
+
said: memory.said.slice(),
|
|
2206
|
+
remarkDay: memory.remarkDay,
|
|
2207
|
+
},
|
|
2208
|
+
instances: (() => {
|
|
2209
|
+
try {
|
|
2210
|
+
return { layers: document.querySelectorAll('.wisp-layer').length, roots: document.querySelectorAll('.wisp-root').length }
|
|
2211
|
+
} catch (e) { return { layers: -1, roots: -1 } }
|
|
2212
|
+
})(),
|
|
2213
|
+
/* 时段招呼:报告当前用的是哪一套、几分钟前算的。她在凌晨和上午的口气不同,
|
|
2214
|
+
而这些文案是按小时选的 —— 报告出来,就不用靠猜"她现在为什么这么说"。 */
|
|
2215
|
+
greeting: {
|
|
2216
|
+
pool: greetName,
|
|
2217
|
+
hour: greetHour,
|
|
2218
|
+
poolSize: greetPool(greetName).length,
|
|
2219
|
+
pools: { morning: LINES.morning.length, afternoon: LINES.afternoon.length, evening: LINES.evening.length, night: LINES.night.length },
|
|
2220
|
+
},
|
|
2221
|
+
problems,
|
|
2222
|
+
/* 已知的下一步:SessionSnapshot 里有权威的 `running` 与 `lastAgentError`
|
|
2223
|
+
(经 sessions.binding(id).session)。它比 busy/error 两个 DOM 选择器可靠得多,
|
|
2224
|
+
但需要先拿到当前会话 id,而且我无法在带鉴权的页面外验证 —— 所以先记在这里。 */
|
|
2225
|
+
sessionsService: !!(ctx && typeof ctx.get === 'function' && ctx.get('sessions')),
|
|
2226
|
+
}
|
|
2227
|
+
},
|
|
2228
|
+
get skinLabels() { return SKIN_IDS.map((id) => ({ id, label: skinLabel(id) })) },
|
|
1023
2229
|
get position() { return { x: Math.round(coords.x), y: Math.round(coords.y) } },
|
|
1024
2230
|
get currentMood() { return mood },
|
|
1025
2231
|
destroy() {
|
|
@@ -1032,13 +2238,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
|
|
|
1032
2238
|
window.removeEventListener('pointermove', onMove)
|
|
1033
2239
|
window.removeEventListener('pointerup', onUp)
|
|
1034
2240
|
bodyEl.removeEventListener('pointerdown', onDown)
|
|
2241
|
+
bodyEl.removeEventListener('keydown', onBodyKey)
|
|
1035
2242
|
bodyEl.removeEventListener('contextmenu', onContext)
|
|
2243
|
+
closeMenu()
|
|
2244
|
+
hideTab()
|
|
1036
2245
|
if (typeof offTheme === 'function') {
|
|
1037
2246
|
try { offTheme() } catch (e) { /* ignore */ }
|
|
1038
2247
|
offTheme = null
|
|
1039
2248
|
}
|
|
1040
2249
|
if (typeof styleDispose === 'function') {
|
|
1041
2250
|
try { styleDispose() } catch (e) { /* ignore */ }
|
|
2251
|
+
/* 背景规则也要收掉:她自己的东西必须自己清干净,不能留在页面上 */
|
|
2252
|
+
removeBackdropStyle()
|
|
1042
2253
|
}
|
|
1043
2254
|
if (layer.parentNode) layer.remove()
|
|
1044
2255
|
revokeSprites()
|