dsh-done-sound 0.1.3 → 0.1.4

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,54 +1,38 @@
1
1
  # dsh-done-sound
2
2
 
3
- DeepSeek Harness Web GUI 的「对话完成音效」插件:你在设置里选一段音频,每次 Agent 完整结束一轮对话时自动播放提示音。
3
+ DeepSeek Harness Web GUI 鐨勩€屽璇濆畬鎴愰煶鏁堛€嶆彃浠讹細浣犲湪璁剧疆閲岄€変竴娈甸煶棰戯紝姣忔 Agent 瀹屾暣缁撴潫涓€杞璇濇椂鑷姩鎾斁鎻愮ず闊炽€?
4
+ ## 鍔熻兘
4
5
 
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
- ## 安装
6
+ - **璁剧疆鍗$墖**锛堣缃?鈫?瀵硅瘽瀹屾垚闊虫晥锛夛細閫夋嫨闊抽鏂囦欢锛坢p3 / wav / ogg / webm / m4a / aac / flac锛屸墹2MB锛夈€佽瘯鍚€佹竻闄ゃ€侀煶閲忚皟鑺傘€?- **瀹屾垚妫€娴?*锛氫互瀹夸富 `turn/end` 浜嬩欢鐨?reason锛堟甯?涓柇/鍑洪敊/瓒呴暱锛変负鏉冨▉瑙﹀彂淇″彿锛屽揩鎱㈠璇濋兘涓嶆紡銆佷笉璇垽锛涙瘡杞彧鍝嶄竴娆°€?- **瑙﹀彂寮€鍏?*锛? - `涓柇鏃朵篃鍝峘锛堥粯璁?*鍏?*锛夛細鎵嬪姩鍋滄鐢熸垚鏃舵槸鍚︿篃鎾斁銆? - `鍑洪敊鏃朵篃鍝峘锛堥粯璁?*寮€**锛夛細瀵硅瘽浠ラ敊璇粨鏉燂紙turn-error / 瓒呴暱鎴柇锛夋椂鏄惁涔熸挱鏀俱€?- 闊抽鏂囦欢淇濆瓨鍦?profile 鐩綍 `.dsh-done-sound/`锛岀粡 Host 璺敱 `/dsh-done-sound/audio/<fileId>` 鎾斁锛岄厤缃寔涔呭寲鍦ㄨ缃腑銆?
7
+ ## 瀹夎
15
8
 
16
9
  ```sh
17
10
  dsh plugin --profile web add dsh-done-sound
18
11
  ```
19
12
 
20
- 本地开发:
13
+ 鏈湴寮€鍙戯細
21
14
 
22
15
  ```sh
23
- dsh plugin --profile web add link:F:\0.AI-CodeProject\DSHProject\0.日常聊天\dsh-done-sound
16
+ dsh plugin --profile web add link:F:\0.AI-CodeProject\DSHProject\0.鏃ュ父鑱婂ぉ\dsh-done-sound
24
17
  ```
25
18
 
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
- ## 开发
19
+ 瀹夎鍚庨噸鍚?`dsh web`锛屾墦寮€璁剧疆椤靛嵆鍙湅鍒般€屽璇濆畬鎴愰煶鏁堛€嶅崱鐗囥€?
20
+ ## 鏋舵瀯
34
21
 
22
+ - **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`锛屼骇鐗╄嚜鍖呭惈锛坙ink 瀹夎鏃舵棤闇€渚濊禆 profile 鐨勬ā鍧楄В鏋愶級銆?- **Client 鍗婂尯**锛坄lib/client.js`锛夛細`settings.section` 璁剧疆鍗$墖锛?*fetch 璋?HTTP API锛屼笉渚濊禆浼氳瘽/鍛戒护 RPC**锛? `conversation.session.header.utilities` 瀹屾垚妫€娴嬪櫒锛坄useSession` 璁㈤槄 `ConversationSnapshot`锛宍partial/running 鈫?绌洪棽` 杞Щ瑙﹀彂锛屾寜鏈€鍚庤妭鐐?seq 鍘婚噸锛夈€?
23
+ ## 寮€鍙?
35
24
  ```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
- 内置提示音不够用?社区精选音色库(百度网盘,永久有效):
25
+ pnpm install # 瀹夎 esbuild
26
+ pnpm run build # 鎵撳寘 src/index.js -> lib/index.js锛堣嚜鍖呭惈锛?pnpm run check # 璇硶妫€鏌ヤ袱涓骇鐗?```
46
27
 
