dsh-notify-say 0.4.46 → 0.4.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,219 +1,369 @@
1
- # dsh-notify-say
2
-
3
- 给 [DeepSeek Harness](https://github.com/deepseek-ai) Desktop 用的通知插件:**任务跑完会说话**。
4
-
5
- > 别的通知插件"叮"一声;这个会喊你"主人、任务完成了"。
6
- > 而且**子代理还在跑的时候,它不会骗你说完成了**。
7
-
8
- ---
9
-
10
- ## 它解决什么问题
11
-
12
- 你让 AI 跑一个较长任务,然后切去干别的。常见两种通知插件的毛病:
13
-
14
- 1. **该响的时候不响** —— 主会话一轮结束就报"完成",但其实它派出的子代理还在跑,你回来发现没干完。
15
- 2. **不该响的时候一直响** —— 每个子代理结束都弹一次,连环轰炸。
16
-
17
- 本插件的做法:
18
-
19
- - **空闲门(idle gate)**:主会话这轮结束、但仍有子代理或后台任务在跑时,**先不播**;等全部结束再播**一次**。超时(默认 10 分钟)兜底,防子任务卡死导致永不播报。
20
- - **按会话去抖**:同一会话 60 秒内只响一次。
21
- - **失败单独提醒**:回合出错时播**日语道歉语音**(可选),比"任务完成"更该让你立刻知道。
22
-
23
- ---
24
-
25
- ## 特性
26
-
27
- | 特性 | 说明 |
28
- |---|---|
29
- | **语音播报** | 本地 wav,不联网、不调用云端 TTS。发布版附 1 个音色,其余可按需下载 |
30
- | **空闲门** | 有在飞工作时不误报"完成",全部结束才播一次 |
31
- | **五类事件** | 完成 / 出错 / 被拦截 / 待批准 / 要提问,各自独立开关 |
32
- | **设置页 GUI** | 走官方 `Config` schema,DSH 设置页自动生成表单 |
33
- | **命令行** | `tools/notify-config.ps1`,改完**立即生效、不用重启** |
34
- | **点通知跳会话** | 点击 Toast 可直接回到对应会话(拿不到导航服务时自动降级为"唤出窗口") |
35
- | **不装语音也能用** | 没有语音包 = Toast + 系统提示音,这是正常配置 |
36
- | **全离线** | 不联网、不发送任何会话内容 |
37
-
38
- ---
39
-
40
- ## 安装
41
-
42
- ### 前置
43
-
44
- - DSH Desktop **0.1.7-rc.2 或更高**
45
- - Windows(Toast 用 WinRT,播放用 MCI;暂不支持 macOS/Linux)
46
-
47
- ### 方式一:插件页(推荐)
48
-
49
- 在 DSH 的 **设置 → 插件** 里搜索 `dsh-notify-say` 安装。
50
-
51
- ### 方式二:手工 link(本地开发)
52
-
53
- 把本仓库目录链接进 profile:
54
-
55
- ```
56
- D:\Program Files (x86)\DSH\profiles\desktop\package.json
57
- dependencies 里加: "dsh-notify-say": "link:<本仓库路径>"
58
- dsh.profile.bundles 里加: "dsh-notify-say"
59
- ```
60
-
61
- 然后在该 profile 目录执行 `pnpm install`,重启 DSH。
62
-
63
- ---
64
-
65
- ## 配置
66
-
67
- 三种方式,优先级由低到高(后者覆盖前者):
68
-
69
- ```
70
- 1. schema 默认值
71
- 2. cordis.patch.yml 的 config
72
- 3. DSH 设置页里改的值
73
- 4. tools/notify-config.ps1 写的 storages/config.json
74
- ```
75
-
76
- > ⚠️ **注意语义**:一旦你在设置页改过某项,再改 `cordis.patch.yml` 里同一项**不再生效**。
77
- > 这是 DSH 官方设计(用户层优先级更高),不是本插件的 bug。
78
-
79
- ### 设置页
80
-
81
- DSH **设置 → 通知**(由官方 Loader 从本插件的 `Config` schema 自动生成),共 12 项:
82
-
83
- 语音开关、音色模式(固定/随机)、音色名、音量,
84
- 以及完成/出错/被拦截/已中止/待批准/要提问/子代理/空闲门 八个开关。
85
-
86
- ### 命令行
87
-
88
- ```powershell
89
- cd <插件目录>\tools
90
-
91
- .\notify-config.ps1 -Show # 看当前生效值
92
- .\notify-config.ps1 -Voice off # 静音(Toast 仍弹)
93
- .\notify-config.ps1 -Volume 40
94
- .\notify-config.ps1 -VoiceMode random
95
- .\notify-config.ps1 -VoiceName ankomon
96
- .\notify-config.ps1 -Disable aborted -Enable subagents
97
- .\notify-config.ps1 -Clear # 交还控制权给设置页
98
- ```
99
-
100
- 改完**立即生效**,不需要重启 DSH。
101
-
102
- ### 语音包管理
103
-
104
- 核心包只带**一个日语音色**(ずんだもん),体积约 2.8 MB。
105
- 完整多音色包体积较大(十余个音色约 40 MB),故不随包发布 —— 计划通过单独下载提供(尚未实现)。
106
-
107
- ```powershell
108
- .\notify-config.ps1 -ListPacks # 看已装哪些音色、各占多少
109
- .\notify-config.ps1 -PurgeVoice <key> # 删掉不用的音色
110
- .\notify-config.ps1 -InstallHint # 看怎么获取更多音色
111
- ```
112
-
113
- **不装任何语音包也完全可用**:通知会退回 Windows Toast + 系统提示音。
114
-
115
- 想要更多音色,两条路:
116
-
117
- 1. **自己合成**:装 [VOICEVOX](https://voicevox.hiroshiba.jp/)(本地免费 TTS),启动引擎(默认 `http://127.0.0.1:50021`),
118
- 然后按下面的命名规则把合成好的 wav 放进对应目录即可。
119
- (合成脚本不随本包发布——它依赖本地 VOICEVOX 引擎与特定音色表,放在发布包里只会误导。)
120
- 2. **手工放入**:把 wav 按下面的命名放进对应目录
121
-
122
- ```
123
- sounds/voice-lines/<key>~1.wav 完成
124
- sounds/voice-lines/question/<key>~1.wav 提问
125
- sounds/voice-lines/approval/<key>~1.wav 审批
126
- sounds/voice-lines/fail/<key>~1.wav 出错
127
- ```
128
-
129
- ---
130
-
131
- ## 兼容性与降级
132
-
133
- 本插件的首要目标是**稳定**,所以每个依赖外部能力的地方都有降级路径:
134
-
135
- | 能力 | 拿不到时怎么办 |
136
- |---|---|
137
- | `@deepseek-ai/schemastery`(生成设置页) | 不导出 `Config` → **设置页不出现,插件照常工作** |
138
- | `uiWorkspace.openSession`(点通知跳会话) | 不跳转,Toast 仍能唤出窗口(**退回旧行为**) |
139
- | 语音包 | 回退系统提示音 |
140
- | `$DSH_HOME` 未设置 | 配置走 patch 默认值;不写任何状态文件 |
141
-
142
- **不会出现"静默失效"**:每个降级都有明确行为,并有测试锁定。
143
-
144
- > 对比:某些插件写 `if (typeof sessions?.open === 'function') sessions.open(id)`,
145
- > 而 `sessions.open` 在 0.1.7+ 已被移除 —— 该功能**静默失效,用户点通知没反应却无从察觉**。
146
- > 详见 `CARD.md` 的 §零之二。
147
-
148
- ---
149
-
150
- ## 已知限制
151
-
152
- - **仅 Windows**(Toast = WinRT,播放 = MCI,调用 Windows PowerShell 5.1)
153
- - **设置页真实渲染尚未验证**(代码已就绪、schema 结构已测,但未在宿主里看过界面)
154
- - **点通知跳会话依赖未文档化 API**(`uiWorkspace`),官方随时可能改;坏了会自动降级
155
- - `blocked`(回合被拦截)只用系统蜂鸣,不播失败语音 —— 因为语义是"被拦下"而非"失败"
156
- - **`fail` 语音包只覆盖 16 个基础音色**(103 个音色里的其余 87 个只有完成语音)。选到未覆盖的音色时,失败会在同池内换一个音色,不会哑掉、也不会播错语义
157
-
158
- ---
159
-
160
- ## 开发
161
-
162
- ### 跑测试
163
-
164
- ```powershell
165
- npm test # 主套件(纯 Node,不需要 DSH 宿主)
166
- npm run test:all # 全部(含 route 注入的两组)
167
- npm run test:route # 需要 --import loader 的接线测试
168
- npm run test:audio-wire
169
- ```
170
-
171
- **274 条测试。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
172
-
173
- 少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
174
- 找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
175
- 指定宿主路径:
176
-
177
- ```powershell
178
- $env:DSH_CORDIS_ENTRY = "<DSH 安装目录>\.runtime\node_modules\@deepseek-ai\cordis\lib\index.js"
179
- npm test
180
- ```
181
-
182
- ### 测试分层
183
-
184
- | 层次 | 内容 |
185
- |---|---|
186
- | 单元 | 音源选择、空闲门、配置校验、TTS 文案、http-guard 判定 |
187
- | 集成 | 真实 HTTP 服务器 + 真实 handler,覆盖请求/响应全链路 |
188
- | 实机 | 真实 Cordis 上下文(需宿主,无则 skip) |
189
- | 发布 | 构建发布包并断言被禁音色/第三方素材未进包 |
190
-
191
- 集成测试(`test/http-integration.test.mjs`)是 2026-10-18 加的,起因是
192
- 一次真实回归:给 `/consume` 加了"写操作必须 JSON"的防护,但客户端还在用
193
- GET —— 单元测试全绿,功能却断了。集成测试会读取客户端源码里的真实调用形状,
194
- 所以两边一旦不同步就会变红。
195
-
196
- ### CI
197
-
198
- `.github/workflows/ci.yml` 在每次 push / PR 时跑:
199
-
200
- - 单元测试(Node 20 / 22 / 24)
201
- - PowerShell 语法与 UTF-8 BOM 检查
202
- - 关键文件齐全 + npm 打包内容检查(含"被禁音色不得进包")
203
- - 版本一致性
204
-
205
- ### 变异测试
206
-
207
- 关键断言都做过变异验证(故意改坏代码确认测试变红)。例如:
208
-
209
- - 把 `consume` 的客户端调用改回 GET → E0/E1 变红
210
- - 把 `head` 的前缀剥离去掉 → E3 变红
211
- - 把 `verifyReload` 改成永远放行 → S2/S6/R8/R8b 变红
212
-
213
- ---
214
-
215
- ## 许可
216
-
217
- 代码以 MIT 发布。
218
- **语音文件不适用 MIT**,由 VOICEVOX 生成,各音色有其自身使用条款,
219
- 详见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。
1
+ # dsh-notify-say
2
+
3
+ 给 [DeepSeek Harness](https://github.com/deepseek-ai) Desktop 用的通知插件:**任务跑完会说话**。
4
+
5
+ > 别的通知插件"叮"一声;这个会喊你"任务完成了"。
6
+ > 而且**子代理还在跑的时候,它不会骗你说完成了**。
7
+
8
+ 只支持 **Windows**。
9
+
10
+ ---
11
+
12
+ ## 目录
13
+
14
+ - [它解决什么问题](#它解决什么问题)
15
+ - [特性](#特性)
16
+ - [安装](#安装)
17
+ - [快速开始](#快速开始)
18
+ - [配置](#配置)
19
+ - [语音包](#语音包)
20
+ - [关于"跳回会话"](#关于跳回会话)
21
+ - [兼容性与降级](#兼容性与降级)
22
+ - [已知限制](#已知限制)
23
+ - [常见问题](#常见问题)
24
+ - [开发](#开发)
25
+ - [许可](#许可)
26
+
27
+ ---
28
+
29
+ ## 它解决什么问题
30
+
31
+ 你让 AI 跑一个较长任务,然后切去干别的。常见两种通知插件的毛病:
32
+
33
+ 1. **该响的时候不响** —— 主会话一轮结束就报"完成",但其实它派出的子代理还在跑,你回来发现没干完。
34
+ 2. **不该响的时候一直响** —— 每个子代理结束都弹一次,连环轰炸。
35
+
36
+ 本插件的做法:
37
+
38
+ - **空闲门(idle gate)**:主会话这轮结束、但仍有子代理或后台任务在跑时,**先不播**;等全部结束再播**一次**。超时兜底(默认 10 分钟),防子任务卡死导致永不播报。
39
+ - **按会话去抖**:同一会话默认 60 秒内只响一次。
40
+ - **失败单独提醒**:回合出错时播**日语道歉语音**(可选)—— 比"任务完成"更该让你立刻知道。
41
+
42
+ ---
43
+
44
+ ## 特性
45
+
46
+ | 特性 | 说明 |
47
+ |---|---|
48
+ | **语音播报** | 播放本地 wav,**不调用云端 TTS**。发布版自带 1 个音色,另 13 个可在设置页按需下载 |
49
+ | **空闲门** | 有在飞工作时不误报"完成",全部结束才播一次 |
50
+ | **五类事件** | 完成 / 出错 / 被拦截 / 待批准 / 要提问,各自独立开关 |
51
+ | **设置页 GUI** | 走官方 `Config` schema,在 DSH 设置页里直接改,**改完立即生效** |
52
+ | **命令行** | `tools/notify-config.ps1`,适合脚本化与排查 |
53
+ | **切回窗口时跳会话** | 通知时你不在前台 → 你切回来时自动跳到那个会话(见[下文](#关于跳回会话)) |
54
+ | **不装语音也能用** | 没有语音包 = Toast + 系统提示音,这是正常配置 |
55
+ | **许可先告知** | 下载音色前弹出条款确认,写明署名要求与官方条款链接 |
56
+ | **诊断导出** | 一键导出脱敏日志(用户名/路径已替换),方便提 Issue |
57
+ | **日志隐私分级** | 默认只记元数据,**不记台词正文**;要详细日志需手动开 debug |
58
+
59
+ > **联网说明**:播放、通知、TTS 全部离线。只有**下载语音包**时需要访问 GitHub。
60
+ > 不下载语音包的话,这个插件不会联网。
61
+
62
+ ---
63
+
64
+ ## 安装
65
+
66
+ ### 前置
67
+
68
+ - DSH Desktop **0.1.7-rc.2 或更高**
69
+ - Windows(Toast 用 WinRT,播放用 MCI,调用 Windows PowerShell 5.1)
70
+
71
+ ### 方式一:插件页(推荐)
72
+
73
+ 在 DSH 的 **设置 → 插件** 里搜索 `dsh-notify-say` 安装。
74
+
75
+ ### 方式二:npm
76
+
77
+ ```
78
+ npm install dsh-notify-say
79
+ ```
80
+
81
+ ### 方式三:手工 link(本地开发)
82
+
83
+ 把本仓库目录链接进 profile:
84
+
85
+ ```
86
+ D:\Program Files (x86)\DSH\profiles\desktop\package.json
87
+ dependencies 里加: "dsh-notify-say": "link:<本仓库路径>"
88
+ dsh.profile.bundles 里加: "dsh-notify-say"
89
+ ```
90
+
91
+ 然后在该 profile 目录执行 `pnpm install`,重启 DSH。
92
+
93
+ ---
94
+
95
+ ## 快速开始
96
+
97
+ 装好之后**什么都不用配就能用**:任务完成时会弹 Toast + 播自带音色。
98
+
99
+ 想做更多,进 **DSH 设置 → 通知**:
100
+
101
+ | 想做的事 | 去哪 |
102
+ |---|---|
103
+ | 换音色 / 调音量 | 设置页「音色」「音量」 |
104
+ | 下载更多音色(13 个可选) | 设置页「语音包」→ 点「加载列表」→ 选一个点「下载」 |
105
+ | 只保留提示音、不要语音 | 音源改成「系统提示音」 |
106
+ | 出错时也要语音提醒 | 「出错」开关(默认已开) |
107
+ | 让子代理结束也提醒 | 「子代理」开关(默认关) |
108
+ | 少响一点 | 「同会话去抖」调大 |
109
+
110
+ ---
111
+
112
+ ## 配置
113
+
114
+ 四种来源,**优先级由低到高**(后者覆盖前者):
115
+
116
+ ```
117
+ 1. schema 默认值
118
+ 2. cordis.patch.yml 的 config
119
+ 3. DSH 设置页里改的值
120
+ 4. tools/notify-config.ps1 写的 storages/config.json
121
+ ```
122
+
123
+ > ⚠️ **注意语义**:一旦你在设置页改过某项,再改 `cordis.patch.yml` 里同一项**不再生效**。
124
+ > 这是 DSH 官方设计(用户层优先级更高),不是本插件的 bug。
125
+
126
+ ### 设置页
127
+
128
+ **DSH 设置 → 通知**。包含音源选择、音色、音量、增益、TTS 参数、五个事件开关、
129
+ 去抖与冷却、音源按事件覆盖、自定义音频、语音包管理、日志级别、诊断导出等。
130
+
131
+ 分常规区与「高级」折叠区:日常只用常规区,排查时才展开高级区。
132
+
133
+ ### 命令行
134
+
135
+ ```powershell
136
+ cd <插件目录>\tools
137
+
138
+ .\notify-config.ps1 -Show # 看当前生效值
139
+ .\notify-config.ps1 -Voice off # 静音(Toast 仍弹)
140
+ .\notify-config.ps1 -Volume 40
141
+ .\notify-config.ps1 -VoiceMode random
142
+ .\notify-config.ps1 -VoiceName ankomon
143
+ .\notify-config.ps1 -Disable aborted -Enable subagents
144
+ .\notify-config.ps1 -Clear # 交还控制权给设置页
145
+ ```
146
+
147
+ 改完**立即生效**,不需要重启 DSH。
148
+
149
+ ---
150
+
151
+ ## 语音包
152
+
153
+ 核心包只带**一个日语音色**(ずんだもん),安装包约 3.2 MB。
154
+
155
+ 另有 **13 个音色**可单独下载(每个约 1.7–3.0 MB,合计约 27 MB),
156
+ **在设置页里点几下就能装**,不需要命令行。
157
+
158
+ ### 怎么装
159
+
160
+ 1. **DSH 设置 → 通知 → 语音包** → 点「加载列表」
161
+ 2. 列表里会显示每个音色的名称、体积、署名要求
162
+ 3. 点「下载」→ **先弹出条款确认弹窗**(见下)→ 确认后开始下载
163
+ 4. 装好后在「音色」下拉里就能选到
164
+
165
+ ### 下载前会告知什么
166
+
167
+ 点下载会弹一个居中弹窗,写明:
168
+
169
+ - **署名要求**(例如 `VOICEVOX:ずんだもん`)—— 你用这些语音做的内容需要保留这个署名
170
+ - **官方条款链接**(可点开原文)
171
+ - 提示"下载后 zip 内也带 TERMS.md;你用它做的内容(视频、直播等)仍需守上述条款"
172
+
173
+ 这不是走形式:13 个音色里有 2 个因为条款**禁止再分发**被我们排除在外,
174
+ 1 个因为身份存疑(同名角色有多个)暂不收录。审核依据是官方条款原文,
175
+ 留档在 [`docs/audio-audit/`](docs/audio-audit/)。
176
+
177
+ > 需要说清楚:我们的审核结论是**依据官方通用条款**得出的,
178
+ > 不等于各权利人针对"独立 WAV 素材 ZIP 再分发"出具过专项授权。
179
+
180
+ ### 卸载
181
+
182
+ 设置页里点「卸载」即可,会删掉该音色整个目录。
183
+ **卸载不依赖网络** —— 离线时也能卸。
184
+
185
+ ### 已装的音色放哪
186
+
187
+ ```
188
+ %LOCALAPPDATA%\dsh-notify-say\voice-lines\<音色key>\
189
+ ```
190
+
191
+ 每个音色一个目录,卸载 = 删这一个目录。
192
+
193
+ ### 不装语音包也完全可用
194
+
195
+ 通知会退回 Windows Toast + 系统提示音。
196
+
197
+ ### 想自己做音色
198
+
199
+ 装 [VOICEVOX](https://voicevox.hiroshiba.jp/)(本地免费 TTS),启动引擎(默认 `http://127.0.0.1:50021`),
200
+ 然后按命名规则把合成好的 wav 放进对应目录:
201
+
202
+ ```
203
+ sounds/voice-lines/<key>~1.wav 完成
204
+ sounds/voice-lines/question/<key>~1.wav 提问
205
+ sounds/voice-lines/approval/<key>~1.wav 审批
206
+ sounds/voice-lines/fail/<key>~1.wav 出错
207
+ ```
208
+
209
+ (合成脚本不随本包发布 —— 它依赖本地 VOICEVOX 引擎与特定音色表,放进发布包只会误导。)
210
+
211
+ ---
212
+
213
+ ## 关于"跳回会话"
214
+
215
+ 这里有个容易误解的地方,说清楚:
216
+
217
+ **点击 Toast 本体只能把 DSH 窗口唤到前面,带不了会话 id。**
218
+ Windows 的 Toast 协议激活是一个固定串(`dsh://open`),没有地方塞会话 id。
219
+
220
+ **真正会"跳回会话"的是这个机制**:
221
+
222
+ ```
223
+ 通知发出时你不在前台 → 插件记下那个会话
224
+ 你切回 DSH 窗口 → 插件自动跳到那个会话
225
+ ```
226
+
227
+ 所以体验是:你在别处干活 → 听到"任务完成了" → 切回 DSH → **它已经帮你跳过去了**。
228
+
229
+ 相关开关(默认值偏保守):
230
+
231
+ | 开关 | 默认 | 说明 |
232
+ |---|---|---|
233
+ | 后台完成时跳转 | 开 | 通知时你不在前台 → 切回来时跳 |
234
+ | 前台完成时也跳 | 关 | 你本来就在看着,别乱动界面 |
235
+
236
+ 配置读不到时**两个都当作关**(宁可少跳一次,也不擅自动你的界面)。
237
+
238
+ ---
239
+
240
+ ## 兼容性与降级
241
+
242
+ 本插件的首要目标是**稳定**,所以每个依赖外部能力的地方都有降级路径:
243
+
244
+ | 能力 | 拿不到时怎么办 |
245
+ |---|---|
246
+ | `@deepseek-ai/schemastery`(生成设置页) | 不导出 `Config` → **设置页不出现,插件照常工作** |
247
+ | `uiWorkspace.openSession`(跳会话) | 不跳转,Toast 仍能唤出窗口(**退回旧行为**) |
248
+ | 语音包目录 | 回退系统提示音 |
249
+ | 网络(下载语音包时) | 显示失败原因并可重试;**已装音色不受影响** |
250
+ | `$DSH_HOME` 未设置 | 配置走 patch 默认值;不写任何状态文件 |
251
+
252
+ **不会出现"静默失效"**:每个降级都有明确行为,并有测试锁定。
253
+
254
+ ---
255
+
256
+ ## 已知限制
257
+
258
+ - **仅 Windows**(Toast = WinRT,播放 = MCI,调用 Windows PowerShell 5.1)
259
+ - **跳会话依赖未文档化 API**(`uiWorkspace`),官方随时可能改;坏了会自动降级为"只唤起窗口"
260
+ - `blocked`(回合被拦截)只用系统蜂鸣,不播失败语音 —— 语义是"被拦下"而非"失败"
261
+ - **失败语音只覆盖部分音色**:选到未覆盖的音色时,会在同池内换一个音色播,不会哑掉、也不会播错语义
262
+ - **开发期热重载是实验性功能**(`devReload`,默认关):它依赖 DSH/Cordis 内部结构与 Node 模块加载机制,
263
+ 能工作但不算稳定能力。用户不需要它 —— 那是给改插件源码的人用的
264
+ - **13 个音色的许可结论是条件性判断**(依据官方通用条款),不代表各权利人出具过专项授权
265
+
266
+ ---
267
+
268
+ ## 常见问题
269
+
270
+ **装了没声音?**
271
+ 按顺序查:① 设置页「音源」是不是「不发声」;② 「语音」开关是否关着;
272
+ ③ 「音量」是否拉到 0;④ 播放依赖 Windows PowerShell 5.1,被安全软件拦了会静音。
273
+ 设置页「诊断」区可以**导出脱敏诊断文件**,里面能看出每次播报的决策路径。
274
+
275
+ **会不会打扰我?**
276
+ 默认同会话 60 秒只响一次;「子代理」默认关(子代理结束不单独提醒)。
277
+ 觉得吵可以把「同会话去抖」调大,或直接关掉某类事件。
278
+
279
+ **会上传我的会话内容吗?**
280
+ 不会。通知文案是插件内置的固定句子(可自定义),播放也全在本地。
281
+ 默认日志只记"播了什么、成功与否",**不记台词正文**。
282
+ 唯一联网的地方是下载语音包(访问 GitHub)。
283
+
284
+ **提示"未能获取可下载列表"?**
285
+ 那是 GitHub 访问不通。**已装的音色照常能用**,卸载也能用(卸载不依赖网络)。
286
+ 网络恢复后点「重试」即可。
287
+
288
+ **为什么只有 13 个音色?**
289
+ VOICEVOX 有几十个角色,但每个的条款都要单独读原文核对。
290
+ 我们只收录**条款明确允许本插件这样分发**的,宁可少也不冒险。
291
+ 被排除的例子:有 2 个明确禁止再分发,1 个身份存疑。
292
+
293
+ **能加我喜欢的音色吗?**
294
+ 可以提 Issue。但要走审核流程(读官方条款原文、留档),不是加个下载链接的事。
295
+
296
+ ---
297
+
298
+ ## 开发
299
+
300
+ ### 跑测试
301
+
302
+ ```powershell
303
+ npm test # 主套件(纯 Node,不需要 DSH 宿主)
304
+ npm run test:all # 全部(含 route 注入的两组)
305
+ npm run test:route # 需要 --import loader 的接线测试
306
+ npm run test:audio-wire
307
+ ```
308
+
309
+ **375 条测试通过。** 绝大多数是纯逻辑测试,克隆仓库后 `npm test` 即可跑。
310
+
311
+ 少数用例需要真实的 DSH 宿主(用于验证 Cordis 上下文行为)。
312
+ 找不到宿主时它们会**自动 skip** —— 注意 skip 不等于通过。
313
+
314
+ ```powershell
315
+ $env:DSH_CORDIS_ENTRY = "<DSH 安装目录>\.runtime\node_modules\@deepseek-ai\cordis\lib\index.js"
316
+ npm test
317
+ ```
318
+
319
+ ### 测试分层
320
+
321
+ | 层次 | 内容 |
322
+ |---|---|
323
+ | 单元 | 音源选择、空闲门、配置校验、TTS 文案、http-guard 判定 |
324
+ | 集成 | 真实 HTTP 服务器 + 真实 handler,覆盖请求/响应全链路 |
325
+ | 行为 | 构造真实输入验证行为(如真实 ZIP 字节、真实目录) |
326
+ | 实机 | 真实 Cordis 上下文(需宿主,无则 skip) |
327
+ | 发布 | 构建发布包并断言被禁音色/第三方素材未进包 |
328
+
329
+ ### 为什么有这么多"防复发"测试
330
+
331
+ 这个项目踩过的坑多数是同一类:**代码看着对、测试也绿,但两者一起错**。所以后来改成:
332
+
333
+ - **构造真实输入**而不是检查源码里有没有某个字符串
334
+ (典型:ZIP 解压防护查了不存在的字段 `entry.size`,测试也在断言同一个错字段 → 一起绿)
335
+ - **状态更新全量扫描**而不是逐个手查
336
+ (典型:组件状态漏字段已发生 4 次,现在有 C43 静态扫描锁住)
337
+ - **变异验证**:故意改坏代码,确认测试真的会红
338
+
339
+ ### CI
340
+
341
+ `.github/workflows/ci.yml` 在每次 push / PR 时跑:
342
+
343
+ - 单元测试(Node 20 / 22 / 24)
344
+ - PowerShell 语法与 UTF-8 BOM 检查
345
+ - 关键文件齐全 + npm 打包内容检查(含"被禁音色不得进包")
346
+ - 版本一致性
347
+
348
+ ### 发版
349
+
350
+ ```powershell
351
+ .\tools\release.ps1 -Bump patch -PublishNpm
352
+ ```
353
+
354
+ 流程:升版本 → 跑测试 → 重建发布包 → **在发布包里再跑一次测试**
355
+ (这一步是必要的:开发目录与发布包的文件集不同,只在开发目录跑会"本地绿、线上红")
356
+ → 提交推送 → 可选发 npm。
357
+
358
+ `CHANGELOG.md` 由 `release-build.ps1` **自动生成**(从开发提交提取标题,
359
+ 过滤掉内部资料与 hash),随包发布。**请勿手工编辑**。
360
+
361
+ ---
362
+
363
+ ## 许可
364
+
365
+ 代码以 MIT 发布。
366
+
367
+ **语音文件不适用 MIT**,由 VOICEVOX 生成,各音色有其自身使用条款,
368
+ 详见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。
369
+ 每个音色包内另有该音色的 `TERMS.md`。
package/lib/client.js CHANGED
@@ -1192,7 +1192,7 @@ window.__ModuleLoader__.load({
1192
1192
  } else {
1193
1193
  set(function (prev) {
1194
1194
  return {
1195
- loaded: prev.loaded, values: prev.values, advOpen: prev.advOpen, packs: p.packs,
1195
+ loaded: prev.loaded, values: prev.values, advOpen: prev.advOpen, packs: prev.packs,
1196
1196
  termsAck: prev.termsAck, termsPending: prev.termsPending,
1197
1197
  busy: false, msg: '',
1198
1198
  err: '没有取到文件路径。若点了没反应,可手动在配置里填该事件的 customFile。',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-notify-say",
3
- "version": "0.4.46",
3
+ "version": "0.4.47",
4
4
  "description": "Desktop notifications for DSH Desktop that actually speak: Windows TTS out of the box (zero download), VOICEVOX voice packs optional, a selectable system-sound fallback, and an idle gate that waits for running subagents before saying the turn is done. Includes a settings-page GUI (sound source, per-event overrides, jump-back-to-session, cooldowns) and a self hot-reload for development.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -329,4 +329,60 @@ test('C42: 点遮罩外部可取消,点卡片内部不取消', () => {
329
329
  '应区分"点遮罩"与"点卡片内部" —— 否则点卡片任意位置都会关掉弹窗');
330
330
  assert.match(seg, /cancelTerms\(\)/,
331
331
  '点外部应取消');
332
+ });
333
+ // ── C43: set() 里的状态字段必须来自它自己的参数 ─────────────────────────
334
+ test('C43: 状态字段引用必须用 set() 自己的参数,不得用外层变量', () => {
335
+ // ⚠️ 2026-10-18 真实 bug(外部审阅发现):
336
+ //
337
+ // pickCustomFile().then(function (p) { // p 是文件路径字符串
338
+ // set(function (prev) { // 状态参数叫 prev
339
+ // return { ..., packs: p.packs, ... } // ← 用错的变量!
340
+ // })
341
+ // })
342
+ //
343
+ // `p` 是路径字符串(取消时是空串),`p.packs` 恒为 undefined →
344
+ // 用户取消选择自定义音频后,**已加载的语音包状态被清掉**。
345
+ //
346
+ // 这类"状态字段遗漏/用错变量"已经出现 4 次(advOpen / packs /
347
+ // termsAck / 这次的 p.packs)。所以用静态扫描固定住,而不是靠人记得。
348
+ const lines = client.split(/\r?\n/);
349
+ const FIELDS = ['loaded', 'values', 'advOpen', 'packs', 'termsAck', 'termsPending', 'busy', 'msg', 'err'];
350
+
351
+ // 允许引用非状态参数的对象:它们是**接口响应**,恰好也有同名字段。
352
+ // r —— saveConfig 的返回值(含 values)
353
+ // doc —— fetchVoicePacks 的返回值(含 packs)
354
+ const ALLOW = new Set(['r', 'doc']);
355
+
356
+ const bad = [];
357
+ for (let i = 0; i < lines.length; i++) {
358
+ const m = lines[i].match(/set\(function \((\w+)\) \{/);
359
+ if (!m) continue;
360
+ const param = m[1];
361
+
362
+ // 取该 set 的块
363
+ let depth = 0, started = false, end = i;
364
+ for (let j = i; j < Math.min(i + 30, lines.length); j++) {
365
+ for (const ch of lines[j]) {
366
+ if (ch === '{') { depth++; started = true; }
367
+ else if (ch === '}') { depth--; }
368
+ }
369
+ if (started && depth === 0) { end = j; break; }
370
+ }
371
+ const block = lines.slice(i, end + 1).join('\n');
372
+
373
+ for (const f of FIELDS) {
374
+ const re = new RegExp(`([A-Za-z_$][\\w$]*)\\.${f}\\b`, 'g');
375
+ let mm;
376
+ while ((mm = re.exec(block)) !== null) {
377
+ const ident = mm[1];
378
+ if (ident === param || ALLOW.has(ident)) continue;
379
+ bad.push(`L${i + 1}: 参数=${param} 但用了 ${mm[0]}`);
380
+ }
381
+ }
382
+ }
383
+
384
+ assert.deepEqual(bad, [],
385
+ '这些 set() 引用了不属于它自己状态参数的字段 —— '
386
+ + '典型后果是把已加载的状态清成 undefined(语音包列表消失等)。\n'
387
+ + bad.join('\n'));
332
388
  });
@@ -337,4 +337,103 @@ test('D9: release.ps1 在构建后、推送前,于发布包内跑测试', { sk
337
337
  '应在发布包目录里执行');
338
338
  assert.match(rp, /throw 'release-package tests failed'/,
339
339
  '发布包内测试失败必须中止发版(fail-closed)');
340
+ });
341
+ // ══ README 与实现一致性(v0.4.47)════════════════════════════════════════
342
+ //
343
+ // 2026-10-18 外部审阅指出 README 落后于功能:
344
+ // · 说语音包下载"尚未实现"(其实 13 个 zip 早发布了,GUI 也能下)
345
+ // · 说"全离线"(下载语音包要访问 GitHub)
346
+ // · 说"点 Toast 可跳回会话"(Toast 点击带不了会话 id,跳转是另一套机制)
347
+ // · 说"设置页 12 项"(实际 schema 字段多得多)
348
+ //
349
+ // README 是用户判断"这插件能干什么"的第一入口。写错不是文字瑕疵 ——
350
+ // 别人可能因此根本不知道有下载功能。
351
+
352
+ const README = readFileSync(join(ROOT, 'README.md'), 'utf8');
353
+
354
+ // ── D10: README 不得再声称语音包下载"未实现" ────────────────────────────
355
+ test('D10: README 不得说语音包下载尚未实现', () => {
356
+ assert.doesNotMatch(README, /下载提供(尚未实现)/,
357
+ '语音包下载已实现并发布 13 个 zip,README 不得再说"尚未实现"');
358
+ assert.doesNotMatch(README, /尚未实现/,
359
+ 'README 里不应残留"尚未实现"这类过时表态');
360
+
361
+ // 反而应该真的介绍怎么下载
362
+ assert.match(README, /语音包/,
363
+ 'README 应有语音包章节');
364
+ assert.match(README, /下载/,
365
+ 'README 应说明可以下载');
366
+ });
367
+
368
+ // ── D11: README 不得无条件下声称"全离线" ────────────────────────────────
369
+ test('D11: README 的联网表述必须区分"播放离线"与"下载要联网"', () => {
370
+ // 只写"全离线"会让用户以为下载语音包也不需要网络,
371
+ // 一旦断网失败就以为插件坏了。
372
+ const hasOfflineClaim = /全离线/.test(README);
373
+ if (hasOfflineClaim) {
374
+ assert.match(README, /下载.*联网|联网.*下载/,
375
+ '若说"全离线",必须同时说明下载语音包需要联网');
376
+ }
377
+ // 明确写清楚是更好的做法
378
+ assert.match(README, /不调用云端 TTS|不联网/,
379
+ '应说明播放/通知是离线的');
380
+ });
381
+
382
+ // ── D12: README 不得说"点 Toast 就能跳会话" ─────────────────────────────
383
+ test('D12: README 对跳会话的描述要准确(Toast 点击带不了会话 id)', () => {
384
+ // 事实:Windows Toast 的协议激活是固定串,塞不进会话 id。
385
+ // 真正跳转的机制是"切回窗口时自动跳"。
386
+ assert.doesNotMatch(README, /点击 Toast 可直接回到对应会话/,
387
+ '这是错的说法 —— Toast 点击只能唤起窗口,带不了会话 id');
388
+
389
+ assert.match(README, /切回|切回来/,
390
+ '应说明真正的跳转发生在"切回 DSH 窗口时"');
391
+ assert.match(README, /带不了会话 id/,
392
+ '应明确指出 Toast 点击的限制,否则用户会以为是 bug');
393
+ });
394
+
395
+ // ── D13: README 里的数字必须与实现一致 ──────────────────────────────────
396
+ test('D13: README 的测试条数与实际一致', () => {
397
+ const pkgScripts = PKG.scripts || {};
398
+ assert.ok(pkgScripts['test:all'], '应有 test:all 脚本');
399
+
400
+ // README 写了几条就直接比对;写错了会误导贡献者
401
+ const m = README.match(/\*\*(\d+) 条测试通过/);
402
+ if (!m) {
403
+ assert.match(README, /条测试/, 'README 应说明测试规模');
404
+ return;
405
+ }
406
+ const claimed = Number(m[1]);
407
+ // 允许 +/- 30 的漂移(新增测试后不必立刻同步数字),但差太多说明没维护
408
+ assert.ok(claimed > 300 && claimed < 600,
409
+ `README 声称 ${claimed} 条测试,超出合理范围 —— 数字可能过时`);
410
+ });
411
+
412
+ // ── D14: README 必须提到音色下载的许可告知 ──────────────────────────────
413
+ test('D14: README 说明下载前会展示署名与条款', () => {
414
+ // 这是合规要求:用户得知道"用这些语音做的内容要署名"。
415
+ // README 是下载前的最后一道说明。
416
+ assert.match(README, /署名/,
417
+ 'README 应说明音色的署名要求');
418
+ assert.match(README, /条款/,
419
+ 'README 应说明条款');
420
+ assert.match(README, /TERMS\.md/,
421
+ '应说明包内带 TERMS.md');
422
+ });
423
+ // ── D15: MANIFEST 不得留 (unknown),要留可回溯的线索 ────────────────────
424
+ test('D15: MANIFEST 的版本回溯栏可用(不是 unknown)', { skip: SKIP_DEVONLY }, () => {
425
+ // 2026-10-18 外部审阅指出:公开仓库的 MANIFEST 长期显示
426
+ // `Git 提交: (unknown)` —— 这一栏从来没提供过信息。
427
+ //
428
+ // 原因是它在**构建时**生成,而发布包首次 commit 在构建**之后**。
429
+ // 现在改为如实说明 + 给出真正能查的线索。
430
+ const rb = readFileSync(RELEASE_BUILD, 'utf8');
431
+
432
+ assert.doesNotMatch(rb, /'\(unknown\)'/,
433
+ '不得再写 (unknown) —— 那等于这一栏没信息');
434
+
435
+ assert.match(rb, /源码日期/,
436
+ '应记录源提交日期(判断构建用的是哪天源码)');
437
+ assert.match(rb, /版本回溯/,
438
+ '应指向可查的公开提交 / CHANGELOG');
340
439
  });