@goodandready/dsh-tts 0.4.33 โ 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 +13 -0
- package/lib/client.js +430 -67
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
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
|
+
|
|
3
16
|
## 0.4.33 (2026-10-11)
|
|
4
17
|
|
|
5
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).
|
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)
|
|
@@ -560,7 +612,7 @@ window.__ModuleLoader__.load({
|
|
|
560
612
|
try {
|
|
561
613
|
const item = JSON.parse(e.data)
|
|
562
614
|
if (!item || !item.id) return
|
|
563
|
-
if (player.enabled === false) return
|
|
615
|
+
if (statusKnown && player.enabled === false) return
|
|
564
616
|
player.after = item.id
|
|
565
617
|
if (seenIds.has(item.id)) return
|
|
566
618
|
seenIds.add(item.id)
|
|
@@ -579,7 +631,7 @@ window.__ModuleLoader__.load({
|
|
|
579
631
|
try {
|
|
580
632
|
const item = JSON.parse(e.data)
|
|
581
633
|
if (!item) return
|
|
582
|
-
if (player.enabled === false) return
|
|
634
|
+
if (statusKnown && player.enabled === false) return
|
|
583
635
|
player.queue.push(item)
|
|
584
636
|
drainQueue()
|
|
585
637
|
} catch (_err) { /* ignore malformed chime payload */ }
|
|
@@ -637,6 +689,7 @@ window.__ModuleLoader__.load({
|
|
|
637
689
|
if (!st.ok) return
|
|
638
690
|
const meta = await st.json()
|
|
639
691
|
player.enabled = !!(meta && meta.speakReplies)
|
|
692
|
+
statusKnown = true
|
|
640
693
|
if (meta && typeof meta.rate === 'number') player.rate = meta.rate
|
|
641
694
|
if (meta && meta.chime) player.chime = meta.chime
|
|
642
695
|
if (meta && meta.roles) player.roles = meta.roles
|
|
@@ -720,6 +773,36 @@ window.__ModuleLoader__.load({
|
|
|
720
773
|
})
|
|
721
774
|
}
|
|
722
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
|
+
|
|
723
806
|
function previewSpeech(provider, model, voice, text) {
|
|
724
807
|
stopPlayback()
|
|
725
808
|
return clientFetch('/dsh-tts/preview', {
|
|
@@ -813,6 +896,9 @@ window.__ModuleLoader__.load({
|
|
|
813
896
|
'dockFavorite': 'Favorite',
|
|
814
897
|
'dockUnfavorite': 'Unfavorite',
|
|
815
898
|
'dockFavorites': 'Favorite clips',
|
|
899
|
+
'speakMessage': 'Read this reply aloud',
|
|
900
|
+
'speakMessageStop': 'Stop reading this reply',
|
|
901
|
+
'speakMessageEmpty': 'Nothing to read in this reply',
|
|
816
902
|
'exportAudio': 'Export audio clip',
|
|
817
903
|
'exportSuccess': 'Audio exported',
|
|
818
904
|
'statusOnline': 'Host online',
|
|
@@ -1039,6 +1125,9 @@ window.__ModuleLoader__.load({
|
|
|
1039
1125
|
'dockFavorite': 'ๆถ่',
|
|
1040
1126
|
'dockUnfavorite': 'ๅๆถๆถ่',
|
|
1041
1127
|
'dockFavorites': 'ๆถ่่ฏญ้ณ',
|
|
1128
|
+
'speakMessage': 'ๆ่ฏป่ฟๆกๅๅค',
|
|
1129
|
+
'speakMessageStop': 'ๅๆญขๆ่ฏป่ฟๆกๅๅค',
|
|
1130
|
+
'speakMessageEmpty': '่ฟๆกๅๅคๆฒกๆๅฏๆ่ฏป็ๅ
ๅฎน',
|
|
1042
1131
|
'exportAudio': 'ๅฏผๅบ่ฏญ้ณ็ๆฎต',
|
|
1043
1132
|
'exportSuccess': '้ณ้ขๅทฒๅฏผๅบ',
|
|
1044
1133
|
'statusOnline': 'ๆๅกๅจ็บฟ',
|
|
@@ -1848,6 +1937,7 @@ window.__ModuleLoader__.load({
|
|
|
1848
1937
|
setCredentials(data && data.credentials ? data.credentials : {})
|
|
1849
1938
|
if (data && data.revision !== undefined) setRevision(data.revision)
|
|
1850
1939
|
player.enabled = !!cfg.speakReplies
|
|
1940
|
+
statusKnown = true
|
|
1851
1941
|
}
|
|
1852
1942
|
|
|
1853
1943
|
React.useEffect(() => {
|
|
@@ -2222,81 +2312,341 @@ window.__ModuleLoader__.load({
|
|
|
2222
2312
|
)
|
|
2223
2313
|
}
|
|
2224
2314
|
|
|
2225
|
-
//
|
|
2226
|
-
|
|
2227
|
-
//
|
|
2228
|
-
//
|
|
2229
|
-
|
|
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)
|
|
2230
2407
|
const p = usePlayer()
|
|
2231
|
-
const
|
|
2232
|
-
const
|
|
2233
|
-
const
|
|
2234
|
-
const
|
|
2235
|
-
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])
|
|
2458
|
+
|
|
2459
|
+
const tailOf = () => (btnRef.current && btnRef.current.closest ? btnRef.current.closest('[data-turn-tail]') : null)
|
|
2460
|
+
|
|
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
|
+
}
|
|
2236
2535
|
|
|
2237
|
-
|
|
2238
|
-
|
|
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
|
+
}
|
|
2239
2546
|
|
|
2240
|
-
const
|
|
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')),
|
|
2241
2573
|
React.createElement('button', {
|
|
2242
|
-
type: 'button', className: 'dts-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
}, text.slice(0, 80)),
|
|
2574
|
+
type: 'button', className: 'dts-tbtn', title: t('dockStop'), 'aria-label': t('dockStop'),
|
|
2575
|
+
onClick: () => stopPlayback(),
|
|
2576
|
+
}, transportIcon('stop')),
|
|
2246
2577
|
React.createElement('button', {
|
|
2247
|
-
type: 'button', className: 'dts-
|
|
2248
|
-
onClick: () =>
|
|
2249
|
-
},
|
|
2578
|
+
type: 'button', className: 'dts-tbtn', title: t('dockSkip'), 'aria-label': t('dockSkip'),
|
|
2579
|
+
onClick: () => skipCurrent(),
|
|
2580
|
+
}, transportIcon('skip')),
|
|
2250
2581
|
React.createElement('button', {
|
|
2251
|
-
type: 'button', className: 'dts-
|
|
2252
|
-
onClick: () => exportAudioClip(
|
|
2253
|
-
}, '
|
|
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
|
+
),
|
|
2254
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
|
|
2255
2645
|
return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
|
|
2256
|
-
|
|
2646
|
+
React.createElement('button', {
|
|
2257
2647
|
type: 'button', className: 'dts-link dts-warn', title: t('unlockAudio') || 'Click to unlock audio',
|
|
2258
2648
|
onClick: unlockAudio,
|
|
2259
|
-
}, '๐')
|
|
2260
|
-
(hasHistory || active) ? React.createElement('button', {
|
|
2261
|
-
type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
|
|
2262
|
-
onClick: () => setShowList((v) => !v),
|
|
2263
|
-
}, 'โฐ') : null,
|
|
2264
|
-
showList ? React.createElement('div', {
|
|
2265
|
-
className: 'dts-wrap',
|
|
2266
|
-
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' },
|
|
2267
|
-
},
|
|
2268
|
-
lists.favs.length ? React.createElement('div', { className: 'dts-h' }, 'โ
') : null,
|
|
2269
|
-
lists.favs.map((text) => row(text, true)),
|
|
2270
|
-
React.createElement('div', { className: 'dts-h' }, 'โณ'),
|
|
2271
|
-
lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
|
|
2272
|
-
!lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
|
|
2273
|
-
) : null,
|
|
2274
|
-
active ? React.createElement(React.Fragment, null,
|
|
2275
|
-
React.createElement('button', {
|
|
2276
|
-
type: 'button',
|
|
2277
|
-
className: 'dts-link',
|
|
2278
|
-
title: p.paused ? t('dockResume') : t('dockPause'),
|
|
2279
|
-
onClick: togglePause,
|
|
2280
|
-
}, p.paused ? 'โถ' : 'โโ'),
|
|
2281
|
-
React.createElement('button', {
|
|
2282
|
-
type: 'button',
|
|
2283
|
-
className: 'dts-link',
|
|
2284
|
-
title: t('dockStop'),
|
|
2285
|
-
onClick: stopPlayback,
|
|
2286
|
-
}, 'โ '),
|
|
2287
|
-
React.createElement('button', {
|
|
2288
|
-
type: 'button',
|
|
2289
|
-
className: 'dts-link',
|
|
2290
|
-
title: t('dockSkip') || 'Skip',
|
|
2291
|
-
onClick: skipCurrent,
|
|
2292
|
-
}, 'โญ'),
|
|
2293
|
-
React.createElement('button', {
|
|
2294
|
-
type: 'button',
|
|
2295
|
-
className: 'dts-link',
|
|
2296
|
-
title: t('exportAudio') || 'Export clip',
|
|
2297
|
-
onClick: () => exportAudioClip(player.lastText),
|
|
2298
|
-
}, 'โค'),
|
|
2299
|
-
) : null,
|
|
2649
|
+
}, '๐'),
|
|
2300
2650
|
)
|
|
2301
2651
|
}
|
|
2302
2652
|
|
|
@@ -2416,10 +2766,20 @@ window.__ModuleLoader__.load({
|
|
|
2416
2766
|
exports.apply = function apply(ctx) {
|
|
2417
2767
|
registerSettings(ctx)
|
|
2418
2768
|
registerSpeaker(ctx)
|
|
2769
|
+
registerMessageAction(ctx)
|
|
2419
2770
|
ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
|
|
2420
2771
|
ctx.effect(() => {
|
|
2421
2772
|
if (typeof window === 'undefined') return () => {}
|
|
2422
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
|
+
}
|
|
2423
2783
|
if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
|
|
2424
2784
|
else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
|
|
2425
2785
|
else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
|
|
@@ -2429,6 +2789,9 @@ window.__ModuleLoader__.load({
|
|
|
2429
2789
|
}, 'dsh-tts: hotkeys')
|
|
2430
2790
|
ctx.effect(() => startSseStream(), 'dsh-tts: SSE audio stream')
|
|
2431
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()
|
|
2432
2795
|
const timer = setInterval(pollPending, 1000)
|
|
2433
2796
|
return () => clearInterval(timer)
|
|
2434
2797
|
}, 'dsh-tts: poll pending audio')
|
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",
|