# @xain_npm/dsh-client-ui-beep

[English](README.md) | 中文

**dsh-beep** —— 面向 Web 界面的「AI 智能体心跳」声音化插件。它用三种程序化合成的 Web Audio 音色，以细微、不打扰的方式告诉你页面上各智能体正在做什么，无需盯着屏幕：

> **要求 DSH ≥ 0.1.7。** 该版本用「profile 配置表单」取代了 `settingsScope` 服务
> （及其背后的 `settings.yaml`）——插件的设置**就是它自己的 cordis `Config`**。
> 0.5.0+ 已改用这套 seam（浏览器端 `configForms`、Host 端 `.volatile()` Config schema）；
> 0.4.x 及更早版本在 0.1.7+ 上无法激活。
>
> 该要求已写入 `peerDependencies`（插件运行时真正读取的 DSH 包 + `cordis`），
> 因此 Harness 版本不兼容时会在**安装阶段**报 peer 警告，而不是插件静默激活失败。
> （浏览器模块表的 seed——`dsh-client-store`、`dsh-client-ui-slots`、
> `dsh-client-ui-primitives`——**刻意不声明**：它们由 web app 在运行时注入，
> 在 profile 的 `node_modules` 里解析不到。）
>
> ⚠️ **semver 预发布版本的坑。** `^0.1.7-rc.2` 能匹配 `0.1.7-rc.x` 与正式版
> `0.1.7`，但**匹配不上未来的 `0.1.8-rc.x`**：semver 规定范围里的预发布版本
> 只能匹配 `major.minor.patch` 元组相同的版本。DSH 每次发布新的预发布元组时，
> 这个范围都需要相应放宽。

