@goodandready/dsh-tts 0.4.32 → 0.4.34
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/CHANGELOG.md +20 -0
- package/lib/client.js +461 -70
- package/lib/engines/manager.js +8 -1
- package/lib/updater.js +25 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.34 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
- **Per-Reply Speak Action** (#208, GitHub PR #2):
|
|
6
|
+
- Added dedicated speaker button to `conversation.chat.assistant-actions` (order 5), positioned beside copy action to read rendered assistant turn text directly from the DOM.
|
|
7
|
+
- Added in-row transport cluster next to the reply clock (pause, stop, skip, export) that remains visible during playback even when mouse leaves message row.
|
|
8
|
+
- **Context Menu & Contained Scroll** (#208, GitHub PR #2):
|
|
9
|
+
- Added right-click context menu on the speaker action to display recent utterances with contained wheel scrolling that prevents closing on scroll.
|
|
10
|
+
- **Hotkeys & Playback Enhancements** (#208, GitHub PR #2):
|
|
11
|
+
- Added `Shift+Space` hotkey to stop active playback or instantly replay the last utterance from memory cache (`player.lastItem`).
|
|
12
|
+
- Added `statusKnown` guard in SSE event handling to prevent dropping initial stream utterances during server startup.
|
|
13
|
+
- Primed pending audio poll immediately on mount and on audio unlock gesture.
|
|
14
|
+
- **Client Bundle**: Rebuilt `lib/client.js` from canonical `lib/client-src/` fragments with updated tests.
|
|
15
|
+
|
|
16
|
+
## 0.4.33 (2026-10-11)
|
|
17
|
+
|
|
18
|
+
- **SSE Stream Watchdog**: Added idle timeout watchdog (30s) in Web player (`lib/client-src/20-player.js`) to automatically trigger reconnect on stale/frozen EventSource connection (#206).
|
|
19
|
+
- **Engine Manager Cleanup**: Added explicit `removeEventListener('abort', ...)` in `execute` finally block for model downloads in `lib/engines/manager.js` (#206).
|
|
20
|
+
- **Updater Testing**: Added behavioral test capturing `child_process.spawn` arguments during `/api/dsh-tts/update` execution verifying no bypass of minimum release age (#206).
|
|
21
|
+
- **Client Bundle**: Rebuilt `lib/client.js` from canonical client-src fragments with idle watchdog.
|
|
22
|
+
|
|
3
23
|
## 0.4.32
|
|
4
24
|
|
|
5
25
|
### Fixed
|
package/lib/client.js
CHANGED
|
@@ -210,6 +210,39 @@ window.__ModuleLoader__.load({
|
|
|
210
210
|
'.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
|
|
211
211
|
'.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 10%, transparent);color:var(--dsw-alias-state-warn-primary, #f59e0b);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 20%, transparent)}' +
|
|
212
212
|
'.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
|
|
213
|
+
// Per-message speak button: icon-only, same weight as the sibling actions.
|
|
214
|
+
'.dts-act{box-sizing:border-box;width:calc(28px + var(--dsh-content-font-delta,0px));height:calc(28px + var(--dsh-content-font-delta,0px));display:inline-flex;align-items:center;justify-content:center;padding:6px;cursor:pointer;border:none;border-radius:var(--dsw-radius-sm, 8px);background:none;color:var(--dsw-alias-label-tertiary, #999);transition:background .15s ease,color .15s ease}' +
|
|
215
|
+
'.dts-act:hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12));color:var(--dsw-alias-label-secondary, #666)}' +
|
|
216
|
+
'.dts-act:active{background:color-mix(in srgb, var(--dsw-alias-label-secondary, #666) 16%, transparent)}' +
|
|
217
|
+
'.dts-act[data-active]{color:var(--dsw-alias-brand-primary, #4d6bfe)}' +
|
|
218
|
+
'.dts-act[data-active]:hover{background:color-mix(in srgb, var(--dsw-alias-brand-primary, #4d6bfe) 12%, transparent);color:var(--dsw-alias-brand-primary, #4d6bfe)}' +
|
|
219
|
+
'.dts-act:focus-visible{outline:2px solid var(--dsw-alias-brand-primary, #4d6bfe);outline-offset:1px}' +
|
|
220
|
+
'.dts-act svg{width:calc(17px + var(--dsh-content-font-delta,0px));height:calc(17px + var(--dsh-content-font-delta,0px));display:block}' +
|
|
221
|
+
'@keyframes dts-act-pulse{0%,100%{opacity:.35}50%{opacity:1}}' +
|
|
222
|
+
'.dts-act[data-busy] [data-wave]{animation:dts-act-pulse 1.1s ease-in-out infinite}' +
|
|
223
|
+
'.dts-act[data-busy] [data-wave="2"]{animation-delay:.22s}' +
|
|
224
|
+
// Right-click menu: just the recent lines, all of them, scrolled by wheel.
|
|
225
|
+
'.dts-menu{position:fixed;z-index:2147483000;box-sizing:border-box;min-width:210px;max-width:300px;max-height:min(64vh, 460px);overflow:hidden;padding:6px;display:flex;flex-direction:column;gap:2px;border-radius:12px;border:1px solid var(--dsw-alias-border-l2, #e8e8e8);background:var(--dsw-alias-bg-layer-2, #fff);box-shadow:0 12px 32px rgba(0,0,0,.16),0 2px 6px rgba(0,0,0,.06);font-size:13px;line-height:1.4}' +
|
|
226
|
+
'.dts-menu-item{display:flex;align-items:center;gap:8px;box-sizing:border-box;width:100%;padding:7px 10px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary, #111);font:inherit;text-align:left;cursor:pointer}' +
|
|
227
|
+
'.dts-menu-item:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))}' +
|
|
228
|
+
'.dts-menu-item:disabled{opacity:.42;cursor:default}' +
|
|
229
|
+
'.dts-menu-item span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
230
|
+
// The list is the whole menu: no cap on how many lines, it scrolls instead.
|
|
231
|
+
'.dts-menu-list{display:flex;flex-direction:column;gap:2px;flex:1 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2, #ddd) transparent}' +
|
|
232
|
+
'.dts-menu-list::-webkit-scrollbar{width:8px}' +
|
|
233
|
+
'.dts-menu-list::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2, #ddd);border-radius:4px}' +
|
|
234
|
+
'.dts-menu-list::-webkit-scrollbar-track{background:transparent}' +
|
|
235
|
+
'.dts-menu-text{flex:none;width:100%;box-sizing:border-box;padding:6px 10px;border:none;border-radius:8px;background:none;color:var(--dsw-alias-label-primary, #111);font:inherit;text-align:left;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}' +
|
|
236
|
+
'.dts-menu-text:hover{background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12))}' +
|
|
237
|
+
// Transport cluster: same row as the reply actions, parked after the clock.
|
|
238
|
+
// DSH fades that row out (opacity:0) until you hover the message, so force
|
|
239
|
+
// it back on while a clip is playing — otherwise the controls vanish.
|
|
240
|
+
'[data-actions-reveal]:has(.dts-transport) [class*="actions"]{opacity:1 !important}' +
|
|
241
|
+
'.dts-transport{display:inline-flex;align-items:center;gap:2px;margin-left:4px;order:999}' +
|
|
242
|
+
'.dts-tbtn{box-sizing:border-box;width:27px;height:27px;display:inline-flex;align-items:center;justify-content:center;padding:4px;border:none;border-radius:7px;background:none;color:var(--dsw-alias-label-tertiary, #999);cursor:pointer;transition:background .15s ease,color .15s ease}' +
|
|
243
|
+
'.dts-tbtn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.12));color:var(--dsw-alias-label-primary, #111)}' +
|
|
244
|
+
'.dts-tbtn:disabled{opacity:.38;cursor:default}' +
|
|
245
|
+
'.dts-tbtn svg{width:16px;height:16px;display:block}' +
|
|
213
246
|
'.dts-grow{flex:1;min-width:100px}'
|
|
214
247
|
|
|
215
248
|
const setCssId = 'dsh-tts/settings.module.css'
|
|
@@ -257,6 +290,9 @@ window.__ModuleLoader__.load({
|
|
|
257
290
|
player.blocked = false
|
|
258
291
|
playerChanged()
|
|
259
292
|
drainQueue()
|
|
293
|
+
// Anything the host synthesised while the browser was blocking playback is
|
|
294
|
+
// still queued server-side; pull it once the gesture opened the gate.
|
|
295
|
+
try { pollPending() } catch { /* host not ready yet */ }
|
|
260
296
|
}
|
|
261
297
|
|
|
262
298
|
function useAutoplayGate() {
|
|
@@ -285,6 +321,13 @@ window.__ModuleLoader__.load({
|
|
|
285
321
|
get autoplayBlocked() { return autoplay.blocked },
|
|
286
322
|
}
|
|
287
323
|
const seenIds = new Set()
|
|
324
|
+
// Reply <div data-turn-tail> the current playback belongs to. Set when the
|
|
325
|
+
// per-message speaker starts a clip; null for auto-speech, where the newest
|
|
326
|
+
// reply hosts the transport instead.
|
|
327
|
+
let activeTailEl = null
|
|
328
|
+
// False until /dsh-tts/status answers: before that the `enabled` default is
|
|
329
|
+
// not a real reading, so stream items must not be discarded.
|
|
330
|
+
let statusKnown = false
|
|
288
331
|
let currentAudioResolve = null
|
|
289
332
|
function playerChanged() {
|
|
290
333
|
for (const listener of [...player.listeners]) {
|
|
@@ -375,6 +418,14 @@ window.__ModuleLoader__.load({
|
|
|
375
418
|
const { audio, url } = item.prepared
|
|
376
419
|
audio.playbackRate = Math.max(0.5, Math.min(2, player.rate || 1))
|
|
377
420
|
player.audio = audio
|
|
421
|
+
if (item.audioBase64 && item.kind !== 'chime') {
|
|
422
|
+
// Keep the last clip in memory so replay is instant (no round trip).
|
|
423
|
+
player.lastItem = {
|
|
424
|
+
text: item.text || '',
|
|
425
|
+
mime: item.mime || 'audio/mpeg',
|
|
426
|
+
audioBase64: item.audioBase64,
|
|
427
|
+
}
|
|
428
|
+
}
|
|
378
429
|
if (item.text) player.lastText = item.text
|
|
379
430
|
playerChanged()
|
|
380
431
|
prebufferNext()
|
|
@@ -454,6 +505,7 @@ window.__ModuleLoader__.load({
|
|
|
454
505
|
}
|
|
455
506
|
|
|
456
507
|
function stopPlayback() {
|
|
508
|
+
activeTailEl = null
|
|
457
509
|
removeGestureListeners()
|
|
458
510
|
player.blocked = false
|
|
459
511
|
setAutoplayBlocked(false)
|
|
@@ -527,21 +579,40 @@ window.__ModuleLoader__.load({
|
|
|
527
579
|
return () => window.removeEventListener('dsh-voice:speaking', onSpeaking)
|
|
528
580
|
}
|
|
529
581
|
|
|
530
|
-
// Unified SSE Realtime Stream Connection (#158)
|
|
582
|
+
// Unified SSE Realtime Stream Connection (#158, #206)
|
|
531
583
|
let activeSse = null
|
|
532
584
|
let activeSseAttempt = 0
|
|
533
585
|
let activeSseTimer = null
|
|
586
|
+
let activeSseWatchdog = null
|
|
534
587
|
let sseDisposed = false
|
|
588
|
+
const SSE_IDLE_TIMEOUT_MS = 30000
|
|
535
589
|
|
|
536
590
|
function startSseStream() {
|
|
537
591
|
if (typeof window === 'undefined' || typeof window.EventSource === 'undefined') return () => {}
|
|
538
592
|
sseDisposed = false
|
|
539
593
|
|
|
594
|
+
const resetWatchdog = () => {
|
|
595
|
+
if (activeSseWatchdog) clearTimeout(activeSseWatchdog)
|
|
596
|
+
if (sseDisposed) return
|
|
597
|
+
activeSseWatchdog = setTimeout(() => {
|
|
598
|
+
if (activeSse && !sseDisposed) {
|
|
599
|
+
try {
|
|
600
|
+
if (typeof activeSse.onerror === 'function') {
|
|
601
|
+
activeSse.onerror(new Event('error'))
|
|
602
|
+
} else {
|
|
603
|
+
activeSse.close()
|
|
604
|
+
}
|
|
605
|
+
} catch (_err) { /* error trigger ignore */ }
|
|
606
|
+
}
|
|
607
|
+
}, SSE_IDLE_TIMEOUT_MS)
|
|
608
|
+
}
|
|
609
|
+
|
|
540
610
|
const onItem = (e) => {
|
|
611
|
+
resetWatchdog()
|
|
541
612
|
try {
|
|
542
613
|
const item = JSON.parse(e.data)
|
|
543
614
|
if (!item || !item.id) return
|
|
544
|
-
if (player.enabled === false) return
|
|
615
|
+
if (statusKnown && player.enabled === false) return
|
|
545
616
|
player.after = item.id
|
|
546
617
|
if (seenIds.has(item.id)) return
|
|
547
618
|
seenIds.add(item.id)
|
|
@@ -556,10 +627,11 @@ window.__ModuleLoader__.load({
|
|
|
556
627
|
}
|
|
557
628
|
|
|
558
629
|
const onChime = (e) => {
|
|
630
|
+
resetWatchdog()
|
|
559
631
|
try {
|
|
560
632
|
const item = JSON.parse(e.data)
|
|
561
633
|
if (!item) return
|
|
562
|
-
if (player.enabled === false) return
|
|
634
|
+
if (statusKnown && player.enabled === false) return
|
|
563
635
|
player.queue.push(item)
|
|
564
636
|
drainQueue()
|
|
565
637
|
} catch (_err) { /* ignore malformed chime payload */ }
|
|
@@ -567,6 +639,7 @@ window.__ModuleLoader__.load({
|
|
|
567
639
|
|
|
568
640
|
const connect = () => {
|
|
569
641
|
if (sseDisposed) return
|
|
642
|
+
if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
|
|
570
643
|
if (activeSse) {
|
|
571
644
|
try { activeSse.close() } catch (_err) { /* active sse already closed */ }
|
|
572
645
|
activeSse = null
|
|
@@ -574,10 +647,15 @@ window.__ModuleLoader__.load({
|
|
|
574
647
|
try {
|
|
575
648
|
const es = new EventSource('/dsh-tts/stream')
|
|
576
649
|
activeSse = es
|
|
577
|
-
es.onopen = () => {
|
|
650
|
+
es.onopen = () => {
|
|
651
|
+
activeSseAttempt = 0
|
|
652
|
+
resetWatchdog()
|
|
653
|
+
}
|
|
654
|
+
es.onmessage = () => { resetWatchdog() }
|
|
578
655
|
es.addEventListener('utterance', onItem)
|
|
579
656
|
es.addEventListener('chime', onChime)
|
|
580
657
|
es.onerror = () => {
|
|
658
|
+
if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
|
|
581
659
|
if (activeSse === es) {
|
|
582
660
|
try { es.close() } catch (_err) { /* eventsource already closed */ }
|
|
583
661
|
activeSse = null
|
|
@@ -588,6 +666,7 @@ window.__ModuleLoader__.load({
|
|
|
588
666
|
if (activeSseTimer) clearTimeout(activeSseTimer)
|
|
589
667
|
activeSseTimer = setTimeout(connect, delay)
|
|
590
668
|
}
|
|
669
|
+
resetWatchdog()
|
|
591
670
|
} catch { /* EventSource failed */ }
|
|
592
671
|
}
|
|
593
672
|
|
|
@@ -595,7 +674,8 @@ window.__ModuleLoader__.load({
|
|
|
595
674
|
|
|
596
675
|
return () => {
|
|
597
676
|
sseDisposed = true
|
|
598
|
-
if (activeSseTimer) clearTimeout(activeSseTimer)
|
|
677
|
+
if (activeSseTimer) { clearTimeout(activeSseTimer); activeSseTimer = null }
|
|
678
|
+
if (activeSseWatchdog) { clearTimeout(activeSseWatchdog); activeSseWatchdog = null }
|
|
599
679
|
if (activeSse) {
|
|
600
680
|
try { activeSse.close() } catch (_err) { /* active sse already closed on dispose */ }
|
|
601
681
|
activeSse = null
|
|
@@ -609,6 +689,7 @@ window.__ModuleLoader__.load({
|
|
|
609
689
|
if (!st.ok) return
|
|
610
690
|
const meta = await st.json()
|
|
611
691
|
player.enabled = !!(meta && meta.speakReplies)
|
|
692
|
+
statusKnown = true
|
|
612
693
|
if (meta && typeof meta.rate === 'number') player.rate = meta.rate
|
|
613
694
|
if (meta && meta.chime) player.chime = meta.chime
|
|
614
695
|
if (meta && meta.roles) player.roles = meta.roles
|
|
@@ -692,6 +773,36 @@ window.__ModuleLoader__.load({
|
|
|
692
773
|
})
|
|
693
774
|
}
|
|
694
775
|
|
|
776
|
+
// Replay the most recent clip: from memory when we still hold it, else ask
|
|
777
|
+
// the host (its synthesis cache makes the second round a fast path).
|
|
778
|
+
function replayLast() {
|
|
779
|
+
const last = player.lastItem
|
|
780
|
+
if (last && last.audioBase64) {
|
|
781
|
+
playAudioItem({
|
|
782
|
+
id: `replay-${Date.now()}`,
|
|
783
|
+
text: last.text,
|
|
784
|
+
mime: last.mime || 'audio/mpeg',
|
|
785
|
+
audioBase64: last.audioBase64,
|
|
786
|
+
})
|
|
787
|
+
return true
|
|
788
|
+
}
|
|
789
|
+
if (player.lastText) {
|
|
790
|
+
playStandalone(player.lastText)
|
|
791
|
+
return true
|
|
792
|
+
}
|
|
793
|
+
const lists = recent.list()
|
|
794
|
+
const fallback = (lists.favs && lists.favs[0]) || (lists.items && lists.items[0])
|
|
795
|
+
if (fallback) {
|
|
796
|
+
playStandalone(fallback)
|
|
797
|
+
return true
|
|
798
|
+
}
|
|
799
|
+
return false
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function isSpeakingNow() {
|
|
803
|
+
return !!player.audio || player.busy || player.queue.length > 0
|
|
804
|
+
}
|
|
805
|
+
|
|
695
806
|
function previewSpeech(provider, model, voice, text) {
|
|
696
807
|
stopPlayback()
|
|
697
808
|
return clientFetch('/dsh-tts/preview', {
|
|
@@ -785,6 +896,9 @@ window.__ModuleLoader__.load({
|
|
|
785
896
|
'dockFavorite': 'Favorite',
|
|
786
897
|
'dockUnfavorite': 'Unfavorite',
|
|
787
898
|
'dockFavorites': 'Favorite clips',
|
|
899
|
+
'speakMessage': 'Read this reply aloud',
|
|
900
|
+
'speakMessageStop': 'Stop reading this reply',
|
|
901
|
+
'speakMessageEmpty': 'Nothing to read in this reply',
|
|
788
902
|
'exportAudio': 'Export audio clip',
|
|
789
903
|
'exportSuccess': 'Audio exported',
|
|
790
904
|
'statusOnline': 'Host online',
|
|
@@ -1011,6 +1125,9 @@ window.__ModuleLoader__.load({
|
|
|
1011
1125
|
'dockFavorite': '收藏',
|
|
1012
1126
|
'dockUnfavorite': '取消收藏',
|
|
1013
1127
|
'dockFavorites': '收藏语音',
|
|
1128
|
+
'speakMessage': '朗读这条回复',
|
|
1129
|
+
'speakMessageStop': '停止朗读这条回复',
|
|
1130
|
+
'speakMessageEmpty': '这条回复没有可朗读的内容',
|
|
1014
1131
|
'exportAudio': '导出语音片段',
|
|
1015
1132
|
'exportSuccess': '音频已导出',
|
|
1016
1133
|
'statusOnline': '服务在线',
|
|
@@ -1820,6 +1937,7 @@ window.__ModuleLoader__.load({
|
|
|
1820
1937
|
setCredentials(data && data.credentials ? data.credentials : {})
|
|
1821
1938
|
if (data && data.revision !== undefined) setRevision(data.revision)
|
|
1822
1939
|
player.enabled = !!cfg.speakReplies
|
|
1940
|
+
statusKnown = true
|
|
1823
1941
|
}
|
|
1824
1942
|
|
|
1825
1943
|
React.useEffect(() => {
|
|
@@ -2194,81 +2312,341 @@ window.__ModuleLoader__.load({
|
|
|
2194
2312
|
)
|
|
2195
2313
|
}
|
|
2196
2314
|
|
|
2197
|
-
//
|
|
2198
|
-
|
|
2199
|
-
//
|
|
2200
|
-
//
|
|
2201
|
-
|
|
2315
|
+
// Per-message speak button in the reply action strip (left of the feedback
|
|
2316
|
+
// thumbs). The slot only hands over the durable message id, so the reply
|
|
2317
|
+
// text is read back from the rendered turn: the same prose blocks the
|
|
2318
|
+
// built-in copy button copies.
|
|
2319
|
+
function replyTextFromTurn(el) {
|
|
2320
|
+
try {
|
|
2321
|
+
const tail = el && el.closest ? el.closest('[data-turn-tail]') : null
|
|
2322
|
+
if (!tail) return ''
|
|
2323
|
+
const turn = tail.getAttribute('data-turn-tail')
|
|
2324
|
+
// The tail sits in its own flow item, so climb until an ancestor also
|
|
2325
|
+
// holds rendered reply blocks, then keep only this turn's ones.
|
|
2326
|
+
let scope = tail.parentElement
|
|
2327
|
+
for (let hops = 0; scope && hops < 8; hops++) {
|
|
2328
|
+
if (scope.querySelector('[data-chat-flow-kind="assistant-step"]')) break
|
|
2329
|
+
scope = scope.parentElement
|
|
2330
|
+
}
|
|
2331
|
+
if (!scope) return ''
|
|
2332
|
+
const collect = (inProcess) => {
|
|
2333
|
+
const parts = []
|
|
2334
|
+
for (const node of scope.querySelectorAll('[data-chat-flow-kind="assistant-step"]')) {
|
|
2335
|
+
if (turn !== null && node.getAttribute('data-chat-turn') !== turn) continue
|
|
2336
|
+
if (node.querySelector('[data-turn-tail]')) continue
|
|
2337
|
+
const nestedProcess = !!node.closest('[data-turn-process-hidden]')
|
|
2338
|
+
if (nestedProcess !== inProcess) continue
|
|
2339
|
+
const text = String(node.innerText || node.textContent || '').trim()
|
|
2340
|
+
if (text) parts.push(text)
|
|
2341
|
+
}
|
|
2342
|
+
return parts
|
|
2343
|
+
}
|
|
2344
|
+
// Prose first; fall back to the compacted process answers only when the
|
|
2345
|
+
// turn kept the reply inside the process accordion.
|
|
2346
|
+
let parts = collect(false)
|
|
2347
|
+
if (!parts.length) parts = collect(true)
|
|
2348
|
+
return parts.join('\n\n').trim()
|
|
2349
|
+
} catch (unreadable) {
|
|
2350
|
+
return ''
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/** Rounded-square speaker chip, styled after the reference design. */
|
|
2355
|
+
function speakGlyph() {
|
|
2356
|
+
return React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true', focusable: 'false' },
|
|
2357
|
+
React.createElement('path', {
|
|
2358
|
+
d: 'M11 5.6 6.7 9H3.6v6h3.1L11 18.4z',
|
|
2359
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
2360
|
+
}),
|
|
2361
|
+
React.createElement('path', {
|
|
2362
|
+
'data-wave': '1',
|
|
2363
|
+
d: 'M14.7 9.3a3.8 3.8 0 0 1 0 5.4',
|
|
2364
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
|
|
2365
|
+
}),
|
|
2366
|
+
React.createElement('path', {
|
|
2367
|
+
'data-wave': '2',
|
|
2368
|
+
d: 'M17.8 6.5a8.2 8.2 0 0 1 0 11',
|
|
2369
|
+
stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round',
|
|
2370
|
+
}),
|
|
2371
|
+
)
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
// Transport glyphs for the in-row playback cluster.
|
|
2375
|
+
function transportIcon(name) {
|
|
2376
|
+
const stroke = { stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round' }
|
|
2377
|
+
const kids = []
|
|
2378
|
+
if (name === 'pause') {
|
|
2379
|
+
kids.push(React.createElement('rect', { key: 'l', x: 7, y: 5, width: 3.2, height: 14, rx: 1, fill: 'currentColor' }))
|
|
2380
|
+
kids.push(React.createElement('rect', { key: 'r', x: 13.8, y: 5, width: 3.2, height: 14, rx: 1, fill: 'currentColor' }))
|
|
2381
|
+
} else if (name === 'play') {
|
|
2382
|
+
kids.push(React.createElement('path', { key: 'p', d: 'M8 5.6 19 12 8 18.4z', fill: 'currentColor' }))
|
|
2383
|
+
} else if (name === 'stop') {
|
|
2384
|
+
kids.push(React.createElement('rect', { key: 's', x: 6.5, y: 6.5, width: 11, height: 11, rx: 2, fill: 'currentColor' }))
|
|
2385
|
+
} else if (name === 'skip') {
|
|
2386
|
+
kids.push(React.createElement('path', { key: 't', d: 'M6.5 5.8 14 12l-7.5 6.2z', fill: 'currentColor' }))
|
|
2387
|
+
kids.push(React.createElement('rect', { key: 'b', x: 15.6, y: 5.8, width: 2.4, height: 12.4, rx: 1, fill: 'currentColor' }))
|
|
2388
|
+
} else {
|
|
2389
|
+
kids.push(React.createElement('path', { key: 'a', d: 'M12 4.5v9.4', ...stroke }))
|
|
2390
|
+
kids.push(React.createElement('path', { key: 'b', d: 'm8.4 10.4 3.6 3.6 3.6-3.6', ...stroke }))
|
|
2391
|
+
kids.push(React.createElement('path', { key: 'c', d: 'M5.5 17.6h13', ...stroke }))
|
|
2392
|
+
}
|
|
2393
|
+
return React.createElement('svg', { viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': 'true', focusable: 'false' }, kids)
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
/** Does this reply own the transport cluster right now? */
|
|
2397
|
+
function ownsTransport(tail) {
|
|
2398
|
+
if (!tail) return false
|
|
2399
|
+
if (activeTailEl && activeTailEl.isConnected) return tail === activeTailEl || tail.contains(activeTailEl)
|
|
2400
|
+
// Auto-speech has no explicit owner: the newest reply carries the controls.
|
|
2401
|
+
const all = document.querySelectorAll('[data-turn-tail]')
|
|
2402
|
+
return all.length > 0 && all[all.length - 1] === tail
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
function MessageSpeakAction(props) {
|
|
2406
|
+
const t = (props && props.t) || ((key) => key)
|
|
2202
2407
|
const p = usePlayer()
|
|
2203
|
-
const
|
|
2204
|
-
const
|
|
2205
|
-
const
|
|
2206
|
-
const
|
|
2207
|
-
const
|
|
2408
|
+
const btnRef = React.useRef(null)
|
|
2409
|
+
const menuRef = React.useRef(null)
|
|
2410
|
+
const textRef = React.useRef('')
|
|
2411
|
+
const seqRef = React.useRef(0)
|
|
2412
|
+
const [busy, setBusy] = React.useState(false)
|
|
2413
|
+
const [warn, setWarn] = React.useState(false)
|
|
2414
|
+
const [menu, setMenu] = React.useState(null)
|
|
2415
|
+
const active = !busy && !!p.audio && textRef.current !== '' && p.lastText === textRef.current
|
|
2416
|
+
|
|
2417
|
+
React.useEffect(() => {
|
|
2418
|
+
if (!busy) return undefined
|
|
2419
|
+
const guard = setTimeout(() => setBusy(false), 20000)
|
|
2420
|
+
return () => clearTimeout(guard)
|
|
2421
|
+
}, [busy])
|
|
2422
|
+
|
|
2423
|
+
// Close the context menu on an outside press, Esc, or any scroll.
|
|
2424
|
+
React.useEffect(() => {
|
|
2425
|
+
if (!menu) return undefined
|
|
2426
|
+
const onDown = (event) => {
|
|
2427
|
+
if (menuRef.current && menuRef.current.contains(event.target)) return
|
|
2428
|
+
if (btnRef.current && btnRef.current.contains(event.target)) return
|
|
2429
|
+
setMenu(null)
|
|
2430
|
+
}
|
|
2431
|
+
const onKey = (event) => { if (event.key === 'Escape') setMenu(null) }
|
|
2432
|
+
// Scrolling inside the menu (its recent list) must not dismiss it.
|
|
2433
|
+
const onScroll = (event) => {
|
|
2434
|
+
if (event && menuRef.current && event.target && menuRef.current.contains(event.target)) return
|
|
2435
|
+
setMenu(null)
|
|
2436
|
+
}
|
|
2437
|
+
window.addEventListener('pointerdown', onDown, true)
|
|
2438
|
+
window.addEventListener('keydown', onKey, true)
|
|
2439
|
+
window.addEventListener('scroll', onScroll, true)
|
|
2440
|
+
window.addEventListener('resize', onScroll)
|
|
2441
|
+
return () => {
|
|
2442
|
+
window.removeEventListener('pointerdown', onDown, true)
|
|
2443
|
+
window.removeEventListener('keydown', onKey, true)
|
|
2444
|
+
window.removeEventListener('scroll', onScroll, true)
|
|
2445
|
+
window.removeEventListener('resize', onScroll)
|
|
2446
|
+
}
|
|
2447
|
+
}, [menu])
|
|
2448
|
+
|
|
2449
|
+
// Keep the whole menu on screen: shift it up when it would run past the fold.
|
|
2450
|
+
React.useEffect(() => {
|
|
2451
|
+
if (!menu || !menuRef.current) return
|
|
2452
|
+
const rect = menuRef.current.getBoundingClientRect()
|
|
2453
|
+
const overflow = rect.bottom - (window.innerHeight - 8)
|
|
2454
|
+
if (overflow > 0) {
|
|
2455
|
+
setMenu((current) => (current ? { x: current.x, y: Math.max(8, current.y - overflow) } : current))
|
|
2456
|
+
}
|
|
2457
|
+
}, [menu])
|
|
2208
2458
|
|
|
2209
|
-
|
|
2210
|
-
if (!active && !hasHistory && !showList && !p.blocked) return null
|
|
2459
|
+
const tailOf = () => (btnRef.current && btnRef.current.closest ? btnRef.current.closest('[data-turn-tail]') : null)
|
|
2211
2460
|
|
|
2212
|
-
const
|
|
2461
|
+
const speak = (text, seq, tailEl) => {
|
|
2462
|
+
stopPlayback()
|
|
2463
|
+
return clientFetch('/dsh-tts/speak', {
|
|
2464
|
+
method: 'POST',
|
|
2465
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2466
|
+
body: JSON.stringify({ text, channel: 'web' }),
|
|
2467
|
+
timeoutMs: 30000,
|
|
2468
|
+
})
|
|
2469
|
+
.then((r) => r.json())
|
|
2470
|
+
.then((d) => {
|
|
2471
|
+
// A second click cancels the first request; never play stale audio.
|
|
2472
|
+
if (seqRef.current !== seq) return false
|
|
2473
|
+
if (!d || !d.ok || !d.audioBase64) return false
|
|
2474
|
+
activeTailEl = tailEl
|
|
2475
|
+
playAudioItem({
|
|
2476
|
+
id: `reply-${Date.now()}`,
|
|
2477
|
+
text,
|
|
2478
|
+
mime: d.mime || 'audio/mpeg',
|
|
2479
|
+
audioBase64: d.audioBase64,
|
|
2480
|
+
})
|
|
2481
|
+
return true
|
|
2482
|
+
})
|
|
2483
|
+
.catch((failed) => {
|
|
2484
|
+
console.warn('[dsh-tts] reply speak failed:', failed)
|
|
2485
|
+
return false
|
|
2486
|
+
})
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
const onClick = () => {
|
|
2490
|
+
if (menu) { setMenu(null); return }
|
|
2491
|
+
if (busy) {
|
|
2492
|
+
seqRef.current += 1
|
|
2493
|
+
stopPlayback()
|
|
2494
|
+
setBusy(false)
|
|
2495
|
+
return
|
|
2496
|
+
}
|
|
2497
|
+
if (active) {
|
|
2498
|
+
stopPlayback()
|
|
2499
|
+
return
|
|
2500
|
+
}
|
|
2501
|
+
const tailEl = tailOf()
|
|
2502
|
+
const text = replyTextFromTurn(btnRef.current)
|
|
2503
|
+
if (!text) {
|
|
2504
|
+
setWarn(true)
|
|
2505
|
+
setTimeout(() => setWarn(false), 1600)
|
|
2506
|
+
return
|
|
2507
|
+
}
|
|
2508
|
+
// Already synthesised this clip? Play it from memory instead of the network.
|
|
2509
|
+
const last = player.lastItem
|
|
2510
|
+
if (last && last.audioBase64 && last.text === text) {
|
|
2511
|
+
textRef.current = text
|
|
2512
|
+
activeTailEl = tailEl
|
|
2513
|
+
playAudioItem({
|
|
2514
|
+
id: `replay-${Date.now()}`,
|
|
2515
|
+
text,
|
|
2516
|
+
mime: last.mime || 'audio/mpeg',
|
|
2517
|
+
audioBase64: last.audioBase64,
|
|
2518
|
+
})
|
|
2519
|
+
return
|
|
2520
|
+
}
|
|
2521
|
+
const seq = seqRef.current + 1
|
|
2522
|
+
seqRef.current = seq
|
|
2523
|
+
textRef.current = text
|
|
2524
|
+
setWarn(false)
|
|
2525
|
+
setBusy(true)
|
|
2526
|
+
speak(text, seq, tailEl).then((ok) => {
|
|
2527
|
+
if (seqRef.current !== seq) return
|
|
2528
|
+
setBusy(false)
|
|
2529
|
+
if (!ok) {
|
|
2530
|
+
setWarn(true)
|
|
2531
|
+
setTimeout(() => setWarn(false), 1600)
|
|
2532
|
+
}
|
|
2533
|
+
})
|
|
2534
|
+
}
|
|
2535
|
+
|
|
2536
|
+
const openMenu = (event) => {
|
|
2537
|
+
event.preventDefault()
|
|
2538
|
+
event.stopPropagation()
|
|
2539
|
+
const box = btnRef.current ? btnRef.current.getBoundingClientRect() : { left: event.clientX, bottom: event.clientY }
|
|
2540
|
+
const x = Math.max(8, Math.min(Math.round(box.left) - 8, window.innerWidth - 312))
|
|
2541
|
+
// Open just under the button; the measuring effect above shifts the menu
|
|
2542
|
+
// up if the (now scrollable, possibly tall) list would run past the fold.
|
|
2543
|
+
const y = Math.max(8, Math.min(Math.round(box.bottom) + 6, window.innerHeight - 120))
|
|
2544
|
+
setMenu({ x, y })
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
const run = (fn) => () => { setMenu(null); fn() }
|
|
2548
|
+
|
|
2549
|
+
const label = active ? t('speakMessageStop') : t('speakMessage')
|
|
2550
|
+
const title = warn ? t('speakMessageEmpty') : label
|
|
2551
|
+
const button = React.createElement('button', {
|
|
2552
|
+
ref: btnRef,
|
|
2553
|
+
type: 'button',
|
|
2554
|
+
className: 'dts-act',
|
|
2555
|
+
title,
|
|
2556
|
+
'aria-label': title,
|
|
2557
|
+
'aria-pressed': active || undefined,
|
|
2558
|
+
'data-active': active || undefined,
|
|
2559
|
+
'data-busy': busy || undefined,
|
|
2560
|
+
onClick,
|
|
2561
|
+
onContextMenu: openMenu,
|
|
2562
|
+
}, speakGlyph())
|
|
2563
|
+
|
|
2564
|
+
// Playback transport lives at the end of the same row, after the clock.
|
|
2565
|
+
const tail = tailOf()
|
|
2566
|
+
const speaking = isSpeakingNow()
|
|
2567
|
+
const transport = speaking && ownsTransport(tail) ? React.createElement('div', { className: 'dts-transport' },
|
|
2568
|
+
React.createElement('button', {
|
|
2569
|
+
type: 'button', className: 'dts-tbtn', title: p.paused ? t('dockResume') : t('dockPause'),
|
|
2570
|
+
'aria-label': p.paused ? t('dockResume') : t('dockPause'),
|
|
2571
|
+
onClick: () => togglePause(),
|
|
2572
|
+
}, transportIcon(p.paused ? 'play' : 'pause')),
|
|
2213
2573
|
React.createElement('button', {
|
|
2214
|
-
type: 'button', className: 'dts-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
}, text.slice(0, 80)),
|
|
2574
|
+
type: 'button', className: 'dts-tbtn', title: t('dockStop'), 'aria-label': t('dockStop'),
|
|
2575
|
+
onClick: () => stopPlayback(),
|
|
2576
|
+
}, transportIcon('stop')),
|
|
2218
2577
|
React.createElement('button', {
|
|
2219
|
-
type: 'button', className: 'dts-
|
|
2220
|
-
onClick: () =>
|
|
2221
|
-
},
|
|
2578
|
+
type: 'button', className: 'dts-tbtn', title: t('dockSkip'), 'aria-label': t('dockSkip'),
|
|
2579
|
+
onClick: () => skipCurrent(),
|
|
2580
|
+
}, transportIcon('skip')),
|
|
2222
2581
|
React.createElement('button', {
|
|
2223
|
-
type: 'button', className: 'dts-
|
|
2224
|
-
onClick: () => exportAudioClip(
|
|
2225
|
-
}, '
|
|
2582
|
+
type: 'button', className: 'dts-tbtn', title: t('exportAudio'), 'aria-label': t('exportAudio'),
|
|
2583
|
+
onClick: () => exportAudioClip(player.lastText),
|
|
2584
|
+
}, transportIcon('export')),
|
|
2585
|
+
) : null
|
|
2586
|
+
|
|
2587
|
+
if (!menu) return React.createElement(React.Fragment, null, button, transport)
|
|
2588
|
+
|
|
2589
|
+
// Recents only, all of them: the menu scrolls when the list is longer than
|
|
2590
|
+
// the popup. Playback controls stay in the reply row, right of the clock.
|
|
2591
|
+
const history = (recent.list().items || [])
|
|
2592
|
+
|
|
2593
|
+
const row = (text, index) => React.createElement('button', {
|
|
2594
|
+
key: `recent-${index}`,
|
|
2595
|
+
type: 'button',
|
|
2596
|
+
className: 'dts-menu-text',
|
|
2597
|
+
title: text,
|
|
2598
|
+
onClick: run(() => playStandalone(text)),
|
|
2599
|
+
}, text.slice(0, 80))
|
|
2600
|
+
|
|
2601
|
+
const menuEl = React.createElement('div', {
|
|
2602
|
+
ref: menuRef,
|
|
2603
|
+
className: 'dts-menu',
|
|
2604
|
+
role: 'menu',
|
|
2605
|
+
style: { left: `${menu.x}px`, top: `${menu.y}px` },
|
|
2606
|
+
onContextMenu: (event) => { event.preventDefault(); event.stopPropagation() },
|
|
2607
|
+
},
|
|
2608
|
+
history.length
|
|
2609
|
+
? React.createElement('div', {
|
|
2610
|
+
className: 'dts-menu-list',
|
|
2611
|
+
// Wheel over the list scrolls the list, not the conversation.
|
|
2612
|
+
onWheel: (event) => event.stopPropagation(),
|
|
2613
|
+
}, history.map((text, index) => row(text, index)))
|
|
2614
|
+
: React.createElement('div', { className: 'dts-menu-item', style: { opacity: 0.45, cursor: 'default' } },
|
|
2615
|
+
React.createElement('span', null, t('recentEmpty')),
|
|
2616
|
+
),
|
|
2226
2617
|
)
|
|
2618
|
+
|
|
2619
|
+
return React.createElement(React.Fragment, null, button, transport, menuEl)
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
function registerMessageAction(ctx) {
|
|
2623
|
+
try {
|
|
2624
|
+
ctx.slots.inject('conversation.chat.assistant-actions', () => ctx.slots.register(
|
|
2625
|
+
{
|
|
2626
|
+
name: 'conversation.chat.assistant-actions',
|
|
2627
|
+
id: 'dsh-tts-speak',
|
|
2628
|
+
// 10 is the feedback thumbs; a lower order keeps us to their left.
|
|
2629
|
+
order: 5,
|
|
2630
|
+
locale: NS,
|
|
2631
|
+
},
|
|
2632
|
+
MessageSpeakAction,
|
|
2633
|
+
))
|
|
2634
|
+
} catch (seatUnavailable) {
|
|
2635
|
+
console.warn('[dsh-tts] reply action seat not available:', seatUnavailable && seatUnavailable.message)
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// The dock keeps only the "sound is blocked" hint now: the transport moved
|
|
2640
|
+
// into the reply row, to the right of the message clock.
|
|
2641
|
+
function SpeakerControl(props) {
|
|
2642
|
+
const t = (props && props.t) || fallbackDockText
|
|
2643
|
+
const p = usePlayer()
|
|
2644
|
+
if (!p.blocked) return null
|
|
2227
2645
|
return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
|
|
2228
|
-
|
|
2646
|
+
React.createElement('button', {
|
|
2229
2647
|
type: 'button', className: 'dts-link dts-warn', title: t('unlockAudio') || 'Click to unlock audio',
|
|
2230
2648
|
onClick: unlockAudio,
|
|
2231
|
-
}, '🔔')
|
|
2232
|
-
(hasHistory || active) ? React.createElement('button', {
|
|
2233
|
-
type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
|
|
2234
|
-
onClick: () => setShowList((v) => !v),
|
|
2235
|
-
}, '☰') : null,
|
|
2236
|
-
showList ? React.createElement('div', {
|
|
2237
|
-
className: 'dts-wrap',
|
|
2238
|
-
style: { position: 'absolute', bottom: '36px', right: 0, zIndex: 30, maxHeight: '320px', overflowY: 'auto', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px', padding: '10px 12px', minWidth: '260px', flexDirection: 'column', gap: '6px', display: 'flex' },
|
|
2239
|
-
},
|
|
2240
|
-
lists.favs.length ? React.createElement('div', { className: 'dts-h' }, '★') : null,
|
|
2241
|
-
lists.favs.map((text) => row(text, true)),
|
|
2242
|
-
React.createElement('div', { className: 'dts-h' }, '⟳'),
|
|
2243
|
-
lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
|
|
2244
|
-
!lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
|
|
2245
|
-
) : null,
|
|
2246
|
-
active ? React.createElement(React.Fragment, null,
|
|
2247
|
-
React.createElement('button', {
|
|
2248
|
-
type: 'button',
|
|
2249
|
-
className: 'dts-link',
|
|
2250
|
-
title: p.paused ? t('dockResume') : t('dockPause'),
|
|
2251
|
-
onClick: togglePause,
|
|
2252
|
-
}, p.paused ? '▶' : '❚❚'),
|
|
2253
|
-
React.createElement('button', {
|
|
2254
|
-
type: 'button',
|
|
2255
|
-
className: 'dts-link',
|
|
2256
|
-
title: t('dockStop'),
|
|
2257
|
-
onClick: stopPlayback,
|
|
2258
|
-
}, '■'),
|
|
2259
|
-
React.createElement('button', {
|
|
2260
|
-
type: 'button',
|
|
2261
|
-
className: 'dts-link',
|
|
2262
|
-
title: t('dockSkip') || 'Skip',
|
|
2263
|
-
onClick: skipCurrent,
|
|
2264
|
-
}, '⏭'),
|
|
2265
|
-
React.createElement('button', {
|
|
2266
|
-
type: 'button',
|
|
2267
|
-
className: 'dts-link',
|
|
2268
|
-
title: t('exportAudio') || 'Export clip',
|
|
2269
|
-
onClick: () => exportAudioClip(player.lastText),
|
|
2270
|
-
}, '⤓'),
|
|
2271
|
-
) : null,
|
|
2649
|
+
}, '🔔'),
|
|
2272
2650
|
)
|
|
2273
2651
|
}
|
|
2274
2652
|
|
|
@@ -2388,10 +2766,20 @@ window.__ModuleLoader__.load({
|
|
|
2388
2766
|
exports.apply = function apply(ctx) {
|
|
2389
2767
|
registerSettings(ctx)
|
|
2390
2768
|
registerSpeaker(ctx)
|
|
2769
|
+
registerMessageAction(ctx)
|
|
2391
2770
|
ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
|
|
2392
2771
|
ctx.effect(() => {
|
|
2393
2772
|
if (typeof window === 'undefined') return () => {}
|
|
2394
2773
|
const onKey = (e) => {
|
|
2774
|
+
// Shift+Space: stop while speaking, otherwise replay the last clip
|
|
2775
|
+
// (memory first, then the host cache) so a finished reply is one key away.
|
|
2776
|
+
if (e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && (e.code === 'Space' || e.key === ' ')) {
|
|
2777
|
+
e.preventDefault()
|
|
2778
|
+
if (e.repeat) return
|
|
2779
|
+
if (isSpeakingNow()) stopPlayback()
|
|
2780
|
+
else replayLast()
|
|
2781
|
+
return
|
|
2782
|
+
}
|
|
2395
2783
|
if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
|
|
2396
2784
|
else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
|
|
2397
2785
|
else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
|
|
@@ -2401,6 +2789,9 @@ window.__ModuleLoader__.load({
|
|
|
2401
2789
|
}, 'dsh-tts: hotkeys')
|
|
2402
2790
|
ctx.effect(() => startSseStream(), 'dsh-tts: SSE audio stream')
|
|
2403
2791
|
ctx.effect(() => {
|
|
2792
|
+
// Prime the status flag right away: autoplay used to wait out the first
|
|
2793
|
+
// interval tick, which dropped early stream items on a fresh start.
|
|
2794
|
+
pollPending()
|
|
2404
2795
|
const timer = setInterval(pollPending, 1000)
|
|
2405
2796
|
return () => clearInterval(timer)
|
|
2406
2797
|
}, 'dsh-tts: poll pending audio')
|
package/lib/engines/manager.js
CHANGED
|
@@ -197,9 +197,13 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
197
197
|
const dir = getModelPath(engineId)
|
|
198
198
|
|
|
199
199
|
const controller = new AbortController()
|
|
200
|
+
let onAbortListener = null
|
|
200
201
|
if (options.signal) {
|
|
201
202
|
if (options.signal.aborted) controller.abort()
|
|
202
|
-
else
|
|
203
|
+
else {
|
|
204
|
+
onAbortListener = () => controller.abort()
|
|
205
|
+
options.signal.addEventListener('abort', onAbortListener, { once: true })
|
|
206
|
+
}
|
|
203
207
|
}
|
|
204
208
|
|
|
205
209
|
const downloadState = {
|
|
@@ -311,6 +315,9 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
|
|
|
311
315
|
lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
|
|
312
316
|
throw err
|
|
313
317
|
} finally {
|
|
318
|
+
if (options.signal && onAbortListener) {
|
|
319
|
+
try { options.signal.removeEventListener('abort', onAbortListener) } catch (_err) { /* listener already removed */ }
|
|
320
|
+
}
|
|
314
321
|
if (activeDownloads.get(engineId) === downloadState) {
|
|
315
322
|
activeDownloads.delete(engineId)
|
|
316
323
|
}
|
package/lib/updater.js
CHANGED
|
@@ -125,7 +125,7 @@ function runtime() {
|
|
|
125
125
|
: validProfileName(basename(profileDir))
|
|
126
126
|
? basename(profileDir)
|
|
127
127
|
: 'web'
|
|
128
|
-
const cliEntry = findDshCliEntry()
|
|
128
|
+
const cliEntry = process.env.DSH_CLI_ENTRY ?? findDshCliEntry()
|
|
129
129
|
return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
|
|
130
130
|
}
|
|
131
131
|
|
|
@@ -170,7 +170,10 @@ export function isNewerVersion(currentValue, candidateValue) {
|
|
|
170
170
|
return comparePrerelease(candidate.prerelease, current.prerelease) > 0
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
async function latestVersion(packageName, registry) {
|
|
173
|
+
async function latestVersion(packageName, registry, options) {
|
|
174
|
+
if (typeof options?.latestVersionImpl === 'function') {
|
|
175
|
+
return await options.latestVersionImpl(packageName, registry)
|
|
176
|
+
}
|
|
174
177
|
if (
|
|
175
178
|
latestCache?.packageName === packageName &&
|
|
176
179
|
latestCache.registry === registry &&
|
|
@@ -202,7 +205,7 @@ async function currentVersion(manifestUrl) {
|
|
|
202
205
|
|
|
203
206
|
export async function status(options, target) {
|
|
204
207
|
const current = await currentVersion(options.manifestUrl)
|
|
205
|
-
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org')
|
|
208
|
+
const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org', options)
|
|
206
209
|
return {
|
|
207
210
|
packageName: options.packageName,
|
|
208
211
|
currentVersion: current,
|
|
@@ -217,7 +220,25 @@ export async function status(options, target) {
|
|
|
217
220
|
async function installExact(target, packageSpec, options) {
|
|
218
221
|
if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
|
|
219
222
|
await new Promise((resolvePromise, reject) => {
|
|
220
|
-
const
|
|
223
|
+
const spawnCall = options?.spawnImpl || spawn
|
|
224
|
+
const child = spawnCall === spawn ? spawn(
|
|
225
|
+
process.execPath,
|
|
226
|
+
[
|
|
227
|
+
target.cliEntry,
|
|
228
|
+
'plugin',
|
|
229
|
+
'--profile',
|
|
230
|
+
target.profileName,
|
|
231
|
+
'add',
|
|
232
|
+
packageSpec,
|
|
233
|
+
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
234
|
+
],
|
|
235
|
+
{
|
|
236
|
+
cwd: target.profileDir,
|
|
237
|
+
windowsHide: true,
|
|
238
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
239
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
240
|
+
}
|
|
241
|
+
) : spawnCall(
|
|
221
242
|
process.execPath,
|
|
222
243
|
[
|
|
223
244
|
target.cliEntry,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.34",
|
|
4
4
|
"description": "Text-to-speech for DeepSeek Harness: speak agent replies in the Web UI with a provider fallback chain (OpenAI, ElevenLabs, Google, Azure, Groq, Deepgram, OpenRouter, Edge, Piper, eSpeak).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|