@goodandready/dsh-voice 0.9.11 → 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() {
@@ -871,6 +910,21 @@ window.__ModuleLoader__.load({
871
910
  return speechRecognitionCtor() !== null
872
911
  }
873
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
+
874
928
  /**
875
929
  * @param options {{lang: string, continuous: boolean, onInterim, onFinal, onError}}
876
930
  * @returns {{stop: Function, abort: Function}}
@@ -878,7 +932,7 @@ window.__ModuleLoader__.load({
878
932
  function startBrowserRecognition(options) {
879
933
  const Ctor = speechRecognitionCtor()
880
934
  const recognition = new Ctor()
881
- recognition.lang = options.lang && options.lang !== 'auto' ? options.lang : 'ru-RU'
935
+ recognition.lang = resolveBrowserRecognitionLang(options.lang)
882
936
  recognition.continuous = options.continuous !== false
883
937
  recognition.interimResults = true
884
938
  let stopped = false
@@ -939,22 +993,19 @@ window.__ModuleLoader__.load({
939
993
 
940
994
  // Fetch the mode chain from the host once. Only needed to decide whether
941
995
 
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
- }
996
+ voice._core = { tidyPhrase, applyVoiceCommands, VOICE_COMMANDS, undoLastInsert, insertHistory, insertDraftText, appendDraft, resolveBrowserRecognitionLang }
997
+ voice._chainsPromise = null
947
998
  function modeChain(mode) {
948
- if (!chainsPromise) {
949
- chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
999
+ if (!voice._chainsPromise) {
1000
+ voice._chainsPromise = fetch('/dsh-voice/status', { cache: 'no-store' })
950
1001
  .then((res) => res.json())
951
1002
  .then((data) => (data && data.modes) || {})
952
1003
  .catch(() => {
953
- chainsPromise = null
1004
+ voice._chainsPromise = null
954
1005
  return {}
955
1006
  })
956
1007
  }
957
- return chainsPromise.then((modes) => {
1008
+ return voice._chainsPromise.then((modes) => {
958
1009
  const row = modes[mode] || {}
959
1010
  return {
960
1011
  chain: Array.isArray(row.chain) ? row.chain.map((e) => e && e.provider) : [],
@@ -971,16 +1022,7 @@ window.__ModuleLoader__.load({
971
1022
  if (rec.analyser) { try { rec.analyser.disconnect() } catch (e) { /* already disconnected */ } }
972
1023
  if (rec.audioCtx) { try { rec.audioCtx.close() } catch (e) { /* already closed */ } }
973
1024
  }
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
- }
1025
+ voice.teardownRec = teardown
984
1026
  function waitStop(recorder) {
985
1027
  if (!recorder || recorder.state === 'inactive') return Promise.resolve()
986
1028
  return new Promise((resolve) => recorder.addEventListener('stop', resolve, { once: true }))
@@ -1279,6 +1321,7 @@ window.__ModuleLoader__.load({
1279
1321
  window.__dsh_voice_hotkey_cleanup = null
1280
1322
  }
1281
1323
  }
1324
+ voice.clearGlobalHotkey = clearGlobalHotkey
1282
1325
  function installHotkey(ctx, keyName, mode) {
1283
1326
  clearGlobalHotkey()
1284
1327
  if (typeof document === 'undefined' || !keyName) return () => {}
@@ -2104,34 +2147,42 @@ window.__ModuleLoader__.load({
2104
2147
  if (key) return t(key)
2105
2148
  return MODEL_HINT_STATIC[provider] || ''
2106
2149
  }
2107
- 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)
2108
2159
 
2109
2160
  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}' +
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}' +
2135
2186
  '.dvs-wrap{display:flex;flex-direction:column;gap:18px;padding:4px 0;max-width:960px}' +
2136
2187
  '.dvs-block{display:flex;flex-direction:column;gap:10px}' +
2137
2188
  '.dvs-h{font-size:14px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
@@ -2153,12 +2204,19 @@ window.__ModuleLoader__.load({
2153
2204
  '.dvo-pcardOpen .dvo-pchev{transform:rotate(180deg)}' +
2154
2205
  '.dvo-pbody{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}'
2155
2206
  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)
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
+ }
2162
2220
  }
2163
2221
 
