dsh-done-sound 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 dsh-turn-chime contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # dsh-done-sound
2
+
3
+ DeepSeek Harness Web GUI 的「对话完成音效」插件:你在设置里选一段音频,每次 Agent 完整结束一轮对话时自动播放提示音。
4
+
5
+ ## 功能
6
+
7
+ - **设置卡片**(设置 → 对话完成音效):选择音频文件(mp3 / wav / ogg / webm / m4a / aac / flac,≤2MB)、试听、清除、音量调节。
8
+ - **完成检测**:以宿主 `turn/end` 事件的 reason(正常/中断/出错/超长)为权威触发信号,快慢对话都不漏、不误判;每轮只响一次。
9
+ - **触发开关**:
10
+ - `中断时也响`(默认**关**):手动停止生成时是否也播放。
11
+ - `出错时也响`(默认**开**):对话以错误结束(turn-error / 超长截断)时是否也播放。
12
+ - 音频文件保存在 profile 目录 `.dsh-done-sound/`,经 Host 路由 `/dsh-done-sound/audio/<fileId>` 播放,配置持久化在设置中。
13
+
14
+ ## 安装
15
+
16
+ ```sh
17
+ dsh plugin --profile web add dsh-done-sound
18
+ ```
19
+
20
+ 本地开发:
21
+
22
+ ```sh
23
+ dsh plugin --profile web add link:F:\0.AI-CodeProject\DSHProject\0.日常聊天\dsh-done-sound
24
+ ```
25
+
26
+ 安装后重启 `dsh web`,打开设置页即可看到「对话完成音效」卡片。
27
+
28
+ ## 架构
29
+
30
+ - **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 的模块解析)。
31
+ - **Client 半区**(`lib/client.js`):`settings.section` 设置卡片(**fetch 调 HTTP API,不依赖会话/命令 RPC**)+ `conversation.session.header.utilities` 完成检测器(`useSession` 订阅 `ConversationSnapshot`,`partial/running → 空闲` 转移触发,按最后节点 seq 去重)。
32
+
33
+ ## 开发
34
+
35
+ ```sh
36
+ pnpm install # 安装 esbuild
37
+ pnpm run build # 打包 src/index.js -> lib/index.js(自包含)
38
+ pnpm run check # 语法检查两个产物
39
+ ```
40
+
41
+ 修改 host 源码后重新 `pnpm run build`;client 半区直接改 `lib/client.js`(`__ModuleLoader__` 格式即运行时契约,无需打包)。
42
+
43
+ ## 音效库下载
44
+
45
+ 内置提示音不够用?社区精选音效库(百度网盘,永久有效)即将上线,届时在此补充链接与提取码:
46
+
47
+ - 链接:(待补充)
48
+ - 提取码:(待补充)
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,8 @@
1
+ # dsh-done-sound bundle patch: inserts the plugin row into the web profile
2
+ # roster. The node half (exports ".") runs in the host process (settings
3
+ # scope, audio file storage, HTTP route, command); the `dsh.client`
4
+ # declaration in package.json makes the browser half (exports "./client")
5
+ # load in the web GUI.
6
+ - insert:
7
+ - id: dsh-done-sound
8
+ name: 'dsh-done-sound'
package/lib/client.js ADDED
@@ -0,0 +1,536 @@
1
+ /**
2
+ * dsh-done-sound — browser half.
3
+ *
4
+ * Two surfaces:
5
+ * 1. A Settings card (`settings.section`): pick an audio file (uploaded to
6
+ * the host via the same-origin JSON API, served back over
7
+ * `/dsh-done-sound/audio/<fileId>`), preview, volume, and the enabled /
8
+ * interrupt / error switches. Transport is plain fetch to the plugin's
9
+ * HTTP API — no session, no slash-command RPC involved.
10
+ * 2. A zero-render detector mounted in `conversation.session.header.utilities`
11
+ * (session scope, receives `useSession` + `sessionId`): watches the
12
+ * ConversationSnapshot and plays the chime when a turn finishes.
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: 'dsh-done-sound',
16
+ factory: (require) => {
17
+ var module = { exports: {} };
18
+ var exports = module.exports;
19
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
20
+
21
+ let react = require('react');
22
+
23
+ // ---- styles (injected once, dsh-balance data-plugin-css pattern) ----
24
+ const CSS_ID = 'dsh-done-sound/styles';
25
+ const css = [
26
+ '.dtc-card{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:10px;flex-direction:column;gap:10px;padding:12px 14px;font-size:13px;line-height:1.5;display:flex}',
27
+ '.dtc-head{justify-content:space-between;align-items:center;gap:8px;font-weight:600;display:flex}',
28
+ '.dtc-row{justify-content:space-between;align-items:center;gap:12px;display:flex}',
29
+ '.dtc-muted{color:var(--dsw-alias-label-secondary);font-size:12px}',
30
+ '.dtc-err{color:var(--dsw-alias-state-error-primary);white-space:pre-wrap;font-size:12px}',
31
+ '.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
+ '.dtc-btn:disabled{opacity:.5;cursor:default}',
33
+ '.dtc-file{display:inline-flex;align-items:center}',
34
+ '.dtc-file input{display:none}',
35
+ '.dtc-slider{flex:1;max-width:220px}',
36
+ '.dtc-vol{min-width:44px;text-align:right;font-variant-numeric:tabular-nums}',
37
+ '.dtc-toggle{display:flex;align-items:center;gap:6px;cursor:pointer}',
38
+ ].join('');
39
+ if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']') === null) {
40
+ const tag = document.createElement('style');
41
+ tag.dataset.plugin = 'dsh-done-sound';
42
+ tag.dataset.pluginCss = CSS_ID;
43
+ tag.textContent = css;
44
+ document.head.appendChild(tag);
45
+ }
46
+
47
+ // ---- i18n ----
48
+ const COPY = {
49
+ zh: {
50
+ title: '对话完成音效',
51
+ enabled: '启用',
52
+ enabledHint: '每轮对话完整结束时播放提示音',
53
+ volume: '音量',
54
+ audio: '音频',
55
+ none: '未设置',
56
+ pick: '选择音频…',
57
+ preview: '试听',
58
+ clear: '清除',
59
+ interrupt: '中断时也响',
60
+ interruptHint: '手动停止生成时也播放(默认关闭)',
61
+ error: '出错时也响',
62
+ errorHint: '对话以错误结束时也播放(默认开启)',
63
+ saved: '已保存:',
64
+ readFailed: '读取文件失败',
65
+ apiFailed: '无法连接插件服务(Host 未加载?),请重启 dsh web',
66
+ noSession: '打开一个会话后配置',
67
+ loading: '读取配置中…',
68
+ lastTrigger: '最近触发',
69
+ evNormal: '正常完成',
70
+ evInterrupt: '中断',
71
+ evError: '出错',
72
+ evPlayed: '已播放',
73
+ evNotPlayed: '未播放',
74
+ },
75
+ en: {
76
+ title: 'Turn Chime',
77
+ enabled: 'Enabled',
78
+ enabledHint: 'Play a chime when a turn finishes',
79
+ volume: 'Volume',
80
+ audio: 'Audio',
81
+ none: 'None',
82
+ pick: 'Choose audio…',
83
+ preview: 'Preview',
84
+ clear: 'Clear',
85
+ interrupt: 'Play on interrupt',
86
+ interruptHint: 'Also play when generation is stopped (default off)',
87
+ error: 'Play on error',
88
+ errorHint: 'Also play when a turn ends in error (default on)',
89
+ saved: 'Saved:',
90
+ readFailed: 'Failed to read file',
91
+ apiFailed: 'Cannot reach the plugin host service (host not loaded?), restart dsh web',
92
+ noSession: 'Open a session to configure',
93
+ loading: 'Loading…',
94
+ lastTrigger: 'Last trigger',
95
+ evNormal: 'completed',
96
+ evInterrupt: 'interrupted',
97
+ evError: 'error',
98
+ evPlayed: 'played',
99
+ evNotPlayed: 'not played',
100
+ },
101
+ };
102
+ function resolveLang() {
103
+ if (typeof document !== 'undefined') {
104
+ const host = document.documentElement.lang || navigator.language || 'zh-CN';
105
+ return /^zh/i.test(host) ? 'zh' : 'en';
106
+ }
107
+ return 'zh';
108
+ }
109
+ function t(lang, key) {
110
+ return COPY[lang][key] ?? COPY.zh[key] ?? String(key);
111
+ }
112
+ function formatSize(bytes) {
113
+ if (!bytes) return '';
114
+ if (bytes < 1024) return bytes + ' B';
115
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
116
+ return (bytes / 1024 / 1024).toFixed(2) + ' MB';
117
+ }
118
+ function formatEventTime(at) {
119
+ try {
120
+ return new Date(at).toLocaleTimeString('zh-CN', { hour12: false });
121
+ } catch {
122
+ return '';
123
+ }
124
+ }
125
+
126
+ // ---- tiny shared config store (card writes, detector reads) ----
127
+ const configStore = {
128
+ value: {
129
+ enabled: true,
130
+ volume: 0.8,
131
+ playOnInterrupt: false,
132
+ playOnError: true,
133
+ url: null,
134
+ fileName: '',
135
+ mime: '',
136
+ size: 0,
137
+ loaded: false,
138
+ lastEvent: null,
139
+ },
140
+ listeners: new Set(),
141
+ };
142
+ function setConfig(patch) {
143
+ configStore.value = { ...configStore.value, ...patch };
144
+ configStore.listeners.forEach((fn) => {
145
+ try {
146
+ fn(configStore.value);
147
+ } catch {
148
+ // listener errors must not break the store
149
+ }
150
+ });
151
+ }
152
+ function subscribeConfig(fn) {
153
+ configStore.listeners.add(fn);
154
+ return () => configStore.listeners.delete(fn);
155
+ }
156
+ function useConfig() {
157
+ const [cfg, setCfg] = react.useState(configStore.value);
158
+ react.useEffect(() => subscribeConfig(setCfg), []);
159
+ return cfg;
160
+ }
161
+ function playChime(cfg) {
162
+ if (!cfg.enabled || !cfg.url) return;
163
+ try {
164
+ const audio = new Audio(cfg.url);
165
+ audio.volume = cfg.volume;
166
+ audio.play().catch(() => {});
167
+ } catch {
168
+ // ignore playback failures (autoplay policy, missing audio device)
169
+ }
170
+ }
171
+
172
+ // ---- same-origin JSON API transport (no session needed) ----
173
+ async function api(pathname, { method = 'GET', body } = {}) {
174
+ let res;
175
+ try {
176
+ res = await fetch(
177
+ pathname,
178
+ method === 'GET'
179
+ ? { method: 'GET' }
180
+ : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body ?? {}) },
181
+ );
182
+ } catch (e) {
183
+ throw new Error('network: ' + (e && e.message ? e.message : String(e)));
184
+ }
185
+ let payload = null;
186
+ try {
187
+ payload = await res.json();
188
+ } catch {
189
+ // non-JSON response
190
+ }
191
+ if (!res.ok || !payload || payload.ok === false) {
192
+ const message = payload && typeof payload.error === 'string' ? payload.error : 'HTTP ' + res.status;
193
+ throw new Error(message);
194
+ }
195
+ return payload;
196
+ }
197
+
198
+ function apply(ctx) {
199
+ const syncConfig = (payload) => {
200
+ if (!payload || typeof payload !== 'object') return;
201
+ setConfig({
202
+ enabled: payload.enabled === true,
203
+ volume: typeof payload.volume === 'number' ? payload.volume : 0.8,
204
+ playOnInterrupt: payload.playOnInterrupt === true,
205
+ playOnError: payload.playOnError !== false,
206
+ url: typeof payload.url === 'string' && payload.url ? payload.url : null,
207
+ fileName: payload.audio && typeof payload.audio.fileName === 'string' ? payload.audio.fileName : '',
208
+ mime: payload.audio && typeof payload.audio.mime === 'string' ? payload.audio.mime : '',
209
+ size: payload.audio && typeof payload.audio.size === 'number' ? payload.audio.size : 0,
210
+ loaded: true,
211
+ });
212
+ };
213
+
214
+ // ---- Settings card (global slot) ----
215
+ const TurnChimeCard = (props) => {
216
+ const cfg = useConfig();
217
+ const [error, setError] = react.useState(null);
218
+ const [busy, setBusy] = react.useState(false);
219
+ const [lang] = react.useState(resolveLang());
220
+ const L = (key) => t(lang, key);
221
+
222
+ react.useEffect(() => {
223
+ let disposed = false;
224
+ (async () => {
225
+ try {
226
+ const payload = await api('/dsh-done-sound/api/status');
227
+ if (!disposed) syncConfig(payload);
228
+ } catch (e) {
229
+ if (!disposed) {
230
+ setError(L('apiFailed') + ' (' + (e && e.message ? e.message : String(e)) + ')');
231
+ setConfig({ loaded: true });
232
+ }
233
+ }
234
+ })();
235
+ return () => {
236
+ disposed = true;
237
+ };
238
+ }, []);
239
+
240
+ const run = async (pathname, body) => {
241
+ setBusy(true);
242
+ setError(null);
243
+ try {
244
+ const payload = await api(pathname, { method: 'POST', body });
245
+ syncConfig(payload);
246
+ return payload;
247
+ } catch (e) {
248
+ setError(L('apiFailed') + ' (' + (e && e.message ? e.message : String(e)) + ')');
249
+ setConfig({ loaded: true });
250
+ return null;
251
+ } finally {
252
+ setBusy(false);
253
+ }
254
+ };
255
+
256
+ const onPick = (event) => {
257
+ const file = event.target.files && event.target.files[0];
258
+ if (event.target) event.target.value = '';
259
+ if (!file) return;
260
+ const reader = new FileReader();
261
+ reader.onload = async () => {
262
+ const dataUrl = String(reader.result || '');
263
+ if (!dataUrl.startsWith('data:')) {
264
+ setError(L('readFailed'));
265
+ return;
266
+ }
267
+ await run('/dsh-done-sound/api/audio', { dataUrl, fileName: file.name });
268
+ };
269
+ reader.onerror = () => setError(L('readFailed'));
270
+ reader.readAsDataURL(file);
271
+ };
272
+
273
+ // ---- volume: local draft while dragging + debounced persist ----
274
+ const [volumeDraft, setVolumeDraft] = react.useState(null);
275
+ const volumeTimerRef = react.useRef(null);
276
+ const volumeValue = volumeDraft !== null ? volumeDraft : Math.round((cfg.volume || 0) * 100);
277
+
278
+ const onVolumeInput = (e) => {
279
+ const value = Number(e.target.value);
280
+ setVolumeDraft(value);
281
+ if (volumeTimerRef.current) clearTimeout(volumeTimerRef.current);
282
+ volumeTimerRef.current = setTimeout(async () => {
283
+ volumeTimerRef.current = null;
284
+ try {
285
+ const payload = await api('/dsh-done-sound/api/config', { method: 'POST', body: { volume: value / 100 } });
286
+ syncConfig(payload);
287
+ setVolumeDraft(null); // follow the saved value again
288
+ } catch (err) {
289
+ setError(L('apiFailed') + ' (' + (err && err.message ? err.message : String(err)) + ')');
290
+ setVolumeDraft(null); // write failed -> snap back to saved value
291
+ }
292
+ }, 300);
293
+ };
294
+ react.useEffect(() => () => {
295
+ if (volumeTimerRef.current) clearTimeout(volumeTimerRef.current);
296
+ }, []);
297
+
298
+ return react.createElement(
299
+ 'div',
300
+ { className: 'dtc-card' },
301
+ react.createElement(
302
+ 'div',
303
+ { className: 'dtc-head' },
304
+ react.createElement('span', null, L('title')),
305
+ react.createElement(
306
+ 'label',
307
+ { className: 'dtc-toggle' },
308
+ react.createElement('input', {
309
+ type: 'checkbox',
310
+ checked: cfg.enabled,
311
+ disabled: busy,
312
+ onChange: (e) => {
313
+ run('/dsh-done-sound/api/config', { enabled: e.target.checked });
314
+ },
315
+ }),
316
+ react.createElement('span', null, L('enabled')),
317
+ ),
318
+ ),
319
+
320
+ react.createElement(
321
+ 'div',
322
+ { className: 'dtc-row' },
323
+ react.createElement('span', { className: 'dtc-muted' }, L('audio') + ':' + (cfg.fileName || L('none')) + (cfg.size ? ' (' + formatSize(cfg.size) + ')' : '')),
324
+ ),
325
+ react.createElement(
326
+ 'div',
327
+ { className: 'dtc-row' },
328
+ react.createElement(
329
+ 'label',
330
+ { className: 'dtc-btn dtc-file' },
331
+ react.createElement('input', { type: 'file', accept: 'audio/*', disabled: busy, onChange: onPick }),
332
+ L('pick'),
333
+ ),
334
+ react.createElement(
335
+ 'button',
336
+ { className: 'dtc-btn', type: 'button', disabled: busy || !cfg.url, onClick: () => playChime(cfg) },
337
+ L('preview'),
338
+ ),
339
+ react.createElement(
340
+ 'button',
341
+ { className: 'dtc-btn', type: 'button', disabled: busy || !cfg.url, onClick: () => run('/dsh-done-sound/api/clear', {}) },
342
+ L('clear'),
343
+ ),
344
+ ),
345
+
346
+ react.createElement(
347
+ 'div',
348
+ { className: 'dtc-row' },
349
+ react.createElement('span', null, L('volume')),
350
+ react.createElement('input', {
351
+ className: 'dtc-slider',
352
+ type: 'range',
353
+ min: 0,
354
+ max: 100,
355
+ step: 1,
356
+ value: volumeValue,
357
+ onChange: onVolumeInput,
358
+ }),
359
+ react.createElement('span', { className: 'dtc-vol dtc-muted' }, volumeValue + '%'),
360
+ ),
361
+
362
+ react.createElement(
363
+ 'div',
364
+ { className: 'dtc-row' },
365
+ react.createElement(
366
+ 'label',
367
+ { className: 'dtc-toggle' },
368
+ react.createElement('input', {
369
+ type: 'checkbox',
370
+ checked: cfg.playOnInterrupt,
371
+ disabled: busy,
372
+ onChange: (e) => {
373
+ run('/dsh-done-sound/api/config', { playOnInterrupt: e.target.checked });
374
+ },
375
+ }),
376
+ react.createElement('span', null, L('interrupt')),
377
+ ),
378
+ react.createElement('span', { className: 'dtc-muted' }, L('interruptHint')),
379
+ ),
380
+ react.createElement(
381
+ 'div',
382
+ { className: 'dtc-row' },
383
+ react.createElement(
384
+ 'label',
385
+ { className: 'dtc-toggle' },
386
+ react.createElement('input', {
387
+ type: 'checkbox',
388
+ checked: cfg.playOnError,
389
+ disabled: busy,
390
+ onChange: (e) => {
391
+ run('/dsh-done-sound/api/config', { playOnError: e.target.checked });
392
+ },
393
+ }),
394
+ react.createElement('span', null, L('error')),
395
+ ),
396
+ react.createElement('span', { className: 'dtc-muted' }, L('errorHint')),
397
+ ),
398
+
399
+ error !== null && react.createElement('div', { className: 'dtc-err' }, error),
400
+ !cfg.loaded && react.createElement('div', { className: 'dtc-muted' }, L('loading')),
401
+ cfg.lastEvent &&
402
+ react.createElement(
403
+ 'div',
404
+ { className: 'dtc-muted' },
405
+ L('lastTrigger') +
406
+ ':' +
407
+ formatEventTime(cfg.lastEvent.at) +
408
+ ' ' +
409
+ (cfg.lastEvent.kind === 'interrupt' ? L('evInterrupt') : cfg.lastEvent.kind === 'error' ? L('evError') : L('evNormal')) +
410
+ ' → ' +
411
+ (cfg.lastEvent.play ? L('evPlayed') : L('evNotPlayed')),
412
+ ),
413
+ );
414
+ };
415
+
416
+ // ---- Turn-completion detector (session scope: gets useSession) ----
417
+ //
418
+ // Trigger = a NEWLY-seen turn/end reason (the authoritative signal the
419
+ // host always appends: completed | aborted | error | max-tokens |
420
+ // blocked). No dependence on observing a streaming->idle transition, so
421
+ // fast turns cannot be missed. On mount/session-switch we seed the
422
+ // current turn number and never play history.
423
+ const TurnChimeDetector = (props) => {
424
+ const useSession = props.useSession;
425
+ const cfg = useConfig();
426
+ const pendingReasonRef = react.useRef(null);
427
+ const lastEndTurnRef = react.useRef(0);
428
+ const lastSessionRef = react.useRef(null);
429
+
430
+ const snap =
431
+ typeof useSession === 'function'
432
+ ? useSession((s) => {
433
+ let endReason = null;
434
+ let endTurn = 0;
435
+ const turns = s && s.chat && s.chat.timeline ? s.chat.timeline.turns : null;
436
+ if (turns) {
437
+ turns.forEach((loc, turn) => {
438
+ const reason = loc && loc.end && loc.end.data && loc.end.data.reason;
439
+ if (reason && typeof reason.kind === 'string' && turn > endTurn) {
440
+ endTurn = turn;
441
+ endReason = reason.kind;
442
+ }
443
+ });
444
+ }
445
+ return {
446
+ sessionId: s && typeof s.sessionId === 'string' ? s.sessionId : null,
447
+ streaming: s.partial !== null || s.running === true,
448
+ endReason,
449
+ endTurn,
450
+ };
451
+ })
452
+ : null;
453
+
454
+ react.useEffect(() => {
455
+ if (snap === null) return;
456
+
457
+ // New session (or first mount): seed with its current state; never
458
+ // play history or a completion that happened before we mounted.
459
+ if (snap.sessionId !== lastSessionRef.current) {
460
+ lastSessionRef.current = snap.sessionId;
461
+ pendingReasonRef.current = null;
462
+ lastEndTurnRef.current = snap.endTurn;
463
+ return;
464
+ }
465
+
466
+ // A newly-closed turn is the authoritative "a turn ended" signal.
467
+ if (snap.endTurn > lastEndTurnRef.current) {
468
+ lastEndTurnRef.current = snap.endTurn;
469
+ if (snap.endReason !== null) pendingReasonRef.current = snap.endReason;
470
+ }
471
+
472
+ if (snap.streaming) return; // never act mid-stream
473
+ if (pendingReasonRef.current === null) return;
474
+
475
+ const reason = pendingReasonRef.current;
476
+ pendingReasonRef.current = null;
477
+
478
+ let kind;
479
+ if (reason === 'aborted' || reason === 'blocked') kind = 'interrupt';
480
+ else if (reason === 'error' || reason === 'max-tokens') kind = 'error';
481
+ else kind = 'normal'; // 'completed' (and unknown future reasons default to playing)
482
+
483
+ let played = false;
484
+ if (kind === 'interrupt') {
485
+ if (cfg.playOnInterrupt && cfg.url) {
486
+ playChime(cfg);
487
+ played = true;
488
+ }
489
+ } else if (kind === 'error') {
490
+ if (cfg.playOnError && cfg.url) {
491
+ playChime(cfg);
492
+ played = true;
493
+ }
494
+ } else if (cfg.url) {
495
+ playChime(cfg);
496
+ played = true;
497
+ }
498
+
499
+ // Visible diagnostic for the settings card (and future debugging).
500
+ setConfig({ lastEvent: { at: Date.now(), kind, reason, play: played } });
501
+ }, [snap, cfg]);
502
+
503
+ return null;
504
+ };
505
+
506
+ ctx.slots.inject('settings.section', () =>
507
+ ctx.slots.register(
508
+ {
509
+ name: 'settings.section',
510
+ id: 'dsh-done-sound',
511
+ order: 30,
512
+ label: '对话完成音效',
513
+ },
514
+ (props) => react.createElement(TurnChimeCard, props),
515
+ ),
516
+ );
517
+
518
+ ctx.slots.inject('conversation.session.header.utilities', () =>
519
+ ctx.slots.register(
520
+ {
521
+ name: 'conversation.session.header.utilities',
522
+ id: 'dsh-done-sound',
523
+ order: 10,
524
+ label: '对话完成音效',
525
+ },
526
+ (props) => react.createElement(TurnChimeDetector, props),
527
+ ),
528
+ );
529
+ }
530
+
531
+ exports.name = 'dsh-done-sound';
532
+ exports.inject = ['slots', 'remote', 'remote.commands'];
533
+ exports.apply = apply;
534
+ return module.exports;
535
+ },
536
+ });