@goodandready/dsh-tts 0.4.1 → 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/breaker.js ADDED
@@ -0,0 +1,64 @@
1
+ // Soft circuit breaker for provider fallback (runtime state only).
2
+ // Tracks consecutive failures per chain id and skips a provider during cooldown.
3
+
4
+ const DEFAULT_THRESHOLD = 3
5
+ const DEFAULT_COOLDOWN_MS = 60000
6
+
7
+ export function createProviderBreaker(options = {}) {
8
+ const threshold = options.threshold || DEFAULT_THRESHOLD
9
+ const cooldownMs = options.cooldownMs || DEFAULT_COOLDOWN_MS
10
+ const now = options.now || (() => Date.now())
11
+ const state = new Map()
12
+
13
+ function slot(id) {
14
+ let s = state.get(id)
15
+ if (!s) {
16
+ s = { failCount: 0, okCount: 0, lastError: '', lastFailAt: 0, openUntil: 0 }
17
+ state.set(id, s)
18
+ }
19
+ return s
20
+ }
21
+
22
+ function isOpen(id) {
23
+ const s = slot(id)
24
+ return s.openUntil > now()
25
+ }
26
+
27
+ function recordFailure(id, error) {
28
+ const s = slot(id)
29
+ s.failCount += 1
30
+ s.lastError = String((error && error.message) || error || 'failure').slice(0, 240)
31
+ s.lastFailAt = now()
32
+ if (s.failCount >= threshold) {
33
+ s.openUntil = now() + cooldownMs
34
+ }
35
+ }
36
+
37
+ function recordSuccess(id) {
38
+ const s = slot(id)
39
+ s.okCount += 1
40
+ s.failCount = 0
41
+ s.openUntil = 0
42
+ }
43
+
44
+ function snapshot(ids) {
45
+ const list = Array.isArray(ids) ? ids : [...state.keys()]
46
+ return list.map((id) => {
47
+ const s = slot(id)
48
+ return {
49
+ id,
50
+ failCount: s.failCount,
51
+ okCount: s.okCount,
52
+ lastError: s.lastError,
53
+ open: isOpen(id),
54
+ openUntil: s.openUntil || 0,
55
+ }
56
+ })
57
+ }
58
+
59
+ function reset() {
60
+ state.clear()
61
+ }
62
+
63
+ return { isOpen, recordFailure, recordSuccess, snapshot, reset, threshold, cooldownMs }
64
+ }
package/lib/cache.js CHANGED
@@ -35,7 +35,7 @@ export function createSpeechCache({ root, maxBytes }) {
35
35
  try {
36
36
  const meta = JSON.parse(await fs.promises.readFile(path.join(dir, name.slice(0, -AUDIO_EXT.length) + '.json'), 'utf8'))
37
37
  if (meta && typeof meta.at === 'number') at = meta.at
38
- } catch (noMeta) { /* старьё без меты — вытесним первым */ }
38
+ } catch (noMeta) { /* legacy entry without meta — evict as first */ }
39
39
  out.push({ full, meta: name.slice(0, -AUDIO_EXT.length) + '.json', size: st.size, at })
40
40
  }
41
41
  return out
@@ -22,8 +22,8 @@
22
22
  '.dts-save:hover:not(:disabled){opacity:0.88}' +
23
23
  '.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}' +
24
24
  '.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))}' +
25
- '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
26
- '.dts-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.5)}' +
25
+ '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
26
+ '.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)}' +
27
27
  '.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)}' +
28
28
  '.dts-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
29
29
  '.dts-row .dts-model{flex:1;min-width:120px}' +
@@ -33,15 +33,15 @@
33
33
  '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
34
34
  '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
35
35
  '.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}' +
36
- '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
37
- '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
38
- '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
36
+ '.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)}' +
37
+ '.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)}' +
38
+ '.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)}' +
39
39
  '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
40
40
  '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
41
41
  '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
