dsh-wisp 1.42.0 → 1.48.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.
Files changed (70) hide show
  1. package/README.md +666 -29
  2. package/assets/canon/attn.webp +0 -0
  3. package/assets/canon/eat.webp +0 -0
  4. package/assets/canon/happy.webp +0 -0
  5. package/assets/canon/idle.webp +0 -0
  6. package/assets/canon/poked.webp +0 -0
  7. package/assets/canon/proud.webp +0 -0
  8. package/assets/canon/sleepy.webp +0 -0
  9. package/assets/canon/work.webp +0 -0
  10. package/assets/classic/attn.webp +0 -0
  11. package/assets/classic/eat.webp +0 -0
  12. package/assets/classic/happy.webp +0 -0
  13. package/assets/classic/idle.webp +0 -0
  14. package/assets/classic/poked.webp +0 -0
  15. package/assets/classic/proud.webp +0 -0
  16. package/assets/classic/work.webp +0 -0
  17. package/assets/deepsea/attn.webp +0 -0
  18. package/assets/deepsea/eat.webp +0 -0
  19. package/assets/deepsea/happy.webp +0 -0
  20. package/assets/deepsea/idle.webp +0 -0
  21. package/assets/deepsea/poked.webp +0 -0
  22. package/assets/deepsea/proud.webp +0 -0
  23. package/assets/deepsea/sleepy.webp +0 -0
  24. package/assets/deepsea/work.webp +0 -0
  25. package/assets/motion/idle.webp +0 -0
  26. package/assets/motion/sleepy.webm +0 -0
  27. package/assets/motion/sleepy.webp +0 -0
  28. package/assets/motion/swim_attn.webp +0 -0
  29. package/assets/motion/swim_eat.webp +0 -0
  30. package/assets/motion/swim_happy.webp +0 -0
  31. package/assets/motion/swim_idle.webp +0 -0
  32. package/assets/motion/swim_poked.webp +0 -0
  33. package/assets/motion/swim_proud.webp +0 -0
  34. package/assets/motion/swim_sleepy.webp +0 -0
  35. package/assets/motion/swim_work.webp +0 -0
  36. package/assets/night/attn.webp +0 -0
  37. package/assets/night/eat.webp +0 -0
  38. package/assets/night/happy.webp +0 -0
  39. package/assets/night/idle.webp +0 -0
  40. package/assets/night/poked.webp +0 -0
  41. package/assets/night/proud.webp +0 -0
  42. package/assets/night/sleepy.webp +0 -0
  43. package/assets/night/work.webp +0 -0
  44. package/assets/pajama/attn.webp +0 -0
  45. package/assets/pajama/eat.webp +0 -0
  46. package/assets/pajama/happy.webp +0 -0
  47. package/assets/pajama/idle.webp +0 -0
  48. package/assets/pajama/poked.webp +0 -0
  49. package/assets/pajama/proud.webp +0 -0
  50. package/assets/pajama/sleepy.webp +0 -0
  51. package/assets/pajama/work.webp +0 -0
  52. package/assets/swim/attn.webp +0 -0
  53. package/assets/swim/eat.webp +0 -0
  54. package/assets/swim/happy.webp +0 -0
  55. package/assets/swim/idle.webp +0 -0
  56. package/assets/swim/poked.webp +0 -0
  57. package/assets/swim/proud.webp +0 -0
  58. package/assets/swim/sleepy.webp +0 -0
  59. package/assets/swim/work.webp +0 -0
  60. package/audit/generation-audit.jsonl +57 -0
  61. package/build.mjs +130 -8
  62. package/cordis.patch.yml +5 -0
  63. package/lib/client.js +1014 -104
  64. package/lib/client.template.js +953 -65
  65. package/lib/index.js +311 -16
  66. package/package.json +1 -1
  67. package/tools/CHARACTER-PROMPT.md +65 -9
  68. package/tools/audit-log.mjs +11 -2
  69. package/tools/engine-probe.mjs +500 -2
  70. package/verify-wisp.mjs +1576 -51
