dsh-notify-chime 1.4.0 → 1.7.0
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 +22 -7
- package/assets/approval.wav +0 -0
- package/assets/chime/approval.wav +0 -0
- package/assets/chime/goal-blocked.wav +0 -0
- package/assets/chime/job-failed.wav +0 -0
- package/assets/chime/plan-review.wav +0 -0
- package/assets/digital/approval.wav +0 -0
- package/assets/digital/goal-blocked.wav +0 -0
- package/assets/digital/job-failed.wav +0 -0
- package/assets/digital/plan-review.wav +0 -0
- package/assets/goal-blocked.wav +0 -0
- package/assets/job-failed.wav +0 -0
- package/assets/plan-review.wav +0 -0
- package/assets/retro/approval.wav +0 -0
- package/assets/retro/goal-blocked.wav +0 -0
- package/assets/retro/job-failed.wav +0 -0
- package/assets/retro/plan-review.wav +0 -0
- package/assets/soft/approval.wav +0 -0
- package/assets/soft/goal-blocked.wav +0 -0
- package/assets/soft/job-failed.wav +0 -0
- package/assets/soft/plan-review.wav +0 -0
- package/assets/wood/approval.wav +0 -0
- package/assets/wood/goal-blocked.wav +0 -0
- package/assets/wood/job-failed.wav +0 -0
- package/assets/wood/plan-review.wav +0 -0
- package/client.js +45 -9
- package/lib/host.js +262 -46
- package/package.json +2 -2
- package/tools/make_sounds.py +48 -0
package/README.md
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
| 回合正常结束 | `finished` | 上行三音 |
|
|
8
8
|
| 中断(停止、归档、取消) | `interrupted` | 下行两音 |
|
|
9
9
|
| 出错结束 | `error` | 低频双响 |
|
|
10
|
-
| Agent
|
|
10
|
+
| Agent 提问(等回答) | `question` | 三声高音 |
|
|
11
|
+
| 等待审批(`approval/asked`) | `approval` | 上行双音,稍急 |
|
|
12
|
+
| 计划评审(`exit_plan_mode`) | `plan-review` | 三音上行 |
|
|
13
|
+
| 目标受阻(`goal/change` → `phase: blocked`) | `goal-blocked` | 低频下行 |
|
|
14
|
+
| 后台任务失败(`job/settled` → `failed`) | `job-failed` | 短促双音 |
|
|
15
|
+
|
|
16
|
+
计划评审以前混在「提问」里,1.5.0 起单独分类。目标受阻只在「非 blocked → blocked」那一次跃迁发声;后台任务失败按会话 3 秒窗口去抖,多个任务同时失败只响一次。
|
|
11
17
|
|
|
12
18
|
应用退出、会话分叉、被阻断,以及默认被过滤的 subagent 会话不发声。
|
|
13
19
|
|
|
@@ -25,7 +31,7 @@ dsh plugin add dsh-notify-chime
|
|
|
25
31
|
|
|
26
32
|
| 组 | 内容 |
|
|
27
33
|
| --- | --- |
|
|
28
|
-
| 声音 |
|
|
34
|
+
| 声音 | 总开关、音色、八类事件各自开关与试听、正在看的会话完成时不响、同类事件最小间隔、子会话提醒 |
|
|
29
35
|
| 通知 | 通知总开关、哪些事件弹、标题前缀、正文是否带会话名 |
|
|
30
36
|
| 高级 | 设置文件 / 音效 / 自定义 / 轨迹目录(只读)、恢复默认 |
|
|
31
37
|
|
|
@@ -54,7 +60,7 @@ dsh plugin add dsh-notify-chime
|
|
|
54
60
|
|
|
55
61
|
音色为「自定义」的行会出现「上传」按钮:
|
|
56
62
|
|
|
57
|
-
-
|
|
63
|
+
- 支持 **WAV / MP3 / M4A / AAC**,单文件 ≤ 5 MB;WAV 走 `SoundPlayer`,其它走 WPF `MediaPlayer`(借系统解码器)
|
|
58
64
|
- 存 `~/.dsh/dsh-notify-chime-sounds/<kind>.wav`,卸载插件不丢
|
|
59
65
|
- 上传后该行切到「自定义」,其它事件的音色不变;没传的类仍用内置音效
|
|
60
66
|
- 上传即生效,可直接点「试听」确认
|
|
@@ -70,7 +76,15 @@ dsh plugin add dsh-notify-chime
|
|
|
70
76
|
soundset: default
|
|
71
77
|
minIntervalMs: 700
|
|
72
78
|
includeSubagents: false
|
|
73
|
-
kinds:
|
|
79
|
+
kinds:
|
|
80
|
+
finished: true
|
|
81
|
+
interrupted: true
|
|
82
|
+
error: true
|
|
83
|
+
question: true
|
|
84
|
+
approval: true
|
|
85
|
+
plan-review: true
|
|
86
|
+
goal-blocked: true
|
|
87
|
+
job-failed: true
|
|
74
88
|
toast: { enabled: true, kinds: [question], includeSession: true }
|
|
75
89
|
```
|
|
76
90
|
|
|
@@ -80,10 +94,11 @@ dsh plugin add dsh-notify-chime
|
|
|
80
94
|
| `soundset` | `default` | 全局音色:`default` / `soft` / `chime` / `retro` / `digital` / `wood` / `custom` |
|
|
81
95
|
| `minIntervalMs` | `700` | 同类事件最小间隔,防连响 |
|
|
82
96
|
| `includeSubagents` | `false` | 是否也为子会话发声 |
|
|
97
|
+
| `quietCurrent` | `false` | 正在查看的那个会话完成时不发声(只影响完成,审批/提问/出错照响) |
|
|
83
98
|
| `kinds.<kind>` | `true` | 该类事件是否发声 |
|
|
84
99
|
| `sounds.<kind>` | 内置 wav | 单类音效路径;`false` 或 `null` 表示不响 |
|
|
85
|
-
| `player` | `auto` | `auto` / `powershell` / `pwsh` / `none` /
|
|
86
|
-
| `questionTools` | `["ask_user_question"
|
|
100
|
+
| `player` | `auto` | `auto` / `powershell` / `pwsh` / `wpf` / `none` / 播放器路径。`auto` 时 wav 用 `SoundPlayer`、其它格式用 WPF `MediaPlayer` |
|
|
101
|
+
| `questionTools` | `["ask_user_question"]` | 只决定谁触发 `question` 音。`exit_plan_mode` 固定归 `plan-review`,想静音它请关 `kinds.plan-review` |
|
|
87
102
|
| `toast.enabled` | `false` | 是否弹系统通知(本包初值为 `true`) |
|
|
88
103
|
| `toast.kinds` | `null` | 只对哪些 kind 弹,`null` 为全部 |
|
|
89
104
|
| `toast.title` | `DeepSeek Harness` | 通知标题前缀,最长 60 字符 |
|
|
@@ -140,7 +155,7 @@ node tools/smoke-test.mjs --verbose
|
|
|
140
155
|
- 监听会话日志的追加流,冷启动打开历史会话不补响。
|
|
141
156
|
- 每次提醒 spawn 独立子进程播放,不阻塞 Host。PowerShell 冷启动约 200 ms,延迟约 0.3 s。
|
|
142
157
|
- 无音量调节,跟随系统默认播放设备;默认设备是虚拟声卡时人耳可能听不到。
|
|
143
|
-
-
|
|
158
|
+
- 自定义音效支持 WAV / MP3 / M4A / AAC;WMA、FLAC 不支持(系统虽有解码器,但没有接)。
|
|
144
159
|
- 目录:`lib/`(宿主)、`client.js`(设置页)、`assets/`(音效)、`tools/`(脚本)。
|
|
145
160
|
|
|
146
161
|
## 音效来源
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/client.js
CHANGED
|
@@ -32,6 +32,10 @@ window.__ModuleLoader__.load({
|
|
|
32
32
|
{ key: 'interrupted', label: '工作中断', sound: null, soundExists: true },
|
|
33
33
|
{ key: 'error', label: '工作出错', sound: null, soundExists: true },
|
|
34
34
|
{ key: 'question', label: 'Agent 提问', sound: null, soundExists: true },
|
|
35
|
+
{ key: 'approval', label: '等待审批', sound: null, soundExists: true },
|
|
36
|
+
{ key: 'plan-review', label: '计划评审', sound: null, soundExists: true },
|
|
37
|
+
{ key: 'goal-blocked', label: '目标受阻', sound: null, soundExists: true },
|
|
38
|
+
{ key: 'job-failed', label: '后台任务失败', sound: null, soundExists: true },
|
|
35
39
|
]
|
|
36
40
|
|
|
37
41
|
/** 宿主没返回音色套清单时的兜底。 */
|
|
@@ -48,6 +52,7 @@ window.__ModuleLoader__.load({
|
|
|
48
52
|
const EMPTY_SETTINGS = {
|
|
49
53
|
enabled: true,
|
|
50
54
|
soundset: 'default',
|
|
55
|
+
quietCurrent: false,
|
|
51
56
|
perKindSoundset: {},
|
|
52
57
|
customSounds: {},
|
|
53
58
|
kinds: { finished: true, interrupted: true, error: true, question: true },
|
|
@@ -73,10 +78,27 @@ window.__ModuleLoader__.load({
|
|
|
73
78
|
}
|
|
74
79
|
const listeners = new Set()
|
|
75
80
|
const emit = () => { for (const fn of listeners) fn(state) }
|
|
81
|
+
/** apply 时保存的客户端上下文,供 refresh 读取「当前会话」。 */
|
|
82
|
+
let pluginCtx = null
|
|
83
|
+
|
|
84
|
+
/** 读客户端当前打开的会话 id;拿不到就返回 null(该功能温和失效,不影响其它能力)。 */
|
|
85
|
+
function currentSessionId () {
|
|
86
|
+
try {
|
|
87
|
+
const sessions = pluginCtx && typeof pluginCtx.get === 'function' ? pluginCtx.get('sessions') : undefined
|
|
88
|
+
const list = sessions && sessions.list
|
|
89
|
+
const snap = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : undefined
|
|
90
|
+
const id = snap ? snap.current : undefined
|
|
91
|
+
return typeof id === 'string' && id.length > 0 ? id : null
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
76
96
|
|
|
77
97
|
async function refresh () {
|
|
78
98
|
try {
|
|
79
|
-
const
|
|
99
|
+
const current = currentSessionId()
|
|
100
|
+
const url = API + '/state' + (current ? '?current=' + encodeURIComponent(current) : '')
|
|
101
|
+
const r = await fetch(url, { headers: { accept: 'application/json' } })
|
|
80
102
|
if (!r.ok) throw new Error('HTTP ' + r.status)
|
|
81
103
|
const j = await r.json()
|
|
82
104
|
state = { ...state, loading: false, error: null, ...j }
|
|
@@ -152,8 +174,9 @@ window.__ModuleLoader__.load({
|
|
|
152
174
|
sectionHint: { fontSize: '12px', color: C.dim, flex: 1, minWidth: 0 },
|
|
153
175
|
chevron: { fontSize: '11px', color: C.dim, marginLeft: 'auto', flex: 'none' },
|
|
154
176
|
sectionBody: { padding: '12px 14px', display: 'grid', gap: '12px' },
|
|
155
|
-
|
|
156
|
-
|
|
177
|
+
// 行布局自适应:空间不够时控件整体换到下一行,而不是把标题挤成一字一列
|
|
178
|
+
row: { display: 'flex', alignItems: 'flex-start', gap: '12px', flexWrap: 'wrap' },
|
|
179
|
+
rowText: { display: 'grid', gap: '2px', flex: '1 1 200px', minWidth: '180px' },
|
|
157
180
|
rowTitle: { fontSize: '13px', fontWeight: 550 },
|
|
158
181
|
rowDesc: { fontSize: '12px', color: C.dim, lineHeight: 1.5 },
|
|
159
182
|
toggle: (on) => ({
|
|
@@ -165,13 +188,13 @@ window.__ModuleLoader__.load({
|
|
|
165
188
|
position: 'absolute', top: '2px', left: on ? '18px' : '2px', width: '16px', height: '16px',
|
|
166
189
|
borderRadius: '50%', background: '#fff', transition: 'left 160ms ease',
|
|
167
190
|
}),
|
|
168
|
-
inline: { display: 'flex', alignItems: 'center', gap: '8px', flex: '
|
|
191
|
+
inline: { display: 'flex', alignItems: 'center', gap: '8px', flex: '0 1 auto', flexWrap: 'wrap', justifyContent: 'flex-end' },
|
|
169
192
|
input: {
|
|
170
193
|
width: '110px', padding: '5px 8px', fontSize: '13px', textAlign: 'right',
|
|
171
194
|
border: '1px solid ' + C.border, borderRadius: C.radiusSm, background: 'transparent', color: C.text,
|
|
172
195
|
},
|
|
173
196
|
inputWide: {
|
|
174
|
-
flex: 1, minWidth:
|
|
197
|
+
flex: '1 1 160px', minWidth: '160px', padding: '5px 8px', fontSize: '13px',
|
|
175
198
|
border: '1px solid ' + C.border, borderRadius: C.radiusSm, background: 'transparent', color: C.text,
|
|
176
199
|
},
|
|
177
200
|
btn: (disabled) => ({
|
|
@@ -179,7 +202,9 @@ window.__ModuleLoader__.load({
|
|
|
179
202
|
border: '1px solid ' + C.border, background: 'transparent', color: C.text,
|
|
180
203
|
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, flex: 'none',
|
|
181
204
|
}),
|
|
182
|
-
chips: { display: 'flex', gap: '6px', flexWrap: 'wrap',
|
|
205
|
+
chips: { display: 'flex', gap: '6px', flexWrap: 'wrap', minWidth: 0 },
|
|
206
|
+
/** 行内 chips:允许收缩换行,右对齐 */
|
|
207
|
+
chipsRow: { display: 'flex', gap: '6px', flexWrap: 'wrap', flex: '0 1 auto', minWidth: 0, maxWidth: '100%', justifyContent: 'flex-end' },
|
|
183
208
|
fileBtn: {
|
|
184
209
|
padding: '5px 10px', fontSize: '12px', borderRadius: C.radiusSm, border: '1px dashed ' + C.border,
|
|
185
210
|
color: C.dim, cursor: 'pointer', flex: 'none', whiteSpace: 'nowrap',
|
|
@@ -414,7 +439,7 @@ window.__ModuleLoader__.load({
|
|
|
414
439
|
uploading === meta.key ? '上传中' : (customSounds[meta.key] ? '重新上传' : '上传'),
|
|
415
440
|
h('input', {
|
|
416
441
|
type: 'file',
|
|
417
|
-
accept: '.wav
|
|
442
|
+
accept: '.wav,.mp3,.m4a,.aac',
|
|
418
443
|
style: S.fileInput,
|
|
419
444
|
onChange: (e) => upload(e, meta),
|
|
420
445
|
}))
|
|
@@ -430,7 +455,7 @@ window.__ModuleLoader__.load({
|
|
|
430
455
|
))
|
|
431
456
|
|
|
432
457
|
/** 音色套选择:选中即写设置,试听按钮随即用新音色。 */
|
|
433
|
-
const soundsetChips = h('div', { style: S.
|
|
458
|
+
const soundsetChips = h('div', { style: S.chipsRow }, soundsets.map((set) => {
|
|
434
459
|
const on = soundset === set.key
|
|
435
460
|
return h('button', {
|
|
436
461
|
type: 'button',
|
|
@@ -441,7 +466,7 @@ window.__ModuleLoader__.load({
|
|
|
441
466
|
}, set.label)
|
|
442
467
|
}))
|
|
443
468
|
|
|
444
|
-
const toastChips = h('div', { style: S.
|
|
469
|
+
const toastChips = h('div', { style: S.chipsRow }, kindMeta.map((meta) => {
|
|
445
470
|
const on = toastKinds.includes(meta.key)
|
|
446
471
|
return h('button', {
|
|
447
472
|
type: 'button',
|
|
@@ -510,6 +535,16 @@ window.__ModuleLoader__.load({
|
|
|
510
535
|
}, '保存'),
|
|
511
536
|
),
|
|
512
537
|
}),
|
|
538
|
+
rowNode({
|
|
539
|
+
key: 'quietCurrent',
|
|
540
|
+
title: '正在看的会话完成时不响',
|
|
541
|
+
desc: '只对「工作完成」生效;审批、提问、出错照常响。默认关闭。',
|
|
542
|
+
control: toggleNode({
|
|
543
|
+
on: settings.quietCurrent === true,
|
|
544
|
+
label: '正在看的会话完成时不响',
|
|
545
|
+
onChange: (v) => commit({ quietCurrent: v }, v ? '正在看的会话完成时不再响' : '完成时照常响'),
|
|
546
|
+
}),
|
|
547
|
+
}),
|
|
513
548
|
rowNode({
|
|
514
549
|
key: 'subagents',
|
|
515
550
|
title: '子会话也提醒',
|
|
@@ -642,6 +677,7 @@ window.__ModuleLoader__.load({
|
|
|
642
677
|
const inject = ['slots']
|
|
643
678
|
|
|
644
679
|
function apply (ctx) {
|
|
680
|
+
pluginCtx = ctx
|
|
645
681
|
ctx.effect(() => {
|
|
646
682
|
refresh()
|
|
647
683
|
const timer = setInterval(() => { if (!document.hidden) refresh() }, POLL_MS)
|
package/lib/host.js
CHANGED
|
@@ -22,14 +22,14 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { spawn } from 'node:child_process'
|
|
25
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
25
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
26
26
|
import { homedir } from 'node:os'
|
|
27
|
-
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
27
|
+
import { dirname, extname, isAbsolute, join, resolve } from 'node:path'
|
|
28
28
|
import { fileURLToPath } from 'node:url'
|
|
29
29
|
|
|
30
30
|
/** 设置页写入的用户设置文件名(放在 DSH 主目录)。 */
|
|
31
31
|
const SETTINGS_FILENAME = 'dsh-notify-chime.json'
|
|
32
|
-
const VERSION = '1.
|
|
32
|
+
const VERSION = '1.7.0'
|
|
33
33
|
/** 去抖间隔上限:填成天文数字会让提示音事实上永久失效,这里兜住。 */
|
|
34
34
|
const MAX_MIN_INTERVAL_MS = 600000
|
|
35
35
|
/** 通知标题前缀的长度上限(再长会被系统截断,也挤掉事件名)。 */
|
|
@@ -50,6 +50,10 @@ const SOUNDSETS = [
|
|
|
50
50
|
const SOUNDSET_KEYS = SOUNDSETS.map((set) => set.key)
|
|
51
51
|
/** 自定义音效上传上限:5 MB,足够十几秒无损音频。 */
|
|
52
52
|
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024
|
|
53
|
+
/** 后台任务失败按会话去抖:一轮里多个任务同时失败只响一次。 */
|
|
54
|
+
const JOB_FAIL_WINDOW_MS = 3000
|
|
55
|
+
/** 客户端上报「当前正在查看的会话」后,多久内有效;过期视为没人在看。 */
|
|
56
|
+
const QUIET_CURRENT_MAX_AGE_MS = 15000
|
|
53
57
|
|
|
54
58
|
/** DSH 主目录:profile 启动时带 DSH_HOME,裸进程回落到 ~/.dsh。 */
|
|
55
59
|
function dshHome () {
|
|
@@ -90,6 +94,10 @@ const KINDS = {
|
|
|
90
94
|
interrupted: { asset: 'interrupted.wav', label: '工作中断', body: '本轮工作已被中断' },
|
|
91
95
|
error: { asset: 'error.wav', label: '工作出错', body: '本轮工作因错误结束' },
|
|
92
96
|
question: { asset: 'question.wav', label: 'Agent 提问', body: 'Agent 正在等待你的回答' },
|
|
97
|
+
approval: { asset: 'approval.wav', label: '等待审批', body: '有操作正在等你批准' },
|
|
98
|
+
'plan-review': { asset: 'plan-review.wav', label: '计划评审', body: 'Agent 提交了计划,等你评审' },
|
|
99
|
+
'goal-blocked': { asset: 'goal-blocked.wav', label: '目标受阻', body: '当前目标被阻塞,需要你介入' },
|
|
100
|
+
'job-failed': { asset: 'job-failed.wav', label: '后台任务失败', body: '一个后台任务执行失败' },
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
/** 默认配置(config 中未给出的字段取这里的值)。 */
|
|
@@ -99,14 +107,25 @@ const DEFAULTS = {
|
|
|
99
107
|
minIntervalMs: 700,
|
|
100
108
|
/** 是否也为 subagent / 子会话发声。 */
|
|
101
109
|
includeSubagents: false,
|
|
102
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* 音色套:default | soft | chime | retro | digital | wood | custom。
|
|
112
|
+
*/
|
|
103
113
|
soundset: 'default',
|
|
114
|
+
/**
|
|
115
|
+
* 「当前正在查看的会话完成时不响」:默认关闭。
|
|
116
|
+
* 只影响 finished,审批 / 提问 / 出错等一律照响。
|
|
117
|
+
*/
|
|
118
|
+
quietCurrent: false,
|
|
104
119
|
/** 每类事件是否发声(设置页可逐类关掉,不必整类静音)。 */
|
|
105
120
|
kinds: {
|
|
106
121
|
finished: true,
|
|
107
122
|
interrupted: true,
|
|
108
123
|
error: true,
|
|
109
124
|
question: true,
|
|
125
|
+
approval: true,
|
|
126
|
+
'plan-review': true,
|
|
127
|
+
'goal-blocked': true,
|
|
128
|
+
'job-failed': true,
|
|
110
129
|
},
|
|
111
130
|
/** 声音播放器:'auto' | 'powershell' | 'pwsh' | 'none',或播放器可执行文件的绝对路径。 */
|
|
112
131
|
player: 'auto',
|
|
@@ -130,8 +149,11 @@ const DEFAULTS = {
|
|
|
130
149
|
},
|
|
131
150
|
/** 每个事件种类的声音文件;null / false 表示该类事件不发声。 */
|
|
132
151
|
sounds: Object.fromEntries(Object.entries(KINDS).map(([key, meta]) => [key, join(ASSET_DIR, meta.asset)])),
|
|
133
|
-
/**
|
|
134
|
-
|
|
152
|
+
/**
|
|
153
|
+
* 视为「提问」的工具名,只决定谁触发 question 音。
|
|
154
|
+
* exit_plan_mode 有专属的 plan-review 分类(1.5.0 起),不再受此表控制。
|
|
155
|
+
*/
|
|
156
|
+
questionTools: ['ask_user_question'],
|
|
135
157
|
/** 调试用:把每次触发追加写入该文件(每行一条 JSON)。默认关闭。 */
|
|
136
158
|
traceFile: null,
|
|
137
159
|
}
|
|
@@ -151,6 +173,7 @@ function settingsFromOptions(options) {
|
|
|
151
173
|
return {
|
|
152
174
|
enabled: options.enabled,
|
|
153
175
|
soundset: options.soundset,
|
|
176
|
+
quietCurrent: Boolean(options.quietCurrent),
|
|
154
177
|
perKindSoundset: { ...DEFAULT_PER_KIND_SOUNDSET },
|
|
155
178
|
customSounds: { ...DEFAULT_CUSTOM_SOUNDS },
|
|
156
179
|
kinds: { ...options.kinds },
|
|
@@ -174,6 +197,7 @@ function normalizeSettings(raw, fallback) {
|
|
|
174
197
|
const out = {
|
|
175
198
|
enabled: fallback.enabled,
|
|
176
199
|
soundset: fallback.soundset,
|
|
200
|
+
quietCurrent: fallback.quietCurrent,
|
|
177
201
|
perKindSoundset: { ...fallback.perKindSoundset },
|
|
178
202
|
customSounds: { ...fallback.customSounds },
|
|
179
203
|
kinds: { ...fallback.kinds },
|
|
@@ -193,6 +217,7 @@ function normalizeSettings(raw, fallback) {
|
|
|
193
217
|
}
|
|
194
218
|
}
|
|
195
219
|
if (typeof raw.soundset === 'string' && SOUNDSET_KEYS.includes(raw.soundset)) out.soundset = raw.soundset
|
|
220
|
+
if (typeof raw.quietCurrent === 'boolean') out.quietCurrent = raw.quietCurrent
|
|
196
221
|
if (raw.perKindSoundset !== null && typeof raw.perKindSoundset === 'object' && !Array.isArray(raw.perKindSoundset)) {
|
|
197
222
|
for (const key of KIND_KEYS) {
|
|
198
223
|
const value = raw.perKindSoundset[key]
|
|
@@ -232,13 +257,14 @@ function applySettingsPatch(current, patch, fallback) {
|
|
|
232
257
|
}
|
|
233
258
|
if (patch.reset === true) return normalizeSettings({}, fallback)
|
|
234
259
|
|
|
235
|
-
const known = new Set(['enabled', 'soundset', 'perKindSoundset', 'customSounds', 'kinds', 'minIntervalMs', 'includeSubagents', 'toast', 'reset'])
|
|
260
|
+
const known = new Set(['enabled', 'soundset', 'quietCurrent', 'perKindSoundset', 'customSounds', 'kinds', 'minIntervalMs', 'includeSubagents', 'toast', 'reset'])
|
|
236
261
|
for (const key of Object.keys(patch)) {
|
|
237
262
|
if (!known.has(key)) throw new Error(`未知设置项 "${key}"`)
|
|
238
263
|
}
|
|
239
264
|
const next = {
|
|
240
265
|
enabled: current.enabled,
|
|
241
266
|
soundset: current.soundset,
|
|
267
|
+
quietCurrent: current.quietCurrent,
|
|
242
268
|
perKindSoundset: { ...current.perKindSoundset },
|
|
243
269
|
customSounds: { ...current.customSounds },
|
|
244
270
|
kinds: { ...current.kinds },
|
|
@@ -252,6 +278,10 @@ function applySettingsPatch(current, patch, fallback) {
|
|
|
252
278
|
}
|
|
253
279
|
next.soundset = patch.soundset
|
|
254
280
|
}
|
|
281
|
+
if (patch.quietCurrent !== undefined) {
|
|
282
|
+
if (typeof patch.quietCurrent !== 'boolean') throw new TypeError('quietCurrent 必须是布尔值')
|
|
283
|
+
next.quietCurrent = patch.quietCurrent
|
|
284
|
+
}
|
|
255
285
|
if (patch.perKindSoundset !== undefined) {
|
|
256
286
|
if (patch.perKindSoundset === null || typeof patch.perKindSoundset !== 'object' || Array.isArray(patch.perKindSoundset)) {
|
|
257
287
|
throw new TypeError('perKindSoundset 必须是对象')
|
|
@@ -399,6 +429,23 @@ function resolveSoundFile(value) {
|
|
|
399
429
|
return isAbsolute(text) ? text : resolve(HERE, '..', text)
|
|
400
430
|
}
|
|
401
431
|
|
|
432
|
+
/**
|
|
433
|
+
* 按文件头判断音频格式;不认识返回 null。
|
|
434
|
+
* 只认四种:WAV(RIFF/WAVE)、MP3(ID3 或 MPEG 帧同步)、M4A(ftyp)、AAC(ADTS 帧同步)。
|
|
435
|
+
*/
|
|
436
|
+
function detectAudioFormat(buffer) {
|
|
437
|
+
if (buffer.length < 12) return null
|
|
438
|
+
const at = (start, end) => buffer.toString('ascii', start, end)
|
|
439
|
+
if (at(0, 4) === 'RIFF' && at(8, 12) === 'WAVE') return 'wav'
|
|
440
|
+
if (at(0, 3) === 'ID3') return 'mp3'
|
|
441
|
+
if (at(4, 8) === 'ftyp') return 'm4a'
|
|
442
|
+
if (buffer[0] === 0xFF && (buffer[1] & 0xE0) === 0xE0) {
|
|
443
|
+
// MPEG 音频帧:layer 位(byte1 的 2–1 位)为 0 是 ADTS/AAC,否则是 MP3
|
|
444
|
+
return (buffer[1] & 0x06) === 0 ? 'aac' : 'mp3'
|
|
445
|
+
}
|
|
446
|
+
return null
|
|
447
|
+
}
|
|
448
|
+
|
|
402
449
|
/** PowerShell 单引号字符串转义。 */
|
|
403
450
|
function quotePowerShell(text) {
|
|
404
451
|
return `'${text.replace(/'/g, "''")}'`
|
|
@@ -417,14 +464,50 @@ function powerShellArgs(script) {
|
|
|
417
464
|
return ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShellCommand(script)]
|
|
418
465
|
}
|
|
419
466
|
|
|
420
|
-
/**
|
|
421
|
-
function
|
|
467
|
+
/** WPF MediaPlayer 需要 STA 线程,单独一组参数。 */
|
|
468
|
+
function powerShellArgsSta(script) {
|
|
469
|
+
return ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encodePowerShellCommand(script)]
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* MP3 / M4A 等非 WAV 格式的播放脚本:WPF MediaPlayer 走系统解码器。
|
|
474
|
+
* 播完才退出进程,避免提示音被截断;时长未知时最多等 5 秒,防止卡死。
|
|
475
|
+
*/
|
|
476
|
+
function mediaPlayerScript(file) {
|
|
477
|
+
return [
|
|
478
|
+
"$ErrorActionPreference='Stop'",
|
|
479
|
+
'Add-Type -AssemblyName PresentationCore',
|
|
480
|
+
'$player = New-Object System.Windows.Media.MediaPlayer',
|
|
481
|
+
`$player.Open([Uri]::new(${quotePowerShell(file)}))`,
|
|
482
|
+
'for ($i = 0; $i -lt 40 -and -not $player.NaturalDuration.HasTimeSpan; $i++) { Start-Sleep -Milliseconds 50 }',
|
|
483
|
+
'$duration = 0; if ($player.NaturalDuration.HasTimeSpan) { $duration = $player.NaturalDuration.TimeSpan.TotalSeconds }',
|
|
484
|
+
'$player.Play()',
|
|
485
|
+
'$limit = if ($duration -gt 0) { [math]::Min(600, [math]::Ceiling($duration * 10) + 20) } else { 50 }',
|
|
486
|
+
'for ($i = 0; $i -lt $limit; $i++) { Start-Sleep -Milliseconds 100; if ($duration -gt 0 -and $player.Position.TotalSeconds -ge $duration) { break } }',
|
|
487
|
+
'$player.Close()',
|
|
488
|
+
].join(';')
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* 解析播放器;返回 { command, args(file) } 或 null。
|
|
493
|
+
*
|
|
494
|
+
* ext 决定 Windows 上走哪条路:WAV → SoundPlayer(延迟低),其它 → WPF MediaPlayer
|
|
495
|
+
* (借系统解码器播 mp3 / m4a,需要 STA)。
|
|
496
|
+
*/
|
|
497
|
+
function resolvePlayer(preference, ext) {
|
|
422
498
|
if (preference === 'none') return null
|
|
423
499
|
const platform = process.platform
|
|
500
|
+
const isWav = ext === '' || ext === '.wav'
|
|
424
501
|
if (platform === 'win32') {
|
|
502
|
+
const wantsPowerShell = preference === 'auto' || preference === 'powershell' || preference === 'pwsh'
|
|
503
|
+
// 非 WAV 一律走 MediaPlayer:SoundPlayer 只认 WAV。
|
|
504
|
+
// MediaPlayer 需要 STA,只有 Windows PowerShell 有 -STA(pwsh 7 没有),所以这里固定用 powershell.exe。
|
|
505
|
+
if (preference === 'wpf' || (wantsPowerShell && !isWav)) {
|
|
506
|
+
return { command: 'powershell.exe', args: (file) => powerShellArgsSta(mediaPlayerScript(file)) }
|
|
507
|
+
}
|
|
425
508
|
// auto / powershell / pwsh 走 PowerShell 脚本(-EncodedCommand 免引号转义);
|
|
426
509
|
// 其它值当播放器可执行文件,直接把文件路径作为唯一参数传过去。
|
|
427
|
-
if (
|
|
510
|
+
if (wantsPowerShell) {
|
|
428
511
|
const command = preference === 'pwsh' ? 'pwsh.exe' : 'powershell.exe'
|
|
429
512
|
return {
|
|
430
513
|
command,
|
|
@@ -438,7 +521,7 @@ function resolvePlayer(preference) {
|
|
|
438
521
|
if (platform === 'darwin') {
|
|
439
522
|
return { command: preference === 'auto' ? 'afplay' : preference, args: (file) => [file] }
|
|
440
523
|
}
|
|
441
|
-
// Linux: 依次尝试 paplay / aplay
|
|
524
|
+
// Linux: 依次尝试 paplay / aplay(两者都支持多种格式)
|
|
442
525
|
const command = preference === 'auto' ? (existsSync('/usr/bin/paplay') ? 'paplay' : 'aplay') : preference
|
|
443
526
|
return { command, args: (file) => [file] }
|
|
444
527
|
}
|
|
@@ -483,10 +566,15 @@ export function apply(ctx, rawConfig) {
|
|
|
483
566
|
const options = resolveOptions(rawConfig)
|
|
484
567
|
const logger = ctx.logger('notify-sound')
|
|
485
568
|
|
|
486
|
-
|
|
487
|
-
if (player === null) logger.info('sound player disabled (player: none)')
|
|
569
|
+
if (options.player === 'none') logger.info('sound player disabled (player: none)')
|
|
488
570
|
const questionTools = new Set(options.questionTools)
|
|
489
571
|
const lastFired = new Map()
|
|
572
|
+
/** 目标阶段的上一状态:只有「非 blocked → blocked」才响一次。 */
|
|
573
|
+
const goalPhase = new Map()
|
|
574
|
+
/** 后台任务失败按会话去抖。 */
|
|
575
|
+
const lastJobFail = new Map()
|
|
576
|
+
/** 客户端上报的「当前正在查看的会话」:{ id, at };过期视为没人在看。 */
|
|
577
|
+
let currentView = { id: null, at: 0 }
|
|
490
578
|
const tracePath = options.traceFile === null ? null : resolveSoundFile(options.traceFile)
|
|
491
579
|
|
|
492
580
|
// patch 值就是初始默认值;设置页写入的值覆盖它(存在 ~/.dsh/dsh-notify-chime.json)
|
|
@@ -500,27 +588,32 @@ export function apply(ctx, rawConfig) {
|
|
|
500
588
|
return own || settings.soundset || 'default'
|
|
501
589
|
}
|
|
502
590
|
|
|
503
|
-
/** 按当前音色套解析某类事件的音效文件;null
|
|
591
|
+
/** 按当前音色套解析某类事件的音效文件;null 表示这类事件不发声。任何异常都退化为"不响"。 */
|
|
504
592
|
const soundFileFor = (kind) => {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (
|
|
513
|
-
|
|
593
|
+
try {
|
|
594
|
+
const configured = options.sounds[kind]
|
|
595
|
+
// patch 显式把该类设成 false / null:整类静音,任何音色套都不覆盖
|
|
596
|
+
if (configured === null || configured === false) return null
|
|
597
|
+
const set = effectiveSoundset(kind)
|
|
598
|
+
if (set === 'custom') {
|
|
599
|
+
const own = settings.customSounds ? settings.customSounds[kind] : null
|
|
600
|
+
if (typeof own === 'string' && own.length > 0) {
|
|
601
|
+
if (existsSync(own)) return own
|
|
602
|
+
logger.warn(`custom sound missing: ${own}`)
|
|
603
|
+
}
|
|
604
|
+
// 这类没上传、或文件被移走:回退内置默认,不静音
|
|
605
|
+
return resolveSoundFile(configured)
|
|
606
|
+
}
|
|
607
|
+
if (set && set !== 'default') {
|
|
608
|
+
const preset = join(ASSET_DIR, set, `${kind}.wav`)
|
|
609
|
+
if (existsSync(preset)) return preset
|
|
610
|
+
logger.warn(`sound set "${set}" has no ${kind}.wav, falling back to default`)
|
|
514
611
|
}
|
|
515
|
-
// 这类没上传、或文件被移走:回退内置默认,不静音
|
|
516
612
|
return resolveSoundFile(configured)
|
|
613
|
+
} catch (error) {
|
|
614
|
+
logger.warn(`sound resolve failed (${kind}): ${error instanceof Error ? error.message : String(error)}`)
|
|
615
|
+
return null
|
|
517
616
|
}
|
|
518
|
-
if (set && set !== 'default') {
|
|
519
|
-
const preset = join(ASSET_DIR, set, `${kind}.wav`)
|
|
520
|
-
if (existsSync(preset)) return preset
|
|
521
|
-
logger.warn(`sound set "${set}" has no ${kind}.wav, falling back to default`)
|
|
522
|
-
}
|
|
523
|
-
return resolveSoundFile(configured)
|
|
524
617
|
}
|
|
525
618
|
|
|
526
619
|
/** 追加一行触发记录(仅在配置了 traceFile 时)。 */
|
|
@@ -535,13 +628,16 @@ export function apply(ctx, rawConfig) {
|
|
|
535
628
|
|
|
536
629
|
/** 播放一个音频文件(不阻塞宿主);返回实际播放的路径或 null。 */
|
|
537
630
|
const playFile = (file) => {
|
|
538
|
-
if (file === null
|
|
539
|
-
if (!existsSync(file)) {
|
|
540
|
-
logger.warn(`sound file not found: ${file}`)
|
|
541
|
-
return null
|
|
542
|
-
}
|
|
631
|
+
if (file === null) return null
|
|
543
632
|
try {
|
|
544
|
-
|
|
633
|
+
if (!existsSync(file)) {
|
|
634
|
+
logger.warn(`sound file not found: ${file}`)
|
|
635
|
+
return null
|
|
636
|
+
}
|
|
637
|
+
// 按扩展名选播放器:.wav 走 SoundPlayer,mp3 / m4a 走 WPF MediaPlayer
|
|
638
|
+
const chosen = resolvePlayer(options.player, extname(file).toLowerCase())
|
|
639
|
+
if (chosen === null) return null
|
|
640
|
+
const child = spawn(chosen.command, chosen.args(file), { windowsHide: true, stdio: 'ignore' })
|
|
545
641
|
child.on('error', (error) => logger.warn(`sound player failed: ${error.message}`))
|
|
546
642
|
child.unref()
|
|
547
643
|
return file
|
|
@@ -563,6 +659,16 @@ export function apply(ctx, rawConfig) {
|
|
|
563
659
|
trace({ time: new Date(now).toISOString(), kind, skipped: skipReason, ...detail })
|
|
564
660
|
return
|
|
565
661
|
}
|
|
662
|
+
// 「当前正在查看的会话完成时不响」:只对 finished 生效,且要求客户端刚上报过
|
|
663
|
+
const viewingThis = settings.quietCurrent
|
|
664
|
+
&& kind === 'finished'
|
|
665
|
+
&& currentView.id !== null
|
|
666
|
+
&& currentView.id === detail?.sessionId
|
|
667
|
+
&& now - currentView.at < QUIET_CURRENT_MAX_AGE_MS
|
|
668
|
+
if (viewingThis) {
|
|
669
|
+
trace({ time: new Date(now).toISOString(), kind, skipped: 'quiet-current', ...detail })
|
|
670
|
+
return
|
|
671
|
+
}
|
|
566
672
|
const previous = lastFired.get(kind) ?? 0
|
|
567
673
|
if (now - previous < settings.minIntervalMs) {
|
|
568
674
|
trace({ time: new Date(now).toISOString(), kind, skipped: 'debounce', ...detail })
|
|
@@ -623,7 +729,13 @@ export function apply(ctx, rawConfig) {
|
|
|
623
729
|
return undefined
|
|
624
730
|
}
|
|
625
731
|
|
|
626
|
-
|
|
732
|
+
/**
|
|
733
|
+
* 处理一条会话事件。
|
|
734
|
+
*
|
|
735
|
+
* 这里所有字段访问都可能因 DSH 版本变化而抛错(getter、缺字段、结构变更),
|
|
736
|
+
* 所以调用方用 try/catch 收口——插件最坏表现是少响一次,绝不能把异常抛回宿主事件循环。
|
|
737
|
+
*/
|
|
738
|
+
const handleSessionEvent = (session, event) => {
|
|
627
739
|
if (!settings.includeSubagents && (session?.header?.origin === 'subagent' || session?.header?.parentSession !== undefined)) return
|
|
628
740
|
if (event === null || typeof event !== 'object') return
|
|
629
741
|
|
|
@@ -634,13 +746,88 @@ export function apply(ctx, rawConfig) {
|
|
|
634
746
|
return
|
|
635
747
|
}
|
|
636
748
|
|
|
637
|
-
if (event.type === 'tool/call'
|
|
638
|
-
|
|
749
|
+
if (event.type === 'tool/call') {
|
|
750
|
+
const name = event.data?.name
|
|
751
|
+
if (typeof name !== 'string') return
|
|
752
|
+
// 计划评审是 exit_plan_mode 的专用语义,单独发声
|
|
753
|
+
if (name === 'exit_plan_mode') {
|
|
754
|
+
fire('plan-review', { sessionId: session?.id, title: sessionTitle(session), tool: name })
|
|
755
|
+
return
|
|
756
|
+
}
|
|
757
|
+
if (questionTools.has(name)) {
|
|
758
|
+
fire('question', {
|
|
759
|
+
sessionId: session?.id,
|
|
760
|
+
title: sessionTitle(session),
|
|
761
|
+
tool: name,
|
|
762
|
+
body: `Agent 正在等待你的回答(${name})`,
|
|
763
|
+
})
|
|
764
|
+
}
|
|
765
|
+
return
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// 审批请求:DSH 0.2.2 起是独立会话事件(见 known-event-types)
|
|
769
|
+
if (event.type === 'approval/asked') {
|
|
770
|
+
const data = event.data
|
|
771
|
+
if (data === null || typeof data !== 'object') return
|
|
772
|
+
const toolName = typeof data.toolName === 'string' ? data.toolName : ''
|
|
773
|
+
fire('approval', {
|
|
639
774
|
sessionId: session?.id,
|
|
640
775
|
title: sessionTitle(session),
|
|
641
|
-
tool:
|
|
642
|
-
body:
|
|
776
|
+
tool: toolName || undefined,
|
|
777
|
+
body: toolName ? `等待你批准:${toolName}` : undefined,
|
|
643
778
|
})
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// 目标受阻:只在「非 blocked → blocked」那一次跃迁发声
|
|
783
|
+
if (event.type === 'goal/change') {
|
|
784
|
+
const data = event.data
|
|
785
|
+
const goal = data !== null && typeof data === 'object' ? data.goal : undefined
|
|
786
|
+
const phase = goal !== null && typeof goal === 'object' && typeof goal.phase === 'string' ? goal.phase : undefined
|
|
787
|
+
const key = session?.id ?? '__none__'
|
|
788
|
+
if (phase === undefined) {
|
|
789
|
+
// clear 等没有 goal 的变更要清掉状态,否则同一会话之后新建目标再受阻会被误判成重复跃迁
|
|
790
|
+
if (data !== null && typeof data === 'object' && data.operation === 'clear') goalPhase.delete(key)
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
const previous = goalPhase.get(key)
|
|
794
|
+
goalPhase.set(key, phase)
|
|
795
|
+
if (phase === 'blocked' && previous !== 'blocked') {
|
|
796
|
+
fire('goal-blocked', {
|
|
797
|
+
sessionId: session?.id,
|
|
798
|
+
title: sessionTitle(session),
|
|
799
|
+
body: typeof goal.objective === 'string' && goal.objective ? `目标受阻:${goal.objective}` : undefined,
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
return
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 后台任务失败:正向断言只认 status === 'failed',并按会话去重
|
|
806
|
+
if (event.type === 'job/settled') {
|
|
807
|
+
const data = event.data
|
|
808
|
+
if (data === null || typeof data !== 'object') return
|
|
809
|
+
if (data.status !== 'failed') return
|
|
810
|
+
const key = session?.id ?? '__none__'
|
|
811
|
+
const now = Date.now()
|
|
812
|
+
const previous = lastJobFail.get(key) ?? 0
|
|
813
|
+
if (now - previous < JOB_FAIL_WINDOW_MS) {
|
|
814
|
+
trace({ time: new Date(now).toISOString(), kind: 'job-failed', skipped: 'job-window', sessionId: session?.id })
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
lastJobFail.set(key, now)
|
|
818
|
+
fire('job-failed', {
|
|
819
|
+
sessionId: session?.id,
|
|
820
|
+
title: sessionTitle(session),
|
|
821
|
+
body: typeof data.detail === 'string' && data.detail ? `后台任务失败:${data.detail}` : undefined,
|
|
822
|
+
})
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
ctx.on('session/event', (session, event) => {
|
|
827
|
+
try {
|
|
828
|
+
handleSessionEvent(session, event)
|
|
829
|
+
} catch (error) {
|
|
830
|
+
logger.warn(`session/event handler failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
644
831
|
}
|
|
645
832
|
})
|
|
646
833
|
|
|
@@ -688,6 +875,19 @@ export function apply(ctx, rawConfig) {
|
|
|
688
875
|
res.end(JSON.stringify(payload))
|
|
689
876
|
}
|
|
690
877
|
|
|
878
|
+
/** 路由兜底:未捕获异常转成 500 JSON,既不让请求挂起,也不冒泡到宿主。 */
|
|
879
|
+
const guard = (handler) => async (req, res) => {
|
|
880
|
+
try {
|
|
881
|
+
await handler(req, res)
|
|
882
|
+
} catch (error) {
|
|
883
|
+
logger.warn(`route failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
884
|
+
try {
|
|
885
|
+
if (res.headersSent) res.end()
|
|
886
|
+
else json(res, 500, { ok: false, error: '插件内部错误' })
|
|
887
|
+
} catch { /* 连接已断,忽略 */ }
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
691
891
|
/** 读 JSON 请求体;超过 16 KB 视为异常请求。出错时已自行响应,返回 null。 */
|
|
692
892
|
const readJson = async (req, res) => {
|
|
693
893
|
let body = ''
|
|
@@ -706,6 +906,13 @@ export function apply(ctx, rawConfig) {
|
|
|
706
906
|
}
|
|
707
907
|
}
|
|
708
908
|
|
|
909
|
+
/** 从 req.url 里取查询串;没有 url(mock / 非常规请求)时返回空表。 */
|
|
910
|
+
const queryOf = (url) => {
|
|
911
|
+
const text = typeof url === 'string' ? url : ''
|
|
912
|
+
const at = text.indexOf('?')
|
|
913
|
+
return new URLSearchParams(at < 0 ? '' : text.slice(at + 1))
|
|
914
|
+
}
|
|
915
|
+
|
|
709
916
|
const routes = [
|
|
710
917
|
{
|
|
711
918
|
kind: 'exact',
|
|
@@ -716,6 +923,9 @@ export function apply(ctx, rawConfig) {
|
|
|
716
923
|
return
|
|
717
924
|
}
|
|
718
925
|
if (req.method === 'HEAD') { res.writeHead(200); res.end(); return }
|
|
926
|
+
// 客户端每次轮询带上「当前正在查看的会话」,供 quietCurrent 判断
|
|
927
|
+
const current = queryOf(req.url).get('current')
|
|
928
|
+
if (current) currentView = { id: current, at: Date.now() }
|
|
719
929
|
json(res, 200, statePayload())
|
|
720
930
|
},
|
|
721
931
|
},
|
|
@@ -763,7 +973,7 @@ export function apply(ctx, rawConfig) {
|
|
|
763
973
|
json(res, 200, { ok: false, kind, error: '这类事件已配置为不发声(patch 的 sounds 设成 false / null)' })
|
|
764
974
|
return
|
|
765
975
|
}
|
|
766
|
-
if (player ===
|
|
976
|
+
if (options.player === 'none') {
|
|
767
977
|
json(res, 200, { ok: false, kind, sound: file, error: '播放器被禁用(patch 的 player: none)' })
|
|
768
978
|
return
|
|
769
979
|
}
|
|
@@ -803,15 +1013,21 @@ export function apply(ctx, rawConfig) {
|
|
|
803
1013
|
chunks.push(chunk)
|
|
804
1014
|
}
|
|
805
1015
|
const buffer = Buffer.concat(chunks)
|
|
806
|
-
|
|
807
|
-
|
|
1016
|
+
const format = detectAudioFormat(buffer)
|
|
1017
|
+
if (format === null) {
|
|
1018
|
+
json(res, 400, { ok: false, error: '无法识别的音频格式,支持 WAV / MP3 / M4A / AAC' })
|
|
808
1019
|
return
|
|
809
1020
|
}
|
|
810
1021
|
const dir = soundsDir()
|
|
811
|
-
const file = join(dir, `${kind}
|
|
1022
|
+
const file = join(dir, `${kind}.${format}`)
|
|
812
1023
|
try {
|
|
813
1024
|
mkdirSync(dir, { recursive: true })
|
|
814
1025
|
writeFileSync(file, buffer)
|
|
1026
|
+
// 换格式时清掉同类旧文件,避免同一声效留两份
|
|
1027
|
+
for (const stale of ['wav', 'mp3', 'm4a', 'aac']) {
|
|
1028
|
+
if (stale === format) continue
|
|
1029
|
+
try { unlinkSync(join(dir, `${kind}.${stale}`)) } catch { /* 不存在就忽略 */ }
|
|
1030
|
+
}
|
|
815
1031
|
} catch (error) {
|
|
816
1032
|
json(res, 500, { ok: false, error: `写入失败:${error instanceof Error ? error.message : String(error)}` })
|
|
817
1033
|
return
|
|
@@ -840,7 +1056,7 @@ export function apply(ctx, rawConfig) {
|
|
|
840
1056
|
]
|
|
841
1057
|
const disposers = routes.map((route) => {
|
|
842
1058
|
try {
|
|
843
|
-
return wctx.webServer.register(route)
|
|
1059
|
+
return wctx.webServer.register({ ...route, handler: guard(route.handler) })
|
|
844
1060
|
} catch (error) {
|
|
845
1061
|
// 单条路由注册失败不该连累事件监听与其它路由
|
|
846
1062
|
logger.warn(`route register failed: ${route.path}: ${error instanceof Error ? error.message : String(error)}`)
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-notify-chime",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "回合完成、中断、出错、审批或提问时发声,可另弹系统通知;六套音色,自定义音效支持 WAV / MP3 / M4A。",
|
|
6
6
|
"main": "lib/host.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./lib/host.js",
|
package/tools/make_sounds.py
CHANGED
|
@@ -115,6 +115,14 @@ SETS = {
|
|
|
115
115
|
length=0.78, gain=0.40, decay=6.5),
|
|
116
116
|
"question": dict(notes=[(93, 0.00, 0.20, 0.90), (93, 0.20, 0.20, 0.95), (100, 0.40, 0.42, 1.00)],
|
|
117
117
|
length=0.85, gain=0.34, decay=7.5),
|
|
118
|
+
"approval": dict(notes=[(81, 0.00, 0.20, 0.90), (88, 0.14, 0.50, 1.00)],
|
|
119
|
+
length=0.75, gain=0.40, decay=6.0),
|
|
120
|
+
"plan-review": dict(notes=[(84, 0.00, 0.24, 0.90), (88, 0.14, 0.24, 0.95), (91, 0.28, 0.55, 1.00)],
|
|
121
|
+
length=0.90, gain=0.38, decay=5.5),
|
|
122
|
+
"goal-blocked": dict(notes=[(48, 0.00, 0.30, 1.00), (43, 0.22, 0.55, 0.90)],
|
|
123
|
+
length=0.85, gain=0.42, decay=5.0),
|
|
124
|
+
"job-failed": dict(notes=[(72, 0.00, 0.14, 1.00), (71, 0.16, 0.30, 0.90)],
|
|
125
|
+
length=0.50, gain=0.40, decay=8.0),
|
|
118
126
|
},
|
|
119
127
|
},
|
|
120
128
|
# 柔和:低八度、慢起音、谐波少,适合安静环境
|
|
@@ -129,6 +137,14 @@ SETS = {
|
|
|
129
137
|
length=0.92, gain=0.30, decay=3.8),
|
|
130
138
|
"question": dict(notes=[(81, 0.00, 0.24, 0.90), (81, 0.24, 0.24, 0.95), (88, 0.48, 0.50, 1.00)],
|
|
131
139
|
length=1.05, gain=0.30, decay=3.4),
|
|
140
|
+
"approval": dict(notes=[(69, 0.00, 0.30, 0.90), (76, 0.18, 0.60, 1.00)],
|
|
141
|
+
length=0.95, gain=0.30, decay=3.2),
|
|
142
|
+
"plan-review": dict(notes=[(72, 0.00, 0.34, 0.90), (76, 0.18, 0.34, 0.95), (79, 0.36, 0.65, 1.00)],
|
|
143
|
+
length=1.15, gain=0.30, decay=3.0),
|
|
144
|
+
"goal-blocked": dict(notes=[(40, 0.00, 0.36, 1.00), (35, 0.26, 0.60, 0.90)],
|
|
145
|
+
length=0.95, gain=0.30, decay=3.4),
|
|
146
|
+
"job-failed": dict(notes=[(64, 0.00, 0.20, 1.00), (63, 0.22, 0.40, 0.90)],
|
|
147
|
+
length=0.65, gain=0.30, decay=4.0),
|
|
132
148
|
},
|
|
133
149
|
},
|
|
134
150
|
# 钟声:长尾、泛音密集、金属感
|
|
@@ -143,6 +159,14 @@ SETS = {
|
|
|
143
159
|
length=1.20, gain=0.30, decay=2.4),
|
|
144
160
|
"question": dict(notes=[(93, 0.00, 0.30, 0.90), (93, 0.25, 0.30, 0.95), (96, 0.50, 0.90, 1.00)],
|
|
145
161
|
length=1.35, gain=0.30, decay=2.0),
|
|
162
|
+
"approval": dict(notes=[(86, 0.00, 0.45, 0.90), (93, 0.16, 0.85, 1.00)],
|
|
163
|
+
length=1.20, gain=0.30, decay=2.2),
|
|
164
|
+
"plan-review": dict(notes=[(84, 0.00, 0.50, 0.90), (88, 0.16, 0.50, 0.95), (91, 0.32, 0.90, 1.00)],
|
|
165
|
+
length=1.40, gain=0.30, decay=2.0),
|
|
166
|
+
"goal-blocked": dict(notes=[(50, 0.00, 0.60, 1.00), (45, 0.30, 0.90, 0.90)],
|
|
167
|
+
length=1.30, gain=0.30, decay=2.4),
|
|
168
|
+
"job-failed": dict(notes=[(74, 0.00, 0.40, 1.00), (73, 0.24, 0.70, 0.90)],
|
|
169
|
+
length=1.00, gain=0.30, decay=2.6),
|
|
146
170
|
},
|
|
147
171
|
},
|
|
148
172
|
# 复古:方波(奇次谐波)、短促、8-bit 味
|
|
@@ -157,6 +181,14 @@ SETS = {
|
|
|
157
181
|
length=0.38, gain=0.34, decay=10.0),
|
|
158
182
|
"question": dict(notes=[(88, 0.00, 0.10, 0.90), (88, 0.12, 0.10, 0.95), (91, 0.24, 0.18, 1.00)],
|
|
159
183
|
length=0.46, gain=0.34, decay=9.0),
|
|
184
|
+
"approval": dict(notes=[(79, 0.00, 0.10, 0.90), (84, 0.10, 0.20, 1.00)],
|
|
185
|
+
length=0.35, gain=0.34, decay=9.0),
|
|
186
|
+
"plan-review": dict(notes=[(76, 0.00, 0.10, 0.90), (79, 0.09, 0.10, 0.95), (84, 0.18, 0.18, 1.00)],
|
|
187
|
+
length=0.40, gain=0.34, decay=9.0),
|
|
188
|
+
"goal-blocked": dict(notes=[(48, 0.00, 0.12, 1.00), (43, 0.12, 0.20, 0.90)],
|
|
189
|
+
length=0.36, gain=0.34, decay=9.0),
|
|
190
|
+
"job-failed": dict(notes=[(72, 0.00, 0.09, 1.00), (71, 0.10, 0.16, 0.90)],
|
|
191
|
+
length=0.30, gain=0.34, decay=10.0),
|
|
160
192
|
},
|
|
161
193
|
},
|
|
162
194
|
# 电子:极硬起音、极短衰减,像设备提示音
|
|
@@ -171,6 +203,14 @@ SETS = {
|
|
|
171
203
|
length=0.30, gain=0.36, decay=16.0),
|
|
172
204
|
"question": dict(notes=[(93, 0.00, 0.07, 0.90), (93, 0.10, 0.07, 0.95), (96, 0.20, 0.14, 1.00)],
|
|
173
205
|
length=0.38, gain=0.36, decay=14.0),
|
|
206
|
+
"approval": dict(notes=[(83, 0.00, 0.07, 0.90), (88, 0.08, 0.14, 1.00)],
|
|
207
|
+
length=0.26, gain=0.36, decay=14.0),
|
|
208
|
+
"plan-review": dict(notes=[(81, 0.00, 0.07, 0.90), (85, 0.07, 0.07, 0.95), (88, 0.14, 0.14, 1.00)],
|
|
209
|
+
length=0.32, gain=0.36, decay=14.0),
|
|
210
|
+
"goal-blocked": dict(notes=[(53, 0.00, 0.08, 1.00), (48, 0.09, 0.16, 0.90)],
|
|
211
|
+
length=0.30, gain=0.36, decay=14.0),
|
|
212
|
+
"job-failed": dict(notes=[(74, 0.00, 0.06, 1.00), (73, 0.07, 0.12, 0.90)],
|
|
213
|
+
length=0.22, gain=0.36, decay=16.0),
|
|
174
214
|
},
|
|
175
215
|
},
|
|
176
216
|
# 木质:低频、极快衰减,敲击感,不刺耳
|
|
@@ -185,6 +225,14 @@ SETS = {
|
|
|
185
225
|
length=0.34, gain=0.42, decay=22.0),
|
|
186
226
|
"question": dict(notes=[(79, 0.00, 0.09, 0.90), (79, 0.11, 0.09, 0.95), (83, 0.22, 0.14, 1.00)],
|
|
187
227
|
length=0.42, gain=0.42, decay=20.0),
|
|
228
|
+
"approval": dict(notes=[(69, 0.00, 0.09, 0.90), (74, 0.08, 0.16, 1.00)],
|
|
229
|
+
length=0.30, gain=0.42, decay=20.0),
|
|
230
|
+
"plan-review": dict(notes=[(67, 0.00, 0.09, 0.90), (71, 0.08, 0.09, 0.95), (74, 0.16, 0.14, 1.00)],
|
|
231
|
+
length=0.35, gain=0.42, decay=20.0),
|
|
232
|
+
"goal-blocked": dict(notes=[(45, 0.00, 0.10, 1.00), (40, 0.10, 0.16, 0.90)],
|
|
233
|
+
length=0.30, gain=0.42, decay=20.0),
|
|
234
|
+
"job-failed": dict(notes=[(64, 0.00, 0.08, 1.00), (63, 0.09, 0.14, 0.90)],
|
|
235
|
+
length=0.26, gain=0.42, decay=20.0),
|
|
188
236
|
},
|
|
189
237
|
},
|
|
190
238
|
}
|