PluginWorld
Ds

dsh-wisp

dsh✓ SPEC VERIFIED

DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.

@969246694 · v1.56.0 · MIT · updated 11d ago

SECURITY

A

SCORE

77

INSTALLS

▲ 6.3K

PLUG IN

npx @deepseek-ai/dsh web

Launch, then search "dsh-wisp" in the built-in market to plug it in

README

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 现在有五道防线,任何一道失败都拒绝写出文件:

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

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


真实浏览器冒烟

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

打包与分享

node tools/pack-check.mjs

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

colorkey 的 similarity 曾经是 0.26(绿幕那版)—— 那会把她的皮肤和浅色衣服一起抠掉 (她自己变成"空心的",暗色主题下看起来就是一块黑色剪影),实测只有 5.5% 的像素还是 全不透明的;0.10 时是 18.1%,也就是整个她。动作层的尺寸由 CSS 补:sleepy 那段她只占 83.5% 盒高(立绘 98.8%),要 scale(1.186) translateY(7.84%);idle 那段占 97.2%, 差 1.01.6% ⇒ 不补(见 lib/client.template.js 里 .wisp-video 上那段推导与 1.46.5 小节)。 1.47.1 把同一把尺子用在泳装那八条上:素材 0.95150.9858 个盒高、立绘 0.9674~0.9967, 差 0.2%~1.6%(最大 swim_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):

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

性能

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

稳态

