@goodandready/dsh-tts 0.3.23 → 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
@@ -5,9 +5,8 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
7
 
8
- // Значок раскрытия — ядровый; свой SVG только как фолбэк для сборок
9
- // без набора примитивов. Иконка всегда смотрит вниз, раскрытие
10
- // поворачивает её классом.
8
+ // Chevron comes from core UI primitives; local SVG is a fallback for
9
+ // builds without the package. Icon always points down; open state rotates.
11
10
  let ChevronIcon = null
12
11
  try {
13
12
  const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
@@ -23,64 +22,126 @@ window.__ModuleLoader__.load({
23
22
  const CLOUD = {
24
23
  openai: 1, elevenlabs: 1, google: 1, azure: 1, groq: 1, deepgram: 1, openrouter: 1, custom: 1,
25
24
  }
25
+ // Hints stay generic: concrete provider model IDs rot quickly.
26
26
  const MODEL_HINT = {
27
- openai: 'gpt-4o-mini-tts',
28
- elevenlabs: 'eleven_multilingual_v2',
29
- google: 'gemini-2.5-flash-preview-tts',
30
- azure: 'region voice, e.g. en-US-JennyNeural',
31
- groq: 'playai-tts',
32
- deepgram: 'aura-asteria-en (no Russian)',
33
- openrouter: 'openai/gpt-4o-mini-tts-2025-12-15',
34
- edge: 'ru-RU-SvetlanaNeural',
27
+ openai: 'provider default',
28
+ elevenlabs: 'provider default',
29
+ google: 'provider default',
30
+ azure: 'region voice name',
31
+ groq: 'provider default',
32
+ deepgram: 'provider default (English-only voices)',
33
+ openrouter: 'provider/model id',
34
+ edge: 'BCP-47 voice, e.g. ru-RU-SvetlanaNeural',
35
35
  piper: 'path to .onnx',
36
- espeak: 'ru',
36
+ espeak: 'language code, e.g. ru',
37
+ kokoro: 'not bundled — offline neural runtime unavailable',
38
+ f5: 'not bundled — offline neural runtime unavailable',
37
39
  }
38
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
+
39
87
  const SET_CSS =
40
- '.dts-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
41
- '.dts-block{display:flex;flex-direction:column;gap:10px}' +
42
- '.dts-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
43
- // Единый вид карточки «Настройки плагинов»: значения из канона
44
- // (issue #19), цвета только переменными темы.
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}' +
45
95
  '.dts-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
46
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}' +
47
97
  '.dts-headText{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}' +
48
98
  '.dts-title{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}' +
49
- '.dts-sub{color:var(--dsw-alias-label-secondary);font-size:13px}' +
50
99
  '.dts-chev{margin-left:auto;color:var(--dsw-alias-label-secondary);flex:none;transition:transform .16s}' +
51
100
  '.dts-chevOpen{transform:rotate(180deg)}' +
52
- '.dts-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}' +
53
- '.dts-field{display:flex;flex-direction:column;gap:6px;padding:12px 0}' +
54
- '.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}' +
55
- '.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}' +
56
- '.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)}' +
57
- // Помощники внутренней формы (цепочка провайдеров).
58
- '.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)}' +
59
113
  '.dts-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
60
114
  '.dts-row .dts-model{flex:1;min-width:120px}' +
61
115
  '.dts-row .dts-key{flex:1;min-width:180px}' +
62
- '.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}' +
63
- '.dts-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
64
- '.dts-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
65
- '.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}' +
66
- '.dts-badge-on{color:var(--dsw-alias-state-success-primary);border-color:currentColor}' +
67
- '.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}'
68
131
 
69
132
  const setCssId = 'dsh-tts/settings.module.css'
70
- if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + setCssId + '"]')) {
133
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-tts"]')) {
71
134
  const tag = document.createElement('style')
72
135
  tag.textContent = SET_CSS
73
- tag.setAttribute('data-plugin', 'dsh-tts')
136
+ tag.setAttribute('data-dsh-plugin', 'dsh-tts')
74
137
  tag.dataset.pluginCss = setCssId
75
138
  document.head.appendChild(tag)
76
139
  }
77
-
78
- // Проигрыватель.
140
+ // Player.
79
141
  //
