@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 +370 -245
- package/lib/config-routes.js +38 -11
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -62,7 +62,7 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
'hide': 'Hide',
|
|
63
63
|
'title': 'Voice',
|
|
64
64
|
'recordSlot': 'Voice recording',
|
|
65
|
-
'browserHint': '
|
|
65
|
+
'browserHint': 'Web Speech recognition (uses page/browser language when auto, Chrome sends audio to Google)',
|
|
66
66
|
'openaiHint': 'whisper-1 · key OPENAI_API_KEY',
|
|
67
67
|
'siliconflowHint': 'FunAudioLLM/SenseVoiceSmall · key SILICONFLOW_API_KEY',
|
|
68
68
|
'deepinfraHint': 'openai/whisper-large-v3-turbo · key DEEPINFRA_API_KEY',
|
|
@@ -89,6 +89,12 @@ window.__ModuleLoader__.load({
|
|
|
89
89
|
'hotkeyHint1': 'Hold it to record, release to send to the agent, Esc cancels. ',
|
|
90
90
|
'hotkeyHint2': 'Any key will do: a letter, an F-key or a modifier.',
|
|
91
91
|
'language': 'Language',
|
|
92
|
+
'langAuto': 'Auto-detect',
|
|
93
|
+
'langRu': 'Russian (ru)',
|
|
94
|
+
'langEn': 'English (en)',
|
|
95
|
+
'langZh': 'Chinese (zh)',
|
|
96
|
+
'langUk': 'Ukrainian (uk)',
|
|
97
|
+
'langDe': 'German (de)',
|
|
92
98
|
'dictationHint': 'Speech is cut at pauses and the text is appended to the input.',
|
|
93
99
|
'pauseMs': 'Pause that ends a phrase, ms',
|
|
94
100
|
'pauseHint': 'Lower means more frequent chunks and faster text, but a higher risk of cutting a word',
|
|
@@ -204,6 +210,9 @@ window.__ModuleLoader__.load({
|
|
|
204
210
|
'updating': 'Updating…',
|
|
205
211
|
'updateSuccess': 'Updated successfully! Restart DSH to apply.',
|
|
206
212
|
'updateFailed': 'Update failed. Check logs.',
|
|
213
|
+
'updateCheckFailed': 'Update check failed',
|
|
214
|
+
'checkingUpdates': 'Checking updates…',
|
|
215
|
+
'updateStatusUnknown': 'Version status unknown',
|
|
207
216
|
'sensevoiceTitle': 'SenseVoice Local ASR (1-Click Setup)',
|
|
208
217
|
'sensevoiceDesc': 'Ultra-fast offline speech recognition on CPU (50–100ms latency, Russian/English).',
|
|
209
218
|
'installSensevoice': 'Install SenseVoice-Small (1-Click)',
|
|
@@ -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
|
-
|
|
511
|
-
window
|
|
512
|
-
|
|
513
|
-
})
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
706
|
-
if (
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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)
|
|
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() {
|
|
895
|
-
|
|
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
|
-
|
|
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 (!
|
|
909
|
-
|
|
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
|
-
|
|
1004
|
+
voice._chainsPromise = null
|
|
914
1005
|
return {}
|
|
915
1006
|
})
|
|
916
1007
|
}
|
|
917
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1352
|
-
voice.browser.stop()
|
|
1406
|
+
const b = voice.browser
|
|
1353
1407
|
voice.browser = null
|
|
1354
|
-
voice.browserFinals = null
|
|
1355
1408
|
voice.caption = ''
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
voice.set({ phase: '
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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 <
|
|
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) {
|
|
1442
|
+
try { URL.revokeObjectURL(voice.lastNote.url) } catch (e) {}
|
|
1383
1443
|
}
|
|
1384
1444
|
let noteUrl = ''
|
|
1385
|
-
try { noteUrl = URL.createObjectURL(blob) } catch (e) {
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
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
|
-
}
|
|
1429
|
-
|
|
1430
|
-
|
|
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')
|
|
1440
|
-
|
|
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
|
-
|
|
1476
|
-
|
|
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
|
-
|
|
1514
|
-
onPointerDown: (e) => { e.preventDefault(); beginHold('message') },
|
|
1590
|
+
onPointerDown: (e) => { e.preventDefault(); beginHold('message', e) },
|
|
1515
1591
|
onPointerUp: () => endHold(false),
|
|
1516
|
-
|
|
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
|
|
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
|
-
|
|
2119
|
-
|
|
2120
|
-
tag
|
|
2121
|
-
tag
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2820
|
-
'
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
? React.createElement('span', { className: 'cb-badge
|
|
2824
|
-
|
|
2825
|
-
)
|
|
2826
|
-
|
|
2827
|
-
|
|
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
|
-
|
|
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) =>
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
}
|
|
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)
|
package/lib/config-routes.js
CHANGED
|
@@ -27,9 +27,14 @@ export function registerConfigRoutes(ctx, {
|
|
|
27
27
|
return
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
// 3. GET: return live configuration snapshot
|
|
30
|
+
// 3. GET: return live configuration snapshot with revision
|
|
31
31
|
if (req.method === 'GET') {
|
|
32
|
-
|
|
32
|
+
let rev
|
|
33
|
+
try {
|
|
34
|
+
const settingsService = getSettingsService?.()
|
|
35
|
+
rev = settingsService?.describe?.()?.find?.((r) => r.ns === NS)?.revision
|
|
36
|
+
} catch { /* ignore revision lookup errors */ }
|
|
37
|
+
writeJson(res, 200, { ok: true, config: live(), revision: rev })
|
|
33
38
|
return
|
|
34
39
|
}
|
|
35
40
|
|
|
@@ -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
|
|
99
|
+
let currentRev
|
|
92
100
|
try {
|
|
93
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|