@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/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +8 -0
- package/lib/breaker.js +64 -0
- package/lib/cache.js +1 -1
- package/lib/client-src/00-open.js +85 -0
- package/lib/client-src/10-styles.js +54 -0
- package/lib/client-src/20-player.js +328 -0
- package/lib/client-src/30-locales.js +361 -0
- package/lib/client-src/40-editors.js +390 -0
- package/lib/client-src/50-settings.js +398 -0
- package/lib/client-src/60-card-dock.js +235 -0
- package/lib/client.js +896 -622
- package/lib/index.js +29 -4
- package/lib/local.js +5 -7
- package/lib/providers/constants.js +61 -0
- package/lib/providers.js +3 -61
- package/lib/routes.js +19 -9
- package/lib/stream-hub.js +61 -9
- package/lib/text.js +3 -3
- package/package.json +4 -2
- package/scripts/build-client.mjs +19 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
function TtsSection(props) {
|
|
2
|
+
// Translator comes from the slot because its registration sets locale.
|
|
3
|
+
const t = (props && props.t) || ((key) => key)
|
|
4
|
+
const [draft, setDraft] = React.useState(null)
|
|
5
|
+
const [credentials, setCredentials] = React.useState({})
|
|
6
|
+
const [keyDrafts, setKeyDrafts] = React.useState({})
|
|
7
|
+
const [saved, setSaved] = React.useState(false)
|
|
8
|
+
const [err, setErr] = React.useState('')
|
|
9
|
+
const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
|
|
10
|
+
const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0, 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
|
+
|
|
16
|
+
const ctx = props && props.ctx
|
|
17
|
+
const scope = React.useMemo(() => {
|
|
18
|
+
const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
|
|
19
|
+
if (!s || !s.bind) return null
|
|
20
|
+
try {
|
|
21
|
+
return s.bind({ namespace: NS })
|
|
22
|
+
} catch (_) {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}, [ctx])
|
|
26
|
+
|
|
27
|
+
const subscribe = React.useMemo(() => {
|
|
28
|
+
return (cb) => {
|
|
29
|
+
if (!scope || !scope.subscribe) return () => {}
|
|
30
|
+
try {
|
|
31
|
+
return scope.subscribe(cb) || (() => {})
|
|
32
|
+
} catch (_) {
|
|
33
|
+
return () => {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}, [scope])
|
|
37
|
+
|
|
38
|
+
const getSnapshot = React.useCallback(() => {
|
|
39
|
+
if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
|
|
40
|
+
try {
|
|
41
|
+
return scope.getSnapshot() || SNAPSHOT_LOADING
|
|
42
|
+
} catch (_) {
|
|
43
|
+
return SNAPSHOT_LOADING
|
|
44
|
+
}
|
|
45
|
+
}, [scope])
|
|
46
|
+
|
|
47
|
+
const snap = (React.useSyncExternalStore
|
|
48
|
+
? React.useSyncExternalStore(subscribe, getSnapshot, React.useCallback(() => SNAPSHOT_LOADING, []))
|
|
49
|
+
: null) || (scope && scope.getSnapshot ? scope.getSnapshot() : SNAPSHOT_LOADING)
|
|
50
|
+
|
|
51
|
+
// Snapshot status matters more than the value:
|
|
52
|
+
// loading — host has not answered yet;
|
|
53
|
+
// unavailable — host answered but the settings namespace is not ready;
|
|
54
|
+
// ready — values are present.
|
|
55
|
+
// On unavailable, writable defaults true; without a status check the card
|
|
56
|
+
// looks like a working empty form.
|
|
57
|
+
const ready = !scope || (!!snap && snap.status === 'ready')
|
|
58
|
+
const writable = !scope ? true : (ready && snap.writable !== false)
|
|
59
|
+
|
|
60
|
+
const applyPayload = (data) => {
|
|
61
|
+
const cfg = data && data.config ? data.config : {}
|
|
62
|
+
setDraft(JSON.parse(JSON.stringify(cfg)))
|
|
63
|
+
setCredentials(data && data.credentials ? data.credentials : {})
|
|
64
|
+
player.enabled = !!cfg.speakReplies
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
let alive = true
|
|
69
|
+
fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
|
|
70
|
+
if (!alive) return
|
|
71
|
+
applyPayload(data)
|
|
72
|
+
}).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
|
|
73
|
+
|
|
74
|
+
fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
|
|
75
|
+
if (!alive) return
|
|
76
|
+
if (data && data.ok) setIntegrations(data)
|
|
77
|
+
}).catch(() => {})
|
|
78
|
+
|
|
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
|
|
104
|
+
|
|
105
|
+
return () => { alive = false; clearInterval(telTimer) }
|
|
106
|
+
}, [])
|
|
107
|
+
|
|
108
|
+
if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
|
|
109
|
+
|
|
110
|
+
const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
|
|
111
|
+
const setDraftKey = (provider, value) => setKeyDrafts((d) => Object.assign({}, d, { [provider]: value }))
|
|
112
|
+
|
|
113
|
+
const commitKey = async (provider, raw) => {
|
|
114
|
+
const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
|
|
115
|
+
if (!value) return
|
|
116
|
+
setErr('')
|
|
117
|
+
const res = await fetch('/dsh-tts/credential', {
|
|
118
|
+
method: 'PUT',
|
|
119
|
+
headers: { 'Content-Type': 'application/json' },
|
|
120
|
+
body: JSON.stringify({ provider: provider, value: value }),
|
|
121
|
+
})
|
|
122
|
+
const data = await res.json().catch(() => ({}))
|
|
123
|
+
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
|
|
124
|
+
if (data && data.credentials) setCredentials(data.credentials)
|
|
125
|
+
setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const clearKey = async (provider) => {
|
|
129
|
+
setErr('')
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch('/dsh-tts/credential', {
|
|
132
|
+
method: 'DELETE',
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
body: JSON.stringify({ provider: provider }),
|
|
135
|
+
})
|
|
136
|
+
const data = await res.json().catch(() => ({}))
|
|
137
|
+
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
|
|
138
|
+
if (data && data.credentials) setCredentials(data.credentials)
|
|
139
|
+
setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
|
|
140
|
+
} catch (e) { setErr(String(e && e.message ? e.message : e)) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const save = async () => {
|
|
144
|
+
setErr(''); setSaved(false)
|
|
145
|
+
if (!draft) return
|
|
146
|
+
try {
|
|
147
|
+
const keys = {}
|
|
148
|
+
Object.keys(keyDrafts || {}).forEach((p) => {
|
|
149
|
+
const v = String(keyDrafts[p] || '').trim()
|
|
150
|
+
if (v) keys[p] = v
|
|
151
|
+
})
|
|
152
|
+
const res = await fetch('/dsh-tts/config', {
|
|
153
|
+
method: 'PUT',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
|
|
156
|
+
})
|
|
157
|
+
const data = await res.json().catch(() => ({}))
|
|
158
|
+
if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
|
|
159
|
+
applyPayload(data)
|
|
160
|
+
setKeyDrafts({})
|
|
161
|
+
setSaved(true); setTimeout(() => setSaved(false), 2000)
|
|
162
|
+
} catch (e) { setErr(String(e && e.message ? e.message : e)) }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const onCommitKey = (provider, raw) => {
|
|
166
|
+
commitKey(provider, raw).catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const clearCacheNow = async () => {
|
|
170
|
+
setErr('')
|
|
171
|
+
const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
|
|
172
|
+
if (!res.ok) throw new Error('HTTP ' + res.status)
|
|
173
|
+
setSaved(true); setTimeout(() => setSaved(false), 2000)
|
|
174
|
+
}
|
|
175
|
+
const onClearCache = () => {
|
|
176
|
+
if (typeof window !== 'undefined' && window.confirm) {
|
|
177
|
+
if (!window.confirm(t('confirmClearCache'))) return
|
|
178
|
+
}
|
|
179
|
+
clearCacheNow().catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const onPreview = (provider, model, voice, text) => {
|
|
183
|
+
fetch('/dsh-tts/preview', {
|
|
184
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
185
|
+
body: JSON.stringify({ provider, model, voice, text }),
|
|
186
|
+
})
|
|
187
|
+
.then((r) => r.json())
|
|
188
|
+
.then((d) => {
|
|
189
|
+
if (!d.ok) throw new Error((d.error && d.error.message) || 'HTTP error')
|
|
190
|
+
new Audio('data:' + d.mime + ';base64,' + d.audioBase64).play().catch(() => {})
|
|
191
|
+
})
|
|
192
|
+
.catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const onPreviewPhrase = (phrase) => {
|
|
196
|
+
if (!phrase) return
|
|
197
|
+
const first = draft && draft.chain && draft.chain[0]
|
|
198
|
+
onPreview((first && first.provider) || 'kokoro', (first && first.model) || '', (first && first.voice) || '', phrase)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const boolField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
|
|
202
|
+
React.createElement('input', {
|
|
203
|
+
type: 'checkbox', checked: !!(draft && draft[key]), disabled: !writable,
|
|
204
|
+
onChange: (e) => setTop(key, e.target.checked),
|
|
205
|
+
}),
|
|
206
|
+
React.createElement('span', { className: 'dts-sub' }, hint))
|
|
207
|
+
|
|
208
|
+
const numberField = (key, label, hint, step) => React.createElement('label', { className: 'dts-field' }, label,
|
|
209
|
+
React.createElement('input', {
|
|
210
|
+
type: 'number', step: step || 1,
|
|
211
|
+
className: 'dts-input',
|
|
212
|
+
value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
|
|
213
|
+
onChange: (e) => setTop(key, Number(e.target.value)),
|
|
214
|
+
}),
|
|
215
|
+
React.createElement('span', { className: 'dts-sub' }, hint))
|
|
216
|
+
|
|
217
|
+
const textField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
|
|
218
|
+
React.createElement('input', {
|
|
219
|
+
className: 'dts-input',
|
|
220
|
+
value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
|
|
221
|
+
onChange: (e) => setTop(key, e.target.value),
|
|
222
|
+
}),
|
|
223
|
+
React.createElement('span', { className: 'dts-sub' }, hint))
|
|
224
|
+
|
|
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' },
|
|
237
|
+
React.createElement('span', {
|
|
238
|
+
className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
|
|
239
|
+
}, telemetry.online ? t('statusOnline') : t('statusOffline')),
|
|
240
|
+
React.createElement('span', {
|
|
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,
|
|
250
|
+
)
|
|
251
|
+
const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
|
|
252
|
+
React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
|
|
253
|
+
React.createElement('div', { className: 'dts-page-sub' }, t('subtitle')),
|
|
254
|
+
telemetryBadges,
|
|
255
|
+
)
|
|
256
|
+
return React.createElement('div', { className: 'dts-wrap' },
|
|
257
|
+
autoplayBanner,
|
|
258
|
+
React.createElement('div', { className: 'dts-block' },
|
|
259
|
+
titleLine,
|
|
260
|
+
React.createElement('label', { className: 'dts-row' },
|
|
261
|
+
React.createElement('input', {
|
|
262
|
+
type: 'checkbox', checked: !!(draft && draft.speakReplies), disabled: !writable,
|
|
263
|
+
onChange: (e) => setTop('speakReplies', e.target.checked),
|
|
264
|
+
}),
|
|
265
|
+
React.createElement('span', null, t('speakReplies')),
|
|
266
|
+
),
|
|
267
|
+
React.createElement('div', { className: 'dts-sub' },
|
|
268
|
+
t('speakRepliesHint')),
|
|
269
|
+
),
|
|
270
|
+
React.createElement(LocalEnginesEditor, {
|
|
271
|
+
t: t, writable: writable, draft: draft,
|
|
272
|
+
boolField: boolField, setTop: setTop,
|
|
273
|
+
}),
|
|
274
|
+
React.createElement(VoiceDuplexEditor, {
|
|
275
|
+
t: t, writable: writable, draft: draft,
|
|
276
|
+
boolField: boolField, setTop: setTop,
|
|
277
|
+
installed: !!(integrations && integrations.voice && integrations.voice.installed),
|
|
278
|
+
}),
|
|
279
|
+
React.createElement(MessengerIntegrationEditor, {
|
|
280
|
+
t: t, writable: writable, draft: draft,
|
|
281
|
+
boolField: boolField, setTop: setTop,
|
|
282
|
+
installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
|
|
283
|
+
}),
|
|
284
|
+
React.createElement('div', { className: 'dts-block' },
|
|
285
|
+
React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
|
|
286
|
+
boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
|
|
287
|
+
React.createElement(PronEditor, {
|
|
288
|
+
t: t, writable: writable,
|
|
289
|
+
value: draft && draft.pronunciation ? draft.pronunciation : [],
|
|
290
|
+
onChange: (v) => setTop('pronunciation', v),
|
|
291
|
+
onPreviewPhrase: onPreviewPhrase,
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
React.createElement('div', { className: 'dts-block' },
|
|
295
|
+
React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
|
|
296
|
+
React.createElement(RolesEditor, {
|
|
297
|
+
t: t, writable: writable, onPreview: onPreview,
|
|
298
|
+
value: draft && draft.roles ? draft.roles : {},
|
|
299
|
+
onChange: (v) => setTop('roles', v),
|
|
300
|
+
}),
|
|
301
|
+
),
|
|
302
|
+
React.createElement('div', { className: 'dts-block' },
|
|
303
|
+
React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
|
|
304
|
+
React.createElement(StatsPanel, { t: t }),
|
|
305
|
+
),
|
|
306
|
+
React.createElement('div', { className: 'dts-block' },
|
|
307
|
+
React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
|
|
308
|
+
React.createElement('div', { className: 'dts-sub' },
|
|
309
|
+
t('chainHintCard')),
|
|
310
|
+
React.createElement(ChainEditor, {
|
|
311
|
+
breaker: telemetry.breaker,
|
|
312
|
+
t: t,
|
|
313
|
+
value: draft && draft.chain ? draft.chain : [], writable: writable,
|
|
314
|
+
credentials: credentials, keyDrafts: keyDrafts,
|
|
315
|
+
onChange: (v) => setTop('chain', v),
|
|
316
|
+
onDraft: setDraftKey,
|
|
317
|
+
onCommitKey: onCommitKey,
|
|
318
|
+
onClearKey: clearKey,
|
|
319
|
+
onPreview: onPreview,
|
|
320
|
+
}),
|
|
321
|
+
),
|
|
322
|
+
React.createElement('div', { className: 'dts-block' },
|
|
323
|
+
React.createElement('div', { className: 'dts-h' }, t('general')),
|
|
324
|
+
boolField('speakAsItGoes', t('speakAsItGoes'),
|
|
325
|
+
t('speakAsItGoesHint')),
|
|
326
|
+
numberField('rate', t('rate'), t('rateHint'), 0.1),
|
|
327
|
+
boolField('bargeIn', t('bargeIn'),
|
|
328
|
+
t('bargeInHint')),
|
|
329
|
+
boolField('announceApproval', t('announce'),
|
|
330
|
+
t('announceHint')),
|
|
331
|
+
boolField('skipCode', t('skipCode'),
|
|
332
|
+
t('skipCodeHint')),
|
|
333
|
+
boolField('cache', t('cache'),
|
|
334
|
+
t('cacheHint')),
|
|
335
|
+
numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
|
|
336
|
+
React.createElement('label', { className: 'dts-field' }, t('longReply'),
|
|
337
|
+
React.createElement('select', {
|
|
338
|
+
className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
|
|
339
|
+
onChange: (e) => setTop('longReply', e.target.value),
|
|
340
|
+
}, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
|
|
341
|
+
React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
|
|
342
|
+
textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
|
|
343
|
+
numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
|
|
344
|
+
boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
|
|
345
|
+
boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
|
|
346
|
+
boolField('skipActions', t('skipActions'), t('skipActionsHint')),
|
|
347
|
+
textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
|
|
348
|
+
textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
|
|
349
|
+
textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
|
|
350
|
+
textField('approvalText', t('approvalText'), t('approvalHint')),
|
|
351
|
+
textField('questionText', t('questionText'), t('questionHint')),
|
|
352
|
+
textField('chime', t('chime'), t('chimeHint')),
|
|
353
|
+
textField('language', t('language'), t('languageHint')),
|
|
354
|
+
textField('piperModel', t('piperModel'), t('piperModelHint')),
|
|
355
|
+
textField('piperBin', t('piperBin'), t('binHint')),
|
|
356
|
+
textField('espeakBin', t('espeakBin'), t('espeakHint')),
|
|
357
|
+
textField('edgeBin', t('edgeBin'), t('edgeHint')),
|
|
358
|
+
textField('azureRegion', t('azureRegion'), t('azureHint')),
|
|
359
|
+
),
|
|
360
|
+
React.createElement('div', { className: 'dts-block' },
|
|
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,
|
|
388
|
+
),
|
|
389
|
+
React.createElement('div', { className: 'dts-foot' },
|
|
390
|
+
React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
|
|
391
|
+
React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
|
|
392
|
+
saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
|
|
393
|
+
err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
|
|
394
|
+
),
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
function TtsCard(props) {
|
|
2
|
+
const t = (props && props.t) || ((key) => key)
|
|
3
|
+
const [open, setOpen] = React.useState(false)
|
|
4
|
+
return React.createElement('li', { className: 'dts-card' },
|
|
5
|
+
React.createElement('button', {
|
|
6
|
+
type: 'button',
|
|
7
|
+
className: 'dts-head',
|
|
8
|
+
onClick: () => setOpen(!open),
|
|
9
|
+
'aria-expanded': open,
|
|
10
|
+
},
|
|
11
|
+
React.createElement('div', { className: 'dts-headText' },
|
|
12
|
+
React.createElement('div', { className: 'dts-title' }, t('title')),
|
|
13
|
+
React.createElement('div', { className: 'dts-sub' }, t('cardHint')),
|
|
14
|
+
),
|
|
15
|
+
ChevronIcon
|
|
16
|
+
? React.createElement(ChevronIcon, { className: 'dts-chev' + (open ? ' dts-chevOpen' : '') })
|
|
17
|
+
: React.createElement('svg', {
|
|
18
|
+
className: 'dts-chev' + (open ? ' dts-chevOpen' : ''),
|
|
19
|
+
width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none',
|
|
20
|
+
},
|
|
21
|
+
React.createElement('path', {
|
|
22
|
+
d: 'M4 6l4 4 4-4', stroke: 'currentColor', 'stroke-width': 1.5,
|
|
23
|
+
'stroke-linecap': 'round', 'stroke-linejoin': 'round',
|
|
24
|
+
}),
|
|
25
|
+
),
|
|
26
|
+
),
|
|
27
|
+
open ? React.createElement('div', { className: 'dts-body' },
|
|
28
|
+
React.createElement(ErrorBoundary, null,
|
|
29
|
+
React.createElement(TtsSection, Object.assign({}, props, { compact: true, ctx: props && props.ctx })),
|
|
30
|
+
),
|
|
31
|
+
) : null,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Input-dock button: shows what is playing, stop control, and recents for replay.
|
|
36
|
+
function SpeakerControl(props) {
|
|
37
|
+
// A slot with `locale` injects translations into props; the fallback is the
|
|
38
|
+
// binder created at registration, in case the slot drops translations.
|
|
39
|
+
const t = (props && props.t) || fallbackDockText
|
|
40
|
+
const p = usePlayer()
|
|
41
|
+
const [showList, setShowList] = React.useState(false)
|
|
42
|
+
const active = !!p.audio || p.queue.length > 0 || p.busy
|
|
43
|
+
const [, force] = React.useReducer((n) => n + 1, 0)
|
|
44
|
+
if (!active && !showList) return null
|
|
45
|
+
const lists = recent.list()
|
|
46
|
+
const row = (text, starred) => React.createElement('div', { className: 'dts-row', key: text.slice(0, 24) + starred },
|
|
47
|
+
React.createElement('button', {
|
|
48
|
+
type: 'button', className: 'dts-link', style: { flex: 1, textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
|
49
|
+
title: text,
|
|
50
|
+
onClick: () => playStandalone(text), title: text,
|
|
51
|
+
}, text.slice(0, 80)),
|
|
52
|
+
React.createElement('button', {
|
|
53
|
+
type: 'button', className: 'dts-link', title: '★',
|
|
54
|
+
onClick: () => { recent.toggleFav(text); force() },
|
|
55
|
+
}, starred ? '★' : '☆'),
|
|
56
|
+
)
|
|
57
|
+
return React.createElement('div', { style: { position: 'relative', display: 'inline-flex', gap: '4px', alignItems: 'center' } },
|
|
58
|
+
React.createElement('button', {
|
|
59
|
+
type: 'button', className: 'dts-link', title: t('recent') || 'Recent',
|
|
60
|
+
onClick: () => setShowList((v) => !v),
|
|
61
|
+
}, '☰'),
|
|
62
|
+
showList ? React.createElement('div', {
|
|
63
|
+
className: 'dts-wrap',
|
|
64
|
+
style: { position: 'absolute', bottom: '36px', right: 0, zIndex: 30, maxHeight: '320px', overflowY: 'auto', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '12px', padding: '10px 12px', minWidth: '260px', flexDirection: 'column', gap: '6px', display: 'flex' },
|
|
65
|
+
},
|
|
66
|
+
lists.favs.length ? React.createElement('div', { className: 'dts-h' }, '★') : null,
|
|
67
|
+
lists.favs.map((text) => row(text, true)),
|
|
68
|
+
React.createElement('div', { className: 'dts-h' }, '⟳'),
|
|
69
|
+
lists.items.filter((x) => !lists.favs.includes(x)).map((text) => row(text, false)),
|
|
70
|
+
!lists.items.length ? React.createElement('span', { className: 'dts-sub' }, t('recentEmpty') || 'No recent speech yet') : null,
|
|
71
|
+
) : null,
|
|
72
|
+
React.createElement('button', {
|
|
73
|
+
type: 'button',
|
|
74
|
+
className: 'dts-link',
|
|
75
|
+
title: p.paused ? t('dockResume') : t('dockPause'),
|
|
76
|
+
onClick: togglePause,
|
|
77
|
+
}, p.paused ? '▶' : '❚❚'),
|
|
78
|
+
React.createElement('button', {
|
|
79
|
+
type: 'button',
|
|
80
|
+
className: 'dts-link',
|
|
81
|
+
title: t('dockStop'),
|
|
82
|
+
onClick: stopPlayback,
|
|
83
|
+
}, '■'),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Fallback translator for the dock.
|
|
89
|
+
*
|
|
90
|
+
* Bound at registration for the page lifetime. The core draws the slot label;
|
|
91
|
+
* props.t never reaches it, so the binder is the only way to give it a language.
|
|
92
|
+
*/
|
|
93
|
+
let fallbackDockText = (key) => key
|
|
94
|
+
|
|
95
|
+
function registerSpeaker(ctx) {
|
|
96
|
+
// `locale` in the slot descriptor is how core injects translations into props.
|
|
97
|
+
// Without it the dock would show raw keys only.
|
|
98
|
+
fallbackDockText = ctx.locale.bind(NS)
|
|
99
|
+
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
|
|
100
|
+
{
|
|
101
|
+
name: 'conversation.input.dock',
|
|
102
|
+
id: '@goodandready/dsh-tts',
|
|
103
|
+
locale: NS,
|
|
104
|
+
order: 5,
|
|
105
|
+
label: () => fallbackDockText('dockLabel'),
|
|
106
|
+
},
|
|
107
|
+
SpeakerControl,
|
|
108
|
+
))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function registerSettings(ctx) {
|
|
112
|
+
// Dictionary packages may also register languages for other namespaces.
|
|
113
|
+
// Core throws on duplicate namespace+language; an unguarded call used to
|
|
114
|
+
// take down the whole plugin ("Failed to load plugins"). Register each
|
|
115
|
+
// language separately: skip if taken; English still lands.
|
|
116
|
+
const addLocale = (locale, dictionary) => {
|
|
117
|
+
try {
|
|
118
|
+
return ctx.locale.register(NS, locale, dictionary)
|
|
119
|
+
} catch (alreadyTaken) {
|
|
120
|
+
return () => {}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
ctx.effect(() => {
|
|
124
|
+
const undo = [addLocale('en', en), addLocale('ru', ru)]
|
|
125
|
+
return () => { for (const off of undo) off() }
|
|
126
|
+
}, 'dsh-tts: locales')
|
|
127
|
+
// The sidebar draws the section label, not our component: props.t never
|
|
128
|
+
// reaches it, so bind the translator to the namespace ourselves.
|
|
129
|
+
const t = ctx.locale.bind(NS)
|
|
130
|
+
// Primary settings home is the plugin-settings tab card. The tab looks up
|
|
131
|
+
// a slot by entryKey equal to the namespace name: key must equal NS or
|
|
132
|
+
// the card never appears, with no log error.
|
|
133
|
+
const tryPluginItem = () => {
|
|
134
|
+
try {
|
|
135
|
+
const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
|
|
136
|
+
{
|
|
137
|
+
name: 'settings.plugin.item',
|
|
138
|
+
key: NS,
|
|
139
|
+
locale: NS,
|
|
140
|
+
inject: () => ({ ctx: ctx }),
|
|
141
|
+
},
|
|
142
|
+
TtsCard,
|
|
143
|
+
))
|
|
144
|
+
if (res === false) return false
|
|
145
|
+
return true
|
|
146
|
+
} catch (noPluginItemSlot) {
|
|
147
|
+
return false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (tryPluginItem()) return
|
|
151
|
+
// Fallback for builds without settings.plugin.item: keep the sidebar section
|
|
152
|
+
// so settings cannot disappear.
|
|
153
|
+
ctx.slots.inject('settings.section', () => ctx.slots.register(
|
|
154
|
+
{
|
|
155
|
+
name: 'settings.section',
|
|
156
|
+
id: '@goodandready/dsh-tts',
|
|
157
|
+
order: 32,
|
|
158
|
+
locale: NS,
|
|
159
|
+
label: () => t('title'),
|
|
160
|
+
inject: () => ({ ctx: ctx }),
|
|
161
|
+
},
|
|
162
|
+
(props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
|
|
163
|
+
))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
exports.inject = ['slots', 'settingsScope', 'locale']
|
|
167
|
+
exports.apply = function apply(ctx) {
|
|
168
|
+
registerSettings(ctx)
|
|
169
|
+
registerSpeaker(ctx)
|
|
170
|
+
ctx.effect(() => listenForVoice(), 'dsh-tts: barge-in on user speech')
|
|
171
|
+
ctx.effect(() => {
|
|
172
|
+
if (typeof window === 'undefined') return () => {}
|
|
173
|
+
const onKey = (e) => {
|
|
174
|
+
if (e.ctrlKey && e.key === 'Escape') { togglePause(); e.preventDefault() }
|
|
175
|
+
else if (e.altKey && (e.key === 's' || e.key === 'S' || e.key === 'ы')) { stopPlayback(); e.preventDefault() }
|
|
176
|
+
else if (e.altKey && e.key === 'ArrowRight') { skipCurrent(); e.preventDefault() }
|
|
177
|
+
}
|
|
178
|
+
window.addEventListener('keydown', onKey)
|
|
179
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
180
|
+
}, 'dsh-tts: hotkeys')
|
|
181
|
+
ctx.effect(() => {
|
|
182
|
+
if (typeof window === 'undefined' || typeof EventSource === 'undefined') return () => {}
|
|
183
|
+
let es = null
|
|
184
|
+
let closed = false
|
|
185
|
+
let attempt = 0
|
|
186
|
+
let retryTimer = null
|
|
187
|
+
const onItem = (e) => {
|
|
188
|
+
try {
|
|
189
|
+
const item = JSON.parse(e.data)
|
|
190
|
+
if (!item || !item.id) return
|
|
191
|
+
if (seenIds.has(item.id)) return
|
|
192
|
+
seenIds.add(item.id)
|
|
193
|
+
if (seenIds.size > 500) {
|
|
194
|
+
const first = seenIds.values().next().value
|
|
195
|
+
seenIds.delete(first)
|
|
196
|
+
}
|
|
197
|
+
player.queue.push(item)
|
|
198
|
+
drainQueue()
|
|
199
|
+
} catch { /* ignore malformed SSE frame */ }
|
|
200
|
+
}
|
|
201
|
+
const connect = () => {
|
|
202
|
+
if (closed) return
|
|
203
|
+
try {
|
|
204
|
+
es = new EventSource('/dsh-tts/stream')
|
|
205
|
+
es.addEventListener('open', () => { attempt = 0 })
|
|
206
|
+
es.addEventListener('utterance', onItem)
|
|
207
|
+
es.addEventListener('chime', onItem)
|
|
208
|
+
es.onerror = () => {
|
|
209
|
+
try { es.close() } catch { /* already closed */ }
|
|
210
|
+
if (closed) return
|
|
211
|
+
attempt += 1
|
|
212
|
+
const delay = Math.min(15000, 500 * Math.pow(2, Math.min(attempt, 5)))
|
|
213
|
+
retryTimer = setTimeout(connect, delay)
|
|
214
|
+
}
|
|
215
|
+
} catch { /* EventSource constructor failed — poll remains */ }
|
|
216
|
+
}
|
|
217
|
+
connect()
|
|
218
|
+
return () => {
|
|
219
|
+
closed = true
|
|
220
|
+
if (retryTimer) clearTimeout(retryTimer)
|
|
221
|
+
if (es) { try { es.close() } catch { /* already closed */ } }
|
|
222
|
+
}
|
|
223
|
+
}, 'dsh-tts: SSE audio stream')
|
|
224
|
+
ctx.effect(() => {
|
|
225
|
+
const timer = setInterval(pollPending, 1000)
|
|
226
|
+
return () => clearInterval(timer)
|
|
227
|
+
}, 'dsh-tts: poll pending audio')
|
|
228
|
+
}
|
|
229
|
+
return module.exports
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ModelManager UI placeholder - manual install buttons for Kokoro/F5
|
|
234
|
+
// Actual implementation would show download progress and status
|
|
235
|
+
|