@@ -51,11 +51,25 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.42.0'
54
+ const VERSION = '1.48.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
58
58
  const WHATS_NEW = {
59
+ '1.48.0': '查到新版可以直接装了:关于她 → 检查更新 → 「安装 vX」,由平台的插件管理器去装(装完该刷新还是该重启,她自己会说)。',
60
+ '1.47.1': '泳装八个状态真的有动图了:idle / attn / happy / sleep / alert / proud / eat / poked 各配一条 720p、24fps、97 帧的循环动图 WebP(八条合计 23.2 MB,仍走宿主半包的 /wisp-motion/ 路由),包围盒逐条量过、差 0.2%~1.6%,八条都不需要尺寸校正;另外动作一览不再点一下就关:连着点几个动作,她一个一个演给你看(Esc / × / 点背景关窗)。',
61
+ '1.47.0': '帧动画素材改成外置投递:宿主半包用 /wisp-motion/ 把 assets/motion/ 里的动图发给页面,客户端半包从 15.3 MB 回到 7.8 MB;泳装八态的接线(按皮肤+状态点素材)一并就位(素材本身这轮没生成出来,缺了就静默降级);另外多了「帧动画」开关 —— 一关画面就永远交给静态立绘,只收走动图,呼吸、气泡、一次性动作照旧。',
62
+ '1.46.5': '站着时她也在动:idle 接上第二条 4.04 秒循环(720x1280 / 24fps / 97 帧一帧没抽),两段素材合计内联压到 7.83 MB —— colorkey 过渡收到 0.05、WebP 质量退到 40,省下的余量换成了**去幕布色**:idle 的品红残留 1.09% → 0.006%,头发边上那圈紫边没了。',
63
+ '1.46.4': '睡着时她不再小一圈:动作层补上 scale(1.186) 与落地对齐(她在那张素材里只占 83.5% 盒高,立绘占 98.8%)。锯齿也修了:colorkey 过渡 0.02 → 0.06、WebP 质量 40 → 60,半透明边缘 0.80% → 1.40%,配套的 RGB 去绿边把绿边从 0.078% 压到 0.001%。',
64
+ '1.46.3': '睡眠循环换成 720p 重生成(480 在屏幕上放大发虚):720x1280、24fps、97 帧、绿边 0.01%;内联体积预算 2.4MB → 4.2MB。',
65
+ '1.46.2': '抠干净绿边:colorkey 0.10/0.10 → 0.22/0.02(边缘绿像素 0.43% → 0.01%,体积还小了一点)。',
66
+ '1.46.1': '帧动画换成动图 WebP(480 宽、24fps、97 帧一帧不抽):这个壳不还原 VP9 的 alpha,同一个文件在 <video> 里是黑/白剪影,在 <img> 里是硬的;「静止」「减少动态效果」「躲起来」改成藏动图、把画面交回立绘。',
67
+ '1.46.0': '查余额真的能用了:走平台自己的 Remote(账户服务),不再依赖只发给动态插件的 host 座位 —— 在这台机器上它一直回"查不了"。',
68
+ '1.45.3': '删掉两条身体层动画:attn 的左右轻推与 happy 的上下跳 —— 只剩 worried 那一下,换表情仍然弹。',
69
+ '1.45.3': '修掉睡眠帧动画变黑:带 alpha 的视频层不能再吃 CSS filter(透明区会被当成黑色)。',
70
+ '1.45.2': '修掉睡觉时的重影:帧动画一播,静态立绘就退场;「静止」档或视频加载失败时它再回来当兜底。',
71
+ '1.45.1': '删掉站立时的上下浮动(wisp-bob)—— 身体层不再有动画,动起来交给帧动画。',
72
+ '1.45.0': '她动起来了:睡觉时那段被子起伏是第一帧动画(带 alpha 的视频循环);「静止」档与系统的「减少动态效果」会把它停住并回到首帧,只留下静态立绘。',
59
73
  '1.16.0': '按社区规范换上鲸鳍耳。',
60
74
  '1.17.0': '鳍耳缩小、加上呆毛。',
61
75
  '1.18.0': '新增原版女仆的完整提示词。',
@@ -86,6 +100,12 @@ const WHATS_NEW = {
86
100
  '1.40.0': '她的透明处真的点不到了:按轮廓命中,站在按钮上也不挡你点它。',
87
101
  '1.41.0': '检查更新更耐得住:一个源连不上不再把另一个拖死,失败时 doctor() 会说清是哪个源、为什么。',
88
102
  '1.42.0': '查更新失败时她会直接说清是哪一种:没通道 / 连不上 / 宿主没应答。',
103
+ '1.43.0': '一批"真的用鼠标点过一遍"才发现的问题:拨完开关面板不再消失、大小能调回出厂值、触摸与指针离开不再留姿势、静止档真的会停。',
104
+ '1.43.1': '检查更新多了一条兜底通道(问插件管理器),并且失败时会直接说出"为什么拿不到"。',
105
+ '1.43.2': '台词只说人话;查不成的原因排进「关于她」弹窗,不再往句子里塞英文碎片。',
106
+ '1.44.0': '查更新改成客户端自己抓(bundle 插件没有 host RPC 座位)—— 这才是它一直缺的那条路。',
107
+ '1.43.4': '把查更新失败时的诊断临时写进窗口标题,供定位用。',
108
+ '1.43.3': '把"没接上宿主"和"宿主没有网络通道"分成两句 —— 断在哪一环,一句话就看得出来。',
89
109
  }
90
110
 
91
111
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -95,13 +115,67 @@ const WHATS_NEW = {
95
115
  所以这里只做三件真能做的事:报版本、说她更新了、把包名复制给你去 DSH 里装。 */
96
116
  const SEEN_VERSION_KEY = 'dsh-wisp:seen-version:v1'
97
117
  const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
118
+ /* 但"按包名安装"只在 npm 上已经有这一版时成立。npm 暂存(或还没发)而 GitHub
119
+ 已经更新时,照那句话装回来的是**旧版** —— 她得改口,并复制仓库地址而不是包名。
120
+ 她本来就知道这个版本号是从哪个源读到的(updateCheck.from / sources)。 */
121
+ const UPDATE_REPO = 'https://github.com/969246694/dsh-wisp'
122
+ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:' + UPDATE_REPO
98
123
 
99
124
  /* ---- sprites: 256x384 WebP, transparent, inlined by build.mjs ---- */
100
125
  const SPRITES = __SPRITES_LITERAL__
101
126
 
127
+ /* ---------------------------------------------------------------------
128
+ 帧动画(motion):**动图 WebP**。每一条对应一个"皮肤+状态"。
129
+
130
+ 在这之前,"她的动作"全是**位移/缩放/旋转**——立绘本身一动不动。这一层
131
+ 是真正的帧动画(v1.45.0 起):站着时那点起伏、睡着时被子的起伏,现在
132
+ 八套泳装状态各有一条。
133
+
134
+ **投递方式在 v1.47.0 换了**(这条注释的其余部分解释的都是为什么):
135
+ 表里存的不再是 base64,而是**素材文件名**;字节留在
136
+ `<包>/assets/motion/<文件名>.webp`,由宿主半包(lib/index.js)挂在平台
137
+ HTTP 载体上的 `/wisp-motion/` 路由发出来,这一层只管把 URL 指给 <img>。
138
+ 原因只有一个但足够:720p 的动图是 MB 级素材,两条内联 7.83 MB、八条
139
+ 泳装再加约 30 MB 会把浏览器半包推到 ~45 MB —— 那是把视频素材放错了地方,
140
+ 不是能"再压一压"的预算问题。想要外置,就必须有一条从"包里的文件"到
141
+ "<img src>" 的路:`@deepseek-ai/dsh-client-resources` 不是它(那是
142
+ `dsh-resource://` 的**取值**模型,要有 provider、要挂在 slot 上),
143
+ 平台的 `ctx.webServer.register` 才是(见 lib/index.js 的那一段长注释)。
144
+
145
+ 代价说清楚:**这一层不再自给自足**。路由不在(Desktop 载体没有 HTTP
146
+ 服务器)、文件 404、解码失败 —— 都走既有的静默降级:<img> 的 error
147
+ 一到,动图藏起来、画面交回静态立绘,她照样在屏幕上,一个都不少。
148
+
149
+ 为什么仍然是"动图 WebP"而不是 alpha 视频(v1.46.1):VP9 的 alpha 平面
150
+ 在这个壳里不还原 —— 同一段素材在暗色主题下渲染成黑色剪影、亮色主题下
151
+ 白色剪影(用户确认过)。图片路径的 alpha 是硬的:<img> 播动图时浏览器
152
+ 直接按 alpha 合成,没有第二条解码通道可以走偏。
153
+
154
+ 代价是**动图停不下来**:<img> 上没有 pause(),也没有 currentTime 可以
155
+ 写回 0。所以"冻结"不再是"停住",而是**把动图藏掉、把画面交回静态立绘**
156
+ (见 mount 里的 syncMotion)。这也是 v1.45.2 那条规则的另一面:画面里
157
+ 同时只该有一个她。
158
+
159
+ 表是空的就代表这个包里没有动作素材(build 允许 motion/ 不存在):
160
+ 客户端据此**连 <img> 都不建**,而不是摆一个永远不播的空盒子。
161
+ --------------------------------------------------------------------- */
162
+ const MOTION = __MOTION_LITERAL__
163
+
164
+ /* `/wisp-motion/<文件名>` 是宿主半包注册的路由(lib/index.js 的 MOTION_PATH,
165
+ 两处必须同名)。相对**页面自己的 base** 解析:部署前缀、端口、以及
166
+ Desktop 的 `dsh-app://app/` 都由 baseURI 带进来 —— 客户端半包因此永远
167
+ 不需要知道宿主的绝对路径,也就没有"路径写死了、换台机器就找不到"这回事。 */
168
+ const MOTION_ROUTE = 'wisp-motion/'
169
+ const motionSrc = (file) => {
170
+ /* 表里万一出现 data URI(预检注入、或构建被人改回内联),走立绘那条老路:
171
+ 这个壳把 data: 当坏图处理,必须先换成 object URL(见下面 resolveSprite)。 */
172
+ if (file.slice(0, 5) === 'data:') return resolveSprite(file)
173
+ return new URL(MOTION_ROUTE + file, document.baseURI).href
174
+ }
175
+
102
176
  /* 皮肤显示名。没在这里登记的皮肤直接用它的目录名 —— 所以增删皮肤只需要
103
177
  放素材 + 重新构建,不必改这一段。 */
104
- const SKIN_LABELS = { deepsea: '深海女仆', canon: '原版女仆', classic: '素绘女仆', night: '宵蓝礼服', pajama: '宵眠睡衣', default: '默认' }
178
+ const SKIN_LABELS = { deepsea: '深海女仆', canon: '原版女仆', classic: '素绘女仆', night: '宵蓝礼服', pajama: '宵眠睡衣', swim: '碧海泳装', default: '默认' }
105
179
  /* 皮肤 id 表放在模块级:mount 里恢复"上次选的皮肤"时就要用它,那时 mount
106
180
  内部的 const 还没求值(写成 mount 内的 const 会踩 TDZ)。 */
107
181
  const SKIN_IDS = Object.keys(SPRITES)
@@ -225,6 +299,10 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
225
299
  默认全开 —— 但必须有一个**不用改系统设置**就能让她安静下来的开关,
226
300
  prefers-reduced-motion 是给"所有动画"的,不是给"我不想让她动"的。 */
227
301
  motion: 'full',
302
+ /* 帧动画开关(v1.47.0):关掉 = 画面永远交给静态立绘。
303
+ 它和 motion:'off' **不是一回事** —— 那个连呼吸、z、一次性动作一起停;
304
+ 这个只收走动图,别的照旧。 */
305
+ frame: true,
228
306
  size: 4, // 560x840 CSS px, drawn from the 2048x3072 tier
229
307
  persist: true, // remember where she was dragged to
230
308
  celebrate: true, // cheer when a run finishes
@@ -404,6 +482,16 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
404
482
  streak: [
405
483
  '连续第 {n} 天了。', '第 {n} 天了,你还在。', '我们已经连着见了 {n} 天。', '第 {n} 天。我记着呢。', '{n} 天没断,挺厉害的。',
406
484
  ],
485
+ /* 装上最新版(v1.48.0):平台的插件管理器是客户端可用的 Remote 命名空间,
486
+ 所以"查到新版"之后可以**直接装** —— 见 installUpdate。 */
487
+ installChecking: ['正在装 {v}……', '好,去装 {v}。'],
488
+ installDone: ['装好了:v{v}。刷新一下页面就是新版。', 'v{v} 装好了 —— 刷新页面生效。'],
489
+ installRestart: ['v{v} 装好了,重启一下 DSH 才换上新的。'],
490
+ installPending: ['装的动作已经发出去了,还没回来 —— 过一会儿看看插件列表。'],
491
+ installCancelled: ['安装被取消了。'],
492
+ installNotPublished: ['npm 上还没有 v{v} 这一版(GitHub 先更新了)—— 等一会儿再装。'],
493
+ installFailed: ['没装成。', '安装没成功。'],
494
+ installUnsupported: ['这个壳里没有插件管理器,我装不了。'],
407
495
  updateCurrent: ['已经是最新的了。', '我就是最新的。', '没有更新的。'],
408
496
  updateAvailable: ['有新版本了:', '有新版:', '更新来了:'],
409
497
  updateFailed: ['更新没查成。', '这次没查到更新。', '更新检查没成功。'],
@@ -411,6 +499,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
411
499
  「宿主那边没应答」是三件事,含糊地说成"没查到"就没人能追下去 ——
412
500
  而用户看到的恰恰只有那一句。 */
413
501
  updateNoChannel: ['这个壳里我没有联网的通道,查不了更新。', '这里没有能出去的网络通道。'],
502
+ /* 两种"没通道"必须分开说 —— 它们是链路上**不同的一环**断了:
503
+ · 没接上宿主半包(客户端这一侧连 `host.call` 都没有)
504
+ · 宿主跑了,但它拿不到 `web` 服务
505
+ 合并成一句话的代价是:谁也说不清断在哪一环(这个坑真踩过)。 */
506
+ updateNoSeat: ['我没接上宿主半包,查不了更新。', '宿主那边没接上,查不了更新。'],
414
507
  updateNoHost: ['宿主那边没应答,更新没查成。', '问不到宿主,更新没查成。'],
415
508
  milestone: [
416
509
  '今天第 {n} 轮了。', '{n} 轮了,还不错。', '已经 {n} 轮了,你挺能撑。',
@@ -468,8 +561,17 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
468
561
  backSoon: ['Back already?', 'There you are.'],
469
562
  backLong: ['You were gone a while.', 'Long break. Welcome back.'],
470
563
  streak: ['{n} days in a row.', 'Day {n} together.'],
564
+ installChecking: ['Installing {v}…', 'Right — installing {v}.'],
565
+ installDone: ['v{v} installed — refresh the page to pick it up.', 'Installed v{v}. Refresh to use it.'],
566
+ installRestart: ['v{v} is installed; restart DSH to switch over.'],
567
+ installPending: ['The install is still running — check the plugin list in a bit.'],
568
+ installCancelled: ['The install was cancelled.'],
569
+ installNotPublished: ['npm does not have v{v} yet (GitHub updated first) — try again in a bit.'],
570
+ installFailed: ["The install didn't work.", 'That install failed.'],
571
+ installUnsupported: ['There is no plugin manager in this shell, so I cannot install it.'],
471
572
  updateFailed: ["The update check didn't work.", "Couldn't reach the update sources."],
472
573
  updateNoChannel: ["This shell gives me no network channel, so I can't check for updates.", 'No way out to the network from here.'],
574
+ updateNoSeat: ["I'm not connected to my host half, so I can't check for updates.", 'No host half attached.'],
473
575
  updateNoHost: ["The host side didn't answer — the update check failed.", 'No answer from the host.'],
474
576
  balanceChecking: ['One moment…', 'Let me check.'],
475
577
  balance: ['Balance: {n}.', "You've got {n}.", '{n} on the account.'],
@@ -656,6 +758,7 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
656
758
  wanderRange: { kind: 'num', lo: 0, hi: 2000 },
657
759
  reactions: { kind: 'bool' },
658
760
  motion: { kind: 'one', of: Object.keys(MOTION_AMP) },
761
+ frame: { kind: 'bool' },
659
762
  persist: { kind: 'bool' },
660
763
  celebrate: { kind: 'bool' },
661
764
  wander: { kind: 'bool' },
@@ -844,17 +947,80 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
844
947
  size rather than transform-scaled.
845
948
  --------------------------------------------------------------------- */
846
949
  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}
847
- .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)}
950
+ .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);--wisp-accent:var(--dsw-alias-brand-primary,#4a7cf7);--wisp-glow:drop-shadow(0 0 0 rgba(0,0,0,0));--wisp-sprite:drop-shadow(0 6px 14px rgba(0,0,0,.30))}
848
951
  .wisp-root[data-dragging="true"]{will-change:transform}
849
952
  .wisp-root[data-gliding="true"]{transition:transform 1.6s cubic-bezier(.4,0,.2,1)}
850
- .wisp-body{position:absolute;inset:0;pointer-events:none;touch-action:none;user-select:none;-webkit-user-select:none;animation:wisp-bob 5.6s ease-in-out infinite}
953
+ .wisp-body{position:absolute;inset:0;pointer-events:none;touch-action:none;user-select:none;-webkit-user-select:none}
851
954
  /* 命中层:一个**不画任何东西**的空盒子,只负责被鼠标点到。它带一层由 alpha 蒙版
852
955
  生成的 clip-path —— 于是"透明处点不到"是**浏览器**做的,而不是 JS 事后补救:
853
956
  事件根本不会派到她身上,下面的应用照常收到 hover / 点击 / 右键 / 滚轮。
854
957
  (只写 pointer-events:none 是不够的:那只是不拦她自己的事,盒子仍然盖在应用上面。) */
855
958
  .wisp-hit{position:absolute;inset:0;pointer-events:auto;cursor:grab}
856
959
  .wisp-hit:active{cursor:grabbing}
857
- .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}
960
+ /* 焦点环不能用浏览器默认的那一个。她的盒子是 560×840,里面**大部分是透明的**:
961
+ 默认焦点环画的正好是这个盒子,于是屏幕上出现一个套着素材的矩形线框 ——
962
+ 看起来像素材的边界,不像"她被选中了"(用户报的就是这个)。焦点照旧给她
963
+ (tabindex 见 JS),改成由**贴着剪影**的辉光表达:drop-shadow 跟的是 alpha 剪影,
964
+ 形状就是她本人,任何尺寸下都不会出现矩形。见下面的 --wisp-glow。 */
965
+ .wisp-body:focus,.wisp-body:focus-visible{outline:none}
966
+ .wisp-img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:bottom center;filter:var(--wisp-glow) var(--wisp-sprite);transition:opacity .28s ease,filter .5s ease}
967
+ /* 帧动画层(v1.45.0,v1.46.1 起是 <img> 动图):叠在立绘**上面**、和它同一个盒子。
968
+ 定位与缩放**逐条复用 .wisp-img 那一套**(inset:0 / 100% / contain / bottom center)——
969
+ 另起一套就会出现"动起来的时候她跳了一下"这种只在真机上看得见的错位。
970
+ pointer-events:none 是硬要求:她能不能被点到,仍然只由 .wisp-hit 的剪影决定。
971
+ display 由 JS 写死(冻结/没有素材/加载失败时是 none),所以这里不写 display。
972
+ 类名留着叫 wisp-video:它是"动起来的那一层",不是"一个 <video>"。 */
973
+ /* 动作层**不许有 CSS filter**(v1.45.3 立下的规矩,v1.46.1 照旧):它必须是一张
974
+ 原样的画面 —— 加了滤镜就多一层栅格化,alpha 剪影会被重新合成一次(v1.45.3 的
975
+ 黑色剪影就是这么来的)。阴影与调色只给静态立绘。
976
+
977
+ 尺寸校正(v1.46.4):两层共用**同一个盒子**,但两张素材里她占画面的比例不同,
978
+ 于是"一进入睡眠、画面交给她"的那一刻她整个人**缩了一圈**。实测包围盒(阈值
979
+ A>127,见 README 的量法):
980
+
981
+ 立绘 1024x1536:她占 0.9883 高、底边距 0.0117(18px)
982
+ —— 它的宽高比 2:3 与盒子 140x210 **相同**,contain 正好铺满盒子的高;
983
+ 动图 720x1280:她占 0.8352 高、底边距 0.0883(113px)
984
+ —— 宽高比 0.5625 窄于盒子,contain 按**高**贴合,画出来的还是盒子高。
985
+
986
+ 两个数都是"占盒子高的比例",所以校正只有一个乘法:她要长到 0.9883 高,
987
+ 就得乘 0.9883/0.8352 = 1.1834(取 **1.186** —— 97 帧之间包围盒自己还在 ±0.2%
988
+ 抖,1.186 落在它中间,也正是 preflight 断言盯着的那个值)。
989
+
990
+ 底边还得对齐。transform-origin 放在**脚底**(50% 100%),缩放不动底边;
991
+ 缩放把底边距一起放大成 0.0883*1.186 = 0.1047,要收回立绘那 0.0117,
992
+ 就得让底边下移 0.0930 个盒子高。而 scale(s) translateY(t) 是**右乘先生效**:
993
+ translate 先作用在元素自身坐标里,再被 s 缩放一次 —— 真正下移的是 s*t,
994
+ 故 t = 0.0930/1.186 = **7.84%**(translateY 的百分比基准就是元素自身高度)。
995
+
996
+ 验算:缩放后 0.8352*1.186 = 0.9905 高(立绘 0.9883,差 0.2% ≈ 1px),
997
+ 底边距 0.1047 - 0.0930 = 0.0117(立绘 0.0117)—— 高度与落地一起对上。
998
+ 两级都在**盒子坐标**里算,与 size 档位无关:放大是纯 transform,不重排版。
999
+
1000
+ 第二段素材(v1.46.5,idle)**不需要**校正:她在那张里占 0.972 个盒高,立绘按同一
1001
+ 量法占 0.982~0.988 —— 差 1.0~1.6%,在 210px 的盒子里是 2~3px,比 sleepy 那
1002
+ 15.7% 小一个量级。所以这一条就不补:每加一条变换就多一处能和"两层同格"对不上的
1003
+ 地方,而它换来的是肉眼看不出的 2px。表在 JS 那边(MOTION_FIT),这里按 data-clip
1004
+ 把"不校正"写成一条**显式**规则 —— 否则 base 那条 1.186 会落到 idle 头上,那才是
1005
+ 真的错。两条素材都是"行内 + 样式表"两份落点(行内见 buildMotion())。 */
1006
+ .wisp-video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;object-position:bottom center;pointer-events:none;transform:scale(1.186) translateY(7.84%);transform-origin:50% 100%}
1007
+ .wisp-video[data-clip="idle"]{transform:none}
1008
+ /* 泳装那八条(v1.47.1):逐条量过包围盒,她在那八张素材里占 **0.9515~0.9858** 个盒高
1009
+ (各自对应的泳装立绘 0.9674~0.9967),差 **0.2%~1.6%** —— 全在"小于 2% 就不补"那条线
1010
+ 以内(最大的一条是 swim_proud:1.6%,在 210px 的盒子里 3px 量级)。所以这八条要的
1011
+ 同样是**显式的不校正**:base 那条 1.186 是给通用 sleepy 的,落到泳装这八条头上就是
1012
+ 平白放大 18.6%。行内那份(buildMotion 按 MOTION_FIT 写)也写 none,两处各查各的 ——
1013
+ 和上面 idle 那条是同一个道理,只是这一条一次盖住八条素材。 */
1014
+ .wisp-video[data-clip^="swim_"]{transform:none}
1015
+ /* 帧动画**接管画面**时,静态立绘退场(v1.45.2)。两层叠在同一格里,同时可见就是
1016
+ **重影** —— 动图的透明处并不遮挡下面那张静帧,于是那段起伏会被看成"两张画
1017
+ 错位叠在一起"。退场必须发生在动图真的上了画面的时候,而不是"有素材"的时候:
1018
+ 冻结与加载失败都还要靠这张立绘撑场面。
1019
+ 用 visibility 而不是 display:none:盒子照旧占位,布局一点不动,"退场/回场"不会
1020
+ 让她跳一下(视频那层正是靠同一套盒模型对齐的,动布局就是动对齐)。
1021
+ 开关只有一处 —— syncMotion() 里的 setFrameOwner();这里只负责把根上那个属性
1022
+ 翻译成画面。行内还有一份镜像(见 setFrameOwner),给样式表缺失的壳兜底。 */
1023
+ .wisp-root[data-frame="on"] .wisp-img{visibility:hidden}
858
1024
  .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}
