@goodandready/dsh-tts 0.4.33 → 0.4.35
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 +22 -0
- package/lib/client.js +430 -67
- package/lib/http-util.js +95 -34
- package/lib/routes.js +49 -50
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.35 (2026-10-20)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Authentication Guard for Administrative and Credential Routes** (#210):
|
|
7
|
+
- Enforced DSH core connection authentication (`connection.requestRejection(req)`) across all `/dsh-tts/*` HTTP endpoints.
|
|
8
|
+
- Fail-closed: Remote LAN clients without an authenticated operator session or token are rejected with 401/403 and cannot manage credentials or mutate plugin configuration (`PUT/DELETE /dsh-tts/credential`, `PUT /dsh-tts/config`, `/dsh-tts/models/install`, `/dsh-tts/models/delete`, `/dsh-tts/cache/clear`).
|
|
9
|
+
- Permitted authenticated operators via DSH connection service to manage provider credentials and configuration from LAN or remote interfaces.
|
|
10
|
+
- Added helpers `getClientIp(req)`, `isLanAddress(address)`, and `getRejectionStatus(req, options)` with comprehensive unit test coverage.
|
|
11
|
+
|
|
12
|
+
## 0.4.34 (2026-10-17)
|
|
13
|
+
|
|
14
|
+
- **Per-Reply Speak Action** (#208, GitHub PR #2):
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- **Context Menu & Contained Scroll** (#208, GitHub PR #2):
|
|
18
|
+
- Added right-click context menu on the speaker action to display recent utterances with contained wheel scrolling that prevents closing on scroll.
|
|
19
|
+
- **Hotkeys & Playback Enhancements** (#208, GitHub PR #2):
|
|
20
|
+
- Added `Shift+Space` hotkey to stop active playback or instantly replay the last utterance from memory cache (`player.lastItem`).
|
|
21
|
+
- Added `statusKnown` guard in SSE event handling to prevent dropping initial stream utterances during server startup.
|
|
22
|
+
- Primed pending audio poll immediately on mount and on audio unlock gesture.
|
|
23
|
+
- **Client Bundle**: Rebuilt `lib/client.js` from canonical `lib/client-src/` fragments with updated tests.
|
|
24
|
+
|
|
3
25
|
## 0.4.33 (2026-10-11)
|
|
4
26
|
|
|
5
27
|
- **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/lib/http-util.js
CHANGED
|
@@ -4,7 +4,7 @@ export function writeJson(res, code, body) {
|
|
|
4
4
|
try {
|
|
5
5
|
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
6
6
|
res.end(JSON.stringify(body))
|
|
7
|
-
} catch { /* socket closed */ }
|
|
7
|
+
} catch (_err) { /* socket closed */ }
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
export function readBody(req, maxBytes) {
|
|
@@ -23,15 +23,36 @@ export function readBody(req, maxBytes) {
|
|
|
23
23
|
|
|
24
24
|
export function isLoopback(address) {
|
|
25
25
|
if (!address || typeof address !== 'string') return false
|
|
26
|
+
const clean = address.toLowerCase().replace(/^\[|\]$/g, '')
|
|
26
27
|
return (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
clean === '127.0.0.1' ||
|
|
29
|
+
clean === '::1' ||
|
|
30
|
+
clean === '::ffff:127.0.0.1' ||
|
|
31
|
+
clean.startsWith('127.') ||
|
|
32
|
+
clean === 'localhost'
|
|
32
33
|
)
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
export function isLanAddress(address) {
|
|
37
|
+
if (!address || typeof address !== 'string') return false
|
|
38
|
+
const clean = address.toLowerCase().replace(/^\[|\]$/g, '')
|
|
39
|
+
const ipv4 = clean.startsWith('::ffff:') ? clean.slice(7) : clean
|
|
40
|
+
return (
|
|
41
|
+
ipv4.startsWith('192.168.') ||
|
|
42
|
+
ipv4.startsWith('10.') ||
|
|
43
|
+
/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ipv4)
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getClientIp(request) {
|
|
48
|
+
if (!request) return ''
|
|
49
|
+
const remote = request.socket?.remoteAddress || request.connection?.remoteAddress
|
|
50
|
+
if (remote) {
|
|
51
|
+
return remote.startsWith('::ffff:') ? remote.slice(7) : remote
|
|
52
|
+
}
|
|
53
|
+
return ''
|
|
54
|
+
}
|
|
55
|
+
|
|
35
56
|
function parseCookies(cookieHeader) {
|
|
36
57
|
if (!cookieHeader || typeof cookieHeader !== 'string') return {}
|
|
37
58
|
const out = {}
|
|
@@ -47,66 +68,106 @@ function parseCookies(cookieHeader) {
|
|
|
47
68
|
}
|
|
48
69
|
|
|
49
70
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
71
|
+
* Returns undefined if the request is permitted, or an HTTP error status code (401, 403) if rejected.
|
|
72
|
+
*
|
|
73
|
+
* Evaluation order:
|
|
74
|
+
* 1. Base validation (fail-closed on missing headers or cross-site Sec-Fetch-Site).
|
|
75
|
+
* 2. Origin vs Host verification.
|
|
76
|
+
* 3. DSH connection service authentication (connection.requestRejection(request)).
|
|
77
|
+
* 4. Fallback authentication when connection service is absent (env token, loopback, etc.).
|
|
78
|
+
*
|
|
79
|
+
* @param {object} request Node HTTP request
|
|
80
|
+
* @param {object} [options]
|
|
81
|
+
* @param {object} [options.connection] DSH connection service
|
|
82
|
+
* @param {boolean} [options.requireAuth] require authenticated operator (fail-closed for remote)
|
|
83
|
+
* @param {boolean} [options.isRemote] mark in-memory/mock request as remote
|
|
84
|
+
* @returns {number|undefined} undefined if allowed, 401 or 403 if rejected
|
|
53
85
|
*/
|
|
54
|
-
export function
|
|
55
|
-
if (!request || !request.headers) return
|
|
86
|
+
export function getRejectionStatus(request, options = {}) {
|
|
87
|
+
if (!request || !request.headers) return 403
|
|
56
88
|
|
|
57
|
-
// Fail-closed: unconditionally reject cross-site and non-same-origin Sec-Fetch-Site (e.g. same-site, cross-site)
|
|
89
|
+
// 1. Fail-closed: unconditionally reject cross-site and non-same-origin Sec-Fetch-Site (e.g. same-site, cross-site)
|
|
58
90
|
const secFetch = request.headers['sec-fetch-site']
|
|
59
|
-
if (secFetch && secFetch !== 'same-origin') {
|
|
60
|
-
return
|
|
91
|
+
if (secFetch && secFetch !== 'same-origin' && secFetch !== 'none') {
|
|
92
|
+
return 403
|
|
61
93
|
}
|
|
62
94
|
|
|
63
|
-
// If origin is present, ensure it matches request host
|
|
95
|
+
// 2. If origin is present, ensure it matches request host
|
|
64
96
|
const origin = request.headers['origin']
|
|
65
97
|
const host = request.headers['host']
|
|
66
98
|
if (origin && host) {
|
|
67
99
|
try {
|
|
68
100
|
const originHost = new URL(origin).host
|
|
69
|
-
if (originHost !== host) return
|
|
70
|
-
} catch {
|
|
71
|
-
return
|
|
101
|
+
if (originHost !== host) return 403
|
|
102
|
+
} catch (_err) {
|
|
103
|
+
return 403
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 3. If DSH connection service is available, invoke requestRejection(req)
|
|
108
|
+
const conn = options.connection
|
|
109
|
+
if (conn && typeof conn.requestRejection === 'function') {
|
|
110
|
+
const rejection = conn.requestRejection(request)
|
|
111
|
+
if (rejection !== undefined && rejection !== null && rejection !== false) {
|
|
112
|
+
return typeof rejection === 'number' ? rejection : 401
|
|
72
113
|
}
|
|
114
|
+
// Admitted by DSH connection service
|
|
115
|
+
return undefined
|
|
73
116
|
}
|
|
74
117
|
|
|
75
|
-
// Verify explicit authorization token against DSH environment tokens if defined
|
|
118
|
+
// 4. Verify explicit authorization token against DSH environment tokens if defined
|
|
76
119
|
const expectedToken = process.env.DSH_AUTH_TOKEN || process.env.DSH_TOKEN
|
|
77
120
|
const rawAuth = request.headers['authorization'] || request.headers['x-dsh-auth']
|
|
78
121
|
const cookieHeader = request.headers['cookie']
|
|
79
122
|
|
|
80
123
|
if (expectedToken) {
|
|
124
|
+
let token = ''
|
|
81
125
|
if (typeof rawAuth === 'string') {
|
|
82
|
-
|
|
83
|
-
if (token && token === expectedToken) return true
|
|
126
|
+
token = rawAuth.startsWith('Bearer ') ? rawAuth.slice(7).trim() : rawAuth.trim()
|
|
84
127
|
}
|
|
85
|
-
if (typeof cookieHeader === 'string') {
|
|
128
|
+
if (!token && typeof cookieHeader === 'string') {
|
|
86
129
|
const cookies = parseCookies(cookieHeader)
|
|
87
|
-
|
|
88
|
-
|
|
130
|
+
token = cookies['token'] || cookies['dsh_token'] || ''
|
|
131
|
+
}
|
|
132
|
+
if (token && token === expectedToken) {
|
|
133
|
+
return undefined
|
|
89
134
|
}
|
|
135
|
+
return 401
|
|
90
136
|
}
|
|
91
137
|
|
|
92
|
-
// Remote IP
|
|
93
|
-
const remoteAddr = request
|
|
138
|
+
// 5. Remote IP check
|
|
139
|
+
const remoteAddr = getClientIp(request)
|
|
94
140
|
if (remoteAddr) {
|
|
95
|
-
|
|
141
|
+
if (isLoopback(remoteAddr)) {
|
|
142
|
+
return undefined
|
|
143
|
+
}
|
|
144
|
+
// Remote client (LAN or WAN) without connection auth or token:
|
|
145
|
+
// Fail-closed. For write / sensitive endpoints requireAuth returns 401, otherwise 403.
|
|
146
|
+
return options.requireAuth ? 401 : 403
|
|
96
147
|
}
|
|
97
148
|
|
|
98
|
-
// Internal/mock caller without socket:
|
|
149
|
+
// 6. Internal/mock caller without socket:
|
|
150
|
+
if (options.requireAuth && options.isRemote) {
|
|
151
|
+
return 401
|
|
152
|
+
}
|
|
99
153
|
if (secFetch === 'same-origin') {
|
|
100
|
-
return
|
|
154
|
+
return undefined
|
|
101
155
|
}
|
|
102
|
-
|
|
103
156
|
if (origin && host) {
|
|
104
157
|
try {
|
|
105
|
-
|
|
106
|
-
} catch {
|
|
107
|
-
return
|
|
158
|
+
if (new URL(origin).host === host) return undefined
|
|
159
|
+
} catch (_err) {
|
|
160
|
+
return 403
|
|
108
161
|
}
|
|
109
162
|
}
|
|
110
163
|
|
|
111
|
-
return
|
|
164
|
+
return 403
|
|
112
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Strict fail-closed check for administrative/settings requests.
|
|
169
|
+
* Backward-compatible boolean wrapper around getRejectionStatus.
|
|
170
|
+
*/
|
|
171
|
+
export function isTrustedSettingsRequest(request, options = {}) {
|
|
172
|
+
return getRejectionStatus(request, options) === undefined
|
|
173
|
+
}
|
package/lib/routes.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MODEL_SPECS } from './engines/manager.js'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import { registerPluginUpdater } from './updater.js'
|
|
4
|
-
import { writeJson, readBody, isTrustedSettingsRequest } from './http-util.js'
|
|
4
|
+
import { writeJson, readBody, getRejectionStatus, isTrustedSettingsRequest } from './http-util.js'
|
|
5
5
|
import { addStreamSubscriber, removeStreamSubscriber, streamSubscriberCount } from './stream-hub.js'
|
|
6
6
|
import { pendingKeyWrites, stripSecretsFromConfig, needsApiKey } from './keys.js'
|
|
7
7
|
import { PROVIDER_KEYS } from './providers.js'
|
|
@@ -29,15 +29,43 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
29
29
|
validateConfig,
|
|
30
30
|
breakerSnapshot,
|
|
31
31
|
} = api
|
|
32
|
+
|
|
33
|
+
const resolveConnection = () => {
|
|
34
|
+
try {
|
|
35
|
+
if (api && api.connection) return api.connection
|
|
36
|
+
} catch (_err) { /* connection resolution fallback */ }
|
|
37
|
+
try {
|
|
38
|
+
if (ctx && typeof ctx.get === 'function') {
|
|
39
|
+
const c = ctx.get('connection')
|
|
40
|
+
if (c) return c
|
|
41
|
+
}
|
|
42
|
+
} catch (_err) { /* connection get fallback */ }
|
|
43
|
+
try {
|
|
44
|
+
if (ctx) return Reflect.get(ctx, 'connection') || ctx.connection
|
|
45
|
+
} catch (_err) { /* connection reflect fallback */ }
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const rejectIfUntrusted = (req, res, extraOptions = {}) => {
|
|
50
|
+
const conn = resolveConnection()
|
|
51
|
+
const status = getRejectionStatus(req, { connection: conn, ...extraOptions })
|
|
52
|
+
if (status !== undefined) {
|
|
53
|
+
const code = status === 401 ? 'unauthorized' : 'forbidden'
|
|
54
|
+
const message = status === 401
|
|
55
|
+
? 'unauthorized: session or token required'
|
|
56
|
+
: (extraOptions.message || 'same-origin only')
|
|
57
|
+
writeJson(res, status, { ok: false, error: { code, message } })
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
32
63
|
ctx.effect(() => ctx.webServer.register({
|
|
33
64
|
kind: 'exact',
|
|
34
65
|
path: '/dsh-tts/status',
|
|
35
66
|
handler: async (req, res) => {
|
|
36
67
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
37
|
-
if (
|
|
38
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
39
|
-
return
|
|
40
|
-
}
|
|
68
|
+
if (rejectIfUntrusted(req, res)) return
|
|
41
69
|
const cfg = live()
|
|
42
70
|
writeJson(res, 200, {
|
|
43
71
|
ok: true,
|
|
@@ -67,7 +95,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
67
95
|
path: '/dsh-tts/stream',
|
|
68
96
|
handler: async (req, res) => {
|
|
69
97
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
70
|
-
if (
|
|
98
|
+
if (rejectIfUntrusted(req, res)) return
|
|
71
99
|
try {
|
|
72
100
|
res.writeHead(200, {
|
|
73
101
|
'Content-Type': 'text/event-stream',
|
|
@@ -88,7 +116,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
88
116
|
path: '/dsh-tts/models/status',
|
|
89
117
|
handler: async (req, res) => {
|
|
90
118
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
91
|
-
if (
|
|
119
|
+
if (rejectIfUntrusted(req, res)) return
|
|
92
120
|
try {
|
|
93
121
|
const list = await modelManager.listStatus()
|
|
94
122
|
writeJson(res, 200, { ok: true, models: list })
|
|
@@ -103,10 +131,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
103
131
|
path: '/dsh-tts/models/install',
|
|
104
132
|
handler: async (req, res) => {
|
|
105
133
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
106
|
-
if (
|
|
107
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
108
|
-
return
|
|
109
|
-
}
|
|
134
|
+
if (rejectIfUntrusted(req, res, { requireAuth: true })) return
|
|
110
135
|
let body = {}
|
|
111
136
|
try {
|
|
112
137
|
const raw = await readBody(req, 16 * 1024)
|
|
@@ -147,10 +172,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
147
172
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
|
|
148
173
|
return
|
|
149
174
|
}
|
|
150
|
-
if (
|
|
151
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
152
|
-
return
|
|
153
|
-
}
|
|
175
|
+
if (rejectIfUntrusted(req, res, { requireAuth: true })) return
|
|
154
176
|
let body = {}
|
|
155
177
|
try {
|
|
156
178
|
const raw = await readBody(req, 16 * 1024)
|
|
@@ -179,10 +201,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
179
201
|
path: '/dsh-tts/integrations',
|
|
180
202
|
handler: async (req, res) => {
|
|
181
203
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
182
|
-
if (
|
|
183
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
184
|
-
return
|
|
185
|
-
}
|
|
204
|
+
if (rejectIfUntrusted(req, res)) return
|
|
186
205
|
function isPluginOrServicePresent(id) {
|
|
187
206
|
if (!ctx) return false
|
|
188
207
|
try {
|
|
@@ -246,10 +265,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
246
265
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
|
|
247
266
|
return
|
|
248
267
|
}
|
|
249
|
-
if (
|
|
250
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
251
|
-
return
|
|
252
|
-
}
|
|
268
|
+
if (rejectIfUntrusted(req, res, { requireAuth: true })) return
|
|
253
269
|
const removed = await speechCache.clear()
|
|
254
270
|
writeJson(res, 200, { ok: true, removed, cleared: removed })
|
|
255
271
|
},
|
|
@@ -263,10 +279,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
263
279
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
264
280
|
return
|
|
265
281
|
}
|
|
266
|
-
if (
|
|
267
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
268
|
-
return
|
|
269
|
-
}
|
|
282
|
+
if (rejectIfUntrusted(req, res, { requireAuth: true })) return
|
|
270
283
|
const removed = await speechCache.clear()
|
|
271
284
|
writeJson(res, 200, { ok: true, removed, cleared: removed })
|
|
272
285
|
},
|
|
@@ -280,10 +293,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
280
293
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
281
294
|
return
|
|
282
295
|
}
|
|
283
|
-
if (
|
|
284
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
285
|
-
return
|
|
286
|
-
}
|
|
296
|
+
if (rejectIfUntrusted(req, res)) return
|
|
287
297
|
let body = {}
|
|
288
298
|
try {
|
|
289
299
|
const raw = await readBody(req, 64 * 1024)
|
|
@@ -313,10 +323,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
313
323
|
path: '/dsh-tts/stats',
|
|
314
324
|
handler: async (req, res) => {
|
|
315
325
|
if (req.method === 'GET') {
|
|
316
|
-
if (
|
|
317
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
318
|
-
return
|
|
319
|
-
}
|
|
326
|
+
if (rejectIfUntrusted(req, res)) return
|
|
320
327
|
writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers, breaker: typeof breakerSnapshot === 'function' ? breakerSnapshot() : [] })
|
|
321
328
|
return
|
|
322
329
|
}
|
|
@@ -324,10 +331,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
324
331
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
|
|
325
332
|
return
|
|
326
333
|
}
|
|
327
|
-
if (
|
|
328
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
329
|
-
return
|
|
330
|
-
}
|
|
334
|
+
if (rejectIfUntrusted(req, res, { requireAuth: true })) return
|
|
331
335
|
stats.total = 0
|
|
332
336
|
stats.cacheHits = 0
|
|
333
337
|
stats.errors = 0
|
|
@@ -340,11 +344,8 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
340
344
|
kind: 'exact',
|
|
341
345
|
path: '/dsh-tts/config',
|
|
342
346
|
handler: async (req, res) => {
|
|
343
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
344
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
345
|
-
return
|
|
346
|
-
}
|
|
347
347
|
if (req.method === 'GET') {
|
|
348
|
+
if (rejectIfUntrusted(req, res, { message: 'dsh-tts: settings writes are same-origin only' })) return
|
|
348
349
|
writeJson(res, 200, await configResponse())
|
|
349
350
|
return
|
|
350
351
|
}
|
|
@@ -352,6 +353,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
352
353
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
353
354
|
return
|
|
354
355
|
}
|
|
356
|
+
if (rejectIfUntrusted(req, res, { requireAuth: true, message: 'dsh-tts: settings writes are same-origin only' })) return
|
|
355
357
|
const settingsApi = typeof getSettingsApi === 'function' ? getSettingsApi() : null
|
|
356
358
|
if (!settingsApi) {
|
|
357
359
|
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
@@ -423,10 +425,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
423
425
|
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
|
|
424
426
|
return
|
|
425
427
|
}
|
|
426
|
-
if (
|
|
427
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
|
|
428
|
-
return
|
|
429
|
-
}
|
|
428
|
+
if (rejectIfUntrusted(req, res, { requireAuth: true, message: 'dsh-tts: keys are same-origin only' })) return
|
|
430
429
|
let payload = {}
|
|
431
430
|
if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
|
|
432
431
|
let raw
|
|
@@ -463,7 +462,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
463
462
|
path: '/dsh-tts/pending',
|
|
464
463
|
handler: async (req, res) => {
|
|
465
464
|
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
466
|
-
if (
|
|
465
|
+
if (rejectIfUntrusted(req, res)) return
|
|
467
466
|
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
468
467
|
const after = url.searchParams.get('after') || ''
|
|
469
468
|
let items = pending.slice()
|
|
@@ -480,7 +479,7 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
480
479
|
path: '/dsh-tts/speak',
|
|
481
480
|
handler: async (req, res) => {
|
|
482
481
|
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
483
|
-
if (
|
|
482
|
+
if (rejectIfUntrusted(req, res)) return
|
|
484
483
|
let raw
|
|
485
484
|
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
486
485
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
@@ -531,4 +530,4 @@ export function registerHttpRoutes(ctx, api) {
|
|
|
531
530
|
manifestUrl: new URL('../package.json', import.meta.url),
|
|
532
531
|
mutex: updaterMutex,
|
|
533
532
|
}), 'dsh-tts: updater route /api/dsh-tts/update')
|
|
534
|
-
}
|
|
533
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-tts",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.35",
|
|
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",
|