42
- '.dts-alert-err{background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);border:1px solid rgba(239,68,68,0.2)}' +
43
- '.dts-alert-warn{background:rgba(245,158,11,0.1);color:var(--dsw-alias-state-warning-primary);border:1px solid rgba(245,158,11,0.2)}' +
44
- '.dts-alert-ok{background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);border:1px solid rgba(16,185,129,0.2)}' +
42
+ '.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)}' +
43
+ '.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)}' +
44
+ '.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)}' +
45
45
  '.dts-grow{flex:1;min-width:100px}'
46
46
 
47
47
  const setCssId = 'dsh-tts/settings.module.css'
@@ -3,6 +3,38 @@
3
3
  // Each incoming chunk used to replace the previous one immediately; while reading
4
4
  // For full replies this was invisible; with speak-as-it-goes only the last
5
5
  // chunk would be heard. Now items queue: the next starts after the previous ends.
6
+ const autoplay = { blocked: false, listeners: new Set() }
7
+
8
+ function setAutoplayBlocked(next) {
9
+ if (autoplay.blocked === next) return
10
+ autoplay.blocked = next
11
+ for (const l of [...autoplay.listeners]) {
12
+ try { l() } catch { /* listener isolation */ }
13
+ }
14
+ }
15
+
16
+ function unlockAudio() {
17
+ try {
18
+ const AC = window.AudioContext || window.webkitAudioContext
19
+ if (AC) {
20
+ const ctx = new AC()
21
+ if (ctx.state === 'suspended') ctx.resume().catch(() => {})
22
+ setTimeout(() => { try { ctx.close() } catch { /* closed */ } }, 300)
23
+ }
24
+ } catch { /* no AudioContext */ }
25
+ setAutoplayBlocked(false)
26
+ drainQueue()
27
+ }
28
+
29
+ function useAutoplayGate() {
30
+ const [, force] = React.useReducer((n) => n + 1, 0)
31
+ React.useEffect(() => {
32
+ autoplay.listeners.add(force)
33
+ return () => { autoplay.listeners.delete(force) }
34
+ }, [])
35
+ return autoplay
36
+ }
37
+
6
38
  const player = {
7
39
  audio: null,
8
40
  after: '',
@@ -14,13 +46,15 @@
14
46
  busy: false,
15
47
  paused: false,
16
48
  listeners: new Set(),
49
+ unlockAudio,
50
+ useAutoplayGate,
51
+ get autoplayBlocked() { return autoplay.blocked },
17
52
  }
18
53
  const seenIds = new Set()
19
54
  let currentAudioResolve = null
20
-
21
55
  function playerChanged() {
22
56
  for (const listener of [...player.listeners]) {
23
- try { listener() } catch (listenerFailure) { /* чужой слушатель не наша забота */ }
57
+ try { listener() } catch (listenerFailure) { /* foreign listener failure is not ours */ }
24
58
  }
25
59
  }
26
60
 
@@ -55,8 +89,8 @@
55
89
  osc.stop(at + 0.2)
56
90
  at += 0.22
57
91
  }
58
- setTimeout(() => { try { audioCtx.close() } catch (already) { /* закрыт */ } resolve() }, 600)
59
- } catch (noAudio) { resolve() }
92
+ setTimeout(() => { try { audioCtx.close() } catch (already) { /* already closed */ } resolve() }, 600)
93
+ } catch { resolve() /* no AudioContext */ }
60
94
  })
61
95
  }
62
96
 
@@ -71,7 +105,7 @@
71
105
  utterance.onend = () => resolve()
72
106
  utterance.onerror = () => resolve()
73
107
  window.speechSynthesis.speak(utterance)
74
- } catch (noSpeech) { resolve() }
108
+ } catch { resolve() /* no speechSynthesis */ }
75
109
  })
76
110
  }
77
111
 
@@ -86,7 +120,7 @@
86
120
  const audio = new Audio(url)
87
121
  audio.preload = 'auto'
88
122
  item.prepared = { audio, url }
89
- } catch (err) { /* decode error */ }
123
+ } catch { /* decode error — item stays unprepared */ }
90
124
  return item
91
125
  }
92
126
 
@@ -114,7 +148,7 @@
114
148
  if (doneCalled) return
115
149
  doneCalled = true
116
150
  currentAudioResolve = null