> 本包是 [deepseek-harness](https://github.com/deepseek-ai/deepseek-harness) 仓库中
> `ui-beep` 插件的独立可发布分支（MIT）。它使用自带的 `tsconfig.json` 与
> `tsdown.config.ts` 构建（仓库内使用共享的 `clientBundle` 预设；本分支独立复刻了
> 相同的输出格式）。

## 安装与使用

```sh
npm install @xain_npm/dsh-client-ui-beep
# 或：pnpm add / yarn add
```

然后在你的 harness `cordis.yml`（或 `cordis.patch.yml` 覆盖层）里挂载为 web client 行：

```yaml
- id: ui-beep
  name: '@xain_npm/dsh-client-ui-beep'
  config:
    enabled: true        # 每浏览器的默认值（各浏览器各自保存自己的设置）
    masterVolume: 0.4    # 主音量 0…2（100% = Web Audio 标称满幅）
```

## 开发

```sh
npm install
npm run build   # tsc + tsdown → lib/
npm test        # vitest（66 个用例）
```

## 发布

```sh
npm login                 # 你的 npm 账号
npm version minor         # 升版本（见下方「版本历史」）
npm publish               # prepublishOnly 会先跑 build + test
```

## 音色

| 音色 | 触发时机 | 声音 |
|---|---|---|
| **hum（低鸣）** | 任意会话**忙碌中**（工作正在进行）——「Deep diving…」（模型请求在途）、工具执行（运行代码、读取文件）、推理思考——**且**没有会话在等待你的输入、**且**当前会话未在流式输出可见内容。每 4 秒重复一次舒缓心跳；流式输出时暂停（由滴答声接管），有待处理交互时也暂停（提示音已提醒过你） | 柔和低频「lub-dub」心跳声（约 118 Hz + 92 Hz，约 500 ms） |
| **tick（滴答）** | 当前会话正在流式输出可见内容 | 2 kHz 高频短促音（约 60 ms） |
| **chime（提示音）** | 任意会话开始等待你的输入（审批 / 计划评审 / 提问），**或最后一个忙碌会话转为空闲**（工作完成、轮到你）。未应答的交互会在 10 秒后再次提示，之后每 30 秒重复一次，直到应答 | 880 Hz + 1320 Hz 双音铃铛声（约 500 ms） |

映射关系源自 AgentPulse：智能体在工作 → 低沉的心跳声；有输出活动 → 轻快的滴答声；智能体在等你（或已完成）→ 清亮的提示音。

## 工作原理

浏览器端读取两个与 React 无关的可观察数据面：

- **`ctx.uiSession.sessionStatus`** —— DSH 0.1.7 引入的统一「每会话状态」数据面（它取代了原先分开的 `sessions.list` + `uiSession.pendingInteractions`）。它是一个 `SessionId → { running, pendingInteraction }` 的 Map，**一次订阅同时驱动心跳与提示音**。
  - `running` 即**忙碌**信号：从提示词受理到工具执行、推理思考，整个回合都为 true。只要有**任意**会话在运行，低频**低鸣**就按固定间隔重复（默认 4 秒）——工作开始的第一拍立即响起（若页面加载时已有会话正在工作，也会立即响起）。仅当当前会话**正在活跃地**流式输出可见内容时低鸣暂停（由滴答声接管——「正在流式输出」指最近 1.5 秒内有文本增长）；当**任意**会话有待处理交互时也**暂停**（提示音已提醒过你）。输出停止约 1.5 秒后低鸣即恢复——即使智能体仍在继续工作（如消息之后的工具调用）——交互清除后也会恢复。最后一个运行中的会话转为空闲后完全停止。这是**电平**判定：它表示「有东西正在工作」。音色本身是柔和的低频 lub-dub 心跳（两段 90–120 Hz 的缓起正弦起伏）——令人安心，而非催促。
  - `pendingInteraction` 驱动**提示音**：某会话出现 0→1 边沿时播放。判定只看**边沿**而非电平——持续等待的会话不会在每次刷新时重复提示。**未应答**的交互会在 10 秒后再次提示，之后每 30 秒重复一次，直到被应答或会话消失；页面加载时已处于等待的交互同样启动提醒阶梯（但不会立即提示）。**工作结束**是它的镜像边沿：最后一个忙碌会话转为空闲的瞬间响一声，提示你 Agent 已完成。
- **当前会话的对话快照**（`ctx.uiConversation.binding(id).snapshot`，`ObservableSnapshot<ConversationSnapshot>`）：其通知器在每一帧组装完成时触发；chat 目标实时 `partial` 中的可见输出文本增长时触发**滴答声**（频率受渲染节奏约束，音频引擎自身的防抖再叠加硬性下限）。当前会话 id 取自渲染器 scope 适配器 —— `ctx.uiSession.adapter.current.getSnapshot().key`（DSH 0.1.7 把它移到了这里；列表状态已不再携带 `current`）。

所有音色均在代码内合成，带线性渐入渐出包络——无需素材文件，状态快速切换也不会产生爆音。每种音色有 50 ms 的最小防抖间隔。

## 浏览器自动播放策略

浏览器会阻止未经用户手势的音频播放，因此引擎会在页面首次 `pointerdown`/`keydown` 时启动，在此之前一律静默不发声。音频不可用时不会抛出异常，也不会刷控制台。点**试听**按钮同样会直接启动引擎（点击本身就是手势）。

## 配置

行 `config:` **就是**插件的设置文档 —— DSH 0.1.7 把插件设置存成它自己的 cordis `Config`，每个字段都声明为 `.volatile()`，因此在设置页写入的值会**即时生效、无需重启**。所有字段均可选：

```yaml
- id: ui-beep
  name: '@xain_npm/dsh-client-ui-beep'
  config:
    enabled: true        # 「从未选择过的浏览器」的初始默认值（见下）
    masterVolume: 0.4    # 每浏览器默认值；主音量 0…2
    tickVolume: 1        # 每浏览器默认值；流式输出音量 0…2
    humVolume: 1         # 每浏览器默认值；工作心跳音量 0…2
    chimeVolume: 1       # 每浏览器默认值；等待输入/完成音量 0…2
    tickPath: ''         # 自定义音频的绝对路径（留空则用内置音）
    humPath: ''
    chimePath: ''
```

### 各项设置都是「每个浏览器各自一份」

`enabled` 与四个音量字段**不是**共享开关——它们是「从未选择过的浏览器」的
*初始默认值*。一旦你在某个浏览器里拨过开关或拖过滑块，**该浏览器就把自己的值
存进 `localStorage`**，此后 Host 里对应字段的值对它不再起作用：

```
设置页开关 + 各音量滑块 ┐
                        ├─→ localStorage（每浏览器）→ 各设备互不影响，
输入框小喇叭          ──┘                        同一浏览器内各处永远一致
        ↑
Host config.enabled / *Volume = 仅作初始默认值

自定义音频「路径」仍留在 Host（文件在运行 harness 的那台机器上）
```

原因：声音是从**这个**浏览器发出来的，就该由**这个**浏览器决定——而且手机外放
和电脑音箱本就不是一回事。这也意味着改动**立即生效**、不需要往返 Host；而往返
正是这些控件在手机上不可靠的原因：写入带乐观并发 `revision`，快照陈旧就会被拒，
旧代码又把失败静默丢弃（所以控件看起来「点不动」）。手机静音了，电脑照常响；
同一浏览器内的各处 UI 永远显示同一个状态。

每个字段**独立回退**：只改过音量，开关仍跟随 Host 默认值。这些选择**跨刷新、
跨重启永久保存**；无痕窗口（或存储被禁用）下退化为「仅本次会话有效」，而不是报错。

**设置 → 提示音** 页面管理同一份数据：一个启用开关、一个总音量，以及每个
模式各自的音量（流式输出提示音 / 工作提示音 / 等待输入提示音），均为 0–200%
并带试听按钮。100% 即 Web Audio 的标称满幅；超过 100% 的部分是留给用户自己的
余量——插件不做任何上限限制，拉高滑块的用户自己决定提示音有多响（超过满幅
可能削波）。默认值保持保守，不会吓到首次使用的用户。

> **内部节奏不可配置。** 心跳周期（4 秒）、复响延迟（10 秒 / 30 秒）与流式暂停
> 窗口（1.5 秒）是 watcher 内部的默认值；0.5.0 之前它们是行配置项，现在刻意
> 不再放进 Config（它们属于调参，不是用户设置）。

输入框右下角（模型选择器旁边）还有一个**静音开关**：小喇叭按钮，点击切换
**本浏览器**的提示音静音（静音时喇叭带叉）。它与设置页开关读写的是同一个
本机文档，因此二者永远不会不一致；静音会立即停止循环中的工作心跳，
取消静音时若 Agent 正在工作会立即响起一拍（无需等待下一个心跳间隔）。

### 每个模式的自定义音频

每个模式都可以播放**用户提供的音频文件**，代替内置合成音。设置页每个模式
有「选择音频」按钮，点击打开全盘文件浏览器（从 `/` 或盘符根目录开始），列出
普通用户的文件夹和音频文件（`.mp3`、`.wav`、`.ogg`、`.flac`、`.m4a`、`.aac`、
`.opus`、`.webm`）——隐藏（dotfile）条目和系统目录会被跳过。所选**绝对路径**
存入插件 Config（一个普通字符串字段）——文件不会被上传或复制，磁盘上的文件
可随意移动/替换。播放语义：

- **tick / chime（输出/等待）** — 每次触发播放一次自定义文件。
- **hum（工作）** — Agent 忙碌时自定义文件**无缝循环**，因此文件自身长度
  决定心跳节奏（文件越长 = 节拍越慢；换文件即可调整间隔）。
- **试听（Preview）** 按钮始终只播放一次——即使对 hum 也一样，试听不会循环。
  试听**不受静音开关限制**：你想试听一个声音，恰恰是在决定要不要开启提示音的
  时候，所以静音状态下点试听照样出声（主音量/单音色音量照常生效，听到的就是
  该音色实际的响度）。
- **没有路径、或路径对应的文件无法读取/解码**（缺失、移动、权限不足、格式
  不支持）时，自动回退到内置音。点「恢复默认」清除路径。

Host 端通过两条 loopback、浏览器鉴权的路由（`GET /ui-beep/audio/:voice`、
`GET /ui-beep/browse`）提供文件——路径从插件 Config 读取、而非来自请求 URL，
因此路由无法被指向任意文件。浏览器每次获取并解码文件一次，之后缓存解码结果。

## 版本历史

- **0.6.1** —— 四个音量滑块也改为**每个浏览器各自一份**（与开关同一机制），
  各设备独立控制自己的响度；只有自定义音频路径仍归 Host。
- **0.6.0** —— 开关改为**每个浏览器各自一份**（`localStorage`）：各设备独立控制
  自己的提示音，设置页开关与输入框小喇叭永远不会不一致。Host 的 `enabled` 字段
  降级为「从未选择过的浏览器」的默认值。另：静音状态下也能试听。
- **0.5.0** —— 迁移到 DSH 0.1.7 的 settings seam（破坏性变更：要求
  DSH ≥ 0.1.7 / cordis ≥ 4.0.4）。Host 设置改为插件自己的 Config schema；
  浏览器端改用 `ctx.configForms`；watcher 改用 `uiSession.sessionStatus`。
- **0.4.0** —— 设置页、0–200% 音量、每音色自定义音频文件、输入框静音开关。

## 模型体验

无。本包仅是浏览器端对已记录会话事实（运行/忙碌、流式输出、等待交互）的只读声音化；它只播放音频，不注册任何面向模型的内容。模型对自身工作的视图仍由产生这些事实的工具与宿主服务负责。

#### KV 缓存影响

无；本包从不组装或发送 provider 请求。

## 已知限制与后续工作

- **声音仅限当前页面。** 只有 Web GUI 所在标签页（且已接收过手势启动）才会发声；插件不会跨标签页或宿主进程。
- **提醒阶梯没有系统通知层。** 未应答的交互会在 10 秒、之后每 30 秒复响，但没有系统通知环节（macOS AgentPulse 阶梯的「120 秒系统通知」留作后续工作）。
- **滴答声仅针对当前会话。** 后台会话（你未在查看的子智能体）的输出不会发声；只有聚焦会话的流式输出驱动滴答声。