859
1025
  .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}
860
1026
  .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}
@@ -862,20 +1028,27 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
862
1028
  .wisp-root[data-mood="happy"]{--wisp-aura:rgba(255,132,206,.50)}
863
1029
  .wisp-root[data-mood="sleep"]{--wisp-aura:rgba(140,152,215,.30)}
864
1030
  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}
865
- .wisp-root[data-mood="sleep"] .wisp-body{animation-duration:9s}
866
- .wisp-root[data-mood="alert"] .wisp-body{animation-duration:4.6s}
867
- .wisp-root[data-mood="happy"] .wisp-body{animation-name:wisp-hop;animation-duration:1.15s;animation-timing-function:ease-in-out}
868
- .wisp-root[data-mood="sleep"] .wisp-img{filter:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.82) brightness(.96)}
869
- @keyframes wisp-bob{0%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-9px,0)}100%{transform:translate3d(0,0,0)}}
1031
+ /* 身体层只剩 worried 那一下(v1.45.3)。happy 的上下跳(wisp-hop)与 attn 的左右轻推
1032
+ (wisp-nudge)都删了:两条都靠 translate 挪整个 .wisp-body,而这一层现在同时承载帧动画
1033
+ 与交叉淡入 —— 位移叠上去不像"她在动",像素材在抖。换表情本来就有 .wisp-motion 那一下弹
1034
+ (POPPY_MOODS 那一组),"她回应了"的信号不缺。
1035
+ 一并删掉 sleep 9s / alert 4.6s 两条 animation-duration:它们是给 1.45.1 删掉的常驻浮动
1036
+ 调速度用的,这里已经没有动画名可调,留着只会让人以为身体层还在动。 */
1037
+ .wisp-root[data-mood="sleep"]{--wisp-sprite:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.82) brightness(.96)}
1038
+ /* 睡眠这档给静态立绘调色(saturate/brightness)。动作层**不跟着调**(v1.45.3 起):
1039
+ 动作层不许挂 filter,所以睡着的那一刻两张图之间会有一点色差 —— 这是已知的、
1040
+ 接受了的取舍:宁可色温跳一下,也不要 alpha 剪影被滤镜重新栅格化一次。 */
870
1041
  @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)}}
871
1042
  .wisp-root[data-mood="attn"]{--wisp-aura:rgba(255,138,120,.55)}
872
1043
  .wisp-root[data-mood="worried"]{--wisp-aura:rgba(150,170,190,.5)}
873
- .wisp-root[data-mood="worried"] .wisp-img{filter:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.86)}
1044
+ .wisp-root[data-mood="worried"]{--wisp-sprite:drop-shadow(0 6px 14px rgba(0,0,0,.25)) saturate(.86)}
1045
+ /* 键盘焦点:只有**键盘**拿到的焦点才亮这一圈(data-focus="key" 由 JS 写,见 focusHer)。
1046
+ 两件事分开:默认的矩形环永远不画;"她有没有焦点"仍然看得见 —— 键盘用户 Tab 到她
1047
+ 身上不能没有任何变化。滤镜链拆成 --wisp-glow + --wisp-sprite 就是为了让这一条
1048
+ 叠加在任意表情的滤镜上(sleep / worried 各改各的那一半,谁也不覆盖谁)。 */
1049
+ .wisp-root[data-focus="key"]{--wisp-glow:drop-shadow(0 0 4px var(--wisp-accent)) drop-shadow(0 0 11px var(--wisp-accent))}
874
1050
  .wisp-root[data-mood="worried"] .wisp-body{animation-name:wisp-worry;animation-duration:2.4s;animation-iteration-count:3}
875
1051
  @keyframes wisp-worry{0%,100%{transform:translate3d(0,0,0)}50%{transform:translate3d(0,-5px,0)}}
876
- .wisp-root[data-mood="attn"] .wisp-body{animation-name:wisp-nudge;animation-duration:1.5s;animation-iteration-count:4}
877
- @keyframes wisp-nudge{0%,100%{transform:translate3d(0,0,0)}25%{transform:translate3d(-7px,0,0)}75%{transform:translate3d(7px,0,0)}}
878
- @keyframes wisp-hop{0%,100%{transform:translate3d(0,0,0)}45%{transform:translate3d(0,-16px,0)}}
879
1052
  /* ---- 动作层(见文件上方 motion 常量处的说明)----
880
1053
  两层都是**空盒子**:绝对定位铺满、不吃指针。姿势与一次性动作分别只碰自己
881
1054
  那一层的 transform,所以它们永远不会互相覆盖。
@@ -891,6 +1064,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
891
1064
  .wisp-root[data-motion="off"]{--wisp-amp:0}
892
1065
  /* 静止档:连呼吸和 z 都停 —— 她说"我不想让她动"时,是最彻底的静止。 */
893
1066
  .wisp-root[data-motion="off"] .wisp-body,.wisp-root[data-motion="off"] .wisp-zzz{animation:none}
1067
+ /* 但"不动"不等于"冻住":动画一关,z 会停在满不透明的初始态,看起来像一个卡住的字 */
1068
+ .wisp-root[data-motion="off"] .wisp-zzz{opacity:.5}
894
1069
  .wisp-root[data-accent="press"] .wisp-motion{animation:wisp-press .2s ease-out}
895
1070
  .wisp-root[data-accent="land"] .wisp-motion{animation:wisp-land .56s cubic-bezier(.2,1.15,.35,1)}
896
1071
  .wisp-root[data-accent="pop-a"] .wisp-motion{animation:wisp-pop-a .38s ease-out}
@@ -976,7 +1151,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
976
1151
  .wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
977
1152
  .wisp-dialog-action{display:block;width:100%;margin:4px 0 2px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));border-radius:8px;background:none;color:inherit;font:inherit;cursor:pointer}
978
1153
  .wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
979
- @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-motion{animation:none!important}.wisp-lean{transform:none!important;transition:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
1154
+ @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-motion{animation:none!important}.wisp-zzz{animation:none!important;opacity:.5}.wisp-lean{transform:none!important;transition:none!important}.wisp-video{display:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
980
1155
 
981
1156
  const installStyles = (styles) => {
982
1157
  if (styles && typeof styles.insert === 'function') {
@@ -1485,6 +1660,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1485
1660
  bodyEl.setAttribute('tabindex', '0')
1486
1661
  bodyEl.setAttribute('aria-label', 'DeepSeek娘')
1487
1662
  bodyEl.setAttribute('aria-haspopup', 'menu')
1663
+ /* 默认焦点环必须关掉,行内再写一遍:它画在**盒子**上,而盒子里大部分是透明的,
1664
+ 看上去就是套着素材的矩形边界线。样式表缺失的壳里同样不该出现 ——
1665
+ 与精灵图、两层盒子、命中层"关键样式行内再写一遍"是同一条理由。
1666
+ 键盘焦点改用贴着剪影的辉光,见 CSS 里的 --wisp-glow。 */
1667
+ bodyEl.style.outline = 'none'
1488
1668
 
1489
1669
  /* 命中层(见 clipPathOf 上面的说明):空盒子 + 由蒙版生成的 clip-path。
1490
1670
  关键样式同样行内再写一遍 —— 样式表缺失时它必须仍然是"铺满、吃指针"的那个。 */
@@ -1601,7 +1781,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1601
1781
 
1602
1782
  /* 结构:body(情绪动画)> motion(一次性动作)> lean(姿势)> shine/img,
1603
1783
  外加一个兄弟节点 hit(命中层)。 */
1604
- leanEl.appendChild(shine)
1784
+ /* 光晕(脚底那圈光)挂在 motion 层、**不在 lean 层**:它是地面上的东西,
1785
+ 不该跟着她的侧身一起歪 —— 挂在 lean 里的时候,她一倾斜光圈也跟着转,
1786
+ 看起来像地板歪了。但按下/落地那一下的压扁它该跟着(那是"她压在地上"),
1787
+ 所以留在 motion 层而不是提到 body 层。 */
1788
+ motionEl.appendChild(shine)
1605
1789
  leanEl.appendChild(img)
1606
1790
  motionEl.appendChild(leanEl)
1607
1791
  bodyEl.appendChild(motionEl)
@@ -1645,6 +1829,49 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1645
1829
  素材还没生成时由 SPRITE_FALLBACK 退回 idle —— 不会出现空白或坏图。 */
1646
1830
  const SPRITE_OF = { idle: 'idle', alert: 'work', happy: 'happy', sleep: 'sleepy', attn: 'attn', care: 'attn', poked: 'poked', proud: 'proud', worried: 'sleepy', eat: 'eat', report: 'report' }
1647
1831
  const SPRITE_FALLBACK = { attn: 'work', report: 'idle' }
1832
+ /* 哪个**皮肤+状态**有帧动画素材(v1.47.0 起按皮肤分开)。
1833
+ 键是 `<皮肤>:<情绪>`,值是 MOTION 里的素材名。
1834
+
1835
+ 为什么键也要带皮肤:泳装那八条是照着**泳装立绘**生成的,别的皮肤穿上
1836
+ 就是另一个人的衣服 —— 把泳装那条放给宵蓝礼服,画面上会当场换装。
1837
+ 所以查表是"先看这个皮肤有没有自己的这一段,没有再退回通用那一对"。
1838
+
1839
+ 值是**素材名**(= 立绘名),不是情绪名:`sleep` 那一档播的是
1840
+ `swim_sleepy`(睡着那张立绘的动画)、`alert` 那一档播的是
1841
+ `swim_work`(敲键盘那张)—— 素材按"哪张立绘"点,`MOTION_FIT` 也按
1842
+ 素材名查,两边不会各说各话。
1843
+
1844
+ 通用那一对(`idle` / `sleepy`)是所有皮肤共用的;`worried` 虽然也用
1845
+ sleepy 那张立绘,但它有自己的抖动,"睡着了的呼吸"不该跟着它走 ——
1846
+ 所以它查不到素材,连 <img> 都不建。 */
1847
+ const MOTION_OF = {
1848
+ 'swim:idle': 'swim_idle',
1849
+ 'swim:attn': 'swim_attn',
1850
+ 'swim:happy': 'swim_happy',
1851
+ 'swim:sleep': 'swim_sleepy',
1852
+ 'swim:alert': 'swim_work',
1853
+ 'swim:proud': 'swim_proud',
1854
+ 'swim:eat': 'swim_eat',
1855
+ 'swim:poked': 'swim_poked',
1856
+ }
1857
+ const MOTION_BASE = { idle: 'idle', sleep: 'sleepy' }
1858
+ /* 查表只有这一处:syncMotion() 与 doctor() 都走它,所以"报告里说有素材、
1859
+ 画面上没有"这种分歧没有地方能长出来。 */
1860
+ const motionKeyFor = (moodNow) => MOTION_OF[activeSkin + ':' + moodNow] ?? MOTION_BASE[moodNow]
1861
+ /* 每一段素材自己的**尺寸校正**(推导见样式表 .wisp-video 上那一段)。
1862
+ 表里没有 = 这一段不需要校正,动作层按原样铺满盒子。
1863
+ 为什么 idle 不在表里:她在那张 720x1280 里占 0.972 个盒高(立绘 0.982~0.988),
1864
+ 差 1.0~1.6% —— **小于 2% 就不补**:这点差在 210px 的盒子里是 2~3px,而每加一条
1865
+ 变换就多一处能和"两层同格"这条规矩对不上的地方(sleepy 那 15.7% 是必须补的,
1866
+ 这一条不是)。底边距 1.7% 对 1.2%,差 1px 量级,同样不补。
1867
+ 泳装那八条按实测包围盒逐条定:差异 ≥2% 才写进来(见 1.47.0 小节)。
1868
+ **1.47.1 量完八条的结果:一条都不用写。**她在那八张 720x1280 素材里占
1869
+ 0.9515~0.9858 个盒高,各自对应的泳装立绘占 0.9674~0.9967 —— 差 0.2%~1.6%,
1870
+ 八条全在 2% 那条线以内(最大的是 swim_proud 的 1.6%,合 3px)。底边距同理
1871
+ (1.1~4.5px)。所以这张表**一个字都没变**,变的是样式表那边多了一条
1872
+ .wisp-video[data-clip^="swim_"]{transform:none}(见那一行上的量法与理由)——
1873
+ 不补也要**显式地**不补,否则 base 那条 1.186 会落到这八条头上。 */
1874
+ const MOTION_FIT = { sleepy: 'scale(1.186) translateY(7.84%)' }
1648
1875
  let mood = 'idle'
1649
1876
 
1650
1877
  /* 换皮肤 = 换一整套精灵图。位置、情绪、气泡都不动,只有图与命中蒙版重建。 */
@@ -1659,6 +1886,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1659
1886
  activeSkin = id
1660
1887
  writeStored(SKIN_KEY, { id })
1661
1888
  if (changed) setSprite(SPRITE_OF[mood])
1889
+ /* 换皮肤也要重算动作层(v1.47.0):素材是**按皮肤**点的,泳装那八条
1890
+ 只属于泳装。少了这一句,换到泳装时画面上还是通用那段 idle 的循环
1891
+ ——"图换了、动的那层没换"正是这一版最容易漏的一处。
1892
+ 它在 setSprite 之后:动图接管画面时要读到的静态立绘已经是新的了。 */
1893
+ if (changed) syncMotion()
1662
1894
  return true
1663
1895
  }
1664
1896
 
@@ -1708,6 +1940,181 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1708
1940
  })
1709
1941
  }