指标 数值
动画帧使用量 0(走 timer 服务,根本不挂帧循环)
帧动画(v1.45.0 新增;v1.46.1 换成动图 <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(她自己仍然不碰网络与文件)

真正更新它的路径

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

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

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

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

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

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

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

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

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

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

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

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

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

预检里的守卫

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

变更

1.56.0

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

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

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

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

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

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

1.55.1

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

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

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

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

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

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

1.55.0

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

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

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

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

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

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

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

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

1.54.3

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

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

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

1.54.1

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

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

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

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

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

修法两处:

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

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

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

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

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

1.54.0

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

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

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

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

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

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

1.53.1

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

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

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

1.53.0

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

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

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

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

1.52.0

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

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

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

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

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

第一轮我给每个状态都写了"动作幅度极小"(呼吸起伏"一两像素"、手腕摆动"不超过一根手指"), 结果六条素材几乎冻住了(帧间平均变化 0.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=- 就是它留下的现场。

现在页面按顺序试两条:

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

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

1.49.6

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

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

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

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

  • error.code: 'operation-error' + packageResult.kind)。

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

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

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

1.49.5

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

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

1.49.4

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

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

1.49.3

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

assets/deepsea/happy.webp 换过新画的一版(sha256 EA78CAA4…8A40),而这条素材 以前根本不存在:在深海女仆这套皮肤上戳她一下(happy)只有静态立绘 —— 那一格在 MOTION_OF 里没有键、查不到素材、连 <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

三处修好 + 一个新开关。

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

1.49.0

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

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

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

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

兜底表删掉之后,"查不到的皮肤+状态"变成一个真实状态。 motionKeyFor() 只查一次 (没有第二级 ??),syncMotion() 查不到就连 <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:v 60 → 40:两段同时进包时 60 装不下(9.69 MB)。这是这一版唯一降质的地方, 而在这个内容上 40 与 60 的差别很小(sleepy 4440 → 3406 KB,560px 的显示尺寸下看不出 块效应)。1.46.4 之所以从 40 提到 60,理由是"块效应叠在硬边上更显锯齿"—— 现在边是软的。
  • idle 那段的幕布是品红(#FE01FF),并补了一条去品红边:源片就是这样生成的,品红对她 其实更远(按 colorkey 那把尺子量色距:青色发梢到绿幕 0.24、到品红 0.51;肤色 0.24 对 0.35)。但绿幕那条去边规则不能照搬:B = min(B, max(G,R)) 会把"蓝色比红绿 都强"的头发本身削掉。新规则只减"R、B 同时高于 G"的那一份 —— 她的蓝 (min(R,B) < G)一个像素不动。实测品红残留 1.09% → 0.006%:头发边上那圈紫边没了 (2× 放大对比过:没去边的版本沿发梢有一整条粉紫线)。
  • 动作层的几何按素材取:新增 MOTION_FIT 表 + 元素上的 data-clip,样式表用 .wisp-video[data-clip="idle"]{transform:none} 把"不校正"写成显式规则(否则 base 那条 1.186 会落到 idle 头上 —— 放大 18.6%)。idle 不补尺寸校正:她在那张里占 97.2% 盒高、 立绘 98.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 两条路都拿不到可用的服务。于是做了两件事:

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

  2. 失败必须能追问:resolveWebService() 现在返回 { service, how, why }, "为什么拿不到"会跟着失败台词一起说出来:

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

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

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

1.43.0

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

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

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

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

1.42.0

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

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

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

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

1.41.0

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

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

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

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

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

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

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

1.40.0

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

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

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

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

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

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

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

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

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

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

验证

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

1.39.0

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

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

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

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

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

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

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

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

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

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

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

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

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

验证

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

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

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

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

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

1.38.0

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

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

三个层,各管一件事

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

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

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

具体做了什么

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

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

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

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

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

reduced motion 有两道闸

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

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

零动画帧这条承诺没变

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

验证

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

1.37.0

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

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

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

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

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

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

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

预检 542 → 546 项。

1.36.0

1.36.0

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

⑤ 英文台词

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

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

语言来源按优先级:① 本地存的强制值 localStorage['dsh-wisp-lang'] ② 页面 <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 会把"菜单 + 展开的子面板" 用真浏览器拍下来。默认不拍,不影响正常冒烟。

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

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

样式改动:

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

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

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

1.33.2

1.33.2

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

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

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

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

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

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

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

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

冒烟 57 → 61 项。

1.33.1

1.33.1

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

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

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

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

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

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

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

1.33.0

1.33.0

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

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

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

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

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

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

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

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

1.32.0

1.32.0

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

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

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

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

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

1.31.0

1.31.0

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

问题(先认下来):

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

现在:

DeepSeek娘                          ← 头部:名字 + 版本 + 当前皮肤 +(专注中)
v1.31.0 · 深海女仆
─────────────────────────────
专注 25 分钟                        ← 常用动作置顶;进行中时这一行就是状态 + 结束
专注 45 分钟
─────────────────────────────
外观                 深海女仆 ▸     ← 摘要行:收起时也知道里面是什么、现在是什么
行为                   4/5 开 ▸
位置                 右下角 ▸
大小   ──────●──────   100%        ← 滑块:拖动即时改;键盘 ←→ 各 0.25 档
─────────────────────────────
动作一览…
关于她…                             ← 检查更新移到这里(版本号已在头部)
─────────────────────────────
先躲起来
↑↓ 选择 · ←→ 调大小 · Esc 关闭
  • 开关是真开关:role="menuitemcheckbox" + aria-checked + 可见的滑轨, 点完不关菜单,原地更新(摘要里的「N/M 开」也跟着变);
  • 大小是滑块:role="slider" + aria-valuenow,指针拖、键盘调都可以,同样不关菜单;
  • 位置是 2×2 罗盘:四个方位按钮,当前所在的那个有标记;
  • 版本与更新收进「关于她」:菜单头部直接显示版本号,检查更新是弹窗里的一个按钮 (上下文才对得上);
  • 三段式:动作在上、设置在中间、信息在下,段间有分隔线;底部一行键盘提示。

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

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

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

1.30.0

1.30.0

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

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

    • 菜单「行为」组里「专注 25 分钟」/「专注 45 分钟」(默认时长 focusMinutes), 专注中多一条「结束专注」(平时不出现 —— 菜单里不该有按不动的条目);
    • 专注期间她保持"干活中"的姿势:稳态轮询不许把她挪走 ✓。这条 hold 放在久坐 / 干饭 / 劝睡之后 —— 那三件事该响还得响,只是姿势归专注管;
    • 戳她一下 = 问还剩多久("还有 23 分钟呢,盯着屏幕。"),而且不计入"连戳", 不会升级成抗议;
    • 到点她换 proud 说收工("这一轮 25 分钟,结束了。"),doctor().focus.done 累计完成次数;
    • 她不碰应用:不改输入框、不发消息、不点任何按钮 —— 只陪着 + 计时。
  • 页面藏起来就安静。 挂 visibilitychange:藏起来时停掉轮询 / 絮叨 / 踱步三个定时器, 回来再按配置重新挂上。没人在看的时候不该空转 CPU(顺带省电)。

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

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

1.29.0

1.29.0

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

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

    • 作息是可配的(bedtimeHour / wakeHour,默认 23 → 5,跨零点)—— 这既是真功能(各人作息不同),也让测试不必伪造时钟:把窗口套在当前那个小时上, 该响就得响,套偏一小时就该安静;
    • 深度档按入睡后经过的小时数算,不是按"小时落在某个绕圈区间里"—— 后者在窄窗口下会把窗外一大段时间也算成深夜(写测试时当场撞上:13 点睡、14 点起, 结果 13 点自己被判成"深度深夜");
    • 菜单「行为」组里多了「深夜劝睡」开关,另有 nightMs(默认 30 分钟一次;0 = 关)。
  • 你离开又回来,她记得。 挂 visibilitychange 记住离开的时刻,回来按时长分两档: 短走一句("回来了。"、"位置我没动。")、长走(15 分钟以上)另一句 ("你走了好久。"、"我还以为你把我关了。")。阈值 backAfterMs,默认 2 分钟,0 = 关。

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

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

  • 预检 435 → 451 项。

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

1.28.0

1.28.0

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

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

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

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

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

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

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

  • 修掉两个真 bug:

    • 文档级 Esc 监听原本每次开弹窗都挂一个、而且摘不掉(清理函数是个空壳)—— 改成全程只有一个,随实例销毁;
    • 点预览格或点背景关掉弹窗后焦点掉进 <body> ✗(下一按 Enter 打不开菜单了)。 现在关弹窗一律把焦点交还给她 —— 这条是真浏览器冒烟抓到的,假 DOM 看不出来。
  • 预检 400 → 410 项,冒烟 46 → 53 项(真浏览器里新增:分组能展开、展开后菜单仍在 视口内、关于弹窗真的在屏幕上、动作一览八张图真的解码完成 8/8、点格子会关弹窗并摆姿势)。

  • 这一轮踩的坑(都记在这里,免得下次重演):

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

1.27.0

1.27.0

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

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

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

    • 计时基准取 max(lastMealAt, activeSince):离开很久回来之后不会一进门就喊饿 (那一段已经结束了,该从零开始算);
    • 不连着说:一顿之后要再过一整个间隔;
    • 两种关法:hungerMs: 0,或菜单里新增的「饿了会说话」开关(hungry: false);
    • doctor().hunger 报告 { enabled, everyMs, lastMealAt } —— 不用猜"她为什么突然捧碗"。
  • 情绪数 7 → 8,素材 35 → 40 张;菜单 18 → 19 项(固定 13 → 14)。 预检 383 → 400 项。

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

1.26.0

1.26.0

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

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

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

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

1.25.1

1.25.0

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

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

    • 结果四态:current / available / failed / unsupported,doctor().update.lastCheck 可读;
    • 「版本与更新」一步到位:报版本 + 复制包名 + 去查,查完用一句话覆盖(一次只冒一个气泡);
    • 非 2xx 是返回结果、不是抛错(服务契约原文),所以状态码必须自己判;
    • 没有 web 服务 / 没有 host 座位 / 调用抛错 —— 三种情况都如实报告,不抛。
  • web 声明为可选 inject 是安全属性:硬依赖一个没加载的服务会让 fiber 永远 waiting, 她会直接消失。预检里两条断言盯着(必须可选、不能必需)。

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

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

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

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

1.24.0

1.24.0

  • 新增「版本与更新」(菜单项 + __wisp.version + doctor().update):

    • 说出当前版本与这一版的要点;
    • 更新感知:记住上次见到的版本,变了就说一句「我更新到 X 了」(第一次安装不吭声);
    • 复制包名 dsh-wisp 到剪贴板,方便在 DSH 里粘贴安装;没有剪贴板权限时静默降级成只说话;
    • doctor().update 如实上报 canSelfUpdate: false 与原因。
  • 先查了平台,再动手:这个插件不能自己检查/安装更新,而且是源码级的结论 —— 宿主半包跑在 vm 沙箱里,harness 只给 { defineTool, registerTool, handle }, 沙箱的 nodeApiTraps() 挡住 Node API,允许的 ctx 动词白名单里没有网络与文件系统; DSH 自带的安装器也没有可注入的服务(服务目录里只有 dynamicCordisRunner / profileContext / loader / modules 等)。所以只做了平台允许范围内真能做到的三件事,并在 README 里写清边界。

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

  • 又踩了两次同一个坑,都记下来:

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

1.23.0

1.23.0

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

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

  • 过程记录(三次失败,都是我自己写错):

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

1.22.0

1.22.0

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

    查出来的结果分两半,都写进了 audit/generation-audit.jsonl 与 README:

    • 代理登记表里确实有 14 条 edit_image(图生图),但全部是 09-27 的,不是本轮;
    • 本轮的 77 次调用全部是 generate_image / batch_generate_images,edit_image 为 0;
    • 入包 140 个 webp 全部是重出当天的,且四档与原母版逐字节可复现(说明中间没有任何加工)。

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

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

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

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

1.21.0

1.21.0

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

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

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

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

  • 包 7436 → 5193 KB(新素材体积更小;解析代价反而下降约 3 ms)。

  • 预检 317 / 冒烟 46 × 2 / PACK OK 全过。

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

1.20.0

1.20.0

  • 新增第五套皮肤「原版女仆」(canon) —— 对齐社区规范的那一套,7 个情绪齐全: 长直发披散 + 大圆环呆毛 + 发箍右侧蓝蝴蝶结 + 后掠的鲸鳍耳(深蓝外表面、下缘白到银灰宽带)

    • 长袖全套女仆装(立领白荷叶边、领口蝴蝶结蓝宝石、金色袖口与裙摆纹样、白色半身围裙、 白色长袜、深蓝圆头皮鞋)。全部为纯文生图,未使用任何参考图。
  • 「初版女仆」改名「素绘女仆」(classic):它的实际特征是与众不同的赛璐璐平涂 (其余几套都是厚涂),叫"初版/基础"在"原版"入场后就站不住了。

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

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

1.19.0

1.19.0

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

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

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

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

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

1.18.0

1.18.0

  • 修正两处与社区规范的偏差(用户指出的):

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

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

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

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

1.17.0

1.17.0

  • 提示词规格修正(两条,都已实测确认):

    1. 鳍耳缩小:上一版每片约占头宽三分之一,实际画出来是一对大翅膀,太抢戏。 现在写死"约头宽的四分之一,不要超过三分之一,绝不要画成翅膀那样大"。
    2. 加呆毛(社区标识之一):一小缕头发从发顶竖起、顶端向前弯成小圆环。
  • 写作技巧(这次最有价值的发现):同一个要求写在提示词开头会被模型忽略, 写在末尾的**【画面完整性,逐条核对】**清单里才会被执行 —— 呆毛第一版写在开头直接被省略, 编进清单后一次就有。双马尾、鲸尾、发尾完整一直生效,本来就都在那个清单里。 已写进 tools/CHARACTER-PROMPT.md。

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

1.16.0

1.16.0

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

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

    社区的鳍耳有四个我漏掉的要点(放大社区立绘逐条核对出来的):

    1. 向后上方斜掠张开(像后掠的小翅膀),不是朝上竖;
    2. 狭长、根部宽、向后收成尖梢,不是等宽直板;
    3. 上表面深蓝、下缘与内侧白色 —— 座头鲸胸鳍的两面配色,这是最显眼的识别点;
    4. 大小约占头宽三分之一,存在感明显。

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

  • 上一版我把它做成了等宽的直板耳坠(像塑料小棍)——那是错的方向上加了一次修正。 这一轮的教训写进了 tools/CHARACTER-PROMPT.md:先去看社区共识,再动手画。

  • 顺带记下另外两处与社区的差异(待定,未擅自改):呆毛(社区锁定标识,我还没有)、 服装(社区是长袖全套女仆装+白袜皮鞋,本插件是单层吊带短裙)。

  • 出处与许可已记录:社区规范是 CC-BY-NC-SA 4.0(规范文档)/ MIT(工具链),角色属社区共创, 非商业二创被明确欢迎并建议注明出处,商业使用需授权。

  • 另外三套皮肤(classic / night / pajama)尚未改(各 7 张,共 21 张)。

  • 预检 316 / 冒烟 46 全过;包 6030 → 6202 KB。

1.15.0

1.15.0

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

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

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

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

  • 本版只重做了默认皮肤 deepsea 的 7 个情绪(母版已换、键控 0.48%–0.62%)。 另外三套皮肤(classic / night / pajama)仍是鱼鳍,它们的母版当初是同一套措辞生成的 —— 要不要一并重做(每套 7 张,共 21 张)由你定。

  • 包 6195 → 6030 KB(新一批图的体积略小)。预检与冒烟不受影响。

1.14.0

1.14.0

  • 她记着你(零美术):记住三件事 —— 一起多少天、你常把她放在哪个角、常让她穿哪套。 够格的时候说出来(proud 或 happy 表情):

    • 第 2 / 7 / 14 / 30 / 100 天:「今天是第 7 天。」
    • 某个角用过 ≥8 次且占多数:「你总把我放在右下角。」
    • 某套皮肤换过 ≥6 次(且不是当前这套):「宵眠睡衣 —— 你好像挺喜欢这套。」

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

  • 实现上踩到并修掉的两个问题:

    1. 我一开始在 applySkin 里写了个不存在的变量名(previousSkin)—— 每次换皮肤都会抛 ReferenceError。这类错误只有跑起来才会暴露,写的时候看着很对。
    2. 记忆台词原本排在

…

SIMILAR PLUGINS