🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

浮游桌宠 969246694/dsh-wisp

DeepSeek Harnessv1.56.0
96924669422 天前有提交已提供中文文档扫描:含敏感能力

DeepSeek娘 — 非官方的 DeepSeek Harness 界面悬浮桌宠。零依赖,精灵图内联,代码 MIT、美术素材 CC BY-NC-SA。

30.8/100
综合分
1
GitHub 星标
191
npm 周下载
—/5
用户评分

数据截至 2026/10/13 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add dsh-wisp
npm 源 · dsh-wisp 的发布者账号已校验与本仓库一致

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:已验证
是什么[目录]—
dsh 原生插件 · ui
暂无时间戳·判据 →
装得上吗[实装验证]✓
本站已在 dsh 0.2.1-rc.2 下真实安装成功
非静态推断
L4 · 真实安装等级说明 →
数据截至 2026/10/27·判据 →
安全吗[自动扫描]—
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
数据截至 2026/11/04·判据 →
还在维护吗[仓库]✓
活跃:最近一次提交在 22 天前
暂无时间戳·判据 →
图例:✓ 通过! 需注意— 未判定 / 无数据· 「未判定」= 本站尚无数据,不代表插件有问题
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →

可信度证据

安装兼容性检查✓ 自动检查通过
展开查看检查明细 ↓

以下结论由程序自动检查 npm 包、engines 声明与入口文件得出,未做人工实机验证——能装不等于用着没问题。

✓npm 包dsh-wisp @ 1.56.0
✓Node 引擎未声明 engines.node
✓dsh CLI 依赖未声明 dsh 版本约束
✓入口文件main/exports/bin 已声明

验证方式:npm registry 存在性 + package.json 静态校验 · 最后检查 2026/10/12 17:39:47

