@goodandready/dsh-tts 0.4.1 → 0.4.5
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 +59 -21
- package/lib/client-src/30-locales.js +36 -0
- package/lib/client-src/40-editors.js +31 -51
- package/lib/client-src/50-settings.js +93 -36
- package/lib/client-src/60-card-dock.js +50 -44
- package/lib/client.js +277 -160
- package/lib/index.js +27 -4
- package/lib/local.js +5 -7
- package/lib/providers/cloud.js +278 -0
- package/lib/providers/constants.js +61 -0
- package/lib/providers/local.js +99 -0
- package/lib/providers.js +11 -435
- package/lib/routes.js +17 -10
- package/lib/stream-hub.js +61 -9
- package/lib/text.js +8 -8
- 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 color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
|
|
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(() => { /* audio context unlock failure ignored */ })
|
|
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
|
|
@@ -279,9 +317,9 @@
|
|
|
279
317
|
.then((d) => {
|
|
280
318
|
if (!d || !d.ok) return
|
|
281
319
|
const audio = new Audio('data:' + (d.mime || 'audio/mpeg') + ';base64,' + d.audioBase64)
|
|
282
|
-
audio.play().catch(() => {})
|
|
320
|
+
audio.play().catch(() => { /* chime autoplay blocked or interrupted */ })
|
|
283
321
|
})
|
|
284
|
-
.catch(() => {})
|
|
322
|
+
.catch(() => { /* chime fetch failed or not found */ })
|
|
285
323
|
}
|
|
286
324
|
|
|
287
325
|
function credOf(map, provider) {
|
|
@@ -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
|
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
}),
|
|
40
40
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
|
|
41
41
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
|
|
42
|
-
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
|
|
42
|
+
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('testProvider') || t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
|
|
43
43
|
React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
|
|
44
44
|
),
|
|
45
45
|
cloud ? React.createElement('div', { className: 'dts-row' },
|
|
@@ -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 dts-bad' },
|
|
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' },
|