@goodandready/dsh-tts 0.4.0 → 0.4.2

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.
@@ -0,0 +1,398 @@
1
+ function TtsSection(props) {
2
+ // Translator comes from the slot because its registration sets locale.
3
+ const t = (props && props.t) || ((key) => key)
4
+ const [draft, setDraft] = React.useState(null)
5
+ const [credentials, setCredentials] = React.useState({})
6
+ const [keyDrafts, setKeyDrafts] = React.useState({})
7
+ const [saved, setSaved] = React.useState(false)
8
+ const [err, setErr] = React.useState('')
9
+ const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
10
+ const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, sseClients: 0, breaker: [], provider: '', queue: 0 })
11
+ const [advancedOpen, setAdvancedOpen] = React.useState(() => {
12
+ try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { return false }
13
+ })
14
+ const autoplay = useAutoplayGate()
15
+
16
+ const ctx = props && props.ctx
17
+ const scope = React.useMemo(() => {
18
+ const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
19
+ if (!s || !s.bind) return null
20
+ try {
21
+ return s.bind({ namespace: NS })
22
+ } catch (_) {
23
+ return null
24
+ }
25
+ }, [ctx])
26
+
27
+ const subscribe = React.useMemo(() => {
28
+ return (cb) => {
29
+ if (!scope || !scope.subscribe) return () => {}
30
+ try {
31
+ return scope.subscribe(cb) || (() => {})
32
+ } catch (_) {
33
+ return () => {}
34
+ }
35
+ }
36
+ }, [scope])
37
+
38
+ const getSnapshot = React.useCallback(() => {
39
+ if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
40
+ try {
41
+ return scope.getSnapshot() || SNAPSHOT_LOADING
42
+ } catch (_) {
43
+ return SNAPSHOT_LOADING
44
+ }
45
+ }, [scope])
46
+
47
+ const snap = (React.useSyncExternalStore
48
+ ? React.useSyncExternalStore(subscribe, getSnapshot, React.useCallback(() => SNAPSHOT_LOADING, []))
49
+ : null) || (scope && scope.getSnapshot ? scope.getSnapshot() : SNAPSHOT_LOADING)
50
+
51
+ // Snapshot status matters more than the value:
52
+ // loading — host has not answered yet;
53
+ // unavailable — host answered but the settings namespace is not ready;
54
+ // ready — values are present.
55
+ // On unavailable, writable defaults true; without a status check the card
56
+ // looks like a working empty form.
57
+ const ready = !scope || (!!snap && snap.status === 'ready')
58
+ const writable = !scope ? true : (ready && snap.writable !== false)
59
+
60
+ const applyPayload = (data) => {
61
+ const cfg = data && data.config ? data.config : {}
62
+ setDraft(JSON.parse(JSON.stringify(cfg)))
63
+ setCredentials(data && data.credentials ? data.credentials : {})
64
+ player.enabled = !!cfg.speakReplies
65
+ }
66
+
67
+ React.useEffect(() => {
68
+ let alive = true
69
+ fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
70
+ if (!alive) return
71
+ applyPayload(data)
72
+ }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
73
+
74
+ fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
75
+ if (!alive) return
76
+ if (data && data.ok) setIntegrations(data)
77
+ }).catch(() => {})
78
+
79
+ const loadTelemetry = () => {
80
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
81
+ if (!alive || !st) return
82
+ setTelemetry((prev) => Object.assign({}, prev, {
83
+ cacheHits: st.cacheHits || 0,
84
+ total: st.total || 0,
85
+ online: true,
86
+ breaker: st.breaker || prev.breaker || [],
87
+ }))
88
+ }).catch(() => {
89
+ if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
90
+ })
91
+ fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
92
+ if (!alive || !st) return
93
+ setTelemetry((prev) => Object.assign({}, prev, {
94
+ sseClients: st.sseClients || 0,
95
+ sseOk: (st.sseClients || 0) >= 0,
96
+ provider: (player.audio && player.provider) || (player.queue[0] && player.queue[0].provider) || '',
97
+ queue: player.queue.length,
98
+ }))
99
+ }).catch(() => {})
100
+ }
101
+ loadTelemetry()
102
+ const telTimer = setInterval(loadTelemetry, 3000)
103
+ // interval cleaned by effect return below
104
+
105
+ return () => { alive = false; clearInterval(telTimer) }
106
+ }, [])
107
+
108
+ if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
109
+
110
+ const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
111
+ const setDraftKey = (provider, value) => setKeyDrafts((d) => Object.assign({}, d, { [provider]: value }))
112
+
113
+ const commitKey = async (provider, raw) => {
114
+ const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
115
+ if (!value) return
116
+ setErr('')
117
+ const res = await fetch('/dsh-tts/credential', {
118
+ method: 'PUT',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ provider: provider, value: value }),
121
+ })
122
+ const data = await res.json().catch(() => ({}))
123
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
124
+ if (data && data.credentials) setCredentials(data.credentials)
125
+ setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
126
+ }
127
+
128
+ const clearKey = async (provider) => {
129
+ setErr('')
130
+ try {
131
+ const res = await fetch('/dsh-tts/credential', {
132
+ method: 'DELETE',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({ provider: provider }),
135
+ })
136
+ const data = await res.json().catch(() => ({}))
137
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
138
+ if (data && data.credentials) setCredentials(data.credentials)
139
+ setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
140
+ } catch (e) { setErr(String(e && e.message ? e.message : e)) }
141
+ }
142
+
143
+ const save = async () => {
144
+ setErr(''); setSaved(false)
145
+ if (!draft) return
146
+ try {
147
+ const keys = {}
148
+ Object.keys(keyDrafts || {}).forEach((p) => {
149
+ const v = String(keyDrafts[p] || '').trim()
150
+ if (v) keys[p] = v
151
+ })
152
+ const res = await fetch('/dsh-tts/config', {
153
+ method: 'PUT',
154
+ headers: { 'Content-Type': 'application/json' },
155
+ body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
156
+ })
157
+ const data = await res.json().catch(() => ({}))
158
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
159
+ applyPayload(data)
160
+ setKeyDrafts({})
161
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
162
+ } catch (e) { setErr(String(e && e.message ? e.message : e)) }
163
+ }
164
+
165
+ const onCommitKey = (provider, raw) => {
166
+ commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
167
+ }
168
+
169
+ const clearCacheNow = async () => {
170
+ setErr('')
171
+ const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
172
+ if (!res.ok) throw new Error('HTTP ' + res.status)
173
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
174
+ }
175
+ const onClearCache = () => {
176
+ if (typeof window !== 'undefined' && window.confirm) {
177
+ if (!window.confirm(t('confirmClearCache'))) return
178
+ }
179
+ clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
180
+ }
181
+
182
+ const onPreview = (provider, model, voice, text) => {
183
+ fetch('/dsh-tts/preview', {
184
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
185
+ body: JSON.stringify({ provider, model, voice, text }),
186
+ })
187
+ .then((r) => r.json())
188
+ .then((d) => {
189
+ if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
190
+ new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
191
+ })
192
+ .catch((e) => setErr(String(e && e.message ? e.message : e)))
193
+ }
194
+
195
+ const onPreviewPhrase = (phrase) => {
196
+ if (!phrase) return
197
+ const first = draft && draft.chain && draft.chain[0]
198
+ onPreview((first && first.provider) || 'kokoro', (first && first.model) || '', (first && first.voice) || '', phrase)
199
+ }
200
+
201
+ const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
202
+ React.createElement('input', {
203
+ type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
204
+ onChange: (e) => setTop(key, e.target.checked),
205
+ }),
206
+ React.createElement('span', { className: 'dts-sub' }, hint))
207
+
208
+ const numberField = (key, label, hint, step) => React.createElement('label', { className: 'dts-field' }, label,
209
+ React.createElement('input', {
210
+ type: 'number', step: step || 1,
211
+ className: 'dts-input',
212
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
213
+ onChange: (e) => setTop(key, Number(e.target.value)),
214
+ }),
215
+ React.createElement('span', { className: 'dts-sub' }, hint))
216
+
217
+ const textField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
218
+ React.createElement('input', {
219
+ className: 'dts-input',
220
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
221
+ onChange: (e) => setTop(key, e.target.value),
222
+ }),
223
+ React.createElement('span', { className: 'dts-sub' }, hint))
224
+
225
+ const autoplayBanner = autoplay.blocked
226
+ ? React.createElement('div', { className: 'dts-unlock' },
227
+ React.createElement('span', null, t('autoplayBlocked')),
228
+ React.createElement('button', {
229
+ type: 'button', className: 'dts-unlock-btn',
230
+ onClick: () => player.unlockAudio && player.unlockAudio(),
231
+ }, t('enableSound')),
232
+ )
233
+ : null
234
+ const breakerBadges = (telemetry.breaker || []).filter((b) => b && b.open).slice(0, 2)
235
+ .map((b) => React.createElement('span', { key: b.id, className: 'dts-badge dts-badge-bad' }, t('breakerOpen') + ': ' + b.id))
236
+ const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' }, 'data-dsh-tts-telemetry': '1' },
237
+ React.createElement('span', {
238
+ className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
239
+ }, telemetry.online ? t('statusOnline') : t('statusOffline')),
240
+ React.createElement('span', {
241
+ className: 'dts-badge ' + (telemetry.sseClients > 0 ? 'dts-badge-on' : ''),
242
+ }, t('sseState') + ': ' + (telemetry.sseClients || 0)),
243
+ React.createElement('span', { className: 'dts-badge' },
244
+ t('queueLen') + ': ' + (telemetry.queue || player.queue.length || 0)),
245
+ telemetry.provider ? React.createElement('span', { className: 'dts-badge' },
246
+ t('activeProvider') + ': ' + telemetry.provider) : null,
247
+ React.createElement('span', { className: 'dts-badge' },
248
+ t('cacheStats') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
249
+ ...breakerBadges,
250
+ )
251
+ const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
252
+ React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
253
+ React.createElement('div', { className: 'dts-page-sub' }, t('subtitle')),
254
+ telemetryBadges,
255
+ )
256
+ return React.createElement('div', { className: 'dts-wrap' },
257
+ autoplayBanner,
258
+ React.createElement('div', { className: 'dts-block' },
259
+ titleLine,
260
+ React.createElement('label', { className: 'dts-row' },
261
+ React.createElement('input', {
262
+ type: 'checkbox', checked: !!(draft && draft.speakReplies), disabled: !writable,
263
+ onChange: (e) => setTop('speakReplies', e.target.checked),
264
+ }),
265
+ React.createElement('span', null, t('speakReplies')),
266
+ ),
267
+ React.createElement('div', { className: 'dts-sub' },
268
+ t('speakRepliesHint')),
269
+ ),
270
+ React.createElement(LocalEnginesEditor, {
271
+ t: t, writable: writable, draft: draft,
272
+ boolField: boolField, setTop: setTop,
273
+ }),
274
+ React.createElement(VoiceDuplexEditor, {
275
+ t: t, writable: writable, draft: draft,
276
+ boolField: boolField, setTop: setTop,
277
+ installed: !!(integrations && integrations.voice && integrations.voice.installed),
278
+ }),
279
+ React.createElement(MessengerIntegrationEditor, {
280
+ t: t, writable: writable, draft: draft,
281
+ boolField: boolField, setTop: setTop,
282
+ installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
283
+ }),
284
+ React.createElement('div', { className: 'dts-block' },
285
+ React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
286
+ boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
287
+ React.createElement(PronEditor, {
288
+ t: t, writable: writable,
289
+ value: draft && draft.pronunciation ? draft.pronunciation : [],
290
+ onChange: (v) => setTop('pronunciation', v),
291
+ onPreviewPhrase: onPreviewPhrase,
292
+ }),
293
+ ),
294
+ React.createElement('div', { className: 'dts-block' },
295
+ React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
296
+ React.createElement(RolesEditor, {
297
+ t: t, writable: writable, onPreview: onPreview,
298
+ value: draft && draft.roles ? draft.roles : {},
299
+ onChange: (v) => setTop('roles', v),
300
+ }),
301
+ ),
302
+ React.createElement('div', { className: 'dts-block' },
303
+ React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
304
+ React.createElement(StatsPanel, { t: t }),
305
+ ),
306
+ React.createElement('div', { className: 'dts-block' },
307
+ React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
308
+ React.createElement('div', { className: 'dts-sub' },
309
+ t('chainHintCard')),
310
+ React.createElement(ChainEditor, {
311
+ breaker: telemetry.breaker,
312
+ t: t,
313
+ value: draft && draft.chain ? draft.chain : [], writable: writable,
314
+ credentials: credentials, keyDrafts: keyDrafts,
315
+ onChange: (v) => setTop('chain', v),
316
+ onDraft: setDraftKey,
317
+ onCommitKey: onCommitKey,
318
+ onClearKey: clearKey,
319
+ onPreview: onPreview,
320
+ }),
321
+ ),
322
+ React.createElement('div', { className: 'dts-block' },
323
+ React.createElement('div', { className: 'dts-h' }, t('general')),
324
+ boolField('speakAsItGoes', t('speakAsItGoes'),
325
+ t('speakAsItGoesHint')),
326
+ numberField('rate', t('rate'), t('rateHint'), 0.1),
327
+ boolField('bargeIn', t('bargeIn'),
328
+ t('bargeInHint')),
329
+ boolField('announceApproval', t('announce'),
330
+ t('announceHint')),
331
+ boolField('skipCode', t('skipCode'),
332
+ t('skipCodeHint')),
333
+ boolField('cache', t('cache'),
334
+ t('cacheHint')),
335
+ numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
336
+ React.createElement('label', { className: 'dts-field' }, t('longReply'),
337
+ React.createElement('select', {
338
+ className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
339
+ onChange: (e) => setTop('longReply', e.target.value),
340
+ }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
341
+ React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
342
+ textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
343
+ numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
344
+ boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
345
+ boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
346
+ boolField('skipActions', t('skipActions'), t('skipActionsHint')),
347
+ textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
348
+ textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
349
+ textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
350
+ textField('approvalText', t('approvalText'), t('approvalHint')),
351
+ textField('questionText', t('questionText'), t('questionHint')),
352
+ textField('chime', t('chime'), t('chimeHint')),
353
+ textField('language', t('language'), t('languageHint')),
354
+ textField('piperModel', t('piperModel'), t('piperModelHint')),
355
+ textField('piperBin', t('piperBin'), t('binHint')),
356
+ textField('espeakBin', t('espeakBin'), t('espeakHint')),
357
+ textField('edgeBin', t('edgeBin'), t('edgeHint')),
358
+ textField('azureRegion', t('azureRegion'), t('azureHint')),
359
+ ),
360
+ React.createElement('div', { className: 'dts-block' },
361
+ React.createElement('button', {
362
+ type: 'button',
363
+ className: 'dts-head',
364
+ 'aria-expanded': advancedOpen,
365
+ onClick: () => {
366
+ setAdvancedOpen((v) => {
367
+ const next = !v
368
+ try { localStorage.setItem('dsh-tts/advancedOpen', next ? '1' : '0') } catch { /* private mode */ }
369
+ return next
370
+ })
371
+ },
372
+ },
373
+ React.createElement('div', { className: 'dts-headText' },
374
+ React.createElement('div', { className: 'dts-title' }, t('advancedTitle')),
375
+ React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
376
+ ),
377
+ ),
378
+ advancedOpen ? React.createElement('div', { className: 'dts-body' },
379
+ numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
380
+ numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
381
+ numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
382
+ numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
383
+ textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
384
+ textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
385
+ textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
386
+ textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
387
+ ) : null,
388
+ ),
389
+ React.createElement('div', { className: 'dts-foot' },
390
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
391
+ React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
392
+ saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
393
+ err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
394
+ ),
395
+ )
396
+ }
397
+
398
+
@@ -0,0 +1,235 @@
1
+ function TtsCard(props) {
2
+ const t = (props && props.t) || ((key) => key)
3
+ const [open, setOpen] = React.useState(false)
4
+ return React.createElement('li', { className: 'dts-card' },
5
+ React.createElement('button', {
6
+ type: 'button',
7
+ className: 'dts-head',
8
+ onClick: () => setOpen(!open),
9
+ 'aria-expanded': open,
10
+ },
11
+ React.createElement('div', { className: 'dts-headText' },
12
+ React.createElement('div', { className: 'dts-title' }, t('title')),
13
+ React.createElement('div', { className: 'dts-sub' }, t('cardHint')),
14
+ ),
15
+ ChevronIcon
16
+ ? React.createElement(ChevronIcon, { className: 'dts-chev' + (open ? ' dts-chevOpen' : '') })
17
+ : React.createElement('svg', {
18
+ className: 'dts-chev' + (open ? ' dts-chevOpen' : ''),
19
+ width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
20
+ },
21
+ React.createElement('path', {
22
+ d: 'M4 6l4 4 4-4', stroke: 'currentColor', 'stroke-width': 1.5,
23
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round',
24
+ }),
25
+ ),
26
+ ),
27
+ open ? React.createElement('div', { className: 'dts-body' },
28
+ React.createElement(ErrorBoundary, null,
29
+ React.createElement(TtsSection, Object.assign({}, props, { compact: true, ctx: props && props.ctx })),
30
+ ),
31
+ ) : null,
32
+ )
33
+ }
34
+
35
+ // Input-dock button: shows what is playing, stop control, and recents for replay.
36
+ function SpeakerControl(props) {
37
+ // A slot with `locale` injects translations into props; the fallback is the
38
+ // binder created at registration, in case the slot drops translations.
39
+ const t = (props && props.t) || fallbackDockText
40
+ const p = usePlayer()
41
+ const [showList, setShowList] = React.useState(false)
42
+ const active = !!p.audio || p.queue.length > 0 || p.busy
43
+ const [, force] = React.useReducer((n) => n + 1, 0)
44
+ if (!active && !showList) return null
45
+ const lists = recent.list()
46
+ const row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
47
+ React.createElement('button', {
48
+ type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
49
+ title: text,
50
+ onClick: () => playStandalone(text), title: text,
51
+ }, text.slice(0, 80)),
52
+ React.createElement('button', {
53
+ type: 'button', className: 'dts-link', title: '★',
54
+ onClick: () => { recent.toggleFav(text); force() },
55
+ }, starred ? '★' : '☆'),
56
+ )
57
+ return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
58
+ React.createElement('button', {
59
+ type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
60
+ onClick: () => setShowList((v) => !v),
61
+ }, '☰'),
62
+ showList ? React.createElement('div', {
63
+ className: 'dts-wrap',
64
+ 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' },
65
+ },
66
+ lists.favs.length ? React.createElement('div', { className: 'dts-h' }, '★') : null,
67
+ lists.favs.map((text) => row(text, true)),
68
+ React.createElement('div', { className: 'dts-h' }, '⟳'),
69
+ lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
70
+ !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
71
+ ) : null,
72
+ React.createElement('button', {
73
+ type: 'button',
74
+ className: 'dts-link',
75
+ title: p.paused ? t('dockResume') : t('dockPause'),
76
+ onClick: togglePause,
77
+ }, p.paused ? '▶' : '❚❚'),
78
+ React.createElement('button', {
79
+ type: 'button',
80
+ className: 'dts-link',
81
+ title: t('dockStop'),
82
+ onClick: stopPlayback,
83
+ }, '■'),
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Fallback translator for the dock.
89
+ *
90
+ * Bound at registration for the page lifetime. The core draws the slot label;
91
+ * props.t never reaches it, so the binder is the only way to give it a language.
92
+ */
93
+ let fallbackDockText = (key) => key
94
+
95
+ function registerSpeaker(ctx) {
96
+ // `locale` in the slot descriptor is how core injects translations into props.
97
+ // Without it the dock would show raw keys only.
98
+ fallbackDockText = ctx.locale.bind(NS)
99
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
100
+ {
101
+ name: 'conversation.input.dock',
102
+ id: '@goodandready/dsh-tts',
103
+ locale: NS,
104
+ order: 5,
105
+ label: () => fallbackDockText('dockLabel'),
106
+ },
107
+ SpeakerControl,
108
+ ))
109
+ }
110
+
111
+ function registerSettings(ctx) {
112
+ // Dictionary packages may also register languages for other namespaces.
113
+ // Core throws on duplicate namespace+language; an unguarded call used to
114
+ // take down the whole plugin ("Failed to load plugins"). Register each
115
+ // language separately: skip if taken; English still lands.
116
+ const addLocale = (locale, dictionary) => {
117
+ try {
118
+ return ctx.locale.register(NS, locale, dictionary)
119
+ } catch (alreadyTaken) {
120
+ return () => {}
121
+ }
122
+ }
123
+ ctx.effect(() => {
124
+ const undo = [addLocale('en', en), addLocale('ru', ru)]
125
+ return () => { for (const off of undo) off() }
126
+ }, 'dsh-tts: locales')
127
+ // The sidebar draws the section label, not our component: props.t never
128
+ // reaches it, so bind the translator to the namespace ourselves.
129
+ const t = ctx.locale.bind(NS)
130
+ // Primary settings home is the plugin-settings tab card. The tab looks up
131
+ // a slot by entryKey equal to the namespace name: key must equal NS or
132
+ // the card never appears, with no log error.
133
+ const tryPluginItem = () => {
134
+ try {
135
+ const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
136
+ {
137
+ name: 'settings.plugin.item',
138
+ key: NS,
139
+ locale: NS,
140
+ inject: () => ({ ctx: ctx }),
141
+ },
142
+ TtsCard,
143
+ ))
144
+ if (res === false) return false
145
+ return true
146
+ } catch (noPluginItemSlot) {
147
+ return false
148
+ }
149
+ }
150
+ if (tryPluginItem()) return
151
+ // Fallback for builds without settings.plugin.item: keep the sidebar section
152
+ // so settings cannot disappear.
153
+ ctx.slots.inject('settings.section', () => ctx.slots.register(
154
+ {
155
+ name: 'settings.section',
156
+ id: '@goodandready/dsh-tts',
157
+ order: 32,
158
+ locale: NS,
159
+ label: () => t('title'),
160
+ inject: () => ({ ctx: ctx }),
161
+ },
162
+ (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
163
+ ))
164
+ }
165
+
166
+ exports.inject = ['slots', 'settingsScope', 'locale']
167
+ exports.apply = function apply(ctx) {
168
+ registerSettings(ctx)
169
+ registerSpeaker(ctx)
170
+ ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
171
+ ctx.effect(() => {
172
+ if (typeof window === 'undefined') return () => {}
173
+ const onKey = (e) => {
174
+ if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
175
+ else if (e.altKey && (e.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
176
+ else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
177
+ }
178
+ window.addEventListener('keydown', onKey)
179
+ return () => window.removeEventListener('keydown', onKey)
180
+ }, 'dsh-tts: hotkeys')
181
+ ctx.effect(() => {
182
+ if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
183
+ let es = null
184
+ let closed = false
185
+ let attempt = 0
186
+ let retryTimer = null
187
+ const onItem = (e) => {
188
+ try {
189
+ const item = JSON.parse(e.data)
190
+ if (!item || !item.id) return
191
+ if (seenIds.has(item.id)) return
192
+ seenIds.add(item.id)
193
+ if (seenIds.size > 500) {
194
+ const first = seenIds.values().next().value
195
+ seenIds.delete(first)
196
+ }
197
+ player.queue.push(item)
198
+ drainQueue()
199
+ } catch { /* ignore malformed SSE frame */ }
200
+ }
201
+ const connect = () => {
202
+ if (closed) return
203
+ try {
204
+ es = new EventSource('/dsh-tts/stream')
205
+ es.addEventListener('open', () => { attempt = 0 })
206
+ es.addEventListener('utterance', onItem)
207
+ es.addEventListener('chime', onItem)
208
+ es.onerror = () => {
209
+ try { es.close() } catch { /* already closed */ }
210
+ if (closed) return
211
+ attempt += 1
212
+ const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
213
+ retryTimer = setTimeout(connect, delay)
214
+ }
215
+ } catch { /* EventSource constructor failed — poll remains */ }
216
+ }
217
+ connect()
218
+ return () => {
219
+ closed = true
220
+ if (retryTimer) clearTimeout(retryTimer)
221
+ if (es) { try { es.close() } catch { /* already closed */ } }
222
+ }
223
+ }, 'dsh-tts: SSE audio stream')
224
+ ctx.effect(() => {
225
+ const timer = setInterval(pollPending, 1000)
226
+ return () => clearInterval(timer)
227
+ }, 'dsh-tts: poll pending audio')
228
+ }
229
+ return module.exports
230
+ },
231
+ })
232
+
233
+ // ModelManager UI placeholder - manual install buttons for Kokoro/F5
234
+ // Actual implementation would show download progress and status
235
+