# dsh-pet · DeepSeek Harness (DSH) 桌面宠物伴侣与 Live2D 互动挂件插件

[English](README.md) | 中文

<p align="center">
  <img src="https://img.shields.io/npm/v/@linxin666/dsh-client-ui-pet?style=flat-square" alt="Version">
  &nbsp;
  <img src="https://img.shields.io/badge/DSH-%3E%3D0.2.1--rc.3-4c6ef5?style=flat-square&amp;labelColor=454a54" alt="DSH">
  &nbsp;
  <img src="https://img.shields.io/badge/license-Apache--2.0-blue?style=flat-square" alt="License">
</p>

<p align="center">
  <strong>DeepSeek Harness（DSH）官方 Web GUI 与桌面客户端专属桌面宠物伴侣生态</strong><br>
  <em>智能体思考跟随 · Live2D / 精灵图桌宠 · 摸头喂食互动 · 实时状态气泡 · 创意工坊宠物下载</em>
</p>

> 一个注册表驱动的桌面宠物伴侣：内置萌系鲸鱼娘，同时支持从创意工坊一键下载安装任意 Live2D 与精灵图桌宠。

模型思考时你在等待，你的桌面宠物在屏幕上畅游交互。它深度跟随 DeepSeek Harness 官方智能体会话生命周期，在空闲等待、模型思考、调用工具（Bash / 代码编写 / 网页检索）、整理回复、庆祝完成与报告失败时自动无缝切换动画；你还可以随时摸摸头、喂食小鱼干互动，看着专属亲密度与等级不断提升。桌面宠物基于资产注册表驱动而不是硬编码：每只宠物只需一份 `pet.json` manifest 加一张贴图集（或 Live2D 动作模型包），宿主启动时自动发现并支持热切换。

从桌面应用的经典宠物系统重构演进，完美适配官方 DSH 插件形态（cordis bundle：host 宿主半区 + client 浏览器半区一站式融合）。

## 功能

| 功能 | 说明 |
|---|---|
| 多宠物注册表 | 宿主扫描内置 `assets/`、hatch-pet 自定义宠物目录和组合配置条目；每只宠物 = manifest + 图集 |
| 设置中选择宠物 | 可视化预览卡列出所有已注册宠物（内置资产 + 用户目录，即已安装集合）；切换即持久化，精灵立即更换。页面位于「设置 → Web 扩展 → 宠物」，父级缺席时保留一级「宠物」分区 |
| 每只宠物独立命名 | 在悬浮面板改名；每只宠物保存自己的名字（按宠物 id 存储，旧版平铺名字自动迁移） |
| 状态动画 | 官方会话活动 → manifest 定义的 9 态轨道序列；每条轨道播完自身完整时长后切换，整条序列循环 |
| 摸头互动 | 点击宠物 → 气泡反馈 + 亲密度 +1（10s 冷却） |
| 喂食 | 悬浮面板 喂食 → 消耗 1 条小鱼干 + 亲密度 +5（30s 冷却） |
| 小鱼干经济 | 库存（上限 20）：每工作 30 轮 +1，每 300 分钟（5 小时）+1 —— 获取难度为原来的 10 倍 |
| 亲密度 | 每完成一轮 +1；9 级：幼鲸 → 伙伴 → 挚友 → 深海羁绊 → 心有灵犀 → 传说羁绊 → 神话羁绊 → 永恒之契 → 鲸生共渡（上限 999,999,999） |
| 拖动 | 按住拖动宠物换位置；位置持久化 |
| 隐藏/召唤 | 悬浮面板位于宠物下方（下方空间不足时上移到状态气泡之上）并提供 隐藏；隐藏后出现 召唤{name} 按钮 |
| 妙语库 | 内置默认妙语库（每类事件 10 句）+ 宠物自定义台词；成功文案按持久化成功次数轮换，冷却文案按持久化拒绝次数轮换 |
| 状态气泡 | 默认只有一个顶层会话说话——上报 GUI 当前会话时优先它，否则是最近活动的一个——其余会话收进主气泡右上角的 +N 角标，不再叠出一长列；悬停气泡（触屏点按角标）即可向上展开所有会话的气泡，点击任一气泡跳转到对应会话；子代理会话借由其发起会话体现，不占用独立气泡；瞬时互动反馈临时优先。气泡文案按场景准备了大量轮换词库（等待 / 思考 / 整理 / 完成 / 失败……），工具调用按工具族映射俏皮文案并带上真实参数（如 跑跑 npm test），同一场景持续数秒会自动换一种说法。宠物自带的这些状态气泡可在 设置 → Web 扩展 → 宠物 → 状态气泡 关掉，此时整行气泡留给自建气泡面的插件；通过 `pet.announce` 推送的公告气泡两种模式下都照常渲染 |
| 碎碎念 | 会话流式输出期间，宠物会偶尔借该会话自己的气泡说出内心独白——新鲜的碎碎念接管所属会话的气泡并以「」引号标记——与状态气泡共用同一片 DeepSeek 蓝黑玻璃，气泡栈内颜色统一不再色差——不再叠出第二只气泡——分类台词由「正在干什么」触发（思考 / 写回复 / 正在跑的工具族），结果台词只由结构化结果触发（测试工具通过 → 全绿、工具失败 → 报错、回合完成 → 收工）——决不读模型说的话，讨论里提到关键词也不会误触发心境；碎碎念也从不引用真实内容（无工具名、路径、模型原文）；分类冷却 9 秒、结果冷却 5 秒，展示 8 秒后气泡恢复状态文案 |
| 多会话活动 | 宠物是宿主全局的：最近一次有意义事件驱动精灵动画，同时每个活动的顶层会话用自己的气泡报告各自状态；每个会话（含子代理）完成的轮次都计入亲密度与小鱼干 |
| 语音包与面板 DIY | 宠物目录 voice.json + 全局 $DSH_HOME/pets/.voice.json 覆盖气泡全部文案与悬浮面板（按钮标签/统计格式/按钮显隐）；合并优先级 宠物自带 > 全局 > 内置，坏包警告不拒载 |
| 面板动作扩展点 | 兄弟插件通过进程内 pet 服务（`ctx.pet.registerPanelAction({ id, label, onSelect })`）把自己的动作注册进悬浮面板的动作行；宠物渲染按钮并把点击派发回插件自己的回调，动作随注册一起回收 |