117
- try { URL.revokeObjectURL(url) } catch (_) {}
151
+ try { URL.revokeObjectURL(url) } catch { /* URL already revoked */ }
118
152
  item.prepared = null
119
153
  player.audio = null
120
154
  playerChanged()
@@ -123,11 +157,14 @@
123
157
  currentAudioResolve = done
124
158
  audio.onended = done
125
159
  audio.onerror = done
126
- audio.play().catch(() => {
127
- // Autoplay without a user gesture is blocked — not a bug; just no sound.
160
+ audio.play().catch((playErr) => {
161
+ // Autoplay without a user gesture is blocked — surface unlock UX, then stop this chunk.
162
+ if (playErr && (playErr.name === 'NotAllowedError' || playErr.name === 'SecurityError')) {
163
+ setAutoplayBlocked(true)
164
+ }
128
165
  done()
129
166
  })
130
- } catch (cannotDecode) {
167
+ } catch { /* cannot decode — skip chunk */
131
168
  currentAudioResolve = null
132
169
  resolve()
133
170
  }
@@ -156,26 +193,27 @@
156
193
  function stopPlayback() {
157
194
  for (const item of player.queue) {
158
195
  if (item && item.prepared && item.prepared.url) {
159
- try { URL.revokeObjectURL(item.prepared.url) } catch (_) {}
196
+ try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
160
197
  item.prepared = null
161
198
  }
162
199
  }
163
200
  player.queue.length = 0
164
201
  player.paused = false
202
+ if (player.audio) {
203
+ try { player.audio.pause() } catch { /* already paused */ }
204
+ }
165
205
  if (currentAudioResolve) {
166
206
  currentAudioResolve()
167
- } else if (player.audio) {
168
- try { player.audio.pause() } catch (already) { /* стоит */ }
169
- player.audio = null
170
207
  }
171
- try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch (noSpeech) { /* нет синтеза */ }
208
+ player.audio = null
209
+ try { if (window.speechSynthesis) window.speechSynthesis.cancel() } catch { /* no speechSynthesis */ }
172
210
  playerChanged()
173
211
  }
174
212
 
175
213
  function togglePause() {
176
214
  player.paused = !player.paused
177
215
  if (player.audio) {
178
- try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* всё равно */ }
216
+ try { player.paused ? player.audio.pause() : player.audio.play() } catch (already) { /* ignore play/pause failure */ }
179
217
  }
180
218
  if (!player.paused) drainQueue()
181
219
  playerChanged()
@@ -185,7 +223,7 @@
185
223
  if (currentAudioResolve) {
186
224
  currentAudioResolve()
187
225
  } else if (player.audio) {
188
- try { player.audio.pause() } catch (already) { /* стоит */ }
226
+ try { player.audio.pause() } catch (already) { /* already stopped */ }
189
227
  player.audio = null
190
228
  playerChanged()
191
229
  if (player.queue.length) drainQueue()
@@ -239,7 +277,7 @@
239
277
  player.queue.push(item)
240
278
  }
241
279
  if (player.queue.length) drainQueue()
242
- } catch (hostRestarting) { /* хост перезапускается */ }
280
+ } catch (hostRestarting) { /* host restarting */ }
243
281
  }
244
282
 
245
283
  // Recent utterances: last reply texts and favorites (localStorage).
@@ -250,7 +288,7 @@
250
288
  const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(KEY) : null
251
289
  st = JSON.parse(raw) || { items: [], favs: [] }
252
290
  } catch (broken) { st = { items: [], favs: [] } }
