dsh-done-sound 0.1.7 → 0.1.9

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,18 +1,28 @@
1
1
  # dsh-done-sound
2
2
 
3
- DeepSeek Harness Web GUI 的「对话完成音效」插件:你在设置里选一段音频,每次 Agent 完整结束一轮对话时自动播放提示音。
3
+ DeepSeek Harness Web GUI 的对话音效插件。不同触发情况可以分别设置音频提醒,并为每种情况提供内置默认音效。
4
4
 
5
5
  ## 功能
6
6
 
7
- - **设置卡片**(设置 → 对话完成音效):选择音频文件(mp3 / wav / ogg / webm / m4a / aac / flac,≤2MB)、试听、清除、音量调节。
8
- - **完成检测**:以宿主 `turn/end` 事件的 reason(正常/中断/出错/超长)为权威触发信号,快慢对话都不漏、不误判;每轮只响一次。
7
+ - **五种场景独立配置**:正常完成、中断、出错、等待确认、自动重连成功各自使用独立音频,可分别选择、试听和清除。
8
+ - **内置默认音效**:即使没有上传任何文件,也会使用对应场景的默认音效:
9
+ - 正常完成:`turn-done.wav`
10
+ - 中断:`turn-interrupt.wav`
11
+ - 出错:`turn-error.wav`
12
+ - 等待确认:`turn-pending.wav`
13
+ - 自动重连成功:`turn-retry.wav`
14
+ - **音频格式**:支持 `mp3`、`wav`、`ogg`、`webm`、`m4a`、`aac`、`flac`,单个文件最大 10MB。
15
+ - **完成检测**:根据宿主 `turn/end` 事件的 reason 判断正常完成、中断、出错和超长截断,避免误触发;每轮只播放一次。
9
16
  - **触发开关**:
10
- - `中断时也响`(默认**关**):手动停止生成时是否也播放。
11
- - `出错时也响`(默认**开**):对话以错误结束(turn-error / 超长截断)时是否也播放。
12
- - **确认等待提醒**:对话中出现等待确认 / 提问时播放提示音。
13
- - **出错自动重连**:对话报错后自动发送「继续」重试(可配置等待秒数)。
14
- - **版本检查**:设置卡片显示当前版本与更新状态。
15
- - 音频文件保存在 profile 目录 `.dsh-done-sound/`,经 Host 路由 `/dsh-done-sound/audio/<fileId>` 播放,配置持久化在设置中。
17
+ - `中断时也响`:手动停止生成时是否播放,默认关闭。
18
+ - `出错时也响`:对话以错误结束或超长截断时是否播放,默认开启。
19
+ - `等待确认提醒`:Agent 等待批准、提问或其他人工确认时是否播放,默认开启。
20
+ - `重连成功也响`:自动发送「继续」成功后是否播放专属音效,默认开启。
21
+ - `出错自动重连`:出错后是否自动发送「继续」重试,默认开启。
22
+ - **自动重连等待时间**:可设置 10-300 秒,按 5 秒步进;等待期间显示倒计时,若会话自行恢复则自动取消。
23
+ - **全局音量**:统一调节五种场景的播放音量。
24
+ - **版本检查**:设置卡片显示当前版本和更新状态。
25
+ - **日志与导出**:Host 和 Client 诊断日志保存到 profile 目录,可从设置卡片导出当天日志。
16
26
 
17
27
  ## 安装
18
28
 
@@ -28,10 +38,22 @@ dsh plugin --profile web add link:F:\0.AI-CodeProject\DSHProject\0.日常聊天\
28
38
 
29
39
  安装后重启 `dsh web`,打开设置页即可看到「对话完成音效」卡片。
30
40
 
41
+ 升级已有安装后,请重启 `dsh web` 使 Host 端更新生效。旧版本中保存的单个自定义音频会自动作为「正常完成」音保留,不会丢失。
42
+
43
+ ## 配置位置
44
+
45
+ 设置 → 对话完成音效中会显示五行独立音频配置。每行都可以选择文件、试听或清除当前自定义音频;清除后会恢复该场景的内置默认音效。
46
+
47
+ 自定义音频文件保存在 profile 目录的 `.dsh-done-sound/` 中,配置保存在 DSH 设置中。Host 音频路由为:
48
+
49
+ ```text
50
+ /dsh-done-sound/audio/<fileId>
51
+ ```
52
+
31
53
  ## 架构
32
54
 
33
- - **Host 半区**(`src/index.js` → 构建产物 `lib/index.js`):`dsh-done-sound` 设置作用域(`ctx.settings`)、音频文件存储、`webServer` 路由与同源 JSON API(`/dsh-done-sound/api/status|config|audio|clear`)、`dsh-done-sound` 命令。路由经 `ctx.inject(['webServer'], cb)` 延迟挂载;构建时由 esbuild 内联 `@deepseek-ai/schemastery`,产物自包含(link 安装时无需依赖 profile 的模块解析)。
34
- - **Client 半区**(`lib/client.js`):`settings.section` 设置卡片,以 fetch 调用 HTTP API,不依赖会话/命令 RPC;`conversation.session.header.utilities` 完成检测器(`useSession` 订阅 `ConversationSnapshot`,由 `partial/running → 空闲` 转移触发,按最后节点 `seq` 去重)。
55
+ - **Host 半区**(`src/index.js` → 构建产物 `lib/index.js`):`dsh-done-sound` 设置作用域(`ctx.settings`)、五场景音频文件存储、`webServer` 路由与同源 JSON API(`/dsh-done-sound/api/status|config|audio|clear`)、`dsh-done-sound` 命令。路由经 `ctx.inject(['webServer'], cb)` 延迟挂载;构建时由 esbuild 内联 `@deepseek-ai/schemastery`,产物自包含(link 安装时无需依赖 profile 的模块解析)。
56
+ - **Client 半区**(`lib/client.js`):`settings.section` 设置卡片,以 fetch 调用 HTTP API;`conversation.session.header.utilities` 完成检测器订阅会话状态,按场景选择音效并处理等待确认与自动重连倒计时。
35
57
 
