dsh-whale-emote
让小鲸鱼挂件表演情绪:agent 通过一个本地 HTTP 调用,就能让表情包从挂件旁边弹出来。
为什么需要单独一层
dsh-whale-widget 本身没有任何接受外部文字/指令的接口。它的全部路由里,只有 /dsh-whale/last-turn.json 会被前端每秒轮询(内容由宿主按真实用量生成,只读);size.json 只在页面初始化时读一次;图片和音效都是初始化或手势时加载。所以想让挂件「表达情绪」,只能另加一层。
这一层不修改第三方挂件的任何文件:它自己注册路由,并用挂件同款的 tapIndex 机制注入一小段脚本,操作挂件根节点 .dshwv-root 的坐标把表情弹在它旁边。
路由
| 方法 | 路径 | 作用 |
|---|
| GET | /dsh-emote/manifest.json | 可用情绪清单(key / 字幕 / 描述 / readable)+ 音效预设(sounds) |
| GET | /dsh-emote/state.json | 当前情绪,客户端每秒轮询(seq 递增表示新事件) |
| GET | /dsh-emote/sticker?key=<key> | 表情图片本体 |
| GET | /dsh-emote/sound?key=<key> | 音效本体(按扩展名给 audio/* 类型,支持 Range) |
| GET/POST | /dsh-emote/set?emotion=<key>&text=<气泡> | 触发情绪(POST 用 JSON body) |
| GET/POST | /dsh-emote/clear | 立刻收起 |
| GET | /dsh-emote/client.js | 注入用的客户端脚本 |
| GET | /dsh-emote/health.json | 探针:客户端是否在轮询、有没有真的显示出来 |
| POST | /dsh-emote/ack | 客户端显示成功后的回执(agent 靠它确认"真的弹了") |
| POST | /dsh-emote/activity-ack | 状态条几何回执:客户端上报 left/top/w/h/display/inView/whaleRect |
| GET | /dsh-emote/activity.json | 当前活动状态(写文件=奋笔疾书 等),含诊断信息 + client 回传 |
| GET/POST | /dsh-emote/activity | 手动覆盖活动状态(POST JSON {label,emoji,detail,ttlMs};?clear=1 立刻清空) |
| GET | /dsh-emote/state.json | 当前情绪 + activity 字段(客户端从这里读状态条,不用额外请求) |
emotion 支持模糊匹配:给 嚣张、最强 或者 key 的一部分都能命中;命中不了会返回 404 并附上全部可用 key。
诊断:用户说「没弹」时先查探针,别先猜代码
agent 看不到浏览器,所以「发了但没显示」和「根本没发」在它眼里长得一样。加了探针之后,一条查询就能定性:
curl.exe http://127.0.0.1:3080/dsh-emote/health.json
| 读出来 | 结论 |
|---|
poll.connected: false | 客户端不在(页面关了 / 浏览器重启了)—— 最常见 |
connected: true 但 ack.seq 落后 seq | 客户端在跑,是显示环节的问题,才值得查代码 |
ack.seq 追上了 seq | 确实弹了,属于用户没看到(时机 / 位置),不是没发 |
poll.count 每个 state.json 请求 +1,客户端每秒一次 → 4 秒涨约 4 才是正常。
poll.ua 能看出是哪个浏览器在轮询。
ack.lastAgoMs 是「上一次真正显示」距今多久。
踩过的坑:先怀疑过「SPA 把节点冲成游离节点」,还加了自愈补丁 —— 结果真凶只是页面关着 / 服务器没重启。教训:先看探针,再动代码。
用法
# 图形化一点
.\emote.ps1 -List # 列出全部情绪
.\emote.ps1 嚣张 # 弹图
.\emote.ps1 委屈 -Text "才、才不是呢" # 弹图 + 自定义气泡
.\emote.ps1 撒娇 -TtlMs 0 # 不自动收起
.\emote.ps1 -Decision 困惑 -Text "选哪个?" # 决策提醒:必弹 + 小鲸鱼唱歌音效
.\emote.ps1 -Clear # 收起
# 或者直接打接口
curl.exe "http://127.0.0.1:3080/dsh-emote/set?emotion=%E5%9A%A3%E5%BC%A0&text=hello"
参数:ttlMs(自动收起毫秒,0 = 不自动收)、text(挂在图下方的气泡文字)、sizePx(覆写尺寸)、sound(是否放音效,默认关)、soundKey(用哪段音效,默认 press)。
状态条:反映「当前在干嘛」(2026-09-21 新增)
用户要求:右下角挂件要有一条反映当前对话状态的表达 —— 例如「写入 → 奋笔疾书」。
现在这条状态条是全自动的:宿主监听会话事件(session/event),把正在跑的工具翻成动作,
常驻在鲸鱼头顶(与挂件同层,z-index:9999)。agent 不用每次手写。
- 常驻:闲置时显示 🐟 摸鱼中,干活时显示具体动作,不会自动消失。
- 可拖动:按住拖到任意位置,松手吸附到最近的视口边缘;位置记在
localStorage。
- 单击还原:没拖动就松手 = 回到「自动跟随鲸鱼头顶」。
- 跟着鲸鱼走:拖鲸鱼 / 挂件吸附 / 改挂件大小时,药丸一起动。
手动摆过之后仍然保持相对关系一起位移(挂件缩放则退回头顶重新锚定)。
| 触发 | 显示 | detail |
|---|
write / edit | ✍️ 奋笔疾书 | 文件名 |
read / read_image | 📖 翻书中 | 文件名 |
grep / glob | 🔍 翻箱倒柜 | pattern |
pwsh / bash / job_* | ⌨️ 敲命令 | 命令首词 |
web_search / web_fetch | 🌐 上网查 | query |
browser_* | 🖱️ 逛网页 | — |
subagent / workflow / ralph | 🐣 摇人帮忙 | — |
ask_user_question | 🙋 等你拍板 | — |
todo_write / goal 系 | 📋 列计划 | — |
hindsight_* | 🧠 翻记忆 | — |
present / skill | 📦 交东西 | — |
| 未知工具 | 🔧 干活中 | 工具名(兜底,绝不静默变空) |
| turn 开着但没在跑工具 | 💭 思考中 | — |
| turn/end | ✅ 收工(定格 3s 后淡出) | — |
几个关键设计(都有 harness 锁着):
- 粘滞期(默认 4s):工具常常只跑几十毫秒,不粘一会儿的话状态条永远只会闪「思考中」。
所以
tool/result 在粘滞期内不覆盖当前动作。
- 多会话判收工:subagent / 另一个标签页也会开 turn,只有最后一个
turn/end 才喊收工。
- 丢事件的兜底:turn 一直挂着但 90s 没有任何会话事件(崩溃/强杀)→ 自动按 idle 处理。
- 与表情是两条独立通道:情绪是 agent 主动表演的一次性事件(有 ttl、会收起),
活动是宿主推出来的持续状态。状态条复用同一次
state.json 轮询(不额外发请求),
服务端没给 activity 字段时客户端不炸(新旧混装安全)。
- Live2D 联动:
dsh-whale-live2d 读同一个 activity,把活动映射到模型表情
(writing→画笔、thinking→放空脸……共 14 张)。活动脸是底噪层:情绪脸盖在上面,
情绪到点回到活动脸,活动没了才回默认脸;闲置随机小动作不抢活动脸。
配置项(profile patch 的 config 段):
| 键 | 默认 | 作用 |
|---|
autoActivity | true | 是否启用自动状态条 |
activityHoldMs | 4000 | 动作粘滞期(防闪) |
activityDoneMs | 3000 | 「收工」定格时长 |
activityIdleMs | 90000 | 丢 turn/end 的兜底超时 |
探针与手动通道:
curl.exe http://127.0.0.1:3080/dsh-emote/activity.json # 现在挂的是什么 + 诊断
.\emote.ps1 -ActivityStatus # 同上(CLI 版)
.\emote.ps1 -Activity "正在读图纸" -ActivityEmoji "📐" # 强制覆盖(演示/排查用)
.\emote.ps1 -ActivityClear # 立刻收起
视觉旋钮在 lib/client.js(改完只要 F5):ACTIVITY_FADE_MS(淡出延迟)、ACTIVITY_GAP(与鲸鱼间距)、ACT_SNAP_MARGIN(吸附留边)。
拖动与吸附
胶囊可以直接拖:按住拖到想放的地方,松手自动吸附到最近的视口边缘(左右二选一,
竖直自由但不出屏)。位置记在 localStorage,F5 后还在。
单击(没拖动)= 还原成自动跟随鲸鱼,不用去翻设置。
⚠️ 实现上的一个坑:挂件自己在 document 捕获阶段 监听 pointerdown 并用
isWhaleHit()(PNG alpha 采样)判断「有没有戳到鲸鱼」。捕获阶段先于 target,
所以我们在胶囊上的 stopPropagation 拦不住它 —— 拖胶囊会把鲸鱼一起拖走。
解法是拖动开始时给 document 补发一个 pointercancel:挂件监听了
pointercancel 并会因此放弃它那次拖拽。改这块代码前请先看 onActDown() 的注释。
决策提醒音效(-Decision)
用户要求:每次我弹表情提醒他做决策(问问题、等确认、等选方案),就放「小鲸鱼唱歌」。
所以有 -Decision 这个组合开关,它等于 -Force(绕过概率必弹)+ -Sound sing:
.\emote.ps1 -Decision 困惑 -Text "这两个方案选哪个?"
音效走每次事件单独指定这条路:set 时带上 soundKey,服务端把它翻成 soundUrl
塞进 state.json,客户端 show() 时优先用 s.soundUrl,没有才回落到挂件自带的 duck。
所以普通情绪照旧是「咔哒」一声,只有决策提醒才唱歌,互不干扰。
自动联动(2026-09-17 起)
决策提醒的唱歌音效已改成自动触发:agent 每次调 ask_user_question(要向用户拍板),
插件在 tools/pre-execute 钩子里自动弹一个 decisionEmotion 情绪 + sing 音效,
不用再靠 agent 每次手动记着补 .\emote.ps1 -Decision。问一次拍板 → 唱一次歌。
配置项(profile patch 的 config 段,默认值如下):
| 键 | 默认 | 作用 |
|---|
autoDecision | true | 是否启用自动联动 |
decisionEmotion | 困惑 | 拍板时自动弹哪个情绪 |
decisionText | '' | 气泡文案;留空则自动取问题标题(header),再退到问题正文(截 40 字) |
decisionTtlMs | 25000 | 自动弹出的停留时间(毫秒) |
/dsh-emote/health.json 的 autoDecision 字段实时报告 enabled / emotion / count / lastAt,
可以用来验证联动有没有真的触发。改这些配置 / 或改 lib/index.js 里的联动逻辑 → 要重启 dsh web。
-Decision 组合开关仍然可用(手动强制 + 唱歌),只是从「唯一通道」降级成「手动通道」。
预设表在 lib/index.js 的 DEFAULT_SOUNDS(也可用 profile 配置的 sounds 覆盖):
| key | 是什么 | 怎么来的 |
|---|
press(默认) | 挂件自带 duck press | 直接指向 /dsh-whale/sound/press.mp3?set=duck |
release | 挂件自带 duck release | 同上,换 release |
sing | 小鲸鱼唱歌 | file: 小鲸鱼唱歌.wav,走插件自己的 /dsh-emote/sound 路由 |
带 file 的预设从 mediaDir(默认就是表情目录 D:\deepseek\表情)取文件。为什么不用挂件那条
/dsh-whale/sound/…:它只认四个写死的候选文件,而且不管什么文件都回 audio/mpeg;
自定义 wav 从那里出去类型就是错的(能响只是浏览器嗅探的运气)。所以自己开一条路由,
按扩展名给 audio/wav,并老实实现 206 / Range —— 浏览器拿媒体时会带 Range: bytes=0-。
加一段新音效:把文件丢进 D:\deepseek\表情,在 DEFAULT_SOUNDS 里加一条
{ key: 'xxx', label: '…', file: 'xxx.wav' },改的是 lib/index.js → 要重启 dsh web。
(只想换掉 sing 对应的文件则不用动代码:清单按 mtime 重读,路由每次从磁盘读文件。)
情绪清单(以 .\emote.ps1 -List 为准)
适合得意/炫耀:嚣张(最强) 挑衅(闭源模型在哪) 暴富 傲娇 嘴硬
适合卖萌/亲近:撒娇 卖萌 开心 糊弄 自嘲 无辜
适合认错/崩坏:委屈(我不是大肥鱼) 耍赖 崩溃 慌乱 大哭 阵亡 害怕
适合吐槽/阴阳:腹黑(四个橙子五个小孩) 嘲讽(真坐得住啊你) 不耐烦 装懂 生气
其他:发疯 讲课 教学 困惑 整活 涨价 庆祝 小心翼翼
完整字幕见 emote-manifest.json 或 .\emote.ps1 -List。
改表情 / 加表情
- 图片目录:
D:\deepseek\表情(插件里是 DEFAULT_IMAGES_DIR)
- 清单:
emote-manifest.json,改动后无需重启(插件按 mtime 自动重读)
加新图:把 png 丢进图片目录,然后在 emote-manifest.json 里加一条:
{ "file": "新文件名.png", "key": "得意", "caption": "字幕原文", "desc": "画面描述", "readable": true }
readable: false 表示字幕小、缩小后看不清 —— 这类会自动放大 1.4 倍显示。
安装
# 从 GitHub(推荐)
dsh plugin --profile web add github:harmless0819-dev/dsh-whale-emote
# 或从本地克隆
dsh plugin --profile web add link:<本仓库绝对路径>
装完重启 dsh web,然后浏览器 F5。
卸载
dsh plugin --profile web remove dsh-whale-emote
然后重启 dsh web。第三方挂件不受影响。
可调旋钮(都在 lib/client.js,改完只要 F5,不用重启)
| 常量 | 当前值 | 作用 |
|---|
SIZE_SCALE | 0.5 | 最终像素 = 服务端 sizePx × 这个值(默认 340 × 0.5 = 170px) |
EDGE | 'random' | 'random' 左右随机 / 'left' / 'right' / 'whale' 跟随鲸鱼所在侧 |
V_MIN V_MAX | 0.35 0.65 | 竖直位置的随机区间(占视口高度比例),即「中间 30%」 |
SOUND_ALWAYS | true | 每次弹出都放音效 |
SOUND_URL | press.mp3?set=duck | 兜底音效:服务端没给 soundUrl 时才用它 |
SOUND_VOLUME | 0.6 | 音量;唱歌那段 20 秒,嫌吵就调小 |
POLL_MS | 1000 | 轮询间隔,决定「触发 → 出现」的延迟上限 |
两个容易踩的实现细节
- 随机位置必须在
show() 里掷骰子,不能写在 place() 里。place() 一次弹出会被调用 3 次(立即 / 图片 onload / requestAnimationFrame),写在那里表情弹出时会左右横跳。现在用 curSide / curAnchor 缓存一次结果,连窗口 resize 都不会跳。
- 浏览器自动播放策略:页面还没有过用户手势时
audio.play() 会被拒(NotAllowedError,且是静默失败)。现在被拒时记 soundPending,并在首次 pointerdown / keydown 时补放一声(仅当表情还挂着,避免空响)。
架构要点(维护必读)
为什么客户端脚本是单独一个文件
lib/client.js 是独立文件,/dsh-emote/client.js 路由每次请求都从磁盘读。原因:改插件模块(lib/index.js)必须重启 dsh web 才生效 —— 实测 HMR 不会重载它(改完代码后服务器仍在吐旧字节)。把客户端逻辑拆出去之后:
| 你要改的东西 | 改哪个文件 | 怎么生效 |
|---|
| 视觉:位置、动效、尺寸、CSS | lib/client.js | 浏览器 F5 即可,不用重启 |
| 宿主:路由、状态机、清单逻辑 | lib/index.js | 必须重启 dsh web |
重启 dsh web(restart-web.ps1)
改完 lib/index.js 之后,用配套脚本重启 —— 请在用户自己的终端里跑:
& 'D:\deepseek\dsh-whale-emote\restart-web.ps1' # 停掉 + 拉起 + 自检
& 'D:\deepseek\dsh-whale-emote\restart-web.ps1' -DryRun # 只看会杀谁、会怎么拉
它会:找到监听 3080 的进程 → 停掉 → 等在 -WorkDir(默认 D:\deepseek)用 dsh web 拉起
→ 轮询首页 → 查 /dsh-emote/health.json 和 /dsh-emote/sound?key=sing(206 表示 Range 那条路通)
→ 提醒你回浏览器 F5。日志写在 D:\deepseek\dsh-web.log。
为什么必须由用户在终端跑,不能让 agent 代跑:agent 的 shell 跑在文件沙箱里,
写不了 %DSH_HOME%\...,dsh web 连 profile 都启不开 ——
实测直接死在 EPERM: open '%DSH_HOME%\profiles\web\cordis.yml'。
就算硬拉起来,也可能是个残的服务。(顺带一提:重启会掐断正在进行的那一轮会话,
所以别在 agent 干到一半时重启。)
鲸鱼的真实几何(定位的坑)
挂件根节点是个正方形大方块,而鲸鱼本体只占它右下角的 59.45%:
.dshwv-root { position:fixed; width:var(--dshw-base); height:var(--dshw-base); }
.dshwv-img { position:absolute; right:0; bottom:0; width:59.45%; height:59.45%; }
所以定位必须量 .dshwv-img。量 .dshwv-root 会得到一个含大片空白的矩形,表情会被推到离鲸鱼很远的地方(V1 的「位置很怪」就是这个 bug)。挂件左吸附时会加 transform:scaleX(-1),但 getBoundingClientRect() 已经把变换算进去了,直接量 img 就是对的。
⚠️ Windows PowerShell 5.1 的两个坑
这套环境跑的是 Windows PowerShell 5.1(Desktop),不是 PowerShell 7。写脚本会连踩两次:
-
.ps1 里有中文 → 必须存成 UTF-8 with BOM。5.1 读无 BOM 的 .ps1 会按 ANSI(GBK) 解码,中文变乱码;乱码字节里可能含引号,直接把脚本解析搞崩(报 Unexpected token / Missing closing ')')。
用 write 类工具改完 emote.ps1 后 BOM 会丢,要补回来:
$p='D:\deepseek\dsh-whale-emote\emote.ps1'
$c=[System.IO.File]::ReadAllText($p,[System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($p,$c,[System.Text.UTF8Encoding]::new($true))
-
Invoke-RestMethod -Body <string> 按 Latin-1 编码,而且 5.1 的 ConvertTo-Json 不会把中文转义成 \uXXXX —— 两者相叠,中文 JSON 到服务端就是乱码(表现为 unknown emotion 的 404)。必须自己转字节:
$bytes = [System.Text.Encoding]::UTF8.GetBytes(($payload | ConvertTo-Json -Compress))
Invoke-RestMethod -Method Post -Uri $uri -ContentType 'application/json; charset=utf-8' -Body $bytes
emote.ps1 里的 Send-Json 已经这么做了。
-
ConvertTo-Json 会把 .NET 数组序列化成 { "value": [...], "Count": n } —— 不是 JSON 数组!而且 @($json | ConvertFrom-Json) 的行为也不符合直觉,可能把整个数组包成单元素。
- 我踩的坑:用 PowerShell 合并清单(32 + 17)时,输出变成了
[{value:[32],Count:32},{value:[17],Count:17}],插件解析不出任何 key,emote.ps1 求饶 直接 404。
- 结论:碰 JSON 就用 node。
JSON.parse / JSON.stringify 没有这些歧义。修复脚本见 repair-manifest.mjs(它会递归钻进 {value} 里把条目捞出来,校验 49 条 + key 唯一后才写回)。
(命令行参数里的中文是没问题的,会烂的是「文件编码」「请求体编码」和「用 PowerShell 拼 JSON」这三处。)
已知限制
- 客户端每秒轮询一次,所以从触发到出现最多约 1 秒延迟。
- 表情包是整幅梗图(1254×1254,字烧在图里),不能替换挂件本体的抠图
DSniang1.png,所以是「弹在旁边」而不是「换脸」。
- 挂件拖动/吸附时表情不会跟着走(只在弹出瞬间按当时位置定位);窗口 resize 会重新定位。
- 首次安装、或改
lib/index.js 时需要重启 dsh web;改 lib/client.js 不需要。