253
- const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* приватный режим */ } }
291
+ const persist = () => { try { if (typeof localStorage !== 'undefined') localStorage.setItem(KEY, JSON.stringify(st)) } catch (full) { /* private mode / quota */ } }
254
292
  return {
255
293
  add(text) {
256
294
  if (!text || text.length < 2) return
@@ -1,5 +1,23 @@
1
1
  const en = {
2
2
  'statusOnline': 'Host online',
3
+ 'recent': 'Recent',
4
+ 'recentEmpty': 'No recent speech yet',
5
+ 'retry': 'Retry',
6
+ 'advancedCollapsed': 'Advanced',
7
+ 'confirmClearCache': 'Clear the synthesis cache? This cannot be undone.',
8
+ 'confirmDeleteModel': 'Delete downloaded weights for this engine?',
9
+ 'autoplayBlocked': 'Sound is blocked until you click or tap once',
10
+ 'enableSound': 'Enable sound',
11
+ 'runtimeNotBundled': 'Neural inference runtime is not bundled',
12
+ 'runtimeNotBundledHint': 'Install weights does not enable speech. Use Edge, Piper, or eSpeak for offline TTS.',
13
+ 'testProvider': 'Test provider',
14
+ 'breakerOpen': 'Circuit open',
15
+ 'lastError': 'Last error',
16
+ 'activeProvider': 'Active provider',
17
+ 'queueLen': 'Queue',
18
+ 'sseState': 'SSE',
19
+ 'currentVoice': 'Voice',
20
+
3
21
  'statusOffline': 'Host unreachable',
4
22
  'cacheStats': 'Cache',
5
23
 
@@ -162,6 +180,24 @@
162
180
  }
163
181
  const ru = {
164
182
  'statusOnline': 'Хост на связи',
183
+ 'recent': 'Недавние',
184
+ 'recentEmpty': 'Пока нет реплик',
185
+ 'retry': 'Повторить',
186
+ 'advancedCollapsed': 'Дополнительно',
187
+ 'confirmClearCache': 'Очистить кэш синтеза? Действие необратимо.',
188
+ 'confirmDeleteModel': 'Удалить скачанные веса движка?',
189
+ 'autoplayBlocked': 'Звук заблокирован — один раз кликните или коснитесь экрана',
190
+ 'enableSound': 'Включить звук',
191
+ 'runtimeNotBundled': 'Рантайм нейросинтеза не входит в пакет',
192
+ 'runtimeNotBundledHint': 'Установка весов не включает речь. Для offline используйте Edge, Piper или eSpeak.',
193
+ 'testProvider': 'Проверить провайдера',
194
+ 'breakerOpen': 'Переключатель открыт',
195
+ 'lastError': 'Последняя ошибка',
196
+ 'activeProvider': 'Активный провайдер',
197
+ 'queueLen': 'Очередь',
198
+ 'sseState': 'SSE',
199
+ 'currentVoice': 'Голос',
200
+
165
201
  'statusOffline': 'Хост недоступен',
166
202
  'cacheStats': 'Кэш',
167
203
 
@@ -57,6 +57,13 @@
57
57
  }, t('clear')) : null,
58
58
  ) : React.createElement('span', { className: 'dts-sub' }, t('localProvider')),
59
59
  cloud && cred.ref ? React.createElement('span', { className: 'dts-sub' }, 'Stored as ' + cred.ref) : null,
60
+ (() => {
61
+ const hit = Array.isArray(props.breaker) ? props.breaker.find((b) => b && b.id === row.provider) : null
62
+ if (!hit || (!hit.open && !hit.lastError)) return null
63
+ return React.createElement('div', { className: 'dts-sub' },
64
+ (hit.open ? t('breakerOpen') + ' · ' : '') + (hit.lastError || '')
65
+ )
66
+ })(),
60
67
  )
61
68
  }),
