@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/README.md +20 -0
- package/README.ru.md +20 -0
- package/README.zh.md +16 -0
- package/lib/chain.js +4 -4
- package/lib/client-src/00-open.js +24 -0
- package/lib/client-src/10-locale.js +162 -0
- package/lib/client-src/20-css.js +39 -0
- package/lib/client-src/30-core.js +284 -0
- package/lib/client-src/40-recording.js +384 -0
- package/lib/client-src/41-buttons.js +30 -0
- package/lib/client-src/50-visualizers.js +121 -0
- package/lib/client-src/60-composer.js +285 -0
- package/lib/client-src/70-settings.js +765 -0
- package/lib/client-src/90-close.js +23 -0
- package/lib/client.js +541 -518
- package/lib/index.js +42 -43
- package/lib/normalize.js +2 -2
- package/lib/providers.js +39 -43
- package/lib/stats.js +2 -2
- package/lib/wav.js +8 -8
- package/package.json +4 -2
package/lib/client.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// dsh-voice —
|
|
1
|
+
// dsh-voice — client half (browser).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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
|
-
//
|
|
10
|
-
// settings.
|
|
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
|
-
|
|
166
|
-
'
|
|
167
|
-
'
|
|
168
|
-
'
|
|
169
|
-
'
|
|
170
|
-
'
|
|
171
|
-
'
|
|
172
|
-
'
|
|
173
|
-
'
|
|
174
|
-
'
|
|
175
|
-
'
|
|
176
|
-
'
|
|
177
|
-
'
|
|
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
|
-
//
|
|
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} —
|
|
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) { /*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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):
|
|
759
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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) { /*
|
|
863
|
+
} catch (e) { /* polish is best-effort */ }
|
|
987
864
|
}
|
|
988
|
-
run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /*
|
|
865
|
+
run().finally(() => setTimeout(() => { try { actions.submit() } catch (e) { /* busy */ } }, 0))
|
|
989
866
|
return
|
|
990
867
|
}
|
|
991
|
-
setTimeout(() => { try { actions.submit() } catch (e) { /*
|
|
868
|
+
setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
|
|
992
869
|
}
|
|
993
870
|
|
|
994
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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,
|
|
1123
|
-
radial.addColorStop(0.4,
|
|
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
|
-
//
|
|
1176
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
sensevoice:
|
|
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
|
-
'.
|
|
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-
|
|
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-
|
|
1464
|
-
'.dvs-
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1582
|
-
//
|
|
1583
|
-
//
|
|
1584
|
-
// dsh-lanmode
|
|
1585
|
-
//
|
|
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
|
-
|
|
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 [
|
|
1687
|
-
React.
|
|
1688
|
-
|
|
1689
|
-
|
|
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
|
-
|
|
1693
|
-
|
|
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
|
-
|
|
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: '
|
|
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('
|
|
1773
|
-
t('hotkey'),
|
|
1774
|
-
React.createElement('div', { className: '
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
1786
|
-
+ t('hotkeyHint2')))
|
|
1732
|
+
React.createElement('span', { className: 'dvs-sub' }, t('hotkeyHint1') + t('hotkeyHint2')),
|
|
1733
|
+
)
|
|
1787
1734
|
|
|
1788
|
-
const micField = () => React.createElement('
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
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
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
|
|
1888
|
-
|
|
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('
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1976
|
-
|
|
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
|
-
|
|
2018
|
-
|
|
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
|
-
//
|
|
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(
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
React.createElement('span', { className: 'dvo-
|
|
2043
|
-
|
|
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('
|
|
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)
|
|
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)
|