2164
2222
  function createErrorBoundary() {
@@ -2671,8 +2729,12 @@ window.__ModuleLoader__.load({
2671
2729
  if (!res.ok) throw new Error(`HTTP ${res.status}`)
2672
2730
  const data = await res.json()
2673
2731
  setUpdaterStatus(data)
2732
+ if (data && data.latestCheckFailed) {
2733
+ setUpdaterMsg(t('updateCheckFailed'))
2734
+ }
2674
2735
  } catch (e) {
2675
- /* best effort update check */
2736
+ setUpdaterStatus(null)
2737
+ setUpdaterMsg(t('updateCheckFailed') + ': ' + (e?.message || String(e)))
2676
2738
  } finally {
2677
2739
  setUpdaterLoading(false)
2678
2740
  }
@@ -2854,16 +2916,24 @@ window.__ModuleLoader__.load({
2854
2916
  ),
2855
2917
  React.createElement('div', { className: 'cb-section-desc' }, t('versionHint')),
2856
2918
  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
- ),
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
+ ),
2867
2937
  updaterStatus && updaterStatus.updateAvailable
2868
2938
  ? React.createElement('button', {
2869
2939
  type: 'button', className: 'cb-btn cb-btn-primary', style: { fontSize: '12px', padding: '4px 10px' },
@@ -2903,6 +2973,7 @@ window.__ModuleLoader__.load({
2903
2973
  const [err, setErr] = React.useState('')
2904
2974
  const [serverConfig, setServerConfig] = React.useState(null)
2905
2975
  const [configLoading, setConfigLoading] = React.useState(true)
2976
+ const [revision, setRevision] = React.useState(null)
2906
2977
 
2907
2978
  React.useEffect(() => {
2908
2979
  let alive = true
@@ -2911,6 +2982,7 @@ window.__ModuleLoader__.load({
2911
2982
  .then((data) => {
2912
2983
  if (alive && data && data.ok && data.config) {
2913
2984
  setServerConfig(data.config)
2985
+ if (typeof data.revision === 'number') setRevision(data.revision)
2914
2986
  setDraft((curr) => curr || deepClone(data.config))
2915
2987
  }
2916
2988
  })
@@ -3079,14 +3151,15 @@ window.__ModuleLoader__.load({
3079
3151
  const res = await fetch('/dsh-voice/config', {
3080
3152
  method: 'PUT',
3081
3153
  headers: { 'Content-Type': 'application/json' },
3082
- body: JSON.stringify({ config: draft }),
3154
+ body: JSON.stringify({ config: draft, expectedRevision: (typeof revision === 'number' ? revision : undefined) }),
3083
3155
  })
3084
3156
  const data = await res.json()
3085
3157
  if (res.ok && data && data.ok) {
3086
3158
  httpOk = true
3087
- if (data.config) {
3088
- setServerConfig(data.config)
3089
- }
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'
3090
3163
  } else {
3091
3164
  httpError = data?.error?.message || data?.error || res.statusText || 'Save failed'
3092
3165
  }
@@ -3094,7 +3167,7 @@ window.__ModuleLoader__.load({
3094
3167
  httpError = e?.message || String(e)
3095
3168
  }
3096
3169
 
3097
- if (scope && typeof scope.set === 'function') {
3170
+ if (httpOk && scope && typeof scope.set === 'function') {
3098
3171
  for (const k of Object.keys(draft)) {
3099
3172
  try {
3100
3173
  await scope.set(k, draft[k])
@@ -3157,13 +3230,22 @@ window.__ModuleLoader__.load({
3157
3230
  .filter((k) => k && BUILTIN.indexOf(k) < 0),
3158
3231
  )
3159
3232
 
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
- )
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
+ }
3167
3249
 
3168
3250
  const numField = (mode, key, label, hint) => React.createElement('div', { className: 'cb-field' },
3169
3251
  React.createElement('label', null, label),
@@ -3180,7 +3262,7 @@ window.__ModuleLoader__.load({
3180
3262
  const whisperRunning = !!(statusData && statusData.whisperRunning)
3181
3263
  const providerCount = (statusData && Array.isArray(statusData.providers)) ? statusData.providers.length : chainOptions.length
3182
3264
 
3183
- return React.createElement('div', { className: 'cb-page' },
3265
+ return React.createElement('div', { className: 'cb-page dvo-settings-root' },
3184
3266
  // 1. Connection & Engine Status Card
3185
3267
  React.createElement(ConnectionStatusCard, {
3186
3268
  t: t,
@@ -3456,13 +3538,14 @@ window.__ModuleLoader__.load({
3456
3538
  ),
3457
3539
  React.createElement('span', { className: 'dvo-pchev' }, chevronIcon()),
3458
3540
  ),
3459
- (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,
3460
3542
  ),
3461
3543
  )
3462
3544
  }
3463
3545
 
3464
3546
  function registerSettings(ctx) {
3465
3547
  ctx.effect(() => {
3548
+ const offCss = installSettingsCss()
3466
3549
  const offItem = ctx.slots.inject('plugins.item', () => ctx.slots.register(
3467
3550
  {
3468
3551
  name: 'plugins.item',
@@ -3493,6 +3576,7 @@ window.__ModuleLoader__.load({
3493
3576
  PluginCard,
3494
3577
  ))
3495
3578
  return () => {
3579
+ if (typeof offCss === 'function') offCss()
3496
3580
  if (typeof offItem === 'function') offItem()
3497
3581
  if (typeof offRow === 'function') offRow()
3498
3582
  if (typeof offLegacy === 'function') offLegacy()
@@ -3516,6 +3600,9 @@ window.__ModuleLoader__.load({
3516
3600
  const undo = [addLocale('en', en), addLocale('zh', zh)]
3517
3601
  return () => { for (const off of undo) off() }
3518
3602
  }, 'dsh-voice: locale dictionaries')
3603
+ ctx.effect(() => {
3604
+ return registerGlobalLifecycle()
3605
+ }, 'dsh-voice: global lifecycle')
3519
3606
  moduleT = ctx.locale.bind(NS)
3520
3607
  registerComposer(ctx)
3521
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.11",
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",