@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.
package/lib/client.js CHANGED
@@ -38,34 +38,96 @@ window.__ModuleLoader__.load({
38
38
  f5: 'not bundled — offline neural runtime unavailable',
39
39
  }
40
40
 
41
+
42
+ function createErrorBoundary() {
43
+ if (!React || typeof React.Component !== 'function') {
44
+ return function NoopBoundary(props) { return (props && props.children) || null }
45
+ }
46
+ return class ErrorBoundary extends React.Component {
47
+ constructor(props) {
48
+ super(props)
49
+ this.state = { hasError: false, error: null }
50
+ }
51
+ static getDerivedStateFromError(error) {
52
+ return { hasError: true, error }
53
+ }
54
+ componentDidCatch(error, errorInfo) {
55
+ console.error('[dsh-tts] React Error:', error, errorInfo)
56
+ }
57
+ render() {
58
+ if (this.state.hasError) {
59
+ return React.createElement(
60
+ 'div',
61
+ {
62
+ className: 'dts-alert dts-alert-err',
63
+ style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
64
+ },
65
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ TTS UI Error:'),
66
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
67
+ React.createElement(
68
+ 'button',
69
+ {
70
+ type: 'button',
71
+ className: 'dts-btn',
72
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
73
+ onClick: () => this.setState({ hasError: false, error: null }),
74
+ },
75
+ 'Retry',
76
+ ),
77
+ )
78
+ }
79
+ return (this.props && this.props.children) || null
80
+ }
81
+ }
82
+ }
83
+ const ErrorBoundary = createErrorBoundary()
84
+ const SNAPSHOT_READY = Object.freeze({ status: 'ready', value: {} })
85
+ const SNAPSHOT_LOADING = Object.freeze({ status: 'loading', value: {} })
86
+
41
87
  const SET_CSS =
42
- '.dts-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
43
- '.dts-block{display:flex;flex-direction:column;gap:10px}' +
44
- '.dts-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
45
- // Plugin-settings card matches the core card pattern; theme variables only.
88
+ '.dts-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:900px}' +
89
+ '.dts-header{display:flex;flex-direction:column;gap:8px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +
90
+ '.dts-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px}' +
91
+ '.dts-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}' +
92
+ '.dts-block{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px}' +
93
+ '.dts-h{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
94
+ '.dts-sub{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}' +
46
95
  '.dts-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
47
96
  '.dts-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
48
97
  '.dts-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
49
98
  '.dts-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
50
- '.dts-sub{color:var(--dsw-alias-label-secondary);font-size:13px}' +
51
99
  '.dts-chev{margin-left:auto;color:var(--dsw-alias-label-secondary);flex:none;transition:transform .16s}' +
52
100
  '.dts-chevOpen{transform:rotate(180deg)}' +
53
- '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
54
- '.dts-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}' +
55
- '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
56
- '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}' +
57
- '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
58
- // Provider-chain form helpers.
59
- '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}' +
101
+ '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0}' +
102
+ '.dts-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
103
+ '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
104
+ '.dts-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
105
+ '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:10px;padding:14px 0 4px}' +
106
+ '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:500;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);transition:all .15s ease}' +
107
+ '.dts-save:hover:not(:disabled){opacity:0.88}' +
108
+ '.dts-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
109
+ '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
110
+ '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
111
+ '.dts-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}' +
112
+ '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}' +
60
113
  '.dts-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
61
114
  '.dts-row .dts-model{flex:1;min-width:120px}' +
62
115
  '.dts-row .dts-key{flex:1;min-width:180px}' +
63
- '.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none}' +
64
- '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
65
- '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
66
- '.dts-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}' +
67
- '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
68
- '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}'
116
+ '.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s ease}' +
117
+ '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2))}' +
118
+ '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
119
+ '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
120
+ '.dts-badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
121
+ '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
122
+ '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
123
+ '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
124
+ '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
125
+ '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
126
+ '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
127
+ '.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)}' +
128
+ '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
129
+ '.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 rgba(16,185,129,0.2)}' +
130
+ '.dts-grow{flex:1;min-width:100px}'
69
131
 
70
132
  const setCssId = 'dsh-tts/settings.module.css'
71
133
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-tts"]')) {
@@ -75,12 +137,43 @@ window.__ModuleLoader__.load({
75
137
  tag.dataset.pluginCss = setCssId
76
138
  document.head.appendChild(tag)
77
139
  }
78
-
79
140
  // Player.
80
141
  //
81
142
  // Each incoming chunk used to replace the previous one immediately; while reading
82
143
  // For full replies this was invisible; with speak-as-it-goes only the last
83
144
  // chunk would be heard. Now items queue: the next starts after the previous ends.
145
+ const autoplay = { blocked: false, listeners: new Set() }
146
+
147
+ function setAutoplayBlocked(next) {
148
+ if (autoplay.blocked === next) return
149
+ autoplay.blocked = next
150
+ for (const l of [...autoplay.listeners]) {
151
+ try { l() } catch { /* listener isolation */ }
152
+ }
153
+ }
154
+
155
+ function unlockAudio() {
156
+ try {
157
+ const AC = window.AudioContext || window.webkitAudioContext
158
+ if (AC) {
159
+ const ctx = new AC()
160
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {})
161
+ setTimeout(() => { try { ctx.close() } catch { /* closed */ } }, 300)
162
+ }
163
+ } catch { /* no AudioContext */ }
164
+ setAutoplayBlocked(false)
165
+ drainQueue()
166
+ }
167
+
168
+ function useAutoplayGate() {
169
+ const [, force] = React.useReducer((n) => n + 1, 0)
170
+ React.useEffect(() => {
171
+ autoplay.listeners.add(force)
172
+ return () => { autoplay.listeners.delete(force) }
173
+ }, [])
174
+ return autoplay
175
+ }
176
+
84
177
  const player = {
85
178
  audio: null,
86
179
  after: '',
@@ -92,13 +185,15 @@ window.__ModuleLoader__.load({
92
185
  busy: false,
93
186
  paused: false,
94
187
  listeners: new Set(),
188
+ unlockAudio,
189
+ useAutoplayGate,
190
+ get autoplayBlocked() { return autoplay.blocked },
95
191
  }
96
192
  const seenIds = new Set()
97
193
  let currentAudioResolve = null
98
-
99
194
  function playerChanged() {
100
195
  for (const listener of [...player.listeners]) {
101
- try { listener() } catch (listenerFailure) { /* чужой слушатель не наша забота */ }
196
+ try { listener() } catch (listenerFailure) { /* foreign listener failure is not ours */ }
102
197
  }
103
198
  }
104
199
 
@@ -133,8 +228,8 @@ window.__ModuleLoader__.load({
133
228
  osc.stop(at + 0.2)
134
229
  at += 0.22
135
230
  }
136
- setTimeout(() => { try { audioCtx.close() } catch (already) { /* закрыт */ } resolve() }, 600)
137
- } catch (noAudio) { resolve() }
231
+ setTimeout(() => { try { audioCtx.close() } catch (already) { /* already closed */ } resolve() }, 600)
232
+ } catch { resolve() /* no AudioContext */ }
138
233
  })
139
234
  }
140
235
 
@@ -149,7 +244,7 @@ window.__ModuleLoader__.load({
149
244
  utterance.onend = () => resolve()
150
245
  utterance.onerror = () => resolve()
151
246
  window.speechSynthesis.speak(utterance)
152
- } catch (noSpeech) { resolve() }
247
+ } catch { resolve() /* no speechSynthesis */ }
153
248
  })
154
249
  }
155
250
 
@@ -164,7 +259,7 @@ window.__ModuleLoader__.load({
164
259
  const audio = new Audio(url)
165
260
  audio.preload = 'auto'
166
261
  item.prepared = { audio, url }
167
- } catch (err) { /* decode error */ }
262
+ } catch { /* decode error — item stays unprepared */ }
168
263
  return item
169
264
  }
170
265
 
