Skip to content
dsh.fish
Bundle

dsh-whale-emote

让小鲸鱼挂件表演情绪:agent 通过 HTTP 触发的表情包弹出层(不动第三方挂件包)

Source
harmless0819-dev
License
MIT
Updated
Updated 10 days ago

Readme

# 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 看不到浏览器,所以「发了但没显示」和「根本没发」在它眼里长得一样。加了探针之后,**一条查询就能定性**:

```powershell
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 把节点冲成游离节点」,还加了自愈补丁 —— 结果真凶只是**页面关着 / 服务器没重启**。教训:先看探针,再动代码。

## 用法

```powershell
# 图形化一点
.\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 的兜底超时 |

探针与手动通道:

```powershell
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`:

```powershell
.\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` 里加一条:

```json
{ "file": "新文件名.png", "key": "得意", "caption": "字幕原文", "desc": "画面描述", "readable": true }
```

`readable: false` 表示字幕小、缩小后看不清 —— 这类会**自动放大 1.4 倍**显示。

## 安装

```sh
# 从 GitHub(推荐)
dsh plugin --profile web add github:harmless0819-dev/dsh-whale-emote

# 或从本地克隆
dsh plugin --profile web add link:<本仓库绝对路径>
```

装完**重启 `dsh web`**,然后浏览器 **F5**。

## 卸载

```sh
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` 之后,用配套脚本重启 —— **请在用户自己的终端里跑**:

```powershell
& '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%**:

```css
.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。写脚本会连踩两次:

1. **`.ps1` 里有中文 → 必须存成 UTF-8 *with BOM***。5.1 读无 BOM 的 .ps1 会按 ANSI(GBK) 解码,中文变乱码;乱码字节里可能含引号,直接把脚本**解析**搞崩(报 `Unexpected token` / `Missing closing ')'`)。
   用 write 类工具改完 `emote.ps1` 后 BOM 会丢,要补回来:
   ```powershell
   $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))
   ```
2. **`Invoke-RestMethod -Body <string>` 按 Latin-1 编码**,而且 5.1 的 `ConvertTo-Json` **不会**把中文转义成 `\uXXXX` —— 两者相叠,中文 JSON 到服务端就是乱码(表现为 `unknown emotion` 的 404)。必须自己转字节:
   ```powershell
   $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` 已经这么做了。

3. **`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` 不需要。

Install

dsh plugin --profile web add github:harmless0819-dev/dsh-whale-emote

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source