47
- - 链接:https://pan.baidu.com/s/1xnef4xkCy8pkooXMGi2ApQ?pwd=1234
48
- - 提取码:1234
28
+ 淇敼 host 婧愮爜鍚庨噸鏂?`pnpm run build`锛沜lient 鍗婂尯鐩存帴鏀?`lib/client.js`锛坄__ModuleLoader__` 鏍煎紡鍗宠繍琛屾椂濂戠害锛屾棤闇€鎵撳寘锛夈€?
29
+ ## 闊虫晥搴撲笅杞?
30
+ 鍐呯疆鎻愮ず闊充笉澶熺敤锛熺ぞ鍖虹簿閫夐煶鑹插簱锛堢櫨搴︾綉鐩橈紝姘镐箙鏈夋晥锛夛細
49
31
 
50
- 下载解压后,在 设置 → 对话完成音效 → 选择音频 中选用即可。
32
+ - 閾炬帴锛歨ttps://pan.baidu.com/s/1xnef4xkCy8pkooXMGi2ApQ?pwd=1234
33
+ - 鎻愬彇鐮侊細1234
51
34
 
35
+ 涓嬭浇瑙e帇鍚庯紝鍦?璁剧疆 鈫?瀵硅瘽瀹屾垚闊虫晥 鈫?閫夋嫨闊抽 涓€夌敤鍗冲彲銆?
52
36
  ## License
53
37
 
54
38
  MIT
package/lib/client.js CHANGED
@@ -35,6 +35,15 @@ window.__ModuleLoader__.load({
35
35
  '.dtc-slider{flex:1;max-width:220px}',
36
36
  '.dtc-vol{min-width:44px;text-align:right;font-variant-numeric:tabular-nums}',
37
37
  '.dtc-toggle{display:flex;align-items:center;gap:6px;cursor:pointer}',
38
+ '.dtc-update{color:var(--dsw-alias-state-error-primary);font-weight:600}',
39
+ '.dtc-uptodate{color:var(--dsw-alias-state-success-primary)}',
40
+ '.dtc-num{width:72px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);background:0 0;border-radius:6px;padding:2px 6px;font-size:12px;font-family:inherit}',
41
+ '.dtc-saved{color:var(--dsw-alias-state-success-primary);font-size:12px;white-space:nowrap}',
42
+ '@keyframes dtc-pulse{0%,100%{transform:scale(1);box-shadow:0 0 0 0 rgba(245,158,11,.5)}50%{transform:scale(1.06);box-shadow:0 0 0 8px rgba(245,158,11,0)}}',
43
+ '.dtc-retry-chip{display:inline-flex;align-items:center;gap:8px;border:2px solid #f59e0b;border-radius:999px;padding:7px 18px;font-size:15px;font-weight:700;color:#fff;background:linear-gradient(135deg,#f59e0b,#ea580c);white-space:nowrap;font-variant-numeric:tabular-nums;line-height:1.2;animation:dtc-pulse 1.2s ease-in-out infinite}',
44
+ '.dtc-retry-chip .dtc-retry-ico{font-size:17px;line-height:1}',
45
+ '.dtc-retry-chip .dtc-retry-num{font-size:19px;font-weight:800;margin-left:2px}',
46
+ '.dtc-cooldown-line{color:var(--dsw-alias-state-warn-primary,#f59e0b);font-weight:600}',
38
47
  ].join('');
39
48
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']') === null) {
40
49
  const tag = document.createElement('style');
@@ -62,7 +71,24 @@ window.__ModuleLoader__.load({
62
71
  errorHint: '对话以错误结束时也播放(默认开启)',
63
72
  pending: '等待确认时提醒',
64
73
  pendingHint: 'Agent 需要你确认操作(如审批)而停住等待时播放提醒(默认开启)',
65
- saved: '已保存:',
74
+ autoRetry: '报错自动重连',
75
+ autoRetryHint: '模型重试耗尽、报错结束后自动发送「继续」恢复(默认开启)',
76
+ retryDelay: '自动重连等待(秒)',
77
+ retryDelayHint: '报错后等待多久再自动重连;期间恢复则取消(10-300 秒)',
78
+ confirm: '确定',
79
+ retrying: '自动重连',
80
+ retryChipTitle: '报错后自动重连等待中,期间恢复则自动取消',
81
+ secondsUnit: '秒',
82
+ retryCooldown: '自动重连冷却中:{s} 秒后可用',
83
+ retryArmed: '等待 {s} 秒后自动发送「继续」',
84
+ retrySent: '已自动发送「继续」',
85
+ retryFailed: '自动发送「继续」失败',
86
+ version: '当前版本',
87
+ updateAvailable: '有新版本',
88
+ upToDate: '已是最新',
89
+ updateCheckFailed: '无法检查更新',
90
+ saved: '已保存 ✓',
91
+ saveMismatch: '保存未生效,当前值仍为 {v} 秒',
66
92
  readFailed: '读取文件失败',
67
93
  apiFailed: '无法连接插件服务(Host 未加载?),请重启 dsh web',
68
94
  noSession: '打开一个会话后配置',
@@ -91,7 +117,24 @@ window.__ModuleLoader__.load({
91
117
  errorHint: 'Also play when a turn ends in error (default on)',
92
118
  pending: 'Alert on approval wait',
93
119
  pendingHint: 'Play a reminder when the agent is waiting for your confirmation (default on)',
94
- saved: 'Saved:',
120
+ autoRetry: 'Auto-retry on error',
121
+ autoRetryHint: 'After the model exhausts its retries and the turn ends in error, automatically send "continue" to resume (default on)',
122
+ retryDelay: 'Auto-retry wait (seconds)',
123
+ retryDelayHint: 'How long to wait before auto-retrying; cancelled if it recovers meanwhile (10-300s)',
124
+ confirm: 'OK',
125
+ retrying: 'Retry',
126
+ retryChipTitle: 'Auto-retrying after error; auto-cancelled if it recovers',
127
+ secondsUnit: 's',
128
+ retryCooldown: 'auto-retry cooling down: {s}s',
129
+ retryArmed: 'will auto-send "continue" in {s}s',
130
+ retrySent: 'auto-sent "continue"',
131
+ retryFailed: 'failed to auto-send "continue"',
132
+ version: 'Version',
133
+ updateAvailable: 'Update available',
134
+ upToDate: 'Up to date',
135
+ updateCheckFailed: 'Update check failed',
136
+ saved: 'Saved ✓',
137
+ saveMismatch: 'Save did not take effect; current value is still {v}s',
95
138
  readFailed: 'Failed to read file',
96
139
  apiFailed: 'Cannot reach the plugin host service (host not loaded?), restart dsh web',
97
140
  noSession: 'Open a session to configure',
@@ -128,6 +171,15 @@ window.__ModuleLoader__.load({
128
171
  return '';
129
172
  }
130
173
  }
174
+ function compareVersions(a, b) {
175
+ const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0);
176
+ const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0);
177
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
178
+ const diff = (pa[i] || 0) - (pb[i] || 0);
179
+ if (diff !== 0) return diff > 0 ? 1 : -1;
180
+ }
181
+ return 0;
182
+ }
131
183
 
132
184
  // ---- tiny shared config store (card writes, detector reads) ----
133
185
  const configStore = {
@@ -137,6 +189,12 @@ window.__ModuleLoader__.load({
137
189
  playOnInterrupt: false,
138
190
  playOnError: true,
139
191
  playOnPending: true,
192
+ autoRetryOnError: true,
193
+ retryDelaySeconds: 60,
194
+ version: null,
195
+ latestVersion: null,
196
+ updateStatus: null, // 'update' | 'uptodate' | 'error' | null
197
+ retryCooldownUntil: null, // timestamp until which auto-retry is cooling down
140
198
  url: null,
141
199
  defaultUrl: null,
142
200
  fileName: '',
@@ -172,10 +230,104 @@ window.__ModuleLoader__.load({
172
230
  try {
173
231
  const audio = new Audio(url);
174
232
  audio.volume = cfg.volume;
175
- audio.play().catch(() => {});
233
+ const p = audio.play();
234
+ if (p && typeof p.catch === 'function') {
235
+ p.catch((err) => {
236
+ try {
237
+ console.warn('[dsh-done-sound] audio play failed (browser blocked it?):', err && err.message ? err.message : err);
238
+ } catch {
239
+ // ignore
240
+ }
241
+ });
242
+ }
243
+ } catch (err) {
244
+ try {
245
+ console.warn('[dsh-done-sound] audio play failed:', err && err.message ? err.message : err);
246
+ } catch {
247
+ // ignore playback failures (autoplay policy, missing audio device)
248
+ }
249
+ }
250
+ }
251
+
252
+ // ---- module-level deferred auto-retry ----
253
+ // Lives outside the component so the "continue" still fires even if the
254
+ // user navigates away and the header (and its timers) unmount.
255
+ let pluginCtx = null;
256
+ const retryState = { armed: false, sessionId: null, endTurnAtError: 0, deadline: 0, timer: null };
257
+ function cancelModuleRetry() {
258
+ if (retryState.timer !== null) {
259
+ clearTimeout(retryState.timer);
260
+ retryState.timer = null;
261
+ }
262
+ retryState.armed = false;
263
+ retryState.sessionId = null;
264
+ retryState.endTurnAtError = 0;
265
+ retryState.deadline = 0;
266
+ }
267
+ function fireModuleRetry() {
268
+ retryState.timer = null;
269
+ if (!retryState.armed) return;
270
+ retryState.armed = false;
271
+ const sid = retryState.sessionId;
272
+ retryState.sessionId = null;
273
+ retryState.deadline = 0;
274
+ // Real send path: the browser→host RPC client (same route the composer's
275
+ // "send" uses). ctx.connection is provided by @deepseek-ai/dsh-client-connection;
276
+ // its api.sessions.prompt carries an RpcResponse whose `.result` is {ok} |
277
+ // {ok:false,error}. Failures are surfaced (console + settings-card row) —
278
+ // the old remote.sessions guess was silently skipped because no such
279
+ // namespace exists on the client context.
280
+ const prompt = pluginCtx && pluginCtx.connection && pluginCtx.connection.api && pluginCtx.connection.api.sessions
281
+ ? pluginCtx.connection.api.sessions.prompt
282
+ : null;
283
+ const fail = (why) => {
284
+ try {
285
+ console.error('[dsh-done-sound] auto-retry could not send "继续":', why);
286
+ } catch {
287
+ // console may be gone during teardown
288
+ }
289
+ setConfig({
290
+ retryCooldownUntil: null,
291
+ lastEvent: { at: Date.now(), kind: 'error', reason: 'error', play: false, retried: true, retryFailed: true },
292
+ });
293
+ };
294
+ if (typeof prompt !== 'function') {
295
+ fail('connection.api.sessions.prompt unavailable');
296
+ return;
297
+ }
298
+ let tz = 'UTC';
299
+ try {
300
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
176
301
  } catch {
177
- // ignore playback failures (autoplay policy, missing audio device)
302
+ // keep UTC
178
303
  }
304
+ Promise.resolve(prompt({ sessionId: sid, mode: 'queue', content: [{ type: 'text', text: '继续' }], clientTimeZone: tz }))
305
+ .then((res) => {
306
+ const result = res && res.result;
307
+ if (result && result.ok) {
308
+ try {
309
+ console.log('[dsh-done-sound] auto-retry sent "继续" to session', sid);
310
+ } catch {
311
+ // ignore
312
+ }
313
+ setConfig({
314
+ retryCooldownUntil: null,
315
+ lastEvent: { at: Date.now(), kind: 'error', reason: 'error', play: false, retried: true, retryFailed: false },
316
+ });
317
+ } else {
318
+ const err = result && result.error ? result.error.message : JSON.stringify(result);
319
+ fail(err || 'prompt not accepted');
320
+ }
321
+ })
322
+ .catch((err) => fail(err && err.message ? err.message : String(err)));
323
+ }
324
+ function armModuleRetry(sessionId, endTurnAtError, delayMs) {
325
+ cancelModuleRetry();
326
+ retryState.armed = true;
327
+ retryState.sessionId = sessionId;
328
+ retryState.endTurnAtError = endTurnAtError;
329
+ retryState.deadline = Date.now() + delayMs;
330
+ retryState.timer = setTimeout(fireModuleRetry, delayMs);
179
331
  }
180
332
 
181
333
  // ---- same-origin JSON API transport (no session needed) ----
@@ -205,6 +357,7 @@ window.__ModuleLoader__.load({
205
357
  }
206
358
 
207
359
  function apply(ctx) {
360
+ pluginCtx = ctx;
208
361
  const syncConfig = (payload) => {
209
362
  if (!payload || typeof payload !== 'object') return;
210
363
  setConfig({
@@ -213,6 +366,9 @@ window.__ModuleLoader__.load({
213
366
  playOnInterrupt: payload.playOnInterrupt === true,
214
367
  playOnError: payload.playOnError !== false,
215
368
  playOnPending: payload.playOnPending !== false,
369
+ autoRetryOnError: payload.autoRetryOnError !== false,
370
+ retryDelaySeconds: typeof payload.retryDelaySeconds === 'number' ? payload.retryDelaySeconds : 60,
371
+ version: typeof payload.version === 'string' ? payload.version : null,
216
372
  url: typeof payload.url === 'string' && payload.url ? payload.url : null,
217
373
  defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
218
374
  fileName: payload.audio && typeof payload.audio.fileName === 'string' ? payload.audio.fileName : '',
@@ -229,13 +385,59 @@ window.__ModuleLoader__.load({
229
385
  const [busy, setBusy] = react.useState(false);
230
386
  const [lang] = react.useState(resolveLang());
231
387
  const L = (key) => t(lang, key);
388
+ const [cardTick, setCardTick] = react.useState(Date.now());
389
+ react.useEffect(() => {
390
+ const timer = setInterval(() => setCardTick(Date.now()), 1000);
391
+ return () => clearInterval(timer);
392
+ }, []);
393
+ // Auto-retry delay: local draft while editing, committed on blur/Enter.
394
+ const delayDraftRef = react.useRef(null);
395
+ const [delayDraft, setDelayDraft] = react.useState(cfg.retryDelaySeconds || 60);
396
+ react.useEffect(() => {
397
+ if (delayDraftRef.current !== document.activeElement) {
398
+ setDelayDraft(cfg.retryDelaySeconds || 60);
399
+ }
400
+ }, [cfg.retryDelaySeconds]);
401
+ const commitDelay = async () => {
402
+ const v = Number(delayDraft);
403
+ if (!Number.isFinite(v)) {
404
+ setDelayDraft(cfg.retryDelaySeconds || 60);
405
+ return;
406
+ }
407
+ const rounded = Math.min(300, Math.max(10, Math.round(v / 5) * 5));
408
+ if (rounded !== Number(cfg.retryDelaySeconds)) {
409
+ setDelayDraft(String(rounded));
410
+ const payload = await run('/dsh-done-sound/api/config', { retryDelaySeconds: rounded });
411
+ if (!payload) return;
412
+ // Verify the host actually persisted it (a 200 alone is not proof).
413
+ if (payload.retryDelaySeconds !== rounded) {
414
+ setError(L('saveMismatch').replace('{v}', String(payload.retryDelaySeconds)));
415
+ return;
416
+ }
417
+ }
418
+ // Save feedback (also confirms the current value without changes).
419
+ setDelaySaved(true);
420
+ if (delaySavedTimer.current) clearTimeout(delaySavedTimer.current);
421
+ delaySavedTimer.current = setTimeout(() => setDelaySaved(false), 2000);
422
+ };
423
+ const [delaySaved, setDelaySaved] = react.useState(false);
424
+ const delaySavedTimer = react.useRef(null);
425
+ react.useEffect(
426
+ () => () => {
427
+ if (delaySavedTimer.current) clearTimeout(delaySavedTimer.current);
428
+ },
429
+ [],
430
+ );
232
431
 
233
432
  react.useEffect(() => {
234
433
  let disposed = false;
235
434
  (async () => {
236
435
  try {
237
436
  const payload = await api('/dsh-done-sound/api/status');
238
- if (!disposed) syncConfig(payload);
437
+ if (!disposed) {
438
+ syncConfig(payload);
439
+ checkLatestVersion(typeof payload.version === 'string' ? payload.version : null);
440
+ }
239
441
  } catch (e) {
240
442
  if (!disposed) {
241
443
  setError(L('apiFailed') + ' (' + (e && e.message ? e.message : String(e)) + ')');
@@ -248,6 +450,26 @@ window.__ModuleLoader__.load({
248
450
  };
249
451
  }, []);
250
452
 
453
+ // Query the npm registry for the latest published version and compare
454
+ // with the installed one (red if an update exists, green if up to date).
455
+ const checkLatestVersion = (currentVersion) => {
456
+ if (typeof currentVersion !== 'string' || currentVersion.length === 0) return;
457
+ fetch('https://registry.npmjs.org/dsh-done-sound/latest')
458
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error('http ' + res.status))))
459
+ .then((data) => {
460
+ const latest = data && typeof data.version === 'string' ? data.version : null;
461
+ if (latest === null) {
462
+ setConfig({ updateStatus: 'error' });
463
+ return;
464
+ }
465
+ setConfig({
466
+ latestVersion: latest,
467
+ updateStatus: compareVersions(latest, currentVersion) > 0 ? 'update' : 'uptodate',
468
+ });
469
+ })
470
+ .catch(() => setConfig({ updateStatus: 'error' }));
471
+ };
472
+
251
473
  const run = async (pathname, body) => {
252
474
  setBusy(true);
253
475
  setError(null);
@@ -328,6 +550,24 @@ window.__ModuleLoader__.load({
328
550
  ),
329
551
  ),
330
552
 
553
+ // version + update status row (red when an update is available)
554
+ react.createElement(
555
+ 'div',
556
+ { className: 'dtc-row' },
557
+ react.createElement(
558
+ 'span',
559
+ null,
560
+ L('version') + ':' + (cfg.version || '—'),
561
+ ),
562
+ cfg.updateStatus === 'update'
563
+ ? react.createElement('span', { className: 'dtc-update' }, L('updateAvailable') + (cfg.latestVersion ? ' v' + cfg.latestVersion : ''))
564
+ : cfg.updateStatus === 'uptodate'
565
+ ? react.createElement('span', { className: 'dtc-uptodate' }, L('upToDate'))
566
+ : cfg.updateStatus === 'error'
567
+ ? react.createElement('span', { className: 'dtc-muted' }, L('updateCheckFailed'))
568
+ : null,
569
+ ),
570
+
331
571
  react.createElement(
332
572
  'div',
333
573
  { className: 'dtc-row' },
@@ -424,9 +664,82 @@ window.__ModuleLoader__.load({
424
664
  ),
425
665
  react.createElement('span', { className: 'dtc-muted' }, L('pendingHint')),
426
666
  ),
667
+ react.createElement(
668
+ 'div',
669
+ { className: 'dtc-row' },
670
+ react.createElement(
671
+ 'label',
672
+ { className: 'dtc-toggle' },
673
+ react.createElement('input', {
674
+ type: 'checkbox',
675
+ checked: cfg.autoRetryOnError,
676
+ disabled: busy,
677
+ onChange: (e) => {
678
+ run('/dsh-done-sound/api/config', { autoRetryOnError: e.target.checked });
679
+ },
680
+ }),
681
+ react.createElement('span', null, L('autoRetry')),
682
+ ),
683
+ react.createElement('span', { className: 'dtc-muted' }, L('autoRetryHint')),
684
+ ),
685
+ react.createElement(
686
+ 'div',
687
+ { className: 'dtc-row' },
688
+ react.createElement('span', null, L('retryDelay')),
689
+ react.createElement('input', {
690
+ className: 'dtc-num',
691
+ type: 'number',
692
+ min: 10,
693
+ max: 300,
694
+ step: 5,
695
+ ref: delayDraftRef,
696
+ value: delayDraft,
697
+ disabled: busy,
698
+ onChange: (e) => setDelayDraft(e.target.value),
699
+ onBlur: () => {
700
+ commitDelay();
701
+ },
702
+ onKeyDown: (e) => {
703
+ if (e.key === 'Enter') {
704
+ e.preventDefault();
705
+ commitDelay();
706
+ }
707
+ },
708
+ }),
709
+ react.createElement('span', { className: 'dtc-muted' }, L('secondsUnit')),
710
+ react.createElement(
711
+ 'button',
712
+ {
713
+ className: 'dtc-btn',
714
+ type: 'button',
715
+ disabled: busy,
716
+ onClick: () => {
717
+ commitDelay();
718
+ },
719
+ },
720
+ L('confirm'),
721
+ ),
722
+ delaySaved && react.createElement('span', { className: 'dtc-saved' }, L('saved')),
723
+ ),
724
+ react.createElement('span', { className: 'dtc-muted' }, L('retryDelayHint')),
427
725
 
428
726
  error !== null && react.createElement('div', { className: 'dtc-err' }, error),
429
727
  !cfg.loaded && react.createElement('div', { className: 'dtc-muted' }, L('loading')),
728
+ cfg.retryCooldownUntil && cfg.retryCooldownUntil > Date.now()
729
+ ? react.createElement(
730
+ 'div',
731
+ { className: 'dtc-muted dtc-cooldown-line' },
732
+ '🔄 ' + L('retryCooldown').replace('{s}', String(Math.ceil((cfg.retryCooldownUntil - cardTick) / 1000))),
733
+ )
734
+ : null,
735
+ retryState.armed && retryState.deadline > cardTick
736
+ ? react.createElement(
737
+ 'div',
738
+ { className: 'dtc-muted dtc-cooldown-line' },
739
+ '🔄 ' +
740
+ L('retryArmed').replace('{s}', String(Math.max(0, Math.ceil((retryState.deadline - cardTick) / 1000)))),
741
+ )
742
+ : null,
430
743
  cfg.lastEvent &&
431
744
  react.createElement(
432
745
  'div',
@@ -443,7 +756,13 @@ window.__ModuleLoader__.load({
443
756
  ? L('evPending')
444
757
  : L('evNormal')) +
445
758
  ' → ' +
446
- (cfg.lastEvent.play ? L('evPlayed') : L('evNotPlayed')),
759
+ (cfg.lastEvent.retried === true
760
+ ? cfg.lastEvent.retryFailed
761
+ ? L('retryFailed')
762
+ : L('retrySent')
763
+ : cfg.lastEvent.play
764
+ ? L('evPlayed')
765
+ : L('evNotPlayed')),
447
766
  ),
448
767
  );
449
768
  };
@@ -461,7 +780,8 @@ window.__ModuleLoader__.load({
461
780
  const pendingReasonRef = react.useRef(null);
462
781
  const lastEndTurnRef = react.useRef(0);
463
782
  const lastSessionRef = react.useRef(null);
464
- const prevPendingRef = react.useRef(false);
783
+ const prevPendingKeysRef = react.useRef('');
784
+ const seededRef = react.useRef(false);
465
785
 
466
786
  const snap =
467
787
  typeof useSession === 'function'
@@ -480,11 +800,28 @@ window.__ModuleLoader__.load({
480
800
  }
481
801
  return {
482
802
  sessionId: s && typeof s.sessionId === 'string' ? s.sessionId : null,
803
+ openState: s && typeof s.openState === 'string' ? s.openState : null,
483
804
  streaming: s.partial !== null || s.running === true,
484
805
  endReason,
485
806
  endTurn,
486
807
  // agent is waiting for human intervention (approval etc.)
487
808
  pendingCount: s && Array.isArray(s.pending) ? s.pending.length : 0,
809
+ // Stable fingerprint of the items currently waiting, so a
810
+ // replacement wait (one approval resolved while the next is
811
+ // requested — count stays at 1) is still detected as a NEW
812
+ // wait. The old 0->N edge detector missed those, which is the
813
+ // "sometimes no sound" cause.
814
+ pendingKey:
815
+ s && Array.isArray(s.pending) && s.pending.length
816
+ ? s.pending
817
+ .map((it) => {
818
+ const p = it && it.payload;
819
+ if (p && typeof p.approvalId === 'string') return 'a:' + p.approvalId;
820
+ if (p && typeof p.questionRpcId === 'string') return 'q:' + p.questionRpcId;
821
+ return 'i:' + JSON.stringify(it || null);
822
+ })
823
+ .join('|')
824
+ : '',
488
825
  };
489
826
  })
490
827
  : null;
@@ -492,28 +829,98 @@ window.__ModuleLoader__.load({
492
829
  react.useEffect(() => {
493
830
  if (snap === null) return;
494
831
 
495
- // New session (or first mount): seed with its current state; never
496
- // play history or a completion that happened before we mounted.
832
+ // Fire the "waiting for human input" reminder for the current config
833
+ // view. When no audio URL is known yet (status fetch still in flight,
834
+ // or the audio was changed elsewhere), re-fetch status once and play
835
+ // with the fresh view instead of silently skipping.
836
+ const notifyPending = () => {
837
+ const canPlay = (view) => view.enabled && view.playOnPending && (view.url || view.defaultUrl);
838
+ const record = (value) => setConfig({ lastEvent: { at: Date.now(), kind: 'pending', reason: 'pending', play: value } });
839
+ if (canPlay(cfg)) {
840
+ playChime(cfg);
841
+ record(true);
842
+ return;
843
+ }
844
+ api('/dsh-done-sound/api/status')
845
+ .then((payload) => {
846
+ if (!payload || typeof payload !== 'object') return;
847
+ syncConfig(payload);
848
+ const fresh = {
849
+ ...cfg,
850
+ enabled: payload.enabled === true,
851
+ playOnPending: payload.playOnPending !== false,
852
+ url: typeof payload.url === 'string' && payload.url ? payload.url : null,
853
+ defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
854
+ };
855
+ if (canPlay(fresh)) {
856
+ playChime(fresh);
857
+ record(true);
858
+ }
859
+ })
860
+ .catch(() => {});
861
+ record(false);
862
+ };
863
+
864
+ // New session (or first mount): reset and wait for the history to
865
+ // settle before listening.
497
866
  if (snap.sessionId !== lastSessionRef.current) {
498
867
  lastSessionRef.current = snap.sessionId;
868
+ seededRef.current = false;
499
869
  pendingReasonRef.current = null;
500
- lastEndTurnRef.current = snap.endTurn;
501
- prevPendingRef.current = snap.pendingCount > 0;
502
- return;
870
+ lastEndTurnRef.current = 0;
871
+ prevPendingKeysRef.current = '';
872
+ cancelModuleRetry();
873
+ setConfig({ retryCooldownUntil: null });
503
874
  }
504
875
 
505
- // Agent entered a waiting-for-human state (rising edge only).
506
- if (snap.pendingCount > 0 && !prevPendingRef.current) {
507
- prevPendingRef.current = true;
508
- let played = false;
509
- if (cfg.enabled && cfg.playOnPending && (cfg.url || cfg.defaultUrl)) {
510
- playChime(cfg);
511
- played = true;
876
+ // Before the session window is open, the timeline fills with the
877
+ // conversation history (replayed turn/end events). Do NOT treat that
878
+ // replay as new completions: seed only once openState reaches 'open'
879
+ // (the latest tail is loaded; older pages only add lower turn
880
+ // numbers, which never move endTurn).
881
+ if (!seededRef.current) {
882
+ if (snap.openState === 'open') {
883
+ seededRef.current = true;
884
+ lastEndTurnRef.current = snap.endTurn;
885
+ // Seed with the current key so the pending detector only fires
886
+ // for NEW waiting items, but still notify once if we mounted
887
+ // into an already-waiting conversation (e.g. refresh mid-approval).
888
+ prevPendingKeysRef.current = snap.pendingKey || '';
889
+ if (snap.pendingCount > 0) notifyPending();
512
890
  }
513
- setConfig({ lastEvent: { at: Date.now(), kind: 'pending', reason: 'pending', play: played } });
514
891
  return;
515
892
  }
516
- prevPendingRef.current = snap.pendingCount > 0;
893
+
894
+ // Cancel a pending auto-retry when the session recovers on its own
895
+ // (agent runs again, a new turn closes, it waits for human input, or
896
+ // we switched sessions) — no need to send "继续" then.
897
+ if (retryState.armed) {
898
+ const cancel =
899
+ snap.sessionId !== retryState.sessionId ||
900
+ snap.streaming === true ||
901
+ snap.endTurn > retryState.endTurnAtError ||
902
+ snap.pendingCount > 0;
903
+ if (cancel) {
904
+ cancelModuleRetry();
905
+ setConfig({ retryCooldownUntil: null });
906
+ }
907
+ }
908
+
909
+ // Agent entered a waiting-for-human state. Notify on ANY new waiting item:
910
+ // the first pending, a replacement approval (one resolved, next requested —
911
+ // count stays 1, which the old 0->N rising-edge detector missed, i.e. the
912
+ // "sometimes no sound" bug), or an additional item stacking on top.
913
+ if (snap.pendingCount > 0) {
914
+ const prevKeys = new Set((prevPendingKeysRef.current || '').split('|').filter(Boolean));
915
+ const curKeys = (snap.pendingKey || '').split('|').filter(Boolean);
916
+ const hasNew = curKeys.some((k) => !prevKeys.has(k));
917
+ if (hasNew) {
918
+ prevPendingKeysRef.current = snap.pendingKey || '';
919
+ notifyPending();
920
+ return;
921
+ }
922
+ }
923
+ prevPendingKeysRef.current = snap.pendingKey || '';
517
924
 
518
925
  // A newly-closed turn is the authoritative "a turn ended" signal.
519
926
  if (snap.endTurn > lastEndTurnRef.current) {
@@ -586,8 +993,42 @@ window.__ModuleLoader__.load({
586
993
  } else {
587
994
  record(played);
588
995
  }
996
+
997
+ // Arm the deferred auto-retry: after a real 'error' end, wait the
998
+ // configured window; if the session recovers during the window
999
+ // (streaming, new turn, approval wait) the retry is cancelled above,
1000
+ // otherwise the module-level timer fires "继续" when it expires.
1001
+ if (reason === 'error' && cfg.enabled && cfg.autoRetryOnError && snap.sessionId) {
1002
+ if (!retryState.armed) {
1003
+ const delayMs = (cfg.retryDelaySeconds || 60) * 1000;
1004
+ armModuleRetry(snap.sessionId, snap.endTurn, delayMs);
1005
+ setConfig({ retryCooldownUntil: retryState.deadline });
1006
+ setConfig({ lastEvent: { at: Date.now(), kind, reason, play: played, retryPending: true } });
1007
+ }
1008
+ }
589
1009
  }, [snap, cfg]);
590
1010
 
1011
+ // Countdown chip display only (the actual fire is a module-level timer
1012
+ // that survives navigation away from the conversation).
1013
+ const [nowTick, setNowTick] = react.useState(Date.now());
1014
+ react.useEffect(() => {
1015
+ const timer = setInterval(() => setNowTick(Date.now()), 1000);
1016
+ return () => clearInterval(timer);
1017
+ }, []);
1018
+ const cooldownLeft =
1019
+ cfg.retryCooldownUntil && cfg.retryCooldownUntil > Date.now()
1020
+ ? Math.ceil((cfg.retryCooldownUntil - nowTick) / 1000)
1021
+ : 0;
1022
+ if (cooldownLeft > 0) {
1023
+ const chipLang = resolveLang();
1024
+ return react.createElement(
1025
+ 'span',
1026
+ { className: 'dtc-retry-chip', title: t(chipLang, 'retryChipTitle') },
1027
+ react.createElement('span', { className: 'dtc-retry-ico' }, '🔄'),
1028
+ react.createElement('span', null, t(chipLang, 'retrying')),
1029
+ react.createElement('span', { className: 'dtc-retry-num' }, cooldownLeft + 's'),
1030
+ );
1031
+ }
591
1032
  return null;
592
1033
  };
593
1034
 
@@ -617,7 +1058,7 @@ window.__ModuleLoader__.load({
617
1058
  }
618
1059
 
619
1060
  exports.name = 'dsh-done-sound';
620
- exports.inject = ['slots', 'remote', 'remote.commands'];
1061
+ exports.inject = ['slots', 'remote', 'remote.commands', 'connection'];
621
1062
  exports.apply = apply;
622
1063
  return module.exports;
623
1064
  },
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
- // dsh-done-sound/src/index.js
1
+ // src/index.js
2
2
  import { fileURLToPath } from "node:url";
3
+ import { readFileSync } from "node:fs";
3
4
  import { mkdir, readFile, writeFile, unlink } from "node:fs/promises";
4
5
  import { join, dirname } from "node:path";
5
6
  import { randomUUID } from "node:crypto";
@@ -792,10 +793,22 @@ defineMethod("transform", [
792
793
  "preserve"
793
794
  ], ({ inner }, isInner) => inner.toString(isInner));
794
795
 
795
- // dsh-done-sound/src/index.js
796
+ // src/index.js
796
797
  var name = "dsh-done-sound";
797
798
  var inject = ["settings", "commands", "webServer"];
798
- var MAX_BYTES = 2 * 1024 * 1024;
799
+ var cachedPluginVersion = null;
800
+ function pluginVersion() {
801
+ if (cachedPluginVersion !== null) return cachedPluginVersion;
802
+ try {
803
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
804
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
805
+ cachedPluginVersion = typeof pkg.version === "string" ? pkg.version : "unknown";
806
+ } catch {
807
+ cachedPluginVersion = "unknown";
808
+ }
809
+ return cachedPluginVersion;
810
+ }
811
+ var MAX_BYTES = 10 * 1024 * 1024;
799
812
  var DEFAULT_AUDIO_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "turn-done.wav");
800
813
  var DEFAULT_AUDIO_URL = "/dsh-done-sound/audio/default";
801
814
  var DEFAULT_AUDIO_MIME = "audio/wav";
@@ -820,6 +833,8 @@ var TurnChimeSchema = Schema.object({
820
833
  playOnInterrupt: Schema.boolean().default(false),
821
834
  playOnError: Schema.boolean().default(true),
822
835
  playOnPending: Schema.boolean().default(true),
836
+ autoRetryOnError: Schema.boolean().default(true),
837
+ retryDelaySeconds: Schema.number().min(10).max(300).step(5).default(60),
823
838
  audio: Schema.object({
824
839
  fileId: Schema.string().default(""),
825
840
  fileName: Schema.string().default(""),
@@ -840,11 +855,14 @@ function apply(ctx) {
840
855
  const fileId = value.audio?.fileId ?? "";
841
856
  return {
842
857
  ok: true,
858
+ version: pluginVersion(),
843
859
  enabled: value.enabled,
844
860
  volume: value.volume,
845
861
  playOnInterrupt: value.playOnInterrupt,
846
862
  playOnError: value.playOnError,
847
863
  playOnPending: value.playOnPending,
864
+ autoRetryOnError: value.autoRetryOnError,
865
+ retryDelaySeconds: value.retryDelaySeconds,
848
866
  audio: {
849
867
  fileId,
850
868
  fileName: value.audio?.fileName ?? "",
@@ -874,7 +892,7 @@ function apply(ctx) {
874
892
  if (!ext) throw new Error(`unsupported audio type: ${mime} (mp3/wav/ogg/webm/m4a/aac/flac only)`);
875
893
  const buffer = Buffer.from(match[2], "base64");
876
894
  if (buffer.length === 0) throw new Error("empty audio content");
877
- if (buffer.length > MAX_BYTES) throw new Error(`audio exceeds 2MB (got ${buffer.length} bytes)`);
895
+ if (buffer.length > MAX_BYTES) throw new Error(`audio exceeds 10MB (got ${buffer.length} bytes)`);
878
896
  const fileId = randomUUID();
879
897
  await mkdir(dataDir, { recursive: true });
880
898
  await writeFile(audioPath(fileId), buffer);
@@ -1031,6 +1049,10 @@ function apply(ctx) {
1031
1049
  if (typeof body.playOnInterrupt === "boolean") patch.playOnInterrupt = body.playOnInterrupt;
1032
1050
  if (typeof body.playOnError === "boolean") patch.playOnError = body.playOnError;
1033
1051
  if (typeof body.playOnPending === "boolean") patch.playOnPending = body.playOnPending;
1052
+ if (typeof body.autoRetryOnError === "boolean") patch.autoRetryOnError = body.autoRetryOnError;
1053
+ if (typeof body.retryDelaySeconds === "number" && body.retryDelaySeconds >= 10 && body.retryDelaySeconds <= 300) {
1054
+ patch.retryDelaySeconds = Math.round(body.retryDelaySeconds / 5) * 5;
1055
+ }
1034
1056
  if (Object.keys(patch).length > 0) await scope.update(patch);
1035
1057
  sendJson(res, 200, statusPayload());
1036
1058
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-done-sound",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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",