## 宠物契约

一只宠物 = 一个目录 + 一份 `pet.json` manifest + 一张图集。除此之外什么都不需要——不用改任何宿主或客户端代码。

```jsonc
{
  "id": "whale-girl",                     // 唯一的小写 kebab id
  "displayName": "鲸鱼娘",                 // 显示在设置选择器与面板上
  "description": "一只软萌治愈的鲸鱼娘。",     // 可选
  "spritesheetPath": "spritesheet.webp",   // 图集，相对 manifest 所在目录
  "cell": { "width": 192, "height": 208 }, // 可选；默认 Codex 契约
  "columns": 8,                            // 可选；默认 8
  "spriteVersionNumber": 1,                // 可选；2 表示 11 行 v2 图集（9 行动画行 + 2 行视线跟随行）
  "frames": [6, 8, 8, 4, 5, 8, 6, 6, 6],   // 可选的每行帧数
  "tracks": {                              // 可选的每轨节奏覆盖
    "idle": { "durations": [400, 400, 500, 400, 400, 500] }
  },
  "sequences": {                           // 可选的每场景轨道序列（每条至少 5 项）
    "thinking": ["running", "running-right", "running", "running-left", "waiting"]
  },
  "remarks": {                             // 可选妙语（每个槽位一行或多行）
    "pet": "摸摸水獭的头～",
    "feed": ["小鱼干真香", "再来一条～"]
  }
}
```

- 图集是 8 列 × 9 行网格（默认 192×208 单元格）；行序固定：0 idle、1 running-right、2 running-left、3 waving、4 jumping、5 failed、6 waiting、7 running、8 review。未使用的格子保持全透明。v2（Codex）图集在 manifest 里声明 `"spriteVersionNumber": 2`，共 11 行——同样的 9 行动画行外加末尾 2 行视线跟随行；插件渲染这 9 行动画行、忽略视线行。
- 可选 remarks 块覆盖宠物在 pet（摸头）/ petCooldown / feed（喂食）/ feedCooldown / noTreats（缺粮）事件上的气泡台词。每个槽位接受一句或一组台词；声明过的槽位只替换该槽位的内置默认池。成功与冷却池使用对应的持久化成功或拒绝次数，noTreats 则独立轮询。社区贡献就是这样给自家宠物配上专属妙语的。
- `frames` 记录每行用到的列数（缺省按 hatch-pet 契约表 `[6, 8, 8, 4, 5, 8, 6, 6, 6]`）；`tracks` 按动画覆盖每帧时长（按该行帧数循环补足）、`loop` 与 `fallback`（默认：全部循环；`jumping` 与 `failed` 停在最后一帧后回到 `idle`）。
- `sequences` 可选地把活动场景（`idle` / `waiting` / `thinking` / `tool` / `review` / `done` / `failed`）映射到至少 5 条动画轨道。每项按 `tracks` 中的时长播完所有帧后进入下一项，整条序列循环；未声明的场景保持标准单轨播放。

### 清单 v2（宠物中心，#623）

宠物目录的 `pet.json` 在 v2 中显式声明渲染器：

- `petManifestVersion: 2`（缺省 = v1，按 `sprite2d` 兼容读并给出迁移提示）；
- `renderer`：`"sprite2d"`（上文图集契约）、`"live2d"` 或 `"frames2d"`；
- `license`（v2 必填）：资产授权标识——社区宠物必须携带来源声明；
- 渲染器专属块：`sprite2d`（spritesheetPath/cell/columns/atlasRows/frames/tracks）、`live2d`（model/motions/expressions/hitAreas/scale/translate）或 `frames2d`（dir/defaultFrameMs/tracks/phases——目录式帧序列）。

校验纪律：结构 fail-closed（未知字段或未知渲染器直接拒载并给出诊断），sequences/remarks 内容维持 warn-and-drop。机器可读 schema 见 `contracts/pet-manifest-v2.schema.json`，权威校验器为 `src/manifest-v2.ts`。迁移 v1 清单：`node scripts/dsh-pet-migrate-v2.mjs <dir> --write`（默认 dry-run；保留 `pet.json.v1.bak`）。

宠物的来源（后注册的来源在同 id 冲突时覆盖前者）：

1. **内置**：本包 `assets/<dir>/pet.json`。
2. **legacy 自定义宠物**：`${CODEX_HOME:-~/.codex}/pets/<pet>/pet.json` —— hatch-pet 流水线把产物放在这里，孵化的宠物无需任何接线即可出现在选择器里。
3. **宠物中心用户目录**：`$DSH_HOME/pets/<id>/`——推荐安放位（见下方 CLI）。
4. **组合注入**：嵌入应用通过 `PetConfig.pets` 传入的 manifest 条目。

用 CLI 校验并安装宠物目录（零构建、零发布）：