62
69
  React.createElement('div', { className: 'dts-row' },
@@ -72,86 +79,59 @@
72
79
  function LocalEnginesEditor(props) {
73
80
  const t = props.t || ((key) => key)
74
81
  const [models, setModels] = React.useState({})
75
- const [loading, setLoading] = React.useState(false)
76
82
 
77
83
  const loadStatus = React.useCallback(() => {
78
84
  if (typeof fetch === 'undefined') return
79
85
  fetch('/dsh-tts/models/status')
80
86
  .then((r) => r.json())
81
87
  .then((d) => { if (d && d.ok && d.models) setModels(d.models) })
82
- .catch(() => {})
88
+ .catch(() => {}) // status poll best-effort
83
89
  }, [])
84
90
 
85
91
  React.useEffect(() => {
86
92
  loadStatus()
87
- const timer = setInterval(loadStatus, 3000)
93
+ const timer = setInterval(loadStatus, 5000)
88
94
  return () => clearInterval(timer)
89
95
  }, [loadStatus])
90
96
 
91
- const install = (engine) => {
92
- setLoading(true)
93
- fetch('/dsh-tts/models/install', {
94
- method: 'POST',
95
- headers: { 'content-type': 'application/json' },
96
- body: JSON.stringify({ engine }),
97
- }).then(() => loadStatus()).finally(() => setLoading(false))
98
- }
99
-
100
97
  const remove = (engine) => {
101
- setLoading(true)
98
+ if (typeof window !== 'undefined' && window.confirm) {
99
+ if (!window.confirm(t('confirmDeleteModel'))) return
100
+ }
102
101
  fetch('/dsh-tts/models/delete', {
103
102
  method: 'DELETE',
104
103
  headers: { 'content-type': 'application/json' },
105
104
  body: JSON.stringify({ engine }),
106
- }).then(() => loadStatus()).finally(() => setLoading(false))
105
+ }).then(() => loadStatus()).catch(() => {})
107
106
  }
108
107
 
109
108
  const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
110
109
  const f5 = models.f5 || { installed: false, downloading: false, progress: 0 }
111
110
 
111
+ const engineRow = (label, state) => React.createElement('div', { className: 'dts-entry' },
112
+ React.createElement('div', { className: 'dts-row' },
113
+ React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, label),
114
+ React.createElement('span', {
115
+ className: 'dts-badge dts-badge-warn',
116
+ }, t('runtimeNotBundled')),
117
+ state.installed ? React.createElement('button', {
118
+ type: 'button', className: 'dts-link', disabled: !props.writable,
119
+ onClick: () => remove(label.toLowerCase().includes('kokoro') ? 'kokoro' : 'f5'),
120
+ }, t('deleteModel')) : null,
121
+ ),
122
+ React.createElement('div', { className: 'dts-sub' }, t('runtimeNotBundledHint')),
123
+ )
124
+
112
125
  return React.createElement('div', { className: 'dts-block' },
113
126
  React.createElement('div', { className: 'dts-h' }, t('localEnginesTitle')),
114
127
  React.createElement('div', { className: 'dts-sub' }, t('localEnginesHint')),
115
- props.boolField('enableLocalEngines', t('enableLocalEngines'), t('enableLocalEnginesHint')),
128
+ React.createElement('div', { className: 'dts-alert-warn dts-sub' }, t('runtimeNotBundledHint')),
116
129
  props.boolField('streamingEnabled', t('streamingEnabled'), t('streamingEnabledHint')),
117
- React.createElement('div', { className: 'dts-entry' },
118
- React.createElement('div', { className: 'dts-row' },
119
- React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'Kokoro-82M (CPU)'),
120
- React.createElement('span', {
121
- className: 'dts-badge' + (kokoro.installed ? ' dts-badge-on' : ''),
122
- }, kokoro.downloading ? `${t('downloading')} ${kokoro.progress}%` : (kokoro.installed ? t('installed') : t('notInstalled'))),
123
- !kokoro.installed && !kokoro.downloading ? React.createElement('button', {
124
- type: 'button', className: 'dts-save', disabled: !props.writable || loading,
125
- onClick: () => install('kokoro'),
126
- }, t('installKokoro')) : null,
127
- kokoro.installed ? React.createElement('button', {
128
- type: 'button', className: 'dts-link', disabled: !props.writable || loading,
129
- onClick: () => remove('kokoro'),
130
- }, t('deleteModel')) : null,
131
- ),
132
- kokoro.installed ? props.boolField('kokoroEnabled', t('useKokoro'), t('useKokoroHint')) : null,
133
- ),
134
- React.createElement('div', { className: 'dts-entry' },
135
- React.createElement('div', { className: 'dts-row' },
136
- React.createElement('span', { className: 'dts-h', style: { minWidth: '150px' } }, 'F5-TTS (GPU)'),
137
- React.createElement('span', {
138
- className: 'dts-badge' + (f5.installed ? ' dts-badge-on' : ''),
139
- }, f5.downloading ? `${t('downloading')} ${f5.progress}%` : (f5.installed ? t('installed') : t('notInstalled'))),
140
- !f5.installed && !f5.downloading ? React.createElement('button', {
141
- type: 'button', className: 'dts-save', disabled: !props.writable || loading,
142
- onClick: () => install('f5'),
143
- }, t('installF5')) : null,
144
- f5.installed ? React.createElement('button', {
145
- type: 'button', className: 'dts-link', disabled: !props.writable || loading,
146
- onClick: () => remove('f5'),
147
- }, t('deleteModel')) : null,
148
- ),
149
- f5.installed ? props.boolField('f5Enabled', t('useF5'), t('useF5Hint')) : null,
150
- ),
130
+ engineRow('Kokoro-82M (CPU)', kokoro),
131
+ engineRow('F5-TTS (GPU)', f5),
151
132
  )
