@goodandready/dsh-tts 0.3.23 → 0.4.1

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,341 @@
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 })
11
+ const ctx = props && props.ctx
12
+ const scope = React.useMemo(() => {
13
+ const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
14
+ if (!s || !s.bind) return null
15
+ try {
16
+ return s.bind({ namespace: NS })
17
+ } catch (_) {
18
+ return null
19
+ }
20
+ }, [ctx])
21
+
22
+ const subscribe = React.useMemo(() => {
23
+ return (cb) => {
24
+ if (!scope || !scope.subscribe) return () => {}
25
+ try {
26
+ return scope.subscribe(cb) || (() => {})
27
+ } catch (_) {
28
+ return () => {}
29
+ }
30
+ }
31
+ }, [scope])
32
+
33
+ const getSnapshot = React.useCallback(() => {
34
+ if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
35
+ try {
36
+ return scope.getSnapshot() || SNAPSHOT_LOADING
37
+ } catch (_) {
38
+ return SNAPSHOT_LOADING
39
+ }
40
+ }, [scope])
41
+
42
+ const snap = (React.useSyncExternalStore
43
+ ? React.useSyncExternalStore(subscribe, getSnapshot, React.useCallback(() => SNAPSHOT_LOADING, []))
44
+ : null) || (scope && scope.getSnapshot ? scope.getSnapshot() : SNAPSHOT_LOADING)
45
+
46
+ // Snapshot status matters more than the value:
47
+ // loading — host has not answered yet;
48
+ // unavailable — host answered but the settings namespace is not ready;
49
+ // ready — values are present.
50
+ // On unavailable, writable defaults true; without a status check the card
51
+ // looks like a working empty form.
52
+ const ready = !scope || (!!snap && snap.status === 'ready')
53
+ const writable = !scope ? true : (ready && snap.writable !== false)
54
+
55
+ const applyPayload = (data) => {
56
+ const cfg = data && data.config ? data.config : {}
57
+ setDraft(JSON.parse(JSON.stringify(cfg)))
58
+ setCredentials(data && data.credentials ? data.credentials : {})
59
+ player.enabled = !!cfg.speakReplies
60
+ }
61
+
62
+ React.useEffect(() => {
63
+ let alive = true
64
+ fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
65
+ if (!alive) return
66
+ applyPayload(data)
67
+ }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
68
+
69
+ fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
70
+ if (!alive) return
71
+ if (data && data.ok) setIntegrations(data)
72
+ }).catch(() => {})
73
+
74
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
75
+ if (!alive || !st) return
76
+ setTelemetry((prev) => Object.assign({}, prev, {
77
+ cacheHits: st.cacheHits || 0,
78
+ total: st.total || 0,
79
+ online: true,
80
+ }))
81
+ }).catch(() => {
82
+ if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
83
+ })
84
+
85
+ return () => { alive = false }
86
+ }, [])
87
+
88
+ if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
89
+
90
+ const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
91
+ const setDraftKey = (provider, value) => setKeyDrafts((d) => Object.assign({}, d, { [provider]: value }))
92
+
93
+ const commitKey = async (provider, raw) => {
94
+ const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
95
+ if (!value) return
96
+ setErr('')
97
+ const res = await fetch('/dsh-tts/credential', {
98
+ method: 'PUT',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify({ provider: provider, value: value }),
101
+ })
102
+ const data = await res.json().catch(() => ({}))
103
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
104
+ if (data && data.credentials) setCredentials(data.credentials)
105
+ setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
106
+ }
107
+
108
+ const clearKey = async (provider) => {
109
+ setErr('')
110
+ try {
111
+ const res = await fetch('/dsh-tts/credential', {
112
+ method: 'DELETE',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({ provider: provider }),
115
+ })
116
+ const data = await res.json().catch(() => ({}))
117
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
118
+ if (data && data.credentials) setCredentials(data.credentials)
119
+ setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
120
+ } catch (e) { setErr(String(e && e.message ? e.message : e)) }
121
+ }
122
+
123
+ const save = async () => {
124
+ setErr(''); setSaved(false)
125
+ if (!draft) return
126
+ try {
127
+ const keys = {}
128
+ Object.keys(keyDrafts || {}).forEach((p) => {
129
+ const v = String(keyDrafts[p] || '').trim()
130
+ if (v) keys[p] = v
131
+ })
132
+ const res = await fetch('/dsh-tts/config', {
133
+ method: 'PUT',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
136
+ })
137
+ const data = await res.json().catch(() => ({}))
138
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
139
+ applyPayload(data)
140
+ setKeyDrafts({})
141
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
142
+ } catch (e) { setErr(String(e && e.message ? e.message : e)) }
143
+ }
144
+
145
+ const onCommitKey = (provider, raw) => {
146
+ commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
147
+ }
148
+
149
+ const clearCacheNow = async () => {
150
+ setErr('')
151
+ const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
152
+ if (!res.ok) throw new Error('HTTP ' + res.status)
153
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
154
+ }
155
+ const onClearCache = () => {
156
+ clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
157
+ }
158
+
159
+ const onPreview = (provider, model, voice, text) => {
160
+ fetch('/dsh-tts/preview', {
161
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({ provider, model, voice, text }),
163
+ })
164
+ .then((r) => r.json())
165
+ .then((d) => {
166
+ if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
167
+ new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
168
+ })
169
+ .catch((e) => setErr(String(e && e.message ? e.message : e)))
170
+ }
171
+
172
+ const onPreviewPhrase = (phrase) => {
173
+ if (!phrase) return
174
+ const first = draft && draft.chain && draft.chain[0]
175
+ onPreview((first && first.provider) || 'kokoro', (first && first.model) || '', (first && first.voice) || '', phrase)
176
+ }
177
+
178
+ const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
179
+ React.createElement('input', {
180
+ type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
181
+ onChange: (e) => setTop(key, e.target.checked),
182
+ }),
183
+ React.createElement('span', { className: 'dts-sub' }, hint))
184
+
185
+ const numberField = (key, label, hint, step) => React.createElement('label', { className: 'dts-field' }, label,
186
+ React.createElement('input', {
187
+ type: 'number', step: step || 1,
188
+ className: 'dts-input',
189
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
190
+ onChange: (e) => setTop(key, Number(e.target.value)),
191
+ }),
192
+ React.createElement('span', { className: 'dts-sub' }, hint))
193
+
194
+ const textField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
195
+ React.createElement('input', {
196
+ className: 'dts-input',
197
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
198
+ onChange: (e) => setTop(key, e.target.value),
199
+ }),
200
+ React.createElement('span', { className: 'dts-sub' }, hint))
201
+
202
+ const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' } },
203
+ React.createElement('span', {
204
+ className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
205
+ }, telemetry.online ? '🟢 ' + (t('statusOnline') || 'Host online') : '🔴 ' + (t('statusOffline') || 'Host offline')),
206
+ React.createElement('span', {
207
+ className: 'dts-badge dts-badge-on',
208
+ }, '⚡ SSE Active'),
209
+ React.createElement('span', {
210
+ className: 'dts-badge',
211
+ }, '💾 ' + (t('cacheStats') || 'Cache') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
212
+ )
213
+ const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
214
+ React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
215
+ React.createElement('div', { className: 'dts-page-sub' }, t('subtitle')),
216
+ telemetryBadges,
217
+ )
218
+ return React.createElement('div', { className: 'dts-wrap' },
219
+ React.createElement('div', { className: 'dts-block' },
220
+ titleLine,
221
+ React.createElement('label', { className: 'dts-row' },
222
+ React.createElement('input', {
223
+ type: 'checkbox', checked: !!(draft && draft.speakReplies), disabled: !writable,
224
+ onChange: (e) => setTop('speakReplies', e.target.checked),
225
+ }),
226
+ React.createElement('span', null, t('speakReplies')),
227
+ ),
228
+ React.createElement('div', { className: 'dts-sub' },
229
+ t('speakRepliesHint')),
230
+ ),
231
+ React.createElement(LocalEnginesEditor, {
232
+ t: t, writable: writable, draft: draft,
233
+ boolField: boolField, setTop: setTop,
234
+ }),
235
+ React.createElement(VoiceDuplexEditor, {
236
+ t: t, writable: writable, draft: draft,
237
+ boolField: boolField, setTop: setTop,
238
+ installed: !!(integrations && integrations.voice && integrations.voice.installed),
239
+ }),
240
+ React.createElement(MessengerIntegrationEditor, {
241
+ t: t, writable: writable, draft: draft,
242
+ boolField: boolField, setTop: setTop,
243
+ installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
244
+ }),
245
+ React.createElement('div', { className: 'dts-block' },
246
+ React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
247
+ boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
248
+ React.createElement(PronEditor, {
249
+ t: t, writable: writable,
250
+ value: draft && draft.pronunciation ? draft.pronunciation : [],
251
+ onChange: (v) => setTop('pronunciation', v),
252
+ onPreviewPhrase: onPreviewPhrase,
253
+ }),
254
+ ),
255
+ React.createElement('div', { className: 'dts-block' },
256
+ React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
257
+ React.createElement(RolesEditor, {
258
+ t: t, writable: writable, onPreview: onPreview,
259
+ value: draft && draft.roles ? draft.roles : {},
260
+ onChange: (v) => setTop('roles', v),
261
+ }),
262
+ ),
263
+ React.createElement('div', { className: 'dts-block' },
264
+ React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
265
+ React.createElement(StatsPanel, { t: t }),
266
+ ),
267
+ React.createElement('div', { className: 'dts-block' },
268
+ React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
269
+ React.createElement('div', { className: 'dts-sub' },
270
+ t('chainHintCard')),
271
+ React.createElement(ChainEditor, {
272
+ t: t,
273
+ value: draft && draft.chain ? draft.chain : [], writable: writable,
274
+ credentials: credentials, keyDrafts: keyDrafts,
275
+ onChange: (v) => setTop('chain', v),
276
+ onDraft: setDraftKey,
277
+ onCommitKey: onCommitKey,
278
+ onClearKey: clearKey,
279
+ onPreview: onPreview,
280
+ }),
281
+ ),
282
+ React.createElement('div', { className: 'dts-block' },
283
+ React.createElement('div', { className: 'dts-h' }, t('general')),
284
+ boolField('speakAsItGoes', t('speakAsItGoes'),
285
+ t('speakAsItGoesHint')),
286
+ numberField('rate', t('rate'), t('rateHint'), 0.1),
287
+ boolField('bargeIn', t('bargeIn'),
288
+ t('bargeInHint')),
289
+ boolField('announceApproval', t('announce'),
290
+ t('announceHint')),
291
+ boolField('skipCode', t('skipCode'),
292
+ t('skipCodeHint')),
293
+ boolField('cache', t('cache'),
294
+ t('cacheHint')),
295
+ numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
296
+ React.createElement('label', { className: 'dts-field' }, t('longReply'),
297
+ React.createElement('select', {
298
+ className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
299
+ onChange: (e) => setTop('longReply', e.target.value),
300
+ }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
301
+ React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
302
+ textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
303
+ numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
304
+ boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
305
+ boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
306
+ boolField('skipActions', t('skipActions'), t('skipActionsHint')),
307
+ textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
308
+ textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
309
+ textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
310
+ textField('approvalText', t('approvalText'), t('approvalHint')),
311
+ textField('questionText', t('questionText'), t('questionHint')),
312
+ textField('chime', t('chime'), t('chimeHint')),
313
+ textField('language', t('language'), t('languageHint')),
314
+ textField('piperModel', t('piperModel'), t('piperModelHint')),
315
+ textField('piperBin', t('piperBin'), t('binHint')),
316
+ textField('espeakBin', t('espeakBin'), t('espeakHint')),
317
+ textField('edgeBin', t('edgeBin'), t('edgeHint')),
318
+ textField('azureRegion', t('azureRegion'), t('azureHint')),
319
+ ),
320
+ React.createElement('div', { className: 'dts-block' },
321
+ React.createElement('div', { className: 'dts-h' }, t('advancedTitle')),
322
+ React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
323
+ numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
324
+ numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
325
+ numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
326
+ numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
327
+ textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
328
+ textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
329
+ textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
330
+ textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
331
+ ),
332
+ React.createElement('div', { className: 'dts-foot' },
333
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
334
+ React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
335
+ saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
336
+ err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
337
+ ),
338
+ )
339
+ }
340
+
341
+
@@ -0,0 +1,229 @@
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),
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: 'Недавние',
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' }, '—') : 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
+ * Запасной перевод дока.
89
+ *
90
+ * Ставится при регистрации и живёт до конца работы страницы. Нужен
91
+ * потому, что подпись слота рисует ядро: `props.t` туда не доходит в
92
+ * принципе, и привязка — единственный способ дать ей язык.
93
+ */
94
+ let fallbackDockText = (key) => key
95
+
96
+ function registerSpeaker(ctx) {
97
+ // `locale` in the slot descriptor is how core injects translations into props.
98
+ // Without it the dock would show raw keys only.
99
+ fallbackDockText = ctx.locale.bind(NS)
100
+ ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
101
+ {
102
+ name: 'conversation.input.dock',
103
+ id: '@goodandready/dsh-tts',
104
+ locale: NS,
105
+ order: 5,
106
+ label: () => fallbackDockText('dockLabel'),
107
+ },
108
+ SpeakerControl,
109
+ ))
110
+ }
111
+
112
+ function registerSettings(ctx) {
113
+ // Dictionary packages may also register languages for other namespaces.
114
+ // Core throws on duplicate namespace+language; an unguarded call used to
115
+ // take down the whole plugin ("Failed to load plugins"). Register each
116
+ // language separately: skip if taken; English still lands.
117
+ const addLocale = (locale, dictionary) => {
118
+ try {
119
+ return ctx.locale.register(NS, locale, dictionary)
120
+ } catch (alreadyTaken) {
121
+ return () => {}
122
+ }
123
+ }
124
+ ctx.effect(() => {
125
+ const undo = [addLocale('en', en), addLocale('ru', ru)]
126
+ return () => { for (const off of undo) off() }
127
+ }, 'dsh-tts: словари')
128
+ // The sidebar draws the section label, not our component: props.t never
129
+ // reaches it, so bind the translator to the namespace ourselves.
130
+ const t = ctx.locale.bind(NS)
131
+ // Primary settings home is the plugin-settings tab card. The tab looks up
132
+ // a slot by entryKey equal to the namespace name: key must equal NS or
133
+ // the card never appears, with no log error.
134
+ const tryPluginItem = () => {
135
+ try {
136
+ const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
137
+ {
138
+ name: 'settings.plugin.item',
139
+ key: NS,
140
+ locale: NS,
141
+ inject: () => ({ ctx: ctx }),
142
+ },
143
+ TtsCard,
144
+ ))
145
+ if (res === false) return false
146
+ return true
147
+ } catch (noPluginItemSlot) {
148
+ return false
149
+ }
150
+ }
151
+ if (tryPluginItem()) return
152
+ // Fallback for builds without settings.plugin.item: keep the sidebar section
153
+ // so settings cannot disappear.
154
+ ctx.slots.inject('settings.section', () => ctx.slots.register(
155
+ {
156
+ name: 'settings.section',
157
+ id: '@goodandready/dsh-tts',
158
+ order: 32,
159
+ locale: NS,
160
+ label: () => t('title'),
161
+ inject: () => ({ ctx: ctx }),
162
+ },
163
+ (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
164
+ ))
165
+ }
166
+
167
+ exports.inject = ['slots', 'settingsScope', 'locale']
168
+ exports.apply = function apply(ctx) {
169
+ registerSettings(ctx)
170
+ registerSpeaker(ctx)
171
+ ctx.effect(() => listenForVoice(), 'dsh-tts: замолкать, когда человек заговорил')
172
+ ctx.effect(() => {
173
+ if (typeof window === 'undefined') return () => {}
174
+ const onKey = (e) => {
175
+ if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
176
+ else if (e.altKey && (e.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
177
+ else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
178
+ }
179
+ window.addEventListener('keydown', onKey)
180
+ return () => window.removeEventListener('keydown', onKey)
181
+ }, 'dsh-tts: горячие клавиши')
182
+ ctx.effect(() => {
183
+ if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
184
+ let es = null
185
+ try {
186
+ es = new EventSource('/dsh-tts/stream')
187
+ es.addEventListener('utterance', (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 {}
200
+ })
201
+ es.addEventListener('chime', (e) => {
202
+ try {
203
+ const item = JSON.parse(e.data)
204
+ if (!item || !item.id) return
205
+ if (seenIds.has(item.id)) return
206
+ seenIds.add(item.id)
207
+ if (seenIds.size > 500) {
208
+ const first = seenIds.values().next().value
209
+ seenIds.delete(first)
210
+ }
211
+ player.queue.push(item)
212
+ drainQueue()
213
+ } catch {}
214
+ })
215
+ } catch {}
216
+ return () => { if (es) es.close() }
217
+ }, 'dsh-tts: потоковое получение звука по SSE')
218
+ ctx.effect(() => {
219
+ const timer = setInterval(pollPending, 1000)
220
+ return () => clearInterval(timer)
221
+ }, 'dsh-tts: опрос готовых озвучек')
222
+ }
223
+ return module.exports
224
+ },
225
+ })
226
+
227
+ // ModelManager UI placeholder - manual install buttons for Kokoro/F5
228
+ // Actual implementation would show download progress and status
229
+