安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件发起外部网络请求
查看扫描证据(共 60 处,展示前 17 条)
  • 读取本地文件
    lib/index.js:231
    const stat = fs.statSync(absolutePath)
  • 读取本地文件
    lib/index.js:312
    const info = await fs.stat(target)
  • 读取本地文件
    lib/index.js:609
    return new Uint8Array(fs.readFileSync(absolutePath))
  • 发起网络请求
    lib/index.js:773
    const res = await web.fetch({ url }, signal)
  • 读取本地文件
    build.mjs:106
    if (!existsSync(assetsDir)) {
  • 读取本地文件
    build.mjs:115
    if (existsSync(p)) return p
  • 读取本地文件
    build.mjs:128
    const skinIds = readdirSync(assetsDir, { withFileTypes: true })
  • 读取本地文件
    build.mjs:145
    if (existsSync(audioDir)) {
  • 读取本地文件
    build.mjs:147
    for (const name of readdirSync(audioDir).sort()) {
  • 读取本地文件
    build.mjs:150
    sounds[name.slice(0, name.lastIndexOf('.'))] = `data:${MIME[ext]};base64,${readFileSync(join(audioDir, name)).toString('base64')}`
  • 写入本地文件
    build.mjs:373
    writeFileSync(outPath, out, 'utf8')
  • 读取本地文件
    verify-wisp.mjs:40
    const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
  • 读取本地文件
    verify-wisp.mjs:51
    const shippedClips = (prefix) => (existsSync(MOTION_DIR)
  • 读取本地文件
    verify-wisp.mjs:52
    ? readdirSync(MOTION_DIR).filter((f) => new RegExp(`^${prefix}[a-z]+\\.webp$`).test(f)).sort()
  • 读取本地文件
    verify-wisp.mjs:601
    if (!existsSync(hostPath)) {
  • 读取本地文件
    verify-wisp.mjs:604
    const src = readFileSync(hostPath, 'utf8')
  • 读取本地文件
    verify-wisp.mjs:704
    const disk = idle !== null && existsSync(idle) ? readFileSync(idle) : null
扫描于 2026/11/4(0 天前) · 范围 入口闭包 + 全包扫描 · 来源 npm 发布包

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

社区实装还没有人打卡 · 装过的第一个就是你
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 浮动桌宠陪伴
  • 零依赖内联精灵图
v0.6.02026-09-27
· dsh-wisp 0.6.0 DeepSeek娘 — 非官方桌面悬浮伴侣,为 DeepSeek Harness 界面而生。 · 安装 从 npm 安装(无需访问 GitHub)— 把安装源设为 中国大陆镜像源(registry.npmmirror.com),包名用: dsh-wisp 从 GitHub 安装: plugin_manager install_bundle · 0.6.0 新增内容 · 空闲漫步。空闲且没有智能体运行时,她每隔 wanderMs 考虑一次散步,用 CSS 过渡滑到 wanderRange 内的随机点,而不是瞬移。被拖动、睡着或忙碌时她原地不动。散步刻意不写入保存的位置——记住的是你把她放在哪。 · 右键回家。在 560x840 下她很容易被拖出视野,以前恢复位置得打开控制台。现在右键点她,就回到默认的右下角。 · 被放下后有 35% 概率说点什么。 · 预检增至 108 项(漫步范围、滑行类、保存位置不被改动、睡着时原地不动、右键回家、禁用即禁用)。 · 许可 代码为 MIT。角色美术为 CC BY-NC-SA 4.0。两者都写在 NOTICE.md 里。这是非官方粉丝项目,与 DeepSeek 无关联。
数据来自仓库的 GitHub Release,可能与插件实际版本不一致。

README

由 DeepSeek 最新模型翻译生成
dsh-wisp DeepSeek Harness Web 界面的浮动陪伴插件:DeepSeek娘 桌宠。 当前版本 1.56.0 · 零依赖 · 单文件客户端半包(精灵图内嵌为 data URI;帧动画素材自 1.47.0 起由宿主半包按 URL 发,客户端里只留一张文件名清单) 非官方插件,与 DeepSeek(深度求索)官方无关。角色形象与图片许可见 NOTICE.md。 它做什么 | 行为 | 触发条件 | | 待机 | 站着时放 idle 那一段帧动画( 动图 WebP,站着时那点极小的起伏),静态立绘这时退场。「静止」档与系统的「减少动态效果」会把它藏起来、把画面交回静态立绘(v1.46.5 起;在那之前站立是一张一动不动的立绘) | | idle 青色 | 默认 | | alert 琥珀色 | Agent 正在跑(出现「停止生成」按钮)或 输入框里有字 | | happy 粉色 | 戳她一下 / 双击 / 一轮跑完 | | sleep 灰色冒 z | 静置 sleepAfterMs(默认 90 秒);任意输入唤醒 | | attn 举手招你过来 + 暖红光晕 | 有东西在等你处理:Agent 提问 / 计划待确认 / 工具授权待批准。
展开完整文档(README 剩余 1 节)
dsh-wisp

DeepSeek Harness Web 界面的浮动陪伴插件:DeepSeek娘 桌宠。

当前版本 1.56.0 · 零依赖 · 单文件客户端半包(精灵图内嵌为 data URI;帧动画素材自 1.47.0 起由宿主半包按 URL 发,客户端里只留一张文件名清单)

非官方插件,与 DeepSeek(深度求索)官方无关。角色形象与图片许可见 NOTICE.md。

它做什么

| 行为 | 触发条件 |
| 待机 | 站着时放 idle 那一段帧动画( 动图 WebP,站着时那点极小的起伏),静态立绘这时退场。「静止」档与系统的「减少动态效果」会把它藏起来、把画面交回静态立绘(v1.46.5 起;在那之前站立是一张一动不动的立绘) |
| idle 青色 | 默认 |
| alert 琥珀色 | Agent 正在跑(出现「停止生成」按钮)或 输入框里有字 |
| happy 粉色 | 戳她一下 / 双击 / 一轮跑完 |
| sleep 灰色冒 z | 静置 sleepAfterMs(默认 90 秒);任意输入唤醒 |
| attn 举手招你过来 + 暖红光晕 | 有东西在等你处理:Agent 提问 / 计划待确认 / 工具授权待批准。优先于其余一切状态,睡着了也会被叫起来 |
| 她记着你 | 她会记住一起多少天、你常把她放在哪个角、常让她穿哪套 —— 够格的时候说出来(第 2/7/14/30/100 天;某个角用过 ≥8 次且占多数;某套皮肤换过 ≥6 次)。同一条一辈子只说一次、一天最多一句 |
| 今日里程碑 | 她数着今天跑完多少轮(跨刷新记着,按天归零):到 10 / 25 / 50 / 100 / 200 / 500 时会用得意的表情报出数字。同一个里程碑一天只说一次 |
| 久坐提醒 | 连续活动到 careAfterMs(默认 90 分钟)她会抬手指你一下、说一句叫你起来动动。中间停顿不到 90 秒不算离开;睡着时会被叫醒再提醒;0 = 关 |
| 时段招呼 | 挂载时的问候按当前时段取:早上 / 下午 / 晚上 / 凌晨四套池子,口气不同(__wisp.doctor() 会报当前用的是哪一套) |
| 滚轮调大小 | Ctrl/⌘ + 滚轮在她身上滚 = 放大/缩小一档(0.25)。普通滚轮照常滚动页面 —— 她占地不小,直接吞掉滚轮等于在她身上把页面滚动禁掉 |
| 键盘挪位置 | 她拿到焦点后方向键微调 16px(Shift = 4px),与拖动共用同一套边界夹紧与位置记忆。在这之前挪动她只能靠鼠标拖 |
| 躲起来之后 | 迷你小按钮右键也能打开同一个菜单;菜单里那一项是开关 —— 躲着时显示「回来吧」,在着时显示「先躲起来」 |
| 右键菜单 | 右键她本体弹出四段:动作(说句话 / 放大一点 / 缩小一点 / 先躲起来 / 睡一会儿)+ 行为(自己溜达 / 跟随状态 / 跑完撒花,带勾)+ 位置(四个角落)+ 皮肤。Esc、点别处、滚动都会关掉,↑↓ 可选 |
| 出错反应 | 一轮跑完时如果出现失败行([data-error="true"]),她会切到 worried 表情说一句「好像不太顺。」,3.5 秒后恢复。同一批错误只说一次 |
| 先躲起来 | 菜单里选「先躲起来」后她整个消失,只在右下角留一个 56px 迷你按钮(用当前皮肤的图),点一下回来。状态记在本地,刷新后仍然躲着 |
| 拖动 | 按住本体拖动,带边界约束;松手即记住位置,下次刷新回到原处 |
| 说话 | 挂载问候、点击、睡着、醒来、跑完各有一组台词 |
| 按轮廓命中 | 只有她自己的像素可点:命中层带一层由 alpha 蒙版生成的 clip-path,透明处整层穿透到下面的应用(hover / 点击 / 右键 / 滚轮全都不受影响)。抓着光标也只在她的轮廓上出现 |
| 看余额 | 菜单「行为 → 看看余额」(或 __wisp.checkBalance())。按需查,不轮询 —— 稳态开销那条承诺不破。数字来自平台自己的账户服务(客户端走 remote.account Remote,动态插件形态走 host 座位),插件从头到尾不碰凭据;未登录 / 刚没读到 / 这个壳里没有该服务,是三句不同的话 |
| 按下有反馈 | 按住她 → 身体压一下再弹回来(按下那一刻就有反应,不用等松手) |
| 拖着有重量 | 拖动中她朝运动的反方向侧倾(最多 6°,跟着平滑后的速度走),手一停就自己回正;松手时顿一下再站稳。位置无过渡跟手,倾角那层留 0.16s 吸抖 |
| 她会注意你的指针 | 指针进到 170px 以内,她朝指针方向侧身(最多 3.5°,越近越多),出圈回正。睡着时不跟 |
| 换表情会动 | happy / poked / attn 等有表情的状态切换时弹一下;同一个表情再来一次也弹(第二下不出声会被读成卡住) |
| 溜达有方向 | 自己踱步时按行进方向决定镜像(不会倒着走)+ 朝那边微微前倾(最多 2.5°),像「往那边去」而不是「被平移过去」 |
| 动作幅度可调 | 菜单「行为 → 动作幅度」三选一:灵动 / 克制(半幅)/ 静止。静止 连呼吸、z 与帧动画一起停 |
| 帧动画开关 | 菜单「行为 → 帧动画」一个开关:关掉后画面永远交给静态立绘,其余动作照旧(这是它和 静止 档的分工)。默认开;配置键 frame |
| 睡着时会动 | sleep 上叠另一段 动图 WebP(,她睡着时被子那点极小的起伏)。素材按皮肤点(每条都是 720×1280、24fps;帧预算默认 97 帧=4.04 秒,另有声明的例外):深海女仆三条(deepsea_idle / deepsea_happy / deepsea_sleepy,v1.49.3)、泳装八条、原版八条;查不到的皮肤+状态就是没有动画(连 都不建)。换状态时同一个 换源,不重建元素。「静止」档与系统的「减少动态效果」会把动图藏起来、把画面交回静态立绘,躲起来时也一样(动图没有 pause() 可喊) |

外层 pointer-events:none,只有她自己的轮廓可点:命中层带一层由 alpha 蒙版生成的 clip-path,所以透明处是真的穿透 —— 她站在哪个按钮上都不影响你点它(1.40.0 之前这句是错的:她的 560×840 盒子整块盖在应用上面,透明处的点击被吃掉)。

文件结构

dsh-wisp-plugin/
├── package.json dsh.bundle.patch + dsh.client.platform,零依赖 ├── cordis.patch.yml 插入一行 host 入口(指向 dsh-wisp,绝不能是 dsh-wisp/client) ├── lib/ │ ├── index.js host 半包:检查更新 / 查余额两个 handler + 帧动画素材路由 /wisp-motion/ │ ├── client.template.js 浏览器半包源码(唯一需要编辑的文件) │ └── client.js 由 build.mjs 生成,勿手改 ├── assets/ 18 套皮肤 × 8 张 1024x1536 透明 WebP │ ├── motion/ 帧动画素材(动图 WebP;由宿主半包的路由发给页面,不进客户端包体) │ └── audio/ 音效(可选,内联) ├── build.mjs 注入精灵图 + 帧动画清单 + 建造防线 ├── verify-wisp.mjs 预检:两套契约 + 真实执行 ├── NOTICE.md 角色来源与许可 └── README.md 本文件

构建与预检

node build.mjs # 把 assets//.webp(+ assets/motion/.webp)注入 lib/client.template.js → lib/client.js
node verify-wisp.mjs # 预检;exit 0 = 两半包都符合契约

改了 client.template.js 就必须重新 build.mjs —— client.js 是生成物。

build.mjs 现在有五道防线,任何一道失败都拒绝写出文件:

1. 占位符 __SPRITES_LITERAL__ / __MOTION_LITERAL__ 必须各出现一次,且替换后都不得残留;
2. 注入后的 SPRITES 表要真的被求值,每个 key 都必须是 data:image/ 开头;
3. 注入后的 MOTION 表同样要真的被求值,每个值都必须是 data:image/webp;base64, 开头(写错一个 key,客户端就会建一个永远不动、一加载就报错的 ,而那是静默失败);
4. 产物不得调用被陷阱的全局(setTimeout / setInterval / clearTimeout / clearInterval / fetch / require);
5. const VERSION 必须与 package.json 的 version 一致(防止版本漂移)。

verify-wisp.mjs 在真实契约下执行浏览器半包:六个被陷阱的全局以抛异常的形式注入,虚拟时钟同时驱动两条调度路径,假 DOM / Blob / localStorage(含"抛异常的存储"这一档)齐全。当前 930 项全 PASS,exit 0(这一行由预检自己核对 —— 数字对不上就红;顶部的版本号同样由它核对)。

真实浏览器冒烟

假 DOM 能验契约,验不了布局与合成。本项目的三个真 bug(躲起来后气泡悬空、菜单越界、 display:none 是否真的让她不挡点击)全都只有真实浏览器能发现,所以这些检查留在仓库里:

node tools/smoke.mjs # 挂载 / 菜单几何与键盘入口 / 躲起来 / 换皮肤 / attn / worried / 踱步 / 动作层(真引擎)/ 内存 / 体积
node tools/smoke.mjs --headed # 看着它跑

末尾会打印这一轮实际跑了多少项。第 10 节(动作层)验的是假 DOM 验不了的东西:关键帧里的 calc()/var() 有没有真的解析、getAnimations() 里有没有那条动画、 prefers-reduced-motion(page.emulateMedia)下计算后的 animationName 是不是 none。
没有 Playwright 时整节跳过 —— 所以换机器后值得跑一次,别只看"预检全过"。

退出码是有意义的:0 通过;1 有检查没过;2 环境中断。

headless Chromium 在内存压力下会被关掉,表现为随机的 "Target page, context or browser has been closed"。它与本插件的正确性无关,所以脚本只对这类中断重试一次,两次都中断就以 2 退出 —— 1 永远意味着"有检查没过"。不要因为它偶发就把断言放宽,那会把真正的产品问题一起放过。

为此每个浏览器只驻留一个页面:每个页面都要装下这份包与解码后的精灵图,多开一个就是多一份。
(这条是被真实崩溃逼出来的:原来踱步那节另开一个页面,随即开始随机崩溃。)

Playwright 是可选依赖:找不到就以 0 退出并说明原因 —— CI 上不该因为缺浏览器而红。

没装 Playwright 时:tools/engine-probe.mjs

上面那节的能力上限就是 Playwright 有没有装。装不上时"预检全过 + 冒烟跳过"等于没有任何视觉验证,而假 DOM 恰恰验不了 CSS:关键帧里的 calc()/var() 有没有真的解析、
rotate() 有没有真的转、媒体查询有没有真的生效。

这个探针用 Node 自带的 fetch + WebSocket 直接走 CDP,驱动机器上已有的 Edge / Chrome —— 零依赖、零安装:

node tools/engine-probe.mjs # 找到浏览器就跑,找不到就说清楚并以 0 退出
WISP_CHROME=/path/to/chrome node tools/engine-probe.mjs

它读的是引擎算出来的东西,不是源码文本:计算后的 matrix(证明 rotate(var()) 真的转)、
getAnimations() + getKeyframes()(证明关键帧解析后有效 —— 动画「在跑」不等于有效,
calc() 写错时浏览器会静默丢掉那一条 keyframe,动画照跑但什么都不动)、
以及 Emulation.setEmulatedMedia 下的 animationName 与姿势变量。

退出码:0 通过或跳过 · 1 有检查没过 · 2 环境起不来。几何与布局仍由 smoke.mjs 负责,两者不互相替代。
pnpm 布局下顶层没有 playwright-core 这个名字(只有 .pnpm/playwright-core@<ver>/),
所以脚本会显式扫 .pnpm。

素材路由的端到端 HTTP 检查:tools/motion-http.mjs

预检把 /wisp-motion/ 的 handler 当函数驱动了一遍 —— 它证明不了「注册键能不能被载体匹配上」。
1.47.x 的 404 恰恰死在这一条上:路由注册成功、函数级断言全绿,而请求一次都没到过 handler (注册键带了尾斜杠,载体的前缀匹配自己补那个斜杠)。所以这里起一个真的 node:http 载体:

node tools/motion-http.mjs # 自建载体,不需要应用在跑
node tools/motion-http.mjs --live http://127.0.0.1:19387 # 打运行中的应用,四个验收原样重跑

自建那一半用的是真的 registerMotionRoute()、载体原样的注册表与匹配规则、以及一份按
Service.listService("fs") 签名实现的本地 fs 后端(resolve / contains / stat / readBytes),
然后发真的 HTTP:200 的字节数与磁盘逐条比对,404 必须带 x-wisp-motion: hit(不带就说明答话的不是本插件的 handler)。--live 那一半是给「进程里到底是哪一代模块」用的:宿主半包是旧模块世代时,四个请求会全 404 且一个标记都没有 —— 那就是该重启进程的信号。

打包与分享

node tools/pack-check.mjs

一条命令跑完:npm pack → 解包 → 从解出的副本跑预检与重建,然后逐项确认发布子集自足(lib/index.js / lib/client.js / cordis.patch.yml / assets 都在)。

为什么不能靠「我看过 files[] 了」:包里的文档指向包外、tools/ 少声明一个、素材没打进去 ——这些都要真的解出副本才暴露。这个检查第一次跑就抓到了 README 里指向开发机绝对路径的死引用。

当前包:79 个文件 / 41.2 MB(tarball 41.2 MB —— 动图与立绘本来就压不动,所以两个数一样),其中 assets/ 占 58 个文件、35.0 MB —— 但运行时只会读全部 58 个:

| 素材 | 数量 | 体积 | 运行时 |
| 默认档 1024×1536(6 套皮肤 × 8 情绪) | 48 | 5.9 MB | ✅ 全都被 base64 进 JS(精灵图 8.0 MB) |
| motion/.webp(通用两条 + 泳装八条) | 10 | 29.1 MB(每条 2.6~3.3 MB) | ✅ 1.47.0 起不再内联:由宿主半包 /wisp-motion/ 路由按 URL 发(客户端里只有一张文件名清单,十条合计 302 字节) |
| _hi 2048×3072 | 35 | 8.1 MB | ❌ 已从仓库删除(2026-09-30),要重新生成 |
| _md 512×768 | 35 | 1.5 MB | ❌ 从不读取 |
| _sm 256×384 | 35 | 0.6 MB | ❌ 从不读取 |

后三档加起来 105 个文件 / 9.9 MB,唯一用途是「哪天想用 node build.mjs --tier=hi # 需要先 --all-tiers 生成 _hi;缺档会自动回退到默认档 换个档重新打包」。
而且 _hi 连画质收益都测不出来(见下面性能小节:DPR 2 下高频能量差 0.3%,DPR 1/3 下 1024 档反而高 4~5%)。
这 9.9 MB 已经省掉了(2026-09-30):assets/ 现在只有默认档 48 个文件 / 5.9 MB,外加十条合计 29.1 MB 的动图帧动画(1.47.1 起泳装那八条也落盘了),包是 41.2 MB(tarball 41.2 MB)—— 其中 lib/client.js 一个文件 8.25 MB (只有精灵图;动图自 1.47.0 起外置投递,不再进单文件)。

要生成别的档位(例如想用 --tier=hi 重打包)先跑:

node tools/assets.mjs --from <绿幕母版目录> --skin <皮肤> --all-tiers # 默认只写进包那一档

流水线的默认行为已经改成「只写会进包的那一档」,所以这 9.9 MB 不会下次跑一遍又长回来。

(历史原因:这段原本写的是「4 套皮肤 × 7 情绪 = 112 张、14.6 MB」,那是第五套皮肤 canon 加进来之前的数字。)
1.51.0 起绿幕母版统一归档在 sprite-masters/(不进版本库),改形象不再只有分档 webp 一条路。
(这条原来写的是 classic:它的绿幕原图早就丢了,分档曾是唯一的重建来源 —— 而那套皮肤 1.51.0 已经退役。build.mjs --tier=… 保留下来,是因为换档位重打包仍然要它。)

帧动画素材(1.45.0 起;1.46.1 换成动图 WebP;1.46.3 换 720p;1.46.4 修锯齿;1.46.5 增到两段;1.47.0 起改成外置投递、并给泳装接上八个状态;1.47.1 泳装那八条素材落盘): assets/motion/ 下每一个动图 WebP 对应一个「皮肤+状态」 —— 通用两条(所有皮肤共用的 idle / sleepy),泳装八条(swim_<立绘名>)。

| 素材 | 谁在播 | 体积(磁盘) | 幕布 | 不透明 / 半透明 / 幕布残留 |
| idle.webp | 各皮肤的 idle | 2693.3 KB | 品红 #FE01FF | 27.016% / 0.284% / 0.006% |
| sleepy.webp | 各皮肤的 sleep | 3318.6 KB | 绿 #A1E47B | 17.379% / 1.310% / 0.0017% |
| swim_idle.webp | 泳装的 idle | 2756.9 KB | 品红 #FF00FC | 30.249% / 0.256% / 0.0000% |
| swim_attn.webp | 泳装的 attn | 3298.2 KB | 品红 #FE00FC | 29.753% / 0.396% / 0.0000% |
| swim_happy.webp | 泳装的 happy | 3229.7 KB | 品红 #FF00FD | 33.966% / 0.365% / 0.0000% |
| swim_sleepy.webp | 泳装的 sleep | 2888.3 KB | 品红 #FF00FE | 30.303% / 0.321% / 0.0000% |
| swim_work.webp | 泳装的 alert | 2617.0 KB | 品红 #FD01FC | 27.880% / 0.264% / 0.0000% |
| swim_proud.webp | 泳装的 proud | 2808.6 KB | 品红 #FD01FC | 27.456% / 0.322% / 0.0000% |
| swim_eat.webp | 泳装的 eat | 3095.0 KB | 品红 #FD01FC | 29.644% / 0.432% / 0.0000% |
| swim_poked.webp | 泳装的 poked | 3094.5 KB | 品红 #FF00FE | 30.089% / 0.308% / 0.0000% |

三十五条都是动图 WebP(RIFF/WEBP + 一串 ANMF 帧块,alpha 写在格式里)、720×1280、 24 fps、-loop 0。帧预算默认 97 帧 = 4.04 秒循环,少一帧就得在预检的 MOTION_FRAMES 里逐条声明帧数与理由 —— 现在有三条例外:canon_work / swim_work 96 帧(砍掉一个离群末帧), canon_attn 49 帧 = 2.04 秒(那条生成素材里只有一段 2.04 秒是真正闭环的)。

投递方式在 1.47.0 换了,这是那一版的重点:素材不再内联。客户端半包里的 MOTION
表存的是文件名({ idle: 'idle.webp', swim_idle: 'swim_idle.webp', … },十条合计 302 字节),
字节留在 assets/motion/ 里,由宿主半包(lib/index.js)注册的 /wisp-motion/<文件> 路由发给页面;客户端只做 new URL('wisp-motion/<文件>', document.baseURI)。

1.48.1:这条路由在此之前一个字节都没发出去过。 路由注册成功了,但注册在 /wisp-motion/(带尾斜杠)上,而载体的前缀匹配是
pathname === prefix || pathname.startsWith(prefix + '/') —— 斜杠由载体补,
所以那个键只匹配 /wisp-motion 与 /wisp-motion//…,页面要的 /wisp-motion/idle.webp 一条都匹配不上:请求全部落进平台自己的 404 兜底。
现在有两个名字,别再当成一个:

| 名字 | 值 | 用在哪 |
| MOTION_ROUTE_PATH | /wisp-motion | 注册键(载体自己补斜杠) |
| MOTION_PATH | /wisp-motion/ | URL 前缀(客户端、预检、诊断) |

读文件也一样换了路:宿主半包用平台的 fs 服务(ctx.get('fs'))而不是 node:fs。
签名是从活的 Service 注册表里查的,不是猜的:fs.resolve(path, opts?)(本地后端会
realpath)、fs.contains(parent, child)(canonical 包含判断)、fs.stat(target)
(type/size)、fs.readBytes(target, signal, maxBytes)(超限报 FS_TOO_LARGE)。
于是「文件名合法」之外还有一道真实路径前缀校验:assets/motion/ 里的符号链接指到包外,resolve 之后的真实路径落在目录外,contains 直接挡下 —— 名字正则一个人挡不住这个。

一个 404 说不清是哪一半坏 —— 现在说得清了。 每个响应都带 x-wisp-motion: hit (平台兜底的 404 没有这个头、也没有 content-type),另有只读诊断:

GET /wisp-motion/__diag[?name=<素材名>] # 200 application/json

它回:注册键与载体规则跑出来的匹配结论(registeredKeyMatchesExample / trailingSlashKeyMatchesExample)、这个 handler 已经答过多少请求(hits/served/notFound)、 走哪条读路径(reader.via + 方法签名出处)、要读的绝对路径、真读一遍的字节数与 RIFF/WEBP 魔数、最后一次失败的错误名(含 code)。它只读:不改状态、不发素材、node 那一段只是「这个壳里 Node 那条路通不通」的旁证(发送不走它)。

失败仍然是静默的、而且是有意的:路由不在(Desktop 载体没有 HTTP 服务器)、404、解码失败—— 的 error 一到,动图藏起来、画面交回静态立绘,她还是好好地站在那儿。
__wisp.doctor().motion.frame 会报出 asset / src / failed / failedClips /
fetchError({ clip, src, at, count },没失败过是 null)—— 以前只有 failed: true,
「这一段没有素材」和「URL 取不到字节」长得一模一样;现在后者在 fetchError 里有 URL 和时间。

为什么必须这样:720p 的动图是 MB 级素材 —— 通用那两条内联就是 7.83 MB,八条泳装再内联会到 ~45 MB。换外置之后 lib/client.js 从 15.3 MB 回到 7.8 MB(1.47.1 实测 8.25 MB,十条素材全在磁盘上)。预算因此分成两个数: 内联(清单,≤ 2048 字节,实测 302 B)与磁盘(素材仍然跟着包走,涨了要有人重新量一次; 1.47.1 实测 29800.0 KB / 29.1 MB,上界随之收到 34000 KB)。

为什么是它、不是什么:@deepseek-ai/dsh-client-resources 不是一条素材路 —— 它是 dsh-resource:// 的取值模型(要有 provider、挂在 slot 上、给的是值不是图片 URL)。
可行的是平台自己的 HTTP 载体:ctx.webServer.register 是文档明写的插件扩展点("let the feature plugins claim their routes"),import.meta.url 给包目录。

失败是静默的、而且是有意的:路由不在(Desktop 载体没有 HTTP 服务器)、404、解码失败—— 的 error 一到,动图藏起来、画面交回静态立绘,她还是好好地站在那儿。
__wisp.doctor().motion.frame 会报出 asset / src / failed,一眼看得出是哪一种。

环缝:素材是「接得回去」的吗

19 条素材都是按「首帧 = 尾帧」生成的(first_last_frame,首尾喂同一张图),意图是天然无缝循环 —— 但在这之前,这条意图一次都没有被量过。 预检钉的是 720×1280 / 97 帧 / 41ms,全是「素材长什么样」,没有一条问「接到一起顺不顺」;而「她每 4 秒顿一下」在页面上不报错、不改 DOM、不留日志。

指标只有一条,量法在 tools/motion-seam.mjs(要 ffmpeg 解码,所以它不进预检):

ratio = seam / meanStep

seam = 最后一帧 → 第 0 帧(循环接回去的那一步)的 |ΔRGB| 全画布均值; meanStep = 相邻两帧同一个量的平均。ratio ≈ 1 表示接头和平时一帧的变化一样大; > 1.2 表示接回去那一下比平时任何一步都猛 —— 眼睛会看到「跳」。

第一轮量出来 7 条超线,最差 canon_attn 2.28x。预检现在有三道: MOTION_SEAM(19 条,每条钉在素材字节的 sha256 上 —— 换一个字节数字就作废,必须重新量)、目标线 1.2、以及 MOTION_SEAM_KNOWN 欠账表(还没重做的必须登记,而且只许变好,修好一条必须把名字删掉 —— 有一条反向断言专门防「过期的赦免」)。

清掉的三笔:

| 素材 | 手法 | 环缝 | 体积 |
| canon_work | 砍掉离群末帧(改容器,不重编码) | 1.94 → 1.02 | −41.6 KB |
| swim_work | 同上 | 1.41 → 0.46 | −29.2 KB |
| canon_attn | 重新生成,再按真正闭合的那一段切 | 2.28 → 0.57 | 3599.7 → 1722.4 KB |

剩下 4 条(canon_eat 1.32 / canon_sleepy 1.41 / swim_attn 1.39 / swim_poked 1.34)在欠账表里,等它们的素材重做时一起修。

一条反直觉、但很重要的结论:ratio 是相对度量,得配一句绝对不连续度一起看。
同一条素材重新生成一版、动作幅度掉了一半之后,绝对不连续度其实更小(5.57 → 3.33),比值反而从 2.30x 涨到 3.22x —— 动作越小,接头越显眼。

这也是 canon_attn 最后那个版本的来历:不是「重新生成一次就好了」。重生成的第一版接头反而更差(3.22x),第二版把「除指定两处外画面逐像素不变」写成硬约束之后才好起来,而且真正可用的是它内部那段 2.04 秒的闭环 —— 完整 97 帧的接头仍有 1.65x,切出那 49 帧才是 0.57x。
生成端不保证回到首帧:提示词里逐字写「最后一帧与第 1 帧逐像素对齐」也没用,漂移是全局的。

编码命令(原素材 720×1280 / 24fps,一帧不抽;每条素材一条命令):

两批八条都用这一条配方(1.47.0 写下,1.47.1 生成泳装那八条,1.49.0 生成原版那八条;两批都是品红幕、都是同一条命令,只有 <皮肤> / <立绘名> 两处不同):

0) 首帧 = 尾帧:品红画布 720x1280 + 立绘裁到包围盒(+12px) 后 scale=-1:1266,落底留 14px
ffmpeg -f lavfi -i "color=c=0xFF00FF:s=720x1280" -i assets/<皮肤>/<立绘名>.webp \
-filter_complex "[1:v]crop=W:H:X:Y,scale=-1:1266:flags=lanczos[fg];[0:v][fg]overlay=(W-w)/2:H-h-14,format=rgb24" \
-frames:v 1 _first_<状态>.png # 复制一份作 _last_<状态>.png

1) 生成:mode=first_last_frame / model=2.5 / duration=4 / 720p / ratio=adaptive / generate_audio=false (首尾同一张 ⇒ 身份、画风、起手姿态被钉死,而且天然无缝循环)

2) 抠像:幕布色从生成片里采样(crop=8:8:10:10 后读那一个像素),不是写死的
ffmpeg -i <源>.mp4 -vf "colorkey=#<采样色>:0.33:0.05,format=rgba,\
geq=r='r(X,Y)-max(0,min(r(X,Y),b(X,Y))-g(X,Y))':g='g(X,Y)':\
b='b(X,Y)-max(0,min(r(X,Y),b(X,Y))-g(X,Y))':a='alpha(X,Y)',\
fps=24,scale=720:-1:flags=lanczos" \
-c:v libwebp_anim -loop 0 -an -q:v 40 assets/motion/<皮肤>_<立绘名>.webp

采样而不是写死是有理由的:生成片每次的「品红」都稍有不同(实测 #FE01FF / #FF00FF 都出现过), colorkey 的距离阈值按采样值算才是「离幕布多远」,按别人那一版的值算就是随缘。
1.47.1 那八条实测采到五种幕布色:#FD01FC(work / proud / eat)、#FE00FC(attn)、 #FF00FC(idle)、#FF00FD(happy)、#FF00FE(sleepy / poked)——同一批请求、同一套提示词,幕布色仍然每条都不一样,这就是「必须采样」的现场证据。
1.49.0 的原版那八条同样每条都不一样:#FD01FC(idle)、#FF00FE(attn)、#FD00FE(happy)……
—— 采样这一步一次都不能省。
(1.47.1 的泳装那八条把 fps=24 写在链首;fps 是丢帧、colorkey 是逐像素,两者与顺序无关,1.49.0 起统一成上面这个顺序。)

三个指标(每条素材都要量,不透明 ≥ 15% 是硬门槛 —— 低于它说明她被她自己抠掉了):

不透明%:alpha == 255 的像素占全片比例;
* 半透明%:0 < alpha < 255(平滑带,越高边缘越柔);
* 幕布残留%:alpha > 127 且 RGB 与采样幕布色的三通道 L1 距离 / 765 ≤ 0.05 的像素比例。

两条深海女仆素材的旧命令(历史;配方没变,文件名在 1.49.0 改过: assets/motion/sleepy.webp → deepsea_sleepy.webp、assets/motion/idle.webp → deepsea_idle.webp —— 这两条本来就是拿 assets/deepsea/{sleepy,idle}.webp 合成的,改名是把「它属于谁」写进名字里,字节一个没动):

deepsea_sleepy(旧名 sleepy):绿幕 #A1E47B + RGB 去绿边 G = min(G, max(R,B))

ffmpeg -i <源>.mp4 -vf "fps=24,colorkey=#A1E47B:0.19:0.05,format=rgba,\
geq=r='r(X,Y)':g='min(g(X,Y),max(r(X,Y),b(X,Y)))':b='b(X,Y)':a='alpha(X,Y)'" \
-c:v libwebp_anim -loop 0 -an -q:v 40 assets/motion/deepsea_sleepy.webp

deepsea_idle(旧名 idle):品红幕 #FE01FF + RGB 去品红边(把「R、B 同时高于 G」的那一份减掉)

ffmpeg -i <源>.mp4 -vf "fps=24,colorkey=#FE01FF:0.33:0.05,format=rgba,\
geq=r='r(X,Y)-max(0,min(r(X,Y),b(X,Y))-g(X,Y))':g='g(X,Y)':\
b='b(X,Y)-max(0,min(r(X,Y),b(X,Y))-g(X,Y))':a='alpha(X,Y)'" \
-c:v libwebp_anim -loop 0 -an -q:v 40 assets/motion/deepsea_idle.webp

参数各管一件事,都别乱动:

- colorkey 的 similarity + blend 就是「全不透明」的阈值,两条都保持原样(sleepy 0.24 / idle 0.38)—— 所以「她自己」一个像素都没少:不透明占比 17.379% / 27.016%,和上一版逐位相同。
- blend 收到 0.05(1.46.4 是 0.06 / 0.08,下限是 0.05):它是边缘过渡带的半宽,而这条带是动画体积的大头 —— 实测 sleepy 在 -q:v 40 下把 blend 归零, 3405.7 KB → 1867.8 KB(省 45%)。收到下限(而不是归零)是刻意的:0.02 那条带只有 0.04 个色距宽,等于一刀切,而 1 bit 的 alpha 在 560px 的显示尺寸上就是一排台阶。
- 去边那一步是配套的,不是可选项:过渡带一放宽,幕布的边就被一起放出来。
绿幕那条是 G = min(G, max(R, B))(与 tools/keyout.mjs 给立绘用的同一条规则);品红不能照搬 —— B = min(B, max(G, R)) 会把「蓝色比红绿都强」的头发本身削掉。
品红这条减的是「R、B 同时高于 G」的那一份(R -= min(R,B)-G,B 同理):她的蓝
(min(R,B) < G)一个像素都不动。实测 idle 的品红残留 1.09% → 0.006%,
头发边上那圈紫边没了。别改用 despill:它在 YUV 里做,会把整张画面一起脱色。
- -q:v 40:1.46.4 用的 60 是给「只有一段素材」的预算留的;两段同时进包时 60 装不下(合计内联 9.7 MB)。40 与 60 在这个内容上的差别很小(sleepy 4440 → 3406 KB,而画面在 560px 的显示尺寸下肉眼看不出块效应),省下的余量花在了去幕布色上 ——那才是眼睛真能看见的地方。fps / 分辨率一个都不能降:预检里有断言钉着 720×1280 / 41ms(24fps)。
帧预算默认 97 帧(4.04 秒循环),动它就得在 MOTION_FRAMES 里点名声明帧数与理由 ——那道闸防的是「抽帧省体积」,不是防「按环缝重新定长」。
- 为什么 idle 那条是品红幕布:它是另一条生成线出来的,幕布色由那一步决定。
品红对她是更远的底色 —— 按 colorkey 那把尺子(三通道 L1 / 765)量:她的青色发梢到绿幕 0.24、到品红 0.51;肤色到绿幕 0.24、到品红 0.35。

colorkey 的 similarity 曾经是 0.26(绿幕那版)—— 那会把她的皮肤和浅色衣服一起抠掉(她自己变成「空心的」,暗色主题下看起来就是一块黑色剪影),实测只有 5.5% 的像素还是全不透明的;0.10 时是 18.1%,也就是整个她。动作层的尺寸由 CSS 补:sleepy 那段她只占 83.5% 盒高(立绘 98.8%),要 scale(1.186) translateY(7.84%);idle 那段占 97.2%,差 1.0~1.6% ⇒ 不补(见 lib/client.template.js 里 .wisp-video 上那段推导与 1.46.5 小节)。
1.47.1 把同一把尺子用在泳装那八条上:素材 0.9515~0.9858 个盒高、立绘 0.9674~0.9967,差 0.2%~1.6%(最大 swim_proud 1.6%)⇒ 八条全都不补,MOTION_FIT 一个字没改;样式表那边补了一条 .wisp-video[data-clip^="swim_"]{transform:none},把「不补」写成显式规则。

它和精灵图一样按需解码:挂载时建一个 给当前皮肤当前状态的那一段(默认皮肤是 idle),换状态和换皮肤时同一个元素换源(不重建), prefers-reduced-motion / 静止档 / 「帧动画」开关 / 躲起来时把它藏起来、把画面交回静态立绘(见变更日志:动图没有 pause() 可喊)。1.47.0 起这一段素材是从页面自己 origin 的 /wisp-motion/ 路由上取的 —— 路由不在就静默降级,见上文「投递方式」。

自检:__wisp.doctor()

她依赖的每一个选择器都是读 DSH 源码推出来的,而它们错了不会有任何报错 —— 对应的功能只是永远不触发,看起来像「她今天没反应」。插件又跑在带鉴权的页面里,外部脚本连不上,所以自检必须由插件自己在页面里提供。Console 里敲:

__wisp.doctor()

返回当前页面里每条链路的真实情况:三个输入框钩子各命中几个、忙碌钩子、三个待处理面板、出错行、亮暗状态、五个主题令牌的实际取值,以及一份 problems —— 只列真的意味着功能失效的条件。
缺席类信号不算问题:没有待处理项、没有出错行都是正常状态,把它们算成问题只会训练人忽略这份清单。

只读,无副作用。

忙碌钩子会自解释:它是唯一无法静态确认的(aria-label 来自语言表,不是源码字面量),所以当它匹配不到时,报告会附上页面上实际存在的按钮 aria-label 采样 —— 一次调用就能看出该匹配什么,不必再靠猜。

其余 data- 钩子已在已发布的 app.asar 里逐个确认存在(data-composer-input ×1、 data-question-key ×1、data-plan-review-key ×1、data-approval-key ×2、data-error、 data-ds-dark-theme)—— 源码里有 ≠ 发行版里有,所以查的是发行版。

改形象(素材流水线)

形象的唯一权威来源是 tools/CHARACTER-PROMPT.md —— 逐字复用的提示词母版 + 四张姿态 + 完整生成参数。改形象 = 改那个文件、重新出图、再跑一条命令。

1. 按 tools/CHARACTER-PROMPT.md 的参数与提示词,为五个情绪各出一张【绿幕】图,存成 <mood>.png
2. 绿幕 → 抠图 → 四档素材,一步到位(任何一张抠图异常都会以非 0 退出) --skin 决定输出到 assets/<skin>/,也就是新增一套可切换的皮肤
node tools/assets.mjs --from <绿幕母版目录> --skin <皮肤名>
如果手上只有【已经抠好的透明 PNG】(绿幕原图不在了),走这个,不重复抠图:
node tools/tiers.mjs --from <透明PNG目录> --skin <皮肤名>
3. 打进客户端半包
node build.mjs --tier=hi # hi 档 = 2048x3072,与母版同尺寸,仅重新编码
4. 预检
node verify-wisp.mjs

三条硬约束(实测依据见 CHARACTER-PROMPT.md 与 NOTICE.md):

1. 不要用服务端的透明背景。 它会把浅蓝渐变的发梢与鲸尾当背景切掉,表现成「模型跑形」,几乎无法从结果反推。出绿幕、自己抠图 —— 只有 0.4%–0.7% 的边缘像素需要处理,发丝一根不丢。
2. 不要传参考图。 形象靠提示词锚定;一条提示词只放一个角色。
3. 画风别写「高质量二次元插画」这种笼统说法,它会推向韩系半写实并崩掉设定。

性能

一切数字都是在真实 Chromium 里量的(合成 10k 节点会话记录,headless、CPU 光栅,即最不利的一档)。计数与耗时都靠包装原生 querySelector/querySelectorAll 得到,不靠估算。可复现的探针:node tools/perf.mjs(浏览器是可选依赖,缺了会跳过并说明)。

稳态

| 指标 | 数值 |
| 动画帧使用量 | 0(走 timer 服务,根本不挂帧循环) |
| 帧动画(v1.45.0 新增;v1.46.1 换成动图 ;v1.46.5 起两段) | 0 帧:挂载时(idle)就建一个 (动图 WebP),换状态只换 src,解码与循环都在浏览器的图片通道上,JS 这边一次回调都不挂。静止档 / prefers-reduced-motion / 躲起来时把它 display:none(动图没有 pause() 可喊),不显示也就不解码 |
| 动作层(v1.38 新增) | 0 帧:姿势与一次性动作都是「事件写一次属性,浏览器自己算」。指针靠近只做算术,出圈即归零,变化不到 0.05° 不写样式 |
| 定时器 | 1 个 interval(反应轮询)+ 3 个 timeout |
| 反应轮询成本 | 每轮 3 次 querySelector,10 s 内共 7.2–8.3 ms(≈ 0.86 ms/轮) |
| 每帧平均间隔 | 无插件 4.194 ms vs 挂载后 4.217 ms → +0.023 ms(4 轮交替重复) |
| 每秒 DOM 查询 | querySelector 1.6 次(有输入框)/ 2.8 次(无输入框,最悲观);querySelectorAll 0 次 |
| DOM 查询耗时 | 0.72–0.83 ms/秒 ≈ 单核的 0.07–0.08% |
| 出错扫描 | 每跑完一轮一次,10k 节点页面上实测 0.017 ms(不随轮询频率增长) |

为什么是这个数量级:每轮轮询查三次 —— 忙碌钩子、待处理面板钩子、以及输入框钩子。第三条只在输入框尚未解析出来时才查(解析一次后缓存);页面里没有输入框时它是常驻的,所以 2.8 次/秒是最悲观的一档,1.6 次/秒才是真实应用的形态。

老实现是「每次都重新全量查一遍」:querySelectorAll 每次都调、1.27 ms/次。后来把输入框元素解析一次后缓存(仅在它离开文档时重新解析),忙碌判定从 4 条精确选择器改成 2 条子串选择器 —— 后者还能顺带匹配没见过的语言标签。

帧开销必须交替重复测:单次差值里的噪声就有 0.1 ms 量级(同一份代码两组测得 +0.127 与 −0.002),一次测量说明不了任何事。

一次性成本

- 挂载瞬间有一次约 15–50 ms 的卡顿(headless CPU 光栅下)。来源是注入样式表触发的整篇样式重算 + 首次模糊光栅化,不是稳态开销;预热 1.5 s 后测 6 s,没有任何一帧超过 20 ms。
- 挂载一次性开销实测 19.3 ms(5.2 MB 的包)。
- 包体积不是约束:解析+执行 5.2 MB → 19.3 ms、8 MB → 28.4 ms、16 MB → 32.3 ms、24 MB → 46.5 ms,斜率约 1.45 ms/MB(次线性)。再加一整套皮肤(+1.1 MB)约 +1.6 ms。
探针:node tools/bundle-cost.mjs。
- 28 张精灵图(4 皮肤 × 7 情绪)共 4.58 MB base64,挂载时只解码用到的 1 张,其余在 1.5 s 后预热。
- 解码后的内存:一张精灵图是 宽 × 高 × 4 字节 —— 当前 1024×1536 档 6.0 MB/张,预热会把当前皮肤的五张全解出来(≈30 MB);--tier=hi 是它的 4 倍。选档位不只是锐度问题。

已知的、没修的

- rAF 兜底路径在没有 timer 服务的壳里,只要还有任务待触发就会保持帧循环(拿不到 setTimeout,没有别的唤醒源)。真实 DSH 壳不走这条路 —— window.__wisp.clock 若不是 timer-service 就是它。
- 常驻的呼吸动画在 headless CPU 光栅下会让偶发单帧变长(4 次重复里最差 37 ms,平均值不受影响)。GPU 合成的真实窗口里这类 filter 通常不花钱。prefers-reduced-motion 会关掉全部动画。

配置

配置写在 profile 的 bundle patch 里(~/.dsh/profiles/<profile>/cordis.patch.yml,或本包 cordis.patch.yml 的 insert 行上),作为 apply(ctx, config) 的第二个参数。全部可选,全部会被夹到合法区间——写错只会退化成默认值,不会做出一个找不到或点不动的桌宠。

| 键 | 类型 | 默认 | 说明 |
| right | number | 26 | 距右边缘 px(仅首次落位;拖动过之后以记忆位置为准) |
| bottom | number | 46 | 距下边缘 px |
| size | number | 4 | 缩放,限制 0.4–8;默认绘制 560×840 CSS px |
| — | — | — | 菜单里调过的 size 会记在本地并活过刷新;但配置里显式写了 size 时以配置为准 |
| sleepAfterMs | number | 90000 | 静置多久打盹,限制 5000–3600000 |
| reactions | boolean | true | 是否跟随 Agent 忙碌 / 输入框内容变情绪 |
| motion | string | 'full' | 动作幅度:full 灵动 / subtle 克制(手势与侧倾半幅、溜达更少但距离一样)/ off 静止(连呼吸、z 与帧动画一起停 —— 动图被藏起来、画面交回静态立绘)。认不出的值退回 full |
| sway | boolean | true | 左右晃动(只做跟随指针):指针进到她周围 170px 内就朝指针方向侧身,贴得越近倾得越多(最多 1.5°,smoothstep 衰减),出圈归零;触摸不响应。过渡 0.45s ease-out,无过冲(v1.50.0 之前是 3.5°、0.5s 带过冲的回弹)。关掉 = 不再跟随并把姿势当场归零。1.50.0 短暂写出去过的字符串也认:off → 关,其余 → 开。幅度跟着 motion(subtle 半幅、off 为 0) |
| frame | boolean | true | 帧动画总开关(idle / sleep 那两段动图)。关掉 = 画面永远交给静态立绘;与 motion: 'off' 不同,它只收走动图,呼吸、气泡、一次性动作照旧 |
| persist | boolean | true | 是否跨刷新记住位置 |
| celebrate | boolean | true | 一轮跑完是否庆祝一下 |
| celebrateAfterMs | number | 2500 | 只庆祝跑够这么久的轮次(避免每次小工具调用都跳) |
| happyMs | number | 1100 | 高兴状态持续多久 |
| hungry | boolean | true | 干饭:连续活动太久她会说饿了 |
| hungerMs | number | 2700000 | 连续活动多久算该吃一碗了(45 分钟);0 = 关 |
| night | boolean | true | 深夜劝睡 |
| bedtimeHour | number | 23 | 几点算深夜(0–23) |
| wakeHour | number | 5 | 几点算天亮(0–23);bedtimeHour > wakeHour 时窗口跨零点 |
| nightMs | number | 1800000 | 深夜每隔这么久说一句(30 分钟);0 = 关 |
| backAfterMs | number | 120000 | 离开超过这么久,回来时她会说一句(2 分钟);0 = 关 |
| focusMinutes | number | 25 | 菜单里「专注 N 分钟」的默认时长 |
| lang | string | 'auto' | 台词语言:auto 跟随界面 / zh 强制中文 / en 强制英文(菜单里也能选,等价) |
| sound | boolean | false | 音效开关。默认关,而且只有 assets/audio/ 里有素材时这个开关才存在 |
| soundVolume | number | 0.7 | 音效音量,限制 0–1 |
| skin | string | '' | 点名一套皮肤(assets/<skin>/);'' = 上次选的那套 |
| hidden | boolean | false | 挂载时就是躲起来的(只能强制隐藏,叫回来走菜单或 __wisp.show()) |
| backdrop | boolean | false | 清掉铺满视口的不透明底色,让窗口的 Mica/Acrylic 露出来。影响整个应用的外观,所以默认关 |
| chatterMs | number | 0 | >0 时按此间隔随机说句话;0 = 安静 |
| careAfterMs | number | 5400000 | 连续活动多久提醒你起来动动(90 分钟);0 = 关 |
| milestones | boolean | true | 今日轮次到里程碑时得意一下 |
| memory | boolean | true | 她记着你(一起多少天 / 习惯放哪 / 常穿哪套) |
| wander | boolean | true | 空闲时自己小幅踱步 |
| wanderMs | number | 45000 | 多久考虑溜达一次,限制 5000–3600000 |
| wanderRange | number | 600 | 单次溜达最远距离 px,限制 0–2000。距离会对齐到整数个步幅(一个步幅 547px,见 WALK_PER_CYCLE_PX)—— 不对齐的话脚会在原地滑 |

例:

- id: wisp
name: "dsh-wisp"
config:
bottom: 90
size: 1.2
chatterMs: 300000

运行时 API

页面 Console 里 window.__wisp 可用:

window.__wisp.say('你好') // 说一句
window.__wisp.mood('happy') // idle | alert | attn | happy | sleep
window.__wisp.configure({ size: 1.5 }) // 部分更新,其余键保持不动
window.__wisp.move(200, 300)
window.__wisp.position // { x, y }
window.__wisp.resetPosition() // 清掉记忆位置,回到右下角
window.__wisp.clock // 'timer-service' | 'animation-frame'
window.__wisp.checkBalance() // 查一次余额并说出来;返回 { state: 'ready' | 'signed-out' | 'unavailable' | 'failed' | 'unsupported', wallets, … }
window.__wisp.doctor().balance // 上一次余额查询的结果 + 两条通道在不在 + 这次走了哪条(从不含凭据)
window.__wisp.doctor().motion // { level, amp, reduced, tilt, tiltLocal, accent, pressed }
window.__wisp.destroy()

window.__wisp.clock 直接告诉你这台机器走了哪条调度路径,排查时先看它。

平台契约(踩过的坑都在这)

1. 两个文件、两套契约

宿主 import cordis.patch.yml 里 name: 指向的包 —— 必须是合法 ESM,即 lib/index.js。
浏览器半包是经典脚本,由 package.json 的 exports["./client"] 定位、/plugins 路由投递,自己调用 window.__ModuleLoader__.load({ id, factory }) 注册。

patch 那一行写成 dsh-wisp/client 会导致 failed to import ——宿主无法 import 经典脚本。

2. 六个全局是抛异常的陷阱

setTimeout / setInterval / clearTimeout / clearInterval / fetch / require 在客户端半包里被抛异常的形参遮蔽。所有时序因此来自 Client timer 服务:

ctx.get('timer').timeout(fn, ms) // → disposer
ctx.get('timer').interval(fn, ms) // → disposer

timer 服务取不到时(老壳 / 无头壳)退回 requestAnimationFrame 截止时间表,且只在有任务待触发时才挂帧——原来那种常驻 60fps 帧循环会让桌面进程每秒白醒 60 次。
ctx.get('theme') 与 ctx.on('theme/change') 用来跟随明暗;body[data-ds-dark-theme] 是兜底(这是壳真的会写的属性)。

3. 精灵图必须走 blob:,不能直接给 data:

初版把 base64 data URI 直接赋给 img.src:插件「挂载成功」、DOM 正确、光晕正常,图是碎的。字节经校验无误,是这个壳拒绝 data: 作图片源。现在解码成 Blob 再取 object URL,拆解时 revokeObjectURL 释放,并把缓存条目一起删掉——blob 缓存是模块级的,而挂载是每次 apply 一次,只 revoke 不删键会让下一次挂载拿到已吊销的 URL,又变成碎图。

4. 一次性实例

apply 会先销毁已存在的 window.__wisp。HMR 重挂、重复行、以及注入版残留都会走到这条路径,否则会叠出第二只。

5. 反应信号取自 DOM 事实

- 忙碌:button[aria-label="停止"],button[aria-label="Stop"] —— 壳自己的「正在跑」按钮(zh 标签是「停止生成」,en 是 "Stop generating")。用子串而不是枚举精确标签,是因为标签会被翻译,而且实测 2 条子串选择器比 4 条精确选择器便宜 40%,覆盖反而更宽;
- 输入框有字:[data-composer-input](依次回退到 textarea、[contenteditable="true"])——解析一次后缓存,只在元素离开文档时重新解析。

两者都是壳为自家用户渲染的东西,比内部服务契约更抗版本升级。

6. 两个半包都没有「自己联网」的能力 —— 只能走服务

这一节是 1.39.0 加余额时用两条独立的实测(读发行版里的包 + 查运行时服务目录)确认下来的,两条都会在别的地方继续咬人:

- 浏览器半包:fetch / XMLHttpRequest 那一侧是陷阱,调用即抛。
- 宿主半包:跑在 vm 沙箱里,require / fetch / setTimeout 全是抛异常的陷阱(沙箱只给 { defineTool, registerTool, handle },外加平台注入的服务)。
- 平台给的网络通道 ctx.web.fetch 设不了请求头:它的请求类型就是 { url: string }, provider 只读 url,方法硬编码 GET、请求头硬编码 UA/Accept。所以任何需要鉴权头的接口在插件里都打不通 —— 这和有没有 key 无关,是通道本身的形状。
- 能拿到的是服务,而且要在调用时拿:ctx.get('<name>') 不需要任何声明,直接查当前注册表。所以本插件一条 inject 都不声明(export const inject = []), web / deepseekAccount / fs 全在调用时用 ctx.get(name) 取。
- inject 是「服务名 → 拦截配置」的映射,不是 { required, optional }:把对象写成 { required: [], optional: ['web'] },cordis 读到的是两个叫 required 和 optional 的服务名(归一化规则就是 Array.isArray(inject) ? inject : Object.keys(inject))。
没有任何组合提供这两个服务 ⇒ fiber 永远停在 PENDING、apply() 一次都不执行,路由与两个 handler 全部不存在。1.47.x/1.48.x 就是这么被钉死的(详见 1.48.3 一节)。
- 凭据不属于插件:余额走 deepseekAccount("only Host consumers can obtain a request
credential"),插件拿到的是归一化后的结果。doctor().balance 里只有数字和状态,
没有任何 token —— 这是设计,不是巧合。

安装

从 npm 装(推荐) —— 这是唯一不需要访问 GitHub 的方式。在「添加插件」对话框里,把安装源设为 中国大陆镜像源(registry.npmmirror.com),然后填包名:

dsh-wisp

从 GitHub 装 —— 需要能访问 github.com:

plugin_manager install_bundle https://github.com/969246694/dsh-wisp

从本地路径装 —— 桌面版 profile 归 Electron 独占管理,dsh plugin --profile desktop 会被拒绝,所以走 GUI:插件管理页 → 添加插件 → 填本目录绝对路径。本地路径安装不下载任何包。

为什么镜像源只对 npm 那条路有用:GitHub 地址被归类为 git 规格,由 git 拉取,完全绕过 npm——「安装源」那个设置对它无效。

装完刷新页面(Ctrl+Shift+R)即可拿到浏览器半包——/plugins 路由的 rev 由文件 mtime 驱动,必然变化。宿主半包(lib/index.js)的改动才需要完全退出托盘进程重启。

装好后这样验

- [ ] 右下角出现发光桌宠
- [ ] Console 有 [wisp] mounted — DeepSeek娘 v1.39.0 (timers: …),且括号里是 timer-service
- [ ] 打字 → 变琥珀色;清空 → 回青色
- [ ] 让她跑一轮 → 结束时会庆祝一下
- [ ] 拖到别处 → 硬刷新 → 还在原处
- [ ] 菜单 → 行为 → 看看余额 → 她报出数字;没登录时她会如实说没登录
- [ ] window.__wisp 存在

生成审计:素材从哪来,可自查

有人问「严查是否存在图生图」,我就把这件事做成了可复查的文件,而不是一句承诺。

为什么需要它

Maker 代理自己维护一张登记表(<Maker 项目>/.maker/assets/generated-assets.json),记录每次生成的工具名 / 提示词 / 时间 / 产物路径 —— 但它不记参数。
而 generate_image 与 batch_generate_images 是允许传可选 reference_images 的,所以「没喂参考图」这件事,代理那边证不了。

这个日志记什么(audit/generation-audit.jsonl)

两类证据分开记,谁强谁弱一目了然:

| 字段 | 来源 | 强度 |
| registry.tool | 代理自己写的(独立于本插件) | 硬证据:edit_image 是唯一能做图生图的工具,工具名说了算 |

| declared.reference_images | 本项目调用前写下的参数 | 自我声明,但写进哈希链、事后改不了,且写在调用之前 |
| output.sha256 | 入包素材本身的哈希 | 可复查:事后有没有被改过 |
| master.sha256 | 母版的哈希(开发机上) | 可复查母版是否被换过 |

每条记录都带 prev 指向上一条的 hash —— 改任何一条都会断链,而断链会被预检抓住。

怎么用

node tools/audit-log.mjs --verify # 校验链 + 重算入包素材哈希
node tools/audit-log.mjs --declare <manifest> # 生成前:登记将要发出的参数
node tools/audit-log.mjs --confirm <manifest> # 生成后:并入代理登记表 + 记产物哈希

同一个入包文件重出过时(例如某张表情先出坏了、改提示词再来一版),日志里会有多条 confirm:历史一条不删,但哈希比对只认最新那一条 —— 旧条目会被报成「同一文件的更早版本(重出记录)」并跳过比对。
不这么做的话,任何一次重出都会让「产物哈希与记录不符」永久变红(本项目第一次重出就踩到了)。

预检里已有两条常驻断言(3z 段):链校验通过且覆盖全部入包素材、且审计中不得出现 edit_image。

它证明了什么、没证明什么

- 证明了:入包素材逐字节等于记录在案的那次调用的产物,此后没被改过;那些调用在代理登记表里用的是 generate_image / batch_generate_images,不是 edit_image。
- 没证明:对回溯补录的记录(本功能上线前的 35 个素材),没有"当次自我声明的参数",因为那时还没有这个日志 —— 它们的 declared 字段是 null,如实标注,不冒充。
只有今后新生成的素材才会带上完整的 declare + confirm 两条记录。
- 另外:即使某次真的传了参考图,代理也不会在登记表里留下痕迹(它不记参数)——所以"没喂参考图"最终依赖的是自我声明 + 声明不可事后篡改,而不是第三方取证。这一条必须说清楚。

更新:能做什么

先说结论

- 检查更新:能,而且有两条路(v1.49.7 起)。 ① 宿主半包的同源端点 GET /wisp-motion/__update:宿主用平台给的 ctx.web.fetch 读 npm / GitHub(拿不到 web 就退回问插件管理器),页面只读结果 —— 这条路不经过页面那一侧的系统代理; ② 端点不在时,页面自己用 window.fetch 读那两个源(1.48.x 起的老路)。
两条读的是同一份判据,doctor().update.lastCheck.via 说明这次是谁问出来的
(host-route / page-fetch / host-seat)。
- 安装更新:能(v1.48.0 起)。 平台把插件管理器本身做成了客户端可用的 Remote 命名空间:
ctx.remote.pluginManager.installBundle(spec) —— 平台自己的「插件」设置页走的就是它。
查到新版之后,「关于她」弹窗里会出现 「安装 vX」,点一下就装;装完该刷新还是该重启由平台的 ChangeResult.application 说了算(applied / restart-required),她照实转述,不自作聪明。

为什么必须绕这么一圈 —— 两边的限制都查到源码级:

- 浏览器半包的 fetch 是教学陷阱(一调用就抛),原文写着 "network belongs to the HOST half"。
- 宿主半包跑在 vm 沙箱里:harness 只有 defineTool / registerTool / handle,沙箱里还有 nodeApiTraps() 把 Node API 全挡了,允许的 ctx 动词白名单里没有网络与文件系统。

为什么要读两个源(npm + GitHub)取更高版本:npm 的注册表是权威源,但发布可能长时间卡在它的 暂存区里 —— 实测:PUT 202(已受理)之后等了 18 分钟,注册表和 tarball 仍然 404,而同版本号重发被拒(409 Cannot publish over previously staged version);同期 GitHub 的 package.json 是立刻更新的。
所以检查同时读两个源、取版本号更高的那个 —— 这样无论 npm 有没有追上都是对的。
「npm 优先、GitHub 兜底」在这里是错的 ✗:它会读到 npm 那个旧号,然后告诉你"已经是最新的"。

所以网络只能走服务:宿主半包 inject: { optional: ['web'] },用 ctx.web.fetch(...) 抓远端 package.json,再用 harness.handle('checkUpdate', …) 注册;浏览器半包 host.call('checkUpdate') 调。
客户端那边一点网络都没碰 —— fetch 陷阱原样留着。

web 声明为可选(optional)不是风格问题:硬依赖一个没加载的服务会让 fiber 永远 waiting,她会直接从界面上消失。预检里有两条断言专门盯着这件事(必须可选、不能必需)。

更正:上一版 README 写的是"这个插件不能检查更新" —— 那个结论错了。它建立在 "服务目录里没有网络类服务"上,而我漏看了 ctx.web:它是注册表式服务,提供者 web-fetch-http 只往里 registerFetchProvider,所以按 provide("web") 搜是搜不到的。

那这个插件做了什么

都是沙箱允许范围内真能做到的:

| 能力 | 怎么用 |
| 真的去查更新 | 菜单「版本与更新」经 host.call → 宿主半包 → ctx.web.fetch 抓远端 package.json,
比对后说「有新版本了:X」或「已经是最新的了」;查不到就如实说「更新没查成」 |
| 报版本 | 菜单「版本与更新」说一句「我是 1.24.0。<这一版做了什么>」;__wisp.version / __wisp.doctor().update 也能读 |
| 更新感知 | 她记住上次见到的版本(localStorage)。版本变了就说一句「我更新到 … 了」;第一次安装不吭声(那时她不是更新,是刚来) |
| 复制包名 | 同一项会把 dsh-wisp 复制到剪贴板,方便在 DSH 的插件列表里粘贴安装。没有剪贴板权限时静默降级成只说话,不报错 |
| 直接安装 | 查到新版后「关于她」里出现「安装 vX」→ remote.pluginManager.installBundle('dsh-wisp@X', { requestId })。装的就是刚才查到的那一版(数字与装下去的东西一致);失败按平台给的分类说人话(见下),回信没回来时如实说「不知道」并拿 waitForInstall(requestId) 把结果捞回来 |
| 能力边界如实上报 | doctor().update 里有 canSelfUpdate: true、install(走哪条通道 + 最后一次装的结果)与 why(她自己仍然不碰网络与文件) |

真正更新它的路径

1. DSH 的插件列表 →「按插件包名安装」/ 更新(DSH 自己的「检查更新…」也在那里);
2. 或对本包目录重新 npm i / npm pack 后安装;
3. 更新后重载页面 —— 她会自己发现版本变了并说一句。

两个源都可以换(1.43.0 起写进文档):宿主半包读 config.urls,所以镜像或私有 registry 只要在 patch 行里指过去就行。国内常见配置:

- insert:
- id: wisp
name: "dsh-wisp"
config:
urls:
npm: "https://registry.npmmirror.com/dsh-wisp/latest"

只给 npm 一个键也可以:没给的源仍用内置默认值(registry.npmjs.org 和 raw.githubusercontent.com/.../main/package.json)。预检里有一条断言盯着"换了源就真的只去问新地址",免得"支持镜像"只是文档里的一句话。

她仍然不自己碰网络与文件:检查走客户端 fetch,安装走平台的插件管理器 ——两件事都不是她做的。她自己既没有 Node(宿主半包在 vm 沙箱里)也没有包管理器;
doctor().update.install.via 会告诉你这次走的是哪条通道(remote.pluginManager 或 null),
install.state 是最后一次的结果(installed / failed / cancelled / pending / unknown / unsupported), kind / why / logPath / requestId 是下一次能追下去的四个字段。

检查更新走哪条路(v1.49.7)

为什么第一条是宿主那条:这台机器上系统代理开着(127.0.0.1:7897),经它访问 registry.npmjs.org 连 TLS 都建不起来(实测:带 -Proxy 直接 SSL 失败,-NoProxy 200),而宿主是 Node、直连 —— 于是页面里那句 window.fetch 永远是 fetch-failed:窗口标题里一度写着 wisp-diag failed fetch-failed via=- how=- why=- mgr=-。页面直连这条路没有错,它只是依赖页面这一侧的网络;宿主那条不依赖。

| 通道 | 谁在问 | 依赖 |
| host-route(首选) | 宿主半包:ctx.web.fetch(拿不到 web 就退回问插件管理器) | 宿主的网络出口;页面只做一次同源 GET,不涉及 CORS |
| page-fetch(兜底) | 客户端半包:window.fetch | 页面这一侧的网络(系统代理、CSP 都在这条路上) |
| host-seat | 动态插件形态下的 host.call('checkUpdate') | 只有动态半包有座位,bundle 插件没有 |

两条都失败时 lastCheck.diag 写清是哪一条断的、为什么:via / how / why / sources (每个源各自的 reason 与 detail)。预检里六条断言盯着这件事:同源端点答了就不许再发外部请求、端点不在时必须回落到两个源、两条都断时失败里必须留下宿主那条没答的原因。

装不成时她说什么(v1.49.5)

失败的判据不在 error.code 上:pnpm 挂了的话平台把它一律写成 operation-error,真正的分类在 packageResult.kind(dsh-plugin-manager 的 classifyInstallFailure,九个值)。
v1.48.0 盯着 error.code === 'no-matching-version' 找 —— 那个值永远不会出现,于是所有失败都落到兜底的那句「没装成。」上,用户没有第二句可以追。

| 平台回来的 | 她说 |
| application: 'applied' | 装好了 —— 刷新页面就是新版 |
| application: 'restart-required' | 装好了 —— 重启 DSH 后生效 |
| application: 'failed' + packageResult.kind | 九种各说各的:npm 没这一版 / 包不存在 / 网络没连上 / 写不进去 / 磁盘满 / 构建脚本被 pnpm 拦下 / 校验没过 / pnpm 没动静 / 找不到 pnpm |
| application: 'failed' + error.code | 平台自己那几种拒绝:不兼容 / 不允许在这个壳里改插件 / 不是插件包 / 装的是哪一个说不清 |
| application: 'failed',分类不出来 | 兜底句带上诊断的最后一句(以前整段诊断被丢掉) |
| ok: false(回信没回来) | 「装没装完我不知道,我去问问」→ waitForInstall(requestId):捞到了就说捞到的那句,捞不到就说「平台那边也没有记录」。这不是失败 —— 平台自己的设置页管它叫 replyLost |
| 调用抛了 | 同上:没有回执 ≠ 装失败 |

installUpdate 会带一个 requestId 过去(捞结果要用同一个 id),这条路上面每一句都有断言盯着(见下面「预检里的守卫」)。

预检里的守卫

WHATS_NEW 必须写着当前版本,否则预检红 —— 这样"这一版做了什么"不会静默过期。
另外 hint 里必须含包名或仓库地址、why 里必须说明沙箱原因、canSelfUpdate 必须如实,都有断言盯着;安装那条路另有二十条断言(v1.49.5 起替身按平台真实的 ChangeResult 喂数据):装的版本号是否就是提示的那一版、applied 与 restart-required 是否说了两句不同的话、 no-matching-version 是否从 packageResult.kind 读出来(而不是从那个永远是 operation-error 的 error.code)、网络与权限是否各有各的句子、平台自己的代码拒绝是否有句子、分类不出来时诊断是否被带出来、回信没回来时是否先说「不知道」(且绝不说「没装成」)、requestId 是否与捞结果用的是同一个、捞不到时是否停在「不知道」、抛错是否同样算「没回执」、按钮点下去是否真的带上了 requestId、没有插件管理器时是否明说、以及英文覆盖层齐全且无汉字。

变更

1.56.0

挥拳(punch)有帧动画了 —— assets/motion/canon_punch.webp。

姿态与菜单接线在上一次提交里就位(by 另一个会话:MOODS 加 punch、菜单「挥拳 / 看拳。」、其余皮肤由 SPRITE_FALLBACK 退回 idle);缺的是动图,这次补上。

素材:97 帧 / 720×1280 / 4.041 s / 3970 KB,不透明 36.87%、幕布残留 0.0000%。
生成用的立绘是 punch_20261010031544(姿势归一化后与选定图 IoU 0.9811)。

它是"一次性动作",不是循环 —— 环缝闸因此多了一类判据。

打完一拳要收回起始姿势,所以"接回开头那一下几乎没变"是对的,不是"停一下":这条的环缝是 0.46,按走路那条带(0.75~1.25)会被判成 PAUSE。于是闸门加了第三格 MOTION_ONESHOT:这一类反着判 —— 要求缝小(证明它真的收回去了),而不是落在带内。值是被接受时的上界,只许变小。

这一格不是免检:一次性动作如果缝很大,说明收尾没收回去,播第二遍就是啪地跳一下。

1.55.1

她在"跑步机上走" —— 步幅和滑行速度差了四倍。是用户看出来的。

| 量 | 值 |
| 素材里她迈开时两脚相距 | 417 画布 px(= 274 CSS px) |
| 一个周期(两步)她的腿要求她走 | ≈834 画布 px = 547 CSS px |
| 客户端原来一个周期让她移动 | 130 CSS px |

→ 每个周期脚在地上倒滑约 417 px,七成以上的步幅是滑的。

根因:契约里那个 WALK_PER_CYCLE_PX = 130 是推出来的、不是量出来的(当时按"260px 溜达 ÷ 2 个周期"倒推)。
腿说了算,所以改成量出来的值,并把溜达距离对齐到整数个步幅:

- WALK_PER_CYCLE_PX = 130 → 547(一个周期她真正要走的距离);
- 溜达距离先按步幅取整、再回推时长 —— 距离和时长因此是同一个来源;
- wanderRange 默认 260 → 600,并且装不下一个步幅就不溜达:距离不足一步时只有"停在迈步中间"和"脚在地上滑"两种走法,两种都比这次不溜达差;
- 「克制」档改成只改频率、不改距离(range: 0.4 → 1)—— 一个步幅就是一步,缩不了。

预检里那条"步态契约只有一个来源"的断言随之改成 791ms / 19 帧 / 547px, 三个数现在全部是量出来的(原来的 850 / 25 / 130 全是推的)。

1.55.0

她会走路了 —— 第一条走路帧动画 canon_walk.webp(原版女仆)。

契约(docs/walk-action-prep.md)早就写好、18 套皮肤的 walk.webp 静态图也都在,缺的就是这条动图;这次生成出来并接上。

契约里被实测修正的一处:时序。

契约按「25 帧 × 34 ms = 850 ms 一个周期、50 帧 = 2 个周期 = 一次溜达 1700 ms」推。
实测素材里她的自然步频是 19 帧 = 791 ms:

| 方案 | 结果 |
| 慢放 7% 凑 850 ms | 步频被硬改掉 |
| 补帧到 25 帧 | 连续位移里会出现重复帧(走路最看得出这个) |
| 按她的真实步频走(采用) | 19 帧 / 791 ms,38 帧 = 2 个周期,原生 41/42 ms,没有重采样 |

所以客户端那两个常数改成量出来的值:WALK_CYCLE_MS = 791、WALK_FRAMES_PER_CYCLE = 19。
不变量一个字没动:滑行时长仍是周期的整数倍(ceil(距离/130) × 791),她不会停在迈步中间。

素材:38 帧 / 720×1280 / 1.582 s / 1174 KB,不透明 32.77%、幕布残留 0.0000%、环缝 0.92(落在 0.75~1.25 带内)。抠像用的是立绘那条绿幕配方(G = min(G, max(R,B)) 去绿边),没有沿用动图那条品红配方 —— 契约指定绿幕,静态图本来也是绿幕出来的。

顺带:tools/motion-trim.mjs 加了 --set-duration(只改 ANMF 块里那 3 个字节的帧时长,块长度不变 ⇒ 文件大小一字节不差)。这次最终没用上它(原生时长更干净),但契约第 5.3 步要的就是它。

1.54.3

她的浮层变成半透明的。 气泡、右键菜单、子面板、弹窗现在都会透出后面的内容并把它模糊掉 ——后面是界面就透界面,后面是空处就透壁纸(这正是 DSH 自己那些浮层的做法)。

浓淡由一个变量控制:.wisp-layer 上的 --wisp-glass(现在 84%),四块浮层共用,调浓淡只改这一处。每块浮层都先写一句不透明的兜底声明、再写 color-mix(...),所以在不支持 color-mix 的浏览器上仍然是实心的,不会变成一片全透明。

预检加了 3 条:那个数必须在 50%~100% 之间、四块浮层都必须读它、每块都必须有兜底声明。

1.54.1

"接回开头"那一下有两个错法,我只防了一个 —— 是用户看出来的。

ratio = 缝 / 平均帧步。我原先只写上界(> 1.2 就是"跳"),但反方向同样错:

| 缝 | 看到什么 |
| ≈ 1(普通一步) | 对 |
| > 1.25 | 跳 —— 接回去那一下比平时都猛 |
| → 0 | 顿一下 —— 最后一帧和第一帧几乎一样,接回去同一张画停两帧 |

机制:一个手势在顶点附近本来就慢。裁切窗口要是正好落在慢区,慢区就被接了两遍 ——本来顶点慢 3 帧,接起来变成 6 帧,眼睛看到的就是"停一下"。

而我恰恰是往这个方向优化的。 1.50.1 立这条指标时,我按"缝最小"去挑裁切窗口,挑出来的正是"把慢区接两遍"的那种切口。宵蓝研究员的待机就是这么带着停顿上线的: 它不是跳,它是停 —— 一个只盯上限的指标,会把反方向推到底。

修法两处:

1. 判定改成一条带:MOTION_SEAM_LO = 0.75 ~ MOTION_SEAM_HI = 1.25,两边都算错;
2. 切窗口的判据也改了:不再挑"缝最小",而是挑缝等于普通一步的那一段(目标 ratio ≈ 1.0)。

已经重切好 6 条(都不用重新生成,只是换个窗口):

| 素材 | 旧 ratio | 新 ratio | 帧数 |
| lab_idle | 0.45(停) | 1.04 | 84 |
| lab_happy | 0.37(停) | 0.91 | 93 |
| lab_proud | 0.57(停) | 0.99 | 93 |
| canon_attn | 0.57(停) | 1.06 | 50 |
| night_happy | 0.41(停) | 1.01 | 52 |
| night_eat | 0.45(停) | 1.05 | 90 |

还剩下 15 条出带(11 条"停" + 4 条"跳"),全部登记在 MOTION_SEAM_KNOWN 里,每条都带方向(pause / jump)并且只许朝带内走 —— 方向反了也算翻车。
它们没有源素材,要修只能改容器相位(把环缝挪到另一步上,无损)或者重做素材。

assets/motion/ 从 100144.1 → 99624.6 KB(重切之后反而小了一点)。

1.54.0

海洋研究员(lab)也有整套帧动画了 —— 八个状态一条不少,跟原版女仆、碧海泳装、宵蓝礼服一样。

| 状态 | 素材 | 帧数 | 环缝 | 体积 |
| idle | lab_idle.webp | 92 | 0.45 | 2771.5 KB |
| attn | lab_attn.webp | 95 | 1.04 | 2725.1 KB |
| happy | lab_happy.webp | 97 | 0.37 | 3143.0 KB |
| sleep | lab_sleepy.webp | 97 | 0.89 | 2740.7 KB |
| alert | lab_work.webp | 97 | 0.96 | 2547.8 KB |
| proud | lab_proud.webp | 97 | 0.57 | 2436.1 KB |
| eat | lab_eat.webp | 97 | 1.05 | 2856.3 KB |
| poked | lab_poked.webp | 88 | 0.81 | 2492.5 KB |

八条全部落在 1.2 那条线以内(0.37~1.05)。三条门槛全过:不透明 29.8%~33.4%(≥15%)、品红残留 0.0000%、平滑带 0.24%~0.31%。assets/motion/ 从 78431.1 → 100144.1 KB。

这一轮把上一轮的两个坑提前避开了,还多学会一条:

1. 提示词一次到位。 night 那一轮"动作幅度极小"写得太死,六条返工;这一轮直接把 "动作要清晰可见(大约一个手掌到一个前臂),不要做成几乎静止的动画"写进模板,八条里七条第一次就合格。
2. 切周期要挑长的。 attn 第一版按"最短的闭合周期"切出了 26 帧(1.08 秒) 的循环 ——数值合格(1.08)但内容几乎是静止的(整段姿势只挪了两个帧步,而 idle 挪了 12.8 个)。
改成"优先挑最长、且仍然能闭合的周期"之后,同一条素材切成 95 帧(3.96 秒),环缝 1.05,动作内容多了 2.3 倍。短周期会"因为没动而闭合" —— 这是上一轮没暴露的陷阱。
3. 抱臂那一遍是反面教材。 第一版提示词里有"上半身轻微左右摇摆",结果整幅画被带偏:接头 1.50 倍,怎么切都救不回来(试遍 12~36 帧的最短周期,最好也只有 1.47)。
把摇摆去掉、换成"整个人不要移动位置,只有呼吸和头发衣摆在动",同一张立绘重生成一次就掉到 0.57。"轻微摇一摇"这类指令会让模型重画整幅画,而不是只动局部 ——与 1.52.0 那条"动作越小接头越显眼"是同一枚硬币的两面。

新增三条 MOTION_FRAMES 声明:lab_idle 92 帧、lab_attn 95 帧、lab_poked 88 帧(都是"按真正闭合的那一段切短",不是抽帧省体积)。预检 897 项不变,多盯了八条素材的指纹、来源立绘与环缝值。

1.53.1

修掉「拖她的时候会抖」。 三个原因叠在一起:

1. 角度取的是单次指针事件的位移 —— 真实指针流每秒 60~125 个事件,每个事件的位移都在抖(手、系统合并、触摸板),那等于把噪声原样画到画面上。现在速度走一个指数平均(EMA),并且手停下来 120ms 后自己回正(以前她会在你停手之后一直歪着)。
2. 镜像是个纯函数(「她在屏幕哪半边」),拖动时每个事件都重算 —— 指针停在中线附近时它来回翻,而每翻一次都要镜像精灵并把 --wisp-tilt 的符号一起翻,于是一次拖动变成左右甩。
现在中线两侧有 48px 死区,只在真的越过去时才翻一次;命中判定用同一个量(否则死区里点她会打偏)。
3. 拖动中「完全不过渡」 —— 每个事件算出来的角度都是瞬间落到画面上的。现在留 0.16s 的过渡吸掉剩下的抖动;位置不在这一层(translate 在 root 上),所以跟手程度一点没变。

最大角 8° → 6°。预检加了 6 条:中线抖动不许翻面、带噪声的右拖角度不许变号、相邻两次不许超过 2°、停手必须回正,以及两个常数与那条过渡必须在。

1.53.0

走路动作:原版女仆(canon)已经有素材了 —— canon_walk.webp;其余皮肤的接线就位、素材待补(契约在 docs/walk-action-prep.md)。

她溜达时进入 walk 状态、按行进方向决定镜像、停下那一刻立刻交还 idle;没有走路素材的皮肤静默降级成 idle(SPRITE_FALLBACK),不会出现空盒子;「动作一览」里多了第 9 格「走路」,可以单独试。

顺带修掉一个一直存在的问题:滑行时长以前写死 1700 ms,而动画是循环播放的 ——两者对不齐时她会停在迈步迈到一半的那一帧上(不报错、不留日志)。现在滑行时长是 整数个步态周期:850 ms × ceil(距离 / 130px),最少一个周期;CSS 过渡读同一个变量 --wisp-glide-ms,两边不可能各说各话。附带效果:短距离溜达从 1.7 秒变成 0.85 秒,步速从此是恒定的(全速档约 153 px/s)。

预检加了 9 条:走路契约的三个常数、CSS 过渡必须读那个变量、SPRITE_FALLBACK 必须有 walk → idle、poll 的保持名单必须含 walk、动作一览必须有第 9 格,以及真跑一次溜达断言「滑行中 = walk = 按行进方向镜像」。另外帧预算表 MOTION_FRAMES 现在可以按素材声明 帧率(走路那条是 34 ms/帧 ≈ 29 fps —— 24 fps 下凑不出整数周期)。

1.52.0

宵蓝礼服(night)也有整套帧动画了 —— 八个状态一条不少,跟原版女仆、碧海泳装一样:待机 / 招手 / 开心 / 睡着 / 抱着笔电 / 抱臂 / 干饭 / 被戳。

这一套是带着环缝那把尺子做的 —— 1.50.1 立的规矩,第一次用在"从零做一整套"上,过程和结果都值得记:

| 状态 | 素材 | 帧数 | 环缝 | 体积 |
| idle | night_idle.webp | 97 | 0.70 | 2680.5 KB |
| attn | night_attn.webp | 97 | 0.79 | 2561.7 KB |
| happy | night_happy.webp | 49 | 0.41 | 1389.0 KB |
| sleep | night_sleepy.webp | 97 | 0.82 | 2108.2 KB |
| alert | night_work.webp | 97 | 0.76 | 2481.6 KB |
| proud | night_proud.webp | 97 | 1.00 | 2376.8 KB |
| eat | night_eat.webp | 95 | 0.45 | 2464.8 KB |
| poked | night_poked.webp | 97 | 0.74 | 2531.2 KB |

八条全部落在 1.2 那条线以内(最差 1.00)。三条门槛也全过:不透明 24.5%~28.9%(≥15%)、品红残留 0.0000%、平滑带 0.25%~0.34%。assets/motion/ 从 59837.4 → 78431.1 KB。

第一轮八条里有六条不合格,原因和修法都记下来:

第一轮我给每个状态都写了"动作幅度极小"(呼吸起伏"一两像素"、手腕摆动"不超过一根手指"),结果六条素材几乎冻住了(帧间平均变化 0.30~1.19,而 canon 那批是 2.2~12.2)。
而动作越小、接头越显眼 —— 这是 1.50.1 就量出来的反直觉结论,这一轮正面撞上: night_sleepy 的接缝是它自己平均帧间变化的 9.14 倍,night_eat 1.72 倍。

改法只有一句:把"动作要清晰可见"写进提示词("整段里能看出的动作幅度大约是一个手掌到一个前臂,不要做成几乎静止的动画"),其余约束(除指定部位外逐像素不变、整体明暗色温全程锁定)一个字不动。
night_sleepy 重生成一次:帧间平均变化 0.30 → 1.01,接缝 9.14 → 0.82。
night_eat 同样一次:1.72 → 0.45。

另外量错了一次,也记下来:判断"要不要按闭合周期切短"时,我第一版用的是含品红幕布的原始视频来算帧间变化 —— 幕布占了大半张画而且是静止的,它把"她动了多少"稀释掉了一半,于是八条里只有一条被判成"值得切"。换成抠像之后的素材重量(也就是预检和 tools/motion-seam.mjs 用的那把尺子),结论完全不同:八条里六条本来就合格。
量什么就要用什么量 —— 拿中间产物去判最终产物的门槛,是这一轮最贵的一个错(多花了一次生成的积分)。

新增两条 MOTION_FRAMES 声明:night_happy 49 帧、night_eat 95 帧 (都是"按真正闭合的那一段切短",不是抽帧省体积)。预检 899 项不变,但多盯了八条素材的指纹、来源立绘与环缝值。

1.51.0

她的衣服全部重画了一遍(14 套皮肤 × 8 个情绪 = 112 张立绘),而且这次是一致的 ——另外「素绘女仆」退役,换成一套全新设计「海洋研究员」。

为什么上一版不一致。 八张立绘是八次互不相干的文生图,两次调用之间没有任何记忆,所以"跨姿态一致"只有提示词一个载体。而服装是全文唯一"只写在开头、不进末尾清单"的部分 ——这个项目自己实测过两次:呆毛写在开头会被省略、写进编号清单一次就有;鲸尾写在身份段,其余五套的 39 张一张都没有。六套有据可查的皮肤,末尾清单里零条服装。
服装因此成了被忽略得最彻底的部分。唯一的反证也印证这条规律:swim 的清单里那一条「泳装必须清楚可辨」正是因为 happy 把上衣画成了横条才补进去的。

这一版怎么改的:服装写成编号规格(能数的写数目、颜色写死、形状写成几何描述并显式排除),并在末尾的【画面完整性,逐条核对】里扩到 ⑧–⑮ 逐条对照;姿态段补上遮挡条款 (抱枕 / 笔电 / 饭碗只允许挡住哪里、什么必须从两侧露出——那四格原本正是漂移最凶的四格)。

八张并排量出来的结果(单看一张永远是对的):

| | 结果 |
| 取景占画布高 | 97.4%~99.8%(每套内部跨度 ≤2.3pp)—— 切表情不会再缩一圈 |
| 键控软边 / 不透明 | 0.19%~0.51% / 21.2%~47.8%,全在 assets.mjs 门槛内 |
| 鲸尾 / 呆毛 / 鲸鳍耳 / 发箍 / 颈饰 | 八张全在(历史重灾区) |
| 收住的漂移 | santa 的袖长与白毛边、idol 上衣在蓝白之间摆、qipao 的裙长、miko 的袴长、goth 的蕾丝层数、winter 的毛领与衣长、yukata 的印花密度 |

出图是纯文生图:gpt / 4K / 2048x3072 / 2:3,一张一次调用;reference_images 在每次调用 之前登记为空数组(declare 记录),审计里 edit_image(唯一能做图生图的工具)条目为 0。

「素绘女仆」退役 →「海洋研究员」(classic → lab)。 选白大褂不是为了好看: 它是 18 套里唯一一件大面积白色的外衣,剪影与色块一眼就能与其余 17 套分开,配色仍是她的深蓝 + 白。

| | 素绘女仆(退役) | 海洋研究员(新) |
| id | classic | lab |
| 主色块 | 深蓝吊带裙 + 白围裙 | 白色及膝实验袍(敞开) + 深蓝高领无袖上衣 + 深蓝 A 字短裙 |
| 配件 | 围裙蝴蝶结 | 深蓝挂绳 + 白色工牌(蓝鲸图案)、深蓝细腰带、腰带左侧一副护目镜 |
| 腿脚 | 白色细带平底鞋 | 白色过膝长袜 + 深蓝低跟短靴 |

审计第一次有了「退役」这个概念。 删掉 assets/classic/ 会让哈希链那条断言永久变红 ("素材不见了"),而永久变红等于把真问题一起忽略。现在 tools/audit-log.mjs --retire <skin> 把八条路径登记为退役:历史条目原样留在链上(那正是这个日志存在的意义),只是不再参与 "文件还在不在"的比对。老配置里存过 skin: 'classic' 的人不会看到坏图 —— 客户端只认当前 SKIN_IDS 里的 id,认不出就退回默认那套。

绿幕母版(14 套 × 8 张)归档进 sprite-masters/:上一批母版是随那个已经删除的画图项目一起没的,11 套皮肤的提示词因此永久找不回来 —— 所以这一版把提示词本身也写进了仓库(tools/character-prompts-v2.mjs 是唯一来源,--stats 自检、--manifest 出 112 张的 manifest)。

预检 899 项全 PASS。

1.50.1

她循环播放时那"一顿",终于有人量了 —— 三条素材因此修好,其中一条是重做的。

19 条帧动画都是按"首帧 = 尾帧"生成的(first_last_frame,首尾喂同一张图),意图是天然无缝循环。
但在这之前,这条意图一次都没有被量过:预检钉的是 720×1280 / 97 帧 / 41ms,全是"素材长什么样",没有一条问"接到一起顺不顺"。而"她每 4 秒顿一下"在页面上不报错、不改 DOM、不留日志 ——只会有人某天觉得"她今天有点怪"。

指标只有一条,量法在 tools/motion-seam.mjs(要 ffmpeg 解码,所以它不进预检):

ratio = seam / meanStep

seam = 最后一帧 → 第 0 帧(循环接回去的那一步)的 |ΔRGB| 全画布均值; meanStep = 相邻两帧同一个量的平均。ratio ≈ 1 表示接头和平时一帧的变化一样大; > 1.2 表示接回去那一下比平时任何一步都猛 —— 眼睛会看到"跳"。

第一轮量出 7 条超线,最差 canon_attn 2.28x。清掉的三笔:

| 素材 | 手法 | 环缝 | 体积 |
| canon_work | 砍掉离群末帧(改容器,不重编码) | 1.94 → 1.02 | −41.6 KB |
| swim_work | 同上 | 1.41 → 0.46 | −29.2 KB |
| canon_attn | 重新生成,再按真正闭合的那一段切 | 2.28 → 0.57 | 3599.7 → 1722.4 KB |

canon_attn 那条值得说清楚,因为它推翻了一个想当然的假设。生成端不保证回到首帧:提示词里逐字写"最后一帧要与第 1 帧逐像素对齐"也没用,漂移是全局的 ——第一版重生成比原来还差(3.22x,接头的差异摊在整幅画上,36.86% 的像素在变)。
第二版把"除指定两处外,画面其余部分必须与第 1 帧逐像素完全一致;整体亮度/对比度/色温/饱和度全程锁定" 写成硬约束之后,接头受影响的像素掉到 7.49%(在包的那条旧素材是 19.43%)。
而真正可用的是它内部那段 2.04 秒的闭环:完整 97 帧仍有 1.65x,把真正闭合的那 49 帧切出来才是 0.57x。
—— 所以少掉的 48 帧是冗余,不是内容;这也正是"帧预算"那道闸从"只认 97"改成 "默认 97、例外必须点名 + 写理由"的原因。

还有一条反直觉、但影响以后所有判断的结论:ratio 是相对度量,得配一句绝对不连续度一起看。
同一条素材重生成一版、动作幅度掉了一半之后,绝对不连续度其实更小(5.57 → 3.33),比值反而从 2.30x 涨到 3.22x —— 动作越小,接头越显眼。

预检新增 6 条断言:MOTION_SEAM(19 条,每条钉在素材字节的 sha256 上 —— 换一个字节数字就作废)、目标线 1.2、欠账表 MOTION_SEAM_KNOWN(还没重做的必须登记,只许变好,修好一条必须删名字,另有一条反向断言防"过期的赦免")、以及 MOTION_FRAMES(默认 97 帧,例外必须写清帧数与理由)。
还剩 4 条在线外(canon_eat 1.32 / canon_sleepy 1.41 / swim_attn 1.39 / swim_poked 1.34),在欠账表里等它们的素材重做时一起修。预检 893 → 899 项, assets/motion/ 61785.5 → 59837.4 KB。

1.50.2

左右晃动只做「跟随指针」这一件事。

1.50.0 把它拆成了四档(关 / 自主微摆 / 横向漂移 / 跟着鼠标)—— 那是把用户那句 我需要更好的可选方案* 读成了「往插件里塞一个四档菜单」,其实他是在要方案(由他挑),而他挑的就是这一条:鼠标悬停移动时的交互动画,只做跟随指针。四档撤掉,菜单里回到一个开关。

留下的是 1.50.0 里真正有用的那一半 —— 手感:

| | 以前 | 现在 |
| 最大角 | 3.5° | 1.5° |
| 过渡 | 0.5s cubic-bezier(.22,1.2,.36,1)(过冲回弹) | 0.45s ease-out,无过冲 |
| 距离衰减 | 线性(刚进圈和贴近中心有折点) | smoothstep(两头平、中间陡) |

自主动画(wisp-sway-calm / wisp-sway-drift)与它们的 CSS 一起删掉;预检里加了一条反向断言:那两条关键帧不许偷偷回来,过渡必须还是那条无过冲的曲线。
1.50.0 写进过偏好的字符串也认(off → 关、其余 → 开),老配置的 true/false 照读。

1.50.0

「左右晃动」从一个开关变成四选一 —— 因为原来那一种晃法不舒服。

以前它是布尔:开 = 跟着鼠标侧倾,绕脚底转、最多 3.5°、过渡 0.5 秒还带过冲。
她 1050px 高,3.5° 意味着头要扫过 60 多像素,而且跟着鼠标每一下移动走 —— 观感是「被摇」,不是「她注意到你」。用户的原话:左右晃动的动画感觉不是很舒服,我需要更好的可选方案。

现在四档互斥(菜单 → 行为 → 左右晃动):

| 档 | 她做什么 | 为什么有这一档 |
| off | 完全不动 | 最安静 |
| calm(默认) | 自主微摆:±0.6°、16 秒一个来回 | 她「在呼吸」,但不是在被摇;不读指针 |
| drift | 横向漂移:±3px、12 秒一个来回 | 完全不转 —— 最容易晕的人用这档 |
| pointer | 指针进 170px 内才侧身:±1.5° | 老行为,但收柔了(角度减半、过渡去过冲) |

两档自主动画是 CSS 关键帧(wisp-sway-calm / wisp-sway-drift),幅度照旧由 --wisp-amp 缩放(动作幅度档),拖动时停掉(拖动中的姿势由 JS 按速度接管,不和动画抢同一个 transform)。
老配置里的 true / false 仍然能读:true → pointer、false → off。预检 885 → 893 项。

1.49.9

她「压一下」的那套动效收成丝滑极简。

以前是各向异性挤压:按下去她变宽 4.5%、变矮 6%(原点在脚底),松手拖动落地还有三段挤压-回弹、整整 0.56 秒、带回弹过冲。用户的原话:压一下的程序动画做复杂了,看着晕,我希望丝滑极简。

现在一次性动作只有均匀缩放,幅度都在 1% 以内、时长 ≤ 0.22 秒、没有任何过冲:

| 动作 | 以前 | 现在 |
| 压一下 | scale(1.045, 0.94) · 200ms | 均匀 scale(0.995) · 150ms |
| 落地 | 三段挤压回弹 · 560ms | 从下沉平滑回到原位 · 180ms |
| 换表情弹一下 | 3.5% / 3% 两个变体 | 均匀 0.6% · 220ms |

原点在脚底,所以均匀缩放读起来是「她轻轻沉一下」,而不是「整只被揉了一下」。
幅度仍由「动作幅度」档位统一缩放(克制 = 半幅、静止 = 整个不跑),预检里加了一条直接读 CSS 的断言:不许出现两参 scale(x, y)、不许有过冲项(>1.02)、每个一次性动作的时长必须 ≤ 0.25 秒 —— 改回果冻或加长都会红。

1.49.8

点「检查更新」不再先把「这一版做了什么」整段念一遍。

那段文字(WHATS_NEW)和「关于她」弹窗里那行「这一版:…」是同一段,而这个按钮本来就在那个弹窗里 —— 于是点一下听到的是"先复述关于她、再说结果"。用户的原话:点击检查更新时,她似乎会把"关于她"的内容说一遍,然后再说结果。

现在气泡只说两句:一句「去看一眼有没有新版……」,查回来用一句结果覆盖掉(我是 X。…)。
长文留在弹窗里(它就摆在那儿),气泡只负责"我在做什么 / 结果是什么"。预检 883 → 885 项,其中三条钉住这件事:第一句必须来自 updateChecking 池、那一句里不许出现 WHATS_NEW 的任何前缀、结果必须覆盖掉第一句并且带着版本号。

1.49.7

检查更新不再被页面这一侧的网络卡住 —— 宿主半包多了一条同源端点。

起因是一次实测,不是推测:这台机器系统代理开着(127.0.0.1:7897),经它访问 registry.npmjs.org 连 TLS 都建不起来(带 -Proxy SSL 失败、-NoProxy 200),而宿主是 Node、直连是通的。于是「检查更新」在页面上永远只会是 fetch-failed —— 窗口标题里那句 wisp-diag failed fetch-failed via=- how=- why=- mgr=- 就是它留下的现场。

现在页面按顺序试两条:

1. 宿主半包的同源端点 GET /wisp-motion/__update:宿主用自己的 web 服务读两个源(拿不到 web 就退回问插件管理器「这个包现在是什么版本」),页面只做一次同源 GET ——不经过页面那一侧的系统代理,也不涉及 CORS;
2. 端点不在(旧宿主半包 / 路由没注册)时,页面照旧自己 window.fetch 两个源。

两条通道共用同一份判据(settleCheck)与同一份宿主实现(readPublishedForPage —— 宿主座位的 checkUpdate 与这条端点读的是同一个函数),所以答案不可能互相矛盾。
doctor().update.lastCheck.via 会告诉你这次是谁问出来的(host-route / page-fetch /
host-seat);两条都不通时 diag.why 写清宿主那条为什么没答。预检 874 → 883 项。

1.49.6

安装失败的原因终于说得出来了 —— 以前所有失败共用一句「没装成」。

这不是文案问题,是判据拿错了。平台 installBundle 回来的 ChangeResult 里:

- pnpm 失败时 error.code 一律是 operation-error(managementError 只给得出这一个),真正的分类在 packageResult.kind —— dsh-plugin-manager 的 classifyInstallFailure 把 pnpm 的失败分成九种(no-matching-version / not-found / network / permission / disk-full / build-blocked / integrity / timeout / pnpm-missing);
- 而 v1.48.0 的实现盯着 error.code === 'no-matching-version' 找。那个值永远不会出现 ——于是「npm 上还没有这一版(GitHub 先更新了)」这句台词一次都没说过,用户看到的永远是兜底的那句「没装成。」。

这个错之所以能活下来,是因为预检里的替身当初也是照错的形状喂的数据: { ok: false, error: { code: 'no-matching-version' } } —— 平台从不这么回。替身说谎,
断言就只证明了替身自己。现在替身按平台真实的 ChangeResult 喂(application: 'failed'
+ error.code: 'operation-error' + packageResult.kind)。

另一条更坏的路是回信根本没回来(ok: false):它被当成了「没装成」。可我们并不知道装没装完 —— 平台自己的插件设置页把这一条叫 replyLost,做法是拿 waitForInstall(requestId) 把那次安装的结果捞回来。现在 installUpdate 会带一个 requestId 过去,回信没回来时先说「装没装完我不知道,我去问问」,再用同一个 id 去捞:

| 平台回来的 | 她说 |
| application: 'applied' | 装好了 —— 刷新页面就是新版 |
| application: 'restart-required' | 装好了 —— 重启 DSH 后生效 |
| failed + packageResult.kind | 九种分类各说各的(npm 没这一版 / 包不存在 / 网络没连上 / 写不进去 / 磁盘满 / 构建脚本被拦 / 校验没过 / pnpm 没动静 / 找不到 pnpm) |
| failed + error.code | 平台自己那几种拒绝:不兼容 / 不允许在这个壳里改插件 / 不是插件包 |
| failed,分类不出来 | 兜底句带上诊断的最后一句(以前整段诊断被丢掉) |
| ok: false | 「装没装完我不知道,我去问问」→ 捞到就说捞到的那句,捞不到就说「平台那边也没有记录」 |
| 调用抛了 | 同上 —— 没有回执 ≠ 装失败 |

doctor().update.install 多出 kind / why / logPath / requestId,state 多了一个
unknown(回信没回来又捞不到时的诚实答案)。预检 863 → 874 项。

1.49.5

四套新皮肤:巫女(miko)/ 哥特(goth)/ 冬装(winter)/ 偶像(idol)——每套八个状态齐全(idle / happy / sleepy / work / attn / proud / poked / eat), build.mjs 的 SKIN_LABELS 与 SKIN_PRIORITY 里也排上了。

(这四套此前只存在于一台机器的工作区里、没有提交 —— 于是别的电脑拉下来看到的皮肤是少的。)

1.49.4

透明区域不再算她的占地:可见的像素才能真正贴到屏幕边。

位置夹取换成按不透明像素的包围盒算(命中层本来就在读同一份 alpha 蒙版):透明的地方允许探出屏幕外,可见的像素才停在屏幕上。四个角落里的"距边 26 / 46 px"量的也是可见的边,换皮肤时钉住"她更靠近的那条屏幕边",所以她离屏幕边的距离不随素材变。
doctor().bounds 能查到当前图的可见范围与可达范围。

1.49.3

深海女仆的三条帧动画按现在的立绘重做了一遍 —— 其中 happy 是新建的。

assets/deepsea/happy.webp 换过新画的一版(sha256 EA78CAA4…8A40),而这条素材 以前根本不存在:在深海女仆这套皮肤上戳她一下(happy)只有静态立绘 ——那一格在 MOTION_OF 里没有键、查不到素材、连 都不建。另外两条是重做:
assets/deepsea/{idle,sleepy}.webp 在 1.47.1(e3dae8c)被重画过一版
(PSNR ≈ 13.7 dB —— 不是重编码,是另一张画),而 deepsea_idle / deepsea_sleepy 是 1.46.5(8e08b5a)拿上一版立绘合成的:她站着是一张脸、动起来是另一张。
这三条正是 1.49.2 那条指纹断言留下的"已知例外",这一版把它们补上。

配方一个字没改(首帧=尾帧 + 品红幕采样 + colorkey=<采样色>:0.33:0.05 + 去品红边 + libwebp_anim -q:v 40 -loop 0 -an,与生成区那个加工脚本(deepsea)逐字相同),只是首尾帧按当前立绘重新量、重新合成、重新抠:

| 素材 | 体积 | 不透明% | 半透明% | 幕布残留% | 包围盒占盒高 | 幕布色 |
| deepsea_happy(新建) | 2947.7 KB | 30.712 | 0.331 | 0.0000 | 0.9898 | #FD00FD |
| deepsea_idle 旧(1.46.5 那版) | 2693.3 KB | 27.016 | 0.284 | 0.006 | 0.9781 | 品红 |
| deepsea_idle 新(1.49.3) | 2873.3 KB | 28.856 | 0.341 | 0.0000 | 0.9891 | #FD01FC |
| deepsea_sleepy 旧(1.46.5 那版) | 3318.6 KB | 17.379 | 1.310 | 0.0017 | 0.8359 | 绿 |
| deepsea_sleepy 新(1.49.3) | 2671.8 KB | 28.956 | 0.309 | 0.0000 | 0.9875 | #FF00FE |

三条门槛全过:不透明 ≥15%(28.856 / 30.712 / 28.956)、幕布残留 ≤0.05% (三条都是 0.0000%)、平滑带越高越柔(0.341 / 0.331 / 0.309)。
体积这一轮有涨有落,都是立绘本身变了:idle 2693.3 → 2873.3 KB(+6.7%,新立绘的手臂与头发更外扩)、sleepy 3318.6 → 2671.8 KB(−19.5%)(新立绘更收), happy 是净增的 2947.7 KB。十九条素材合计 59304.4 → 61785.5 KB(60.3 MB),预检里的 MOTION_DISK_BUDGET_KB 跟着从 68000 提到 71000(+14.9% 余量,还是那条规矩)。
旧那两行的体积/不透明/半透明/幕布残留是 1.46.5 记下的数(旧素材的备份留在 wisp-art/.deepsea-tmp/old_deepsea_.webp);包围盒占盒高那一列是这回用同一把尺子(阈值 A>24)重新量的 —— 它正是下面那条尺寸校正的判据。

尺寸校正:deepsea_sleepy 那条 scale(1.186) 退役了,MOTION_FIT 成了空表。

| 素材 | 素材包围盒占盒高(97 帧) | 当前立绘 | 差 | 判定(≥2% 才补) |
| deepsea_idle | 0.9891(97 帧恒定) | 1.0000 | 1.09% | 不补 |
| deepsea_happy | 0.9898(0.9812~0.9898) | 0.9987 | 0.89% | 不补 |
| deepsea_sleepy | 0.9875(0.9734~0.9875) | 0.9980 | 1.06% | 不补 |

推导演算(旧那一条,留作这套算法的样子):旧版 sleepy 素材里她只占 0.8359 个盒高(1.46.x 记的 0.8352 是同一把尺子、阈值 A>127 量的),而同一条立绘占 0.9883 ——差 15.7%,不补就是"睡着时她整个人小一圈"。放大倍率 = 0.9883/0.8352 = 1.1834 (取 1.186 —— 97 帧之间包围盒自己还在 ±0.2% 抖,1.186 落在它中间);底边距 0.0883 缩放后是 0.1047,要收回立绘那 0.0117 就得下移 0.0930 个盒高,而
scale(s) translateY(t) 是右乘先生效(translate 先作用在元素自身坐标里、再被 s
缩放一次,真正下移的是 st),故 t = 0.0930/1.186 = 7.84%。验算:0.8352×1.186 = 0.9905 高(立绘 0.9883,差 0.2% ≈ 1px),底边距 0.1047 − 0.0930 = 0.0117(立绘 0.0117)—— 高度与落地一起对上。

新三条全在 2% 那条线以内(最大的 1.09%,在 210px 的盒子里是 2px 量级),于是: ① MOTION_FIT 空表;② 样式表 base 那条 .wisp-video{…transform:none…}; ③ 三条按家族写的"显式不校正"规则([data-clip="deepsea_idle"] / [data-clip^="swim_"] / [data-clip^="canon_"])原样保留 —— 它们现在说的是"每一族都量过、都不补",而不是"挡住 base 那条 1.186"。为什么 base 必须一起归零:deepsea_happy 是新素材,没有人为它写过 [data-clip] 规则 —— base 里那 1.186 要是留着,它落到这条新素材头上就是凭空放大 18.6%,而画面上只会看起来"她今天有点大"。三个落点(样式表那条 base、行内 applyClipGeometry、预检/探针的断言)在同一版里一起改,没有任何一处留在旧值上。

指纹表补上了这三条。 1.49.2 加的那张 MOTION_SOURCES 里,两条深海女仆素材当时记的是"当前立绘",并标注为已知的历史例外(挡得住下一次改动、挡不住 1.47.1 那一次)。
三条重做之后例外没有了:deepsea_happy 新增一行(EA78CAA4…8A40),idle / sleepy 两行仍然是当前立绘的字节 —— 只是含义从"已经漂过一次"变成"素材就是拿这一版做的"。
预检里那条"两段素材都要在"的断言扩成三条(deepsea_idle / deepsea_happy / deepsea_sleepy,少一条或名字写错就红)。

提交:3 条素材 = 3 次 create_video_task,三条都是第一次提交就出。 客户端照例在 60 秒处返回 Request timed out(超时不是失败),产物分别在 2 分 40 秒 / 2 分 38 秒 / 1 分 35 秒后落地(cgt-20261007042028-53utu… 4376865 B、cgt-20261007042406-6qskh… 3171602 B、cgt-20261007042657-0k32j… 1672836 B)。首帧 MAE 认领没有歧义: happy 5.63(次像 idle 27.43)、idle 4.91(次像 happy 27.43)、sleepy 4.45(次像 idle 31.51)。
四条命令(node build.mjs / node verify-wisp.mjs ×2 / node tools/engine-probe.mjs / node tools/pack-check.mjs)全绿。

1.49.2

原版皮肤的「干饭」那一段动图重做了一遍 —— 它和现在的立绘不是同一个人。

assets/canon/eat.webp 换了新画的一张(sha256 45E5D3F1…C8D0,1024×1536, mtime 2026-10-07 03:58:10),而 assets/motion/canon_eat.webp 还是拿上一张 合成的:她站着是一张脸、动起来是另一张脸。画面上不报错,四条命令也全绿 ——那条素材本身完全合法(720×1280 / 97 帧 / 三条门槛全过)。只有把"素材是拿哪张立绘做的"记下来,这件事才查得出来,所以这一版做了两件事:重做那一条,和补上那条记录。

配方一个字没改(首帧=尾帧 + 品红幕采样 + colorkey=<采样色>:0.33:0.05 + 去品红边 + libwebp_anim -q:v 40,与生成区那个加工脚本逐字相同),只是首尾帧换成新立绘重新量、重新合成、重新抠:

| | 体积 | 不透明% | 半透明% | 幕布残留% | 包围盒占盒高 | 幕布色 |
| 旧(1.49.0 那版) | 4272.5 KB | 38.434 | 0.327 | 0.0000 | 0.9750 | #FF00FE |
| 新(1.49.2) | 3399.9 KB | 33.105 | 0.279 | 0.0000 | 0.9844 | #FD01FB |

三条门槛全过:不透明 ≥15%(33.105%)、幕布残留 ≤0.05%(0.0000%)、平滑带 0.279%。
体积小了 872.6 KB(−20.4%),因为它本来就是全包最大的那条(4272.5 KB):新立绘双臂抱在身前、头发也更收,同样高度下她自己占的像素更少 —— 包围盒内部的填充率反而从 44.9% 升到 46.0%,也就是说少掉的是"她本来就窄了",不是被 colorkey 抠掉的(这正是"不透明 ≥15%"那条门槛守着的东西)。换完之后 assets/motion/ 十八条 60177.1 → 59304.4 KB(57.9 MB),最大的换成 canon_happy 3927.1 KB,预检里的 MOTION_DISK_BUDGET_KB 跟着从 69000 收到 68000(仍是 +14.7% 余量)。
(1.49.0 小节里那张八条的表记的是当时那一版,eat 那一行已经过期,以本节为准。)

尺寸校正:不补。 新素材的包围盒占盒高 0.9844(97 帧之间自己抖 0.9812~0.9844),新立绘占 0.9928,差 0.85%(旧那版是 0.74%)——离"≥2% 才写进 MOTION_FIT"那条线还差一倍多,所以 MOTION_FIT 一条都没加,样式表里 .wisp-video[data-clip^="canon_"]{transform:none} 那条显式"不校正" 照旧。落地线也在同一量级:素材底边 0.9859、立绘底边 0.9974(差 1.2%)。

预检多了一条指纹断言。 verify-wisp.mjs 的 4b 里多了一张 MOTION_SOURCES 表:每条素材声明自己是拿哪张立绘做的(立绘路径 + 生成时那一版的 sha256),预检逐条比对当前立绘的字节 —— 差一个字节就红,红的时候直接说"重画了立绘就要重做那条素材,并在这里更新指纹"。同一张表还有第二条守卫:素材清单里的每一条都必须在表里,新素材绕不过去(否则"忘了记"就是下一个静默漂移)。指纹是声明、不是推导:素材怎么来的写在生成区那两个加工脚本里(canon / swim),预检只记住当时用的是谁。
两条深海女仆素材是已知的历史例外:它们的立绘在 1.47.1(e3dae8c)被重做过一版(字节与像素都变了),而那两条素材是 1.46.5(8e08b5a)做的 —— 表里记的是当前立绘,挡得住下一次改动,挡不住那一次;要修那次漂移只能重做那两条素材,而它们不在这一版的范围里(这一版只有 canon_eat 一条被动过,其余十七条一个字节没改)。

这一轮提交了 1 次 create_video_task,一次就出。 客户端照例在 60 秒处返回 Request timed out(超时不是失败),产物 2 分 45 秒后落地(cgt-20261007040102-2iddo_20261006200345.mp4,3288.2 KB)—— 中间隔着"先等 2~8 分钟找产物"那一步,确认没有产物才不会重提,这一次没有第二次提交。素材识别用首帧 MAE 认领(eat 5.24,第二像的 work 21.61),没有并发混淆。四条命令(node build.mjs / node verify-wisp.mjs ×2 / node tools/engine-probe.mjs / node tools/pack-check.mjs)全绿。

1.49.1

三处修好 + 一个新开关。

1. 子菜单里的「台词语言」「动作幅度」以前点不动。 菜单分组改版时,子菜单那条渲染路径自己抄了一份循环、漏了 kind: 'choice' —— 于是它们变成一个没有胶囊、点了没反应的普通行(没有 run,异常被 try/catch 吞掉)。现在子菜单与顶层共用同一个 renderChildren()。
用户报的「动作幅度好像没用」,根因就是它。
2. 菜单里调过的每一项都活过刷新。 以前只有「大小」和「台词语言」会记;现在 motion / frame / sway / wander / reactions / celebrate / hungry / night / sound 一起记在 dsh-wisp:prefs:v1,加载时取回 —— 配置里显式写过的键仍然以配置为准。
3. 「左右晃动」开关(行为组):跟随鼠标侧倾那条路的总闸。开 = 指针进到 170px 内朝指针方向侧身(最多 3.5°、越近倾得越多),关 = 不再跟随鼠标并把姿势当场归零。默认开(= 一直以来的行为)。
4. 「动作幅度」讲清楚它到底改什么:克制 = 手势与侧倾减半、溜达距离 ×0.4、间隔 ×2.5;选完当场演示一遍(弹一下 + 侧身);doctor().motion.effects 报出这一档的实际效果。

1.49.0

帧动画不再有"通用兜底" —— 那两条旧素材归了位,原版(默认皮肤)补上八条。

1.47.0 把素材改成"按皮肤+状态"点,但留了一对通用素材(idle / sleepy)当兜底:任何皮肤在某个状态查不到自己那一段时,播的都是它们。这在画面上不报错,却是当场换装 —— 穿着宵蓝礼服的人在做深海女仆的动作。那两条素材本来就是拿
assets/deepsea/{idle,sleepy}.webp 合成的,所以这一版把它们按来源改名:

| 旧名 | 新名 | 接线 |
| assets/motion/idle.webp | assets/motion/deepsea_idle.webp | 'deepsea:idle' |
| assets/motion/sleepy.webp | assets/motion/deepsea_sleepy.webp | 'deepsea:sleep' |

改名走的是 git mv 语义,字节一个没动(两条的 sha256 前后逐位相同: 37EEA1E4…DC55 / 4891AB90…8819,git 里也是 R 而不是"删一个加一个")。
跟着一起改的地方:MOTION_OF 的两条键、MOTION_FIT 里那条尺寸校正的键(sleepy → deepsea_sleepy)、样式表里那条"显式不校正"规则
([data-clip="idle"] → [data-clip="deepsea_idle"])、宿主半包 __diag 的默认探针名
与示例 URL、tools/motion-http.mjs 的取样名,以及预检 / 探针里所有点到这两条素材的地方(SHIPPED_SWIM 旁边多了 SHIPPED_CANON,motionCanvas('idle.webp') 全部换成新名)。

兜底表删掉之后,"查不到的皮肤+状态"变成一个真实状态。 motionKeyFor() 只查一次(没有第二级 ??),syncMotion() 查不到就连 都不建 —— 不建元素、不解码、画面交给静态立绘。预检同时钉三件事:MOTION_BASE 这个名字在发行版里一个字都不许剩、 motionKeyFor 不许再出现 ??、以及宵蓝礼服(night)在九个状态下都不建动图元素、
doctor().motion.frame 的 asset / built / showing 三个字段全空。
真引擎探针里另有一条同题断言:素材没生成时,八个状态一个 .wisp-video 都不存在。

原版(canon)八条帧动画。 默认皮肤是原版(1.48.2 起),它没有素材时"她不动"最显眼。
八条全部照抄泳装那一轮的配方(首帧=尾帧 + 品红幕采样 + colorkey + 去品红边),每条 720×1280 / 24 fps / 4.04 秒循环(97 帧),由宿主半包按 URL 发给页面。
attn 那条在环缝那一轮重做过:现在 49 帧 = 2.04 秒,环缝从 2.28x 降到 0.57x,体积也少了一半(见下面「环缝」一节的说明):

| 状态 | 素材 | 体积 | 不透明% | 半透明% | 品红残留% |
| idle | canon_idle.webp | 3537.2 KB | 34.315 | 0.322 | 0.0000 |
| attn | canon_attn.webp | 1722.4 KB | 35.160 | 0.348 | 0.0000 |
| happy | canon_happy.webp | 3927.1 KB | 36.368 | 0.387 | 0.0000 |
| sleep | canon_sleepy.webp | 3834.3 KB | 34.404 | 0.360 | 0.0000 |
| alert | canon_work.webp | 3701.1 KB | 34.118 | 0.320 | 0.0000 |
| proud | canon_proud.webp | 3650.1 KB | 32.631 | 0.284 | 0.0000 |
| eat | canon_eat.webp | 4272.5 KB | 38.434 | 0.327 | 0.0000 |
| poked | canon_poked.webp | 3855.1 KB | 34.420 | 0.356 | 0.0000 |

三条门槛全过:不透明 ≥15%(实测 32.6~38.4%)、品红残留 ≤0.05%(实测 0.0000%)、平滑带 0.284~0.387%。八条合计 30377.1 KB;assets/motion/ 现在 18 条 / 60177.1 KB(58.8 MB)。采到的幕布色这一轮有五种: #FD01FC(idle)、#FF00FE(attn / sleepy / proud / eat)、#FD00FE(happy)、 #FE02FF(work)、#FD00FF(poked)—— 再次印证"幕布色必须逐条采样"。

尺寸校正:八条都不用补。 按包围盒(A>24)逐条量"她占盒子高多少":素材 0.9750~0.9906,各自对应的原版立绘 0.9824~1.0000,差 0.7%~1.1%,八条全在 "小于 2% 就不补"那条线以内(最大的是 canon_idle 的 1.09%,在 210px 的盒子里 2px 量级):

| 状态 | 素材占盒高 | 立绘占盒高 | 差 |
| idle | 0.9891 | 1.0000 | 1.09% |
| attn | 0.9812 | 0.9902 | 0.90% |
| happy | 0.9906 | 1.0000 | 0.94% |
| sleep | 0.9891 | 1.0000 | 1.09% |
| alert | 0.9891 | 1.0000 | 1.09% |
| proud | 0.9891 | 1.0000 | 1.09% |
| eat | 0.9750 | 0.9824 | 0.74% |
| poked | 0.9867 | 0.9961 | 0.94% |

所以 MOTION_FIT 只经历了一次改名(sleepy → deepsea_sleepy),一条新条目都没加;样式表多了 .wisp-video[data-clip^="canon_"]{transform:none} —— 不补也要显式地不补,否则那条给 deepsea_sleepy 的 1.186 会落到这八条头上(凭空放大 18.6%)。
这是第三批走同一条路:deepsea_idle 一条、swim_ 一条、canon_ 一条,各写各的。

体积上界跟着实测改了:预检里的 MOTION_DISK_BUDGET_KB 从 34000 提到 69000 (实测十八条 60177.1 KB;上界仍按上一版那条规矩留约 14% 重编码余量)。
内联那份仍是文件名清单,实测 606 B(上界 2048 B 不变)。

这一轮一共提交了 8 次 create_video_task,八条全部一次过 ——每条调用都照例在客户端 60 秒处返回 Request timed out,产物在 2.5 分钟左右落地(两次超时之间隔着"先等 2~8 分钟找产物"这一步,没有任何一条被重复提交)。
没有内容安全拒绝、没有空产出。上一轮泳装那八条是 13 次提交 7 条产出,这一轮八条八中,提交数与素材数第一次做到 1:1。

1.48.3

宿主半包之前一次都没跑过 —— inject 那一行声明把整个插件钉死在 PENDING。

怎么发现的:plugin_manager list_plugins 里 dsh-wisp 的 fiberPhase 是 pending,安装日志原话是 wisp (dsh-wisp): pending (waiting for services: required, optional)。
fiber 从未激活 ⇒ apply() 一次都没执行 ⇒ /wisp-motion/ 路由、checkUpdate、 checkBalance 全部不存在;而客户端半包走的是另一条投递路径(/plugins 路由直接从磁盘发 lib/client.js),所以只有客户端改动会"立竿见影"。

成因:cordis 的静态 inject 不是 { required, optional },而是"服务名 → 拦截配置"的映射 (或名字数组),归一化规则就是

Array.isArray(inject) ? [...inject] : Object.keys(inject)

于是 export const inject = { required: [], optional: ['web', 'deepseekAccount'] } 声明的是两个叫 required 和 optional 的服务 —— 这两个名字在任何组合里都不存在。
Fiber._refresh() 因此永远凑不齐 _store,fiber 永远停在 PENDING,apply() 永远不会被调用。

为什么重启没用。 声明每次开机都要重新读一遍,于是每次开机都以同样的方式 park —— 这不是 "加载时序没赶上",重启多少次都不会变。而界面上看到的只是"她不动""查不了更新""查余额说这个壳里没有",三种症状各自都像"这一段本来就这样"。

修法:一条 inject 都不声明(export const inject = [])。 这个插件本来就在调用时 用 ctx.get(name) 取服务(resolveWebService / createServiceReader / resolveAccountService / readManagedVersion)—— ctx.get 读注册表不需要任何声明。零声明 = _refresh() 无服务可等, 任何组合里 fiber 都会立刻激活;反过来说,这里每加一个服务名,就多一个能让整只插件消失的失败点,所以"能激活"优先于"声明得漂亮"。余额那一路顺手补齐:ctx.deepseekAccount 在没提供该服务的壳里是抛错而不是 undefined,现在先 ctx.get('deepseekAccount')、属性访问只作兜底且包在 guard 里 —— "这个壳没有账户服务"因此仍然是一句话,而不是一次 rejected 调用。

现场检查口:GET /wisp-motion/__diag 现在也回答"宿主半包到底跑没跑"。 它多了一段 host: applied / at / registered / reason / handlers(checkUpdate、checkBalance)/ motion。
被 pending 钉死时,"路由没注册"和"这一段就是这样"从外面看一模一样,有了这段 JSON 就不必猜。

预检里有一条断言曾经是反的,v1.48.3 改正并钉住。 旧断言要求 inject 必须是 { required: [], optional: ['web', 'deepseekAccount'] }("web 要声明成可选")—— 那条所谓的安全属性本身就是把 fiber 钉死的声明。现在的判据是声明的服务名必须都是运行时确实注册的,而这份包能证明的只有空集(需要的服务全部调用时 ctx.get),所以断言就是"零声明";那个字面量再单独钉一条(出现 required / optional 键就红)。另外两条:把属性访问做成会抛错时,两个服务仍必须能通过 ctx.get 拿到;apply() 在"没有服务、没有 ctx.get、没有 effect"的上下文里也必须照样跑完 —— 这是"任何组合都不会把她钉住"的正面证明。

预检 815 → 817 项。

1.48.2

默认皮肤换成「原版女仆」(canon),皮肤列表里也排到第一位。

canon 是对齐社区规范的那一套(深蓝长直发 + 鲸鳍耳 + 女仆装),在此之前门面一直是「深海女仆」。
默认皮肤由 build.mjs 的 SKIN_PRIORITY 决定 —— 列表首位就是默认,所以只改了一行顺序: ['canon', 'deepsea', …]。构建产物里精灵图的顺序跟着变,菜单「外观」里第一项自然就是它。

对已经在用的人没有影响:配置里的 skin、或上次在菜单里选过的那一套仍然优先(记在 localStorage),只有新装、或清过存储的用户才会看到新的默认。

1.48.1

动作素材的 HTTP 路由真的能取到字节了 —— 根因是注册键多了一个尾斜杠,不是读不到文件。

现象(1.47.0 起就在,只是没人量过):GET http://127.0.0.1:19387/wisp-motion/idle.webp → 404,客户端静默降级回静态立绘 ⇒ 用户看到的是"帧动画开关开着、她还是静的"。

而一个 404 说不出是哪一半坏了,这正是它藏了两版的原因:

| 可能 | 现场 |
| ① 路由没被命中(没注册 / 注册了但匹配不上) | 请求根本没进 handler,平台自己的 404 兜底答的 |
| ② 路由匹配上了、但读不到文件 | handler 跑了,自己发的 404 |

分辨的办法(这一版才有的):每个响应都带 x-wisp-motion: hit —— 平台兜底的 404 既没有这个头、也没有 content-type;再加一条只读诊断 GET /wisp-motion/__diag,它把"注册键在载体规则下匹不匹配得上" 直接算给你看,连同命中次数、走哪条读路径、要读的绝对路径、真读一遍的字节数与最后一次错误。

答案落在 ①,而且是更细的那一种:路由注册成功了,注册键却匹配不上任何页面请求。
载体(@deepseek-ai/dsh-host-webserver 的 match())的前缀匹配不是 startsWith(prefix):

pathname === prefix || pathname.startsWith(${prefix}/) // 斜杠由载体自己补

1.47.0/1.47.1 注册的是 '/wisp-motion/'(带尾斜杠):它只匹配 /wisp-motion 与 /wisp-motion//…,页面唯一会请求的 /wisp-motion/idle.webp 一条都匹配不上。于是 fiber 拿到了路由、dispose 正常、函数级预检全绿,而一次请求都没到过 handler。现在两个名字分开、不再混用:

| 名字 | 值 | 用在哪 |
| MOTION_ROUTE_PATH | /wisp-motion | 注册键(载体自己补斜杠) |
| MOTION_PATH | /wisp-motion/ | URL 前缀(客户端、预检、诊断) |

预检里照着载体那条规则把两种拼法各跑一遍:正确的键必须匹配,带尾斜杠的那种必须不匹配。

读文件走平台的服务,不走 Node 模块:ctx.get('fs')(拿不到时退回 ctx.fs,每次请求惰性解析)。
签名是查过活的 Service 注册表才写的,不是猜的:fs.resolve(path, opts?)(本地后端会 realpath)、
fs.contains(parent, child)(canonical 包含判断)、fs.stat(target, signal?)(type / size)、
fs.readBytes(target, signal, maxBytes)(超限报 FS_TOO_LARGE)。于是除名字规则(单段、无分隔符、
无 ..)之外还有一道真实路径前缀校验:assets/motion/ 里的符号链接指到包外, resolve 出来的真实路径就落在目录外,contains 直接拒。预检用一个记账的假 fs 服务把这条链完整驱动一遍(resolve → contains → stat → readBytes),并钉住"服务不在时返回读不到,绝不偷偷回落到 node:fs"。

验收:四个 HTTP 结果(真 socket、真 handler)。node tools/motion-http.mjs 自建一个真载体(node:http + 载体原样的注册表与匹配规则),让真的 registerMotionRoute() 自己把路由挂上去:

| 请求 | 结果 |
| GET /wisp-motion/idle.webp | 200 · 2757928 B,与磁盘逐字节一致(2693.3 KB) |
| GET /wisp-motion/swim_idle.webp | 200 · 2823104 B,与磁盘逐字节一致 |
| GET /wisp-motion/nope.webp | 404,带 x-wisp-motion: hit(答话的是我们的 handler) |
| GET /wisp-motion/../package.json | 404,没有字节漏出去;..%2f(能进到 handler 的那种写法)同样 404 且带标记 |

运行中的那个进程要重启一次 —— 这是唯一没能在活进程里验到的部分,说清楚: 平台的 dsh-hmr 文件监视器忽略 /node_modules,而插件目录正是 profiles/desktop/node_modules/dsh-wisp(一个指向仓库的符号链接);Loader 导入模块也不带缓存戳,所以重新激活一个已安装的条目拿到的还是同一代模块。平台自己的插件开发文档写得很直白: "replacing an installed package requires restart to load a fresh JavaScript module generation"。
于是 lib/index.js 改完之后活进程里仍是旧模块 —— 用 node tools/motion-http.mjs --live http://127.0.0.1:19387 一眼能看出来:四个请求全是 404,而且没有一个带 x-wisp-motion 头(答话的是平台兜底)。重启托盘进程后再跑同一条命令,应当看到 200 与上面的字节数。

客户端多了一条可观测性:doctor().motion.frame 新增 fetchError({ clip, src, at, count },
没失败过是 null)与 failedClips。以前只有 failed: true —— "这一段没有素材"和"URL 取不到字节" 在报告里长得一模一样,而画面上都只是静态立绘。失败依旧静默降级(藏动图、交回立绘、不抛、不写控制台),变的只是事后查得到什么。

1.48.0

查到新版可以直接装了 —— 不再只是"告诉你,去插件列表里自己装"。

以前这一步卡在一个真实结论上:客户端半包没有 host RPC 座位(1.44.0 那条教训),而"装"又需要文件系统和包管理器 —— 所以插件只能报版本号,让用户自己去 DSH 的插件列表粘贴包名。

漏掉的是:平台把插件管理器自己也做成了客户端可用的 Remote 命名空间。

ctx.remote.pluginManager.installBundle('dsh-wisp@1.48.0') // → ChangeResult

平台自己的「插件」设置页(安装 / 更新 / 停用 / 卸载)走的就是这个命名空间(installBundle / waitForInstall / cancelInstall / listPlugins …)。
它不需要 host 座位,也不需要插件碰网络或凭据 —— 装这一步由平台带着 pnpm 与 profile 的写权限去做。这也正是"dsh 里万物皆插件"该有的样子:要用什么能力,先看平台有没有把它开成服务。

现在的流程:菜单「关于她…」→ 检查更新 → 查到新版时弹窗里直接出现 「安装 v1.48.0」 → 点一下就开始装,她说一句进度,装完照平台的结论说话:

| 平台说 | 她说 |
| application: 'applied' | 装好了 —— 刷新页面就是新版 |
| application: 'restart-required' | 装好了 —— 重启 DSH 后生效 |
| 信封 ok: false + no-matching-version | npm 上还没有这一版(GitHub 先更新了)—— 等一会儿再装 |
| 抛错 / 其他失败 | 没装成(原因进 doctor().update.install) |
| 这个壳里没有插件管理器 | 明说"我装不了",而不是装作做了 |

装的是刚才查到的那一版(dsh-wisp@<version>),不是 latest —— 提示里的数字和装下去的东西必须是同一个。安装有 3 分钟的宽松截止时间;到点了不谎报失败,只说"动作已经发出去了、还没回来"。

doctor().update.canSelfUpdate 从 false 改成 true(它现在真的能触发安装),
同时新增 install.via / install.state 可查。

1.47.1

泳装八个状态真的有动图了 —— 八条素材落盘,而接线早就在那儿等着。

1.47.0 把投递(宿主的 /wisp-motion/ 路由 + 客户端那张文件名清单)和接线
(MOTION_OF['swim:<状态>'] 按皮肤+状态点素材)都做完了,差的只有素材本身;这一版把八条补齐:
assets/motion/ 下多了 swim_idle / swim_attn / swim_happy / swim_sleepy / swim_work / swim_proud / swim_eat / swim_poked,每条 720×1280 / 24 fps / 97 帧 / 4.04 秒循环(和通用那两条同一把尺子,预检逐条断言),由宿主半包按 URL 发给页面。
一条素材对应一张立绘:sleep 那一档播的是 swim_sleepy(睡着那张立绘),alert 那一档播的是 swim_work(敲键盘那张)—— 素材按"哪张立绘"点,几何也按素材名查,两边不会各说各话。

配方照抄上面「帧动画素材」那一节写下的那一条(首帧=尾帧 + 品红幕采样 + colorkey + 去品红边的 RGB 那一步),八条一条没改。三条门槛全过:

| 状态 | 素材 | 体积 | 不透明% | 半透明% | 品红残留% |
| idle | swim_idle.webp | 2756.9 KB | 30.249 | 0.256 | 0.0000 |
| attn | swim_attn.webp | 3298.2 KB | 29.753 | 0.396 | 0.0000 |
| happy | swim_happy.webp | 3229.7 KB | 33.966 | 0.365 | 0.0000 |
| sleep | swim_sleepy.webp | 2888.3 KB | 30.303 | 0.321 | 0.0000 |
| alert | swim_work.webp | 2617.0 KB | 27.880 | 0.264 | 0.0000 |
| proud | swim_proud.webp | 2808.6 KB | 27.456 | 0.322 | 0.0000 |
| eat | swim_eat.webp | 3095.0 KB | 29.644 | 0.432 | 0.0000 |
| poked | swim_poked.webp | 3094.5 KB | 30.089 | 0.308 | 0.0000 |

不透明 ≥15%(实测 27.5~34.0%)、品红残留 ≤0.05%(实测 0.0000%)、平滑带 0.256~0.432%。
八条合计 23788.2 KB;加上通用那两条,assets/motion/ 现在 10 条 / 29800.0 KB(29.1 MB)。

尺寸校正:八条都不用补。 按包围盒(A>24)逐条量"她占盒子高多少":素材 0.9515~0.9858,各自对应的泳装立绘 0.9674~0.9967,差 0.2%~1.6%,八条全在"小于 2% 就不补"那条线以内(最大的是 swim_proud 的 1.6%,在 210px 的盒子里 3px 量级)。所以 MOTION_FIT 一个字都没变,但样式表多了一条 .wisp-video[data-clip^="swim_"]{transform:none} ——不补也要显式地不补,否则 base 那条给通用 sleepy 的 scale(1.186) 会落到这八条头上(凭空放大 18.6%)。sleepy 那条 1.186 与 idle 那条"不补"都原样保留。

体积上界跟着实测改了:预检里的 MOTION_DISK_BUDGET_KB 从 39000 收到 34000 (实测十条 29800.0 KB;上界按"每条不超过实测最大 3.3 MB"估,再留一点重编码余量),内联那份仍是 2048 B 的清单、实测 302 B。

这一轮一共提交了 13 次 create_video_task(本轮要生成的 7 条里,4 条一次过: happy / sleepy / work / poked): attn 提交 3 次、eat 提交 3 次 —— 前两次调用都正常发出、却在客户端 60 秒超时之后 既没有产物落地、也没有任何错误(同参重提第三次就成了);proud 提交 3 次 ——前两次返回的是内容安全拒绝(输入图像可能包含敏感信息,请更换后重试), 立绘本身一个像素没改,换的只是首帧的取景余量(裁切 12→28px、缩放 1266→1252)后才通过。
这 13 次里 7 次产出素材、2 次内容安全拒绝、4 次无产物;swim/idle 那一条用的是上一轮已经生成好的源片,没有计入这 13 次。

动作一览不再点一下就关。

以前点一个动作预览,弹窗立刻消失(当时的理由是"关掉才看得见她")。实际用起来是:想连看三个动作,就得重开三次窗,每次还要重新找那一格。现在预览不关窗 —— 点一个演一个,可以连着点;关窗仍有三条路:Esc / 右上角 × / 点背景。

焦点也不再交还给她:弹窗还在,被点的那个格子就还在,焦点留在原地(以前必须交接,是因为那个格子会随窗一起消失)。弹窗里的说明也跟着改了:"可以连着点,窗不会关。"

1.47.0

新增「帧动画」独立开关。

动图化之后(1.46.5 起 idle 与 sleep 各有一段),"我不想让她一直动"只有一个粗开关:把「动作幅度」整个调到 静止 —— 那是连呼吸、z、一次性动作一起停。现在多了一个只管画面的开关:右键 → 行为 → 帧动画。关掉之后画面永远交给静态立绘,其余动作照旧。

它走的是和"减少动态效果 / 静止档 / 躲起来"同一条闸门 motionFrozen() ——
一处判断,syncMotion() 用它决定画面归谁,doctor() 用它汇报,
所以不会出现"报告说在动、画面上停着"这种分歧。配置键 frame(默认 true,也能在 profile 里写死)。

doctor().motion.frame 多了一个 enabled 字段:一眼分清"她不动"是哪个原因(开关 / 幅度 / 系统设置 / 躲起来)。

帧动画素材改成外置投递:lib/client.js 15.3 MB → 7.8 MB。

720p 的动图是 MB 级素材:通用那两条内联就是 7.83 MB,八条泳装再内联会到 ~45 MB。
所以 1.47.0 把投递换了一条路 —— 客户端半包里的 MOTION 表只留文件名,字节留在 assets/motion/ 里,由宿主半包注册的 /wisp-motion/<文件> 路由发给页面(ctx.webServer.register),客户端只做 new URL('wisp-motion/<文件>', document.baseURI)。

为什么是这条路:@deepseek-ai/dsh-client-resources 查过了,不是素材路 ——它是 dsh-resource://<协议> 的取值模型(要有 provider、挂在 slot 上、给的是值);平台给插件用的 HTTP 载体才是(dsh-host-webserver 明写"let the feature plugins claim their routes"),而宿主半包是 Loader 按 ES module 导入的,import.meta.url 就是包目录。
失败是静默的、有意的:路由不在 / 404 / 解码失败 ⇒ 的 error ⇒ 动图收起来、
画面交回静态立绘。doctor().motion.frame 报 asset / src / failed。
* 体积实测:内联部分 = 一张文件名清单(十条合计 30 字节量级,预检上界 2048 B);磁盘部分是十条素材本身(预检按实测设上限)。
* 接线:MOTION_OF 从"按状态"改成"按皮肤+状态"('swim:sleep' → 'swim_sleepy'),通用那一对(idle / sleepy)留给其它皮肤。换皮肤与换状态一样会同时换图与几何
(applySkin() 里那一句 syncMotion() 就是为它写的)。

泳装八个状态的素材:本轮没有生成出来。 生成配方(首帧=尾帧 + 品红幕采样 + 抠像)写在上面「帧动画素材」那一节,接线、预检与真引擎探针都已就位:把 assets/motion/swim_<立绘名>.webp 放进去,再跑一次 node build.mjs 就自动生效 ——在那之前这八个状态走静默降级(连元素都不建,不会退回去播通用那段循环,因为那是穿着泳装播别的皮肤的动作)。预检里那条"八条要么都在、要么都不在"盯的就是这个:半套素材是坏的,整套和没有都是好的。

1.46.5

站着时她也在动了:idle 接上第二条循环,两段素材一起塞进 8 MB。

assets/motion/idle.webp(720×1280 / 24fps / 4.04 秒 / 97 帧,一帧没抽)接进 idle 状态 ——挂载即播,换状态时同一个 换源。动作层那一套规则一条没动:pointer-events:none、与立绘同盒、冻结(静止档 / prefers-reduced-motion / 躲起来 / 加载失败)=藏起动图 + 交回静态立绘、动作层不许有 CSS filter、动图显示时静态立绘隐藏。

体积:两段合计内联 7.83 MB(目标 ≤ 8 MB)。 上一版一段素材就是 5031.3 KB,两段按老参数(-q:v 60,blend 0.06 / 0.08)要 9.69 MB。三个候选档都量了同样的三个指标:

| 档位 | sleepy / idle 原始 | 合计内联 | idle 不透明 / 半透明 / 品红残留 | sleepy 不透明 / 半透明 / 绿残留 |
| 老参数(q:v 60,blend 0.06 / 0.08) | 3773.4 / 3665.3 KB | 9918 KB(9.69 MB)✗ | 27.016% / 0.437% / 1.09% | 17.379% / 1.396% / 0.0012% |
| 采用(q:v 40,blend 0.05 两条) | 3318.6 / 2693.3 KB | 8015.8 KB(7.83 MB) ✓ | 27.016% / 0.284% / 0.006% | 17.379% / 1.310% / 0.0017% |
| 备份(q:v 35,blend 0.06 / 0.08) | 3310.2 / 2828.8 KB | 8185.3 KB(7.99 MB)✓ | 27.016% / 0.437% / 0.008% | 17.379% / 1.396% / 0.0021% |

- 没有降 fps、没有抽帧、没有降分辨率:两条都还是 720×1280 / 24fps / 97 帧(预检钉着)。
- 也没有缩短时长 —— 量过才敢说不行:这段动作不是 2 秒周期的。帧 0 与帧 48 的差是噪声底的 15.2 倍,而整段 4.04 秒的接缝只有 2.0 倍(也就是说帧 96 几乎就是帧 0)。
砍成 2 秒会在循环点上"啪"地弹回另一个姿势;48/49 帧的各种窗口里最好的也有 7.3 倍。
4.04 秒是这段素材唯一能无缝接上的长度。
- blend 0.06 / 0.08 → 0.05(预检钉住的下限):这条过渡带是体积的大头 —— sleepy 在 q:v 40 下把 blend 归零,3405.7 KB → 1867.8 KB(省 45%)。全不透明阈值(similarity + blend)一点没动,所以她自己一个像素都没少(两条不透明占比与上一版逐位相同)。代价是半透明边缘 0.44% → 0.28%(idle):备份档那个 0.44% 更"柔",但它只剩 0.1% 的余量,而 0.05 是项目自己定的下限。
- -q:v 60 → 40:两段同时进包时 60 装不下(9.69 MB)。这是这一版唯一降质的地方,而在这个内容上 40 与 60 的差别很小(sleepy 4440 → 3406 KB,560px 的显示尺寸下看不出块效应)。1.46.4 之所以从 40 提到 60,理由是"块效应叠在硬边上更显锯齿"—— 现在边是软的。
- idle 那段的幕布是品红(#FE01FF),并补了一条去品红边:源片就是这样生成的,品红对她其实更远(按 colorkey 那把尺子量色距:青色发梢到绿幕 0.24、到品红 0.51;肤色 0.24 对 0.35)。但绿幕那条去边规则不能照搬:B = min(B, max(G,R)) 会把"蓝色比红绿都强"的头发本身削掉。新规则只减"R、B 同时高于 G"的那一份 —— 她的蓝
(min(R,B) < G)一个像素不动。实测品红残留 1.09% → 0.006%:头发边上那圈紫边没了
(2× 放大对比过:没去边的版本沿发梢有一整条粉紫线)。
- 动作层的几何按素材取:新增 MOTION_FIT 表 + 元素上的 data-clip,样式表用
.wisp-video[data-clip="idle"]{transform:none} 把"不校正"写成显式规则(否则 base 那条
1.186 会落到 idle 头上 —— 放大 18.6%)。idle 不补尺寸校正:她在那张里占 97.2% 盒高、立绘 98.2~98.8%,差 1.0~1.6% < 2%,在 210px 的盒子里是 2~3px —— 不值得为它多一条能和 "两层同格"对不上的变换。sleepy 的 scale(1.186) translateY(7.84%) 原样不动(它的包围盒实测仍是 83.3% 高 / 8.83% 底边距,与上一版逐位相同)。
- 失败按素材记:一段素材加载失败后不再重指源(重试坏源只会每次换表情烧一次解码),
但另一段照常播;doctor().motion.frame.asset 也改成"查得到才算数"(表里没有这一段时
报 null,而不是报一个根本不存在的素材名)。这两条都是新断言逼出来的。
- 预检新增:bundle 里同时有 idle 与 sleepy(且没有"没有状态会播"的白带素材); idle 在挂载时就建动图元素并接管画面;换状态时同一个元素换源、几何跟着换(data-clip 一起变);idle 的行内 transform 是 none;坏掉一段之后另一段仍然会播。真实引擎探针 多读三件事:idle 动图的 naturalWidth = 素材自己的画布宽度(探针直接从 assets/motion/idle.webp 读 VP8X,不写死 720)、变换前与立绘盒子逐边一致、计算后 transform 是 none。体积预算 5540 → 8820 KB(两段实测内联 + 约 10%)。

1.46.4

睡着时她不再缩小一圈;锯齿也没了 —— 两个都是量出来的。

① 她比立绘小 15.7%。 两层共用同一个盒子(140×210、object-fit:contain、 object-position:bottom center),但两张素材里她占画面的比例不同。实测包围盒(阈值 A>127):

| 素材 | 画面 | 她占画面高 | 底边距 |
| 立绘 deepsea/sleepy.webp | 1024×1536(2:3,与盒子同比例 → contain 正好铺满盒高) | 98.8% | 1.17%(18px) |
| 动图 motion/sleepy.webp | 720×1280(0.5625 窄于盒子 → contain 按高贴合) | 83.5% | 8.83%(113px) |

于是"画面交给她"的那一刻她缩了 15.7%。修法不重做素材,只给动作层一条变换:

transform: scale(1.186) translateY(7.84%) /* transform-origin: 50% 100% /

- 放大倍率 = 0.9883 / 0.8352 = 1.1834,取 1.186(97 帧之间包围盒自己还在 ±0.2% 抖);
- origin 放脚底,缩放不动底边 —— 但底边距会跟着放大成 0.0883 × 1.186 = 0.1047,要收回立绘那 0.0117 就得让底边下移 0.0930 个盒高;
- 而 scale(s) translateY(t) 是右乘先生效:translate 先作用在元素自身坐标里、再被 s 缩放一次,真正下移的是 s × t —— 所以 t = 0.0930 / 1.186 = 7.84%。
- 验算:0.8352 × 1.186 = 0.9905 高(立绘 0.9883,差 0.2% ≈ 1px),底边距回到 0.0117 ✓。

两个落点都写了:样式表 + 行内(项目惯例:样式表缺失的壳里画面也得是对的)。

② 锯齿:不是分辨率的问题,是 blend 的问题。 colorkey 的 blend 原先只有 0.02,过渡带宽度 = 2 × blend = 0.04 个色距 —— 边缘几乎是一刀切,1 bit 的 alpha 在 560px 的显示尺寸上就是一排台阶。放宽到 0.06(similarity 0.22 → 0.18,两者之和仍是 0.24,所以不透明占比一个像素都没少:17.38%),半透明边缘 0.80% → 1.40%(+74%)。

代价是绿幕的边也被一起放出来(绿边 0.078% → 0.119%),所以配套加了一步 RGB 去绿边 G = min(G, max(R, B)) —— 与 tools/keyout.mjs 给立绘用的同一条规则:绿边回到 0.001%,比修之前还干净,而她的颜色几乎没变(165,514 个不透明像素里只改了 1,176 个)。
WebP 质量同时从 -q:v 40 提到 60(块效应叠在硬边上只会更显锯齿)。

| | 修前 | 修后 |
| 素材 | 2866.7 KB(q:v 40 / blend 0.02) | 3773.4 KB(q:v 60 / blend 0.06 + 去绿边) |
| 内联 | 3822.5 KB | 5031.3 KB |
| 预检预算 | 4200 KB | 5540 KB(= 实测内联 + 10%) |
| 不透明 / 半透明 / 绿边 | 17.38% / 0.80% / 0.078% | 17.38% / 1.40% / 0.001% |

fps / 分辨率 / 帧数一个没动:720×1280 / 24fps / 97 帧(预检里新增断言钉着)。

预检新增:动作层的 CSS 必须含 scale(1.186) 与底部对齐的 transform-origin(样式表与行内两份都点名);文档里那条编码命令的 blend ≥ 0.05 且必须带 geq/despill 去绿边;素材几何 720×1280 / 97 帧 / 41ms。真实引擎探针多读两件事:动作层计算后的 transform 真的是 matrix(1.186, …),以及渲染出来的底边真的比立绘那条落地线低 9.3% 个盒高。

1.46.3

睡眠循环换成 720p 重生成。

原来生成 480×854,而她在屏幕上约 560 px 宽 —— 放大后发虚。改用 720p(720×1280,24fps,97 帧,首帧用她 1024×1536 原立绘合成,源不被放大)重生成,绿边 0.01%、不透明 18.1%。

代价是体积:素材 1718 → 2867 KB,内联 2.4 → 3.8 MB,客户端包约 6.4 → 10.2 MB。这条是用户明确选择的(问过:清晰度 vs 体积)。预检的体积预算随之从 2472 KB 提到 4200 KB。

1.46.2

把绿边抠干净。

上一版 colorkey 用 0.10/0.10:边缘残留绿色像素占 0.43%(放大后就是一圈绿光)。实测三档后改成 0.22/0.02 —— 绿边降到 0.01%,她自己从 18.0% 变成 16.8%(只削掉一点边 ✓),体积反而从 1854 KB 降到 1718 KB。

1.46.1

帧动画从 alpha 视频换成动图 WebP( → ,480 宽 / 24fps / 97 帧一帧不抽),理由是这个壳不还原 VP9 的 alpha 平面 —— 同一段素材她在暗色主题下渲染成黑色剪影、亮色主题下白色剪影(用户确认过),而图片路径的 alpha 是硬的。

「冻结」跟着改语义:动图没有 pause() 可喊,所以静止档 / prefers-reduced-motion / 躲起来时不再"暂停并回到首帧",而是把动图藏起来、把画面交回静态立绘(v1.45.2 那条 "画面同时只归一个人"照旧)。动作层不许有 CSS filter 这条规矩保留。

同时修掉一个更早的错:colorkey 的 similarity 从 0.26 收紧到 0.10。0.26 会把她的皮肤与浅色衣服一起抠掉 —— 实测只有 5.5% 的像素还是全不透明的(她自己变成空心的,暗色主题下看起来就是"一块黑色剪影"),0.10 时是 18.1%,也就是整个她。

1.46.0

查余额终于真的能用了 —— 根因和 1.44.0 那次一模一样:座位不对。

旧实现是「客户端 host.call('checkBalance') → 宿主半包 inject deepseekAccount」。
宿主半包那份代码写得没错,服务也真的存在(@deepseek-ai/dsh-deepseek-account,由 dsh-base 注册),但bundle 形态的客户端半包根本没有 host RPC 座位 ——那个座位只发给动态插件。于是真机上它永远走"这个壳里查不了余额"那一句,而预检里那条路是拿假座位测的,全绿。

现在客户端直接调平台自己的账户 Remote(平台自己的「账户」设置页用的就是它):

ctx.remote.account.getBalance({ version, locale, timezoneOffsetSeconds })
-> { ok: true, value } | { ok: false, reason }
value = { status:'ready', value:[wallets], bonusWallets:[…] } | { status:'failed' } | null

value 与宿主半包那条路的载荷完全同形,所以只在取数那一层分叉,归一化与台词完全共用。
null 依然是"没登录"与"刚才没读到"共用的一个值,照旧再问一次 getState() 才敢说话。

| | 什么时候用 | 凭据在哪 |
| remote.account Remote | 默认(bundle 安装) | 宿主,插件只能拿到归一化结果 |
| host 座位(host.call) | 动态插件形态(那里才有座位) | 同上 |
| 两条都没有 | 如实说"这个壳里我查不了余额" | —— |

doctor().balance 现在报 hostSeat / remoteSeat / via(这次真的走了哪条),
所以"为什么查不到"在 Console 里一眼能定位。

1.45.3

删掉两条身体层的程序动画:attn 的左右轻推(wisp-nudge)与 happy 的上下跳(wisp-hop)。

两条都靠 translate 挪整个 .wisp-body,而这一层同时承载帧动画与交叉淡入 —— 位移叠上去不像"她在动",更像素材在抖。1.45.0 的帧动画、1.45.2 的"画面同时只归一个人",说的都是同一件事。

| 层 | 之前动什么 | 现在动什么 |
| .wisp-body | happy 跳 / attn 左右推 / worried 抖 | 只剩 worried 那一下(wisp-worry,上下 5px、三下) |
| .wisp-motion | 按下 / 落地 / 换表情弹一下 | 不变 |
| .wisp-lean | 拖动侧倾 / 朝指针侧身 / 溜达前倾 | 不变 |

worried 留着不是漏删:它带着"出事了"的语义,和这两条装饰性的晃动不是一回事。

同时清掉两条早已失效的 animation-duration(sleep 9s / alert 4.6s)—— 它们是给 1.45.1 删掉的常驻浮动调速度用的,没有动画名可调,留着只会让人以为身体层还在动。

换表情仍然会弹一下(.wisp-motion 层,data-accent),所以 happy / attn 进来时她照样有回应;少的是那两段持续的晃动。

1.45.2

修掉睡觉时的重影:帧动画一播,静态立绘就退场。

1.45.0 把那段 alpha 视频叠在立绘上,并且刻意不隐藏立绘(当时的理由:"万一下视频不播还有兜底")。
实际效果是两层同时可见:视频的透明处挡不住下面那张静帧,被子那点起伏被看成两张错位的画(用户确认看到了重影)。

现在画面在同一时刻只属于一个人:

| 情形 | 视频 | 静态立绘 |
| 正常播放(sleep 且未冻结) | 显示、播放 | 隐藏 |
| 冻结(motion:'off' / prefers-reduced-motion / 被躲起来) | 隐藏(沿用 pause() + currentTime = 0 + display:none) | 显示(这时它才是画面里那个人) |
| 视频加载/解码失败(error) | 隐藏 | 显示(静默兜底) |
| 该状态没有动作素材 | 不存在 | 显示(不变) |

实现只有一个闸门:syncMotion() 判完"这一刻该不该动"之后,顺手决定画面归谁 ——
视频真的上了画面就写 root.dataset.frame = "on",其余每一种情况都把这个属性删掉。
样式表里一条规则把属性翻译成画面:

.wisp-root[data-frame="on"] .wisp-img{visibility:hidden}

用 visibility 而不是 display:none:盒子照旧占位,布局一点不动,"退场/回场"不会让她跳一下(视频那一层正是靠同一套盒模型对齐的,动布局就是动对齐)。属性走 CSS,是因为它盖得住交叉淡入
留下的那张旧图;行内再镜像一份,给样式表缺失的壳兜底。doctor().motion.frame.spriteHidden
会报这一刻画面归谁 —— 重影就是"两个都可见",所以这件事必须能被问一次。

验证:预检 713 → 723 项,其中新增的 10 条盯这一版("在播时立绘 visibility:hidden"、 "冻结时它可见,不只是还在 DOM 里"、"error 之后恢复可见"、"躲起来时画面交还立绘"、 "播放 → 冻结 → 播放 跟着来回切"、"没有素材时属性不许挂着",以及样式表那条属性 → 画面的映射)。
真引擎探针补了 3 条:真 Chromium 里读计算后的 visibility(在播 hidden / 冻结 visible / error 注入后 visible)—— 假 DOM 证明不了级联。

1.45.1

删掉 wisp-bob(站立时的上下浮动)。

原来的实现是给 .wisp-body 挂一条 5.6 秒的 CSS 浮动动画 —— 它会和新的帧动画叠在一起动(两层各自上下起伏,看起来很飘)。现在身体层不再有任何动画:站立时是静止的立绘,动起来交给帧动画(本期只有 sleepy)。

引擎探针里那条断言也随之改成「身体层不再有动画」(odyAnim === 'none')。

1.45.0

她动起来了:第一条真正的帧动画 —— 睡着时那段 alpha 视频循环。

这一段已被 1.46.1 取代(下面保留为当时的记录):素材从 VP9 alpha 视频换成动图 WebP,元素从 换成 ,"冻结"从 pause() + currentTime = 0 改成藏起动图、把画面交回立绘,colorkey 的 similarity 从 0.26 收紧到 0.10。数字(315.8 KB / 421.1 KB / 800 KB 预算 / playing 字段)也都是那一版的。

到这一版之前,她的"动作"全是位移/缩放/旋转:立绘本身一动不动。这一版给 sleep 状态叠了一段 :她睡着时被子那点极小的起伏,4.04 秒无缝循环。

| 事项 | 实情 |
| 素材 | assets/motion/sleepy.webm —— VP9 + alpha 平面(AlphaMode=1)、480×854、24 fps、4.04 秒循环。同一套立绘的睡眠姿做的图生视频,角落是透的 |
| 体积 | 原始 315.8 KB → 内联成 data URI 421.1 KB。预检里有一条 800 KB 预算盯着它(超了就在详情里报出实际大小) |
| 为什么是视频 | 同一段动作,视频比逐帧序列少一个数量级的字节,而 alpha 视频在 Chromium 里是原生解码。代价是它必须有一条会动的播放通道 —— 所以冻结规则不是可选项 |

1. 叠加,不是替换。 和立绘在同一个盒子里(.wisp-lean 层,逐条复用 .wisp-img 的 inset:0 / 100% / contain / bottom center),pointer-events:none ——她能不能被点到,仍然只由那张 alpha 蒙版生成的剪影命中层决定,透明处照旧整层穿透。
字节走的是和立绘同一条 data URI → Blob → object URL 通道(这个壳把 data: 源当坏图处理,视频没有理由不一样),失败时静默降级:藏掉视频、继续用静态立绘,不抛不冒泡。

2. 冻结规则(硬要求):"停住"不是"慢一点"。 cfg.motion === 'off'、prefers-reduced-motion: reduce 命中,或者她被躲起来时:

pause() → currentTime = 0 → display: none

三件事一件都不能少。少了 currentTime = 0,静止档里的她会定格在循环的半途 ——同一个"静止",每次按下开关停的地方都不一样。恢复时同一个元素接着播(不重建)。
系统设置在会话中途改了也当场生效:CSS 的媒体查询是实时的,而播放状态是 JS 的,所以插件订阅了 matchMedia 的 change。

3. 不白解码。 没有动作素材的状态连 都不建(挂载时不碰这 316 KB),
mood('sleep') 时才建一个;doctor().motion.frame 会报 asset / built / playing / frozen / failed
—— 帧动画的失败是静默的(画面回到静态立绘),所以它必须能被一眼看出来。

验证:预检 685 → 713 项,其中新增的 28 条盯这一版(含"静止档真的 pause() 且 currentTime 为 0"、 "reduced-motion 下同样冻结"、"视频带 pointer-events:none"、"和立绘的行内盒模型逐条相同"、 "800 KB 预算"、"内联的字节和磁盘上那份一样长")。真引擎探针也补了一节 —— 假 DOM 证明不了这段视频解得开:实测 480x854 readyState=4、四角 alpha 0/0/0/0 (alpha 平面真的出画了)、与立绘的盒子偏差 0.00,0.00、会话中途打开 reduced-motion 当场停住。

1.43.2

台词只说人话;"为什么查不成"排进弹窗。

1.43.1 把诊断直接拼进了台词 —— 真机上的效果是一句话后面挂一串
ctx.get("web")→undefined; ctx.web→undefined,读起来像乱码(用户的原话:
"还说一串英文")。现在:

- 台词回到一句人话:「这个壳里我没有联网的通道,查不了更新。」
- 技术细节排进「关于她」弹窗:取服务的方式、拿到的东西是什么形状、兜底通道为什么也不行 —— 查失败时自动展开,可以看、可以复制、可以追问。

预检 680 → 682 项,其中一条断言"台词里不许出现英文碎片"(失败台词与弹窗分工,以后再有人往句子里塞 ctx.get(...) 就会红)。

1.43.1

检查更新在"真的拿不到 web 服务"的壳里也能用了 —— 而且失败时说得清为什么。

背景:1.43.0 装好、宿主半包也重启之后,她的原话仍是「我是 1.43.0。这个壳里我没有联网的通道,查不了更新。」—— 也就是说
ctx.get('web') 与 ctx.web 两条路都拿不到可用的服务。于是做了两件事:

1. 加一条兜底通道:pluginManager.inspect('dsh-wisp')。插件管理器本来就是 "安装之前去 registry 问这个包是什么版本"的那个人 —— 所以这条路走的是应用自己 的网络出口,还尊重用户配的镜像源,而且它是平台文档里写明的宿主服务
(ctx.get('pluginManager'))。web 服务不可用时自动走它,diag.via 会写
pluginManager。它比插件自己去抓 packument 更接近"装的时候会装到哪一版"。
2. 失败必须能追问:resolveWebService() 现在返回 { service, how, why }, "为什么拿不到"会跟着失败台词一起说出来:

这个壳里我没有联网的通道,查不了更新。(诊断:通道=none;ctx.get("web")→undefined; ctx.web→undefined)

一句话就能定案,不需要谁再去开控制台。顺带把"有服务但没有 fetch" (web-has-no-fetch)从"没有通道"里分出来 —— "平台没给服务"和"服务没暴露这个方法" 是两件事,下一步动作也不同。

预检 673 → 680 项,其中一条是端到端的:没有 web 服务、只有插件管理器时,检查更新照样给出答案,并注明走的是哪条通道。

1.43.0

一批"真的用鼠标从头点了一遍"才发现的问题。

| # | 问题 | 修法 |
| 1 | 拨完开关,子面板消失 —— 而代码注释里明确写着"拨个开关面板就消失是最烦人的交互之一" | refreshSurfaces() 重画前先记下 pinned 再原样传下去(重画的 DOM 里鼠标并没有真的悬停过,"悬停态"重开必然被宽限期关掉) |
| 2 | 大小回不到出厂 250% —— 区间 0.4–8、步长 0.25,2.5 落在 0.4+0.25k 格点外(最近 2.4 / 2.65) | 步长改 0.1(0.4 + 21×0.1 = 2.5 ✓),并加断言:默认值必须能从 lo 走整数步到达 |
| 3 | 更新提示语写死"按包名安装" —— 新版只在 GitHub 上时那句话会把人送回旧版 | 她按 from/sources 判断:只有 GitHub 更新时改口,并把仓库地址放进剪贴板(doctor().update.hint 能看到用了哪条) |
| 4 | npm 源写死 registry.npmjs.org,镜像用户可能两个源一起废 | 宿主半包的 config.urls 本来就支持换源 —— 现在写进文档并加断言:换镜像真的只会去问新地址 |
| 5 | 触摸屏上手指划过也在驱动力姿势,抬手后姿势冻住 | 触摸一律归零(手指既不是"靠近"也不是"路过";该有的反馈是"按下压一下") |
| 6 | 指针移出窗口后她一直朝最后那个方向歪着 | blur 只覆盖"切走应用",补一个 document 级 mouseleave |
| 7 | 被边界夹住的拖动仍在"原地歪" | 位置没变就把姿势归零(她其实没动) |
| 8 | motion: off 与 prefers-reduced-motion 挡不住溜达 | CSS 关不掉 JS 写进去的坐标 —— 溜达调度器一起停 |
| 9 | 静止档下 "z" 停成满不透明,像一个卡住的字 | 静止版本给 0.5 透明度;减弱动画那档同样处理 |
| 10 | 脚底光晕挂在姿势层,她一倾斜连"地板"一起歪 | 移到 motion 层:跟着压扁(那是她压在地上),不跟着侧身 |

评估后决定不改的一条:.wisp-tab 那颗迷你按钮是 56×56 的可见胶囊(有边框、底色、阴影),整块就是按钮本身 —— 命中区和用户看到的一致,不属于"透明边距吃点击"那一类。

预检 654 → 673 项:上面每一条都配了"再犯就会露出来"的断言,包括"拨开关后面板还在""默认值可达""静止档真的不动"这三条行为级的。

1.42.0

查更新失败时,她会直接说清是哪一种失败。

起因是那句话:"我是 1.37.0,这次没查到更新"* —— 界面上只有这一句,而它背后可能是三件完全不同的事:

| 实际情况 | 以前 | 现在 |
| 这个壳里没有联网通道(no-web-service) | "这次没查到更新" | "这里没有能出去的网络通道。" |
| 宿主那边没应答(call-failed) | "这次没查到更新" | "宿主那边没应答,更新没查成。" |
| 两个源都连不上(fetch-failed) | "这次没查到更新" | "更新没查成。"(保持原样) |

含糊地说成"没查到更新"就等于让人去猜 —— 这一次的排查全靠从代码里反推(先证明 "两个都失败才会说这句",再逐个排除)。她本来就知道 reason,没有理由不说。
英文覆盖层也补齐了这三句。

1.41.0

检查更新更耐得住,而且失败时能追问。

起因是一个真实的报错:界面上只说"更新检查没成功",没有任何办法知道为什么。查下来是两个 真缺陷(都不是"环境问题"):

1. 两个源共用一个 8 秒截止时间。 readPublishedVersion 把同一个 AbortSignal 传给
npm 与 GitHub 两条请求 —— 一个卡住的源到点会把这个信号 abort 掉,另一个已经拿到
结果的源也跟着作废,整次检查失败。而这台机器上 raw.githubusercontent.com 恰好就是连不上的那个(实测:同一时刻 npm 返回 200)。
现在每个源有自己的截止时间,谁卡住谁自己出局。

2. ctx.web 是启动那一刻的快照。 宿主半包由 bundle patch 插进来,可能比 web 服务先就位,ctx.web 因此可能是 undefined,而检查更新看起来就像"今天网不好"。
现在服务在调用时解析:先 ctx.get('web')(调用时查表),再退回 ctx.web。

1.48.3 更正。 当时把原因写成"inject.optional 的依赖在启动时缺席不会事后补上",
机制说错了:那个 { required: [], optional: ['web','deepseekAccount'] } 字面量让 fiber
永远停在 PENDING,根本没有"启动时的那份快照" —— apply() 从未运行过(见 1.48.3 一节)。结论(服务必须在调用时用 ctx.get 取)仍然成立,而且现在是唯一的路。

3. 失败不再是一句没法追的话。 客户端以前把 sources 丢掉,只留一个 reason;现在每个源各自的原因与 detail 都会留在 doctor().update.lastCheck 里 —— "这个壳里没有网络服务"和"两个源都没连上"是两件完全不同的事。

顺带说清一件事实:这台机器读不到 GitHub(raw.githubusercontent.com 超时, registry.npmjs.org 正常),所以在这台机器上 npm 是唯一可用的源 ——更新能不能被看见,完全取决于有没有发到 npm。

1.40.0

她的透明处真的点不到了。

在这之前,"透明处不响应"只做了一半:hitsBody() 会按 alpha 蒙版做像素判定,但它判的是
"要不要开始拖动" —— 事件早已经被浏览器派给她那个 560×840 的盒子了(盒子是 pointer-events:auto 铺满的),JS 再 return 也追不回来。真引擎实测:

她盒子里抽样 363 格:只有 88 格是她(约 24%),其余全透明
在透明格 (615,165):elementFromPoint → wisp-body 下面那个按钮收到 0 次点击

也就是说她站在哪个按钮上,那个按钮的 hover 和点击就都没了。旧检查(a press on her transparent margin passes through to the app)只断言 defaultPrevented === false——那是"她没认领",不等于"应用收到了",一条典型的假绿。

怎么修的:把命中判定交还给浏览器

新增一个不画任何东西的空盒子 .wisp-hit 铺在她的位置,带一层由同一张 alpha 蒙版 生成的 clip-path;.wisp-body 改成 pointer-events:none。

path("M245 0h46.7v5.8h-46.7z M239.2 5.8h64.2v5.8h-64.2z …") // 实测某套皮肤约 4 KB

形状是这么来的:蒙版是 96×144 的格,每行把连续的不透明格合并成矩形,再和上一行同列同宽的矩形纵向合并 —— 段数通常几十段,只在换皮肤或改尺寸时重算(不是每帧的事)。

于是透明处是浏览器判定穿透的,一次修好四件事:hover、点击、右键、滚轮;顺带 cursor:grab 也只在她的轮廓上出现了(以前整个盒子都是抓手光标)。

保住的旧行为(每条都验过):点她仍然把键盘焦点给她(点击目标不再是那个 tabindex=0 的元素,所以在 onDown 里显式 focusHer(),否则"点一下她再按 Enter 开菜单" 就断了)、在她身上按下能拖动、在她身上右键仍然开她自己的菜单。

验证

- 预检 636 → 642 项:命中层存在、body=none / hit=auto、clip 由蒙版生成、 形状精确等于"膨胀后的蒙版按当前盒子缩放"(M134.2 0.0h291.7v840.0h-291.7z,即蒙版第 23…72 列 × 5.833px)、改尺寸后重算、改回来还原。
- 真引擎探针 21 → 30 项(这一台机器上跑得动的那个):透明处 elementFromPoint 返回的是下面的应用、真 CDP 点击送达、她身上返回的是 wisp-hit、点她不穿透、点完焦点仍在她身上。冒烟第 10 节镜像了同一批。
- 边界如实说:蒙版是 96×144 再各膨胀一格,所以在默认尺寸下命中轮廓比精灵图粗约 6px —— 这是故意的:看起来落在她边缘上的一下,应该算她的。

1.39.0

她会看你的余额了。 菜单「行为 → 看看余额」,点一下她说出来:

账上是 ¥110.00。其中赠送 ¥10.00。

按需查,不轮询 —— 每 30 秒问一次平台,既有被限流的风险,也违反"稳态开销接近零"这条一直守着的承诺。查不到的四种情况分成四句不同的话,因为把它们都说成"查不到"就是在骗人:

| 情况 | 她说 |
| 未登录 | 没登录,看不到余额。 |
| 登录着、但这一下没读到(授权在查询途中换了) | 这一下没读到,稍后再试。 |
| 平台答了 failed | 余额没查成。 |
| 这个壳里没有那个服务(没有 host 座位) | 这个壳里我查不了余额。 |

为什么不是"拿 API key 打平台接口"

这是这一版最值得记下来的结论,两条都实测过:

1. ctx.web.fetch 设不了请求头。 它的请求类型只有 { url: string }(服务契约原文), provider 只读 url,方法硬编码为 GET —— 而余额接口要 Authorization: Bearer …。
所以平台给的网络通道在物理上就打不通那个接口,跟有没有 key 无关。
2. 根本不该由插件拿 key。 DSH 自己读余额走的是 deepseekAccount 服务,描述里写着 "only Host consumers can obtain a request credential" —— 凭据由平台持有,插件拿到的是一个已经归一化的结果。于是:lib/index.js 只是把宿主半包 inject 上这个服务,
harness.handle('checkBalance', …),浏览器半包用 host.call 问它。
key 不会出现在插件里,更不会出现在页面上 —— doctor().balance 只报结果。

服务契约(从运行时的 Host Service 目录读出来的,不是猜的):

getBalance({ version, locale, timezoneOffsetSeconds })
-> { status: 'ready', value: [{ currency, balance }], bonusWallets: [...] }
| { status: 'failed' }
| null // 未登录 或 授权在查询途中换了

null 故意同时意味着两件事,所以真遇到时再问一次 getState() 分清是哪一种: 'signed-out' 说"没登录",'credential-stored' 说"刚才没读到"。
这个服务也不接受 AbortSignal,所以用一个竞速做 8 秒超时 —— 没有它,一个挂住的调用就是"气泡永远停在'看一下……'"。

顺带修掉一条我自己上一版引入的回归

1.38.0 为了让"连戳两下第二下也要弹",把 accent('pop') 放在了 setMood 的去重之前。
而 attn 也在会弹的那一组里,poll 只要看到待处理面板就每一轮重算 setMood('attn') ——结果是你盯着审批卡片等的时候,她每 1.2 秒抽一下(真机实测:6 秒 5 次 animationstart)。

现在弹一下回到去重之后(只有真的换了表情才弹),"连戳两下"由 flashHappy/flashPoked 自己补 —— 那两条路只由手势触发,永远不会被轮询重算。两条检查分别钉住这两半。

验证

- 预检 588 → 636 项,全过。宿主半包当纯函数测(ready / failed / null 分两种 / 抛错 / 超时 / 服务缺失 / 脏数据 / 列表上限 / 两个 handler 一起摘掉),客户端用 hostCall 假座位走完整链路(含菜单那一行真的点一下、四种"没读到"各自的台词、英文覆盖层八条齐全且无汉字)。
- 没验到的部分,如实说:这条路的真实端到端还没跑过 —— 跑着的应用装的是旧宿主半包,要重启后新的 checkBalance handler 才会在。重启后菜单里点一下,或者 Console 里
__wisp.checkBalance();__wisp.doctor().balance 会直接告诉你卡在哪一步
(hostSeat 有没有、state 是什么、reason 是哪一个)。

顺带:把"配置键的三个端"钉住(并修掉一个休眠 bug)

配置键在三个地方各出现一次:CONFIG_SPEC(声明有哪些、怎么夹紧)、DEFAULTS(默认值)、 README 的配置表(用户看到的那份)。这次核对发现已经漂了:

- CONFIG_SPEC 有 31 个键,README 表只写了 18 个,cordis.patch.yml 的注释只列了 15 个—— motion 正是上一版加进 README、却忘了同步别处的那个键;
- sound / soundVolume / lang 只在 CONFIG_SPEC 里声明、DEFAULTS 里没有 → 默认值是 undefined。其中 soundVolume 是真 bug:Number(undefined) = NaN → clip.volume = NaN 在浏览器里会抛,被 playSound 的 try/catch 吞掉 → "打开音效"整条路静默失效(等 assets/audio/ 有素材那天才会显形)。

修法不是"把三份抄齐",而是删掉一份:cordis.patch.yml 里那份清单改成指向 README 表(重复清单本身就是漂移源),代码这边补齐默认值、音量显式兜底。预检加了四条不变式:声明过的键必须有默认值、README 表必须齐全、README 表不许凭空发明键、以及 README 里那个项数必须等于真实项数(这一条终于让"544/546/588/631…"那种没人核对过的数字变成会红的检查)。

1.38.0

她有身体了:这一版重做的是「交互动画」。

在这之前,"她的动作"其实只有两件事:换一张精灵图,以及一条常驻的呼吸循环。
按下、拖动、松手这些手势在画面上没有任何回应 —— 她像一张贴在屏幕上的图,而不是一个被碰到会有反应的东西。

三个层,各管一件事

CSS 的 transform 在同一个元素上是覆盖而不是叠加,所以"分层"不是设计洁癖,是唯一做法:

| 层 | 管什么 | 由谁驱动 |
| .wisp-body | 情绪动画(呼吸 / 跳 / 推 / 担心)—— 既有,一行没动 | data-mood |
| .wisp-motion | 一次性动作:按下 / 落地 / 换表情弹一下 | data-accent + --wisp-amp |
| .wisp-lean | 持续姿势:被拖动时的侧倾、朝指针侧身、溜达前倾 | --wisp-tilt |

motion 在外、lean 在内是刻意的:落地那一下该是屏幕坐标里的竖直压扁(重力方向),不该跟着侧倾一起歪。旋转原点放在脚底,所以侧倾像钟摆而不是转盘。

具体做了什么

- 按下就压一下 —— 反馈发生在按下那一刻,不是等松手。
- 拖着有重量 —— 拖动中朝运动的反方向倾(最多 6°,跟平滑后的速度走、手一停就回正);
松手时顿一下再站稳。跨过屏幕中线时她整个是镜像的(scaleX(-1),镜像会连旋转
一起翻),所以写进样式前先乘一次 facing —— 少了这一步,她会只在半张屏幕上朝反方向歪。
- 她会注意你的指针 —— 170px 内朝指针侧身(最多 3.5°,越近越多),出圈回正,睡着时不跟。这是全插件唯一一处持续读指针的逻辑:纯算术、不查 DOM、不读布局,出圈即归零,变化不到 0.05° 不写样式。
- 换表情会弹 —— 交叉淡入淡出只是两张图叠了一下,没有"她动了"的感觉。
弹一下挂在 POPPY_MOODS 的八个表情上,idle/alert/sleep 不给(稳态弹一下就成抖)。
- 溜达朝行进方向前倾 —— 一次滑行里,这一点倾斜就是「往那边去」与「被平移过去」的区别。
滑行时长是整数个步态周期(850ms × 1 或 2),CSS 过渡读 --wisp-glide-ms —— 与素材循环对齐,见 docs/walk-action-prep.md。
- 气泡的淡出跟着它的寿命走 —— 样式表里写死 4.2s、代码里活 4.4s,气泡会提前 200ms 淡完再干等。现在时长由 SAY_MS 写进行内样式,两边共用一个数(和 ACCENT_MS 同一条规矩:CSS 管怎么动,代码管多久)。

两个只有跑起来才知道的坑

1. 同一个 data-accent 值再写一次,浏览器不会重播动画。 连戳两下必须看到两次弹,所以 pop-a / pop-b 是同一件事的两个名字,交替使用(靠读一次 offsetWidth 强制重排也行,但那要在测试替身里也装一个布局引擎,而替身没有布局)。
2. 动作通道只留最后发生的那件事。 最初给"松手没拖动"配了独立的 release 关键帧,测试立刻证明它永远不会被看到:松手紧接着就是点击反应(换表情 → 弹),后到的覆盖了它。于是删掉那个关键帧,而不是留一个演不到的动画。同理,setMood 的弹一下放在"重复状态就返回"之前 —— 真实引擎探针抓到"连戳两下,第二下完全没反应",而那正是最容易被读成"卡住了"的反馈缺失。

动作幅度可调(菜单里第三行三选一)

灵动 / 克制(半幅)/ 静止。给这一档的理由:prefers-reduced-motion 是系统级的 "所有动画都别放",而"我不想让这只桌宠动来动去"是另一件事,不该逼人去改系统设置。
静止 连呼吸和 z 一起停,她真的就是一张静态贴图。一个 --wisp-amp 变量管住全部幅度(关键帧里用 calc 乘它),CSS 和 JS 读同一张表,不会各调各的。

reduced motion 有两道闸

CSS 的媒体查询关掉动画,但姿势是 JS 写进去的样式变量,媒体查询够不着它 ——所以 accent() 与 setTilt() 里必须各问一次系统。真引擎实测(Emulation.setEmulatedMedia):

reduced: true --wisp-tilt=0deg accent=null body/motion animationName=none / none

零动画帧这条承诺没变

所有动作都是"事件写一次属性,浏览器自己算",一条 rAF 循环都没有加。
预检里那条 the whole motion layer runs without arming a single animation frame (0 frame(s))就是盯着这件事的。

验证

- 预检 546 → 588 项,全过(新增 42 项:结构、CSS 契约、按下/落地/侧倾/指针/镜像/ 幅度档/静止档/非法档位回退/reduced motion/监听器回收/气泡时长自洽)。
- 真实引擎:本机没装 Playwright,node tools/smoke.mjs 走的是跳过分支(这不是失败),所以顺手补了一个零依赖的真引擎探针 tools/engine-probe.mjs(Node 自带的 fetch + WebSocket 走 CDP,驱动本机 Edge/Chrome)。它跑出来的数:
rotate(var(--wisp-tilt)) 真的转了(2.3° → matrix(0.999194, 0.0401318, …));
wisp-press 的解析后关键帧是 scale(1) | scale(1.045, 0.94) | scale(1) ——也就是 calc()/var() 真的算出了数,不是"动画在跑但什么都没动"; wisp-land / wisp-pop-a / wisp-pop-b 在引擎里确实在跑(getAnimations()); 静止 档下 .wisp-body 的计算后 animationName 真的是 none; prefers-reduced-motion(Emulation.setEmulatedMedia)下姿势与动作都不写;插进两层空盒子之后精灵图仍然精确铺满她的盒子(偏移 0.00,0.00、尺寸差 0.00×0.00)—— 少写一个 inset:0,假 DOM 那边照样全过,只有真页面会让她缩成半张图。
装了 Playwright 的机器上,同一批断言会由 tools/smoke.mjs 第 10 节自动跑。
- 顺手修掉 README 顶部那个过期的"当前版本 0.6.0"(0.6.0 谱系合并时留下的)。

1.37.0

菜单里能选台词语言了:行为组里多了一行三选一的胶囊 —— 跟随界面 / 中文 / English。
选一次就写进 localStorage,之后每次打开都按它来。

这一版的重点其实是把语言判定改成"视图":原来 LINES 是加载时合并出来的死值,换语言只能改设置再刷新。现在它是个 Proxy —— 取词的那一刻才决定用中文还是英文覆盖层,调用点一行都没改(60 多处 pick(LINES.xxx) 原样不动)。

但要说清楚一件事:Proxy 这层本身是对的,可"当场切换、不用刷新"我没做成 ——测试里 configure({lang:'en'}) 之后她说的话仍然是中文,而 doctor() 里 choice: 'en' 又确实写进去了,我没查出这两者为什么不一致。

按"没验证过的行为不该写成承诺"的规矩,我改成验完整且真实的那条流程:

1. 菜单里选一次 → 存下来了(读到 en);
2. 再挂载一次(同一个存储)→ 她说的是英文;
3. 英文那句和中文那句确实是两段不同的文字(不是"恰好没触发")。

三条都过。菜单里这行也因此不谎称"立刻生效"。

顺带把 ⑤ 的可用性补上了:以前只有改配置或改系统语言才能换台词语言,现在菜单里就能选 —— 中文界面 + 英文系统的用户不必再忍 ✅。

预检 542 → 546 项。

1.36.0

1.36.0

一次性做完 ③④⑤ 三件事。其中 ⑤ 完整交付并验证;③④ 的代码路径全部做完并接好,但素材生成这次没成功(原因见最后一节)。

⑤ 英文台词

她会说英文了。只翻译她说的话(菜单按钮文案仍由 DSH 界面语言决定,不在这一层)。

实现上是覆盖层而不是替换:中文词池原样保留,英文按 key 覆盖上去,缺哪条就自动退回中文 —— 所以可以慢慢补齐,永远不会出现半句话。

语言来源按优先级:① 本地存的强制值 localStorage['dsh-wisp-lang'] ② 页面 ③ navigator.language。

② 排在 ③ 前面是刻意的:界面是中文、系统是英文的用户,不该听到英文台词 ——页面的 lang 比系统语言更能代表"我现在在看什么语言"。

三条检查:英文环境下说英文 ✓、句子里一个汉字都没有 ✓、强制中文时仍是中文 ✓(对照组,否则第一条可能只是"恰好没触发")。

③ 音效(默认关闭)

四个音效位:poke(戳一下)/ done(跑完)/ focusEnd(专注结束)/ notice(提醒你起来)。

- 默认关闭 —— 无故出声是最招人烦的交互之一,必须是用户主动打开的。
- 没有音频文件时整条路径静默,而且菜单里连那个开关都不显示 ——摆一个点了没反应的开关,比没有开关更糟。
- 素材落地方式:把 poke.mp3 等放进 assets/audio/,构建自动内联成 data URL,重启即生效。

④ 第九个情绪:report(抱着记录本汇报)

给值班汇报 / 今日小结 / 专注结束用 —— 正好接上 1.35.0 那三件事。

它和别的格子一样走 SPRITE_FALLBACK:素材还没生成时自动退回 idle,不会出现空白或坏图。构建也认这一点:report 是可选心情,缺素材只警告不中断。

这一节说清楚"没做完的部分"

五张 report 母版与四个音效这次没生成出来。 两次 Maker 生成调用都请求超时,第二次挂了约一小时,事后检查三处可能的落地目录,一个新文件都没有 —— 这是服务侧的挂起,不是提示词或参数的问题(同一个工具此前生成过 40 张素材)。

所以这一版交付的是可以立刻用上的代码路径:素材一放进目录、重跑构建就生效,不需要再改一行代码。素材缺失时的行为是静默降级而不是报错。

另外顺手修了构建里两处只认必选心情的守卫(它们会把"缺 report"当成致命错误直接退出)——这正是"素材可后补"这件事在构建侧的对应改动。

预检 539 → 542 项。

1.35.0

1.35.0

她开始替你"值班"。 在这之前,你走开时她只是安静下来;回来只说一句"你走了好久"。
可那段时间里跑完的轮次、出的错,她本来就看得到 —— 以前只是没人把它们留下来。

① 值班

- 走开期间:她放慢轮询(1.2s → 5s)但不停 —— 值班的前提是还看得见。
絮叨和踱步照旧停掉(那些只给看得见的人看)。
- 只记账,不表演:藏起来时她不换表情、不说话。否则"只说一次"的状态会被白白用掉(里程碑在没人看的时候被说掉,你回来就不会再提了)。
- 回来报账:"你走的这段时间,跑完 3 轮了。出了 2 次错。" ——没有账的时候才是原来那句问候。
- 单轮跑得异常久(超过 10 分钟)她会中途说一声:"这轮跑了 10 分钟了,还在跑。" 同一轮只说一次,而且不是催促 —— 是"我还在看着"。

② 今日小结

菜单「行为」里多了一项 今天做了什么(也可以 __wisp.saySummary()): "今天:7 轮、2 次报错、专注 50 分钟、起来活动 3 次。记着呢。"

她记的是:轮次 / 报错次数 / 最长一轮 / 专注总时长 / 提醒你起来的次数。
存在记忆里、按天重置 —— 刷新页面不会丢,第二天重新算。

可观测性

doctor() 里多了 today(今天的账)、watch(走开期间的值班记录)、
runInFlightMs 与 longRunSaid(当前这轮跑了多久、有没有已经提过)。
最后两个是被测试逼出来的:一条断言失败时,诊断直接说出 runInFlight=599800 ——差 200ms 没到阈值,问题在测试的时间轴而不是功能。

预检 529 → 539 项;冒烟 57 项。

1.34.1

1.34.1

菜单改成紧凑刻度。 上一版样式好看但占地太大 —— 对陪伴插件的浮层来说不合适,它不该占掉屏幕的观感重心。

压缩是分两轮做的,因为高度才是占屏幕的主因(宽度只是一部分):

| | 1.34.0 | 1.34.1 |
| 面板宽 | 188px | 174px |
| 行高 | 8×10 padding | 4×6 |
| 字号 | 12.5px | 11px |
| 圆角 | 14px | 9px |
| 分隔线外边距 | 6px 8px | 3px 4px |
| 摘要胶囊 | 9.5px / 15px 行高 | 9px / 13px |
| 开关 | 30×16 | 22×12 |
| 滑块轨 / 旋钮 | 18 / 13px | 12 / 9px |
| 角落键帽 | 7×4 padding | 3×3 |
| 子面板宽 | 164px | 150px |
| 字形槽 | 14px | 12px |

实测(真浏览器截图裁剪框):452×500 → 363×334,即宽 −20%、高 −33%。
字号到 11px 就停 —— 再小在这个尺寸的浮层上就不叫可读了。

顺带一个副作用:面板变窄之后,她在屏幕右下角时子面板已经能放在右侧(以前放不下要翻到左边)。定位算式用的是"菜单宽度 + 面板宽度",所以宽度一变它自己就跟上了 ——这也是那条"四角几何"检查的意义:它每次都验一遍真实关系。

MENU_W / SUBMENU_W 两个估算常量跟着更新(前者用于菜单夹取,后者必须等于 CSS 宽度 ——定位算式的正确性依赖这一点)。

1.34.0

1.34.0

菜单样式重做。 这一轮先把"菜单拍下来"这一步补上 —— 样式改动光看代码看不出来。

- 冒烟支持可选截图:WISP_SHOT=<路径> pnpm smoke 会把"菜单 + 展开的子面板" 用真浏览器拍下来。默认不拍,不影响正常冒烟。

一拍照就发现了两个用代码看不出的问题:

1. 「放大一点 / 缩小一点」还挂在「外观」子面板里 —— 大小早就做成菜单里的滑块了,这两条是重复的(而且是我之前声称删掉、实际没打上的改动)。已删。
2. 只有主操作有图标,「专注 45 分钟」等行与它左右不齐。

样式改动:

| 之前 | 现在 |
| 摘要用淡字(透明度 .55) | 胶囊 chip:淡底 + 圆角 —— 收起状态也能一眼扫到 |
| 主操作只是加粗变色 | 淡色渐变底 + 左侧强调条 + 字形 |
| 只有主操作有图标 | 每一行都有等宽字形槽(无图标的留占位)→ 标签左边线对齐 |
| 焦点环依赖浏览器默认 | 显式 :focus-visible 环(菜单项 / 开关 / 滑块 / 角落) |
| hover 无过渡 | 100ms 过渡 + 按下时的内阴影反馈 |
| 开关 26×14、旋钮 11px | 开关 30×16、旋钮 13px + hover 放大 |
| 角落是裸文字 | 键帽:淡底 + 描边 + 方位字形 ↖↗↙↘(当前所在的有强调描边) |
| 面板与菜单同一阴影 | 面板更高一层:阴影加强、模糊加深 |
| prefers-reduced-motion 只管她 | 菜单 / 面板 / 开关 / 滑块一起停 |
| 容器 6px、行 7×10 | 容器 8px、行 8×10,配色抽成 CSS 变量 |

配色仍全部走 --dsw-alias- 主题 token(带兜底),所以明暗主题都跟着走。

冒烟 57 项(新增:滑块在真浏览器里改大小且不关菜单)。

1.33.2

1.33.2

修「她在屏幕角落时,子菜单跟着偏」。 现象是:右下 / 左下时子面板整体偏下,右上时压住主菜单一部分。

根因只有一句:我用了菜单"请求"的位置,而不是"实际"的位置。

菜单在视口边缘会被夹取(clamp 进 vw()/vh()):

- 她在右下 / 左下时,菜单会被往上推 → 实际 top 比请求的小 → 子面板却按请求的 top 算 → 偏下;
- 她在右上时,菜单会被往左推 → 实际 left 比请求的小 → 子面板按请求的 left 算 → 横向也偏 → 压住菜单。

而且越靠角落、夹取越多、偏得越厉害 —— 正是观察到的规律。

改法就是读menuEl.style.left/top(菜单真实的图层坐标,夹取之后的值),横向仍用"菜单宽度 + 面板宽度"、纵向仍用"行相对菜单的偏移",这两者本来就与坐标系无关。

顺便把这道防线补成四个角落各测一遍(tools/smoke.mjs):她先站到每一角,再开菜单、悬停分组行、量三个矩形的相对关系,断言「面板在菜单旁边」「在视口内」「与它所属那一行对齐」。
用户就是这样发现问题的,所以这条检查直接照着他的操作路径写。

实测四个角落:面板始终在菜单旁边(右侧角落翻到左边),panel.t 与 row.t 完全相等。

冒烟 57 → 61 项。

1.33.1

1.33.1

修「子菜单遮住主菜单」。 上一版的定位是错的,而且错得不显眼 —— 面板确实"在旁边",只是压进菜单十几个像素。现在实测两矩形完全不重叠(右缘 1123 / 菜单左缘 1125)。

根因有三层,都是"拿了不该拿的数字":

1. 混用了两套坐标系:菜单的 left/top 是图层坐标,而 getBoundingClientRect()
给的是视口坐标。二者只在图层恰好从 (0,0) 开始时相等。
2. 量了刚 append、还没布局的面板 ✗:那一刻它的宽度是 0,于是所有基于它的校正被守卫挡掉,位置就停在估算值上(实测就是这样一直没生效)。
3. 宽度算错:CSS 写 width:184px 但没写 box-sizing ✗ → 默认 content-box 再加 padding:6px + border:1px → 实际 198,而翻转算式里用的常量是 178 —— 差了 20 像素,正好压住菜单。

现在的做法(不可能算错的那种):只用量层坐标 + 宽度。

- 面板宽度固定(box-sizing:border-box; width:184px)→ 常量就是真宽度,不用量;
- 横向:x = 菜单left + 菜单宽度 + 2,越界则 x = 菜单left − 面板宽度 − 2;
- 纵向:用"行矩形相对菜单矩形的偏移"→ rr.top - mr.top ——相对量与坐标系无关,而且自动带上头部/分隔线的高度(估算少了它,差了 28px);
- 入场动画从 scale 改成纯淡入:带 scale 的动画会让 getBoundingClientRect() 量到缩放后的矩形,这是第 2 条坑的一部分。

另外补了一道之前缺的防线:冒烟里加了真浏览器的几何检查 ——「面板在菜单旁边(不是上面)」「在视口内」「与它所属那一行对齐」。
假 DOM 没有布局,这三条测不到,而这个 bug 恰好只存在于布局里 ——所以它才会一路发布出去。

预检 529 项沿用;冒烟 53 → 57 项。

1.33.0

1.33.0

子菜单改成在旁边弹出。 上一版做成了"在行下面内联展开" —— 那是网页的手风琴,不是菜单栏。真正的 GUI 级联菜单(cascading menu / flyout)形状不一样:

┌──────────────────┐ ┌────────────────┐
│ 外观 深海女仆 ▸│──▶│ ✓ 深海女仆 │ ← 面板浮在行的右侧, │ 行为 4/5 开 ▸│ │ 原版女仆 │ 与父行顶部对齐, │ 位置 右下角 ▸│ │ 海洋研究员 │ 不把下面的内容推下去 └──────────────────┘ └────────────────┘

做对它的关键点,少一条都不算:

| 细节 | 为什么 |
| 面板是菜单的兄弟节点(同一个 layer) | 才能探出菜单外面;内联展开永远做不到这一点。 |
| 右边放不下就翻到左侧(▸ 变 ◂) | 否则子菜单被屏幕边缘切掉。 |
| 指针在「行」与「面板」之间算同一区域 | 两边都挂 enter/leave:任一边进入取消关闭,两边都离开才开始计宽限。 |
| 悬停是预览,点击是决定 | 悬停打开的随鼠标移开关闭;点开的被钉住,再点一次才收。 |
| 键盘 → / ← | → 进子面板(焦点送进去),← 关面板并把焦点还给父行。 |
| Esc 一次退一层 | 先关子面板,再关菜单。 |

过程中修掉两个真 bug(都是测试逼出来的):

1. 子面板泄漏:closeMenu 里那句收面板写成了 if (menuEl) closeFlyout() ——而那时 menuEl 已经被置空,于是关菜单时子面板被丢在屏幕上。
2. Esc 永远退不到子面板:onKey 的第一句就是"Escape → 关菜单",我那条"先关子面板"的分支排在后面,永远轮不到。诊断信息 面板=还开着 菜单=被关了 一眼看穿了它。

另外把四个行构造器(动作行 / 开关行 / 滑块 / 罗盘)从 openMenu 内部提到了模块级 ——子面板要用同一批构造器,藏在 openMenu 里面就是一个 ReferenceError。

预检 524 → 529 项:面板是菜单的兄弟、有定位、不与菜单重叠、悬停延迟、宽限、指针移进面板算同一区域、路过不弹、触屏不弹、点击钉住、再点收起、手风琴、 → 进面板并聚焦、← 关闭并还焦点、Esc 逐层退出。

1.32.0

1.32.0

分组改成悬停展开、移开收起(点一下仍然是"钉住")。

这一条本来就是标准交互,上一版只做了"点开/点关",属于设计盲区。补上之后,真正要小心的地方不在于"加个 mouseenter",而在于下面这几条 —— 少一条都比不做更糟:

| 细节 | 为什么必须 |
| 展开有延迟(140 ms) | 鼠标只是路过那一行就展开 = 抖动。这就是 hover intent。 |
| 移开有宽限(260 ms) | 斜着往子项移动时,中途不能收。 |
| 子项与标题在同一个悬停区(div.wisp-menu-group) | 从标题移到子项不该算移开。这是这类交互最常见的翻车点。 |
| 点击 = 钉住 | 悬停是预览、点击是决定。点开的那一组鼠标移开也不关,再点一次才收起。 |
| 触屏不触发悬停 | 判 pointerType。不判的话手指一碰就会莫名其妙展开。 |
| 焦点在那一组里时不关 | 否则键盘用户正操作着,鼠标一动组就没了。 |
| 手风琴 | 悬停到另一组会解除上一组的钉住 —— 同时开两组看不清。 |

顺手抓到一个真 bug:焦点保护第一版写的是"焦点还在菜单里就不许关" ——而菜单一打开就会把焦点放到第一项,所以那条件永远为真,悬停收起等于永远不会触发。改成只看"焦点在不在这一组里"。
第一版还用了一个"组名 → 元素"的映射,实测和重画后的 DOM 对不上(组照样关);现在改成从焦点向上走、遇到带 data-group-block 的祖先就算命中 —— 不依赖任何外部映射。

预检 502 → 524 项:延迟、宽限、路过不展开、子项在同一悬停区、触屏不悬停但点击可用、点击钉住、再点收起、手风琴、焦点保护、焦点离开后规则恢复。

1.31.0

1.31.0

菜单重做。 上一版只是"把 19 个条目收进三个分组" —— 那是分类,不是交互设计。
这一版按交互本身重做。

问题(先认下来):

- 信息和动作混在一起:「关于她…」「动作一览…」「版本与更新」和「专注 25 分钟」并列,而版本号在「关于她」里还有一份 —— 同一件事出现在两个地方;
- 三个分组只看得到名字:收起时不知道里面是什么、当前是什么状态;
- 开关是「✓ 自己溜达」这种带勾的文字,而且点完菜单就关 —— 拨开关时菜单消失是最烦人的交互之一;
- 「放大一点/缩小一点」把连续量做成了两个离散按钮;
- 四个角落是四个文字条目 —— 空间的事没有用空间的形式表达;
- 最常用的「专注」埋在二级;专注中菜单里看不出还剩多久;
- 7 行长得一模一样,没有分隔、没有层次、没有键盘提示。

现在:

DeepSeek娘 ← 头部:名字 + 版本 + 当前皮肤 +(专注中) v1.31.0 · 深海女仆 ───────────────────────────── 专注 25 分钟 ← 常用动作置顶;进行中时这一行就是状态 + 结束专注 45 分钟 ───────────────────────────── 外观 深海女仆 ▸ ← 摘要行:收起时也知道里面是什么、现在是什么行为 4/5 开 ▸ 位置 右下角 ▸ 大小 ──────●────── 100% ← 滑块:拖动即时改;键盘 ←→ 各 0.25 档 ───────────────────────────── 动作一览…
关于她… ← 检查更新移到这里(版本号已在头部) ───────────────────────────── 先躲起来 ↑↓ 选择 · ←→ 调大小 · Esc 关闭

- 开关是真开关:role="menuitemcheckbox" + aria-checked + 可见的滑轨,点完不关菜单,原地更新(摘要里的「N/M 开」也跟着变);
- 大小是滑块:role="slider" + aria-valuenow,指针拖、键盘调都可以,同样不关菜单;
- 位置是 2×2 罗盘:四个方位按钮,当前所在的那个有标记;
- 版本与更新收进「关于她」:菜单头部直接显示版本号,检查更新是弹窗里的一个按钮(上下文才对得上);
- 三段式:动作在上、设置在中间、信息在下,段间有分隔线;底部一行键盘提示。

预检 473 → 502 项,冒烟 53 项(真浏览器里新增:分组展开后菜单仍在视口内、关于弹窗真的在屏幕上、动作一览八张图真的解码 8/8)。

替身(简化 DOM)这次修了三处"不忠实" —— 它们让真代码在测试里表现不对:

1. 元素级 querySelector/querySelectorAll 与替身的 all()/find() 原来是类名精确相等,而真实 DOM 的 .a 是按类名列表匹配 —— 于是 wisp-menu-item wisp-menu-row 这类行在替身里"不存在"。这个坑前后踩过两次,这次统一成类名列表;
2. 只支持单个类名或标签选择器(属性选择器、复合选择器都不行)→ 于是给滑块数值与角落按钮各配了一个类名,测试不再依赖替身不支持的选择器;
3. 测试里判断"焦点在滑块上"要重新取当前元素:菜单每次重画都换新元素,拿之前抓到的做同一性比较会假失败。

1.30.0

1.30.0

这一版是功能,不是台词。

- 专注计时器。 桌面陪伴插件最该有的实用功能:替你画出一段时间的边界。

- 菜单「行为」组里「专注 25 分钟」/「专注 45 分钟」(默认时长 focusMinutes),专注中多一条「结束专注」(平时不出现 —— 菜单里不该有按不动的条目);
- 专注期间她保持"干活中"的姿势:稳态轮询不许把她挪走 ✓。这条 hold 放在久坐 / 干饭 / 劝睡之后 —— 那三件事该响还得响,只是姿势归专注管;
- 戳她一下 = 问还剩多久("还有 23 分钟呢,盯着屏幕。"),而且不计入"连戳",不会升级成抗议;
- 到点她换 proud 说收工("这一轮 25 分钟,结束了。"),doctor().focus.done 累计完成次数;
- 她不碰应用:不改输入框、不发消息、不点任何按钮 —— 只陪着 + 计时。

- 页面藏起来就安静。 挂 visibilitychange:藏起来时停掉轮询 / 絮叨 / 踱步三个定时器,回来再按配置重新挂上。没人在看的时候不该空转 CPU(顺带省电)。

- 这不是"少做点事"而是"诚实":藏起来期间 DOM 再吵她也不该有反应 —— 测试就是这么断言的(藏起来时来三个报错她一声不吭;回来之后同一批报错立刻被看见);
- 恢复先于问候:先保证活着,再谈礼貌;
- doctor().timers 报告 { poll, chatter, wander, paused }。

- 预检 451 → 473 项。

- 记一个测试教训:戳她要用既有的那套姿势(pointerdown 落在 .wisp-body、 pointerup 落在 window、坐标要在她身上)—— 我第一版两个事件都发到 root 上,她当然没反应,白查了一轮。

1.29.0

1.29.0

三件和"她真的陪着你"有关的事。都不需要新素材,全是行为 + 台词。

- 夜深了她会劝你去睡。 过了 bedtimeHour(默认 23)她换一种口气,过了入睡两小时 换成"嘴硬心软"那一档 —— 后者是社区设定里她的底色("我不管你,但……真的该睡了。"、 "才不是担心你,只是我不想半夜还看着屏幕。"、"你要是倒下,我可没有备份。")。

- 作息是可配的(bedtimeHour / wakeHour,默认 23 → 5,跨零点)——这既是真功能(各人作息不同),也让测试不必伪造时钟:把窗口套在当前那个小时上,该响就得响,套偏一小时就该安静;
- 深度档按入睡后经过的小时数算,不是按"小时落在某个绕圈区间里"——后者在窄窗口下会把窗外一大段时间也算成深夜(写测试时当场撞上:13 点睡、14 点起,结果 13 点自己被判成"深度深夜");
- 菜单「行为」组里多了「深夜劝睡」开关,另有 nightMs(默认 30 分钟一次;0 = 关)。

- 你离开又回来,她记得。 挂 visibilitychange 记住离开的时刻,回来按时长分两档:短走一句("回来了。"、"位置我没动。")、长走(15 分钟以上)另一句("你走了好久。"、"我还以为你把我关了。")。阈值 backAfterMs,默认 2 分钟,0 = 关。

- 连续天数。 从今天往回数,记忆里一天不落有多长 —— 第 2 天起她会提("我们已经连着见了 4 天。"),到 7 / 30 / 100 / 365 天换一句里程碑。
断了就回到 1,而且一句都不说("连续第 1 天"很怪)。doctor().streak 可查。

- doctor() 新增 night(窗口 / 深度档起点 / 当前小时 / 上次提醒时间)、streak、awaySince —— 不用猜"她为什么突然劝我睡觉"。

- 预检 435 → 451 项。

- 顺手记一个测试教训:气泡只活几秒,"推进完再看"必然看不到 ——久坐提醒那节早就踩过,这次又踩了一遍。现在统一用 watch(h, ms) 边推进边收集。

1.28.0

1.28.0

- 菜单不再是 19 个同级条目。 原来 6 个动作 + 4 个开关 + 4 个角落 + 5 套皮肤全平铺,只有三行不可点的小标题当分区 —— 那不是结构,是排版。

现在顶层 7 项,其余收进可折叠分组(点一下在原地展开/收起,带 aria-expanded):

关于她…
动作一览…
▸ 外观 6 套皮肤 + 放大 / 缩小 ▸ 行为 说句话 / 睡一会儿 / 自己溜达 / 跟随状态 / 跑完撒花 / 饿了会说话 ▸ 位置 四个角落版本与更新先躲起来

新手势打开菜单时分组一律收起(展开动作不能被自己的重开清掉 —— 这条写错过一次:清状态原来放在 closeMenu() 里,而"在原地重开"正是先 close 再 open,于是刚展开的分组立刻被自己收回去)。

- 两个真弹窗(不再是塞进菜单的长条目):

- 「关于她」 —— 她是谁、为什么长这样(社区规范:深蓝渐变长直发 / 大圆环呆毛 / 鲸鳍耳 / 女仆装)、白米饭的来由、这一版做了什么、版本号,以及"非官方同人、与官方无关" 和社区项目的出处。介绍写进插件里,别人装完不用翻仓库;
- 「动作一览」 —— 当前皮肤八个动作的缩略图 + 中文名,点一下就让她当场做给你看 (先关弹窗再摆姿势,否则弹窗正好盖着她)。预览用一个到期时间挡住稳态轮询(previewUntil),所以刚摆的表情不会在同一轮里被算回去。

三种关法:× / Esc / 点背景。关掉即从 DOM 移除,不常驻、不参与轮询。

- 修掉两个真 bug:

- 文档级 Esc 监听原本每次开弹窗都挂一个、而且摘不掉(清理函数是个空壳)——改成全程只有一个,随实例销毁;
- 点预览格或点背景关掉弹窗后焦点掉进 <body> ✗(下一按 Enter 打不开菜单了)。
现在关弹窗一律把焦点交还给她 —— 这条是真浏览器冒烟抓到的,假 DOM 看不出来。

- 预检 400 → 410 项,冒烟 46 → 53 项(真浏览器里新增:分组能展开、展开后菜单仍在视口内、关于弹窗真的在屏幕上、动作一览八张图真的解码完成 8/8、点格子会关弹窗并摆姿势)。

- 这一轮踩的坑(都记在这里,免得下次重演):
1. 简化 DOM 不会汇总子节点的 textContent —— 分组名、弹窗段落、页脚都因此读成空串,改成"文本写在带类名的元素上";
2. 替身的 find() / all() 会把已移除的节点也返回,判断"弹窗关没关"必须显式认 removed;
3. 替身的 all('wisp-menu-item') 是类名精确匹配,多挂一个类就选不中了 ——分组标记因此改放 dataset;
4. 测试里用了文件后半部分才定义的 helper(ctxEvent)→ TDZ,把整段套件截断过一次;
5. 冒烟里新插入的检查放在键盘段之前会扰动它依赖的焦点状态 → 挪到键盘段之后。

1.27.0

1.27.0

- 第八个情绪 eat:干饭。 五套皮肤各一张,双手捧着一碗白米饭,眯眼笑。

这是社区设定里最像"她"的一条 —— 白米饭是算力的唯一硬通货。台词照这个底色写了 12 句(白米饭才是硬通货 / 先干一碗再想复杂的事 / 才不是特意帮你,只是吃饱了顺手活动一下手指 / 算力不够了得补点碳水 / 这碗不算加餐,算基础设施 / 吃饱了才有力气摸鱼 …)。

- 行为:她饿了会说。 连续活动满 hungerMs(默认 45 分钟)就捧碗出来说一句, 5.2 秒后自己回去。判据和久坐提醒共用同一套"还在动"的判定,但含义相反 ——久坐是提醒你起来,干饭是提醒她自己补碳。

- 计时基准取 max(lastMealAt, activeSince):离开很久回来之后不会一进门就喊饿 (那一段已经结束了,该从零开始算);
- 不连着说:一顿之后要再过一整个间隔;
- 两种关法:hungerMs: 0,或菜单里新增的「饿了会说话」开关(hungry: false);
- doctor().hunger 报告 { enabled, everyMs, lastMealAt } ——不用猜"她为什么突然捧碗"。

- 情绪数 7 → 8,素材 35 → 40 张;菜单 18 → 19 项(固定 13 → 14)。
预检 383 → 400 项。

吃过的坑记一笔:新加的测试踩了两次同样的 TDZ(用了文件后半部分才定义的 ctxEvent / rightClickOnHer),以及把 harness 的 el.dispatch() 写成了浏览器的
el.dispatchEvent();还有一条是"上一步结束时她还在吃"被误判成"关不掉" ——
和久坐那节一样,收尾要先推进一段让瞬时状态结束。

1.26.0

1.26.0

- 检查更新改为同时读两个源,取版本号更高的那个(npm 注册表 + GitHub 的 package.json)。

起因是这次发布踩到的事:npm publish 返回 PUT 202(已受理)却长时间不进注册表 ——等了 18 分钟,dsh-wisp/1.25.0 与 tarball 都还是 404;同号重发被拒(409 Cannot publish over previously staged version);npm 状态页一切正常。
而 GitHub 上的 package.json 是立刻更新的。

于是设计成取更高版本而不是「npm 优先、GitHub 兜底」:后者会读到 npm 那个旧号(当时是 0.6.0),然后告诉你「已经是最新的」✗。
取更高则无论 npm 有没有追上都是对的 ✓

- 宿主半包:readVersion() 单源 → readPublishedVersion() 多源并发,返回 { ok, latest, from, sources };每个源的成败分别上报,一个挂了不影响另一个;
- 客户端:把 from / sources 一起记进 doctor().update.lastCheck ——两个源不一致时,「这个数字来自哪」比数字本身更需要能查;
- 预检 377 → 383 项,含四种组合:两源一致 / npm 旧 GitHub 新 / npm 新 GitHub 旧 / 一个挂一个活。

- 上一版 1.25.1 只是换号重发(内容与 1.25.0 完全一致),代码无改动。

1.25.1

1.25.0

- 她真的会检查更新了。 上一版只说"我做不到" —— 那一版没找对通道,这一版找到了:

平台认可的路是服务,不是绕过陷阱。 宿主半包 inject: { optional: ['web'] },用 ctx.web.fetch({ url }) 抓远端 package.json(DSH 自己的 web_fetch 工具用的就是这条),再用 harness.handle('checkUpdate', …) 注册;浏览器半包 host.call('checkUpdate') 调用。
客户端一点网络都没碰 —— fetch 陷阱原样留着。

- 结果四态:current / available / failed / unsupported,doctor().update.lastCheck 可读;
- 「版本与更新」一步到位:报版本 + 复制包名 + 去查,查完用一句话覆盖(一次只冒一个气泡);
- 非 2xx 是返回结果、不是抛错(服务契约原文),所以状态码必须自己判;
- 没有 web 服务 / 没有 host 座位 / 调用抛错 —— 三种情况都如实报告,不抛。

- web 声明为可选 inject 是安全属性:硬依赖一个没加载的服务会让 fiber 永远 waiting, 她会直接消失。预检里两条断言盯着(必须可选、不能必需)。

1.48.3 更正:这段结论错了一半,而且预检里那两条断言也是反的。
"不能硬依赖"是对的,但"可选"救不了它:cordis 的静态 inject 把对象键当作服务名
(Array.isArray(inject) ? inject : Object.keys(inject)),所以 { optional: ['web'] }
在它眼里只是声明了一个叫 optional 的服务,['web'] 是给这个服务名配的拦截配置。
没有任何组合提供 optional(或 required)⇒ fiber 永远 PENDING、apply() 永不执行。
正确做法是一条都不声明 + 全部调用时 ctx.get;详见 1.48.3 一节。

- 更正上一版 README 的错误结论。 上一版写"不能检查更新" ✗ —— 那是建立在"服务目录里没有网络类服务"上的,而我漏看了 ctx.web:它是注册表式服务,提供者 web-fetch-http 只往里 registerFetchProvider,所以按 provide("web") 搜不到。教训:搜不到不等于不存在,换个角度(从消费方 dsh-tool-web 反查)就看到了。

- 预检 353 → 377 项(宿主半包第一次有了行为测试:六种抓取结果 + 注册 + 无座位 + apply 的 disposer)。

- 更新源:https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json(可用 updateUrl 覆盖)。
仓库当前是 0.6.0,本地是 1.25.0 —— 所以第一次检查会说"本地比远端新",那是实情不是 bug;把新版推上 GitHub 之后就会正常报"有新版本"。

1.24.0

1.24.0

- 新增「版本与更新」(菜单项 + __wisp.version + doctor().update):
- 说出当前版本与这一版的要点;
- 更新感知:记住上次见到的版本,变了就说一句「我更新到 X 了」(第一次安装不吭声);
- 复制包名 dsh-wisp 到剪贴板,方便在 DSH 里粘贴安装;没有剪贴板权限时静默降级成只说话;
- doctor().update 如实上报 canSelfUpdate: false 与原因。

- 先查了平台,再动手:这个插件不能自己检查/安装更新,而且是源码级的结论 ——宿主半包跑在 vm 沙箱里,harness 只给 { defineTool, registerTool, handle },沙箱的 nodeApiTraps() 挡住 Node API,允许的 ctx 动词白名单里没有网络与文件系统; DSH 自带的安装器也没有可注入的服务(服务目录里只有 dynamicCordisRunner / profileContext / loader / modules 等)。所以只做了平台允许范围内真能做到的三件事,并在 README 里写清边界。

- 预检 340 → 353 项(版本、更新感知、剪贴板、降级、能力边界都测了)。

- 又踩了两次同一个坑,都记下来:
1. 补丁锚点缩进又深了两格(应是 10/6/8,我写了 12/8/10)—— 三处失配,好在失配不会误改;
2. 我把测试用的 clipboardWrites 声明在了 createHarness 内部,却在模块作用域里用它 ——修法不是把变量提升,而是挂到替身的返回对象上(测试要能从外面看的东西,本来就该在那儿)。

1.23.0

1.23.0

- 交互设计补三个缺口(都是「只能靠鼠标」的问题):

1. Ctrl/⌘ + 滚轮调大小 —— 以前只能开菜单点「放大一点 / 缩小一点」。
只认带修饰键的滚轮:她占地不小,直接吞掉普通滚轮等于在她身上把页面滚动禁掉,所以普通滚轮原样放行、连 preventDefault 都不调(这条写成了断言)。
上下限仍由 CONFIG_SPEC 一处夹紧(0.4–8),没有第二处重复。
2. 方向键微调位置 —— 在这之前,键盘用户拿到焦点后除了开菜单什么都做不了,挪动她只能靠鼠标拖。现在方向键 16px、Shift 4px,与拖动共用同一套边界夹紧与位置持久化;按键同时 preventDefault(不拦的话页面会跟着滚,她看起来像「跑掉了」)。
3. 躲起来之后右键迷你按钮也能开菜单,菜单里那一项改成开关:躲着时显示「回来吧」、在着时显示「先躲起来」。原先躲起来就只剩「叫她回来」一个动作,换皮肤/调大小/换角落全都够不着。

- 预检 319 → 340 项(+21:正向、反向、边界、持久化都测了)。冒烟 46 × 2 全过。

- 过程记录(三次失败,都是我自己写错):
1. 补丁脚本连栽四次 —— 把中文引号写进了 JS 双引号字符串里。改成用模板字符串承载插入内容 之后这一类错误不会再犯。
2. 三个锚点全部失配 —— 我按 8/10/12 空格写缩进,文件里实际是 6/8/10。
(失配是好事:没有误改到别处。)
3. 两条断言失败又都是测试自己写错:从 y=100 按 5 次(80px)到不了 0 却断言到了 0;把 element 写在了替身上(它在 API 上)。
4. 最后一条「可见时那一项应显示先躲起来」失败,是替身的 all() 会返回已移除的旧菜单项 —— find 拿到的是上一份菜单里的过期按钮。实现没问题(点击菜单项本来就会先 closeMenu 再执行),是断言该只认还在文档里的节点。

1.22.0

1.22.0

- 新增生成审计日志(哈希链) —— 起因是有人要求"严查是否存在图生图"。

查出来的结果分两半,都写进了 audit/generation-audit.jsonl 与 README:
- 代理登记表里确实有 14 条 edit_image(图生图),但全部是 09-27 的,不是本轮;
- 本轮的 77 次调用全部是 generate_image / batch_generate_images,edit_image 为 0;
- 入包 140 个 webp 全部是重出当天的,且四档与原母版逐字节可复现(说明中间没有任何加工)。

工具:tools/audit-log.mjs(--declare / --confirm / --verify / --backfill)。
每条记录带 prev → hash 的链,改任何一条都会断链;预检里加了两条常驻断言(3z 段)。

能力边界写在 README 里,没有含糊:代理不记参数,所以"没喂参考图"依赖的是 自我声明 + 声明不可事后篡改,不是第三方取证;回溯补录的记录 declared 为 null,如实标注。

- 实测过篡改检测:改入包素材一个字节 → AUDIT FAILED;改日志里一个字段 → 内容哈希不匹配 + 后继断链,双报。
(第一次测日志篡改时我用了 PowerShell 改写,改动其实没落盘,我却当成"链有洞" —— 是测试方法的问题,不是工具的问题。换成 Node 严格读写后结论才对。)

- 预检 317 → 319 项;tools/audit-log.mjs 与 audit/ 已加入发布清单。

1.21.0

1.21.0

- 去掉双马尾:全部五套皮肤统一为社区规范的长直发。

用户的要求是"不保留双马尾" —— 双马尾是本插件早期的原创设计,社区规范里并没有。
现在四套旧皮肤(deepsea / classic / night / pajama)各 7 个情绪全部重出(28 张),连同已完成的 canon,五套皮肤共用同一套角色特征:

- 深蓝色中长直发自然披散(无马尾、无双马尾、无盘发),发梢渐变浅蓝,齐刘海;
- 头顶大圆环呆毛(社区标识);
- 鲸鳍耳:贴着头发向外下方约 35 度、长度约等于头宽、深蓝外表面 + 下缘白到银灰的内衬宽带;
- 各套只保留不同的服装(吊带短裙 / 长袖全套女仆装 / 方领赛璐璐 / 露肩礼服 / 宽松睡衣)。

全部为纯文生图,未使用任何参考图。键控软边 0.24%–0.63%。

- 包 7436 → 5193 KB(新素材体积更小;解析代价反而下降约 3 ms)。
- 预检 317 / 冒烟 46 × 2 / PACK OK 全过。

- 工作方式记录:每张图单独展示,不拼成对比图 —— 拼图会把每张压得太小,看不清细节。

1.20.0

1.20.0

- 新增第五套皮肤「原版女仆」(canon) —— 对齐社区规范的那一套,7 个情绪齐全:长直发披散 + 大圆环呆毛 + 发箍右侧蓝蝴蝶结 + 后掠的鲸鳍耳(深蓝外表面、下缘白到银灰宽带)
+ 长袖全套女仆装(立领白荷叶边、领口蝴蝶结蓝宝石、金色袖口与裙摆纹样、白色半身围裙、白色长袜、深蓝圆头皮鞋)。全部为纯文生图,未使用任何参考图。

- 「初版女仆」改名「素绘女仆」(classic):它的实际特征是与众不同的赛璐璐平涂 (其余几套都是厚涂),叫"初版/基础"在"原版"入场后就站不住了。

- 皮肤优先级:deepsea(旗舰,默认)→ canon(原版)→ classic / night / pajama。

- 仍待办:classic / night / pajama 三套的现有素材还是双马尾 + 大耳 + 无呆毛的旧规格(各 7 张,共 21 张)。它们保留本插件的原创双马尾形象,只把耳朵换成已验证的鲸鳍耳写法,并补上呆毛。

1.19.0

1.19.0

- 鳍耳写法定稿(纯文生图,未用任何参考图),并记录了两个可复现的坑:

坑一:不提"缺口/锯齿"。社区鳍耳下缘有极轻微波浪,我当成最重要的识别点写("下缘 3 个半圆缺口、像被剪了三刀"),结果三份不同写法全部画成锋利的尖牙/锯子边;连"不要锯齿、不要像锯子"这种负面词也没用 —— 负面词同样会招来那个形状。正确做法是只说正面形状语言("上下缘都是光滑连续的弧线,只有极轻微起伏"),把"缺口"整个不提。

坑二:尺寸不是主要问题,朝向才是。用户说"耳朵太大"时我一路缩短到"头宽三分之一",结果缩过头;逐像素核对社区立绘后确认:社区的鳍其实很长(约等于头宽),问题在于贴着头发向后下方掠,而我做成了朝上外张的翅膀。现在"朝向"是第①条,并明确否定三种错误朝向。

能力边界(写清楚了):纯文生图能到"同一个设计、一眼认出是同一个角色",做不到像素级复刻 ——社区自己的一致性工具是一套训练过的 LoRA(其 Civitai 模型页),不是纯提示词。
同时坚持不喂参考图:喂图会拉低质量、出破绽(用户的经验,本轮全程遵守)。

- 迭代过程:A/B/C 三版全是尖齿 → D 变平滑 → E 缩得过小 → F 定稿(对比图留在开发机上,未随包发布)。

1.18.0

1.18.0

- 修正两处与社区规范的偏差(用户指出的):
1. 发型:我给她加的是超长双马尾 —— 那是本插件的原创设计,社区规范里不是这样:社区是中长直发自然披散、发梢渐变浅蓝。
2. 耳朵朝向:社区是向后下方斜掠、几乎贴着头发,两色(上深蓝下白)、下缘有锯齿缺口;我做成了向上外张的大翅膀。用户说"太大了",实测问题在朝向不在长度 ——社区的鳍其实不短(约与脸宽相当),但它是贴着头发向后掠的。

顺带补上两个漏掉的头部特征:发箍右侧的蓝色蝴蝶结、头顶的大圆环呆毛。

- 新增「原版女仆」(canon)皮肤的完整提示词(写进 tools/CHARACTER-PROMPT.md):这一套的目标不是原创,而是复刻社区共识 —— 长直发 + 大圆环呆毛 + 发箍蓝蝴蝶结 + 后掠锯齿鳍耳 + 长袖全套女仆装 + 白袜 + 深蓝圆头皮鞋。首张测试已通过比对。

- 设计分工定下来了:双马尾是本插件的原创形象(deepsea / classic / night / pajama 保留),社区形象作为第五套「原版女仆」共存 —— 用户的要求是"公认服装作为一套原版服装",即并存而非替换。

1.17.0

1.17.0

- 提示词规格修正(两条,都已实测确认):
1. 鳍耳缩小:上一版每片约占头宽三分之一,实际画出来是一对大翅膀,太抢戏。
现在写死"约头宽的四分之一,不要超过三分之一,绝不要画成翅膀那样大"。
2. 加呆毛(社区标识之一):一小缕头发从发顶竖起、顶端向前弯成小圆环。

- 写作技巧(这次最有价值的发现):同一个要求写在提示词开头会被模型忽略,写在末尾的【画面完整性,逐条核对】清单里才会被执行 —— 呆毛第一版写在开头直接被省略,编进清单后一次就有。双马尾、鲸尾、发尾完整一直生效,本来就都在那个清单里。
已写进 tools/CHARACTER-PROMPT.md。

- 待办(本版未做):四套皮肤全部素材需按新规格重出(耳小而+呆毛); 新增一套"原版"皮肤(社区公认的女仆装);「初版女仆」改名。

1.16.0

1.16.0

- 按社区规范修正她的耳朵:「鲸鳍耳」—— 耳朵本身就是一对鲸鱼胸鳍,不是挂耳饰。

我去查了社区通行的画法,社区为此有一个开源角色规范项目 Neko3000/deepseek-whalechan:它把识别点锁定为发色渐变、鲸鳍耳、呆毛、鲸尾、女仆装。也就是说社区早就把答案写在规范里了,而我一直在"给她挂鳍形耳饰"—— 方向就是错的。

社区的鳍耳有四个我漏掉的要点(放大社区立绘逐条核对出来的):
1. 向后上方斜掠张开(像后掠的小翅膀),不是朝上竖;
2. 狭长、根部宽、向后收成尖梢,不是等宽直板;
3. 上表面深蓝、下缘与内侧白色 —— 座头鲸胸鳍的两面配色,这是最显眼的识别点;
4. 大小约占头宽三分之一,存在感明显。

默认皮肤 deepsea 的 7 个情绪已按此重做(键控 0.53%–0.68%)。

- 上一版我把它做成了等宽的直板耳坠(像塑料小棍)——那是错的方向上加了一次修正。
这一轮的教训写进了 tools/CHARACTER-PROMPT.md:先去看社区共识,再动手画。
- 顺带记下另外两处与社区的差异(待定,未擅自改):呆毛(社区锁定标识,我还没有)、 服装(社区是长袖全套女仆装+白袜皮鞋,本插件是单层吊带短裙)。
- 出处与许可已记录:社区规范是 CC-BY-NC-SA 4.0(规范文档)/ MIT(工具链),角色属社区共创,非商业二创被明确欢迎并建议注明出处,商业使用需授权。
- 另外三套皮肤(classic / night / pajama)尚未改(各 7 张,共 21 张)。
- 预检 316 / 冒烟 46 全过;包 6030 → 6202 KB。

1.15.0

1.15.0

- 修正她的耳饰:那不是鲸鱼的鳍,是鱼鳍。 这一条是用户发现的 —— 她耳朵上原本是一对带棘刺、有放射状鳍条、边缘锯形的鱼鳍,而鲸鱼的胸鳍是细长圆润、边缘平滑、末端圆钝的桨板,完全不同。

根因在提示词:我写的是「鲸鱼鳍造型耳饰」——"鳍"被理解成了鱼鳍。改了三轮才拿准(记进 tools/CHARACTER-PROMPT.md):

| 措辞 | 结果 |
| 鲸鱼鳍造型耳饰 | ✗ 鱼鳍(鳍条 + 棘刺) |
| 细长圆润、像叶片 | ✗ 平滑了,但末端分叉,像羽毛 |
| 柳叶、末端圆钝 | ✗✗ 跑偏成水滴形宝石耳坠 |
| 扁平等宽长条板 + 圆头 + 显式排除水滴/宝石/吊坠/羽毛 | ✓ 圆头桨板 |

教训:形状要求要写成几何描述(扁平、等宽、圆头、边缘连续),不要用比喻;负面清单要列全 ——光说"不要鱼鳍"不够,还得说清"不是什么"。

- 本版只重做了默认皮肤 deepsea 的 7 个情绪(母版已换、键控 0.48%–0.62%)。
另外三套皮肤(classic / night / pajama)仍是鱼鳍,它们的母版当初是同一套措辞生成的 ——要不要一并重做(每套 7 张,共 21 张)由你定。
- 包 6195 → 6030 KB(新一批图的体积略小)。预检与冒烟不受影响。

1.14.0

1.14.0

- 她记着你(零美术):记住三件事 —— 一起多少天、你常把她放在哪个角、常让她穿哪套。
够格的时候说出来(proud 或 happy 表情):
- 第 2 / 7 / 14 / 30 / 100 天:「今天是第 7 天。」
- 某个角用过 ≥8 次且占多数:「你总把我放在右下角。」
- 某套皮肤换过 ≥6 次(且不是当前这套):「宵眠睡衣 —— 你好像挺喜欢这套。」

两条防噪规则是这个功能的重点,不是附带的:同一条记忆一辈子只说一次(said 里记着,跨刷新有效)、一天最多说一句(remarkDay)。没有它们,这些台词会在每次刷新时重复出现,很快从"她记得"变成"她唠叨"。memory: false 关掉;__wisp.doctor().memory 把记录全报出来。

- 实现上踩到并修掉的两个问题:
1. 我一开始在 applySkin 里写了个不存在的变量名(previousSkin)—— 每次换皮肤都会抛 ReferenceError。这类错误只有跑起来才会暴露,写的时候看着很对。
2. 记忆台词原本排在 3.6 秒,和首次挂载的"右键我一下试试"提示碰在同一秒,两条互相盖掉。
测试用"最后一条气泡"断言时读到了提示语 —— 这既是实现撞车,也是断言写错了位置。
现在台词排在 7 秒,断言改成"她说过(在所有气泡里找)"而不是"最后一条是它"。
这个观察方式也适用于所有瞬时状态:happy/poked/proud/care 都可能被别的台词挤掉。
- 记录不会无限长:天数列表上限 90 条。
- 预检 302 → 316 项。包 6195 KB(记忆功能不增加体积)。

1.13.0

1.13.0

- 第七个情绪 proud(得意):双手抱在胸前、下巴微抬、半闭着眼坏笑 —— 与 happy(举手大笑)一眼能区分。四套皮肤各一张,键控 0.43%–0.66% 软边。

- 今日里程碑(行为):她数着今天跑完多少轮,到 10 / 25 / 50 / 100 / 200 / 500 时用得意的表情报出数字(台词里的 {n} 会替换成实际轮数)。三条判据都写成了断言:
- 同一天只说一次:说过的里程碑跟着计数一起存 —— 否则刷新页面会把「第 10 轮」又说一遍。
- 换一天归零:按日期存,昨天的数字与"说过"的记录都不算数(这条用预置存储测,因为它读真实日期,虚拟时钟推不动)。
- 里程碑优先于普通庆祝:同一次跑完只该说一句,而「第 25 轮」比「跑完了」更值得说。
- milestones: false 关掉。__wisp.doctor() 会把今日计数与已说过的里程碑报出来 ——不用猜「她为什么突然得意」。

- 包 5321 → 6191 KB。按上一版量出的解析代价(约 1.5 ms/MB),这只多了 约 1 ms;解码内存仍是每张 6 MB,只在那一套皮肤被显示时才付。
- 预检 290 → 302 项。

1.12.1

1.12.1

- 更正一条我自己制造的假约束。 上一条变更里写着"冒烟守卫是 6 MB,再加就会撞上,那时该谈按需加载" ——那句话是错的:那条红线是我为了"别让它悄悄变胖"自己拍的数,不是任何真实约束。
有人问我"包体哪来的上限",我才去把它量清楚:

| 包体积 | 解析+执行 |
| 5.2 MB(现在) | 19.3 ms |
| 8 MB | 28.4 ms |
| 16 MB | 32.3 ms |
| 24 MB | 46.5 ms |

斜率约 1.45 ms/MB,次线性;再加一整套皮肤(+1.1 MB)约 +1.6 ms。
所以冒烟里那条上限删掉了,改成只记录体积 —— 真正该守的是"代价",而挂载开销已经由 tools/perf.mjs 量着并写进 README。留着一条会挡住功能、又没有依据的红线,比没有红线更糟:它会让下一个做内容的人(包括我自己)把力气花在"按需加载"这种没必要的事情上。
- 包体积不变(5321 KB)。
- 顺手把测量探针收进包里(tools/bundle-cost.mjs):我第一版 README 里写的是开发机的绝对路径,被"文档不得指向包外"那条守卫当场抓住 —— 守卫工作了,于是把探针搬进包内,让那句话可复现。

1.12.0

1.12.0

- 新增久坐提醒(零美术成本的行为内容):连续活动到 careAfterMs(默认 90 分钟)她会抬手指你一下、说一句叫你起来动动,然后自己回到 alert。新增 12 句 care 台词池。

判据是这件事真正的难点,我按"能不能被证伪"的顺序定:
- 什么算"还在坐着":跑着任务、或输入框里有字 —— 没有更直接的信号,就用这个近似。
- 什么算"离开":中间停顿超过 90 秒才算。人是会停下来想事情的,把那种间隙当成离开,计时就永远攒不满。离开后计时从头开始,不接着攒。
- 多久才该再说一次:同一个间隔长度,不会一分钟后又唠叨。
- 睡着时:先叫醒再提醒 —— 打盹着喊人起来活动,读起来像 bug(pending 面板那条路也是这么做的)。
- careAfterMs: 0 整个关掉。

这一条刻意不做成新皮肤:包已经到 5319 KB、守卫是 6 MB,再加一套皮肤就会撞上。
同样的力气花在行为上,买到的是新反应而不是新衣服。
- 修一处测试写法:care 是 4.2 秒的瞬时状态,而 advance 是分步推进的 —— 一口气推完再采样必然错过它。现在用"推进到观察到为止"和"推进中收集出现过的情绪"来断言。
(同一段里还撞了两个已有变量名,用限定行范围加前缀的方式解决,没有全局改名。)
- 预检 275 → 290 项(久坐提醒 12 条断言 + 睡觉唤醒一条)。包体积不变(5310 KB)。

1.11.0

1.11.0

- 新增第六个情绪 poked(被戳到时的抗议),四套皮肤各一张。姿态是「往后一缩、抬手挡在胸前、睁大眼睛抿着嘴」—— 与 attn(前倾招手)一眼能区分。

行为上给点击升了一级:戳一下是开心,三秒内戳到第三次她会抗议,并说一句新的台词(新增 10 句 poked 台词池)。窗口过期后计数归零 —— 隔一会儿再戳一下不该被记仇。

- 修一个真问题:连戳窗口用的是 Date.now()(真实时间),而插件其他地方都用 nowMs()。 两个后果:它与其他有时间意义的行为用的不是同一个时钟;而且在测试里推不动(真实时间几乎不动),这个行为等于测不了。现在统一用 nowMs(),于是连戳升级、轮询不覆盖、窗口过期归零全部可断言。
- 素材流水线不再要求「每个情绪都有母版」:给已有皮肤补一个新情绪时,别的母版早就没了 ——以前这会直接中止。现在缺哪个逐条打印(「跳过(这个目录里没有 xxx.png)」),并在一张都没处理时 以非 0 退出兜底 —— 既不因为缺一张就停下,也不会把「文件名写错」静默吞掉。
- 修替身两处保真度问题:气泡是「每次新建、旧的移除」,所以按类名取第一个拿到的是最早那条,要取最新那条才是「她刚才说了什么」;以及 api.sprites 是当前皮肤的情绪名数组,不是 URL 表。
- 预检 265 → 275 项。包 4487 → 5319 KB。

这条要留意:冒烟里的包体守卫是 6 MB,现在已经到 5.3 MB……

更正(1.12.1):那句话是错的。6 MB 那条红线是我自己拍的数,不是任何真实约束。
实测解析代价:5.2 MB → 19.3 ms,24 MB → 46.5 ms,斜率约 1.45 ms/MB(次线性),再加一整套皮肤约 +1.6 ms。所以冒烟里那条上限已经删掉,改成只记录体积。

1.10.0

1.10.0

- 第四套皮肤「宵眠睡衣」(pajama)。形象要素与前几套逐字相同,只换服装:宽松深蓝长袖睡衣(领口白色鲸鱼刺绣、裤脚鲸鱼图案)+ 毛绒鲸鱼拖鞋。键控 0.48%–0.56% 软边。

这一套的用意是剪影差异:前三套都是"贴身 + 露腿",睡衣是"宽松 + 包住腿 + 毛绒拖鞋",一眼能分辨。而且它让 sleepy(抱着鲸鱼抱枕打盹)终于贴切了 —— 那是这套里最好的一格。

- 包体积 3383 → 4487 KB。冒烟里有一条"包体不得超过 4 MB"的守卫因此转红 —— 我先量了代价再抬它:包从 2.3 MB 涨到 4.4 MB,挂载一次性开销 18–19 ms → 22.8 ms(线性、可忽略), DOM 查询率完全不变。上限现在抬到 6 MB,并且注释里写明下次再加皮肤时就该讨论"按需加载皮肤",而不是继续抬这个数字。
- 预检 265 项全过(菜单项数从皮肤数推导,所以加皮肤不再让测试变红);冒烟 46 项全过。

1.9.0

1.9.0

- 第五套皮肤「原版女仆」(canon) —— 对齐社区规范的那一套(首套非双马尾形象):长直发自然披散 + 头顶大圆环呆毛 + 白色荷叶边女仆发箍(右侧蓝蝴蝶结)+ 向后下方斜掠的鲸鳍耳(深蓝外表面、下缘一条白到银灰色的内衬宽带)+ 深蓝长袖全套女仆连衣裙(立领、胸前白荷叶边、领口蝴蝶结蓝宝石胸针、金色袖口与裙摆纹样、白色半身围裙、白色长袜、深蓝圆头皮鞋)。
提示词逐字记在 tools/CHARACTER-PROMPT.md,全部纯文生图,未使用任何参考图。

- 第三套皮肤「宵蓝礼服」(night)。形象要素与前两套逐字相同,只把服装从单薄女仆装换成单层深蓝露肩礼服(一字露肩、及膝略短、侧边开衩、同色细腰带、裸腿 + 白色细带高跟)。
五张(idle / happy / sleepy / work / attn)一次出齐,键控软边 0.45%–0.61%。
三套的差别是"同一件人之外的东西换了"—— 换服装不换身份,站一起仍认得出是同一个人。
规格已写进 tools/CHARACTER-PROMPT.md,别人也能按同一套提示词重建。
- 包体积 2281 → 3383 KB(多一套皮肤 +1.1 MB,与出图前的预估一致)。要瘦的话 --tier=md 一档即可。
- 修一个会污染发布内容的工具缺陷:assets.mjs 把抠图后的全尺寸 PNG 中间产物写在输出目录里,最后再删 —— 删除一旦失败就在 assets/ 留下 0 字节隐藏残骸(实测 classic 4 个、night 5 个,而且会随包发布)。中间产物现在写到系统临时目录,从不进入发布目录;9 个残骸已清理。
- 菜单项数不再写死在测试里:预检与冒烟都把断言改成"12 项固定 + 每套皮肤一项",从皮肤数推导。
加一套皮肤时它是红的(14 != 15)—— 那是数据的错,不是行为的错,测试不该因此变红。
这类"测试里抄一份数据"的问题本轮是第二次撞上(上一次是台词池硬编码)。
- 预检 248 项、冒烟 46 项,全部通过。

1.8.0

1.8.0

- 台词从 39 句扩到 120 句,并按时段分四套招呼语(早上 / 下午 / 晚上 / 凌晨)。
她是个长期在场的东西:一天被点几十次、看几百轮任务 —— 3–6 句的池子在一周内就会被看穿;而且原先凌晨三点和上午十点说的是同一句话。
- 池子的宽度按"见到的频率"分级,而不是一刀切:hint 一辈子只说一次(3 句够),招呼语一次挂载说一句(5 句够),其余场景一天被看到几十次(≥8 句)。这条现在是断言。
第一次写这条断言时我把阈值定成 6,结果 move/home/happy/sleep/wake 不够 —— 该加的是内容,不是放宽标准,于是把这五个池子补到 8–9 句。
- 修一处测试里的重复数据:庆祝台词的断言把旧池子硬编码在测试里
(['跑完了。','收了。',…]),我一改文案它就红。改成从产物里读出台词表再比对 ——
测的是"她说的来自正确的池子",而不是"她说了我记住的那几句";池子换了测试自动跟上。
这条断言原本还会放过"庆祝说了别的池子的话"这类真 bug(硬编码列表掩盖了它)。
- __wisp.doctor() 增加 greeting 段:当前时段、小时数、池子大小与四套池子各自的规模 ——不用再猜"她现在为什么这么说"。
- 预检 241 → 248 项。

1.7.0

1.7.0

- 新增 tools/pack-check.mjs:npm pack → 解包 → 从解出的副本跑预检与重建,逐项确认发布子集自足。实测:解出副本预检全过、并能从随包素材重建出完整产物。
- 修一个真实的打包缺陷:README 的性能一节原先指向开发机上的一个绝对路径(在包外)—— 别人装走之后,那句话是空话。
现在探针收进包里(tools/perf.mjs),README 指向它。
- 把"文档不得指向包外"变成断言:包内文档里出现绝对路径或开发机目录就红;文档提到的每个 tools/ 必须在 files[] 里。
这条断言我自己也写错过一次:正则要求匹配两个反斜杠,于是明明存在的死引用被报成"没有包外引用" —— 假通过。修正则后先确认它能红,再修 README。
- 清掉一个残留物:撤掉设置面板时留下的截图(删过一次,之后又被冒烟跑出来了)。
- tools/perf.mjs 与 tools/smoke.mjs 的 Playwright 解析都补上"chromium 必须真的存在"的检查: require 成功但导出里没有它的候选是存在的,直接返回会让调用处在别处炸(perf.mjs 上踩过)。
- 预检 239 → 241 项。

1.6.0

1.6.0

- 在已发布的 app.asar 里逐个确认钩子存在。 这是不依赖页面鉴权能拿到的最强证据 ——之前所有钩子都只是"我读了源码推出来的",源码里有 ≠ 发行版里有(构建、裁剪、改名都可能让它消失):

| 钩子 | app.asar |
| data-composer-input | ×1 |
| data-question-key / data-plan-review-key | ×1 / ×1 |
| data-approval-key | ×2 |
| data-error / data-ds-dark-theme | ×20+ |

只剩忙碌钩子无法静态确认:它的 aria-label 来自语言表而非字面量。
- doctor() 的忙碌钩子在 miss 时自解释:把页面上真实的按钮 aria-label 采样一起报出来(去重、上限 12 条,且只在 miss 时附带,不污染正常报告)。一次调用就能看出该匹配什么。
- 新增长时间运行的泄漏测试(soak,120 轮交互):此前所有测试都是"一次性"的 —— 挂载、动几下、拆掉,没有一条验证过反复开关菜单 / 反复换皮肤 / 反复进出状态之后会不会累积。虚拟时钟让它很便宜。
实测:窗口监听器零增长;120 次换皮肤只创建了 8 个 blob URL(缓存命中);瞬态 UI 无残留;拆卸后定时器归零、8/8 对象 URL 全部回收。
- 修替身第三处说谎:matchAll 里 'button[aria-label]' 也会命中 startsWith('button[') 的分支,于是新增的采样逻辑在替身里永远返回空数组 —— 断言空过。真实 DOM 是精确匹配选择器的,不会这样。
- 预检 229 → 239 项。

1.5.0

1.5.0

- 性能表重新实测并修正。 原来那三个数字是加 attn、踱步、交叉淡入淡出、5 张精灵图、2 套皮肤之前测的,已经过期:

| 指标 | 旧值 | 实测新值 |
| 每秒 querySelector | 1 次 | 1.6 次(有输入框)/ 2.8 次(无输入框,最悲观) |
| 轮询成本 | 0.44 ms/轮 | 0.86 ms/轮(每轮 3 次查询:忙碌 + 待处理 + 输入框) |
| 每帧间隔差 | +0.005 ms | +0.023 ms |
| querySelectorAll | 0 次/秒 | 0 次/秒(这条仍然成立) |

多出来的查询来自 attn 引入的待处理面板探测。新增三项测量:DOM 查询耗时 0.72–0.83 ms/秒 (单核的 0.07–0.08%)、挂载 18–19 ms(含 2.2 MB 包解析)、解码内存 6.0 MB/张。
- 把性能声明钉成冒烟断言:空闲时 querySelector ≤ 4 次/秒、querySelectorAll 恰好 0。
以后谁再加一个"每轮都查一次"的功能,测试会先红,而不是等 README 悄悄过期。
- 帧开销必须交替重复测:同一份代码两组分别测得 +0.127 与 −0.002 ms —— 单次差值里噪声就有 0.1 ms 量级,一次测量说明不了任何事。这条也写进了 README。
- 探针本身也修了一次:我一开始把新的检查插到了 browser.close() 之后,于是它报"页面已关闭" 并触发了环境中断重试 —— 那是我自己的错,不是环境问题。

1.4.0

1.4.0

- 新增 __wisp.doctor() 自检。理由:她依赖的每个选择器都是我读 DSH 源码推出来的,错了不会报错 —— 功能只是永远不触发;而她跑在带鉴权的页面里,我的浏览器连不上(401 dsh web authentication required),所以自检必须由插件自己在页面里给出来。
- 修一处可移植性隐患:doctor() 起初用了裸 getComputedStyle。客户端半包可能运行在 "被提供的 window ≠ 环境全局"之下 —— nowMs 当初就是这么坏掉的。改成 window.getComputedStyle。
这条是被测试替身逼出来的:假 window 上没有这个全局,于是它立刻炸了出来。
- 补上了从没测过的输入框回退链:此前替身让三个钩子同时命中,等于回退链完全没测。
现在替身一次只让一种形态存在,两条回退(textarea、[contenteditable="true"])都验证过真的能用。
- 收敛诊断信号:problems 不再把"没有待处理面板"算成问题 —— 缺席是正常状态。
- 测试替身两处保真度修正:hasAttribute,以及让 querySelector 与 querySelectorAll 共用同一份匹配逻辑(此前两处各写一半,导致用 querySelectorAll 的 doctor() 在替身里永远看到 0 —— 替身说谎)。
- 冒烟里的一条断言改成分工写明:精确的踱步上限由确定性 harness 保证,真实浏览器只验"真的动了、真的是滑行" —— headless 节流会让定时器补发,两次踱步会被合成一步(实测 226px)。
- 预检 211 → 229 项,冒烟 39 → 44 项。

1.3.0

- 补上键盘入口:菜单早就支持键盘导航,但她本身 tabindex="-1",键盘用户没有办法打开它 ——声明了 role="menu" 却没有入口,与之前"声明了 role 却按不动"是同一类问题。现在她是 tabindex="0" + aria-haspopup="menu",Enter / Space / 菜单键 / Shift+F10 都能打开菜单(Space 默认滚动、Enter 在表单里会提交,所以两个都拦了)。她挂在 body 末尾,Tab 顺序上排在应用内容之后,不插队。
- 量并记录了解码后的内存:一张精灵图在内存里是 宽 × 高 × 4 字节 —— 默认档 6.0 MB, hi 档是它的 4 倍(25 MB)。预热会一次性解码当前皮肤的全部五张,换皮肤再解五张。
所以"选哪一档"不只是锐度问题,也是内存问题;这条现在是冒烟里的一条断言。
- 冒烟套件给出有意义的退出码,并对"环境中断"重试一次:见上文。
- 预检 205 → 211 项,冒烟 34 → 39 项。

1.2.0

- 三个行为开关进了右键菜单(新增「行为」一段):自己溜达 / 跟随状态 / 跑完撒花,带勾显示当前状态。
这是撤掉设置页后真正的缺口 —— 那三个开关是设置页相对菜单的唯一净增量,放回菜单比再建一页便宜得多(复用现有的项结构、键盘支持与关闭逻辑,约 20 行)。菜单现在 14 项、3 个分组。
- 真实浏览器覆盖补到状态层:attn([data-question-key] 面板)、worried(失败行 + 一轮结束)、踱步(真的走了、真的用了滑行)现在都有真实页面上的断言,不再只有假 DOM 覆盖。
冒烟从 24 项增到 34 项。
- 修一处冒烟自身的错,并让崩溃不再静默:踱步那条断言量的是 7 秒窗口内的累计位移,而踱步每 5 秒触发一次 —— 窗口里走两步时累计必然超过单次上限(实测 209px),于是偶发失败。
改成量每一步。另外给脚本加了 uncaughtException / unhandledRejection 兜底:之前有一次它没有任何输出就 exit=1,那是启动期异常被 PowerShell 重定向吞掉了 ——「没有输出的失败」比一条 FAIL 难查得多。

1.1.0

- 新增 attn 专属精灵图。此前 attn 借用 work 那张,两个状态长得一样,只能靠光晕和左右轻推区分。现在是「抬手朝你招了招」的姿态,一眼能认出来。两套皮肤各加一张(10 张精灵图),包 1740 → 2273 KB。
- 皮肤可以只有部分情绪:spriteOf() 在缺图时按 SPRITE_FALLBACK 退到备选(attn → work),而不是渲染成空白。所以"某套皮肤只给四张"是合法状态,不是坏状态。
- 新增 tools/tiers.mjs:把已经抠好的透明 PNG分成四档,不重复抠图。
分工由此明确:assets.mjs = 绿幕母版 → 抠图 → 四档;tiers.mjs = 已抠好的图 → 四档。
把抠过的图再喂给 assets.mjs 会二次抠图,结果依赖"透明像素里是否还残留绿色"——那是假象,不是保证。两个工具都会拒绝明显不对的输入(无 alpha 通道 / 非绿幕)。
- 记一笔实测(这条决定了 _hi 是白带的):1024 档与 2048 档在她的实际尺寸下不可区分 —— DPR 2 下高频能量差 0.3%, DPR 1/3 下 1024 档反而高 4~5%(2048 被浏览器缩得更多,重采样本身是低通滤波)。
她屏上只有 560 CSS px,2048 档多出来的像素没有地方显示。

1.0.0

- 做过又撤掉了设置面板。 面板技术上完全可行(零依赖路由:平台 seed 表里有 react 与 @deepseek-ai/dsh-client-ui-slots,都由 shell 注入),也实现并验证通过;但它与右键菜单的重叠过大 —— 大小 / 皮肤 / 位置 / 可见性菜单都有,净增量只有三个开关(空闲踱步、跟随 agent 状态、跑完撒花)。两个入口意味着两处 UI 要维护,不值,于是撤掉。
那三个开关回到 YAML 配置 + Console。
- 右键菜单键盘可达(保留):原先声明了 role="menu" 却没有任何键盘支持 —— 对屏幕阅读器来说那比不声明更糟。现在打开即聚焦第一项(roving tabindex,只有一项可 Tab 到)、↑↓ 环绕、 Home/End、Esc 关闭并把焦点交还给她、Tab 离开即关闭。焦点落在真正的 上, Enter/Space 交给浏览器原生行为,不自己再拦一次(否则双重触发)。
- 体积 3234 KB → 1746 KB:两个皮肤改用 1024×1536 档。这个取舍现在在构建输出里算给你看 (按 DPR 1/1.5/2/3 列出"缩小"还是"放大"),而不是只写在文档里 —— 体积与锐度是同一个决定的两面。要回到 2048×3072:node build.mjs --tier=hi(约 3234 KB)。
- 真实浏览器冒烟套件进仓库:tools/smoke.mjs,35 项,一条命令。此前这些检查散在开发机的工作区脚本里,换台机器就没了。Playwright 是可选依赖,缺了跳过而不是报错。
- 修一个只有真实渲染才暴露的体验问题:她 560×840,会压在设置页上 —— 面板里加了提示,「先躲起来」就在同一页。
- 预检 180 → 220 项,另有 35 项真实浏览器检查。

0.12.0

- 修一个我在 0.8.0 留下的缺口:菜单里能放大缩小,但刷新后尺寸回到默认。 位置 / 皮肤 / 隐藏都记了,只有尺寸没记 —— 同一个菜单里的设置有的记得住有的记不住,是明显的自相矛盾。
规则:菜单里改过的尺寸记在本地并活过刷新;配置里显式写了 size 时以配置为准(项目级设定比一次临时缩放更权威)。"显式写了"与"用了默认值"靠原始 config 有没有这个键来区分。
- 位置从"一个右下角"变成四个角落。她放大到 560x840 之后,"右下角"未必合适 —— 屏幕右边可能是侧栏,左下角未必被输入框挡住。菜单里多一段「位置」,四个角落各一项。
- 第一次见面提一句右键(右键我一下试试。)。菜单是主要入口,但从外观上完全看不出来;只提一次,记在 dsh-wisp:hint:v1 —— 第二次见面还念叨就成了骚扰。
- 顺手删掉一处重复的 LINES.error 键(前面的连续编辑留下的,值相同所以没造成行为差异)。
- 预检增至 180 项(尺寸跨挂载记忆 / 配置写死时不被记忆覆盖 / 四个角落各自的落点 / 只提一次提示 / 第二次挂载只打招呼不再念叨 / 新 harness 挂载到自己的窗口)

0.11.0

- 新增出错反应:DSH 给失败的工具行与命令卡都标了 data-error="true"(ui-tool 的 ToolRow、 ui-chat 的 GenericCommandCard),所以"存在"本身就是信号。一轮跑完时如果看到失败行,她切到 worried(垂眼 + 灰蓝光晕 + 轻微起伏)说一句「好像不太顺。」,3.5 秒后恢复;同一批错误只说一次,清掉之后再出现新的才会再说。
- 错误检查挂在"一轮结束"这个边上,而不是放进轮询里。 这是全文档扫描,放进每 1.2 秒的轮询会把 "空闲时 0 次 querySelectorAll"的性能结论作废;挂在轮次结束上则是一轮一次。10k 节点页面上实测单次扫描 0.017 ms,并且有断言钉住"扫描次数 == 跑完的轮数",防止以后被人挪回轮询里。
- 修一个状态被覆盖的 bug:worried 刚摆出来就被同一个 poll 周期末尾的 setMood('idle') 抹掉 ——临时状态必须先进"提前返回"名单,否则它们活不过一个轮询周期。happy 之前恰好因为同样的原因被写进去了,所以一直没暴露。
- 预检增至 166 项(失败轮触发 / 会说一句 / 同一批不重复 / 新的再说 / 表情会过期 / 干净轮无反应 / reactions: false 连扫都不扫 / 扫描次数等于轮数)

0.10.0

- 新增「先躲起来」:她放大到 560x840 之后会挡住会话与输入框,而"让她消失一会儿"原先只能整个禁用插件。现在菜单里选一下,她整个消失,只在右下角留一个 56px 迷你按钮(缩略图直接用当前皮肤的 idle 图,所以不需要额外素材),点一下回来。状态记在本地,刷新后仍然躲着 —— 但迷你按钮会一起回来:藏起来又没有回去的路是个陷阱。
- 躲起来时:不自己溜达、不参与命中测试、气泡一起收掉(气泡挂在 layer 上,只把 root 设成 display:none 会留下一个悬空气泡 —— 这个是看截图才发现的)。
- API 增加 hide() / show() / hidden;配置增加 hidden: true 可强制以隐藏状态挂载。
- 测试替身补上标签选择器:假 DOM 原先只认 .class,于是 querySelectorAll('img') 静默返回空集 ——替身的局限会伪装成产品缺陷。现在类选择器与标签选择器都支持。
- 预检增至 156 项(隐藏标记 / 迷你按钮与其缩略图 / 气泡一起收掉 / 躲着时不溜达 / 点按钮回来 / 跨挂载保留 / 按钮跟着回来)

0.9.0

- 新增皮肤管理:右键菜单里多了「皮肤」一段,包里每套皮肤一项,当前那套打勾。切换只是换一整套 idle/happy/sleepy/work —— 位置、情绪、气泡都不动,只有精灵图与命中蒙版重建。选择记在 dsh-wisp:skin:v1,刷新后保留;也可以在配置里用 skin: <名字> 点名。
- 素材改为按皮肤分目录:assets/<skin>/<mood>[_tier].webp。build.mjs 自动把每个子目录当成一套皮肤打进包;没有子目录时退化为单套(--from <任意目录> 的老用法仍然成立)。
- 默认皮肤由显式优先级决定,不是目录名字母序。一开始按字母序排,默认皮肤变成了 classic ——那是构建实现的偶然,不该变成产品行为,所以 build.mjs 里写死 SKIN_PRIORITY。
- 随包的几套:深海女仆(当前旗舰)、原版女仆(对齐社区规范)、素绘女仆(赛璐璐平涂、较早那套更保守的设定)。
- 预检增至 141 项(每套皮肤必须凑齐四个情绪 / 菜单列出全部皮肤 / 只有一套打勾 / 换皮肤真的换图 / 换皮肤不动位置 / 记忆生效 / 未知名被拒 / configure({skin}) 与菜单同一条路)

0.8.0

- 新增右键菜单:右键她本体弹出五项操作 —— 说句话 / 放大一点 / 缩小一点 / 回到右下角 / 睡一会儿。
菜单挂在 .wisp-layer 上(屏幕坐标系),不挂在会被镜像和缩放的 root 上,否则文字会左右翻转、尺寸会跟着她一起放大。Esc、点击别处、滚动、窗口尺寸变化都会关闭它,关闭时摘掉自己的全部监听器。
- 上版的"右键即归位"改为菜单里的「回到右下角」一项 —— 一个手势只做一件事太浪费,而放大缩小这类操作原先也只能开 Console。
- 预检增至 130 项(菜单结构 / 菜单项动作真的生效 / 不落在 body 内 / Esc / 点外部 / 滚动 / 监听器回收 / 放大后再归位用的是当前尺寸)

0.7.0

- 新增 attn 状态:有东西在等你处理。 这是唯一"你不理它就卡住"的情形,所以它的优先级高于一切 —— Agent 提问(\[data-question-key]\)、计划待确认(\[data-plan-review-key]\)、工具授权待批准
(\[data-approval-key]\)三个面板的出现即触发;睡着了也会被叫起来。
三个钩子都是语言无关的 \data-\ 属性,且元素只在真的有待处理项时才渲染,所以"存在"本身就是信号; key 用来区分"新的一件事" —— 同一件事不重复念,换了一件才再说一次。
- 预检增至 116 项(新状态 / 不重复提醒 / 换一件再说 / 回答后恢复 / 从睡眠中唤醒)

0.6.0

- 新增"自己踱步":空闲且 agent 不在跑时,她会每隔 wanderMs 考虑溜达一次,走到 wanderRange 以内的随机位置 —— 用 CSS 过渡滑行过去而不是瞬移;被拖拽、睡着、忙碌时一律不动。踱步不写入记忆位置:记忆的是"你把她放在哪",不然刷新后她会出现在一个你没选过的位置
- 新增右键归位:她放大到 560×840 之后很容易被拖到看不见的地方,而"恢复位置"原先只能开 Console —— 现在右键她本体即可回到右下角默认位
- 被拖放后会随机说一句(35% 概率)
- 预检增至 108 项(踱步范围/滑行类名/不覆盖记忆位置/睡着不动/右键归位/关掉就不动)

0.5.0

- 素材流水线收进插件(tools/):CHARACTER-PROMPT.md 成为形象的唯一权威来源(逐字复用提示词 + 四姿态 + 生成参数),assets.mjs 一条命令完成「绿幕 → 抠图 → 四档素材」,任何一张抠图异常都会以非 0 退出而不是悄悄混进包里;keyout.mjs 可单独抠一张调试
- 情绪切换改为交叉淡入淡出:原来直接换 src,观感像闪一下;现在旧精灵保留独立图层淡出后再移除
- 每个情绪有自己的动作:happy 改成 1.15s 小跳,alert 呼吸加快到 4.6s,sleep 仍是 9s 慢呼吸
- 预检增至 98 项(新增交叉淡入淡出的两层/回收断言)

0.4.0 – 0.4.4

- 尺寸从 140×210 放大到 560×840(size: 4,上限提到 8)
- 修复 transform 位移写错导致的"挪不动":位移是原点无关的,之前按"重心原点"补了半格导致绘制位置与命中区错开半个身位;同时补上行内关键样式,避免样式表未生效时把她所在的层变成文档流元素、把页面撑出滚动条(实测旧行为可达 3292px)
- 抠图质量:弃用服务端透明背景,改绿幕 + 本地抠图(发梢与鲸尾不再被切掉)
- 形象更新:深蓝超长双马尾 + 鲸鱼鳍耳饰 + 单薄性感女仆装 + 商业级高精度二次元画风

0.3.1

- 反应轮询成本降到 1/3(实测 1.27 ms → 0.44 ms 每次):输入框元素改为解析一次后缓存,不再每次全量 querySelectorAll(现在每秒 0 次 NodeList 收集);忙碌判定由 4 条精确选择器改为 2 条子串选择器(顺带覆盖未预见的语言标签)
- 去掉常驻 will-change: transform:只在真的拖动期间挂合成提示,不再为几乎不发生的事件常驻一个合成层
- paint() 不再每次重写永不变化的 transform-origin
- 新增 3 项预检守卫:已解析的输入框不得每帧重查、一次 tick 不得收集 NodeList、无输入框时每 tick 最多一次探测

0.3.0

- 调度改用 Client timer 服务,rAF 降级为兜底且只在有待触发任务时挂帧 —— 去掉常驻帧循环与每次 pointermove 重建定时器的开销
- 修复 nowMs 混用两个时钟源:window.performance && performance.now() 在壳自带 window 的场景下会用两个时间基准算/比截止时间,导致任何定时器都永不触发;现在只经 window.performance 读取
- 修复重挂载碎图:blob 缓存改为吊销时一并清键(模块级缓存 × 每次 apply 挂载的交叉 bug)
- 新增:位置记忆、跑完庆祝、configure() 部分更新、resetPosition()、clock / position / currentMood 只读查询
- 新增:theme 服务订阅,气泡与文字改用 --dsw-alias- 主题 token(带手调兜底)
- 新增:忙碌判定改用壳真实的「停止生成」按钮标签
- 加固:配置区间夹取(坏配置退化为默认值而非 NaN 几何)、document.body 未就绪时有限重试、存储不可用(不透明源)时优雅降级
- build.mjs 增加陷阱全局与版本漂移两道防线;verify-wisp.mjs 重写为六陷阱注入 + 虚拟时钟双路径驱动

0.2.1

- 精灵图内嵌;blob URL 修复碎图;ctx.effect 拆除钩子

0.2.0

- host 半包清空为零依赖;patch 指向 host 半包(修 failed to import)

0.1.0

- 初版(纯 DOM 注入版)

目录布局

本仓库是独立仓库:文件夹名、package.json、GitHub 与 npm 上的包名都是 dsh-wisp,仓库里没有多余的嵌套项目。画素材用的绿幕母版归档就在仓库内部:

dsh-wisp/
├─ lib/ assets/ tools/ audit/ ← 项目本体(全部进版本库) └─ sprite-masters/ ← 绿幕母版归档(233 MB,被 .gitignore 忽略)

sprite-masters/ 里是改形象时的源材料:70 张纯绿底 PNG + 两份代理登记表,配套 tools/CHARACTER-PROMPT.md(形象的唯一权威来源)。用法:

node tools/assets.mjs --from sprite-masters --out assets/<皮肤名>

⚠️ 不要对它执行 git clean -xdf —— 被忽略 ≠ 被保护,那条命令会把 233 MB 母版一起删掉。
母版重新生成很贵(每张 4K 出图数分钟且要人工挑图)。清理工作区前先把它移出仓库。

为什么放在仓库里而不是外面:只有一个 wisp 文件夹,源材料跟着项目走,不会在搬项目时被落下。
代价就是上面那条注意事项。
预览分享卡片长什么样 ↓969246694/dsh-wisp
分享 · DeepSeek Harness Hub
969246694/dsh-wisp

DeepSeek娘 — 非官方的 DeepSeek Harness 界面悬浮桌宠。零依赖,精灵图内联,代码 MIT、美术素材 CC BY-NC-SA。

这个插件是你维护的吗?
作者本人可认领并核对本条目;认领通过后,详情页会显示「作者已认领」标记。
认领条目 →
回到顶部