152
133
  }
153
134
 
154
-
155
135
  const CLIENT_IT_TERMS = [
156
136
  { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
157
137
  { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
@@ -7,7 +7,12 @@
7
7
  const [saved, setSaved] = React.useState(false)
8
8
  const [err, setErr] = React.useState('')
9
9
  const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
10
- const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0 })
10
+ const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, sseClients: 0, breaker: [], provider: '', queue: 0 })
11
+ const [advancedOpen, setAdvancedOpen] = React.useState(() => {
12
+ try { return localStorage.getItem('dsh-tts/advancedOpen') === '1' } catch { return false }
13
+ })
14
+ const autoplay = useAutoplayGate()
15
+
11
16
  const ctx = props && props.ctx
12
17
  const scope = React.useMemo(() => {
13
18
  const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
@@ -71,18 +76,33 @@
71
76
  if (data && data.ok) setIntegrations(data)
72
77
  }).catch(() => {})
73
78
 
74
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
75
- if (!alive || !st) return
76
- setTelemetry((prev) => Object.assign({}, prev, {
77
- cacheHits: st.cacheHits || 0,
78
- total: st.total || 0,
79
- online: true,
80
- }))
81
- }).catch(() => {
82
- if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
83
- })
79
+ const loadTelemetry = () => {
80
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
81
+ if (!alive || !st) return
82
+ setTelemetry((prev) => Object.assign({}, prev, {
83
+ cacheHits: st.cacheHits || 0,
84
+ total: st.total || 0,
85
+ online: true,
86
+ breaker: st.breaker || prev.breaker || [],
87
+ }))
88
+ }).catch(() => {
89
+ if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
90
+ })
91
+ fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
92
+ if (!alive || !st) return
93
+ setTelemetry((prev) => Object.assign({}, prev, {
94
+ sseClients: st.sseClients || 0,
95
+ sseOk: (st.sseClients || 0) >= 0,
96
+ provider: (player.audio && player.provider) || (player.queue[0] && player.queue[0].provider) || '',
97
+ queue: player.queue.length,
98
+ }))
99
+ }).catch(() => {})
100
+ }
101
+ loadTelemetry()
102
+ const telTimer = setInterval(loadTelemetry, 3000)
103
+ // interval cleaned by effect return below
84
104
 
85
- return () => { alive = false }
105
+ return () => { alive = false; clearInterval(telTimer) }
86
106
  }, [])
87
107
 
88
108
  if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
@@ -153,6 +173,9 @@
153
173
  setSaved(true); setTimeout(() => setSaved(false), 2000)
154
174
  }
155
175
  const onClearCache = () => {
176
+ if (typeof window !== 'undefined' && window.confirm) {
177
+ if (!window.confirm(t('confirmClearCache'))) return
178
+ }
156
179
  clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
157
180
  }
158
181
 
@@ -199,16 +222,31 @@
199
222
  }),
200
223
  React.createElement('span', { className: 'dts-sub' }, hint))
201
224
 