1710
1942
 
1943
+ /* ---- 帧动画的闸门(v1.45.0;v1.46.1 起是 <img> 动图)--------------------
1944
+ syncMotion() 是**唯一**决定"这一刻画面归谁"的地方:
1945
+
1946
+ 这个表情没有动作素材 -> 连 <img> 都不建(有也藏起来),不白解码 1.9 MB
1947
+ 有素材但该冻结 -> 把动图藏掉,画面交回静态立绘
1948
+ 有素材且可以动 -> 显示动图,静态立绘退场
1949
+
1950
+ 它还顺带决定**画面归谁**(v1.45.2):动图真的上了画面,静态立绘就退场
1951
+ (setFrameOwner(true));其余每一种情况 —— 没有素材、冻结、加载失败 ——
1952
+ 立绘都是画面里的那个人(setFrameOwner(false))。这不是两件事,是同一件事的
1953
+ 两面:帧动画没在显示的时候,屏幕上必须有一个她。
1954
+
1955
+ 什么算"该冻结":静止档(cfg.motion === 'off')、系统的
1956
+ prefers-reduced-motion,以及她**被躲起来**(root 上是 display:none)——
1957
+ 屏幕上看不见的东西不该继续占着解码器。
1958
+
1959
+ 动图**停不下来**:<img> 上没有 pause(),也没有 currentTime 可以写回 0。
1960
+ 所以"冻结"只有一条路 —— 藏起来、把画面交回立绘。这也是为什么这一层
1961
+ 必须和静态立绘同格:冻住的时候,"她"就是那张静帧。
1962
+
1963
+ 三个触发点:换表情(setMood)、档位改动(applyMotion)、系统设置在会话
1964
+ 中途变了(matchMedia 的 change)。以后再加一个"她该不该动"的开关,就必须
1965
+ 接到这里来:这一版最容易犯的错就是"静止档里她还在动",而它只在真机上看得见。
1966
+
1967
+ 元素换成了 <img>,play()/autoplay 那一串就没有了:动图加载完自己动。
1968
+ 唯一还要包住的是 resolveSprite()(壳里的替身可能没有 Blob 通道),
1969
+ 它失败时我们只是不显示这一层 —— 静默降级,画面回到立绘。
1970
+
1971
+ 失败**按素材记**(v1.46.5):包里有两条素材了,"她这段坏了"和"她那段坏了"
1972
+ 是两件事。坏过的那一段不再重指源(重试一个坏源只会每次换表情都烧一次解码,
1973
+ 换来的画面和静态立绘一模一样);没坏的那一段照常播。 */
1974
+ let motionImg = null
1975
+ let motionKey = null
1976
+ let motionFailed = false
1977
+ const motionFailedKeys = Object.create(null)
1978
+ let motionMqlOff = null
1979
+
1980
+ /* 静态立绘什么时候退场,只有这一个开关(v1.45.2)。
1981
+ "帧动画接管画面"= 动图这一层真的显示出来了 —— 那一刻两层同时可见就是重影,
1982
+ 而重影不是兜底,是错。属性走 CSS(.wisp-root[data-frame="on"] .wisp-img),
1983
+ 因为属性盖得住**交叉淡入**留下的那张旧图,而行内只写得中当前这两张;
1984
+ 行内再镜像一份,是为了样式表缺失的壳里画面仍然是对的(这条理由和精灵图、
1985
+ 两层盒子、动图的"关键样式行内再写一遍"完全一样)。
1986
+ 读的人只需要记住:谁在画面里,由 syncMotion() 说了算。 */
1987
+ const setFrameOwner = (on) => {
1988
+ if (on) root.dataset.frame = 'on'
1989
+ else delete root.dataset.frame
1990
+ const imgs = leanEl.querySelectorAll('.wisp-img')
1991
+ for (let i = 0; i < imgs.length; i++) imgs[i].style.visibility = on ? 'hidden' : ''
1992
+ }
1993
+
1994
+ const hideMotion = () => {
1995
+ if (motionImg !== null) motionImg.style.display = 'none'
1996
+ /* 动图退场,静态立绘立刻回到画面:冻结时看到的、加载失败时兜底的都是这张。
1997
+ 动图没有"暂停"可喊 —— 藏起来就是这一层唯一也是正确的冻结方式。 */
1998
+ setFrameOwner(false)
1999
+ }
2000
+
2001
+ const startMotion = () => {
2002
+ if (motionImg === null || motionFailed) return
2003
+ motionImg.style.display = ''
2004
+ /* 动图接管画面:静态立绘退场。这一行必须在任何提前返回**之前** ——
2005
+ 每次换表情都会重建交叉淡入的那张旧图,而它同样得被藏掉,
2006
+ 所以只要 syncMotion() 判了"在动",这个开关就要重写一遍。 */
2007
+ setFrameOwner(true)
2008
+ }
2009
+
2010
+ /* 一段素材的**几何**(行内那一份):变换按素材查表,元素上那个 data-clip 同时
2011
+ 给样式表用(.wisp-video[data-clip="idle"]{transform:none})。
2012
+ 两处都得调它:新建元素时、以及**换源**时 —— 换源不重写这两样,睡下之后
2013
+ 身上还挂着站着那段的"不校正",而醒着的时候会反过来被放大 18.6%。
2014
+ (这正是 v1.46.5 换源那一条断言抓到的:图片换了、几何没换。) */
2015
+ const applyClipGeometry = (img, key) => {
2016
+ img.dataset.clip = key
2017
+ img.style.transform = MOTION_FIT[key] ?? 'none'
2018
+ img.style.transformOrigin = '50% 100%'
2019
+ }
2020
+
2021
+ const buildMotion = (key) => {
2022
+ const img = document.createElement('img')
2023
+ img.className = 'wisp-video'
2024
+ /* 关键样式**行内再写一遍**(和精灵图、两层盒子同一个理由):样式表缺失时
2025
+ 它必须仍然是"和立绘同一格、不吃指针"的那个。 */
2026
+ img.style.position = 'absolute'
2027
+ img.style.top = '0'
2028
+ img.style.left = '0'
2029
+ img.style.width = '100%'
2030
+ img.style.height = '100%'
2031
+ img.style.objectFit = 'contain'
2032
+ img.style.objectPosition = 'bottom center'
2033
+ img.style.pointerEvents = 'none'
2034
+ /* 尺寸校正(推导见样式表 .wisp-video 上那一段):她在那张 720x1280 的动图里
2035
+ 只占 0.8352 个盒高,而立绘占 0.9883 —— 不补这一下,睡着时她比立绘小 15.7%。
2036
+ 按**素材**查表(MOTION_FIT),不是按状态写死:哪天换素材,改的是那张表。
2037
+ idle 不在表里 —— 它只差 1%,补它是在给"两层同格"制造一个能对不上的地方。
2038
+ 没有校正时写 'none' 而不是留空:空的看不出是"不需要"还是"忘了"。 */
2039
+ applyClipGeometry(img, key)
2040
+ img.style.display = 'none'
2041
+ /* 动图是**装饰**:alt 留空、aria-hidden,读屏器不该为一个循环动图分心。 */
2042
+ img.setAttribute('alt', '')
2043
+ img.setAttribute('aria-hidden', 'true')
2044
+ img.draggable = false
2045
+ /* 加载失败**静默降级**:藏掉动图、继续用静态立绘。不抛、不冒泡、不写控制台
2046
+ —— 这条路的"失败"和"没有素材"是同一幅画面,没有可追问的东西。
2047
+ 失败记在**这一段素材**名下(不是"这个元素坏了"):另一段素材没坏,
2048
+ 她换个状态就该照常动。所以这里读的是元素**当下**在播的那一段(motionKey),
2049
+ 不是建元素时那一段 —— 元素是复用的,绑定时的 key 早就过期了。 */
2050
+ const onFail = () => { motionFailedKeys[motionKey ?? key] = true; motionFailed = true; hideMotion() }
2051
+ if (typeof img.addEventListener === 'function') img.addEventListener('error', onFail)
2052
+ else img.onerror = onFail
2053
+ /* 先认领这个元素,再指源:错误是异步来的,但"它属于哪一段素材"不能等到
2054
+ 指完源才知道(下面那个 catch 走的是同一条账)。 */
2055
+ motionImg = img
2056
+ motionKey = key
2057
+ /* 素材字节走**和立绘同一条路**:motionSrc() 把素材名变成 URL ——
2058
+ v1.47.0 起是宿主半包发出来的 `/wisp-motion/<文件>`(页面自己的 base
2059
+ 起算),万一表里是 data URI 才回落到 Blob → object URL。
2060
+ 这个壳把 data: 源当坏图处理(见文件上方的平台事实 3),动图没有理由
2061
+ 不一样;而 resolveSprite 在 Blob 拿不到时会回落到原始 data URI。 */
2062
+ try { img.src = motionSrc(MOTION[key]) } catch (e) { motionFailedKeys[key] = true; motionFailed = true }
2063
+ leanEl.appendChild(img)
2064
+ }
2065
+
2066
+ /* "这一刻该不该停住"只有这一份判断:syncMotion 拿它做决定,doctor() 拿它做汇报。
2067
+ 两处各写一遍的话,"报告说在播、画面上停着"这种分歧迟早会出现。
2068
+ 四个来源:**帧动画开关**(cfg.frame,v1.47.0,只收走动图)/ 幅度归零 / 系统的
2069
+ 减少动态效果 / 她被躲起来(屏幕上看不见的东西不该占着解码)。 */
2070
+ const motionFrozen = () => cfg.frame === false || amp() === 0 || reduced() || root.dataset.hidden === 'true'
2071
+
2072
+ const syncMotion = () => {
2073
+ if (destroyed) return
2074
+ const key = motionKeyFor(mood)
2075
+ if (typeof key !== 'string' || typeof MOTION[key] !== 'string') {
2076
+ /* 这个皮肤在这个状态没有动作素材:不建 <img>。 */
2077
+ if (motionImg !== null) hideMotion()
2078
+ return
2079
+ }
2080
+ /* 这一段素材**已经坏过**:不再重指一次源。判断按素材走(见上面 motionFailedKeys)。
2081
+ motionFailed 是**派生**的 —— 它必须等于"当前这一段坏没坏",而不是"最近一次
2082
+ 失败留下的印记":后者会把一段坏素材变成她**永远**不动(连换回好的那段也不动),
2083
+ 而这正是 v1.46.5 的预检抓到的那条。 */
2084
+ motionFailed = motionFailedKeys[key] === true
2085
+ if (motionFailed) {
2086
+ if (motionImg !== null) hideMotion()
2087
+ return
2088
+ }
2089
+ if (motionImg === null) buildMotion(key)
2090
+ else if (motionKey !== key) {
2091
+ /* 多段素材时,同一个元素换 src 就够了 —— 不重建元素,
2092
+ 免得把"元素存在"这件事也变成一次竞态。
2093
+ **几何要跟着换**:transform 与 data-clip 都是**按素材**取的,
2094
+ 换源不重写它们,画面上的大小就会停在上一段素材那一档。 */
2095
+ motionKey = key
2096
+ motionFailed = false
2097
+ applyClipGeometry(motionImg, key)
2098
+ try { motionImg.src = motionSrc(MOTION[key]) } catch (e) { motionFailedKeys[key] = true; motionFailed = true }
2099
+ }
2100
+ if (motionFailed || motionFrozen()) {
2101
+ hideMotion()
2102
+ return
2103
+ }
2104
+ startMotion()
2105
+ }
2106
+
2107
+ /* 系统设置**在会话中途**改了也要当场生效:CSS 的媒体查询是实时的,而"这一层
2108
+ 现在显不显示"是 JS 写的,媒体查询够不着它。不订阅这个变化,"你现在去系统里
2109
+ 关掉动画"就得等到下一次换表情才生效 —— 而那一刻她正动着。 */
2110
+ try {
2111
+ reduced() // 先把 MQL 取出来(reduced() 里那次是懒加载)
2112
+ if (reducedMql && typeof reducedMql.addEventListener === 'function') {
2113
+ reducedMql.addEventListener('change', syncMotion)
2114
+ motionMqlOff = () => { try { reducedMql.removeEventListener('change', syncMotion) } catch (e) { /* ignore */ } }
2115
+ }
2116
+ } catch (e) { /* 拿不到就算了:冻结规则在"读一次"这条路上仍然成立 */ }
2117
+
1711
2118
  /* 姿势:° 是**屏幕坐标**里的角度(正 = 顶朝右),乘镜像由 applyTilt 做。
1712
2119
  这里是所有姿势的唯一闸门 —— 静止档与"系统要求别动"都在这一个地方归零,
1713
2120
  调用点不必各自记得判断。 */
