@goodandready/dsh-tts 0.3.23 → 0.4.0
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 +12 -12
- package/README.ru.md +13 -10
- package/README.zh.md +10 -10
- package/lib/cache.js +4 -5
- package/lib/client.js +69 -81
- package/lib/engines/f5.js +45 -33
- package/lib/engines/kokoro.js +21 -29
- package/lib/engines/types.js +26 -13
- package/lib/http-util.js +27 -0
- package/lib/index.js +41 -455
- package/lib/providers.js +29 -18
- package/lib/routes.js +400 -0
- package/lib/stream-hub.js +31 -0
- package/lib/text.js +19 -19
- package/package.json +5 -2
- package/scripts/f5_daemon.py +40 -0
package/lib/index.js
CHANGED
|
@@ -5,11 +5,12 @@ import { runChain } from './chain.js'
|
|
|
5
5
|
import { makeProviders, PROVIDER_KEYS, DEFAULT_MODELS, DEFAULT_VOICES } from './providers.js'
|
|
6
6
|
import { applyNarrationFilters, applyPronunciation, assistantText, detectLang, speechPhrases, splitSentences, stripForSpeech } from './text.js'
|
|
7
7
|
import { cacheKey, createSpeechCache } from './cache.js'
|
|
8
|
-
import fs from 'node:fs'
|
|
9
8
|
import os from 'node:os'
|
|
10
9
|
import path from 'node:path'
|
|
11
10
|
import { createModelManager } from './engines/manager.js'
|
|
12
|
-
import {
|
|
11
|
+
import { registerHttpRoutes } from './routes.js'
|
|
12
|
+
import { broadcastStream, clearStreamSubscribers } from './stream-hub.js'
|
|
13
|
+
import { writeJson } from './http-util.js'
|
|
13
14
|
import {
|
|
14
15
|
CLOUD_PROVIDERS,
|
|
15
16
|
assertCredentialRef,
|
|
@@ -173,32 +174,6 @@ export const Config = z.object({
|
|
|
173
174
|
maxQueue: z.number().default(8),
|
|
174
175
|
})
|
|
175
176
|
|
|
176
|
-
function writeJson(res, code, body) {
|
|
177
|
-
try {
|
|
178
|
-
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
|
|
179
|
-
res.end(JSON.stringify(body))
|
|
180
|
-
} catch { /* socket closed */ }
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function readBody(req, maxBytes) {
|
|
184
|
-
return new Promise((resolve, reject) => {
|
|
185
|
-
const chunks = []
|
|
186
|
-
let size = 0
|
|
187
|
-
req.on('data', (c) => {
|
|
188
|
-
size += c.length
|
|
189
|
-
if (size > maxBytes) { reject(new Error('body too large')); req.destroy(); return }
|
|
190
|
-
chunks.push(c)
|
|
191
|
-
})
|
|
192
|
-
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
193
|
-
req.on('error', reject)
|
|
194
|
-
})
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** Reject cross-site POSTs. Do not require loopback or Origin==Host: the Web UI is used over LAN and reverse proxies. */
|
|
198
|
-
function isTrustedSettingsRequest(request) {
|
|
199
|
-
return request.headers['sec-fetch-site'] !== 'cross-site'
|
|
200
|
-
}
|
|
201
|
-
|
|
202
177
|
let utteranceSeq = 0
|
|
203
178
|
|
|
204
179
|
export function apply(ctx, config) {
|
|
@@ -218,19 +193,11 @@ export function apply(ctx, config) {
|
|
|
218
193
|
|
|
219
194
|
const pending = []
|
|
220
195
|
const collectors = new Map()
|
|
221
|
-
const streamSubscribers = new Set()
|
|
222
196
|
|
|
223
197
|
const modelManager = createModelManager({
|
|
224
198
|
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
225
199
|
})
|
|
226
200
|
|
|
227
|
-
function broadcastStream(event, data) {
|
|
228
|
-
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
|
229
|
-
for (const res of streamSubscribers) {
|
|
230
|
-
try { res.write(payload) } catch { streamSubscribers.delete(res) }
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
201
|
async function resolveKey(ref) {
|
|
235
202
|
try {
|
|
236
203
|
const resolved = await ctx.credentials.resolve(credentialRef(ref))
|
|
@@ -306,13 +273,13 @@ export function apply(ctx, config) {
|
|
|
306
273
|
|
|
307
274
|
const llm = ctx.llm
|
|
308
275
|
|
|
309
|
-
//
|
|
276
|
+
// In-memory synthesis counters; reset on restart or DELETE /stats.
|
|
310
277
|
const stats = { total: 0, cacheHits: 0, errors: 0, providers: {} }
|
|
311
278
|
|
|
312
|
-
//
|
|
279
|
+
// In-flight synthesis deduplication (cache-stampede protection)
|
|
313
280
|
const inFlightSyntheses = new Map()
|
|
314
281
|
|
|
315
|
-
//
|
|
282
|
+
// Synthesis cache lives next to other harness data.
|
|
316
283
|
const speechCache = createSpeechCache({
|
|
317
284
|
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
318
285
|
maxBytes: () => {
|
|
@@ -320,11 +287,11 @@ export function apply(ctx, config) {
|
|
|
320
287
|
return mb > 0 ? mb * 1024 * 1024 : 100 * 1024 * 1024
|
|
321
288
|
},
|
|
322
289
|
})
|
|
323
|
-
//
|
|
324
|
-
//
|
|
290
|
+
// Cache only short repeatable phrases; long unique streaming chunks skip the cache.
|
|
291
|
+
// Otherwise the cache fills with garbage that never repeats.
|
|
325
292
|
const CACHE_MAX_TEXT = 200
|
|
326
293
|
|
|
327
|
-
//
|
|
294
|
+
// Text-cleaning options are read per call because config is live.
|
|
328
295
|
function cleanOpts(cfg) {
|
|
329
296
|
return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
|
|
330
297
|
}
|
|
@@ -337,13 +304,13 @@ export function apply(ctx, config) {
|
|
|
337
304
|
)
|
|
338
305
|
}
|
|
339
306
|
|
|
340
|
-
//
|
|
341
|
-
//
|
|
307
|
+
// Fast-model summary. Any failure returns an empty string and the caller
|
|
308
|
+
// silently falls back to truncation.
|
|
342
309
|
async function summarizeReply(text, cfg, signal) {
|
|
343
310
|
const sentences = Number(cfg.summarySentences) > 0 ? Number(cfg.summarySentences) : 3
|
|
344
311
|
if (!llm || typeof llm.stream !== 'function') return ''
|
|
345
312
|
const opts = {
|
|
346
|
-
messages: [{ role: 'user', content: '
|
|
313
|
+
messages: [{ role: 'user', content: 'Summarize the text in ' + sentences + ' sentences in the same language as the text. Summary only, no preamble:\n\n' + text }],
|
|
347
314
|
signal,
|
|
348
315
|
}
|
|
349
316
|
const m = String(cfg.summaryModel || '')
|
|
@@ -384,15 +351,15 @@ export function apply(ctx, config) {
|
|
|
384
351
|
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
|
|
385
352
|
voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
|
|
386
353
|
}
|
|
387
|
-
//
|
|
354
|
+
// Role overrides: empty fields inherit the main chain.
|
|
388
355
|
const ov = (cfg.roles && cfg.roles[role]) || {}
|
|
389
356
|
if (ov.provider && PROVIDER_KEYS.includes(ov.provider)) order.unshift(ov.provider)
|
|
390
357
|
if (order.length) {
|
|
391
358
|
if (ov.model) models[order[0]] = ov.model
|
|
392
359
|
if (ov.voice) voices[order[0]] = ov.voice
|
|
393
360
|
}
|
|
394
|
-
//
|
|
395
|
-
//
|
|
361
|
+
// Playback rate is browser-side only and is not part of the cache key.
|
|
362
|
+
// It does not affect synthesis.
|
|
396
363
|
const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
|
|
397
364
|
if (cacheAllowed) {
|
|
398
365
|
for (const provider of order) {
|
|
@@ -436,9 +403,8 @@ export function apply(ctx, config) {
|
|
|
436
403
|
return synthPromise
|
|
437
404
|
}
|
|
438
405
|
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
// обгоняли бы длинные.
|
|
406
|
+
// Synthesize one phrase with queue reservation. The id is issued immediately,
|
|
407
|
+
// not when the provider answers, so short phrases cannot overtake long ones.
|
|
442
408
|
function speakPiece(sid, text, cfg, kind) {
|
|
443
409
|
if (!text) return
|
|
444
410
|
const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : (kind && kind !== 'speech' ? kind : 'reply')
|
|
@@ -465,8 +431,8 @@ export function apply(ctx, config) {
|
|
|
465
431
|
.finally(() => clearTimeout(timer))
|
|
466
432
|
}
|
|
467
433
|
|
|
468
|
-
//
|
|
469
|
-
//
|
|
434
|
+
// Announcement: short chime then phrase. The chime is drawn by the browser,
|
|
435
|
+
// so nothing is downloaded or stored.
|
|
470
436
|
function roleChime(cfg, role) {
|
|
471
437
|
const ov = (cfg.roles && cfg.roles[role]) || {}
|
|
472
438
|
return ov.chime || cfg.chime
|
|
@@ -496,8 +462,8 @@ export function apply(ctx, config) {
|
|
|
496
462
|
}
|
|
497
463
|
}
|
|
498
464
|
|
|
499
|
-
//
|
|
500
|
-
//
|
|
465
|
+
// Reserve the queue slot before synthesis so phrase order matches
|
|
466
|
+
// speech order, not arrival race.
|
|
501
467
|
function reserve(id) {
|
|
502
468
|
enqueue({ id, kind: 'reserved' })
|
|
503
469
|
}
|
|
@@ -516,8 +482,8 @@ export function apply(ctx, config) {
|
|
|
516
482
|
if (!cfg.speakReplies) return
|
|
517
483
|
const sid = session && session.id
|
|
518
484
|
if (!sid) return
|
|
519
|
-
//
|
|
520
|
-
//
|
|
485
|
+
// The agent is waiting for human approval — worth speaking aloud
|
|
486
|
+
// so the user notices without watching the screen.
|
|
521
487
|
if (event.type === 'approval/asked') {
|
|
522
488
|
if (!cfg.announceApproval) return
|
|
523
489
|
const tool = event.data && event.data.toolName
|
|
@@ -535,9 +501,9 @@ export function apply(ctx, config) {
|
|
|
535
501
|
const agentName = (event.data && (event.data.agent || (event.data.message && event.data.message.agent) || (event.data.message && event.data.message.author && event.data.message.author.name))) || (session && session.agent) || ''
|
|
536
502
|
const activeRole = (agentName && cfg.roles && cfg.roles[agentName] && cfg.roles[agentName].provider) ? agentName : 'reply'
|
|
537
503
|
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
504
|
+
// Speak as it goes: each incoming chunk is spoken immediately
|
|
505
|
+
// in sentence pieces; waiting for turn end means silence while the agent works.
|
|
506
|
+
|
|
541
507
|
if (cfg.speakAsItGoes) {
|
|
542
508
|
const ready = cleanText(text, cfg)
|
|
543
509
|
for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
|
|
@@ -550,7 +516,7 @@ export function apply(ctx, config) {
|
|
|
550
516
|
return
|
|
551
517
|
}
|
|
552
518
|
if (event.type !== 'turn/end') return
|
|
553
|
-
//
|
|
519
|
+
// With speak-as-it-goes everything was already spoken by turn end.
|
|
554
520
|
if (cfg.speakAsItGoes) { collectors.delete(sid); return }
|
|
555
521
|
const cur = collectors.get(sid)
|
|
556
522
|
collectors.delete(sid)
|
|
@@ -581,405 +547,25 @@ export function apply(ctx, config) {
|
|
|
581
547
|
.finally(() => clearTimeout(timer))
|
|
582
548
|
}), 'dsh-tts: speak finished replies')
|
|
583
549
|
|
|
584
|
-
ctx.effect(() => ctx.webServer.register({
|
|
585
|
-
kind: 'exact',
|
|
586
|
-
path: '/dsh-tts/status',
|
|
587
|
-
handler: async (req, res) => {
|
|
588
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
589
|
-
const cfg = live()
|
|
590
|
-
writeJson(res, 200, {
|
|
591
|
-
ok: true,
|
|
592
|
-
speakReplies: cfg.speakReplies,
|
|
593
|
-
enableLocalEngines: cfg.enableLocalEngines,
|
|
594
|
-
kokoroEnabled: cfg.kokoroEnabled,
|
|
595
|
-
f5Enabled: cfg.f5Enabled,
|
|
596
|
-
streamingEnabled: cfg.streamingEnabled,
|
|
597
|
-
rate: cfg.rate,
|
|
598
|
-
chime: cfg.chime,
|
|
599
|
-
bargeIn: cfg.bargeIn,
|
|
600
|
-
language: cfg.language,
|
|
601
|
-
chain: cfg.chain,
|
|
602
|
-
roles: cfg.roles,
|
|
603
|
-
providers: PROVIDER_KEYS,
|
|
604
|
-
})
|
|
605
|
-
},
|
|
606
|
-
}), 'dsh-tts: /status route')
|
|
607
550
|
|
|
608
|
-
ctx
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
req.on('close', () => streamSubscribers.delete(res))
|
|
622
|
-
} catch {
|
|
623
|
-
streamSubscribers.delete(res)
|
|
624
|
-
}
|
|
625
|
-
},
|
|
626
|
-
}), 'dsh-tts: /stream route')
|
|
551
|
+
registerHttpRoutes(ctx, {
|
|
552
|
+
live,
|
|
553
|
+
modelManager,
|
|
554
|
+
stats,
|
|
555
|
+
speechCache,
|
|
556
|
+
cleanText,
|
|
557
|
+
synthesize,
|
|
558
|
+
credentialsView,
|
|
559
|
+
configResponse,
|
|
560
|
+
storeProviderKey,
|
|
561
|
+
clearProviderKey,
|
|
562
|
+
pending,
|
|
563
|
+
})
|
|
627
564
|
|
|
628
565
|
ctx.effect(() => () => {
|
|
629
|
-
|
|
630
|
-
try { res.end() } catch {}
|
|
631
|
-
}
|
|
632
|
-
streamSubscribers.clear()
|
|
566
|
+
clearStreamSubscribers()
|
|
633
567
|
}, 'dsh-tts: cleanup stream subscribers')
|
|
634
568
|
|
|
635
|
-
ctx.effect(() => ctx.webServer.register({
|
|
636
|
-
kind: 'exact',
|
|
637
|
-
path: '/dsh-tts/models/status',
|
|
638
|
-
handler: async (req, res) => {
|
|
639
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
640
|
-
try {
|
|
641
|
-
const list = await modelManager.listStatus()
|
|
642
|
-
writeJson(res, 200, { ok: true, models: list })
|
|
643
|
-
} catch (err) {
|
|
644
|
-
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
645
|
-
}
|
|
646
|
-
},
|
|
647
|
-
}), 'dsh-tts: /models/status route')
|
|
648
|
-
|
|
649
|
-
ctx.effect(() => ctx.webServer.register({
|
|
650
|
-
kind: 'exact',
|
|
651
|
-
path: '/dsh-tts/models/install',
|
|
652
|
-
handler: async (req, res) => {
|
|
653
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
654
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
655
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
656
|
-
return
|
|
657
|
-
}
|
|
658
|
-
let body = {}
|
|
659
|
-
try {
|
|
660
|
-
const raw = await readBody(req, 16 * 1024)
|
|
661
|
-
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
662
|
-
} catch (e) {
|
|
663
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
664
|
-
return
|
|
665
|
-
}
|
|
666
|
-
const engine = String(body.engine || '').trim()
|
|
667
|
-
if (!engine) {
|
|
668
|
-
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
669
|
-
return
|
|
670
|
-
}
|
|
671
|
-
try {
|
|
672
|
-
// Fire installation in background or await initial start
|
|
673
|
-
modelManager.installModel(engine).catch(() => {})
|
|
674
|
-
const current = await modelManager.getStatus(engine)
|
|
675
|
-
writeJson(res, 200, { ok: true, model: current })
|
|
676
|
-
} catch (err) {
|
|
677
|
-
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
678
|
-
}
|
|
679
|
-
},
|
|
680
|
-
}), 'dsh-tts: /models/install route')
|
|
681
|
-
|
|
682
|
-
ctx.effect(() => ctx.webServer.register({
|
|
683
|
-
kind: 'exact',
|
|
684
|
-
path: '/dsh-tts/models/delete',
|
|
685
|
-
handler: async (req, res) => {
|
|
686
|
-
if (req.method !== 'DELETE' && req.method !== 'POST') {
|
|
687
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
|
|
688
|
-
return
|
|
689
|
-
}
|
|
690
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
691
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
692
|
-
return
|
|
693
|
-
}
|
|
694
|
-
let body = {}
|
|
695
|
-
try {
|
|
696
|
-
const raw = await readBody(req, 16 * 1024)
|
|
697
|
-
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
698
|
-
} catch { body = {} }
|
|
699
|
-
const engine = String(body.engine || '').trim()
|
|
700
|
-
if (!engine) {
|
|
701
|
-
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
702
|
-
return
|
|
703
|
-
}
|
|
704
|
-
try {
|
|
705
|
-
const result = await modelManager.deleteModel(engine)
|
|
706
|
-
writeJson(res, 200, { ok: true, model: result })
|
|
707
|
-
} catch (err) {
|
|
708
|
-
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
709
|
-
}
|
|
710
|
-
},
|
|
711
|
-
}), 'dsh-tts: /models/delete route')
|
|
712
|
-
|
|
713
|
-
ctx.effect(() => ctx.webServer.register({
|
|
714
|
-
kind: 'exact',
|
|
715
|
-
path: '/dsh-tts/integrations',
|
|
716
|
-
handler: async (req, res) => {
|
|
717
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
718
|
-
let voiceInstalled = false
|
|
719
|
-
let messengerInstalled = false
|
|
720
|
-
|
|
721
|
-
try {
|
|
722
|
-
if (typeof ctx.webServer?.hasRoute === 'function') {
|
|
723
|
-
voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
|
|
724
|
-
messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
|
|
725
|
-
}
|
|
726
|
-
} catch {}
|
|
727
|
-
|
|
728
|
-
if (!voiceInstalled) {
|
|
729
|
-
try {
|
|
730
|
-
const rootDev = path.join(process.cwd(), '..', 'dsh-voice')
|
|
731
|
-
const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-voice')
|
|
732
|
-
if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
|
|
733
|
-
voiceInstalled = true
|
|
734
|
-
}
|
|
735
|
-
} catch {}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
if (!messengerInstalled) {
|
|
739
|
-
try {
|
|
740
|
-
const rootDev = path.join(process.cwd(), '..', 'dsh-messenger-gateway')
|
|
741
|
-
const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-messenger-gateway')
|
|
742
|
-
if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
|
|
743
|
-
messengerInstalled = true
|
|
744
|
-
}
|
|
745
|
-
} catch {}
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
writeJson(res, 200, {
|
|
749
|
-
ok: true,
|
|
750
|
-
voice: {
|
|
751
|
-
installed: voiceInstalled,
|
|
752
|
-
package: '@goodandready/dsh-voice',
|
|
753
|
-
hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
|
|
754
|
-
},
|
|
755
|
-
messenger: {
|
|
756
|
-
installed: messengerInstalled,
|
|
757
|
-
package: '@goodandready/dsh-messenger-gateway',
|
|
758
|
-
hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
|
|
759
|
-
},
|
|
760
|
-
})
|
|
761
|
-
},
|
|
762
|
-
}), 'dsh-tts: /integrations route')
|
|
763
|
-
|
|
764
|
-
ctx.effect(() => ctx.webServer.register({
|
|
765
|
-
kind: 'exact',
|
|
766
|
-
path: '/dsh-tts/cache',
|
|
767
|
-
handler: async (req, res) => {
|
|
768
|
-
if (req.method !== 'DELETE') {
|
|
769
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
|
|
770
|
-
return
|
|
771
|
-
}
|
|
772
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
773
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
774
|
-
return
|
|
775
|
-
}
|
|
776
|
-
const removed = await speechCache.clear()
|
|
777
|
-
writeJson(res, 200, { ok: true, removed })
|
|
778
|
-
},
|
|
779
|
-
}), 'dsh-tts: /cache route')
|
|
780
|
-
|
|
781
|
-
ctx.effect(() => ctx.webServer.register({
|
|
782
|
-
kind: 'exact',
|
|
783
|
-
path: '/dsh-tts/preview',
|
|
784
|
-
handler: async (req, res) => {
|
|
785
|
-
if (req.method !== 'POST') {
|
|
786
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
787
|
-
return
|
|
788
|
-
}
|
|
789
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
790
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
791
|
-
return
|
|
792
|
-
}
|
|
793
|
-
let body = {}
|
|
794
|
-
try {
|
|
795
|
-
const raw = await readBody(req, 64 * 1024)
|
|
796
|
-
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
797
|
-
} catch (e) {
|
|
798
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
799
|
-
return
|
|
800
|
-
}
|
|
801
|
-
const cfg = live()
|
|
802
|
-
const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
|
|
803
|
-
try {
|
|
804
|
-
const probe = Object.assign({}, cfg, {
|
|
805
|
-
chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
|
|
806
|
-
})
|
|
807
|
-
const out = await synthesize(String(body.text || phrase), probe, null, 'reply')
|
|
808
|
-
writeJson(res, 200, { ok: true, mime: out.mime, audioBase64: Buffer.from(out.audio).toString('base64') })
|
|
809
|
-
} catch (e) {
|
|
810
|
-
writeJson(res, 502, { ok: false, error: { code: 'tts', message: String(e && e.message || e) } })
|
|
811
|
-
}
|
|
812
|
-
},
|
|
813
|
-
}), 'dsh-tts: /preview route')
|
|
814
|
-
|
|
815
|
-
ctx.effect(() => ctx.webServer.register({
|
|
816
|
-
kind: 'exact',
|
|
817
|
-
path: '/dsh-tts/stats',
|
|
818
|
-
handler: async (req, res) => {
|
|
819
|
-
if (req.method === 'GET') {
|
|
820
|
-
writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
|
|
821
|
-
return
|
|
822
|
-
}
|
|
823
|
-
if (req.method !== 'DELETE') {
|
|
824
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
|
|
825
|
-
return
|
|
826
|
-
}
|
|
827
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
828
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
829
|
-
return
|
|
830
|
-
}
|
|
831
|
-
stats.total = 0
|
|
832
|
-
stats.cacheHits = 0
|
|
833
|
-
stats.errors = 0
|
|
834
|
-
stats.providers = {}
|
|
835
|
-
writeJson(res, 200, { ok: true })
|
|
836
|
-
},
|
|
837
|
-
}), 'dsh-tts: /stats route')
|
|
838
|
-
|
|
839
|
-
ctx.effect(() => ctx.webServer.register({
|
|
840
|
-
kind: 'exact',
|
|
841
|
-
path: '/dsh-tts/config',
|
|
842
|
-
handler: async (req, res) => {
|
|
843
|
-
if (req.method === 'GET') {
|
|
844
|
-
writeJson(res, 200, await configResponse())
|
|
845
|
-
return
|
|
846
|
-
}
|
|
847
|
-
if (req.method !== 'PUT' && req.method !== 'POST') {
|
|
848
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
849
|
-
return
|
|
850
|
-
}
|
|
851
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
852
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
853
|
-
return
|
|
854
|
-
}
|
|
855
|
-
if (!settingsApi) {
|
|
856
|
-
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
857
|
-
return
|
|
858
|
-
}
|
|
859
|
-
let raw
|
|
860
|
-
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
861
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
862
|
-
return
|
|
863
|
-
}
|
|
864
|
-
let payload
|
|
865
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
866
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
867
|
-
return
|
|
868
|
-
}
|
|
869
|
-
if (payload && typeof payload.config === 'object') {
|
|
870
|
-
payload = { ...payload.config, keys: payload.keys }
|
|
871
|
-
}
|
|
872
|
-
const keys = pendingKeyWrites(payload && payload.keys)
|
|
873
|
-
const stripped = stripSecretsFromConfig(payload)
|
|
874
|
-
let parsed
|
|
875
|
-
try { parsed = Config(stripped) } catch (e) {
|
|
876
|
-
writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
|
|
877
|
-
return
|
|
878
|
-
}
|
|
879
|
-
try {
|
|
880
|
-
for (const item of keys) {
|
|
881
|
-
await storeProviderKey(item.provider, item.value)
|
|
882
|
-
}
|
|
883
|
-
await settingsApi.replace(parsed)
|
|
884
|
-
writeJson(res, 200, await configResponse())
|
|
885
|
-
} catch (e) {
|
|
886
|
-
writeJson(res, 500, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
|
|
887
|
-
}
|
|
888
|
-
},
|
|
889
|
-
}), 'dsh-tts: /config route')
|
|
890
|
-
|
|
891
|
-
ctx.effect(() => ctx.webServer.register({
|
|
892
|
-
kind: 'exact',
|
|
893
|
-
path: '/dsh-tts/credential',
|
|
894
|
-
handler: async (req, res) => {
|
|
895
|
-
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
896
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
|
|
897
|
-
return
|
|
898
|
-
}
|
|
899
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
900
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
|
|
901
|
-
return
|
|
902
|
-
}
|
|
903
|
-
let payload = {}
|
|
904
|
-
if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
|
|
905
|
-
let raw
|
|
906
|
-
try { raw = await readBody(req, 16 * 1024) } catch (e) {
|
|
907
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
908
|
-
return
|
|
909
|
-
}
|
|
910
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
911
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
912
|
-
return
|
|
913
|
-
}
|
|
914
|
-
}
|
|
915
|
-
const provider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
|
|
916
|
-
if (!needsApiKey(provider)) {
|
|
917
|
-
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown cloud provider' } })
|
|
918
|
-
return
|
|
919
|
-
}
|
|
920
|
-
try {
|
|
921
|
-
if (req.method === 'DELETE') {
|
|
922
|
-
const ref = await clearProviderKey(provider)
|
|
923
|
-
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
924
|
-
return
|
|
925
|
-
}
|
|
926
|
-
const ref = await storeProviderKey(provider, payload.value)
|
|
927
|
-
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
928
|
-
} catch (e) {
|
|
929
|
-
writeJson(res, 400, { ok: false, error: { code: 'credential', message: String(e && e.message || e) } })
|
|
930
|
-
}
|
|
931
|
-
},
|
|
932
|
-
}), 'dsh-tts: /credential route')
|
|
933
|
-
|
|
934
|
-
ctx.effect(() => ctx.webServer.register({
|
|
935
|
-
kind: 'exact',
|
|
936
|
-
path: '/dsh-tts/pending',
|
|
937
|
-
handler: async (req, res) => {
|
|
938
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
939
|
-
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
940
|
-
const after = url.searchParams.get('after') || ''
|
|
941
|
-
let items = pending.slice()
|
|
942
|
-
if (after) {
|
|
943
|
-
const idx = items.findIndex((x) => x.id === after)
|
|
944
|
-
items = idx >= 0 ? items.slice(idx + 1) : items
|
|
945
|
-
}
|
|
946
|
-
writeJson(res, 200, { ok: true, items })
|
|
947
|
-
},
|
|
948
|
-
}), 'dsh-tts: /pending route')
|
|
949
|
-
|
|
950
|
-
ctx.effect(() => ctx.webServer.register({
|
|
951
|
-
kind: 'exact',
|
|
952
|
-
path: '/dsh-tts/speak',
|
|
953
|
-
handler: async (req, res) => {
|
|
954
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
955
|
-
let raw
|
|
956
|
-
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
957
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
958
|
-
}
|
|
959
|
-
let payload
|
|
960
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
961
|
-
const text = cleanText(payload.text, live())
|
|
962
|
-
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
963
|
-
const cfg = live()
|
|
964
|
-
const controller = new AbortController()
|
|
965
|
-
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
966
|
-
try {
|
|
967
|
-
const out = await synthesize(text, cfg, controller.signal)
|
|
968
|
-
writeJson(res, 200, {
|
|
969
|
-
ok: true,
|
|
970
|
-
provider: out.provider,
|
|
971
|
-
mime: out.mime,
|
|
972
|
-
audioBase64: Buffer.from(out.audio).toString('base64'),
|
|
973
|
-
tookMs: out.tookMs,
|
|
974
|
-
})
|
|
975
|
-
} catch (e) {
|
|
976
|
-
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
977
|
-
} finally {
|
|
978
|
-
clearTimeout(timer)
|
|
979
|
-
}
|
|
980
|
-
},
|
|
981
|
-
}), 'dsh-tts: /speak route')
|
|
982
|
-
|
|
983
569
|
ctx.tools.register(
|
|
984
570
|
defineTool({
|
|
985
571
|
name: 'speak_text',
|