202
- const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' } },
225
+ const autoplayBanner = autoplay.blocked
226
+ ? React.createElement('div', { className: 'dts-unlock' },
227
+ React.createElement('span', null, t('autoplayBlocked')),
228
+ React.createElement('button', {
229
+ type: 'button', className: 'dts-unlock-btn',
230
+ onClick: () => player.unlockAudio && player.unlockAudio(),
231
+ }, t('enableSound')),
232
+ )
233
+ : null
234
+ const breakerBadges = (telemetry.breaker || []).filter((b) => b && b.open).slice(0, 2)
235
+ .map((b) => React.createElement('span', { key: b.id, className: 'dts-badge dts-badge-bad' }, t('breakerOpen') + ': ' + b.id))
236
+ const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' }, 'data-dsh-tts-telemetry': '1' },
203
237
  React.createElement('span', {
204
238
  className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
205
- }, telemetry.online ? '🟢 ' + (t('statusOnline') || 'Host online') : '🔴 ' + (t('statusOffline') || 'Host offline')),
206
- React.createElement('span', {
207
- className: 'dts-badge dts-badge-on',
208
- }, '⚡ SSE Active'),
239
+ }, telemetry.online ? t('statusOnline') : t('statusOffline')),
209
240
  React.createElement('span', {
210
- className: 'dts-badge',
211
- }, '💾 ' + (t('cacheStats') || 'Cache') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
241
+ className: 'dts-badge ' + (telemetry.sseClients > 0 ? 'dts-badge-on' : ''),
242
+ }, t('sseState') + ': ' + (telemetry.sseClients || 0)),
243
+ React.createElement('span', { className: 'dts-badge' },
244
+ t('queueLen') + ': ' + (telemetry.queue || player.queue.length || 0)),
245
+ telemetry.provider ? React.createElement('span', { className: 'dts-badge' },
246
+ t('activeProvider') + ': ' + telemetry.provider) : null,
247
+ React.createElement('span', { className: 'dts-badge' },
248
+ t('cacheStats') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
249
+ ...breakerBadges,
212
250
  )
213
251
  const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
214
252
  React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
@@ -216,6 +254,7 @@
216
254
  telemetryBadges,
217
255
  )
218
256
  return React.createElement('div', { className: 'dts-wrap' },
257
+ autoplayBanner,
219
258
  React.createElement('div', { className: 'dts-block' },
220
259
  titleLine,
221
260
  React.createElement('label', { className: 'dts-row' },
@@ -269,6 +308,7 @@
269
308
  React.createElement('div', { className: 'dts-sub' },
270
309
  t('chainHintCard')),
271
310
  React.createElement(ChainEditor, {
311
+ breaker: telemetry.breaker,
272
312
  t: t,
273
313
  value: draft && draft.chain ? draft.chain : [], writable: writable,
274
314
  credentials: credentials, keyDrafts: keyDrafts,
@@ -318,16 +358,33 @@
318
358
  textField('azureRegion', t('azureRegion'), t('azureHint')),
319
359
  ),
320
360
  React.createElement('div', { className: 'dts-block' },
321
- React.createElement('div', { className: 'dts-h' }, t('advancedTitle')),
322
- React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
323
- numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
324
- numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
325
- numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
326
- numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
327
- textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
328
- textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
329
- textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
330
- textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
361
+ React.createElement('button', {
362
+ type: 'button',
363
+ className: 'dts-head',
364
+ 'aria-expanded': advancedOpen,
365
+ onClick: () => {
366
+ setAdvancedOpen((v) => {
367
+ const next = !v
368
+ try { localStorage.setItem('dsh-tts/advancedOpen', next ? '1' : '0') } catch { /* private mode */ }
369
+ return next
370
+ })
371
+ },
372
+ },
373
+ React.createElement('div', { className: 'dts-headText' },
374
+ React.createElement('div', { className: 'dts-title' }, t('advancedTitle')),
375
+ React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
376
+ ),
377
+ ),
378
+ advancedOpen ? React.createElement('div', { className: 'dts-body' },
379
+ numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
380
+ numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
381
+ numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
382
+ numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
383
+ textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
384
+ textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
385
+ textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
386
+ textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
387
+ ) : null,
331
388
  ),
332
389
  React.createElement('div', { className: 'dts-foot' },
333
390
  React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),