@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/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,13 +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
- // Дедупликация параллельных запросов на синтез (защита от Cache Stampede)
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: 'Перескажи текст в ' + 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 }],
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.effect(() => ctx.webServer.register({
609
- kind: 'exact',
610
- path: '/dsh-tts/stream',
611
- handler: async (req, res) => {
612
- if (req.method !== 'GET') { writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } }); return }
613
- try {
614
- res.writeHead(200, {
615
- 'Content-Type': 'text/event-stream',
616
- 'Cache-Control': 'no-cache, no-transform',
617
- 'Connection': 'keep-alive',
618
- })
619
- res.write(': connected\n\n')
620
- streamSubscribers.add(res)
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
- for (const res of streamSubscribers) {
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',