80
- // Раньше каждый пришедший кусок немедленно вытеснял предыдущий: при чтении
81
- // ответа целиком это было незаметно, а при чтении по ходу слышался бы
82
- // только последний. Теперь это очередь: следующее начинается, когда
83
- // закончилось предыдущее.
142
+ // Each incoming chunk used to replace the previous one immediately; while reading
143
+ // For full replies this was invisible; with speak-as-it-goes only the last
144
+ // chunk would be heard. Now items queue: the next starts after the previous ends.
84
145
  const player = {
85
146
  audio: null,
86
147
  after: '',
@@ -111,8 +172,7 @@ window.__ModuleLoader__.load({
111
172
  return player
112
173
  }
113
174
 
114
- // Короткий сигнал рисуем сами: файл не нужен, значит нечего качать,
115
- // хранить и настраивать.
175
+ // Short chime is synthesized in-browser: no file to download, store, or configure.
116
176
  function playChime(kind) {
117
177
  return new Promise((resolve) => {
118
178
  try {
@@ -139,8 +199,8 @@ window.__ModuleLoader__.load({
139
199
  })
140
200
  }
141
201
 
142
- // Провайдер не смог — читаем голосом браузера. Хуже по звучанию, но
143
- // молчание хуже вдвойне: человек не узнает, что ответ готов.
202
+ // Provider failed — fall back to the browser voice. Worse quality, but silence
203
+ // is worse: the user would not know the reply is ready.
144
204
  function speakInBrowser(text) {
145
205
  return new Promise((resolve) => {
146
206
  try {
@@ -203,8 +263,7 @@ window.__ModuleLoader__.load({
203
263
  audio.onended = done
204
264
  audio.onerror = done
205
265
  audio.play().catch(() => {
206
- // Браузер не даёт играть без действия человека — не считаем это
207
- // поломкой, просто ничего не звучит.
266
+ // Autoplay without a user gesture is blocked — not a bug; just no sound.
208
267
  done()
209
268
  })
210
269
  } catch (cannotDecode) {
@@ -272,11 +331,10 @@ window.__ModuleLoader__.load({
272
331
  }
273
332
  }
274
333
 
275
- // Человек заговорил — замолкаем.
334
+ // User started talking — stop playback.
276
335
  //
277
- // Событие приходит из плагина голосового ввода, но никакой связи с ним
278
- // нет: если его не установили, событие просто никогда не придёт. Слушаем
279
- // окно, а не чужую службу, ровно поэтому.
336
+ // The event may come from a voice-input plugin, but there is no hard dependency:
337
+ // without that plugin the event simply never fires. We listen on window on purpose.
280
338
  function listenForVoice() {
281
339
  if (typeof window === 'undefined') return () => {}
282
340
  const onSpeaking = (event) => {
@@ -306,8 +364,8 @@ window.__ModuleLoader__.load({
306
364
  const data = await res.json()
307
365
  const items = data && Array.isArray(data.items) ? data.items : []
308
366
  for (const item of items) {
309
- // Место в очереди занято, но синтез ещё идёт — подождём следующего
310
- // опроса, иначе порядок фраз рассыплется.
367
+ // Queue slot reserved but synthesis still running — wait for the next poll
368
+ // or phrase order would break.
311
369
  if (item.kind === 'reserved') break
312
370
  player.after = item.id
313
371
  if (seenIds.has(item.id)) continue
@@ -323,7 +381,7 @@ window.__ModuleLoader__.load({
323
381
  } catch (hostRestarting) { /* хост перезапускается */ }
324
382
  }
325
383
 
326
- // Недавние озвучки: тексты последних реплик и избранное (localStorage).
384
+ // Recent utterances: last reply texts and favorites (localStorage).
327
385
  function recentStore() {
328
386
  const KEY = 'dsh-tts/recent'
329
387
  let st
@@ -349,7 +407,7 @@ window.__ModuleLoader__.load({
349
407
  }
350
408
  const recent = recentStore()
351
409
 
352
- // Разовое проигрывание вне очереди: реплай из списка недавних.
410
+ // One-shot playback outside the queue: replay from recents.
353
411
  function playStandalone(text) {
354
412
  fetch('/dsh-tts/speak', {
355
413
  method: 'POST',
@@ -369,78 +427,11 @@ window.__ModuleLoader__.load({
369
427
  return (map && map[provider]) || { configured: false, writable: true, ref: '' }
370
428
  }
371
429
 
372
- function ChainEditor(props) {
373
- // Переводчик приходит от карточки: слот отдаёт props.t только ей.
374
- const t = props.t || ((key) => key)
375
- const rows = Array.isArray(props.value) ? props.value : []
376
- const change = (i, patch) => {
377
- const next = rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r))
378
- props.onChange(next)
379
- }
380
- const move = (i, delta) => {
381
- const j = i + delta
382
- if (j < 0 || j >= rows.length) return
383
- const next = rows.slice()
384
- const tmp = next[i]; next[i] = next[j]; next[j] = tmp
385
- props.onChange(next)
386
- }
387
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
388
- const add = () => props.onChange(rows.concat([{ provider: 'espeak', model: '', voice: '' }]))
389
- return React.createElement('div', { className: 'dts-block' },
390
- rows.map((row, i) => {
391
- const cloud = !!CLOUD[row.provider]
392
- const cred = credOf(props.credentials, row.provider)
393
- const draft = (props.keyDrafts && props.keyDrafts[row.provider]) || ''
394
- const badge = !cloud ? null
395
- : (!cred.writable ? t('fromEnv') : (cred.configured ? t('configured') : t('notSet')))
396
- return React.createElement('div', { className: 'dts-entry', key: i },
397
- React.createElement('div', { className: 'dts-row' },
398
- React.createElement('select', {
399
- className: 'dts-input',
400
- value: row.provider, disabled: !props.writable,
401
- onChange: (e) => change(i, { provider: e.target.value }),
402
- }, PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p))),
403
- React.createElement('input', {
404
- className: 'dts-model dts-input', value: row.model || '', disabled: !props.writable,
405
- placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
406
- }),
407
- React.createElement('input', {
408
- className: 'dts-model dts-input', value: row.voice || '', disabled: !props.writable,
409
- placeholder: 'voice', onChange: (e) => change(i, { voice: e.target.value }),
410
- }),
411
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '\u2191'),
412
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '\u2193'),
413
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('preview'), disabled: !props.writable, onClick: () => props.onPreview(row.provider, row.model, row.voice) }, '\u25b6'),
414
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
415
- ),
416
- cloud ? React.createElement('div', { className: 'dts-row' },
417
- React.createElement('input', {
418
- className: 'dts-key dts-input', type: 'password', autoComplete: 'off',
419
- value: draft, disabled: !props.writable || !cred.writable,
420
- placeholder: cred.configured ? 'leave blank to keep' : 'paste API key',
421
- onChange: (e) => props.onDraft(row.provider, e.target.value),
422
- onBlur: (e) => props.onCommitKey(row.provider, e.target.value),
423
- }),
424
- React.createElement('span', { className: 'dts-badge' + (cred.configured ? ' dts-badge-on' : '') }, badge),
425
- cred.configured && cred.writable ? React.createElement('button', {
426
- type: 'button', className: 'dts-link', disabled: !props.writable,
427
- onClick: () => props.onClearKey(row.provider),
428
- }, t('clear')) : null,
429
- ) : React.createElement('span', { className: 'dts-sub' }, t('localProvider')),
430
- cloud && cred.ref ? React.createElement('span', { className: 'dts-sub' }, 'Stored as ' + cred.ref) : null,
431
- )
432
- }),
433
- React.createElement('div', { className: 'dts-row' },
434
- React.createElement('button', { type: 'button', className: 'dts-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
435
- React.createElement('span', { className: 'dts-sub' }, t('chainHint')),
436
- ),
437
- )
438
- }
439
-
440
- // Строки карточки живут в реестре локалей: так их переводит отдельный
441
- // пакет, не трогая код этого плагина. Английский — язык по умолчанию,
442
- // на него же приходится откат, если перевода нет.
443
430
  const en = {
431
+ 'statusOnline': 'Host online',
432
+ 'statusOffline': 'Host unreachable',
433
+ 'cacheStats': 'Cache',
434
+
444
435
  'general': 'General',
445
436
  'loading': 'Loading\u2026',
446
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.',
@@ -474,6 +465,24 @@ window.__ModuleLoader__.load({
474
465
  'edgeHint': 'Python CLI from the edge-tts package.',
475
466
  'azureRegion': 'Azure region',
476
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.',
477
486
  'chainTitle': 'Provider chain',
478
487
  'addProvider': 'Add provider',
479
488
  'localProvider': 'Local / CLI provider — no API key',
@@ -581,6 +590,10 @@ window.__ModuleLoader__.load({
581
590
  'dockStop': 'Stop speech',
582
591
  }
583
592
  const ru = {
593
+ 'statusOnline': 'Хост на связи',
594
+ 'statusOffline': 'Хост недоступен',
595
+ 'cacheStats': 'Кэш',
596
+
584
597
  'dockLabel': 'Чтение',
585
598
  'dockPause': 'Пауза',
586
599
  'dockResume': 'Продолжить чтение',
@@ -619,6 +632,24 @@ window.__ModuleLoader__.load({
619
632
  'edgeHint': 'Консольная утилита из пакета edge-tts.',
620
633
  'azureRegion': 'Azure: регион',
621
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': 'Отбрасывать самые старые куски сверх этого числа.',
622
653
  'chainTitle': 'Цепочка провайдеров',
623
654
  'addProvider': 'Добавить провайдера',
624
655
  'localProvider': 'Локальный провайдер — ключ не нужен',
@@ -673,7 +704,7 @@ window.__ModuleLoader__.load({
673
704
  'removeRegex': 'Регулярка вырезания',
674
705
  'removeRegexHint': 'Глобальная регулярка: совпадения удаляются перед синтезом.',
675
706
  'customBaseUrl': 'Свой TTS endpoint',
676
- 'customBaseUrlHint': 'OpenAI-совместимый адрес, например http://localhost:8880/v1; пусто = выключен.',
707
+ 'customBaseUrlHint': 'OpenAI-compatible base URL, e.g. http://localhost:8880/v1; empty disables it.',
677
708
  'customKeyEnv': 'Имя ключа для endpoint',
678
709
  'customKeyEnvHint': 'Имя credential для Bearer-ключа своего endpointа.',
679
710
 
@@ -721,6 +752,77 @@ window.__ModuleLoader__.load({
721
752
  'messengerTtsEnabledHint': 'Разрешить мосту мессенджеров озвучивать ответы голосовыми заметками.',
722
753
  }
723
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
+
724
826
  function LocalEnginesEditor(props) {
725
827
  const t = props.t || ((key) => key)
726
828
  const [models, setModels] = React.useState({})
@@ -803,96 +905,382 @@ window.__ModuleLoader__.load({
803
905
  )
804
906
  }
805
907
 
806
- function TtsSection(props) {
807
- // Переводчик приходит от слота, потому что в его записи указан locale.
808
- const t = (props && props.t) || ((key) => key)
809
- const [draft, setDraft] = React.useState(null)
810
- const [credentials, setCredentials] = React.useState({})
811
- const [keyDrafts, setKeyDrafts] = React.useState({})
812
- const [saved, setSaved] = React.useState(false)
813
- const [err, setErr] = React.useState('')
814
- const [integrations, setIntegrations] = React.useState({ voice: { installed: false }, messenger: { installed: false } })
815
- const ctx = props && props.ctx
816
- const scope = (ctx && ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope))
817
- ? ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
818
- : null
819
- const [snap, setSnap] = React.useState(null)
820
-
821
- React.useEffect(() => {
822
- if (!scope) return undefined
823
- let alive = true
824
- const render = () => { if (alive && scope.getSnapshot) setSnap(scope.getSnapshot()) }
825
- render()
826
- if (scope.subscribe) {
827
- const off = scope.subscribe(render)
828
- return () => { alive = false; if (typeof off === 'function') off() }
829
- }
830
- return () => { alive = false }
831
- }, [scope])
832
908
 
833
- // Статус снимка настроек важнее самого значения:
834
- // loading — ответа хоста ещё нет;
835
- // unavailable — хост ответил, но пространство настроек пока не готово;
836
- // ready — значения на месте.
837
- // При unavailable поле writable берётся из документа и остаётся true,
838
- // поэтому без проверки статуса карточка рисует пустую, но с виду рабочую форму.
839
- const ready = !scope || (!!snap && snap.status === 'ready')
840
- 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
+ ]
841
936
 
842
- const applyPayload = (data) => {
843
- const cfg = data && data.config ? data.config : {}
844
- setDraft(JSON.parse(JSON.stringify(cfg)))
845
- setCredentials(data && data.credentials ? data.credentials : {})
846
- 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)))
847
943
  }
848
-
849
- React.useEffect(() => {
850
- let alive = true
851
- fetch('/dsh-tts/config', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
852
- if (!alive) return
853
- applyPayload(data)
854
- }).catch((e) => { if (alive) setErr(String(e && e.message ? e.message : e)) })
855
-
856
- fetch('/dsh-tts/integrations', { cache: 'no-store' }).then((res) => res.json()).then((data) => {
857
- if (!alive) return
858
- if (data && data.ok) setIntegrations(data)
859
- }).catch(() => {})
860
-
861
- return () => { alive = false }
862
- }, [])
863
-
864
- if (!draft) return React.createElement('div', { className: 'dts-wrap' }, t('loading'))
865
-
866
- const setTop = (key, v) => setDraft((d) => Object.assign({}, d || {}, { [key]: v }))
867
- const setDraftKey = (provider, value) => setKeyDrafts((d) => Object.assign({}, d, { [provider]: value }))
868
-
869
- const commitKey = async (provider, raw) => {
870
- const value = String(raw != null ? raw : ((keyDrafts && keyDrafts[provider]) || '')).trim()
871
- if (!value) return
872
- setErr('')
873
- const res = await fetch('/dsh-tts/credential', {
874
- method: 'PUT',
875
- headers: { 'Content-Type': 'application/json' },
876
- body: JSON.stringify({ provider: provider, value: value }),
877
- })
878
- const data = await res.json().catch(() => ({}))
879
- if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
880
- if (data && data.credentials) setCredentials(data.credentials)
881
- setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
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))
882
950
  }
883
951
 
884
- const clearKey = async (provider) => {
885
- setErr('')
886
- try {
887
- const res = await fetch('/dsh-tts/credential', {
888
- method: 'DELETE',
889
- headers: { 'Content-Type': 'application/json' },
890
- body: JSON.stringify({ provider: provider }),
891
- })
892
- const data = await res.json().catch(() => ({}))
893
- if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
894
- if (data && data.credentials) setCredentials(data.credentials)
895
- setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
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))
1268
+ if (data && data.credentials) setCredentials(data.credentials)
1269
+ setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
1270
+ }
1271
+
1272
+ const clearKey = async (provider) => {
1273
+ setErr('')
1274
+ try {
1275
+ const res = await fetch('/dsh-tts/credential', {
1276
+ method: 'DELETE',
1277
+ headers: { 'Content-Type': 'application/json' },
1278
+ body: JSON.stringify({ provider: provider }),
1279
+ })
1280
+ const data = await res.json().catch(() => ({}))
1281
+ if (!res.ok) throw new Error((data && data.error && data.error.message) || ('HTTP ' + res.status))
1282
+ if (data && data.credentials) setCredentials(data.credentials)
1283
+ setKeyDrafts((d) => Object.assign({}, d, { [provider]: '' }))
896
1284
  } catch (e) { setErr(String(e && e.message ? e.message : e)) }
897
1285
  }
898
1286
 
@@ -975,8 +1363,22 @@ window.__ModuleLoader__.load({
975
1363
  }),
976
1364
  React.createElement('span', { className: 'dts-sub' }, hint))
977
1365
 
978
- // В карточке заголовок рисует шапка — свой блок не дублируем.
979
- const titleLine = props && props.compact ? null : React.createElement('div', { className: 'dts-h' }, t('title'))
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
+ )
980
1382
  return React.createElement('div', { className: 'dts-wrap' },
981
1383
  React.createElement('div', { className: 'dts-block' },
982
1384
  titleLine,
@@ -1051,18 +1453,10 @@ window.__ModuleLoader__.load({
1051
1453
  boolField('announceApproval', t('announce'),
1052
1454
  t('announceHint')),
1053
1455
  boolField('skipCode', t('skipCode'),
1054
- t('skipCodeHint')),
1055
- boolField('cache', t('cache'),
1056
- t('cacheHint')),
1057
- numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
1058
- React.createElement('label', { className: 'dts-field' }, t('longReply'),
1059
- React.createElement('select', {
1060
- className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
1061
- onChange: (e) => setTop('longReply', e.target.value),
1062
- }, ['truncate', 'summarize', 'full'].map((mode) => React.createElement('option', { key: mode, value: mode }, mode))),
1063
- React.createElement('span', { className: 'dts-sub' }, t('longReplyHint'))),
1064
- textField('summaryModel', t('summaryModel'), t('summaryModelHint')),
1065
- numberField('summarySentences', t('summarySentences'), t('summarySentencesHint'), 1),
1456
+ t('skipCodeHint')),
1457
+ boolField('cache', t('cache'),
1458
+ t('cacheHint')),
1459
+ numberField('cacheMaxMb', t('cacheMaxMb'), t('cacheMaxMbHint'), 10),
1066
1460
  React.createElement('label', { className: 'dts-field' }, t('longReply'),
1067
1461
  React.createElement('select', {
1068
1462
  className: 'dts-input', value: (draft && draft.longReply) || 'truncate', disabled: !writable,
@@ -1087,6 +1481,18 @@ window.__ModuleLoader__.load({
1087
1481
  textField('edgeBin', t('edgeBin'), t('edgeHint')),
1088
1482
  textField('azureRegion', t('azureRegion'), t('azureHint')),
1089
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
+ ),
1090
1496
  React.createElement('div', { className: 'dts-foot' },
1091
1497
  React.createElement('button', { type: 'button', className: 'dts-link', disabled: !writable, onClick: onClearCache }, t('clearCache')),
1092
1498
  React.createElement('button', { type: 'button', className: 'dts-save', disabled: !writable, onClick: save }, t('save')),
@@ -1097,263 +1503,6 @@ window.__ModuleLoader__.load({
1097
1503
  }
1098
1504
 
1099
1505
 
1100
- const CLIENT_IT_TERMS = [
1101
- { from: 'SQL', to: 'сиквел', whole: true, lang: 'ru' },
1102
- { from: 'Nginx', to: 'энджинкс', whole: true, lang: 'ru' },
1103
- { from: 'Kubernetes', to: 'кубернетис', whole: true, lang: 'ru' },
1104
- { from: 'K8s', to: 'кубернетис', whole: true, lang: 'ru' },
1105
- { from: 'PostgreSQL', to: 'постгрес', whole: true, lang: 'ru' },
1106
- { from: 'Redis', to: 'рэдис', whole: true, lang: 'ru' },
1107
- { from: 'Docker', to: 'докер', whole: true, lang: 'ru' },
1108
- { from: 'API', to: 'апи', whole: true, lang: 'ru' },
1109
- { from: 'JSON', to: 'джейсон', whole: true, lang: 'ru' },
1110
- { from: 'YAML', to: 'ямл', whole: true, lang: 'ru' },
1111
- { from: 'GUI', to: 'гуи', whole: true, lang: 'ru' },
1112
- { from: 'CLI', to: 'си элай', whole: true, lang: 'ru' },
1113
- { from: 'CI/CD', to: 'си ай си ди', whole: false, lang: 'ru' },
1114
- { from: 'PR', to: 'пулл реквест', whole: true, lang: 'ru' },
1115
- { from: 'Regex', to: 'регэкс', whole: true, lang: 'ru' },
1116
- { from: 'OAuth', to: 'о-аус', whole: true, lang: 'ru' },
1117
- { from: 'HTTP', to: 'эйч ти ти пи', whole: true, lang: 'ru' },
1118
- { from: 'HTTPS', to: 'эйч ти ти пи эс', whole: true, lang: 'ru' },
1119
- { from: 'URL', to: 'юрл', whole: true, lang: 'ru' },
1120
- { from: 'SSH', to: 'эс эс эйч', whole: true, lang: 'ru' },
1121
- { from: 'IP', to: 'ай пи', whole: true, lang: 'ru' },
1122
- { from: 'DNS', to: 'ди эн эс', whole: true, lang: 'ru' },
1123
- { from: 'CPU', to: 'си пи ю', whole: true, lang: 'ru' },
1124
- { from: 'GPU', to: 'джи пи ю', whole: true, lang: 'ru' },
1125
- { from: 'RAM', to: 'рам', whole: true, lang: 'ru' },
1126
- ]
1127
-
1128
- // Редактор словаря произношения: таблица правил сверху вниз.
1129
- function PronEditor(props) {
1130
- const t = props.t || ((key) => key)
1131
- const rows = Array.isArray(props.value) ? props.value : []
1132
- const change = (i, patch) => {
1133
- props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
1134
- }
1135
- const remove = (i) => props.onChange(rows.filter((_, k) => k !== i))
1136
- const add = () => props.onChange(rows.concat([{ from: '', to: '', whole: false, lang: '' }]))
1137
- const loadIt = () => {
1138
- const existing = new Set(rows.map((r) => String(r.from).toLowerCase()))
1139
- const toAdd = CLIENT_IT_TERMS.filter((r) => !existing.has(String(r.from).toLowerCase()))
1140
- props.onChange(rows.concat(toAdd))
1141
- }
1142
-
1143
- return React.createElement('div', { className: 'dts-wrap-in' },
1144
- rows.map((rule, i) => React.createElement('div', { className: 'dts-row', key: i },
1145
- React.createElement('input', {
1146
- className: 'dts-input dts-grow', placeholder: t('pronFrom'),
1147
- value: rule.from || '', disabled: !props.writable,
1148
- onChange: (e) => change(i, { from: e.target.value }),
1149
- }),
1150
- React.createElement('input', {
1151
- className: 'dts-input dts-grow', placeholder: t('pronTo'),
1152
- value: rule.to || '', disabled: !props.writable,
1153
- onChange: (e) => change(i, { to: e.target.value }),
1154
- }),
1155
- React.createElement('label', { className: 'dts-sub', title: t('pronWhole') },
1156
- React.createElement('input', {
1157
- type: 'checkbox', checked: !!rule.whole, disabled: !props.writable,
1158
- onChange: (e) => change(i, { whole: e.target.checked }),
1159
- }),
1160
- ),
1161
- React.createElement('input', {
1162
- className: 'dts-input', style: { maxWidth: '70px' }, placeholder: t('pronLang'),
1163
- value: rule.lang || '', disabled: !props.writable,
1164
- onChange: (e) => change(i, { lang: e.target.value }),
1165
- }),
1166
- React.createElement('button', {
1167
- type: 'button', className: 'dts-mini', title: t('previewRule'), disabled: !props.writable,
1168
- onClick: () => {
1169
- if (props.onPreviewPhrase) props.onPreviewPhrase(rule.to || rule.from)
1170
- },
1171
- }, '\u25b6'),
1172
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: () => remove(i) }, '\u00d7'),
1173
- )),
1174
- React.createElement('div', { className: 'dts-row' },
1175
- React.createElement('button', { type: 'button', className: 'dts-mini', disabled: !props.writable, onClick: add }, '+'),
1176
- React.createElement('button', { type: 'button', className: 'dts-link', disabled: !props.writable, onClick: loadIt }, t('loadItDictionary')),
1177
- React.createElement('span', { className: 'dts-sub' }, t('pronHint')),
1178
- ),
1179
- )
1180
- }
1181
-
1182
- function RolesEditor(props) {
1183
- const t = props.t || ((key) => key)
1184
- const value = props.value || {}
1185
- const [newRole, setNewRole] = React.useState('')
1186
- const set = (r, patch) => props.onChange(Object.assign({}, value, { [r]: Object.assign({}, value[r] || {}, patch) }))
1187
- const removeRole = (r) => {
1188
- const next = Object.assign({}, value)
1189
- delete next[r]
1190
- props.onChange(next)
1191
- }
1192
-
1193
- const standardKeys = ['reply', 'approval', 'error']
1194
- const customKeys = Object.keys(value).filter((k) => !standardKeys.includes(k))
1195
- const allKeys = [...standardKeys, ...customKeys]
1196
-
1197
- const addCustom = () => {
1198
- const name = newRole.trim().toLowerCase()
1199
- if (!name || value[name]) return
1200
- set(name, { provider: '', model: '', voice: '', chime: '', ssmlStyle: '' })
1201
- setNewRole('')
1202
- }
1203
-
1204
- return React.createElement('div', { className: 'dts-block' },
1205
- React.createElement('span', { className: 'dts-sub' }, t('rolesHint')),
1206
- allKeys.map((r) => React.createElement('div', { className: 'dts-row', key: r },
1207
- React.createElement('span', { className: 'dts-sub', style: { minWidth: '110px' } }, t('role_' + r) !== ('role_' + r) ? t('role_' + r) : `👤 ${r}`),
1208
- React.createElement('select', {
1209
- className: 'dts-input', value: (value[r] && value[r].provider) || '', disabled: !props.writable,
1210
- onChange: (e) => set(r, { provider: e.target.value }),
1211
- },
1212
- React.createElement('option', { value: '' }, '—'),
1213
- PROVIDERS.map((p) => React.createElement('option', { key: p, value: p }, p)),
1214
- ),
1215
- React.createElement('input', {
1216
- className: 'dts-input dts-grow', placeholder: 'model',
1217
- value: (value[r] && value[r].model) || '', disabled: !props.writable,
1218
- onChange: (e) => set(r, { model: e.target.value }),
1219
- }),
1220
- React.createElement('input', {
1221
- className: 'dts-input dts-grow', placeholder: 'voice',
1222
- value: (value[r] && value[r].voice) || '', disabled: !props.writable,
1223
- onChange: (e) => set(r, { voice: e.target.value }),
1224
- }),
1225
- React.createElement('input', {
1226
- className: 'dts-input', style: { maxWidth: '90px' }, placeholder: t('chime'),
1227
- value: (value[r] && value[r].chime) || '', disabled: !props.writable,
1228
- onChange: (e) => set(r, { chime: e.target.value }),
1229
- }),
1230
- React.createElement('input', {
1231
- className: 'dts-input dts-grow', placeholder: 'ssml',
1232
- value: (value[r] && value[r].ssmlStyle) || '', disabled: !props.writable,
1233
- onChange: (e) => set(r, { ssmlStyle: e.target.value }),
1234
- }),
1235
- 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'),
1236
- !standardKeys.includes(r) ? React.createElement('button', { type: 'button', className: 'dts-mini', title: t('remove'), disabled: !props.writable, onClick: () => removeRole(r) }, '\u00d7') : null,
1237
- )),
1238
- React.createElement('div', { className: 'dts-row' },
1239
- React.createElement('input', {
1240
- className: 'dts-input', style: { maxWidth: '180px' }, placeholder: t('newSubagentRole'),
1241
- value: newRole, disabled: !props.writable,
1242
- onChange: (e) => setNewRole(e.target.value),
1243
- onKeyDown: (e) => { if (e.key === 'Enter') addCustom() },
1244
- }),
1245
- React.createElement('button', { type: 'button', className: 'dts-save', disabled: !props.writable || !newRole.trim(), onClick: addCustom }, t('addSubagentRole')),
1246
- ),
1247
- )
1248
- }
1249
-
1250
- function VoiceDuplexEditor(props) {
1251
- const t = props.t || ((key) => key)
1252
- const installed = !!props.installed
1253
-
1254
- return React.createElement('div', { className: 'dts-block' },
1255
- React.createElement('div', { className: 'dts-h' }, t('voiceDuplexTitle')),
1256
- React.createElement('div', { className: 'dts-sub' }, t('voiceDuplexHint')),
1257
- !installed ? React.createElement('div', {
1258
- style: {
1259
- padding: '10px 14px',
1260
- borderRadius: '8px',
1261
- border: '1px solid var(--dsw-alias-state-warning-primary, #eab308)',
1262
- background: 'var(--dsw-alias-bg-layer-2)',
1263
- display: 'flex',
1264
- flexDirection: 'column',
1265
- gap: '4px',
1266
- fontSize: '13px',
1267
- },
1268
- },
1269
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #eab308)' } },
1270
- '⚠️ ' + t('voiceNotInstalledTitle')
1271
- ),
1272
- React.createElement('span', { className: 'dts-sub' },
1273
- t('voiceInstallHint') + ': '
1274
- ),
1275
- React.createElement('code', {
1276
- style: {
1277
- fontFamily: 'monospace',
1278
- fontSize: '12px',
1279
- padding: '2px 6px',
1280
- borderRadius: '4px',
1281
- background: 'var(--dsw-alias-bg-layer-3)',
1282
- color: 'var(--dsw-alias-label-primary)',
1283
- width: 'fit-content',
1284
- },
1285
- }, 'dsh plugin --profile web add @goodandready/dsh-voice')
1286
- ) : null,
1287
- props.boolField('voiceDuplexEnabled', t('voiceDuplex'), t('voiceDuplexHintToggle'), !installed),
1288
- props.boolField('vadBargeIn', t('vadBargeIn'), t('vadBargeInHint'), !installed),
1289
- )
1290
- }
1291
-
1292
- function MessengerIntegrationEditor(props) {
1293
- const t = props.t || ((key) => key)
1294
- const installed = !!props.installed
1295
-
1296
- return React.createElement('div', { className: 'dts-block' },
1297
- React.createElement('div', { className: 'dts-h' }, t('messengerIntegrationTitle')),
1298
- React.createElement('div', { className: 'dts-sub' }, t('messengerIntegrationHint')),
1299
- !installed ? React.createElement('div', {
1300
- style: {
1301
- padding: '10px 14px',
1302
- borderRadius: '8px',
1303
- border: '1px solid var(--dsw-alias-state-warning-primary, #eab308)',
1304
- background: 'var(--dsw-alias-bg-layer-2)',
1305
- display: 'flex',
1306
- flexDirection: 'column',
1307
- gap: '4px',
1308
- fontSize: '13px',
1309
- },
1310
- },
1311
- React.createElement('span', { style: { fontWeight: 600, color: 'var(--dsw-alias-state-warning-primary, #eab308)' } },
1312
- '⚠️ ' + t('messengerNotInstalledTitle')
1313
- ),
1314
- React.createElement('span', { className: 'dts-sub' },
1315
- t('messengerInstallHint') + ': '
1316
- ),
1317
- React.createElement('code', {
1318
- style: {
1319
- fontFamily: 'monospace',
1320
- fontSize: '12px',
1321
- padding: '2px 6px',
1322
- borderRadius: '4px',
1323
- background: 'var(--dsw-alias-bg-layer-3)',
1324
- color: 'var(--dsw-alias-label-primary)',
1325
- width: 'fit-content',
1326
- },
1327
- }, 'dsh plugin --profile web add @goodandready/dsh-messenger-gateway')
1328
- ) : null,
1329
- props.boolField('messengerTtsEnabled', t('messengerTtsEnabled'), t('messengerTtsEnabledHint'), !installed),
1330
- )
1331
- }
1332
-
1333
- // Панель статистики синтеза: живые счётчики хоста.
1334
- function StatsPanel(props) {
1335
- const t = props.t || ((key) => key)
1336
- const [data, setData] = React.useState(null)
1337
- const loadStats = () => {
1338
- fetch('/dsh-tts/stats', { cache: 'no-store' }).then((r) => r.json()).then(setData).catch(() => {})
1339
- }
1340
- React.useEffect(() => { loadStats() }, [])
1341
- const reset = () => {
1342
- fetch('/dsh-tts/stats', { method: 'DELETE' }).then(loadStats).catch(() => {})
1343
- }
1344
- return React.createElement('div', { className: 'dts-block' },
1345
- React.createElement('span', { className: 'dts-sub' }, data
1346
- ? t('statTotal') + ': ' + data.total + ' · ' + t('statHits') + ': ' + data.cacheHits + ' · ' + t('statErrors') + ': ' + data.errors
1347
- : t('loading')),
1348
- data && Object.keys(data.providers || {}).map((p) => React.createElement('span', { className: 'dts-sub', key: p },
1349
- p + ': ' + data.providers[p].n + ' / ' + data.providers[p].ms + 'ms')),
1350
- React.createElement('button', { type: 'button', className: 'dts-link', onClick: reset }, t('statReset')),
1351
- )
1352
- }
1353
-
1354
- // Карточка во вкладке «Настройки плагинов» — разметка и стили по
1355
- // единому канону (issue #19): li в списке ядра, шапка со сбросом темы,
1356
- // тело с чертой-разделителем. Шеврон — наш, ядро его не даёт.
1357
1506
  function TtsCard(props) {
1358
1507
  const t = (props && props.t) || ((key) => key)
1359
1508
  const [open, setOpen] = React.useState(false)
@@ -1381,17 +1530,17 @@ window.__ModuleLoader__.load({
1381
1530
  ),
1382
1531
  ),
1383
1532
  open ? React.createElement('div', { className: 'dts-body' },
1384
- 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
+ ),
1385
1536
  ) : null,
1386
1537
  )
1387
1538
  }
1388
1539
 
1389
- // Кнопка в строке ввода: видно, что сейчас читается, можно остановить и
1390
- // открыть недавние озвучки для реплая.
1540
+ // Input-dock button: shows what is playing, stop control, and recents for replay.
1391
1541
  function SpeakerControl(props) {
1392
- // Слот с `locale` кладёт перевод в props; запасной путь — привязка,
1393
- // сделанная при регистрации. Второй нужен на случай, если слот перевод
1394
- // не донесёт: без него кнопки остались бы без подписей вовсе.
1542
+ // A slot with `locale` injects translations into props; the fallback is the
1543
+ // binder created at registration, in case the slot drops translations.
1395
1544
  const t = (props && props.t) || fallbackDockText
1396
1545
  const p = usePlayer()
1397
1546
  const [showList, setShowList] = React.useState(false)
@@ -1450,8 +1599,8 @@ window.__ModuleLoader__.load({
1450
1599
  let fallbackDockText = (key) => key
1451
1600
 
1452
1601
  function registerSpeaker(ctx) {
1453
- // `locale` в описании слота — то, чем ядро кладёт перевод в props
1454
- // компонента. Без него внутри дока не было бы ничего, кроме ключей.
1602
+ // `locale` in the slot descriptor is how core injects translations into props.
1603
+ // Without it the dock would show raw keys only.
1455
1604
  fallbackDockText = ctx.locale.bind(NS)
1456
1605
  ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
1457
1606
  {
@@ -1466,14 +1615,10 @@ window.__ModuleLoader__.load({
1466
1615
  }
1467
1616
 
1468
1617
  function registerSettings(ctx) {
1469
- // Язык может принести не только плагин: словарные пакеты объявляют
1470
- // русский для чужих пространств. Ядро на повторное объявление той же
1471
- // пары «пространство + язык» бросает исключение, и незащищённый вызов
1472
- // уносил с собой весь плагин — в интерфейсе это выглядело как «Failed to
1473
- // load plugins» с перечнем ни в чём не повинных соседей.
1474
- //
1475
- // Поэтому каждый язык объявляется отдельно и по-хорошему: заняли до нас —
1476
- // уступаем, свой английский при этом всё равно встаёт на место.
1618
+ // Dictionary packages may also register languages for other namespaces.
1619
+ // Core throws on duplicate namespace+language; an unguarded call used to
1620
+ // take down the whole plugin ("Failed to load plugins"). Register each
1621
+ // language separately: skip if taken; English still lands.
1477
1622
  const addLocale = (locale, dictionary) => {
1478
1623
  try {
1479
1624
  return ctx.locale.register(NS, locale, dictionary)
@@ -1485,13 +1630,12 @@ window.__ModuleLoader__.load({
1485
1630
  const undo = [addLocale('en', en), addLocale('ru', ru)]
1486
1631
  return () => { for (const off of undo) off() }
1487
1632
  }, 'dsh-tts: словари')
1488
- // Подпись раздела рисует боковой список, а не наш компонент: props.t
1489
- // туда не доходит, поэтому берём переводчик, привязанный к namespace.
1633
+ // The sidebar draws the section label, not our component: props.t never
1634
+ // reaches it, so bind the translator to the namespace ourselves.
1490
1635
  const t = ctx.locale.bind(NS)
1491
- // Штатное место настроек — карточка во вкладке «Настройки плагинов».
1492
- // Вкладка перебирает пространства настроек и ищет слот по entryKey,
1493
- // равному имени пространства: ключ обязан равняться NS, иначе карточка
1494
- // не появится молча, без единой ошибки в журнале.
1636
+ // Primary settings home is the plugin-settings tab card. The tab looks up
1637
+ // a slot by entryKey equal to the namespace name: key must equal NS or
1638
+ // the card never appears, with no log error.
1495
1639
  const tryPluginItem = () => {
1496
1640
  try {
1497
1641
  const res = ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
@@ -1510,8 +1654,8 @@ window.__ModuleLoader__.load({
1510
1654
  }
1511
1655
  }
1512
1656
  if (tryPluginItem()) return
1513
- // Запасной путь для сборок без settings.plugin.item: прежний раздел
1514
- // в боковом списке, чтобы настройки не пропали.
1657
+ // Fallback for builds without settings.plugin.item: keep the sidebar section
1658
+ // so settings cannot disappear.
1515
1659
  ctx.slots.inject('settings.section', () => ctx.slots.register(
1516
1660
  {
1517
1661
  name: 'settings.section',
@@ -1521,7 +1665,7 @@ window.__ModuleLoader__.load({
1521
1665
  label: () => t('title'),
1522
1666
  inject: () => ({ ctx: ctx }),
1523
1667
  },
1524
- TtsSection,
1668
+ (props) => React.createElement(ErrorBoundary, null, React.createElement(TtsSection, props)),
1525
1669
  ))
1526
1670
  }
1527
1671
 
@@ -1587,3 +1731,4 @@ window.__ModuleLoader__.load({
1587
1731
 
1588
1732
  // ModelManager UI placeholder - manual install buttons for Kokoro/F5
1589
1733
  // Actual implementation would show download progress and status
1734
+