dsh-wisp
DeepSeek Harness Web 界面的浮动陪伴插件:DeepSeek娘 桌宠。
当前版本 1.56.0 · 零依赖 · 单文件客户端半包(精灵图内嵌为 data URI;帧动画素材自 1.47.0 起由宿主半包按 URL 发,客户端里只留一张文件名清单)
非官方插件,与 DeepSeek(深度求索)官方无关。角色形象与图片许可见 NOTICE.md。
它做什么
| 行为 | 触发条件 |
|---|---|
| 待机 | 站着时放 idle 那一段帧动画(<img> 动图 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(<img>,她睡着时被子那点极小的起伏)。素材按皮肤点(每条都是 720×1280、24fps;帧预算默认 97 帧=4.04 秒,另有声明的例外):深海女仆三条(deepsea_idle / deepsea_happy / deepsea_sleepy,v1.49.3)、泳装八条、原版八条;查不到的皮肤+状态就是没有动画(连 <img> 都不建)。换状态时同一个 <img> 换源,不重建元素。「静止」档与系统的「减少动态效果」会把动图藏起来、把画面交回静态立绘,躲起来时也一样(动图没有 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 现在有五道防线,任何一道失败都拒绝写出文件:
- 占位符
__SPRITES_LITERAL__/__MOTION_LITERAL__必须各出现一次,且替换后都不得残留; - 注入后的
SPRITES表要真的被求值,每个 key 都必须是data:image/开头; - 注入后的
MOTION表同样要真的被求值,每个值都必须是data:image/webp;base64,开头 (写错一个 key,客户端就会建一个永远不动、一加载就报错的<img>,而那是静默失败); - 产物不得调用被陷阱的全局(
setTimeout/setInterval/clearTimeout/clearInterval/fetch/require); 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、解码失败
—— <img> 的 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、解码失败
—— <img> 的 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就是"全不透明"的阈值,两条都保持原样 (sleepy0.24/ idle0.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.01.6% ⇒ 不补(见 0.9858 个盒高、立绘 0.9674~0.9967,
差 0.2%~1.6%(最大 lib/client.template.js 里 .wisp-video 上那段推导与 1.46.5 小节)。
1.47.1 把同一把尺子用在泳装那八条上:素材 0.9515swim_proud 1.6%)⇒ 八条全都不补,MOTION_FIT 一个字没改;
样式表那边补了一条 .wisp-video[data-clip^="swim_"]{transform:none},把"不补"写成显式规则。
它和精灵图一样按需解码:挂载时建一个 <img> 给当前皮肤当前状态的那一段
(默认皮肤是 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):
- 不要用服务端的透明背景。 它会把浅蓝渐变的发梢与鲸尾当背景切掉,表现成"模型跑形",几乎无法从结果反推。出绿幕、自己抠图 —— 只有 0.4%–0.7% 的边缘像素需要处理,发丝一根不丢。
- 不要传参考图。 形象靠提示词锚定;一条提示词只放一个角色。
- 画风别写"高质量二次元插画"这种笼统说法,它会推向韩系半写实并崩掉设定。
性能
一切数字都是在真实 Chromium 里量的(合成 10k 节点会话记录,headless、CPU 光栅,即最不利的一档)。计数与耗时都靠包装原生 querySelector/querySelectorAll 得到,不靠估算。可复现的探针:node tools/perf.mjs(浏览器是可选依赖,缺了会跳过并说明)。
稳态
| 指标 | 数值 |
|---|---|
| 动画帧使用量 | 0(走 timer 服务,根本不挂帧循环) |
帧动画(v1.45.0 新增;v1.46.1 换成动图 <img>;v1.46.5 起两段) |
0 帧:挂载时(idle)就建一个 <img>(动图 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(她自己仍然不碰网络与文件) |
真正更新它的路径
- DSH 的插件列表 →「按插件包名安装」/ 更新(DSH 自己的「检查更新…」也在那里);
- 或对本包目录重新
npm i/npm pack后安装; - 更新后重载页面 —— 她会自己发现版本变了并说一句。
两个源都可以换(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 立这条指标时,我按"缝最小"去挑裁切窗口, 挑出来的正是"把慢区接两遍"的那种切口。宵蓝研究员的待机就是这么带着停顿上线的: 它不是跳,它是停 —— 一个只盯上限的指标,会把反方向推到底。
修法两处:
- 判定改成一条带:
MOTION_SEAM_LO = 0.75~MOTION_SEAM_HI = 1.25,两边都算错; - 切窗口的判据也改了:不再挑"缝最小",而是挑缝等于普通一步的那一段(目标 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。
这一轮把上一轮的两个坑提前避开了,还多学会一条:
- 提示词一次到位。 night 那一轮"动作幅度极小"写得太死,六条返工;这一轮直接把 "动作要清晰可见(大约一个手掌到一个前臂),不要做成几乎静止的动画"写进模板, 八条里七条第一次就合格。
- 切周期要挑长的。
attn第一版按"最短的闭合周期"切出了 26 帧(1.08 秒) 的循环 —— 数值合格(1.08)但内容几乎是静止的(整段姿势只挪了两个帧步,而idle挪了 12.8 个)。 改成"优先挑最长、且仍然能闭合的周期"之后,同一条素材切成 95 帧(3.96 秒), 环缝 1.05,动作内容多了 2.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
修掉「拖她的时候会抖」。 三个原因叠在一起:
- 角度取的是单次指针事件的位移 —— 真实指针流每秒 60~125 个事件,每个事件的位移都在抖 (手、系统合并、触摸板),那等于把噪声原样画到画面上。现在速度走一个指数平均(EMA), 并且手停下来 120ms 后自己回正(以前她会在你停手之后一直歪着)。
- 镜像是个纯函数(「她在屏幕哪半边」),拖动时每个事件都重算 —— 指针停在中线附近时它
来回翻,而每翻一次都要镜像精灵并把
--wisp-tilt的符号一起翻,于是一次拖动变成左右甩。 现在中线两侧有 48px 死区,只在真的越过去时才翻一次;命中判定用同一个量(否则死区里点她会打偏)。 - 拖动中「完全不过渡」 —— 每个事件算出来的角度都是瞬间落到画面上的。现在留 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.301.19,而 canon 那批是 2.212.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=- 就是它留下的现场。
现在页面按顺序试两条:
- 宿主半包的同源端点
GET /wisp-motion/__update:宿主用自己的web服务读两个源 (拿不到web就退回问插件管理器「这个包现在是什么版本」),页面只做一次同源 GET —— 不经过页面那一侧的系统代理,也不涉及 CORS; - 端点不在(旧宿主半包 / 路由没注册)时,页面照旧自己
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 里没有键、查不到素材、连 <img> 都不建。另外两条是重做:
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
缩放一次,真正下移的是 s*t),故 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
三处修好 + 一个新开关。
- 子菜单里的「台词语言」「动作幅度」以前点不动。 菜单分组改版时,子菜单那条渲染路径自己
抄了一份循环、漏了
kind: 'choice'—— 于是它们变成一个没有胶囊、点了没反应的普通行 (没有run,异常被try/catch吞掉)。现在子菜单与顶层共用同一个renderChildren()。 用户报的「动作幅度好像没用」,根因就是它。 - 菜单里调过的每一项都活过刷新。 以前只有「大小」和「台词语言」会记;现在
motion/frame/sway/wander/reactions/celebrate/hungry/night/sound一起记在dsh-wisp:prefs:v1,加载时取回 —— 配置里显式写过的键仍然以配置为准。 - 「左右晃动」开关(行为组):跟随鼠标侧倾那条路的总闸。开 = 指针进到 170px 内朝指针方向 侧身(最多 3.5°、越近倾得越多),关 = 不再跟随鼠标并把姿势当场归零。默认开(= 一直以来的行为)。
- 「动作幅度」讲清楚它到底改什么:
克制= 手势与侧倾减半、溜达距离 ×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() 查不到就连 <img> 都不建 —— 不建元素、不解码、
画面交给静态立绘。预检同时钉三件事: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.534.0%)、品红残留 ≤0.05%(实测 0.0000%)、平滑带 0.2560.432%。
八条合计 23788.2 KB;加上通用那两条,assets/motion/ 现在 10 条 / 29800.0 KB(29.1 MB)。
尺寸校正:八条都不用补。 按包围盒(A>24)逐条量"她占盒子高多少":素材 0.95150.9858,
各自对应的泳装立绘 0.96740.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 / 解码失败 ⇒
<img>的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 状态 ——
挂载即播,换状态时同一个 <img> 换源。动作层那一套规则一条没动: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:v60 → 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.298.8%,差 1.01.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(<video> → <img>,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, 元素从
<video>换成<img>,"冻结"从pause() + currentTime = 0改成藏起动图、把画面 交回立绘,colorkey的 similarity 从 0.26 收紧到 0.10。数字(315.8 KB / 421.1 KB / 800 KB 预算 /playing字段)也都是那一版的。
到这一版之前,她的"动作"全是位移/缩放/旋转:立绘本身一动不动。这一版给 sleep
状态叠了一段 <video>:她睡着时被子那点极小的起伏,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. 叠加,不是替换。 <video> 和立绘在同一个盒子里(.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. 不白解码。 没有动作素材的状态连 <video> 都不建(挂载时不碰这 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 两条路都拿不到可用的服务。于是做了两件事:
加一条兜底通道:
pluginManager.inspect('dsh-wisp')。插件管理器本来就是 "安装之前去 registry 问这个包是什么版本"的那个人 —— 所以这条路走的是应用自己 的网络出口,还尊重用户配的镜像源,而且它是平台文档里写明的宿主服务 (ctx.get('pluginManager'))。web 服务不可用时自动走它,diag.via会写pluginManager。它比插件自己去抓 packument 更接近"装的时候会装到哪一版"。失败必须能追问:
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
检查更新更耐得住,而且失败时能追问。
起因是一个真实的报错:界面上只说"更新检查没成功",没有任何办法知道为什么。查下来是两个 真缺陷(都不是"环境问题"):
两个源共用一个 8 秒截止时间。
readPublishedVersion把同一个AbortSignal传给 npm 与 GitHub 两条请求 —— 一个卡住的源到点会把这个信号 abort 掉,另一个已经拿到 结果的源也跟着作废,整次检查失败。而这台机器上raw.githubusercontent.com恰好就是连不上的那个(实测:同一时刻 npm 返回 200)。 现在每个源有自己的截止时间,谁卡住谁自己出局。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取)仍然成立,而且现在是唯一的路。失败不再是一句没法追的话。 客户端以前把
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 打平台接口"
这是这一版最值得记下来的结论,两条都实测过:
ctx.web.fetch设不了请求头。 它的请求类型只有{ url: string }(服务契约原文), provider 只读url,方法硬编码为 GET —— 而余额接口要Authorization: Bearer …。 所以平台给的网络通道在物理上就打不通那个接口,跟有没有 key 无关。- 根本不该由插件拿 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假座位走完整链路(含菜单那一行真的点一下、四种"没读到"各自的台词、英文覆盖层八条齐全 且无汉字)。 - 没验到的部分,如实说:这条路的真实端到端还没跑过 —— 跑着的应用装的是旧宿主半包,
要重启后新的
checkBalancehandler 才会在。重启后菜单里点一下,或者 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 管怎么动,代码管多久)。
两个只有跑起来才知道的坑
- 同一个
data-accent值再写一次,浏览器不会重播动画。 连戳两下必须看到两次弹, 所以pop-a/pop-b是同一件事的两个名字,交替使用(靠读一次offsetWidth强制重排也行,但那要在测试替身里也装一个布局引擎,而替身没有布局)。 - 动作通道只留最后发生的那件事。 最初给"松手没拖动"配了独立的
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' 又确实写进去了,我没查出这两者为什么不一致。
按"没验证过的行为不该写成承诺"的规矩,我改成验完整且真实的那条流程:
- 菜单里选一次 → 存下来了(读到
en); - 再挂载一次(同一个存储)→ 她说的是英文;
- 英文那句和中文那句确实是两段不同的文字(不是"恰好没触发")。
三条都过。菜单里这行也因此不谎称"立刻生效"。
顺带把 ⑤ 的可用性补上了:以前只有改配置或改系统语言才能换台词语言, 现在菜单里就能选 —— 中文界面 + 英文系统的用户不必再忍 ✅。
预检 542 → 546 项。
1.36.0
1.36.0
一次性做完 ③④⑤ 三件事。其中 ⑤ 完整交付并验证;③④ 的代码路径全部做完并接好, 但素材生成这次没成功(原因见最后一节)。
⑤ 英文台词
她会说英文了。只翻译她说的话(菜单按钮文案仍由 DSH 界面语言决定,不在这一层)。
实现上是覆盖层而不是替换:中文词池原样保留,英文按 key 覆盖上去, 缺哪条就自动退回中文 —— 所以可以慢慢补齐,永远不会出现半句话。
语言来源按优先级:① 本地存的强制值 localStorage['dsh-wisp-lang']
② 页面 <html 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会把"菜单 + 展开的子面板" 用真浏览器拍下来。默认不拍,不影响正常冒烟。
一拍照就发现了两个用代码看不出的问题:
- 「放大一点 / 缩小一点」还挂在「外观」子面板里 —— 大小早就做成菜单里的滑块了, 这两条是重复的(而且是我之前声称删掉、实际没打上的改动)。已删。
- 只有主操作有图标,「专注 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)。
根因有三层,都是"拿了不该拿的数字":
- 混用了两套坐标系:菜单的
left/top是图层坐标,而getBoundingClientRect()给的是视口坐标。二者只在图层恰好从 (0,0) 开始时相等。 - 量了刚 append、还没布局的面板 ✗:那一刻它的宽度是 0, 于是所有基于它的校正被守卫挡掉,位置就停在估算值上(实测就是这样一直没生效)。
- 宽度算错: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(都是测试逼出来的):
- 子面板泄漏:
closeMenu里那句收面板写成了if (menuEl) closeFlyout()—— 而那时menuEl已经被置空,于是关菜单时子面板被丢在屏幕上。 - 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)这次修了三处"不忠实" —— 它们让真代码在测试里表现不对:
- 元素级
querySelector/querySelectorAll与替身的all()/find()原来是类名精确相等, 而真实 DOM 的.a是按类名列表匹配 —— 于是wisp-menu-item wisp-menu-row这类行 在替身里"不存在"。这个坑前后踩过两次,这次统一成类名列表; - 只支持单个类名或标签选择器(属性选择器、复合选择器都不行)→ 于是给滑块数值与角落按钮 各配了一个类名,测试不再依赖替身不支持的选择器;
- 测试里判断"焦点在滑块上"要重新取当前元素:菜单每次重画都换新元素, 拿之前抓到的做同一性比较会假失败。
1.30.0
1.30.0
这一版是功能,不是台词。
专注计时器。 桌面陪伴插件最该有的实用功能:替你画出一段时间的边界。
- 菜单「行为」组里「专注 25 分钟」/「专注 45 分钟」(默认时长
focusMinutes), 专注中多一条「结束专注」(平时不出现 —— 菜单里不该有按不动的条目); - 专注期间她保持"干活中"的姿势:稳态轮询不许把她挪走 ✓。这条 hold 放在久坐 / 干饭 / 劝睡之后 —— 那三件事该响还得响,只是姿势归专注管;
- 戳她一下 = 问还剩多久("还有 23 分钟呢,盯着屏幕。"),而且不计入"连戳", 不会升级成抗议;
- 到点她换
proud说收工("这一轮 25 分钟,结束了。"),doctor().focus.done累计完成次数; - 她不碰应用:不改输入框、不发消息、不点任何按钮 —— 只陪着 + 计时。
- 菜单「行为」组里「专注 25 分钟」/「专注 45 分钟」(默认时长
页面藏起来就安静。 挂
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、点格子会关弹窗并摆姿势)。
这一轮踩的坑(都记在这里,免得下次重演):
- 简化 DOM 不会汇总子节点的
textContent—— 分组名、弹窗段落、页脚都因此读成空串, 改成"文本写在带类名的元素上"; - 替身的
find()/all()会把已移除的节点也返回,判断"弹窗关没关"必须显式认removed; - 替身的
all('wisp-menu-item')是类名精确匹配,多挂一个类就选不中了 —— 分组标记因此改放dataset; - 测试里用了文件后半部分才定义的 helper(
ctxEvent)→ TDZ,把整段套件截断过一次; - 冒烟里新插入的检查放在键盘段之前会扰动它依赖的焦点状态 → 挪到键盘段之后。
- 简化 DOM 不会汇总子节点的
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 项(版本、更新感知、剪贴板、降级、能力边界都测了)。
又踩了两次同一个坑,都记下来:
- 补丁锚点缩进又深了两格(应是 10/6/8,我写了 12/8/10)—— 三处失配,好在失配不会误改;
- 我把测试用的
clipboardWrites声明在了createHarness内部,却在模块作用域里用它 —— 修法不是把变量提升,而是挂到替身的返回对象上(测试要能从外面看的东西,本来就该在那儿)。
1.23.0
1.23.0
交互设计补三个缺口(都是「只能靠鼠标」的问题):
- Ctrl/⌘ + 滚轮调大小 —— 以前只能开菜单点「放大一点 / 缩小一点」。
只认带修饰键的滚轮:她占地不小,直接吞掉普通滚轮等于在她身上把页面滚动禁掉,
所以普通滚轮原样放行、连 preventDefault 都不调(这条写成了断言)。
上下限仍由
CONFIG_SPEC一处夹紧(0.4–8),没有第二处重复。 - 方向键微调位置 —— 在这之前,键盘用户拿到焦点后除了开菜单什么都做不了,
挪动她只能靠鼠标拖。现在方向键 16px、Shift 4px,与拖动共用同一套边界夹紧与位置持久化;
按键同时
preventDefault(不拦的话页面会跟着滚,她看起来像「跑掉了」)。 - 躲起来之后右键迷你按钮也能开菜单,菜单里那一项改成开关: 躲着时显示「回来吧」、在着时显示「先躲起来」。原先躲起来就只剩「叫她回来」一个动作, 换皮肤/调大小/换角落全都够不着。
- Ctrl/⌘ + 滚轮调大小 —— 以前只能开菜单点「放大一点 / 缩小一点」。
只认带修饰键的滚轮:她占地不小,直接吞掉普通滚轮等于在她身上把页面滚动禁掉,
所以普通滚轮原样放行、连 preventDefault 都不调(这条写成了断言)。
上下限仍由
预检 319 → 340 项(+21:正向、反向、边界、持久化都测了)。冒烟 46 × 2 全过。
过程记录(三次失败,都是我自己写错):
- 补丁脚本连栽四次 —— 把中文引号写进了 JS 双引号字符串里。改成用模板字符串承载插入内容 之后这一类错误不会再犯。
- 三个锚点全部失配 —— 我按 8/10/12 空格写缩进,文件里实际是 6/8/10。 (失配是好事:没有误改到别处。)
- 两条断言失败又都是测试自己写错:从 y=100 按 5 次(80px)到不了 0 却断言到了 0;
把
element写在了替身上(它在 API 上)。 - 最后一条「可见时那一项应显示先躲起来」失败,是替身的
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,如实标注。- 代理登记表里确实有 14 条
实测过篡改检测:改入包素材一个字节 →
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
修正两处与社区规范的偏差(用户指出的):
- 发型:我给她加的是超长双马尾 —— 那是本插件的原创设计,社区规范里不是这样: 社区是中长直发自然披散、发梢渐变浅蓝。
- 耳朵朝向:社区是向后下方斜掠、几乎贴着头发,两色(上深蓝下白)、下缘有锯齿缺口; 我做成了向上外张的大翅膀。用户说"太大了",实测问题在朝向不在长度 —— 社区的鳍其实不短(约与脸宽相当),但它是贴着头发向后掠的。
顺带补上两个漏掉的头部特征:发箍右侧的蓝色蝴蝶结、头顶的大圆环呆毛。
新增「原版女仆」(
canon)皮肤的完整提示词(写进tools/CHARACTER-PROMPT.md): 这一套的目标不是原创,而是复刻社区共识 —— 长直发 + 大圆环呆毛 + 发箍蓝蝴蝶结 + 后掠锯齿鳍耳 + 长袖全套女仆装 + 白袜 + 深蓝圆头皮鞋。首张测试已通过比对。设计分工定下来了:双马尾是本插件的原创形象(deepsea / classic / night / pajama 保留), 社区形象作为第五套「原版女仆」共存 —— 用户的要求是"公认服装作为一套原版服装",即并存而非替换。
1.17.0
1.17.0
提示词规格修正(两条,都已实测确认):
- 鳍耳缩小:上一版每片约占头宽三分之一,实际画出来是一对大翅膀,太抢戏。 现在写死"约头宽的四分之一,不要超过三分之一,绝不要画成翅膀那样大"。
- 加呆毛(社区标识之一):一小缕头发从发顶竖起、顶端向前弯成小圆环。
写作技巧(这次最有价值的发现):同一个要求写在提示词开头会被模型忽略, 写在末尾的**【画面完整性,逐条核对】**清单里才会被执行 —— 呆毛第一版写在开头直接被省略, 编进清单后一次就有。双马尾、鲸尾、发尾完整一直生效,本来就都在那个清单里。 已写进
tools/CHARACTER-PROMPT.md。待办(本版未做):四套皮肤全部素材需按新规格重出(耳小而+呆毛); 新增一套"原版"皮肤(社区公认的女仆装);「初版女仆」改名。
1.16.0
1.16.0
按社区规范修正她的耳朵:「鲸鳍耳」—— 耳朵本身就是一对鲸鱼胸鳍,不是挂耳饰。
我去查了社区通行的画法,社区为此有一个开源角色规范项目 Neko3000/deepseek-whalechan: 它把识别点锁定为发色渐变、鲸鳍耳、呆毛、鲸尾、女仆装。也就是说社区早就把答案写在规范里了, 而我一直在"给她挂鳍形耳饰"—— 方向就是错的。
社区的鳍耳有四个我漏掉的要点(放大社区立绘逐条核对出来的):
- 向后上方斜掠张开(像后掠的小翅膀),不是朝上竖;
- 狭长、根部宽、向后收成尖梢,不是等宽直板;
- 上表面深蓝、下缘与内侧白色 —— 座头鲸胸鳍的两面配色,这是最显眼的识别点;
- 大小约占头宽三分之一,存在感明显。
默认皮肤 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把记录全报出来。实现上踩到并修掉的两个问题:
- 我一开始在
applySkin里写了个不存在的变量名(previousSkin)—— 每次换皮肤都会抛ReferenceError。这类错误只有跑起来才会暴露,写的时候看着很对。 - 记忆台词原本排在
- 我一开始在
…