@goodandready/dsh-voice 0.7.1 → 0.7.3

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.
Files changed (2) hide show
  1. package/lib/client.js +256 -78
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -19,6 +19,159 @@ window.__ModuleLoader__.load({
19
19
 
20
20
  const NS = 'dsh-voice'
21
21
 
22
+ // Строки интерфейса живут в реестре локалей: так их переводит отдельный
23
+ // пакет, не трогая код этого плагина. Английский — язык по умолчанию,
24
+ // на него же приходится откат, если перевода нет.
25
+ const en = {
26
+ 'saveFailed': 'Some fields were not saved —',
27
+ 'composerUnavailable': 'Composer unavailable',
28
+ 'keySpace': 'Space',
29
+ 'keyUnset': 'not set',
30
+ 'recognitionError': 'recognition failed',
31
+ 'micUnavailable': 'Microphone unavailable: needs HTTPS or localhost',
32
+ 'noRecorder': 'MediaRecorder is not supported by this browser',
33
+ 'browserFailed': 'Browser did not recognize: ',
34
+ 'nothingHeard': 'Nothing was recognized',
35
+ 'dictationBtn': 'Voice typing',
36
+ 'messageBtn': 'Voice message — click or hold',
37
+ 'dictationPill': 'Dictation — text is appended to the input',
38
+ 'messagePill': 'Recording a voice message',
39
+ 'cancel': 'Cancel',
40
+ 'holdHint': 'Hold — release to send',
41
+ 'listening': 'Listening in the browser…',
42
+ 'stop': 'Stop',
43
+ 'transcribing': 'Transcribing…',
44
+ 'sendingIn': 'Sending to the agent in',
45
+ 'secondsShort': ' s',
46
+ 'keepPending': 'Do not send',
47
+ 'hide': 'Hide',
48
+ 'title': 'Voice',
49
+ 'recordSlot': 'Voice recording',
50
+ 'browserHint': 'the browser does the recognition, no key needed',
51
+ 'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
52
+ 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
53
+ 'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY',
54
+ 'fireworksHint': 'whisper-v3-turbo · key FIREWORKS_API_KEY',
55
+ 'mistralHint': 'voxtral-mini-latest · key MISTRAL_API_KEY',
56
+ 'openrouterHint': 'google/gemini-2.5-flash · key OPENROUTER_API_KEY',
57
+ 'localHint': 'set when the server is started',
58
+ 'up': 'Up',
59
+ 'down': 'Down',
60
+ 'remove': 'Remove',
61
+ 'addProvider': 'Add provider',
62
+ 'chainHint': 'Top to bottom is the order they are tried in',
63
+ 'customName': 'name used in the chain',
64
+ 'customModel': 'model',
65
+ 'customKeyName': 'key name',
66
+ 'customModelHint': 'model to request (empty means the built-in one)',
67
+ 'addCustom': 'Add your own provider',
68
+ 'loadingSettings': 'Loading settings…',
69
+ 'notReady1': 'The harness has not announced this plugin’s settings yet. If it has just restarted, ',
70
+ 'notReady2': 'the section will appear on its own in a few seconds.',
71
+ 'hotkey': 'Voice message key',
72
+ 'pressKey': 'Press a key…',
73
+ 'clearKey': 'Clear the key',
74
+ 'hotkeyHint1': 'Hold it to record, release to send to the agent, Esc cancels. ',
75
+ 'hotkeyHint2': 'Any key will do: a letter, an F-key or a modifier.',
76
+ 'language': 'Language',
77
+ 'dictationHint': 'Speech is cut at pauses and the text is appended to the input.',
78
+ 'pauseMs': 'Pause that ends a phrase, ms',
79
+ 'pauseHint': 'Lower means more frequent chunks and faster text, but a higher risk of cutting a word',
80
+ 'messageTitle': 'Voice message',
81
+ 'messageHint': 'One whole recording, sent to the agent once it is transcribed.',
82
+ 'undoMs': 'Undo window, ms',
83
+ 'undoHint': 'How long the automatic send can still be called off',
84
+ 'customTitle': 'Your own providers',
85
+ 'customHint': 'Any OpenAI-compatible API. The name becomes available in the chains above.',
86
+ 'general': 'General',
87
+ 'whisperEndpoint': 'Local whisper: endpoint',
88
+ 'whisperEndpointHint': 'POST /inference of a whisper.cpp server',
89
+ 'whisperBin': 'Local whisper: binary',
90
+ 'whisperBinHint': 'used when autostart is on',
91
+ 'whisperModel': 'Local whisper: model',
92
+ 'whisperAutostart': 'Autostart the local whisper',
93
+ 'save': 'Save',
94
+ 'saved': 'Saved ✓',
95
+ 'openrouterWarning': 'OpenRouter has no /audio/transcriptions \u2014 use the openai-chat-audio template there',
96
+ }
97
+ const ru = {
98
+ 'saveFailed': 'Часть полей не сохранилась —',
99
+ 'composerUnavailable': 'Композер недоступен',
100
+ 'keySpace': 'Пробел',
101
+ 'keyUnset': 'не задана',
102
+ 'recognitionError': 'ошибка распознавания',
103
+ 'micUnavailable': 'Микрофон недоступен: нужен HTTPS или localhost',
104
+ 'noRecorder': 'MediaRecorder не поддерживается браузером',
105
+ 'browserFailed': 'Браузер не распознал: ',
106
+ 'nothingHeard': 'Речь не распознана',
107
+ 'dictationBtn': 'Голосовой набор',
108
+ 'messageBtn': 'Голосовое сообщение — нажать или удерживать',
109
+ 'dictationPill': 'Диктовка — текст дописывается в строку',
110
+ 'messagePill': 'Запись голосового',
111
+ 'cancel': 'Отмена',
112
+ 'holdHint': 'Держите — отпустите, чтобы отправить',
113
+ 'listening': 'Слушаю в браузере…',
114
+ 'stop': 'Стоп',
115
+ 'transcribing': 'Распознаю…',
116
+ 'sendingIn': 'Отправляю агенту через',
117
+ 'secondsShort': ' с',
118
+ 'keepPending': 'Отменить отправку',
119
+ 'hide': 'Скрыть',
120
+ 'title': 'Голос',
121
+ 'recordSlot': 'Запись голоса',
122
+ 'browserHint': 'распознаёт сам браузер, ключ не нужен',
123
+ 'openaiHint': 'whisper-1 · ключ OPENAI_API_KEY',
124
+ 'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · ключ SILICONFLOW_API_KEY',
125
+ 'deepinfraHint': 'openai/whisper-large-v3-turbo · ключ DEEPINFRA_API_KEY',
126
+ 'fireworksHint': 'whisper-v3-turbo · ключ FIREWORKS_API_KEY',
127
+ 'mistralHint': 'voxtral-mini-latest · ключ MISTRAL_API_KEY',
128
+ 'openrouterHint': 'google/gemini-2.5-flash · ключ OPENROUTER_API_KEY',
129
+ 'localHint': 'задаётся при запуске сервера',
130
+ 'up': 'Выше',
131
+ 'down': 'Ниже',
132
+ 'remove': 'Убрать',
133
+ 'addProvider': 'Добавить провайдера',
134
+ 'chainHint': 'Порядок сверху вниз — порядок попыток',
135
+ 'customName': 'имя для цепочки',
136
+ 'customModel': 'модель',
137
+ 'customKeyName': 'имя ключа',
138
+ 'customModelHint': 'указание модели (пусто — встроенное)',
139
+ 'addCustom': 'Добавить своего провайдера',
140
+ 'loadingSettings': 'Загрузка настроек…',
141
+ 'notReady1': 'Харнесс ещё не объявил настройки плагина. Если он только что перезапустился, ',
142
+ 'notReady2': 'раздел появится сам через несколько секунд.',
143
+ 'hotkey': 'Клавиша для голосового сообщения',
144
+ 'pressKey': 'Нажмите клавишу…',
145
+ 'clearKey': 'Убрать клавишу',
146
+ 'hotkeyHint1': 'Держите её — идёт запись, отпустите — уйдёт агенту, Esc — отмена. ',
147
+ 'hotkeyHint2': 'Можно любую: буква, F-клавиша или модификатор.',
148
+ 'language': 'Язык',
149
+ 'dictationHint': 'Речь режется по паузам, текст дописывается в строку ввода.',
150
+ 'pauseMs': 'Пауза до конца фразы, мс',
151
+ 'pauseHint': 'Меньше — чаще куски и быстрее текст, но выше риск обрезать слово',
152
+ 'messageTitle': 'Голосовое сообщение',
153
+ 'messageHint': 'Одна запись целиком, после распознавания уходит агенту.',
154
+ 'undoMs': 'Окно отмены, мс',
155
+ 'undoHint': 'Сколько времени можно отменить автоматическую отправку',
156
+ 'customTitle': 'Свои провайдеры',
157
+ 'customHint': 'Любой OpenAI-совместимый API. Имя становится доступным в цепочках выше.',
158
+ 'general': 'Общее',
159
+ 'whisperEndpoint': 'Локальный whisper: endpoint',
160
+ 'whisperEndpointHint': 'POST /inference сервера whisper.cpp',
161
+ 'whisperBin': 'Локальный whisper: бинарь',
162
+ 'whisperBinHint': 'используется при автозапуске',
163
+ 'whisperModel': 'Локальный whisper: модель',
164
+ 'whisperAutostart': 'Автозапуск локального whisper',
165
+ 'save': 'Сохранить',
166
+ 'saved': 'Сохранено ✓',
167
+ 'openrouterWarning': 'У OpenRouter нет /audio/transcriptions \u2014 там нужен шаблон openai-chat-audio',
168
+ }
169
+
170
+ // Строки нужны и вне компонентов — в обработчиках записи, в подписях
171
+ // слотов. Поэтому переводчик модульный, а не только через props.
172
+ let moduleT = (key) => key
173
+ const t = (key) => moduleT(key)
174
+
22
175
  // ------------------------------------------------------------------ css
23
176
  const CSS =
24
177
  '.dvo-btn{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0;box-sizing:border-box}' +
@@ -118,7 +271,7 @@ window.__ModuleLoader__.load({
118
271
  function appendDraft(text) {
119
272
  const actions = voice.inputActions
120
273
  if (!actions || typeof actions.setDraft !== 'function') {
121
- voice.set({ phase: 'error', error: 'Композер недоступен' })
274
+ voice.set({ phase: 'error', error: t('composerUnavailable') })
122
275
  return
123
276
  }
124
277
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
@@ -128,11 +281,11 @@ window.__ModuleLoader__.load({
128
281
  // Как клавиша называется на человеческом языке.
129
282
  const KEY_LABELS = {
130
283
  Control: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win',
131
- Space: 'Пробел', Escape: 'Esc',
284
+ Space: t('keySpace'), Escape: 'Esc',
132
285
  }
133
286
 
134
287
  function keyLabel(name) {
135
- if (!name) return 'не задана'
288
+ if (!name) return t('keyUnset')
136
289
  if (KEY_LABELS[name]) return KEY_LABELS[name]
137
290
  // Коды вида KeyR и Digit5 показываем без служебной приставки.
138
291
  return String(name).replace(/^Key/, '').replace(/^Digit/, '')
@@ -202,7 +355,7 @@ window.__ModuleLoader__.load({
202
355
  // no-speech и aborted — обычная жизнь, а не поломка.
203
356
  const code = event && event.error
204
357
  if (code === 'no-speech' || code === 'aborted') return
205
- options.onError(code || 'ошибка распознавания')
358
+ options.onError(code || t('recognitionError'))
206
359
  }
207
360
  // Браузер обрывает распознавание сам: на паузах, по таймауту. Пока нас не
208
361
  // остановили — поднимаем заново, иначе диктовка молча умрёт на первой паузе.
@@ -252,9 +405,9 @@ window.__ModuleLoader__.load({
252
405
 
253
406
  async function openMic(mode) {
254
407
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
255
- throw new Error('Микрофон недоступен: нужен HTTPS или localhost')
408
+ throw new Error(t('micUnavailable'))
256
409
  }
257
- if (typeof MediaRecorder === 'undefined') throw new Error('MediaRecorder не поддерживается браузером')
410
+ if (typeof MediaRecorder === 'undefined') throw new Error(t('noRecorder'))
258
411
  const stream = await navigator.mediaDevices.getUserMedia({
259
412
  audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
260
413
  })
@@ -341,7 +494,7 @@ window.__ModuleLoader__.load({
341
494
  // Браузер отказал уже после старта — честно говорим об этом, а не
342
495
  // делаем вид, что слушаем.
343
496
  voice.browser = null
344
- voice.set({ phase: 'error', error: 'Браузер не распознал: ' + reason })
497
+ voice.set({ phase: 'error', error: t('browserFailed') + reason })
345
498
  },
346
499
  })
347
500
  voice.browserFinals = finals
@@ -487,7 +640,7 @@ window.__ModuleLoader__.load({
487
640
  voice.set({ phase: 'processing' })
488
641
  try {
489
642
  const text = await sendAudio(blob, rec.mime, mode)
490
- if (!text) { voice.set({ phase: 'error', error: 'Речь не распознана' }); return }
643
+ if (!text) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
491
644
  appendDraft(text)
492
645
  if (mode === 'message') {
493
646
  voice.set({ phase: 'pending', pending: { text: text, leftMs: voice.settings.autoSendMs } })
@@ -524,11 +677,11 @@ window.__ModuleLoader__.load({
524
677
  return React.createElement(React.Fragment, null,
525
678
  React.createElement('button', {
526
679
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
527
- title: err ? v.error : 'Голосовой набор', onClick: startDictation,
680
+ title: err ? v.error : t('dictationBtn'), onClick: startDictation,
528
681
  }, micIcon()),
529
682
  React.createElement('button', {
530
683
  type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
531
- title: err ? v.error : 'Голосовое сообщение — нажать или удерживать',
684
+ title: err ? v.error : t('messageBtn'),
532
685
  // Удержание: пишет, пока держишь; увёл курсор далеко вверх — отмена.
533
686
  onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
534
687
  onPointerUp: () => endHold(false),
@@ -609,12 +762,12 @@ window.__ModuleLoader__.load({
609
762
 
610
763
  if (v.phase === 'recording') {
611
764
  const inBrowser = !!voice.browser
612
- const hint = v.mode === 'dictation' ? 'Диктовка — текст дописывается в строку' : 'Запись голосового'
765
+ const hint = v.mode === 'dictation' ? t('dictationPill') : t('messagePill')
613
766
  // Живая подпись: что слышно прямо сейчас. Пока браузер не выдал
614
767
  // окончательный кусок, текст черновой и меняется на глазах.
615
768
  const caption = voice.caption || (inBrowser ? '' : null)
616
769
  return React.createElement('div', { className: 'dvo-pill' },
617
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Отмена', onClick: cancelCurrent }, xIcon()),
770
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('cancel'), onClick: cancelCurrent }, xIcon()),
618
771
  inBrowser
619
772
  ? null
620
773
  : React.createElement('canvas', { className: 'dvo-wave', ref: canvasRef, width: 720, height: 40 }),
@@ -622,29 +775,29 @@ window.__ModuleLoader__.load({
622
775
  caption
623
776
  ? caption
624
777
  : (voice.holding
625
- ? 'Держите — отпустите, чтобы отправить'
626
- : (inBrowser ? 'Слушаю в браузере…' : hint))),
627
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Стоп', onClick: stopCurrent }, stopIcon()),
778
+ ? t('holdHint')
779
+ : (inBrowser ? t('listening') : hint))),
780
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('stop'), onClick: stopCurrent }, stopIcon()),
628
781
  )
629
782
  }
630
783
 
631
784
  if (v.phase === 'processing') {
632
785
  return React.createElement('div', { className: 'dvo-pill' },
633
- React.createElement('span', { className: 'dvo-status' }, spinIcon(), 'Распознаю…'))
786
+ React.createElement('span', { className: 'dvo-status' }, spinIcon(), t('transcribing')))
634
787
  }
635
788
 
636
789
  if (v.phase === 'pending') {
637
790
  const left = Math.max(0, Math.ceil((voice.pending ? voice.pending.leftMs : 0) / 1000))
638
791
  return React.createElement('div', { className: 'dvo-pill' },
639
- React.createElement('span', { className: 'dvo-status' }, 'Отправляю агенту через'),
640
- React.createElement('span', { className: 'dvo-count' }, left + ' с'),
641
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Отменить отправку', onClick: keepPending }, xIcon()),
792
+ React.createElement('span', { className: 'dvo-status' }, t('sendingIn')),
793
+ React.createElement('span', { className: 'dvo-count' }, left + t('secondsShort')),
794
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('keepPending'), onClick: keepPending }, xIcon()),
642
795
  )
643
796
  }
644
797
 
645
798
  return React.createElement('div', { className: 'dvo-pill' },
646
799
  React.createElement('span', { className: 'dvo-status dvo-err' }, warnIcon(), v.error),
647
- React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: 'Скрыть', onClick: () => voice.set({ phase: 'idle', error: '' }) }, xIcon()),
800
+ React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: t('hide'), onClick: () => voice.set({ phase: 'idle', error: '' }) }, xIcon()),
648
801
  )
649
802
  }
650
803
 
@@ -679,11 +832,11 @@ window.__ModuleLoader__.load({
679
832
  }, 'dsh-voice: горячая клавиша удержания')
680
833
 
681
834
  ctx.slots.inject('conversation.input.right', () => ctx.slots.register(
682
- { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => 'Голос' },
835
+ { name: 'conversation.input.right', id: '@goodandready/dsh-voice', order: 6, label: () => t('title') },
683
836
  (props) => React.createElement(VoiceButtons, { input: props.input, inputActions: props.inputActions }),
684
837
  ))
685
838
  ctx.slots.inject('conversation.input.dock', () => ctx.slots.register(
686
- { name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, label: () => 'Запись голоса' },
839
+ { name: 'conversation.input.dock', id: 'dsh-voice-rec', order: 0, label: () => t('recordSlot') },
687
840
  (props) => React.createElement(RecordPill, { input: props.input, inputActions: props.inputActions, ctx: ctx }),
688
841
  ))
689
842
  }
@@ -696,15 +849,15 @@ window.__ModuleLoader__.load({
696
849
  ]
697
850
  const TEMPLATES = ['openai-transcriptions', 'openai-chat-audio']
698
851
  const MODEL_HINT = {
699
- browser: 'распознаёт сам браузер, ключ не нужен',
700
- openai: 'whisper-1 · ключ OPENAI_API_KEY',
701
- siliconflow: 'FunAudioLLM/SenseVoiceSmall · ключ SILICONFLOW_API_KEY',
702
- deepinfra: 'openai/whisper-large-v3-turbo · ключ DEEPINFRA_API_KEY',
703
- fireworks: 'whisper-v3-turbo · ключ FIREWORKS_API_KEY',
704
- mistral: 'voxtral-mini-latest · ключ MISTRAL_API_KEY',
705
- openrouter: 'google/gemini-2.5-flash · ключ OPENROUTER_API_KEY',
852
+ browser: t('browserHint'),
853
+ openai: t('openaiHint'),
854
+ siliconflow: t('siliconflowHint'),
855
+ deepinfra: t('deepinfraHint'),
856
+ fireworks: t('fireworksHint'),
857
+ mistral: t('mistralHint'),
858
+ openrouter: t('openrouterHint'),
706
859
  deepgram: 'nova-2', groq: 'whisper-large-v3-turbo',
707
- hf: 'openai/whisper-large-v3', 'local-whisper': 'задаётся при запуске сервера',
860
+ hf: 'openai/whisper-large-v3', 'local-whisper': t('localHint'),
708
861
  }
709
862
  const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
710
863
 
@@ -763,13 +916,13 @@ window.__ModuleLoader__.load({
763
916
  className: 'dvs-model', value: row.model || '', disabled: !props.writable,
764
917
  placeholder: MODEL_HINT[row.provider] || '', onChange: (e) => change(i, { model: e.target.value }),
765
918
  }),
766
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: 'Выше', disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
767
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: 'Ниже', disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
768
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: 'Убрать', disabled: !props.writable, onClick: () => remove(i) }, '×'),
919
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('up'), disabled: !props.writable, onClick: () => move(i, -1) }, '↑'),
920
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('down'), disabled: !props.writable, onClick: () => move(i, 1) }, '↓'),
921
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('remove'), disabled: !props.writable, onClick: () => remove(i) }, '×'),
769
922
  )),
