@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.
- package/lib/client.js +256 -78
- 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: '
|
|
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('
|
|
408
|
+
throw new Error(t('micUnavailable'))
|
|
256
409
|
}
|
|
257
|
-
if (typeof MediaRecorder === 'undefined') throw new Error('
|
|
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: '
|
|
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: '
|
|
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 : '
|
|
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: '
|
|
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 ? '
|
|
627
|
-
React.createElement('button', { type: 'button', className: 'dvo-pbtn', title: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
701
|
-
siliconflow: '
|
|
702
|
-
deepinfra: '
|
|
703
|
-
fireworks: '
|
|
704
|
-
mistral: '
|
|
705
|
-
openrouter: '
|
|
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: '
|
|
767
|
-
React.createElement('button', { type: 'button', className: 'dvs-mini', title: '
|
|
768
|
-
React.createElement('button', { type: 'button', className: 'dvs-mini', title: '
|
|
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: '
|
|
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', '
|
|
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', '
|
|
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
|
-
'
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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 ? '
|
|
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
|
-
'
|
|
971
|
-
+ '
|
|
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
|
-
'
|
|
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', '
|
|
1029
|
-
textField('whisperBin', '
|
|
1030
|
-
textField('whisperModel', '
|
|
1031
|
-
React.createElement('label', { className: 'dvs-field' }, '
|
|
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' }, '
|
|
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
|
-
{
|
|
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.
|
|
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",
|