dsh-done-sound 0.1.3 → 0.1.5
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 +6 -3
- package/lib/client.js +510 -22
- package/lib/index.js +64 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,9 @@ DeepSeek Harness Web GUI 的「对话完成音效」插件:你在设置里选
|
|
|
9
9
|
- **触发开关**:
|
|
10
10
|
- `中断时也响`(默认**关**):手动停止生成时是否也播放。
|
|
11
11
|
- `出错时也响`(默认**开**):对话以错误结束(turn-error / 超长截断)时是否也播放。
|
|
12
|
+
- **确认等待提醒**:对话中出现等待确认 / 提问时播放提示音。
|
|
13
|
+
- **出错自动重连**:对话报错后自动发送「继续」重试(可配置等待秒数)。
|
|
14
|
+
- **版本检查**:设置卡片显示当前版本与更新状态。
|
|
12
15
|
- 音频文件保存在 profile 目录 `.dsh-done-sound/`,经 Host 路由 `/dsh-done-sound/audio/<fileId>` 播放,配置持久化在设置中。
|
|
13
16
|
|
|
14
17
|
## 安装
|
|
@@ -27,14 +30,14 @@ dsh plugin --profile web add link:F:\0.AI-CodeProject\DSHProject\0.日常聊天\
|
|
|
27
30
|
|
|
28
31
|
## 架构
|
|
29
32
|
|
|
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)`
|
|
31
|
-
- **Client 半区**(`lib/client.js`):`settings.section`
|
|
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` 去重)。
|
|
32
35
|
|
|
33
36
|
## 开发
|
|
34
37
|
|
|
35
38
|
```sh
|
|
36
39
|
pnpm install # 安装 esbuild
|
|
37
|
-
pnpm run build # 打包 src/index.js -> lib/index.js
|
|
40
|
+
pnpm run build # 打包 src/index.js -> lib/index.js(自带)
|
|
38
41
|
pnpm run check # 语法检查两个产物
|
|
39
42
|
```
|
|
40
43
|
|
package/lib/client.js
CHANGED
|
@@ -35,6 +35,16 @@ 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}',
|
|
47
|
+
'.dtc-logpath{word-break:break-all;text-align:right;max-width:70%;line-height:1.4}',
|
|
38
48
|
].join('');
|
|
39
49
|
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(CSS_ID) + ']') === null) {
|
|
40
50
|
const tag = document.createElement('style');
|
|
@@ -62,7 +72,25 @@ window.__ModuleLoader__.load({
|
|
|
62
72
|
errorHint: '对话以错误结束时也播放(默认开启)',
|
|
63
73
|
pending: '等待确认时提醒',
|
|
64
74
|
pendingHint: 'Agent 需要你确认操作(如审批)而停住等待时播放提醒(默认开启)',
|
|
65
|
-
|
|
75
|
+
autoRetry: '报错自动重连',
|
|
76
|
+
autoRetryHint: '模型重试耗尽、报错结束后自动发送「继续」恢复(默认开启)',
|
|
77
|
+
retryDelay: '自动重连等待(秒)',
|
|
78
|
+
retryDelayHint: '报错后等待多久再自动重连;期间恢复则取消(10-300 秒)',
|
|
79
|
+
confirm: '确定',
|
|
80
|
+
retrying: '自动重连',
|
|
81
|
+
retryChipTitle: '报错后自动重连等待中,期间恢复则自动取消',
|
|
82
|
+
secondsUnit: '秒',
|
|
83
|
+
retryCooldown: '自动重连冷却中:{s} 秒后可用',
|
|
84
|
+
retryArmed: '等待 {s} 秒后自动发送「继续」',
|
|
85
|
+
retrySent: '已自动发送「继续」',
|
|
86
|
+
retryFailed: '自动发送「继续」失败',
|
|
87
|
+
logPath: '日志文件',
|
|
88
|
+
version: '当前版本',
|
|
89
|
+
updateAvailable: '有新版本',
|
|
90
|
+
upToDate: '已是最新',
|
|
91
|
+
updateCheckFailed: '无法检查更新',
|
|
92
|
+
saved: '已保存 ✓',
|
|
93
|
+
saveMismatch: '保存未生效,当前值仍为 {v} 秒',
|
|
66
94
|
readFailed: '读取文件失败',
|
|
67
95
|
apiFailed: '无法连接插件服务(Host 未加载?),请重启 dsh web',
|
|
68
96
|
noSession: '打开一个会话后配置',
|
|
@@ -91,7 +119,25 @@ window.__ModuleLoader__.load({
|
|
|
91
119
|
errorHint: 'Also play when a turn ends in error (default on)',
|
|
92
120
|
pending: 'Alert on approval wait',
|
|
93
121
|
pendingHint: 'Play a reminder when the agent is waiting for your confirmation (default on)',
|
|
94
|
-
|
|
122
|
+
autoRetry: 'Auto-retry on error',
|
|
123
|
+
autoRetryHint: 'After the model exhausts its retries and the turn ends in error, automatically send "continue" to resume (default on)',
|
|
124
|
+
retryDelay: 'Auto-retry wait (seconds)',
|
|
125
|
+
retryDelayHint: 'How long to wait before auto-retrying; cancelled if it recovers meanwhile (10-300s)',
|
|
126
|
+
confirm: 'OK',
|
|
127
|
+
retrying: 'Retry',
|
|
128
|
+
retryChipTitle: 'Auto-retrying after error; auto-cancelled if it recovers',
|
|
129
|
+
secondsUnit: 's',
|
|
130
|
+
retryCooldown: 'auto-retry cooling down: {s}s',
|
|
131
|
+
retryArmed: 'will auto-send "continue" in {s}s',
|
|
132
|
+
retrySent: 'auto-sent "continue"',
|
|
133
|
+
retryFailed: 'failed to auto-send "continue"',
|
|
134
|
+
logPath: 'Log file',
|
|
135
|
+
version: 'Version',
|
|
136
|
+
updateAvailable: 'Update available',
|
|
137
|
+
upToDate: 'Up to date',
|
|
138
|
+
updateCheckFailed: 'Update check failed',
|
|
139
|
+
saved: 'Saved ✓',
|
|
140
|
+
saveMismatch: 'Save did not take effect; current value is still {v}s',
|
|
95
141
|
readFailed: 'Failed to read file',
|
|
96
142
|
apiFailed: 'Cannot reach the plugin host service (host not loaded?), restart dsh web',
|
|
97
143
|
noSession: 'Open a session to configure',
|
|
@@ -128,6 +174,15 @@ window.__ModuleLoader__.load({
|
|
|
128
174
|
return '';
|
|
129
175
|
}
|
|
130
176
|
}
|
|
177
|
+
function compareVersions(a, b) {
|
|
178
|
+
const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0);
|
|
179
|
+
const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0);
|
|
180
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
181
|
+
const diff = (pa[i] || 0) - (pb[i] || 0);
|
|
182
|
+
if (diff !== 0) return diff > 0 ? 1 : -1;
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
131
186
|
|
|
132
187
|
// ---- tiny shared config store (card writes, detector reads) ----
|
|
133
188
|
const configStore = {
|
|
@@ -137,6 +192,12 @@ window.__ModuleLoader__.load({
|
|
|
137
192
|
playOnInterrupt: false,
|
|
138
193
|
playOnError: true,
|
|
139
194
|
playOnPending: true,
|
|
195
|
+
autoRetryOnError: true,
|
|
196
|
+
retryDelaySeconds: 60,
|
|
197
|
+
version: null,
|
|
198
|
+
latestVersion: null,
|
|
199
|
+
updateStatus: null, // 'update' | 'uptodate' | 'error' | null
|
|
200
|
+
retryCooldownUntil: null, // timestamp until which auto-retry is cooling down
|
|
140
201
|
url: null,
|
|
141
202
|
defaultUrl: null,
|
|
142
203
|
fileName: '',
|
|
@@ -166,16 +227,144 @@ window.__ModuleLoader__.load({
|
|
|
166
227
|
react.useEffect(() => subscribeConfig(setCfg), []);
|
|
167
228
|
return cfg;
|
|
168
229
|
}
|
|
230
|
+
// ---- browser -> host log reporting ----
|
|
231
|
+
// Mirrors console output into the host's daily log file
|
|
232
|
+
// (<pluginRoot>/logs/<YYYYMMDD>/dsh-done-sound.log) so issues survive a
|
|
233
|
+
// closed DevTools. Must never call console itself (avoids recursion) and
|
|
234
|
+
// must never break the plugin when the report fails.
|
|
235
|
+
function reportLog(level, args) {
|
|
236
|
+
try {
|
|
237
|
+
const parts = [];
|
|
238
|
+
for (let i = 0; i < args.length; i++) {
|
|
239
|
+
const a = args[i];
|
|
240
|
+
try {
|
|
241
|
+
if (typeof a === 'string') parts.push(a);
|
|
242
|
+
else if (a instanceof Error) parts.push(a.stack || 'Error: ' + (a.message || String(a)));
|
|
243
|
+
else parts.push(JSON.stringify(a));
|
|
244
|
+
} catch {
|
|
245
|
+
parts.push(String(a));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const message = parts.join(' ');
|
|
249
|
+
if (!message) return;
|
|
250
|
+
fetch('/dsh-done-sound/api/log', {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: { 'Content-Type': 'application/json' },
|
|
253
|
+
body: JSON.stringify({ level: level, source: 'client', message: message }),
|
|
254
|
+
}).catch(() => {});
|
|
255
|
+
} catch {
|
|
256
|
+
// reporting must never break the plugin
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
169
260
|
function playChime(cfg) {
|
|
170
261
|
const url = cfg.url || cfg.defaultUrl;
|
|
171
262
|
if (!cfg.enabled || !url) return;
|
|
172
263
|
try {
|
|
173
264
|
const audio = new Audio(url);
|
|
174
265
|
audio.volume = cfg.volume;
|
|
175
|
-
audio.play()
|
|
266
|
+
const p = audio.play();
|
|
267
|
+
if (p && typeof p.catch === 'function') {
|
|
268
|
+
p.catch((err) => {
|
|
269
|
+
try {
|
|
270
|
+
console.warn('[dsh-done-sound] audio play failed (browser blocked it?):', err && err.message ? err.message : err);
|
|
271
|
+
reportLog('warn', ['audio play failed (browser blocked it?):', err && err.message ? err.message : err]);
|
|
272
|
+
} catch {
|
|
273
|
+
// ignore
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
} catch (err) {
|
|
278
|
+
try {
|
|
279
|
+
console.warn('[dsh-done-sound] audio play failed:', err && err.message ? err.message : err);
|
|
280
|
+
reportLog('warn', ['audio play failed:', err && err.message ? err.message : err]);
|
|
281
|
+
} catch {
|
|
282
|
+
// ignore playback failures (autoplay policy, missing audio device)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- module-level deferred auto-retry ----
|
|
288
|
+
// Lives outside the component so the "continue" still fires even if the
|
|
289
|
+
// user navigates away and the header (and its timers) unmount.
|
|
290
|
+
let pluginCtx = null;
|
|
291
|
+
const retryState = { armed: false, sessionId: null, endTurnAtError: 0, deadline: 0, timer: null };
|
|
292
|
+
function cancelModuleRetry() {
|
|
293
|
+
if (retryState.timer !== null) {
|
|
294
|
+
clearTimeout(retryState.timer);
|
|
295
|
+
retryState.timer = null;
|
|
296
|
+
}
|
|
297
|
+
retryState.armed = false;
|
|
298
|
+
retryState.sessionId = null;
|
|
299
|
+
retryState.endTurnAtError = 0;
|
|
300
|
+
retryState.deadline = 0;
|
|
301
|
+
}
|
|
302
|
+
function fireModuleRetry() {
|
|
303
|
+
retryState.timer = null;
|
|
304
|
+
if (!retryState.armed) return;
|
|
305
|
+
retryState.armed = false;
|
|
306
|
+
const sid = retryState.sessionId;
|
|
307
|
+
retryState.sessionId = null;
|
|
308
|
+
retryState.deadline = 0;
|
|
309
|
+
// Real send path: the browser→host RPC client (same route the composer's
|
|
310
|
+
// "send" uses). ctx.connection is provided by @deepseek-ai/dsh-client-connection;
|
|
311
|
+
// its api.sessions.prompt carries an RpcResponse whose `.result` is {ok} |
|
|
312
|
+
// {ok:false,error}. Failures are surfaced (console + settings-card row) —
|
|
313
|
+
// the old remote.sessions guess was silently skipped because no such
|
|
314
|
+
// namespace exists on the client context.
|
|
315
|
+
const prompt = pluginCtx && pluginCtx.connection && pluginCtx.connection.api && pluginCtx.connection.api.sessions
|
|
316
|
+
? pluginCtx.connection.api.sessions.prompt
|
|
317
|
+
: null;
|
|
318
|
+
const fail = (why) => {
|
|
319
|
+
try {
|
|
320
|
+
console.error('[dsh-done-sound] auto-retry could not send "继续":', why);
|
|
321
|
+
reportLog('error', ['auto-retry could not send "继续":', why]);
|
|
322
|
+
} catch {
|
|
323
|
+
// console may be gone during teardown
|
|
324
|
+
}
|
|
325
|
+
setConfig({
|
|
326
|
+
retryCooldownUntil: null,
|
|
327
|
+
lastEvent: { at: Date.now(), kind: 'error', reason: 'error', play: false, retried: true, retryFailed: true },
|
|
328
|
+
});
|
|
329
|
+
};
|
|
330
|
+
if (typeof prompt !== 'function') {
|
|
331
|
+
fail('connection.api.sessions.prompt unavailable');
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
let tz = 'UTC';
|
|
335
|
+
try {
|
|
336
|
+
tz = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
176
337
|
} catch {
|
|
177
|
-
//
|
|
338
|
+
// keep UTC
|
|
178
339
|
}
|
|
340
|
+
Promise.resolve(prompt({ sessionId: sid, mode: 'queue', content: [{ type: 'text', text: '继续' }], clientTimeZone: tz }))
|
|
341
|
+
.then((res) => {
|
|
342
|
+
const result = res && res.result;
|
|
343
|
+
if (result && result.ok) {
|
|
344
|
+
try {
|
|
345
|
+
console.log('[dsh-done-sound] auto-retry sent "继续" to session', sid);
|
|
346
|
+
reportLog('info', ['auto-retry sent "继续" to session', sid]);
|
|
347
|
+
} catch {
|
|
348
|
+
// ignore
|
|
349
|
+
}
|
|
350
|
+
setConfig({
|
|
351
|
+
retryCooldownUntil: null,
|
|
352
|
+
lastEvent: { at: Date.now(), kind: 'error', reason: 'error', play: false, retried: true, retryFailed: false },
|
|
353
|
+
});
|
|
354
|
+
} else {
|
|
355
|
+
const err = result && result.error ? result.error.message : JSON.stringify(result);
|
|
356
|
+
fail(err || 'prompt not accepted');
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
.catch((err) => fail(err && err.message ? err.message : String(err)));
|
|
360
|
+
}
|
|
361
|
+
function armModuleRetry(sessionId, endTurnAtError, delayMs) {
|
|
362
|
+
cancelModuleRetry();
|
|
363
|
+
retryState.armed = true;
|
|
364
|
+
retryState.sessionId = sessionId;
|
|
365
|
+
retryState.endTurnAtError = endTurnAtError;
|
|
366
|
+
retryState.deadline = Date.now() + delayMs;
|
|
367
|
+
retryState.timer = setTimeout(fireModuleRetry, delayMs);
|
|
179
368
|
}
|
|
180
369
|
|
|
181
370
|
// ---- same-origin JSON API transport (no session needed) ----
|
|
@@ -205,6 +394,7 @@ window.__ModuleLoader__.load({
|
|
|
205
394
|
}
|
|
206
395
|
|
|
207
396
|
function apply(ctx) {
|
|
397
|
+
pluginCtx = ctx;
|
|
208
398
|
const syncConfig = (payload) => {
|
|
209
399
|
if (!payload || typeof payload !== 'object') return;
|
|
210
400
|
setConfig({
|
|
@@ -213,6 +403,10 @@ window.__ModuleLoader__.load({
|
|
|
213
403
|
playOnInterrupt: payload.playOnInterrupt === true,
|
|
214
404
|
playOnError: payload.playOnError !== false,
|
|
215
405
|
playOnPending: payload.playOnPending !== false,
|
|
406
|
+
autoRetryOnError: payload.autoRetryOnError !== false,
|
|
407
|
+
retryDelaySeconds: typeof payload.retryDelaySeconds === 'number' ? payload.retryDelaySeconds : 60,
|
|
408
|
+
version: typeof payload.version === 'string' ? payload.version : null,
|
|
409
|
+
logPath: typeof payload.logPath === 'string' && payload.logPath ? payload.logPath : null,
|
|
216
410
|
url: typeof payload.url === 'string' && payload.url ? payload.url : null,
|
|
217
411
|
defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
|
|
218
412
|
fileName: payload.audio && typeof payload.audio.fileName === 'string' ? payload.audio.fileName : '',
|
|
@@ -229,13 +423,59 @@ window.__ModuleLoader__.load({
|
|
|
229
423
|
const [busy, setBusy] = react.useState(false);
|
|
230
424
|
const [lang] = react.useState(resolveLang());
|
|
231
425
|
const L = (key) => t(lang, key);
|
|
426
|
+
const [cardTick, setCardTick] = react.useState(Date.now());
|
|
427
|
+
react.useEffect(() => {
|
|
428
|
+
const timer = setInterval(() => setCardTick(Date.now()), 1000);
|
|
429
|
+
return () => clearInterval(timer);
|
|
430
|
+
}, []);
|
|
431
|
+
// Auto-retry delay: local draft while editing, committed on blur/Enter.
|
|
432
|
+
const delayDraftRef = react.useRef(null);
|
|
433
|
+
const [delayDraft, setDelayDraft] = react.useState(cfg.retryDelaySeconds || 60);
|
|
434
|
+
react.useEffect(() => {
|
|
435
|
+
if (delayDraftRef.current !== document.activeElement) {
|
|
436
|
+
setDelayDraft(cfg.retryDelaySeconds || 60);
|
|
437
|
+
}
|
|
438
|
+
}, [cfg.retryDelaySeconds]);
|
|
439
|
+
const commitDelay = async () => {
|
|
440
|
+
const v = Number(delayDraft);
|
|
441
|
+
if (!Number.isFinite(v)) {
|
|
442
|
+
setDelayDraft(cfg.retryDelaySeconds || 60);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
const rounded = Math.min(300, Math.max(10, Math.round(v / 5) * 5));
|
|
446
|
+
if (rounded !== Number(cfg.retryDelaySeconds)) {
|
|
447
|
+
setDelayDraft(String(rounded));
|
|
448
|
+
const payload = await run('/dsh-done-sound/api/config', { retryDelaySeconds: rounded });
|
|
449
|
+
if (!payload) return;
|
|
450
|
+
// Verify the host actually persisted it (a 200 alone is not proof).
|
|
451
|
+
if (payload.retryDelaySeconds !== rounded) {
|
|
452
|
+
setError(L('saveMismatch').replace('{v}', String(payload.retryDelaySeconds)));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// Save feedback (also confirms the current value without changes).
|
|
457
|
+
setDelaySaved(true);
|
|
458
|
+
if (delaySavedTimer.current) clearTimeout(delaySavedTimer.current);
|
|
459
|
+
delaySavedTimer.current = setTimeout(() => setDelaySaved(false), 2000);
|
|
460
|
+
};
|
|
461
|
+
const [delaySaved, setDelaySaved] = react.useState(false);
|
|
462
|
+
const delaySavedTimer = react.useRef(null);
|
|
463
|
+
react.useEffect(
|
|
464
|
+
() => () => {
|
|
465
|
+
if (delaySavedTimer.current) clearTimeout(delaySavedTimer.current);
|
|
466
|
+
},
|
|
467
|
+
[],
|
|
468
|
+
);
|
|
232
469
|
|
|
233
470
|
react.useEffect(() => {
|
|
234
471
|
let disposed = false;
|
|
235
472
|
(async () => {
|
|
236
473
|
try {
|
|
237
474
|
const payload = await api('/dsh-done-sound/api/status');
|
|
238
|
-
if (!disposed)
|
|
475
|
+
if (!disposed) {
|
|
476
|
+
syncConfig(payload);
|
|
477
|
+
checkLatestVersion(typeof payload.version === 'string' ? payload.version : null);
|
|
478
|
+
}
|
|
239
479
|
} catch (e) {
|
|
240
480
|
if (!disposed) {
|
|
241
481
|
setError(L('apiFailed') + ' (' + (e && e.message ? e.message : String(e)) + ')');
|
|
@@ -248,6 +488,26 @@ window.__ModuleLoader__.load({
|
|
|
248
488
|
};
|
|
249
489
|
}, []);
|
|
250
490
|
|
|
491
|
+
// Query the npm registry for the latest published version and compare
|
|
492
|
+
// with the installed one (red if an update exists, green if up to date).
|
|
493
|
+
const checkLatestVersion = (currentVersion) => {
|
|
494
|
+
if (typeof currentVersion !== 'string' || currentVersion.length === 0) return;
|
|
495
|
+
fetch('https://registry.npmjs.org/dsh-done-sound/latest')
|
|
496
|
+
.then((res) => (res.ok ? res.json() : Promise.reject(new Error('http ' + res.status))))
|
|
497
|
+
.then((data) => {
|
|
498
|
+
const latest = data && typeof data.version === 'string' ? data.version : null;
|
|
499
|
+
if (latest === null) {
|
|
500
|
+
setConfig({ updateStatus: 'error' });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
setConfig({
|
|
504
|
+
latestVersion: latest,
|
|
505
|
+
updateStatus: compareVersions(latest, currentVersion) > 0 ? 'update' : 'uptodate',
|
|
506
|
+
});
|
|
507
|
+
})
|
|
508
|
+
.catch(() => setConfig({ updateStatus: 'error' }));
|
|
509
|
+
};
|
|
510
|
+
|
|
251
511
|
const run = async (pathname, body) => {
|
|
252
512
|
setBusy(true);
|
|
253
513
|
setError(null);
|
|
@@ -328,6 +588,33 @@ window.__ModuleLoader__.load({
|
|
|
328
588
|
),
|
|
329
589
|
),
|
|
330
590
|
|
|
591
|
+
// version + update status row (red when an update is available)
|
|
592
|
+
react.createElement(
|
|
593
|
+
'div',
|
|
594
|
+
{ className: 'dtc-row' },
|
|
595
|
+
react.createElement(
|
|
596
|
+
'span',
|
|
597
|
+
null,
|
|
598
|
+
L('version') + ':' + (cfg.version || '—'),
|
|
599
|
+
),
|
|
600
|
+
cfg.updateStatus === 'update'
|
|
601
|
+
? react.createElement('span', { className: 'dtc-update' }, L('updateAvailable') + (cfg.latestVersion ? ' v' + cfg.latestVersion : ''))
|
|
602
|
+
: cfg.updateStatus === 'uptodate'
|
|
603
|
+
? react.createElement('span', { className: 'dtc-uptodate' }, L('upToDate'))
|
|
604
|
+
: cfg.updateStatus === 'error'
|
|
605
|
+
? react.createElement('span', { className: 'dtc-muted' }, L('updateCheckFailed'))
|
|
606
|
+
: null,
|
|
607
|
+
),
|
|
608
|
+
|
|
609
|
+
cfg.logPath
|
|
610
|
+
? react.createElement(
|
|
611
|
+
'div',
|
|
612
|
+
{ className: 'dtc-row' },
|
|
613
|
+
react.createElement('span', { className: 'dtc-muted' }, L('logPath') + ':'),
|
|
614
|
+
react.createElement('span', { className: 'dtc-muted dtc-logpath' }, cfg.logPath),
|
|
615
|
+
)
|
|
616
|
+
: null,
|
|
617
|
+
|
|
331
618
|
react.createElement(
|
|
332
619
|
'div',
|
|
333
620
|
{ className: 'dtc-row' },
|
|
@@ -424,9 +711,82 @@ window.__ModuleLoader__.load({
|
|
|
424
711
|
),
|
|
425
712
|
react.createElement('span', { className: 'dtc-muted' }, L('pendingHint')),
|
|
426
713
|
),
|
|
714
|
+
react.createElement(
|
|
715
|
+
'div',
|
|
716
|
+
{ className: 'dtc-row' },
|
|
717
|
+
react.createElement(
|
|
718
|
+
'label',
|
|
719
|
+
{ className: 'dtc-toggle' },
|
|
720
|
+
react.createElement('input', {
|
|
721
|
+
type: 'checkbox',
|
|
722
|
+
checked: cfg.autoRetryOnError,
|
|
723
|
+
disabled: busy,
|
|
724
|
+
onChange: (e) => {
|
|
725
|
+
run('/dsh-done-sound/api/config', { autoRetryOnError: e.target.checked });
|
|
726
|
+
},
|
|
727
|
+
}),
|
|
728
|
+
react.createElement('span', null, L('autoRetry')),
|
|
729
|
+
),
|
|
730
|
+
react.createElement('span', { className: 'dtc-muted' }, L('autoRetryHint')),
|
|
731
|
+
),
|
|
732
|
+
react.createElement(
|
|
733
|
+
'div',
|
|
734
|
+
{ className: 'dtc-row' },
|
|
735
|
+
react.createElement('span', null, L('retryDelay')),
|
|
736
|
+
react.createElement('input', {
|
|
737
|
+
className: 'dtc-num',
|
|
738
|
+
type: 'number',
|
|
739
|
+
min: 10,
|
|
740
|
+
max: 300,
|
|
741
|
+
step: 5,
|
|
742
|
+
ref: delayDraftRef,
|
|
743
|
+
value: delayDraft,
|
|
744
|
+
disabled: busy,
|
|
745
|
+
onChange: (e) => setDelayDraft(e.target.value),
|
|
746
|
+
onBlur: () => {
|
|
747
|
+
commitDelay();
|
|
748
|
+
},
|
|
749
|
+
onKeyDown: (e) => {
|
|
750
|
+
if (e.key === 'Enter') {
|
|
751
|
+
e.preventDefault();
|
|
752
|
+
commitDelay();
|
|
753
|
+
}
|
|
754
|
+
},
|
|
755
|
+
}),
|
|
756
|
+
react.createElement('span', { className: 'dtc-muted' }, L('secondsUnit')),
|
|
757
|
+
react.createElement(
|
|
758
|
+
'button',
|
|
759
|
+
{
|
|
760
|
+
className: 'dtc-btn',
|
|
761
|
+
type: 'button',
|
|
762
|
+
disabled: busy,
|
|
763
|
+
onClick: () => {
|
|
764
|
+
commitDelay();
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
L('confirm'),
|
|
768
|
+
),
|
|
769
|
+
delaySaved && react.createElement('span', { className: 'dtc-saved' }, L('saved')),
|
|
770
|
+
),
|
|
771
|
+
react.createElement('span', { className: 'dtc-muted' }, L('retryDelayHint')),
|
|
427
772
|
|
|
428
773
|
error !== null && react.createElement('div', { className: 'dtc-err' }, error),
|
|
429
774
|
!cfg.loaded && react.createElement('div', { className: 'dtc-muted' }, L('loading')),
|
|
775
|
+
cfg.retryCooldownUntil && cfg.retryCooldownUntil > Date.now()
|
|
776
|
+
? react.createElement(
|
|
777
|
+
'div',
|
|
778
|
+
{ className: 'dtc-muted dtc-cooldown-line' },
|
|
779
|
+
'🔄 ' + L('retryCooldown').replace('{s}', String(Math.ceil((cfg.retryCooldownUntil - cardTick) / 1000))),
|
|
780
|
+
)
|
|
781
|
+
: null,
|
|
782
|
+
retryState.armed && retryState.deadline > cardTick
|
|
783
|
+
? react.createElement(
|
|
784
|
+
'div',
|
|
785
|
+
{ className: 'dtc-muted dtc-cooldown-line' },
|
|
786
|
+
'🔄 ' +
|
|
787
|
+
L('retryArmed').replace('{s}', String(Math.max(0, Math.ceil((retryState.deadline - cardTick) / 1000)))),
|
|
788
|
+
)
|
|
789
|
+
: null,
|
|
430
790
|
cfg.lastEvent &&
|
|
431
791
|
react.createElement(
|
|
432
792
|
'div',
|
|
@@ -443,7 +803,13 @@ window.__ModuleLoader__.load({
|
|
|
443
803
|
? L('evPending')
|
|
444
804
|
: L('evNormal')) +
|
|
445
805
|
' → ' +
|
|
446
|
-
(cfg.lastEvent.
|
|
806
|
+
(cfg.lastEvent.retried === true
|
|
807
|
+
? cfg.lastEvent.retryFailed
|
|
808
|
+
? L('retryFailed')
|
|
809
|
+
: L('retrySent')
|
|
810
|
+
: cfg.lastEvent.play
|
|
811
|
+
? L('evPlayed')
|
|
812
|
+
: L('evNotPlayed')),
|
|
447
813
|
),
|
|
448
814
|
);
|
|
449
815
|
};
|
|
@@ -461,7 +827,8 @@ window.__ModuleLoader__.load({
|
|
|
461
827
|
const pendingReasonRef = react.useRef(null);
|
|
462
828
|
const lastEndTurnRef = react.useRef(0);
|
|
463
829
|
const lastSessionRef = react.useRef(null);
|
|
464
|
-
const
|
|
830
|
+
const prevPendingKeysRef = react.useRef('');
|
|
831
|
+
const seededRef = react.useRef(false);
|
|
465
832
|
|
|
466
833
|
const snap =
|
|
467
834
|
typeof useSession === 'function'
|
|
@@ -480,11 +847,28 @@ window.__ModuleLoader__.load({
|
|
|
480
847
|
}
|
|
481
848
|
return {
|
|
482
849
|
sessionId: s && typeof s.sessionId === 'string' ? s.sessionId : null,
|
|
850
|
+
openState: s && typeof s.openState === 'string' ? s.openState : null,
|
|
483
851
|
streaming: s.partial !== null || s.running === true,
|
|
484
852
|
endReason,
|
|
485
853
|
endTurn,
|
|
486
854
|
// agent is waiting for human intervention (approval etc.)
|
|
487
855
|
pendingCount: s && Array.isArray(s.pending) ? s.pending.length : 0,
|
|
856
|
+
// Stable fingerprint of the items currently waiting, so a
|
|
857
|
+
// replacement wait (one approval resolved while the next is
|
|
858
|
+
// requested — count stays at 1) is still detected as a NEW
|
|
859
|
+
// wait. The old 0->N edge detector missed those, which is the
|
|
860
|
+
// "sometimes no sound" cause.
|
|
861
|
+
pendingKey:
|
|
862
|
+
s && Array.isArray(s.pending) && s.pending.length
|
|
863
|
+
? s.pending
|
|
864
|
+
.map((it) => {
|
|
865
|
+
const p = it && it.payload;
|
|
866
|
+
if (p && typeof p.approvalId === 'string') return 'a:' + p.approvalId;
|
|
867
|
+
if (p && typeof p.questionRpcId === 'string') return 'q:' + p.questionRpcId;
|
|
868
|
+
return 'i:' + JSON.stringify(it || null);
|
|
869
|
+
})
|
|
870
|
+
.join('|')
|
|
871
|
+
: '',
|
|
488
872
|
};
|
|
489
873
|
})
|
|
490
874
|
: null;
|
|
@@ -492,28 +876,98 @@ window.__ModuleLoader__.load({
|
|
|
492
876
|
react.useEffect(() => {
|
|
493
877
|
if (snap === null) return;
|
|
494
878
|
|
|
495
|
-
//
|
|
496
|
-
//
|
|
879
|
+
// Fire the "waiting for human input" reminder for the current config
|
|
880
|
+
// view. When no audio URL is known yet (status fetch still in flight,
|
|
881
|
+
// or the audio was changed elsewhere), re-fetch status once and play
|
|
882
|
+
// with the fresh view instead of silently skipping.
|
|
883
|
+
const notifyPending = () => {
|
|
884
|
+
const canPlay = (view) => view.enabled && view.playOnPending && (view.url || view.defaultUrl);
|
|
885
|
+
const record = (value) => setConfig({ lastEvent: { at: Date.now(), kind: 'pending', reason: 'pending', play: value } });
|
|
886
|
+
if (canPlay(cfg)) {
|
|
887
|
+
playChime(cfg);
|
|
888
|
+
record(true);
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
api('/dsh-done-sound/api/status')
|
|
892
|
+
.then((payload) => {
|
|
893
|
+
if (!payload || typeof payload !== 'object') return;
|
|
894
|
+
syncConfig(payload);
|
|
895
|
+
const fresh = {
|
|
896
|
+
...cfg,
|
|
897
|
+
enabled: payload.enabled === true,
|
|
898
|
+
playOnPending: payload.playOnPending !== false,
|
|
899
|
+
url: typeof payload.url === 'string' && payload.url ? payload.url : null,
|
|
900
|
+
defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
|
|
901
|
+
};
|
|
902
|
+
if (canPlay(fresh)) {
|
|
903
|
+
playChime(fresh);
|
|
904
|
+
record(true);
|
|
905
|
+
}
|
|
906
|
+
})
|
|
907
|
+
.catch(() => {});
|
|
908
|
+
record(false);
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
// New session (or first mount): reset and wait for the history to
|
|
912
|
+
// settle before listening.
|
|
497
913
|
if (snap.sessionId !== lastSessionRef.current) {
|
|
498
914
|
lastSessionRef.current = snap.sessionId;
|
|
915
|
+
seededRef.current = false;
|
|
499
916
|
pendingReasonRef.current = null;
|
|
500
|
-
lastEndTurnRef.current =
|
|
501
|
-
|
|
502
|
-
|
|
917
|
+
lastEndTurnRef.current = 0;
|
|
918
|
+
prevPendingKeysRef.current = '';
|
|
919
|
+
cancelModuleRetry();
|
|
920
|
+
setConfig({ retryCooldownUntil: null });
|
|
503
921
|
}
|
|
504
922
|
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
923
|
+
// Before the session window is open, the timeline fills with the
|
|
924
|
+
// conversation history (replayed turn/end events). Do NOT treat that
|
|
925
|
+
// replay as new completions: seed only once openState reaches 'open'
|
|
926
|
+
// (the latest tail is loaded; older pages only add lower turn
|
|
927
|
+
// numbers, which never move endTurn).
|
|
928
|
+
if (!seededRef.current) {
|
|
929
|
+
if (snap.openState === 'open') {
|
|
930
|
+
seededRef.current = true;
|
|
931
|
+
lastEndTurnRef.current = snap.endTurn;
|
|
932
|
+
// Seed with the current key so the pending detector only fires
|
|
933
|
+
// for NEW waiting items, but still notify once if we mounted
|
|
934
|
+
// into an already-waiting conversation (e.g. refresh mid-approval).
|
|
935
|
+
prevPendingKeysRef.current = snap.pendingKey || '';
|
|
936
|
+
if (snap.pendingCount > 0) notifyPending();
|
|
512
937
|
}
|
|
513
|
-
setConfig({ lastEvent: { at: Date.now(), kind: 'pending', reason: 'pending', play: played } });
|
|
514
938
|
return;
|
|
515
939
|
}
|
|
516
|
-
|
|
940
|
+
|
|
941
|
+
// Cancel a pending auto-retry when the session recovers on its own
|
|
942
|
+
// (agent runs again, a new turn closes, it waits for human input, or
|
|
943
|
+
// we switched sessions) — no need to send "继续" then.
|
|
944
|
+
if (retryState.armed) {
|
|
945
|
+
const cancel =
|
|
946
|
+
snap.sessionId !== retryState.sessionId ||
|
|
947
|
+
snap.streaming === true ||
|
|
948
|
+
snap.endTurn > retryState.endTurnAtError ||
|
|
949
|
+
snap.pendingCount > 0;
|
|
950
|
+
if (cancel) {
|
|
951
|
+
cancelModuleRetry();
|
|
952
|
+
setConfig({ retryCooldownUntil: null });
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Agent entered a waiting-for-human state. Notify on ANY new waiting item:
|
|
957
|
+
// the first pending, a replacement approval (one resolved, next requested —
|
|
958
|
+
// count stays 1, which the old 0->N rising-edge detector missed, i.e. the
|
|
959
|
+
// "sometimes no sound" bug), or an additional item stacking on top.
|
|
960
|
+
if (snap.pendingCount > 0) {
|
|
961
|
+
const prevKeys = new Set((prevPendingKeysRef.current || '').split('|').filter(Boolean));
|
|
962
|
+
const curKeys = (snap.pendingKey || '').split('|').filter(Boolean);
|
|
963
|
+
const hasNew = curKeys.some((k) => !prevKeys.has(k));
|
|
964
|
+
if (hasNew) {
|
|
965
|
+
prevPendingKeysRef.current = snap.pendingKey || '';
|
|
966
|
+
notifyPending();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
prevPendingKeysRef.current = snap.pendingKey || '';
|
|
517
971
|
|
|
518
972
|
// A newly-closed turn is the authoritative "a turn ended" signal.
|
|
519
973
|
if (snap.endTurn > lastEndTurnRef.current) {
|
|
@@ -586,8 +1040,42 @@ window.__ModuleLoader__.load({
|
|
|
586
1040
|
} else {
|
|
587
1041
|
record(played);
|
|
588
1042
|
}
|
|
1043
|
+
|
|
1044
|
+
// Arm the deferred auto-retry: after a real 'error' end, wait the
|
|
1045
|
+
// configured window; if the session recovers during the window
|
|
1046
|
+
// (streaming, new turn, approval wait) the retry is cancelled above,
|
|
1047
|
+
// otherwise the module-level timer fires "继续" when it expires.
|
|
1048
|
+
if (reason === 'error' && cfg.enabled && cfg.autoRetryOnError && snap.sessionId) {
|
|
1049
|
+
if (!retryState.armed) {
|
|
1050
|
+
const delayMs = (cfg.retryDelaySeconds || 60) * 1000;
|
|
1051
|
+
armModuleRetry(snap.sessionId, snap.endTurn, delayMs);
|
|
1052
|
+
setConfig({ retryCooldownUntil: retryState.deadline });
|
|
1053
|
+
setConfig({ lastEvent: { at: Date.now(), kind, reason, play: played, retryPending: true } });
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
589
1056
|
}, [snap, cfg]);
|
|
590
1057
|
|
|
1058
|
+
// Countdown chip display only (the actual fire is a module-level timer
|
|
1059
|
+
// that survives navigation away from the conversation).
|
|
1060
|
+
const [nowTick, setNowTick] = react.useState(Date.now());
|
|
1061
|
+
react.useEffect(() => {
|
|
1062
|
+
const timer = setInterval(() => setNowTick(Date.now()), 1000);
|
|
1063
|
+
return () => clearInterval(timer);
|
|
1064
|
+
}, []);
|
|
1065
|
+
const cooldownLeft =
|
|
1066
|
+
cfg.retryCooldownUntil && cfg.retryCooldownUntil > Date.now()
|
|
1067
|
+
? Math.ceil((cfg.retryCooldownUntil - nowTick) / 1000)
|
|
1068
|
+
: 0;
|
|
1069
|
+
if (cooldownLeft > 0) {
|
|
1070
|
+
const chipLang = resolveLang();
|
|
1071
|
+
return react.createElement(
|
|
1072
|
+
'span',
|
|
1073
|
+
{ className: 'dtc-retry-chip', title: t(chipLang, 'retryChipTitle') },
|
|
1074
|
+
react.createElement('span', { className: 'dtc-retry-ico' }, '🔄'),
|
|
1075
|
+
react.createElement('span', null, t(chipLang, 'retrying')),
|
|
1076
|
+
react.createElement('span', { className: 'dtc-retry-num' }, cooldownLeft + 's'),
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
591
1079
|
return null;
|
|
592
1080
|
};
|
|
593
1081
|
|
|
@@ -617,7 +1105,7 @@ window.__ModuleLoader__.load({
|
|
|
617
1105
|
}
|
|
618
1106
|
|
|
619
1107
|
exports.name = 'dsh-done-sound';
|
|
620
|
-
exports.inject = ['slots', 'remote', 'remote.commands'];
|
|
1108
|
+
exports.inject = ['slots', 'remote', 'remote.commands', 'connection'];
|
|
621
1109
|
exports.apply = apply;
|
|
622
1110
|
return module.exports;
|
|
623
1111
|
},
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// src/index.js
|
|
2
2
|
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { readFileSync, mkdirSync, appendFileSync } 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,40 @@ defineMethod("transform", [
|
|
|
792
793
|
"preserve"
|
|
793
794
|
], ({ inner }, isInner) => inner.toString(isInner));
|
|
794
795
|
|
|
795
|
-
//
|
|
796
|
+
// src/index.js
|
|
796
797
|
var name = "dsh-done-sound";
|
|
797
798
|
var inject = ["settings", "commands", "webServer"];
|
|
798
|
-
var
|
|
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 LOGS_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "logs");
|
|
812
|
+
function datedLogPath(now = /* @__PURE__ */ new Date()) {
|
|
813
|
+
const y = now.getFullYear();
|
|
814
|
+
const m = String(now.getMonth() + 1).padStart(2, "0");
|
|
815
|
+
const d = String(now.getDate()).padStart(2, "0");
|
|
816
|
+
return join(LOGS_ROOT, `${y}${m}${d}`, "dsh-done-sound.log");
|
|
817
|
+
}
|
|
818
|
+
function writeLog(level, message) {
|
|
819
|
+
try {
|
|
820
|
+
const now = /* @__PURE__ */ new Date();
|
|
821
|
+
const stamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;
|
|
822
|
+
const file = datedLogPath(now);
|
|
823
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
824
|
+
appendFileSync(file, `[${stamp}] [${level}] ${message}
|
|
825
|
+
`, "utf8");
|
|
826
|
+
} catch {
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
var MAX_BYTES = 10 * 1024 * 1024;
|
|
799
830
|
var DEFAULT_AUDIO_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "turn-done.wav");
|
|
800
831
|
var DEFAULT_AUDIO_URL = "/dsh-done-sound/audio/default";
|
|
801
832
|
var DEFAULT_AUDIO_MIME = "audio/wav";
|
|
@@ -820,6 +851,8 @@ var TurnChimeSchema = Schema.object({
|
|
|
820
851
|
playOnInterrupt: Schema.boolean().default(false),
|
|
821
852
|
playOnError: Schema.boolean().default(true),
|
|
822
853
|
playOnPending: Schema.boolean().default(true),
|
|
854
|
+
autoRetryOnError: Schema.boolean().default(true),
|
|
855
|
+
retryDelaySeconds: Schema.number().min(10).max(300).step(5).default(60),
|
|
823
856
|
audio: Schema.object({
|
|
824
857
|
fileId: Schema.string().default(""),
|
|
825
858
|
fileName: Schema.string().default(""),
|
|
@@ -840,11 +873,14 @@ function apply(ctx) {
|
|
|
840
873
|
const fileId = value.audio?.fileId ?? "";
|
|
841
874
|
return {
|
|
842
875
|
ok: true,
|
|
876
|
+
version: pluginVersion(),
|
|
843
877
|
enabled: value.enabled,
|
|
844
878
|
volume: value.volume,
|
|
845
879
|
playOnInterrupt: value.playOnInterrupt,
|
|
846
880
|
playOnError: value.playOnError,
|
|
847
881
|
playOnPending: value.playOnPending,
|
|
882
|
+
autoRetryOnError: value.autoRetryOnError,
|
|
883
|
+
retryDelaySeconds: value.retryDelaySeconds,
|
|
848
884
|
audio: {
|
|
849
885
|
fileId,
|
|
850
886
|
fileName: value.audio?.fileName ?? "",
|
|
@@ -852,7 +888,8 @@ function apply(ctx) {
|
|
|
852
888
|
size: value.audio?.size ?? 0
|
|
853
889
|
},
|
|
854
890
|
url: fileId ? `${ROUTE_PREFIX}/audio/${fileId}` : null,
|
|
855
|
-
defaultUrl: DEFAULT_AUDIO_URL
|
|
891
|
+
defaultUrl: DEFAULT_AUDIO_URL,
|
|
892
|
+
logPath: datedLogPath()
|
|
856
893
|
};
|
|
857
894
|
};
|
|
858
895
|
const clearAudio = async () => {
|
|
@@ -865,6 +902,7 @@ function apply(ctx) {
|
|
|
865
902
|
}
|
|
866
903
|
}
|
|
867
904
|
await scope.update({ audio: { fileId: "", fileName: "", mime: "", size: 0 } });
|
|
905
|
+
writeLog("INFO", "audio cleared");
|
|
868
906
|
};
|
|
869
907
|
const setAudio = async (dataUrl, fileName) => {
|
|
870
908
|
const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
|
@@ -874,7 +912,7 @@ function apply(ctx) {
|
|
|
874
912
|
if (!ext) throw new Error(`unsupported audio type: ${mime} (mp3/wav/ogg/webm/m4a/aac/flac only)`);
|
|
875
913
|
const buffer = Buffer.from(match[2], "base64");
|
|
876
914
|
if (buffer.length === 0) throw new Error("empty audio content");
|
|
877
|
-
if (buffer.length > MAX_BYTES) throw new Error(`audio exceeds
|
|
915
|
+
if (buffer.length > MAX_BYTES) throw new Error(`audio exceeds 10MB (got ${buffer.length} bytes)`);
|
|
878
916
|
const fileId = randomUUID();
|
|
879
917
|
await mkdir(dataDir, { recursive: true });
|
|
880
918
|
await writeFile(audioPath(fileId), buffer);
|
|
@@ -888,6 +926,7 @@ function apply(ctx) {
|
|
|
888
926
|
} catch {
|
|
889
927
|
}
|
|
890
928
|
}
|
|
929
|
+
writeLog("INFO", `audio stored: ${fileName || "chime"} (${buffer.length} bytes, ${mime})`);
|
|
891
930
|
return fileId;
|
|
892
931
|
};
|
|
893
932
|
ctx.commands.register({
|
|
@@ -944,6 +983,7 @@ function apply(ctx) {
|
|
|
944
983
|
return { kind: "error", text: `\u672A\u77E5\u64CD\u4F5C\uFF1A${action}` };
|
|
945
984
|
}
|
|
946
985
|
} catch (error) {
|
|
986
|
+
writeLog("ERROR", `command failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
947
987
|
return { kind: "error", text: error instanceof Error ? error.message : String(error) };
|
|
948
988
|
}
|
|
949
989
|
}
|
|
@@ -1031,6 +1071,10 @@ function apply(ctx) {
|
|
|
1031
1071
|
if (typeof body.playOnInterrupt === "boolean") patch.playOnInterrupt = body.playOnInterrupt;
|
|
1032
1072
|
if (typeof body.playOnError === "boolean") patch.playOnError = body.playOnError;
|
|
1033
1073
|
if (typeof body.playOnPending === "boolean") patch.playOnPending = body.playOnPending;
|
|
1074
|
+
if (typeof body.autoRetryOnError === "boolean") patch.autoRetryOnError = body.autoRetryOnError;
|
|
1075
|
+
if (typeof body.retryDelaySeconds === "number" && body.retryDelaySeconds >= 10 && body.retryDelaySeconds <= 300) {
|
|
1076
|
+
patch.retryDelaySeconds = Math.round(body.retryDelaySeconds / 5) * 5;
|
|
1077
|
+
}
|
|
1034
1078
|
if (Object.keys(patch).length > 0) await scope.update(patch);
|
|
1035
1079
|
sendJson(res, 200, statusPayload());
|
|
1036
1080
|
return;
|
|
@@ -1046,7 +1090,22 @@ function apply(ctx) {
|
|
|
1046
1090
|
sendJson(res, 200, statusPayload());
|
|
1047
1091
|
return;
|
|
1048
1092
|
}
|
|
1093
|
+
if (pathname === "/dsh-done-sound/api/log" && method === "POST") {
|
|
1094
|
+
const body = await readJsonBody(req);
|
|
1095
|
+
const message = typeof body.message === "string" ? body.message : "";
|
|
1096
|
+
if (message) {
|
|
1097
|
+
const level = typeof body.level === "string" && /^[A-Za-z]+$/.test(body.level) ? body.level.toUpperCase() : "INFO";
|
|
1098
|
+
writeLog(body.source === "client" ? `CLIENT-${level}` : level, message.slice(0, 4e3));
|
|
1099
|
+
}
|
|
1100
|
+
sendJson(res, 200, { ok: true });
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (pathname === "/dsh-done-sound/api/log" && method === "GET") {
|
|
1104
|
+
sendJson(res, 200, { ok: true, logPath: datedLogPath() });
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1049
1107
|
} catch (error) {
|
|
1108
|
+
writeLog("ERROR", `api ${method} ${pathname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1050
1109
|
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
1051
1110
|
return;
|
|
1052
1111
|
}
|