770
923
  React.createElement('div', { className: 'dvs-row' },
771
- React.createElement('button', { type: 'button', className: 'dvs-mini', title: 'Добавить провайдера', disabled: !props.writable, onClick: add }, '+'),
772
- React.createElement('span', { className: 'dvs-sub' }, 'Порядок сверху вниз — порядок попыток'),
924
+ React.createElement('button', { type: 'button', className: 'dvs-mini', title: t('addProvider'), disabled: !props.writable, onClick: add }, '+'),
925
+ React.createElement('span', { className: 'dvs-sub' }, t('chainHint')),
773
926
  ),
774
927
  )
775
928
  }
@@ -790,13 +943,13 @@ window.__ModuleLoader__.load({
790
943
  return React.createElement('div', { className: 'dvs-block' },
791
944
  rows.map((row, i) => React.createElement('div', { className: 'dvs-card', key: i },
792
945
  React.createElement('div', { className: 'dvs-row' },
793
- field(i, row, 'key', 'имя для цепочки'),
946
+ field(i, row, 'key', t('customName')),
794
947
  React.createElement('select', {
795
948
  value: row.template || 'openai-transcriptions', disabled: !props.writable,
796
949
  onChange: (e) => change(i, { template: e.target.value }),
797
950
  }, TEMPLATES.map((t) => React.createElement('option', { key: t, value: t }, t))),
798
951
  React.createElement('button', {
799
- type: 'button', className: 'dvs-mini', title: 'Убрать',
952
+ type: 'button', className: 'dvs-mini', title: t('remove'),
800
953
  disabled: !props.writable, onClick: () => remove(i),
801
954
  }, '\u00d7'),
802
955
  ),
@@ -804,26 +957,27 @@ window.__ModuleLoader__.load({
804
957
  field(i, row, 'baseURL', 'https://openrouter.ai/api/v1', true),
805
958
  ),
806
959
  React.createElement('div', { className: 'dvs-row' },
807
- field(i, row, 'model', 'модель', true),
808
- field(i, row, 'keyEnv', 'имя ключа'),
960
+ field(i, row, 'model', t('customModel'), true),
961
+ field(i, row, 'keyEnv', t('customKeyName')),
809
962
  ),
810
963
  row.template === 'openai-chat-audio'
811
964
  ? React.createElement('div', { className: 'dvs-row' },
812
- field(i, row, 'prompt', 'указание модели (пусто — встроенное)', true))
965
+ field(i, row, 'prompt', t('customModelHint'), true))
813
966
  : null,
814
967
  )),
815
968
  React.createElement('div', { className: 'dvs-row' },
816
969
  React.createElement('button', {
817
- type: 'button', className: 'dvs-mini', title: 'Добавить своего провайдера',
970
+ type: 'button', className: 'dvs-mini', title: t('addCustom'),
818
971
  disabled: !props.writable, onClick: add,
819
972
  }, '+'),
820
973
  React.createElement('span', { className: 'dvs-sub' },
821
- 'У OpenRouter нет /audio/transcriptions \u2014 там нужен шаблон openai-chat-audio'),
974
+ t('openrouterWarning')),
822
975
  ),
823
976
  )
824
977
  }
