@goodandready/dsh-voice 0.9.10 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js 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)',
@@ -256,7 +265,7 @@ window.__ModuleLoader__.load({
256
265
  'hide': '隐藏',
257
266
  'title': '语音',
258
267
  'recordSlot': '语音录制',
259
- 'browserHint': '浏览器直接识别,无需密钥',
268
+ 'browserHint': 'Web Speech 语音识别 (设为 auto 时使用页面或浏览器语言,Chrome 会上传音频至 Google)',
260
269
  'openaiHint': 'whisper-1 · 密钥 OPENAI_API_KEY',
261
270
  'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · 密钥 SILICONFLOW_API_KEY',
262
271
  'deepinfraHint': 'openai/whisper-large-v3-turbo · 密钥 DEEPINFRA_API_KEY',
@@ -283,6 +292,12 @@ window.__ModuleLoader__.load({
283
292
  'hotkeyHint1': '按住录音,松开发送给智能体,按 Esc 取消。',
284
293
  'hotkeyHint2': '支持任意按键:字母键、功能键 (F1-F12) 或修饰键。',
285
294
  'language': '识别语言',
295
+ 'langAuto': '自动检测',
296
+ 'langRu': '俄语 (ru)',
297
+ 'langEn': '英语 (en)',
298
+ 'langZh': '中文 (zh)',
299
+ 'langUk': '乌克兰语 (uk)',
300
+ 'langDe': '德语 (de)',
286
301
  'dictationHint': '语音按停顿自动切句,文字实时追加至输入框。',
287
302
  'pauseMs': '断句静音停顿时长(毫秒)',
288
303
  'pauseHint': '数值越小切分越频繁、出字越快,但可能切断未说完的词',
@@ -398,6 +413,9 @@ window.__ModuleLoader__.load({
398
413
  'updating': '正在更新…',
399
414
  'updateSuccess': '更新成功!请重启 DSH 生效。',
400
415
  'updateFailed': '更新失败,请查看日志。',
416
+ 'updateCheckFailed': '检查更新失败',
417
+ 'checkingUpdates': '正在检查更新…',
418
+ 'updateStatusUnknown': '版本状态未知',
401
419
  'sensevoiceTitle': 'SenseVoice 本地 ASR (一键部署)',
402
420
  'sensevoiceDesc': 'CPU 上超低延迟离线语音识别 (50–100ms,中英俄多语种)。',
403
421
  'installSensevoice': '一键安装 SenseVoice-Small',
@@ -507,13 +525,34 @@ window.__ModuleLoader__.load({
507
525
  subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l) },
508
526
  }
509
527
 
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
- })
528
+ function registerGlobalLifecycle() {
529
+ if (typeof window === 'undefined') return () => {}
530
+ const onTtsStart = () => voice.set({ isTtsSpeaking: true })
531
+ const onTtsStop = () => voice.set({ isTtsSpeaking: false })
532
+ const onSettingsSaved = () => { voice._chainsPromise = null }
533
+ const onPageUnload = () => {
534
+ if (voice.rec && typeof voice.teardownRec === 'function') voice.teardownRec(voice.rec)
535
+ if (voice.browser) {
536
+ try { voice.browser.abort() } catch (_) {}
537
+ }
538
+ }
539
+
540
+ window.addEventListener('dsh:tts:start', onTtsStart)
541
+ window.addEventListener('dsh:tts:stop', onTtsStop)
542
+ window.addEventListener('dsh-voice:settings-saved', onSettingsSaved)
543
+ window.addEventListener('pagehide', onPageUnload)
544
+ window.addEventListener('beforeunload', onPageUnload)
545
+
546
+ return () => {
547
+ window.removeEventListener('dsh:tts:start', onTtsStart)
548
+ window.removeEventListener('dsh:tts:stop', onTtsStop)
549
+ window.removeEventListener('dsh-voice:settings-saved', onSettingsSaved)
550
+ window.removeEventListener('pagehide', onPageUnload)
551
+ window.removeEventListener('beforeunload', onPageUnload)
552
+ onPageUnload()
553
+ if (typeof voice.clearGlobalHotkey === 'function') voice.clearGlobalHotkey()
554
+ voice.set({ phase: 'idle', caption: '', interimTranscript: '', isTtsSpeaking: false })
555
+ }
517
556
  }
518
557
 
519
558
  function triggerBargeIn() {
@@ -702,11 +741,15 @@ window.__ModuleLoader__.load({
702
741
  }
703
742
 
704
743
  function tidyPhrase(text) {
705
- let s = String(text || '').trim()
706
- if (!s) return s
744
+ if (!text) return ''
745
+ if (/^\n+$/.test(text)) return text
746
+ const lead = (text.match(/^\n+/) || [''])[0]
747
+ const trail = (text.match(/\n+$/) || [''])[0]
748
+ let s = String(text).trim()
749
+ if (!s) return lead || trail || ''
707
750
  s = s.replace(/\s*,\s*/g, ', ')
708
- s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, lead, ch) => lead + ch.toUpperCase())
709
- return s
751
+ s = s.replace(/(^|[.!?\n]\s+)([a-zа-яё])/gi, (m, l, ch) => l + ch.toUpperCase())
752
+ return lead + s + trail
710
753
  }
711
754
 
712
755
  // Functional speech edit commands (#37): spoken line breaks and punctuation -> symbols.
@@ -724,7 +767,12 @@ window.__ModuleLoader__.load({
724
767
  function applyVoiceCommands(text) {
725
768
  let s = text
726
769
  for (const [re, to] of VOICE_COMMANDS) s = s.replace(re, to)
727
- return s.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]+/g, ' ').trim()
770
+ if (/^\n+$/.test(s)) return s
771
+ const lead = (s.match(/^\n+/) || [''])[0]
772
+ const trail = (s.match(/\n+$/) || [''])[0]
773
+ const trimmed = s.replace(/[ \t]*\n[ \t]*/g, '\n').replace(/[ \t]+/g, ' ').trim()
774
+ if (!trimmed) return lead || trail || ''
775
+ return lead + trimmed + trail
728
776
  }
729
777
 
730
778
  // Insert history for undo (#29-9). Browser-only storage.
@@ -747,22 +795,33 @@ window.__ModuleLoader__.load({
747
795
  return t('undone')
748
796
  }
749
797
 
750
- function appendDraft(text) {
798
+ function insertDraftText(text) {
751
799
  const actions = voice.inputActions
752
- if (!actions || typeof actions.setDraft !== 'function') {
800
+ if (!actions || (typeof actions.setDraft !== 'function' && typeof actions.insertText !== 'function')) {
753
801
  voice.set({ phase: 'error', error: t('composerUnavailable') })
754
- return
802
+ return false
803
+ }
804
+ if (typeof actions.insertText === 'function') {
805
+ try { actions.insertText(text); return true } catch (_) {}
755
806
  }
756
- let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
757
- if (!clean) return
758
807
  const draft = voice.input && typeof voice.input.draft === 'string' ? voice.input.draft : ''
759
808
  const before = draft
760
- actions.setDraft(draft ? draft + ' ' + clean : clean)
809
+ const sep = (!draft || draft.endsWith('\n') || text.startsWith('\n')) ? '' : ' '
810
+ const after = draft + sep + text
811
+ actions.setDraft(after)
761
812
  const limit = Number(voice.settings.historyLimit)
762
813
  if (limit > 0) {
763
- insertHistory.push({ before, added: draft ? ' ' + clean : clean, after: draft ? draft + ' ' + clean : clean })
814
+ insertHistory.push({ before, added: sep + text, after })
764
815
  while (insertHistory.length > limit) insertHistory.shift()
765
816
  }
817
+ return true
818
+ }
819
+
820
+ function appendDraft(text) {
821
+ if (!text) return
822
+ let clean = voice.settings.voiceCommands ? applyVoiceCommands(text) : tidyPhrase(text)
823
+ if (!clean) return
824
+ insertDraftText(clean)
766
825
  }
767
826
 
768
827
  // Human-readable key name.
@@ -851,6 +910,21 @@ window.__ModuleLoader__.load({
851
910
  return speechRecognitionCtor() !== null
852
911
  }
853
912
 
913
+ function resolveBrowserRecognitionLang(lang) {
914
+ if (lang && lang !== 'auto') {
915
+ if (lang.includes('-')) return lang
916
+ const BCP47 = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', uk: 'uk-UA', de: 'de-DE' }
917
+ return BCP47[lang.toLowerCase()] || lang
918
+ }
919
+ if (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) {
920
+ return document.documentElement.lang
921
+ }
922
+ if (typeof navigator !== 'undefined' && navigator.language) {
923
+ return navigator.language
924
+ }
925
+ return 'en-US'
926
+ }
927
+
854
928
  /**
855
929
  * @param options {{lang: string, continuous: boolean, onInterim, onFinal, onError}}
856
930
  * @returns {{stop: Function, abort: Function}}
@@ -858,10 +932,12 @@ window.__ModuleLoader__.load({
858
932
  function startBrowserRecognition(options) {
859
933
  const Ctor = speechRecognitionCtor()
860
934
  const recognition = new Ctor()
861
- recognition.lang = options.lang && options.lang !== 'auto' ? options.lang : 'ru-RU'
935
+ recognition.lang = resolveBrowserRecognitionLang(options.lang)
862
936
  recognition.continuous = options.continuous !== false
863
937
  recognition.interimResults = true
864
938
  let stopped = false
939
+ let stopPromise = null
940
+ let resolveStop = null
865
941
 
866
942
  recognition.onresult = (event) => {
867
943
  let interim = ''
@@ -878,12 +954,18 @@ window.__ModuleLoader__.load({
878
954
  // no-speech and aborted are normal life, not failures.
879
955
  const code = event && event.error
880
956
  if (code === 'no-speech' || code === 'aborted') return
957
+ stopped = true
958
+ try { recognition.abort() } catch (_) {}
959
+ if (resolveStop) { resolveStop(); resolveStop = null }
881
960
  options.onError(code || t('recognitionError'))
882
961
  }
883
962
  // The browser ends recognition on its own (pauses, timeout). Restart
884
963
  // until we stop it, otherwise dictation dies silently on the first pause.
885
964
  recognition.onend = () => {
886
- if (stopped) return
965
+ if (stopped) {
966
+ if (resolveStop) { resolveStop(); resolveStop = null }
967
+ return
968
+ }
887
969
  try { recognition.start() } catch (alreadyRunning) { /* already running */ }
888
970
  }
889
971
 
@@ -891,30 +973,39 @@ window.__ModuleLoader__.load({
891
973
  options.onError(String(cannotStart && cannotStart.message || cannotStart))
892
974
  }
893
975
  return {
894
- stop() { stopped = true; try { recognition.stop() } catch (already) { /* already stopped */ } },
895
- abort() { stopped = true; try { recognition.abort() } catch (already) { /* already stopped */ } },
976
+ stop() {
977
+ if (stopPromise) return stopPromise
978
+ stopped = true
979
+ stopPromise = new Promise((resolve) => {
980
+ resolveStop = resolve
981
+ try { recognition.stop() } catch (already) { resolve() }
982
+ setTimeout(resolve, 800)
983
+ })
984
+ return stopPromise
985
+ },
986
+ abort() {
987
+ stopped = true
988
+ if (resolveStop) { resolveStop(); resolveStop = null }
989
+ try { recognition.abort() } catch (already) { /* already stopped */ }
990
+ },
896
991
  }
897
992
  }
898
993
 
899
994
  // Fetch the mode chain from the host once. Only needed to decide whether
900
995
 
901
- voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory }
902
- // to use browser recognition or record a file.
903
- let chainsPromise = null
904
- if (typeof window !== 'undefined') {
905
- window.addEventListener('dsh-voice:settings-saved', () => { chainsPromise = null })
906
- }
996
+ voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
997
+ voice._chainsPromise = null
907
998
  function modeChain(mode) {
908
- if (!chainsPromise) {
909
- chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
999
+ if (!voice._chainsPromise) {
1000
+ voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
910
1001
  .then((res) => res.json())
911
1002
  .then((data) => (data && data.modes) || {})
912
1003
  .catch(() => {
913
- chainsPromise = null
1004
+ voice._chainsPromise = null
914
1005
  return {}
915
1006
  })
916
1007
  }
917
- return chainsPromise.then((modes) => {
1008
+ return voice._chainsPromise.then((modes) => {
918
1009
  const row = modes[mode] || {}
919
1010
  return {
920
1011
  chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
@@ -931,16 +1022,7 @@ window.__ModuleLoader__.load({
931
1022
  if (rec.analyser) { try { rec.analyser.disconnect() } catch (e) { /* already disconnected */ } }
932
1023
  if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
933
1024
  }
934
- if (typeof window !== 'undefined') {
935
- const onPageUnload = () => {
936
- if (voice.rec) teardown(voice.rec)
937
- if (voice.browser) {
938
- try { voice.browser.abort() } catch (e) { /* ignore */ }
939
- }
940
- }
941
- window.addEventListener('pagehide', onPageUnload)
942
- window.addEventListener('beforeunload', onPageUnload)
943
- }
1025
+ voice.teardownRec = teardown
944
1026
  function waitStop(recorder) {
945
1027
  if (!recorder || recorder.state === 'inactive') return Promise.resolve()
946
1028
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
@@ -992,7 +1074,6 @@ window.__ModuleLoader__.load({
992
1074
  rec.analyser.fftSize = 128
993
1075
  lastNode.connect(rec.analyser)
994
1076
  }
995
- // No timeslice: only then each stop() yields a standalone webm file.
996
1077
  recorder.start()
997
1078
  return rec
998
1079
  }
@@ -1010,8 +1091,6 @@ window.__ModuleLoader__.load({
1010
1091
  }
1011
1092
  return raw
1012
1093
  }
1013
- // Cut the current phrase: stop the recorder, send the finished file and
1014
- // immediately start a new recording with the same recorder.
1015
1094
  let dictationQueue = Promise.resolve()
1016
1095
  function cutPhrase() {
1017
1096
  const rec = voice.rec
@@ -1047,40 +1126,22 @@ window.__ModuleLoader__.load({
1047
1126
  if (rec.cancelled) return
1048
1127
  const text = out && out.text ? out.text : ''
1049
1128
  const action = out && out.action ? out.action : null
1050
-
1051
1129
  if (voice.settings.structuredPromptVoice !== false && text) {
1052
1130
  const promptInfo = findActiveStructuredPrompt()
1053
1131
  if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
1054
1132
  return
1055
1133
  }
1056
1134
  }
1057
-
1058
- if (action === 'send') {
1059
- if (text) appendDraft(text)
1060
- if (voice.inputActions && typeof voice.inputActions.send === 'function') {
1061
- voice.inputActions.send()
1062
- }
1135
+ if (action === 'send' || action === 'clear' || action === 'newline') {
1136
+ executeVoiceAction(action, text)
1063
1137
  return
1064
1138
  }
1065
1139
  if (action === 'cancel') {
1066
1140
  cancelCurrent()
1067
1141
  return
1068
1142
  }
1069
- if (action === 'clear') {
1070
- if (voice.inputActions && typeof voice.inputActions.clear === 'function') {
1071
- voice.inputActions.clear()
1072
- }
1073
- return
1074
- }
1075
- if (action === 'newline') {
1076
- appendDraft(text ? (text + '\n') : '\n')
1077
- return
1078
- }
1079
-
1080
1143
  const delay = Number(voice.settings.sendDelayMs) || 0
1081
1144
  if (text && delay > 0 && !voice.holding) {
1082
- // Delayed insert with a cancel window (#29-5): text is already
1083
- // inserted; the window only allows undoing it.
1084
1145
  appendDraft(text)
1085
1146
  voice.set({ phase: 'pending', pending: { text, undoOnly: true, leftMs: delay } })
1086
1147
  return
@@ -1094,9 +1155,6 @@ window.__ModuleLoader__.load({
1094
1155
  })
1095
1156
  })
1096
1157
  }
1097
-
1098
- // Browser recognition instead of file recording. Returns false when the
1099
- // browser cannot do it — then fall back to the normal path.
1100
1158
  function startBrowserLeg(mode, language) {
1101
1159
  if (voice.settings && voice.settings.localOnly) return false
1102
1160
  if (!browserRecognitionAvailable()) return false
@@ -1111,12 +1169,13 @@ window.__ModuleLoader__.load({
1111
1169
  voice.interimTranscript = text
1112
1170
  }
1113
1171
  voice.notify()
1114
- // Wake-word (#45): if interim starts with the trigger phrase, stop
1115
- // browser listening and switch to normal recording.
1116
1172
  const ww = String(voice.settings.wakeWord || '').trim().toLowerCase()
1117
1173
  if (ww && mode === 'dictation' && !voice.rec) {
1118
1174
  const t = String(text || '').trim().toLowerCase()
1119
1175
  if (t.startsWith(ww)) {
1176
+ if (voice.browser && typeof voice.browser.abort === 'function') {
1177
+ try { voice.browser.abort() } catch (_) {}
1178
+ }
1120
1179
  voice.browser = null
1121
1180
  voice.caption = ''
1122
1181
  voice.interimTranscript = ''
@@ -1137,12 +1196,10 @@ window.__ModuleLoader__.load({
1137
1196
  finals.push(text)
1138
1197
  voice.caption = ''
1139
1198
  voice.interimTranscript = ''
1140
- // Dictation appends immediately; messages accumulate until release.
1141
1199
  if (mode === 'dictation') appendDraft(text)
1142
1200
  else voice.notify()
1143
1201
  },
1144
1202
  onError: (reason) => {
1145
- // Browser failed after start — say so instead of pretending to listen.
1146
1203
  voice.browser = null
1147
1204
  voice.interimTranscript = ''
1148
1205
  voice.set({ phase: 'error', error: t('browserFailed') + reason })
@@ -1152,7 +1209,6 @@ window.__ModuleLoader__.load({
1152
1209
  voice.notify()
1153
1210
  return true
1154
1211
  }
1155
-
1156
1212
  function startRecording(mode) {
1157
1213
  if (voice.phase !== 'idle' && voice.phase !== 'error') return
1158
1214
  voice.activeOpId = (voice.activeOpId || 0) + 1
@@ -1168,7 +1224,6 @@ window.__ModuleLoader__.load({
1168
1224
  dictationQueue = Promise.resolve()
1169
1225
  voice.set({ phase: 'recording', mode, error: '', levels: [], caption: '', interimTranscript: '' })
1170
1226
  modeChain(mode).then((info) => {
1171
- // Browser leg only when it is explicitly first in the chain.
1172
1227
  if (info.chain[0] === 'browser' && startBrowserLeg(mode, info.language)) return
1173
1228
  openMic(mode)
1174
1229
  .then((rec) => {
@@ -1182,47 +1237,50 @@ window.__ModuleLoader__.load({
1182
1237
  .catch((err) => voice.set({ phase: 'error', error: String(err && err.message ? err.message : err), rec: null }))
1183
1238
  })
1184
1239
  }
1185
-
1186
1240
  function startDictation() { startRecording('dictation') }
1187
1241
  function startMessage() { startRecording('message') }
1188
-
1189
1242
  // --------------------------------------------------------- hold gesture
1190
- //
1191
- // Two gestures on one button: a short click toggles recording until the
1192
- // next click; hold records only while pressed. Release sends.
1193
- //
1194
- // Distinguished by time: release before the threshold is a click.
1195
1243
  const HOLD_THRESHOLD_MS = 350
1196
-
1197
1244
  const hold = { active: false, mode: null, startedAt: 0, armed: false }
1198
-
1199
- function beginHold(mode) {
1245
+ let activePointerCleanup = null
1246
+ function beginHold(mode, pointerEvent) {
1200
1247
  if (hold.armed || (voice.phase !== 'idle' && voice.phase !== 'error')) return
1201
1248
  hold.armed = true
1202
1249
  hold.mode = mode
1203
1250
  hold.startedAt = Date.now()
1204
1251
  hold.active = false
1205
- // Start recording immediately: waiting for the threshold loses the first word.
1252
+ if (pointerEvent?.target?.setPointerCapture && pointerEvent.pointerId !== undefined) {
1253
+ try { pointerEvent.target.setPointerCapture(pointerEvent.pointerId) } catch (e) {}
1254
+ }
1255
+ if (typeof window !== 'undefined') {
1256
+ if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1257
+ const onUp = () => endHold(false)
1258
+ const onCancel = () => endHold(true)
1259
+ window.addEventListener('pointerup', onUp, true)
1260
+ window.addEventListener('pointercancel', onCancel, true)
1261
+ window.addEventListener('blur', onCancel, true)
1262
+ activePointerCleanup = () => {
1263
+ window.removeEventListener('pointerup', onUp, true)
1264
+ window.removeEventListener('pointercancel', onCancel, true)
1265
+ window.removeEventListener('blur', onCancel, true)
1266
+ activePointerCleanup = null
1267
+ }
1268
+ }
1206
1269
  startRecording(mode)
1207
1270
  voice.holding = true
1208
1271
  voice.notify()
1209
1272
  }
1210
-
1211
1273
  function endHold(cancelled) {
1212
1274
  if (!hold.armed) return
1275
+ if (activePointerCleanup) { try { activePointerCleanup() } catch (e) {} }
1213
1276
  const heldMs = Date.now() - hold.startedAt
1214
1277
  hold.armed = false
1215
1278
  hold.active = false
1216
1279
  voice.holding = false
1217
- // Short press is a click: recording is already on, leave it running;
1218
- // the second click will stop it.
1219
1280
  if (!cancelled && heldMs < HOLD_THRESHOLD_MS) { voice.notify(); return }
1220
1281
  if (cancelled) cancelCurrent()
1221
1282
  else stopCurrent()
1222
1283
  }
1223
-
1224
- // Hotkey: holding a key is easier than aiming the mouse. While the key is
1225
- // down we record; Escape cancels.
1226
1284
  function hotkeyMatches(event, name) {
1227
1285
  if (!event || !name) return false
1228
1286
  if (typeof name === 'string' && name.includes('+')) {
@@ -1244,7 +1302,6 @@ window.__ModuleLoader__.load({
1244
1302
  if (name === 'Meta') return event.key === 'Meta'
1245
1303
  return event.code === name || event.key === name
1246
1304
  }
1247
-
1248
1305
  function isAllowedInEditable(name) {
1249
1306
  if (!name) return false
1250
1307
  if (['Control', 'Alt', 'Shift', 'Meta'].includes(name)) return true
@@ -1253,9 +1310,7 @@ window.__ModuleLoader__.load({
1253
1310
  if (['Tab', 'Pause', 'ScrollLock', 'Insert'].includes(name)) return true
1254
1311
  return false
1255
1312
  }
1256
-
1257
1313
  let activeHotkeyCleanup = null
1258
-
1259
1314
  function clearGlobalHotkey() {
1260
1315
  if (typeof activeHotkeyCleanup === 'function') {
1261
1316
  try { activeHotkeyCleanup() } catch (_) { /* ignore */ }
@@ -1266,31 +1321,35 @@ window.__ModuleLoader__.load({
1266
1321
  window.__dsh_voice_hotkey_cleanup = null
1267
1322
  }
1268
1323
  }
1269
-
1324
+ voice.clearGlobalHotkey = clearGlobalHotkey
1270
1325
  function installHotkey(ctx, keyName, mode) {
1271
1326
  clearGlobalHotkey()
1272
1327
  if (typeof document === 'undefined' || !keyName) return () => {}
1273
1328
  const down = (event) => {
1274
1329
  if (event.repeat) return
1275
- // A modifier, function key or combo does not fight the text field:
1276
- // modifiers/combos do not type plain text. A plain letter key must not be hijacked while typing.
1277
1330
  const target = event.target
1278
- const isEditable = target && (
1279
- target.tagName === 'INPUT' ||
1280
- target.tagName === 'TEXTAREA' ||
1281
- target.isContentEditable ||
1282
- (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true')
1283
- )
1331
+ const isEditable = target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || (typeof target.getAttribute === 'function' && target.getAttribute('contenteditable') === 'true'))
1284
1332
  if (isEditable && !isAllowedInEditable(keyName)) return
1285
-
1286
1333
  if (hotkeyMatches(event, keyName)) {
1287
1334
  event.preventDefault()
1288
1335
  event.stopPropagation()
1289
1336
  beginHold(mode)
1290
1337
  }
1291
1338
  }
1339
+ function isHotkeyRelease(event, name) {
1340
+ if (!event || !name) return false
1341
+ if (typeof name === 'string' && name.includes('+')) {
1342
+ const parts = name.split('+')
1343
+ if (event.code === parts[parts.length - 1] || event.key === parts[parts.length - 1]) return true
1344
+ for (let i = 0; i < parts.length - 1; i++) {
1345
+ if (event.key === parts[i] || event.code === parts[i]) return true
1346
+ }
1347
+ return false
1348
+ }
1349
+ return event.code === name || event.key === name
1350
+ }
1292
1351
  const up = (event) => {
1293
- if (hotkeyMatches(event, keyName)) {
1352
+ if (hold.armed && isHotkeyRelease(event, keyName)) {
1294
1353
  event.preventDefault()
1295
1354
  endHold(false)
1296
1355
  } else if (event.key === 'Escape' && hold.armed) {
@@ -1317,7 +1376,6 @@ window.__ModuleLoader__.load({
1317
1376
  }
1318
1377
  return cleanup
1319
1378
  }
1320
-
1321
1379
  function cancelCurrent() {
1322
1380
  voice.activeOpId = (voice.activeOpId || 0) + 1
1323
1381
  announceVoice('end')
@@ -1340,27 +1398,25 @@ window.__ModuleLoader__.load({
1340
1398
  } catch (e) { /* already stopped */ }
1341
1399
  stopped.then(() => { teardown(rec); voice.rec = null; voice.set({ phase: 'idle', error: '' }) })
1342
1400
  }
1343
-
1344
- // Stop on the second click: dictation flushes the tail; a voice message
1345
- // sends the whole recording and opens the cancel window.
1346
1401
  function stopCurrent() {
1347
1402
  announceVoice('end')
1348
1403
  focusComposer()
1349
1404
  if (voice.browser) {
1350
1405
  const mode = voice.mode
1351
- const said = (voice.browserFinals || []).join(' ').trim()
1352
- voice.browser.stop()
1406
+ const b = voice.browser
1353
1407
  voice.browser = null
1354
- voice.browserFinals = null
1355
1408
  voice.caption = ''
1356
- if (!said) { voice.set({ phase: 'idle' }); return }
1357
- if (mode === 'message') {
1358
- appendDraft(said)
1359
- voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
1360
- } else {
1361
- // Dictation already appended while speaking — nothing extra to add.
1362
- voice.set({ phase: 'idle' })
1363
- }
1409
+ Promise.resolve(b.stop()).then(() => {
1410
+ const said = (voice.browserFinals || []).join(' ').trim()
1411
+ voice.browserFinals = null
1412
+ if (!said) { voice.set({ phase: 'idle' }); return }
1413
+ if (mode === 'message') {
1414
+ appendDraft(said)
1415
+ voice.set({ phase: 'pending', pending: { text: said, leftMs: voice.settings.autoSendMs } })
1416
+ } else {
1417
+ voice.set({ phase: 'idle' })
1418
+ }
1419
+ })
1364
1420
  return
1365
1421
  }
1366
1422
  const rec = voice.rec
@@ -1377,67 +1433,101 @@ window.__ModuleLoader__.load({
1377
1433
  const blob = new Blob(rec.chunks, { type: rec.mime })
1378
1434
  teardown(rec)
1379
1435
  voice.rec = null
1380
- if (blob.size < 1200) { voice.set({ phase: 'idle' }); return }
1436
+ if (blob.size < (mode === 'dictation' ? 600 : 1200)) {
1437
+ if (mode === 'dictation') { try { await dictationQueue } catch (_) {} }
1438
+ voice.set({ phase: 'idle' })
1439
+ return
1440
+ }
1381
1441
  if (voice.lastNote && voice.lastNote.url) {
1382
- try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) { /* ignore */ }
1442
+ try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) {}
1383
1443
  }
1384
1444
  let noteUrl = ''
1385
- try { noteUrl = URL.createObjectURL(blob) } catch (e) { /* ignore */ }
1445
+ try { noteUrl = URL.createObjectURL(blob) } catch (e) {}
1386
1446
  voice.lastNote = { blob, url: noteUrl, mime: rec.mime, text: '' }
1387
1447
  voice.set({ phase: 'processing' })
1388
- try {
1389
- const out = await sendAudio(blob, rec.mime, mode)
1448
+ const processTail = async () => {
1390
1449
  if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1391
- if (out.command) { runSessionCommand(out.command); return }
1392
- const text = out.text || ''
1393
- const action = out.action || null
1394
- if (!text && !action) { voice.set({ phase: 'error', error: t('nothingHeard') }); return }
1395
-
1396
- if (voice.settings.structuredPromptVoice !== false && text) {
1397
- const promptInfo = findActiveStructuredPrompt()
1398
- if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
1399
- voice.set({ phase: 'idle' })
1450
+ try {
1451
+ const out = await sendAudio(blob, rec.mime, mode)
1452
+ if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1453
+ if (out.command) { runSessionCommand(out.command); return }
1454
+ const text = out.text || ''
1455
+ const action = out.action || null
1456
+ if (!text && !action) {
1457
+ if (mode === 'message') voice.set({ phase: 'error', error: t('nothingHeard') })
1458
+ else voice.set({ phase: 'idle' })
1400
1459
  return
1401
1460
  }
1402
- }
1403
-
1404
- if (action === 'send') {
1405
- if (text) appendDraft(text)
1406
- submitPending()
1407
- return
1408
- }
1409
- if (action === 'cancel') {
1410
- cancelCurrent()
1411
- return
1412
- }
1413
- if (action === 'clear') {
1414
- if (voice.inputActions && typeof voice.inputActions.clear === 'function') {
1415
- voice.inputActions.clear()
1461
+ if (voice.settings.structuredPromptVoice !== false && text) {
1462
+ const promptInfo = findActiveStructuredPrompt()
1463
+ if (promptInfo && submitToStructuredPrompt(text, promptInfo)) {
1464
+ voice.set({ phase: 'idle' })
1465
+ return
1466
+ }
1416
1467
  }
1417
- voice.set({ phase: 'idle' })
1418
- return
1419
- }
1420
-
1421
- appendDraft(text)
1422
- if (voice.lastNote) voice.lastNote.text = text
1423
- if (mode === 'message') {
1424
- voice.set({ phase: 'pending', pending: { text: text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1425
- } else {
1426
- voice.set({ phase: 'idle' })
1468
+ if (action === 'send' || action === 'clear' || action === 'newline') {
1469
+ executeVoiceAction(action, text)
1470
+ return
1471
+ }
1472
+ if (action === 'cancel') { cancelCurrent(); return }
1473
+ appendDraft(text)
1474
+ if (voice.lastNote) voice.lastNote.text = text
1475
+ if (mode === 'message') {
1476
+ voice.set({ phase: 'pending', pending: { text, totalMs: voice.settings.autoSendMs, leftMs: voice.settings.autoSendMs, audioUrl: noteUrl } })
1477
+ } else {
1478
+ voice.set({ phase: 'idle' })
1479
+ }
1480
+ } catch (e) {
1481
+ if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1482
+ voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
1427
1483
  }
1428
- } catch (e) {
1429
- if (rec.cancelled || voice.activeOpId !== opId || voice.phase === 'idle') return
1430
- voice.set({ phase: 'error', error: String(e && e.message ? e.message : e) })
1484
+ }
1485
+ if (mode === 'dictation') {
1486
+ dictationQueue = dictationQueue.then(processTail)
1487
+ try { await dictationQueue } catch (_) {}
1488
+ } else {
1489
+ await processTail()
1431
1490
  }
1432
1491
  })
1433
1492
  }
1434
-
1493
+ function clearDraft() {
1494
+ const actions = voice.inputActions
1495
+ if (!actions || typeof actions.setDraft !== 'function') {
1496
+ voice.set({ phase: 'error', error: t('composerUnavailable') })
1497
+ return false
1498
+ }
1499
+ actions.setDraft('')
1500
+ return true
1501
+ }
1502
+ function executeVoiceAction(action, text) {
1503
+ if (action === 'send') {
1504
+ if (text) appendDraft(text)
1505
+ submitPending()
1506
+ return true
1507
+ }
1508
+ if (action === 'clear') {
1509
+ const ok = clearDraft()
1510
+ if (ok && voice.phase !== 'recording') voice.set({ phase: 'idle' })
1511
+ return ok
1512
+ }
1513
+ if (action === 'newline') {
1514
+ const payload = text ? (text.endsWith('\n') ? text : text + '\n') : '\n'
1515
+ appendDraft(payload)
1516
+ if (voice.phase !== 'recording') voice.set({ phase: 'idle' })
1517
+ return true
1518
+ }
1519
+ if (action === 'cancel') { cancelCurrent(); return true }
1520
+ return false
1521
+ }
1522
+ voice.executeVoiceAction = executeVoiceAction
1435
1523
  function submitPending() {
1436
1524
  voice.pending = null
1437
- voice.set({ phase: 'idle' })
1438
1525
  const actions = voice.inputActions
1439
- if (!actions || typeof actions.submit !== 'function') return
1440
- // Polish the whole draft before submit (#46). Errors do not block.
1526
+ if (!actions || typeof actions.submit !== 'function') {
1527
+ voice.set({ phase: 'error', error: t('composerUnavailable') })
1528
+ return
1529
+ }
1530
+ voice.set({ phase: 'idle' })
1441
1531
  if (voice.settings.polishSend) {
1442
1532
  const run = async () => {
1443
1533
  try {
@@ -1467,36 +1557,23 @@ window.__ModuleLoader__.load({
1467
1557
  }
1468
1558
  setTimeout(() => { try { actions.submit() } catch (e) { /* composer busy */ } }, 0)
1469
1559
  }
1470
-
1471
- // Session voice commands (#48): clean "send/cancel/stop/continue".
1472
1560
  function runSessionCommand(cmd) {
1473
1561
  const actions = voice.inputActions
1474
1562
  voice.set({ phase: 'idle', pending: null })
1475
- switch (cmd) {
1476
- case 'send':
1477
- case 'continue':
1478
- if (actions && typeof actions.submit === 'function') actions.submit()
1479
- break
1480
- case 'cancel':
1481
- case 'stop':
1482
- // Clear wait/recording; leave the draft text intentionally untouched.
1483
- break
1484
- default:
1485
- break
1563
+ if ((cmd === 'send' || cmd === 'continue') && actions && typeof actions.submit === 'function') {
1564
+ actions.submit()
1486
1565
  }
1487
1566
  }
1488
-
1489
1567
  function keepPending() {
1490
1568
  voice.pending = null
1491
1569
  voice.set({ phase: 'idle' })
1492
1570
  }
1493
-
1494
1571
  // ----------------------------------------------------------- components
1495
1572
  function VoiceButtons(props) {
1496
1573
  const v = useVoice()
1497
1574
  voice.inputActions = props.inputActions
1498
1575
  voice.input = props.input
1499
- if (v.phase !== 'idle' && v.phase !== 'error') return null
1576
+ if (v.phase !== 'idle' && v.phase !== 'error' && !hold.armed) return null
1500
1577
  const err = v.phase === 'error'
1501
1578
  const hk = voice.hotkey ? ` (${voice.hotkey})` : ''
1502
1579
  return React.createElement(React.Fragment, null,
@@ -1507,13 +1584,17 @@ window.__ModuleLoader__.load({
1507
1584
  onClick: startDictation,
1508
1585
  }, micIcon()),
1509
1586
  React.createElement('button', {
1510
- type: 'button', className: 'dvo-btn', 'data-err': err ? '1' : '0',
1587
+ type: 'button', className: 'dvo-btn' + (hold.armed ? ' dvo-btn-active' : ''), 'data-err': err ? '1' : '0',
1511
1588
  title: err ? v.error : (t('messageBtn') + hk),
1512
1589
  'aria-label': err ? v.error : (t('messageBtn') + hk),
1513
- // Hold: record while pressed; leaving the control cancels.
1514
- onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
1590
+ onPointerDown: (e) => { e.preventDefault(); beginHold('message', e) },
1515
1591
  onPointerUp: () => endHold(false),
1516
- onPointerLeave: () => { if (hold.armed) endHold(true) },
1592
+ onPointerCancel: () => endHold(true),
1593
+ onPointerLeave: (e) => {
1594
+ if (hold.armed && (!e.target || typeof e.target.hasPointerCapture !== 'function' || !e.target.hasPointerCapture(e.pointerId))) {
1595
+ endHold(true)
1596
+ }
1597
+ },
1517
1598
  }, waveIcon()),
1518
1599
  voice.lastNote && voice.lastNote.url
1519
1600
  ? React.createElement('button', {
@@ -2066,34 +2147,42 @@ window.__ModuleLoader__.load({
2066
2147
  if (key) return t(key)
2067
2148
  return MODEL_HINT_STATIC[provider] || ''
2068
2149
  }
2069
- const LANGS = ['auto', 'ru', 'en', 'uk', 'de']
2150
+ const SUPPORTED_LANGS = [
2151
+ { code: '', labelKey: 'langAuto', defaultLabel: 'auto' },
2152
+ { code: 'ru', labelKey: 'langRu', defaultLabel: 'ru' },
2153
+ { code: 'en', labelKey: 'langEn', defaultLabel: 'en' },
2154
+ { code: 'zh', labelKey: 'langZh', defaultLabel: 'zh' },
2155
+ { code: 'uk', labelKey: 'langUk', defaultLabel: 'uk' },
2156
+ { code: 'de', labelKey: 'langDe', defaultLabel: 'de' },
2157
+ ]
2158
+ const LANGS = SUPPORTED_LANGS.map((o) => o.code)
2070
2159
 
2071
2160
  const SET_CSS =
2072
- '.cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
2073
- '.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}' +
2074
- '.cb-section-title{font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
2075
- '.cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
2076
- '.cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
2077
- '.cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
2078
- '.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}' +
2079
- '.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)}' +
2080
- '.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)}' +
2081
- '.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)}' +
2082
- '.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}' +
2083
- '.cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
2084
- '.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}' +
2085
- '.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))}' +
2086
- '.cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
2087
- '.cb-btn-primary:hover:not(:disabled){background:var(--dsw-alias-label-primary) !important;color:var(--dsw-alias-bg-layer-3) !important;opacity:0.88}' +
2088
- '.cb-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)}' +
2089
- '.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}' +
2090
- '.cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
2091
- '.cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
2092
- '.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}' +
2093
- '.cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
2094
- '.cb-check-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--dsw-alias-label-primary);user-select:none}' +
2095
- '.cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
2096
- '.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}' +
2161
+ '.dvo-settings-root.cb-page,.dvo-settings-root .cb-page{display:flex;flex-direction:column;gap:18px;padding:4px 0 24px;max-width:960px}' +
2162
+ '.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}' +
2163
+ '.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}' +
2164
+ '.dvo-settings-root .cb-section-desc{font-size:13px;color:var(--dsw-alias-label-secondary);margin-top:-4px;line-height:1.4}' +
2165
+ '.dvo-settings-root .cb-row{display:flex;flex-wrap:wrap;gap:10px;align-items:center}' +
2166
+ '.dvo-settings-root .cb-grid-2{display:grid;grid-template-columns:repeat(auto-fit, minmax(260px, 1fr));gap:12px}' +
2167
+ '.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}' +
2168
+ '.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)}' +
2169
+ '.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)}' +
2170
+ '.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)}' +
2171
+ '.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}' +
2172
+ '.dvo-settings-root .cb-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
2173
+ '.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}' +
2174
+ '.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))}' +
2175
+ '.dvo-settings-root .cb-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}' +
2176
+ '.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}' +
2177
+ '.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)}' +
2178
+ '.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}' +
2179
+ '.dvo-settings-root .cb-btn-mini:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)}' +
2180
+ '.dvo-settings-root .cb-field{display:flex;flex-direction:column;gap:6px;padding:6px 0;font-size:13px;color:var(--dsw-alias-label-primary)}' +
2181
+ '.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}' +
2182
+ '.dvo-settings-root .cb-field textarea{height:auto;padding:8px 10px;font-family:inherit}' +
2183
+ '.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}' +
2184
+ '.dvo-settings-root .cb-check-label input[type="checkbox"]{width:16px;height:16px;cursor:pointer}' +
2185
+ '.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}' +
2097
2186
  '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
2098
2187
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
2099
2188
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
@@ -2115,12 +2204,19 @@ window.__ModuleLoader__.load({
2115
2204
  '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
2116
2205
  '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
2117
2206
  const setCssId = 'dsh-voice/settings.module.css'
2118
- if (typeof document !== 'undefined' && !document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')) {
2119
- const tag = document.createElement('style')
2120
- tag.textContent = SET_CSS
2121
- tag.setAttribute('data-dsh-plugin', 'dsh-voice')
2122
- tag.dataset.pluginCss = setCssId
2123
- document.head.appendChild(tag)
2207
+ function installSettingsCss() {
2208
+ if (typeof document === 'undefined') return () => {}
2209
+ let tag = document.querySelector('style[data-dsh-plugin="dsh-voice"][data-plugin-css="' + setCssId + '"]')
2210
+ if (!tag) {
2211
+ tag = document.createElement('style')
2212
+ tag.textContent = SET_CSS
2213
+ tag.setAttribute('data-dsh-plugin', 'dsh-voice')
2214
+ tag.dataset.pluginCss = setCssId
2215
+ document.head.appendChild(tag)
2216
+ }
2217
+ return () => {
2218
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
2219
+ }
2124
2220
  }
2125
2221
 
2126
2222
  function createErrorBoundary() {
@@ -2633,8 +2729,12 @@ window.__ModuleLoader__.load({
2633
2729
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
2634
2730
  const data = await res.json()
2635
2731
  setUpdaterStatus(data)
2732
+ if (data && data.latestCheckFailed) {
2733
+ setUpdaterMsg(t('updateCheckFailed'))
2734
+ }
2636
2735
  } catch (e) {
2637
- /* best effort update check */
2736
+ setUpdaterStatus(null)
2737
+ setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
2638
2738
  } finally {
2639
2739
  setUpdaterLoading(false)
2640
2740
  }
@@ -2816,16 +2916,24 @@ window.__ModuleLoader__.load({
2816
2916
  ),
2817
2917
  React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
2818
2918
  React.createElement('div', { className: 'cb-row', style: { alignItems: 'center', gap: '12px' } },
2819
- React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2820
- 'v' + ((updaterStatus && updaterStatus.currentVersion) || '0.9.6')
2821
- ),
2822
- updaterStatus && updaterStatus.updateAvailable
2823
- ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2824
- t('updateAvailable') + ': v' + updaterStatus.latestVersion
2825
- )
2826
- : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2827
- t('upToDate')
2828
- ),
2919
+ updaterStatus && updaterStatus.currentVersion
2920
+ ? React.createElement('span', { className: 'cb-badge cb-badge-ok' }, 'v' + updaterStatus.currentVersion)
2921
+ : null,
2922
+ updaterLoading && (!updaterStatus || !updaterStatus.currentVersion)
2923
+ ? React.createElement('span', { className: 'cb-badge' }, '⏳ ' + t('checkingUpdates'))
2924
+ : !updaterStatus
2925
+ ? React.createElement('span', { className: 'cb-badge cb-badge-warn' }, '❓ ' + t('updateStatusUnknown'))
2926
+ : updaterStatus.updateAvailable
2927
+ ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2928
+ t('updateAvailable') + ': v' + updaterStatus.latestVersion
2929
+ )
2930
+ : updaterStatus.latestCheckFailed
2931
+ ? React.createElement('span', { className: 'cb-badge cb-badge-warn' },
2932
+ '⚠️ ' + t('updateCheckFailed')
2933
+ )
2934
+ : React.createElement('span', { className: 'cb-badge cb-badge-ok' },
2935
+ t('upToDate')
2936
+ ),
2829
2937
  updaterStatus && updaterStatus.updateAvailable
2830
2938
  ? React.createElement('button', {
2831
2939
  type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
@@ -2865,6 +2973,7 @@ window.__ModuleLoader__.load({
2865
2973
  const [err, setErr] = React.useState('')
2866
2974
  const [serverConfig, setServerConfig] = React.useState(null)
2867
2975
  const [configLoading, setConfigLoading] = React.useState(true)
2976
+ const [revision, setRevision] = React.useState(null)
2868
2977
 
2869
2978
  React.useEffect(() => {
2870
2979
  let alive = true
@@ -2873,6 +2982,7 @@ window.__ModuleLoader__.load({
2873
2982
  .then((data) => {
2874
2983
  if (alive && data && data.ok && data.config) {
2875
2984
  setServerConfig(data.config)
2985
+ if (typeof data.revision === 'number') setRevision(data.revision)
2876
2986
  setDraft((curr) => curr || deepClone(data.config))
2877
2987
  }
2878
2988
  })
@@ -3041,14 +3151,15 @@ window.__ModuleLoader__.load({
3041
3151
  const res = await fetch('/dsh-voice/config', {
3042
3152
  method: 'PUT',
3043
3153
  headers: { 'Content-Type': 'application/json' },
3044
- body: JSON.stringify({ config: draft }),
3154
+ body: JSON.stringify({ config: draft, expectedRevision: (typeof revision === 'number' ? revision : undefined) }),
3045
3155
  })
3046
3156
  const data = await res.json()
3047
3157
  if (res.ok && data && data.ok) {
3048
3158
  httpOk = true
3049
- if (data.config) {
3050
- setServerConfig(data.config)
3051
- }
3159
+ if (data.config) setServerConfig(data.config)
3160
+ if (typeof data.revision === 'number') setRevision(data.revision)
3161
+ } else if (res.status === 409) {
3162
+ httpError = data?.error?.message || 'Settings conflict: parallel changes detected'
3052
3163
  } else {
3053
3164
  httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
3054
3165
  }
@@ -3056,7 +3167,7 @@ window.__ModuleLoader__.load({
3056
3167
  httpError = e?.message || String(e)
3057
3168
  }
3058
3169
 
3059
- if (scope && typeof scope.set === 'function') {
3170
+ if (httpOk && scope && typeof scope.set === 'function') {
3060
3171
  for (const k of Object.keys(draft)) {
3061
3172
  try {
3062
3173
  await scope.set(k, draft[k])
@@ -3119,13 +3230,22 @@ window.__ModuleLoader__.load({
3119
3230
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
3120
3231
  )
3121
3232
 
3122
- const langField = (mode) => React.createElement('div', { className: 'cb-field' },
3123
- React.createElement('label', null, t('language')),
3124
- React.createElement('select', {
3125
- value: modeVal(mode, 'language', 'ru'), disabled: !writable,
3126
- onChange: (e) => setIn(mode, 'language', e.target.value),
3127
- }, LANGS.map((o) => React.createElement('option', { key: o, value: o }, o))),
3128
- )
3233
+ const langField = (mode) => {
3234
+ const val = modeVal(mode, 'language', '')
3235
+ const opts = SUPPORTED_LANGS.slice()
3236
+ if (val && !opts.some((o) => o.code === val)) {
3237
+ opts.push({ code: val, labelKey: val, defaultLabel: val })
3238
+ }
3239
+ return React.createElement('div', { className: 'cb-field' },
3240
+ React.createElement('label', null, t('language')),
3241
+ React.createElement('select', {
3242
+ value: val, disabled: !writable,
3243
+ onChange: (e) => setIn(mode, 'language', e.target.value),
3244
+ }, opts.map((o) => React.createElement('option', { key: o.code || 'auto', value: o.code },
3245
+ t(o.labelKey) !== o.labelKey ? t(o.labelKey) : o.defaultLabel
3246
+ ))),
3247
+ )
3248
+ }
3129
3249
 
3130
3250
  const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
3131
3251
  React.createElement('label', null, label),
@@ -3142,7 +3262,7 @@ window.__ModuleLoader__.load({
3142
3262
  const whisperRunning = !!(statusData && statusData.whisperRunning)
3143
3263
  const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
3144
3264
 
3145
- return React.createElement('div', { className: 'cb-page' },
3265
+ return React.createElement('div', { className: 'cb-page dvo-settings-root' },
3146
3266
  // 1. Connection & Engine Status Card
3147
3267
  React.createElement(ConnectionStatusCard, {
3148
3268
  t: t,
@@ -3418,13 +3538,14 @@ window.__ModuleLoader__.load({
3418
3538
  ),
3419
3539
  React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
3420
3540
  ),
3421
- (page || open) ? React.createElement('div', { className: 'dvo-pbody' }, React.createElement(VoiceSection, props)) : null,
3541
+ (page || open) ? React.createElement('div', { className: 'dvo-pbody dvo-settings-root' }, React.createElement(VoiceSection, props)) : null,
3422
3542
  ),
3423
3543
  )
3424
3544
  }
3425
3545
 
3426
3546
  function registerSettings(ctx) {
3427
3547
  ctx.effect(() => {
3548
+ const offCss = installSettingsCss()
3428
3549
  const offItem = ctx.slots.inject('plugins.item', () => ctx.slots.register(
3429
3550
  {
3430
3551
  name: 'plugins.item',
@@ -3455,6 +3576,7 @@ window.__ModuleLoader__.load({
3455
3576
  PluginCard,
3456
3577
  ))
3457
3578
  return () => {
3579
+ if (typeof offCss === 'function') offCss()
3458
3580
  if (typeof offItem === 'function') offItem()
3459
3581
  if (typeof offRow === 'function') offRow()
3460
3582
  if (typeof offLegacy === 'function') offLegacy()
@@ -3478,6 +3600,9 @@ window.__ModuleLoader__.load({
3478
3600
  const undo = [addLocale('en', en), addLocale('zh', zh)]
3479
3601
  return () => { for (const off of undo) off() }
3480
3602
  }, 'dsh-voice: locale dictionaries')
3603
+ ctx.effect(() => {
3604
+ return registerGlobalLifecycle()
3605
+ }, 'dsh-voice: global lifecycle')
3481
3606
  moduleT = ctx.locale.bind(NS)
3482
3607
  registerComposer(ctx)
3483
3608
  registerSettings(ctx)
@@ -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
 
@@ -62,6 +67,9 @@ export function registerConfigRoutes(ctx, {
62
67
  const rawConfig = payload && typeof payload.config === 'object' && payload.config !== null
63
68
  ? payload.config
64
69
  : payload
70
+ const expectedRevision = (typeof payload?.expectedRevision === 'number')
71
+ ? payload.expectedRevision
72
+ : ((typeof payload?.revision === 'number') ? payload.revision : undefined)
65
73
 
66
74
  if (!rawConfig || typeof rawConfig !== 'object' || Array.isArray(rawConfig)) {
67
75
  writeJson(res, 400, { ok: false, error: { code: 'body', message: 'body must be a JSON object' } })
@@ -88,21 +96,35 @@ export function registerConfigRoutes(ctx, {
88
96
  try {
89
97
  let saved = false
90
98
  if (settingsService) {
91
- let rev
99
+ let currentRev
92
100
  try {
93
- rev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
101
+ currentRev = settingsService.describe?.()?.find?.((r) => r.ns === NS)?.revision
94
102
  } catch { /* ignore revision lookup errors */ }
95
103
 
104
+ if (typeof expectedRevision === 'number' && typeof currentRev === 'number' && expectedRevision !== currentRev) {
105
+ writeJson(res, 409, {
106
+ ok: false,
107
+ error: {
108
+ code: 'conflict',
109
+ message: `Settings revision conflict: expected ${expectedRevision}, current is ${currentRev}`,
110
+ currentRevision: currentRev,
111
+ },
112
+ })
113
+ return
114
+ }
115
+
116
+ const targetRev = (typeof expectedRevision === 'number') ? expectedRevision : currentRev
117
+
96
118
  if (typeof settingsService.replace === 'function') {
97
- await settingsService.replace(NS, merged, rev)
119
+ await settingsService.replace(NS, merged, targetRev)
98
120
  saved = true
99
121
  } else if (typeof settingsService.update === 'function') {
100
- await settingsService.update(NS, merged, rev)
122
+ await settingsService.update(NS, merged, targetRev)
101
123
  saved = true
102
124
  }
103
125
  }
104
126
 
105
- if (scope) {
127
+ if (!saved && scope) {
106
128
  if (typeof scope.replace === 'function') {
107
129
  await scope.replace(merged)
108
130
  saved = true
@@ -110,10 +132,11 @@ export function registerConfigRoutes(ctx, {
110
132
  await scope.patch(merged)
111
133
  saved = true
112
134
  } else if (typeof scope.set === 'function') {
113
- for (const [k, v] of Object.entries(merged)) {
114
- await scope.set(k, v).catch(() => {})
135
+ const entries = Object.entries(merged)
136
+ for (const [k, v] of entries) {
137
+ await scope.set(k, v)
115
138
  }
116
- saved = true
139
+ if (entries.length > 0) saved = true
117
140
  }
118
141
  }
119
142
 
@@ -126,7 +149,11 @@ export function registerConfigRoutes(ctx, {
126
149
  triggerAutostart()
127
150
  }
128
151
 
129
- writeJson(res, 200, { ok: true, config: live() })
152
+ let newRev
153
+ try {
154
+ newRev = settingsService?.describe?.()?.find?.((r) => r.ns === NS)?.revision
155
+ } catch {}
156
+ writeJson(res, 200, { ok: true, config: live(), revision: newRev })
130
157
  } catch (err) {
131
158
  writeJson(res, 500, { ok: false, error: { code: 'save', message: String(err?.message || err) } })
132
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-voice",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
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",