@goodandready/dsh-tts 0.3.22 → 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 +105 -88
- 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 +70 -467
- package/lib/providers.js +105 -25
- package/lib/routes.js +400 -0
- package/lib/stream-hub.js +31 -0
- package/lib/text.js +46 -19
- package/package.json +6 -3
- package/scripts/f5_daemon.py +40 -0
- package/lib/client/worklet.js +0 -71
- package/lib/router.js +0 -4
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,10 +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)
|
|
280
|
+
const inFlightSyntheses = new Map()
|
|
281
|
+
|
|
282
|
+
// Synthesis cache lives next to other harness data.
|
|
313
283
|
const speechCache = createSpeechCache({
|
|
314
284
|
root: process.env.DSH_HOME || path.join(os.homedir(), '.dsh'),
|
|
315
285
|
maxBytes: () => {
|
|
@@ -317,11 +287,11 @@ export function apply(ctx, config) {
|
|
|
317
287
|
return mb > 0 ? mb * 1024 * 1024 : 100 * 1024 * 1024
|
|
318
288
|
},
|
|
319
289
|
})
|
|
320
|
-
//
|
|
321
|
-
//
|
|
290
|
+
// Cache only short repeatable phrases; long unique streaming chunks skip the cache.
|
|
291
|
+
// Otherwise the cache fills with garbage that never repeats.
|
|
322
292
|
const CACHE_MAX_TEXT = 200
|
|
323
293
|
|
|
324
|
-
//
|
|
294
|
+
// Text-cleaning options are read per call because config is live.
|
|
325
295
|
function cleanOpts(cfg) {
|
|
326
296
|
return { skipCode: cfg.skipCode !== false, phrases: speechPhrases(cfg.language) }
|
|
327
297
|
}
|
|
@@ -334,13 +304,13 @@ export function apply(ctx, config) {
|
|
|
334
304
|
)
|
|
335
305
|
}
|
|
336
306
|
|
|
337
|
-
//
|
|
338
|
-
//
|
|
307
|
+
// Fast-model summary. Any failure returns an empty string and the caller
|
|
308
|
+
// silently falls back to truncation.
|
|
339
309
|
async function summarizeReply(text, cfg, signal) {
|
|
340
310
|
const sentences = Number(cfg.summarySentences) > 0 ? Number(cfg.summarySentences) : 3
|
|
341
311
|
if (!llm || typeof llm.stream !== 'function') return ''
|
|
342
312
|
const opts = {
|
|
343
|
-
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 }],
|
|
344
314
|
signal,
|
|
345
315
|
}
|
|
346
316
|
const m = String(cfg.summaryModel || '')
|
|
@@ -381,15 +351,15 @@ export function apply(ctx, config) {
|
|
|
381
351
|
models[entry.provider] = entry.model || DEFAULT_MODELS[entry.provider]
|
|
382
352
|
voices[entry.provider] = entry.voice || DEFAULT_VOICES[entry.provider]
|
|
383
353
|
}
|
|
384
|
-
//
|
|
354
|
+
// Role overrides: empty fields inherit the main chain.
|
|
385
355
|
const ov = (cfg.roles && cfg.roles[role]) || {}
|
|
386
356
|
if (ov.provider && PROVIDER_KEYS.includes(ov.provider)) order.unshift(ov.provider)
|
|
387
357
|
if (order.length) {
|
|
388
358
|
if (ov.model) models[order[0]] = ov.model
|
|
389
359
|
if (ov.voice) voices[order[0]] = ov.voice
|
|
390
360
|
}
|
|
391
|
-
//
|
|
392
|
-
//
|
|
361
|
+
// Playback rate is browser-side only and is not part of the cache key.
|
|
362
|
+
// It does not affect synthesis.
|
|
393
363
|
const cacheAllowed = cfg.cache !== false && text.length <= CACHE_MAX_TEXT
|
|
394
364
|
if (cacheAllowed) {
|
|
395
365
|
for (const provider of order) {
|
|
@@ -403,25 +373,38 @@ export function apply(ctx, config) {
|
|
|
403
373
|
}
|
|
404
374
|
}
|
|
405
375
|
}
|
|
406
|
-
const
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
)
|
|
410
|
-
const out = await runChain(order, providers)
|
|
411
|
-
stats.total++
|
|
412
|
-
const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
|
|
413
|
-
ps.n++
|
|
414
|
-
ps.ms += out.tookMs || 0
|
|
415
|
-
if (cacheAllowed) {
|
|
416
|
-
const p = out.provider
|
|
417
|
-
speechCache.put(cacheKey([text, p, models[p], voices[p], role, ov.ssmlStyle]), out.mime, out.audio).catch(() => {})
|
|
376
|
+
const flightKey = cacheKey([text, order.join(','), JSON.stringify(models), JSON.stringify(voices), role, ov.ssmlStyle])
|
|
377
|
+
if (inFlightSyntheses.has(flightKey)) {
|
|
378
|
+
return inFlightSyntheses.get(flightKey)
|
|
418
379
|
}
|
|
419
|
-
|
|
380
|
+
|
|
381
|
+
const synthPromise = (async () => {
|
|
382
|
+
try {
|
|
383
|
+
const providers = makeProviders(
|
|
384
|
+
{ resolveKey, fetchImpl: fetch, cfg, modelManager },
|
|
385
|
+
{ text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
|
|
386
|
+
)
|
|
387
|
+
const out = await runChain(order, providers)
|
|
388
|
+
stats.total++
|
|
389
|
+
const ps = stats.providers[out.provider] || (stats.providers[out.provider] = { n: 0, e: 0, ms: 0 })
|
|
390
|
+
ps.n++
|
|
391
|
+
ps.ms += out.tookMs || 0
|
|
392
|
+
if (cacheAllowed) {
|
|
393
|
+
const p = out.provider
|
|
394
|
+
speechCache.put(cacheKey([text, p, models[p], voices[p], role, ov.ssmlStyle]), out.mime, out.audio).catch(() => {})
|
|
395
|
+
}
|
|
396
|
+
return out
|
|
397
|
+
} finally {
|
|
398
|
+
inFlightSyntheses.delete(flightKey)
|
|
399
|
+
}
|
|
400
|
+
})()
|
|
401
|
+
|
|
402
|
+
inFlightSyntheses.set(flightKey, synthPromise)
|
|
403
|
+
return synthPromise
|
|
420
404
|
}
|
|
421
405
|
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
// обгоняли бы длинные.
|
|
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.
|
|
425
408
|
function speakPiece(sid, text, cfg, kind) {
|
|
426
409
|
if (!text) return
|
|
427
410
|
const role = kind === 'notice' ? 'approval' : kind === 'error' ? 'error' : (kind && kind !== 'speech' ? kind : 'reply')
|
|
@@ -448,8 +431,8 @@ export function apply(ctx, config) {
|
|
|
448
431
|
.finally(() => clearTimeout(timer))
|
|
449
432
|
}
|
|
450
433
|
|
|
451
|
-
//
|
|
452
|
-
//
|
|
434
|
+
// Announcement: short chime then phrase. The chime is drawn by the browser,
|
|
435
|
+
// so nothing is downloaded or stored.
|
|
453
436
|
function roleChime(cfg, role) {
|
|
454
437
|
const ov = (cfg.roles && cfg.roles[role]) || {}
|
|
455
438
|
return ov.chime || cfg.chime
|
|
@@ -479,8 +462,8 @@ export function apply(ctx, config) {
|
|
|
479
462
|
}
|
|
480
463
|
}
|
|
481
464
|
|
|
482
|
-
//
|
|
483
|
-
//
|
|
465
|
+
// Reserve the queue slot before synthesis so phrase order matches
|
|
466
|
+
// speech order, not arrival race.
|
|
484
467
|
function reserve(id) {
|
|
485
468
|
enqueue({ id, kind: 'reserved' })
|
|
486
469
|
}
|
|
@@ -499,8 +482,8 @@ export function apply(ctx, config) {
|
|
|
499
482
|
if (!cfg.speakReplies) return
|
|
500
483
|
const sid = session && session.id
|
|
501
484
|
if (!sid) return
|
|
502
|
-
//
|
|
503
|
-
//
|
|
485
|
+
// The agent is waiting for human approval — worth speaking aloud
|
|
486
|
+
// so the user notices without watching the screen.
|
|
504
487
|
if (event.type === 'approval/asked') {
|
|
505
488
|
if (!cfg.announceApproval) return
|
|
506
489
|
const tool = event.data && event.data.toolName
|
|
@@ -518,9 +501,9 @@ export function apply(ctx, config) {
|
|
|
518
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) || ''
|
|
519
502
|
const activeRole = (agentName && cfg.roles && cfg.roles[agentName] && cfg.roles[agentName].provider) ? agentName : 'reply'
|
|
520
503
|
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
|
|
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
|
+
|
|
524
507
|
if (cfg.speakAsItGoes) {
|
|
525
508
|
const ready = cleanText(text, cfg)
|
|
526
509
|
for (const piece of splitSentences(ready, cfg.sentenceChars)) speakPiece(sid, piece, cfg, activeRole)
|
|
@@ -533,7 +516,7 @@ export function apply(ctx, config) {
|
|
|
533
516
|
return
|
|
534
517
|
}
|
|
535
518
|
if (event.type !== 'turn/end') return
|
|
536
|
-
//
|
|
519
|
+
// With speak-as-it-goes everything was already spoken by turn end.
|
|
537
520
|
if (cfg.speakAsItGoes) { collectors.delete(sid); return }
|
|
538
521
|
const cur = collectors.get(sid)
|
|
539
522
|
collectors.delete(sid)
|
|
@@ -564,405 +547,25 @@ export function apply(ctx, config) {
|
|
|
564
547
|
.finally(() => clearTimeout(timer))
|
|
565
548
|
}), 'dsh-tts: speak finished replies')
|
|
566
549
|
|
|
567
|
-
ctx.effect(() => ctx.webServer.register({
|
|
568
|
-
kind: 'exact',
|
|
569
|
-
path: '/dsh-tts/status',
|
|
570
|
-
handler: async (req, res) => {
|
|
571
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
572
|
-
const cfg = live()
|
|
573
|
-
writeJson(res, 200, {
|
|
574
|
-
ok: true,
|
|
575
|
-
speakReplies: cfg.speakReplies,
|
|
576
|
-
enableLocalEngines: cfg.enableLocalEngines,
|
|
577
|
-
kokoroEnabled: cfg.kokoroEnabled,
|
|
578
|
-
f5Enabled: cfg.f5Enabled,
|
|
579
|
-
streamingEnabled: cfg.streamingEnabled,
|
|
580
|
-
rate: cfg.rate,
|
|
581
|
-
chime: cfg.chime,
|
|
582
|
-
bargeIn: cfg.bargeIn,
|
|
583
|
-
language: cfg.language,
|
|
584
|
-
chain: cfg.chain,
|
|
585
|
-
roles: cfg.roles,
|
|
586
|
-
providers: PROVIDER_KEYS,
|
|
587
|
-
})
|
|
588
|
-
},
|
|
589
|
-
}), 'dsh-tts: /status route')
|
|
590
550
|
|
|
591
|
-
ctx
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
req.on('close', () => streamSubscribers.delete(res))
|
|
605
|
-
} catch {
|
|
606
|
-
streamSubscribers.delete(res)
|
|
607
|
-
}
|
|
608
|
-
},
|
|
609
|
-
}), '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
|
+
})
|
|
610
564
|
|
|
611
565
|
ctx.effect(() => () => {
|
|
612
|
-
|
|
613
|
-
try { res.end() } catch {}
|
|
614
|
-
}
|
|
615
|
-
streamSubscribers.clear()
|
|
566
|
+
clearStreamSubscribers()
|
|
616
567
|
}, 'dsh-tts: cleanup stream subscribers')
|
|
617
568
|
|
|
618
|
-
ctx.effect(() => ctx.webServer.register({
|
|
619
|
-
kind: 'exact',
|
|
620
|
-
path: '/dsh-tts/models/status',
|
|
621
|
-
handler: async (req, res) => {
|
|
622
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
623
|
-
try {
|
|
624
|
-
const list = await modelManager.listStatus()
|
|
625
|
-
writeJson(res, 200, { ok: true, models: list })
|
|
626
|
-
} catch (err) {
|
|
627
|
-
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
628
|
-
}
|
|
629
|
-
},
|
|
630
|
-
}), 'dsh-tts: /models/status route')
|
|
631
|
-
|
|
632
|
-
ctx.effect(() => ctx.webServer.register({
|
|
633
|
-
kind: 'exact',
|
|
634
|
-
path: '/dsh-tts/models/install',
|
|
635
|
-
handler: async (req, res) => {
|
|
636
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
637
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
638
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
639
|
-
return
|
|
640
|
-
}
|
|
641
|
-
let body = {}
|
|
642
|
-
try {
|
|
643
|
-
const raw = await readBody(req, 16 * 1024)
|
|
644
|
-
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
645
|
-
} catch (e) {
|
|
646
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
647
|
-
return
|
|
648
|
-
}
|
|
649
|
-
const engine = String(body.engine || '').trim()
|
|
650
|
-
if (!engine) {
|
|
651
|
-
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
652
|
-
return
|
|
653
|
-
}
|
|
654
|
-
try {
|
|
655
|
-
// Fire installation in background or await initial start
|
|
656
|
-
modelManager.installModel(engine).catch(() => {})
|
|
657
|
-
const current = await modelManager.getStatus(engine)
|
|
658
|
-
writeJson(res, 200, { ok: true, model: current })
|
|
659
|
-
} catch (err) {
|
|
660
|
-
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
661
|
-
}
|
|
662
|
-
},
|
|
663
|
-
}), 'dsh-tts: /models/install route')
|
|
664
|
-
|
|
665
|
-
ctx.effect(() => ctx.webServer.register({
|
|
666
|
-
kind: 'exact',
|
|
667
|
-
path: '/dsh-tts/models/delete',
|
|
668
|
-
handler: async (req, res) => {
|
|
669
|
-
if (req.method !== 'DELETE' && req.method !== 'POST') {
|
|
670
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE or POST only' } })
|
|
671
|
-
return
|
|
672
|
-
}
|
|
673
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
674
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
675
|
-
return
|
|
676
|
-
}
|
|
677
|
-
let body = {}
|
|
678
|
-
try {
|
|
679
|
-
const raw = await readBody(req, 16 * 1024)
|
|
680
|
-
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
681
|
-
} catch { body = {} }
|
|
682
|
-
const engine = String(body.engine || '').trim()
|
|
683
|
-
if (!engine) {
|
|
684
|
-
writeJson(res, 400, { ok: false, error: { code: 'param', message: 'missing engine parameter' } })
|
|
685
|
-
return
|
|
686
|
-
}
|
|
687
|
-
try {
|
|
688
|
-
const result = await modelManager.deleteModel(engine)
|
|
689
|
-
writeJson(res, 200, { ok: true, model: result })
|
|
690
|
-
} catch (err) {
|
|
691
|
-
writeJson(res, 500, { ok: false, error: String(err && err.message || err) })
|
|
692
|
-
}
|
|
693
|
-
},
|
|
694
|
-
}), 'dsh-tts: /models/delete route')
|
|
695
|
-
|
|
696
|
-
ctx.effect(() => ctx.webServer.register({
|
|
697
|
-
kind: 'exact',
|
|
698
|
-
path: '/dsh-tts/integrations',
|
|
699
|
-
handler: async (req, res) => {
|
|
700
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
701
|
-
let voiceInstalled = false
|
|
702
|
-
let messengerInstalled = false
|
|
703
|
-
|
|
704
|
-
try {
|
|
705
|
-
if (typeof ctx.webServer?.hasRoute === 'function') {
|
|
706
|
-
voiceInstalled = ctx.webServer.hasRoute('/dsh-voice/status')
|
|
707
|
-
messengerInstalled = ctx.webServer.hasRoute('/dsh-messenger-gateway/status')
|
|
708
|
-
}
|
|
709
|
-
} catch {}
|
|
710
|
-
|
|
711
|
-
if (!voiceInstalled) {
|
|
712
|
-
try {
|
|
713
|
-
const rootDev = path.join(process.cwd(), '..', 'dsh-voice')
|
|
714
|
-
const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-voice')
|
|
715
|
-
if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
|
|
716
|
-
voiceInstalled = true
|
|
717
|
-
}
|
|
718
|
-
} catch {}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
if (!messengerInstalled) {
|
|
722
|
-
try {
|
|
723
|
-
const rootDev = path.join(process.cwd(), '..', 'dsh-messenger-gateway')
|
|
724
|
-
const rootNm = path.join(process.cwd(), 'node_modules', '@goodandready', 'dsh-messenger-gateway')
|
|
725
|
-
if (fs.existsSync(rootDev) || fs.existsSync(rootNm)) {
|
|
726
|
-
messengerInstalled = true
|
|
727
|
-
}
|
|
728
|
-
} catch {}
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
writeJson(res, 200, {
|
|
732
|
-
ok: true,
|
|
733
|
-
voice: {
|
|
734
|
-
installed: voiceInstalled,
|
|
735
|
-
package: '@goodandready/dsh-voice',
|
|
736
|
-
hint: 'dsh plugin --profile web add @goodandready/dsh-voice',
|
|
737
|
-
},
|
|
738
|
-
messenger: {
|
|
739
|
-
installed: messengerInstalled,
|
|
740
|
-
package: '@goodandready/dsh-messenger-gateway',
|
|
741
|
-
hint: 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway',
|
|
742
|
-
},
|
|
743
|
-
})
|
|
744
|
-
},
|
|
745
|
-
}), 'dsh-tts: /integrations route')
|
|
746
|
-
|
|
747
|
-
ctx.effect(() => ctx.webServer.register({
|
|
748
|
-
kind: 'exact',
|
|
749
|
-
path: '/dsh-tts/cache',
|
|
750
|
-
handler: async (req, res) => {
|
|
751
|
-
if (req.method !== 'DELETE') {
|
|
752
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'DELETE only' } })
|
|
753
|
-
return
|
|
754
|
-
}
|
|
755
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
756
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
757
|
-
return
|
|
758
|
-
}
|
|
759
|
-
const removed = await speechCache.clear()
|
|
760
|
-
writeJson(res, 200, { ok: true, removed })
|
|
761
|
-
},
|
|
762
|
-
}), 'dsh-tts: /cache route')
|
|
763
|
-
|
|
764
|
-
ctx.effect(() => ctx.webServer.register({
|
|
765
|
-
kind: 'exact',
|
|
766
|
-
path: '/dsh-tts/preview',
|
|
767
|
-
handler: async (req, res) => {
|
|
768
|
-
if (req.method !== 'POST') {
|
|
769
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST 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
|
-
let body = {}
|
|
777
|
-
try {
|
|
778
|
-
const raw = await readBody(req, 64 * 1024)
|
|
779
|
-
body = JSON.parse(raw.toString('utf8') || '{}')
|
|
780
|
-
} catch (e) {
|
|
781
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
782
|
-
return
|
|
783
|
-
}
|
|
784
|
-
const cfg = live()
|
|
785
|
-
const phrase = String(cfg.language || '').toLowerCase().startsWith('ru') ? 'Проверка голоса.' : 'Voice check.'
|
|
786
|
-
try {
|
|
787
|
-
const probe = Object.assign({}, cfg, {
|
|
788
|
-
chain: [{ provider: body.provider || '', model: body.model || '', voice: body.voice || '' }],
|
|
789
|
-
})
|
|
790
|
-
const out = await synthesize(String(body.text || phrase), probe, null, 'reply')
|
|
791
|
-
writeJson(res, 200, { ok: true, mime: out.mime, audioBase64: Buffer.from(out.audio).toString('base64') })
|
|
792
|
-
} catch (e) {
|
|
793
|
-
writeJson(res, 502, { ok: false, error: { code: 'tts', message: String(e && e.message || e) } })
|
|
794
|
-
}
|
|
795
|
-
},
|
|
796
|
-
}), 'dsh-tts: /preview route')
|
|
797
|
-
|
|
798
|
-
ctx.effect(() => ctx.webServer.register({
|
|
799
|
-
kind: 'exact',
|
|
800
|
-
path: '/dsh-tts/stats',
|
|
801
|
-
handler: async (req, res) => {
|
|
802
|
-
if (req.method === 'GET') {
|
|
803
|
-
writeJson(res, 200, { ok: true, total: stats.total, cacheHits: stats.cacheHits, errors: stats.errors, providers: stats.providers })
|
|
804
|
-
return
|
|
805
|
-
}
|
|
806
|
-
if (req.method !== 'DELETE') {
|
|
807
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or DELETE' } })
|
|
808
|
-
return
|
|
809
|
-
}
|
|
810
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
811
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
812
|
-
return
|
|
813
|
-
}
|
|
814
|
-
stats.total = 0
|
|
815
|
-
stats.cacheHits = 0
|
|
816
|
-
stats.errors = 0
|
|
817
|
-
stats.providers = {}
|
|
818
|
-
writeJson(res, 200, { ok: true })
|
|
819
|
-
},
|
|
820
|
-
}), 'dsh-tts: /stats route')
|
|
821
|
-
|
|
822
|
-
ctx.effect(() => ctx.webServer.register({
|
|
823
|
-
kind: 'exact',
|
|
824
|
-
path: '/dsh-tts/config',
|
|
825
|
-
handler: async (req, res) => {
|
|
826
|
-
if (req.method === 'GET') {
|
|
827
|
-
writeJson(res, 200, await configResponse())
|
|
828
|
-
return
|
|
829
|
-
}
|
|
830
|
-
if (req.method !== 'PUT' && req.method !== 'POST') {
|
|
831
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or PUT' } })
|
|
832
|
-
return
|
|
833
|
-
}
|
|
834
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
835
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: settings writes are same-origin only' } })
|
|
836
|
-
return
|
|
837
|
-
}
|
|
838
|
-
if (!settingsApi) {
|
|
839
|
-
writeJson(res, 503, { ok: false, error: { code: 'settings', message: 'settings not ready' } })
|
|
840
|
-
return
|
|
841
|
-
}
|
|
842
|
-
let raw
|
|
843
|
-
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
844
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
845
|
-
return
|
|
846
|
-
}
|
|
847
|
-
let payload
|
|
848
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
849
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
850
|
-
return
|
|
851
|
-
}
|
|
852
|
-
if (payload && typeof payload.config === 'object') {
|
|
853
|
-
payload = { ...payload.config, keys: payload.keys }
|
|
854
|
-
}
|
|
855
|
-
const keys = pendingKeyWrites(payload && payload.keys)
|
|
856
|
-
const stripped = stripSecretsFromConfig(payload)
|
|
857
|
-
let parsed
|
|
858
|
-
try { parsed = Config(stripped) } catch (e) {
|
|
859
|
-
writeJson(res, 400, { ok: false, error: { code: 'schema', message: String(e && e.message || e) } })
|
|
860
|
-
return
|
|
861
|
-
}
|
|
862
|
-
try {
|
|
863
|
-
for (const item of keys) {
|
|
864
|
-
await storeProviderKey(item.provider, item.value)
|
|
865
|
-
}
|
|
866
|
-
await settingsApi.replace(parsed)
|
|
867
|
-
writeJson(res, 200, await configResponse())
|
|
868
|
-
} catch (e) {
|
|
869
|
-
writeJson(res, 500, { ok: false, error: { code: 'save', message: String(e && e.message || e) } })
|
|
870
|
-
}
|
|
871
|
-
},
|
|
872
|
-
}), 'dsh-tts: /config route')
|
|
873
|
-
|
|
874
|
-
ctx.effect(() => ctx.webServer.register({
|
|
875
|
-
kind: 'exact',
|
|
876
|
-
path: '/dsh-tts/credential',
|
|
877
|
-
handler: async (req, res) => {
|
|
878
|
-
if (req.method !== 'PUT' && req.method !== 'DELETE') {
|
|
879
|
-
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'PUT or DELETE only' } })
|
|
880
|
-
return
|
|
881
|
-
}
|
|
882
|
-
if (!isTrustedSettingsRequest(req)) {
|
|
883
|
-
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'dsh-tts: keys are same-origin only' } })
|
|
884
|
-
return
|
|
885
|
-
}
|
|
886
|
-
let payload = {}
|
|
887
|
-
if (req.method === 'PUT' || (req.headers['content-length'] && Number(req.headers['content-length']) > 0)) {
|
|
888
|
-
let raw
|
|
889
|
-
try { raw = await readBody(req, 16 * 1024) } catch (e) {
|
|
890
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
891
|
-
return
|
|
892
|
-
}
|
|
893
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch {
|
|
894
|
-
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
895
|
-
return
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
const provider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
|
|
899
|
-
if (!needsApiKey(provider)) {
|
|
900
|
-
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown cloud provider' } })
|
|
901
|
-
return
|
|
902
|
-
}
|
|
903
|
-
try {
|
|
904
|
-
if (req.method === 'DELETE') {
|
|
905
|
-
const ref = await clearProviderKey(provider)
|
|
906
|
-
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
907
|
-
return
|
|
908
|
-
}
|
|
909
|
-
const ref = await storeProviderKey(provider, payload.value)
|
|
910
|
-
writeJson(res, 200, { ok: true, ref, credentials: await credentialsView() })
|
|
911
|
-
} catch (e) {
|
|
912
|
-
writeJson(res, 400, { ok: false, error: { code: 'credential', message: String(e && e.message || e) } })
|
|
913
|
-
}
|
|
914
|
-
},
|
|
915
|
-
}), 'dsh-tts: /credential route')
|
|
916
|
-
|
|
917
|
-
ctx.effect(() => ctx.webServer.register({
|
|
918
|
-
kind: 'exact',
|
|
919
|
-
path: '/dsh-tts/pending',
|
|
920
|
-
handler: async (req, res) => {
|
|
921
|
-
if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
|
|
922
|
-
const url = new URL(req.url || '/', 'http://127.0.0.1')
|
|
923
|
-
const after = url.searchParams.get('after') || ''
|
|
924
|
-
let items = pending.slice()
|
|
925
|
-
if (after) {
|
|
926
|
-
const idx = items.findIndex((x) => x.id === after)
|
|
927
|
-
items = idx >= 0 ? items.slice(idx + 1) : items
|
|
928
|
-
}
|
|
929
|
-
writeJson(res, 200, { ok: true, items })
|
|
930
|
-
},
|
|
931
|
-
}), 'dsh-tts: /pending route')
|
|
932
|
-
|
|
933
|
-
ctx.effect(() => ctx.webServer.register({
|
|
934
|
-
kind: 'exact',
|
|
935
|
-
path: '/dsh-tts/speak',
|
|
936
|
-
handler: async (req, res) => {
|
|
937
|
-
if (req.method !== 'POST') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } }); return }
|
|
938
|
-
let raw
|
|
939
|
-
try { raw = await readBody(req, 256 * 1024) } catch (e) {
|
|
940
|
-
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return
|
|
941
|
-
}
|
|
942
|
-
let payload
|
|
943
|
-
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { payload = {} }
|
|
944
|
-
const text = cleanText(payload.text, live())
|
|
945
|
-
if (!text) { writeJson(res, 400, { ok: false, error: { code: 'no-text', message: 'no text' } }); return }
|
|
946
|
-
const cfg = live()
|
|
947
|
-
const controller = new AbortController()
|
|
948
|
-
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs)
|
|
949
|
-
try {
|
|
950
|
-
const out = await synthesize(text, cfg, controller.signal)
|
|
951
|
-
writeJson(res, 200, {
|
|
952
|
-
ok: true,
|
|
953
|
-
provider: out.provider,
|
|
954
|
-
mime: out.mime,
|
|
955
|
-
audioBase64: Buffer.from(out.audio).toString('base64'),
|
|
956
|
-
tookMs: out.tookMs,
|
|
957
|
-
})
|
|
958
|
-
} catch (e) {
|
|
959
|
-
writeJson(res, 502, { ok: false, error: { code: 'chain', message: String(e && e.message || e) } })
|
|
960
|
-
} finally {
|
|
961
|
-
clearTimeout(timer)
|
|
962
|
-
}
|
|
963
|
-
},
|
|
964
|
-
}), 'dsh-tts: /speak route')
|
|
965
|
-
|
|
966
569
|
ctx.tools.register(
|
|
967
570
|
defineTool({
|
|
968
571
|
name: 'speak_text',
|