```sh
node scripts/dsh-pet validate <dir>           # 清单 + 资产 + Live2D 引用闭包 + voice.json
node scripts/dsh-pet install <dir>            # 校验通过后拷入 $DSH_HOME/pets/<id>/
node scripts/dsh-pet install <dir> --force    # 覆盖同名已安装宠物
```

非法条目永不覆盖可用宠物：它们被跳过（设置页不再列出它们；用 `node scripts/dsh-pet validate <dir>` 查看目录被拒的原因）。注册表在宿主启动时构建一次；新增或修改宠物后重启 `dsh web` 生效。

## 语音包与面板定制（voice.json，宠物中心 M4，#677）

气泡里的状态/工具/碎碎念文案与悬浮面板的按钮/统计/等级名，都可以被一只宠物（或你本人）整体替换——宠物不再只能换图，还能换「话」与「面板」。载体是宠物目录内的可选 voice.json（随宠物分发），以及全局覆盖文件 $DSH_HOME/pets/.voice.json（不改宠物目录即可换词）。

```jsonc
{
  "voicePackVersion": 1,              // 可选；缺省视为 1
  "status": {                          // 状态池：键 = 场景 id，逐键覆盖内置池
    "done": ["搞定收工～", "交差！下一位"]
  },
  "tools": {                           // 工具池：键 = 工具族；允许 {tool} / {hint}
    "shell": ["跑跑 {hint}", "敲回车！{hint}"]
  },
  "toolRemaining": ["后台还有 {n} 位小助手"],   // 允许 {n}
  "whispers": {                        // 碎碎念：按键替换内置池
    "categories": {                    // 分类池；显式空数组 = 静音该分类
      "thinking": ["让我想想……"],
      "running": ["正在跑，我盯着"]
    },
    "results": {                       // 结果池（测试全绿 / 报错 / 完成）
      "pass": ["全绿！"]
    }
  },
  "panel": {                           // 悬浮面板（每槽未声明时回落插件 i18n 文案）
    "labels": { "feed": "投喂", "hide": "藏起来", "rename": "起名字", "confirm": "好的" },
    "stats": { "rank": "好感 {rank}", "treats": "鱼干 ×{n}", "points": "{points} 分" },
    "ranks": [                           // 仅显示用的等级阶梯；首条 min 必须为 0
      { "min": 0, "name": "小煤球", "nameEn": "Little Coal Ball" },
      { "min": 25, "name": "火星伙伴", "nameEn": "Martian Buddy" }
    ],
    "actions": ["feed", "rename", "hide"]  // 子集（按规范顺序）；省略 = 全部；[] = 只显示统计行
  }
}
```

- 合并优先级（逐槽）：宠物自带 voice.json > 全局 .voice.json > 内置文案。status/tools/whispers 逐键合并、panel 逐槽合并，任何层缺失的槽位回落下一层。
- 占位符白名单：tools 允许 {tool} / {hint}；toolRemaining 允许 {n}；panel.stats 允许 {rank} / {n} / {points}；status、碎碎念与面板标签不允许任何占位符（含非法占位符的行被警告丢弃）。
- 等级阶梯（panel.ranks）：替换该宠物内置的鲸鱼等级名，仅影响显示——点数、每轮奖励、冷却与持久化账本都不变。必须是 1-16 条的数组，首条 min 为 0，min 严格递增且唯一，名字 1-24 字符。name 为默认名字；nameEn 在 UI 语言为英文时显示（省略则两种语言都用 name）。非法阶梯整体丢弃并警告，沿用内置名字。panel.stats 的 {rank} 收到宠物当前等级名。
- 上限（warn-and-drop）：每池 64 行以内、每行 160 字符以内；面板标签 40 以内、统计 80 字符以内。
- 坏包不影响宠物：voice.json 不是合法 JSON 或根不是对象 → 警告并整体忽略；其余问题逐槽警告丢弃。node scripts/dsh-pet validate <dir> 会把结构错误判为安装失败、内容问题列为警告。
- 语义细节：status/tools 的空池回落内置文案（场景行始终有话说）；whispers 的显式空数组是静音（关掉该通道）；面板 actions 为空数组 = 三个按钮全部隐藏；未覆盖的按钮/统计继续使用插件双语字典。旧的 whispers.generic / whispers.rules 字段已不再支持，会被忽略并给出警告。

## Live2D 宠物（renderer: live2d）

Live2D 宠物经 PixiJS/WebGL 渲染：MIT 许可的 pixi.js + untitled-pixi-live2d-engine 栈以按需加载的 vendor 分包随插件内置，纯 sprite 用户永不下载、永不解析它。**本插件永不内置、永不代下 Cubism Core 运行时**——Live2D 专有许可不允许再分发。启用 Live2D 宠物：

1. 自行从 Live2D 官方渠道获取 Cubism SDK for Web（你自行同意其许可），取得 `live2dcubismcore.min.js`；
2. 放到 `$DSH_HOME/pets/.runtime/live2dcubismcore.min.js`——插件把它连同自带的 vendor 分包一起提供给页面；
3. 安装 Live2D 宠物（含 `pet.json` v2、`renderer: "live2d"` 与模型文件的目录）。

core 缺失时，Live2D 宠物在渲染位置给出安装指引卡；sprite2d 宠物不受影响。法律提示：本插件属 Live2D 术语下的「可扩展性 APP」——你公开发布基于可加载用户模型的衍生作品时，可能不论规模都须与 Live2D 签订发行许可；发布前请自行评估义务。

Live2D 清单把七个活动相位映射到模型的动作组：

