@goodandready/dsh-tts 0.4.22 → 0.4.23

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.
@@ -1,399 +0,0 @@
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 { /* localStorage unavailable */ 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.configForms)
19
- if (!s || !s.get) return null
20
- try {
21
- return s.get(NS)
22
- } catch (_) { /* settings scope binding failed */
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 (_) { /* settings scope binding failed */
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 (_) { /* settings scope binding failed */
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(() => ({ /* parse error */ }))
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(() => ({ /* parse error */ }))
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(() => ({ /* parse error */ }))
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(() => { /* autoplay blocked */ })
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
- boolField('autoDetectSubagent', t('autoDetectSubagent'), t('autoDetectSubagent')),
297
- React.createElement(RolesEditor, {
298
- t: t, writable: writable, onPreview: onPreview,
299
- value: draft && draft.roles ? draft.roles : {},
300
- onChange: (v) => setTop('roles', v),
301
- }),
302
- ),
303
- React.createElement('div', { className: 'dts-block' },
304
- React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
305
- React.createElement(StatsPanel, { t: t }),
306
- ),
307
- React.createElement('div', { className: 'dts-block' },
308
- React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
309
- React.createElement('div', { className: 'dts-sub' },
310
- t('chainHintCard')),
311
- React.createElement(ChainEditor, {
312
- breaker: telemetry.breaker,
313
- t: t,
314
- value: draft && draft.chain ? draft.chain : [], writable: writable,
315
- credentials: credentials, keyDrafts: keyDrafts,
316
- onChange: (v) => setTop('chain', v),
317
- onDraft: setDraftKey,
318
- onCommitKey: onCommitKey,
319
- onClearKey: clearKey,
320
- onPreview: onPreview,
321
- }),
322
- ),
323
- React.createElement('div', { className: 'dts-block' },
324
- React.createElement('div', { className: 'dts-h' }, t('general')),
325
- boolField('speakAsItGoes', t('speakAsItGoes'),
326
- t('speakAsItGoesHint')),
327
- numberField('rate', t('rate'), t('rateHint'), 0.1),
328
- boolField('bargeIn', t('bargeIn'),
329
- t('bargeInHint')),
330
- boolField('announceApproval', t('announce'),
331
- t('announceHint')),
332
- boolField('skipCode', t('skipCode'),
333
- t('skipCodeHint')),
334
- boolField('cache', t('cache'),
335
- t('cacheHint')),
336
- numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
337
- React.createElement('label', { className: 'dts-field' }, t('longReply'),
338
- React.createElement('select', {
339
- className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
340
- onChange: (e) => setTop('longReply', e.target.value),
341
- }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
342
- React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
343
- textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
344
- numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
345
- boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
346
- boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
347
- boolField('skipActions', t('skipActions'), t('skipActionsHint')),
348
- textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
349
- textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
350
- textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
351
- textField('approvalText', t('approvalText'), t('approvalHint')),
352
- textField('questionText', t('questionText'), t('questionHint')),
353
- textField('chime', t('chime'), t('chimeHint')),
354
- textField('language', t('language'), t('languageHint')),
355
- textField('piperModel', t('piperModel'), t('piperModelHint')),
356
- textField('piperBin', t('piperBin'), t('binHint')),
357
- textField('espeakBin', t('espeakBin'), t('espeakHint')),
358
- textField('edgeBin', t('edgeBin'), t('edgeHint')),
359
- textField('azureRegion', t('azureRegion'), t('azureHint')),
360
- ),
361
- React.createElement('div', { className: 'dts-block' },
362
- React.createElement('button', {
363
- type: 'button',
364
- className: 'dts-head',
365
- 'aria-expanded': advancedOpen,
366
- onClick: () => {
367
- setAdvancedOpen((v) => {
368
- const next = !v
369
- try { localStorage.setItem('dsh-tts/advancedOpen', next ? '1' : '0') } catch { /* private mode */ }
370
- return next
371
- })
372
- },
373
- },
374
- React.createElement('div', { className: 'dts-headText' },
375
- React.createElement('div', { className: 'dts-title' }, t('advancedTitle')),
376
- React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
377
- ),
378
- ),
379
- advancedOpen ? React.createElement('div', { className: 'dts-body' },
380
- numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
381
- numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
382
- numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
383
- numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
384
- textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
385
- textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
386
- textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
387
- textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
388
- ) : null,
389
- ),
390
- React.createElement('div', { className: 'dts-foot' },
391
- React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
392
- React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
393
- saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
394
- err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
395
- ),
396
- )
397
- }
398
-
399
-
@@ -1,278 +0,0 @@
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
- React.createElement('button', {
57
- type: 'button', className: 'dts-link', title: t('exportAudio') || 'Export',
58
- onClick: () => exportAudioClip(text),
59
- }, '⤓'),
60
- )
61
- return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
62
- React.createElement('button', {
63
- type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
64
- onClick: () => setShowList((v) => !v),
65
- }, '☰'),
66
- showList ? React.createElement('div', {
67
- className: 'dts-wrap',
68
- 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' },
69
- },
70
- lists.favs.length ? React.createElement('div', { className: 'dts-h' }, '★') : null,
71
- lists.favs.map((text) => row(text, true)),
72
- React.createElement('div', { className: 'dts-h' }, '⟳'),
73
- lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
74
- !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
75
- ) : null,
76
- React.createElement('button', {
77
- type: 'button',
78
- className: 'dts-link',
79
- title: p.paused ? t('dockResume') : t('dockPause'),
80
- onClick: togglePause,
81
- }, p.paused ? '▶' : '❚❚'),
82
- React.createElement('button', {
83
- type: 'button',
84
- className: 'dts-link',
85
- title: t('dockStop'),
86
- onClick: stopPlayback,
87
- }, '■'),
88
- React.createElement('button', {
89
- type: 'button',
90
- className: 'dts-link',
91
- title: t('exportAudio') || 'Export clip',
92
- onClick: () => exportAudioClip(player.lastText),
93
- }, '⤓'),
94
- )
95
- }
96
-
97
- /**
98
- * Fallback translator for the dock.
99
- *
100
- * Bound at registration for the page lifetime. The core draws the slot label;
101
- * props.t never reaches it, so the binder is the only way to give it a language.
102
- */
103
- let fallbackDockText = (key) => key
104
-
105
- function registerSpeaker(ctx) {
106
- // `locale` in the slot descriptor is how core injects translations into props.
107
- // Without it the dock would show raw keys only.
108
- fallbackDockText = ctx.locale.bind(NS)
109
- ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
110
- {
111
- name: 'conversation.input.dock',
112
- id: '@goodandready/dsh-tts',
113
- locale: NS,
114
- order: 5,
115
- label: () => fallbackDockText('dockLabel'),
116
- },
117
- SpeakerControl,
118
- ))
119
- }
120
-
121
- /**
122
- * Plugins page row seat (`plugins.row.config`, current core).
123
- *
124
- * The host page draws the title, icon and crumb and provides the content
125
- * padding, so the page view renders the settings form bare: our own card
126
- * chrome would double the border and shift the form out of the content
127
- * area. The summary view feeds the one-liner under the row title.
128
- */
129
- function TtsRowConfig(props) {
130
- const t = (props && props.t) || ((key) => key)
131
- if (props && props.view === 'summary') {
132
- return React.createElement('div', { className: 'dts-sub' }, t('subtitle'))
133
- }
134
- return React.createElement('div', { className: 'dts-page' },
135
- React.createElement(ErrorBoundary, null,
136
- React.createElement(TtsSection, Object.assign({}, props, { ctx: props && props.ctx })),
137
- ),
138
- )
139
- }
140
-
141
- function registerSettings(ctx) {
142
- // Dictionary packages may also register languages for other namespaces.
143
- // Core throws on duplicate namespace+language; an unguarded call used to
144
- // take down the whole plugin ("Failed to load plugins"). Register each
145
- // language separately: skip if taken; English still lands.
146
- const addLocale = (locale, dictionary) => {
147
- try {
148
- return ctx.locale.register(NS, locale, dictionary)
149
- } catch (alreadyTaken) {
150
- return () => {}
151
- }
152
- }
153
- ctx.effect(() => {
154
- const undo = [addLocale('en', en), addLocale('zh', zh)]
155
- return () => { for (const off of undo) off() }
156
- }, 'dsh-tts: locales')
157
- // The sidebar draws the section label, not our component: props.t never
158
- // reaches it, so bind the translator to the namespace ourselves.
159
- const t = ctx.locale.bind(NS)
160
- // Current core: settings live on the plugin's own row on the Plugins page
161
- // (`plugins.row.config`). Key = '<package name>#<row id>'; the row gains a
162
- // configure control that asks for view:'summary' and view:'page'.
163
- try {
164
- ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
165
- {
166
- name: 'plugins.row.config',
167
- key: ROW_CONFIG_KEY,
168
- locale: NS,
169
- inject: () => ({ ctx: ctx }),
170
- },
171
- TtsRowConfig,
172
- ))
173
- } catch (rowSeatUnavailable) {
174
- console.warn('[dsh-tts] row seat plugins.row.config not available:', rowSeatUnavailable && rowSeatUnavailable.message)
175
- }
176
- // Plugin-list seat (`plugins.item`): the seat the current core (0.1.6-alpha.2)
177
- // renders as the plugin's own page with its configuration. The label is a static
178
- // string on purpose — it is resolved while the page renders, and a locale lookup
179
- // there would take the whole client batch down with it.
180
- try {
181
- ctx.slots.inject('plugins.item', () => ctx.slots.register(
182
- {
183
- name: 'plugins.item',
184
- id: ROW_ID,
185
- order: 60,
186
- label: () => 'Text to Speech',
187
- locale: NS,
188
- inject: () => ({ ctx: ctx }),
189
- },
190
- TtsRowConfig,
191
- ))
192
- } catch (itemSeatUnavailable) {
193
- console.warn('[dsh-tts] list seat plugins.item not available:', itemSeatUnavailable && itemSeatUnavailable.message)
194
- }
195
- // Fallback for hosts that render the older plugin-settings tab card. The tab
196
- // looks up a slot by entryKey equal to the namespace name: key must equal NS
197
- // or the card never appears, with no log error.
198
- ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
199
- {
200
- name: 'settings.plugin.item',
201
- key: NS,
202
- locale: NS,
203
- inject: () => ({ ctx: ctx }),
204
- },
205
- TtsCard,
206
- ))
207
- }
208
-
209
- exports.inject = ['slots', 'configForms', 'locale']
210
- exports.apply = function apply(ctx) {
211
- registerSettings(ctx)
212
- registerSpeaker(ctx)
213
- ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
214
- ctx.effect(() => {
215
- if (typeof window === 'undefined') return () => {}
216
- const onKey = (e) => {
217
- if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
218
- else if (e.altKey && (e.code === 'KeyS' || e.key === 's' || e.key === 'S')) { stopPlayback(); e.preventDefault() }
219
- else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
220
- }
221
- window.addEventListener('keydown', onKey)
222
- return () => window.removeEventListener('keydown', onKey)
223
- }, 'dsh-tts: hotkeys')
224
- ctx.effect(() => {
225
- if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
226
- let es = null
227
- let closed = false
228
- let attempt = 0
229
- let retryTimer = null
230
- const onItem = (e) => {
231
- try {
232
- const item = JSON.parse(e.data)
233
- if (!item || !item.id) return
234
- if (seenIds.has(item.id)) return
235
- seenIds.add(item.id)
236
- if (seenIds.size > 500) {
237
- const first = seenIds.values().next().value
238
- seenIds.delete(first)
239
- }
240
- player.queue.push(item)
241
- drainQueue()
242
- } catch { /* ignore malformed SSE frame */ }
243
- }
244
- const connect = () => {
245
- if (closed) return
246
- try {
247
- es = new EventSource('/dsh-tts/stream')
248
- es.addEventListener('open', () => { attempt = 0 })
249
- es.addEventListener('utterance', onItem)
250
- es.addEventListener('chime', onItem)
251
- es.onerror = () => {
252
- try { es.close() } catch { /* already closed */ }
253
- if (closed) return
254
- attempt += 1
255
- const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
256
- retryTimer = setTimeout(connect, delay)
257
- }
258
- } catch { /* EventSource constructor failed — poll remains */ }
259
- }
260
- connect()
261
- return () => {
262
- closed = true
263
- if (retryTimer) clearTimeout(retryTimer)
264
- if (es) { try { es.close() } catch { /* already closed */ } }
265
- }
266
- }, 'dsh-tts: SSE audio stream')
267
- ctx.effect(() => {
268
- const timer = setInterval(pollPending, 1000)
269
- return () => clearInterval(timer)
270
- }, 'dsh-tts: poll pending audio')
271
- }
272
- return module.exports
273
- },
274
- })
275
-
276
- // ModelManager UI placeholder - manual install buttons for Kokoro/F5
277
- // Actual implementation would show download progress and status
278
-