@goodandready/dsh-voice 0.9.11 → 0.9.13
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 +2 -2
- package/README.ru.md +2 -2
- package/README.zh.md +2 -2
- package/lib/client.js +243 -133
- package/lib/config-routes.js +42 -11
- package/lib/http-util.js +43 -5
- package/lib/index.js +8 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ graph LR
|
|
|
76
76
|
* 🎮 **Tactile Push-to-Talk**:
|
|
77
77
|
* **Mouse**: Hold the wave button — releasing sends the message; dragging pointer away discards.
|
|
78
78
|
* **Keyboard**: Hold <kbd>Ctrl</kbd> (or custom hotkey) for hands-free speaking; press <kbd>Esc</kbd> to cancel.
|
|
79
|
-
* ⚡ **
|
|
79
|
+
* ⚡ **Live In-Browser Captions (`browser`)**: Web Speech API speech-to-text with real-time floating captions as you speak (Note: standard browser recognition typically streams audio to vendor servers like Google in Chrome; for true 100% offline privacy, use local `whisper` or `sensevoice`).
|
|
80
80
|
* 🛡️ **Ironclad Multi-Provider Fallbacks**: If your primary cloud provider runs out of credits or hits a 429 rate limit, requests seamlessly fail over down the chain.
|
|
81
81
|
* 🧠 **Context Glossary Injection**: Automatically extracts code variables and identifiers from your composer draft to steer STT model accuracy on technical jargon.
|
|
82
82
|
* 🎵 **Embedded Audio Player**: Preview, scrubber, and playback of your recorded voice message directly in chat and the composer dock.
|
|
@@ -151,7 +151,7 @@ Quality batch after the v0.8.18 review (Gitea #79–#87, PR #88):
|
|
|
151
151
|
|
|
152
152
|
| Provider Key | Service Backend | Default Model | Credential Ref | Features & Notes |
|
|
153
153
|
|---|---|---|---|---|
|
|
154
|
-
| `browser` | Web Speech API | Native Browser | *None* | Zero latency, floating live captions in Chrome |
|
|
154
|
+
| `browser` | Web Speech API | Native Browser | *None* | Zero latency, floating live captions in Chrome (vendor server-based; not offline) |
|
|
155
155
|
| `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | Ultra-fast cloud transcription |
|
|
156
156
|
| `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Near-instant inference speed |
|
|
157
157
|
| `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | High-accuracy open Whisper |
|
package/README.ru.md
CHANGED
|
@@ -76,7 +76,7 @@ graph LR
|
|
|
76
76
|
* 🎮 **Тактильный Push-to-Talk**:
|
|
77
77
|
* **Мышь**: зажмите кнопку волны — запись идёт пока кнопка зажата; отпускание отправляет, увод курсора отменяет запись.
|
|
78
78
|
* **Клавиатура**: зажмите <kbd>Ctrl</kbd> для записи без мыши; нажмите <kbd>Esc</kbd> для отмены.
|
|
79
|
-
* ⚡ **Субтитры браузера в реальном времени (`browser`)**:
|
|
79
|
+
* ⚡ **Субтитры браузера в реальном времени (`browser`)**: распознавание Web Speech API с плавающими субтитрами в реальном времени (Внимание: стандартный браузерный движок в Chrome отправляет аудио на серверы Google; для полностью локальной приватной работы без внешних вызовов используйте локальный `whisper` или `sensevoice`).
|
|
80
80
|
* 🛡️ **Надёжные цепочки фолбеков**: при исчерпании квоты или ошибке 429 плагин мгновенно обращается к следующему провайдеру в списке.
|
|
81
81
|
* 🧠 **Контекстный словарь (Context Glossary)**: автоматическое извлечение переменных и технических терминов из черновика композера для точного распознавания редких слов и кода.
|
|
82
82
|
* 🎵 **Встроенный аудиоплеер**: предпросмотр и воспроизведение записанного голосового сообщения в чате и доке перед отправкой или для переслушивания.
|
|
@@ -151,7 +151,7 @@ Quality-батч после ревью v0.8.18 (Gitea #79–#87, PR #88):
|
|
|
151
151
|
|
|
152
152
|
| Ключ | Сервис | Модель по умолчанию | Переменная секрета | Особенности |
|
|
153
153
|
|---|---|---|---|---|
|
|
154
|
-
| `browser` | Web Speech API | Нативная в браузере | *Не требуется* | Нулевая задержка, живые субтитры в Chrome |
|
|
154
|
+
| `browser` | Web Speech API | Нативная в браузере | *Не требуется* | Нулевая задержка, живые субтитры в Chrome (обработка на серверах вендора; не является офлайн) |
|
|
155
155
|
| `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | Сверхбыстрая облачная транскрипция |
|
|
156
156
|
| `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | Мгновенная скорость генерации |
|
|
157
157
|
| `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | Высокоточный облачный Whisper |
|
package/README.zh.md
CHANGED
|
@@ -76,7 +76,7 @@ graph LR
|
|
|
76
76
|
* 🎮 **沉浸式 Push-to-Talk 对讲**:
|
|
77
77
|
* **鼠标操作**:按住声波按钮开始录音,松开发送,拖离按钮取消。
|
|
78
78
|
* **键盘操作**:按住 <kbd>Ctrl</kbd> 无需鼠标即刻说话,按 <kbd>Esc</kbd> 放弃本次录音。
|
|
79
|
-
* ⚡
|
|
79
|
+
* ⚡ **浏览器实时同声字幕 (`browser`)**:Web Speech API 实时语音识别与浮动同声字幕(注意:Chrome 等标准浏览器通常将音频发送至服务商云端解析;若需 100% 本地离线隐私保护,请使用本地 `whisper` 或 `sensevoice`)。
|
|
80
80
|
* 🛡️ **多服务商自动容灾切换**:首选 API 额度耗尽或遭遇 429 限流时,毫秒级顺位切换备用引擎。
|
|
81
81
|
* 🧠 **上下文术语注入 (Context Glossary)**:自动从输入草稿中提取代码变量名与专业术语,引导 STT 模型精准转写专业词汇。
|
|
82
82
|
* 🎵 **内嵌音频播放器**:在输入框与录音浮层中随时试听和回放刚刚录制的原始音频片段。
|
|
@@ -120,7 +120,7 @@ v0.8.18 质量审查后的修复批次(Gitea #79–#87,PR #88):
|
|
|
120
120
|
|
|
121
121
|
| 服务商标识 | 对应引擎 | 默认模型 | 环境变量凭据 | 特性说明 |
|
|
122
122
|
|---|---|---|---|---|
|
|
123
|
-
| `browser` | Web Speech API | 浏览器原生引擎 | *无需密钥* |
|
|
123
|
+
| `browser` | Web Speech API | 浏览器原生引擎 | *无需密钥* | 零延迟同声字幕输出(依赖浏览器厂商云端解析;非离线本地) |
|
|
124
124
|
| `deepgram` | Deepgram API | `nova-2` | `DEEPGRAM_API_KEY` | 极速高精云端转写 |
|
|
125
125
|
| `groq` | Groq Whisper | `whisper-large-v3-turbo` | `GROQ_API_KEY` | 毫秒级极速推理 |
|
|
126
126
|
| `hf` | HuggingFace Inference | `openai/whisper-large-v3` | `HF_TOKEN` | 经典高精度开源模型 |
|
package/lib/client.js
CHANGED
|
@@ -62,7 +62,7 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
'hide': 'Hide',
|
|
63
63
|
'title': 'Voice',
|
|
64
64
|
'recordSlot': 'Voice recording',
|
|
65
|
-
'browserHint': '
|
|
65
|
+
'browserHint': 'Web Speech recognition (uses page/browser language when auto, Chrome sends audio to Google)',
|
|
66
66
|
'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
|
|
67
67
|
'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
|
|
68
68
|
'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY',
|
|
@@ -89,6 +89,12 @@ window.__ModuleLoader__.load({
|
|
|
89
89
|
'hotkeyHint1': 'Hold it to record, release to send to the agent, Esc cancels. ',
|
|
90
90
|
'hotkeyHint2': 'Any key will do: a letter, an F-key or a modifier.',
|
|
91
91
|
'language': 'Language',
|
|
92
|
+
'langAuto': 'Auto-detect',
|
|
93
|
+
'langRu': 'Russian (ru)',
|
|
94
|
+
'langEn': 'English (en)',
|
|
95
|
+
'langZh': 'Chinese (zh)',
|
|
96
|
+
'langUk': 'Ukrainian (uk)',
|
|
97
|
+
'langDe': 'German (de)',
|
|
92
98
|
'dictationHint': 'Speech is cut at pauses and the text is appended to the input.',
|
|
93
99
|
'pauseMs': 'Pause that ends a phrase, ms',
|
|
94
100
|
'pauseHint': 'Lower means more frequent chunks and faster text, but a higher risk of cutting a word',
|
|
@@ -204,6 +210,9 @@ window.__ModuleLoader__.load({
|
|
|
204
210
|
'updating': 'Updating…',
|
|
205
211
|
'updateSuccess': 'Updated successfully! Restart DSH to apply.',
|
|
206
212
|
'updateFailed': 'Update failed. Check logs.',
|
|
213
|
+
'updateCheckFailed': 'Update check failed',
|
|
214
|
+
'checkingUpdates': 'Checking updates…',
|
|
215
|
+
'updateStatusUnknown': 'Version status unknown',
|
|
207
216
|
'sensevoiceTitle': 'SenseVoice Local ASR (1-Click Setup)',
|
|
208
217
|
'sensevoiceDesc': 'Ultra-fast offline speech recognition on CPU (50–100ms latency, Russian/English).',
|
|
209
218
|
'installSensevoice': 'Install SenseVoice-Small (1-Click)',
|
|
@@ -226,6 +235,10 @@ window.__ModuleLoader__.load({
|
|
|
226
235
|
'jargonFrom': 'Spoken slang',
|
|
227
236
|
'jargonTo': 'Correct spelling',
|
|
228
237
|
'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
|
|
238
|
+
'voiceInput': 'Voice input',
|
|
239
|
+
'assistantSpeakingGated': 'Assistant speaking (gated mode)',
|
|
240
|
+
'jargonFormatHint': ' (spoken -> replacement)',
|
|
241
|
+
'errorPrefix': 'Error',
|
|
229
242
|
}
|
|
230
243
|
|
|
231
244
|
const zh = {
|
|
@@ -256,7 +269,7 @@ window.__ModuleLoader__.load({
|
|
|
256
269
|
'hide': '隐藏',
|
|
257
270
|
'title': '语音',
|
|
258
271
|
'recordSlot': '语音录制',
|
|
259
|
-
'browserHint': '
|
|
272
|
+
'browserHint': 'Web Speech 语音识别 (设为 auto 时使用页面或浏览器语言,Chrome 会上传音频至 Google)',
|
|
260
273
|
'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY',
|
|
261
274
|
'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · 密钥 SILICONFLOW_API_KEY',
|
|
262
275
|
'deepinfraHint': 'openai/whisper-large-v3-turbo · 密钥 DEEPINFRA_API_KEY',
|
|
@@ -283,6 +296,12 @@ window.__ModuleLoader__.load({
|
|
|
283
296
|
'hotkeyHint1': '按住录音,松开发送给智能体,按 Esc 取消。',
|
|
284
297
|
'hotkeyHint2': '支持任意按键:字母键、功能键 (F1-F12) 或修饰键。',
|
|
285
298
|
'language': '识别语言',
|
|
299
|
+
'langAuto': '自动检测',
|
|
300
|
+
'langRu': '俄语 (ru)',
|
|
301
|
+
'langEn': '英语 (en)',
|
|
302
|
+
'langZh': '中文 (zh)',
|
|
303
|
+
'langUk': '乌克兰语 (uk)',
|
|
304
|
+
'langDe': '德语 (de)',
|
|
286
305
|
'dictationHint': '语音按停顿自动切句,文字实时追加至输入框。',
|
|
287
306
|
'pauseMs': '断句静音停顿时长(毫秒)',
|
|
288
307
|
'pauseHint': '数值越小切分越频繁、出字越快,但可能切断未说完的词',
|
|
@@ -398,6 +417,9 @@ window.__ModuleLoader__.load({
|
|
|
398
417
|
'updating': '正在更新…',
|
|
399
418
|
'updateSuccess': '更新成功!请重启 DSH 生效。',
|
|
400
419
|
'updateFailed': '更新失败,请查看日志。',
|
|
420
|
+
'updateCheckFailed': '检查更新失败',
|
|
421
|
+
'checkingUpdates': '正在检查更新…',
|
|
422
|
+
'updateStatusUnknown': '版本状态未知',
|
|
401
423
|
'sensevoiceTitle': 'SenseVoice 本地 ASR (一键部署)',
|
|
402
424
|
'sensevoiceDesc': 'CPU 上超低延迟离线语音识别 (50–100ms,中英俄多语种)。',
|
|
403
425
|
'installSensevoice': '一键安装 SenseVoice-Small',
|
|
@@ -420,11 +442,15 @@ window.__ModuleLoader__.load({
|
|
|
420
442
|
'jargonFrom': '口语词汇',
|
|
421
443
|
'jargonTo': '标准拼写',
|
|
422
444
|
'jargonPlaceholder': 'k8s -> kubernetes\npr -> pull request',
|
|
445
|
+
'voiceInput': '语音输入',
|
|
446
|
+
'assistantSpeakingGated': '助手正在发言(门控模式)',
|
|
447
|
+
'jargonFormatHint': ' (口语 -> 替换文本)',
|
|
448
|
+
'errorPrefix': '错误',
|
|
423
449
|
}
|
|
424
450
|
|
|
425
451
|
// Strings are also needed outside components — in recording handlers and
|
|
426
452
|
// slot labels — so the translator is module-level, not only via props.
|
|
427
|
-
let moduleT = (key) => key
|
|
453
|
+
let moduleT = (key) => en[key] || key
|
|
428
454
|
const t = (key) => moduleT(key)
|
|
429
455
|
// ------------------------------------------------------------------ css
|
|
430
456
|
const CSS =
|
|
@@ -507,13 +533,34 @@ window.__ModuleLoader__.load({
|
|
|
507
533
|
subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l) },
|
|
508
534
|
}
|
|
509
535
|
|
|
510
|
-
|
|
511
|
-
window
|
|
512
|
-
|
|
513
|
-
})
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
536
|
+
function registerGlobalLifecycle() {
|
|
537
|
+
if (typeof window === 'undefined') return () => {}
|
|
538
|
+
const onTtsStart = () => voice.set({ isTtsSpeaking: true })
|
|
539
|
+
const onTtsStop = () => voice.set({ isTtsSpeaking: false })
|
|
540
|
+
const onSettingsSaved = () => { voice._chainsPromise = null }
|
|
541
|
+
const onPageUnload = () => {
|
|
542
|
+
if (voice.rec && typeof voice.teardownRec === 'function') voice.teardownRec(voice.rec)
|
|
543
|
+
if (voice.browser) {
|
|
544
|
+
try { voice.browser.abort() } catch (_) {}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
window.addEventListener('dsh:tts:start', onTtsStart)
|
|
549
|
+
window.addEventListener('dsh:tts:stop', onTtsStop)
|
|
550
|
+
window.addEventListener('dsh-voice:settings-saved', onSettingsSaved)
|
|
551
|
+
window.addEventListener('pagehide', onPageUnload)
|
|
552
|
+
window.addEventListener('beforeunload', onPageUnload)
|
|
553
|
+
|
|
554
|
+
return () => {
|
|
555
|
+
window.removeEventListener('dsh:tts:start', onTtsStart)
|
|
556
|
+
window.removeEventListener('dsh:tts:stop', onTtsStop)
|
|
557
|
+
window.removeEventListener('dsh-voice:settings-saved', onSettingsSaved)
|
|
558
|
+
window.removeEventListener('pagehide', onPageUnload)
|
|
559
|
+
window.removeEventListener('beforeunload', onPageUnload)
|
|
560
|
+
onPageUnload()
|
|
561
|
+
if (typeof voice.clearGlobalHotkey === 'function') voice.clearGlobalHotkey()
|
|
562
|
+
voice.set({ phase: 'idle', caption: '', interimTranscript: '', isTtsSpeaking: false })
|
|
563
|
+
}
|
|
517
564
|
}
|
|
518
565
|
|
|
519
566
|
function triggerBargeIn() {
|
|
@@ -823,6 +870,50 @@ window.__ModuleLoader__.load({
|
|
|
823
870
|
return main
|
|
824
871
|
}
|
|
825
872
|
|
|
873
|
+
function hotkeyMatches(event, name) {
|
|
874
|
+
if (!event || !name) return false
|
|
875
|
+
if (typeof name === 'string' && name.includes('+')) {
|
|
876
|
+
const parts = name.split('+')
|
|
877
|
+
const main = parts[parts.length - 1]
|
|
878
|
+
const needCtrl = parts.includes('Control')
|
|
879
|
+
const needAlt = parts.includes('Alt')
|
|
880
|
+
const needShift = parts.includes('Shift')
|
|
881
|
+
const needMeta = parts.includes('Meta')
|
|
882
|
+
if (needCtrl && !event.ctrlKey) return false
|
|
883
|
+
if (needAlt && !event.altKey) return false
|
|
884
|
+
if (needShift && !event.shiftKey) return false
|
|
885
|
+
if (needMeta && !event.metaKey) return false
|
|
886
|
+
return event.code === main || event.key === main
|
|
887
|
+
}
|
|
888
|
+
if (name === 'Control') return event.key === 'Control'
|
|
889
|
+
if (name === 'Alt') return event.key === 'Alt'
|
|
890
|
+
if (name === 'Shift') return event.key === 'Shift'
|
|
891
|
+
if (name === 'Meta') return event.key === 'Meta'
|
|
892
|
+
return event.code === name || event.key === name
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function isAllowedInEditable(name) {
|
|
896
|
+
if (!name) return false
|
|
897
|
+
if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
|
|
898
|
+
if (/^F\d{1,2}$/.test(name)) return true
|
|
899
|
+
if (typeof name === 'string' && name.includes('+')) return true
|
|
900
|
+
if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
|
|
901
|
+
return false
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function isHotkeyRelease(event, name) {
|
|
905
|
+
if (!event || !name) return false
|
|
906
|
+
if (typeof name === 'string' && name.includes('+')) {
|
|
907
|
+
const parts = name.split('+')
|
|
908
|
+
if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
|
|
909
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
910
|
+
if (event.key === parts[i] || event.code === parts[i]) return true
|
|
911
|
+
}
|
|
912
|
+
return false
|
|
913
|
+
}
|
|
914
|
+
return event.code === name || event.key === name
|
|
915
|
+
}
|
|
916
|
+
|
|
826
917
|
// Announce that the user started speaking.
|
|
827
918
|
//
|
|
828
919
|
// Playback should mute immediately: listening and talking at once is
|
|
@@ -871,6 +962,21 @@ window.__ModuleLoader__.load({
|
|
|
871
962
|
return speechRecognitionCtor() !== null
|
|
872
963
|
}
|
|
873
964
|
|
|
965
|
+
function resolveBrowserRecognitionLang(lang) {
|
|
966
|
+
if (lang && lang !== 'auto') {
|
|
967
|
+
if (lang.includes('-')) return lang
|
|
968
|
+
const BCP47 = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', uk: 'uk-UA', de: 'de-DE' }
|
|
969
|
+
return BCP47[lang.toLowerCase()] || lang
|
|
970
|
+
}
|
|
971
|
+
if (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) {
|
|
972
|
+
return document.documentElement.lang
|
|
973
|
+
}
|
|
974
|
+
if (typeof navigator !== 'undefined' && navigator.language) {
|
|
975
|
+
return navigator.language
|
|
976
|
+
}
|
|
977
|
+
return 'en-US'
|
|
978
|
+
}
|
|
979
|
+
|
|
874
980
|
/**
|
|
875
981
|
* @param options {{lang: string, continuous: boolean, onInterim, onFinal, onError}}
|
|
876
982
|
* @returns {{stop: Function, abort: Function}}
|
|
@@ -878,7 +984,7 @@ window.__ModuleLoader__.load({
|
|
|
878
984
|
function startBrowserRecognition(options) {
|
|
879
985
|
const Ctor = speechRecognitionCtor()
|
|
880
986
|
const recognition = new Ctor()
|
|
881
|
-
recognition.lang = options.lang
|
|
987
|
+
recognition.lang = resolveBrowserRecognitionLang(options.lang)
|
|
882
988
|
recognition.continuous = options.continuous !== false
|
|
883
989
|
recognition.interimResults = true
|
|
884
990
|
let stopped = false
|
|
@@ -939,22 +1045,19 @@ window.__ModuleLoader__.load({
|
|
|
939
1045
|
|
|
940
1046
|
// Fetch the mode chain from the host once. Only needed to decide whether
|
|
941
1047
|
|
|
942
|
-
voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft }
|
|
943
|
-
|
|
944
|
-
if (typeof window !== 'undefined') {
|
|
945
|
-
window.addEventListener('dsh-voice:settings-saved', () => { chainsPromise = null })
|
|
946
|
-
}
|
|
1048
|
+
voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
|
|
1049
|
+
voice._chainsPromise = null
|
|
947
1050
|
function modeChain(mode) {
|
|
948
|
-
if (!
|
|
949
|
-
|
|
1051
|
+
if (!voice._chainsPromise) {
|
|
1052
|
+
voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
|
|
950
1053
|
.then((res) => res.json())
|
|
951
1054
|
.then((data) => (data && data.modes) || {})
|
|
952
1055
|
.catch(() => {
|
|
953
|
-
|
|
1056
|
+
voice._chainsPromise = null
|
|
954
1057
|
return {}
|
|
955
1058
|
})
|
|
956
1059
|
}
|
|
957
|
-
return
|
|
1060
|
+
return voice._chainsPromise.then((modes) => {
|
|
958
1061
|
const row = modes[mode] || {}
|
|
959
1062
|
return {
|
|
960
1063
|
chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
|
|
@@ -971,16 +1074,7 @@ window.__ModuleLoader__.load({
|
|
|
971
1074
|
if (rec.analyser) { try { rec.analyser.disconnect() } catch (e) { /* already disconnected */ } }
|
|
972
1075
|
if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
|
|
973
1076
|
}
|
|
974
|
-
|
|
975
|
-
const onPageUnload = () => {
|
|
976
|
-
if (voice.rec) teardown(voice.rec)
|
|
977
|
-
if (voice.browser) {
|
|
978
|
-
try { voice.browser.abort() } catch (e) { /* ignore */ }
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
window.addEventListener('pagehide', onPageUnload)
|
|
982
|
-
window.addEventListener('beforeunload', onPageUnload)
|
|
983
|
-
}
|
|
1077
|
+
voice.teardownRec = teardown
|
|
984
1078
|
function waitStop(recorder) {
|
|
985
1079
|
if (!recorder || recorder.state === 'inactive') return Promise.resolve()
|
|
986
1080
|
return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
|
|
@@ -1174,7 +1268,7 @@ window.__ModuleLoader__.load({
|
|
|
1174
1268
|
if (voice.settings.bargeIn) {
|
|
1175
1269
|
triggerBargeIn()
|
|
1176
1270
|
} else if (voice.settings.gatedTurnTaking !== false) {
|
|
1177
|
-
voice.set({ phase: 'error', error: '
|
|
1271
|
+
voice.set({ phase: 'error', error: t('assistantSpeakingGated') })
|
|
1178
1272
|
return
|
|
1179
1273
|
}
|
|
1180
1274
|
}
|
|
@@ -1239,35 +1333,7 @@ window.__ModuleLoader__.load({
|
|
|
1239
1333
|
if (cancelled) cancelCurrent()
|
|
1240
1334
|
else stopCurrent()
|
|
1241
1335
|
}
|
|
1242
|
-
|
|
1243
|
-
if (!event || !name) return false
|
|
1244
|
-
if (typeof name === 'string' && name.includes('+')) {
|
|
1245
|
-
const parts = name.split('+')
|
|
1246
|
-
const main = parts[parts.length - 1]
|
|
1247
|
-
const needCtrl = parts.includes('Control')
|
|
1248
|
-
const needAlt = parts.includes('Alt')
|
|
1249
|
-
const needShift = parts.includes('Shift')
|
|
1250
|
-
const needMeta = parts.includes('Meta')
|
|
1251
|
-
if (needCtrl && !event.ctrlKey) return false
|
|
1252
|
-
if (needAlt && !event.altKey) return false
|
|
1253
|
-
if (needShift && !event.shiftKey) return false
|
|
1254
|
-
if (needMeta && !event.metaKey) return false
|
|
1255
|
-
return event.code === main || event.key === main
|
|
1256
|
-
}
|
|
1257
|
-
if (name === 'Control') return event.key === 'Control'
|
|
1258
|
-
if (name === 'Alt') return event.key === 'Alt'
|
|
1259
|
-
if (name === 'Shift') return event.key === 'Shift'
|
|
1260
|
-
if (name === 'Meta') return event.key === 'Meta'
|
|
1261
|
-
return event.code === name || event.key === name
|
|
1262
|
-
}
|
|
1263
|
-
function isAllowedInEditable(name) {
|
|
1264
|
-
if (!name) return false
|
|
1265
|
-
if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
|
|
1266
|
-
if (/^F\d{1,2}$/.test(name)) return true
|
|
1267
|
-
if (typeof name === 'string' && name.includes('+')) return true
|
|
1268
|
-
if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
|
|
1269
|
-
return false
|
|
1270
|
-
}
|
|
1336
|
+
|
|
1271
1337
|
let activeHotkeyCleanup = null
|
|
1272
1338
|
function clearGlobalHotkey() {
|
|
1273
1339
|
if (typeof activeHotkeyCleanup === 'function') {
|
|
@@ -1279,6 +1345,7 @@ window.__ModuleLoader__.load({
|
|
|
1279
1345
|
window.__dsh_voice_hotkey_cleanup = null
|
|
1280
1346
|
}
|
|
1281
1347
|
}
|
|
1348
|
+
voice.clearGlobalHotkey = clearGlobalHotkey
|
|
1282
1349
|
function installHotkey(ctx, keyName, mode) {
|
|
1283
1350
|
clearGlobalHotkey()
|
|
1284
1351
|
if (typeof document === 'undefined' || !keyName) return () => {}
|
|
@@ -1293,18 +1360,7 @@ window.__ModuleLoader__.load({
|
|
|
1293
1360
|
beginHold(mode)
|
|
1294
1361
|
}
|
|
1295
1362
|
}
|
|
1296
|
-
|
|
1297
|
-
if (!event || !name) return false
|
|
1298
|
-
if (typeof name === 'string' && name.includes('+')) {
|
|
1299
|
-
const parts = name.split('+')
|
|
1300
|
-
if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
|
|
1301
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
1302
|
-
if (event.key === parts[i] || event.code === parts[i]) return true
|
|
1303
|
-
}
|
|
1304
|
-
return false
|
|
1305
|
-
}
|
|
1306
|
-
return event.code === name || event.key === name
|
|
1307
|
-
}
|
|
1363
|
+
|
|
1308
1364
|
const up = (event) => {
|
|
1309
1365
|
if (hold.armed && isHotkeyRelease(event, keyName)) {
|
|
1310
1366
|
event.preventDefault()
|
|
@@ -2104,34 +2160,42 @@ window.__ModuleLoader__.load({
|
|
|
2104
2160
|
if (key) return t(key)
|
|
2105
2161
|
return MODEL_HINT_STATIC[provider] || ''
|
|
2106
2162
|
}
|
|
2107
|
-
const
|
|
2163
|
+
const SUPPORTED_LANGS = [
|
|
2164
|
+
{ code: '', labelKey: 'langAuto', defaultLabel: 'auto' },
|
|
2165
|
+
{ code: 'ru', labelKey: 'langRu', defaultLabel: 'ru' },
|
|
2166
|
+
{ code: 'en', labelKey: 'langEn', defaultLabel: 'en' },
|
|
2167
|
+
{ code: 'zh', labelKey: 'langZh', defaultLabel: 'zh' },
|
|
2168
|
+
{ code: 'uk', labelKey: 'langUk', defaultLabel: 'uk' },
|
|
2169
|
+
{ code: 'de', labelKey: 'langDe', defaultLabel: 'de' },
|
|
2170
|
+
]
|
|
2171
|
+
const LANGS = SUPPORTED_LANGS.map((o) => o.code)
|
|
2108
2172
|
|
|
2109
2173
|
const SET_CSS =
|
|
2110
|
-
'.cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
|
|
2111
|
-
'.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}' +
|
|
2112
|
-
'.cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
|
|
2113
|
-
'.cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
|
|
2114
|
-
'.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
|
|
2115
|
-
'.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
|
|
2116
|
-
'.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}' +
|
|
2117
|
-
'.cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
|
|
2118
|
-
'.cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
|
|
2119
|
-
'.cb-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
|
|
2120
|
-
'.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}' +
|
|
2121
|
-
'.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
|
|
2122
|
-
'.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}' +
|
|
2123
|
-
'.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))}' +
|
|
2124
|
-
'.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
|
|
2125
|
-
'.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
|
|
2126
|
-
'.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
|
|
2127
|
-
'.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}' +
|
|
2128
|
-
'.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
|
|
2129
|
-
'.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
|
|
2130
|
-
'.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}' +
|
|
2131
|
-
'.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
|
|
2132
|
-
'.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
|
|
2133
|
-
'.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
|
|
2134
|
-
'.cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
|
|
2174
|
+
'.dvo-settings-root.cb-page,.dvo-settings-root .cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
|
|
2175
|
+
'.dvo-settings-root .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}' +
|
|
2176
|
+
'.dvo-settings-root .cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
|
|
2177
|
+
'.dvo-settings-root .cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
|
|
2178
|
+
'.dvo-settings-root .cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
|
|
2179
|
+
'.dvo-settings-root .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
|
|
2180
|
+
'.dvo-settings-root .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}' +
|
|
2181
|
+
'.dvo-settings-root .cb-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}' +
|
|
2182
|
+
'.dvo-settings-root .cb-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}' +
|
|
2183
|
+
'.dvo-settings-root .cb-badge-bad,.dvo-settings-root .cb-badge-err{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}' +
|
|
2184
|
+
'.dvo-settings-root .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}' +
|
|
2185
|
+
'.dvo-settings-root .cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
|
|
2186
|
+
'.dvo-settings-root .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}' +
|
|
2187
|
+
'.dvo-settings-root .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))}' +
|
|
2188
|
+
'.dvo-settings-root .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
|
|
2189
|
+
'.dvo-settings-root .cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
|
|
2190
|
+
'.dvo-settings-root .cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
|
|
2191
|
+
'.dvo-settings-root .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}' +
|
|
2192
|
+
'.dvo-settings-root .cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
|
|
2193
|
+
'.dvo-settings-root .cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
|
|
2194
|
+
'.dvo-settings-root .cb-field select,.dvo-settings-root .cb-field input[type="text"],.dvo-settings-root .cb-field input[type="number"],.dvo-settings-root .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}' +
|
|
2195
|
+
'.dvo-settings-root .cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
|
|
2196
|
+
'.dvo-settings-root .cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
|
|
2197
|
+
'.dvo-settings-root .cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
|
|
2198
|
+
'.dvo-settings-root .cb-alert-err{padding:10px 14px;border-radius:8px;background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:13px}' +
|
|
2135
2199
|
'.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
|
|
2136
2200
|
'.dvs-block{display:flex;flex-direction:column;gap:10px}' +
|
|
2137
2201
|
'.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
@@ -2153,12 +2217,19 @@ window.__ModuleLoader__.load({
|
|
|
2153
2217
|
'.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
|
|
2154
2218
|
'.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
|
|
2155
2219
|
const setCssId = 'dsh-voice/settings.module.css'
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
tag
|
|
2159
|
-
tag
|
|
2160
|
-
|
|
2161
|
-
|
|
2220
|
+
function installSettingsCss() {
|
|
2221
|
+
if (typeof document === 'undefined') return () => {}
|
|
2222
|
+
let tag = document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')
|
|
2223
|
+
if (!tag) {
|
|
2224
|
+
tag = document.createElement('style')
|
|
2225
|
+
tag.textContent = SET_CSS
|
|
2226
|
+
tag.setAttribute('data-dsh-plugin', 'dsh-voice')
|
|
2227
|
+
tag.dataset.pluginCss = setCssId
|
|
2228
|
+
document.head.appendChild(tag)
|
|
2229
|
+
}
|
|
2230
|
+
return () => {
|
|
2231
|
+
if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
|
|
2232
|
+
}
|
|
2162
2233
|
}
|
|
2163
2234
|
|
|
2164
2235
|
function createErrorBoundary() {
|
|
@@ -2328,7 +2399,7 @@ window.__ModuleLoader__.load({
|
|
|
2328
2399
|
}
|
|
2329
2400
|
}
|
|
2330
2401
|
const rate = item.attempts > 0 ? Math.round((item.successes / item.attempts) * 100) : 100
|
|
2331
|
-
return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ?
|
|
2402
|
+
return React.createElement('div', { key, className: 'dvo-dash-item', title: item.lastError ? `${t('errorPrefix')}: ${item.lastError}` : '' },
|
|
2332
2403
|
React.createElement('div', { className: 'dvo-dash-name' }, key),
|
|
2333
2404
|
React.createElement('div', { className: 'dvo-dash-row' },
|
|
2334
2405
|
React.createElement('span', { className: 'dvo-badge ' + badgeClass }, badgeText),
|
|
@@ -2671,8 +2742,12 @@ window.__ModuleLoader__.load({
|
|
|
2671
2742
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
2672
2743
|
const data = await res.json()
|
|
2673
2744
|
setUpdaterStatus(data)
|
|
2745
|
+
if (data && data.latestCheckFailed) {
|
|
2746
|
+
setUpdaterMsg(t('updateCheckFailed'))
|
|
2747
|
+
}
|
|
2674
2748
|
} catch (e) {
|
|
2675
|
-
|
|
2749
|
+
setUpdaterStatus(null)
|
|
2750
|
+
setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
|
|
2676
2751
|
} finally {
|
|
2677
2752
|
setUpdaterLoading(false)
|
|
2678
2753
|
}
|
|
@@ -2695,7 +2770,7 @@ window.__ModuleLoader__.load({
|
|
|
2695
2770
|
if (!res.ok || data.ok === false) {
|
|
2696
2771
|
throw new Error(data.error || `HTTP ${res.status}`)
|
|
2697
2772
|
}
|
|
2698
|
-
setUpdaterMsg(data.message || '
|
|
2773
|
+
setUpdaterMsg(data.message || t('updateSuccess'))
|
|
2699
2774
|
checkUpdater()
|
|
2700
2775
|
} catch (err) {
|
|
2701
2776
|
setUpdaterMsg(err && err.message ? err.message : String(err))
|
|
@@ -2729,7 +2804,7 @@ window.__ModuleLoader__.load({
|
|
|
2729
2804
|
t('techJargonCorrection'),
|
|
2730
2805
|
),
|
|
2731
2806
|
React.createElement('div', { className: 'cb-field' },
|
|
2732
|
-
React.createElement('label', null, t('jargonAdd') +
|
|
2807
|
+
React.createElement('label', null, t('jargonAdd') + t('jargonFormatHint')),
|
|
2733
2808
|
React.createElement('textarea', {
|
|
2734
2809
|
rows: 3,
|
|
2735
2810
|
disabled: !writable,
|
|
@@ -2854,16 +2929,24 @@ window.__ModuleLoader__.load({
|
|
|
2854
2929
|
),
|
|
2855
2930
|
React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
|
|
2856
2931
|
React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
|
|
2857
|
-
|
|
2858
|
-
'
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
? React.createElement('span', { className: 'cb-badge
|
|
2862
|
-
|
|
2863
|
-
)
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2932
|
+
updaterStatus && updaterStatus.currentVersion
|
|
2933
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, 'v' + updaterStatus.currentVersion)
|
|
2934
|
+
: null,
|
|
2935
|
+
updaterLoading && (!updaterStatus || !updaterStatus.currentVersion)
|
|
2936
|
+
? React.createElement('span', { className: 'cb-badge' }, '⏳ ' + t('checkingUpdates'))
|
|
2937
|
+
: !updaterStatus
|
|
2938
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-warn' }, '❓ ' + t('updateStatusUnknown'))
|
|
2939
|
+
: updaterStatus.updateAvailable
|
|
2940
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
2941
|
+
t('updateAvailable') + ': v' + updaterStatus.latestVersion
|
|
2942
|
+
)
|
|
2943
|
+
: updaterStatus.latestCheckFailed
|
|
2944
|
+
? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
|
|
2945
|
+
'⚠️ ' + t('updateCheckFailed')
|
|
2946
|
+
)
|
|
2947
|
+
: React.createElement('span', { className: 'cb-badge cb-badge-ok' },
|
|
2948
|
+
t('upToDate')
|
|
2949
|
+
),
|
|
2867
2950
|
updaterStatus && updaterStatus.updateAvailable
|
|
2868
2951
|
? React.createElement('button', {
|
|
2869
2952
|
type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
|
|
@@ -2903,6 +2986,7 @@ window.__ModuleLoader__.load({
|
|
|
2903
2986
|
const [err, setErr] = React.useState('')
|
|
2904
2987
|
const [serverConfig, setServerConfig] = React.useState(null)
|
|
2905
2988
|
const [configLoading, setConfigLoading] = React.useState(true)
|
|
2989
|
+
const [revision, setRevision] = React.useState(null)
|
|
2906
2990
|
|
|
2907
2991
|
React.useEffect(() => {
|
|
2908
2992
|
let alive = true
|
|
@@ -2911,6 +2995,7 @@ window.__ModuleLoader__.load({
|
|
|
2911
2995
|
.then((data) => {
|
|
2912
2996
|
if (alive && data && data.ok && data.config) {
|
|
2913
2997
|
setServerConfig(data.config)
|
|
2998
|
+
if (typeof data.revision === 'number') setRevision(data.revision)
|
|
2914
2999
|
setDraft((curr) => curr || deepClone(data.config))
|
|
2915
3000
|
}
|
|
2916
3001
|
})
|
|
@@ -3079,14 +3164,15 @@ window.__ModuleLoader__.load({
|
|
|
3079
3164
|
const res = await fetch('/dsh-voice/config', {
|
|
3080
3165
|
method: 'PUT',
|
|
3081
3166
|
headers: { 'Content-Type': 'application/json' },
|
|
3082
|
-
body: JSON.stringify({ config: draft }),
|
|
3167
|
+
body: JSON.stringify({ config: draft, expectedRevision: (typeof revision === 'number' ? revision : undefined) }),
|
|
3083
3168
|
})
|
|
3084
3169
|
const data = await res.json()
|
|
3085
3170
|
if (res.ok && data && data.ok) {
|
|
3086
3171
|
httpOk = true
|
|
3087
|
-
if (data.config)
|
|
3088
|
-
|
|
3089
|
-
|
|
3172
|
+
if (data.config) setServerConfig(data.config)
|
|
3173
|
+
if (typeof data.revision === 'number') setRevision(data.revision)
|
|
3174
|
+
} else if (res.status === 409) {
|
|
3175
|
+
httpError = data?.error?.message || 'Settings conflict: parallel changes detected'
|
|
3090
3176
|
} else {
|
|
3091
3177
|
httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
|
|
3092
3178
|
}
|
|
@@ -3094,7 +3180,7 @@ window.__ModuleLoader__.load({
|
|
|
3094
3180
|
httpError = e?.message || String(e)
|
|
3095
3181
|
}
|
|
3096
3182
|
|
|
3097
|
-
if (scope && typeof scope.set === 'function') {
|
|
3183
|
+
if (httpOk && scope && typeof scope.set === 'function') {
|
|
3098
3184
|
for (const k of Object.keys(draft)) {
|
|
3099
3185
|
try {
|
|
3100
3186
|
await scope.set(k, draft[k])
|
|
@@ -3157,13 +3243,22 @@ window.__ModuleLoader__.load({
|
|
|
3157
3243
|
.filter((k) => k && BUILTIN.indexOf(k) < 0),
|
|
3158
3244
|
)
|
|
3159
3245
|
|
|
3160
|
-
const langField = (mode) =>
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3246
|
+
const langField = (mode) => {
|
|
3247
|
+
const val = modeVal(mode, 'language', '')
|
|
3248
|
+
const opts = SUPPORTED_LANGS.slice()
|
|
3249
|
+
if (val && !opts.some((o) => o.code === val)) {
|
|
3250
|
+
opts.push({ code: val, labelKey: val, defaultLabel: val })
|
|
3251
|
+
}
|
|
3252
|
+
return React.createElement('div', { className: 'cb-field' },
|
|
3253
|
+
React.createElement('label', null, t('language')),
|
|
3254
|
+
React.createElement('select', {
|
|
3255
|
+
value: val, disabled: !writable,
|
|
3256
|
+
onChange: (e) => setIn(mode, 'language', e.target.value),
|
|
3257
|
+
}, opts.map((o) => React.createElement('option', { key: o.code || 'auto', value: o.code },
|
|
3258
|
+
t(o.labelKey) !== o.labelKey ? t(o.labelKey) : o.defaultLabel
|
|
3259
|
+
))),
|
|
3260
|
+
)
|
|
3261
|
+
}
|
|
3167
3262
|
|
|
3168
3263
|
const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
|
|
3169
3264
|
React.createElement('label', null, label),
|
|
@@ -3180,7 +3275,7 @@ window.__ModuleLoader__.load({
|
|
|
3180
3275
|
const whisperRunning = !!(statusData && statusData.whisperRunning)
|
|
3181
3276
|
const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
|
|
3182
3277
|
|
|
3183
|
-
return React.createElement('div', { className: 'cb-page' },
|
|
3278
|
+
return React.createElement('div', { className: 'cb-page dvo-settings-root' },
|
|
3184
3279
|
// 1. Connection & Engine Status Card
|
|
3185
3280
|
React.createElement(ConnectionStatusCard, {
|
|
3186
3281
|
t: t,
|
|
@@ -3456,19 +3551,20 @@ window.__ModuleLoader__.load({
|
|
|
3456
3551
|
),
|
|
3457
3552
|
React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
|
|
3458
3553
|
),
|
|
3459
|
-
(page || open) ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
|
|
3554
|
+
(page || open) ? React.createElement('div', { className: 'dvo-pbody dvo-settings-root' }, React.createElement(VoiceSection, props)) : null,
|
|
3460
3555
|
),
|
|
3461
3556
|
)
|
|
3462
3557
|
}
|
|
3463
3558
|
|
|
3464
3559
|
function registerSettings(ctx) {
|
|
3465
3560
|
ctx.effect(() => {
|
|
3561
|
+
const offCss = installSettingsCss()
|
|
3466
3562
|
const offItem = ctx.slots.inject('plugins.item', () => ctx.slots.register(
|
|
3467
3563
|
{
|
|
3468
3564
|
name: 'plugins.item',
|
|
3469
3565
|
id: ROW_ID,
|
|
3470
3566
|
order: 60,
|
|
3471
|
-
label: () => '
|
|
3567
|
+
label: () => t('voiceInput'),
|
|
3472
3568
|
locale: NS,
|
|
3473
3569
|
inject: () => ({ ctx: ctx }),
|
|
3474
3570
|
},
|
|
@@ -3493,6 +3589,7 @@ window.__ModuleLoader__.load({
|
|
|
3493
3589
|
PluginCard,
|
|
3494
3590
|
))
|
|
3495
3591
|
return () => {
|
|
3592
|
+
if (typeof offCss === 'function') offCss()
|
|
3496
3593
|
if (typeof offItem === 'function') offItem()
|
|
3497
3594
|
if (typeof offRow === 'function') offRow()
|
|
3498
3595
|
if (typeof offLegacy === 'function') offLegacy()
|
|
@@ -3516,10 +3613,23 @@ window.__ModuleLoader__.load({
|
|
|
3516
3613
|
const undo = [addLocale('en', en), addLocale('zh', zh)]
|
|
3517
3614
|
return () => { for (const off of undo) off() }
|
|
3518
3615
|
}, 'dsh-voice: locale dictionaries')
|
|
3616
|
+
ctx.effect(() => {
|
|
3617
|
+
return registerGlobalLifecycle()
|
|
3618
|
+
}, 'dsh-voice: global lifecycle')
|
|
3519
3619
|
moduleT = ctx.locale.bind(NS)
|
|
3520
3620
|
registerComposer(ctx)
|
|
3521
3621
|
registerSettings(ctx)
|
|
3522
3622
|
}
|
|
3623
|
+
exports._test = {
|
|
3624
|
+
voice,
|
|
3625
|
+
keyLabel,
|
|
3626
|
+
keyFromEvent,
|
|
3627
|
+
hotkeyMatches,
|
|
3628
|
+
isAllowedInEditable,
|
|
3629
|
+
isHotkeyRelease,
|
|
3630
|
+
extractContextKeywords,
|
|
3631
|
+
resolveBrowserRecognitionLang,
|
|
3632
|
+
}
|
|
3523
3633
|
return module.exports
|
|
3524
3634
|
},
|
|
3525
3635
|
})
|
package/lib/config-routes.js
CHANGED
|
@@ -27,9 +27,14 @@ export function registerConfigRoutes(ctx, {
|
|
|
27
27
|
return
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// 3. GET: return live configuration snapshot
|
|
30
|
+
// 3. GET: return live configuration snapshot with revision
|
|
31
31
|
if (req.method === 'GET') {
|
|
32
|
-
|
|
32
|
+
let rev
|
|
33
|
+
try {
|
|
34
|
+
const settingsService = getSettingsService?.()
|
|
35
|
+
rev = settingsService?.describe?.()?.find?.((r) => r.ns === NS)?.revision
|
|
36
|
+
} catch { /* ignore revision lookup errors */ }
|
|
37
|
+
writeJson(res, 200, { ok: true, config: live(), revision: rev })
|
|
33
38
|
return
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -46,6 +51,10 @@ export function registerConfigRoutes(ctx, {
|
|
|
46
51
|
try {
|
|
47
52
|
raw = await readBody(req, 1024 * 1024)
|
|
48
53
|
} catch (e) {
|
|
54
|
+
if (e && (e.statusCode === 413 || e.message === 'body too large')) {
|
|
55
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'payload too large' } })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
49
58
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
50
59
|
return
|
|
51
60
|
}
|
|
@@ -62,6 +71,9 @@ export function registerConfigRoutes(ctx, {
|
|
|
62
71
|
const rawConfig = payload && typeof payload.config === 'object' && payload.config !== null
|
|
63
72
|
? payload.config
|
|
64
73
|
: payload
|
|
74
|
+
const expectedRevision = (typeof payload?.expectedRevision === 'number')
|
|
75
|
+
? payload.expectedRevision
|
|
76
|
+
: ((typeof payload?.revision === 'number') ? payload.revision : undefined)
|
|
65
77
|
|
|
66
78
|
if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
|
|
67
79
|
writeJson(res, 400, { ok: false, error: { code: 'body', message: 'body must be a JSON object' } })
|
|
@@ -88,21 +100,35 @@ export function registerConfigRoutes(ctx, {
|
|
|
88
100
|
try {
|
|
89
101
|
let saved = false
|
|
90
102
|
if (settingsService) {
|
|
91
|
-
let
|
|
103
|
+
let currentRev
|
|
92
104
|
try {
|
|
93
|
-
|
|
105
|
+
currentRev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
|
|
94
106
|
} catch { /* ignore revision lookup errors */ }
|
|
95
107
|
|
|
108
|
+
if (typeof expectedRevision === 'number' && typeof currentRev === 'number' && expectedRevision !== currentRev) {
|
|
109
|
+
writeJson(res, 409, {
|
|
110
|
+
ok: false,
|
|
111
|
+
error: {
|
|
112
|
+
code: 'conflict',
|
|
113
|
+
message: `Settings revision conflict: expected ${expectedRevision}, current is ${currentRev}`,
|
|
114
|
+
currentRevision: currentRev,
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const targetRev = (typeof expectedRevision === 'number') ? expectedRevision : currentRev
|
|
121
|
+
|
|
96
122
|
if (typeof settingsService.replace === 'function') {
|
|
97
|
-
await settingsService.replace(NS, merged,
|
|
123
|
+
await settingsService.replace(NS, merged, targetRev)
|
|
98
124
|
saved = true
|
|
99
125
|
} else if (typeof settingsService.update === 'function') {
|
|
100
|
-
await settingsService.update(NS, merged,
|
|
126
|
+
await settingsService.update(NS, merged, targetRev)
|
|
101
127
|
saved = true
|
|
102
128
|
}
|
|
103
129
|
}
|
|
104
130
|
|
|
105
|
-
if (scope) {
|
|
131
|
+
if (!saved && scope) {
|
|
106
132
|
if (typeof scope.replace === 'function') {
|
|
107
133
|
await scope.replace(merged)
|
|
108
134
|
saved = true
|
|
@@ -110,10 +136,11 @@ export function registerConfigRoutes(ctx, {
|
|
|
110
136
|
await scope.patch(merged)
|
|
111
137
|
saved = true
|
|
112
138
|
} else if (typeof scope.set === 'function') {
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
const entries = Object.entries(merged)
|
|
140
|
+
for (const [k, v] of entries) {
|
|
141
|
+
await scope.set(k, v)
|
|
115
142
|
}
|
|
116
|
-
saved = true
|
|
143
|
+
if (entries.length > 0) saved = true
|
|
117
144
|
}
|
|
118
145
|
}
|
|
119
146
|
|
|
@@ -126,7 +153,11 @@ export function registerConfigRoutes(ctx, {
|
|
|
126
153
|
triggerAutostart()
|
|
127
154
|
}
|
|
128
155
|
|
|
129
|
-
|
|
156
|
+
let newRev
|
|
157
|
+
try {
|
|
158
|
+
newRev = settingsService?.describe?.()?.find?.((r) => r.ns === NS)?.revision
|
|
159
|
+
} catch {}
|
|
160
|
+
writeJson(res, 200, { ok: true, config: live(), revision: newRev })
|
|
130
161
|
} catch (err) {
|
|
131
162
|
writeJson(res, 500, { ok: false, error: { code: 'save', message: String(err?.message || err) } })
|
|
132
163
|
}
|
package/lib/http-util.js
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
// Pure HTTP helpers shared by host routes. No cordis, no network.
|
|
2
2
|
|
|
3
|
+
export class BodyTooLargeError extends Error {
|
|
4
|
+
constructor(message = 'body too large') {
|
|
5
|
+
super(message)
|
|
6
|
+
this.name = 'BodyTooLargeError'
|
|
7
|
+
this.statusCode = 413
|
|
8
|
+
this.code = 'ERR_BODY_TOO_LARGE'
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export function writeJson(res, code, body) {
|
|
4
13
|
try {
|
|
5
|
-
|
|
14
|
+
const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }
|
|
15
|
+
if (code === 413) {
|
|
16
|
+
headers['Connection'] = 'close'
|
|
17
|
+
}
|
|
18
|
+
res.writeHead(code, headers)
|
|
6
19
|
res.end(JSON.stringify(body))
|
|
20
|
+
if (code === 413 && res && typeof res.on === 'function') {
|
|
21
|
+
res.on('finish', () => {
|
|
22
|
+
const sock = res.socket || (res.req && res.req.socket)
|
|
23
|
+
if (sock && !sock.destroyed && typeof sock.destroy === 'function') {
|
|
24
|
+
sock.destroy()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
}
|
|
7
28
|
} catch { /* socket may already be closed */ }
|
|
8
29
|
}
|
|
9
30
|
|
|
@@ -17,13 +38,30 @@ export function readBody(req, maxBytes) {
|
|
|
17
38
|
return new Promise((resolve, reject) => {
|
|
18
39
|
const chunks = []
|
|
19
40
|
let size = 0
|
|
20
|
-
|
|
41
|
+
let exceeded = false
|
|
42
|
+
const onData = (c) => {
|
|
43
|
+
if (exceeded) return
|
|
21
44
|
size += c.length
|
|
22
|
-
if (size > maxBytes) {
|
|
45
|
+
if (size > maxBytes) {
|
|
46
|
+
exceeded = true
|
|
47
|
+
if (typeof req.removeListener === 'function') {
|
|
48
|
+
req.removeListener('data', onData)
|
|
49
|
+
}
|
|
50
|
+
if (typeof req.resume === 'function') {
|
|
51
|
+
req.resume()
|
|
52
|
+
}
|
|
53
|
+
reject(new BodyTooLargeError('body too large'))
|
|
54
|
+
return
|
|
55
|
+
}
|
|
23
56
|
chunks.push(c)
|
|
57
|
+
}
|
|
58
|
+
req.on('data', onData)
|
|
59
|
+
req.on('end', () => {
|
|
60
|
+
if (!exceeded) resolve(Buffer.concat(chunks))
|
|
61
|
+
})
|
|
62
|
+
req.on('error', (err) => {
|
|
63
|
+
if (!exceeded) reject(err)
|
|
24
64
|
})
|
|
25
|
-
req.on('end', () => resolve(Buffer.concat(chunks)))
|
|
26
|
-
req.on('error', reject)
|
|
27
65
|
})
|
|
28
66
|
}
|
|
29
67
|
|
package/lib/index.js
CHANGED
|
@@ -421,7 +421,14 @@ export function apply(ctx, baseConfig) {
|
|
|
421
421
|
if (!isTrustedCaller(req)) { writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden source origin' } }); return }
|
|
422
422
|
const cfg = live()
|
|
423
423
|
let raw
|
|
424
|
-
try { raw = await readBody(req, 1024 * 1024) } catch (e) {
|
|
424
|
+
try { raw = await readBody(req, 1024 * 1024) } catch (e) {
|
|
425
|
+
if (e && (e.statusCode === 413 || e.message === 'body too large')) {
|
|
426
|
+
writeJson(res, 413, { ok: false, error: { code: 'too-large', message: 'body exceeds limit of 1MB' } })
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } })
|
|
430
|
+
return
|
|
431
|
+
}
|
|
425
432
|
let payload = {}
|
|
426
433
|
try { payload = JSON.parse(raw.toString('utf8') || '{}') } catch { /* empty */ }
|
|
427
434
|
const text = typeof payload.text === 'string' ? payload.text.trim() : ''
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-voice",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.13",
|
|
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",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"test": "node --test test/*.test.mjs",
|
|
45
45
|
"build:client": "node scripts/build-client.mjs",
|
|
46
46
|
"build:check": "node scripts/build-client.mjs --check",
|
|
47
|
-
"pretest": "node scripts/build-client.mjs",
|
|
48
|
-
"lint": "node
|
|
47
|
+
"pretest": "node scripts/build-client.mjs && node scripts/lint.mjs && node scripts/pack-check.mjs",
|
|
48
|
+
"lint": "node scripts/lint.mjs",
|
|
49
49
|
"pack:check": "node scripts/pack-check.mjs"
|
|
50
50
|
},
|
|
51
51
|
"dsh": {
|