```json
{
  "petManifestVersion": 2,
  "id": "my-live2d-pet",
  "displayName": "My Live2D Pet",
  "license": "CC0-1.0",
  "renderer": "live2d",
  "live2d": {
    "model": "model/my-pet.model3.json",
    "motions": { "idle": "Idle", "thinking": "Think", "failed": "TapBody" },
    "hitAreas": ["Body"]
  }
}
```

- `model`：相对宠物目录的 `.model3.json` 路径。模型引用的一切文件（moc、贴图、动作、物理、姿态、表情）都必须放在目录内——宿主恰好只服务这个引用闭包。
- `motions`（必填，且必须含 `idle`）：相位 → 动作组。未映射的相位与模型缺失的组一律回退 `idle`；组内有多条动作时随机播放一条。官方 Cubism 示例模型只带 `Idle` 与 `TapBody` 两个组。
- `expressions`（可选）：相位 → 表情名，叠加在动作之上。
- `hitAreas`（可选）：点击落在列出的命中区时播放模型的 `TapBody` 组，播完回到当前相位的动作组。任何点击仍计入摸头——交互经济与 sprite2d 一样由 chrome 掌管。
- `scale` / `translate`（可选）：模型自动适配显示盒；`scale` 在适配结果上乘算（默认 1，范围 (0, 10]），`translate` 以中心为基准做像素偏移。

模型授权：Live2D 官方示例模型（Hiyori、Haru 等）仅供评估、禁止再分发——只发布你有权的模型（原创作品或宽松授权的模型）。

## Frames2d 宠物与玩法（renderer: frames2d）

frames2d 宠物不用图集，直接交付目录式帧序列：`thumb/<track>/<frame>.webp`，帧时长取文件名 `_<ms>` 尾缀或轨道的 `frameMs` 列表（默认 200ms，范围 16–5000）。清单把七个活动相位映射到轨道；`drag` 轨道跟随外壳的拖拽手势；非循环轨道播完进入 `fallback`（默认 idle 轨道），入睡/睡熟这类"引子 + 循环"拆分（sleep-intro → sleep）就是纯清单数据。

frames2d 块还可声明 **skins**（皮肤）：可选的 `{ id, label, idleTrack }` 数组。选中某皮肤后，宠物"回待机"的目标（idle 相位、未映射相位、以及一切回 idle 的 fallback）都切到该皮肤的 `idleTrack`——休息外观随皮肤切换，而玩法轨道（shy/work/sleep…）仍挂在默认资产上。每个皮肤还可声明 `clickActions`——仅对该皮肤生效的概率掷骰点击反应（`{ track, probability, phrases? }`）：皮肤激活时点击按声明顺序依次掷骰，命中则播一次命中轨道，未命中回落普通点击加成（绝不落入默认触摸分区）。

frames2d 宠物可声明 `gameplay` 块——从 miku 桌宠泛化而来的可选玩法层：属性条（`stats`，按分钟衰减，另有打工中与空闲变体）、统一的小鱼干货币（玩法收入与商店支出都走宠物面板那条小鱼干库存，上限 20 条：`work` 成功、`passiveIncome` 与彩票奖品发放小鱼干，商品也以小鱼干标价扣款，无独立钱包页）、加权 `idleDirector`（每 `intervalMs` 掷骰演出小动作，连续落空 `maxMiss` 次必演）、`hitBox` 内的 `touch` 触摸分区（分支掷骰：效果 + 轨道保持 + 台词气泡）、`work` 打工循环（宿主裁决 tick，成功/失败结果轨道）、`sleep` 睡觉循环（惰性恢复）、`modes` 命名模式（work/sleep 之外的模式各有自己的菜单按钮、`label` / `activeLabel` 与可选的周期属性恢复）、`roam` 随机漫游（慢周期掷骰后按四个方向之一走随机距离，位移由浏览器半侧负责，落点与拖拽同一条持久化路径）、`passiveIncome` 被动收入，以及 `shop` 商店（商品可带效果或分档抽奖）。所有掷骰与记账由宿主权威裁决（`POST /api/pet/gameplay/*`），状态按宠物持久化在 `pet.json`，沿用小鱼干经济的惰性结算纪律。浏览器半侧为声明了该块的宠物自动渲染玩法菜单卡（属性条、每个已声明模式一个按钮、皮肤选择、商店网格）。精灵显示尺寸范围为 32–1024 px。

**Miku 宠物**（stushansusu 涂山苏苏以 MIT 贡献；初音未来角色权利归 Crypton Future Media，受 Piapro 角色许可约束——见 THIRD_PARTY_NOTICES.md）是 frames2d 玩法的参考实现。它只经**创意工坊**分发（不打进 npm 包）：从工坊宠物列表安装后落在 `$DSH_HOME/pets/miku/`。

## 状态装饰（decoration.json，宠物中心 M5，#567）

宠物状态气泡里的文字之前可以有一个小的状态装饰（内置：喷水鲸鱼），由 ActivityPhase 流驱动换帧。装饰与宠物相互独立：独立描述符、独立 id、独立目录，换宠物不换装饰。入口资产只收 PNG/WebP 单行精灵条带（不收 SVG/CSS）；气泡自身的 role=status/aria-live（或会话气泡按钮语义）永远保留，装饰 aria-hidden；prefers-reduced-motion 时停在帧段首帧，资产加载失败只消失装饰、文字照常。