@@ -192,7 +287,7 @@ window.__ModuleLoader__.load({
192
287
  if (doneCalled) return
193
288
  doneCalled = true
194
289
  currentAudioResolve = null
195
- try { URL.revokeObjectURL(url) } catch (_) {}
290
+ try { URL.revokeObjectURL(url) } catch { /* URL already revoked */ }
196
291
  item.prepared = null
197
292
  player.audio = null
198
293
  playerChanged()
@@ -201,11 +296,14 @@ window.__ModuleLoader__.load({
201
296
  currentAudioResolve = done
202
297
  audio.onended = done
203
298
  audio.onerror = done
204
- audio.play().catch(() => {
205
- // Autoplay without a user gesture is blocked — not a bug; just no sound.
299
+ audio.play().catch((playErr) => {
300
+ // Autoplay without a user gesture is blocked — surface unlock UX, then stop this chunk.
301
+ if (playErr && (playErr.name === 'NotAllowedError' || playErr.name === 'SecurityError')) {
302
+ setAutoplayBlocked(true)
303
+ }
206
304
  done()
207
305
  })
208
- } catch (cannotDecode) {
306
+ } catch { /* cannot decode — skip chunk */
209
307
  currentAudioResolve = null
210
308
  resolve()
211
309
  }
@@ -234,26 +332,27 @@ window.__ModuleLoader__.load({
234
332
  function stopPlayback() {
235
333
  for (const item of player.queue) {
236
334
  if (item && item.prepared && item.prepared.url) {
237
- try { URL.revokeObjectURL(item.prepared.url) } catch (_) {}
335
+ try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
238
336
  item.prepared = null
239
337
  }
240
338
  }
241
339
  player.queue.length = 0
242
340
  player.paused = false
341
+ if (player.audio) {
342
+ try { player.audio.pause() } catch { /* already paused */ }
343
+ }
243
344
  if (currentAudioResolve) {
244
345
  currentAudioResolve()
245
- } else if (player.audio) {
246
- try { player.audio.pause() } catch (already) { /* стоит */ }
247
- player.audio = null
248
346
  }
249
- try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
347
+ player.audio = null
348
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch { /* no speechSynthesis */ }
250
349
  playerChanged()
251
350
  }
252
351
 
253
352
  function togglePause() {
254
353
  player.paused = !player.paused
255
354
  if (player.audio) {
256
- try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* всё равно */ }
355
+ try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* ignore play/pause failure */ }
257
356
  }
258
357
  if (!player.paused) drainQueue()
259
358
  playerChanged()
@@ -263,7 +362,7 @@ window.__ModuleLoader__.load({
263
362
  if (currentAudioResolve) {
264
363
  currentAudioResolve()
265
364
  } else if (player.audio) {
266
- try { player.audio.pause() } catch (already) { /* стоит */ }
365
+ try { player.audio.pause() } catch (already) { /* already stopped */ }
267
366
  player.audio = null
268
367
  playerChanged()
269
368
  if (player.queue.length) drainQueue()
@@ -317,7 +416,7 @@ window.__ModuleLoader__.load({
317
416
  player.queue.push(item)
318
417
  }
319
418
  if (player.queue.length) drainQueue()
320
- } catch (hostRestarting) { /* хост перезапускается */ }
419
+ } catch (hostRestarting) { /* host restarting */ }
321
420
  }
322
421
 
323
422
  // Recent utterances: last reply texts and favorites (localStorage).
@@ -328,7 +427,7 @@ window.__ModuleLoader__.load({
328
427
  const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null
329
428
  st = JSON.parse(raw) || { items: [], favs: [] }
330
429
  } catch (broken) { st = { items: [], favs: [] } }
331
- const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* приватный режим */ } }
430
+ const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* private mode / quota */ } }
332
431
  return {
333
432
  add(text) {
334
433
  if (!text || text.length < 2) return
@@ -366,77 +465,29 @@ window.__ModuleLoader__.load({
366
465
  return (map && map[provider]) || { configured: false, writable: true, ref: '' }
367
466
  }
368
467
 
369
- function ChainEditor(props) {
370
- // Translator comes from the card: the slot passes props.t only to it.
371
- const t = props.t || ((key) => key)
372
- const rows = Array.isArray(props.value) ? props.value : []
373
- const change = (i, patch) => {
374
- const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
375
- props.onChange(next)
376
- }
377
- const move = (i, delta) => {
378
- const j = i + delta
379
- if (j < 0 || j >= rows.length) return
380
- const next = rows.slice()
381
- const tmp = next[i]; next[i] = next[j]; next[j] = tmp
382
- props.onChange(next)
383
- }
384
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
385
- const add = () => props.onChange(rows.concat([{ provider: 'espeak', model: '', voice: '' }]))
386
- return React.createElement('div', { className: 'dts-block' },
387
- rows.map((row, i) => {
388
- const cloud = !!CLOUD[row.provider]
389
- const cred = credOf(props.credentials, row.provider)
390
- const draft = (props.keyDrafts && props.keyDrafts[row.provider]) || ''
391
- const badge = !cloud ? null
392
- : (!cred.writable ? t('fromEnv') : (cred.configured ? t('configured') : t('notSet')))
393
- return React.createElement('div', { className: 'dts-entry', key: i },
394
- React.createElement('div', { className: 'dts-row' },
395
- React.createElement('select', {
396
- className: 'dts-input',
397
- value: row.provider, disabled: !props.writable,
398
- onChange: (e) => change(i, { provider: e.target.value }),
399
- }, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
400
- React.createElement('input', {
401
- className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
402
- placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
403
- }),
404
- React.createElement('input', {
405
- className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
406
- placeholder: 'voice', onChange: (e) => change(i, { voice: e.target.value }),
407
- }),
408
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
409
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
410
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
411
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
412
- ),
413
- cloud ? React.createElement('div', { className: 'dts-row' },
414
- React.createElement('input', {
415
- className: 'dts-key dts-input', type: 'password', autoComplete: 'off',
416
- value: draft, disabled: !props.writable || !cred.writable,
417
- placeholder: cred.configured ? 'leave blank to keep' : 'paste API key',
418
- onChange: (e) => props.onDraft(row.provider, e.target.value),
419
- onBlur: (e) => props.onCommitKey(row.provider, e.target.value),
420
- }),
421
- React.createElement('span', { className: 'dts-badge' + (cred.configured ? ' dts-badge-on' : '') }, badge),
422
- cred.configured && cred.writable ? React.createElement('button', {
423
- type: 'button', className: 'dts-link', disabled: !props.writable,
424
- onClick: () => props.onClearKey(row.provider),
425
- }, t('clear')) : null,
426
- ) : React.createElement('span', { className: 'dts-sub' }, t('localProvider')),
427
- cloud && cred.ref ? React.createElement('span', { className: 'dts-sub' }, 'Stored as ' + cred.ref) : null,
428
- )
429
- }),
430
- React.createElement('div', { className: 'dts-row' },
431
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
432
- React.createElement('span', { className: 'dts-sub' }, t('chainHint')),
433
- ),
434
- )
435
- }
436
-
437
- // Card strings live in the locale registry so a separate package can translate
438
- // them without touching this plugin. English is the source language and fallback.
439
468
  const en = {
469
+ 'statusOnline': 'Host online',
470
+ 'recent': 'Recent',
471
+ 'recentEmpty': 'No recent speech yet',
472
+ 'retry': 'Retry',
473
+ 'advancedCollapsed': 'Advanced',
474
+ 'confirmClearCache': 'Clear the synthesis cache? This cannot be undone.',
475
+ 'confirmDeleteModel': 'Delete downloaded weights for this engine?',
476
+ 'autoplayBlocked': 'Sound is blocked until you click or tap once',
477
+ 'enableSound': 'Enable sound',
478
+ 'runtimeNotBundled': 'Neural inference runtime is not bundled',
479
+ 'runtimeNotBundledHint': 'Install weights does not enable speech. Use Edge, Piper, or eSpeak for offline TTS.',
480
+ 'testProvider': 'Test provider',
481
+ 'breakerOpen': 'Circuit open',
482
+ 'lastError': 'Last error',
483
+ 'activeProvider': 'Active provider',
484
+ 'queueLen': 'Queue',
485
+ 'sseState': 'SSE',
486
+ 'currentVoice': 'Voice',
487
+
488
+ 'statusOffline': 'Host unreachable',
489
+ 'cacheStats': 'Cache',
490
+
440
491
  'general': 'General',
441
492
  'loading': 'Loading\u2026',
442
493
  'chainHintCard': 'Pick a provider, paste its API key, and Save (or leave the key field). The key is written to the host credentials store immediately. The browser never reads it back.',
@@ -470,6 +521,24 @@ window.__ModuleLoader__.load({
470
521
  'edgeHint': 'Python CLI from the edge-tts package.',
471
522
  'azureRegion': 'Azure region',
472
523
  'azureHint': 'Required for the azure provider, e.g. eastus.',
524
+ 'openaiBaseUrl': 'OpenAI base URL',
525
+ 'openaiBaseUrlHint': 'OpenAI-compatible origin for the openai provider.',
526
+ 'mimoBaseUrl': 'MiMo base URL',
527
+ 'mimoBaseUrlHint': 'MiMo API root; synthesis uses its chat endpoint.',
528
+ 'mimoFormat': 'MiMo format',
529
+ 'mimoFormatHint': 'mp3 or wav.',
530
+ 'minimaxBin': 'MiniMax CLI',
531
+ 'minimaxBinHint': 'Looked up in PATH unless absolute; install and log in separately.',
532
+ 'advancedTitle': 'Advanced',
533
+ 'advancedHint': 'Limits and endpoint overrides. Provider API keys stay in the chain editor; credential slot names (*KeyEnv) are config-only.',
534
+ 'maxChars': 'Max characters',
535
+ 'maxCharsHint': 'Hard cap before long-reply handling; 0 disables.',
536
+ 'sentenceChars': 'Sentence chunk size',
537
+ 'sentenceCharsHint': 'Max characters per streaming sentence piece.',
538
+ 'timeoutMs': 'Provider timeout (ms)',
539
+ 'timeoutMsHint': 'Abort a provider attempt after this many milliseconds.',
540
+ 'maxQueue': 'Max queue length',
541
+ 'maxQueueHint': 'Drop oldest pending pieces beyond this count.',
473
542
  'chainTitle': 'Provider chain',
474
543
  'addProvider': 'Add provider',
475
544
  'localProvider': 'Local / CLI provider — no API key',
@@ -577,6 +646,28 @@ window.__ModuleLoader__.load({
577
646
  'dockStop': 'Stop speech',
578
647
  }
579
648
  const ru = {
649
+ 'statusOnline': 'Хост на связи',
650
+ 'recent': 'Недавние',
651
+ 'recentEmpty': 'Пока нет реплик',
652
+ 'retry': 'Повторить',
653
+ 'advancedCollapsed': 'Дополнительно',
654
+ 'confirmClearCache': 'Очистить кэш синтеза? Действие необратимо.',
655
+ 'confirmDeleteModel': 'Удалить скачанные веса движка?',
656
+ 'autoplayBlocked': 'Звук заблокирован — один раз кликните или коснитесь экрана',
657
+ 'enableSound': 'Включить звук',
658
+ 'runtimeNotBundled': 'Рантайм нейросинтеза не входит в пакет',
659
+ 'runtimeNotBundledHint': 'Установка весов не включает речь. Для offline используйте Edge, Piper или eSpeak.',
660
+ 'testProvider': 'Проверить провайдера',
661
+ 'breakerOpen': 'Переключатель открыт',
662
+ 'lastError': 'Последняя ошибка',
663
+ 'activeProvider': 'Активный провайдер',
664
+ 'queueLen': 'Очередь',
665
+ 'sseState': 'SSE',
666
+ 'currentVoice': 'Голос',
667
+
668
+ 'statusOffline': 'Хост недоступен',
669
+ 'cacheStats': 'Кэш',
670
+
580
671
  'dockLabel': 'Чтение',
581
672
  'dockPause': 'Пауза',
582
673
  'dockResume': 'Продолжить чтение',
@@ -615,6 +706,24 @@ window.__ModuleLoader__.load({
615
706
  'edgeHint': 'Консольная утилита из пакета edge-tts.',
616
707
  'azureRegion': 'Azure: регион',
617
708
  'azureHint': 'Нужен провайдеру azure, например eastus.',
709
+ 'openaiBaseUrl': 'OpenAI base URL',
710
+ 'openaiBaseUrlHint': 'OpenAI-совместимый origin для провайдера openai.',
711
+ 'mimoBaseUrl': 'MiMo base URL',
712
+ 'mimoBaseUrlHint': 'Корень API MiMo; синтез идёт через chat endpoint.',
713
+ 'mimoFormat': 'Формат MiMo',
714
+ 'mimoFormatHint': 'mp3 или wav.',
715
+ 'minimaxBin': 'MiniMax CLI',
716
+ 'minimaxBinHint': 'Ищется в PATH, если путь не абсолютный; вход в CLI — отдельно.',
717
+ 'advancedTitle': 'Дополнительно',
718
+ 'advancedHint': 'Лимиты и override endpoint. API-ключи — в редакторе цепочки; имена credential-слотов (*KeyEnv) — только в конфиге.',
719
+ 'maxChars': 'Максимум символов',
720
+ 'maxCharsHint': 'Жёсткий лимит до обработки длинных ответов; 0 — без лимита.',
721
+ 'sentenceChars': 'Размер куска (символы)',
722
+ 'sentenceCharsHint': 'Максимум символов в streaming-куске предложения.',
723
+ 'timeoutMs': 'Таймаут провайдера (мс)',
724
+ 'timeoutMsHint': 'Прервать попытку провайдера через столько миллисекунд.',
725
+ 'maxQueue': 'Длина очереди',
726
+ 'maxQueueHint': 'Отбрасывать самые старые куски сверх этого числа.',
618
727
  'chainTitle': 'Цепочка провайдеров',
619
728
  'addProvider': 'Добавить провайдера',
620
729
  'localProvider': 'Локальный провайдер — ключ не нужен',
@@ -717,136 +826,467 @@ window.__ModuleLoader__.load({
717
826
  'messengerTtsEnabledHint': 'Разрешить мосту мессенджеров озвучивать ответы голосовыми заметками.',
718
827
  }
719
828
 
829
+ function ChainEditor(props) {
830
+ // Translator comes from the card: the slot passes props.t only to it.
831
+ const t = props.t || ((key) => key)
832
+ const rows = Array.isArray(props.value) ? props.value : []
833
+ const change = (i, patch) => {
834
+ const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
835
+ props.onChange(next)
836
+ }
837
+ const move = (i, delta) => {
838
+ const j = i + delta
839
+ if (j < 0 || j >= rows.length) return
840
+ const next = rows.slice()
841
+ const tmp = next[i]; next[i] = next[j]; next[j] = tmp
842
+ props.onChange(next)
843
+ }
844
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
845
+ const add = () => props.onChange(rows.concat([{ provider: 'espeak', model: '', voice: '' }]))
846
+ return React.createElement('div', { className: 'dts-block' },
847
+ rows.map((row, i) => {
848
+ const cloud = !!CLOUD[row.provider]
849
+ const cred = credOf(props.credentials, row.provider)
850
+ const draft = (props.keyDrafts && props.keyDrafts[row.provider]) || ''
851
+ const badge = !cloud ? null
852
+ : (!cred.writable ? t('fromEnv') : (cred.configured ? t('configured') : t('notSet')))
853
+ return React.createElement('div', { className: 'dts-entry', key: i },
854
+ React.createElement('div', { className: 'dts-row' },
855
+ React.createElement('select', {
856
+ className: 'dts-input',
857
+ value: row.provider, disabled: !props.writable,
858
+ onChange: (e) => change(i, { provider: e.target.value }),
859
+ }, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
860
+ React.createElement('input', {
861
+ className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
862
+ placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
863
+ }),
864
+ React.createElement('input', {
865
+ className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
866
+ placeholder: 'voice', onChange: (e) => change(i, { voice: e.target.value }),
867
+ }),
868
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
869
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
870
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
871
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
872
+ ),
873
+ cloud ? React.createElement('div', { className: 'dts-row' },
874
+ React.createElement('input', {
875
+ className: 'dts-key dts-input', type: 'password', autoComplete: 'off',
876
+ value: draft, disabled: !props.writable || !cred.writable,
877
+ placeholder: cred.configured ? 'leave blank to keep' : 'paste API key',
878
+ onChange: (e) => props.onDraft(row.provider, e.target.value),
879
+ onBlur: (e) => props.onCommitKey(row.provider, e.target.value),
880
+ }),
881
+ React.createElement('span', { className: 'dts-badge' + (cred.configured ? ' dts-badge-on' : '') }, badge),
882
+ cred.configured && cred.writable ? React.createElement('button', {
883
+ type: 'button', className: 'dts-link', disabled: !props.writable,
884
+ onClick: () => props.onClearKey(row.provider),
885
+ }, t('clear')) : null,
886
+ ) : React.createElement('span', { className: 'dts-sub' }, t('localProvider')),
887
+ cloud && cred.ref ? React.createElement('span', { className: 'dts-sub' }, 'Stored as ' + cred.ref) : null,
888
+ (() => {
889
+ const hit = Array.isArray(props.breaker) ? props.breaker.find((b) => b && b.id === row.provider) : null
890
+ if (!hit || (!hit.open && !hit.lastError)) return null
891
+ return React.createElement('div', { className: 'dts-sub' },
892
+ (hit.open ? t('breakerOpen') + ' · ' : '') + (hit.lastError || '')
893
+ )
894
+ })(),
895
+ )
896
+ }),
897
+ React.createElement('div', { className: 'dts-row' },
898
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
899
+ React.createElement('span', { className: 'dts-sub' }, t('chainHint')),
900
+ ),
901
+ )
902
+ }
903
+
904
+ // Card strings live in the locale registry so a separate package can translate
905
+ // them without touching this plugin. English is the source language and fallback.
906
+
720
907
  function LocalEnginesEditor(props) {
721
908
  const t = props.t || ((key) => key)
722
909
  const [models, setModels] = React.useState({})
723
- const [loading, setLoading] = React.useState(false)
724
910
 
725
911
  const loadStatus = React.useCallback(() => {
726
912
  if (typeof fetch === 'undefined') return
727
913
  fetch('/dsh-tts/models/status')
728
914
  .then((r) => r.json())
729
915
  .then((d) => { if (d && d.ok && d.models) setModels(d.models) })
730
- .catch(() => {})
916
+ .catch(() => {}) // status poll best-effort
731
917
  }, [])
732
918
 
733
919
  React.useEffect(() => {
734
920
  loadStatus()
735
- const timer = setInterval(loadStatus, 3000)
921
+ const timer = setInterval(loadStatus, 5000)
736
922
  return () => clearInterval(timer)
737
923
  }, [loadStatus])
738
924
 
739
- const install = (engine) => {
740
- setLoading(true)
741
- fetch('/dsh-tts/models/install', {
742
- method: 'POST',
743
- headers: { 'content-type': 'application/json' },
744
- body: JSON.stringify({ engine }),
745
- }).then(() => loadStatus()).finally(() => setLoading(false))
746
- }
747
-
748
925
  const remove = (engine) => {
749
- setLoading(true)
926
+ if (typeof window !== 'undefined' && window.confirm) {
927
+ if (!window.confirm(t('confirmDeleteModel'))) return
928
+ }
750
929
  fetch('/dsh-tts/models/delete', {
751
930
  method: 'DELETE',
752
931
  headers: { 'content-type': 'application/json' },
753
932
  body: JSON.stringify({ engine }),
754
- }).then(() => loadStatus()).finally(() => setLoading(false))
933
+ }).then(() => loadStatus()).catch(() => {})
755
934
  }
756
935
 
757
936
  const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
758
937
  const f5 = models.f5 || { installed: false, downloading: false, progress: 0 }
759
938
 
939
+ const engineRow = (label, state) => React.createElement('div', { className: 'dts-entry' },
940
+ React.createElement('div', { className: 'dts-row' },
941
+ React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, label),
942
+ React.createElement('span', {
943
+ className: 'dts-badge dts-badge-warn',
944
+ }, t('runtimeNotBundled')),
945
+ state.installed ? React.createElement('button', {
946
+ type: 'button', className: 'dts-link', disabled: !props.writable,
947
+ onClick: () => remove(label.toLowerCase().includes('kokoro') ? 'kokoro' : 'f5'),
948
+ }, t('deleteModel')) : null,
949
+ ),
950
+ React.createElement('div', { className: 'dts-sub' }, t('runtimeNotBundledHint')),
951
+ )
952
+
760
953
  return React.createElement('div', { className: 'dts-block' },
761
954
  React.createElement('div', { className: 'dts-h' }, t('localEnginesTitle')),
762
955
  React.createElement('div', { className: 'dts-sub' }, t('localEnginesHint')),
763
- props.boolField('enableLocalEngines', t('enableLocalEngines'), t('enableLocalEnginesHint')),
956
+ React.createElement('div', { className: 'dts-alert-warn dts-sub' }, t('runtimeNotBundledHint')),
764
957
  props.boolField('streamingEnabled', t('streamingEnabled'), t('streamingEnabledHint')),
765
- React.createElement('div', { className: 'dts-entry' },
766
- React.createElement('div', { className: 'dts-row' },
767
- React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'Kokoro-82M (CPU)'),
768
- React.createElement('span', {
769
- className: 'dts-badge' + (kokoro.installed ? ' dts-badge-on' : ''),
770
- }, kokoro.downloading ? `${t('downloading')} ${kokoro.progress}%` : (kokoro.installed ? t('installed') : t('notInstalled'))),
771
- !kokoro.installed && !kokoro.downloading ? React.createElement('button', {
772
- type: 'button', className: 'dts-save', disabled: !props.writable || loading,
773
- onClick: () => install('kokoro'),
774
- }, t('installKokoro')) : null,
775
- kokoro.installed ? React.createElement('button', {
776
- type: 'button', className: 'dts-link', disabled: !props.writable || loading,
777
- onClick: () => remove('kokoro'),
778
- }, t('deleteModel')) : null,
779
- ),
780
- kokoro.installed ? props.boolField('kokoroEnabled', t('useKokoro'), t('useKokoroHint')) : null,
781
- ),
782
- React.createElement('div', { className: 'dts-entry' },
783
- React.createElement('div', { className: 'dts-row' },
784
- React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'F5-TTS (GPU)'),
785
- React.createElement('span', {
786
- className: 'dts-badge' + (f5.installed ? ' dts-badge-on' : ''),
787
- }, f5.downloading ? `${t('downloading')} ${f5.progress}%` : (f5.installed ? t('installed') : t('notInstalled'))),
788
- !f5.installed && !f5.downloading ? React.createElement('button', {
789
- type: 'button', className: 'dts-save', disabled: !props.writable || loading,
790
- onClick: () => install('f5'),
791
- }, t('installF5')) : null,
792
- f5.installed ? React.createElement('button', {
793
- type: 'button', className: 'dts-link', disabled: !props.writable || loading,
794
- onClick: () => remove('f5'),
795
- }, t('deleteModel')) : null,
796
- ),
797
- f5.installed ? props.boolField('f5Enabled', t('useF5'), t('useF5Hint')) : null,
798
- ),
958
+ engineRow('Kokoro-82M (CPU)', kokoro),
959
+ engineRow('F5-TTS (GPU)', f5),
799
960
  )
800
961
  }
801
962
 
802
- function TtsSection(props) {
803
- // Translator comes from the slot because its registration sets locale.
804
- const t = (props && props.t) || ((key) => key)
805
- const [draft, setDraft] = React.useState(null)
806
- const [credentials, setCredentials] = React.useState({})
807
- const [keyDrafts, setKeyDrafts] = React.useState({})
808
- const [saved, setSaved] = React.useState(false)
809
- const [err, setErr] = React.useState('')
810
- const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
811
- const ctx = props && props.ctx
812
- const scope = (ctx && ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope))
813
- ? ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
814
- : null
815
- const [snap, setSnap] = React.useState(null)
816
-
817
- React.useEffect(() => {
818
- if (!scope) return undefined
819
- let alive = true
820
- const render = () => { if (alive && scope.getSnapshot) setSnap(scope.getSnapshot()) }
821
- render()
822
- if (scope.subscribe) {
823
- const off = scope.subscribe(render)
824
- return () => { alive = false; if (typeof off === 'function') off() }
825
- }
826
- return () => { alive = false }
827
- }, [scope])
828
-
829
- // Snapshot status matters more than the value:
830
- // loading — host has not answered yet;
831
- // unavailable — host answered but the settings namespace is not ready;
832
- // ready — values are present.
833
- // On unavailable, writable defaults true; without a status check the card
834
- // looks like a working empty form.
835
- const ready = !scope || (!!snap && snap.status === 'ready')
836
- const writable = !scope ? true : (ready && snap.writable !== false)
963
+ const CLIENT_IT_TERMS = [
964
+ { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
965
+ { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
966
+ { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
967
+ { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
968
+ { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
969
+ { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
970
+ { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
971
+ { from: 'API', to: 'апи', whole: true, lang: 'ru' },
972
+ { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
973
+ { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
974
+ { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
975
+ { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
976
+ { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
977
+ { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
978
+ { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
979
+ { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
980
+ { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
981
+ { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
982
+ { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
983
+ { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
984
+ { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
985
+ { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
986
+ { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
987
+ { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
988
+ { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
989
+ ]
837
990
 
838
- const applyPayload = (data) => {
839
- const cfg = data && data.config ? data.config : {}
840
- setDraft(JSON.parse(JSON.stringify(cfg)))
841
- setCredentials(data && data.credentials ? data.credentials : {})
842
- player.enabled = !!cfg.speakReplies
991
+ // Pronunciation dictionary editor: rules apply top-down.
992
+ function PronEditor(props) {
993
+ const t = props.t || ((key) => key)
994
+ const rows = Array.isArray(props.value) ? props.value : []
995
+ const change = (i, patch) => {
996
+ props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
843
997
  }
844
-
845
- React.useEffect(() => {
846
- let alive = true
847
- fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
848
- if (!alive) return
849
- applyPayload(data)
998
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
999
+ const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
1000
+ const loadIt = () => {
1001
+ const existing = new Set(rows.map((r) => String(r.from).toLowerCase()))
1002
+ const toAdd = CLIENT_IT_TERMS.filter((r) => !existing.has(String(r.from).toLowerCase()))
1003
+ props.onChange(rows.concat(toAdd))
1004
+ }
1005
+
1006
+ return React.createElement('div', { className: 'dts-wrap-in' },
1007
+ rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
1008
+ React.createElement('input', {
1009
+ className: 'dts-input dts-grow', placeholder: t('pronFrom'),
1010
+ value: rule.from || '', disabled: !props.writable,
1011
+ onChange: (e) => change(i, { from: e.target.value }),
1012
+ }),
1013
+ React.createElement('input', {
1014
+ className: 'dts-input dts-grow', placeholder: t('pronTo'),
1015
+ value: rule.to || '', disabled: !props.writable,
1016
+ onChange: (e) => change(i, { to: e.target.value }),
1017
+ }),
1018
+ React.createElement('label', { className: 'dts-sub', title: t('pronWhole') },
1019
+ React.createElement('input', {
1020
+ type: 'checkbox', checked: !!rule.whole, disabled: !props.writable,
1021
+ onChange: (e) => change(i, { whole: e.target.checked }),
1022
+ }),
1023
+ ),
1024
+ React.createElement('input', {
1025
+ className: 'dts-input', style: { maxWidth: '70px' }, placeholder: t('pronLang'),
1026
+ value: rule.lang || '', disabled: !props.writable,
1027
+ onChange: (e) => change(i, { lang: e.target.value }),
1028
+ }),
1029
+ React.createElement('button', {
1030
+ type: 'button', className: 'dts-mini', title: t('previewRule'), disabled: !props.writable,
1031
+ onClick: () => {
1032
+ if (props.onPreviewPhrase) props.onPreviewPhrase(rule.to || rule.from)
1033
+ },
1034
+ }, '\u25b6'),
1035
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
1036
+ )),
1037
+ React.createElement('div', { className: 'dts-row' },
1038
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
1039
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
1040
+ React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
1041
+ ),
1042
+ )
1043
+ }
1044
+
1045
+ function RolesEditor(props) {
1046
+ const t = props.t || ((key) => key)
1047
+ const value = props.value || {}
1048
+ const [newRole, setNewRole] = React.useState('')
1049
+ const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r] || {}, patch) }))
1050
+ const removeRole = (r) => {
1051
+ const next = Object.assign({}, value)
1052
+ delete next[r]
1053
+ props.onChange(next)
1054
+ }
1055
+
1056
+ const standardKeys = ['reply', 'approval', 'error']
1057
+ const customKeys = Object.keys(value).filter((k) => !standardKeys.includes(k))
1058
+ const allKeys = [...standardKeys, ...customKeys]
1059
+
1060
+ const addCustom = () => {
1061
+ const name = newRole.trim().toLowerCase()
1062
+ if (!name || value[name]) return
1063
+ set(name, { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' })
1064
+ setNewRole('')
1065
+ }
1066
+
1067
+ return React.createElement('div', { className: 'dts-block' },
1068
+ React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
1069
+ allKeys.map((r) => React.createElement('div', { className: 'dts-row', key: r },
1070
+ React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r) !== ('role_' + r) ? t('role_' + r) : `👤 ${r}`),
1071
+ React.createElement('select', {
1072
+ className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
1073
+ onChange: (e) => set(r, { provider: e.target.value }),
1074
+ },
1075
+ React.createElement('option', { value: '' }, '—'),
1076
+ PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p)),
1077
+ ),
1078
+ React.createElement('input', {
1079
+ className: 'dts-input dts-grow', placeholder: 'model',
1080
+ value: (value[r] && value[r].model) || '', disabled: !props.writable,
1081
+ onChange: (e) => set(r, { model: e.target.value }),
1082
+ }),
1083
+ React.createElement('input', {
1084
+ className: 'dts-input dts-grow', placeholder: 'voice',
1085
+ value: (value[r] && value[r].voice) || '', disabled: !props.writable,
1086
+ onChange: (e) => set(r, { voice: e.target.value }),
1087
+ }),
1088
+ React.createElement('input', {
1089
+ className: 'dts-input', style: { maxWidth: '90px' }, placeholder: t('chime'),
1090
+ value: (value[r] && value[r].chime) || '', disabled: !props.writable,
1091
+ onChange: (e) => set(r, { chime: e.target.value }),
1092
+ }),
1093
+ React.createElement('input', {
1094
+ className: 'dts-input dts-grow', placeholder: 'ssml',
1095
+ value: (value[r] && value[r].ssmlStyle) || '', disabled: !props.writable,
1096
+ onChange: (e) => set(r, { ssmlStyle: e.target.value }),
1097
+ }),
1098
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => props.onPreview((value[r] && value[r].provider) || '', (value[r] && value[r].model) || '', (value[r] && value[r].voice) || '') }, '\u25b6'),
1099
+ !standardKeys.includes(r) ? React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => removeRole(r) }, '\u00d7') : null,
1100
+ )),
1101
+ React.createElement('div', { className: 'dts-row' },
1102
+ React.createElement('input', {
1103
+ className: 'dts-input', style: { maxWidth: '180px' }, placeholder: t('newSubagentRole'),
1104
+ value: newRole, disabled: !props.writable,
1105
+ onChange: (e) => setNewRole(e.target.value),
1106
+ onKeyDown: (e) => { if (e.key === 'Enter') addCustom() },
1107
+ }),
1108
+ React.createElement('button', { type: 'button', className: 'dts-save', disabled: !props.writable || !newRole.trim(), onClick: addCustom }, t('addSubagentRole')),
1109
+ ),
1110
+ )
1111
+ }
1112
+
1113
+ function VoiceDuplexEditor(props) {
1114
+ const t = props.t || ((key) => key)
1115
+ const installed = !!props.installed
1116
+
1117
+ return React.createElement('div', { className: 'dts-block' },
1118
+ React.createElement('div', { className: 'dts-h' }, t('voiceDuplexTitle')),
1119
+ React.createElement('div', { className: 'dts-sub' }, t('voiceDuplexHint')),
1120
+ !installed ? React.createElement('div', {
1121
+ style: {
1122
+ padding: '10px 14px',
1123
+ borderRadius: '8px',
1124
+ border: '1px solid var(--dsw-alias-state-warning-primary)',
1125
+ background: 'var(--dsw-alias-bg-layer-2)',
1126
+ display: 'flex',
1127
+ flexDirection: 'column',
1128
+ gap: '4px',
1129
+ fontSize: '13px',
1130
+ },
1131
+ },
1132
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1133
+ '⚠️ ' + t('voiceNotInstalledTitle')
1134
+ ),
1135
+ React.createElement('span', { className: 'dts-sub' },
1136
+ t('voiceInstallHint') + ': '
1137
+ ),
1138
+ React.createElement('code', {
1139
+ style: {
1140
+ fontFamily: 'monospace',
1141
+ fontSize: '12px',
1142
+ padding: '2px 6px',
1143
+ borderRadius: '4px',
1144
+ background: 'var(--dsw-alias-bg-layer-3)',
1145
+ color: 'var(--dsw-alias-label-primary)',
1146
+ width: 'fit-content',
1147
+ },
1148
+ }, 'dsh plugin --profile web add @goodandready/dsh-voice')
1149
+ ) : null,
1150
+ props.boolField('voiceDuplexEnabled', t('voiceDuplex'), t('voiceDuplexHintToggle'), !installed),
1151
+ props.boolField('vadBargeIn', t('vadBargeIn'), t('vadBargeInHint'), !installed),
1152
+ )
1153
+ }
1154
+
1155
+ function MessengerIntegrationEditor(props) {
1156
+ const t = props.t || ((key) => key)
1157
+ const installed = !!props.installed
1158
+
1159
+ return React.createElement('div', { className: 'dts-block' },
1160
+ React.createElement('div', { className: 'dts-h' }, t('messengerIntegrationTitle')),
1161
+ React.createElement('div', { className: 'dts-sub' }, t('messengerIntegrationHint')),
1162
+ !installed ? React.createElement('div', {
1163
+ style: {
1164
+ padding: '10px 14px',
1165
+ borderRadius: '8px',
1166
+ border: '1px solid var(--dsw-alias-state-warning-primary)',
1167
+ background: 'var(--dsw-alias-bg-layer-2)',
1168
+ display: 'flex',
1169
+ flexDirection: 'column',
1170
+ gap: '4px',
1171
+ fontSize: '13px',
1172
+ },
1173
+ },
1174
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1175
+ '⚠️ ' + t('messengerNotInstalledTitle')
1176
+ ),
1177
+ React.createElement('span', { className: 'dts-sub' },
1178
+ t('messengerInstallHint') + ': '
1179
+ ),
1180
+ React.createElement('code', {
1181
+ style: {
1182
+ fontFamily: 'monospace',
1183
+ fontSize: '12px',
1184
+ padding: '2px 6px',
1185
+ borderRadius: '4px',
1186
+ background: 'var(--dsw-alias-bg-layer-3)',
1187
+ color: 'var(--dsw-alias-label-primary)',
1188
+ width: 'fit-content',
1189
+ },
1190
+ }, 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway')
1191
+ ) : null,
1192
+ props.boolField('messengerTtsEnabled', t('messengerTtsEnabled'), t('messengerTtsEnabledHint'), !installed),
1193
+ )
1194
+ }
1195
+
1196
+ // Synthesis stats panel: live host counters.
1197
+ function StatsPanel(props) {
1198
+ const t = props.t || ((key) => key)
1199
+ const [data, setData] = React.useState(null)
1200
+ const loadStats = () => {
1201
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1202
+ }
1203
+ React.useEffect(() => { loadStats() }, [])
1204
+ const reset = () => {
1205
+ fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1206
+ }
1207
+ return React.createElement('div', { className: 'dts-block' },
1208
+ React.createElement('span', { className: 'dts-sub' }, data
1209
+ ? t('statTotal') + ': ' + data.total + ' · ' + t('statHits') + ': ' + data.cacheHits + ' · ' + t('statErrors') + ': ' + data.errors
1210
+ : t('loading')),
1211
+ data && Object.keys(data.providers || {}).map((p) => React.createElement('span', { className: 'dts-sub', key: p },
1212
+ p + ': ' + data.providers[p].n + ' / ' + data.providers[p].ms + 'ms')),
1213
+ React.createElement('button', { type: 'button', className: 'dts-link', onClick: reset }, t('statReset')),
1214
+ )
1215
+ }
1216
+
1217
+ // Plugin-settings tab card follows the shared card pattern: li in the core list,
1218
+ // theme-reset header, body with a divider. Chevron is ours; core does not provide it.
1219
+ function TtsSection(props) {
1220
+ // Translator comes from the slot because its registration sets locale.
1221
+ const t = (props && props.t) || ((key) => key)
1222
+ const [draft, setDraft] = React.useState(null)
1223
+ const [credentials, setCredentials] = React.useState({})
1224
+ const [keyDrafts, setKeyDrafts] = React.useState({})
1225
+ const [saved, setSaved] = React.useState(false)
1226
+ const [err, setErr] = React.useState('')
1227
+ const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
1228
+ const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, sseClients: 0, breaker: [], provider: '', queue: 0 })
1229
+ const [advancedOpen, setAdvancedOpen] = React.useState(() => {
1230
+ try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { return false }
1231
+ })
1232
+ const autoplay = useAutoplayGate()
1233
+
1234
+ const ctx = props && props.ctx
1235
+ const scope = React.useMemo(() => {
1236
+ const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
1237
+ if (!s || !s.bind) return null
1238
+ try {
1239
+ return s.bind({ namespace: NS })
1240
+ } catch (_) {
1241
+ return null
1242
+ }
1243
+ }, [ctx])
1244
+
1245
+ const subscribe = React.useMemo(() => {
1246
+ return (cb) => {
1247
+ if (!scope || !scope.subscribe) return () => {}
1248
+ try {
1249
+ return scope.subscribe(cb) || (() => {})
1250
+ } catch (_) {
1251
+ return () => {}
1252
+ }
1253
+ }
1254
+ }, [scope])
1255
+
1256
+ const getSnapshot = React.useCallback(() => {
1257
+ if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
1258
+ try {
1259
+ return scope.getSnapshot() || SNAPSHOT_LOADING
1260
+ } catch (_) {
1261
+ return SNAPSHOT_LOADING
1262
+ }
1263
+ }, [scope])
1264
+
1265
+ const snap = (React.useSyncExternalStore
1266
+ ? React.useSyncExternalStore(subscribe, getSnapshot, React.useCallback(() => SNAPSHOT_LOADING, []))
1267
+ : null) || (scope && scope.getSnapshot ? scope.getSnapshot() : SNAPSHOT_LOADING)
1268
+
1269
+ // Snapshot status matters more than the value:
1270
+ // loading — host has not answered yet;
1271
+ // unavailable — host answered but the settings namespace is not ready;
1272
+ // ready — values are present.
1273
+ // On unavailable, writable defaults true; without a status check the card
1274
+ // looks like a working empty form.
1275
+ const ready = !scope || (!!snap && snap.status === 'ready')
1276
+ const writable = !scope ? true : (ready && snap.writable !== false)
1277
+
1278
+ const applyPayload = (data) => {
1279
+ const cfg = data && data.config ? data.config : {}
1280
+ setDraft(JSON.parse(JSON.stringify(cfg)))
1281
+ setCredentials(data && data.credentials ? data.credentials : {})
1282
+ player.enabled = !!cfg.speakReplies
1283
+ }
1284
+
1285
+ React.useEffect(() => {
1286
+ let alive = true
1287
+ fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1288
+ if (!alive) return
1289
+ applyPayload(data)
850
1290
  }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
851
1291
 
852
1292
  fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
@@ -854,7 +1294,33 @@ window.__ModuleLoader__.load({
854
1294
  if (data && data.ok) setIntegrations(data)
855
1295
  }).catch(() => {})
856
1296
 
857
- return () => { alive = false }
1297
+ const loadTelemetry = () => {
1298
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1299
+ if (!alive || !st) return
1300
+ setTelemetry((prev) => Object.assign({}, prev, {
1301
+ cacheHits: st.cacheHits || 0,
1302
+ total: st.total || 0,
1303
+ online: true,
1304
+ breaker: st.breaker || prev.breaker || [],
1305
+ }))
1306
+ }).catch(() => {
1307
+ if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
1308
+ })
1309
+ fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1310
+ if (!alive || !st) return
1311
+ setTelemetry((prev) => Object.assign({}, prev, {
1312
+ sseClients: st.sseClients || 0,
1313
+ sseOk: (st.sseClients || 0) >= 0,
1314
+ provider: (player.audio && player.provider) || (player.queue[0] && player.queue[0].provider) || '',
1315
+ queue: player.queue.length,
1316
+ }))
1317
+ }).catch(() => {})
1318
+ }
1319
+ loadTelemetry()
1320
+ const telTimer = setInterval(loadTelemetry, 3000)
1321
+ // interval cleaned by effect return below
1322
+
1323
+ return () => { alive = false; clearInterval(telTimer) }
858
1324
  }, [])
859
1325
 
860
1326
  if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
@@ -925,6 +1391,9 @@ window.__ModuleLoader__.load({
925
1391
  setSaved(true); setTimeout(() => setSaved(false), 2000)
926
1392
  }
927
1393
  const onClearCache = () => {
1394
+ if (typeof window !== 'undefined' && window.confirm) {
1395
+ if (!window.confirm(t('confirmClearCache'))) return
1396
+ }
928
1397
  clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
929
1398
  }
930
1399
 
@@ -965,390 +1434,186 @@ window.__ModuleLoader__.load({
965
1434
 
966
1435
  const textField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
967
1436
  React.createElement('input', {
968
- className: 'dts-input',
969
- value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
970
- onChange: (e) => setTop(key, e.target.value),
971
- }),
972
- React.createElement('span', { className: 'dts-sub' }, hint))
973
-
974
- // The card header draws the title — do not duplicate a second title block.
975
- const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-h' }, t('title'))
976
- return React.createElement('div', { className: 'dts-wrap' },
977
- React.createElement('div', { className: 'dts-block' },
978
- titleLine,
979
- React.createElement('label', { className: 'dts-row' },
980
- React.createElement('input', {
981
- type: 'checkbox', checked: !!(draft && draft.speakReplies), disabled: !writable,
982
- onChange: (e) => setTop('speakReplies', e.target.checked),
983
- }),
984
- React.createElement('span', null, t('speakReplies')),
985
- ),
986
- React.createElement('div', { className: 'dts-sub' },
987
- t('speakRepliesHint')),
988
- ),
989
- React.createElement(LocalEnginesEditor, {
990
- t: t, writable: writable, draft: draft,
991
- boolField: boolField, setTop: setTop,
992
- }),
993
- React.createElement(VoiceDuplexEditor, {
994
- t: t, writable: writable, draft: draft,
995
- boolField: boolField, setTop: setTop,
996
- installed: !!(integrations && integrations.voice && integrations.voice.installed),
997
- }),
998
- React.createElement(MessengerIntegrationEditor, {
999
- t: t, writable: writable, draft: draft,
1000
- boolField: boolField, setTop: setTop,
1001
- installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
1002
- }),
1003
- React.createElement('div', { className: 'dts-block' },
1004
- React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
1005
- boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
1006
- React.createElement(PronEditor, {
1007
- t: t, writable: writable,
1008
- value: draft && draft.pronunciation ? draft.pronunciation : [],
1009
- onChange: (v) => setTop('pronunciation', v),
1010
- onPreviewPhrase: onPreviewPhrase,
1011
- }),
1012
- ),
1013
- React.createElement('div', { className: 'dts-block' },
1014
- React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
1015
- React.createElement(RolesEditor, {
1016
- t: t, writable: writable, onPreview: onPreview,
1017
- value: draft && draft.roles ? draft.roles : {},
1018
- onChange: (v) => setTop('roles', v),
1019
- }),
1020
- ),
1021
- React.createElement('div', { className: 'dts-block' },
1022
- React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
1023
- React.createElement(StatsPanel, { t: t }),
1024
- ),
1025
- React.createElement('div', { className: 'dts-block' },
1026
- React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
1027
- React.createElement('div', { className: 'dts-sub' },
1028
- t('chainHintCard')),
1029
- React.createElement(ChainEditor, {
1030
- t: t,
1031
- value: draft && draft.chain ? draft.chain : [], writable: writable,
1032
- credentials: credentials, keyDrafts: keyDrafts,
1033
- onChange: (v) => setTop('chain', v),
1034
- onDraft: setDraftKey,
1035
- onCommitKey: onCommitKey,
1036
- onClearKey: clearKey,
1037
- onPreview: onPreview,
1038
- }),
1039
- ),
1040
- React.createElement('div', { className: 'dts-block' },
1041
- React.createElement('div', { className: 'dts-h' }, t('general')),
1042
- boolField('speakAsItGoes', t('speakAsItGoes'),
1043
- t('speakAsItGoesHint')),
1044
- numberField('rate', t('rate'), t('rateHint'), 0.1),
1045
- boolField('bargeIn', t('bargeIn'),
1046
- t('bargeInHint')),
1047
- boolField('announceApproval', t('announce'),
1048
- t('announceHint')),
1049
- boolField('skipCode', t('skipCode'),
1050
- t('skipCodeHint')),
1051
- boolField('cache', t('cache'),
1052
- t('cacheHint')),
1053
- numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
1054
- React.createElement('label', { className: 'dts-field' }, t('longReply'),
1055
- React.createElement('select', {
1056
- className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1057
- onChange: (e) => setTop('longReply', e.target.value),
1058
- }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1059
- React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1060
- textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1061
- numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
1062
- React.createElement('label', { className: 'dts-field' }, t('longReply'),
1063
- React.createElement('select', {
1064
- className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1065
- onChange: (e) => setTop('longReply', e.target.value),
1066
- }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1067
- React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1068
- textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1069
- numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
1070
- boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
1071
- boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
1072
- boolField('skipActions', t('skipActions'), t('skipActionsHint')),
1073
- textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
1074
- textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
1075
- textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
1076
- textField('approvalText', t('approvalText'), t('approvalHint')),
1077
- textField('questionText', t('questionText'), t('questionHint')),
1078
- textField('chime', t('chime'), t('chimeHint')),
1079
- textField('language', t('language'), t('languageHint')),
1080
- textField('piperModel', t('piperModel'), t('piperModelHint')),
1081
- textField('piperBin', t('piperBin'), t('binHint')),
1082
- textField('espeakBin', t('espeakBin'), t('espeakHint')),
1083
- textField('edgeBin', t('edgeBin'), t('edgeHint')),
1084
- textField('azureRegion', t('azureRegion'), t('azureHint')),
1085
- ),
1086
- React.createElement('div', { className: 'dts-foot' },
1087
- React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
1088
- React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
1089
- saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
1090
- err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
1091
- ),
1092
- )
1093
- }
1094
-
1095
-
1096
- const CLIENT_IT_TERMS = [
1097
- { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
1098
- { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
1099
- { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
1100
- { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
1101
- { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
1102
- { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
1103
- { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
1104
- { from: 'API', to: 'апи', whole: true, lang: 'ru' },
1105
- { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
1106
- { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
1107
- { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
1108
- { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
1109
- { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
1110
- { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
1111
- { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
1112
- { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
1113
- { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
1114
- { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
1115
- { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
1116
- { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
1117
- { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
1118
- { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
1119
- { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
1120
- { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
1121
- { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
1122
- ]
1123
-
1124
- // Pronunciation dictionary editor: rules apply top-down.
1125
- function PronEditor(props) {
1126
- const t = props.t || ((key) => key)
1127
- const rows = Array.isArray(props.value) ? props.value : []
1128
- const change = (i, patch) => {
1129
- props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
1130
- }
1131
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
1132
- const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
1133
- const loadIt = () => {
1134
- const existing = new Set(rows.map((r) => String(r.from).toLowerCase()))
1135
- const toAdd = CLIENT_IT_TERMS.filter((r) => !existing.has(String(r.from).toLowerCase()))
1136
- props.onChange(rows.concat(toAdd))
1137
- }
1138
-
1139
- return React.createElement('div', { className: 'dts-wrap-in' },
1140
- rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
1141
- React.createElement('input', {
1142
- className: 'dts-input dts-grow', placeholder: t('pronFrom'),
1143
- value: rule.from || '', disabled: !props.writable,
1144
- onChange: (e) => change(i, { from: e.target.value }),
1145
- }),
1146
- React.createElement('input', {
1147
- className: 'dts-input dts-grow', placeholder: t('pronTo'),
1148
- value: rule.to || '', disabled: !props.writable,
1149
- onChange: (e) => change(i, { to: e.target.value }),
1150
- }),
1151
- React.createElement('label', { className: 'dts-sub', title: t('pronWhole') },
1152
- React.createElement('input', {
1153
- type: 'checkbox', checked: !!rule.whole, disabled: !props.writable,
1154
- onChange: (e) => change(i, { whole: e.target.checked }),
1155
- }),
1156
- ),
1157
- React.createElement('input', {
1158
- className: 'dts-input', style: { maxWidth: '70px' }, placeholder: t('pronLang'),
1159
- value: rule.lang || '', disabled: !props.writable,
1160
- onChange: (e) => change(i, { lang: e.target.value }),
1161
- }),
1162
- React.createElement('button', {
1163
- type: 'button', className: 'dts-mini', title: t('previewRule'), disabled: !props.writable,
1164
- onClick: () => {
1165
- if (props.onPreviewPhrase) props.onPreviewPhrase(rule.to || rule.from)
1166
- },
1167
- }, '\u25b6'),
1168
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
1169
- )),
1170
- React.createElement('div', { className: 'dts-row' },
1171
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
1172
- React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
1173
- React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
1174
- ),
1175
- )
1176
- }
1177
-
1178
- function RolesEditor(props) {
1179
- const t = props.t || ((key) => key)
1180
- const value = props.value || {}
1181
- const [newRole, setNewRole] = React.useState('')
1182
- const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r] || {}, patch) }))
1183
- const removeRole = (r) => {
1184
- const next = Object.assign({}, value)
1185
- delete next[r]
1186
- props.onChange(next)
1187
- }
1188
-
1189
- const standardKeys = ['reply', 'approval', 'error']
1190
- const customKeys = Object.keys(value).filter((k) => !standardKeys.includes(k))
1191
- const allKeys = [...standardKeys, ...customKeys]
1192
-
1193
- const addCustom = () => {
1194
- const name = newRole.trim().toLowerCase()
1195
- if (!name || value[name]) return
1196
- set(name, { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' })
1197
- setNewRole('')
1198
- }
1199
-
1200
- return React.createElement('div', { className: 'dts-block' },
1201
- React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
1202
- allKeys.map((r) => React.createElement('div', { className: 'dts-row', key: r },
1203
- React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r) !== ('role_' + r) ? t('role_' + r) : `👤 ${r}`),
1204
- React.createElement('select', {
1205
- className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
1206
- onChange: (e) => set(r, { provider: e.target.value }),
1207
- },
1208
- React.createElement('option', { value: '' }, '—'),
1209
- PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p)),
1437
+ className: 'dts-input',
1438
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1439
+ onChange: (e) => setTop(key, e.target.value),
1440
+ }),
1441
+ React.createElement('span', { className: 'dts-sub' }, hint))
1442
+
1443
+ const autoplayBanner = autoplay.blocked
1444
+ ? React.createElement('div', { className: 'dts-unlock' },
1445
+ React.createElement('span', null, t('autoplayBlocked')),
1446
+ React.createElement('button', {
1447
+ type: 'button', className: 'dts-unlock-btn',
1448
+ onClick: () => player.unlockAudio && player.unlockAudio(),
1449
+ }, t('enableSound')),
1450
+ )
1451
+ : null
1452
+ const breakerBadges = (telemetry.breaker || []).filter((b) => b && b.open).slice(0, 2)
1453
+ .map((b) => React.createElement('span', { key: b.id, className: 'dts-badge dts-badge-bad' }, t('breakerOpen') + ': ' + b.id))
1454
+ const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' }, 'data-dsh-tts-telemetry': '1' },
1455
+ React.createElement('span', {
1456
+ className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
1457
+ }, telemetry.online ? t('statusOnline') : t('statusOffline')),
1458
+ React.createElement('span', {
1459
+ className: 'dts-badge ' + (telemetry.sseClients > 0 ? 'dts-badge-on' : ''),
1460
+ }, t('sseState') + ': ' + (telemetry.sseClients || 0)),
1461
+ React.createElement('span', { className: 'dts-badge' },
1462
+ t('queueLen') + ': ' + (telemetry.queue || player.queue.length || 0)),
1463
+ telemetry.provider ? React.createElement('span', { className: 'dts-badge' },
1464
+ t('activeProvider') + ': ' + telemetry.provider) : null,
1465
+ React.createElement('span', { className: 'dts-badge' },
1466
+ t('cacheStats') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
1467
+ ...breakerBadges,
1468
+ )
1469
+ const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
1470
+ React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
1471
+ React.createElement('div', { className: 'dts-page-sub' }, t('subtitle')),
1472
+ telemetryBadges,
1473
+ )
1474
+ return React.createElement('div', { className: 'dts-wrap' },
1475
+ autoplayBanner,
1476
+ React.createElement('div', { className: 'dts-block' },
1477
+ titleLine,
1478
+ React.createElement('label', { className: 'dts-row' },
1479
+ React.createElement('input', {
1480
+ type: 'checkbox', checked: !!(draft && draft.speakReplies), disabled: !writable,
1481
+ onChange: (e) => setTop('speakReplies', e.target.checked),
1482
+ }),
1483
+ React.createElement('span', null, t('speakReplies')),
1210
1484
  ),
1211
- React.createElement('input', {
1212
- className: 'dts-input dts-grow', placeholder: 'model',
1213
- value: (value[r] && value[r].model) || '', disabled: !props.writable,
1214
- onChange: (e) => set(r, { model: e.target.value }),
1215
- }),
1216
- React.createElement('input', {
1217
- className: 'dts-input dts-grow', placeholder: 'voice',
1218
- value: (value[r] && value[r].voice) || '', disabled: !props.writable,
1219
- onChange: (e) => set(r, { voice: e.target.value }),
1220
- }),
1221
- React.createElement('input', {
1222
- className: 'dts-input', style: { maxWidth: '90px' }, placeholder: t('chime'),
1223
- value: (value[r] && value[r].chime) || '', disabled: !props.writable,
1224
- onChange: (e) => set(r, { chime: e.target.value }),
1485
+ React.createElement('div', { className: 'dts-sub' },
1486
+ t('speakRepliesHint')),
1487
+ ),
1488
+ React.createElement(LocalEnginesEditor, {
1489
+ t: t, writable: writable, draft: draft,
1490
+ boolField: boolField, setTop: setTop,
1491
+ }),
1492
+ React.createElement(VoiceDuplexEditor, {
1493
+ t: t, writable: writable, draft: draft,
1494
+ boolField: boolField, setTop: setTop,
1495
+ installed: !!(integrations && integrations.voice && integrations.voice.installed),
1496
+ }),
1497
+ React.createElement(MessengerIntegrationEditor, {
1498
+ t: t, writable: writable, draft: draft,
1499
+ boolField: boolField, setTop: setTop,
1500
+ installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
1501
+ }),
1502
+ React.createElement('div', { className: 'dts-block' },
1503
+ React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
1504
+ boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
1505
+ React.createElement(PronEditor, {
1506
+ t: t, writable: writable,
1507
+ value: draft && draft.pronunciation ? draft.pronunciation : [],
1508
+ onChange: (v) => setTop('pronunciation', v),
1509
+ onPreviewPhrase: onPreviewPhrase,
1225
1510
  }),
1226
- React.createElement('input', {
1227
- className: 'dts-input dts-grow', placeholder: 'ssml',
1228
- value: (value[r] && value[r].ssmlStyle) || '', disabled: !props.writable,
1229
- onChange: (e) => set(r, { ssmlStyle: e.target.value }),
1511
+ ),
1512
+ React.createElement('div', { className: 'dts-block' },
1513
+ React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
1514
+ React.createElement(RolesEditor, {
1515
+ t: t, writable: writable, onPreview: onPreview,
1516
+ value: draft && draft.roles ? draft.roles : {},
1517
+ onChange: (v) => setTop('roles', v),
1230
1518
  }),
1231
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => props.onPreview((value[r] && value[r].provider) || '', (value[r] && value[r].model) || '', (value[r] && value[r].voice) || '') }, '\u25b6'),
1232
- !standardKeys.includes(r) ? React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => removeRole(r) }, '\u00d7') : null,
1233
- )),
1234
- React.createElement('div', { className: 'dts-row' },
1235
- React.createElement('input', {
1236
- className: 'dts-input', style: { maxWidth: '180px' }, placeholder: t('newSubagentRole'),
1237
- value: newRole, disabled: !props.writable,
1238
- onChange: (e) => setNewRole(e.target.value),
1239
- onKeyDown: (e) => { if (e.key === 'Enter') addCustom() },
1519
+ ),
1520
+ React.createElement('div', { className: 'dts-block' },
1521
+ React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
1522
+ React.createElement(StatsPanel, { t: t }),
1523
+ ),
1524
+ React.createElement('div', { className: 'dts-block' },
1525
+ React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
1526
+ React.createElement('div', { className: 'dts-sub' },
1527
+ t('chainHintCard')),
1528
+ React.createElement(ChainEditor, {
1529
+ breaker: telemetry.breaker,
1530
+ t: t,
1531
+ value: draft && draft.chain ? draft.chain : [], writable: writable,
1532
+ credentials: credentials, keyDrafts: keyDrafts,
1533
+ onChange: (v) => setTop('chain', v),
1534
+ onDraft: setDraftKey,
1535
+ onCommitKey: onCommitKey,
1536
+ onClearKey: clearKey,
1537
+ onPreview: onPreview,
1240
1538
  }),
1241
- React.createElement('button', { type: 'button', className: 'dts-save', disabled: !props.writable || !newRole.trim(), onClick: addCustom }, t('addSubagentRole')),
1242
1539
  ),
1243
- )
1244
- }
1245
-
1246
- function VoiceDuplexEditor(props) {
1247
- const t = props.t || ((key) => key)
1248
- const installed = !!props.installed
1249
-
1250
- return React.createElement('div', { className: 'dts-block' },
1251
- React.createElement('div', { className: 'dts-h' }, t('voiceDuplexTitle')),
1252
- React.createElement('div', { className: 'dts-sub' }, t('voiceDuplexHint')),
1253
- !installed ? React.createElement('div', {
1254
- style: {
1255
- padding: '10px 14px',
1256
- borderRadius: '8px',
1257
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1258
- background: 'var(--dsw-alias-bg-layer-2)',
1259
- display: 'flex',
1260
- flexDirection: 'column',
1261
- gap: '4px',
1262
- fontSize: '13px',
1263
- },
1264
- },
1265
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1266
- '⚠️ ' + t('voiceNotInstalledTitle')
1267
- ),
1268
- React.createElement('span', { className: 'dts-sub' },
1269
- t('voiceInstallHint') + ': '
1270
- ),
1271
- React.createElement('code', {
1272
- style: {
1273
- fontFamily: 'monospace',
1274
- fontSize: '12px',
1275
- padding: '2px 6px',
1276
- borderRadius: '4px',
1277
- background: 'var(--dsw-alias-bg-layer-3)',
1278
- color: 'var(--dsw-alias-label-primary)',
1279
- width: 'fit-content',
1540
+ React.createElement('div', { className: 'dts-block' },
1541
+ React.createElement('div', { className: 'dts-h' }, t('general')),
1542
+ boolField('speakAsItGoes', t('speakAsItGoes'),
1543
+ t('speakAsItGoesHint')),
1544
+ numberField('rate', t('rate'), t('rateHint'), 0.1),
1545
+ boolField('bargeIn', t('bargeIn'),
1546
+ t('bargeInHint')),
1547
+ boolField('announceApproval', t('announce'),
1548
+ t('announceHint')),
1549
+ boolField('skipCode', t('skipCode'),
1550
+ t('skipCodeHint')),
1551
+ boolField('cache', t('cache'),
1552
+ t('cacheHint')),
1553
+ numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
1554
+ React.createElement('label', { className: 'dts-field' }, t('longReply'),
1555
+ React.createElement('select', {
1556
+ className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1557
+ onChange: (e) => setTop('longReply', e.target.value),
1558
+ }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1559
+ React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1560
+ textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1561
+ numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
1562
+ boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
1563
+ boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
1564
+ boolField('skipActions', t('skipActions'), t('skipActionsHint')),
1565
+ textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
1566
+ textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
1567
+ textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
1568
+ textField('approvalText', t('approvalText'), t('approvalHint')),
1569
+ textField('questionText', t('questionText'), t('questionHint')),
1570
+ textField('chime', t('chime'), t('chimeHint')),
1571
+ textField('language', t('language'), t('languageHint')),
1572
+ textField('piperModel', t('piperModel'), t('piperModelHint')),
1573
+ textField('piperBin', t('piperBin'), t('binHint')),
1574
+ textField('espeakBin', t('espeakBin'), t('espeakHint')),
1575
+ textField('edgeBin', t('edgeBin'), t('edgeHint')),
1576
+ textField('azureRegion', t('azureRegion'), t('azureHint')),
1577
+ ),
1578
+ React.createElement('div', { className: 'dts-block' },
1579
+ React.createElement('button', {
1580
+ type: 'button',
1581
+ className: 'dts-head',
1582
+ 'aria-expanded': advancedOpen,
1583
+ onClick: () => {
1584
+ setAdvancedOpen((v) => {
1585
+ const next = !v
1586
+ try { localStorage.setItem('dsh-tts/advancedOpen', next ? '1' : '0') } catch { /* private mode */ }
1587
+ return next
1588
+ })
1280
1589
  },
1281
- }, 'dsh plugin --profile web add @goodandready/dsh-voice')
1282
- ) : null,
1283
- props.boolField('voiceDuplexEnabled', t('voiceDuplex'), t('voiceDuplexHintToggle'), !installed),
1284
- props.boolField('vadBargeIn', t('vadBargeIn'), t('vadBargeInHint'), !installed),
1285
- )
1286
- }
1287
-
1288
- function MessengerIntegrationEditor(props) {
1289
- const t = props.t || ((key) => key)
1290
- const installed = !!props.installed
1291
-
1292
- return React.createElement('div', { className: 'dts-block' },
1293
- React.createElement('div', { className: 'dts-h' }, t('messengerIntegrationTitle')),
1294
- React.createElement('div', { className: 'dts-sub' }, t('messengerIntegrationHint')),
1295
- !installed ? React.createElement('div', {
1296
- style: {
1297
- padding: '10px 14px',
1298
- borderRadius: '8px',
1299
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1300
- background: 'var(--dsw-alias-bg-layer-2)',
1301
- display: 'flex',
1302
- flexDirection: 'column',
1303
- gap: '4px',
1304
- fontSize: '13px',
1305
1590
  },
1306
- },
1307
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1308
- '⚠️ ' + t('messengerNotInstalledTitle')
1309
- ),
1310
- React.createElement('span', { className: 'dts-sub' },
1311
- t('messengerInstallHint') + ': '
1591
+ React.createElement('div', { className: 'dts-headText' },
1592
+ React.createElement('div', { className: 'dts-title' }, t('advancedTitle')),
1593
+ React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
1594
+ ),
1312
1595
  ),
1313
- React.createElement('code', {
1314
- style: {
1315
- fontFamily: 'monospace',
1316
- fontSize: '12px',
1317
- padding: '2px 6px',
1318
- borderRadius: '4px',
1319
- background: 'var(--dsw-alias-bg-layer-3)',
1320
- color: 'var(--dsw-alias-label-primary)',
1321
- width: 'fit-content',
1322
- },
1323
- }, 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway')
1324
- ) : null,
1325
- props.boolField('messengerTtsEnabled', t('messengerTtsEnabled'), t('messengerTtsEnabledHint'), !installed),
1596
+ advancedOpen ? React.createElement('div', { className: 'dts-body' },
1597
+ numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
1598
+ numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
1599
+ numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
1600
+ numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
1601
+ textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
1602
+ textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
1603
+ textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
1604
+ textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
1605
+ ) : null,
1606
+ ),
1607
+ React.createElement('div', { className: 'dts-foot' },
1608
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
1609
+ React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
1610
+ saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
1611
+ err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
1612
+ ),
1326
1613
  )
1327
1614
  }
1328
1615
 
1329
- // Synthesis stats panel: live host counters.
1330
- function StatsPanel(props) {
1331
- const t = props.t || ((key) => key)
1332
- const [data, setData] = React.useState(null)
1333
- const loadStats = () => {
1334
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1335
- }
1336
- React.useEffect(() => { loadStats() }, [])
1337
- const reset = () => {
1338
- fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1339
- }
1340
- return React.createElement('div', { className: 'dts-block' },
1341
- React.createElement('span', { className: 'dts-sub' }, data
1342
- ? t('statTotal') + ': ' + data.total + ' · ' + t('statHits') + ': ' + data.cacheHits + ' · ' + t('statErrors') + ': ' + data.errors
1343
- : t('loading')),
1344
- data && Object.keys(data.providers || {}).map((p) => React.createElement('span', { className: 'dts-sub', key: p },
1345
- p + ': ' + data.providers[p].n + ' / ' + data.providers[p].ms + 'ms')),
1346
- React.createElement('button', { type: 'button', className: 'dts-link', onClick: reset }, t('statReset')),
1347
- )
1348
- }
1349
1616
 
1350
- // Plugin-settings tab card follows the shared card pattern: li in the core list,
1351
- // theme-reset header, body with a divider. Chevron is ours; core does not provide it.
1352
1617
  function TtsCard(props) {
1353
1618
  const t = (props && props.t) || ((key) => key)
1354
1619
  const [open, setOpen] = React.useState(false)
@@ -1376,7 +1641,9 @@ window.__ModuleLoader__.load({
1376
1641
  ),
1377
1642
  ),
1378
1643
  open ? React.createElement('div', { className: 'dts-body' },
1379
- React.createElement(TtsSection, Object.assign({}, props, { compact: true, ctx: props && props.ctx })),
1644
+ React.createElement(ErrorBoundary, null,
1645
+ React.createElement(TtsSection, Object.assign({}, props, { compact: true, ctx: props && props.ctx })),
1646
+ ),
1380
1647
  ) : null,
1381
1648
  )
1382
1649
  }
@@ -1396,7 +1663,7 @@ window.__ModuleLoader__.load({
1396
1663
  React.createElement('button', {
1397
1664
  type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
1398
1665
  title: text,
1399
- onClick: () => playStandalone(text),
1666
+ onClick: () => playStandalone(text), title: text,
1400
1667
  }, text.slice(0, 80)),
1401
1668
  React.createElement('button', {
1402
1669
  type: 'button', className: 'dts-link', title: '★',
@@ -1405,7 +1672,7 @@ window.__ModuleLoader__.load({
1405
1672
  )
1406
1673
  return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
1407
1674
  React.createElement('button', {
1408
- type: 'button', className: 'dts-link', title: 'Недавние',
1675
+ type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
1409
1676
  onClick: () => setShowList((v) => !v),
1410
1677
  }, '☰'),
1411
1678
  showList ? React.createElement('div', {
@@ -1416,7 +1683,7 @@ window.__ModuleLoader__.load({
1416
1683
  lists.favs.map((text) => row(text, true)),
1417
1684
  React.createElement('div', { className: 'dts-h' }, '⟳'),
1418
1685
  lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
1419
- !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, '—') : null,
1686
+ !lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
1420
1687
  ) : null,
1421
1688
  React.createElement('button', {
1422
1689
  type: 'button',
@@ -1434,11 +1701,10 @@ window.__ModuleLoader__.load({
1434
1701
  }
1435
1702
 
1436
1703
  /**
1437
- * Запасной перевод дока.
1704
+ * Fallback translator for the dock.
1438
1705
  *
1439
- * Ставится при регистрации и живёт до конца работы страницы. Нужен
1440
- * потому, что подпись слота рисует ядро: `props.t` туда не доходит в
1441
- * принципе, и привязка — единственный способ дать ей язык.
1706
+ * Bound at registration for the page lifetime. The core draws the slot label;
1707
+ * props.t never reaches it, so the binder is the only way to give it a language.
1442
1708
  */
1443
1709
  let fallbackDockText = (key) => key
1444
1710
 
@@ -1473,7 +1739,7 @@ window.__ModuleLoader__.load({
1473
1739
  ctx.effect(() => {
1474
1740
  const undo = [addLocale('en', en), addLocale('ru', ru)]
1475
1741
  return () => { for (const off of undo) off() }
1476
- }, 'dsh-tts: словари')
1742
+ }, 'dsh-tts: locales')
1477
1743
  // The sidebar draws the section label, not our component: props.t never
1478
1744
  // reaches it, so bind the translator to the namespace ourselves.
1479
1745
  const t = ctx.locale.bind(NS)
@@ -1509,7 +1775,7 @@ window.__ModuleLoader__.load({
1509
1775
  label: () => t('title'),
1510
1776
  inject: () => ({ ctx: ctx }),
1511
1777
  },
1512
- TtsSection,
1778
+ (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
1513
1779
  ))
1514
1780
  }
1515
1781
 
@@ -1517,7 +1783,7 @@ window.__ModuleLoader__.load({
1517
1783
  exports.apply = function apply(ctx) {
1518
1784
  registerSettings(ctx)
1519
1785
  registerSpeaker(ctx)
1520
- ctx.effect(() => listenForVoice(), 'dsh-tts: замолкать, когда человек заговорил')
1786
+ ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
1521
1787
  ctx.effect(() => {
1522
1788
  if (typeof window === 'undefined') return () => {}
1523
1789
  const onKey = (e) => {
@@ -1527,47 +1793,54 @@ window.__ModuleLoader__.load({
1527
1793
  }
1528
1794
  window.addEventListener('keydown', onKey)
1529
1795
  return () => window.removeEventListener('keydown', onKey)
1530
- }, 'dsh-tts: горячие клавиши')
1796
+ }, 'dsh-tts: hotkeys')
1531
1797
  ctx.effect(() => {
1532
1798
  if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
1533
1799
  let es = null
1534
- try {
1535
- es = new EventSource('/dsh-tts/stream')
1536
- es.addEventListener('utterance', (e) => {
1537
- try {
1538
- const item = JSON.parse(e.data)
1539
- if (!item || !item.id) return
1540
- if (seenIds.has(item.id)) return
1541
- seenIds.add(item.id)
1542
- if (seenIds.size > 500) {
1543
- const first = seenIds.values().next().value
1544
- seenIds.delete(first)
1545
- }
1546
- player.queue.push(item)
1547
- drainQueue()
1548
- } catch {}
1549
- })
1550
- es.addEventListener('chime', (e) => {
1551
- try {
1552
- const item = JSON.parse(e.data)
1553
- if (!item || !item.id) return
1554
- if (seenIds.has(item.id)) return
1555
- seenIds.add(item.id)
1556
- if (seenIds.size > 500) {
1557
- const first = seenIds.values().next().value
1558
- seenIds.delete(first)
1559
- }
1560
- player.queue.push(item)
1561
- drainQueue()
1562
- } catch {}
1563
- })
1564
- } catch {}
1565
- return () => { if (es) es.close() }
1566
- }, 'dsh-tts: потоковое получение звука по SSE')
1800
+ let closed = false
1801
+ let attempt = 0
1802
+ let retryTimer = null
1803
+ const onItem = (e) => {
1804
+ try {
1805
+ const item = JSON.parse(e.data)
1806
+ if (!item || !item.id) return
1807
+ if (seenIds.has(item.id)) return
1808
+ seenIds.add(item.id)
1809
+ if (seenIds.size > 500) {
1810
+ const first = seenIds.values().next().value
1811
+ seenIds.delete(first)
1812
+ }
1813
+ player.queue.push(item)
1814
+ drainQueue()
1815
+ } catch { /* ignore malformed SSE frame */ }
1816
+ }
1817
+ const connect = () => {
1818
+ if (closed) return
1819
+ try {
1820
+ es = new EventSource('/dsh-tts/stream')
1821
+ es.addEventListener('open', () => { attempt = 0 })
1822
+ es.addEventListener('utterance', onItem)
1823
+ es.addEventListener('chime', onItem)
1824
+ es.onerror = () => {
1825
+ try { es.close() } catch { /* already closed */ }
1826
+ if (closed) return
1827
+ attempt += 1
1828
+ const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
1829
+ retryTimer = setTimeout(connect, delay)
1830
+ }
1831
+ } catch { /* EventSource constructor failed — poll remains */ }
1832
+ }
1833
+ connect()
1834
+ return () => {
1835
+ closed = true
1836
+ if (retryTimer) clearTimeout(retryTimer)
1837
+ if (es) { try { es.close() } catch { /* already closed */ } }
1838
+ }
1839
+ }, 'dsh-tts: SSE audio stream')
1567
1840
  ctx.effect(() => {
1568
1841
  const timer = setInterval(pollPending, 1000)
1569
1842
  return () => clearInterval(timer)
1570
- }, 'dsh-tts: опрос готовых озвучек')
1843
+ }, 'dsh-tts: poll pending audio')
1571
1844
  }
1572
1845
  return module.exports
1573
1846
  },
@@ -1575,3 +1848,4 @@ window.__ModuleLoader__.load({
1575
1848
 
1576
1849
  // ModelManager UI placeholder - manual install buttons for Kokoro/F5
1577
1850
  // Actual implementation would show download progress and status
1851
+