dsh-notify-chime 0.0.0-stage → 1.4.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/client.js ADDED
@@ -0,0 +1,670 @@
1
+ // dsh-notify-chime — 浏览器半 / browser half
2
+ //
3
+ // 注册一个设置页(settings.section),三组折叠分区:
4
+ // 声音 —— 总开关、四类事件各自开关与试听、最小间隔、子会话
5
+ // 通知 —— 通知总开关、哪些事件弹、标题前缀、是否带会话名
6
+ // 高级 —— 只读路径与 patch 初值、恢复默认
7
+ // 所有写入走宿主路由 /api/dsh-notify-chime/*,改完立即生效(不需要重启)。
8
+ //
9
+ // 契约:
10
+ // window.__ModuleLoader__.load({ id, factory(require) }) —— id 必须是包名
11
+ // 不 require 任何 @deepseek-ai/dsh-client-* 包;React 从 browser module table 取
12
+ // 样式只用 --dsw-alias-* 主题 token,且都带 fallback
13
+ //
14
+ // 实现取舍:除了顶层 SettingsCard,页面里的分区、行、开关都用普通函数拼元素,
15
+ // 不用自定义组件——渲染测试(tools/test-client-render.mjs)只执行顶层函数体,
16
+ // 子组件里的样式引用漏定义在测试里看不见,真实浏览器会整页空白(踩坑第 15 条)。
17
+
18
+ window.__ModuleLoader__.load({
19
+ id: 'dsh-notify-chime',
20
+ factory: (require) => {
21
+ const React = require('react')
22
+ const h = React.createElement
23
+ const { useCallback, useEffect, useRef, useState } = React
24
+
25
+ const API = '/api/dsh-notify-chime'
26
+ const POLL_MS = 3000
27
+ const LOG = '[dsh-notify-chime]'
28
+
29
+ /** 宿主没返回元数据时的兜底(字段缺失也要能渲染)。 */
30
+ const KIND_FALLBACK = [
31
+ { key: 'finished', label: '工作完成', sound: null, soundExists: true },
32
+ { key: 'interrupted', label: '工作中断', sound: null, soundExists: true },
33
+ { key: 'error', label: '工作出错', sound: null, soundExists: true },
34
+ { key: 'question', label: 'Agent 提问', sound: null, soundExists: true },
35
+ ]
36
+
37
+ /** 宿主没返回音色套清单时的兜底。 */
38
+ const SOUNDSET_FALLBACK = [
39
+ { key: 'default', label: '默认' },
40
+ { key: 'soft', label: '柔和' },
41
+ { key: 'chime', label: '钟声' },
42
+ { key: 'retro', label: '复古' },
43
+ { key: 'digital', label: '电子' },
44
+ { key: 'wood', label: '木质' },
45
+ { key: 'custom', label: '自定义' },
46
+ ]
47
+
48
+ const EMPTY_SETTINGS = {
49
+ enabled: true,
50
+ soundset: 'default',
51
+ perKindSoundset: {},
52
+ customSounds: {},
53
+ kinds: { finished: true, interrupted: true, error: true, question: true },
54
+ minIntervalMs: 700,
55
+ includeSubagents: false,
56
+ toast: { enabled: false, kinds: ['question'], title: 'DeepSeek Harness', includeSession: true },
57
+ }
58
+
59
+ let state = {
60
+ loading: true,
61
+ error: null,
62
+ version: '',
63
+ enabledByPatch: true,
64
+ settings: EMPTY_SETTINGS,
65
+ kinds: KIND_FALLBACK,
66
+ soundsets: SOUNDSET_FALLBACK,
67
+ customSounds: {},
68
+ soundsDir: '',
69
+ traceFile: null,
70
+ settingsFile: '',
71
+ questionTools: [],
72
+ toastAppId: '',
73
+ }
74
+ const listeners = new Set()
75
+ const emit = () => { for (const fn of listeners) fn(state) }
76
+
77
+ async function refresh () {
78
+ try {
79
+ const r = await fetch(API + '/state', { headers: { accept: 'application/json' } })
80
+ if (!r.ok) throw new Error('HTTP ' + r.status)
81
+ const j = await r.json()
82
+ state = { ...state, loading: false, error: null, ...j }
83
+ } catch (e) {
84
+ state = { ...state, loading: false, error: (e && e.message) || '无法连接宿主路由' }
85
+ }
86
+ emit()
87
+ }
88
+
89
+ async function save (patch) {
90
+ const r = await fetch(API + '/set', {
91
+ method: 'POST',
92
+ headers: { 'content-type': 'application/json' },
93
+ body: JSON.stringify(patch),
94
+ })
95
+ let j = null
96
+ try { j = await r.json() } catch { /* 非 JSON 响应按失败处理 */ }
97
+ if (!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ('HTTP ' + r.status))
98
+ state = { ...state, settings: j.settings || state.settings, error: null }
99
+ emit()
100
+ // 设置变了,各事件生效的音色与文件路径(kind 元数据)也要立刻跟上,不等下一轮询
101
+ await refresh()
102
+ return j
103
+ }
104
+
105
+ async function playPreview (kind) {
106
+ const r = await fetch(API + '/preview', {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json' },
109
+ body: JSON.stringify({ kind }),
110
+ })
111
+ let j = null
112
+ try { j = await r.json() } catch { /* 非 JSON 响应按失败处理 */ }
113
+ if (!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ('HTTP ' + r.status))
114
+ return j
115
+ }
116
+
117
+ function useStore () {
118
+ const [snap, setSnap] = useState(state)
119
+ useEffect(() => {
120
+ listeners.add(setSnap)
121
+ setSnap(state)
122
+ return () => { listeners.delete(setSnap) }
123
+ }, [])
124
+ return snap
125
+ }
126
+
127
+ const C = {
128
+ border: 'var(--dsw-alias-border-subtle, rgba(130,130,140,0.28))',
129
+ text: 'var(--dsw-alias-text-primary, inherit)',
130
+ dim: 'var(--dsw-alias-text-secondary, rgba(140,140,150,1))',
131
+ accent: 'var(--dsw-alias-accent, #4f7cff)',
132
+ surface: 'var(--dsw-alias-surface-raised, rgba(130,130,140,0.06))',
133
+ danger: 'var(--dsw-alias-danger, #e5534b)',
134
+ radius: 'var(--dsw-radius-lg, 12px)',
135
+ radiusSm: 'var(--dsw-radius-sm, 8px)',
136
+ }
137
+
138
+ const S = {
139
+ card: { border: '1px solid ' + C.border, borderRadius: C.radius, padding: '16px 18px', display: 'grid', gap: '14px', color: C.text },
140
+ head: { display: 'flex', alignItems: 'baseline', gap: '8px', flexWrap: 'wrap' },
141
+ title: { fontSize: '15px', fontWeight: 600, margin: 0 },
142
+ badge: { fontSize: '12px', color: C.dim, border: '1px solid ' + C.border, borderRadius: '999px', padding: '1px 8px' },
143
+ status: { marginLeft: 'auto', fontSize: '12px', color: C.dim },
144
+ desc: { margin: 0, fontSize: '13px', lineHeight: 1.6, color: C.dim },
145
+ section: { border: '1px solid ' + C.border, borderRadius: C.radiusSm, overflow: 'hidden' },
146
+ sectionHead: {
147
+ width: '100%', display: 'flex', alignItems: 'baseline', gap: '8px', padding: '9px 12px',
148
+ background: C.surface, border: 'none', color: C.text, cursor: 'pointer', textAlign: 'left',
149
+ font: 'inherit',
150
+ },
151
+ sectionTitle: { fontSize: '13px', fontWeight: 600 },
152
+ sectionHint: { fontSize: '12px', color: C.dim, flex: 1, minWidth: 0 },
153
+ chevron: { fontSize: '11px', color: C.dim, marginLeft: 'auto', flex: 'none' },
154
+ sectionBody: { padding: '12px 14px', display: 'grid', gap: '12px' },
155
+ row: { display: 'flex', alignItems: 'flex-start', gap: '12px' },
156
+ rowText: { display: 'grid', gap: '2px', flex: 1, minWidth: 0 },
157
+ rowTitle: { fontSize: '13px', fontWeight: 550 },
158
+ rowDesc: { fontSize: '12px', color: C.dim, lineHeight: 1.5 },
159
+ toggle: (on) => ({
160
+ width: '38px', height: '22px', borderRadius: '999px', border: '1px solid ' + C.border,
161
+ background: on ? C.accent : 'rgba(130,130,140,0.25)', position: 'relative', cursor: 'pointer',
162
+ flex: 'none', marginTop: '2px', transition: 'background 160ms ease',
163
+ }),
164
+ knob: (on) => ({
165
+ position: 'absolute', top: '2px', left: on ? '18px' : '2px', width: '16px', height: '16px',
166
+ borderRadius: '50%', background: '#fff', transition: 'left 160ms ease',
167
+ }),
168
+ inline: { display: 'flex', alignItems: 'center', gap: '8px', flex: 'none' },
169
+ input: {
170
+ width: '110px', padding: '5px 8px', fontSize: '13px', textAlign: 'right',
171
+ border: '1px solid ' + C.border, borderRadius: C.radiusSm, background: 'transparent', color: C.text,
172
+ },
173
+ inputWide: {
174
+ flex: 1, minWidth: 0, padding: '5px 8px', fontSize: '13px',
175
+ border: '1px solid ' + C.border, borderRadius: C.radiusSm, background: 'transparent', color: C.text,
176
+ },
177
+ btn: (disabled) => ({
178
+ padding: '5px 12px', fontSize: '13px', borderRadius: C.radiusSm,
179
+ border: '1px solid ' + C.border, background: 'transparent', color: C.text,
180
+ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, flex: 'none',
181
+ }),
182
+ chips: { display: 'flex', gap: '6px', flexWrap: 'wrap', flex: 'none' },
183
+ fileBtn: {
184
+ padding: '5px 10px', fontSize: '12px', borderRadius: C.radiusSm, border: '1px dashed ' + C.border,
185
+ color: C.dim, cursor: 'pointer', flex: 'none', whiteSpace: 'nowrap',
186
+ },
187
+ fileInput: { display: 'none' },
188
+ pickerBtn: (on) => ({
189
+ padding: '4px 9px', fontSize: '12px', borderRadius: C.radiusSm,
190
+ border: '1px solid ' + (on ? C.accent : C.border),
191
+ background: 'transparent', color: on ? C.accent : C.dim,
192
+ cursor: 'pointer', flex: 'none', whiteSpace: 'nowrap',
193
+ }),
194
+ kindBlock: { display: 'grid', gap: '6px' },
195
+ panelTitle: { fontSize: '12px', color: C.dim },
196
+ chip: (on) => ({
197
+ padding: '3px 10px', fontSize: '12px', borderRadius: '999px', cursor: 'pointer',
198
+ border: '1px solid ' + (on ? C.accent : C.border),
199
+ background: on ? C.accent : 'transparent',
200
+ color: on ? '#fff' : C.dim,
201
+ }),
202
+ panel: { background: C.surface, borderRadius: C.radiusSm, padding: '12px 14px', display: 'grid', gap: '8px' },
203
+ kv: { display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '4px 12px', fontSize: '12px', color: C.dim },
204
+ mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', wordBreak: 'break-all' },
205
+ msg: (bad) => ({ fontSize: '12px', color: bad ? C.danger : C.dim }),
206
+ foot: { fontSize: '12px', color: C.dim, lineHeight: 1.6, margin: 0 },
207
+ warn: { fontSize: '12px', color: C.danger },
208
+ }
209
+
210
+ /** 开关按钮(普通函数,非组件:保证渲染测试能执行到 S.xxx)。 */
211
+ const toggleNode = ({ on, onChange, label }) => h('button', {
212
+ type: 'button',
213
+ 'aria-label': label,
214
+ 'aria-pressed': on ? 'true' : 'false',
215
+ style: S.toggle(on),
216
+ onClick: () => onChange(!on),
217
+ }, h('span', { style: S.knob(on) }))
218
+
219
+ /** 一行设置:左标题与说明,右控件。 */
220
+ const rowNode = ({ title, desc, control }) => h('div', { style: S.row },
221
+ h('div', { style: S.rowText },
222
+ h('div', { style: S.rowTitle }, title),
223
+ desc ? h('div', { style: S.rowDesc }, desc) : null,
224
+ ),
225
+ control,
226
+ )
227
+
228
+ function SettingsCard () {
229
+ const s = useStore()
230
+ const [busy, setBusy] = useState(false)
231
+ // 试听 / 上传各自独立:共用一个 busy 会让别的行的按钮一起变灰(看起来像被点了)
232
+ const [previewing, setPreviewing] = useState(null)
233
+ const [uploading, setUploading] = useState(null)
234
+ const [openPanel, setOpenPanel] = useState(null)
235
+ const [msg, setMsg] = useState(null)
236
+ const [openSound, setOpenSound] = useState(true)
237
+ const [openToast, setOpenToast] = useState(true)
238
+ const [openAdvanced, setOpenAdvanced] = useState(false)
239
+ const [confirmReset, setConfirmReset] = useState(false)
240
+
241
+ // 兜底:宿主载荷缺字段时也不能让整页空白(踩坑第 15 条)
242
+ const settings = s.settings || EMPTY_SETTINGS
243
+ const toast = settings.toast || EMPTY_SETTINGS.toast
244
+ const kindMap = settings.kinds || {}
245
+ const toastKinds = Array.isArray(toast.kinds) ? toast.kinds : []
246
+ const kindMeta = (Array.isArray(s.kinds) && s.kinds.length > 0) ? s.kinds : KIND_FALLBACK
247
+ const soundsets = (Array.isArray(s.soundsets) && s.soundsets.length > 0) ? s.soundsets : SOUNDSET_FALLBACK
248
+ const soundset = typeof settings.soundset === 'string' ? settings.soundset : 'default'
249
+ const customSounds = settings.customSounds || {}
250
+
251
+ const [interval, setIntervalText] = useState(String(settings.minIntervalMs))
252
+ const [intervalDirty, setIntervalDirty] = useState(false)
253
+ const [titleText, setTitleText] = useState(String(toast.title || ''))
254
+ const [titleDirty, setTitleDirty] = useState(false)
255
+ // 宿主值变了(恢复默认、别处改过)就同步输入框;用户正在编辑时不覆盖
256
+ useEffect(() => {
257
+ if (!intervalDirty) setIntervalText(String(settings.minIntervalMs))
258
+ }, [settings.minIntervalMs, intervalDirty])
259
+ useEffect(() => {
260
+ if (!titleDirty) setTitleText(String(toast.title || ''))
261
+ }, [toast.title, titleDirty])
262
+
263
+ const flashTimer = useRef(null)
264
+ const flash = (text, bad) => {
265
+ setMsg({ text, bad })
266
+ // 连续提示时先清掉上一个定时器,否则旧定时器会把新消息提前抹掉
267
+ if (flashTimer.current) window.clearTimeout(flashTimer.current)
268
+ flashTimer.current = window.setTimeout(() => { flashTimer.current = null; setMsg(null) }, 2600)
269
+ }
270
+
271
+ const commit = useCallback(async (patch, okText) => {
272
+ setBusy(true)
273
+ try {
274
+ await save(patch)
275
+ if (okText) flash(okText, false)
276
+ return true
277
+ } catch (e) {
278
+ flash('保存失败:' + ((e && e.message) || '请确认 DSH 仍在运行'), true)
279
+ return false
280
+ } finally {
281
+ setBusy(false)
282
+ }
283
+ }, [])
284
+
285
+ const preview = async (meta) => {
286
+ setPreviewing(meta.key)
287
+ try {
288
+ await playPreview(meta.key)
289
+ flash('正在试听:' + meta.label, false)
290
+ } catch (e) {
291
+ flash('试听失败:' + ((e && e.message) || '宿主未响应'), true)
292
+ } finally {
293
+ setPreviewing(null)
294
+ }
295
+ }
296
+
297
+ const statusText = s.error ? ('连接异常:' + s.error)
298
+ : s.loading ? '正在读取…'
299
+ : !settings.enabled ? '已停用:不发声、不弹通知'
300
+ : '正在监听'
301
+
302
+ /** 折叠分区。children 在调用处就已构造,内容始终渲染,只用 display 收起。 */
303
+ const sectionNode = (key, title, hint, open, onToggle, children) => h('section', { style: S.section, key },
304
+ h('button', {
305
+ type: 'button',
306
+ style: S.sectionHead,
307
+ 'aria-expanded': open ? 'true' : 'false',
308
+ onClick: onToggle,
309
+ },
310
+ h('span', { style: S.sectionTitle }, title),
311
+ h('span', { style: S.sectionHint }, hint),
312
+ h('span', { style: S.chevron }, open ? '▾' : '▸')),
313
+ h('div', { style: open ? S.sectionBody : { ...S.sectionBody, display: 'none' } }, children),
314
+ )
315
+
316
+ /** 上传自定义音效:文件选择器拿到的 WAV 直接传给宿主,宿主只写固定目录与固定文件名。 */
317
+ const upload = async (e, meta) => {
318
+ const input = e && e.target
319
+ const file = input && input.files && input.files[0]
320
+ if (!file) return
321
+ if (file.size > 5 * 1024 * 1024) {
322
+ flash('音频文件超过 5 MB', true)
323
+ if (input) input.value = ''
324
+ return
325
+ }
326
+ setUploading(meta.key)
327
+ try {
328
+ const buffer = await file.arrayBuffer()
329
+ const r = await fetch(API + '/sound', {
330
+ method: 'POST',
331
+ headers: { 'content-type': 'application/octet-stream', 'x-chime-kind': meta.key },
332
+ body: buffer,
333
+ })
334
+ let j = null
335
+ try { j = await r.json() } catch { /* 非 JSON 响应按失败处理 */ }
336
+ if (!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ('HTTP ' + r.status))
337
+ await refresh()
338
+ flash('已上传:' + meta.label + '(该行改用自定义)', false)
339
+ } catch (err) {
340
+ flash('上传失败:' + ((err && err.message) || '未知错误'), true)
341
+ } finally {
342
+ setUploading(null)
343
+ if (input) input.value = ''
344
+ }
345
+ }
346
+
347
+ const baseName = (p) => String(p || '').replace(/^.*[\\/]/, '')
348
+ const setLabelOf = (key) => {
349
+ const found = soundsets.find((set) => set.key === key)
350
+ return found ? found.label : key
351
+ }
352
+ /** 该事件最终生效的音色套(宿主已解析好;字段缺失时用全局值兜底)。 */
353
+ const effectiveSet = (meta) => meta.soundset || soundset
354
+
355
+ const kindDesc = (meta) => {
356
+ if (meta.soundExists === false) return '音效文件缺失:' + (meta.sound || '(未配置文件路径)')
357
+ if (effectiveSet(meta) === 'custom') {
358
+ const own = customSounds[meta.key]
359
+ return own ? ('自定义文件:' + baseName(own)) : '未上传,这类仍用内置音效'
360
+ }
361
+ return meta.body || ''
362
+ }
363
+
364
+ /** 每类事件的音色面板:常驻 DOM、只用 display 收起,渲染测试才覆盖得到。 */
365
+ const soundsetPanel = (meta) => h('div', {
366
+ key: meta.key + '-panel',
367
+ style: openPanel === meta.key ? S.panel : { ...S.panel, display: 'none' },
368
+ },
369
+ h('div', { style: S.panelTitle }, meta.label + ' 用哪套音色'),
370
+ h('div', { style: S.chips },
371
+ h('button', {
372
+ type: 'button',
373
+ key: 'inherit',
374
+ 'aria-pressed': meta.soundsetPinned ? 'false' : 'true',
375
+ style: S.chip(!meta.soundsetPinned),
376
+ onClick: () => {
377
+ setOpenPanel(null)
378
+ commit({ perKindSoundset: { [meta.key]: null } }, meta.label + ':跟随全局(' + setLabelOf(soundset) + ')')
379
+ },
380
+ }, '跟随全局'),
381
+ soundsets.map((set) => h('button', {
382
+ type: 'button',
383
+ key: set.key,
384
+ 'aria-pressed': (meta.soundsetPinned && meta.soundset === set.key) ? 'true' : 'false',
385
+ style: S.chip(meta.soundsetPinned && meta.soundset === set.key),
386
+ onClick: () => {
387
+ setOpenPanel(null)
388
+ commit({ perKindSoundset: { [meta.key]: set.key } }, meta.label + ':' + set.label)
389
+ },
390
+ }, set.label)),
391
+ ))
392
+
393
+ const kindRows = kindMeta.map((meta) => h('div', { key: meta.key, style: S.kindBlock },
394
+ rowNode({
395
+ title: meta.label,
396
+ desc: kindDesc(meta),
397
+ control: h('div', { style: S.inline },
398
+ h('button', {
399
+ type: 'button',
400
+ key: 'preview',
401
+ style: S.btn(previewing === meta.key || meta.soundExists === false),
402
+ disabled: previewing === meta.key || meta.soundExists === false,
403
+ onClick: () => preview(meta),
404
+ }, previewing === meta.key ? '播放中' : '试听'),
405
+ h('button', {
406
+ type: 'button',
407
+ key: 'soundset',
408
+ 'aria-expanded': openPanel === meta.key ? 'true' : 'false',
409
+ style: S.pickerBtn(openPanel === meta.key),
410
+ onClick: () => setOpenPanel(openPanel === meta.key ? null : meta.key),
411
+ }, (meta.soundsetPinned ? setLabelOf(meta.soundset) : '跟随全局') + ' ▾'),
412
+ effectiveSet(meta) === 'custom'
413
+ ? h('label', { key: 'upload', style: S.fileBtn, title: '上传 WAV(不超过 5 MB)' },
414
+ uploading === meta.key ? '上传中' : (customSounds[meta.key] ? '重新上传' : '上传'),
415
+ h('input', {
416
+ type: 'file',
417
+ accept: '.wav,audio/wav',
418
+ style: S.fileInput,
419
+ onChange: (e) => upload(e, meta),
420
+ }))
421
+ : null,
422
+ toggleNode({
423
+ on: kindMap[meta.key] !== false,
424
+ label: meta.label + ' 发声',
425
+ onChange: (v) => commit({ kinds: { [meta.key]: v } }, (v ? '已开启:' : '已关闭:') + meta.label),
426
+ }),
427
+ ),
428
+ }),
429
+ soundsetPanel(meta),
430
+ ))
431
+
432
+ /** 音色套选择:选中即写设置,试听按钮随即用新音色。 */
433
+ const soundsetChips = h('div', { style: S.chips }, soundsets.map((set) => {
434
+ const on = soundset === set.key
435
+ return h('button', {
436
+ type: 'button',
437
+ key: set.key,
438
+ 'aria-pressed': on ? 'true' : 'false',
439
+ style: S.chip(on),
440
+ onClick: () => commit({ soundset: set.key }, '音色已切到「' + set.label + '」'),
441
+ }, set.label)
442
+ }))
443
+
444
+ const toastChips = h('div', { style: S.chips }, kindMeta.map((meta) => {
445
+ const on = toastKinds.includes(meta.key)
446
+ return h('button', {
447
+ type: 'button',
448
+ key: meta.key,
449
+ 'aria-pressed': on ? 'true' : 'false',
450
+ style: S.chip(on),
451
+ onClick: () => commit(
452
+ { toast: { kinds: on ? toastKinds.filter((k) => k !== meta.key) : [...toastKinds, meta.key] } },
453
+ '通知事件已更新',
454
+ ),
455
+ }, meta.label)
456
+ }))
457
+
458
+ return h('div', { style: S.card },
459
+ h('div', { style: S.head },
460
+ h('h3', { style: S.title }, '提示音'),
461
+ s.version ? h('span', { style: S.badge }, 'v' + s.version) : null,
462
+ h('span', { style: S.status }, statusText),
463
+ ),
464
+ h('p', { style: S.desc },
465
+ '回合完成、中断、出错或提问时发声,可另弹通知。改动立即生效。'),
466
+
467
+ sectionNode('sound', '声音', settings.enabled ? '四类事件各自开关' : '总开关已关', openSound, () => setOpenSound(!openSound), [
468
+ rowNode({
469
+ key: 'enabled',
470
+ title: '启用提示音',
471
+ desc: settings.enabled
472
+ ? '关闭后不发声也不弹通知,事件照常记录。'
473
+ : '已关闭' + (s.enabledByPatch === false ? '(patch 初值即 false)' : '') + '。',
474
+ control: toggleNode({
475
+ on: settings.enabled !== false,
476
+ label: '启用提示音',
477
+ onChange: (v) => commit({ enabled: v }, v ? '提示音已启用' : '提示音已停用'),
478
+ }),
479
+ }),
480
+ rowNode({
481
+ key: 'soundset',
482
+ title: '音色',
483
+ desc: '全局默认;某类要单独配,点该行右侧的音色名。',
484
+ control: soundsetChips,
485
+ }),
486
+ ...kindRows,
487
+ rowNode({
488
+ key: 'interval',
489
+ title: '同类事件最小间隔',
490
+ desc: '同一类事件在该间隔内只响一次,单位毫秒。',
491
+ control: h('div', { style: S.inline },
492
+ h('input', {
493
+ style: S.input,
494
+ inputMode: 'numeric',
495
+ value: interval,
496
+ 'aria-label': '同类事件最小间隔(毫秒)',
497
+ onChange: (e) => { setIntervalText(e.target.value); setIntervalDirty(true) },
498
+ }),
499
+ h('button', {
500
+ type: 'button',
501
+ style: S.btn(busy || !intervalDirty),
502
+ disabled: busy || !intervalDirty,
503
+ onClick: async () => {
504
+ const n = Number(interval)
505
+ if (!isFinite(n) || n < 0) { flash('间隔必须是非负数', true); return }
506
+ if (n > 600000) { flash('上限 600000 毫秒(10 分钟)', true); return }
507
+ const ok = await commit({ minIntervalMs: Math.floor(n) }, '间隔已保存')
508
+ if (ok) setIntervalDirty(false)
509
+ },
510
+ }, '保存'),
511
+ ),
512
+ }),
513
+ rowNode({
514
+ key: 'subagents',
515
+ title: '子会话也提醒',
516
+ desc: '打开后 subagent 子会话结束也发声。',
517
+ control: toggleNode({
518
+ on: settings.includeSubagents === true,
519
+ label: '子会话也提醒',
520
+ onChange: (v) => commit({ includeSubagents: v }, v ? '子会话也会提醒' : '子会话不再提醒'),
521
+ }),
522
+ }),
523
+ ]),
524
+
525
+ sectionNode('toast', '通知', '系统 Toast,默认只对提问', openToast, () => setOpenToast(!openToast), [
526
+ rowNode({
527
+ key: 'toast-enabled',
528
+ title: '启用系统通知',
529
+ desc: '声音之外再弹系统通知,适合切到别的窗口。',
530
+ control: toggleNode({
531
+ on: toast.enabled === true,
532
+ label: '启用系统通知',
533
+ onChange: (v) => commit({ toast: { enabled: v } }, v ? '通知已启用' : '通知已关闭'),
534
+ }),
535
+ }),
536
+ rowNode({
537
+ key: 'toast-kinds',
538
+ title: '哪些事件弹通知',
539
+ desc: toastKinds.length === 0
540
+ ? '当前一个都没选:不会弹任何通知。'
541
+ : '已选 ' + toastKinds.length + ' 类;点选切换。',
542
+ control: toastChips,
543
+ }),
544
+ rowNode({
545
+ key: 'toast-title',
546
+ title: '通知标题前缀',
547
+ desc: '实际标题是「前缀 · 事件名」,最长 60 字符。',
548
+ control: h('div', { style: S.inline },
549
+ h('input', {
550
+ style: S.inputWide,
551
+ value: titleText,
552
+ maxLength: 60,
553
+ 'aria-label': '通知标题前缀',
554
+ onChange: (e) => { setTitleText(e.target.value); setTitleDirty(true) },
555
+ }),
556
+ h('button', {
557
+ type: 'button',
558
+ style: S.btn(busy || !titleDirty),
559
+ disabled: busy || !titleDirty,
560
+ onClick: async () => {
561
+ if (String(titleText).trim().length === 0) { flash('通知标题不能为空', true); return }
562
+ const ok = await commit({ toast: { title: String(titleText).trim() } }, '标题已保存')
563
+ if (ok) setTitleDirty(false)
564
+ },
565
+ }, '保存'),
566
+ ),
567
+ }),
568
+ rowNode({
569
+ key: 'toast-session',
570
+ title: '正文附带会话名',
571
+ desc: '正文后接「 · 会话标题」,多会话并行时分得清。',
572
+ control: toggleNode({
573
+ on: toast.includeSession === true,
574
+ label: '正文附带会话名',
575
+ onChange: (v) => commit({ toast: { includeSession: v } }),
576
+ }),
577
+ }),
578
+ ]),
579
+
580
+ sectionNode('advanced', '高级', '路径与调试信息', openAdvanced, () => setOpenAdvanced(!openAdvanced), [
581
+ h('div', { style: S.panel },
582
+ h('div', { style: S.kv },
583
+ h('span', null, '设置文件'),
584
+ h('span', { style: S.mono }, s.settingsFile || '~/.dsh/dsh-notify-chime.json'),
585
+ h('span', null, '音效目录'),
586
+ h('span', { style: S.mono }, (kindMeta[0] && kindMeta[0].sound) ? kindMeta[0].sound.replace(/[\\/][^\\/]+$/, '') : '—'),
587
+ h('span', null, '自定义目录'),
588
+ h('span', { style: S.mono }, s.soundsDir || '~/.dsh/dsh-notify-chime-sounds'),
589
+ h('span', null, '轨迹文件'),
590
+ h('span', { style: S.mono }, s.traceFile || '(未配置,patch 里设 traceFile 才写)'),
591
+ h('span', null, '提问工具'),
592
+ h('span', { style: S.mono }, (s.questionTools && s.questionTools.length) ? s.questionTools.join(' / ') : 'ask_user_question'),
593
+ h('span', null, 'Toast AppID'),
594
+ h('span', { style: S.mono }, s.toastAppId || 'com.deepseek.dsh'),
595
+ h('span', null, 'patch 初始值'),
596
+ h('span', { style: S.mono }, 'enabled=' + String(s.enabledByPatch) + '(改它需重启 DSH)'),
597
+ ),
598
+ ),
599
+ h('div', { style: S.inline },
600
+ h('button', {
601
+ type: 'button',
602
+ style: S.btn(busy),
603
+ disabled: busy,
604
+ onClick: async () => {
605
+ if (!confirmReset) {
606
+ setConfirmReset(true)
607
+ flash('再点一次确认:会清掉本页所有自定义,回到 patch 初值', true)
608
+ return
609
+ }
610
+ const ok = await commit({ reset: true }, '已恢复默认设置')
611
+ if (ok) setConfirmReset(false)
612
+ },
613
+ }, confirmReset ? '确认恢复默认' : '恢复默认设置'),
614
+ ),
615
+ h('p', { style: S.foot },
616
+ '音效文件、播放器、Toast AppID、提问工具在 cordis.patch.yml 里,改动需重启 DSH。'),
617
+ ]),
618
+
619
+ msg ? h('div', { style: S.msg(msg.bad) }, msg.text) : null,
620
+ h('p', { style: S.foot },
621
+ '设置存 ~/.dsh/dsh-notify-chime.json,保存即生效;patch 值仅在设置文件缺该项时生效。'),
622
+ )
623
+ }
624
+
625
+ function contribute (ctx, key, options, Component, what) {
626
+ const fail = (e) => console.error(LOG, 'could not register ' + what + ':', e)
627
+ try {
628
+ return ctx.slots.inject(key, () => {
629
+ try {
630
+ return ctx.slots.register(options, Component)
631
+ } catch (e) {
632
+ fail(e)
633
+ return () => {}
634
+ }
635
+ })
636
+ } catch (e) {
637
+ fail(e)
638
+ return () => {}
639
+ }
640
+ }
641
+
642
+ const inject = ['slots']
643
+
644
+ function apply (ctx) {
645
+ ctx.effect(() => {
646
+ refresh()
647
+ const timer = setInterval(() => { if (!document.hidden) refresh() }, POLL_MS)
648
+ const onVisible = () => { if (!document.hidden) refresh() }
649
+ document.addEventListener('visibilitychange', onVisible)
650
+ return () => {
651
+ clearInterval(timer)
652
+ document.removeEventListener('visibilitychange', onVisible)
653
+ }
654
+ }, 'notify-sound-poll')
655
+
656
+ ctx.effect(
657
+ () => contribute(
658
+ ctx,
659
+ 'settings.section',
660
+ { name: 'settings.section', id: 'notify-sound', order: 210, label: '提示音' },
661
+ SettingsCard,
662
+ 'settings.section',
663
+ ),
664
+ 'notify-sound-settings-section',
665
+ )
666
+ }
667
+
668
+ return { inject, apply }
669
+ },
670
+ })