```jsonc
{
  "decorationManifestVersion": 1,
  "id": "whale",                     // 唯一小写 kebab id
  "displayName": "喷水鲸鱼",           // 可选
  "license": "MIT",                   // 必填：资产授权（社区装饰必须携带来源声明）
  "entry": "whale-frames.png",        // PNG/WebP 单行条带，相对本目录
  "cell": { "width": 64, "height": 48 },
  "columns": 4,                       // 条带帧数（1..16）
  "frameMs": 160,                     // 常量帧时长；或用 "durations": [..] 逐帧覆盖
  "loop": true,
  "phases": {                         // ActivityPhase 七态 -> 帧段（含端点）；"hide" = 不显示；缺省 = hide
    "idle": "hide",
    "waiting": { "from": 0, "to": 1 },
    "thinking": { "from": 0, "to": 3 },
    "done": { "from": 2, "to": 3 },
    "failed": { "from": 3, "to": 3 }
  }
}
```

- 结构 fail-closed（未知字段、越界尺寸、非 PNG/WebP 入口直接拒载并进诊断），帧段内容 warn-and-drop。机器可读 schema 见 contracts/status-decoration-v1.schema.json，权威校验器为 src/decoration.ts。
- 来源：内置 assets/decorations/ + 用户目录 $DSH_HOME/pets/decorations/<id>/（同 id 覆盖内置）。资产经 /api/pet/decoration/<id>/<file> 路由，containment 与白名单与宠物资产同构。
- 开关：设置 → Web 扩展 → 宠物 → 状态装饰（默认开）。内置鲸鱼素材派生自 DeepSeek wordmark（MIT），完整声明见 THIRD_PARTY_NOTICES.md。
## 公告气泡（pet.announce）

宿主侧的兄弟插件可通过 `pet` cordis 服务推送一条结构化公告（`ctx.pet.announce({ source, kind, title, ... })`）；客户端半区将其渲染为一只专用、特别设计的气泡，挂在会话气泡栈顶部——独立玻璃样式与色调描边（`ok`/`warn`/`low`）、余额与今日消费类金额胶囊、套餐百分比窗口带微型计量条、展示重置时间。载荷经有界校验（src/announce.ts）：未知字段丢弃、超长文本截断、TTL 收敛到 1 秒 - 2 小时（默认 10 秒；周期型发布方按自身轮询节奏声明 TTL，常驻气泡因此跨轮询连续，上限仍保证失活来源的气泡至多一个刷新周期内消失），畸形公告静默丢弃。公告仅存内存：每个发布方按 `source` 各占一个槽位，各自持有自己的 TTL，过期即不再渲染，不触碰 pet.json 与台账。刻意不提供 HTTP 面——按跨插件协作规则，API 就是进程内服务。

**一只宠物上的多个发布方。** 该契约最初只有一个槽位，第二个公告插件会把第一个顶掉；现在 `source`（载荷本来就带）就是槽位键，各发布方 TTL 独立。每条新鲜槽位各渲染一只气泡，按各发布方首次到达的顺序叠在会话气泡之上——周期型发布方就地刷新自己那只，两个节奏不同的发布方不会互换位置，气泡栈也不会抖动。槽位表有上限（`MAX_ANNOUNCEMENTS` = 4）：过期发布方的槽位自动释放；表真的满时，新来的发布方拿走沉默最久的那只而不是被拒绝，因此持续刷新的发布方永远不会被挤掉。为兼容灰度升级，最新鲜的那条同时仍以旧的单数字段 `announcement` 下发，旧浏览器半区照常渲染一只气泡而不会空白。

## 面板动作扩展点（pet.registerPanelAction）

宿主侧的兄弟插件可以往宠物悬浮面板的动作行里追加自己的按钮，与内置的喂食 / 改名 / 隐藏并存：

```ts
ctx.effect(() => ctx.pet.registerPanelAction({
  id: 'pet-quota-swap',            // kebab id，点击按它派发回插件
  label: '换装',                    // 有界标签（超过就会撑换行）
  title: '切换到下一只宠物',         // 可选 tooltip
  order: 0,                         // 可选：在已注册动作块内的排序键
  onSelect: async ({ petId }) => { /* 插件自己做事 */ },
}))
```

- 宠物拥有这一行，插件拥有这个按钮：宿主对每次注册做校验（src/panel-actions.ts），把已注册动作放进状态快照（`panelActions`）下发，点击经 `POST /api/pet/panel-action` 派发回 `onSelect`。扩展动作渲染在内置按钮之后，因此语音包隐藏内置动作行时它们依然可见。
- 生命周期：同一 id 再次注册会替换旧动作（被替换的 disposer 不再拥有这一行），`registerPanelAction` 返回的 disposer 负责移除它——按上面的写法用 `ctx.effect` 绑到注册插件自己的 fiber 上即可。动作行最多容纳 `MAX_PANEL_ACTIONS`（8）个注册。
- 失败只算插件自己的问题：畸形注册会在注册插件自己的 apply 里抛错（点击无法派发回它），回调抛错返回 `{ ok: false, error: 'panel-action-failed' }`，点击一个插件已经注销的动作属于过期点击（`{ ok: false, error: 'unknown-panel-action' }`），都不会表现为宠物故障。
- 与 `pet.announce` 一样，注册是宿主侧（进程内）契约；浏览器半区的插件走既有的 `/api/pet/*` 端点驱动宠物即可。

## 状态气泡开关（statusBubbles）

设置 → Web 扩展 → 宠物 → 状态气泡（默认 `auto`，改为 `off` 即让宠物闭嘴）。`off` 只隐藏宠物自带的会话／状态气泡——每个会话的气泡栈（正在思考 / 正在使用……）与旧式单状态气泡——互动反馈气泡与兄弟插件通过公告契约推送的气泡照常渲染，这正是自建气泡面的插件需要的。两种模式下状态快照都照常携带气泡数据：只有渲染被关掉，随时切回即刻恢复；早于该字段的宿主不带模式，读取时按 `auto` 处理。

