dsh-done-sound 0.1.8 → 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
@@ -92,6 +92,14 @@ window.__ModuleLoader__.load({
92
92
  exportSavedFallbackHint: '未弹出保存窗口,文件已存到浏览器默认下载文件夹(通常 C:\\Users\\你的用户名\\Downloads)',
93
93
  exportLogPath: '原始日志路径:{path}(复制到资源管理器地址栏可打开)',
94
94
  exportLogPathUnknown: '原始日志路径未知(可在插件目录 logs 文件夹查看)',
95
+ sceneNormal: '正常完成音',
96
+ sceneInterrupt: '中断音',
97
+ sceneError: '出错音',
98
+ scenePending: '等待确认音',
99
+ sceneRetry: '自动重连成功音',
100
+ defaultSound: '(默认音)',
101
+ retrySound: '重连成功也响',
102
+ retrySoundHint: '自动发送「继续」成功后播放提示音(默认开启)',
95
103
  version: '当前版本',
96
104
  updateAvailable: '有新版本',
97
105
  upToDate: '已是最新',
@@ -145,6 +153,14 @@ window.__ModuleLoader__.load({
145
153
  exportSavedFallbackHint: 'No save dialog shown; saved to the browser default Downloads folder (usually C:\\Users\\YourName\\Downloads)',
146
154
  exportLogPath: 'Original log path: {path} (copy into File Explorer address bar to open)',
147
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)',
148
164
  version: 'Version',
149
165
  updateAvailable: 'Update available',
150
166
  upToDate: 'Up to date',
@@ -198,6 +214,29 @@ window.__ModuleLoader__.load({
198
214
  }
199
215
 
200
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
+ };
201
240
  const configStore = {
202
241
  value: {
203
242
  enabled: true,
@@ -205,17 +244,14 @@ window.__ModuleLoader__.load({
205
244
  playOnInterrupt: false,
206
245
  playOnError: true,
207
246
  playOnPending: true,
247
+ playOnRetry: true,
208
248
  autoRetryOnError: true,
209
249
  retryDelaySeconds: 60,
210
250
  version: null,
211
251
  latestVersion: null,
212
252
  updateStatus: null, // 'update' | 'uptodate' | 'error' | null
213
253
  retryCooldownUntil: null, // timestamp until which auto-retry is cooling down
214
- url: null,
215
- defaultUrl: null,
216
- fileName: '',
217
- mime: '',
218
- size: 0,
254
+ sounds: emptySounds(),
219
255
  loaded: false,
220
256
  lastEvent: null,
221
257
  },
@@ -270,8 +306,9 @@ window.__ModuleLoader__.load({
270
306
  }
271
307
  }
272
308
 
273
- function playChime(cfg) {
274
- 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;
275
312
  if (!cfg.enabled || !url) return;
276
313
  try {
277
314
  const audio = new Audio(url);
@@ -360,6 +397,13 @@ window.__ModuleLoader__.load({
360
397
  } catch {
361
398
  // ignore
362
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
+ }
363
407
  setConfig({
364
408
  retryCooldownUntil: null,
365
409
  lastEvent: { at: Date.now(), kind: 'error', reason: 'error', play: false, retried: true, retryFailed: false },
@@ -410,22 +454,20 @@ window.__ModuleLoader__.load({
410
454
  pluginCtx = ctx;
411
455
  const syncConfig = (payload) => {
412
456
  if (!payload || typeof payload !== 'object') return;
457
+ const sounds = syncSoundsFrom(payload, null);
413
458
  setConfig({
414
459
  enabled: payload.enabled === true,
415
460
  volume: typeof payload.volume === 'number' ? payload.volume : 0.8,
416
461
  playOnInterrupt: payload.playOnInterrupt === true,
417
462
  playOnError: payload.playOnError !== false,
418
463
  playOnPending: payload.playOnPending !== false,
464
+ playOnRetry: payload.playOnRetry !== false,
419
465
  autoRetryOnError: payload.autoRetryOnError !== false,
420
466
  retryDelaySeconds: typeof payload.retryDelaySeconds === 'number' ? payload.retryDelaySeconds : 60,
421
467
  version: typeof payload.version === 'string' ? payload.version : null,
422
468
  logPath: typeof payload.logPath === 'string' && payload.logPath ? payload.logPath : null,
423
469
  logFileName: typeof payload.logFileName === 'string' && payload.logFileName ? payload.logFileName : 'dsh-done-sound.log',
424
- url: typeof payload.url === 'string' && payload.url ? payload.url : null,
425
- defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
426
- fileName: payload.audio && typeof payload.audio.fileName === 'string' ? payload.audio.fileName : '',
427
- mime: payload.audio && typeof payload.audio.mime === 'string' ? payload.audio.mime : '',
428
- size: payload.audio && typeof payload.audio.size === 'number' ? payload.audio.size : 0,
470
+ sounds,
429
471
  loaded: true,
430
472
  });
431
473
  };
@@ -627,7 +669,7 @@ window.__ModuleLoader__.load({
627
669
  }
628
670
  };
629
671
 
630
- const onPick = (event) => {
672
+ const onPick = (event, scene) => {
631
673
  const file = event.target.files && event.target.files[0];
632
674
  if (event.target) event.target.value = '';
633
675
  if (!file) return;
@@ -638,7 +680,7 @@ window.__ModuleLoader__.load({
638
680
  setError(L('readFailed'));
639
681
  return;
640
682
  }
641
- await run('/dsh-done-sound/api/audio', { dataUrl, fileName: file.name });
683
+ await run('/dsh-done-sound/api/audio', { dataUrl, fileName: file.name, scene });
642
684
  };
643
685
  reader.onerror = () => setError(L('readFailed'));
644
686
  reader.readAsDataURL(file);
@@ -669,6 +711,34 @@ window.__ModuleLoader__.load({
669
711
  if (volumeTimerRef.current) clearTimeout(volumeTimerRef.current);
670
712
  }, []);
671
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
+
672
742
  return react.createElement(
673
743
  'div',
674
744
  { className: 'dtc-card' },
@@ -728,31 +798,7 @@ window.__ModuleLoader__.load({
728
798
  ),
729
799
  exportInfo !== null && react.createElement('div', { className: 'dtc-ok' }, exportInfo),
730
800
 
731
- react.createElement(
732
- 'div',
733
- { className: 'dtc-row' },
734
- react.createElement('span', { className: 'dtc-muted' }, L('audio') + ':' + (cfg.fileName || L('none')) + (cfg.size ? ' (' + formatSize(cfg.size) + ')' : '')),
735
- ),
736
- react.createElement(
737
- 'div',
738
- { className: 'dtc-row' },
739
- react.createElement(
740
- 'label',
741
- { className: 'dtc-btn dtc-file' },
742
- react.createElement('input', { type: 'file', accept: 'audio/*', disabled: busy, onChange: onPick }),
743
- L('pick'),
744
- ),
745
- react.createElement(
746
- 'button',
747
- { className: 'dtc-btn', type: 'button', disabled: busy || !cfg.url, onClick: () => playChime(cfg) },
748
- L('preview'),
749
- ),
750
- react.createElement(
751
- 'button',
752
- { className: 'dtc-btn', type: 'button', disabled: busy || !cfg.url, onClick: () => run('/dsh-done-sound/api/clear', {}) },
753
- L('clear'),
754
- ),
755
- ),
801
+ SCENES.map((scene) => sceneRow(scene)),
756
802
 
757
803
  react.createElement(
758
804
  'div',
@@ -824,6 +870,24 @@ window.__ModuleLoader__.load({
824
870
  ),
825
871
  react.createElement('span', { className: 'dtc-muted' }, L('pendingHint')),
826
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
+ ),
827
891
  react.createElement(
828
892
  'div',
829
893
  { className: 'dtc-row' },
@@ -1005,10 +1069,13 @@ window.__ModuleLoader__.load({
1005
1069
  // or the audio was changed elsewhere), re-fetch status once and play
1006
1070
  // with the fresh view instead of silently skipping.
1007
1071
  const notifyPending = () => {
1008
- 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
+ };
1009
1076
  const record = (value) => setConfig({ lastEvent: { at: Date.now(), kind: 'pending', reason: 'pending', play: value } });
1010
1077
  if (canPlay(cfg)) {
1011
- playChime(cfg);
1078
+ playChime(cfg, 'pending');
1012
1079
  record(true);
1013
1080
  return;
1014
1081
  }
@@ -1020,11 +1087,10 @@ window.__ModuleLoader__.load({
1020
1087
  ...cfg,
1021
1088
  enabled: payload.enabled === true,
1022
1089
  playOnPending: payload.playOnPending !== false,
1023
- url: typeof payload.url === 'string' && payload.url ? payload.url : null,
1024
- defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
1090
+ sounds: syncSoundsFrom(payload, cfg),
1025
1091
  };
1026
1092
  if (canPlay(fresh)) {
1027
- playChime(fresh);
1093
+ playChime(fresh, 'pending');
1028
1094
  record(true);
1029
1095
  }
1030
1096
  })
@@ -1111,33 +1177,40 @@ if (snap.pendingCount > 0) {
1111
1177
  else kind = 'normal'; // 'completed' (and unknown future reasons default to playing)
1112
1178
 
1113
1179
  // Fire the chime for a given config view; returns whether it played.
1114
- 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).
1115
1183
  const firePlay = (view) => {
1116
1184
  if (kind === 'interrupt') {
1117
- if (view.playOnInterrupt && effectiveUrlOf(view)) {
1118
- playChime(view);
1185
+ if (view.playOnInterrupt) {
1186
+ playChime(view, 'interrupt');
1119
1187
  return true;
1120
1188
  }
1121
1189
  return false;
1122
1190
  }
1123
1191
  if (kind === 'error') {
1124
- if (view.playOnError && effectiveUrlOf(view)) {
1125
- playChime(view);
1192
+ if (view.playOnError) {
1193
+ playChime(view, 'error');
1126
1194
  return true;
1127
1195
  }
1128
1196
  return false;
1129
1197
  }
1130
- if (effectiveUrlOf(view)) {
1131
- playChime(view);
1132
- return true;
1133
- }
1134
- 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;
1135
1204
  };
1136
1205
 
1137
1206
  let played = firePlay(cfg);
1138
1207
  const record = (value) =>
1139
1208
  setConfig({ lastEvent: { at: Date.now(), kind, reason, play: value } });
1140
- 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()) {
1141
1214
  // Self-heal: the in-memory config may be stale (e.g. the audio was
1142
1215
  // uploaded after this page mounted, or in another tab). Re-fetch
1143
1216
  // status once and retry before giving up.
@@ -1152,8 +1225,8 @@ if (snap.pendingCount > 0) {
1152
1225
  playOnInterrupt: payload.playOnInterrupt === true,
1153
1226
  playOnError: payload.playOnError !== false,
1154
1227
  playOnPending: payload.playOnPending !== false,
1155
- url: typeof payload.url === 'string' && payload.url ? payload.url : null,
1156
- defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
1228
+ playOnRetry: payload.playOnRetry !== false,
1229
+ sounds: syncSoundsFrom(payload, cfg),
1157
1230
  };
1158
1231
  record(firePlay(fresh));
1159
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.8",
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",