@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 +64 -0
- package/lib/cache.js +1 -1
- package/lib/client-src/10-styles.js +8 -8
- package/lib/client-src/20-player.js +57 -19
- package/lib/client-src/30-locales.js +36 -0
- package/lib/client-src/40-editors.js +30 -50
- package/lib/client-src/50-settings.js +86 -29
- package/lib/client-src/60-card-dock.js +50 -44
- package/lib/client.js +267 -150
- package/lib/index.js +27 -4
- package/lib/local.js +5 -7
- package/lib/providers/constants.js +61 -0
- package/lib/providers.js +3 -61
- package/lib/routes.js +15 -8
- package/lib/stream-hub.js +61 -9
- package/lib/text.js +3 -3
- package/package.json +1 -1
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:
|
|
26
|
-
'.dts-btn-danger:hover:not(:disabled){background:
|
|
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:
|
|
37
|
-
'.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:
|
|
38
|
-
'.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:
|
|
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:
|
|
43
|
-
'.dts-alert-warn{background:
|
|
44
|
-
'.dts-alert-ok{background:
|
|
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) { /*
|
|
59
|
-
} catch
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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()).
|
|
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
|
-
|
|
128
|
+
React.createElement('div', { className: 'dts-alert-warn dts-sub' }, t('runtimeNotBundledHint')),
|
|
116
129
|
props.boolField('streamingEnabled', t('streamingEnabled'), t('streamingEnabledHint')),
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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 ?
|
|
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
|
-
},
|
|
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('
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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')),
|