## 内置宠物

| 注册表 id | 选择器名称 | 来源 |
|---|---|---|
| `blue-throated-bee-eater` | 蓝喉蜂虎 | 贡献者以 Apache-2.0 贡献的伙伴插画（12 张同角色 AI 立绘素材，配色取蓝喉蜂虎皮肤同源；由 docs/archive/blue-throated-bee-eater-pet/gen-pet.py 组装，各轨道独立姿态：栖枝/飞行/正面悬停/挥翅/降落/垂头/歪头/昂首；饲料以「小蜜蜂」命名） |
| `deep-chan` | deep酱 | 由 Daizhdd 贡献的 sprite2d 像素风宠物：8 列 × 9 行、格 256×288 的图集（2048×2592，共 57 帧）——其中 49 帧为逐帧绘制的独立姿势，行走 8 帧是完整步态循环、跳跃 5 帧是独立姿势，其余状态由表情与小幅动作连续变化——另有 8 帧左走由右走水平镜像而来。deep酱 是「鲸鱼娘」的非官方同人二创形象：角色及相关权利归 DeepSeek 所有，与官方无隶属或背书关系，素材仅限个人非商业使用（见 THIRD_PARTY_NOTICES.md）。只放仓库、不随 npm 包发布 |
| `doro` | doro | 由 stushansusu 以 MIT 许可证贡献的 frames2d 玩法宠物：11 条轨道 / 802 帧 42ms webp 序列——呼吸待机、打工三件套、睡觉、洗澡模式（心情 +3/秒）、随机漫游用的四向爬行、三个待机小动作（cola / orange / tongue）与仅拖拽期间播放的挣扎循环。Doro 是《胜利女神：妮姬》桃乐丝的非官方同人二创/梗衍生形象，角色及相关权利归 SHIFT UP 所有——仅限个人非商业使用，与官方无隶属或背书关系（见 THIRD_PARTY_NOTICES.md）。 |
| `jyn` | 女仆鲸鱼娘 | frames2d 玩法宠物（stushansusu 以 MIT 贡献）：带打工/睡觉/触摸玩法与三款可选皮肤（暗夜鎏金 / 蓝海霓裳 / 冰晶公主）的女仆鲸鱼同桌宠，每款皮肤各带一个概率掷骰点击动作；暗夜鎏金另将专属休息循环换入睡觉玩法 |
| `jyn-foxtail` | 鲸鱼娘·狗尾巴草 | 由 stushansusu 以 MIT 贡献的 frames2d 玩法宠物：剪纸风格的鲸鱼娘，13 条 3.00s 帧序列轨道（idle / tease / eat / heart / face / work / work-success / work-fail / sleep / sleeping / snack / sneeze / yawn），带点击、打工、睡觉与四个待机小动作玩法；previews/ 下每条轨道一张静态 webp 封面 |
| `jyn-loli` | 鲸鱼娘·萝莉 | 由 stushansusu 以 MIT 贡献的 frames2d 玩法宠物：鲸鱼娘的萝莉形态，10 条 2.88s 轨道共 720 帧 40ms webp（idle / scratch-head / whale-pillow / lollipop / dance-sway / work / work-success / work-fail / rest / resting），十轨共用一个 764×1172 裁切框，切状态时角色既不跳大小也不跳位置；带 stats、四个待机小动作、三个触摸区、五成成败判定的打工、睡觉与躺下回能、挂机收益与商店玩法，previews/ 下每条轨道一张 GIF 封面。与 jyn、jyn-foxtail、doro 一样只放仓库、不随 npm 包发布（见 assets/jyn-loli/README.md） |
| `ouo-neko` | OUO Neko | `Pessimist0906` 以 MIT 许可证贡献的粉色樱花猫耳伙伴 |
| `whale-girl` | 鲸鱼娘（原版） | 仓库原有的鲸鱼娘图集 |
| `whale-girl-refined` | 鲸鱼娘（精致版） | 以鲸鱼娘设计方向为基础，经 AI 辅助二次创作、修复和细节精修的衍生版本 |

Miku 宠物有意不随包内置：它是 frames2d 玩法宠物，经创意工坊按需安装（见上文 frames2d 一节）。**星夜人偶（Starry Doll）**（Theater-ahyeon 以 CC-BY-NC-SA-4.0 贡献）同样仅经创意工坊分发：sprite2d 哥特星夜人偶，从单张插画抠像后以剪纸变换生成动画（呼吸、蹦跳、挥手、沮丧、review 态放大镜道具），从创意工坊宠物列表安装后落在 `$DSH_HOME/pets/starry-doll/`。**Doro 宠物**（内置）同样是《胜利女神：妮姬》桃乐丝的非官方同人衍生形象；角色及相关权利归 SHIFT UP 所有，素材仅限个人非商业使用——见 THIRD_PARTY_NOTICES.md。

