@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 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
- * ⚡ **Zero-Latency In-Browser Captions (`browser`)**: Chrome Web Speech API recognition runs 100% locally with live floating captions as you speak.
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`)**: локальное распознавание Chrome Web Speech API без отправки звука на сервер с плавающими субтитрами.
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
- * ⚡ **浏览器本地零延迟同声字幕 (`browser`)**:Chrome Web Speech API 本地离线解析,说话同时浮动显示实时字幕。
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': 'the browser does the recognition, no key needed',
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
- if (typeof window !== 'undefined') {
511
- window.addEventListener('dsh:tts:start', () => {
512
- voice.set({ isTtsSpeaking: true })
513
- })
514
- window.addEventListener('dsh:tts:stop', () => {
515
- voice.set({ isTtsSpeaking: false })
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 && options.lang !== 'auto' ? options.lang : 'ru-RU'
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
- let chainsPromise = null
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 (!chainsPromise) {
949
- chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
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
- chainsPromise = null
1056
+ voice._chainsPromise = null
954
1057
  return {}
955
1058
  })
956
1059
  }
957
- return chainsPromise.then((modes) => {
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
- if (typeof window !== 'undefined') {
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: 'Assistant speaking (gated mode)' })
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
- function hotkeyMatches(event, name) {
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
- function isHotkeyRelease(event, name) {
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 LANGS = ['auto', 'ru', 'en', 'uk', 'de']
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
- if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
2157
- const tag = document.createElement('style')
2158
- tag.textContent = SET_CSS
2159
- tag.setAttribute('data-dsh-plugin', 'dsh-voice')
2160
- tag.dataset.pluginCss = setCssId
2161
- document.head.appendChild(tag)
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 ? `Error: ${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
- /* best effort update check */
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 || 'Update completed successfully.')
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') + ' (spoken -> replacement)'),
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
- React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2858
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.6')
2859
- ),
2860
- updaterStatus && updaterStatus.updateAvailable
2861
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2862
- t('updateAvailable') + ': v' + updaterStatus.latestVersion
2863
- )
2864
- : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2865
- t('upToDate')
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
- setServerConfig(data.config)
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) => React.createElement('div', { className: 'cb-field' },
3161
- React.createElement('label', null, t('language')),
3162
- React.createElement('select', {
3163
- value: modeVal(mode, 'language', 'ru'), disabled: !writable,
3164
- onChange: (e) => setIn(mode, 'language', e.target.value),
3165
- }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))),
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: () => 'Voice input',
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
  })
@@ -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
- writeJson(res, 200, { ok: true, config: live() })
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 rev
103
+ let currentRev
92
104
  try {
93
- rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
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, rev)
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, rev)
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
- for (const [k, v] of Object.entries(merged)) {
114
- await scope.set(k, v).catch(() => {})
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
- writeJson(res, 200, { ok: true, config: live() })
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
- res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
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
- req.on('data', (c) => {
41
+ let exceeded = false
42
+ const onData = (c) => {
43
+ if (exceeded) return
21
44
  size += c.length
22
- if (size > maxBytes) { reject(new Error('body too large')); req.destroy(); return }
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) { writeJson(res, 400, { ok: false, error: { code: 'body', message: e.message } }); return }
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.11",
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 --check lib/*.js scripts/*.mjs",
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": {