# dsh-no-long-sit · 猫猫桌宠（久坐提醒）

> DeepSeek Harness 的**桌宠**：一只住在 Web GUI 角落的猫猫，可拖到任意位置，四个状态各有循环动画。
> 它现在最先给你干的一件事是**久坐提醒**——到点居中弹窗 + 三声滴滴喊你起来走动、喝口水；
> 你说"再等会"它就掏出**带真实来源的案例**劝你；本次工作结束时给一份好/中/差评价。
> 后续会继续长功能，目标是做一只有用的桌宠。

[English →](./README.md)

![界面：右下角猫猫 + 居中提醒弹窗](https://raw.githubusercontent.com/wangzhanchao883/dsh-no-long-sit/main/docs/images/reminder.png)

## 功能

| 能力 | 说明 |
|---|---|
| 猫猫桌宠 | 右下角常驻，四个状态循环动画（趴着/起身/喝水/睡着）；**可拖到任意位置并记住**；不透明度可调；素材缺失自动回落内置 SVG |
| 定时提醒 | 专注周期到点 → **居中弹窗**（遮罩，无法忽略）+ **三声"滴滴滴"提示音**，带休息倒计时 |
| 三按钮闭环 | 【休息好了】直接开下一轮专注 /【再等会】看案例、N 分钟后重弹 /【结束】出总结 |
| 伤害案例窗 | 内置 **17 条真实案例**（久坐 9 / 缺水 8），每次只挑 **1 条久坐 + 1 条缺水**：走**洗牌袋**——一轮内不重复、相邻两次必不同，袋子存 localStorage，**刷新/重启也接着上次继续**（不会每次开机都从第一条开始）；每条带来源媒体、发布日期、原文链接 |
| 休息结束 | 休息倒计时跑完 → 再响一次"滴滴滴" + 弹「回来干活」，点【开始干活】进下一轮；若一直没人点，**超过等待上限自动开下一轮**（默认 10 分钟，可设 0 关闭） |
| 总结评价 | 本次工作时长、完成专注轮数、成功中断次数、提醒触发次数、再等会次数、建议喝水次数 → 好/中/差 + 猫猫点评 |
| 设置面板 | 称呼、专注周期 30–90 分钟、休息周期 3–15 分钟、再等会间隔与上限、提示音开关与音量、桌宠不透明度、等待上限、评价阈值、悬浮宠物开关；带 **「恢复默认值」** 一键回退（两段确认） |
| 桌宠可拖动 | 按住猫猫拖到任意位置（自动夹在视口内），位置用 `localStorage` 记住；**拖动不会误触菜单**，单击才开菜单 |
| 计时权威在宿主 | 刷新页面/切标签不丢计时；多标签页不会各弹各的 |

默认：**每 30 分钟专注 → 提醒 → 休息 5 分钟 → 回来确认 → 下一轮**。

## 安装

```powershell
# 桌面版（Electron）：DSH_HOME 指向桌面数据目录
$env:DSH_HOME='D:\dsh-desktop-home\data'
& '<DSHzm>\resources\runtime\cli\bin\dsh.cmd' plugin --profile desktop add <本插件路径或包名>

# Web 版
$env:DSH_HOME='C:\Users\<你>\.dsh'
dsh plugin --profile web add dsh-no-long-sit
```

装完**重启 DSH**（bundle 层在启动期合成）。重启后右下角出现猫猫即表示加载成功。

## 设置项

设置 → 插件 → **猫猫 · 久坐提醒**：

| 字段 | 默认 | 范围 | 含义 |
|---|---:|---|---|
| `address` | 大人 | ≤24 字 | 猫猫对你的称呼（宿主没有统一的"用户称呼"字段，所以由插件自己持有） |
| `focusMinutes` | 30 | 30–90 | 专注周期（分钟） |
| `breakMinutes` | 5 | 3–15 | 休息周期（分钟） |
| `snoozeMinutes` | 5 | 1–15 | 点【再等会】后多久重新弹提醒（案例窗一关就立刻恢复提醒） |
| `maxSnooze` | 2 | 0–5 | 每轮最多"再等会"几次，超出记一次"未中断" |
| `soundEnabled` | 开 | — | 到点播放"滴滴滴"提示音（Web Audio 现场合成，不依赖素材；设置页可试听） |
| `soundVolume` | 0.6 | 0–1 | 提示音音量 |
| `awaitReturnTimeoutMinutes` | 10 | 0–60 | 「回来干活」最多等多久，超时自动开下一轮（0 = 一直等） |
| `earlyBreakGuard` | 开 | — | 休息没到点就点【休息好了】时，先软提示一次 |
| `goodRatio` / `midRatio` | 0.8 / 0.5 | 0–1 | 评价「好」/「中」所需的中断命中率 |
| `caseRefreshEnabled` | 开 | — | 启动后后台刷新案例库 |
| `caseRefreshDelayMinutes` | 10 | 1–120 | 启动后多久触发一次刷新 |
| `showFloatingPet` | 开 | — | 右下角常驻猫猫（可拖动，位置记忆在浏览器 `localStorage`） |
| `petOpacity` | 0.75 | 0.2–1 | 桌宠不透明度；0.75 = 25% 透明，设 1 完全不透明 |
| `timeScale` | 1 | 0.001–1 | **仅调试**：把分钟压成秒（0.01 = 30 分钟变 18 秒） |

改完 ≤2 秒生效（宿主每 2 秒重读一次 `settings.describe()`），当前阶段会按新时长重锚。

## 工作原理

```
┌─ host 半身 index.mjs ──────────────────────────────┐   ┌─ client 半身 client.js ────────┐
│ 权威计时（1s tick）→ 状态机 focus/break/awaitReturn │   │ shell.overlay 浮层：猫猫 +    │
│ 账本持久化 <DSH_HOME>/data/dsh-no-long-sit/*.json  │←→ │ 提醒窗 / 案例窗 / 总结窗          │
│ 案例库：assets/cases.json + 运行期缓存 + 后台刷新    │   │ 3s 轮询宿主状态，按钮 POST 动作   │
│ 路由 /dsh-no-long-sit/{state,choice,cases,refresh-  │   │ 设置卡片 settings.section +      │
│      cases,art}（自查跨源，白名单素材）             │   │ ctx.configForms.get(id)          │
└───────────────────────────────────────────────────┘   └────────────────────────────────┘
```

- **计时放宿主**：浏览器计时刷新即丢、多标签页无去重机制，所以状态机与账本都在 host，前端只渲染与发动作。
- **不做 host→client 事件推送**：DSH 没有给第三方插件的推送通道（转发事件白名单是官方封闭常量），
  所以前端用 3 秒轮询 + 本地 1 秒时钟算倒计时（宿主下发绝对时间戳，不用额外请求）。
- **持久化**：`<DSH_HOME>/data/dsh-no-long-sit/state.json`（同目录 `.tmp` + rename 原子替换，失败不阻塞插件）。
- **案例刷新**：`ctx.web.search()` 拿来源 → `ctx.llm.stream()`（`reasoningEffort:"off"`、60s 超时）改写成短案例
  → **只保留 url 出现在检索结果里的条目**（防模型编造来源）→ 原子写盘；失败保留旧库并在设置页显示原因与上次成功时间。

## 已知限制

1. **DSH 所有页面都关掉时不会再提醒**（架构决定：没有页面可弹，宿主也没有面向浏览器的第三方推送通道）。
   要系统级提醒得再加浏览器通知或独立桌面窗口，本期未做。
2. **提示音需要页面里有过一次用户交互**（浏览器自动播放策略）：正常使用 DSH 必然点过页面，插件会在第一次点击/按键时解锁音频。
   若 DSH 窗口被最小化或标签页在后台，浏览器会节流定时器，弹窗与提示音可能迟到几秒（切回前台会立刻补一次状态）。
3. **案例后台刷新可能失败**：桌面版搜索凭据顺序是「账号 token → `DEEPSEEK_API_KEY`」，且账号 token 只在
   会话活跃时可解析。失败时**保留旧库**并把原因写进设置页状态区，不会静默变成空列表。
4. **不统计真实喝水次数**：程序无法感知你有没有喝水，按你的口径用"专注轮数"给出**建议喝水次数**。
5. 弹窗只在 DSH GUI 内（`shell.overlay`），不覆盖宿主设置弹窗之上（宿主设置面板 z-index 更高）。

## 开发

```powershell
node --test          # 40 项：状态机 / 配置 / 案例校验 / 路由鉴权与素材净化 / mock 宿主契约 / settings 同步
node --check index.mjs; node --check client.js
```

- `lib/state.mjs` 是纯函数状态机（零依赖，可单测）；`lib/config.mjs` 是 schemastery schema。
- 前端是**经典脚本**（`window.__ModuleLoader__.load` + `React.createElement`），**没有构建步骤**，改完刷新页面即生效。
- 依赖：`@deepseek-ai/schemastery` 是唯一运行时依赖（peer）。本地 `link:` 开发时把它放进插件自己的 `node_modules`。

## 换猫猫素材

素材全部由 `assets/pet-manifest.json` 描述，**换素材不用改代码**：支持**动图文件**（WebP/GIF，当前形态）、
**帧序列**（`frames/<状态>/<状态>-NN.png`）与静态图（SVG/PNG）三种写法；清单缺失或某状态未定义时自动回落到内置猫猫 SVG。

当前装机素材：`assets/anim/{idle,rise,drink,sleep}.webp` —— 由用户的 512×512 透明 PNG 帧序列
（10 / 8 / 10 / 7 帧，fps 6 / 8 / 8 / 4）经「统一基线 → 缩到 256×256 → 合成为动图 WebP(q95)」流水线产出，
36 个文件 11.4 MB 压到 **4 个文件 626 KB**。

制作规格（画布、帧数、fps、命名、背景抠图要求）与现行装机素材对照见 **[docs/ART-SPEC.md](docs/ART-SPEC.md)**；
最省事的交付方式是给我 4 段纯色背景的 MP4（或直接给 512×512 透明 PNG 帧序列），我来抠图/抽帧/压缩装机。

## 验收记录（2026-09-30）

- 单测 **40/40** 通过（状态机 / 配置 / 案例校验 / 路由鉴权与素材净化 / mock 宿主契约 / settings 同步）。
- **隔离实例**（桌面 runtime 0.2.0-rc.2，独立 `DSH_HOME` + 端口 3099）+ 无头 Chrome CDP 全流程：
  宠物渲染 → 专注到点**居中弹窗**（实测几何中心 = 视口中心）→【再等会】案例窗（1 条久坐 + 1 条缺水，轮换）
  → 关窗后**提醒窗立刻恢复并继续倒计时** → 休息结束弹「回来干活」→【开始干活】进入下一轮（`breaksTaken=1`）。
- **提示音路径**用音频探针实测（统计 `createOscillator` 调用）：专注到点 +3、休息结束再 +3，两处都真的执行了。
- 设置生效链路：面板值 → profile patch → `settings.describe()` → 插件 ≤2s 生效（`cfgSource=user`）。

## 修复记录

| 日期 | 问题 | 根因 | 修复 |
|---|---|---|---|
| 09-30 | 到点没声音、弹窗被忽略 | 提示音从未实现；提醒窗是右下角贴边卡片 | Web Audio 现场合成三声"滴滴滴"（设置页可试听/调音量）；提醒窗改为**居中遮罩弹窗** |
| 09-30 | 看完案例后休息倒计时不再出现、跑完也不进下一轮 | 前端用 `dismissedAt === phaseStartedAt` 做**永久屏蔽**，点过一次【再等会】后整个阶段都不再弹窗 | 改为只由 `snoozeUntil` 控制；案例窗关闭时发 `resume` 立刻恢复提醒；休息结束时清空 `snoozeUntil`；另加「等待上限」兜底自动开下一轮 |
| 09-30 | 保存设置后整张卡片变空白，改过的值看着"改不回去" | `form.subscribe()` 的回调**不带参数**（store 只通知"变了"），旧代码把回调参数当新快照 → 快照被写成 `undefined`，全部字段渲染成空 | 回调里重新 `getSnapshot()`，并只接受有效快照；保存改为一次原子 `mutate`；新增「恢复默认值」与调试加速「一键刹车」 |
| 09-30 | 点「恢复默认值」后宿主仍按旧值跑 | 所有字段被 unset 后设置服务把 profile patch 里整条条目剪掉，宿主重读时"没有这一行"就直接 return → 配置停在上次的值 | 三档兜底：用户层非空用它 / 用户层清空但条目在 → 用 effective / 条目整条消失 → 回落 schema 默认值 |
| 10-02 | 案例窗"每次都显示同两条"，像是案例库不更新 | 选案例用 `arr[rotation % len]`，而 `rotation` 是每次页面加载都归零的 state → 开机第一次打开永远取第 0 条；后台刷新出的新案例恰好排在数组最前，于是天天看到的就是那两条 | 改为**洗牌袋 + localStorage**：一轮内不重复、相邻必不同、刷新/重启接着上次继续；新刷到的案例插队首；案例窗底部显示"共 N 条，每次弹窗都会换" |

## License

MIT