精致版参考了 DreamSkin 的「DeepSeek-鲸鱼娘」主题。历史来源记录标注原主题作者为 `powerdog996`，并标注主题为 MIT：[DreamSkin](https://dreamskin.cc)、[仓库来源记录](https://github.com/zhu1090093659/dsh-web/commit/87edd7ff4800dffd40bc93fb76e4ae450390facd)。此处用于记录素材来源与衍生关系；精致版不表述为原作者的官方作品，也不重新定义原始美术作品的授权范围。

## 人气宠物

[dsh-market.com](https://dsh-market.com) 创意工坊宠物分类里人气最高的三只宠物，按网站默认的「按人气」排序。每只都是「一个 `pet.json` 加素材」，由上文契约渲染——无需任何按宠物编写的代码：

| 宠物 | 渲染器 | 作者 | 来源 |
|---|---|---|---|
| [鲸鱼娘（精致版）](https://dsh-market.com/#pet:whale-girl-refined)（`whale-girl-refined`） | sprite2d | dsh-web | 内置图集 `assets/whale-refined/` |
| [Miku](https://dsh-market.com/#pet:miku)（`miku`） | frames2d | stushansusu（MIT） | 仅经创意工坊分发，安装到 `$DSH_HOME/pets/miku/`；初音未来角色权利归 Crypton Future Media，适用 Piapro Character License |
| [女仆鲸鱼娘](https://dsh-market.com/#pet:jyn)（`jyn`） | frames2d | stushansusu（MIT） | 内置 `assets/jyn/` |

| 鲸鱼娘（精致版） | Miku | 女仆鲸鱼娘 |
|---|---|---|
| ![鲸鱼娘（精致版）](assets/whale-refined/previews/idle.gif) | ![Miku](assets/miku/previews/idle.webp) | ![女仆鲸鱼娘](assets/jyn/previews/idle.webp) |

精灵宠物使用由 [hatch-pet](https://github.com/dsh2026) 流水线生成的 8 列图集，单元格为 192×208。经典图集包含 9 行动画；v2 图集额外增加 2 行，共提供 16 个观察方向。女仆鲸鱼娘改用目录式帧序列，因此它的其它玩法状态是各自独立的预览文件：

| 害羞 | 工作 | 工作成功 | 睡眠 |
|---|---|---|---|
| ![害羞](assets/jyn/previews/shy.webp) | ![工作](assets/jyn/previews/work.webp) | ![工作成功](assets/jyn/previews/work-success.webp) | ![睡眠](assets/jyn/previews/sleep.webp) |

## 架构

```text
dsh-pet/
|-- src/
|   |-- index.ts             # host 半区：插件入口（构建注册表、设置区、路由）
|   |-- registry.ts          # 多宠物契约：manifest 扫描 + 归一化（内置 + 自定义宠物）
|   |-- service.ts           # PetService：宠物选择 + 状态机 + 亲密度 + 配置
|   |-- state.ts             # 宠物状态机：会话活动投影 → 9 态动画
|   |-- remarks.ts           # 妙语库：内置默认池 + 每宠物覆盖 + 计数选取
|   |-- affinity.ts          # 亲密度账本（纯函数 + 冷却）
|   |-- treats.ts            # 小鱼干库存账本
|   |-- persist.ts           # 持久化（$DSH_HOME/pet.json：选择 + 名字 + 互动计数）
|   |-- routes.ts            # /api/pet/* JSON API + /pet/<id>/* 静态资源路由
|   `-- client/             # 浏览器半区
|       |-- index.ts         # 全局挂载（createRoot → body）+ 注册表拉取 + 轮询 + 接线
|       |-- PetDockEntry.tsx # 全局浮层入口（document.body，始终显示）
|       |-- PetSprite.tsx    # 由定义驱动的浮层精灵（portal + rAF + 拖动）
|       |-- PetSettingsCard.tsx # 设置卡片：宠物选择器 + 显示布局
|       |-- sequences.ts     # 完整轨道场景序列计时
|       |-- spritesheet.ts   # 图集几何辅助 + 轨道裁剪
|       `-- pet.module.css
|-- assets/whale/            # 内置原版鲸鱼娘（manifest + 图集 + 预览）
|-- assets/whale-refined/    # 内置精致版鲸鱼娘注册表变体
|-- assets/ouo-neko/         # 内置 OUO Neko v2 宠物（11 行图集 + 预览）
|-- assets/blue-throated-bee-eater/  # 内置蓝喉蜂虎宠物（9 行图集 + 预览）
`-- cordis.patch.yml         # bundle 补丁：插入宠物插件行
```

### 数据流

```text
官方会话事件（turn/step/chunk/tool）----\
                                                    > PetService（宿主）<-- 注册表（内置 + 自定义宠物）
可选兼容 activity/status ------------------/
                                                              | /api/pet/* JSON
全局 React 根（createRoot → document.body）<-- 2s 轮询 -- pet-client（浏览器）
                                                              |
                                       PetSprite 浮层（portal + rAF）
```

- **状态来源**：宿主把官方 `turn/start`、`step/start`、`assistant/message`、`tool/call`、`tool/result`、`turn/end` 事件与实时 `agent/assistant-stream` 增量投影为 waiting/thinking/tool/review/done/failed 状态。可选兼容 `activity/status` 事件仍作为输入。
- **注册表**：宿主把每份 manifest 归一化为完整渲染定义（几何、每行帧数、每轨时长），经 `/api/pet/pets` 下发；浏览器半区用该定义渲染任意条目，不携带任何宠物专属代码。
- **选择与命名**：`petId` 存于插件自身的配置——宿主据此 profile 条目生成 Pet 设置页；每只宠物的名字存于 `pet.json` 的 `names`，通过悬浮面板对当前宠物改名编辑。旧版安装的平铺 `name` 自动迁移到鲸鱼娘名下。
- **显示**：`visible`、`size`、`right`、`bottom` 与 `bubbleScale` 存在 `pet.json`，在设置卡片里编辑。气泡字号跟随精灵自身的尺寸（默认 160px 宠物对应 12px），`bubbleScale` 在此结果上再乘一个倍率，并限制在 10–24px——缩小的宠物不会顶着读不清的字（issue #1549）。
- **多会话语义**：API 与浏览器挂载都是宿主全局的，不暴露前台会话身份。并行会话各自保留投影状态：最近一次有意义事件驱动精灵动画，同时每个活动的顶层会话在独立气泡里报告自己的阶段（state 视图的 sessions 列表，最多保留最近 12 个）。子代理会话仍参与动画、计奖与单一显示气泡，但不占独立气泡位——N 个对话不会变成"N + 子代理数"的气泡堆。每个会话完成的轮次仍独立计奖；销毁会话移除它的气泡，销毁当前显示会话则回退到最近仍在活动的会话。
- **挂载点**：`document.body`（全局 React 根，始终显示：无会话 / 新会话 / 会话中都可见——旧挂载点 `conversation.composer.dock` 只在活动会话里渲染，新会话里宠物消失）；组件内部用 `createPortal` 渲染全局浮层。根容器随插件 fiber 生命周期走：fiber 销毁时卸载 React 根、移除容器并停止轮询与设置订阅；热重载或重复注入的新 bundle 接管页面级单挂载槽，页面始终只有一个 `[data-dsh-pet-root]`（issue #785）。
- **渲染**：CSS 精灵（background-position）逐帧动画；帧时长和可选场景序列来自下发定义。悬浮面板锚定在宠物下方，间隙由指针桥接覆盖；当视口下方空间不足时，面板翻转到宠物上方并抬升到状态气泡栈之上，两者互不遮挡。
- **通信**：浏览器 ↔ 宿主走同源 `/api/pet/*` JSON 端点（state/pets/interact/set-visible/set-config/set-name/set-pet）；每只宠物的图集从 `/pet/<id>/<spritesheetPath>` 加载——插件自给自足地提供自己的 API 与资源（与 dsh-remote-web-ui 的 `/api/pair` 同一模式）。

## 安装

安装聚合全家桶 `@linxin666/dsh-web-all`（全部插件与皮肤一次到位），或单独安装本插件：

```sh
### 从 npm 安装（推荐）
dsh plugin --profile web add @linxin666/dsh-pet@latest

### 从仓库安装（开发调试）
git clone https://github.com/zhu1090093659/dsh-web.git
cd dsh-web
pnpm install && pnpm -r build
dsh plugin --profile web add link:$(pwd)/packages/dsh-pet

```

安装后**重启 `dsh web`**——你选择的宠物出现在界面右下角。link 模式下改代码后 `pnpm build` 并刷新页面即可，无需重装。

## 开发

```sh
pnpm build        # tsc -b（类型+声明）&& tsdown（node 半区 + 浏览器 bundle）
pnpm test         # vitest 单元/组件测试（注册表 / 事件投影 / 状态 / UI / 账本）
pnpm prepare      # 仅转译构建（不做类型检查，供消费者安装）
pnpm typecheck    # 仅类型检查
```

浏览器 bundle 走 `window.__ModuleLoader__.load` 契约；React/cordis 等从 loader 模块表解析（external）；CSS Modules 由 lightningcss 以内联 `<style data-plugin>` 编译进 bundle。

## 精灵图与动画轨道校准

两套内置鲸鱼娘图集使用同一份 9 态 × 8 列契约：`assets/whale/` 是原版，`assets/whale-refined/` 是精致版。每张图集均为 1536×1872（8 列 × 9 行，192×208 单元格）。每行帧数、节奏与场景轮换写在各目录的 `pet.json` 中；未覆盖的宠物沿用 hatch-pet 契约节奏和标准单轨场景映射（行序：0 idle / 1 running-right / 2 running-left / 3 waving / 4 jumping / 5 failed / 6 waiting / 7 running / 8 review）。

OUO Neko 使用扩展 v2 契约：图集为 1536×2288，在相同 9 行动画之后增加 2 行、共 16 帧观察方向。其清单声明 `sprite2d.atlasRows: 11`，渲染器把最后 16 个单元格作为顺时针观察方向循环。

## 安全模型

- 全部 `/api/pet/*` 与 `/pet/<id>/*` 路由默认仅限 loopback（插件家族共享围栏：loopback 套接字 + Host 头 + 浏览器同源标记）：未配对的局域网客户端在任何宠物状态或图集下发前即收到 `403 forbidden: loopback-only`。同时装了 `dsh-remote-web-ui` 时，有效的已配对设备 cookie 是额外放行路径（与 `api/gate` 检查同一枚 cookie）；未配对与已撤销设备仍 403。宠物插件不硬依赖远程插件。
- 资产服务对宠物目录与目标文件双双做 `realpath` 解析；symlink 越界一律拒绝（403）。文件读入内存前按类限大小（清单 64 KB、图像 20 MB；超限 413）。
- Live2D 模型按闭包放行：仅清单、声明的主资产与 `.model3.json` 引用到的文件（引用先经穿越/绝对路径/URL 形态筛查）。
- 插件从不下载可执行文件，也从不内置 Live2D Cubism Core。
- 清单结构 fail-closed：未知字段或未知渲染器直接拒载，而不是加载一只半坏的宠物（拒载原因可用 `node scripts/dsh-pet validate <dir>` 查看）。

## 数据遥测

浏览器半区每个 UTC 日向 dsh-market.com 发送一次匿名安装心跳：仅含一个 localStorage 随机 ID 与本包名，无其他数据。服务端只存储该 ID 的加盐哈希，不存 IP，且只暴露聚合计数。完整契约见 [docs/telemetry.md](../../docs/telemetry.md)。

## 许可证

[BSD-3-Clause](LICENSE)
