dsh-wisp 0.6.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/LICENSE +32 -0
- package/NOTICE.md +151 -0
- package/README.md +285 -0
- 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/build.mjs +169 -0
- package/cordis.patch.yml +51 -0
- package/lib/client.js +1091 -0
- package/lib/client.template.js +1086 -0
- package/lib/index.js +19 -0
- package/package.json +72 -0
- package/tools/CHARACTER-PROMPT.md +88 -0
- package/tools/assets.mjs +139 -0
- package/tools/keyout.mjs +104 -0
- package/verify-wisp.mjs +872 -0
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
dsh-wisp — browser half. GENERATED FILE — edit lib/client.template.js then
|
|
3
|
+
run `node build.mjs`. The build injects one object of data URIs (see the SPRITES const).
|
|
4
|
+
|
|
5
|
+
A CLASSIC browser script that registers itself with the web shell's module
|
|
6
|
+
loader. Not an ES module: no import, no export.
|
|
7
|
+
|
|
8
|
+
window.__ModuleLoader__.load({ id, factory: (require) => { … } })
|
|
9
|
+
|
|
10
|
+
The companion is DeepSeek娘 — the community's personification of DeepSeek.
|
|
11
|
+
Four AI-generated sprites are embedded as data URIs so the plugin stays a
|
|
12
|
+
single self-contained file with no asset paths to resolve. See NOTICE.md for
|
|
13
|
+
the character and image provenance.
|
|
14
|
+
|
|
15
|
+
Moods map to sprites:
|
|
16
|
+
idle -> idle (default)
|
|
17
|
+
alert -> work (agent is busy, or the composer has text)
|
|
18
|
+
happy -> happy (you clicked her, or a run just finished)
|
|
19
|
+
sleep -> sleepy (left alone for a while)
|
|
20
|
+
|
|
21
|
+
FOUR PLATFORM FACTS THIS FILE IS BUILT AROUND (each one cost a debugging
|
|
22
|
+
session — the traps are documented in NOTICE.md):
|
|
23
|
+
|
|
24
|
+
1. setTimeout / setInterval / clearTimeout / clearInterval / fetch / require
|
|
25
|
+
are THROWING TRAPS inside a client half evaluated by the dynamic runner.
|
|
26
|
+
All timing here comes from the Client `timer` service (ctx.get('timer')),
|
|
27
|
+
with a requestAnimationFrame deadline table as the fallback for a shell
|
|
28
|
+
that does not provide it. Neither path ever touches a trapped global.
|
|
29
|
+
|
|
30
|
+
2. The rAF fallback is armed ONLY while jobs are pending. An always-on frame
|
|
31
|
+
loop would keep a desktop GUI process waking sixty times a second to do
|
|
32
|
+
nothing — and pointermove fires often enough that re-arming a job per
|
|
33
|
+
event is its own small leak.
|
|
34
|
+
|
|
35
|
+
3. Sprite bytes reach <img> through a blob: object URL, never as the raw
|
|
36
|
+
`data:` URI: this shell serves its UI from a custom origin and renders a
|
|
37
|
+
data: image source as a broken-image icon.
|
|
38
|
+
|
|
39
|
+
4. The bubble and text colors come from the app's own theme tokens
|
|
40
|
+
(--dsw-alias-*) with hard-coded fallbacks, and the light/dark fallback is
|
|
41
|
+
keyed off `body[data-ds-dark-theme]` — the attribute the shell's theme
|
|
42
|
+
presenter actually writes.
|
|
43
|
+
========================================================================== */
|
|
44
|
+
window.__ModuleLoader__.load({
|
|
45
|
+
id: 'dsh-wisp/client',
|
|
46
|
+
factory: (require) => {
|
|
47
|
+
var module = { exports: {} }
|
|
48
|
+
var exports = module.exports
|
|
49
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
50
|
+
|
|
51
|
+
const VERSION = '0.6.0'
|
|
52
|
+
|
|
53
|
+
/* ---- sprites: 256x384 WebP, transparent, inlined by build.mjs ---- */
|
|
54
|
+
const SPRITES = __SPRITES_LITERAL__
|
|
55
|
+
|
|
56
|
+
/* ---------------------------------------------------------------------
|
|
57
|
+
SPRITE URL RESOLUTION — why this is not simply `img.src = dataUri`.
|
|
58
|
+
|
|
59
|
+
The first shipped build set `img.src` to the base64 data URI directly.
|
|
60
|
+
The plugin loaded (console showed "[wisp] mounted"), the DOM was correct
|
|
61
|
+
and the ground glow rendered — but the sprite box showed a broken-image
|
|
62
|
+
icon. The embedded bytes were verified good (RIFF/WEBP magic, 256x384
|
|
63
|
+
RGBA), so the failure was the page refusing a `data:` URI as an image
|
|
64
|
+
source. This shell serves its UI over a custom `dsh-app://` protocol,
|
|
65
|
+
which appears to intercept resource loads.
|
|
66
|
+
|
|
67
|
+
So the data URI is decoded into a Blob and an object URL is used instead,
|
|
68
|
+
which is what the page accepts. Blob URLs are revoked on teardown.
|
|
69
|
+
--------------------------------------------------------------------- */
|
|
70
|
+
const blobUrls = Object.create(null)
|
|
71
|
+
|
|
72
|
+
const resolveSprite = (dataUri) => {
|
|
73
|
+
if (blobUrls[dataUri]) return blobUrls[dataUri]
|
|
74
|
+
try {
|
|
75
|
+
const comma = dataUri.indexOf(',')
|
|
76
|
+
const meta = dataUri.slice(0, comma)
|
|
77
|
+
const body = dataUri.slice(comma + 1)
|
|
78
|
+
const mime = (meta.match(/data:([^;]+)/) || [])[1] || 'image/webp'
|
|
79
|
+
const bin = atob(body)
|
|
80
|
+
const bytes = new Uint8Array(bin.length)
|
|
81
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
82
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: mime }))
|
|
83
|
+
blobUrls[dataUri] = url
|
|
84
|
+
return url
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// Blob unavailable for some reason — fall back to the raw data URI.
|
|
87
|
+
blobUrls[dataUri] = dataUri
|
|
88
|
+
return dataUri
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/* Revoking is not enough: this cache is per MODULE, while a mount is per
|
|
93
|
+
apply, so a second apply in the same module (HMR, or the singleton
|
|
94
|
+
replacement below) would otherwise hand the new <img> a revoked blob URL
|
|
95
|
+
and render the sprite as a broken image. Dropping the entries makes the
|
|
96
|
+
next mount rebuild them. */
|
|
97
|
+
const revokeSprites = () => {
|
|
98
|
+
for (const key in blobUrls) {
|
|
99
|
+
const url = blobUrls[key]
|
|
100
|
+
if (typeof url === 'string' && url.indexOf('blob:') === 0) {
|
|
101
|
+
try { URL.revokeObjectURL(url) } catch (e) { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
delete blobUrls[key]
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---- geometry: one box per mood so she never jumps ---- */
|
|
108
|
+
const BOX_W = 140
|
|
109
|
+
const BOX_H = 210
|
|
110
|
+
const GROUND = 46 // px from the bottom edge to her feet
|
|
111
|
+
const SAY_MS = 4400 // bubble lifetime
|
|
112
|
+
const SAY_MAX_W = 210 // must match .wisp-say max-width in the CSS
|
|
113
|
+
const SAY_CLEARANCE = 84 // room the bubble needs above its anchor
|
|
114
|
+
const POLL_MS = 1200 // reaction poll
|
|
115
|
+
const WAKE_THROTTLE_MS = 1000
|
|
116
|
+
|
|
117
|
+
const DEFAULTS = {
|
|
118
|
+
bottom: GROUND,
|
|
119
|
+
right: 26,
|
|
120
|
+
sleepAfterMs: 90000,
|
|
121
|
+
reactions: true,
|
|
122
|
+
size: 4, // 560x840 CSS px, drawn from the 2048x3072 tier
|
|
123
|
+
persist: true, // remember where she was dragged to
|
|
124
|
+
celebrate: true, // cheer when a run finishes
|
|
125
|
+
celebrateAfterMs: 2500, // ...but only for runs that lasted this long
|
|
126
|
+
chatterMs: 0, // 0 = she stays quiet when idle
|
|
127
|
+
happyMs: 1100,
|
|
128
|
+
wander: true, // stroll around on her own while idle
|
|
129
|
+
wanderMs: 45000, // how often she considers moving
|
|
130
|
+
wanderRange: 260, // furthest a single stroll may travel
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const LINES = {
|
|
134
|
+
hello: ['我在这儿。', '灯亮着,你忙你的。', '需要我就戳一下。'],
|
|
135
|
+
click: ['嗯?', '在的。', '别戳啦。', '……痒。', '有事说,没事也成。', '我一直看着屏幕呢。'],
|
|
136
|
+
happy: ['叮——', '这个我喜欢。', '再来一次?'],
|
|
137
|
+
sleep: ['呼……', '我眯一会儿。'],
|
|
138
|
+
wake: ['醒了醒了。', '嗯,我在。'],
|
|
139
|
+
done: ['跑完了。', '收了。', '这一轮结束。', '去看看?'],
|
|
140
|
+
chatter: ['……', '有点安静。', '我在数窗外的光。'],
|
|
141
|
+
move: ['这儿也行。', '我挪个位置。', '让让,我站这儿。'],
|
|
142
|
+
home: ['回来了。', '还是老地方。', '嗯,归位。'],
|
|
143
|
+
}
|
|
144
|
+
const pick = (a) => a[Math.floor(Math.random() * a.length)]
|
|
145
|
+
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v)
|
|
146
|
+
|
|
147
|
+
/* Read the clock through `window` only. The obvious `window.performance &&
|
|
148
|
+
performance.now()` mixes two references: in a page they are the same
|
|
149
|
+
object, but under a shell that supplies its own `window` the bare
|
|
150
|
+
`performance` global can be a different — or absent — one, and then every
|
|
151
|
+
deadline is computed on one time base and compared against another, so
|
|
152
|
+
nothing ever comes due. */
|
|
153
|
+
const nowMs = () => {
|
|
154
|
+
const perf = window.performance
|
|
155
|
+
return perf && typeof perf.now === 'function' ? perf.now() : Date.now()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* ---------------------------------------------------------------------
|
|
159
|
+
CONFIG — normalized and clamped on the way in.
|
|
160
|
+
|
|
161
|
+
The config arrives from a profile YAML patch, so nothing about it is
|
|
162
|
+
guaranteed: a typo yields a string where a number belongs, and an
|
|
163
|
+
extreme value yields a companion nobody can find or click. Clamping
|
|
164
|
+
here means a bad patch degrades into a usable wisp instead of a bug
|
|
165
|
+
report.
|
|
166
|
+
--------------------------------------------------------------------- */
|
|
167
|
+
const num = (value, lo, hi, fallback) => {
|
|
168
|
+
const n = typeof value === 'number' && isFinite(value) ? value : fallback
|
|
169
|
+
return clamp(n, lo, hi)
|
|
170
|
+
}
|
|
171
|
+
const bool = (value, fallback) => (typeof value === 'boolean' ? value : fallback)
|
|
172
|
+
|
|
173
|
+
/* One table drives both the initial normalization and the partial merge a
|
|
174
|
+
runtime `configure({…})` performs, so a key can never be clamped one way
|
|
175
|
+
at mount and another way later. */
|
|
176
|
+
const CONFIG_SPEC = {
|
|
177
|
+
bottom: { kind: 'num', lo: 0, hi: 4000 },
|
|
178
|
+
right: { kind: 'num', lo: 0, hi: 4000 },
|
|
179
|
+
size: { kind: 'num', lo: 0.4, hi: 8 },
|
|
180
|
+
sleepAfterMs: { kind: 'num', lo: 5000, hi: 3600000 },
|
|
181
|
+
celebrateAfterMs: { kind: 'num', lo: 0, hi: 600000 },
|
|
182
|
+
chatterMs: { kind: 'num', lo: 0, hi: 3600000 },
|
|
183
|
+
happyMs: { kind: 'num', lo: 200, hi: 10000 },
|
|
184
|
+
wanderMs: { kind: 'num', lo: 5000, hi: 3600000 },
|
|
185
|
+
wanderRange: { kind: 'num', lo: 0, hi: 2000 },
|
|
186
|
+
reactions: { kind: 'bool' },
|
|
187
|
+
persist: { kind: 'bool' },
|
|
188
|
+
celebrate: { kind: 'bool' },
|
|
189
|
+
wander: { kind: 'bool' },
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const coerce = (key, value, fallback) => {
|
|
193
|
+
const spec = CONFIG_SPEC[key]
|
|
194
|
+
return spec.kind === 'num' ? num(value, spec.lo, spec.hi, fallback) : bool(value, fallback)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const normalizeConfig = (config) => {
|
|
198
|
+
const src = config && typeof config === 'object' ? config : {}
|
|
199
|
+
const cfg = {}
|
|
200
|
+
for (const key in CONFIG_SPEC) cfg[key] = coerce(key, src[key], DEFAULTS[key])
|
|
201
|
+
return cfg
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Merge only the keys the caller actually sent: `configure({size: 2})` must
|
|
205
|
+
not silently reset every other preference to its default. */
|
|
206
|
+
const mergeConfig = (cfg, partial) => {
|
|
207
|
+
if (!partial || typeof partial !== 'object') return cfg
|
|
208
|
+
for (const key in CONFIG_SPEC) {
|
|
209
|
+
if (!Object.prototype.hasOwnProperty.call(partial, key)) continue
|
|
210
|
+
cfg[key] = coerce(key, partial[key], cfg[key])
|
|
211
|
+
}
|
|
212
|
+
return cfg
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ---------------------------------------------------------------------
|
|
216
|
+
CLOCK — one scheduling surface, two implementations.
|
|
217
|
+
|
|
218
|
+
`mode` is reported on the handle and in the mount log so a bug report can
|
|
219
|
+
say which path this page took without a debugger.
|
|
220
|
+
--------------------------------------------------------------------- */
|
|
221
|
+
const createServiceClock = (timer) => {
|
|
222
|
+
const live = []
|
|
223
|
+
const track = (handle) => { live.push(handle); return handle }
|
|
224
|
+
const release = (handle) => {
|
|
225
|
+
const at = live.indexOf(handle)
|
|
226
|
+
if (at >= 0) live.splice(at, 1)
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
mode: 'timer-service',
|
|
230
|
+
after(ms, fn) {
|
|
231
|
+
const handle = { cancelled: false, release: null }
|
|
232
|
+
handle.release = timer.timeout(() => {
|
|
233
|
+
handle.cancelled = true
|
|
234
|
+
release(handle)
|
|
235
|
+
fn()
|
|
236
|
+
}, ms)
|
|
237
|
+
return track(handle)
|
|
238
|
+
},
|
|
239
|
+
every(ms, fn) {
|
|
240
|
+
const handle = { cancelled: false, release: null }
|
|
241
|
+
handle.release = timer.interval(() => {
|
|
242
|
+
if (!handle.cancelled) fn()
|
|
243
|
+
}, ms)
|
|
244
|
+
return track(handle)
|
|
245
|
+
},
|
|
246
|
+
cancel(handle) {
|
|
247
|
+
if (!handle || handle.cancelled) return
|
|
248
|
+
handle.cancelled = true
|
|
249
|
+
release(handle)
|
|
250
|
+
try { handle.release() } catch (e) { /* already fired */ }
|
|
251
|
+
},
|
|
252
|
+
dispose() {
|
|
253
|
+
const pending = live.splice(0, live.length)
|
|
254
|
+
for (const handle of pending) {
|
|
255
|
+
handle.cancelled = true
|
|
256
|
+
try { handle.release() } catch (e) { /* already fired */ }
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const createFrameClock = (win) => {
|
|
263
|
+
const jobs = []
|
|
264
|
+
let frame = 0
|
|
265
|
+
|
|
266
|
+
const arm = () => {
|
|
267
|
+
if (frame === 0 && jobs.length > 0) frame = win.requestAnimationFrame(tick)
|
|
268
|
+
}
|
|
269
|
+
const tick = () => {
|
|
270
|
+
frame = 0
|
|
271
|
+
const now = nowMs()
|
|
272
|
+
for (let i = jobs.length - 1; i >= 0; i--) {
|
|
273
|
+
const job = jobs[i]
|
|
274
|
+
if (now < job.at) continue
|
|
275
|
+
if (job.every > 0) job.at = now + job.every
|
|
276
|
+
else { job.cancelled = true; jobs.splice(i, 1) }
|
|
277
|
+
try { job.fn() } catch (e) { /* one failing job must not stop the clock */ }
|
|
278
|
+
}
|
|
279
|
+
arm() // stop the loop entirely once nothing is pending
|
|
280
|
+
}
|
|
281
|
+
const push = (ms, fn, every) => {
|
|
282
|
+
const handle = { cancelled: false, at: nowMs() + ms, fn, every: every ? ms : 0 }
|
|
283
|
+
jobs.push(handle)
|
|
284
|
+
arm()
|
|
285
|
+
return handle
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
mode: 'animation-frame',
|
|
290
|
+
after: (ms, fn) => push(ms, fn, false),
|
|
291
|
+
every: (ms, fn) => push(ms, fn, true),
|
|
292
|
+
cancel(handle) {
|
|
293
|
+
if (!handle || handle.cancelled) return
|
|
294
|
+
handle.cancelled = true
|
|
295
|
+
const at = jobs.indexOf(handle)
|
|
296
|
+
if (at >= 0) jobs.splice(at, 1)
|
|
297
|
+
},
|
|
298
|
+
dispose() {
|
|
299
|
+
for (const job of jobs) job.cancelled = true
|
|
300
|
+
jobs.length = 0
|
|
301
|
+
if (frame !== 0) {
|
|
302
|
+
try { win.cancelAnimationFrame(frame) } catch (e) { /* ignore */ }
|
|
303
|
+
frame = 0
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const createClock = (ctx) => {
|
|
310
|
+
const timer = ctx && typeof ctx.get === 'function' ? ctx.get('timer') : undefined
|
|
311
|
+
if (timer && typeof timer.timeout === 'function' && typeof timer.interval === 'function') {
|
|
312
|
+
return createServiceClock(timer)
|
|
313
|
+
}
|
|
314
|
+
return createFrameClock(window)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/* ---------------------------------------------------------------------
|
|
318
|
+
STORAGE — best effort, never fatal.
|
|
319
|
+
|
|
320
|
+
Reading `window.localStorage` itself throws on an opaque origin, which is
|
|
321
|
+
exactly what the desktop shell can be: the same plugin has to survive a
|
|
322
|
+
page that has no storage at all, so every access is guarded and a miss
|
|
323
|
+
just means "no remembered position".
|
|
324
|
+
--------------------------------------------------------------------- */
|
|
325
|
+
const POSITION_KEY = 'dsh-wisp:position:v1'
|
|
326
|
+
|
|
327
|
+
const readStored = (key) => {
|
|
328
|
+
try {
|
|
329
|
+
const raw = window.localStorage.getItem(key)
|
|
330
|
+
return raw === null ? null : JSON.parse(raw)
|
|
331
|
+
} catch (e) {
|
|
332
|
+
return null
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const writeStored = (key, value) => {
|
|
336
|
+
try {
|
|
337
|
+
window.localStorage.setItem(key, JSON.stringify(value))
|
|
338
|
+
return true
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return false
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* ---------------------------------------------------------------------
|
|
345
|
+
THE CSS. Three things are deliberate:
|
|
346
|
+
|
|
347
|
+
* Colors prefer the app's own alias tokens, so the companion inherits
|
|
348
|
+
whatever theme is active (including a third-party one) instead of
|
|
349
|
+
hard-coding a white bubble that looks wrong in one palette. The
|
|
350
|
+
token-free fallbacks are the hand-tuned pair, and
|
|
351
|
+
`body[data-ds-dark-theme]` is the shell's real dark switch.
|
|
352
|
+
|
|
353
|
+
* The bubble lives in `.wisp-layer`, NOT inside `.wisp-root`. It used to
|
|
354
|
+
be a child of the root, which mirrored its text whenever she faced left
|
|
355
|
+
(the root carries `scaleX(-1)`) and scaled its font with `size`. In
|
|
356
|
+
screen space it stays readable and unmirrored at any size.
|
|
357
|
+
|
|
358
|
+
* `--wisp-scale` carries the render scale for the few decorations that
|
|
359
|
+
should grow with her even though the root is laid out at its final
|
|
360
|
+
size rather than transform-scaled.
|
|
361
|
+
--------------------------------------------------------------------- */
|
|
362
|
+
const CSS = `.wisp-layer{position:fixed;inset:0;z-index:2147482000;pointer-events:none;overflow:hidden;font-family:inherit;--wisp-scale:1;--wisp-say-fg:#0d1a22;--wisp-say-bg:rgba(255,255,255,.93);--wisp-say-line:rgba(15,30,40,.10);--wisp-ink:#3b5a70}
|
|
363
|
+
.wisp-root{position:absolute;width:${BOX_W}px;height:${BOX_H}px;pointer-events:none;transform-origin:top center;--wisp-aura:rgba(110,200,255,.42)}
|
|
364
|
+
.wisp-root[data-dragging="true"]{will-change:transform}
|
|
365
|
+
.wisp-root[data-gliding="true"]{transition:transform 1.6s cubic-bezier(.4,0,.2,1)}
|
|
366
|
+
.wisp-body{position:absolute;inset:0;pointer-events:auto;cursor:grab;touch-action:none;user-select:none;-webkit-user-select:none;animation:wisp-bob 5.6s ease-in-out infinite}
|
|
367
|
+
.wisp-body:active{cursor:grabbing}
|
|
368
|
+
.wisp-img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:bottom center;filter:drop-shadow(0 6px 14px rgba(0,0,0,.30));transition:opacity .28s ease,filter .5s ease}
|
|
369
|
+
.wisp-shine{position:absolute;left:50%;bottom:-8px;width:68%;height:16px;margin-left:-34%;border-radius:50%;background:radial-gradient(ellipse at center,var(--wisp-aura) 0%,transparent 70%);filter:blur(4px);pointer-events:none;transition:background .5s ease}
|
|
370
|
+
.wisp-zzz{position:absolute;right:6px;top:8px;font-size:calc(13px * var(--wisp-scale));font-weight:600;color:var(--wisp-ink);text-shadow:0 0 8px var(--wisp-aura);animation:wisp-zzz 2.6s ease-out infinite;pointer-events:none}
|
|
371
|
+
.wisp-say{position:absolute;max-width:210px;width:max-content;padding:7px 11px;border-radius:12px;font-size:12px;line-height:1.45;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 6px 24px rgba(0,0,0,.22);-webkit-backdrop-filter:blur(14px) saturate(160%);backdrop-filter:blur(14px) saturate(160%);animation:wisp-say 4.2s ease forwards;pointer-events:none;white-space:pre-wrap}
|
|
372
|
+
.wisp-root[data-mood="alert"]{--wisp-aura:rgba(255,196,92,.50)}
|
|
373
|
+
.wisp-root[data-mood="happy"]{--wisp-aura:rgba(255,132,206,.50)}
|
|
374
|
+
.wisp-root[data-mood="sleep"]{--wisp-aura:rgba(140,152,215,.30)}
|
|
375
|
+
body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20,24,32,.88);--wisp-say-line:rgba(255,255,255,.14);--wisp-ink:#cfe6ff}
|
|
376
|
+
.wisp-root[data-mood="sleep"] .wisp-body{animation-duration:9s}
|
|
377
|
+
.wisp-root[data-mood="alert"] .wisp-body{animation-duration:4.6s}
|
|
378
|
+
.wisp-root[data-mood="happy"] .wisp-body{animation-name:wisp-hop;animation-duration:1.15s;animation-timing-function:ease-in-out}
|
|
379
|
+
.wisp-root[data-mood="sleep"] .wisp-img{filter:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.82) brightness(.96)}
|
|
380
|
+
@keyframes wisp-bob{0%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-9px,0)}100%{transform:translate3d(0,0,0)}}
|
|
381
|
+
@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)}}
|
|
382
|
+
@keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
|
|
383
|
+
@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
|
+
@media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}}`
|
|
385
|
+
|
|
386
|
+
const installStyles = (styles) => {
|
|
387
|
+
if (styles && typeof styles.insert === 'function') {
|
|
388
|
+
try {
|
|
389
|
+
const dispose = styles.insert(CSS)
|
|
390
|
+
if (typeof dispose === 'function') return dispose
|
|
391
|
+
return null
|
|
392
|
+
} catch (e) { /* fall through to the owned <style> tag */ }
|
|
393
|
+
}
|
|
394
|
+
const head = document.head || document.documentElement
|
|
395
|
+
if (!head) return null
|
|
396
|
+
if (!document.getElementById('dsh-wisp-css')) {
|
|
397
|
+
const tag = document.createElement('style')
|
|
398
|
+
tag.id = 'dsh-wisp-css'
|
|
399
|
+
tag.textContent = CSS
|
|
400
|
+
head.appendChild(tag)
|
|
401
|
+
return () => { if (tag.parentNode) tag.remove() }
|
|
402
|
+
}
|
|
403
|
+
return null
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/* ---- hit testing: only her own pixels may take the pointer ----
|
|
407
|
+
`.wisp-body` has to cover a whole box to be draggable, and that box is
|
|
408
|
+
mostly empty: a standing figure in a 2:3 frame only fills 55-87% of the
|
|
409
|
+
width (measured), and the rest is transparent margin. At the shipped size
|
|
410
|
+
that margin sits over the composer, so a click aimed at Send would be
|
|
411
|
+
swallowed by nothing. Sampling the sprite's alpha channel is what makes a
|
|
412
|
+
large companion harmless. */
|
|
413
|
+
const MASK_W = 96
|
|
414
|
+
const MASK_H = 144
|
|
415
|
+
const MASK_MIN_ALPHA = 16
|
|
416
|
+
const masks = Object.create(null)
|
|
417
|
+
|
|
418
|
+
/** 3x3 grow, so the hit area is never tighter than the eye expects. */
|
|
419
|
+
const dilate = (src) => {
|
|
420
|
+
const out = new Uint8Array(src.length)
|
|
421
|
+
for (let y = 0; y < MASK_H; y++) {
|
|
422
|
+
for (let x = 0; x < MASK_W; x++) {
|
|
423
|
+
if (src[y * MASK_W + x] <= MASK_MIN_ALPHA) continue
|
|
424
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
425
|
+
const ny = y + dy
|
|
426
|
+
if (ny < 0 || ny >= MASK_H) continue
|
|
427
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
428
|
+
const nx = x + dx
|
|
429
|
+
if (nx < 0 || nx >= MASK_W) continue
|
|
430
|
+
out[ny * MASK_W + nx] = 255
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return out
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const buildMask = (key, url) => {
|
|
439
|
+
if (masks[key]) return
|
|
440
|
+
try {
|
|
441
|
+
const canvas = document.createElement('canvas')
|
|
442
|
+
if (!canvas || typeof canvas.getContext !== 'function') return
|
|
443
|
+
const ctx = canvas.getContext('2d')
|
|
444
|
+
if (!ctx || typeof ctx.drawImage !== 'function' || typeof ctx.getImageData !== 'function') return
|
|
445
|
+
canvas.width = MASK_W
|
|
446
|
+
canvas.height = MASK_H
|
|
447
|
+
const probe = new Image()
|
|
448
|
+
probe.onload = () => {
|
|
449
|
+
try {
|
|
450
|
+
ctx.drawImage(probe, 0, 0, MASK_W, MASK_H)
|
|
451
|
+
const data = ctx.getImageData(0, 0, MASK_W, MASK_H).data
|
|
452
|
+
const raw = new Uint8Array(MASK_W * MASK_H)
|
|
453
|
+
let anyOpaque = false
|
|
454
|
+
for (let i = 0; i < raw.length; i++) {
|
|
455
|
+
raw[i] = data[i * 4 + 3]
|
|
456
|
+
if (raw[i] > MASK_MIN_ALPHA) anyOpaque = true
|
|
457
|
+
}
|
|
458
|
+
// A mask with no opaque pixel at all means the draw or the read did
|
|
459
|
+
// not do what we think (a cleared canvas, an unexpected format).
|
|
460
|
+
// Falling back to the whole box keeps her grabbable; the alternative
|
|
461
|
+
// is a companion that ignores every click.
|
|
462
|
+
if (!anyOpaque) return
|
|
463
|
+
// One cell of dilation: the mask is coarser than the sprite (each
|
|
464
|
+
// cell is ~3.6 CSS px at the shipped size) and her widest pixels are
|
|
465
|
+
// the aura, so without this a click that looks like it is on her
|
|
466
|
+
// edge falls into the pass-through margin.
|
|
467
|
+
masks[key] = dilate(raw)
|
|
468
|
+
} catch (e) { /* tainted or unsupported: the full box stays hittable */ }
|
|
469
|
+
}
|
|
470
|
+
probe.src = url
|
|
471
|
+
} catch (e) { /* no canvas: the full box stays hittable */ }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/* ---------------------------------------------------------------------
|
|
475
|
+
WHAT THE APP IS DOING — read from the DOM, deliberately.
|
|
476
|
+
|
|
477
|
+
These are facts the shell renders for its own users, so they survive DSH
|
|
478
|
+
version upgrades the way an internal service contract does not.
|
|
479
|
+
|
|
480
|
+
Cost matters here because this runs on a timer for as long as the app is
|
|
481
|
+
open, and both arms were measured in Chromium on a 10k-node transcript
|
|
482
|
+
(40k in brackets):
|
|
483
|
+
|
|
484
|
+
querySelectorAll('[data-composer-input],[contenteditable="true"],textarea')
|
|
485
|
+
0.33 ms (1.42 ms) full-document COLLECTION, every tick
|
|
486
|
+
querySelector('button[aria-label="…"]') x4 exact
|
|
487
|
+
0.45 ms (1.86 ms) four full traversals to find nothing, which is
|
|
488
|
+
the usual case: the button is absent when idle
|
|
489
|
+
querySelector('button[aria-label*="停止"],…*="Stop"]')
|
|
490
|
+
0.27 ms (1.10 ms) two traversals, and it also matches a label we
|
|
491
|
+
have not seen yet — the label is localized
|
|
492
|
+
|
|
493
|
+
So: the composer is resolved ONCE and cached, re-resolved only when the
|
|
494
|
+
element leaves the document, and busy detection uses the two substring
|
|
495
|
+
arms. Same reaction, roughly a third of the cost.
|
|
496
|
+
--------------------------------------------------------------------- */
|
|
497
|
+
const STOP_SELECTOR = 'button[aria-label*="停止"],button[aria-label*="Stop"]'
|
|
498
|
+
|
|
499
|
+
/* Most specific hook first; the older shapes are only tried when it is
|
|
500
|
+
missing, and only on the cold path. */
|
|
501
|
+
const COMPOSER_HOOKS = ['[data-composer-input]', 'textarea', '[contenteditable="true"]']
|
|
502
|
+
const MISS_RETRY_POLLS = 4
|
|
503
|
+
|
|
504
|
+
const agentIsBusy = () => !!document.querySelector(STOP_SELECTOR)
|
|
505
|
+
|
|
506
|
+
let composerEl = null
|
|
507
|
+
let composerMisses = 0
|
|
508
|
+
|
|
509
|
+
const composerHasText = () => {
|
|
510
|
+
if (composerEl === null || composerEl.isConnected === false) {
|
|
511
|
+
// The precise hook is probed every tick while unresolved — it is one
|
|
512
|
+
// early-exit query, and a composer that has just appeared (a session was
|
|
513
|
+
// opened) must not wait seconds to be noticed.
|
|
514
|
+
composerEl = document.querySelector(COMPOSER_HOOKS[0])
|
|
515
|
+
if (composerEl === null && composerMisses++ % MISS_RETRY_POLLS === 0) {
|
|
516
|
+
// The pre-`data-composer-input` shapes match any rich-text editor on
|
|
517
|
+
// the page, so they are only worth probing occasionally.
|
|
518
|
+
for (let i = 1; i < COMPOSER_HOOKS.length && composerEl === null; i++) {
|
|
519
|
+
composerEl = document.querySelector(COMPOSER_HOOKS[i])
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (composerEl === null) return false
|
|
524
|
+
composerMisses = 0
|
|
525
|
+
const text = composerEl.tagName === 'TEXTAREA' ? composerEl.value : composerEl.textContent
|
|
526
|
+
return !!(text && text.trim().length > 0)
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const mount = (config, ctx) => {
|
|
530
|
+
const cfg = normalizeConfig(config)
|
|
531
|
+
const clock = createClock(ctx)
|
|
532
|
+
const styleDispose = installStyles(ctx && typeof ctx.get === 'function' ? ctx.get('styles') : undefined)
|
|
533
|
+
|
|
534
|
+
const vw = () => window.innerWidth || 1400
|
|
535
|
+
const vh = () => window.innerHeight || 900
|
|
536
|
+
/* The box she OCCUPIES is the base box times the render scale, and every
|
|
537
|
+
clamp and placement below goes through these two. Clamping against the
|
|
538
|
+
unscaled box let a scaled wisp hang off the edge with her drag area in
|
|
539
|
+
the wrong place — invisible at size 1, obvious at any other. */
|
|
540
|
+
const boxW = () => BOX_W * cfg.size
|
|
541
|
+
const boxH = () => BOX_H * cfg.size
|
|
542
|
+
const maxX = () => Math.max(0, vw() - boxW())
|
|
543
|
+
const maxY = () => Math.max(0, vh() - boxH())
|
|
544
|
+
const halfSay = () => Math.min(SAY_MAX_W / 2, Math.max(0, (vw() - 20) / 2))
|
|
545
|
+
|
|
546
|
+
const layer = document.createElement('div')
|
|
547
|
+
layer.className = 'wisp-layer'
|
|
548
|
+
/* Critical layout is written INLINE as well as in the stylesheet.
|
|
549
|
+
A `position: static` div appended to <body> is an in-flow block, and a
|
|
550
|
+
non-absolute <img> is an in-flow replaced element — either one can make
|
|
551
|
+
the whole page scrollable if the stylesheet is not applied yet (or at
|
|
552
|
+
all) when this mounts, which is exactly what boot-time mounting risks.
|
|
553
|
+
Inline styles cannot be missing. */
|
|
554
|
+
layer.style.position = 'fixed'
|
|
555
|
+
layer.style.top = '0'
|
|
556
|
+
layer.style.left = '0'
|
|
557
|
+
layer.style.right = '0'
|
|
558
|
+
layer.style.bottom = '0'
|
|
559
|
+
layer.style.overflow = 'hidden'
|
|
560
|
+
layer.style.pointerEvents = 'none'
|
|
561
|
+
layer.style.zIndex = '2147482000'
|
|
562
|
+
|
|
563
|
+
const root = document.createElement('div')
|
|
564
|
+
root.className = 'wisp-root'
|
|
565
|
+
root.dataset.mood = 'idle'
|
|
566
|
+
root.style.position = 'absolute'
|
|
567
|
+
root.style.pointerEvents = 'none'
|
|
568
|
+
root.style.transformOrigin = 'top center'
|
|
569
|
+
|
|
570
|
+
/* Restore where she was left, clamped into the current viewport: the
|
|
571
|
+
window may well have been resized (or moved to another monitor) since
|
|
572
|
+
the position was written. */
|
|
573
|
+
const stored = cfg.persist ? readStored(POSITION_KEY) : null
|
|
574
|
+
const coords = { x: 0, y: 0 }
|
|
575
|
+
if (stored && isFinite(stored.x) && isFinite(stored.y)) {
|
|
576
|
+
coords.x = clamp(stored.x, 0, maxX())
|
|
577
|
+
coords.y = clamp(stored.y, 0, maxY())
|
|
578
|
+
} else {
|
|
579
|
+
coords.x = clamp(vw() - cfg.right - boxW(), 0, maxX())
|
|
580
|
+
coords.y = clamp(vh() - cfg.bottom - boxH(), 0, maxY())
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/* The root is LAID OUT at its final size rather than transform-scaled:
|
|
584
|
+
a scaled layer is rasterized once and then stretched, so an enlarged
|
|
585
|
+
sprite goes soft, whereas a laid-out box rasterizes the 512px source at
|
|
586
|
+
the size it is actually drawn at. Only the left/right mirror stays a
|
|
587
|
+
transform. `transform-origin` is the top CENTRE so mirroring happens in
|
|
588
|
+
place — with the old `top left` origin she jumped a full box width to
|
|
589
|
+
the left the moment she crossed the midline. */
|
|
590
|
+
root.style.width = Math.round(boxW()) + 'px'
|
|
591
|
+
root.style.height = Math.round(boxH()) + 'px'
|
|
592
|
+
layer.style.setProperty('--wisp-scale', String(cfg.size))
|
|
593
|
+
|
|
594
|
+
/* ---- the speech bubble lives in screen space (see the CSS note) ---- */
|
|
595
|
+
let sayEl = null
|
|
596
|
+
const placeSay = () => {
|
|
597
|
+
if (sayEl === null) return
|
|
598
|
+
const half = halfSay()
|
|
599
|
+
const centre = coords.x + boxW() / 2
|
|
600
|
+
const upper = Math.max(half + 10, vw() - half - 10)
|
|
601
|
+
sayEl.style.left = Math.round(Math.min(Math.max(centre, half + 10), upper)) + 'px'
|
|
602
|
+
// The bubble hangs ABOVE this anchor, so it needs clearance: at a size
|
|
603
|
+
// where she is pinned to the top of the viewport the anchor would sit at
|
|
604
|
+
// y=0 and the bubble would render entirely off-screen.
|
|
605
|
+
sayEl.style.top = Math.round(Math.max(coords.y, SAY_CLEARANCE)) + 'px'
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const paint = () => {
|
|
609
|
+
const facing = coords.x + boxW() / 2 < vw() / 2 ? -1 : 1
|
|
610
|
+
// The translate places the box's TOP-LEFT corner, so it is `coords.x`,
|
|
611
|
+
// NOT `coords.x + boxW()/2`: a translation is origin-independent, and
|
|
612
|
+
// feeding it a centre offset put the whole box half a box-width to the
|
|
613
|
+
// right, off the edge of the viewport. The mirror still pivots in place
|
|
614
|
+
// because `transform-origin` is the top CENTRE.
|
|
615
|
+
root.style.transform = 'translate3d(' + coords.x + 'px,' + coords.y + 'px,0) scaleX(' + facing + ')'
|
|
616
|
+
placeSay()
|
|
617
|
+
}
|
|
618
|
+
paint()
|
|
619
|
+
|
|
620
|
+
const bodyEl = document.createElement('div')
|
|
621
|
+
bodyEl.className = 'wisp-body'
|
|
622
|
+
bodyEl.style.position = 'absolute'
|
|
623
|
+
bodyEl.style.top = '0'
|
|
624
|
+
bodyEl.style.left = '0'
|
|
625
|
+
bodyEl.style.right = '0'
|
|
626
|
+
bodyEl.style.bottom = '0'
|
|
627
|
+
bodyEl.style.pointerEvents = 'auto'
|
|
628
|
+
bodyEl.setAttribute('role', 'button')
|
|
629
|
+
bodyEl.setAttribute('tabindex', '-1')
|
|
630
|
+
bodyEl.setAttribute('aria-label', 'DeepSeek娘')
|
|
631
|
+
|
|
632
|
+
const shine = document.createElement('div')
|
|
633
|
+
shine.className = 'wisp-shine'
|
|
634
|
+
|
|
635
|
+
const img = document.createElement('img')
|
|
636
|
+
img.className = 'wisp-img'
|
|
637
|
+
img.style.position = 'absolute'
|
|
638
|
+
img.style.top = '0'
|
|
639
|
+
img.style.left = '0'
|
|
640
|
+
img.style.width = '100%'
|
|
641
|
+
img.style.height = '100%'
|
|
642
|
+
img.style.objectFit = 'contain'
|
|
643
|
+
img.style.objectPosition = 'bottom center'
|
|
644
|
+
img.alt = ''
|
|
645
|
+
img.draggable = false
|
|
646
|
+
img.decoding = 'async'
|
|
647
|
+
img.onerror = () => {
|
|
648
|
+
if (typeof console !== 'undefined' && console.error) console.error('[wisp] sprite failed to load')
|
|
649
|
+
}
|
|
650
|
+
let currentSprite = 'idle'
|
|
651
|
+
|
|
652
|
+
const setSprite = (key) => {
|
|
653
|
+
const url = resolveSprite(SPRITES[key])
|
|
654
|
+
buildMask(key, url)
|
|
655
|
+
currentSprite = key
|
|
656
|
+
/* Cross-fade rather than swapping `src` outright: a hard swap makes every
|
|
657
|
+
mood change read as a glitch. The outgoing sprite keeps its own layer
|
|
658
|
+
for the length of the fade, then is removed. */
|
|
659
|
+
const previous = img.src
|
|
660
|
+
if (!previous || previous === url) {
|
|
661
|
+
img.src = url
|
|
662
|
+
img.style.opacity = '1'
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
const outgoing = document.createElement('img')
|
|
666
|
+
outgoing.className = 'wisp-img'
|
|
667
|
+
outgoing.style.position = 'absolute'
|
|
668
|
+
outgoing.style.top = '0'
|
|
669
|
+
outgoing.style.left = '0'
|
|
670
|
+
outgoing.style.width = '100%'
|
|
671
|
+
outgoing.style.height = '100%'
|
|
672
|
+
outgoing.style.objectFit = 'contain'
|
|
673
|
+
outgoing.style.objectPosition = 'bottom center'
|
|
674
|
+
outgoing.style.opacity = '1'
|
|
675
|
+
outgoing.draggable = false
|
|
676
|
+
outgoing.setAttribute('aria-hidden', 'true')
|
|
677
|
+
outgoing.src = previous
|
|
678
|
+
bodyEl.insertBefore(outgoing, img)
|
|
679
|
+
img.src = url
|
|
680
|
+
img.style.opacity = '0'
|
|
681
|
+
clock.after(16, () => {
|
|
682
|
+
if (destroyed) return
|
|
683
|
+
img.style.opacity = '1'
|
|
684
|
+
outgoing.style.opacity = '0'
|
|
685
|
+
})
|
|
686
|
+
clock.after(340, () => { if (outgoing.parentNode) outgoing.parentNode.removeChild(outgoing) })
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
bodyEl.appendChild(shine)
|
|
690
|
+
bodyEl.appendChild(img)
|
|
691
|
+
root.appendChild(bodyEl)
|
|
692
|
+
layer.appendChild(root)
|
|
693
|
+
setSprite('idle')
|
|
694
|
+
|
|
695
|
+
let destroyed = false
|
|
696
|
+
let attached = false
|
|
697
|
+
let attachAttempts = 0
|
|
698
|
+
|
|
699
|
+
const attach = () => {
|
|
700
|
+
if (destroyed || attached) return
|
|
701
|
+
if (!document.body) {
|
|
702
|
+
// The client half can in principle run before <body> exists; a bounded
|
|
703
|
+
// retry beats both a crash and an unbounded poll.
|
|
704
|
+
if (attachAttempts++ < 60) clock.after(50, attach)
|
|
705
|
+
else if (console && console.error) console.error('[wisp] document.body never appeared')
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
document.body.appendChild(layer)
|
|
709
|
+
attached = true
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/* ---- mood -> sprite ---- */
|
|
713
|
+
const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy' }
|
|
714
|
+
let mood = 'idle'
|
|
715
|
+
|
|
716
|
+
const setMood = (next) => {
|
|
717
|
+
if (destroyed || mood === next) return
|
|
718
|
+
mood = next
|
|
719
|
+
root.dataset.mood = next
|
|
720
|
+
const want = SPRITE_OF[next] || 'idle'
|
|
721
|
+
if (want !== currentSprite) setSprite(want)
|
|
722
|
+
const zs = root.querySelectorAll('.wisp-zzz')
|
|
723
|
+
for (let i = zs.length - 1; i >= 0; i--) zs[i].remove()
|
|
724
|
+
if (next === 'sleep') {
|
|
725
|
+
const z1 = document.createElement('div'); z1.className = 'wisp-zzz'; z1.textContent = 'z'
|
|
726
|
+
const z2 = document.createElement('div'); z2.className = 'wisp-zzz'; z2.textContent = 'z'
|
|
727
|
+
z2.style.right = '24px'; z2.style.top = '-2px'; z2.style.animationDelay = '1.1s'
|
|
728
|
+
root.appendChild(z1); root.appendChild(z2)
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
let sayHandle = null
|
|
733
|
+
const say = (text) => {
|
|
734
|
+
if (destroyed) return
|
|
735
|
+
if (sayEl !== null && sayEl.parentNode) sayEl.remove()
|
|
736
|
+
const el = document.createElement('div')
|
|
737
|
+
el.className = 'wisp-say'
|
|
738
|
+
el.style.position = 'absolute'
|
|
739
|
+
el.textContent = text
|
|
740
|
+
// Into the LAYER, not the root: a child of the root would be mirrored
|
|
741
|
+
// with her and scaled by `size`.
|
|
742
|
+
layer.appendChild(el)
|
|
743
|
+
sayEl = el
|
|
744
|
+
placeSay()
|
|
745
|
+
if (sayHandle) clock.cancel(sayHandle)
|
|
746
|
+
sayHandle = clock.after(SAY_MS, () => {
|
|
747
|
+
if (sayEl === el) sayEl = null
|
|
748
|
+
if (el.parentNode) el.remove()
|
|
749
|
+
})
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/* ---- idle / wake ----
|
|
753
|
+
The sleep deadline is re-armed at most once a second. pointermove can
|
|
754
|
+
fire over a hundred times a second, and cancelling plus recreating a
|
|
755
|
+
timer for each of those is pure overhead for a decision whose real
|
|
756
|
+
resolution is ninety seconds. */
|
|
757
|
+
let sleepHandle = null
|
|
758
|
+
let lastWakeAt = 0
|
|
759
|
+
const armSleep = () => {
|
|
760
|
+
if (sleepHandle) clock.cancel(sleepHandle)
|
|
761
|
+
sleepHandle = clock.after(cfg.sleepAfterMs, () => {
|
|
762
|
+
sleepHandle = null
|
|
763
|
+
if (destroyed) return
|
|
764
|
+
setMood('sleep')
|
|
765
|
+
if (Math.random() < 0.6) say(pick(LINES.sleep))
|
|
766
|
+
})
|
|
767
|
+
}
|
|
768
|
+
const wake = (force) => {
|
|
769
|
+
if (destroyed) return
|
|
770
|
+
const now = nowMs()
|
|
771
|
+
if (!force && now - lastWakeAt < WAKE_THROTTLE_MS) return
|
|
772
|
+
lastWakeAt = now
|
|
773
|
+
if (mood === 'sleep') {
|
|
774
|
+
setMood('idle')
|
|
775
|
+
if (Math.random() < 0.5) say(pick(LINES.wake))
|
|
776
|
+
}
|
|
777
|
+
armSleep()
|
|
778
|
+
}
|
|
779
|
+
const WAKE_EVENTS = ['pointerdown', 'keydown', 'wheel', 'pointermove']
|
|
780
|
+
for (const ev of WAKE_EVENTS) window.addEventListener(ev, wake, { passive: true })
|
|
781
|
+
|
|
782
|
+
/* ---- theme: follow the shell's own light/dark decision ---- */
|
|
783
|
+
const themeService = ctx && typeof ctx.get === 'function' ? ctx.get('theme') : undefined
|
|
784
|
+
let offTheme = null
|
|
785
|
+
const applyTheme = (snapshot) => {
|
|
786
|
+
const scheme = snapshot && snapshot.active && snapshot.active.colorScheme === 'dark' ? 'dark' : 'light'
|
|
787
|
+
root.dataset.wispTheme = scheme
|
|
788
|
+
}
|
|
789
|
+
if (themeService && typeof themeService.getTheme === 'function') {
|
|
790
|
+
try { applyTheme(themeService.getTheme()) } catch (e) { /* the CSS fallback still applies */ }
|
|
791
|
+
}
|
|
792
|
+
if (ctx && typeof ctx.on === 'function') {
|
|
793
|
+
try {
|
|
794
|
+
offTheme = ctx.on('theme/change', (snapshot) => {
|
|
795
|
+
try { applyTheme(snapshot) } catch (e) { /* ignore a malformed snapshot */ }
|
|
796
|
+
})
|
|
797
|
+
} catch (e) { offTheme = null }
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/* ---- react to what the app is doing ---- */
|
|
801
|
+
let lastBusy = false
|
|
802
|
+
let busySince = 0
|
|
803
|
+
let happyHandle = null
|
|
804
|
+
|
|
805
|
+
const flashHappy = (text) => {
|
|
806
|
+
setMood('happy')
|
|
807
|
+
say(text)
|
|
808
|
+
if (happyHandle) clock.cancel(happyHandle)
|
|
809
|
+
happyHandle = clock.after(cfg.happyMs, () => { if (mood === 'happy') setMood('idle') })
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const poll = () => {
|
|
813
|
+
if (destroyed) return
|
|
814
|
+
let busy = false
|
|
815
|
+
try { busy = agentIsBusy() } catch (e) { busy = false }
|
|
816
|
+
if (busy && !lastBusy) busySince = nowMs()
|
|
817
|
+
if (!busy && lastBusy && cfg.celebrate && nowMs() - busySince >= cfg.celebrateAfterMs) {
|
|
818
|
+
flashHappy(pick(LINES.done))
|
|
819
|
+
}
|
|
820
|
+
lastBusy = busy
|
|
821
|
+
if (mood === 'sleep' || mood === 'happy') return
|
|
822
|
+
let typing = false
|
|
823
|
+
try { typing = composerHasText() } catch (e) { typing = false }
|
|
824
|
+
setMood((busy || typing) ? 'alert' : 'idle')
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
let chatterHandle = null
|
|
828
|
+
const syncChatter = () => {
|
|
829
|
+
if (chatterHandle) { clock.cancel(chatterHandle); chatterHandle = null }
|
|
830
|
+
if (cfg.chatterMs <= 0) return
|
|
831
|
+
chatterHandle = clock.every(cfg.chatterMs, () => {
|
|
832
|
+
if (destroyed || mood !== 'idle') return
|
|
833
|
+
if (agentIsBusy()) return
|
|
834
|
+
say(pick(LINES.chatter))
|
|
835
|
+
})
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
let pollHandle = null
|
|
839
|
+
const syncPoll = () => {
|
|
840
|
+
if (pollHandle) { clock.cancel(pollHandle); pollHandle = null }
|
|
841
|
+
if (cfg.reactions) pollHandle = clock.every(POLL_MS, poll)
|
|
842
|
+
}
|
|
843
|
+
syncPoll()
|
|
844
|
+
syncChatter()
|
|
845
|
+
|
|
846
|
+
/* ---- 自己踱步 ----
|
|
847
|
+
只在真正空闲时发生,并且是一段可见的滑行而不是瞬移。滑行期间挂
|
|
848
|
+
`data-gliding`,由 CSS 过渡接管 transform;一旦按下就立刻摘掉,
|
|
849
|
+
否则拖动会被过渡拖后腿。 */
|
|
850
|
+
let glideHandle = null
|
|
851
|
+
const glideTo = () => {
|
|
852
|
+
root.dataset.gliding = 'true'
|
|
853
|
+
paint()
|
|
854
|
+
if (glideHandle) clock.cancel(glideHandle)
|
|
855
|
+
glideHandle = clock.after(1700, () => {
|
|
856
|
+
glideHandle = null
|
|
857
|
+
delete root.dataset.gliding
|
|
858
|
+
})
|
|
859
|
+
}
|
|
860
|
+
const stopGlide = () => {
|
|
861
|
+
if (glideHandle) { clock.cancel(glideHandle); glideHandle = null }
|
|
862
|
+
delete root.dataset.gliding
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
let wanderHandle = null
|
|
866
|
+
const syncWander = () => {
|
|
867
|
+
if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
|
|
868
|
+
if (!cfg.wander || cfg.wanderRange <= 0) return
|
|
869
|
+
wanderHandle = clock.every(cfg.wanderMs, () => {
|
|
870
|
+
if (destroyed || mood !== 'idle' || start !== null) return
|
|
871
|
+
if (agentIsBusy()) return
|
|
872
|
+
// A random bearing with a floor on the distance, so a stroll is always
|
|
873
|
+
// a real stroll rather than a rounding error in a random direction.
|
|
874
|
+
const angle = Math.random() * Math.PI * 2
|
|
875
|
+
const dist = cfg.wanderRange * (0.45 + Math.random() * 0.55)
|
|
876
|
+
coords.x = clamp(coords.x + Math.cos(angle) * dist, 0, maxX())
|
|
877
|
+
coords.y = clamp(coords.y + Math.sin(angle) * dist * 0.6, 0, maxY())
|
|
878
|
+
glideTo()
|
|
879
|
+
// 故意不 rememberPosition():记忆的是"用户把她放在哪",她自己的溜达
|
|
880
|
+
// 不该覆盖那个意图,否则刷新后她会出现在一个用户从没选过的位置。
|
|
881
|
+
})
|
|
882
|
+
}
|
|
883
|
+
syncWander()
|
|
884
|
+
|
|
885
|
+
/* ---- drag & click ---- */
|
|
886
|
+
let start = null, origin = null, moved = false, lastClick = 0
|
|
887
|
+
|
|
888
|
+
const rememberPosition = () => {
|
|
889
|
+
if (!cfg.persist) return
|
|
890
|
+
writeStored(POSITION_KEY, { x: Math.round(coords.x), y: Math.round(coords.y) })
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** Whether this pointer is over an opaque pixel of the current sprite. */
|
|
894
|
+
const hitsBody = (e) => {
|
|
895
|
+
const mask = masks[currentSprite]
|
|
896
|
+
if (!mask) return true // no mask yet: stay grabbable rather than vanish
|
|
897
|
+
const boxw = boxW()
|
|
898
|
+
const boxh = boxH()
|
|
899
|
+
let mx = (e.clientX - coords.x) / boxw
|
|
900
|
+
const my = (e.clientY - coords.y) / boxh
|
|
901
|
+
if (mx < 0 || mx >= 1 || my < 0 || my >= 1) return false
|
|
902
|
+
// The root mirrors about its centre, so sample the un-mirrored pixel.
|
|
903
|
+
if (coords.x + boxw / 2 < vw() / 2) mx = 1 - mx
|
|
904
|
+
const col = Math.min(MASK_W - 1, Math.floor(mx * MASK_W))
|
|
905
|
+
const row = Math.min(MASK_H - 1, Math.floor(my * MASK_H))
|
|
906
|
+
return mask[row * MASK_W + col] > MASK_MIN_ALPHA
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const onMove = (e) => {
|
|
910
|
+
if (!start) return
|
|
911
|
+
const dx = e.clientX - start.x, dy = e.clientY - start.y
|
|
912
|
+
if (!moved && Math.sqrt(dx * dx + dy * dy) > 4) moved = true
|
|
913
|
+
if (!moved) return
|
|
914
|
+
coords.x = clamp(origin.x + dx, 0, maxX())
|
|
915
|
+
coords.y = clamp(origin.y + dy, 0, maxY())
|
|
916
|
+
paint()
|
|
917
|
+
}
|
|
918
|
+
const onUp = () => {
|
|
919
|
+
window.removeEventListener('pointermove', onMove)
|
|
920
|
+
window.removeEventListener('pointerup', onUp)
|
|
921
|
+
delete root.dataset.dragging
|
|
922
|
+
start = null
|
|
923
|
+
if (moved) {
|
|
924
|
+
rememberPosition()
|
|
925
|
+
if (Math.random() < 0.35) say(pick(LINES.move))
|
|
926
|
+
return
|
|
927
|
+
}
|
|
928
|
+
if (destroyed) return
|
|
929
|
+
const now = Date.now()
|
|
930
|
+
const dbl = now - lastClick < 420
|
|
931
|
+
lastClick = now
|
|
932
|
+
wake(true)
|
|
933
|
+
flashHappy(dbl ? pick(LINES.happy) : pick(LINES.click))
|
|
934
|
+
}
|
|
935
|
+
const onDown = (e) => {
|
|
936
|
+
if (e.button !== 0) return
|
|
937
|
+
// Only her own pixels grab the pointer. Her transparent margins pass the
|
|
938
|
+
// click through, but anywhere she is actually drawn she is grabbable —
|
|
939
|
+
// including over the composer: she can be moved, so she does not have to
|
|
940
|
+
// yield to whatever she is standing on.
|
|
941
|
+
if (!hitsBody(e)) return
|
|
942
|
+
stopGlide() // a stroll must not fight the drag
|
|
943
|
+
e.preventDefault(); e.stopPropagation()
|
|
944
|
+
start = { x: e.clientX, y: e.clientY }
|
|
945
|
+
origin = { x: coords.x, y: coords.y }
|
|
946
|
+
moved = false
|
|
947
|
+
root.dataset.dragging = 'true'
|
|
948
|
+
window.addEventListener('pointermove', onMove)
|
|
949
|
+
window.addEventListener('pointerup', onUp)
|
|
950
|
+
}
|
|
951
|
+
bodyEl.addEventListener('pointerdown', onDown)
|
|
952
|
+
|
|
953
|
+
/* ---- 右键 = 叫回原位 ----
|
|
954
|
+
她大了之后很容易被拖到看不见的地方,而"恢复位置"原先只能开 Console。
|
|
955
|
+
右键她本体即可归位。 */
|
|
956
|
+
const onContext = (e) => {
|
|
957
|
+
if (!hitsBody(e)) return
|
|
958
|
+
e.preventDefault(); e.stopPropagation()
|
|
959
|
+
stopGlide()
|
|
960
|
+
// 默认落点(右下角),并把记忆的位置一起抹掉,避免下次刷新又跑回去
|
|
961
|
+
try { window.localStorage.removeItem(POSITION_KEY) } catch (err) { /* 存储不可用 */ }
|
|
962
|
+
coords.x = clamp(vw() - cfg.right - boxW(), 0, maxX())
|
|
963
|
+
coords.y = clamp(vh() - cfg.bottom - boxH(), 0, maxY())
|
|
964
|
+
paint()
|
|
965
|
+
rememberPosition()
|
|
966
|
+
say(pick(LINES.home))
|
|
967
|
+
}
|
|
968
|
+
bodyEl.addEventListener('contextmenu', onContext)
|
|
969
|
+
|
|
970
|
+
const onResize = () => {
|
|
971
|
+
coords.x = clamp(coords.x, 0, maxX())
|
|
972
|
+
coords.y = clamp(coords.y, 0, maxY())
|
|
973
|
+
paint()
|
|
974
|
+
}
|
|
975
|
+
window.addEventListener('resize', onResize)
|
|
976
|
+
|
|
977
|
+
attach()
|
|
978
|
+
clock.after(800, () => say(pick(LINES.hello)))
|
|
979
|
+
wake(true)
|
|
980
|
+
|
|
981
|
+
/* warm the other sprites after the first frame so mood changes never blink */
|
|
982
|
+
clock.after(1500, () => {
|
|
983
|
+
if (destroyed) return
|
|
984
|
+
for (const key in SPRITES) {
|
|
985
|
+
if (key === currentSprite) continue
|
|
986
|
+
const pre = new Image()
|
|
987
|
+
pre.src = resolveSprite(SPRITES[key])
|
|
988
|
+
}
|
|
989
|
+
})
|
|
990
|
+
|
|
991
|
+
const applyConfig = (partial) => {
|
|
992
|
+
mergeConfig(cfg, partial)
|
|
993
|
+
// A bigger companion occupies a bigger box, so the stored position has
|
|
994
|
+
// to be re-clamped or she would grow off the edge.
|
|
995
|
+
root.style.width = Math.round(boxW()) + 'px'
|
|
996
|
+
root.style.height = Math.round(boxH()) + 'px'
|
|
997
|
+
layer.style.setProperty('--wisp-scale', String(cfg.size))
|
|
998
|
+
coords.x = clamp(coords.x, 0, maxX())
|
|
999
|
+
coords.y = clamp(coords.y, 0, maxY())
|
|
1000
|
+
paint()
|
|
1001
|
+
armSleep()
|
|
1002
|
+
syncPoll()
|
|
1003
|
+
syncChatter()
|
|
1004
|
+
syncWander()
|
|
1005
|
+
return api
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const api = {
|
|
1009
|
+
version: VERSION,
|
|
1010
|
+
clock: clock.mode,
|
|
1011
|
+
say,
|
|
1012
|
+
mood: setMood,
|
|
1013
|
+
move(x, y) { coords.x = x; coords.y = y; paint() },
|
|
1014
|
+
configure: applyConfig,
|
|
1015
|
+
resetPosition() {
|
|
1016
|
+
try { window.localStorage.removeItem(POSITION_KEY) } catch (e) { /* ignore */ }
|
|
1017
|
+
coords.x = clamp(vw() - cfg.right - boxW(), 0, maxX())
|
|
1018
|
+
coords.y = clamp(vh() - cfg.bottom - boxH(), 0, maxY())
|
|
1019
|
+
paint()
|
|
1020
|
+
},
|
|
1021
|
+
get element() { return root },
|
|
1022
|
+
get sprites() { return Object.keys(SPRITES) },
|
|
1023
|
+
get position() { return { x: Math.round(coords.x), y: Math.round(coords.y) } },
|
|
1024
|
+
get currentMood() { return mood },
|
|
1025
|
+
destroy() {
|
|
1026
|
+
if (destroyed) return
|
|
1027
|
+
destroyed = true
|
|
1028
|
+
rememberPosition()
|
|
1029
|
+
clock.dispose()
|
|
1030
|
+
for (const ev of WAKE_EVENTS) window.removeEventListener(ev, wake)
|
|
1031
|
+
window.removeEventListener('resize', onResize)
|
|
1032
|
+
window.removeEventListener('pointermove', onMove)
|
|
1033
|
+
window.removeEventListener('pointerup', onUp)
|
|
1034
|
+
bodyEl.removeEventListener('pointerdown', onDown)
|
|
1035
|
+
bodyEl.removeEventListener('contextmenu', onContext)
|
|
1036
|
+
if (typeof offTheme === 'function') {
|
|
1037
|
+
try { offTheme() } catch (e) { /* ignore */ }
|
|
1038
|
+
offTheme = null
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof styleDispose === 'function') {
|
|
1041
|
+
try { styleDispose() } catch (e) { /* ignore */ }
|
|
1042
|
+
}
|
|
1043
|
+
if (layer.parentNode) layer.remove()
|
|
1044
|
+
revokeSprites()
|
|
1045
|
+
if (window.__wisp === api) delete window.__wisp
|
|
1046
|
+
},
|
|
1047
|
+
}
|
|
1048
|
+
window.__wisp = api
|
|
1049
|
+
return api
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const plugin = {
|
|
1053
|
+
name: 'wisp',
|
|
1054
|
+
apply(ctx, config) {
|
|
1055
|
+
try {
|
|
1056
|
+
/* Single instance, always. A re-apply (HMR, a duplicate row, or the
|
|
1057
|
+
console-injection build) must replace the companion rather than
|
|
1058
|
+
stack a second one on top of it. */
|
|
1059
|
+
const previous = window.__wisp
|
|
1060
|
+
if (previous && typeof previous.destroy === 'function') {
|
|
1061
|
+
try { previous.destroy() } catch (e) { /* never let a dead instance block a live one */ }
|
|
1062
|
+
}
|
|
1063
|
+
const instance = mount(config, ctx)
|
|
1064
|
+
if (ctx && typeof ctx.effect === 'function') {
|
|
1065
|
+
ctx.effect(() => () => instance.destroy(), 'dsh-wisp: teardown')
|
|
1066
|
+
}
|
|
1067
|
+
if (typeof console !== 'undefined' && console.log) {
|
|
1068
|
+
console.log('[wisp] mounted — DeepSeek娘 v' + VERSION + ' (timers: ' + instance.clock + ')')
|
|
1069
|
+
}
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
if (typeof console !== 'undefined' && console.error) {
|
|
1072
|
+
console.error('[wisp] mount failed', error && error.message)
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
},
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
exports.default = plugin
|
|
1079
|
+
exports.plugin = plugin
|
|
1080
|
+
exports.apply = plugin.apply
|
|
1081
|
+
exports.name = plugin.name
|
|
1082
|
+
exports.version = VERSION
|
|
1083
|
+
|
|
1084
|
+
return module.exports
|
|
1085
|
+
},
|
|
1086
|
+
})
|