36
58
  ## 开发
37
59
 
@@ -39,9 +61,10 @@ dsh plugin --profile web add link:F:\0.AI-CodeProject\DSHProject\0.日常聊天\
39
61
  pnpm install # 安装 esbuild
40
62
  pnpm run build # 打包 src/index.js -> lib/index.js(自带)
41
63
  pnpm run check # 语法检查两个产物
64
+ npm test # Host API 与完成检测测试
42
65
  ```
43
66
 
44
- 修改 host 源码后重新 `pnpm run build`;client 半区直接改 `lib/client.js`(`__ModuleLoader__` 格式即运行时契约,无需打包)。
67
+ 修改 Host 源码后重新 `pnpm run build`;Client 半区直接改 `lib/client.js`(`__ModuleLoader__` 格式即运行时契约,无需打包)。
45
68
 
46
69
  ## 音效库下载
47
70
 
@@ -50,7 +73,7 @@ pnpm run check # 语法检查两个产物
50
73
  - 链接:https://pan.baidu.com/s/1xnef4xkCy8pkooXMGi2ApQ?pwd=1234
51
74
  - 提取码:1234
52
75
 
53
- 下载解压后,在 设置 → 对话完成音效 → 选择音频 中选用即可。
76
+ 下载解压后,在 设置 → 对话完成音效 → 对应场景 → 选择音频 中选用即可。
54
77
 
55
78
  ## License
56
79
 
Binary file
Binary file
Binary file
Binary file
package/lib/client.js CHANGED
@@ -28,6 +28,7 @@ window.__ModuleLoader__.load({
28
28
  '.dtc-row{justify-content:space-between;align-items:center;gap:12px;display:flex}',
29
29
  '.dtc-muted{color:var(--dsw-alias-label-secondary);font-size:12px}',
30
30
  '.dtc-err{color:var(--dsw-alias-state-error-primary);white-space:pre-wrap;font-size:12px}',
31
+ '.dtc-ok{color:var(--dsw-alias-state-success-primary);white-space:pre-wrap;font-size:12px;word-break:break-all}',
31
32
  '.dtc-btn{border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;border-radius:6px;padding:3px 12px;font-size:12px;white-space:nowrap}',
32
33
  '.dtc-btn:disabled{opacity:.5;cursor:default}',
33
34
  '.dtc-file{display:inline-flex;align-items:center}',
@@ -86,6 +87,19 @@ window.__ModuleLoader__.load({
86
87
  retryFailed: '自动发送「继续」失败',
87
88
  logPath: '日志文件',
88
89
  exportLog: '导出日志',
90
+ exportSaved: '日志已导出',
91
+ exportSavedFile: '保存文件名:{name}',
92
+ exportSavedFallbackHint: '未弹出保存窗口,文件已存到浏览器默认下载文件夹(通常 C:\\Users\\你的用户名\\Downloads)',
93
+ exportLogPath: '原始日志路径:{path}(复制到资源管理器地址栏可打开)',
94
+ exportLogPathUnknown: '原始日志路径未知(可在插件目录 logs 文件夹查看)',
95
+ sceneNormal: '正常完成音',
96
+ sceneInterrupt: '中断音',
97
+ sceneError: '出错音',
98
+ scenePending: '等待确认音',
99
+ sceneRetry: '自动重连成功音',
100
+ defaultSound: '(默认音)',
101
+ retrySound: '重连成功也响',
102
+ retrySoundHint: '自动发送「继续」成功后播放提示音(默认开启)',
89
103
  version: '当前版本',
90
104
  updateAvailable: '有新版本',
91
105
  upToDate: '已是最新',
@@ -134,6 +148,19 @@ window.__ModuleLoader__.load({
134
148
  retryFailed: 'failed to auto-send "continue"',
135
149
  logPath: 'Log file',
136
150
  exportLog: 'Export log',
151
+ exportSaved: 'Log exported',
152
+ exportSavedFile: 'Saved file name: {name}',
153
+ exportSavedFallbackHint: 'No save dialog shown; saved to the browser default Downloads folder (usually C:\\Users\\YourName\\Downloads)',
154
+ exportLogPath: 'Original log path: {path} (copy into File Explorer address bar to open)',
155
+ exportLogPathUnknown: 'Original log path unknown (check the plugin logs folder)',
156
+ sceneNormal: 'Completion sound',
157
+ sceneInterrupt: 'Interrupt sound',
158
+ sceneError: 'Error sound',
159
+ scenePending: 'Approval-wait sound',
160
+ sceneRetry: 'Auto-retry success sound',
161
+ defaultSound: '(default)',
162
+ retrySound: 'Play on retry success',
163
+ retrySoundHint: 'Play a sound when "continue" is sent successfully (default on)',
137
164
  version: 'Version',
138
165
  updateAvailable: 'Update available',
139
166
  upToDate: 'Up to date',
@@ -187,6 +214,29 @@ window.__ModuleLoader__.load({
187
214
  }
188
215
 
189
216
  // ---- tiny shared config store (card writes, detector reads) ----
217
+ const SCENES = ['normal', 'interrupt', 'error', 'pending', 'retry'];
218
+ const emptySounds = () =>
219
+ Object.fromEntries(
220
+ SCENES.map((s) => [s, { fileId: '', fileName: '', mime: '', size: 0, url: null, defaultUrl: null }]),
221
+ );
222
+ // Build the per-scene sounds map from a host status payload (reused by
223
+ // syncConfig and by the detector's fresh-view re-fetches).
224
+ const syncSoundsFrom = (payload, fallback) => {
225
+ const out = (fallback && fallback.sounds && typeof fallback.sounds === 'object' ? { ...fallback.sounds } : emptySounds());
226
+ const rawSounds = payload.sounds && typeof payload.sounds === 'object' ? payload.sounds : {};
227
+ for (const scene of SCENES) {
228
+ const s = rawSounds[scene] && typeof rawSounds[scene] === 'object' ? rawSounds[scene] : {};
229
+ out[scene] = {
230
+ fileId: typeof s.fileId === 'string' ? s.fileId : '',
231
+ fileName: typeof s.fileName === 'string' ? s.fileName : '',
232
+ mime: typeof s.mime === 'string' ? s.mime : '',
233
+ size: typeof s.size === 'number' ? s.size : 0,
234
+ url: typeof s.url === 'string' && s.url ? s.url : null,
235
+ defaultUrl: typeof s.defaultUrl === 'string' && s.defaultUrl ? s.defaultUrl : null,
236
+ };
237
+ }
238
+ return out;
239
+ };
190
240
  const configStore = {
191
241
  value: {
192
242
  enabled: true,
@@ -194,17 +244,14 @@ window.__ModuleLoader__.load({
194
244
  playOnInterrupt: false,
195
245
  playOnError: true,
196
246
  playOnPending: true,
247
+ playOnRetry: true,
197
248
  autoRetryOnError: true,
198
249
  retryDelaySeconds: 60,
199
250
  version: null,
200
251
  latestVersion: null,
201
252
  updateStatus: null, // 'update' | 'uptodate' | 'error' | null
202
253
  retryCooldownUntil: null, // timestamp until which auto-retry is cooling down
203
- url: null,
204
- defaultUrl: null,
205
- fileName: '',
206
- mime: '',
207
- size: 0,
254
+ sounds: emptySounds(),
208
255
  loaded: false,
209
256
  lastEvent: null,
210
257
  },
@@ -259,8 +306,9 @@ window.__ModuleLoader__.load({
259
306
  }
260
307
  }
261
308
 
262
- function playChime(cfg) {
263
- const url = cfg.url || cfg.defaultUrl;
309
+ function playChime(cfg, scene) {
310
+ const s = (cfg.sounds && cfg.sounds[scene]) || {};
311
+ const url = s.url || s.defaultUrl;
264
312
  if (!cfg.enabled || !url) return;
265
313
  try {
266
314
  const audio = new Audio(url);
@@ -349,6 +397,13 @@ window.__ModuleLoader__.load({
349
397
  } catch {
350
398
  // ignore
351
399
  }
400
+ // Play the dedicated "auto-retry ok" sound when enabled.
401
+ try {
402
+ const view = configStore.value;
403
+ if (view && view.enabled && view.playOnRetry !== false) playChime(view, 'retry');
404
+ } catch {
405
+ // never let a playback error break the retry flow
406
+ }
352
407
  setConfig({
353
408
  retryCooldownUntil: null,
354
409
  lastEvent: { at: Date.now(), kind: 'error', reason: 'error', play: false, retried: true, retryFailed: false },
@@ -399,30 +454,38 @@ window.__ModuleLoader__.load({
399
454
  pluginCtx = ctx;
400
455
  const syncConfig = (payload) => {
401
456
  if (!payload || typeof payload !== 'object') return;
457
+ const sounds = syncSoundsFrom(payload, null);
402
458
  setConfig({
403
459
  enabled: payload.enabled === true,
404
460
  volume: typeof payload.volume === 'number' ? payload.volume : 0.8,
405
461
  playOnInterrupt: payload.playOnInterrupt === true,
406
462
  playOnError: payload.playOnError !== false,
407
463
  playOnPending: payload.playOnPending !== false,
464
+ playOnRetry: payload.playOnRetry !== false,
408
465
  autoRetryOnError: payload.autoRetryOnError !== false,
409
466
  retryDelaySeconds: typeof payload.retryDelaySeconds === 'number' ? payload.retryDelaySeconds : 60,
410
467
  version: typeof payload.version === 'string' ? payload.version : null,
411
468
  logPath: typeof payload.logPath === 'string' && payload.logPath ? payload.logPath : null,
412
469
  logFileName: typeof payload.logFileName === 'string' && payload.logFileName ? payload.logFileName : 'dsh-done-sound.log',
413
- url: typeof payload.url === 'string' && payload.url ? payload.url : null,
414
- defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
415
- fileName: payload.audio && typeof payload.audio.fileName === 'string' ? payload.audio.fileName : '',
416
- mime: payload.audio && typeof payload.audio.mime === 'string' ? payload.audio.mime : '',
417
- size: payload.audio && typeof payload.audio.size === 'number' ? payload.audio.size : 0,
470
+ sounds,
418
471
  loaded: true,
419
472
  });
420
473
  };
474
+ // 预热:模块一激活就拉取 Host 持久化配置(如自动重连秒数),
475
+ // 避免页面刷新后倒计时/自动重连先用初始默认值(60s),
476
+ // 而设置卡却显示已保存值(20s)——两者必须一致。
477
+ // 失败静默,卡片挂载和 detector 挂载时还会再次同步兜底。
478
+ api('/dsh-done-sound/api/status')
479
+ .then((payload) => {
480
+ if (payload && typeof payload === 'object') syncConfig(payload);
481
+ })
482
+ .catch(() => {});
421
483
 
422
484
  // ---- Settings card (global slot) ----
423
485
  const TurnChimeCard = (props) => {
424
486
  const cfg = useConfig();
425
487
  const [error, setError] = react.useState(null);
488
+ const [exportInfo, setExportInfo] = react.useState(null);
426
489
  const [busy, setBusy] = react.useState(false);
427
490
  const [lang] = react.useState(resolveLang());
428
491
  const L = (key) => t(lang, key);
@@ -471,8 +534,20 @@ window.__ModuleLoader__.load({
471
534
  );
472
535
  // Export today's log file: fetch it from the host, then hand the
473
536
  // browser a Blob download — the native save dialog lets the user pick
474
- // where to store it (filename is today's dated log name).
537
+ // where to store it (filename is today's dated log name). After a
538
+ // successful export, show a green banner with the saved file name and
539
+ // the original log path so the user can find the file on Windows even
540
+ // when the browser cannot show a save dialog.
475
541
  const exportLog = () => {
542
+ setExportInfo(null);
543
+ const showSaved = (savedName, fallbackHint) => {
544
+ const lines = [L('exportSaved') + ' ✓', L('exportSavedFile').replace('{name}', savedName)];
545
+ if (fallbackHint) lines.push(L('exportSavedFallbackHint'));
546
+ // 原始日志路径来自 Host,复制到资源管理器地址栏即可直接打开,
547
+ // 最方便排查「有概率无音效」类问题。
548
+ lines.push(cfg.logPath ? L('exportLogPath').replace('{path}', cfg.logPath) : L('exportLogPathUnknown'));
549
+ setExportInfo(lines.join('\n'));
550
+ };
476
551
  fetch('/dsh-done-sound/api/log/export')
477
552
  .then((res) => {
478
553
  if (!res.ok) {
@@ -483,15 +558,50 @@ window.__ModuleLoader__.load({
483
558
  return res.text();
484
559
  })
485
560
  .then((text) => {
486
- const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
487
- const url = URL.createObjectURL(blob);
488
- const a = document.createElement('a');
489
- a.href = url;
490
- a.download = cfg.logFileName || 'dsh-done-sound.log';
491
- document.body.appendChild(a);
492
- a.click();
493
- a.remove();
494
- setTimeout(() => URL.revokeObjectURL(url), 2000);
561
+ const fileName = cfg.logFileName || 'dsh-done-sound.log';
562
+ const fallbackDownload = () => {
563
+ const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
564
+ const url = URL.createObjectURL(blob);
565
+ const a = document.createElement('a');
566
+ a.href = url;
567
+ a.download = fileName;
568
+ document.body.appendChild(a);
569
+ a.click();
570
+ a.remove();
571
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
572
+ showSaved(fileName, true);
573
+ };
574
+ // 优先用浏览器原生「另存为」对话框(File System Access API,
575
+ // Chrome/Edge 支持),让用户自己选择保存位置;
576
+ // 不支持或用户取消时回退为普通下载。
577
+ if (typeof window.showSaveFilePicker === 'function') {
578
+ window
579
+ .showSaveFilePicker({
580
+ suggestedName: fileName,
581
+ types: [{ description: '日志文件', accept: { 'text/plain': ['.log', '.txt'] } }],
582
+ })
583
+ .then((handle) => {
584
+ const savedName = handle && typeof handle.name === 'string' && handle.name ? handle.name : fileName;
585
+ return handle.createWritable().then(async (writable) => {
586
+ await writable.write(text);
587
+ await writable.close();
588
+ showSaved(savedName, false);
589
+ });
590
+ })
591
+ .catch((err) => {
592
+ // 用户点了「取消」关闭对话框 → 不算错误,直接结束。
593
+ if (err && err.name === 'AbortError') return;
594
+ // 其他失败(如手势过期、浏览器限制)→ 回退为普通下载。
595
+ try {
596
+ if (typeof console !== 'undefined') console.warn('[dsh-done-sound] save picker failed, falling back to download:', err);
597
+ } catch {
598
+ // ignore
599
+ }
600
+ fallbackDownload();
601
+ });
602
+ return;
603
+ }
604
+ fallbackDownload();
495
605
  })
496
606
  .catch((err) => {
497
607
  try {
@@ -559,7 +669,7 @@ window.__ModuleLoader__.load({
559
669
  }
560
670
  };
561
671
 
562
- const onPick = (event) => {
672
+ const onPick = (event, scene) => {
563
673
  const file = event.target.files && event.target.files[0];
564
674
  if (event.target) event.target.value = '';
565
675
  if (!file) return;
@@ -570,7 +680,7 @@ window.__ModuleLoader__.load({
570
680
  setError(L('readFailed'));
571
681
  return;
572
682
  }
573
- await run('/dsh-done-sound/api/audio', { dataUrl, fileName: file.name });
683
+ await run('/dsh-done-sound/api/audio', { dataUrl, fileName: file.name, scene });
574
684
  };
575
685
  reader.onerror = () => setError(L('readFailed'));
576
686
  reader.readAsDataURL(file);
@@ -601,6 +711,34 @@ window.__ModuleLoader__.load({
601
711
  if (volumeTimerRef.current) clearTimeout(volumeTimerRef.current);
602
712
  }, []);
603
713
 
714
+ // One audio picker row per trigger scene. Each scene has its own
715
+ // uploaded file (or falls back to its bundled default sound).
716
+ const sceneRow = (scene) => {
717
+ const s = (cfg.sounds && cfg.sounds[scene]) || {};
718
+ return react.createElement(
719
+ 'div',
720
+ { className: 'dtc-row' },
721
+ react.createElement('span', { className: 'dtc-muted' }, L('scene' + scene[0].toUpperCase() + scene.slice(1))),
722
+ react.createElement('span', { className: 'dtc-muted dtc-logpath' }, s.fileName || L('defaultSound')),
723
+ react.createElement(
724
+ 'label',
725
+ { className: 'dtc-btn dtc-file' },
726
+ react.createElement('input', { type: 'file', accept: 'audio/*', disabled: busy, onChange: (e) => onPick(e, scene) }),
727
+ L('pick'),
728
+ ),
729
+ react.createElement(
730
+ 'button',
731
+ { className: 'dtc-btn', type: 'button', disabled: busy || !(s.url || s.defaultUrl), onClick: () => playChime(cfg, scene) },
732
+ L('preview'),
733
+ ),
734
+ react.createElement(
735
+ 'button',
736
+ { className: 'dtc-btn', type: 'button', disabled: busy || !s.fileId, onClick: () => run('/dsh-done-sound/api/clear', { scene }) },
737
+ L('clear'),
738
+ ),
739
+ );
740
+ };
741
+
604
742
  return react.createElement(
605
743
  'div',
606
744
  { className: 'dtc-card' },
@@ -658,32 +796,9 @@ window.__ModuleLoader__.load({
658
796
  L('exportLog'),
659
797
  ),
660
798
  ),
799
+ exportInfo !== null && react.createElement('div', { className: 'dtc-ok' }, exportInfo),
661
800
 
662
- react.createElement(
663
- 'div',
664
- { className: 'dtc-row' },
665
- react.createElement('span', { className: 'dtc-muted' }, L('audio') + ':' + (cfg.fileName || L('none')) + (cfg.size ? ' (' + formatSize(cfg.size) + ')' : '')),
666
- ),
667
- react.createElement(
668
- 'div',
669
- { className: 'dtc-row' },
670
- react.createElement(
671
- 'label',
672
- { className: 'dtc-btn dtc-file' },
673
- react.createElement('input', { type: 'file', accept: 'audio/*', disabled: busy, onChange: onPick }),
674
- L('pick'),
675
- ),
676
- react.createElement(
677
- 'button',
678
- { className: 'dtc-btn', type: 'button', disabled: busy || !cfg.url, onClick: () => playChime(cfg) },
679
- L('preview'),
680
- ),
681
- react.createElement(
682
- 'button',
683
- { className: 'dtc-btn', type: 'button', disabled: busy || !cfg.url, onClick: () => run('/dsh-done-sound/api/clear', {}) },
684
- L('clear'),
685
- ),
686
- ),
801
+ SCENES.map((scene) => sceneRow(scene)),
687
802
 
688
803
  react.createElement(
689
804
  'div',
@@ -755,6 +870,24 @@ window.__ModuleLoader__.load({
755
870
  ),
756
871
  react.createElement('span', { className: 'dtc-muted' }, L('pendingHint')),
757
872
  ),
873
+ react.createElement(
874
+ 'div',
875
+ { className: 'dtc-row' },
876
+ react.createElement(
877
+ 'label',
878
+ { className: 'dtc-toggle' },
879
+ react.createElement('input', {
880
+ type: 'checkbox',
881
+ checked: cfg.playOnRetry,
882
+ disabled: busy,
883
+ onChange: (e) => {
884
+ run('/dsh-done-sound/api/config', { playOnRetry: e.target.checked });
885
+ },
886
+ }),
887
+ react.createElement('span', null, L('retrySound')),
888
+ ),
889
+ react.createElement('span', { className: 'dtc-muted' }, L('retrySoundHint')),
890
+ ),
758
891
  react.createElement(
759
892
  'div',
760
893
  { className: 'dtc-row' },
@@ -874,6 +1007,17 @@ window.__ModuleLoader__.load({
874
1007
  const prevPendingKeysRef = react.useRef('');
875
1008
  const seededRef = react.useRef(false);
876
1009
 
1010
+ // 兜底:挂载时若配置尚未从 Host 同步(如页面刚刷新、apply 预热未完成),
1011
+ // 主动拉取一次,确保自动重连倒计时使用用户保存的秒数而非初始默认值。
1012
+ react.useEffect(() => {
1013
+ if (configStore.value.loaded) return;
1014
+ api('/dsh-done-sound/api/status')
1015
+ .then((payload) => {
1016
+ if (payload && typeof payload === 'object') syncConfig(payload);
1017
+ })
1018
+ .catch(() => {});
1019
+ }, []);
1020
+
877
1021
  const snap =
878
1022
  typeof useSession === 'function'
879
1023
  ? useSession((s) => {
@@ -925,10 +1069,13 @@ window.__ModuleLoader__.load({
925
1069
  // or the audio was changed elsewhere), re-fetch status once and play
926
1070
  // with the fresh view instead of silently skipping.
927
1071
  const notifyPending = () => {
928
- const canPlay = (view) => view.enabled && view.playOnPending && (view.url || view.defaultUrl);
1072
+ const canPlay = (view) => {
1073
+ const s = (view.sounds && view.sounds.pending) || {};
1074
+ return view.enabled && view.playOnPending && (s.url || s.defaultUrl);
1075
+ };
929
1076
  const record = (value) => setConfig({ lastEvent: { at: Date.now(), kind: 'pending', reason: 'pending', play: value } });
930
1077
  if (canPlay(cfg)) {
931
- playChime(cfg);
1078
+ playChime(cfg, 'pending');
932
1079
  record(true);
933
1080
  return;
934
1081
  }
@@ -940,11 +1087,10 @@ window.__ModuleLoader__.load({
940
1087
  ...cfg,
941
1088
  enabled: payload.enabled === true,
942
1089
  playOnPending: payload.playOnPending !== false,
943
- url: typeof payload.url === 'string' && payload.url ? payload.url : null,
944
- defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
1090
+ sounds: syncSoundsFrom(payload, cfg),
945
1091
  };
946
1092
  if (canPlay(fresh)) {
947
- playChime(fresh);
1093
+ playChime(fresh, 'pending');
948
1094
  record(true);
949
1095
  }
950
1096
  })
@@ -1031,33 +1177,40 @@ if (snap.pendingCount > 0) {
1031
1177
  else kind = 'normal'; // 'completed' (and unknown future reasons default to playing)
1032
1178
 
1033
1179
  // Fire the chime for a given config view; returns whether it played.
1034
- const effectiveUrlOf = (view) => view.url || view.defaultUrl;
1180
+ // Each trigger kind maps to its own per-scene audio (normal /
1181
+ // interrupt / error); pending and retry scenes are handled by their
1182
+ // own paths (notifyPending / fireModuleRetry success).
1035
1183
  const firePlay = (view) => {
1036
1184
  if (kind === 'interrupt') {
1037
- if (view.playOnInterrupt && effectiveUrlOf(view)) {
1038
- playChime(view);
1185
+ if (view.playOnInterrupt) {
1186
+ playChime(view, 'interrupt');
1039
1187
  return true;
1040
1188
  }
1041
1189
  return false;
1042
1190
  }
1043
1191
  if (kind === 'error') {
1044
- if (view.playOnError && effectiveUrlOf(view)) {
1045
- playChime(view);
1192
+ if (view.playOnError) {
1193
+ playChime(view, 'error');
1046
1194
  return true;
1047
1195
  }
1048
1196
  return false;
1049
1197
  }
1050
- if (effectiveUrlOf(view)) {
1051
- playChime(view);
1052
- return true;
1053
- }
1054
- return false;
1198
+ const normalSound = (view) => {
1199
+ const s = (view.sounds && view.sounds.normal) || {};
1200
+ return s.url || s.defaultUrl;
1201
+ };
1202
+ playChime(view, 'normal');
1203
+ return normalSound(view) ? true : false;
1055
1204
  };
1056
1205
 
1057
1206
  let played = firePlay(cfg);
1058
1207
  const record = (value) =>
1059
1208
  setConfig({ lastEvent: { at: Date.now(), kind, reason, play: value } });
1060
- if (!played && !cfg.url && !cfg.defaultUrl) {
1209
+ const normalSoundMissing = () => {
1210
+ const s = (cfg.sounds && cfg.sounds.normal) || {};
1211
+ return !s.url && !s.defaultUrl;
1212
+ };
1213
+ if (!played && normalSoundMissing()) {
1061
1214
  // Self-heal: the in-memory config may be stale (e.g. the audio was
1062
1215
  // uploaded after this page mounted, or in another tab). Re-fetch
1063
1216
  // status once and retry before giving up.
@@ -1072,8 +1225,8 @@ if (snap.pendingCount > 0) {
1072
1225
  playOnInterrupt: payload.playOnInterrupt === true,
1073
1226
  playOnError: payload.playOnError !== false,
1074
1227
  playOnPending: payload.playOnPending !== false,
1075
- url: typeof payload.url === 'string' && payload.url ? payload.url : null,
1076
- defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
1228
+ playOnRetry: payload.playOnRetry !== false,
1229
+ sounds: syncSoundsFrom(payload, cfg),
1077
1230
  };
1078
1231
  record(firePlay(fresh));
1079
1232
  } else {
package/lib/index.js CHANGED
@@ -5,7 +5,7 @@ import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
5
5
  import { join, dirname } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
 
8
- // C:/Users/Administrator/.dsh/profiles/web/node_modules/@deepseek-ai/cosmokit/lib/index.js
8
+ // node_modules/@deepseek-ai/cosmokit/lib/index.js
9
9
  function isNullable(value) {
10
10
  return value === null || value === void 0;
11
11
  }
@@ -191,7 +191,7 @@ var Time;
191
191
  Time2.template = template;
192
192
  })(Time || (Time = {}));
193
193
 
194
- // C:/Users/Administrator/.dsh/profiles/web/node_modules/@deepseek-ai/schemastery/lib/index.mjs
194
+ // node_modules/@deepseek-ai/schemastery/lib/index.mjs
195
195
  var kSchema = Symbol.for("schemastery");
196
196
  var kValidationError = Symbol.for("ValidationError");
197
197
  globalThis.__schemastery_index__ ??= 0;
@@ -830,8 +830,21 @@ function writeLog(level, message) {
830
830
  }
831
831
  }
832
832
  var MAX_BYTES = 10 * 1024 * 1024;
833
- var DEFAULT_AUDIO_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "turn-done.wav");
834
- var DEFAULT_AUDIO_URL = "/dsh-done-sound/audio/default";
833
+ var SCENES = ["normal", "interrupt", "error", "pending", "retry"];
834
+ var DEFAULT_AUDIO_FILES = {
835
+ normal: "turn-done.wav",
836
+ interrupt: "turn-interrupt.wav",
837
+ error: "turn-error.wav",
838
+ pending: "turn-pending.wav",
839
+ retry: "turn-retry.wav"
840
+ };
841
+ var DEFAULT_AUDIO_PATHS = Object.fromEntries(
842
+ Object.entries(DEFAULT_AUDIO_FILES).map(([scene, file]) => [
843
+ scene,
844
+ join(dirname(fileURLToPath(import.meta.url)), "..", "assets", file)
845
+ ])
846
+ );
847
+ var DEFAULT_AUDIO_URLS = Object.fromEntries(SCENES.map((s) => [s, `/dsh-done-sound/audio/default-${s}`]));
835
848
  var DEFAULT_AUDIO_MIME = "audio/wav";
836
849
  var AUDIO_MIMES = {
837
850
  "audio/mpeg": "mp3",
@@ -848,14 +861,32 @@ var AUDIO_MIMES = {
848
861
  "audio/flac": "flac",
849
862
  "audio/x-flac": "flac"
850
863
  };
864
+ var SoundMetaSchema = Schema.object({
865
+ fileId: Schema.string().default(""),
866
+ fileName: Schema.string().default(""),
867
+ mime: Schema.string().default(""),
868
+ size: Schema.number().default(0)
869
+ }).default({});
851
870
  var TurnChimeSchema = Schema.object({
852
871
  enabled: Schema.boolean().default(true),
853
872
  volume: Schema.number().min(0).max(1).step(0.01).default(0.8),
854
873
  playOnInterrupt: Schema.boolean().default(false),
855
874
  playOnError: Schema.boolean().default(true),
856
875
  playOnPending: Schema.boolean().default(true),
876
+ playOnRetry: Schema.boolean().default(true),
857
877
  autoRetryOnError: Schema.boolean().default(true),
858
878
  retryDelaySeconds: Schema.number().min(10).max(300).step(5).default(60),
879
+ // One independently configurable audio per trigger scene.
880
+ sounds: Schema.object({
881
+ normal: SoundMetaSchema,
882
+ interrupt: SoundMetaSchema,
883
+ error: SoundMetaSchema,
884
+ pending: SoundMetaSchema,
885
+ retry: SoundMetaSchema
886
+ }).default({}),
887
+ // Legacy single-audio field (pre-0.1.9). Kept for migration: when
888
+ // `sounds.normal` is empty, the old `audio` value is used for the normal
889
+ // scene until the user picks a new one.
859
890
  audio: Schema.object({
860
891
  fileId: Schema.string().default(""),
861
892
  fileName: Schema.string().default(""),
@@ -871,9 +902,30 @@ function apply(ctx) {
871
902
  const dataDir = join(profileDir, ".dsh-done-sound");
872
903
  const scope = ctx.settings.register(NS, TurnChimeSchema, { base: {} });
873
904
  const audioPath = (fileId) => join(dataDir, `${fileId}.bin`);
905
+ const isScene = (s) => typeof s === "string" && SCENES.includes(s);
906
+ const sceneAudio = (value, scene) => {
907
+ const s = value.sounds?.[scene];
908
+ if (s && typeof s.fileId === "string" && s.fileId) return s;
909
+ if (scene === "normal" && value.audio && typeof value.audio.fileId === "string" && value.audio.fileId) {
910
+ return value.audio;
911
+ }
912
+ return { fileId: "", fileName: "", mime: "", size: 0 };
913
+ };
874
914
  const statusPayload = () => {
875
915
  const value = scope.get();
876
- const fileId = value.audio?.fileId ?? "";
916
+ const sounds = {};
917
+ for (const scene of SCENES) {
918
+ const meta = sceneAudio(value, scene);
919
+ const fileId = meta.fileId ?? "";
920
+ sounds[scene] = {
921
+ fileId,
922
+ fileName: meta.fileName ?? "",
923
+ mime: meta.mime ?? "",
924
+ size: meta.size ?? 0,
925
+ url: fileId ? `${ROUTE_PREFIX}/audio/${fileId}` : null,
926
+ defaultUrl: DEFAULT_AUDIO_URLS[scene]
927
+ };
928
+ }
877
929
  return {
878
930
  ok: true,
879
931
  version: pluginVersion(),
@@ -882,33 +934,31 @@ function apply(ctx) {
882
934
  playOnInterrupt: value.playOnInterrupt,
883
935
  playOnError: value.playOnError,
884
936
  playOnPending: value.playOnPending,
937
+ playOnRetry: value.playOnRetry,
885
938
  autoRetryOnError: value.autoRetryOnError,
886
939
  retryDelaySeconds: value.retryDelaySeconds,
887
- audio: {
888
- fileId,
889
- fileName: value.audio?.fileName ?? "",
890
- mime: value.audio?.mime ?? "",
891
- size: value.audio?.size ?? 0
892
- },
893
- url: fileId ? `${ROUTE_PREFIX}/audio/${fileId}` : null,
894
- defaultUrl: DEFAULT_AUDIO_URL,
940
+ sounds,
895
941
  logPath: datedLogPath(),
896
942
  logFileName: datedLogFileName()
897
943
  };
898
944
  };
899
- const clearAudio = async () => {
945
+ const clearAudio = async (scene) => {
900
946
  const value = scope.get();
901
- const fileId = value.audio?.fileId;
947
+ const target = isScene(scene) ? scene : "normal";
948
+ const meta = sceneAudio(value, target);
949
+ const fileId = meta.fileId;
902
950
  if (fileId) {
903
951
  try {
904
952
  await unlink(audioPath(fileId));
905
953
  } catch {
906
954
  }
907
955
  }
908
- await scope.update({ audio: { fileId: "", fileName: "", mime: "", size: 0 } });
909
- writeLog("INFO", "audio cleared");
956
+ const patch = { sounds: { ...value.sounds ?? {}, [target]: { fileId: "", fileName: "", mime: "", size: 0 } } };
957
+ if (target === "normal") patch.audio = { fileId: "", fileName: "", mime: "", size: 0 };
958
+ await scope.update(patch);
959
+ writeLog("INFO", `audio cleared (${target})`);
910
960
  };
911
- const setAudio = async (dataUrl, fileName) => {
961
+ const setAudio = async (dataUrl, fileName, scene) => {
912
962
  const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
913
963
  if (!match) throw new Error("invalid audio data (need data:<mime>;base64,<data>)");
914
964
  const mime = match[1].toLowerCase();
@@ -917,20 +967,23 @@ function apply(ctx) {
917
967
  const buffer = Buffer.from(match[2], "base64");
918
968
  if (buffer.length === 0) throw new Error("empty audio content");
919
969
  if (buffer.length > MAX_BYTES) throw new Error(`audio exceeds 10MB (got ${buffer.length} bytes)`);
970
+ const target = isScene(scene) ? scene : "normal";
920
971
  const fileId = randomUUID();
921
972
  await mkdir(dataDir, { recursive: true });
922
973
  await writeFile(audioPath(fileId), buffer);
923
- const previous = scope.get().audio?.fileId;
924
- await scope.update({
925
- audio: { fileId, fileName: fileName || "chime", mime, size: buffer.length }
926
- });
974
+ const previous = sceneAudio(scope.get(), target).fileId;
975
+ const patch = {
976
+ sounds: { ...scope.get().sounds ?? {}, [target]: { fileId, fileName: fileName || "chime", mime, size: buffer.length } }
977
+ };
978
+ if (target === "normal") patch.audio = { fileId, fileName: fileName || "chime", mime, size: buffer.length };
979
+ await scope.update(patch);
927
980
  if (previous && previous !== fileId) {
928
981
  try {
929
982
  await unlink(audioPath(previous));
930
983
  } catch {
931
984
  }
932
985
  }
933
- writeLog("INFO", `audio stored: ${fileName || "chime"} (${buffer.length} bytes, ${mime})`);
986
+ writeLog("INFO", `audio stored (${target}): ${fileName || "chime"} (${buffer.length} bytes, ${mime})`);
934
987
  return fileId;
935
988
  };
936
989
  ctx.commands.register({
@@ -1022,9 +1075,11 @@ function apply(ctx) {
1022
1075
  pathname = "/";
1023
1076
  }
1024
1077
  const method = (req.method ?? "GET").toUpperCase();
1025
- if (pathname === DEFAULT_AUDIO_URL) {
1078
+ const defaultMatch = /^\/dsh-done-sound\/audio\/default-([A-Za-z0-9-]+)$/.exec(pathname);
1079
+ if (defaultMatch && SCENES.includes(defaultMatch[1])) {
1080
+ const scene = defaultMatch[1];
1026
1081
  try {
1027
- const buffer = await readFile(DEFAULT_AUDIO_PATH);
1082
+ const buffer = await readFile(DEFAULT_AUDIO_PATHS[scene]);
1028
1083
  res.writeHead(200, {
1029
1084
  "Content-Type": DEFAULT_AUDIO_MIME,
1030
1085
  "Content-Length": buffer.length,
@@ -1041,8 +1096,9 @@ function apply(ctx) {
1041
1096
  const audioMatch = /^\/dsh-done-sound\/audio\/([A-Za-z0-9-]+)$/.exec(pathname);
1042
1097
  if (audioMatch) {
1043
1098
  const fileId = audioMatch[1];
1044
- const audio = scope.get().audio;
1045
- if (!FILE_ID_RE.test(fileId) || !audio || audio.fileId !== fileId) {
1099
+ const value = scope.get();
1100
+ const meta = SCENES.map((s) => sceneAudio(value, s)).find((m) => m.fileId === fileId);
1101
+ if (!FILE_ID_RE.test(fileId) || !meta) {
1046
1102
  res.writeHead(404);
1047
1103
  res.end("not found");
1048
1104
  return;
@@ -1050,7 +1106,7 @@ function apply(ctx) {
1050
1106
  try {
1051
1107
  const buffer = await readFile(audioPath(fileId));
1052
1108
  res.writeHead(200, {
1053
- "Content-Type": audio.mime || "application/octet-stream",
1109
+ "Content-Type": meta.mime || "application/octet-stream",
1054
1110
  "Content-Length": buffer.length,
1055
1111
  "Cache-Control": "no-cache",
1056
1112
  "X-Content-Type-Options": "nosniff"
@@ -1075,6 +1131,7 @@ function apply(ctx) {
1075
1131
  if (typeof body.playOnInterrupt === "boolean") patch.playOnInterrupt = body.playOnInterrupt;
1076
1132
  if (typeof body.playOnError === "boolean") patch.playOnError = body.playOnError;
1077
1133
  if (typeof body.playOnPending === "boolean") patch.playOnPending = body.playOnPending;
1134
+ if (typeof body.playOnRetry === "boolean") patch.playOnRetry = body.playOnRetry;
1078
1135
  if (typeof body.autoRetryOnError === "boolean") patch.autoRetryOnError = body.autoRetryOnError;
1079
1136
  if (typeof body.retryDelaySeconds === "number" && body.retryDelaySeconds >= 10 && body.retryDelaySeconds <= 300) {
1080
1137
  patch.retryDelaySeconds = Math.round(body.retryDelaySeconds / 5) * 5;
@@ -1085,12 +1142,15 @@ function apply(ctx) {
1085
1142
  }
1086
1143
  if (pathname === "/dsh-done-sound/api/audio" && method === "POST") {
1087
1144
  const body = await readJsonBody(req);
1088
- await setAudio(typeof body.dataUrl === "string" ? body.dataUrl : "", typeof body.fileName === "string" ? body.fileName : "");
1145
+ const scene = typeof body.scene === "string" && SCENES.includes(body.scene) ? body.scene : "normal";
1146
+ await setAudio(typeof body.dataUrl === "string" ? body.dataUrl : "", typeof body.fileName === "string" ? body.fileName : "", scene);
1089
1147
  sendJson(res, 200, statusPayload());
1090
1148
  return;
1091
1149
  }
1092
1150
  if (pathname === "/dsh-done-sound/api/clear" && method === "POST") {
1093
- await clearAudio();
1151
+ const body = await readJsonBody(req);
1152
+ const scene = typeof body.scene === "string" && SCENES.includes(body.scene) ? body.scene : "normal";
1153
+ await clearAudio(scene);
1094
1154
  sendJson(res, 200, statusPayload());
1095
1155
  return;
1096
1156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-done-sound",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Play a user-chosen sound whenever a conversation finishes in the DeepSeek Harness web GUI",
5
5
  "license": "MIT",
6
6
  "type": "module",