@@ -1720,10 +2127,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1720
2127
 
1721
2128
  /* 档位改动要**当场**生效,不用刷新:属性一改,CSS 那边的全部幅度规则
1722
2129
  (关键帧里的 --wisp-amp、静止档关掉呼吸与 z)立刻跟上。
1723
- 另外把姿势归零 —— 从"灵动"切到"静止"时她还歪着,就成了半截状态。 */
2130
+ 另外把姿势归零 —— 从"灵动"切到"静止"时她还歪着,就成了半截状态。
2131
+ 帧动画也在这里收:`motion: off` 是"她不动"的两个闸门之一,而动图是 JS
2132
+ 在显示 —— CSS 的 --wisp-amp:0 对它一个字也说不上(见 syncMotion)。 */
1724
2133
  const applyMotion = () => {
1725
2134
  root.dataset.motion = cfg.motion
1726
2135
  if (amp() === 0 || reduced()) setTilt(0)
2136
+ syncMotion()
1727
2137
  }
1728
2138
  applyMotion()
1729
2139
 
@@ -1752,6 +2162,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1752
2162
  z2.style.right = '24px'; z2.style.top = '-2px'; z2.style.animationDelay = '1.1s'
1753
2163
  root.appendChild(z1); root.appendChild(z2)
1754
2164
  }
2165
+ /* 换表情是帧动画的第三个触发点:睡下时开始播、醒来时收掉(见 syncMotion)。
2166
+ 放在**最后**:它读的是上面那几行刚写完的 mood 与 dataset。 */
2167
+ syncMotion()
1755
2168
  }
1756
2169
 
1757
2170
  /* 气泡也挂在 layer 上,所以躲起来时必须单独收掉 —— 只把 root 设成
@@ -2316,6 +2729,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2316
2729
  let wanderHandle = null
2317
2730
  const syncWander = () => {
2318
2731
  if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
2732
+ /* 溜达也是"动作":`motion: off` 和 prefers-reduced-motion 都该让她待在原地。
2733
+ 之前只关了 CSS 动画,她照样会自己滑走 —— 那是最容易被忽略的一种"没停下来",
2734
+ 因为看起来只是一次正常的挪动。(CSS 关不掉 JS 写进去的坐标。) */
2735
+ if (cfg.motion === 'off' || reduced()) return
2319
2736
  if (!cfg.wander || cfg.wanderRange <= 0 || paused) return
2320
2737
  wanderHandle = clock.every(cfg.wanderMs, () => {
2321
2738
  if (destroyed || hidden || mood !== 'idle' || start !== null) return
@@ -2385,6 +2802,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2385
2802
  root.dataset.hidden = hidden ? 'true' : 'false'
2386
2803
  if (hidden) { closeMenu(); hideTab(); clearSay(); showTab(); setTilt(0) }
2387
2804
  else { hideTab(); paint() }
2805
+ /* 躲起来 = 屏幕上看不见:帧动画跟着收(syncMotion 读的就是这个 dataset,
2806
+ 所以它必须写在前面)。不接这一下的话,一层看不见的动图会继续解码。 */
2807
+ syncMotion()
2388
2808
  if (cfg.persist) writeStored(HIDDEN_KEY, { hidden })
2389
2809
  }
2390
2810
 
@@ -2424,8 +2844,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2424
2844
  const dx = e.clientX - start.x, dy = e.clientY - start.y
2425
2845
  if (!moved && Math.sqrt(dx * dx + dy * dy) > 4) moved = true
2426
2846
  if (!moved) return
2847
+ const beforeX = coords.x, beforeY = coords.y
2427
2848
  coords.x = clamp(origin.x + dx, 0, maxX())
2428
2849
  coords.y = clamp(origin.y + dy, 0, maxY())
2850
+ /* 被边界夹住时她其实**没有动** —— 这时候再按指针位移去倾,就是"原地歪",
2851
+ 看起来像坏了。位置没变就把姿势归零(指针继续拖也不影响,因为下一帧
2852
+ 只要真的动了就会重新算)。 */
2853
+ if (coords.x === beforeX && coords.y === beforeY) {
2854
+ lastDragX = e.clientX
2855
+ setTilt(0)
2856
+ return
2857
+ }
2429
2858
  /* 拖动时她朝**运动的反方向**倾:被拎着走的东西,顶是往后倒的。
2430
2859
  只看"这一次事件走了多远"就等价于速度,不用存历史、不用积分 ——
2431
2860
  而且它天然跟着指针事件的频率走,慢拖就是小角度。 */
@@ -2512,6 +2941,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2512
2941
  if (destroyed || paused || hidden || start !== null) return
2513
2942
  if (mood === 'sleep') return
2514
2943
  if (cfg.motion === 'off' || reduced()) return
2944
+ /* 触摸屏上"指针"就是手指:手指划过时让她整只歪过去是错的 ——
2945
+ 那既不是"靠近"也不是"路过",而且手指一抬起姿势就冻在那儿。
2946
+ 所以触摸一律归零(真正该有的反馈是"按下压一下",那是另一条通道)。 */
2947
+ if (e.pointerType === 'touch') { setTilt(0); return }
2515
2948
  if (typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return
2516
2949
  const dx = e.clientX - (coords.x + boxW() / 2)
2517
2950
  const dy = e.clientY - (coords.y + boxH() / 2)
@@ -2522,9 +2955,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2522
2955
  }
2523
2956
  window.addEventListener('pointermove', leanToPointer, { passive: true })
2524
2957
  /* 指针移出窗口、或者你切走应用之后,不会再有点事件来把它归零 ——
2525
- 没有这一下,她会一直朝着最后那个位置歪着。 */
2958
+ 没有这一下,她会一直朝着最后那个位置歪着。
2959
+ `blur` 只覆盖"切走应用",**指针移出窗口不会触发它** —— 补一个文档级
2960
+ mouseleave(指针离开文档时才发),两条一起才真的覆盖到。 */
2526
2961
  const resetTilt = () => setTilt(0)
2527
2962
  window.addEventListener('blur', resetTilt)
2963
+ try {
2964
+ if (typeof document !== 'undefined' && document.addEventListener) {
2965
+ document.addEventListener('mouseleave', resetTilt)
2966
+ }
2967
+ } catch (e) { /* 没有 document:她最多保留最后一次侧身,不影响其它功能 */ }
2528
2968
 
2529
2969
  /* 键盘入口:Enter / Space / 菜单键 / Shift+F10 打开菜单。
2530
2970
  Space 默认会滚动页面,Enter 在表单里会提交 —— 都要拦。 */
@@ -2564,7 +3004,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2564
3004
  只认**带修饰键**的滚轮:她占地不小,直接吞掉普通滚轮等于在她身上把页面滚动禁掉。
2565
3005
  带修饰键是「缩放」的通行手势,此时 preventDefault 才是帮忙而不是捣乱。
2566
3006
  步长 0.25,上下限由 CONFIG_SPEC 统一夹紧(0.4–8),不在这里再写一遍。 */
2567
- const WHEEL_STEP = 0.25
3007
+ /* 步长必须是"默认值可达"的:size 的区间是 0.4–8,默认 2.5。
3008
+ 原来键盘/滚轮步长 0.25,可达值只有 0.4 + 0.25k —— **2.5 不在格点上**
3009
+ (最近的是 2.4 / 2.65),于是用户只要动过一次滑块,就再也调不回出厂大小。
3010
+ 0.1 让 2.5 正好落在格点上(0.4 + 21×0.1)。预检里有一条守着这个不变式。 */
3011
+ const WHEEL_STEP = 0.1
2568
3012
  const onWheel = (ev) => {
2569
3013
  if (!ev.ctrlKey && !ev.metaKey) return // 普通滚轮原样放行
2570
3014
  if (typeof ev.preventDefault === 'function') ev.preventDefault()
@@ -2875,12 +3319,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2875
3319
  子面板里的开关点完必须留在原地 —— 拨个开关面板就消失是最烦人的交互之一。 */
2876
3320
  const refreshSurfaces = () => {
2877
3321
  const keep = flyoutName
3322
+ /* 重画之前先记住"这个面板是点开的还是悬停开的":openMenu 里的 closeFlyout()
3323
+ 会把标记清掉,不记下来,拨一次开关后面板就从"钉住"降级成"悬停开启" ——
3324
+ 而重画出来的 DOM 里鼠标并没有真的悬停过,宽限期一到面板就自己关了。
3325
+ 用户看到的现象就是:拨个开关,面板消失(而这正是这段注释上面说不能有的)。 */
3326
+ const keepPinned = flyoutPinned
2878
3327
  const keepIndex = flyoutRow && flyoutRow.dataset ? Number(flyoutRow.dataset.rowIndex) : -1
2879
3328
  openMenu(menuAtX, menuAtY, false) // 重画菜单(会先关掉面板)
2880
3329
  if (!keep || !menuEl) return
2881
3330
  const row = menuEl.querySelectorAll('.wisp-menu-item')
2882
3331
  .find((el) => el.dataset && el.dataset.group === keep)
2883
- if (row) openFlyout(keep, row, Number.isFinite(keepIndex) && keepIndex >= 0 ? keepIndex : Number(row.dataset.rowIndex))
3332
+ if (row) openFlyout(keep, row, Number.isFinite(keepIndex) && keepIndex >= 0 ? keepIndex : Number(row.dataset.rowIndex), keepPinned)
2884
3333
  }
2885
3334
 
2886
3335
  const openFlyout = (name, rowEl, index, pinned) => {
@@ -3010,6 +3459,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3010
3459
  行为开关原本在设置页里,但那页与菜单重叠过大被撤掉了 —— 这三个开关是面板
3011
3460
  真正的净增量,放进菜单比再建一页便宜得多(复用现有的项结构、键盘支持与关闭逻辑)。 */
3012
3461
  const TOGGLES = [
3462
+ /* 帧动画单独一个开关(v1.47.0):它是**画面里唯一会自己动的东西**,
3463
+ 所以"我不想让她动图循环"不该被逼着去动整个动作幅度档。 */
3464
+ { key: 'frame', label: '帧动画' },
3013
3465
  { key: 'wander', label: '自己溜达' },
3014
3466
  { key: 'reactions', label: '跟随状态' },
3015
3467
  { key: 'celebrate', label: '跑完撒花' },
@@ -3120,9 +3572,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3120
3572
  const previewMood = (key) => {
3121
3573
  const row = ACTION_ROWS.find((r) => r.key === key)
3122
3574
  if (!row) return false
3123
- closeDialog()
3124
- /* 被点掉的那个格子随弹窗一起消失了,焦点必须有人接 —— 否则掉进 <body>。 */
3125
- focusHer()
3575
+ /* **不关窗**(v1.47.1):动作一览是"一个一个试"的地方 —— 点一下关一次的话,
3576
+ 想连看三个动作就得重开三次,每次还要重新找位置。焦点留在被点的格子上
3577
+ (弹窗还在,那个格子就还在,所以不用像以前那样把焦点交还给她)。
3578
+ 要关窗仍有三条路:Esc / 右上角 × / 点背景。 */
3126
3579
  previewUntil = nowMs() + 6000
3127
3580
  setMood(row.mood)
3128
3581
  /* 预览必须**自己有到期时间**:预览的姿势可能在"稳态轮询不许覆盖"的名单里
@@ -3154,13 +3607,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3154
3607
  grid.appendChild(cell)
3155
3608
  }
3156
3609
  return openDialog('actions', '动作一览', [
3157
- para('点一个动作,她就当场做给你看。(图都是当前皮肤 ' + skinLabel(activeSkin) + ')'),
3610
+ para('点一个动作,她就当场做给你看 —— 可以连着点,窗不会关。(图都是当前皮肤 ' + skinLabel(activeSkin) + ')'),
3158
3611
  grid,
3159
3612
  dialogFoot('共 ' + ACTION_ROWS.length + ' 个动作', '按 Esc 关闭'),
3160
3613
  ])
3161
3614
  }
3162
3615
 
3163
- const aboutDialog = () => {
3616
+ const aboutDialog = (diagRes) => {
3164
3617
  const note = WHATS_NEW[VERSION]
3165
3618
  /* 菜单里那条「版本与更新」已经收掉了(版本号现在直接显示在菜单头部),
3166
3619
  所以检查更新必须有新的入口 —— 放在这里,上下文正合适。 */
@@ -3173,6 +3626,22 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3173
3626
  closeDialog()
3174
3627
  showUpdate()
3175
3628
  })
3629
+ /* 安装按钮:只在**查到新版**时出现,装的就是那一版(数字与装下去的东西一致)。 */
3630
+ const latest = typeof updateCheck.latest === 'string' ? updateCheck.latest : null
3631
+ const installBtn = document.createElement('button')
3632
+ installBtn.className = 'wisp-dialog-action'
3633
+ installBtn.setAttribute('type', 'button')
3634
+ installBtn.textContent = '安装 v' + String(latest)
3635
+ installBtn.addEventListener('click', (ev) => {
3636
+ ev.preventDefault()
3637
+ closeDialog()
3638
+ installUpdate(latest)
3639
+ })
3640
+ const installedNote = installState.state === 'installed' && installState.target === latest
3641
+ ? para('v' + latest + (installState.application === 'restart-required'
3642
+ ? ' 已经装上了 —— 重启 DSH 后生效。'
3643
+ : ' 已经装上了 —— 刷新页面就是新版。'))
3644
+ : null
3176
3645
  return openDialog('about', '关于她', [
3177
3646
  para('DeepSeek娘 —— DSH 界面上的浮动桌宠。她会跟着你的状态换表情:你打字她在忙,你闲着她在晃,你跑完一轮她替你高兴。'),
3178
3647
  para('她的样子照社区规范来:深蓝色渐变长直发、头顶一个大圆环呆毛、两片鲸鳍耳、女仆装。'),
@@ -3180,9 +3649,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3180
3649
  para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
3181
3650
  para('这一版:' + (note || VERSION)),
3182
3651
  para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
3652
+ /* 上一次检查更新失败时,把宿主看到的东西摆在最显眼的位置 —— 台词只有一句,
3653
+ 而"为什么拿不到"必须能被看到、被复制、被追问。 */
3654
+ diagRes ? para('上次查更新为什么没成:\n' + describeDiag(diagRes)) : null,
3655
+ /* 查到新版就**在这里装**(v1.48.0):装这一步由平台的插件管理器去做,
3656
+ 插件自己不碰网络、也不需要宿主座位(见 installUpdate)。 */
3657
+ installedNote,
3658
+ (updateCheck.state === 'available' && latest !== null && installState.state !== 'installed')
3659
+ ? installBtn
3660
+ : null,
3183
3661
  checkBtn,
3184
3662
  dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
3185
- ])
3663
+ ].filter(Boolean))
3186
3664
  }
