dsh-done-sound 0.1.2 → 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 +18 -34
- package/assets/turn-done.wav +0 -0
- package/lib/client.js +556 -27
- package/lib/index.js +51 -6
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -1,54 +1,38 @@
|
|
|
1
1
|
# dsh-done-sound
|
|
2
2
|
|
|
3
|
-
DeepSeek Harness Web GUI
|
|
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
|
|
16
|
+
dsh plugin --profile web add link:F:\0.AI-CodeProject\DSHProject\0.鏃ュ父鑱婂ぉ\dsh-done-sound
|
|
24
17
|
```
|
|
25
18
|
|
|
26
|
-
|
|
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 #
|
|
37
|
-
pnpm run build #
|
|
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
|
-
|
|
48
|
-
|
|
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
|
|
Binary file
|
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');
|
|
@@ -60,7 +69,26 @@ window.__ModuleLoader__.load({
|
|
|
60
69
|
interruptHint: '手动停止生成时也播放(默认关闭)',
|
|
61
70
|
error: '出错时也响',
|
|
62
71
|
errorHint: '对话以错误结束时也播放(默认开启)',
|
|
63
|
-
|
|
72
|
+
pending: '等待确认时提醒',
|
|
73
|
+
pendingHint: 'Agent 需要你确认操作(如审批)而停住等待时播放提醒(默认开启)',
|
|
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} 秒',
|
|
64
92
|
readFailed: '读取文件失败',
|
|
65
93
|
apiFailed: '无法连接插件服务(Host 未加载?),请重启 dsh web',
|
|
66
94
|
noSession: '打开一个会话后配置',
|
|
@@ -69,6 +97,7 @@ window.__ModuleLoader__.load({
|
|
|
69
97
|
evNormal: '正常完成',
|
|
70
98
|
evInterrupt: '中断',
|
|
71
99
|
evError: '出错',
|
|
100
|
+
evPending: '等待确认',
|
|
72
101
|
evPlayed: '已播放',
|
|
73
102
|
evNotPlayed: '未播放',
|
|
74
103
|
},
|
|
@@ -86,7 +115,26 @@ window.__ModuleLoader__.load({
|
|
|
86
115
|
interruptHint: 'Also play when generation is stopped (default off)',
|
|
87
116
|
error: 'Play on error',
|
|
88
117
|
errorHint: 'Also play when a turn ends in error (default on)',
|
|
89
|
-
|
|
118
|
+
pending: 'Alert on approval wait',
|
|
119
|
+
pendingHint: 'Play a reminder when the agent is waiting for your confirmation (default on)',
|
|
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',
|
|
90
138
|
readFailed: 'Failed to read file',
|
|
91
139
|
apiFailed: 'Cannot reach the plugin host service (host not loaded?), restart dsh web',
|
|
92
140
|
noSession: 'Open a session to configure',
|
|
@@ -95,6 +143,7 @@ window.__ModuleLoader__.load({
|
|
|
95
143
|
evNormal: 'completed',
|
|
96
144
|
evInterrupt: 'interrupted',
|
|
97
145
|
evError: 'error',
|
|
146
|
+
evPending: 'approval wait',
|
|
98
147
|
evPlayed: 'played',
|
|
99
148
|
evNotPlayed: 'not played',
|
|
100
149
|
},
|
|
@@ -122,6 +171,15 @@ window.__ModuleLoader__.load({
|
|
|
122
171
|
return '';
|
|
123
172
|
}
|
|
124
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
|
+
}
|
|
125
183
|
|
|
126
184
|
// ---- tiny shared config store (card writes, detector reads) ----
|
|
127
185
|
const configStore = {
|
|
@@ -130,7 +188,15 @@ window.__ModuleLoader__.load({
|
|
|
130
188
|
volume: 0.8,
|
|
131
189
|
playOnInterrupt: false,
|
|
132
190
|
playOnError: true,
|
|
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
|
|
133
198
|
url: null,
|
|
199
|
+
defaultUrl: null,
|
|
134
200
|
fileName: '',
|
|
135
201
|
mime: '',
|
|
136
202
|
size: 0,
|
|
@@ -159,14 +225,109 @@ window.__ModuleLoader__.load({
|
|
|
159
225
|
return cfg;
|
|
160
226
|
}
|
|
161
227
|
function playChime(cfg) {
|
|
162
|
-
|
|
228
|
+
const url = cfg.url || cfg.defaultUrl;
|
|
229
|
+
if (!cfg.enabled || !url) return;
|
|
163
230
|
try {
|
|
164
|
-
const audio = new Audio(
|
|
231
|
+
const audio = new Audio(url);
|
|
165
232
|
audio.volume = cfg.volume;
|
|
166
|
-
audio.play()
|
|
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';
|
|
167
301
|
} catch {
|
|
168
|
-
//
|
|
302
|
+
// keep UTC
|
|
169
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);
|
|
170
331
|
}
|
|
171
332
|
|
|
172
333
|
// ---- same-origin JSON API transport (no session needed) ----
|
|
@@ -196,6 +357,7 @@ window.__ModuleLoader__.load({
|
|
|
196
357
|
}
|
|
197
358
|
|
|
198
359
|
function apply(ctx) {
|
|
360
|
+
pluginCtx = ctx;
|
|
199
361
|
const syncConfig = (payload) => {
|
|
200
362
|
if (!payload || typeof payload !== 'object') return;
|
|
201
363
|
setConfig({
|
|
@@ -203,7 +365,12 @@ window.__ModuleLoader__.load({
|
|
|
203
365
|
volume: typeof payload.volume === 'number' ? payload.volume : 0.8,
|
|
204
366
|
playOnInterrupt: payload.playOnInterrupt === true,
|
|
205
367
|
playOnError: payload.playOnError !== false,
|
|
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,
|
|
206
372
|
url: typeof payload.url === 'string' && payload.url ? payload.url : null,
|
|
373
|
+
defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
|
|
207
374
|
fileName: payload.audio && typeof payload.audio.fileName === 'string' ? payload.audio.fileName : '',
|
|
208
375
|
mime: payload.audio && typeof payload.audio.mime === 'string' ? payload.audio.mime : '',
|
|
209
376
|
size: payload.audio && typeof payload.audio.size === 'number' ? payload.audio.size : 0,
|
|
@@ -218,13 +385,59 @@ window.__ModuleLoader__.load({
|
|
|
218
385
|
const [busy, setBusy] = react.useState(false);
|
|
219
386
|
const [lang] = react.useState(resolveLang());
|
|
220
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
|
+
);
|
|
221
431
|
|
|
222
432
|
react.useEffect(() => {
|
|
223
433
|
let disposed = false;
|
|
224
434
|
(async () => {
|
|
225
435
|
try {
|
|
226
436
|
const payload = await api('/dsh-done-sound/api/status');
|
|
227
|
-
if (!disposed)
|
|
437
|
+
if (!disposed) {
|
|
438
|
+
syncConfig(payload);
|
|
439
|
+
checkLatestVersion(typeof payload.version === 'string' ? payload.version : null);
|
|
440
|
+
}
|
|
228
441
|
} catch (e) {
|
|
229
442
|
if (!disposed) {
|
|
230
443
|
setError(L('apiFailed') + ' (' + (e && e.message ? e.message : String(e)) + ')');
|
|
@@ -237,6 +450,26 @@ window.__ModuleLoader__.load({
|
|
|
237
450
|
};
|
|
238
451
|
}, []);
|
|
239
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
|
+
|
|
240
473
|
const run = async (pathname, body) => {
|
|
241
474
|
setBusy(true);
|
|
242
475
|
setError(null);
|
|
@@ -317,6 +550,24 @@ window.__ModuleLoader__.load({
|
|
|
317
550
|
),
|
|
318
551
|
),
|
|
319
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
|
+
|
|
320
571
|
react.createElement(
|
|
321
572
|
'div',
|
|
322
573
|
{ className: 'dtc-row' },
|
|
@@ -395,9 +646,100 @@ window.__ModuleLoader__.load({
|
|
|
395
646
|
),
|
|
396
647
|
react.createElement('span', { className: 'dtc-muted' }, L('errorHint')),
|
|
397
648
|
),
|
|
649
|
+
react.createElement(
|
|
650
|
+
'div',
|
|
651
|
+
{ className: 'dtc-row' },
|
|
652
|
+
react.createElement(
|
|
653
|
+
'label',
|
|
654
|
+
{ className: 'dtc-toggle' },
|
|
655
|
+
react.createElement('input', {
|
|
656
|
+
type: 'checkbox',
|
|
657
|
+
checked: cfg.playOnPending,
|
|
658
|
+
disabled: busy,
|
|
659
|
+
onChange: (e) => {
|
|
660
|
+
run('/dsh-done-sound/api/config', { playOnPending: e.target.checked });
|
|
661
|
+
},
|
|
662
|
+
}),
|
|
663
|
+
react.createElement('span', null, L('pending')),
|
|
664
|
+
),
|
|
665
|
+
react.createElement('span', { className: 'dtc-muted' }, L('pendingHint')),
|
|
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')),
|
|
398
725
|
|
|
399
726
|
error !== null && react.createElement('div', { className: 'dtc-err' }, error),
|
|
400
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,
|
|
401
743
|
cfg.lastEvent &&
|
|
402
744
|
react.createElement(
|
|
403
745
|
'div',
|
|
@@ -406,9 +748,21 @@ window.__ModuleLoader__.load({
|
|
|
406
748
|
':' +
|
|
407
749
|
formatEventTime(cfg.lastEvent.at) +
|
|
408
750
|
' ' +
|
|
409
|
-
(cfg.lastEvent.kind === 'interrupt'
|
|
751
|
+
(cfg.lastEvent.kind === 'interrupt'
|
|
752
|
+
? L('evInterrupt')
|
|
753
|
+
: cfg.lastEvent.kind === 'error'
|
|
754
|
+
? L('evError')
|
|
755
|
+
: cfg.lastEvent.kind === 'pending'
|
|
756
|
+
? L('evPending')
|
|
757
|
+
: L('evNormal')) +
|
|
410
758
|
' → ' +
|
|
411
|
-
(cfg.lastEvent.
|
|
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')),
|
|
412
766
|
),
|
|
413
767
|
);
|
|
414
768
|
};
|
|
@@ -426,6 +780,8 @@ window.__ModuleLoader__.load({
|
|
|
426
780
|
const pendingReasonRef = react.useRef(null);
|
|
427
781
|
const lastEndTurnRef = react.useRef(0);
|
|
428
782
|
const lastSessionRef = react.useRef(null);
|
|
783
|
+
const prevPendingKeysRef = react.useRef('');
|
|
784
|
+
const seededRef = react.useRef(false);
|
|
429
785
|
|
|
430
786
|
const snap =
|
|
431
787
|
typeof useSession === 'function'
|
|
@@ -444,9 +800,28 @@ window.__ModuleLoader__.load({
|
|
|
444
800
|
}
|
|
445
801
|
return {
|
|
446
802
|
sessionId: s && typeof s.sessionId === 'string' ? s.sessionId : null,
|
|
803
|
+
openState: s && typeof s.openState === 'string' ? s.openState : null,
|
|
447
804
|
streaming: s.partial !== null || s.running === true,
|
|
448
805
|
endReason,
|
|
449
806
|
endTurn,
|
|
807
|
+
// agent is waiting for human intervention (approval etc.)
|
|
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
|
+
: '',
|
|
450
825
|
};
|
|
451
826
|
})
|
|
452
827
|
: null;
|
|
@@ -454,15 +829,99 @@ window.__ModuleLoader__.load({
|
|
|
454
829
|
react.useEffect(() => {
|
|
455
830
|
if (snap === null) return;
|
|
456
831
|
|
|
457
|
-
//
|
|
458
|
-
//
|
|
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.
|
|
459
866
|
if (snap.sessionId !== lastSessionRef.current) {
|
|
460
867
|
lastSessionRef.current = snap.sessionId;
|
|
868
|
+
seededRef.current = false;
|
|
461
869
|
pendingReasonRef.current = null;
|
|
462
|
-
lastEndTurnRef.current =
|
|
870
|
+
lastEndTurnRef.current = 0;
|
|
871
|
+
prevPendingKeysRef.current = '';
|
|
872
|
+
cancelModuleRetry();
|
|
873
|
+
setConfig({ retryCooldownUntil: null });
|
|
874
|
+
}
|
|
875
|
+
|
|
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();
|
|
890
|
+
}
|
|
463
891
|
return;
|
|
464
892
|
}
|
|
465
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 || '';
|
|
924
|
+
|
|
466
925
|
// A newly-closed turn is the authoritative "a turn ended" signal.
|
|
467
926
|
if (snap.endTurn > lastEndTurnRef.current) {
|
|
468
927
|
lastEndTurnRef.current = snap.endTurn;
|
|
@@ -480,26 +939,96 @@ window.__ModuleLoader__.load({
|
|
|
480
939
|
else if (reason === 'error' || reason === 'max-tokens') kind = 'error';
|
|
481
940
|
else kind = 'normal'; // 'completed' (and unknown future reasons default to playing)
|
|
482
941
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
942
|
+
// Fire the chime for a given config view; returns whether it played.
|
|
943
|
+
const effectiveUrlOf = (view) => view.url || view.defaultUrl;
|
|
944
|
+
const firePlay = (view) => {
|
|
945
|
+
if (kind === 'interrupt') {
|
|
946
|
+
if (view.playOnInterrupt && effectiveUrlOf(view)) {
|
|
947
|
+
playChime(view);
|
|
948
|
+
return true;
|
|
949
|
+
}
|
|
950
|
+
return false;
|
|
488
951
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
952
|
+
if (kind === 'error') {
|
|
953
|
+
if (view.playOnError && effectiveUrlOf(view)) {
|
|
954
|
+
playChime(view);
|
|
955
|
+
return true;
|
|
956
|
+
}
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
if (effectiveUrlOf(view)) {
|
|
960
|
+
playChime(view);
|
|
961
|
+
return true;
|
|
493
962
|
}
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
963
|
+
return false;
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
let played = firePlay(cfg);
|
|
967
|
+
const record = (value) =>
|
|
968
|
+
setConfig({ lastEvent: { at: Date.now(), kind, reason, play: value } });
|
|
969
|
+
if (!played && !cfg.url && !cfg.defaultUrl) {
|
|
970
|
+
// Self-heal: the in-memory config may be stale (e.g. the audio was
|
|
971
|
+
// uploaded after this page mounted, or in another tab). Re-fetch
|
|
972
|
+
// status once and retry before giving up.
|
|
973
|
+
api('/dsh-done-sound/api/status')
|
|
974
|
+
.then((payload) => {
|
|
975
|
+
if (payload && typeof payload === 'object') {
|
|
976
|
+
syncConfig(payload);
|
|
977
|
+
const fresh = {
|
|
978
|
+
...cfg,
|
|
979
|
+
enabled: payload.enabled === true,
|
|
980
|
+
volume: typeof payload.volume === 'number' ? payload.volume : 0.8,
|
|
981
|
+
playOnInterrupt: payload.playOnInterrupt === true,
|
|
982
|
+
playOnError: payload.playOnError !== false,
|
|
983
|
+
playOnPending: payload.playOnPending !== false,
|
|
984
|
+
url: typeof payload.url === 'string' && payload.url ? payload.url : null,
|
|
985
|
+
defaultUrl: typeof payload.defaultUrl === 'string' && payload.defaultUrl ? payload.defaultUrl : null,
|
|
986
|
+
};
|
|
987
|
+
record(firePlay(fresh));
|
|
988
|
+
} else {
|
|
989
|
+
record(false);
|
|
990
|
+
}
|
|
991
|
+
})
|
|
992
|
+
.catch(() => record(false));
|
|
993
|
+
} else {
|
|
994
|
+
record(played);
|
|
497
995
|
}
|
|
498
996
|
|
|
499
|
-
//
|
|
500
|
-
|
|
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
|
+
}
|
|
501
1009
|
}, [snap, cfg]);
|
|
502
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
|
+
}
|
|
503
1032
|
return null;
|
|
504
1033
|
};
|
|
505
1034
|
|
|
@@ -529,7 +1058,7 @@ window.__ModuleLoader__.load({
|
|
|
529
1058
|
}
|
|
530
1059
|
|
|
531
1060
|
exports.name = 'dsh-done-sound';
|
|
532
|
-
exports.inject = ['slots', 'remote', 'remote.commands'];
|
|
1061
|
+
exports.inject = ['slots', 'remote', 'remote.commands', 'connection'];
|
|
533
1062
|
exports.apply = apply;
|
|
534
1063
|
return module.exports;
|
|
535
1064
|
},
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
//
|
|
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
|
-
import { join } from "node:path";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
|
|
7
8
|
// C:/Users/Administrator/.dsh/profiles/web/node_modules/@deepseek-ai/cosmokit/lib/index.js
|
|
@@ -792,10 +793,25 @@ 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 MAX_BYTES = 10 * 1024 * 1024;
|
|
812
|
+
var DEFAULT_AUDIO_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "turn-done.wav");
|
|
813
|
+
var DEFAULT_AUDIO_URL = "/dsh-done-sound/audio/default";
|
|
814
|
+
var DEFAULT_AUDIO_MIME = "audio/wav";
|
|
799
815
|
var AUDIO_MIMES = {
|
|
800
816
|
"audio/mpeg": "mp3",
|
|
801
817
|
"audio/mp3": "mp3",
|
|
@@ -816,6 +832,9 @@ var TurnChimeSchema = Schema.object({
|
|
|
816
832
|
volume: Schema.number().min(0).max(1).step(0.01).default(0.8),
|
|
817
833
|
playOnInterrupt: Schema.boolean().default(false),
|
|
818
834
|
playOnError: Schema.boolean().default(true),
|
|
835
|
+
playOnPending: Schema.boolean().default(true),
|
|
836
|
+
autoRetryOnError: Schema.boolean().default(true),
|
|
837
|
+
retryDelaySeconds: Schema.number().min(10).max(300).step(5).default(60),
|
|
819
838
|
audio: Schema.object({
|
|
820
839
|
fileId: Schema.string().default(""),
|
|
821
840
|
fileName: Schema.string().default(""),
|
|
@@ -836,17 +855,22 @@ function apply(ctx) {
|
|
|
836
855
|
const fileId = value.audio?.fileId ?? "";
|
|
837
856
|
return {
|
|
838
857
|
ok: true,
|
|
858
|
+
version: pluginVersion(),
|
|
839
859
|
enabled: value.enabled,
|
|
840
860
|
volume: value.volume,
|
|
841
861
|
playOnInterrupt: value.playOnInterrupt,
|
|
842
862
|
playOnError: value.playOnError,
|
|
863
|
+
playOnPending: value.playOnPending,
|
|
864
|
+
autoRetryOnError: value.autoRetryOnError,
|
|
865
|
+
retryDelaySeconds: value.retryDelaySeconds,
|
|
843
866
|
audio: {
|
|
844
867
|
fileId,
|
|
845
868
|
fileName: value.audio?.fileName ?? "",
|
|
846
869
|
mime: value.audio?.mime ?? "",
|
|
847
870
|
size: value.audio?.size ?? 0
|
|
848
871
|
},
|
|
849
|
-
url: fileId ? `${ROUTE_PREFIX}/audio/${fileId}` : null
|
|
872
|
+
url: fileId ? `${ROUTE_PREFIX}/audio/${fileId}` : null,
|
|
873
|
+
defaultUrl: DEFAULT_AUDIO_URL
|
|
850
874
|
};
|
|
851
875
|
};
|
|
852
876
|
const clearAudio = async () => {
|
|
@@ -868,7 +892,7 @@ function apply(ctx) {
|
|
|
868
892
|
if (!ext) throw new Error(`unsupported audio type: ${mime} (mp3/wav/ogg/webm/m4a/aac/flac only)`);
|
|
869
893
|
const buffer = Buffer.from(match[2], "base64");
|
|
870
894
|
if (buffer.length === 0) throw new Error("empty audio content");
|
|
871
|
-
if (buffer.length > MAX_BYTES) throw new Error(`audio exceeds
|
|
895
|
+
if (buffer.length > MAX_BYTES) throw new Error(`audio exceeds 10MB (got ${buffer.length} bytes)`);
|
|
872
896
|
const fileId = randomUUID();
|
|
873
897
|
await mkdir(dataDir, { recursive: true });
|
|
874
898
|
await writeFile(audioPath(fileId), buffer);
|
|
@@ -972,6 +996,22 @@ function apply(ctx) {
|
|
|
972
996
|
pathname = "/";
|
|
973
997
|
}
|
|
974
998
|
const method = (req.method ?? "GET").toUpperCase();
|
|
999
|
+
if (pathname === DEFAULT_AUDIO_URL) {
|
|
1000
|
+
try {
|
|
1001
|
+
const buffer = await readFile(DEFAULT_AUDIO_PATH);
|
|
1002
|
+
res.writeHead(200, {
|
|
1003
|
+
"Content-Type": DEFAULT_AUDIO_MIME,
|
|
1004
|
+
"Content-Length": buffer.length,
|
|
1005
|
+
"Cache-Control": "no-cache",
|
|
1006
|
+
"X-Content-Type-Options": "nosniff"
|
|
1007
|
+
});
|
|
1008
|
+
res.end(buffer);
|
|
1009
|
+
} catch {
|
|
1010
|
+
res.writeHead(404);
|
|
1011
|
+
res.end("not found");
|
|
1012
|
+
}
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
975
1015
|
const audioMatch = /^\/dsh-done-sound\/audio\/([A-Za-z0-9-]+)$/.exec(pathname);
|
|
976
1016
|
if (audioMatch) {
|
|
977
1017
|
const fileId = audioMatch[1];
|
|
@@ -1008,6 +1048,11 @@ function apply(ctx) {
|
|
|
1008
1048
|
if (typeof body.volume === "number" && body.volume >= 0 && body.volume <= 1) patch.volume = body.volume;
|
|
1009
1049
|
if (typeof body.playOnInterrupt === "boolean") patch.playOnInterrupt = body.playOnInterrupt;
|
|
1010
1050
|
if (typeof body.playOnError === "boolean") patch.playOnError = body.playOnError;
|
|
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
|
+
}
|
|
1011
1056
|
if (Object.keys(patch).length > 0) await scope.update(patch);
|
|
1012
1057
|
sendJson(res, 200, statusPayload());
|
|
1013
1058
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-done-sound",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"files": [
|
|
32
32
|
"lib",
|
|
33
|
+
"assets",
|
|
33
34
|
"cordis.patch.yml",
|
|
34
35
|
"README.md",
|
|
35
36
|
"LICENSE"
|
|
@@ -81,8 +82,12 @@
|
|
|
81
82
|
"devDependencies": {
|
|
82
83
|
"esbuild": "^0.25.0"
|
|
83
84
|
},
|
|
85
|
+
"engines": {
|
|
86
|
+
"node": ">=18"
|
|
87
|
+
},
|
|
84
88
|
"scripts": {
|
|
85
89
|
"build": "esbuild src/index.js --bundle --format=esm --platform=node --target=node22 --outfile=lib/index.js",
|
|
86
|
-
"check": "node --check lib/index.js && node --check lib/client.js"
|
|
90
|
+
"check": "node --check lib/index.js && node --check lib/client.js",
|
|
91
|
+
"test": "node test/host.test.mjs && node test/classify.test.mjs"
|
|
87
92
|
}
|
|
88
93
|
}
|