@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/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 { EngineRegistry } from './engines/types.js'
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
- // Счётчики синтеза: в памяти, обнуляются рестартом и DELETE /stats.
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: 'Перескажи текст в ' + sentences + ' предложениях на его языке. Только пересказ, без вступлений:\n\n' + text }],
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 providers = makeProviders(
407
- { resolveKey, fetchImpl: fetch, cfg, modelManager },
408
- { text, lang: cfg.autoDetect ? detectLang(text) : cfg.language, signal, models, voices, role },
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
- return out
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.effect(() => ctx.webServer.register({
592
- kind: 'exact',
593
- path: '/dsh-tts/stream',
594
- handler: async (req, res) => {
595
- if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
596
- try {
597
- res.writeHead(200, {
598
- 'Content-Type': 'text/event-stream',
599
- 'Cache-Control': 'no-cache, no-transform',
600
- 'Connection': 'keep-alive',
601
- })
602
- res.write(': connected\n\n')
603
- streamSubscribers.add(res)
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
- for (const res of streamSubscribers) {
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',