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 +36 -13
- package/assets/turn-error.wav +0 -0
- package/assets/turn-interrupt.wav +0 -0
- package/assets/turn-pending.wav +0 -0
- package/assets/turn-retry.wav +0 -0
- package/lib/client.js +130 -57
- package/lib/index.js +90 -30
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
# dsh-done-sound
|
|
2
2
|
|
|
3
|
-
DeepSeek Harness Web GUI
|
|
3
|
+
DeepSeek Harness Web GUI 的对话音效插件。不同触发情况可以分别设置音频提醒,并为每种情况提供内置默认音效。
|
|
4
4
|
|
|
5
5
|
## 功能
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
-
|
|
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
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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
|
|
34
|
-
- **Client 半区**(`lib/client.js`):`settings.section` 设置卡片,以 fetch 调用 HTTP API
|
|
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
|
-
修改
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1125
|
-
playChime(view);
|
|
1192
|
+
if (view.playOnError) {
|
|
1193
|
+
playChime(view, 'error');
|
|
1126
1194
|
return true;
|
|
1127
1195
|
}
|
|
1128
1196
|
return false;
|
|
1129
1197
|
}
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
return
|
|
1133
|
-
}
|
|
1134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1156
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
834
|
-
var
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
909
|
-
|
|
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().
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
1045
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
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
|
}
|