@goodandready/dsh-voice 0.8.18 → 0.8.20

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
@@ -1,13 +1,13 @@
1
- // dsh-voice — клиентская половина (браузер).
1
+ // dsh-voice — client half (browser).
2
2
  //
3
- // Две кнопки в conversation.input.right:
4
- // микрофон — диктовка: речь режется по паузам, каждый кусок распознаётся и
5
- // дописывается в строку ввода; отправка остаётся за пользователем;
6
- // волна — голосовое: одна запись целиком, после распознавания текст
7
- // уходит агенту по истечении окна отмены.
3
+ // Two buttons in conversation.input.right:
4
+ // mic — dictation: speech is cut on pauses, each chunk is recognized and
5
+ // appended to the composer; send stays with the user;
6
+ // wave — voice message: one whole recording; after recognition the text is
7
+ // sent to the agent when the cancel window expires.
8
8
  //
9
- // Полоска записи живёт в conversation.input.dock, страница настроек — в
10
- // settings.section.
9
+ // The recording pill lives in conversation.input.dock; settings are a card
10
+ // in settings.plugin.item.
11
11
 
12
12
  window.__ModuleLoader__.load({
13
13
  id: '@goodandready/dsh-voice',
@@ -19,9 +19,9 @@ window.__ModuleLoader__.load({
19
19
 
20
20
  const NS = 'dsh-voice'
21
21
 
22
- // Строки интерфейса живут в реестре локалей: так их переводит отдельный
23
- // пакет, не трогая код этого плагина. Английский — язык по умолчанию,
24
- // на него же приходится откат, если перевода нет.
22
+ // UI strings live in the locale registry so a separate package can
23
+ // translate them without touching this plugin. English is the source
24
+ // language, the default, and the fallback.
25
25
  const en = {
26
26
  'saveFailed': 'Some fields were not saved —',
27
27
  'cardHint': 'Dictation and voice messages: providers, chains, local whisper',
@@ -128,6 +128,7 @@ window.__ModuleLoader__.load({
128
128
  'whisperBin': 'Local whisper: binary',
129
129
  'whisperBinHint': 'used when autostart is on',
130
130
  'whisperModel': 'Local whisper: model',
131
+ 'whisperModelHint': 'Absolute path to the ggml model file',
131
132
  'whisperAutostart': 'Autostart the local whisper',
132
133
  'save': 'Save',
133
134
  'saved': 'Saved ✓',
@@ -154,6 +155,7 @@ window.__ModuleLoader__.load({
154
155
  'sensevoiceBin': 'SenseVoice: binary',
155
156
  'sensevoiceBinHint': 'used when autostart is on',
156
157
  'sensevoiceModel': 'SenseVoice: model path',
158
+ 'sensevoiceModelHint': 'Absolute path or identifier of the SenseVoice / sherpa-onnx model',
157
159
  'sensevoiceAutostart': 'Autostart the SenseVoice server',
158
160
  'visualizerStyle': 'Audio visualizer style',
159
161
  'visualizerStyleHint': 'Waveform animation inside the recording pill',
@@ -161,150 +163,24 @@ window.__ModuleLoader__.load({
161
163
  'visDynamicOrb': 'Dynamic Orb',
162
164
  'visBars': 'Classic Bars',
163
165
  'visOff': 'Off',
164
- }
165
- const ru = {
166
- 'saveFailed': 'Часть полей не сохранилась —',
167
- 'cardHint': 'Диктовка и голосовые сообщения: провайдеры, цепочки, локальный whisper',
168
- 'expand': 'Развернуть',
169
- 'collapse': 'Свернуть',
170
- 'composerUnavailable': 'Композер недоступен',
171
- 'keySpace': 'Пробел',
172
- 'keyUnset': 'не задана',
173
- 'recognitionError': 'ошибка распознавания',
174
- 'micUnavailable': 'Микрофон недоступен: нужен HTTPS или localhost',
175
- 'noRecorder': 'MediaRecorder не поддерживается браузером',
176
- 'browserFailed': 'Браузер не распознал: ',
177
- 'nothingHeard': 'Речь не распознана',
178
- 'dictationBtn': 'Голосовой набор',
179
- 'messageBtn': 'Голосовое сообщение — нажать или удерживать',
180
- 'dictationPill': 'Диктовка — текст дописывается в строку',
181
- 'messagePill': 'Запись голосового',
182
- 'cancel': 'Отмена',
183
- 'holdHint': 'Держите — отпустите, чтобы отправить',
184
- 'listening': 'Слушаю в браузере…',
185
- 'stop': 'Стоп',
186
- 'transcribing': 'Распознаю…',
187
- 'sendingIn': 'Отправляю агенту через',
188
- 'secondsShort': ' с',
189
- 'keepPending': 'Отменить отправку',
190
- 'hide': 'Скрыть',
191
- 'title': 'Голос',
192
- 'recordSlot': 'Запись голоса',
193
- 'browserHint': 'распознаёт сам браузер, ключ не нужен',
194
- 'openaiHint': 'whisper-1 · ключ OPENAI_API_KEY',
195
- 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · ключ SILICONFLOW_API_KEY',
196
- 'deepinfraHint': 'openai/whisper-large-v3-turbo · ключ DEEPINFRA_API_KEY',
197
- 'fireworksHint': 'whisper-v3-turbo · ключ FIREWORKS_API_KEY',
198
- 'mistralHint': 'voxtral-mini-latest · ключ MISTRAL_API_KEY',
199
- 'openrouterHint': 'google/gemini-2.5-flash · ключ OPENROUTER_API_KEY',
200
- 'localHint': 'задаётся при запуске сервера',
201
- 'up': 'Выше',
202
- 'down': 'Ниже',
203
- 'remove': 'Убрать',
204
- 'addProvider': 'Добавить провайдера',
205
- 'chainHint': 'Порядок сверху вниз — порядок попыток',
206
- 'customName': 'имя для цепочки',
207
- 'customModel': 'модель',
208
- 'customKeyName': 'имя ключа',
209
- 'customModelHint': 'указание модели (пусто — встроенное)',
210
- 'addCustom': 'Добавить своего провайдера',
211
- 'loadingSettings': 'Загрузка настроек…',
212
- 'notReady1': 'Харнесс ещё не объявил настройки плагина. Если он только что перезапустился, ',
213
- 'notReady2': 'раздел появится сам через несколько секунд.',
214
- 'hotkey': 'Клавиша для голосового сообщения',
215
- 'pressKey': 'Нажмите клавишу…',
216
- 'clearKey': 'Убрать клавишу',
217
- 'hotkeyHint1': 'Держите её — идёт запись, отпустите — уйдёт агенту, Esc — отмена. ',
218
- 'hotkeyHint2': 'Можно любую: буква, F-клавиша или модификатор.',
219
- 'language': 'Язык',
220
- 'dictationHint': 'Речь режется по паузам, текст дописывается в строку ввода.',
221
- 'pauseMs': 'Пауза до конца фразы, мс',
222
- 'pauseHint': 'Меньше — чаще куски и быстрее текст, но выше риск обрезать слово',
223
- 'speaking': 'Вы говорите…',
224
- 'silence': 'Пауза…',
225
- 'normalizeTranscript': 'Нормализация расшифровок',
226
- 'undo': 'Отменить вставку',
227
- 'undone': 'Вставка отменена',
228
- 'nothingToUndo': 'Отменять нечего',
229
- 'beep': 'Звук старта/стопа',
230
- 'localOnly': 'Только локальный whisper',
231
- 'localOnlyHint': 'Обе цепочки — только локальный сервер whisper.cpp: полностью офлайн.',
232
- 'sendDelay': 'Задержка вставки диктовки (мс)',
233
- 'sendDelayHint': 'Пауза перед вставкой фразы с окном отмены. 0 — выкл',
234
- 'mic': 'Микрофон',
235
- 'micDefault': 'Системной по умолчанию',
236
- 'vocabulary': 'Свой словарь (одно слово в строке)',
237
- 'polish': 'Полировка текста моделью',
238
- 'polishHint': 'Пунктуация и слова-паразиты через модель харнесса перед вставкой',
239
- 'stream': 'Непрерывная диктовка',
240
- 'streamHint': 'Резать фразы по таймеру во время речи, а не по длинной паузе',
241
- 'streamChunkMs': 'Кусок потока (мс)',
242
- 'vadAdapt': 'Адаптивная тишина',
243
- 'vadAdaptHint': 'Авто-подстройка порога под темп речи. 0 = фикс. поведение',
244
- 'wakeWord': 'Слово-активатор',
245
- 'wakeWordHint': 'Распознавание в браузере начинает запись, если речь начинается с этой фразы. Пусто = выкл',
246
- 'bargeIn': 'Перебивание',
247
- 'polishSend': 'Полировка всего текста перед отправкой',
248
- 'polishSendHint': 'Прогнать весь текст через модель перед отправкой агенту',
249
- 'sessionCommands': 'Голосовые команды сессии',
250
- 'sessionCommandsHint': '«отправь», «отмени», «стоп», «продолжи» — действия сессии, а не текст',
251
- 'polishBaseUrl': 'Локальный ендпоинт полировки',
252
- 'polishBaseUrlHint': 'OpenAI-совместимый /chat/completions базовый URL, напр. локальный Ollama. Пусто = модель харнесса',
253
- 'polishModel': 'Модель офлайн-полировки',
254
- 'polishKeyEnv': 'Ключ офлайн-полировки',
255
- 'voiceCommandsLabel': 'Голосовые команды («с новой строки», «абзац»)',
256
- 'normalizeTranscriptHint': 'transcribe_audio: числа словами — в цифры, аккуратная пунктуация',
257
- 'messageTitle': 'Голосовое сообщение',
258
- 'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
259
- 'undoMs': 'Окно отмены, мс',
260
- 'undoHint': 'Сколько времени можно отменить автоматическую отправку',
261
- 'customTitle': 'Свои провайдеры',
262
- 'customHint': 'Любой OpenAI-совместимый API. Имя становится доступным в цепочках выше.',
263
- 'general': 'Общее',
264
- 'whisperEndpoint': 'Локальный whisper: endpoint',
265
- 'whisperEndpointHint': 'POST /inference сервера whisper.cpp',
266
- 'deepgramEndpoint': 'Deepgram: базовый URL',
267
- 'deepgramEndpointHint': 'Базовый адрес Deepgram или self-hosted развёртывания (по умолчанию https://api.deepgram.com)',
268
- 'whisperBin': 'Локальный whisper: бинарь',
269
- 'whisperBinHint': 'используется при автозапуске',
270
- 'whisperModel': 'Локальный whisper: модель',
271
- 'whisperAutostart': 'Автозапуск локального whisper',
272
- 'save': 'Сохранить',
273
- 'saved': 'Сохранено ✓',
274
- 'openrouterWarning': 'У OpenRouter нет /audio/transcriptions \u2014 там нужен шаблон openai-chat-audio',
275
- 'noiseSuppression': 'Аппаратное шумоподавление',
276
- 'noiseSuppressionHint': 'Включить шумоподавление, эхоподавление и АРУ микрофона в браузере',
277
- 'contextGlossary': 'Контекстный словарь терминов',
278
- 'contextGlossaryHint': 'Авто-извлечение кода и терминов из композера для повышения точности STT',
279
- 'providerDashboard': 'Задержка и здоровье провайдеров',
280
- 'avgLatency': 'Ср. задержка',
281
- 'successRate': 'Успешность',
282
- 'fast': 'Быстро',
283
- 'normal': 'Норма',
284
- 'slow': 'Медленно',
285
- 'error': 'Сбой',
286
- 'idle': 'Нет вызовов',
287
- 'play': 'Слушать',
288
- 'pause': 'Пауза',
289
- 'listenBack': 'Прослушать запись',
290
- 'lastRecording': 'Последняя запись',
291
- 'sensevoiceHint': 'SenseVoice-ONNX / Sherpa-ONNX · сверхбыстрый локальный STT (~50мс)',
292
- 'sensevoiceEndpoint': 'SenseVoice: endpoint',
293
- 'sensevoiceEndpointHint': 'POST endpoint сервера sherpa-onnx или совместимого',
294
- 'sensevoiceBin': 'SenseVoice: бинарь',
295
- 'sensevoiceBinHint': 'используется при автозапуске',
296
- 'sensevoiceModel': 'SenseVoice: путь к модели',
297
- 'sensevoiceAutostart': 'Автозапуск сервера SenseVoice',
298
- 'visualizerStyle': 'Стиль визуализатора звука',
299
- 'visualizerStyleHint': 'Анимация волны внутри полоски записи',
300
- 'visLiquidWave': 'Жидкая волна (Liquid Wave)',
301
- 'visDynamicOrb': 'Динамическая сфера (Dynamic Orb)',
302
- 'visBars': 'Классические столбики',
303
- 'visOff': 'Выключен',
166
+ 'statusTitle': 'Connection & Engine Status',
167
+ 'statusDesc': 'Current runtime connectivity to voice backends and recognition services.',
168
+ 'badgeHostOnline': 'Host online ({ms} ms)',
169
+ 'badgeHostOffline': 'Host unreachable',
170
+ 'badgeWhisperActive': 'Whisper active',
171
+ 'badgeWhisperInactive': 'Whisper offline',
172
+ 'badgeSenseVoiceActive': 'SenseVoice active',
173
+ 'badgeSenseVoiceInactive': 'SenseVoice offline',
174
+ 'badgeProviders': '{count} providers ready',
175
+ 'refreshStats': 'Refresh Stats',
176
+ 'hardwareTitle': 'Hardware & Local Engines',
177
+ 'hardwareDesc': 'Microphone device, local whisper.cpp and SenseVoice runtime settings.',
178
+ 'uiError': 'Voice UI Error:',
179
+ 'retry': 'Retry',
304
180
  }
305
181
 
306
- // Строки нужны и вне компонентов — в обработчиках записи, в подписях
307
- // слотов. Поэтому переводчик модульный, а не только через props.
182
+ // Strings are also needed outside components — in recording handlers and
183
+ // slot labels — so the translator is module-level, not only via props.
308
184
  let moduleT = (key) => key
309
185
  const t = (key) => moduleT(key)
310
186
 
@@ -339,10 +215,10 @@ window.__ModuleLoader__.load({
339
215
  '.dvo-badge-err{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary)}' +
340
216
  '.dvo-badge-idle{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary)}'
341
217
  const cssId = 'dsh-voice/client.module.css'
342
- if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + cssId + '"]')) {
218
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + cssId + '"]')) {
343
219
  const tag = document.createElement('style')
344
220
  tag.textContent = CSS
345
- tag.setAttribute('data-plugin', 'dsh-voice')
221
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
346
222
  tag.dataset.pluginCss = cssId
347
223
  document.head.appendChild(tag)
348
224
  }
@@ -354,8 +230,8 @@ window.__ModuleLoader__.load({
354
230
  error: '',
355
231
  rec: null,
356
232
  levels: [],
357
- pending: null, // {text, leftMs} — окно отмены режима message
358
- lastNote: null, // {blob, url, mime, text} — последняя запись
233
+ pending: null, // {text, leftMs} — message-mode cancel window
234
+ lastNote: null, // {blob, url, mime, text} — last recording
359
235
  showPlayer: false,
360
236
  inputActions: null,
361
237
  input: null,
@@ -444,7 +320,7 @@ window.__ModuleLoader__.load({
444
320
  body: JSON.stringify(payload),
445
321
  })
446
322
  let parsed = null
447
- try { parsed = await res.json() } catch (e) { /* не json */ }
323
+ try { parsed = await res.json() } catch (e) { /* not json */ }
448
324
  if (!res.ok || !parsed || !parsed.ok) {
449
325
  throw new Error((parsed && parsed.error && parsed.error.message) || `HTTP ${res.status}`)
450
326
  }
@@ -460,13 +336,16 @@ window.__ModuleLoader__.load({
460
336
  return s
461
337
  }
462
338
 
463
- // Голосовые команды редактирования (#37): «с новой строки» -> \n и т.п.
464
- // Применяются до нормализации, только когда включены в настройках.
339
+ // Spoken edit commands (#37): "new line" / "с новой строки" -> \n and so on.
340
+ // Applied before normalization, only when enabled in settings.
341
+ // Russian phrases match RU STT output; English covers EN dictation.
465
342
  const VOICE_COMMANDS = [
466
343
  [/(^|[\s,.!?])с новой строки([\s,.!?]|$)/gi, '$1\n$2'],
467
344
  [/(^|[\s,.!?])новая строка([\s,.!?]|$)/gi, '$1\n$2'],
468
345
  [/(^|[\s,.!?])абзац([\s,.!?]|$)/gi, '$1\n\n$2'],
469
346
  [/(^|\s)тире(\s|$)/gi, '$1—$2'],
347
+ [/(^|[\s,.!?])new line([\s,.!?]|$)/gi, '$1\n$2'],
348
+ [/(^|[\s,.!?])paragraph([\s,.!?]|$)/gi, '$1\n\n$2'],
470
349
  ]
471
350
 
472
351
  function applyVoiceCommands(text) {
@@ -475,7 +354,7 @@ window.__ModuleLoader__.load({
475
354
  return s.replace(/[ \t]+/g, ' ').trim()
476
355
  }
477
356
 
478
- // История вставок для undo (#29-9). Хранится только в браузере.
357
+ // Insert history for undo (#29-9). Browser-only storage.
479
358
  const insertHistory = []
480
359
 
481
360
  async function undoLastInsert() {
@@ -486,7 +365,7 @@ window.__ModuleLoader__.load({
486
365
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
487
366
  if (draft === last.after) actions.setDraft(last.before)
488
367
  else {
489
- // Текст уже менялся руками — вырезаем последнюю вставку как подстроку.
368
+ // Draft was edited manually — cut the last insert as a substring.
490
369
  const i = draft.lastIndexOf(last.added)
491
370
  if (i < 0) { insertHistory.push(last); return t('nothingToUndo') }
492
371
  actions.setDraft((draft.slice(0, i) + draft.slice(i + last.added.length)).replace(/\s+$/, ''))
@@ -512,7 +391,7 @@ window.__ModuleLoader__.load({
512
391
  }
513
392
  }
514
393
 
515
- // Как клавиша называется на человеческом языке.
394
+ // Human-readable key name.
516
395
  const KEY_LABELS = {
517
396
  Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
518
397
  Escape: 'Esc',
@@ -522,32 +401,31 @@ window.__ModuleLoader__.load({
522
401
  if (!name) return t('keyUnset')
523
402
  if (name === 'Space') return t('keySpace')
524
403
  if (KEY_LABELS[name]) return KEY_LABELS[name]
525
- // Коды вида KeyR и Digit5 показываем без служебной приставки.
404
+ // Drop the Key/Digit prefix from codes like KeyR and Digit5.
526
405
  return String(name).replace(/^Key/, '').replace(/^Digit/, '')
527
406
  }
528
407
 
529
- // Что записать в настройку по нажатию. Чистый модификатор запоминаем по
530
- // имени: код у левого и правого разный, а человек нажимает «какой-нибудь».
408
+ // What to store on key press. Pure modifiers are remembered by name:
409
+ // left and right have different codes but the user means "either".
531
410
  function keyFromEvent(event) {
532
411
  if (['Control', 'Alt', 'Shift', 'Meta'].includes(event.key)) return event.key
533
412
  if (event.code) return event.code
534
413
  return event.key || ''
535
414
  }
536
415
 
537
- // Объявление о том, что человек заговорил.
416
+ // Announce that the user started speaking.
538
417
  //
539
- // Нужно, чтобы озвучка немедленно замолчала: слушать ответ и говорить
540
- // одновременно невозможно, а перекрикивать собственный плагин — глупо.
541
- // Связи между плагинами нет: голос кричит в окно, кто хочет — слышит.
542
- // Поэтому оба плагина работают и поодиночке.
418
+ // Playback should mute immediately: listening and talking at once is
419
+ // impossible. There is no plugin-to-plugin API — this broadcasts a window
420
+ // event that anyone may hear, so either side works alone.
543
421
  function announceVoice(phase) {
544
422
  try {
545
423
  window.dispatchEvent(new CustomEvent('dsh-voice:speaking', { detail: { phase } }))
546
- } catch (noEvents) { /* окна нет — значит и слушать некому */ }
424
+ } catch (noEvents) { /* no window — nobody to hear it */ }
547
425
  if (voice.settings.beep) playBeep(phase === 'start' ? 880 : 660)
548
426
  }
549
427
 
550
- // Короткий пик через WebAudio: слышно без взгляда на экран (#29-6).
428
+ // Short WebAudio beep so start/stop is audible without looking (#29-6).
551
429
  function playBeep(freq) {
552
430
  try {
553
431
  const AC = typeof AudioContext !== 'undefined' ? AudioContext
@@ -562,18 +440,18 @@ window.__ModuleLoader__.load({
562
440
  gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.09)
563
441
  osc.connect(gain); gain.connect(ac.destination)
564
442
  osc.start(); osc.stop(ac.currentTime + 0.1)
565
- osc.onended = () => { try { ac.close() } catch (e) { /* уже закрыт */ } }
566
- } catch (noAudio) { /* без звука — не критично */ }
443
+ osc.onended = () => { try { ac.close() } catch (e) { /* already closed */ } }
444
+ } catch (noAudio) { /* no audio — not critical */ }
567
445
  }
568
446
 
569
- // ------------------------------------------------- распознавание в браузере
447
+ // ------------------------------------------------- browser recognition
570
448
  //
571
- // Отдельная нога, не похожая на все остальные: речь распознаёт сам браузер,
572
- // на хост ничего не уходит, ключи не нужны, а текст появляется по словам
573
- // прямо во время речи.
449
+ // A separate leg unlike the others: the browser recognizes speech itself,
450
+ // nothing is uploaded to the host, no keys are needed, and text appears
451
+ // word by word while you speak.
574
452
  //
575
- // Плата за это: в Chrome звук уходит на серверы Google. Поэтому провайдер
576
- // никогда не включается сам — только если его прямо поставили в цепочку.
453
+ // Cost: in Chrome audio goes to Google servers. This provider is never
454
+ // enabled automatically — only when placed explicitly in a chain.
577
455
  function speechRecognitionCtor() {
578
456
  if (typeof window === 'undefined') return null
579
457
  return window.SpeechRecognition || window.webkitSpeechRecognition || null
@@ -607,29 +485,29 @@ window.__ModuleLoader__.load({
607
485
  options.onInterim(interim)
608
486
  }
609
487
  recognition.onerror = (event) => {
610
- // no-speech и aborted — обычная жизнь, а не поломка.
488
+ // no-speech and aborted are normal life, not failures.
611
489
  const code = event && event.error
612
490
  if (code === 'no-speech' || code === 'aborted') return
613
491
  options.onError(code || t('recognitionError'))
614
492
  }
615
- // Браузер обрывает распознавание сам: на паузах, по таймауту. Пока нас не
616
- // остановили — поднимаем заново, иначе диктовка молча умрёт на первой паузе.
493
+ // The browser ends recognition on its own (pauses, timeout). Restart
494
+ // until we stop it, otherwise dictation dies silently on the first pause.
617
495
  recognition.onend = () => {
618
496
  if (stopped) return
619
- try { recognition.start() } catch (alreadyRunning) { /* уже поднято */ }
497
+ try { recognition.start() } catch (alreadyRunning) { /* already running */ }
620
498
  }
621
499
 
622
500
  try { recognition.start() } catch (cannotStart) {
623
501
  options.onError(String(cannotStart && cannotStart.message || cannotStart))
624
502
  }
625
503
  return {
626
- stop() { stopped = true; try { recognition.stop() } catch (already) { /* уже стоит */ } },
627
- abort() { stopped = true; try { recognition.abort() } catch (already) { /* уже стоит */ } },
504
+ stop() { stopped = true; try { recognition.stop() } catch (already) { /* already stopped */ } },
505
+ abort() { stopped = true; try { recognition.abort() } catch (already) { /* already stopped */ } },
628
506
  }
629
507
  }
630
508
 
631
- // Какие провайдеры стоят в цепочке режима — узнаём у хоста один раз.
632
- // Нужно только чтобы понять, идти в браузер или писать файл.
509
+ // Fetch the mode chain from the host once. Only needed to decide whether
510
+ // to use browser recognition or record a file.
633
511
  let chainsPromise = null
634
512
  function modeChain(mode) {
635
513
  if (!chainsPromise) {
@@ -650,8 +528,8 @@ window.__ModuleLoader__.load({
650
528
  // ------------------------------------------------------------ recording
651
529
  function teardown(rec) {
652
530
  if (!rec) return
653
- try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* уже остановлен */ }
654
- if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* уже закрыт */ } }
531
+ try { rec.stream.getTracks().forEach((t) => t.stop()) } catch (e) { /* already stopped */ }
532
+ if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
655
533
  }
656
534
 
657
535
  function waitStop(recorder) {
@@ -693,7 +571,7 @@ window.__ModuleLoader__.load({
693
571
  rec.analyser.fftSize = 128
694
572
  src.connect(rec.analyser)
695
573
  }
696
- // Без timeslice: только тогда каждый stop() даёт самостоятельный webm-файл.
574
+ // No timeslice: only then each stop() yields a standalone webm file.
697
575
  recorder.start()
698
576
  return rec
699
577
  }
@@ -707,14 +585,14 @@ window.__ModuleLoader__.load({
707
585
  return Math.min(1, (sum / data.length / 255) * 2.2)
708
586
  }
709
587
 
710
- // Режет текущую фразу: останавливает рекордер, отправляет готовый файл и
711
- // немедленно начинает новую запись тем же рекордером.
588
+ // Cut the current phrase: stop the recorder, send the finished file and
589
+ // immediately start a new recording with the same recorder.
712
590
  function cutPhrase() {
713
591
  const rec = voice.rec
714
592
  if (!rec || rec.cutting || rec.closing) return
715
593
  rec.cutting = true
716
594
  const stopped = waitStop(rec.recorder)
717
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
595
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
718
596
  stopped.then(async () => {
719
597
  const blob = new Blob(rec.chunks, { type: rec.mime })
720
598
  rec.chunks = []
@@ -722,17 +600,17 @@ window.__ModuleLoader__.load({
722
600
  rec.hadSpeech = false
723
601
  rec.streamMs = 0
724
602
  if (!rec.closing) {
725
- try { rec.recorder.start() } catch (e) { /* поток закрылся */ }
603
+ try { rec.recorder.start() } catch (e) { /* stream already closed */ }
726
604
  }
727
605
  rec.cutting = false
728
- if (blob.size < 1200) return // слишком короткий кусок — это не речь
606
+ if (blob.size < 1200) return // too short — not speech
729
607
  try {
730
608
  const out = await sendAudio(blob, rec.mime, 'dictation')
731
609
  const text = out && out.text ? out.text : ''
732
610
  const delay = Number(voice.settings.sendDelayMs) || 0
733
611
  if (text && delay > 0 && !voice.holding) {
734
- // Отложенная вставка с окном отмены (#29-5): текст уже вставлен,
735
- // окно позволяет его отменить.
612
+ // Delayed insert with a cancel window (#29-5): text is already
613
+ // inserted; the window only allows undoing it.
736
614
  appendDraft(text)
737
615
  voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
738
616
  return
@@ -744,8 +622,8 @@ window.__ModuleLoader__.load({
744
622
  })
745
623
  }
746
624
 
747
- // Распознавание браузером вместо записи файла. Возвращает false, если
748
- // браузер этого не умеет, — тогда идём обычным путём.
625
+ // Browser recognition instead of file recording. Returns false when the
626
+ // browser cannot do it — then fall back to the normal path.
749
627
  function startBrowserLeg(mode, language) {
750
628
  if (!browserRecognitionAvailable()) return false
751
629
  const finals = []
@@ -755,8 +633,8 @@ window.__ModuleLoader__.load({
755
633
  continuous: true,
756
634
  onInterim: (text) => {
757
635
  voice.caption = text; voice.notify()
758
- // Wake-word (#45): если interim начинается с ключевой фразы —
759
- // обрываем прослушивание browser и уходим в обычную запись.
636
+ // Wake-word (#45): if interim starts with the trigger phrase, stop
637
+ // browser listening and switch to normal recording.
760
638
  const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
761
639
  if (ww && mode === 'dictation' && !voice.rec) {
762
640
  const t = String(text || '').trim().toLowerCase()
@@ -779,13 +657,12 @@ window.__ModuleLoader__.load({
779
657
  onFinal: (text) => {
780
658
  finals.push(text)
781
659
  voice.caption = ''
782
- // Диктовка дописывает сразу, голосовое копит до отпускания.
660
+ // Dictation appends immediately; messages accumulate until release.
783
661
  if (mode === 'dictation') appendDraft(text)
784
662
  else voice.notify()
785
663
  },
786
664
  onError: (reason) => {
787
- // Браузер отказал уже после старта — честно говорим об этом, а не
788
- // делаем вид, что слушаем.
665
+ // Browser failed after start — say so instead of pretending to listen.
789
666
  voice.browser = null
790
667
  voice.set({ phase: 'error', error: t('browserFailed') + reason })
791
668
  },
@@ -797,12 +674,12 @@ window.__ModuleLoader__.load({
797
674
 
798
675
  function startRecording(mode) {
799
676
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
800
- // Кричим до открытия микрофона, а не после: чем раньше замолчит
801
- // озвучка, тем меньше её попадёт в запись.
677
+ // Announce before opening the mic: the sooner playback mutes, the less
678
+ // of it ends up in the recording.
802
679
  announceVoice('start')
803
680
  voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '' })
804
681
  modeChain(mode).then((info) => {
805
- // Браузерная нога — только если её прямо поставили первой в цепочке.
682
+ // Browser leg only when it is explicitly first in the chain.
806
683
  if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
807
684
  openMic(mode)
808
685
  .then((rec) => {
@@ -820,12 +697,12 @@ window.__ModuleLoader__.load({
820
697
  function startDictation() { startRecording('dictation') }
821
698
  function startMessage() { startRecording('message') }
822
699
 
823
- // --------------------------------------------------------- удержание
700
+ // --------------------------------------------------------- hold gesture
824
701
  //
825
- // Два жеста одной кнопкой: короткое нажатие включает запись до второго
826
- // нажатия (как было), удержание пишет ровно пока держишь. Отпустил — ушло.
702
+ // Two gestures on one button: a short click toggles recording until the
703
+ // next click; hold records only while pressed. Release sends.
827
704
  //
828
- // Различаем по времени: если отпустили раньше порога — это клик.
705
+ // Distinguished by time: release before the threshold is a click.
829
706
  const HOLD_THRESHOLD_MS = 350
830
707
 
831
708
  const hold = { active: false, mode: null, startedAt: 0, armed: false }
@@ -836,7 +713,7 @@ window.__ModuleLoader__.load({
836
713
  hold.mode = mode
837
714
  hold.startedAt = Date.now()
838
715
  hold.active = false
839
- // Запись начинаем сразу: ждать порога — значит потерять первое слово.
716
+ // Start recording immediately: waiting for the threshold loses the first word.
840
717
  startRecording(mode)
841
718
  voice.holding = true
842
719
  voice.notify()
@@ -848,15 +725,15 @@ window.__ModuleLoader__.load({
848
725
  hold.armed = false
849
726
  hold.active = false
850
727
  voice.holding = false
851
- // Короткое нажатие — это клик: запись уже идёт, оставляем её включённой,
852
- // остановит второе нажатие.
728
+ // Short press is a click: recording is already on, leave it running;
729
+ // the second click will stop it.
853
730
  if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
854
731
  if (cancelled) cancelCurrent()
855
732
  else stopCurrent()
856
733
  }
857
734
 
858
- // Горячая клавиша: держать её удобнее, чем целиться мышью. Пока клавиша
859
- // зажата — идёт запись, Esc отменяет.
735
+ // Hotkey: holding a key is easier than aiming the mouse. While the key is
736
+ // down we record; Escape cancels.
860
737
  function hotkeyMatches(event, name) {
861
738
  if (name === 'Control') return event.key === 'Control'
862
739
  if (name === 'Alt') return event.key === 'Alt'
@@ -868,8 +745,8 @@ window.__ModuleLoader__.load({
868
745
  if (typeof document === 'undefined' || !keyName) return () => {}
869
746
  const down = (event) => {
870
747
  if (event.repeat) return
871
- // В поле ввода горячая клавиша-модификатор не мешает: она сама по себе
872
- // ничего не печатает. А вот обычную букву перехватывать нельзя.
748
+ // A modifier hotkey does not fight the text field: modifiers do not
749
+ // type. A plain letter key must not be hijacked while typing.
873
750
  if (hotkeyMatches(event, keyName)) beginHold(mode)
874
751
  }
875
752
  const up = (event) => {
@@ -901,12 +778,12 @@ window.__ModuleLoader__.load({
901
778
  if (!rec) { voice.set({ phase: 'idle', error: '' }); return }
902
779
  rec.closing = true
903
780
  const stopped = waitStop(rec.recorder)
904
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
781
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
905
782
  stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
906
783
  }
907
784
 
908
- // Останов по второму нажатию: диктовка досылает хвост, голосовое —
909
- // отправляет всю запись и открывает окно отмены.
785
+ // Stop on the second click: dictation flushes the tail; a voice message
786
+ // sends the whole recording and opens the cancel window.
910
787
  function stopCurrent() {
911
788
  announceVoice('end')
912
789
  if (voice.browser) {
@@ -921,7 +798,7 @@ window.__ModuleLoader__.load({
921
798
  appendDraft(said)
922
799
  voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
923
800
  } else {
924
- // Диктовка дописывала по ходу — добавлять нечего.
801
+ // Dictation already appended while speaking — nothing extra to add.
925
802
  voice.set({ phase: 'idle' })
926
803
  }
927
804
  return
@@ -931,17 +808,17 @@ window.__ModuleLoader__.load({
931
808
  rec.closing = true
932
809
  const mode = rec.mode
933
810
  const stopped = waitStop(rec.recorder)
934
- try { rec.recorder.stop() } catch (e) { /* уже остановлен */ }
811
+ try { rec.recorder.stop() } catch (e) { /* already stopped */ }
935
812
  stopped.then(async () => {
936
813
  const blob = new Blob(rec.chunks, { type: rec.mime })
937
814
  teardown(rec)
938
815
  voice.rec = null
939
816
  if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
940
817
  if (voice.lastNote && voice.lastNote.url) {
941
- try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* игнор */ }
818
+ try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* ignore */ }
942
819
  }
943
820
  let noteUrl = ''
944
- try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* игнор */ }
821
+ try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* ignore */ }
945
822
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
946
823
  voice.set({ phase: 'processing' })
947
824
  try {
@@ -967,7 +844,7 @@ window.__ModuleLoader__.load({
967
844
  voice.set({ phase: 'idle' })
968
845
  const actions = voice.inputActions
969
846
  if (!actions || typeof actions.submit !== 'function') return
970
- // Полировка всего draft перед отправкой (#46). Ошибка не блокирует.
847
+ // Polish the whole draft before submit (#46). Errors do not block.
971
848
  if (voice.settings.polishSend) {
972
849
  const run = async () => {
973
850
  try {
@@ -983,15 +860,15 @@ window.__ModuleLoader__.load({
983
860
  actions.setDraft(parsed.text.trim())
984
861
  }
985
862
  }
986
- } catch (e) { /* полировка best-effort */ }
863
+ } catch (e) { /* polish is best-effort */ }
987
864
  }
988
- run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* занят */ } }, 0))
865
+ run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
989
866
  return
990
867
  }
991
- setTimeout(() => { try { actions.submit() } catch (e) { /* композер занят */ } }, 0)
868
+ setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
992
869
  }
993
870
 
994
- // Голосовые команды сессии (#48): чистые «отправь/отмени/стоп/продолжи».
871
+ // Session voice commands (#48): clean "send/cancel/stop/continue".
995
872
  function runSessionCommand(cmd) {
996
873
  const actions = voice.inputActions
997
874
  voice.set({ phase: 'idle', pending: null })
@@ -1002,7 +879,7 @@ window.__ModuleLoader__.load({
1002
879
  break
1003
880
  case 'cancel':
1004
881
  case 'stop':
1005
- // Сбрасываем ожидание/запись; текст draft намеренно не трогаем.
882
+ // Clear wait/recording; leave the draft text intentionally untouched.
1006
883
  break
1007
884
  default:
1008
885
  break
@@ -1029,7 +906,7 @@ window.__ModuleLoader__.load({
1029
906
  React.createElement('button', {
1030
907
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1031
908
  title: err ? v.error : t('messageBtn'),
1032
- // Удержание: пишет, пока держишь; увёл курсор далеко вверх — отмена.
909
+ // Hold: record while pressed; leaving the control cancels.
1033
910
  onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
1034
911
  onPointerUp: () => endHold(false),
1035
912
  onPointerLeave: () => { if (hold.armed) endHold(true) },
@@ -1045,7 +922,26 @@ window.__ModuleLoader__.load({
1045
922
  }
1046
923
 
1047
924
  // ----------------------------------------------------------- visualizers
1048
- // 1. Liquid Wave: органическая текучая многослойная волна
925
+ function accentColor(fallback) {
926
+ try {
927
+ const root = document.documentElement
928
+ const s = getComputedStyle(root)
929
+ const pick = s.getPropertyValue('--dsw-alias-state-info-primary').trim()
930
+ || s.getPropertyValue('--dsw-alias-label-primary').trim()
931
+ if (pick) return pick
932
+ } catch (noTheme) { /* canvas-only fallback */ }
933
+ return fallback || voice.waveColor || 'currentColor'
934
+ }
935
+ function softColor(fallback) {
936
+ try {
937
+ const s = getComputedStyle(document.documentElement)
938
+ const pick = s.getPropertyValue('--dsw-alias-bg-layer-3').trim()
939
+ || s.getPropertyValue('--dsw-alias-label-primary').trim()
940
+ if (pick) return pick
941
+ } catch (noTheme) { /* fallback */ }
942
+ return fallback || voice.waveColor || 'currentColor'
943
+ }
944
+ // 1. Liquid Wave: organic multi-layer wave
1049
945
  function drawLiquidWave(g, w, h, levels, color, time) {
1050
946
  const midY = h / 2
1051
947
  const curLevel = levels.length ? levels[levels.length - 1] : 0
@@ -1064,7 +960,7 @@ window.__ModuleLoader__.load({
1064
960
 
1065
961
  const grad = g.createLinearGradient(0, 0, w, 0)
1066
962
  grad.addColorStop(0, color)
1067
- grad.addColorStop(0.5, '#38bdf8')
963
+ grad.addColorStop(0.5, accentColor(color))
1068
964
  grad.addColorStop(1, color)
1069
965
  g.strokeStyle = grad
1070
966
  g.lineWidth = layerIdx === 2 ? 2.5 : 1.5
@@ -1083,7 +979,7 @@ window.__ModuleLoader__.load({
1083
979
  g.globalAlpha = 1
1084
980
  }
1085
981
 
1086
- // 2. Dynamic Orb: интерактивная пульсирующая сфера по центру
982
+ // 2. Dynamic Orb: interactive pulsing sphere in the center
1087
983
  function drawDynamicOrb(g, w, h, levels, color, time) {
1088
984
  const cx = w / 2
1089
985
  const cy = h / 2
@@ -1103,7 +999,7 @@ window.__ModuleLoader__.load({
1103
999
  const rRing = 14 + smoothLevel * 14 + Math.sin(time * 0.08) * 3
1104
1000
  g.beginPath()
1105
1001
  g.arc(cx, cy, rRing, 0, Math.PI * 2)
1106
- g.strokeStyle = '#38bdf8'
1002
+ g.strokeStyle = accentColor(color)
1107
1003
  g.globalAlpha = 0.35 + smoothLevel * 0.4
1108
1004
  g.lineWidth = 1.5
1109
1005
  g.stroke()
@@ -1119,8 +1015,8 @@ window.__ModuleLoader__.load({
1119
1015
 
1120
1016
  const rCore = 6 + smoothLevel * 8 + Math.sin(time * 0.12) * 1.5
1121
1017
  const radial = g.createRadialGradient(cx, cy, 1, cx, cy, rCore + 4)
1122
- radial.addColorStop(0, '#ffffff')
1123
- radial.addColorStop(0.4, '#38bdf8')
1018
+ radial.addColorStop(0, softColor(color))
1019
+ radial.addColorStop(0.4, accentColor(color))
1124
1020
  radial.addColorStop(1, color)
1125
1021
  g.beginPath()
1126
1022
  g.arc(cx, cy, rCore, 0, Math.PI * 2)
@@ -1131,7 +1027,7 @@ window.__ModuleLoader__.load({
1131
1027
  g.globalAlpha = 1
1132
1028
  }
1133
1029
 
1134
- // 3. Classic Bars: классические вертикальные столбики
1030
+ // 3. Classic Bars: classic vertical bars
1135
1031
  function drawClassicBars(g, w, h, levels, color) {
1136
1032
  const midY = h / 2
1137
1033
  for (let i = 0; i < levels.length && i * 7 < w; i++) {
@@ -1155,7 +1051,7 @@ window.__ModuleLoader__.load({
1155
1051
  voice.input = props.input
1156
1052
  const ctx = props.ctx
1157
1053
 
1158
- // VAD: копим тишину и режем фразу, когда пауза превысила порог.
1054
+ // VAD: accumulate silence and cut the phrase when the pause exceeds the threshold.
1159
1055
  React.useEffect(() => {
1160
1056
  if (v.phase !== 'recording') return
1161
1057
  const tick = 50
@@ -1172,16 +1068,16 @@ window.__ModuleLoader__.load({
1172
1068
  const adapt = Number(voice.settings.vadAdapt) || 0
1173
1069
  let effectiveVad = Number(voice.settings.vadSilenceMs) || 700
1174
1070
  if (adapt > 0 && rec.hadSpeech) {
1175
- // Адаптивный порог (#41): считаем плотность речи за последние
1176
- // ~1s (20 сэмплов). Плотная речь -> порог ниже (точнее режем),
1177
- // паузная -> порог растёт к базе (не режем на вдохе).
1071
+ // Adaptive threshold (#41): speech density over ~1s (20 samples).
1072
+ // Dense speech -> lower threshold (cut more precisely);
1073
+ // pause-heavy -> threshold rises toward base (do not cut on breaths).
1178
1074
  const win = voice.levels.slice(-20)
1179
1075
  const density = win.length ? win.filter((v) => v > 0.06).length / win.length : 0
1180
1076
  const k = adapt * (density - 0.5) * 2
1181
1077
  effectiveVad = Math.max(150, Math.round(Number(voice.settings.vadSilenceMs) * (1 - k)))
1182
1078
  }
1183
- // Непрерывный режим (#40): режем по таймеру непрерывной речи,
1184
- // не дожидаясь длинной паузы.
1079
+ // Continuous mode (#40): cut on a timer while speech continues,
1080
+ // without waiting for a long pause.
1185
1081
  const stream = !!voice.settings.stream && rec.mode === 'dictation'
1186
1082
  if (stream && rec.hadSpeech && !rec.cutting) {
1187
1083
  rec.streamMs += tick
@@ -1198,7 +1094,7 @@ window.__ModuleLoader__.load({
1198
1094
  return () => dispose()
1199
1095
  }, [v.phase])
1200
1096
 
1201
- // Анимация осциллограммы / визуализатора.
1097
+ // Waveform / visualizer animation.
1202
1098
  React.useEffect(() => {
1203
1099
  if (v.phase !== 'recording') return
1204
1100
  let frame = 0
@@ -1239,7 +1135,7 @@ window.__ModuleLoader__.load({
1239
1135
  return () => dispose()
1240
1136
  }, [v.phase])
1241
1137
 
1242
- // Окно отмены режима message.
1138
+ // Message-mode cancel window.
1243
1139
  React.useEffect(() => {
1244
1140
  if (v.phase !== 'pending') return
1245
1141
  const tick = 100
@@ -1247,8 +1143,8 @@ window.__ModuleLoader__.load({
1247
1143
  const p = voice.pending
1248
1144
  if (!p) return
1249
1145
  if (p.undoOnly) {
1250
- // Режим «только отмена вставки» (#29-5): по истечении окна просто
1251
- // прячем панель; текст либо остался, либо уже откатили руками.
1146
+ // Undo-only mode (#29-5): when the window expires just hide the
1147
+ // panel; the text either stayed or was already undone.
1252
1148
  p.leftMs -= tick
1253
1149
  if (p.leftMs <= 0) voice.set({ phase: 'idle' })
1254
1150
  else voice.notify()
@@ -1299,8 +1195,8 @@ window.__ModuleLoader__.load({
1299
1195
  const inBrowser = !!voice.browser
1300
1196
  const rec = voice.rec
1301
1197
  const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
1302
- // Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
1303
- // окончательный кусок, текст черновой и меняется на глазах.
1198
+ // Live caption of what is heard right now. Until the browser emits a
1199
+ // final chunk the text is interim and changes on screen.
1304
1200
  const caption = voice.caption || (inBrowser ? '' : null)
1305
1201
  return React.createElement('div', { className: 'dvo-pill' },
1306
1202
  React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
@@ -1327,7 +1223,7 @@ window.__ModuleLoader__.load({
1327
1223
  if (v.phase === 'pending') {
1328
1224
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
1329
1225
  if (voice.pending && voice.pending.undoOnly) {
1330
- // Окно отмены отложенной вставки диктовки (#29-5).
1226
+ // Cancel window for delayed dictation insert (#29-5).
1331
1227
  return React.createElement('div', { className: 'dvo-pill' },
1332
1228
  React.createElement('span', { className: 'dvo-status' }, t('undo'), ': ', left, t('secondsShort')),
1333
1229
  React.createElement('button', {
@@ -1375,8 +1271,8 @@ window.__ModuleLoader__.load({
1375
1271
 
1376
1272
  // --------------------------------------------------------------- slots
1377
1273
  function registerComposer(ctx) {
1378
- // Горячая клавиша живёт всё время, пока плагин применён, и меняется без
1379
- // перезапуска: карточка настроек кричит в окно, композер перечитывает.
1274
+ // The hotkey lives for as long as the plugin is applied and can change
1275
+ // without restart: the settings card broadcasts and the composer reloads.
1380
1276
  ctx.effect(() => {
1381
1277
  let dispose = () => {}
1382
1278
  let alive = true
@@ -1390,7 +1286,7 @@ window.__ModuleLoader__.load({
1390
1286
  dispose = () => {}
1391
1287
  const key = data && data.hotkey
1392
1288
  if (key) dispose = installHotkey(ctx, key, 'message')
1393
- // Новые настройки композеру нужны без открытия карточки.
1289
+ // The composer needs fresh settings without opening the card.
1394
1290
  Object.assign(voice.settings, {
1395
1291
  beep: !!(data && data.beep),
1396
1292
  micDeviceId: String((data && data.micDeviceId) || ''),
@@ -1409,7 +1305,7 @@ window.__ModuleLoader__.load({
1409
1305
  visualizerStyle: (data && data.visualizerStyle) || 'liquid-wave',
1410
1306
  })
1411
1307
  })
1412
- .catch(() => { /* без подсказки хоста клавиши просто не будет */ })
1308
+ .catch(() => { /* no host hint — no hotkey */ })
1413
1309
  }
1414
1310
 
1415
1311
  reload()
@@ -1419,7 +1315,7 @@ window.__ModuleLoader__.load({
1419
1315
  window.removeEventListener('dsh-voice:settings-saved', reload)
1420
1316
  dispose()
1421
1317
  }
1422
- }, 'dsh-voice: горячая клавиша удержания')
1318
+ }, 'dsh-voice: hold hotkey')
1423
1319
 
1424
1320
  ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
1425
1321
  { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => t('title') },
@@ -1434,38 +1330,69 @@ window.__ModuleLoader__.load({
1434
1330
  // ------------------------------------------------------- settings page
1435
1331
  const BUILTIN = [
1436
1332
  'browser', 'deepgram', 'groq', 'hf', 'local-whisper', 'sensevoice',
1437
- // Заготовки: адрес и модель уже прописаны на хосте, нужен только ключ.
1333
+ // Presets: address and model are filled on the host; only a key is needed.
1438
1334
  'openai', 'siliconflow', 'deepinfra', 'fireworks', 'mistral', 'openrouter',
1439
1335
  ]
1440
1336
  const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
1441
- const MODEL_HINT = {
1442
- browser: t('browserHint'),
1443
- openai: t('openaiHint'),
1444
- siliconflow: t('siliconflowHint'),
1445
- deepinfra: t('deepinfraHint'),
1446
- fireworks: t('fireworksHint'),
1447
- mistral: t('mistralHint'),
1448
- openrouter: t('openrouterHint'),
1449
- deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
1450
- hf: 'openai/whisper-large-v3', 'local-whisper': t('localHint'),
1451
- sensevoice: t('sensevoiceHint'),
1337
+ // Resolve at render time: t() must not be captured before locale bind.
1338
+ const MODEL_HINT_KEYS = {
1339
+ browser: 'browserHint',
1340
+ openai: 'openaiHint',
1341
+ siliconflow: 'siliconflowHint',
1342
+ deepinfra: 'deepinfraHint',
1343
+ fireworks: 'fireworksHint',
1344
+ mistral: 'mistralHint',
1345
+ openrouter: 'openrouterHint',
1346
+ 'local-whisper': 'localHint',
1347
+ sensevoice: 'sensevoiceHint',
1348
+ }
1349
+ const MODEL_HINT_STATIC = {
1350
+ deepgram: 'nova-2',
1351
+ groq: 'whisper-large-v3-turbo',
1352
+ hf: 'openai/whisper-large-v3',
1353
+ }
1354
+ function modelHint(provider) {
1355
+ const key = MODEL_HINT_KEYS[provider]
1356
+ if (key) return t(key)
1357
+ return MODEL_HINT_STATIC[provider] || ''
1452
1358
  }
1453
1359
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
1454
1360
 
1455
1361
  const SET_CSS =
1456
- '.dvs-wrap{display:flex;flex-direction:column;gap:22px;padding:4px 0;max-width:720px}' +
1362
+ '.cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
1363
+ '.cb-section-card{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}' +
1364
+ '.cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
1365
+ '.cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
1366
+ '.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
1367
+ '.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
1368
+ '.cb-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
1369
+ '.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:rgba(16,185,129,0.08)}' +
1370
+ '.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}' +
1371
+ '.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}' +
1372
+ '.cb-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}' +
1373
+ '.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
1374
+ '.cb-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}' +
1375
+ '.cb-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))}' +
1376
+ '.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
1377
+ '.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
1378
+ '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}' +
1379
+ '.cb-btn-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;display:inline-flex;align-items:center;justify-content:center;padding:0;font-size:13px}' +
1380
+ '.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
1381
+ '.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
1382
+ '.cb-field select,.cb-field input[type="text"],.cb-field input[type="number"],.cb-field textarea{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}' +
1383
+ '.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
1384
+ '.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
1385
+ '.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
1386
+ '.cb-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
1387
+ '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
1457
1388
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
1458
1389
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
1459
1390
  '.dvs-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
1460
1391
  '.dvs-row{display:flex;gap:8px;align-items:center}' +
1461
- '.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
1392
+ '.dvs-row select,.dvs-row input{background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);border-radius:8px;padding:6px 10px;font-size:13px}' +
1462
1393
  '.dvs-row .dvs-model{flex:1}' +
1463
- '.dvs-field{display:flex;flex-direction:column;gap:6px;padding:12px 0;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
1464
- '.dvs-field input,.dvs-field select{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}' +
1465
- '.dvs-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}' +
1466
- '.dvs-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)}' +
1467
- '.dvs-card{display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px}' +
1468
- '.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
1394
+ '.dvs-card{display:flex;flex-direction:column;gap:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);border-radius:10px;padding:12px}' +
1395
+ '.dvs-card input,.dvs-card select{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:6px 8px;font-size:13px}' +
1469
1396
  '.dvs-wait{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5;max-width:520px}' +
1470
1397
  '.dvs-ok{font-size:12px;color:var(--dsw-alias-state-success-primary)}' +
1471
1398
  '.dvs-bad{font-size:12px;color:var(--dsw-alias-state-error-primary)}' +
@@ -1476,17 +1403,17 @@ window.__ModuleLoader__.load({
1476
1403
  '.dvo-pdesc{color:var(--dsw-alias-label-secondary);font-size:13px}' +
1477
1404
  '.dvo-pchev{flex:none;display:flex;color:var(--dsw-alias-label-secondary);transition:transform .15s ease}' +
1478
1405
  '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
1479
- '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}'
1406
+ '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
1480
1407
  const setCssId = 'dsh-voice/settings.module.css'
1481
- if (typeof document !== 'undefined' && !document.querySelector('style[data-plugin-css="' + setCssId + '"]')) {
1408
+ if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
1482
1409
  const tag = document.createElement('style')
1483
1410
  tag.textContent = SET_CSS
1484
- tag.setAttribute('data-plugin', 'dsh-voice')
1411
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
1485
1412
  tag.dataset.pluginCss = setCssId
1486
1413
  document.head.appendChild(tag)
1487
1414
  }
1488
1415
 
1489
- // Редактор одной цепочки: строки «провайдер + модель» с перестановкой.
1416
+ // One chain editor: provider+model rows with reordering.
1490
1417
  function ChainEditor(props) {
1491
1418
  const rows = Array.isArray(props.value) ? props.value : []
1492
1419
  const change = (i, patch) => {
@@ -1513,7 +1440,7 @@ window.__ModuleLoader__.load({
1513
1440
  .map((p) => React.createElement('option', { key: p, value: p }, p))),
1514
1441
  React.createElement('input', {
1515
1442
  className: 'dvs-model', value: row.model || '', disabled: !props.writable,
1516
- placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
1443
+ placeholder: modelHint(row.provider), onChange: (e) => change(i, { model: e.target.value }),
1517
1444
  }),
1518
1445
  React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
1519
1446
  React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
@@ -1526,7 +1453,7 @@ window.__ModuleLoader__.load({
1526
1453
  )
1527
1454
  }
1528
1455
 
1529
- // Свои провайдеры: имя, шаблон API, куда ходить и чем авторизоваться.
1456
+ // Custom providers: name, API template, where to call, how to authorize.
1530
1457
  function CustomEditor(props) {
1531
1458
  const rows = Array.isArray(props.value) ? props.value : []
1532
1459
  const change = (i, patch) => props.onChange(rows.map((r, k) => (k === i ? Object.assign({}, r, patch) : r)))
@@ -1575,27 +1502,61 @@ window.__ModuleLoader__.load({
1575
1502
  )
1576
1503
  }
1577
1504
 
1505
+ function createErrorBoundary() {
1506
+ if (!React || typeof React.Component !== 'function') {
1507
+ return function NoopBoundary(props) { return props?.children || null }
1508
+ }
1509
+ return class ErrorBoundary extends React.Component {
1510
+ constructor(props) {
1511
+ super(props)
1512
+ this.state = { hasError: false, error: null }
1513
+ }
1514
+ static getDerivedStateFromError(error) {
1515
+ return { hasError: true, error }
1516
+ }
1517
+ componentDidCatch(error, errorInfo) {
1518
+ console.error('[dsh-voice] React Error:', error, errorInfo)
1519
+ }
1520
+ render() {
1521
+ if (this.state.hasError) {
1522
+ return React.createElement(
1523
+ 'div',
1524
+ { className: 'cb-alert-err', style: { margin: '12px 0', padding: '14px', borderRadius: '8px' } },
1525
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ ' + t('uiError')),
1526
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String(this.state.error?.message || this.state.error)),
1527
+ React.createElement(
1528
+ 'button',
1529
+ {
1530
+ type: 'button',
1531
+ className: 'cb-btn',
1532
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
1533
+ onClick: () => this.setState({ hasError: false, error: null }),
1534
+ },
1535
+ t('retry'),
1536
+ ),
1537
+ )
1538
+ }
1539
+ return this.props?.children || null
1540
+ }
1541
+ }
1542
+ }
1543
+ const ErrorBoundary = createErrorBoundary()
1544
+
1578
1545
  function VoiceSection(props) {
1579
1546
  const t = (props && props.t) || moduleT
1580
1547
  const ctx = props.ctx
1581
- // На странице, открытой не с localhost, ядро отключает настройки целиком:
1582
- // общий вид документа не читается, каждый раздел получает "unavailable",
1583
- // запись выбрасывается. Сервер это ограничение не разделяет, и плагин
1584
- // dsh-lanmode поднимает ту же механику поверх тех же вызовов. Если он
1585
- // установлен — берём его службу, иначе ядровую: на loopback-странице она
1586
- // работает штатно.
1548
+ // On a non-localhost page the kernel disables settings entirely: the
1549
+ // shared document is not readable, every section gets "unavailable" and
1550
+ // writes are dropped. The server does not share that restriction, and
1551
+ // dsh-lanmode rebuilds the same mechanics on the same calls. Prefer its
1552
+ // service when installed, otherwise the kernel one (fine on loopback).
1587
1553
  const scope = ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).bind({ namespace: NS })
1588
1554
  const [snap, setSnap] = React.useState(null)
1589
1555
  const [draft, setDraft] = React.useState(null)
1590
1556
  const [saved, setSaved] = React.useState(false)
1591
1557
  const [err, setErr] = React.useState('')
1592
1558
 
1593
- // Выбор клавиши: не поле для ввода имени, а «нажмите ту, которую хотите».
1594
- // Имена вроде KeyR человек знать не обязан.
1595
- //
1596
- // Хуки объявлены здесь, выше всех возвратов: если объявить их ниже, при
1597
- // неготовых настройках карточка вернётся раньше, хуков окажется меньше,
1598
- // и React снимет весь раздел с ошибкой.
1559
+ // Hotkey picker: not a name field — press the key you want.
1599
1560
  const [catching, setCatching] = React.useState(false)
1600
1561
  React.useEffect(() => {
1601
1562
  if (!catching) return undefined
@@ -1627,39 +1588,23 @@ window.__ModuleLoader__.load({
1627
1588
  .catch(() => {})
1628
1589
  }, [])
1629
1590
 
1630
- // Снимок приходит со статусом, и он важнее самого значения.
1631
- // loading — ответа хоста ещё нет;
1632
- // unavailable — хост ответил, но наш namespace ему пока неизвестен:
1633
- // так бывает, если страницу открыли в первые секунды
1634
- // после старта, пока плагины ещё регистрируются;
1635
- // ready — значения на месте.
1636
- // При unavailable поле writable берётся из документа и остаётся true,
1637
- // поэтому без проверки статуса карточка рисует пустую, но с виду рабочую
1638
- // форму — именно так этот баг и выглядел.
1639
1591
  const ready = !!snap && snap.status === 'ready'
1640
1592
  const value = ready && snap.value ? snap.value : {}
1641
1593
  const writable = ready && snap.writable !== false
1642
1594
 
1643
- // Зеркало настроек в браузере перечитывается только на запись значения и
1644
- // на переподключение; появление namespace таким сигналом не считается.
1645
- // Значит само оно не починится — просим перечитать, пока не готово.
1646
- // Зеркало общее, так что это чинит и остальные разделы настроек.
1647
1595
  React.useEffect(() => {
1648
1596
  if (ready) return undefined
1649
1597
  let tries = 0
1650
1598
  const timer = setInterval(() => {
1651
1599
  if (tries >= 15) { clearInterval(timer); return }
1652
1600
  tries += 1
1653
- try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* сервис ещё не поднялся */ }
1601
+ try { ((ctx.get && ctx.get('lanSettings')) || ctx.settingsScope).describe().load() } catch (e) { /* service not up yet */ }
1654
1602
  }, 1000)
1655
1603
  return () => clearInterval(timer)
1656
1604
  }, [ready])
1657
1605
 
1658
- // Черновик сеем только из готового снимка. Раньше он заполнялся из
1659
- // первого пришедшего, и пустой замораживался навсегда: следующий снимок
1660
- // уже не пересевал его, и карточка оставалась пустой до перезагрузки.
1661
1606
  React.useEffect(() => { if (ready && draft === null) setDraft(JSON.parse(JSON.stringify(value))) }, [ready, draft, value])
1662
- // Клиент должен знать порог VAD и окно отмены — они живут в тех же настройках.
1607
+
1663
1608
  React.useEffect(() => {
1664
1609
  if (!ready) return
1665
1610
  voice.settings = Object.assign({}, voice.settings, {
@@ -1683,23 +1628,34 @@ window.__ModuleLoader__.load({
1683
1628
  })
1684
1629
  }, [ready, value, snap])
1685
1630
 
1686
- const [statsData, setStatsData] = React.useState({})
1687
- React.useEffect(() => {
1688
- let alive = true
1689
- fetch('/dsh-voice/status', { cache: 'no-store' })
1631
+ const [statusData, setStatusData] = React.useState(null)
1632
+ const [statusLatency, setStatusLatency] = React.useState(null)
1633
+ const fetchStatus = React.useCallback(() => {
1634
+ const start = performance.now()
1635
+ const ctrl = new AbortController()
1636
+ const tId = setTimeout(() => ctrl.abort(), 5000)
1637
+ fetch('/dsh-voice/status', { cache: 'no-store', signal: ctrl.signal })
1690
1638
  .then((r) => r.json())
1691
1639
  .then((data) => {
1692
- if (alive && data && data.providerStats) {
1693
- setStatsData(data.providerStats)
1640
+ clearTimeout(tId)
1641
+ setStatusLatency(Math.round(performance.now() - start))
1642
+ if (data && data.ok) {
1643
+ setStatusData(data)
1694
1644
  }
1695
1645
  })
1696
- .catch(() => {})
1697
- return () => { alive = false }
1646
+ .catch(() => {
1647
+ clearTimeout(tId)
1648
+ setStatusLatency(null)
1649
+ })
1698
1650
  }, [])
1699
1651
 
1652
+ React.useEffect(() => {
1653
+ fetchStatus()
1654
+ }, [fetchStatus])
1655
+
1700
1656
  if (!ready) {
1701
1657
  const waiting = !snap || snap.status === 'loading'
1702
- return React.createElement('div', { className: 'dvs-wrap' },
1658
+ return React.createElement('div', { className: 'cb-page' },
1703
1659
  React.createElement('div', { className: 'dvs-wait' }, waiting
1704
1660
  ? t('loadingSettings')
1705
1661
  : t('notReady1')
@@ -1719,11 +1675,6 @@ window.__ModuleLoader__.load({
1719
1675
  setErr(''); setSaved(false)
1720
1676
  if (!draft) return
1721
1677
 
1722
- // Поля пишутся по одному, и раньше первая же неудача обрывала цикл:
1723
- // остальные поля не записывались, композер не получал сигнала
1724
- // перечитать клавишу, а на кнопку это было похоже на «ничего не
1725
- // происходит». Теперь пишутся все, а неудачи собираются и называются
1726
- // поимённо — иначе непонятно, какое поле виновато.
1727
1678
  const failed = []
1728
1679
  for (const k of Object.keys(draft)) {
1729
1680
  try {
@@ -1748,12 +1699,11 @@ window.__ModuleLoader__.load({
1748
1699
  contextGlossary: draft.contextGlossary !== false,
1749
1700
  visualizerStyle: draft.visualizerStyle || 'liquid-wave',
1750
1701
  })
1751
- // Композер держит обработчик клавиши: пусть перечитает настройку,
1752
- // иначе новая клавиша заработает только после перезагрузки страницы.
1753
- try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
1702
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* nobody */ }
1754
1703
 
1755
1704
  if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
1756
1705
  setSaved(true); setTimeout(() => setSaved(false), 2000)
1706
+ fetchStatus()
1757
1707
  }
1758
1708
 
1759
1709
  const modeVal = (mode, key, fallback) => {
@@ -1761,174 +1711,270 @@ window.__ModuleLoader__.load({
1761
1711
  return m && m[key] !== undefined ? m[key] : fallback
1762
1712
  }
1763
1713
 
1764
- // Имена своих провайдеров берём из черновика, чтобы только что
1765
- // добавленный сразу появлялся в списках цепочек.
1766
1714
  const chainOptions = BUILTIN.concat(
1767
1715
  (draft && Array.isArray(draft.customProviders) ? draft.customProviders : [])
1768
1716
  .map((c) => String(c && c.key || '').trim())
1769
1717
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
1770
1718
  )
1771
1719
 
1772
- const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
1773
- t('hotkey'),
1774
- React.createElement('div', { className: 'dvs-row' },
1720
+ const hotkeyField = () => React.createElement('div', { className: 'cb-field' },
1721
+ React.createElement('label', null, t('hotkey')),
1722
+ React.createElement('div', { className: 'cb-row' },
1775
1723
  React.createElement('button', {
1776
- type: 'button', className: 'dvs-save', disabled: !writable,
1724
+ type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable,
1777
1725
  onClick: () => setCatching(true),
1778
1726
  }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
1779
1727
  React.createElement('button', {
1780
- type: 'button', className: 'dvs-mini', title: t('clearKey'),
1728
+ type: 'button', className: 'cb-btn cb-btn-mini', title: t('clearKey'),
1781
1729
  disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
1782
1730
  }, '×'),
1783
1731
  ),
1784
- React.createElement('span', { className: 'dvs-sub' },
1785
- t('hotkeyHint1')
1786
- + t('hotkeyHint2')))
1732
+ React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
1733
+ )
1787
1734
 
1788
- const micField = () => React.createElement('label', { className: 'dvs-field' }, t('mic'),
1789
- React.createElement('select', {
1790
- value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1791
- onChange: (e) => setTop('micDeviceId', e.target.value),
1792
- },
1793
- React.createElement('option', { value: '' }, t('micDefault')),
1794
- devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1795
- d.label || d.deviceId.slice(0, 12)))))
1796
- const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
1735
+ const micField = () => React.createElement('div', { className: 'cb-field' },
1736
+ React.createElement('label', null, t('mic')),
1737
+ React.createElement('select', {
1738
+ value: String((snap && snap.micDeviceId) || ''), disabled: !writable,
1739
+ onChange: (e) => setTop('micDeviceId', e.target.value),
1740
+ },
1741
+ React.createElement('option', { value: '' }, t('micDefault')),
1742
+ devices.map((d) => React.createElement('option', { key: d.deviceId, value: d.deviceId },
1743
+ d.label || d.deviceId.slice(0, 12))),
1744
+ ),
1745
+ )
1746
+
1747
+ const langField = (mode) => React.createElement('div', { className: 'cb-field' },
1748
+ React.createElement('label', null, t('language')),
1797
1749
  React.createElement('select', {
1798
1750
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
1799
1751
  onChange: (e) => setIn(mode, 'language', e.target.value),
1800
- }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))))
1752
+ }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))),
1753
+ )
1801
1754
 
1802
- const numField = (mode, key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
1755
+ const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
1756
+ React.createElement('label', null, label),
1803
1757
  React.createElement('input', {
1804
1758
  type: 'number', value: modeVal(mode, key, ''), disabled: !writable,
1805
1759
  onChange: (e) => setIn(mode, key, Number(e.target.value)),
1806
1760
  }),
1807
- React.createElement('span', { className: 'dvs-sub' }, hint))
1761
+ React.createElement('span', { className: 'dvs-sub' }, hint),
1762
+ )
1808
1763
 
1809
- const textField = (key, label, hint) => React.createElement('label', { className: 'dvs-field' }, label,
1764
+ const textField = (key, label, hint) => React.createElement('div', { className: 'cb-field' },
1765
+ React.createElement('label', null, label),
1810
1766
  React.createElement('input', {
1811
- value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1767
+ type: 'text', value: draft && draft[key] !== undefined ? draft[key] : '', disabled: !writable,
1812
1768
  onChange: (e) => setTop(key, e.target.value),
1813
1769
  }),
1814
- React.createElement('span', { className: 'dvs-sub' }, hint))
1770
+ React.createElement('span', { className: 'dvs-sub' }, hint),
1771
+ )
1815
1772
 
1816
- return React.createElement('div', { className: 'dvs-wrap' },
1817
- React.createElement('div', { className: 'dvs-block' },
1818
- React.createElement('div', { className: 'dvs-h' }, t('dictationBtn')),
1819
- React.createElement('div', { className: 'dvs-sub' }, t('dictationHint')),
1773
+ const statsData = (statusData && statusData.providerStats) || {}
1774
+ const hostOnline = statusLatency !== null
1775
+ const whisperRunning = !!(statusData && statusData.whisperRunning)
1776
+ const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
1777
+
1778
+ return React.createElement('div', { className: 'cb-page' },
1779
+ // 1. Connection & Engine Status Card
1780
+ React.createElement('div', { className: 'cb-section-card' },
1781
+ React.createElement('div', { className: 'cb-section-title' },
1782
+ React.createElement('span', null, t('statusTitle')),
1783
+ React.createElement('button', {
1784
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
1785
+ onClick: fetchStatus,
1786
+ }, '🔄 ' + t('refreshStats')),
1787
+ ),
1788
+ React.createElement('div', { className: 'cb-section-desc' }, t('statusDesc')),
1789
+ React.createElement('div', { className: 'cb-row' },
1790
+ hostOnline
1791
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeHostOnline').replace('{ms}', statusLatency))
1792
+ : React.createElement('span', { className: 'cb-badge cb-badge-bad' }, '● ' + t('badgeHostOffline')),
1793
+ whisperRunning
1794
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '● ' + t('badgeWhisperActive'))
1795
+ : React.createElement('span', { className: 'cb-badge cb-badge-warn' }, '○ ' + t('badgeWhisperInactive')),
1796
+ React.createElement('span', { className: 'cb-badge cb-badge-ok' }, '✓ ' + t('badgeProviders').replace('{count}', providerCount)),
1797
+ ),
1798
+ ),
1799
+
1800
+ // 2. Dictation Mode Card
1801
+ React.createElement('div', { className: 'cb-section-card' },
1802
+ React.createElement('div', { className: 'cb-section-title' },
1803
+ React.createElement('span', null, '🎙️ ' + t('dictationBtn')),
1804
+ ),
1805
+ React.createElement('div', { className: 'cb-section-desc' }, t('dictationHint')),
1820
1806
  React.createElement(ChainEditor, {
1821
1807
  value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
1822
1808
  options: chainOptions,
1823
1809
  onChange: (v) => setIn('dictation', 'chain', v),
1824
1810
  }),
1825
- langField('dictation'),
1826
- numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1827
- numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
1828
- React.createElement('label', { className: 'dvs-field' }, t('polish'),
1829
- React.createElement('input', {
1830
- type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
1831
- onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1832
- })),
1833
- React.createElement('div', { className: 'dvs-sub' }, t('polishHint')),
1834
- React.createElement('label', { className: 'dvs-field', title: t('wakeWordHint') }, t('wakeWord'),
1835
- React.createElement('input', {
1836
- type: 'text', value: String((snap && snap.wakeWord) || ''), disabled: !writable,
1837
- onChange: (e) => setTop('wakeWord', e.target.value),
1838
- })),
1839
- React.createElement('label', { className: 'dvs-field', title: t('streamHint') }, t('stream'),
1840
- React.createElement('input', {
1841
- type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
1842
- onChange: (e) => setIn('dictation', 'stream', e.target.checked),
1843
- })),
1844
- numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
1845
- React.createElement('label', { className: 'dvs-field', title: t('vadAdaptHint') }, t('vadAdapt'),
1811
+ React.createElement('div', { className: 'cb-grid-2' },
1812
+ langField('dictation'),
1813
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1814
+ numField('dictation', 'sendDelayMs', t('sendDelay'), t('sendDelayHint')),
1815
+ numField('dictation', 'streamChunkMs', t('streamChunkMs'), t('streamHint')),
1816
+ ),
1817
+ React.createElement('div', { className: 'cb-row' },
1818
+ React.createElement('label', { className: 'cb-check-label' },
1819
+ React.createElement('input', {
1820
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.polish), disabled: !writable,
1821
+ onChange: (e) => setIn('dictation', 'polish', e.target.checked),
1822
+ }),
1823
+ t('polish'),
1824
+ ),
1825
+ React.createElement('label', { className: 'cb-check-label' },
1826
+ React.createElement('input', {
1827
+ type: 'checkbox', checked: !!(draft && draft.dictation && draft.dictation.stream), disabled: !writable,
1828
+ onChange: (e) => setIn('dictation', 'stream', e.target.checked),
1829
+ }),
1830
+ t('stream'),
1831
+ ),
1832
+ ),
1833
+ React.createElement('div', { className: 'cb-field' },
1834
+ React.createElement('label', null, t('vadAdapt')),
1846
1835
  React.createElement('input', {
1847
1836
  type: 'range', min: 0, max: 1, step: 0.1,
1848
1837
  value: Number(draft && draft.dictation && draft.dictation.vadAdapt) || 0, disabled: !writable,
1849
1838
  onChange: (e) => setIn('dictation', 'vadAdapt', Number(e.target.value)),
1850
- })),
1839
+ }),
1840
+ React.createElement('span', { className: 'dvs-sub' }, t('vadAdaptHint')),
1841
+ ),
1842
+ textField('wakeWord', t('wakeWord'), t('wakeWordHint')),
1851
1843
  ),
1852
- React.createElement('div', { className: 'dvs-block' },
1853
- React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
1854
- React.createElement('div', { className: 'dvs-sub' }, t('messageHint')),
1844
+
1845
+ // 3. Voice Message Card
1846
+ React.createElement('div', { className: 'cb-section-card' },
1847
+ React.createElement('div', { className: 'cb-section-title' },
1848
+ React.createElement('span', null, '💬 ' + t('messageTitle')),
1849
+ ),
1850
+ React.createElement('div', { className: 'cb-section-desc' }, t('messageHint')),
1855
1851
  React.createElement(ChainEditor, {
1856
1852
  value: draft && draft.message ? draft.message.chain : [], writable: writable,
1857
1853
  options: chainOptions,
1858
1854
  onChange: (v) => setIn('message', 'chain', v),
1859
1855
  }),
1860
- langField('message'),
1861
- numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1862
- React.createElement('label', { className: 'dvs-field' }, t('polish'),
1863
- React.createElement('input', {
1864
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1865
- onChange: (e) => setIn('message', 'polish', e.target.checked),
1866
- })),
1867
- React.createElement('label', { className: 'dvs-field', title: t('polishSendHint') }, t('polishSend'),
1868
- React.createElement('input', {
1869
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
1870
- onChange: (e) => setIn('message', 'polishSend', e.target.checked),
1871
- })),
1872
- React.createElement('label', { className: 'dvs-field', title: t('sessionCommandsHint') }, t('sessionCommands'),
1873
- React.createElement('input', {
1874
- type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
1875
- onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
1876
- })),
1856
+ React.createElement('div', { className: 'cb-grid-2' },
1857
+ langField('message'),
1858
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1859
+ ),
1860
+ React.createElement('div', { className: 'cb-row' },
1861
+ React.createElement('label', { className: 'cb-check-label' },
1862
+ React.createElement('input', {
1863
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polish), disabled: !writable,
1864
+ onChange: (e) => setIn('message', 'polish', e.target.checked),
1865
+ }),
1866
+ t('polish'),
1867
+ ),
1868
+ React.createElement('label', { className: 'cb-check-label' },
1869
+ React.createElement('input', {
1870
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.polishSend), disabled: !writable,
1871
+ onChange: (e) => setIn('message', 'polishSend', e.target.checked),
1872
+ }),
1873
+ t('polishSend'),
1874
+ ),
1875
+ React.createElement('label', { className: 'cb-check-label' },
1876
+ React.createElement('input', {
1877
+ type: 'checkbox', checked: !!(draft && draft.message && draft.message.sessionCommands), disabled: !writable,
1878
+ onChange: (e) => setIn('message', 'sessionCommands', e.target.checked),
1879
+ }),
1880
+ t('sessionCommands'),
1881
+ ),
1882
+ ),
1877
1883
  ),
1878
- React.createElement('div', { className: 'dvs-block' },
1879
- React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
1880
- React.createElement('div', { className: 'dvs-sub' },
1881
- t('customHint')),
1884
+
1885
+ // 4. Custom Providers Card
1886
+ React.createElement('div', { className: 'cb-section-card' },
1887
+ React.createElement('div', { className: 'cb-section-title' },
1888
+ React.createElement('span', null, '⚙️ ' + t('customTitle')),
1889
+ ),
1890
+ React.createElement('div', { className: 'cb-section-desc' }, t('customHint')),
1882
1891
  React.createElement(CustomEditor, {
1883
1892
  value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
1893
+ options: chainOptions,
1884
1894
  onChange: (v) => setTop('customProviders', v),
1885
1895
  }),
1886
1896
  ),
1887
- React.createElement('div', { className: 'dvs-block' },
1888
- React.createElement('div', { className: 'dvs-h' }, t('general')),
1897
+
1898
+ // 5. Hardware & Local Engines Card
1899
+ React.createElement('div', { className: 'cb-section-card' },
1900
+ React.createElement('div', { className: 'cb-section-title' },
1901
+ React.createElement('span', null, '🔧 ' + t('hardwareTitle')),
1902
+ ),
1903
+ React.createElement('div', { className: 'cb-section-desc' }, t('hardwareDesc')),
1889
1904
  hotkeyField(),
1890
- textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1891
- textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1892
- textField('whisperModel', t('whisperModel'), t('whisperBinHint')),
1893
- textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
1894
- React.createElement('label', { className: 'dvs-field' }, t('whisperAutostart'),
1895
- React.createElement('input', {
1896
- type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
1897
- onChange: (e) => setTop('autoStart', e.target.checked),
1898
- })),
1899
- React.createElement('label', { className: 'dvs-field', title: t('normalizeTranscriptHint') },
1900
- t('normalizeTranscript'),
1901
- React.createElement('input', {
1902
- type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1903
- onChange: (e) => setTop('normalizeTranscript', e.target.checked),
1904
- })),
1905
- React.createElement('label', { className: 'dvs-field' }, t('beep'),
1906
- React.createElement('input', {
1907
- type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
1908
- onChange: (e) => setTop('beep', e.target.checked),
1909
- })),
1910
- React.createElement('label', { className: 'dvs-field', title: t('localOnlyHint') }, t('localOnly'),
1911
- React.createElement('input', {
1912
- type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
1913
- onChange: (e) => setTop('localOnly', e.target.checked),
1914
- })),
1915
- React.createElement('label', { className: 'dvs-field' }, t('voiceCommandsLabel'),
1916
- React.createElement('input', {
1917
- type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
1918
- onChange: (e) => setTop('voiceCommands', e.target.checked),
1919
- })),
1920
1905
  micField(),
1921
- React.createElement('label', { className: 'dvs-field', title: t('noiseSuppressionHint') }, t('noiseSuppression'),
1922
- React.createElement('input', {
1923
- type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
1924
- onChange: (e) => setTop('noiseSuppression', e.target.checked),
1925
- })),
1926
- React.createElement('label', { className: 'dvs-field', title: t('contextGlossaryHint') }, t('contextGlossary'),
1927
- React.createElement('input', {
1928
- type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
1929
- onChange: (e) => setTop('contextGlossary', e.target.checked),
1930
- })),
1931
- React.createElement('label', { className: 'dvs-field', title: t('visualizerStyleHint') }, t('visualizerStyle'),
1906
+ React.createElement('div', { className: 'cb-grid-2' },
1907
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1908
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1909
+ textField('whisperModel', t('whisperModel'), t('whisperModelHint')),
1910
+ textField('deepgramBaseUrl', t('deepgramEndpoint'), t('deepgramEndpointHint')),
1911
+ textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1912
+ textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1913
+ textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceModelHint')),
1914
+ textField('polishBaseUrl', t('polishBaseUrl'), t('polishBaseUrlHint')),
1915
+ textField('polishModel', t('polishModel'), ''),
1916
+ textField('polishKeyEnv', t('polishKeyEnv'), ''),
1917
+ ),
1918
+ React.createElement('div', { className: 'cb-row' },
1919
+ React.createElement('label', { className: 'cb-check-label' },
1920
+ React.createElement('input', {
1921
+ type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
1922
+ onChange: (e) => setTop('autoStart', e.target.checked),
1923
+ }),
1924
+ t('whisperAutostart'),
1925
+ ),
1926
+ React.createElement('label', { className: 'cb-check-label' },
1927
+ React.createElement('input', {
1928
+ type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1929
+ onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1930
+ }),
1931
+ t('sensevoiceAutostart'),
1932
+ ),
1933
+ React.createElement('label', { className: 'cb-check-label' },
1934
+ React.createElement('input', {
1935
+ type: 'checkbox', checked: !!(draft && draft.localOnly), disabled: !writable,
1936
+ onChange: (e) => setTop('localOnly', e.target.checked),
1937
+ }),
1938
+ t('localOnly'),
1939
+ ),
1940
+ React.createElement('label', { className: 'cb-check-label' },
1941
+ React.createElement('input', {
1942
+ type: 'checkbox', checked: !!(draft && draft.beep), disabled: !writable,
1943
+ onChange: (e) => setTop('beep', e.target.checked),
1944
+ }),
1945
+ t('beep'),
1946
+ ),
1947
+ React.createElement('label', { className: 'cb-check-label' },
1948
+ React.createElement('input', {
1949
+ type: 'checkbox', checked: draft ? draft.noiseSuppression !== false : true, disabled: !writable,
1950
+ onChange: (e) => setTop('noiseSuppression', e.target.checked),
1951
+ }),
1952
+ t('noiseSuppression'),
1953
+ ),
1954
+ React.createElement('label', { className: 'cb-check-label' },
1955
+ React.createElement('input', {
1956
+ type: 'checkbox', checked: draft ? draft.contextGlossary !== false : true, disabled: !writable,
1957
+ onChange: (e) => setTop('contextGlossary', e.target.checked),
1958
+ }),
1959
+ t('contextGlossary'),
1960
+ ),
1961
+ React.createElement('label', { className: 'cb-check-label' },
1962
+ React.createElement('input', {
1963
+ type: 'checkbox', checked: !!(draft && draft.voiceCommands), disabled: !writable,
1964
+ onChange: (e) => setTop('voiceCommands', e.target.checked),
1965
+ }),
1966
+ t('voiceCommandsLabel'),
1967
+ ),
1968
+ React.createElement('label', { className: 'cb-check-label' },
1969
+ React.createElement('input', {
1970
+ type: 'checkbox', checked: !!(draft && draft.normalizeTranscript), disabled: !writable,
1971
+ onChange: (e) => setTop('normalizeTranscript', e.target.checked),
1972
+ }),
1973
+ t('normalizeTranscript'),
1974
+ ),
1975
+ ),
1976
+ React.createElement('div', { className: 'cb-field' },
1977
+ React.createElement('label', null, t('visualizerStyle')),
1932
1978
  React.createElement('select', {
1933
1979
  value: String((draft && draft.visualizerStyle) || 'liquid-wave'), disabled: !writable,
1934
1980
  onChange: (e) => setTop('visualizerStyle', e.target.value),
@@ -1937,43 +1983,27 @@ window.__ModuleLoader__.load({
1937
1983
  React.createElement('option', { value: 'dynamic-orb' }, t('visDynamicOrb')),
1938
1984
  React.createElement('option', { value: 'bars' }, t('visBars')),
1939
1985
  React.createElement('option', { value: 'off' }, t('visOff')),
1940
- )),
1941
- React.createElement('label', { className: 'dvs-field' }, t('vocabulary'),
1986
+ ),
1987
+ ),
1988
+ React.createElement('div', { className: 'cb-field' },
1989
+ React.createElement('label', null, t('vocabulary')),
1942
1990
  React.createElement('textarea', {
1943
1991
  rows: 3, disabled: !writable,
1944
1992
  value: Array.isArray(draft && draft.vocabulary) ? draft.vocabulary.join('\n') : '',
1945
1993
  onChange: (e) => setTop('vocabulary', e.target.value.split('\n').map((x) => x.trim()).filter(Boolean)),
1946
- })),
1947
- React.createElement('label', { className: 'dvs-field', title: t('polishBaseUrlHint') }, t('polishBaseUrl'),
1948
- React.createElement('input', {
1949
- type: 'text', value: String((snap && snap.polishBaseUrl) || ''), disabled: !writable,
1950
- onChange: (e) => setTop('polishBaseUrl', e.target.value),
1951
- })),
1952
- React.createElement('label', { className: 'dvs-field' }, t('polishModel'),
1953
- React.createElement('input', {
1954
- type: 'text', value: String((draft && draft.polishModel) || ''), disabled: !writable,
1955
- onChange: (e) => setTop('polishModel', e.target.value),
1956
- })),
1957
- React.createElement('label', { className: 'dvs-field' }, t('polishKeyEnv'),
1958
- React.createElement('input', {
1959
- type: 'text', value: String((draft && draft.polishKeyEnv) || ''), disabled: !writable,
1960
- onChange: (e) => setTop('polishKeyEnv', e.target.value),
1961
- })),
1962
- ),
1963
- React.createElement('div', { className: 'dvs-block' },
1964
- React.createElement('div', { className: 'dvs-h' }, 'SenseVoice-ONNX / Sherpa-ONNX'),
1965
- React.createElement('div', { className: 'dvs-sub' }, t('sensevoiceHint')),
1966
- textField('sensevoiceUrl', t('sensevoiceEndpoint'), t('sensevoiceEndpointHint')),
1967
- textField('sensevoiceBin', t('sensevoiceBin'), t('sensevoiceBinHint')),
1968
- textField('sensevoiceModel', t('sensevoiceModel'), t('sensevoiceBinHint')),
1969
- React.createElement('label', { className: 'dvs-field' }, t('sensevoiceAutostart'),
1970
- React.createElement('input', {
1971
- type: 'checkbox', checked: !!(draft && draft.sensevoiceAutostart), disabled: !writable,
1972
- onChange: (e) => setTop('sensevoiceAutostart', e.target.checked),
1973
- })),
1994
+ }),
1995
+ ),
1974
1996
  ),
1975
- React.createElement('div', { className: 'dvs-block' },
1976
- React.createElement('div', { className: 'dvs-h' }, t('providerDashboard')),
1997
+
1998
+ // 6. Provider Telemetry & Latency Dashboard
1999
+ React.createElement('div', { className: 'cb-section-card' },
2000
+ React.createElement('div', { className: 'cb-section-title' },
2001
+ React.createElement('span', null, '📊 ' + t('providerDashboard')),
2002
+ React.createElement('button', {
2003
+ type: 'button', className: 'cb-btn', style: { fontSize: '12px', padding: '4px 8px' },
2004
+ onClick: fetchStatus,
2005
+ }, '🔄 ' + t('refreshStats')),
2006
+ ),
1977
2007
  React.createElement('div', { className: 'dvo-dash' },
1978
2008
  React.createElement('div', { className: 'dvo-dash-grid' },
1979
2009
  Object.keys(statsData).length === 0
@@ -2010,53 +2040,51 @@ window.__ModuleLoader__.load({
2010
2040
  React.createElement('span', null, t('successRate')),
2011
2041
  ),
2012
2042
  )
2013
- })
2014
- )
2015
- )
2043
+ }),
2044
+ ),
2045
+ ),
2016
2046
  ),
2017
- React.createElement('div', { className: 'dvs-row' },
2018
- React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
2047
+
2048
+ // Action bar (Save)
2049
+ React.createElement('div', { className: 'cb-row' },
2050
+ React.createElement('button', { type: 'button', className: 'cb-btn cb-btn-primary', disabled: !writable, onClick: save }, t('save')),
2019
2051
  saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
2020
2052
  err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
2021
2053
  ),
2022
2054
  )
2023
2055
  }
2024
2056
 
2025
- // Карточка во вкладке «Настройки плагинов» (#18): ядро даёт только рамку
2026
- // списка, поэтому заголовок, пояснение и сворачивание — наши.
2027
- // Тело монтируется при первом раскрытии: снапшот настроек тогда и придёт.
2057
+ // Card in Settings → Plugins → Plugin settings (#18)
2028
2058
  function PluginCard(props) {
2029
2059
  const [open, setOpen] = React.useState(false)
2030
2060
  const tt = (props && props.t) || t
2031
2061
  const title = tt('title')
2032
- return React.createElement('li',
2033
- { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
2034
- React.createElement('button',
2035
- {
2036
- type: 'button', className: 'dvo-phead',
2037
- 'aria-expanded': open ? 'true' : 'false',
2038
- 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
2039
- onClick: () => setOpen((v) => !v),
2040
- },
2041
- React.createElement('span', { className: 'dvo-pheadtext' },
2042
- React.createElement('span', { className: 'dvo-ptitle' }, title),
2043
- React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
2062
+ return React.createElement(ErrorBoundary, null,
2063
+ React.createElement('li',
2064
+ { className: open ? 'dvo-pcard dvo-pcardOpen' : 'dvo-pcard' },
2065
+ React.createElement('button',
2066
+ {
2067
+ type: 'button', className: 'dvo-phead',
2068
+ 'aria-expanded': open ? 'true' : 'false',
2069
+ 'aria-label': (open ? tt('collapse') : tt('expand')) + ': ' + title,
2070
+ onClick: () => setOpen((v) => !v),
2071
+ },
2072
+ React.createElement('span', { className: 'dvo-pheadtext' },
2073
+ React.createElement('span', { className: 'dvo-ptitle' }, title),
2074
+ React.createElement('span', { className: 'dvo-pdesc' }, tt('cardHint')),
2075
+ ),
2076
+ React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
2044
2077
  ),
2045
- React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
2078
+ open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
2046
2079
  ),
2047
- open ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
2048
2080
  )
2049
2081
  }
2050
2082
 
2051
2083
  function registerSettings(ctx) {
2052
- // Карточка настроек во вкладке «Настройки → Плагины → Настройки плагинов» (#18).
2053
- // Ключ регистрации обязан равняться NS (пространству настроек), иначе
2054
- // вкладка молча пропустит слот.
2055
2084
  ctx.slots.inject('settings.plugin.item', () => ctx.slots.register(
2056
2085
  {
2057
2086
  name: 'settings.plugin.item',
2058
2087
  key: NS,
2059
- // locale в записи слота — то, из-за чего компонент получает props.t.
2060
2088
  locale: NS,
2061
2089
  inject: () => ({ ctx: ctx }),
2062
2090
  },
@@ -2066,14 +2094,9 @@ window.__ModuleLoader__.load({
2066
2094
 
2067
2095
  exports.inject = ['timer', 'slots', 'settingsScope', 'locale']
2068
2096
  exports.apply = function apply(ctx) {
2069
- // Язык может принести не только плагин: словарные пакеты объявляют
2070
- // русский для чужих пространств. Ядро на повторное объявление той же
2071
- // пары «пространство + язык» бросает исключение, и незащищённый вызов
2072
- // уносил с собой весь плагин — в интерфейсе это выглядело как «Failed to
2073
- // load plugins» с перечнем ни в чём не повинных соседей.
2074
- //
2075
- // Поэтому каждый язык объявляется отдельно и по-хорошему: заняли до нас —
2076
- // уступаем, свой английский при этом всё равно встаёт на место.
2097
+ // English is the source language. Other languages come from the
2098
+ // separate translation plugin at runtime. Re-registering the same
2099
+ // namespace+language throws, so tolerate a pre-registered dictionary.
2077
2100
  const addLocale = (locale, dictionary) => {
2078
2101
  try {
2079
2102
  return ctx.locale.register(NS, locale, dictionary)
@@ -2082,9 +2105,9 @@ window.__ModuleLoader__.load({
2082
2105
  }
2083
2106
  }
2084
2107
  ctx.effect(() => {
2085
- const undo = [addLocale('en', en), addLocale('ru', ru)]
2108
+ const undo = [addLocale('en', en)]
2086
2109
  return () => { for (const off of undo) off() }
2087
- }, 'dsh-voice: словари')
2110
+ }, 'dsh-voice: locale dictionaries')
2088
2111
  moduleT = ctx.locale.bind(NS)
2089
2112
  registerComposer(ctx)
2090
2113
  registerSettings(ctx)