@goodandready/dsh-tts 0.4.0 → 0.4.1

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
@@ -38,34 +38,96 @@ window.__ModuleLoader__.load({
38
38
  f5: 'not bundled — offline neural runtime unavailable',
39
39
  }
40
40
 
41
+
42
+ function createErrorBoundary() {
43
+ if (!React || typeof React.Component !== 'function') {
44
+ return function NoopBoundary(props) { return (props && props.children) || null }
45
+ }
46
+ return class ErrorBoundary extends React.Component {
47
+ constructor(props) {
48
+ super(props)
49
+ this.state = { hasError: false, error: null }
50
+ }
51
+ static getDerivedStateFromError(error) {
52
+ return { hasError: true, error }
53
+ }
54
+ componentDidCatch(error, errorInfo) {
55
+ console.error('[dsh-tts] React Error:', error, errorInfo)
56
+ }
57
+ render() {
58
+ if (this.state.hasError) {
59
+ return React.createElement(
60
+ 'div',
61
+ {
62
+ className: 'dts-alert dts-alert-err',
63
+ style: { margin: '12px 0', padding: '14px', borderRadius: '8px' },
64
+ },
65
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ TTS UI Error:'),
66
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
67
+ React.createElement(
68
+ 'button',
69
+ {
70
+ type: 'button',
71
+ className: 'dts-btn',
72
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
73
+ onClick: () => this.setState({ hasError: false, error: null }),
74
+ },
75
+ 'Retry',
76
+ ),
77
+ )
78
+ }
79
+ return (this.props && this.props.children) || null
80
+ }
81
+ }
82
+ }
83
+ const ErrorBoundary = createErrorBoundary()
84
+ const SNAPSHOT_READY = Object.freeze({ status: 'ready', value: {} })
85
+ const SNAPSHOT_LOADING = Object.freeze({ status: 'loading', value: {} })
86
+
41
87
  const SET_CSS =
42
- '.dts-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
43
- '.dts-block{display:flex;flex-direction:column;gap:10px}' +
44
- '.dts-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
45
- // Plugin-settings card matches the core card pattern; theme variables only.
88
+ '.dts-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:900px}' +
89
+ '.dts-header{display:flex;flex-direction:column;gap:8px;padding-bottom:14px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +
90
+ '.dts-page-title{font-size:20px;font-weight:700;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:10px}' +
91
+ '.dts-page-sub{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}' +
92
+ '.dts-block{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:16px 18px;display:flex;flex-direction:column;gap:12px}' +
93
+ '.dts-h{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
94
+ '.dts-sub{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}' +
46
95
  '.dts-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
47
96
  '.dts-head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
48
97
  '.dts-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
49
98
  '.dts-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
50
- '.dts-sub{color:var(--dsw-alias-label-secondary);font-size:13px}' +
51
99
  '.dts-chev{margin-left:auto;color:var(--dsw-alias-label-secondary);flex:none;transition:transform .16s}' +
52
100
  '.dts-chevOpen{transform:rotate(180deg)}' +
53
- '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
54
- '.dts-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}' +
55
- '.dts-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}' +
56
- '.dts-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}' +
57
- '.dts-save{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:8px;padding:5px 14px;font-size:13px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}' +
58
- // Provider-chain form helpers.
59
- '.dts-entry{display:flex;flex-direction:column;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}' +
101
+ '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0}' +
102
+ '.dts-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
103
+ '.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}' +
104
+ '.dts-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
105
+ '.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}' +
106
+ '.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}' +
107
+ '.dts-save:hover:not(:disabled){opacity:0.88}' +
108
+ '.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}' +
109
+ '.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))}' +
110
+ '.dts-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
111
+ '.dts-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.5)}' +
112
+ '.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)}' +
60
113
  '.dts-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
61
114
  '.dts-row .dts-model{flex:1;min-width:120px}' +
62
115
  '.dts-row .dts-key{flex:1;min-width:180px}' +
63
- '.dts-mini{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);border-radius:6px;width:28px;height:28px;cursor:pointer;flex:none}' +
64
- '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
65
- '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
66
- '.dts-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);white-space:nowrap}' +
67
- '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
68
- '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0}'
116
+ '.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}' +
117
+ '.dts-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2))}' +
118
+ '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary);font-weight:500}' +
119
+ '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary);font-weight:500}' +
120
+ '.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}' +
121
+ '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
122
+ '.dts-badge-warn{color:var(--dsw-alias-state-warning-primary);border-color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
123
+ '.dts-badge-bad{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
124
+ '.dts-link{background:none;border:none;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:12px;padding:0;text-decoration:underline}' +
125
+ '.dts-link:hover:not(:disabled){color:var(--dsw-alias-label-primary)}' +
126
+ '.dts-alert{padding:10px 14px;border-radius:8px;font-size:13px}' +
127
+ '.dts-alert-err{background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);border:1px solid rgba(239,68,68,0.2)}' +
128
+ '.dts-alert-warn{background:rgba(245,158,11,0.1);color:var(--dsw-alias-state-warning-primary);border:1px solid rgba(245,158,11,0.2)}' +
129
+ '.dts-alert-ok{background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);border:1px solid rgba(16,185,129,0.2)}' +
130
+ '.dts-grow{flex:1;min-width:100px}'
69
131
 
70
132
  const setCssId = 'dsh-tts/settings.module.css'
71
133
  if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-tts"]')) {
@@ -75,7 +137,6 @@ window.__ModuleLoader__.load({
75
137
  tag.dataset.pluginCss = setCssId
76
138
  document.head.appendChild(tag)
77
139
  }
78
-
79
140
  // Player.
80
141
  //
81
142
  // Each incoming chunk used to replace the previous one immediately; while reading
@@ -366,77 +427,11 @@ window.__ModuleLoader__.load({
366
427
  return (map && map[provider]) || { configured: false, writable: true, ref: '' }
367
428
  }
368
429
 
369
- function ChainEditor(props) {
370
- // Translator comes from the card: the slot passes props.t only to it.
371
- const t = props.t || ((key) => key)
372
- const rows = Array.isArray(props.value) ? props.value : []
373
- const change = (i, patch) => {
374
- const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
375
- props.onChange(next)
376
- }
377
- const move = (i, delta) => {
378
- const j = i + delta
379
- if (j < 0 || j >= rows.length) return
380
- const next = rows.slice()
381
- const tmp = next[i]; next[i] = next[j]; next[j] = tmp
382
- props.onChange(next)
383
- }
384
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
385
- const add = () => props.onChange(rows.concat([{ provider: 'espeak', model: '', voice: '' }]))
386
- return React.createElement('div', { className: 'dts-block' },
387
- rows.map((row, i) => {
388
- const cloud = !!CLOUD[row.provider]
389
- const cred = credOf(props.credentials, row.provider)
390
- const draft = (props.keyDrafts && props.keyDrafts[row.provider]) || ''
391
- const badge = !cloud ? null
392
- : (!cred.writable ? t('fromEnv') : (cred.configured ? t('configured') : t('notSet')))
393
- return React.createElement('div', { className: 'dts-entry', key: i },
394
- React.createElement('div', { className: 'dts-row' },
395
- React.createElement('select', {
396
- className: 'dts-input',
397
- value: row.provider, disabled: !props.writable,
398
- onChange: (e) => change(i, { provider: e.target.value }),
399
- }, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
400
- React.createElement('input', {
401
- className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
402
- placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
403
- }),
404
- React.createElement('input', {
405
- className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
406
- placeholder: 'voice', onChange: (e) => change(i, { voice: e.target.value }),
407
- }),
408
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
409
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
410
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
411
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
412
- ),
413
- cloud ? React.createElement('div', { className: 'dts-row' },
414
- React.createElement('input', {
415
- className: 'dts-key dts-input', type: 'password', autoComplete: 'off',
416
- value: draft, disabled: !props.writable || !cred.writable,
417
- placeholder: cred.configured ? 'leave blank to keep' : 'paste API key',
418
- onChange: (e) => props.onDraft(row.provider, e.target.value),
419
- onBlur: (e) => props.onCommitKey(row.provider, e.target.value),
420
- }),
421
- React.createElement('span', { className: 'dts-badge' + (cred.configured ? ' dts-badge-on' : '') }, badge),
422
- cred.configured && cred.writable ? React.createElement('button', {
423
- type: 'button', className: 'dts-link', disabled: !props.writable,
424
- onClick: () => props.onClearKey(row.provider),
425
- }, t('clear')) : null,
426
- ) : React.createElement('span', { className: 'dts-sub' }, t('localProvider')),
427
- cloud && cred.ref ? React.createElement('span', { className: 'dts-sub' }, 'Stored as ' + cred.ref) : null,
428
- )
429
- }),
430
- React.createElement('div', { className: 'dts-row' },
431
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
432
- React.createElement('span', { className: 'dts-sub' }, t('chainHint')),
433
- ),
434
- )
435
- }
436
-
437
- // Card strings live in the locale registry so a separate package can translate
438
- // them without touching this plugin. English is the source language and fallback.
439
430
  const en = {
431
+ 'statusOnline': 'Host online',
432
+ 'statusOffline': 'Host unreachable',
433
+ 'cacheStats': 'Cache',
434
+
440
435
  'general': 'General',
441
436
  'loading': 'Loading\u2026',
442
437
  'chainHintCard': 'Pick a provider, paste its API key, and Save (or leave the key field). The key is written to the host credentials store immediately. The browser never reads it back.',
@@ -470,6 +465,24 @@ window.__ModuleLoader__.load({
470
465
  'edgeHint': 'Python CLI from the edge-tts package.',
471
466
  'azureRegion': 'Azure region',
472
467
  'azureHint': 'Required for the azure provider, e.g. eastus.',
468
+ 'openaiBaseUrl': 'OpenAI base URL',
469
+ 'openaiBaseUrlHint': 'OpenAI-compatible origin for the openai provider.',
470
+ 'mimoBaseUrl': 'MiMo base URL',
471
+ 'mimoBaseUrlHint': 'MiMo API root; synthesis uses its chat endpoint.',
472
+ 'mimoFormat': 'MiMo format',
473
+ 'mimoFormatHint': 'mp3 or wav.',
474
+ 'minimaxBin': 'MiniMax CLI',
475
+ 'minimaxBinHint': 'Looked up in PATH unless absolute; install and log in separately.',
476
+ 'advancedTitle': 'Advanced',
477
+ 'advancedHint': 'Limits and endpoint overrides. Provider API keys stay in the chain editor; credential slot names (*KeyEnv) are config-only.',
478
+ 'maxChars': 'Max characters',
479
+ 'maxCharsHint': 'Hard cap before long-reply handling; 0 disables.',
480
+ 'sentenceChars': 'Sentence chunk size',
481
+ 'sentenceCharsHint': 'Max characters per streaming sentence piece.',
482
+ 'timeoutMs': 'Provider timeout (ms)',
483
+ 'timeoutMsHint': 'Abort a provider attempt after this many milliseconds.',
484
+ 'maxQueue': 'Max queue length',
485
+ 'maxQueueHint': 'Drop oldest pending pieces beyond this count.',
473
486
  'chainTitle': 'Provider chain',
474
487
  'addProvider': 'Add provider',
475
488
  'localProvider': 'Local / CLI provider — no API key',
@@ -577,6 +590,10 @@ window.__ModuleLoader__.load({
577
590
  'dockStop': 'Stop speech',
578
591
  }
579
592
  const ru = {
593
+ 'statusOnline': 'Хост на связи',
594
+ 'statusOffline': 'Хост недоступен',
595
+ 'cacheStats': 'Кэш',
596
+
580
597
  'dockLabel': 'Чтение',
581
598
  'dockPause': 'Пауза',
582
599
  'dockResume': 'Продолжить чтение',
@@ -615,6 +632,24 @@ window.__ModuleLoader__.load({
615
632
  'edgeHint': 'Консольная утилита из пакета edge-tts.',
616
633
  'azureRegion': 'Azure: регион',
617
634
  'azureHint': 'Нужен провайдеру azure, например eastus.',
635
+ 'openaiBaseUrl': 'OpenAI base URL',
636
+ 'openaiBaseUrlHint': 'OpenAI-совместимый origin для провайдера openai.',
637
+ 'mimoBaseUrl': 'MiMo base URL',
638
+ 'mimoBaseUrlHint': 'Корень API MiMo; синтез идёт через chat endpoint.',
639
+ 'mimoFormat': 'Формат MiMo',
640
+ 'mimoFormatHint': 'mp3 или wav.',
641
+ 'minimaxBin': 'MiniMax CLI',
642
+ 'minimaxBinHint': 'Ищется в PATH, если путь не абсолютный; вход в CLI — отдельно.',
643
+ 'advancedTitle': 'Дополнительно',
644
+ 'advancedHint': 'Лимиты и override endpoint. API-ключи — в редакторе цепочки; имена credential-слотов (*KeyEnv) — только в конфиге.',
645
+ 'maxChars': 'Максимум символов',
646
+ 'maxCharsHint': 'Жёсткий лимит до обработки длинных ответов; 0 — без лимита.',
647
+ 'sentenceChars': 'Размер куска (символы)',
648
+ 'sentenceCharsHint': 'Максимум символов в streaming-куске предложения.',
649
+ 'timeoutMs': 'Таймаут провайдера (мс)',
650
+ 'timeoutMsHint': 'Прервать попытку провайдера через столько миллисекунд.',
651
+ 'maxQueue': 'Длина очереди',
652
+ 'maxQueueHint': 'Отбрасывать самые старые куски сверх этого числа.',
618
653
  'chainTitle': 'Цепочка провайдеров',
619
654
  'addProvider': 'Добавить провайдера',
620
655
  'localProvider': 'Локальный провайдер — ключ не нужен',
@@ -717,6 +752,77 @@ window.__ModuleLoader__.load({
717
752
  'messengerTtsEnabledHint': 'Разрешить мосту мессенджеров озвучивать ответы голосовыми заметками.',
718
753
  }
719
754
 
755
+ function ChainEditor(props) {
756
+ // Translator comes from the card: the slot passes props.t only to it.
757
+ const t = props.t || ((key) => key)
758
+ const rows = Array.isArray(props.value) ? props.value : []
759
+ const change = (i, patch) => {
760
+ const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
761
+ props.onChange(next)
762
+ }
763
+ const move = (i, delta) => {
764
+ const j = i + delta
765
+ if (j < 0 || j >= rows.length) return
766
+ const next = rows.slice()
767
+ const tmp = next[i]; next[i] = next[j]; next[j] = tmp
768
+ props.onChange(next)
769
+ }
770
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
771
+ const add = () => props.onChange(rows.concat([{ provider: 'espeak', model: '', voice: '' }]))
772
+ return React.createElement('div', { className: 'dts-block' },
773
+ rows.map((row, i) => {
774
+ const cloud = !!CLOUD[row.provider]
775
+ const cred = credOf(props.credentials, row.provider)
776
+ const draft = (props.keyDrafts && props.keyDrafts[row.provider]) || ''
777
+ const badge = !cloud ? null
778
+ : (!cred.writable ? t('fromEnv') : (cred.configured ? t('configured') : t('notSet')))
779
+ return React.createElement('div', { className: 'dts-entry', key: i },
780
+ React.createElement('div', { className: 'dts-row' },
781
+ React.createElement('select', {
782
+ className: 'dts-input',
783
+ value: row.provider, disabled: !props.writable,
784
+ onChange: (e) => change(i, { provider: e.target.value }),
785
+ }, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
786
+ React.createElement('input', {
787
+ className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
788
+ placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
789
+ }),
790
+ React.createElement('input', {
791
+ className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
792
+ placeholder: 'voice', onChange: (e) => change(i, { voice: e.target.value }),
793
+ }),
794
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
795
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
796
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
797
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
798
+ ),
799
+ cloud ? React.createElement('div', { className: 'dts-row' },
800
+ React.createElement('input', {
801
+ className: 'dts-key dts-input', type: 'password', autoComplete: 'off',
802
+ value: draft, disabled: !props.writable || !cred.writable,
803
+ placeholder: cred.configured ? 'leave blank to keep' : 'paste API key',
804
+ onChange: (e) => props.onDraft(row.provider, e.target.value),
805
+ onBlur: (e) => props.onCommitKey(row.provider, e.target.value),
806
+ }),
807
+ React.createElement('span', { className: 'dts-badge' + (cred.configured ? ' dts-badge-on' : '') }, badge),
808
+ cred.configured && cred.writable ? React.createElement('button', {
809
+ type: 'button', className: 'dts-link', disabled: !props.writable,
810
+ onClick: () => props.onClearKey(row.provider),
811
+ }, t('clear')) : null,
812
+ ) : React.createElement('span', { className: 'dts-sub' }, t('localProvider')),
813
+ cloud && cred.ref ? React.createElement('span', { className: 'dts-sub' }, 'Stored as ' + cred.ref) : null,
814
+ )
815
+ }),
816
+ React.createElement('div', { className: 'dts-row' },
817
+ React.createElement('button', { type: 'button', className: 'dts-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
818
+ React.createElement('span', { className: 'dts-sub' }, t('chainHint')),
819
+ ),
820
+ )
821
+ }
822
+
823
+ // Card strings live in the locale registry so a separate package can translate
824
+ // them without touching this plugin. English is the source language and fallback.
825
+
720
826
  function LocalEnginesEditor(props) {
721
827
  const t = props.t || ((key) => key)
722
828
  const [models, setModels] = React.useState({})
@@ -799,80 +905,366 @@ window.__ModuleLoader__.load({
799
905
  )
800
906
  }
801
907
 
802
- function TtsSection(props) {
803
- // Translator comes from the slot because its registration sets locale.
804
- const t = (props && props.t) || ((key) => key)
805
- const [draft, setDraft] = React.useState(null)
806
- const [credentials, setCredentials] = React.useState({})
807
- const [keyDrafts, setKeyDrafts] = React.useState({})
808
- const [saved, setSaved] = React.useState(false)
809
- const [err, setErr] = React.useState('')
810
- const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
811
- const ctx = props && props.ctx
812
- const scope = (ctx && ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope))
813
- ? ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
814
- : null
815
- const [snap, setSnap] = React.useState(null)
816
-
817
- React.useEffect(() => {
818
- if (!scope) return undefined
819
- let alive = true
820
- const render = () => { if (alive && scope.getSnapshot) setSnap(scope.getSnapshot()) }
821
- render()
822
- if (scope.subscribe) {
823
- const off = scope.subscribe(render)
824
- return () => { alive = false; if (typeof off === 'function') off() }
825
- }
826
- return () => { alive = false }
827
- }, [scope])
828
908
 
829
- // Snapshot status matters more than the value:
830
- // loading — host has not answered yet;
831
- // unavailable — host answered but the settings namespace is not ready;
832
- // ready — values are present.
833
- // On unavailable, writable defaults true; without a status check the card
834
- // looks like a working empty form.
835
- const ready = !scope || (!!snap && snap.status === 'ready')
836
- const writable = !scope ? true : (ready && snap.writable !== false)
909
+ const CLIENT_IT_TERMS = [
910
+ { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
911
+ { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
912
+ { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
913
+ { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
914
+ { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
915
+ { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
916
+ { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
917
+ { from: 'API', to: 'апи', whole: true, lang: 'ru' },
918
+ { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
919
+ { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
920
+ { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
921
+ { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
922
+ { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
923
+ { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
924
+ { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
925
+ { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
926
+ { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
927
+ { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
928
+ { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
929
+ { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
930
+ { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
931
+ { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
932
+ { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
933
+ { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
934
+ { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
935
+ ]
837
936
 
838
- const applyPayload = (data) => {
839
- const cfg = data && data.config ? data.config : {}
840
- setDraft(JSON.parse(JSON.stringify(cfg)))
841
- setCredentials(data && data.credentials ? data.credentials : {})
842
- player.enabled = !!cfg.speakReplies
937
+ // Pronunciation dictionary editor: rules apply top-down.
938
+ function PronEditor(props) {
939
+ const t = props.t || ((key) => key)
940
+ const rows = Array.isArray(props.value) ? props.value : []
941
+ const change = (i, patch) => {
942
+ props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
943
+ }
944
+ const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
945
+ const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
946
+ const loadIt = () => {
947
+ const existing = new Set(rows.map((r) => String(r.from).toLowerCase()))
948
+ const toAdd = CLIENT_IT_TERMS.filter((r) => !existing.has(String(r.from).toLowerCase()))
949
+ props.onChange(rows.concat(toAdd))
843
950
  }
844
951
 
845
- React.useEffect(() => {
846
- let alive = true
847
- fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
848
- if (!alive) return
849
- applyPayload(data)
850
- }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
851
-
852
- fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
853
- if (!alive) return
854
- if (data && data.ok) setIntegrations(data)
855
- }).catch(() => {})
856
-
857
- return () => { alive = false }
858
- }, [])
859
-
860
- if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
861
-
862
- const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
863
- const setDraftKey = (provider, value) => setKeyDrafts((d) => Object.assign({}, d, { [provider]: value }))
864
-
865
- const commitKey = async (provider, raw) => {
866
- const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
867
- if (!value) return
868
- setErr('')
869
- const res = await fetch('/dsh-tts/credential', {
870
- method: 'PUT',
871
- headers: { 'Content-Type': 'application/json' },
872
- body: JSON.stringify({ provider: provider, value: value }),
873
- })
874
- const data = await res.json().catch(() => ({}))
875
- if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
952
+ return React.createElement('div', { className: 'dts-wrap-in' },
953
+ rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
954
+ React.createElement('input', {
955
+ className: 'dts-input dts-grow', placeholder: t('pronFrom'),
956
+ value: rule.from || '', disabled: !props.writable,
957
+ onChange: (e) => change(i, { from: e.target.value }),
958
+ }),
959
+ React.createElement('input', {
960
+ className: 'dts-input dts-grow', placeholder: t('pronTo'),
961
+ value: rule.to || '', disabled: !props.writable,
962
+ onChange: (e) => change(i, { to: e.target.value }),
963
+ }),
964
+ React.createElement('label', { className: 'dts-sub', title: t('pronWhole') },
965
+ React.createElement('input', {
966
+ type: 'checkbox', checked: !!rule.whole, disabled: !props.writable,
967
+ onChange: (e) => change(i, { whole: e.target.checked }),
968
+ }),
969
+ ),
970
+ React.createElement('input', {
971
+ className: 'dts-input', style: { maxWidth: '70px' }, placeholder: t('pronLang'),
972
+ value: rule.lang || '', disabled: !props.writable,
973
+ onChange: (e) => change(i, { lang: e.target.value }),
974
+ }),
975
+ React.createElement('button', {
976
+ type: 'button', className: 'dts-mini', title: t('previewRule'), disabled: !props.writable,
977
+ onClick: () => {
978
+ if (props.onPreviewPhrase) props.onPreviewPhrase(rule.to || rule.from)
979
+ },
980
+ }, '\u25b6'),
981
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
982
+ )),
983
+ React.createElement('div', { className: 'dts-row' },
984
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
985
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
986
+ React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
987
+ ),
988
+ )
989
+ }
990
+
991
+ function RolesEditor(props) {
992
+ const t = props.t || ((key) => key)
993
+ const value = props.value || {}
994
+ const [newRole, setNewRole] = React.useState('')
995
+ const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r] || {}, patch) }))
996
+ const removeRole = (r) => {
997
+ const next = Object.assign({}, value)
998
+ delete next[r]
999
+ props.onChange(next)
1000
+ }
1001
+
1002
+ const standardKeys = ['reply', 'approval', 'error']
1003
+ const customKeys = Object.keys(value).filter((k) => !standardKeys.includes(k))
1004
+ const allKeys = [...standardKeys, ...customKeys]
1005
+
1006
+ const addCustom = () => {
1007
+ const name = newRole.trim().toLowerCase()
1008
+ if (!name || value[name]) return
1009
+ set(name, { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' })
1010
+ setNewRole('')
1011
+ }
1012
+
1013
+ return React.createElement('div', { className: 'dts-block' },
1014
+ React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
1015
+ allKeys.map((r) => React.createElement('div', { className: 'dts-row', key: r },
1016
+ React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r) !== ('role_' + r) ? t('role_' + r) : `👤 ${r}`),
1017
+ React.createElement('select', {
1018
+ className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
1019
+ onChange: (e) => set(r, { provider: e.target.value }),
1020
+ },
1021
+ React.createElement('option', { value: '' }, '—'),
1022
+ PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p)),
1023
+ ),
1024
+ React.createElement('input', {
1025
+ className: 'dts-input dts-grow', placeholder: 'model',
1026
+ value: (value[r] && value[r].model) || '', disabled: !props.writable,
1027
+ onChange: (e) => set(r, { model: e.target.value }),
1028
+ }),
1029
+ React.createElement('input', {
1030
+ className: 'dts-input dts-grow', placeholder: 'voice',
1031
+ value: (value[r] && value[r].voice) || '', disabled: !props.writable,
1032
+ onChange: (e) => set(r, { voice: e.target.value }),
1033
+ }),
1034
+ React.createElement('input', {
1035
+ className: 'dts-input', style: { maxWidth: '90px' }, placeholder: t('chime'),
1036
+ value: (value[r] && value[r].chime) || '', disabled: !props.writable,
1037
+ onChange: (e) => set(r, { chime: e.target.value }),
1038
+ }),
1039
+ React.createElement('input', {
1040
+ className: 'dts-input dts-grow', placeholder: 'ssml',
1041
+ value: (value[r] && value[r].ssmlStyle) || '', disabled: !props.writable,
1042
+ onChange: (e) => set(r, { ssmlStyle: e.target.value }),
1043
+ }),
1044
+ React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => props.onPreview((value[r] && value[r].provider) || '', (value[r] && value[r].model) || '', (value[r] && value[r].voice) || '') }, '\u25b6'),
1045
+ !standardKeys.includes(r) ? React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => removeRole(r) }, '\u00d7') : null,
1046
+ )),
1047
+ React.createElement('div', { className: 'dts-row' },
1048
+ React.createElement('input', {
1049
+ className: 'dts-input', style: { maxWidth: '180px' }, placeholder: t('newSubagentRole'),
1050
+ value: newRole, disabled: !props.writable,
1051
+ onChange: (e) => setNewRole(e.target.value),
1052
+ onKeyDown: (e) => { if (e.key === 'Enter') addCustom() },
1053
+ }),
1054
+ React.createElement('button', { type: 'button', className: 'dts-save', disabled: !props.writable || !newRole.trim(), onClick: addCustom }, t('addSubagentRole')),
1055
+ ),
1056
+ )
1057
+ }
1058
+
1059
+ function VoiceDuplexEditor(props) {
1060
+ const t = props.t || ((key) => key)
1061
+ const installed = !!props.installed
1062
+
1063
+ return React.createElement('div', { className: 'dts-block' },
1064
+ React.createElement('div', { className: 'dts-h' }, t('voiceDuplexTitle')),
1065
+ React.createElement('div', { className: 'dts-sub' }, t('voiceDuplexHint')),
1066
+ !installed ? React.createElement('div', {
1067
+ style: {
1068
+ padding: '10px 14px',
1069
+ borderRadius: '8px',
1070
+ border: '1px solid var(--dsw-alias-state-warning-primary)',
1071
+ background: 'var(--dsw-alias-bg-layer-2)',
1072
+ display: 'flex',
1073
+ flexDirection: 'column',
1074
+ gap: '4px',
1075
+ fontSize: '13px',
1076
+ },
1077
+ },
1078
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1079
+ '⚠️ ' + t('voiceNotInstalledTitle')
1080
+ ),
1081
+ React.createElement('span', { className: 'dts-sub' },
1082
+ t('voiceInstallHint') + ': '
1083
+ ),
1084
+ React.createElement('code', {
1085
+ style: {
1086
+ fontFamily: 'monospace',
1087
+ fontSize: '12px',
1088
+ padding: '2px 6px',
1089
+ borderRadius: '4px',
1090
+ background: 'var(--dsw-alias-bg-layer-3)',
1091
+ color: 'var(--dsw-alias-label-primary)',
1092
+ width: 'fit-content',
1093
+ },
1094
+ }, 'dsh plugin --profile web add @goodandready/dsh-voice')
1095
+ ) : null,
1096
+ props.boolField('voiceDuplexEnabled', t('voiceDuplex'), t('voiceDuplexHintToggle'), !installed),
1097
+ props.boolField('vadBargeIn', t('vadBargeIn'), t('vadBargeInHint'), !installed),
1098
+ )
1099
+ }
1100
+
1101
+ function MessengerIntegrationEditor(props) {
1102
+ const t = props.t || ((key) => key)
1103
+ const installed = !!props.installed
1104
+
1105
+ return React.createElement('div', { className: 'dts-block' },
1106
+ React.createElement('div', { className: 'dts-h' }, t('messengerIntegrationTitle')),
1107
+ React.createElement('div', { className: 'dts-sub' }, t('messengerIntegrationHint')),
1108
+ !installed ? React.createElement('div', {
1109
+ style: {
1110
+ padding: '10px 14px',
1111
+ borderRadius: '8px',
1112
+ border: '1px solid var(--dsw-alias-state-warning-primary)',
1113
+ background: 'var(--dsw-alias-bg-layer-2)',
1114
+ display: 'flex',
1115
+ flexDirection: 'column',
1116
+ gap: '4px',
1117
+ fontSize: '13px',
1118
+ },
1119
+ },
1120
+ React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1121
+ '⚠️ ' + t('messengerNotInstalledTitle')
1122
+ ),
1123
+ React.createElement('span', { className: 'dts-sub' },
1124
+ t('messengerInstallHint') + ': '
1125
+ ),
1126
+ React.createElement('code', {
1127
+ style: {
1128
+ fontFamily: 'monospace',
1129
+ fontSize: '12px',
1130
+ padding: '2px 6px',
1131
+ borderRadius: '4px',
1132
+ background: 'var(--dsw-alias-bg-layer-3)',
1133
+ color: 'var(--dsw-alias-label-primary)',
1134
+ width: 'fit-content',
1135
+ },
1136
+ }, 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway')
1137
+ ) : null,
1138
+ props.boolField('messengerTtsEnabled', t('messengerTtsEnabled'), t('messengerTtsEnabledHint'), !installed),
1139
+ )
1140
+ }
1141
+
1142
+ // Synthesis stats panel: live host counters.
1143
+ function StatsPanel(props) {
1144
+ const t = props.t || ((key) => key)
1145
+ const [data, setData] = React.useState(null)
1146
+ const loadStats = () => {
1147
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1148
+ }
1149
+ React.useEffect(() => { loadStats() }, [])
1150
+ const reset = () => {
1151
+ fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1152
+ }
1153
+ return React.createElement('div', { className: 'dts-block' },
1154
+ React.createElement('span', { className: 'dts-sub' }, data
1155
+ ? t('statTotal') + ': ' + data.total + ' · ' + t('statHits') + ': ' + data.cacheHits + ' · ' + t('statErrors') + ': ' + data.errors
1156
+ : t('loading')),
1157
+ data && Object.keys(data.providers || {}).map((p) => React.createElement('span', { className: 'dts-sub', key: p },
1158
+ p + ': ' + data.providers[p].n + ' / ' + data.providers[p].ms + 'ms')),
1159
+ React.createElement('button', { type: 'button', className: 'dts-link', onClick: reset }, t('statReset')),
1160
+ )
1161
+ }
1162
+
1163
+ // Plugin-settings tab card follows the shared card pattern: li in the core list,
1164
+ // theme-reset header, body with a divider. Chevron is ours; core does not provide it.
1165
+ function TtsSection(props) {
1166
+ // Translator comes from the slot because its registration sets locale.
1167
+ const t = (props && props.t) || ((key) => key)
1168
+ const [draft, setDraft] = React.useState(null)
1169
+ const [credentials, setCredentials] = React.useState({})
1170
+ const [keyDrafts, setKeyDrafts] = React.useState({})
1171
+ const [saved, setSaved] = React.useState(false)
1172
+ const [err, setErr] = React.useState('')
1173
+ const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
1174
+ const [telemetry, setTelemetry] = React.useState({ online: true, cacheHits: 0, sseOk: true, total: 0 })
1175
+ const ctx = props && props.ctx
1176
+ const scope = React.useMemo(() => {
1177
+ const s = (ctx && ctx.get && ctx.get('lanSettings')) || (ctx && ctx.settingsScope)
1178
+ if (!s || !s.bind) return null
1179
+ try {
1180
+ return s.bind({ namespace: NS })
1181
+ } catch (_) {
1182
+ return null
1183
+ }
1184
+ }, [ctx])
1185
+
1186
+ const subscribe = React.useMemo(() => {
1187
+ return (cb) => {
1188
+ if (!scope || !scope.subscribe) return () => {}
1189
+ try {
1190
+ return scope.subscribe(cb) || (() => {})
1191
+ } catch (_) {
1192
+ return () => {}
1193
+ }
1194
+ }
1195
+ }, [scope])
1196
+
1197
+ const getSnapshot = React.useCallback(() => {
1198
+ if (!scope || !scope.getSnapshot) return SNAPSHOT_LOADING
1199
+ try {
1200
+ return scope.getSnapshot() || SNAPSHOT_LOADING
1201
+ } catch (_) {
1202
+ return SNAPSHOT_LOADING
1203
+ }
1204
+ }, [scope])
1205
+
1206
+ const snap = (React.useSyncExternalStore
1207
+ ? React.useSyncExternalStore(subscribe, getSnapshot, React.useCallback(() => SNAPSHOT_LOADING, []))
1208
+ : null) || (scope && scope.getSnapshot ? scope.getSnapshot() : SNAPSHOT_LOADING)
1209
+
1210
+ // Snapshot status matters more than the value:
1211
+ // loading — host has not answered yet;
1212
+ // unavailable — host answered but the settings namespace is not ready;
1213
+ // ready — values are present.
1214
+ // On unavailable, writable defaults true; without a status check the card
1215
+ // looks like a working empty form.
1216
+ const ready = !scope || (!!snap && snap.status === 'ready')
1217
+ const writable = !scope ? true : (ready && snap.writable !== false)
1218
+
1219
+ const applyPayload = (data) => {
1220
+ const cfg = data && data.config ? data.config : {}
1221
+ setDraft(JSON.parse(JSON.stringify(cfg)))
1222
+ setCredentials(data && data.credentials ? data.credentials : {})
1223
+ player.enabled = !!cfg.speakReplies
1224
+ }
1225
+
1226
+ React.useEffect(() => {
1227
+ let alive = true
1228
+ fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1229
+ if (!alive) return
1230
+ applyPayload(data)
1231
+ }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
1232
+
1233
+ fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
1234
+ if (!alive) return
1235
+ if (data && data.ok) setIntegrations(data)
1236
+ }).catch(() => {})
1237
+
1238
+ fetch('/dsh-tts/stats', { cache: 'no-store' }).then((res) => res.json()).then((st) => {
1239
+ if (!alive || !st) return
1240
+ setTelemetry((prev) => Object.assign({}, prev, {
1241
+ cacheHits: st.cacheHits || 0,
1242
+ total: st.total || 0,
1243
+ online: true,
1244
+ }))
1245
+ }).catch(() => {
1246
+ if (alive) setTelemetry((prev) => Object.assign({}, prev, { online: false }))
1247
+ })
1248
+
1249
+ return () => { alive = false }
1250
+ }, [])
1251
+
1252
+ if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
1253
+
1254
+ const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
1255
+ const setDraftKey = (provider, value) => setKeyDrafts((d) => Object.assign({}, d, { [provider]: value }))
1256
+
1257
+ const commitKey = async (provider, raw) => {
1258
+ const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
1259
+ if (!value) return
1260
+ setErr('')
1261
+ const res = await fetch('/dsh-tts/credential', {
1262
+ method: 'PUT',
1263
+ headers: { 'Content-Type': 'application/json' },
1264
+ body: JSON.stringify({ provider: provider, value: value }),
1265
+ })
1266
+ const data = await res.json().catch(() => ({}))
1267
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
876
1268
  if (data && data.credentials) setCredentials(data.credentials)
877
1269
  setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
878
1270
  }
@@ -966,389 +1358,151 @@ window.__ModuleLoader__.load({
966
1358
  const textField = (key, label, hint) => React.createElement('label', { className: 'dts-field' }, label,
967
1359
  React.createElement('input', {
968
1360
  className: 'dts-input',
969
- value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
970
- onChange: (e) => setTop(key, e.target.value),
971
- }),
972
- React.createElement('span', { className: 'dts-sub' }, hint))
973
-
974
- // The card header draws the title — do not duplicate a second title block.
975
- const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-h' }, t('title'))
976
- return React.createElement('div', { className: 'dts-wrap' },
977
- React.createElement('div', { className: 'dts-block' },
978
- titleLine,
979
- React.createElement('label', { className: 'dts-row' },
980
- React.createElement('input', {
981
- type: 'checkbox', checked: !!(draft && draft.speakReplies), disabled: !writable,
982
- onChange: (e) => setTop('speakReplies', e.target.checked),
983
- }),
984
- React.createElement('span', null, t('speakReplies')),
985
- ),
986
- React.createElement('div', { className: 'dts-sub' },
987
- t('speakRepliesHint')),
988
- ),
989
- React.createElement(LocalEnginesEditor, {
990
- t: t, writable: writable, draft: draft,
991
- boolField: boolField, setTop: setTop,
992
- }),
993
- React.createElement(VoiceDuplexEditor, {
994
- t: t, writable: writable, draft: draft,
995
- boolField: boolField, setTop: setTop,
996
- installed: !!(integrations && integrations.voice && integrations.voice.installed),
997
- }),
998
- React.createElement(MessengerIntegrationEditor, {
999
- t: t, writable: writable, draft: draft,
1000
- boolField: boolField, setTop: setTop,
1001
- installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
1002
- }),
1003
- React.createElement('div', { className: 'dts-block' },
1004
- React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
1005
- boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
1006
- React.createElement(PronEditor, {
1007
- t: t, writable: writable,
1008
- value: draft && draft.pronunciation ? draft.pronunciation : [],
1009
- onChange: (v) => setTop('pronunciation', v),
1010
- onPreviewPhrase: onPreviewPhrase,
1011
- }),
1012
- ),
1013
- React.createElement('div', { className: 'dts-block' },
1014
- React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
1015
- React.createElement(RolesEditor, {
1016
- t: t, writable: writable, onPreview: onPreview,
1017
- value: draft && draft.roles ? draft.roles : {},
1018
- onChange: (v) => setTop('roles', v),
1019
- }),
1020
- ),
1021
- React.createElement('div', { className: 'dts-block' },
1022
- React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
1023
- React.createElement(StatsPanel, { t: t }),
1024
- ),
1025
- React.createElement('div', { className: 'dts-block' },
1026
- React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
1027
- React.createElement('div', { className: 'dts-sub' },
1028
- t('chainHintCard')),
1029
- React.createElement(ChainEditor, {
1030
- t: t,
1031
- value: draft && draft.chain ? draft.chain : [], writable: writable,
1032
- credentials: credentials, keyDrafts: keyDrafts,
1033
- onChange: (v) => setTop('chain', v),
1034
- onDraft: setDraftKey,
1035
- onCommitKey: onCommitKey,
1036
- onClearKey: clearKey,
1037
- onPreview: onPreview,
1038
- }),
1039
- ),
1040
- React.createElement('div', { className: 'dts-block' },
1041
- React.createElement('div', { className: 'dts-h' }, t('general')),
1042
- boolField('speakAsItGoes', t('speakAsItGoes'),
1043
- t('speakAsItGoesHint')),
1044
- numberField('rate', t('rate'), t('rateHint'), 0.1),
1045
- boolField('bargeIn', t('bargeIn'),
1046
- t('bargeInHint')),
1047
- boolField('announceApproval', t('announce'),
1048
- t('announceHint')),
1049
- boolField('skipCode', t('skipCode'),
1050
- t('skipCodeHint')),
1051
- boolField('cache', t('cache'),
1052
- t('cacheHint')),
1053
- numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
1054
- React.createElement('label', { className: 'dts-field' }, t('longReply'),
1055
- React.createElement('select', {
1056
- className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1057
- onChange: (e) => setTop('longReply', e.target.value),
1058
- }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1059
- React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1060
- textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1061
- numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
1062
- React.createElement('label', { className: 'dts-field' }, t('longReply'),
1063
- React.createElement('select', {
1064
- className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1065
- onChange: (e) => setTop('longReply', e.target.value),
1066
- }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1067
- React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1068
- textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1069
- numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
1070
- boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
1071
- boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
1072
- boolField('skipActions', t('skipActions'), t('skipActionsHint')),
1073
- textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
1074
- textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
1075
- textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
1076
- textField('approvalText', t('approvalText'), t('approvalHint')),
1077
- textField('questionText', t('questionText'), t('questionHint')),
1078
- textField('chime', t('chime'), t('chimeHint')),
1079
- textField('language', t('language'), t('languageHint')),
1080
- textField('piperModel', t('piperModel'), t('piperModelHint')),
1081
- textField('piperBin', t('piperBin'), t('binHint')),
1082
- textField('espeakBin', t('espeakBin'), t('espeakHint')),
1083
- textField('edgeBin', t('edgeBin'), t('edgeHint')),
1084
- textField('azureRegion', t('azureRegion'), t('azureHint')),
1085
- ),
1086
- React.createElement('div', { className: 'dts-foot' },
1087
- React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
1088
- React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
1089
- saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
1090
- err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
1091
- ),
1092
- )
1093
- }
1094
-
1095
-
1096
- const CLIENT_IT_TERMS = [
1097
- { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
1098
- { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
1099
- { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
1100
- { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
1101
- { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
1102
- { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
1103
- { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
1104
- { from: 'API', to: 'апи', whole: true, lang: 'ru' },
1105
- { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
1106
- { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
1107
- { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
1108
- { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
1109
- { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
1110
- { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
1111
- { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
1112
- { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
1113
- { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
1114
- { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
1115
- { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
1116
- { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
1117
- { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
1118
- { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
1119
- { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
1120
- { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
1121
- { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
1122
- ]
1123
-
1124
- // Pronunciation dictionary editor: rules apply top-down.
1125
- function PronEditor(props) {
1126
- const t = props.t || ((key) => key)
1127
- const rows = Array.isArray(props.value) ? props.value : []
1128
- const change = (i, patch) => {
1129
- props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
1130
- }
1131
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
1132
- const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
1133
- const loadIt = () => {
1134
- const existing = new Set(rows.map((r) => String(r.from).toLowerCase()))
1135
- const toAdd = CLIENT_IT_TERMS.filter((r) => !existing.has(String(r.from).toLowerCase()))
1136
- props.onChange(rows.concat(toAdd))
1137
- }
1138
-
1139
- return React.createElement('div', { className: 'dts-wrap-in' },
1140
- rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
1141
- React.createElement('input', {
1142
- className: 'dts-input dts-grow', placeholder: t('pronFrom'),
1143
- value: rule.from || '', disabled: !props.writable,
1144
- onChange: (e) => change(i, { from: e.target.value }),
1145
- }),
1146
- React.createElement('input', {
1147
- className: 'dts-input dts-grow', placeholder: t('pronTo'),
1148
- value: rule.to || '', disabled: !props.writable,
1149
- onChange: (e) => change(i, { to: e.target.value }),
1150
- }),
1151
- React.createElement('label', { className: 'dts-sub', title: t('pronWhole') },
1152
- React.createElement('input', {
1153
- type: 'checkbox', checked: !!rule.whole, disabled: !props.writable,
1154
- onChange: (e) => change(i, { whole: e.target.checked }),
1155
- }),
1156
- ),
1157
- React.createElement('input', {
1158
- className: 'dts-input', style: { maxWidth: '70px' }, placeholder: t('pronLang'),
1159
- value: rule.lang || '', disabled: !props.writable,
1160
- onChange: (e) => change(i, { lang: e.target.value }),
1161
- }),
1162
- React.createElement('button', {
1163
- type: 'button', className: 'dts-mini', title: t('previewRule'), disabled: !props.writable,
1164
- onClick: () => {
1165
- if (props.onPreviewPhrase) props.onPreviewPhrase(rule.to || rule.from)
1166
- },
1167
- }, '\u25b6'),
1168
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
1169
- )),
1170
- React.createElement('div', { className: 'dts-row' },
1171
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
1172
- React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
1173
- React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
1174
- ),
1175
- )
1176
- }
1177
-
1178
- function RolesEditor(props) {
1179
- const t = props.t || ((key) => key)
1180
- const value = props.value || {}
1181
- const [newRole, setNewRole] = React.useState('')
1182
- const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r] || {}, patch) }))
1183
- const removeRole = (r) => {
1184
- const next = Object.assign({}, value)
1185
- delete next[r]
1186
- props.onChange(next)
1187
- }
1188
-
1189
- const standardKeys = ['reply', 'approval', 'error']
1190
- const customKeys = Object.keys(value).filter((k) => !standardKeys.includes(k))
1191
- const allKeys = [...standardKeys, ...customKeys]
1192
-
1193
- const addCustom = () => {
1194
- const name = newRole.trim().toLowerCase()
1195
- if (!name || value[name]) return
1196
- set(name, { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' })
1197
- setNewRole('')
1198
- }
1361
+ value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1362
+ onChange: (e) => setTop(key, e.target.value),
1363
+ }),
1364
+ React.createElement('span', { className: 'dts-sub' }, hint))
1199
1365
 
1200
- return React.createElement('div', { className: 'dts-block' },
1201
- React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
1202
- allKeys.map((r) => React.createElement('div', { className: 'dts-row', key: r },
1203
- React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r) !== ('role_' + r) ? t('role_' + r) : `👤 ${r}`),
1204
- React.createElement('select', {
1205
- className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
1206
- onChange: (e) => set(r, { provider: e.target.value }),
1207
- },
1208
- React.createElement('option', { value: '' }, '—'),
1209
- PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p)),
1366
+ const telemetryBadges = React.createElement('div', { className: 'dts-row', style: { marginBottom: '8px' } },
1367
+ React.createElement('span', {
1368
+ className: 'dts-badge ' + (telemetry.online ? 'dts-badge-on' : 'dts-badge-bad'),
1369
+ }, telemetry.online ? '🟢 ' + (t('statusOnline') || 'Host online') : '🔴 ' + (t('statusOffline') || 'Host offline')),
1370
+ React.createElement('span', {
1371
+ className: 'dts-badge dts-badge-on',
1372
+ }, '⚡ SSE Active'),
1373
+ React.createElement('span', {
1374
+ className: 'dts-badge',
1375
+ }, '💾 ' + (t('cacheStats') || 'Cache') + ': ' + telemetry.cacheHits + ' / ' + telemetry.total),
1376
+ )
1377
+ const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-header' },
1378
+ React.createElement('div', { className: 'dts-page-title' }, '🔊 ' + t('title')),
1379
+ React.createElement('div', { className: 'dts-page-sub' }, t('subtitle')),
1380
+ telemetryBadges,
1381
+ )
1382
+ return React.createElement('div', { className: 'dts-wrap' },
1383
+ React.createElement('div', { className: 'dts-block' },
1384
+ titleLine,
1385
+ React.createElement('label', { className: 'dts-row' },
1386
+ React.createElement('input', {
1387
+ type: 'checkbox', checked: !!(draft && draft.speakReplies), disabled: !writable,
1388
+ onChange: (e) => setTop('speakReplies', e.target.checked),
1389
+ }),
1390
+ React.createElement('span', null, t('speakReplies')),
1210
1391
  ),
1211
- React.createElement('input', {
1212
- className: 'dts-input dts-grow', placeholder: 'model',
1213
- value: (value[r] && value[r].model) || '', disabled: !props.writable,
1214
- onChange: (e) => set(r, { model: e.target.value }),
1215
- }),
1216
- React.createElement('input', {
1217
- className: 'dts-input dts-grow', placeholder: 'voice',
1218
- value: (value[r] && value[r].voice) || '', disabled: !props.writable,
1219
- onChange: (e) => set(r, { voice: e.target.value }),
1220
- }),
1221
- React.createElement('input', {
1222
- className: 'dts-input', style: { maxWidth: '90px' }, placeholder: t('chime'),
1223
- value: (value[r] && value[r].chime) || '', disabled: !props.writable,
1224
- onChange: (e) => set(r, { chime: e.target.value }),
1392
+ React.createElement('div', { className: 'dts-sub' },
1393
+ t('speakRepliesHint')),
1394
+ ),
1395
+ React.createElement(LocalEnginesEditor, {
1396
+ t: t, writable: writable, draft: draft,
1397
+ boolField: boolField, setTop: setTop,
1398
+ }),
1399
+ React.createElement(VoiceDuplexEditor, {
1400
+ t: t, writable: writable, draft: draft,
1401
+ boolField: boolField, setTop: setTop,
1402
+ installed: !!(integrations && integrations.voice && integrations.voice.installed),
1403
+ }),
1404
+ React.createElement(MessengerIntegrationEditor, {
1405
+ t: t, writable: writable, draft: draft,
1406
+ boolField: boolField, setTop: setTop,
1407
+ installed: !!(integrations && integrations.messenger && integrations.messenger.installed),
1408
+ }),
1409
+ React.createElement('div', { className: 'dts-block' },
1410
+ React.createElement('div', { className: 'dts-h' }, t('pronTitle')),
1411
+ boolField('enableItDictionary', t('enableItDictionary'), t('enableItDictionaryHint')),
1412
+ React.createElement(PronEditor, {
1413
+ t: t, writable: writable,
1414
+ value: draft && draft.pronunciation ? draft.pronunciation : [],
1415
+ onChange: (v) => setTop('pronunciation', v),
1416
+ onPreviewPhrase: onPreviewPhrase,
1225
1417
  }),
1226
- React.createElement('input', {
1227
- className: 'dts-input dts-grow', placeholder: 'ssml',
1228
- value: (value[r] && value[r].ssmlStyle) || '', disabled: !props.writable,
1229
- onChange: (e) => set(r, { ssmlStyle: e.target.value }),
1418
+ ),
1419
+ React.createElement('div', { className: 'dts-block' },
1420
+ React.createElement('div', { className: 'dts-h' }, t('rolesTitle')),
1421
+ React.createElement(RolesEditor, {
1422
+ t: t, writable: writable, onPreview: onPreview,
1423
+ value: draft && draft.roles ? draft.roles : {},
1424
+ onChange: (v) => setTop('roles', v),
1230
1425
  }),
1231
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => props.onPreview((value[r] && value[r].provider) || '', (value[r] && value[r].model) || '', (value[r] && value[r].voice) || '') }, '\u25b6'),
1232
- !standardKeys.includes(r) ? React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => removeRole(r) }, '\u00d7') : null,
1233
- )),
1234
- React.createElement('div', { className: 'dts-row' },
1235
- React.createElement('input', {
1236
- className: 'dts-input', style: { maxWidth: '180px' }, placeholder: t('newSubagentRole'),
1237
- value: newRole, disabled: !props.writable,
1238
- onChange: (e) => setNewRole(e.target.value),
1239
- onKeyDown: (e) => { if (e.key === 'Enter') addCustom() },
1426
+ ),
1427
+ React.createElement('div', { className: 'dts-block' },
1428
+ React.createElement('div', { className: 'dts-h' }, t('statsTitle')),
1429
+ React.createElement(StatsPanel, { t: t }),
1430
+ ),
1431
+ React.createElement('div', { className: 'dts-block' },
1432
+ React.createElement('div', { className: 'dts-h' }, t('chainTitle')),
1433
+ React.createElement('div', { className: 'dts-sub' },
1434
+ t('chainHintCard')),
1435
+ React.createElement(ChainEditor, {
1436
+ t: t,
1437
+ value: draft && draft.chain ? draft.chain : [], writable: writable,
1438
+ credentials: credentials, keyDrafts: keyDrafts,
1439
+ onChange: (v) => setTop('chain', v),
1440
+ onDraft: setDraftKey,
1441
+ onCommitKey: onCommitKey,
1442
+ onClearKey: clearKey,
1443
+ onPreview: onPreview,
1240
1444
  }),
1241
- React.createElement('button', { type: 'button', className: 'dts-save', disabled: !props.writable || !newRole.trim(), onClick: addCustom }, t('addSubagentRole')),
1445
+ ),
1446
+ React.createElement('div', { className: 'dts-block' },
1447
+ React.createElement('div', { className: 'dts-h' }, t('general')),
1448
+ boolField('speakAsItGoes', t('speakAsItGoes'),
1449
+ t('speakAsItGoesHint')),
1450
+ numberField('rate', t('rate'), t('rateHint'), 0.1),
1451
+ boolField('bargeIn', t('bargeIn'),
1452
+ t('bargeInHint')),
1453
+ boolField('announceApproval', t('announce'),
1454
+ t('announceHint')),
1455
+ boolField('skipCode', t('skipCode'),
1456
+ t('skipCodeHint')),
1457
+ boolField('cache', t('cache'),
1458
+ t('cacheHint')),
1459
+ numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
1460
+ React.createElement('label', { className: 'dts-field' }, t('longReply'),
1461
+ React.createElement('select', {
1462
+ className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1463
+ onChange: (e) => setTop('longReply', e.target.value),
1464
+ }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1465
+ React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1466
+ textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1467
+ numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
1468
+ boolField('autoDetect', t('autoDetect'), t('autoDetectHint')),
1469
+ boolField('narrateQuotesOnly', t('narrateQuotesOnly'), t('narrateQuotesHint')),
1470
+ boolField('skipActions', t('skipActions'), t('skipActionsHint')),
1471
+ textField('removeRegex', t('removeRegex'), t('removeRegexHint')),
1472
+ textField('customBaseUrl', t('customBaseUrl'), t('customBaseUrlHint')),
1473
+ textField('customKeyEnv', t('customKeyEnv'), t('customKeyEnvHint')),
1474
+ textField('approvalText', t('approvalText'), t('approvalHint')),
1475
+ textField('questionText', t('questionText'), t('questionHint')),
1476
+ textField('chime', t('chime'), t('chimeHint')),
1477
+ textField('language', t('language'), t('languageHint')),
1478
+ textField('piperModel', t('piperModel'), t('piperModelHint')),
1479
+ textField('piperBin', t('piperBin'), t('binHint')),
1480
+ textField('espeakBin', t('espeakBin'), t('espeakHint')),
1481
+ textField('edgeBin', t('edgeBin'), t('edgeHint')),
1482
+ textField('azureRegion', t('azureRegion'), t('azureHint')),
1483
+ ),
1484
+ React.createElement('div', { className: 'dts-block' },
1485
+ React.createElement('div', { className: 'dts-h' }, t('advancedTitle')),
1486
+ React.createElement('div', { className: 'dts-sub' }, t('advancedHint')),
1487
+ numberField('maxChars', t('maxChars'), t('maxCharsHint'), 100),
1488
+ numberField('sentenceChars', t('sentenceChars'), t('sentenceCharsHint'), 20),
1489
+ numberField('timeoutMs', t('timeoutMs'), t('timeoutMsHint'), 1000),
1490
+ numberField('maxQueue', t('maxQueue'), t('maxQueueHint'), 1),
1491
+ textField('openaiBaseUrl', t('openaiBaseUrl'), t('openaiBaseUrlHint')),
1492
+ textField('mimoBaseUrl', t('mimoBaseUrl'), t('mimoBaseUrlHint')),
1493
+ textField('mimoFormat', t('mimoFormat'), t('mimoFormatHint')),
1494
+ textField('minimaxBin', t('minimaxBin'), t('minimaxBinHint')),
1495
+ ),
1496
+ React.createElement('div', { className: 'dts-foot' },
1497
+ React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
1498
+ React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
1499
+ saved ? React.createElement('span', { className: 'dts-ok' }, t('saved')) : null,
1500
+ err ? React.createElement('span', { className: 'dts-bad' }, err) : null,
1242
1501
  ),
1243
1502
  )
1244
1503
  }
1245
1504
 
1246
- function VoiceDuplexEditor(props) {
1247
- const t = props.t || ((key) => key)
1248
- const installed = !!props.installed
1249
-
1250
- return React.createElement('div', { className: 'dts-block' },
1251
- React.createElement('div', { className: 'dts-h' }, t('voiceDuplexTitle')),
1252
- React.createElement('div', { className: 'dts-sub' }, t('voiceDuplexHint')),
1253
- !installed ? React.createElement('div', {
1254
- style: {
1255
- padding: '10px 14px',
1256
- borderRadius: '8px',
1257
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1258
- background: 'var(--dsw-alias-bg-layer-2)',
1259
- display: 'flex',
1260
- flexDirection: 'column',
1261
- gap: '4px',
1262
- fontSize: '13px',
1263
- },
1264
- },
1265
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1266
- '⚠️ ' + t('voiceNotInstalledTitle')
1267
- ),
1268
- React.createElement('span', { className: 'dts-sub' },
1269
- t('voiceInstallHint') + ': '
1270
- ),
1271
- React.createElement('code', {
1272
- style: {
1273
- fontFamily: 'monospace',
1274
- fontSize: '12px',
1275
- padding: '2px 6px',
1276
- borderRadius: '4px',
1277
- background: 'var(--dsw-alias-bg-layer-3)',
1278
- color: 'var(--dsw-alias-label-primary)',
1279
- width: 'fit-content',
1280
- },
1281
- }, 'dsh plugin --profile web add @goodandready/dsh-voice')
1282
- ) : null,
1283
- props.boolField('voiceDuplexEnabled', t('voiceDuplex'), t('voiceDuplexHintToggle'), !installed),
1284
- props.boolField('vadBargeIn', t('vadBargeIn'), t('vadBargeInHint'), !installed),
1285
- )
1286
- }
1287
-
1288
- function MessengerIntegrationEditor(props) {
1289
- const t = props.t || ((key) => key)
1290
- const installed = !!props.installed
1291
-
1292
- return React.createElement('div', { className: 'dts-block' },
1293
- React.createElement('div', { className: 'dts-h' }, t('messengerIntegrationTitle')),
1294
- React.createElement('div', { className: 'dts-sub' }, t('messengerIntegrationHint')),
1295
- !installed ? React.createElement('div', {
1296
- style: {
1297
- padding: '10px 14px',
1298
- borderRadius: '8px',
1299
- border: '1px solid var(--dsw-alias-state-warning-primary)',
1300
- background: 'var(--dsw-alias-bg-layer-2)',
1301
- display: 'flex',
1302
- flexDirection: 'column',
1303
- gap: '4px',
1304
- fontSize: '13px',
1305
- },
1306
- },
1307
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary)' } },
1308
- '⚠️ ' + t('messengerNotInstalledTitle')
1309
- ),
1310
- React.createElement('span', { className: 'dts-sub' },
1311
- t('messengerInstallHint') + ': '
1312
- ),
1313
- React.createElement('code', {
1314
- style: {
1315
- fontFamily: 'monospace',
1316
- fontSize: '12px',
1317
- padding: '2px 6px',
1318
- borderRadius: '4px',
1319
- background: 'var(--dsw-alias-bg-layer-3)',
1320
- color: 'var(--dsw-alias-label-primary)',
1321
- width: 'fit-content',
1322
- },
1323
- }, 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway')
1324
- ) : null,
1325
- props.boolField('messengerTtsEnabled', t('messengerTtsEnabled'), t('messengerTtsEnabledHint'), !installed),
1326
- )
1327
- }
1328
-
1329
- // Synthesis stats panel: live host counters.
1330
- function StatsPanel(props) {
1331
- const t = props.t || ((key) => key)
1332
- const [data, setData] = React.useState(null)
1333
- const loadStats = () => {
1334
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1335
- }
1336
- React.useEffect(() => { loadStats() }, [])
1337
- const reset = () => {
1338
- fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1339
- }
1340
- return React.createElement('div', { className: 'dts-block' },
1341
- React.createElement('span', { className: 'dts-sub' }, data
1342
- ? t('statTotal') + ': ' + data.total + ' · ' + t('statHits') + ': ' + data.cacheHits + ' · ' + t('statErrors') + ': ' + data.errors
1343
- : t('loading')),
1344
- data && Object.keys(data.providers || {}).map((p) => React.createElement('span', { className: 'dts-sub', key: p },
1345
- p + ': ' + data.providers[p].n + ' / ' + data.providers[p].ms + 'ms')),
1346
- React.createElement('button', { type: 'button', className: 'dts-link', onClick: reset }, t('statReset')),
1347
- )
1348
- }
1349
1505
 
1350
- // Plugin-settings tab card follows the shared card pattern: li in the core list,
1351
- // theme-reset header, body with a divider. Chevron is ours; core does not provide it.
1352
1506
  function TtsCard(props) {
1353
1507
  const t = (props && props.t) || ((key) => key)
1354
1508
  const [open, setOpen] = React.useState(false)
@@ -1376,7 +1530,9 @@ window.__ModuleLoader__.load({
1376
1530
  ),
1377
1531
  ),
1378
1532
  open ? React.createElement('div', { className: 'dts-body' },
1379
- React.createElement(TtsSection, Object.assign({}, props, { compact: true, ctx: props && props.ctx })),
1533
+ React.createElement(ErrorBoundary, null,
1534
+ React.createElement(TtsSection, Object.assign({}, props, { compact: true, ctx: props && props.ctx })),
1535
+ ),
1380
1536
  ) : null,
1381
1537
  )
1382
1538
  }
@@ -1509,7 +1665,7 @@ window.__ModuleLoader__.load({
1509
1665
  label: () => t('title'),
1510
1666
  inject: () => ({ ctx: ctx }),
1511
1667
  },
1512
- TtsSection,
1668
+ (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
1513
1669
  ))
1514
1670
  }
1515
1671
 
@@ -1575,3 +1731,4 @@ window.__ModuleLoader__.load({
1575
1731
 
1576
1732
  // ModelManager UI placeholder - manual install buttons for Kokoro/F5
1577
1733
  // Actual implementation would show download progress and status
1734
+