3187
3665
 
3188
3666
  /* 菜单的结构 ------------------------------------------------------------
@@ -3305,6 +3783,31 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3305
3783
  }
3306
3784
  }
3307
3785
 
3786
+ /* 焦点环长什么样(CSS 里 .wisp-body:focus 那段说了"为什么不能是矩形"),
3787
+ 这里只回答一个问题:这一次焦点是不是**键盘**给的。
3788
+
3789
+ 鼠标点她也会把焦点交给她(onDown 里的 focusHer —— 为的是"点一下她再按 Enter
3790
+ 开菜单"这条不能断),那一下不该出现任何"被选中"的框:她的表情就是回执。
3791
+ 键盘给的焦点才亮起那圈贴着剪影的辉光 —— 没有它,键盘用户 Tab 到她身上时
3792
+ 屏幕上什么都不会变,而"声明了 role 却看不出焦点在哪"是这一版要避免的另一半。
3793
+
3794
+ 记录必须走**捕获**阶段:focusHer() 是在 pointerdown 的冒泡处理器里调的,
3795
+ 晚一档读到的就还是上一次的输入方式(先点她再按 Esc 会被当成鼠标)。 */
3796
+ let focusByKeyboard = false
3797
+ const syncFocusRing = () => {
3798
+ if (document.activeElement === bodyEl && focusByKeyboard) root.dataset.focus = 'key'
3799
+ else delete root.dataset.focus
3800
+ }
3801
+ /* 输入方式一变就重算一次:她已经拿着键盘焦点时再点她一下,focus() 是空操作
3802
+ (真实 DOM 里不会重复派发 focus 事件),只靠 focus 事件的话,那圈辉光会留在
3803
+ 一个刚被鼠标点过的她身上 —— 而那一下的焦点其实是鼠标给的。 */
3804
+ const noteKeyInput = () => { focusByKeyboard = true; syncFocusRing() }
3805
+ const notePointerInput = () => { focusByKeyboard = false; syncFocusRing() }
3806
+ window.addEventListener('keydown', noteKeyInput, true)
3807
+ window.addEventListener('pointerdown', notePointerInput, true)
3808
+ bodyEl.addEventListener('focus', syncFocusRing)
3809
+ bodyEl.addEventListener('blur', syncFocusRing)
3810
+
3308
3811
  /* keepGroup:点分组是"在原地重开",得把刚设好的分组带过去。
3309
3812
  其他入口都是新手势,一律回到收起状态 —— 所以这个重置放在这里,
3310
3813
  而不是 closeMenu 里(放那里会让展开动作把自己清掉)。 */
