@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 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 = () => { activeSseAttempt = 0 }
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
- // Input-dock button: shows what is playing, stop control, and recents for replay.
2198
- function SpeakerControl(props) {
2199
- // A slot with `locale` injects translations into props; the fallback is the
2200
- // binder created at registration, in case the slot drops translations.
2201
- const t = (props && props.t) || fallbackDockText
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 [showList, setShowList] = React.useState(false)
2204
- const active = !!p.audio || p.queue.length > 0 || p.busy
2205
- const [, force] = React.useReducer((n) => n + 1, 0)
2206
- const lists = recent.list()
2207
- const hasHistory = (lists.items && lists.items.length > 0) || (lists.favs && lists.favs.length > 0)
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
- // Hide only if completely idle and no recent/favorites history (#161)
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 row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
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-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
2215
- title: text,
2216
- onClick: () => playStandalone(text),
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-link', title: '★',
2220
- onClick: () => { recent.toggleFav(text); force() },
2221
- }, starred ? '★' : '☆'),
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-link', title: t('exportAudio') || 'Export',
2224
- onClick: () => exportAudioClip(text),
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
- p.blocked ? React.createElement('button', {
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
- }, '🔔') : null,
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')
@@ -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 options.signal.addEventListener('abort', () => controller.abort(), { once: true })
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 child = spawn(
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.32",
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",