@goodandready/dsh-tts 0.4.28 → 0.4.30

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/client.js CHANGED
@@ -51,6 +51,31 @@ window.__ModuleLoader__.load({
51
51
  }
52
52
 
53
53
 
54
+ function clientFetch(url, options) {
55
+ const opts = options || {}
56
+ const timeoutMs = Number(opts.timeoutMs) > 0 ? Number(opts.timeoutMs) : 15000
57
+ const controller = new AbortController()
58
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
59
+
60
+ if (opts.signal) {
61
+ if (opts.signal.aborted) controller.abort()
62
+ else opts.signal.addEventListener('abort', () => controller.abort(), { once: true })
63
+ }
64
+
65
+ const fetchOpts = Object.assign({}, opts, { signal: controller.signal })
66
+ delete fetchOpts.timeoutMs
67
+
68
+ return fetch(url, fetchOpts).finally(() => clearTimeout(timer))
69
+ }
70
+
71
+ function getModelHint(provider, t) {
72
+ if (typeof t === 'function') {
73
+ const loc = t('modelHint_' + provider)
74
+ if (loc && !loc.startsWith('modelHint_')) return loc
75
+ }
76
+ return MODEL_HINT[provider] || ''
77
+ }
78
+
54
79
  function createErrorBoundary() {
55
80
  if (!React || typeof React.Component !== 'function') {
56
81
  return function NoopBoundary(props) { return (props && props.children) || null }
@@ -74,7 +99,7 @@ window.__ModuleLoader__.load({
74
99
  className: 'dts-alert dts-alert-err',
75
100
  style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
76
101
  },
77
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ TTS UI Error:'),
102
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, (typeof fallbackDockText === 'function' ? fallbackDockText('ttsUiError') : '⚠️ TTS UI Error:')),
78
103
  React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
79
104
  React.createElement(
80
105
  'button',
@@ -114,12 +139,12 @@ window.__ModuleLoader__.load({
114
139
  '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0}' +
115
140
  '.dts-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
116
141
  '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
117
- '.dts-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
142
+ '.dts-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary, #6366f1)}' +
118
143
  '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:10px;padding:14px 0 4px}' +
119
144
  '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:6px 16px;font-size:13px;font-weight:500;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);transition:all .15s ease}' +
120
145
  '.dts-save:hover:not(:disabled){opacity:0.88}' +
121
146
  '.dts-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:6px 12px;font-size:13px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-weight:500;display:inline-flex;align-items:center;justify-content:center;gap:6px;transition:all .15s ease}' +
122
- '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
147
+ '.dts-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}' +
123
148
  '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
124
149
  '.dts-btn-danger:hover:not(:disabled){background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 50%, transparent)}' +
125
150
  '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2)}' +
@@ -127,18 +152,18 @@ window.__ModuleLoader__.load({
127
152
  '.dts-row .dts-model{flex:1;min-width:120px}' +
128
153
  '.dts-row .dts-key{flex:1;min-width:180px}' +
129
154
  '.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:13px;transition:all .15s ease}' +
130
- '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2))}' +
155
+ '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-3, var(--dsw-alias-bg-layer-2))}' +
131
156
  '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
132
157
  '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
133
158
  '.dts-badge{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap;display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
134
159
  '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
135
- '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
160
+ '.dts-badge-warn{color:var(--dsw-alias-state-warn-primary, #f59e0b);border-color:var(--dsw-alias-state-warn-primary, #f59e0b);background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 8%, transparent)}' +
136
161
  '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
137
162
  '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
138
163
  '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
139
164
  '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
140
165
  '.dts-alert-err{background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 20%, transparent)}' +
141
- '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 10%, transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warning-primary) 20%, transparent)}' +
166
+ '.dts-alert-warn{background:color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 10%, transparent);color:var(--dsw-alias-state-warn-primary, #f59e0b);border:1px solid color-mix(in srgb, var(--dsw-alias-state-warn-primary, #f59e0b) 20%, transparent)}' +
142
167
  '.dts-alert-ok{background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 20%, transparent)}' +
143
168
  '.dts-grow{flex:1;min-width:100px}'
144
169
 
@@ -165,7 +190,16 @@ window.__ModuleLoader__.load({
165
190
  }
166
191
  }
167
192
 
193
+ let clearGestureListeners = null
194
+ function removeGestureListeners() {
195
+ if (typeof clearGestureListeners === 'function') {
196
+ clearGestureListeners()
197
+ clearGestureListeners = null
198
+ }
199
+ }
200
+
168
201
  function unlockAudio() {
202
+ removeGestureListeners()
169
203
  try {
170
204
  const AC = window.AudioContext || window.webkitAudioContext
171
205
  if (AC) {
@@ -330,16 +364,21 @@ window.__ModuleLoader__.load({
330
364
  player.busy = false
331
365
  playerChanged()
332
366
  if (typeof window !== 'undefined') {
367
+ removeGestureListeners()
333
368
  const onUserGesture = () => {
334
- window.removeEventListener('click', onUserGesture)
335
- window.removeEventListener('keydown', onUserGesture)
336
- window.removeEventListener('touchstart', onUserGesture)
369
+ removeGestureListeners()
337
370
  unlockAudio()
338
371
  }
339
372
  window.addEventListener('click', onUserGesture, { once: true })
340
373
  window.addEventListener('keydown', onUserGesture, { once: true })
341
374
  window.addEventListener('touchstart', onUserGesture, { once: true })
375
+ clearGestureListeners = () => {
376
+ window.removeEventListener('click', onUserGesture)
377
+ window.removeEventListener('keydown', onUserGesture)
378
+ window.removeEventListener('touchstart', onUserGesture)
379
+ }
342
380
  }
381
+ resolve()
343
382
  return
344
383
  }
345
384
  done()
@@ -370,6 +409,9 @@ window.__ModuleLoader__.load({
370
409
  }
371
410
 
372
411
  function stopPlayback() {
412
+ removeGestureListeners()
413
+ player.blocked = false
414
+ setAutoplayBlocked(false)
373
415
  for (const item of player.queue) {
374
416
  if (item && item.prepared && item.prepared.url) {
375
417
  try { URL.revokeObjectURL(item.prepared.url) } catch { /* already revoked */ }
@@ -402,16 +444,22 @@ window.__ModuleLoader__.load({
402
444
  }
403
445
 
404
446
  function skipCurrent() {
447
+ try {
448
+ if (typeof window !== 'undefined' && window.speechSynthesis) {
449
+ window.speechSynthesis.cancel()
450
+ }
451
+ } catch (_err) { /* no speechSynthesis */ }
405
452
  if (player.audio) {
406
453
  try {
407
454
  player.audio.pause()
408
455
  player.audio.currentTime = 0
409
- } catch { /* ignore pause error */ }
456
+ } catch (_err) { /* ignore pause error */ }
410
457
  }
411
458
  if (currentAudioResolve) {
412
459
  currentAudioResolve()
413
460
  } else {
414
461
  player.audio = null
462
+ player.busy = false
415
463
  playerChanged()
416
464
  if (player.queue.length) drainQueue()
417
465
  }
@@ -469,13 +517,13 @@ window.__ModuleLoader__.load({
469
517
  if (player.enabled === false) return
470
518
  player.queue.push(item)
471
519
  drainQueue()
472
- } catch {}
520
+ } catch (_err) { /* ignore malformed chime payload */ }
473
521
  }
474
522
 
475
523
  const connect = () => {
476
524
  if (sseDisposed) return
477
525
  if (activeSse) {
478
- try { activeSse.close() } catch {}
526
+ try { activeSse.close() } catch (_err) { /* active sse already closed */ }
479
527
  activeSse = null
480
528
  }
481
529
  try {
@@ -486,7 +534,7 @@ window.__ModuleLoader__.load({
486
534
  es.addEventListener('chime', onChime)
487
535
  es.onerror = () => {
488
536
  if (activeSse === es) {
489
- try { es.close() } catch {}
537
+ try { es.close() } catch (_err) { /* eventsource already closed */ }
490
538
  activeSse = null
491
539
  }
492
540
  if (sseDisposed) return
@@ -504,7 +552,7 @@ window.__ModuleLoader__.load({
504
552
  sseDisposed = true
505
553
  if (activeSseTimer) clearTimeout(activeSseTimer)
506
554
  if (activeSse) {
507
- try { activeSse.close() } catch {}
555
+ try { activeSse.close() } catch (_err) { /* active sse already closed on dispose */ }
508
556
  activeSse = null
509
557
  }
510
558
  }
@@ -512,7 +560,7 @@ window.__ModuleLoader__.load({
512
560
 
513
561
  async function pollPending() {
514
562
  try {
515
- const st = await fetch('/dsh-tts/status', { cache: 'no-store' })
563
+ const st = await clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 })
516
564
  if (!st.ok) return
517
565
  const meta = await st.json()
518
566
  player.enabled = !!(meta && meta.speakReplies)
@@ -521,7 +569,7 @@ window.__ModuleLoader__.load({
521
569
  if (meta && meta.roles) player.roles = meta.roles
522
570
  if (meta && typeof meta.bargeIn === 'boolean') player.bargeIn = meta.bargeIn
523
571
  if (!player.enabled) return
524
- const res = await fetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store' })
572
+ const res = await clientFetch('/dsh-tts/pending?after=' + encodeURIComponent(player.after || ''), { cache: 'no-store', timeoutMs: 8000 })
525
573
  if (!res.ok) return
526
574
  const data = await res.json()
527
575
  const items = data && Array.isArray(data.items) ? data.items : []
@@ -577,10 +625,11 @@ window.__ModuleLoader__.load({
577
625
  function playStandalone(text) {
578
626
  if (!text) return
579
627
  stopPlayback()
580
- fetch('/dsh-tts/speak', {
628
+ clientFetch('/dsh-tts/speak', {
581
629
  method: 'POST',
582
630
  headers: { 'Content-Type': 'application/json' },
583
631
  body: JSON.stringify({ text }),
632
+ timeoutMs: 15000,
584
633
  })
585
634
  .then((r) => r.json())
586
635
  .then((d) => {
@@ -592,15 +641,18 @@ window.__ModuleLoader__.load({
592
641
  audioBase64: d.audioBase64,
593
642
  })
594
643
  })
595
- .catch(() => {})
644
+ .catch((err) => {
645
+ console.warn('[dsh-tts] playStandalone fetch failed:', err)
646
+ })
596
647
  }
597
648
 
598
649
  function previewSpeech(provider, model, voice, text) {
599
650
  stopPlayback()
600
- return fetch('/dsh-tts/preview', {
651
+ return clientFetch('/dsh-tts/preview', {
601
652
  method: 'POST',
602
653
  headers: { 'Content-Type': 'application/json' },
603
654
  body: JSON.stringify({ provider, model, voice, text }),
655
+ timeoutMs: 15000,
604
656
  })
605
657
  .then((r) => r.json())
606
658
  .then((d) => {
@@ -626,10 +678,11 @@ window.__ModuleLoader__.load({
626
678
  if (list && list.items && list.items.length) phrase = list.items[0]
627
679
  }
628
680
  if (!phrase) return
629
- fetch('/dsh-tts/speak', {
681
+ clientFetch('/dsh-tts/speak', {
630
682
  method: 'POST',
631
683
  headers: { 'Content-Type': 'application/json' },
632
684
  body: JSON.stringify({ text: phrase }),
685
+ timeoutMs: 15000,
633
686
  })
634
687
  .then((r) => r.json())
635
688
  .then((d) => {
@@ -655,6 +708,25 @@ window.__ModuleLoader__.load({
655
708
  return (map && map[provider]) || { configured: false, writable: true, ref: '' }
656
709
  }
657
710
  const en = {
711
+ 'ttsUiError': '⚠️ TTS UI Error:',
712
+ 'modelHint_openai': 'provider default',
713
+ 'modelHint_elevenlabs': 'provider default',
714
+ 'modelHint_google': 'provider default',
715
+ 'modelHint_azure': 'region voice name',
716
+ 'modelHint_groq': 'provider default',
717
+ 'modelHint_deepgram': 'provider default (English-only voices)',
718
+ 'modelHint_openrouter': 'provider/model id',
719
+ 'modelHint_siliconflow': 'model id (e.g. FunAudioLLM/CosyVoice2-0.5B)',
720
+ 'modelHint_deepinfra': 'model id (e.g. hexgrad/Kokoro-82M)',
721
+ 'modelHint_fireworks': 'model id (e.g. kokoro)',
722
+ 'modelHint_mimo': 'model id (e.g. mimo-v2.5-tts)',
723
+ 'modelHint_custom': 'custom model',
724
+ 'modelHint_minimax': 'model id',
725
+ 'modelHint_edge': 'BCP-47 voice, e.g. en-US-AriaNeural',
726
+ 'modelHint_piper': 'path to .onnx',
727
+ 'modelHint_espeak': 'language code, e.g. en',
728
+ 'modelHint_kokoro': 'not bundled — offline neural runtime unavailable',
729
+ 'modelHint_f5': 'not bundled — offline neural runtime unavailable',
658
730
  'dockLabel': 'Speech',
659
731
  'dockPause': 'Pause',
660
732
  'dockResume': 'Resume speech',
@@ -862,6 +934,25 @@ window.__ModuleLoader__.load({
862
934
  }
863
935
 
864
936
  const zh = {
937
+ 'ttsUiError': '⚠️ TTS 界面错误:',
938
+ 'modelHint_openai': '服务商默认',
939
+ 'modelHint_elevenlabs': '服务商默认',
940
+ 'modelHint_google': '服务商默认',
941
+ 'modelHint_azure': '区域语音名称',
942
+ 'modelHint_groq': '服务商默认',
943
+ 'modelHint_deepgram': '服务商默认 (仅英语语音)',
944
+ 'modelHint_openrouter': '服务商/模型 ID',
945
+ 'modelHint_siliconflow': '模型 ID (如 FunAudioLLM/CosyVoice2-0.5B)',
946
+ 'modelHint_deepinfra': '模型 ID (如 hexgrad/Kokoro-82M)',
947
+ 'modelHint_fireworks': '模型 ID (如 kokoro)',
948
+ 'modelHint_mimo': '模型 ID (如 mimo-v2.5-tts)',
949
+ 'modelHint_custom': '自定义模型',
950
+ 'modelHint_minimax': '模型 ID',
951
+ 'modelHint_edge': 'BCP-47 语音名称,如 en-US-AriaNeural',
952
+ 'modelHint_piper': '.onnx 模型文件路径',
953
+ 'modelHint_espeak': '语言代码,如 en',
954
+ 'modelHint_kokoro': '未内置 — 离线神经语音运行时不可用',
955
+ 'modelHint_f5': '未内置 — 离线神经语音运行时不可用',
865
956
  'dockLabel': '语音朗读',
866
957
  'dockPause': '暂停朗读',
867
958
  'dockResume': '继续朗读',
@@ -1100,7 +1191,7 @@ window.__ModuleLoader__.load({
1100
1191
  }, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
1101
1192
  React.createElement('input', {
1102
1193
  className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
1103
- placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
1194
+ placeholder: (typeof getModelHint === 'function' ? getModelHint(row.provider, t) : (MODEL_HINT[row.provider] || '')), onChange: (e) => change(i, { model: e.target.value }),
1104
1195
  }),
1105
1196
  React.createElement('input', {
1106
1197
  className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
@@ -1150,8 +1241,8 @@ window.__ModuleLoader__.load({
1150
1241
  const [models, setModels] = React.useState({})
1151
1242
 
1152
1243
  const loadStatus = React.useCallback(() => {
1153
- if (typeof fetch === 'undefined') return
1154
- fetch('/dsh-tts/models/status')
1244
+ if (typeof clientFetch === 'undefined' && typeof fetch === 'undefined') return
1245
+ clientFetch('/dsh-tts/models/status', { timeoutMs: 8000 })
1155
1246
  .then((r) => r.json())
1156
1247
  .then((d) => { if (d && d.ok && d.models) setModels(d.models) })
1157
1248
  .catch(() => {}) // status poll best-effort
@@ -1167,11 +1258,14 @@ window.__ModuleLoader__.load({
1167
1258
  if (typeof window !== 'undefined' && window.confirm) {
1168
1259
  if (!window.confirm(t('confirmDeleteModel'))) return
1169
1260
  }
1170
- fetch('/dsh-tts/models/delete', {
1261
+ clientFetch('/dsh-tts/models/delete', {
1171
1262
  method: 'DELETE',
1172
1263
  headers: { 'content-type': 'application/json' },
1173
1264
  body: JSON.stringify({ engine }),
1174
- }).then(() => loadStatus()).catch(() => {})
1265
+ timeoutMs: 10000,
1266
+ }).then(() => loadStatus()).catch((err) => {
1267
+ console.warn('[dsh-tts] model delete failed:', err)
1268
+ })
1175
1269
  }
1176
1270
 
1177
1271
  const kokoro = models.kokoro || { installed: false, downloading: false, progress: 0 }
@@ -1411,7 +1505,7 @@ window.__ModuleLoader__.load({
1411
1505
  style: {
1412
1506
  padding: '10px 14px',
1413
1507
  borderRadius: '8px',
1414
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1508
+ border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
1415
1509
  background: 'var(--dsw-alias-bg-layer-2)',
1416
1510
  display: 'flex',
1417
1511
  flexDirection: 'column',
@@ -1419,7 +1513,7 @@ window.__ModuleLoader__.load({
1419
1513
  fontSize: '13px',
1420
1514
  },
1421
1515
  },
1422
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1516
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
1423
1517
  '⚠️ ' + t('voiceNotInstalledTitle')
1424
1518
  ),
1425
1519
  React.createElement('span', { className: 'dts-sub' },
@@ -1453,7 +1547,7 @@ window.__ModuleLoader__.load({
1453
1547
  style: {
1454
1548
  padding: '10px 14px',
1455
1549
  borderRadius: '8px',
1456
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1550
+ border: '1px solid var(--dsw-alias-state-warn-primary, #f59e0b)',
1457
1551
  background: 'var(--dsw-alias-bg-layer-2)',
1458
1552
  display: 'flex',
1459
1553
  flexDirection: 'column',
@@ -1461,7 +1555,7 @@ window.__ModuleLoader__.load({
1461
1555
  fontSize: '13px',
1462
1556
  },
1463
1557
  },
1464
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1558
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' } },
1465
1559
  '⚠️ ' + t('messengerNotInstalledTitle')
1466
1560
  ),
1467
1561
  React.createElement('span', { className: 'dts-sub' },
@@ -1496,8 +1590,9 @@ window.__ModuleLoader__.load({
1496
1590
  setLoading(true)
1497
1591
  setMsg(null)
1498
1592
  try {
1499
- const res = await fetch('/api/dsh-tts/update', {
1593
+ const res = await clientFetch('/api/dsh-tts/update', {
1500
1594
  headers: { 'x-dsh-plugin-update': '1' },
1595
+ timeoutMs: 10000,
1501
1596
  })
1502
1597
  if (!res.ok) throw new Error('HTTP ' + res.status)
1503
1598
  const data = await res.json()
@@ -1513,12 +1608,13 @@ window.__ModuleLoader__.load({
1513
1608
  setUpdating(true)
1514
1609
  setMsg(null)
1515
1610
  try {
1516
- const res = await fetch('/api/dsh-tts/update', {
1611
+ const res = await clientFetch('/api/dsh-tts/update', {
1517
1612
  method: 'POST',
1518
1613
  headers: {
1519
1614
  'x-dsh-plugin-update': '1',
1520
1615
  'content-type': 'application/json',
1521
1616
  },
1617
+ timeoutMs: 60000,
1522
1618
  })
1523
1619
  const data = await res.json()
1524
1620
  if (!res.ok) throw new Error((data && (data.error || data.message)) || 'HTTP ' + res.status)
@@ -1562,7 +1658,7 @@ window.__ModuleLoader__.load({
1562
1658
  {
1563
1659
  type: 'button',
1564
1660
  className: 'dts-btn',
1565
- style: { borderColor: 'var(--dsw-alias-state-warning-primary)', color: 'var(--dsw-alias-state-warning-primary)' },
1661
+ style: { borderColor: 'var(--dsw-alias-state-warn-primary, #f59e0b)', color: 'var(--dsw-alias-state-warn-primary, #f59e0b)' },
1566
1662
  onClick: onUpdateNow,
1567
1663
  disabled: updating || loading,
1568
1664
  },
@@ -1592,11 +1688,11 @@ window.__ModuleLoader__.load({
1592
1688
  const t = props.t || ((key) => key)
1593
1689
  const [data, setData] = React.useState(null)
1594
1690
  const loadStats = () => {
1595
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1691
+ clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((r) => r.json()).then(setData).catch((err) => console.warn('[dsh-tts] loadStats failed:', err))
1596
1692
  }
1597
1693
  React.useEffect(() => { loadStats() }, [])
1598
1694
  const reset = () => {
1599
- fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1695
+ clientFetch('/dsh-tts/stats', { method: 'DELETE', timeoutMs: 8000 }).then(loadStats).catch((err) => console.warn('[dsh-tts] resetStats failed:', err))
1600
1696
  }
1601
1697
  return React.createElement('div', { className: 'dts-block' },
1602
1698
  React.createElement('span', { className: 'dts-sub' }, data
@@ -1668,6 +1764,7 @@ window.__ModuleLoader__.load({
1668
1764
  // looks like a working empty form.
1669
1765
  // Snapshot status contract (#163):
1670
1766
  // Without valid scope, or when loading/unavailable, editing is disabled.
1767
+ const [revision, setRevision] = React.useState(null)
1671
1768
  const ready = !!scope && !!snap && snap.status === 'ready'
1672
1769
  const writable = ready && snap.writable !== false
1673
1770
 
@@ -1675,23 +1772,24 @@ window.__ModuleLoader__.load({
1675
1772
  const cfg = data && data.config ? data.config : {}
1676
1773
  setDraft(JSON.parse(JSON.stringify(cfg)))
1677
1774
  setCredentials(data && data.credentials ? data.credentials : {})
1775
+ if (data && data.revision !== undefined) setRevision(data.revision)
1678
1776
  player.enabled = !!cfg.speakReplies
1679
1777
  }
1680
1778
 
1681
1779
  React.useEffect(() => {
1682
1780
  let alive = true
1683
- fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1781
+ clientFetch('/dsh-tts/config', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
1684
1782
  if (!alive) return
1685
1783
  applyPayload(data)
1686
1784
  }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
1687
1785
 
1688
- fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1786
+ clientFetch('/dsh-tts/integrations', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((data) => {
1689
1787
  if (!alive) return
1690
- if (data && data.ok) setIntegrations(data)
1788
+ if (data && data.ok) setIntegrations(data.integrations || data)
1691
1789
  }).catch(() => {})
1692
1790
 
1693
1791
  const loadTelemetry = () => {
1694
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1792
+ clientFetch('/dsh-tts/stats', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
1695
1793
  if (!alive || !st) return
1696
1794
  setTelemetry((prev) => Object.assign({}, prev, {
1697
1795
  cacheHits: st.cacheHits || 0,
@@ -1702,7 +1800,7 @@ window.__ModuleLoader__.load({
1702
1800
  }).catch(() => {
1703
1801
  if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
1704
1802
  })
1705
- fetch('/dsh-tts/status', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1803
+ clientFetch('/dsh-tts/status', { cache: 'no-store', timeoutMs: 8000 }).then((res) => res.json()).then((st) => {
1706
1804
  if (!alive || !st) return
1707
1805
  setTelemetry((prev) => Object.assign({}, prev, {
1708
1806
  sseClients: st.sseClients || 0,
@@ -1733,10 +1831,11 @@ window.__ModuleLoader__.load({
1733
1831
  const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
1734
1832
  if (!value) return
1735
1833
  setErr('')
1736
- const res = await fetch('/dsh-tts/credential', {
1834
+ const res = await clientFetch('/dsh-tts/credential', {
1737
1835
  method: 'PUT',
1738
1836
  headers: { 'Content-Type': 'application/json' },
1739
1837
  body: JSON.stringify({ provider: provider, value: value }),
1838
+ timeoutMs: 10000,
1740
1839
  })
1741
1840
  const data = await res.json().catch(() => ({ /* parse error */ }))
1742
1841
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1747,10 +1846,11 @@ window.__ModuleLoader__.load({
1747
1846
  const clearKey = async (provider) => {
1748
1847
  setErr('')
1749
1848
  try {
1750
- const res = await fetch('/dsh-tts/credential', {
1849
+ const res = await clientFetch('/dsh-tts/credential', {
1751
1850
  method: 'DELETE',
1752
1851
  headers: { 'Content-Type': 'application/json' },
1753
1852
  body: JSON.stringify({ provider: provider }),
1853
+ timeoutMs: 10000,
1754
1854
  })
1755
1855
  const data = await res.json().catch(() => ({ /* parse error */ }))
1756
1856
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1768,10 +1868,15 @@ window.__ModuleLoader__.load({
1768
1868
  const v = String(keyDrafts[p] || '').trim()
1769
1869
  if (v) keys[p] = v
1770
1870
  })
1771
- const res = await fetch('/dsh-tts/config', {
1871
+ const bodyPayload = Object.assign({}, draft, { keys: keys })
1872
+ if (revision !== null && revision !== undefined) {
1873
+ bodyPayload.revision = revision
1874
+ }
1875
+ const res = await clientFetch('/dsh-tts/config', {
1772
1876
  method: 'PUT',
1773
1877
  headers: { 'Content-Type': 'application/json' },
1774
- body: JSON.stringify(Object.assign({}, draft, { keys: keys })),
1878
+ body: JSON.stringify(bodyPayload),
1879
+ timeoutMs: 10000,
1775
1880
  })
1776
1881
  const data = await res.json().catch(() => ({ /* parse error */ }))
1777
1882
  if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
@@ -1787,7 +1892,7 @@ window.__ModuleLoader__.load({
1787
1892
 
1788
1893
  const clearCacheNow = async () => {
1789
1894
  setErr('')
1790
- const res = await fetch('/dsh-tts/cache', { method: 'DELETE' })
1895
+ const res = await clientFetch('/dsh-tts/cache', { method: 'DELETE', timeoutMs: 10000 })
1791
1896
  if (!res.ok) throw new Error('HTTP ' + res.status)
1792
1897
  setSaved(true); setTimeout(() => setSaved(false), 2000)
1793
1898
  }
@@ -1885,12 +1990,12 @@ window.__ModuleLoader__.load({
1885
1990
  React.createElement(VoiceDuplexEditor, {
1886
1991
  t: t, writable: writable, draft: draft,
1887
1992
  boolField: boolField, setTop: setTop,
1888
- installed: !!(integrations && integrations.voice && integrations.voice.installed),
1993
+ installed: !!(integrations && (integrations.voice?.installed ?? integrations.integrations?.voice?.installed ?? integrations.voiceInstalled)),
1889
1994
  }),
1890
1995
  React.createElement(MessengerIntegrationEditor, {
1891
1996
  t: t, writable: writable, draft: draft,
1892
1997
  boolField: boolField, setTop: setTop,
1893
- installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
1998
+ installed: !!(integrations && (integrations.messenger?.installed ?? integrations.integrations?.messenger?.installed ?? integrations.messengerInstalled)),
1894
1999
  }),
1895
2000
  React.createElement('div', { className: 'dts-block' },
1896
2001
  React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
@@ -11,12 +11,12 @@ export const MODEL_SPECS = {
11
11
  files: [
12
12
  {
13
13
  name: 'kokoro-v0_19.onnx',
14
- url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/kokoro-v0_19.onnx',
14
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/f905c3175b9f7a77d4c1b9be894ff99ff7e9a8f2/kokoro-v0_19.onnx',
15
15
  minBytes: 1024,
16
16
  },
17
17
  {
18
18
  name: 'voices.json',
19
- url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/main/voices.json',
19
+ url: 'https://huggingface.co/hexgrad/Kokoro-82M/resolve/f905c3175b9f7a77d4c1b9be894ff99ff7e9a8f2/voices.json',
20
20
  minBytes: 1024,
21
21
  },
22
22
  ],
@@ -45,13 +45,26 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
45
45
  return path.join(baseDir, engineId)
46
46
  }
47
47
 
48
- async function checkFileExists(filePath, minBytes = 1024) {
49
- try {
50
- const st = await fs.promises.stat(filePath)
51
- return st.isFile() && st.size >= minBytes
52
- } catch {
48
+ async function validateFileIntegrity(filePath, minBytes = 1024) {
49
+ try {
50
+ const st = await fs.promises.stat(filePath)
51
+ if (!st.isFile() || st.size < minBytes) return false
52
+ const fd = await fs.promises.open(filePath, 'r')
53
+ const buf = Buffer.alloc(Math.min(st.size, 1024))
54
+ await fd.read(buf, 0, buf.length, 0)
55
+ await fd.close()
56
+ const str = buf.toString('utf8').trim().toLowerCase()
57
+ if (str.startsWith('<!doctype') || str.startsWith('<html') || str.startsWith('<head') || str.includes('<html')) {
53
58
  return false
54
59
  }
60
+ return true
61
+ } catch (_err) {
62
+ return false
63
+ }
64
+ }
65
+
66
+ async function checkFileExists(filePath, minBytes = 1024) {
67
+ return await validateFileIntegrity(filePath, minBytes)
55
68
  }
56
69
 
57
70
  async function getDirSize(dirPath) {
@@ -236,6 +249,10 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
236
249
  if (st.size < minExpected) {
237
250
  throw new Error(`File ${f.name} truncated: got ${st.size} bytes, expected at least ${minExpected}`)
238
251
  }
252
+ const valid = await validateFileIntegrity(tmpFile, minExpected)
253
+ if (!valid) {
254
+ throw new Error(`File ${f.name} failed integrity check: invalid format or HTML error`)
255
+ }
239
256
 
240
257
  await fs.promises.rename(tmpFile, targetFile)
241
258
  const idx = createdTmpFiles.indexOf(tmpFile)
@@ -255,7 +272,9 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
255
272
  lastErrors.set(engineId, { message: sanitized, failedAt: Date.now() })
256
273
  throw err
257
274
  } finally {
258
- activeDownloads.delete(engineId)
275
+ if (activeDownloads.get(engineId) === downloadState) {
276
+ activeDownloads.delete(engineId)
277
+ }
259
278
  }
260
279
  }
261
280
 
@@ -269,7 +288,16 @@ export function createModelManager({ root = process.env.DSH_HOME || path.join(os
269
288
  const active = activeDownloads.get(engineId)
270
289
  if (active && active.controller) {
271
290
  active.controller.abort()
272
- activeDownloads.delete(engineId)
291
+ if (active.promise) {
292
+ try {
293
+ await active.promise
294
+ } catch (_err) {
295
+ // aborted download rejected as expected
296
+ }
297
+ }
298
+ if (activeDownloads.get(engineId) === active) {
299
+ activeDownloads.delete(engineId)
300
+ }
273
301
  lastErrors.delete(engineId)
274
302
  return true
275
303
  }