@@ -3475,7 +3978,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3475
3978
  if (focused && focused.dataset && focused.dataset.slider === '1'
3476
3979
  && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
3477
3980
  if (typeof ev.preventDefault === 'function') ev.preventDefault()
3478
- const step = ev.shiftKey ? 1 : 0.25
3981
+ const step = ev.shiftKey ? 1 : 0.1
3479
3982
  applyConfig({ size: cfg.size + (ev.key === 'ArrowRight' ? step : -step) })
3480
3983
  /* 只更新滑块自身,不重画整张菜单 —— 焦点和展开状态都不动 */
3481
3984
  try {
@@ -3670,7 +4173,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3670
4173
  applyHitClip(currentSprite)
3671
4174
  /* 动作档位也要当场生效(菜单里的胶囊就靠这一行):它只改一个属性,
3672
4175
  CSS 那边所有幅度规则都挂在它上面。 */
3673
- if (partial && typeof partial === 'object' && partial.motion !== undefined) applyMotion()
4176
+ /* 帧动画开关也走 applyMotion:它只改属性与画面归属,当场生效不用刷新。 */
4177
+ if (partial && typeof partial === 'object'
4178
+ && (partial.motion !== undefined || partial.frame !== undefined)) applyMotion()
3674
4179
  coords.x = clamp(coords.x, 0, maxX())
3675
4180
  coords.y = clamp(coords.y, 0, maxY())
3676
4181
  paint()
@@ -3692,6 +4197,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3692
4197
 
3693
4198
  host 是 shell 注入的名字,不是全局:拿不到就如实说"查不了",不许抛。 */
3694
4199
  let updateCheck = { state: 'idle', at: 0 }
4200
+ /* 上一次真正复制给用户的提示语(doctor 里能看到)—— "她让你装的是哪个" 必须可查,
4201
+ 否则"按包名安装"和"用 GitHub 地址装"这两条路到底走了哪条就没人说得清。 */
4202
+ let lastUpdateHint = UPDATE_HINT
3695
4203
 
3696
4204
  const compareVersions = (a, b) => {
3697
4205
  const parse = (v) => String(v).split('-')[0].split('.').map((n) => {
@@ -3717,12 +4225,58 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3717
4225
  return null
3718
4226
  }
3719
4227
 
4228
+ /* 客户端**自己的**通道 —— 这才是这个插件真正的联网方式。
4229
+
4230
+ 事实(从平台的 dsh-client-modules 加载器读出来的):bundle 插件的客户端半包
4231
+ 是用 <script> 加载的**普通页面脚本**,平台不注入陷阱、不替换全局 —— 所以
4232
+ `window.fetch` 对它可用。而 `host.call` 那个 RPC 座位只发给**动态插件**
4233
+ (runner 里 `hasHostHalf: definition.hostCode !== undefined`),我们这个形态
4234
+ 从来没有过它。
4235
+
4236
+ 以前代码里写着"浏览器半包的 fetch 是陷阱",那是把**预检替身**的契约当成了
4237
+ 平台契约 —— 整个更新检查因此在错误的路上走了很久(真机上永远"没接上宿主")。 */
4238
+ const NPM_LATEST_URL = 'https://registry.npmjs.org/dsh-wisp/latest'
4239
+ const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json'
4240
+ const clientVersionAt = (url) => {
4241
+ try {
4242
+ const fetcher = window.fetch
4243
+ if (typeof fetcher !== 'function') return Promise.resolve(null)
4244
+ return fetcher.call(window, url, { cache: 'no-store' })
4245
+ .then((res) => (res && res.ok === true ? res.json() : null))
4246
+ .then((data) => (data && typeof data.version === 'string' ? data.version : null))
4247
+ .catch(() => null)
4248
+ } catch (e) { return Promise.resolve(null) }
4249
+ }
4250
+
4251
+ /* 客户端侧的完整检查:两个源各取一次,取更高的那个(与宿主侧同一套规则)。 */
4252
+ const clientSideCheck = () => {
4253
+ updateCheck = { state: 'checking', at: nowMs() }
4254
+ return Promise.all([clientVersionAt(NPM_LATEST_URL), clientVersionAt(GITHUB_RAW_URL)])
4255
+ .then(([npmVersion, githubVersion]) => {
4256
+ const sources = {
4257
+ npm: npmVersion ? { ok: true, version: npmVersion } : { ok: false, reason: 'fetch-failed' },
4258
+ github: githubVersion ? { ok: true, version: githubVersion } : { ok: false, reason: 'fetch-failed' },
4259
+ }
4260
+ const cands = [['npm', npmVersion], ['github', githubVersion]].filter((p) => typeof p[1] === 'string')
4261
+ if (cands.length === 0) {
4262
+ updateCheck = { state: 'failed', reason: 'fetch-failed', sources, diag: null, at: nowMs() }
4263
+ publishDiagToTitle(updateCheck)
4264
+ return updateCheck
4265
+ }
4266
+ let best = cands[0]
4267
+ for (const c of cands) if (compareVersions(best[1], c[1]) < 0) best = c
4268
+ updateCheck = compareVersions(VERSION, best[1]) < 0
4269
+ ? { state: 'available', latest: best[1], from: best[0], sources, at: nowMs() }
4270
+ : { state: 'current', latest: best[1], from: best[0], sources, at: nowMs() }
4271
+ return updateCheck
4272
+ })
4273
+ }
4274
+
3720
4275
  const checkForUpdate = () => {
3721
4276
  const seat = hostSeat()
3722
- if (seat === null) {
3723
- updateCheck = { state: 'unsupported', at: nowMs() }
3724
- return Promise.resolve(updateCheck)
3725
- }
4277
+ /* 没有 host 座位不是失败 —— 那是**这个形态的插件的常态**(bundle 插件没有 host RPC)。
4278
+ 客户端自己抓:这才是它本来该走的路。 */
4279
+ if (seat === null) return clientSideCheck()
3726
4280
  updateCheck = { state: 'checking', at: nowMs() }
3727
4281
  return Promise.resolve()
3728
4282
  .then(() => seat.call('checkUpdate', { current: VERSION }))
@@ -3735,8 +4289,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3735
4289
  state: 'failed',
3736
4290
  reason: (res && res.reason) || 'no-result',
3737
4291
  sources: (res && res.sources) || null,
4292
+ /* 宿主看到的诊断也要留下来 —— 失败台词会把它念出来,doctor 里也能查。 */
4293
+ diag: (res && res.diag) || null,
3738
4294
  at: nowMs(),
3739
4295
  }
4296
+ publishDiagToTitle(updateCheck)
3740
4297
  } else if (compareVersions(VERSION, res.latest) < 0) {
3741
4298
  /* from / sources 一起留着:两个源不一致时(npm 暂存、GitHub 已更新),
3742
4299
  "这个数字是哪儿来的"比数字本身更需要能查。 */
@@ -3758,13 +4315,49 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3758
4315
  }
3759
4316
 
3760
4317
  /* ---- 看余额 -------------------------------------------------------------
3761
- 和"检查更新"走同一条路,但服务不同:宿主半包 inject 'deepseekAccount',
3762
- 用平台自己的账户服务取余额。**插件从头到尾不碰 API key** —— 凭据在平台
3763
- 手里,浏览器半包连读都读不到(它没有网络,fetch 是陷阱)。
3764
-
3765
- 未登录、读不到、这个壳里没有那个服务,是三句不同的话:
4318
+ 两条通道读的是**同一个**平台账户服务(凭据始终在宿主手里,插件从头到尾
4319
+ 碰不到 token):
4320
+
4321
+ ① 客户端 Remote —— **默认这条路**。平台自己的「账户」设置页就是这么读的:
4322
+ ctx.remote.account.getBalance(client) → { ok, value }。
4323
+ 1.44.0 的那条教训在这里同样成立:bundle 形态的客户端半包**没有** host RPC
4324
+ 座位,所以只写下面那条路的旧实现,在真机上永远回"这个壳里查不了余额"。
4325
+ ② host 座位 —— 只有动态插件形态才有:host.call('checkBalance'),宿主半包
4326
+ inject 'deepseekAccount'。两条路的**载荷形状完全一样**,所以只在取数那一层
4327
+ 分叉,归一化与台词完全共用。
4328
+
4329
+ 未登录、读不到、这个壳里没有账户服务,是三句不同的话:
3766
4330
  说是"查不到"但其实是"没登录",就是在骗人。 */
3767
4331
  let balanceCheck = { state: 'idle', at: 0 }
4332
+ let balanceVia = null // 上一次真的走了哪条通道:'remote' | 'host'
4333
+
4334
+ /* 每次调用**当场**找服务 —— 客户端服务是后注册的,闭包里存一份就会定格成 null
4335
+ (host 座位那条路上踩过同一个坑)。客户端服务的规矩就是运行时取:
4336
+ ctx.get('theme') / ctx.get('styles') 都是这么拿的,这里照同一条规矩。 */
4337
+ const remoteAccount = () => {
4338
+ const usable = (value) => (value && typeof value.getBalance === 'function' ? value : null)
4339
+ try {
4340
+ if (ctx && typeof ctx.get === 'function') {
4341
+ const direct = usable(ctx.get('remote.account'))
4342
+ if (direct !== null) return direct
4343
+ const parent = ctx.get('remote')
4344
+ const throughParent = usable(parent && parent.account)
4345
+ if (throughParent !== null) return throughParent
4346
+ }
4347
+ } catch (e) { /* 这个壳里没有 remote:换下一条 */ }
4348
+ try {
4349
+ const viaProperty = usable(ctx && ctx.remote && ctx.remote.account)
4350
+ if (viaProperty !== null) return viaProperty
4351
+ } catch (e) { /* 没声明 inject 时读属性会抛 —— 那同样只是"没有" */ }
4352
+ return null
4353
+ }
4354
+
4355
+ /* 账户服务要的是**这次调用**的身份:版本、界面语言、UTC 偏移秒数(向东为正)。 */
4356
+ const accountMetadata = () => ({
4357
+ version: VERSION,
4358
+ locale: LANG_TAG,
4359
+ timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60,
4360
+ })
3768
4361
 
3769
4362
  /* 金额由客户端拼,台词只管口气。货币符号认不出来就原样写代码。 */
3770
4363
  const balanceAmount = (wallets) => {
@@ -3791,12 +4384,85 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3791
4384
  return pick(LINES.balanceFailed)
3792
4385
  }
3793
4386
 
4387
+ /* 宿主半包那条路的载荷 → 状态。 */
4388
+ const balanceStateFrom = (res) => {
4389
+ if (!res || res.ok !== true) {
4390
+ return { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
4391
+ }
4392
+ if (res.status === 'ready') {
4393
+ return {
4394
+ state: 'ready',
4395
+ wallets: Array.isArray(res.wallets) ? res.wallets : [],
4396
+ bonusWallets: Array.isArray(res.bonusWallets) ? res.bonusWallets : [],
4397
+ at: nowMs(),
4398
+ }
4399
+ }
4400
+ if (res.status === 'signed-out' || res.status === 'unavailable') {
4401
+ return { state: res.status, at: nowMs() }
4402
+ }
4403
+ /* 平台答了,答案是 failed —— 是结果,不是异常。 */
4404
+ return { state: 'failed', reason: 'service-failed', at: nowMs() }
4405
+ }
4406
+
4407
+ /* Remote 那条路外面裹着一层信封:{ ok:true, value } | { ok:false, … }。
4408
+ value 与宿主半包同形状,所以先拆信封,剩下的交给上面那张表。
4409
+ value 为 null 时**再问一次 getState()** 分清"没登录"与"刚才没读到":
4410
+ 叫一个已经登录的人去登录,比说"刚才没读到"糟得多。 */
4411
+ const balanceStateFromRemote = (envelope, account) => {
4412
+ if (!envelope || envelope.ok !== true) {
4413
+ return { state: 'failed', reason: (envelope && envelope.reason) || 'no-result', at: nowMs() }
4414
+ }
4415
+ const value = envelope.value
4416
+ if (value !== null && value !== undefined) {
4417
+ if (value.status !== 'ready') return { state: 'failed', reason: 'service-failed', at: nowMs() }
4418
+ return {
4419
+ state: 'ready',
4420
+ wallets: Array.isArray(value.value) ? value.value : [],
4421
+ bonusWallets: Array.isArray(value.bonusWallets) ? value.bonusWallets : [],
4422
+ at: nowMs(),
4423
+ }
4424
+ }
4425
+ return Promise.resolve()
4426
+ .then(() => (account && typeof account.getState === 'function' ? account.getState() : null))
4427
+ .catch(() => null)
4428
+ .then((state) => {
4429
+ const view = state && state.ok === true ? state.value : null
4430
+ const signedIn = view && typeof view.status === 'string' ? view.status === 'credential-stored' : null
4431
+ return { state: signedIn === false ? 'signed-out' : 'unavailable', at: nowMs() }
4432
+ })
4433
+ }
4434
+
4435
+ /* Remote 也不接受 AbortSignal,所以同样只能竞速 —— 一个挂住的调用就是气泡
4436
+ 永远停在"看一下……"。8 秒,与宿主半包那条路同一个数。 */
4437
+ const BALANCE_TIMEOUT_MS = 8000
4438
+ const withDeadline = (promise, ms) => new Promise((resolve, reject) => {
4439
+ let settled = false
4440
+ const limit = typeof ms === 'number' && ms > 0 ? ms : BALANCE_TIMEOUT_MS
4441
+ const handle = clock.after(limit, () => {
4442
+ if (settled) return
4443
+ settled = true
4444
+ reject(new Error('timeout'))
4445
+ })
4446
+ const finish = (fn, value) => {
4447
+ if (settled) return
4448
+ settled = true
4449
+ if (handle) clock.cancel(handle)
4450
+ fn(value)
4451
+ }
4452
+ Promise.resolve(promise).then(
4453
+ (value) => finish(resolve, value),
4454
+ (error) => finish(reject, error),
4455
+ )
4456
+ })
4457
+
3794
4458
  /* 按需查:菜单里那一行和 `__wisp.checkBalance()` 都走这里。
3795
4459
  它**自己说话**(先"看一下……"再报结果)—— 菜单点完就关了,留一个
3796
4460
  只返回状态、不出声的函数,用户会以为没反应。 */
3797
4461
  const checkBalance = () => {
3798
- const seat = hostSeat()
3799
- if (seat === null) {
4462
+ const account = remoteAccount()
4463
+ const seat = account === null ? hostSeat() : null
4464
+ if (account === null && seat === null) {
4465
+ balanceVia = null
3800
4466
  balanceCheck = { state: 'unsupported', at: nowMs() }
3801
4467
  wake(true)
3802
4468
  say(balanceLine(balanceCheck))
@@ -3805,25 +4471,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3805
4471
  balanceCheck = { state: 'checking', at: nowMs() }
3806
4472
  wake(true)
3807
4473
  say(pick(LINES.balanceChecking))
3808
- return Promise.resolve()
3809
- .then(() => seat.call('checkBalance', { locale: LANG_TAG }))
3810
- .then((res) => {
3811
- if (!res || res.ok !== true) {
3812
- balanceCheck = { state: 'failed', reason: (res && res.reason) || 'no-result', at: nowMs() }
3813
- } else if (res.status === 'ready') {
3814
- balanceCheck = {
3815
- state: 'ready',
3816
- wallets: Array.isArray(res.wallets) ? res.wallets : [],
3817
- bonusWallets: Array.isArray(res.bonusWallets) ? res.bonusWallets : [],
3818
- at: nowMs(),
3819
- }
3820
- } else if (res.status === 'signed-out' || res.status === 'unavailable') {
3821
- balanceCheck = { state: res.status, at: nowMs() }
3822
- } else {
3823
- /* 平台答了,答案是 failed —— 是结果,不是异常。 */
3824
- balanceCheck = { state: 'failed', reason: 'service-failed', at: nowMs() }
3825
- }
3826
- say(balanceLine(balanceCheck))
4474
+ balanceVia = account !== null ? 'remote' : 'host'
4475
+ const request = account !== null
4476
+ ? withDeadline(Promise.resolve()
4477
+ .then(() => account.getBalance(accountMetadata()))
4478
+ .then((envelope) => balanceStateFromRemote(envelope, account)))
4479
+ : Promise.resolve()
4480
+ .then(() => seat.call('checkBalance', { locale: LANG_TAG }))
4481
+ .then((res) => balanceStateFrom(res))
4482
+ return request
4483
+ .then((next) => {
4484
+ balanceCheck = next
4485
+ say(balanceLine(next))
3827
4486
  return balanceCheck
3828
4487
  })
3829
4488
  .catch((err) => {
@@ -3838,17 +4497,73 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3838
4497
  })
3839
4498
  }
3840
4499
 
4500
+ /* 版本号来自 GitHub,而 npm 上那份**不是同一个号**(通常是 npm 暂存或还没发)——
4501
+ 这时候"按包名安装"会把人送回旧版。 */
4502
+ const githubOnlyNewer = (res) => {
4503
+ if (!res || res.state !== 'available' || res.from !== 'github') return false
4504
+ const npm = res.sources && res.sources.npm
4505
+ return !!(npm && npm.ok === true && npm.version !== res.latest)
4506
+ }
4507
+
3841
4508
  const updateLine = (res) => {
3842
4509
  if (res.state === 'available') return pick(LINES.updateAvailable) + res.latest
3843
4510
  if (res.state === 'current') return pick(LINES.updateCurrent)
3844
4511
  /* 失败要说清是哪一种:没有通道 / 连不上 / 宿主没应答。
3845
4512
  `no-web-service` 是"这个壳根本没有联网入口",不是"网络不好"——
3846
4513
  以前两者共用一句"没查到更新",于是诊断只能靠猜。 */
3847
- if (res.state === 'unsupported' || res.reason === 'no-web-service') return pick(LINES.updateNoChannel)
4514
+ /* 失败要说清是哪一种 —— 它们是链路上**不同的一环**断了:
4515
+ `unsupported`:客户端这侧连 `host.call` 都没有(第一环);
4516
+ `no-web-service`:宿主跑了,但它拿不到 `web` 服务(第三环);
4517
+ `fetch-failed`:服务拿到了,两个源都连不上(第四环)。 */
4518
+ if (res.state === 'unsupported') return pick(LINES.updateNoSeat)
4519
+ if (res.reason === 'no-web-service') return pick(LINES.updateNoChannel)
3848
4520
  if (res.reason === 'call-failed') return pick(LINES.updateNoHost)
3849
4521
  return pick(LINES.updateFailed)
3850
4522
  }
3851
4523
 
4524
+ /* 技术的部分**不进台词**(台词是给人读的一句话,塞进去就是一串英文碎片),
4525
+ 但必须看得见 —— 失败时把宿主实际看到的排进「关于她」弹窗:
4526
+ 取服务的方式、拿到的东西是什么形状、兜底通道为什么也不行。
4527
+ 这是"她说查不了"和"我知道为什么"之间的那一步。 */
4528
+ /* 诊断的**临时出口**:把"宿主看见了什么"写进窗口标题。
4529
+ Agent 侧读一下窗口标题就能拿到(不用截图、不用控制台、不用用户复制)——
4530
+ 这台机器上的断点已经卡了两轮,值得开一个临时通道把它钉死。
4531
+ 120 秒后自动还原;定位到问题后这一条会撤掉。 */
4532
+ let diagTitleTimer = null
4533
+ const publishDiagToTitle = (res) => {
4534
+ try {
4535
+ if (typeof document === 'undefined' || !document || typeof document.title !== 'string') return
4536
+ const diag = (res && res.diag) || {}
4537
+ const short = [
4538
+ 'wisp-diag',
4539
+ String((res && res.state) || '?'),
4540
+ String((res && res.reason) || '-'),
4541
+ 'via=' + String(diag.via || '-'),
4542
+ 'how=' + String(diag.how || '-'),
4543
+ 'why=' + String(diag.why || '-'),
4544
+ 'mgr=' + String(diag.manager || '-'),
4545
+ ].join(' ')
4546
+ const original = document.title
4547
+ document.title = short
4548
+ if (diagTitleTimer !== null) clock.cancel(diagTitleTimer)
4549
+ diagTitleTimer = clock.timeout(() => {
4550
+ try { document.title = original } catch (e) { /* ignore */ }
4551
+ diagTitleTimer = null
4552
+ }, 120000)
4553
+ } catch (e) { /* 标题写不了不该影响任何别的功能 */ }
4554
+ }
4555
+
4556
+ const describeDiag = (res) => {
4557
+ const diag = res && res.diag
4558
+ if (!diag || typeof diag !== 'object') return '这次连诊断信息都没有拿到。'
4559
+ const lines = ['通道:' + String(diag.via || '未知')]
4560
+ if (diag.how) lines.push('取服务的方式:' + String(diag.how))
4561
+ if (diag.why) lines.push('看到的东西:' + String(diag.why))
4562
+ if (diag.registry) lines.push('插件管理器的 registry:' + String(diag.registry))
4563
+ if (diag.manager) lines.push('兜底通道:' + String(diag.manager))
4564
+ return lines.join('\n')
4565
+ }
4566
+
3852
4567
  /* 把包名复制到剪贴板,便于在 DSH 的「按插件包名安装」里粘贴。
3853
4568
  没有剪贴板权限(或不是安全上下文)时**静默降级**成只说话 —— 不该因此报错。 */
3854
4569
  const copyText = (text) => {
@@ -3868,6 +4583,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3868
4583
  用户点这一项想知道的就是这两件事。 */
3869
4584
  const showUpdate = () => {
3870
4585
  const note = WHATS_NEW[VERSION]
4586
+ let hint = UPDATE_HINT
3871
4587
  const copied = copyText('dsh-wisp')
3872
4588
  /* 一步到位:先把"我是谁、这一版做了什么"说出来,同时去查有没有新版;
3873
4589
  查回来再用一句话覆盖掉 —— 一次只冒一个气泡,所以是"说完结果就结束"。 */
@@ -3878,12 +4594,135 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3878
4594
  started.then((res) => {
3879
4595
  if (destroyed) return
3880
4596
  if (res.state === 'checking') return
4597
+ /* 新版只在 GitHub 上 → 改口 + 把仓库地址放进剪贴板(包名会装回旧版)。 */
4598
+ if (githubOnlyNewer(res)) {
4599
+ hint = UPDATE_HINT_GITHUB
4600
+ lastUpdateHint = hint
4601
+ copyText(UPDATE_REPO)
4602
+ }
3881
4603
  flashHappy(base + updateLine(res))
4604
+ /* 查到新版就把弹窗摆出来 —— 里面那个「安装 vX」按钮就是这一步的下一步(v1.48.0)。
4605
+ 查不成的时候同样开:把"为什么"摆在最显眼的位置(台词只留一句人话)。 */
4606
+ if (res.state === 'failed') aboutDialog(res)
4607
+ else if (res.state === 'available') aboutDialog()
3882
4608
  })
3883
4609
  }
4610
+ lastUpdateHint = hint
3884
4611
  return { version: VERSION, note: note || null, copied, checking: true }
3885
4612
  }
3886
4613
 
4614
+ /* ---- 装上最新版(v1.48.0)-------------------------------------------------
4615
+ 平台把**插件管理器本身**也做成了 Remote 命名空间:`remote.pluginManager`
4616
+ (installBundle / waitForInstall / cancelInstall / listPlugins …)—— 平台自己的
4617
+ 「插件」设置页用的就是它。所以「检查更新只能告诉你、装不了」是旧结论:客户端半包
4618
+ 虽然没有 host 座位(1.44.0 那条教训),但这条路**不需要座位** ——
4619
+ 它是平台给客户端开的正式通道,装这一步由平台带着 pnpm 与 profile 的写权限去做。
4620
+
4621
+ 装的是**刚才查到的那一版**(spec = dsh-wisp@<version>),不是 latest ——
4622
+ 用户看到的数字和装下去的东西必须是同一个。装完要不要重启由平台说了算
4623
+ (ChangeResult.application),我们照实转述,不自作聪明。 */
4624
+ const UPDATE_PACKAGE = 'dsh-wisp'
4625
+ /* 下载 + 落盘 + 链接可能很慢,所以给一个**宽松的**截止时间。到点了不谎报失败 ——
4626
+ 只说「动作已经发出去了、还没回来」(安装本身不会被我们取消)。 */
4627
+ const INSTALL_TIMEOUT_MS = 180000
4628
+ let installState = { state: 'idle', at: 0 }
4629
+
4630
+ const pluginManagerRemote = () => {
4631
+ const usable = (value) => (value && typeof value.installBundle === 'function' ? value : null)
4632
+ try {
4633
+ if (ctx && typeof ctx.get === 'function') {
4634
+ const direct = usable(ctx.get('remote.pluginManager'))
4635
+ if (direct !== null) return direct
4636
+ const parent = ctx.get('remote')
4637
+ const nested = usable(parent && parent.pluginManager)
4638
+ if (nested !== null) return nested
4639
+ }
4640
+ } catch (e) { /* 这个壳里没有 remote:换下一条 */ }
4641
+ try {
4642
+ const viaProperty = usable(ctx && ctx.remote && ctx.remote.pluginManager)
4643
+ if (viaProperty !== null) return viaProperty
4644
+ } catch (e) { /* 没声明 inject 时读属性会抛 —— 那同样只是「没有」 */ }
4645
+ return null
4646
+ }
4647
+
4648
+ /* 台词里写**版本号**,不写 spec:平台回来的 target 是 `dsh-wisp@1.48.0`,
4649
+ 直接塞进句子里就成了「装好了:vdsh-wisp@1.48.0」—— 数字对,话不对。 */
4650
+ const installVersion = (res) => {
4651
+ if (typeof res.version === 'string' && res.version !== '') return res.version
4652
+ const target = String(res.target ?? '')
4653
+ const at = target.lastIndexOf('@')
4654
+ return at > 0 ? target.slice(at + 1) : target
4655
+ }
4656
+
4657
+ const installLine = (res) => {
4658
+ if (res.state === 'installed') {
4659
+ return pick(res.application === 'restart-required' ? LINES.installRestart : LINES.installDone)
4660
+ .split('{v}').join(installVersion(res))
4661
+ }
4662
+ if (res.state === 'pending') return pick(LINES.installPending)
4663
+ if (res.state === 'cancelled') return pick(LINES.installCancelled)
4664
+ if (res.state === 'unsupported') return pick(LINES.installUnsupported)
4665
+ if (res.reason === 'no-matching-version') {
4666
+ return pick(LINES.installNotPublished).split('{v}').join(installVersion(res))
4667
+ }
4668
+ return pick(LINES.installFailed)
4669
+ }
4670
+
4671
+ const installUpdate = (version) => {
4672
+ const target = typeof version === 'string' && version !== ''
4673
+ ? version
4674
+ : (typeof updateCheck.latest === 'string' ? updateCheck.latest : null)
4675
+ const manager = pluginManagerRemote()
4676
+ if (manager === null) {
4677
+ installState = { state: 'unsupported', at: nowMs() }
4678
+ wake(true)
4679
+ say(installLine(installState))
4680
+ return Promise.resolve(installState)
4681
+ }
4682
+ if (target === null) {
4683
+ installState = { state: 'failed', reason: 'no-version', at: nowMs() }
4684
+ wake(true)
4685
+ say(installLine(installState))
4686
+ return Promise.resolve(installState)
4687
+ }
4688
+ installState = { state: 'installing', target, at: nowMs() }
4689
+ wake(true)
4690
+ setMood('report')
4691
+ say(pick(LINES.installChecking).split('{v}').join(target))
4692
+ return withDeadline(
4693
+ Promise.resolve().then(() => manager.installBundle(UPDATE_PACKAGE + '@' + target)),
4694
+ INSTALL_TIMEOUT_MS,
4695
+ )
4696
+ .then((envelope) => {
4697
+ if (!envelope || envelope.ok !== true) {
4698
+ const code = envelope && envelope.error && typeof envelope.error.code === 'string' ? envelope.error.code : null
4699
+ installState = { state: 'failed', reason: code || 'refused', target, at: nowMs() }
4700
+ } else {
4701
+ const result = envelope.value && typeof envelope.value === 'object' ? envelope.value : {}
4702
+ const application = typeof result.application === 'string' ? result.application : 'unknown'
4703
+ const code = result.error && typeof result.error.code === 'string' ? result.error.code : null
4704
+ installState = {
4705
+ state: application === 'failed' ? 'failed' : (application === 'cancelled' ? 'cancelled' : 'installed'),
4706
+ application,
4707
+ reason: code,
4708
+ version: target,
4709
+ target: typeof result.target === 'string' ? result.target : target,
4710
+ at: nowMs(),
4711
+ }
4712
+ }
4713
+ say(installLine(installState))
4714
+ return installState
4715
+ })
4716
+ .catch((err) => {
4717
+ const detail = String(err && err.message ? err.message : err)
4718
+ installState = detail === 'timeout'
4719
+ ? { state: 'pending', target, at: nowMs() }
4720
+ : { state: 'failed', reason: 'call-failed', detail, target, at: nowMs() }
4721
+ say(installLine(installState))
4722
+ return installState
4723
+ })
4724
+ }
4725
+
3887
4726
  const api = {
3888
4727
  version: VERSION,
3889
4728
  clock: clock.mode,
@@ -3906,6 +4745,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3906
4745
  setSkin: applySkin,
3907
4746
  showUpdate,
3908
4747
  checkForUpdate,
4748
+ installUpdate,
3909
4749
  /* 弹窗与折叠分组:菜单结构变了之后,测试要能问"现在开着哪个弹窗 / 哪个分组"。 */
3910
4750
  /* 专注计时器:脚本与测试都能驱动 */
3911
4751
  startFocus,
@@ -4006,9 +4846,13 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
4006
4846
  error: { selector: ERROR_SELECTOR, matches: probe(ERROR_SELECTOR) },
4007
4847
  },
4008
4848
  dark: !!(body && body.hasAttribute('data-ds-dark-theme')),
4009
- /* 余额:最后一次查的结果 + 有没有 host 座位。**凭据从不经过这里** ——
4010
- 插件拿不到 key,也就不可能被谁从 doctor() 里读走。 */
4011
- balance: Object.assign({ hostSeat: hostSeat() !== null }, balanceCheck),
4849
+ /* 余额:最后一次查的结果 + 两条通道各自在不在 + 这一次真的走了哪条。
4850
+ **凭据从不经过这里** —— 插件拿不到 token(更别说 API key),
4851
+ 也就不可能被谁从 doctor() 里读走。 */
4852
+ balance: Object.assign(
4853
+ { hostSeat: hostSeat() !== null, remoteSeat: remoteAccount() !== null, via: balanceVia },
4854
+ balanceCheck,
4855
+ ),
4012
4856
  /* 动作层的实时状态。姿势角是**屏幕坐标**里的角度(未乘镜像),
4013
4857
  accent 是当下挂着的一次性动作 —— "她为什么歪着/弹了一下"
4014
4858
  在 Console 里一眼能看出来,不用去读样式。 */
@@ -4020,6 +4864,29 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
4020
4864
  tiltLocal: tiltWritten,
4021
4865
  accent: root.dataset.accent || null,
4022
4866
  pressed: root.dataset.dragging === 'true',
4867
+ /* 帧动画(v1.45.0;v1.46.1 起是 <img> 动图):哪个状态有素材、元素建了没、
4868
+ 现在在不在画面上、冻没冻住。它的失败是**静默降级**(画面回到静态立绘),
4869
+ 所以"她怎么不动了"必须能在这里一眼看出来,而不是靠读代码。 */
4870
+ frame: {
4871
+ /* 这个状态**能不能**播、播的是哪一段:查得到才算数。只报 MOTION_OF 的话,
4872
+ "这一段素材不在包里"会显示成一个根本不存在的素材名(而画面早就是立绘了)。 */
4873
+ enabled: cfg.frame !== false,
4874
+ asset: typeof MOTION[motionKeyFor(mood)] === 'string' ? motionKeyFor(mood) : null,
4875
+ /* 这一刻的素材是**从哪来的**(v1.47.0):外置时它是一个
4876
+ `/wisp-motion/<文件>` URL,内联时是 data URI。"她怎么不动"
4877
+ 的第一个分岔就在这里 —— 是没接上路由,还是这一段真的坏了。 */
4878
+ src: motionImg === null || typeof motionImg.src !== 'string' ? null : motionImg.src,
4879
+ built: motionImg !== null,
4880
+ /* 动图没有"在播"这个可读状态(<img> 上没有 paused),所以这里报的是
4881
+ **它是不是正在画面上** —— 也就是"这一刻画面归谁"的事实。 */
4882
+ showing: motionImg !== null && motionImg.style.display !== 'none',
4883
+ frozen: motionFrozen(),
4884
+ failed: motionFailed,
4885
+ /* 画面归谁(v1.45.2):true = 帧动画在接管,静态立绘已退场;
4886
+ false = 立绘就是画面里那个人(没有素材 / 冻结 / 加载失败)。
4887
+ "两层同时可见"就是重影,所以这件事必须能在这里一眼看出来。 */
4888
+ spriteHidden: root.dataset.frame === 'on',
4889
+ },
4023
4890
  },
4024
4891
  tokens,
4025
4892
  /* 窗口背景:Mica 能不能露出来,取决于页面有没有在背景处画不透明像素。
@@ -4040,11 +4907,19 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
4040
4907
  version: VERSION,
4041
4908
  whatsNew: WHATS_NEW[VERSION] ?? null,
4042
4909
  seenBefore: readStored(SEEN_VERSION_KEY),
4043
- hint: UPDATE_HINT,
4044
- canSelfUpdate: false,
4910
+ hint: lastUpdateHint,
4911
+ /* v1.48.0 起是 true —— 但要说清"她怎么做到的":她自己依然没有网络、没有 Node
4912
+ (宿主半包在 vm 沙箱里,harness 只有 defineTool/registerTool/handle),
4913
+ **装**这一步是交给平台的插件管理器(remote.pluginManager)去做的。 */
4914
+ canSelfUpdate: true,
4045
4915
  /* 上一次检查的结果(idle / checking / current / available / failed / unsupported) */
4046
4916
  lastCheck: { ...updateCheck },
4047
- why: '宿主半包在 vm 沙箱里,harness 只有 defineTool/registerTool/handle,Node API 被 nodeApiTraps 挡住',
4917
+ /* 安装这条路:平台那个 Remote 命名空间在不在 + 最后一次装的结果 */
4918
+ install: Object.assign(
4919
+ { via: pluginManagerRemote() !== null ? 'remote.pluginManager' : null, package: UPDATE_PACKAGE },
4920
+ installState,
4921
+ ),
4922
+ why: '她自己没有网络也没有 Node(宿主半包在 vm 沙箱里),但**装**不用她做:平台的插件管理器是客户端可用的 Remote 命名空间(remote.pluginManager),她把版本号交给它,由它带着 pnpm 与 profile 写权限去装',
4048
4923
  },
4049
4924
  /* 干饭:不用猜"她为什么突然捧碗" */
4050
4925
  hunger: { enabled: cfg.hungry === true, everyMs: cfg.hungerMs, lastMealAt: Math.round(lastMealAt) },
@@ -4128,10 +5003,23 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
4128
5003
  bodyEl.removeEventListener('pointerdown', onDown)
4129
5004
  bodyEl.removeEventListener('keydown', onBodyKey)
4130
5005
  bodyEl.removeEventListener('contextmenu', onContext)
5006
+ bodyEl.removeEventListener('focus', syncFocusRing)
5007
+ bodyEl.removeEventListener('blur', syncFocusRing)
5008
+ window.removeEventListener('keydown', noteKeyInput, true)
5009
+ window.removeEventListener('pointerdown', notePointerInput, true)
4131
5010
  closeMenu()
4132
5011
  closeDialog()
4133
5012
  if (dialogKeyOff) { dialogKeyOff(); dialogKeyOff = null }
4134
5013
  hideTab()
5014
+ /* 帧动画自己收:动图在 detach 之后没有"停"这个动作可喊(<img> 上没有
5015
+ pause()),能做的就是把它从画面上拿掉 —— 隐藏 + 紧接着摘掉整个 layer,
5016
+ 不再有一层看不见的循环在解码。系统设置的订阅也要摘掉(这条泄漏过一次,
5017
+ 见 onVisibility 那段的教训)。 */
5018
+ hideMotion()
5019
+ if (typeof motionMqlOff === 'function') {
5020
+ try { motionMqlOff() } catch (e) { /* ignore */ }
5021
+ motionMqlOff = null
5022
+ }
4135
5023
  if (typeof offTheme === 'function') {
4136
5024
  try { offTheme() } catch (e) { /* ignore */ }
4137
5025
  offTheme = null