825
978
 
826
979
  function VoiceSection(props) {
980
+ const t = (props && props.t) || moduleT
827
981
  const ctx = props.ctx
828
982
  // На странице, открытой не с localhost, ядро отключает настройки целиком:
829
983
  // общий вид документа не читается, каждый раздел получает "unavailable",
@@ -911,9 +1065,9 @@ window.__ModuleLoader__.load({
911
1065
  const waiting = !snap || snap.status === 'loading'
912
1066
  return React.createElement('div', { className: 'dvs-wrap' },
913
1067
  React.createElement('div', { className: 'dvs-wait' }, waiting
914
- ? 'Загрузка настроек…'
915
- : 'Харнесс ещё не объявил настройки плагина. Если он только что перезапустился, '
916
- + 'раздел появится сам через несколько секунд.'),
1068
+ ? t('loadingSettings')
1069
+ : t('notReady1')
1070
+ + t('notReady2')),
917
1071
  )
918
1072
  }
919
1073
 
@@ -928,17 +1082,31 @@ window.__ModuleLoader__.load({
928
1082
  const save = async () => {
929
1083
  setErr(''); setSaved(false)
930
1084
  if (!draft) return
931
- try {
932
- for (const k of Object.keys(draft)) await scope.set(k, draft[k])
933
- voice.settings = {
934
- vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
935
- autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
1085
+
1086
+ // Поля пишутся по одному, и раньше первая же неудача обрывала цикл:
1087
+ // остальные поля не записывались, композер не получал сигнала
1088
+ // перечитать клавишу, а на кнопку это было похоже на «ничего не
1089
+ // происходит». Теперь пишутся все, а неудачи собираются и называются
1090
+ // поимённо — иначе непонятно, какое поле виновато.
1091
+ const failed = []
1092
+ for (const k of Object.keys(draft)) {
1093
+ try {
1094
+ await scope.set(k, draft[k])
1095
+ } catch (e) {
1096
+ failed.push(k + ': ' + String(e && e.message ? e.message : e))
936
1097
  }
937
- // Композер держит обработчик клавиши: пусть перечитает настройку,
938
- // иначе новая клавиша заработает только после перезагрузки страницы.
939
- try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
940
- setSaved(true); setTimeout(() => setSaved(false), 2000)
941
- } catch (e) { setErr(String(e && e.message ? e.message : e)) }
1098
+ }
1099
+
1100
+ voice.settings = {
1101
+ vadSilenceMs: Number(draft.dictation && draft.dictation.vadSilenceMs) || 700,
1102
+ autoSendMs: Number(draft.message && draft.message.autoSendMs) || 4000,
1103
+ }
1104
+ // Композер держит обработчик клавиши: пусть перечитает настройку,
1105
+ // иначе новая клавиша заработает только после перезагрузки страницы.
1106
+ try { window.dispatchEvent(new CustomEvent('dsh-voice:settings-saved')) } catch (noEvents) { /* некому */ }
1107
+
1108
+ if (failed.length) { setErr(t('saveFailed') + ' ' + failed.join('; ')); return }
1109
+ setSaved(true); setTimeout(() => setSaved(false), 2000)
942
1110
  }
943
1111
 
944
1112
  const modeVal = (mode, key, fallback) => {
@@ -955,22 +1123,22 @@ window.__ModuleLoader__.load({
955
1123
  )
956
1124
 
957
1125
  const hotkeyField = () => React.createElement('label', { className: 'dvs-field' },
958
- 'Клавиша для голосового сообщения',
1126
+ t('hotkey'),
959
1127
  React.createElement('div', { className: 'dvs-row' },
960
1128
  React.createElement('button', {
961
1129
  type: 'button', className: 'dvs-save', disabled: !writable,
962
1130
  onClick: () => setCatching(true),
963
- }, catching ? 'Нажмите клавишу…' : keyLabel(draft && draft.hotkey)),
1131
+ }, catching ? t('pressKey') : keyLabel(draft && draft.hotkey)),
964
1132
  React.createElement('button', {
965
- type: 'button', className: 'dvs-mini', title: 'Убрать клавишу',
1133
+ type: 'button', className: 'dvs-mini', title: t('clearKey'),
966
1134
  disabled: !writable, onClick: () => setDraft((d) => Object.assign({}, d || {}, { hotkey: '' })),
967
1135
  }, '×'),
968
1136
  ),
969
1137
  React.createElement('span', { className: 'dvs-sub' },
970
- 'Держите её — идёт запись, отпустите — уйдёт агенту, Esc — отмена. '
971
- + 'Можно любую: буква, F-клавиша или модификатор.'))
1138
+ t('hotkeyHint1')
1139
+ + t('hotkeyHint2')))
972
1140
 
973
- const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, 'Язык',
1141
+ const langField = (mode) => React.createElement('label', { className: 'dvs-field' }, t('language'),
974
1142
  React.createElement('select', {
975
1143
  value: modeVal(mode, 'language', 'ru'), disabled: !writable,
976
1144
  onChange: (e) => setIn(mode, 'language', e.target.value),
@@ -992,51 +1160,51 @@ window.__ModuleLoader__.load({
992
1160
 
993
1161
  return React.createElement('div', { className: 'dvs-wrap' },
994
1162
  React.createElement('div', { className: 'dvs-block' },
995
- React.createElement('div', { className: 'dvs-h' }, 'Голосовой набор'),
996
- React.createElement('div', { className: 'dvs-sub' }, 'Речь режется по паузам, текст дописывается в строку ввода.'),
1163
+ React.createElement('div', { className: 'dvs-h' }, t('dictationBtn')),
1164
+ React.createElement('div', { className: 'dvs-sub' }, t('dictationHint')),
997
1165
  React.createElement(ChainEditor, {
998
1166
  value: draft && draft.dictation ? draft.dictation.chain : [], writable: writable,
999
1167
  options: chainOptions,
1000
1168
  onChange: (v) => setIn('dictation', 'chain', v),
1001
1169
  }),
1002
1170
  langField('dictation'),
1003
- numField('dictation', 'vadSilenceMs', 'Пауза до конца фразы, мс', 'Меньше — чаще куски и быстрее текст, но выше риск обрезать слово'),
1171
+ numField('dictation', 'vadSilenceMs', t('pauseMs'), t('pauseHint')),
1004
1172
  ),
1005
1173
  React.createElement('div', { className: 'dvs-block' },
1006
- React.createElement('div', { className: 'dvs-h' }, 'Голосовое сообщение'),
1007
- React.createElement('div', { className: 'dvs-sub' }, 'Одна запись целиком, после распознавания уходит агенту.'),
1174
+ React.createElement('div', { className: 'dvs-h' }, t('messageTitle')),
1175
+ React.createElement('div', { className: 'dvs-sub' }, t('messageHint')),
1008
1176
  React.createElement(ChainEditor, {
1009
1177
  value: draft && draft.message ? draft.message.chain : [], writable: writable,
1010
1178
  options: chainOptions,
1011
1179
  onChange: (v) => setIn('message', 'chain', v),
1012
1180
  }),
1013
1181
  langField('message'),
1014
- numField('message', 'autoSendMs', 'Окно отмены, мс', 'Сколько времени можно отменить автоматическую отправку'),
1182
+ numField('message', 'autoSendMs', t('undoMs'), t('undoHint')),
1015
1183
  ),
1016
1184
  React.createElement('div', { className: 'dvs-block' },
1017
- React.createElement('div', { className: 'dvs-h' }, 'Свои провайдеры'),
1185
+ React.createElement('div', { className: 'dvs-h' }, t('customTitle')),
1018
1186
  React.createElement('div', { className: 'dvs-sub' },
1019
- 'Любой OpenAI-совместимый API. Имя становится доступным в цепочках выше.'),
1187
+ t('customHint')),
1020
1188
  React.createElement(CustomEditor, {
1021
1189
  value: draft && draft.customProviders ? draft.customProviders : [], writable: writable,
1022
1190
  onChange: (v) => setTop('customProviders', v),
1023
1191
  }),
1024
1192
  ),
1025
1193
  React.createElement('div', { className: 'dvs-block' },
1026
- React.createElement('div', { className: 'dvs-h' }, 'Общее'),
1194
+ React.createElement('div', { className: 'dvs-h' }, t('general')),
1027
1195
  hotkeyField(),
1028
- textField('whisperUrl', 'Локальный whisper: endpoint', 'POST /inference сервера whisper.cpp'),
1029
- textField('whisperBin', 'Локальный whisper: бинарь', 'используется при автозапуске'),
1030
- textField('whisperModel', 'Локальный whisper: модель', 'используется при автозапуске'),
1031
- React.createElement('label', { className: 'dvs-field' }, 'Автозапуск локального whisper',
1196
+ textField('whisperUrl', t('whisperEndpoint'), t('whisperEndpointHint')),
1197
+ textField('whisperBin', t('whisperBin'), t('whisperBinHint')),
1198
+ textField('whisperModel', t('whisperModel'), t('whisperBinHint')),
1199
+ React.createElement('label', { className: 'dvs-field' }, t('whisperAutostart'),
1032
1200
  React.createElement('input', {
1033
1201
  type: 'checkbox', checked: !!(draft && draft.autoStart), disabled: !writable,
1034
1202
  onChange: (e) => setTop('autoStart', e.target.checked),
1035
1203
  })),
1036
1204
  ),
1037
1205
  React.createElement('div', { className: 'dvs-row' },
1038
- React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, 'Сохранить'),
1039
- saved ? React.createElement('span', { className: 'dvs-ok' }, 'Сохранено ✓') : null,
1206
+ React.createElement('button', { type: 'button', className: 'dvs-save', disabled: !writable, onClick: save }, t('save')),
1207
+ saved ? React.createElement('span', { className: 'dvs-ok' }, t('saved')) : null,
1040
1208
  err ? React.createElement('span', { className: 'dvs-bad' }, err) : null,
1041
1209
  ),
1042
1210
  )
@@ -1044,13 +1212,23 @@ window.__ModuleLoader__.load({
1044
1212
 
1045
1213
  function registerSettings(ctx) {
1046
1214
  ctx.slots.inject('settings.section', () => ctx.slots.register(
1047
- { name: 'settings.section', id: '@goodandready/dsh-voice', order: 30, label: () => 'Голос', inject: () => ({ ctx: ctx }) },
1215
+ {
1216
+ name: 'settings.section',
1217
+ id: '@goodandready/dsh-voice',
1218
+ order: 30,
1219
+ // locale в записи слота — то, из-за чего компонент получает props.t.
1220
+ locale: NS,
1221
+ label: () => t('title'),
1222
+ inject: () => ({ ctx: ctx }),
1223
+ },
1048
1224
  VoiceSection,
1049
1225
  ))
1050
1226
  }
1051
1227
 
1052
- exports.inject = ['timer', 'slots', 'settingsScope']
1228
+ exports.inject = ['timer', 'slots', 'settingsScope', 'locale']
1053
1229
  exports.apply = function apply(ctx) {
1230
+ ctx.effect(() => ctx.locale.register(NS, { en, ru }), 'dsh-voice: словари')
1231
+ moduleT = ctx.locale.bind(NS)
1054
1232
  registerComposer(ctx)
1055
1233
  registerSettings(ctx)
1056
1234
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Voice input for DeepSeek Harness: dictation chunked by pauses and voice messages, each with its own provider fallback chain (Deepgram, Groq, HuggingFace, local whisper.cpp, plus any OpenAI-compatible API of your own).",
5
5
  "license": "MIT",
6
6
  "type": "module",