Guga0620/dsh-pet-bellylaugh ↗★ 0
dsh-pet-bellylaugh
奶龙(bellylaugh)桌宠 DSH 插件:常驻 Web 界面右下角,跟随 Agent 工作状态切换动作,任务完成时捧腹大笑。 适合想要趣味界面的用户,桌宠动作会随待机、工作、成功或出错等状态切换。
インストール
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:Guga0620/dsh-pet-bellylaughドキュメント
README 全文を読む ↗dsh-pet-bellylaugh · 奶龙桌宠
一个 DeepSeek Harness(DSH)桌面宠物插件:把奶龙放在 Web 界面右下角, 动作跟着 Agent 的工作状态走,任务完成时捧腹大笑,并放一段「哈哈哈哈哈哈哈哈」。




- 🐣 零依赖:宿主半只用 Node 内置模块,浏览器半只碰 DOM / fetch / Audio
- 🎞️ 状态驱动:待机 / 工作中 / 工具跑完 / 任务完成 / 出错,五种反应
- 🖱️ 可拖动:拖着走、松手记住、右键归位
- 🎵 音频可热换:往
assets/丢一个laugh.*就换掉了,不用改代码、不用重启 - 🧩 素材可换:覆盖
assets/spritesheet.webp就是换宠物(8 列 × 9 行、单帧 192×208)
目录
dsh-pet-bellylaugh/
├─ package.json # dsh.bundle.patch + dsh.client 声明
├─ cordis.patch.yml # bundle 自带的 loader 插入项
├─ lib/host.js # 宿主半:监听 Agent 事件 + 提供 HTTP 接口
├─ lib/client.js # 浏览器半:渲染桌宠、播动画、放声音
├─ assets/spritesheet.webp # 默认宠物(8×9)
├─ assets/pet.json # 默认宠物的名字
├─ assets/laugh.mp3
├─ assets/pets// # 可选:放别的宠物,优先于默认那只
├─ preview/*.png
├─ tools/preview.mjs # 假宿主预览,不装 DSH 也能看
├─ LICENSE # MIT(仅覆盖代码)
└─ NOTICE # 素材版权说明
两半的分工刻意做得很薄:宿主只负责把"现在忙不忙 / 刚干完什么"变成一个 JSON, 浏览器端每 500ms 拉一次。没有 RPC、没有 SSE、没有 WebSocket,出问题一眼就能看出来。
安装
# 在你 profile 的 cordis.patch.yml 末尾追加
- insert:
- id: ui-pet-bellylaugh
name: 'dsh-pet-bellylaugh'
再把本目录放进该 profile 的 node_modules/(软链、junction 或直接 dsh plugin --profile add 都行)。
生效方式:
- 改
cordis.patch.yml后配置热重载,不用重启应用; - 浏览器端插件是在渲染
index.html时注入的,所以要刷新一次页面才看得到宠物; - 改了
lib/host.js需要重启 DSH(Node 的 ESM 缓存按包名留存)。
开发时预览(不装 DSH 也能看)
浏览器端的动作完全由 /state 驱动,所以可以拿一个假宿主单独渲染:
node tools/preview.mjs 18999
# 打开 http://127.0.0.1:18999/?mode=idle|busy|tool|done|error
动作映射
精灵图是固定的 8 列 × 9 行网格(单帧 192×208),每一行是一段现成的动画。插件只做"选行 + 播帧":
| 状态 | 触发条件 | 精灵图 | 表现 |
|---|---|---|---|
| 待机 | 没有 Agent 在跑 | 第 0 行 · 6 帧 · 5.5fps | 站着呼吸、眨眼 |
| 工作中 | 有 Agent 处于 running | 第 7 行 · 6 帧 · 7fps | 手在肚子上揉来揉去 |
| 工具跑完 | 每次 Tool 调用结束 | 第 1 / 2 行 · 8 帧 · 13fps | 左右小跑两圈(方向随机) |
| 任务完成 | 最后一个 Agent 回到 idle | 第 5 行 ×3 → 第 8 行 | 捧腹大笑再招手,气泡「哈哈哈哈哈哈哈哈」,同时放笑声 |
| 出错 | 本回合有 agent/error | 第 6 行 + 抖动 | 气泡「哎呀,好像出错了」 |
| 被戳一下 | 鼠标点宠物 | 第 8 行 · 6 帧 · 9fps | 招手 |
没被用到的第 3、4 行(笑得更凶的两段)保留在表里,想换台词改 ANIMS 即可。
素材来源
- 形象:原样拷贝自本地 Codex 的
~/.codex/pets/bellylaugh/spritesheet.webp,一像素没改。 - 声音:由作者从用户本地提供的一段音频转换而来。原文件是 WMA(ASF) 容器套了个
.flac后缀, Chromium 放不了,所以用 Windows Media Foundation 转成了assets/laugh.mp3(192 kbps / 48 kHz / 9.98 s,240 KB)。每次播放 4.2 秒后淡出,不会一直响。
⚠️ 形象和音频属于第三方素材,不在 MIT 许可范围内,详见 NOTICE。
操作
| 动作 | 效果 |
|---|---|
| 单击(不拖动) | 越戳越嗨,见下面「互动」一节的递进表 |
| 长按 0.7 秒 | 抚摸它:眯起来摇三下 + 「❤️ 呼噜呼噜…」 |
| 按住拖动 | 把宠物挪到任意位置;拖的时候会朝拖动方向跑动,松手记住位置(存 localStorage,刷新后还在) |
| Shift + 单击 | 立刻大笑一次,用来试听当前的音频 |
| 鼠标右键 | 回到右下角默认位置(复位) |
| 悬停 → 右上角 🔊 | 静音 / 取消静音 |
| 悬停 → 右上角「龙」 | 隐藏桌宠(黄底小圆,字取宠物名的最后一个字) |

互动
点击递进
连着戳会一路升级,1.6 秒内没有下一次点击就重新计数:
| 第几次 | 动作 | 台词(每次随机) |
|---|---|---|
| 1 | 招手(第 8 行) | 嗨~ / 在呢在呢 / 有事吗? / 戳我干嘛~ |
| 2 | 拍肚子(第 7 行) | 嘿嘿~ / 舒服~ / 再来一下 / 咕噜咕噜 |
| 3 | 怕痒(第 3 行) | 痒~哈哈哈 |
| 4–6 | 捧腹大笑(第 5 行)+ 笑声 | 哈哈哈哈哈哈哈哈 |
| 7+ | 不高兴(第 6 行 + 抖动) | 别戳啦! / 晕了晕了 / 再戳要生气咯 / 住手住手 |
第 4~6 下会真的放笑声(就那段奶龙大笑),每次戳都从头重放、1.8 秒后淡出, 所以连戳是"哈哈哈…哈哈哈…"的节奏,不会叠成一团。静音状态下照旧不出声。
第 7 下之后计数清零,再戳又从头开始;停手 1.6 秒也会归零。
抚摸
按住不动 0.7 秒算抚摸(移动超过 4px 就变成拖动,不会误触)。抚摸会打断正在播的动作, 换成拍肚子三圈 + 爱心台词。
自己找乐子
空闲时每 42 秒掷一次骰子:45% 概率随便演一段(招手 / 待机 B / 拍肚子), 50% 概率冒一句话;说话内容会根据 Agent 忙不忙切换(「发呆中…」vs「我帮你盯着」)。 隐藏、正在拖动、或已有动画在播时不会插话。
三段台词表和节奏档位都在 lib/client.js 顶部,改文案直接改数组即可:
const CLICK_STREAK_MS = 1600; // 连击窗口
const LONG_PRESS_MS = 700; // 长按阈值
const IDLE_TALK_MS = 42000; // 闲聊间隔
const GREET_LINES = ["嗨~", …];
隐藏与恢复
隐藏和恢复是一对同款小黄圆,各自取宠物名的一个字:
| 位置 | 内容 | 作用 |
|---|---|---|
| 悬停时右上角 | 「龙」(名字最后一个字) | 点击隐藏 |
| 隐藏后原地 | 「奶」(名字第一个字) | 点击恢复 |
默认宠物叫「奶龙」,所以就是龙 ↔ 奶;换成豆包就自动变成「包」↔「豆」。

恢复入口是 26px 的黄色小圆,刚出现时会脉冲三下提醒位置,鼠标移上去会放大。
点它、或者按 Tab 聚焦后回车,宠物就回来了。隐藏状态记在 localStorage
(dsh-pet-bellylaugh:hidden),所以刷新页面也还是隐藏的 —— 但那个小圆始终在,
不存在「藏了找不回来」的情况。想彻底不留痕迹就别用隐藏,直接按下面「怎么卸载」关掉插件。
隐藏期间插件不轮询、不放声音(省得一有任务完成就凭空传来笑声); 重新显示时会静默对齐状态,不会把隐藏期间攒下的事件一次性补播。
怎么换音频
不用改代码,也不用重启 —— 丢文件就行:
- 把音频放进
dsh-pet-bellylaugh\assets\,文件名以laugh开头,比如laugh.wav。 - 目录里修改时间最新的那个
laugh.*会被选中,旧的不用删。 - 宿主每 1.5 秒重扫一次目录,
/state里带出文件名和版本号(mtime);版本一变, 浏览器端就换上新的 URL —— 连刷新页面都不必,下一次大笑就是新声音。 - 一个
laugh.*都没有时,会退而选assets\里最新的任意音频文件。
支持的格式:mp3 / m4a / aac / wav / ogg / opus / flac / webm(Chromium 能解的那些)。
⚠️ WMA / ASF 不行。Chromium 解不了这两个容器,得先转成上面任一格式再丢进来。 本项目自带的
laugh.mp3就是从这样一个"WMA 套 .flac 后缀"的文件转出来的。
怎么换宠物
跟音频一个待遇 —— 不用改代码。两种方式:
A. 换掉默认那只:直接覆盖 assets\spritesheet.webp(刷新页面生效)。
B. 单放一个宠物目录(推荐,可以和默认那只共存):
assets/
├─ pet.json # 默认宠物的名字,可留
├─ spritesheet.webp # 默认那只
├─ laugh.mp3
└─ pets/
└─ doubao/ # 目录名随意
├─ pet.json # 可选:{"displayName": "豆包"}
└─ spritesheet.webp
规则:
- 只要
assets\pets\下有宠物,就优先用它(多个时取修改时间最新的那个)。 这条比"按时间取最新"更明确 —— Windows 的普通复制会保留源文件 mtime, 光比时间会让"刚拷进来的宠物反而不生效"。 - 网格列数/行数从图片实际尺寸算:
宽 ÷ 192、高 ÷ 208。 所以 8×9(1536×1872)和 8×11(1536×2288)两种 Codex 素材包都能直接用, 不会把 11 行压成 9 行。 - 名字取同目录
pet.json的displayName,没有就退回目录名 —— 会用在提示气泡和 tooltip 上。 - 宿主每 1.5 秒重扫,
/state.pet里带id / name / columns / rows / v,版本一变浏览器就换图, 连刷新都不必。
你本机
~/.codex/pets/里那 8 只里,gugu-gaga和nai-long-2是 8×11 的 v2 素材包, 其余是 8×9;把整个文件夹(pet.json+spritesheet.webp)拷进assets\pets\\就能换过去。
行序沿用同一套 Codex 约定(第 0 行待机、第 1/2 行跑、第 5 行大笑、第 7 行拍肚子、第 8 行招手), 所以换任何一只 Codex 宠物,动作映射都成立。
怎么卸载
两种,从轻到重:
① 临时关掉(保留文件,随时能开回来)
# profile 的 cordis.patch.yml 里,给这条加上 disabled
- insert:
- id: ui-pet-bellylaugh
name: 'dsh-pet-bellylaugh'
disabled: true
配置热重载,改完刷新页面就没了;想开回来把 disabled 删掉即可。
DSH 的「设置 → 插件」页面里也有同样的开关。
② 彻底移除:删掉 cordis.patch.yml 里那条 insert 项,再删掉
/node_modules/dsh-pet-bellylaugh(如果是 junction,删链接即可,源目录还在),刷新页面。
只想让它安静但不想消失?悬停宠物,点右上角出现的 🔇 图标静音即可(记在 localStorage)。
拖拽用 pointerdown/move/up + setPointerCapture,位移小于 4px 视为点击,所以"点一下"和"拖一下"不会互相打架。
位置按"距右下角的距离"存储,窗口缩放时会自动夹回可视区域内。
可调参数
| 想改什么 | 改哪里 |
|---|---|
| 大小 | lib/client.js 的 --dsh-pet-fw / --dsh-pet-fh(保持 12:13,否则会裁帧) |
| 默认位置 / 层级 | .dsh-pet { right / bottom / z-index },默认位置另见 DEFAULT_POS |
| 音量 | VOLUME(默认 0.85) |
| 笑声时长 | LAUGH_MS(任务完成后,默认 4200)/ GIGGLE_MS(点出来的,默认 1800) |
| 轮询频率 | POLL_MS(默认 500) |
| 哪一行演什么 | ANIMS 表 |
| 眨眼快慢 | ANIMS.idle.frames:这是个显式帧序列(不是帧数),想更少眨眼就把两次 1 / 4(闭眼帧)之间多塞几帧睁眼姿势;ANIMS.idle.fps 控制整体速度 |
| 拖起来跑多快 | ANIMS.runR / ANIMS.runL 的 fps |
| 连击节奏 / 长按阈值 | CLICK_STREAK_MS(默认 1600)/ LONG_PRESS_MS(默认 700) |
| 自动闲聊间隔 | IDLE_TALK_MS(默认 42000),设成 0 之外的大数即可近似关掉 |
| 互动台词 | GREET_LINES / HAPPY_LINES / ANNOYED_LINES / PET_LINES / IDLE_LINES / BUSY_LINES |
| 挪不动了想复位 | 右键宠物 |
ANIMS里frames写数字时表示"播 0..n-1 全套",写数组时表示"按这个顺序播", 两者都支持。待机之所以用数组,是因为直接循环 6 帧会让它每 1.1 秒眨一次眼, 现在改成 30 帧的序列,眨眼间隔约 2.9–3.8 秒。
接口
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /pet-bellylaugh/state | 当前快照:busy、running、lastTool、toolSeq、doneSeq、errorSeq、clientMountedAt、laugh{file,v,size}、pet{id,name,columns,rows,v} … |
| GET | /pet-bellylaugh/sheet | 当前生效的精灵图(由目录扫描决定),带版本号 ETag |
| GET | /pet-bellylaugh/audio | 当前生效的笑声(由目录扫描决定),带版本号 ETag |
| GET | /pet-bellylaugh/assets/ | assets/ 里白名单扩展名的文件,带 mtime ETag |
| POST | /pet-bellylaugh/report | 浏览器端挂载上报,纯诊断 |
文件名只接受纯文件名(拒绝 /、\、..),扩展名必须在白名单里,所以请求路径拼不进文件系统。
媒体一律 Cache-Control: no-cache + 基于 mtime/size 的 ETag:文件换了就换校验值,没换就 304。
clientMountedAt 非空说明宠物真的挂到页面上了。
已知边界
- 最小化窗口时宠物会跟着消失,这是插件模型的硬边界,不是 bug。
DSH 桌面端是 Electron 主进程 fork 一个
ELECTRON_RUN_AS_NODE的 Node 子进程来跑 Host 的 (探测结果:process.versions.electron = 44.0.0,但process.type为空、require('electron')直接Cannot find module),窗口只归主进程所有;主进程 ↔ Host 子进程的 IPC 只定义了ready/shutdown/quit-inspection/update-tasks/platform-session/fatal六种消息,没有任何"开窗口 / 置顶 / 托盘"的通道。所以 Host 插件拿不到BrowserWindow/screen/Tray,也就做不出独立于应用窗口的桌面悬浮窗。 宠物活在渲染进程里,窗口不画它就不在。 → 想做到"最小化也在桌面上",只能改 DSH 桌面端本体的主进程(会给升级覆盖), 或者另做一个独立小窗程序,两者都不是插件的范畴。 - 宿主半改不了热:Node 的 ESM 缓存按包名留存,改了
lib/host.js要重启 DSH 应用; 只改lib/client.js刷新页面就够。 - 插件目录若以软链 / junction 方式放进 profile 的
node_modules,该 profile 跑pnpm install时可能被清掉,重装即可。 - 宠物是
position: fixed贴在document.body上的独立浮层(z-index: 8000), 不占用 DSH 的 slot 布局,所以模态框、右栏开合都不会把它挤走;反过来它也可能盖住右下角一点点内容, 拖走或右键归位即可。 - 声音受浏览器自动播放策略约束:页面还没被点过时第一次播放可能被拒,插件会静默吞掉, 正常用起来(敲过字、点过按钮)之后就不会再被拦。
贡献
欢迎 PR。改之前建议先跑 node tools/preview.mjs 18999,四种状态都能用 ?mode= 切出来对比。
提交前请确认:node --check lib/host.js && node --check lib/client.js,
并且真机上刷新页面后 GET /pet-bellylaugh/state 仍返回 200。
许可证
致谢
- 精灵图来自本地 Codex 的
pets/bellylaugh素材包,行序靠逐行裁图确认 (9 个宠物素材的网格布局一致,说明这是 Codex 的固定动画表)