dsh-live-voice 0.3.1 → 0.3.2

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +75 -3
  2. package/PLAN.md +19 -1
  3. package/README.md +1 -1
  4. package/docs/CONFIGURATION.md +17 -3
  5. package/docs/VOICE-LIFECYCLE.md +5 -1
  6. package/lib/client.js +512 -318
  7. package/lib/server.js +157 -4
  8. package/package.json +1 -1
  9. package/src/app/client/apply.tsx +96 -40
  10. package/src/app/client/i18n/catalogs/base.ts +6 -0
  11. package/src/app/client/i18n/catalogs/en.ts +9 -0
  12. package/src/app/client/i18n/catalogs/es.ts +9 -0
  13. package/src/app/client/i18n/catalogs/fr.ts +9 -0
  14. package/src/app/client/i18n/catalogs/hi.ts +9 -0
  15. package/src/app/client/i18n/catalogs/pt-BR.ts +9 -0
  16. package/src/app/client/i18n/catalogs/zh.ts +8 -0
  17. package/src/app/server/apply.ts +4 -0
  18. package/src/app/server/registerRoutes.ts +40 -0
  19. package/src/modules/conversation/ARCHITECTURE.md +2 -0
  20. package/src/modules/core/ARCHITECTURE.md +1 -1
  21. package/src/modules/core/coordinator.ts +8 -6
  22. package/src/modules/core/qwen/QwenSettings.tsx +15 -14
  23. package/src/modules/core/settings.ts +26 -8
  24. package/src/modules/core/transcript.ts +5 -43
  25. package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +5 -3
  26. package/src/modules/recognition/engines/whisper/WhisperSettings.tsx +19 -20
  27. package/src/modules/settings/ARCHITECTURE.md +1 -1
  28. package/src/modules/settings/models/settingsHost.ts +45 -0
  29. package/src/modules/settings/models/settingsStorage.ts +45 -10
  30. package/src/modules/settings/sections/recognition/RecognitionFilterSettings.tsx +2 -2
  31. package/src/modules/settings/sections/recognition/SilenceDetectionSettings.tsx +37 -7
  32. package/src/modules/settings/sections/recognition/VoiceCommandSettings.tsx +2 -2
  33. package/src/modules/settings/sections/speak/OutputFilterSettings.tsx +4 -4
  34. package/src/modules/settings/sections/speak/SpeechAdvancedSettings.tsx +2 -2
  35. package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +2 -2
  36. package/src/modules/settings/services/releases.ts +9 -1
  37. package/src/shared/ARCHITECTURE.md +1 -1
  38. package/src/shared/design-system/forms/DraftField.tsx +79 -0
  39. package/src/shared/design-system/forms/NumberField.tsx +4 -10
  40. package/src/shared/design-system/forms/TextAreaField.tsx +4 -10
  41. package/src/shared/design-system/forms/TextField.tsx +4 -10
  42. package/src/styles/index.ts +1 -1
package/lib/client.js CHANGED
@@ -34,63 +34,34 @@ __export(apply_exports, {
34
34
  inject: () => inject
35
35
  });
36
36
  module.exports = __toCommonJS(apply_exports);
37
- var import_react53 = __toESM(require("react"), 1);
37
+ var import_react54 = __toESM(require("react"), 1);
38
38
  var import_react_dom = require("react-dom");
39
39
 
40
40
  // src/modules/core/transcript.ts
41
41
  var TranscriptDraft = class {
42
- constructor() {
43
- this.reset();
44
- }
45
42
  reset() {
46
- this.owned = null;
47
- this.edited = false;
48
43
  }
49
44
  update(current, hypothesis, final = false) {
50
- if (this.edited) {
51
- if (final) this.edited = false;
52
- return current;
53
- }
54
- let base = current;
55
- let at = current.length;
56
- if (this.owned) {
57
- const { start, text: text2, before: before2, after: after2 } = this.owned;
58
- if (current === before2 + text2 + after2) {
59
- base = before2 + after2;
60
- at = start;
61
- } else if (text2 && current.indexOf(text2) >= 0 && current.indexOf(text2) === current.lastIndexOf(text2)) {
62
- at = current.indexOf(text2);
63
- base = current.slice(0, at) + current.slice(at + text2.length);
64
- } else {
65
- this.owned = null;
66
- this.edited = !final;
67
- return current;
68
- }
69
- }
70
- const before = base.slice(0, at);
71
- const after = base.slice(at);
72
- const separator = hypothesis && before && !/\s$/.test(before) ? " " : "";
73
- const text = separator + hypothesis;
74
- const result = before + text + after;
75
- this.owned = final ? null : { start: at, text, before, after };
76
- return result;
45
+ if (!final || !hypothesis) return current;
46
+ const separator = current && !/\s$/.test(current) ? " " : "";
47
+ return current + separator + hypothesis;
77
48
  }
78
49
  };
79
50
 
80
51
  // src/modules/core/settings.ts
81
52
  var voiceDetectionPresets = Object.freeze({
82
53
  short: Object.freeze({
83
- silenceMs: 900,
54
+ silenceMs: 500,
84
55
  label: "Short",
85
56
  description: "Send quickly after a short pause."
86
57
  }),
87
58
  natural: Object.freeze({
88
- silenceMs: 1500,
59
+ silenceMs: 1e3,
89
60
  label: "Natural",
90
61
  description: "Allow normal pauses between phrases."
91
62
  }),
92
63
  long: Object.freeze({
93
- silenceMs: 2200,
64
+ silenceMs: 2e3,
94
65
  label: "Long",
95
66
  description: "Wait through longer thinking pauses."
96
67
  })
@@ -116,7 +87,8 @@ var defaultSettings = Object.freeze({
116
87
  recognitionEngine: "browser",
117
88
  recognitionProcessLocally: true,
118
89
  recognitionAutoInstall: true,
119
- voiceDetectionPreset: "natural",
90
+ voiceDetectionPreset: "short",
91
+ voiceDetectionCustomSilenceMs: 1e3,
120
92
  recognitionMaxUtteranceSeconds: 60,
121
93
  microphoneEnabled: true,
122
94
  holdToTalkEnabled: true,
@@ -150,6 +122,10 @@ var defaultSettings = Object.freeze({
150
122
  segmentGapMs: 400
151
123
  });
152
124
  var normalizeCommandPhrases = (value, fallback) => typeof value === "string" && value.length <= 1e3 && !value.includes("\0") ? value.split(/[,\r\n]+/).map((phrase) => phrase.trim()).filter(Boolean).slice(0, 20).join(", ") : fallback;
125
+ var customSilenceMinMs = 100;
126
+ var customSilenceMaxMs = 1e4;
127
+ var normalizeCustomSilenceMs = (value) => Number.isInteger(value) && value >= customSilenceMinMs && value <= customSilenceMaxMs ? value : defaultSettings.voiceDetectionCustomSilenceMs;
128
+ var voiceDetectionSilenceMs = (settings) => settings.voiceDetectionPreset === "custom" ? normalizeCustomSilenceMs(settings.voiceDetectionCustomSilenceMs) : (voiceDetectionPresets[settings.voiceDetectionPreset] || voiceDetectionPresets[defaultSettings.voiceDetectionPreset]).silenceMs;
153
129
  function normalizeSettings(value) {
154
130
  const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
155
131
  return {
@@ -157,7 +133,8 @@ function normalizeSettings(value) {
157
133
  recognitionEngine: ["browser", "whisper-http", "qwen-http"].includes(source.recognitionEngine) ? source.recognitionEngine : defaultSettings.recognitionEngine,
158
134
  recognitionProcessLocally: typeof source.recognitionProcessLocally === "boolean" ? source.recognitionProcessLocally : defaultSettings.recognitionProcessLocally,
159
135
  recognitionAutoInstall: typeof source.recognitionAutoInstall === "boolean" ? source.recognitionAutoInstall : defaultSettings.recognitionAutoInstall,
160
- voiceDetectionPreset: Object.hasOwn(voiceDetectionPresets, source.voiceDetectionPreset) ? source.voiceDetectionPreset : defaultSettings.voiceDetectionPreset,
136
+ voiceDetectionPreset: source.voiceDetectionPreset === "custom" || Object.hasOwn(voiceDetectionPresets, source.voiceDetectionPreset) ? source.voiceDetectionPreset : defaultSettings.voiceDetectionPreset,
137
+ voiceDetectionCustomSilenceMs: normalizeCustomSilenceMs(source.voiceDetectionCustomSilenceMs),
161
138
  recognitionMaxUtteranceSeconds: Number.isInteger(source.recognitionMaxUtteranceSeconds) && source.recognitionMaxUtteranceSeconds >= 10 && source.recognitionMaxUtteranceSeconds <= 300 ? source.recognitionMaxUtteranceSeconds : defaultSettings.recognitionMaxUtteranceSeconds,
162
139
  microphoneEnabled: typeof source.microphoneEnabled === "boolean" ? source.microphoneEnabled : defaultSettings.microphoneEnabled,
163
140
  holdToTalkEnabled: typeof source.holdToTalkEnabled === "boolean" ? source.holdToTalkEnabled : defaultSettings.holdToTalkEnabled,
@@ -339,7 +316,8 @@ var VoiceCoordinator = class {
339
316
  lang: this.snapshot.settings.recognitionLang,
340
317
  processLocally: this.snapshot.settings.recognitionProcessLocally,
341
318
  autoInstallLocalPack: this.snapshot.settings.recognitionAutoInstall,
342
- voiceDetectionPreset: this.snapshot.settings.voiceDetectionPreset
319
+ voiceDetectionPreset: this.snapshot.settings.voiceDetectionPreset,
320
+ voiceDetectionCustomSilenceMs: this.snapshot.settings.voiceDetectionCustomSilenceMs
343
321
  });
344
322
  this.patch({ capabilities: { ...this.snapshot.capabilities, recognition: void 0 } });
345
323
  }
@@ -361,7 +339,8 @@ var VoiceCoordinator = class {
361
339
  lang: settings.recognitionLang,
362
340
  processLocally: settings.recognitionProcessLocally,
363
341
  autoInstallLocalPack: settings.recognitionAutoInstall,
364
- voiceDetectionPreset: settings.voiceDetectionPreset
342
+ voiceDetectionPreset: settings.voiceDetectionPreset,
343
+ voiceDetectionCustomSilenceMs: settings.voiceDetectionCustomSilenceMs
365
344
  });
366
345
  this.patch({
367
346
  settings,
@@ -454,7 +433,6 @@ var VoiceCoordinator = class {
454
433
  }
455
434
  muteListening() {
456
435
  this.cancelAutoSend();
457
- this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
458
436
  this.transcript.reset();
459
437
  this.updateSettings({ microphoneEnabled: false });
460
438
  if (!this.snapshot.settings.voiceCommandsEnabled) return this.stopListening();
@@ -636,7 +614,6 @@ var VoiceCoordinator = class {
636
614
  clear: this.snapshot.settings.voiceCommandClear
637
615
  });
638
616
  if (command) {
639
- this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
640
617
  this.transcript.reset();
641
618
  this.cancelAutoSend();
642
619
  this.patch({ recognizing: false });
@@ -660,13 +637,11 @@ var VoiceCoordinator = class {
660
637
  return;
661
638
  }
662
639
  if (this.snapshot.muted) {
663
- this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
664
640
  this.transcript.reset();
665
641
  this.patch({ recognizing: false });
666
642
  return;
667
643
  }
668
644
  if (this.snapshot.settings.recognitionFilterEnabled && !hasMinimumWords(final, this.snapshot.settings.recognitionMinimumWords)) {
669
- this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
670
645
  this.transcript.reset();
671
646
  this.patch({ recognizing: false });
672
647
  return;
@@ -677,7 +652,6 @@ var VoiceCoordinator = class {
677
652
  }
678
653
  if (interim) {
679
654
  this.cancelAutoSend();
680
- this.composer.setDraft(this.transcript.update(this.composer.getDraft(), interim));
681
655
  }
682
656
  if (!interim && this.snapshot.recognizing)
683
657
  this.assistantSpeechNotBefore = Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1e3;
@@ -745,7 +719,6 @@ var VoiceCoordinator = class {
745
719
  });
746
720
  }
747
721
  async cancelDictation() {
748
- this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
749
722
  await this.stopListening();
750
723
  }
751
724
  async endConversation() {
@@ -1233,7 +1206,7 @@ var VoiceOwnership = class {
1233
1206
  };
1234
1207
 
1235
1208
  // src/modules/recognition/components/RecognitionCapabilityStatus.tsx
1236
- var import_react19 = __toESM(require("react"), 1);
1209
+ var import_react20 = __toESM(require("react"), 1);
1237
1210
 
1238
1211
  // src/app/client/i18n/catalogs/en.ts
1239
1212
  var en = {
@@ -1324,6 +1297,8 @@ var en = {
1324
1297
  "dsh-live-voice.recognition.planned.vote": "Coming Soon \u2014 vote on repo issues",
1325
1298
  "dsh-live-voice.recognition.planned.voxtral": "Voxtral Realtime \u2014 Soon",
1326
1299
  "dsh-live-voice.recognition.planned.webGpu": "Browser WebGPU Inference \u2014 Soon",
1300
+ "dsh-live-voice.recognition.presets.custom.description": "Choose your own silence duration.",
1301
+ "dsh-live-voice.recognition.presets.custom.label": "Custom",
1327
1302
  "dsh-live-voice.recognition.presets.long.description": "Wait through longer thinking pauses.",
1328
1303
  "dsh-live-voice.recognition.presets.long.label": "Long",
1329
1304
  "dsh-live-voice.recognition.presets.natural.description": "Allow normal pauses between phrases.",
@@ -1336,6 +1311,8 @@ var en = {
1336
1311
  "dsh-live-voice.recognition.qwen.endpointHelp": "HTTP API at the configured DSH host URL (default: http://127.0.0.1:8080/inference). Audio uses the authenticated DSH host transcription route.",
1337
1312
  "dsh-live-voice.recognition.qwen.hostHelp": "Host-wide settings for the Qwen3 ASR + TTS server. Enter any HTTP or HTTPS base URL reachable from the DSH host. The browser accesses it through authenticated DSH routes.",
1338
1313
  "dsh-live-voice.recognition.qwen.label": "Qwen3 ASR \u2014 HTTP API",
1314
+ "dsh-live-voice.recognition.silenceDetection.customHelp": "Enter a whole number from 100 to 10,000 ms. Saved when you leave the field. Short pauses may split speech; recognition adds its own latency.",
1315
+ "dsh-live-voice.recognition.silenceDetection.customLabel": "Custom pause (milliseconds)",
1339
1316
  "dsh-live-voice.recognition.silenceDetection.duration": "Pause before sending: {milliseconds} ms",
1340
1317
  "dsh-live-voice.recognition.silenceDetection.help": "Controls how long a pause must last before captured speech is sent for recognition.",
1341
1318
  "dsh-live-voice.recognition.silenceDetection.label": "Silence detection",
@@ -1373,6 +1350,8 @@ var en = {
1373
1350
  "dsh-live-voice.settings.engine.refresh": "Refresh available engines",
1374
1351
  "dsh-live-voice.settings.filters.title": "Filtering",
1375
1352
  "dsh-live-voice.settings.general.title": "General",
1353
+ "dsh-live-voice.settings.persistence.loadError": "Could not load Live Voice settings from the server. Reload to try again.",
1354
+ "dsh-live-voice.settings.persistence.saveError": "Could not save Live Voice settings on the server. Please try again.",
1376
1355
  "dsh-live-voice.settings.tabs.conversation": "Conversation",
1377
1356
  "dsh-live-voice.settings.tabs.recognition": "Speech recognition",
1378
1357
  "dsh-live-voice.settings.tabs.speak": "Speech",
@@ -1541,6 +1520,8 @@ var es = {
1541
1520
  "dsh-live-voice.recognition.planned.vote": "Pr\xF3ximamente \u2014 vota en las incidencias del repositorio",
1542
1521
  "dsh-live-voice.recognition.planned.voxtral": "Voxtral Realtime \u2014 Pr\xF3ximamente",
1543
1522
  "dsh-live-voice.recognition.planned.webGpu": "Inferencia WebGPU en el navegador \u2014 Pr\xF3ximamente",
1523
+ "dsh-live-voice.recognition.presets.custom.description": "Elige la duraci\xF3n del silencio.",
1524
+ "dsh-live-voice.recognition.presets.custom.label": "Personalizada",
1544
1525
  "dsh-live-voice.recognition.presets.long.description": "Espera durante pausas de reflexi\xF3n m\xE1s largas.",
1545
1526
  "dsh-live-voice.recognition.presets.long.label": "Larga",
1546
1527
  "dsh-live-voice.recognition.presets.natural.description": "Permite pausas normales entre frases.",
@@ -1553,6 +1534,8 @@ var es = {
1553
1534
  "dsh-live-voice.recognition.qwen.endpointHelp": "API HTTP en la URL configurada en el host de DSH (predeterminada: http://127.0.0.1:8080/inference). El audio utiliza la ruta de transcripci\xF3n autenticada del host de DSH.",
1554
1535
  "dsh-live-voice.recognition.qwen.hostHelp": "Configuraci\xF3n para todo el host del servidor Qwen3 ASR + TTS. Introduce una URL base HTTP o HTTPS accesible desde el host de DSH. El navegador accede a ella mediante las rutas autenticadas de DSH.",
1555
1536
  "dsh-live-voice.recognition.qwen.label": "Qwen3 ASR \u2014 API HTTP",
1537
+ "dsh-live-voice.recognition.silenceDetection.customHelp": "Introduce un n\xFAmero entero de 100 a 10.000 ms. Se guarda al salir del campo. Las pausas cortas pueden dividir el habla; el reconocimiento a\xF1ade su propia latencia.",
1538
+ "dsh-live-voice.recognition.silenceDetection.customLabel": "Pausa personalizada (milisegundos)",
1556
1539
  "dsh-live-voice.recognition.silenceDetection.duration": "Pausa antes de enviar: {milliseconds} ms",
1557
1540
  "dsh-live-voice.recognition.silenceDetection.help": "Determina cu\xE1nto debe durar una pausa antes de enviar la voz capturada para su reconocimiento.",
1558
1541
  "dsh-live-voice.recognition.silenceDetection.label": "Detecci\xF3n de silencio",
@@ -1590,6 +1573,8 @@ var es = {
1590
1573
  "dsh-live-voice.settings.engine.refresh": "Actualizar los motores disponibles",
1591
1574
  "dsh-live-voice.settings.filters.title": "Filtrado",
1592
1575
  "dsh-live-voice.settings.general.title": "General",
1576
+ "dsh-live-voice.settings.persistence.loadError": "No se pudieron cargar los ajustes de Live Voice del servidor. Recarga para volver a intentarlo.",
1577
+ "dsh-live-voice.settings.persistence.saveError": "No se pudieron guardar los ajustes de Live Voice en el servidor. Int\xE9ntalo de nuevo.",
1593
1578
  "dsh-live-voice.settings.tabs.conversation": "Conversaci\xF3n",
1594
1579
  "dsh-live-voice.settings.tabs.recognition": "Reconocimiento de voz",
1595
1580
  "dsh-live-voice.settings.tabs.speak": "S\xEDntesis de voz",
@@ -1758,6 +1743,8 @@ var fr = {
1758
1743
  "dsh-live-voice.recognition.planned.vote": "Bient\xF4t disponible \u2014 votez dans les tickets du d\xE9p\xF4t",
1759
1744
  "dsh-live-voice.recognition.planned.voxtral": "Voxtral Realtime \u2014 Bient\xF4t disponible",
1760
1745
  "dsh-live-voice.recognition.planned.webGpu": "Inf\xE9rence WebGPU dans le navigateur \u2014 Bient\xF4t disponible",
1746
+ "dsh-live-voice.recognition.presets.custom.description": "Choisissez la dur\xE9e du silence.",
1747
+ "dsh-live-voice.recognition.presets.custom.label": "Personnalis\xE9e",
1761
1748
  "dsh-live-voice.recognition.presets.long.description": "Attend pendant les pauses de r\xE9flexion plus longues.",
1762
1749
  "dsh-live-voice.recognition.presets.long.label": "Longue",
1763
1750
  "dsh-live-voice.recognition.presets.natural.description": "Autorise des pauses normales entre les phrases.",
@@ -1770,6 +1757,8 @@ var fr = {
1770
1757
  "dsh-live-voice.recognition.qwen.endpointHelp": "API HTTP \xE0 l\u2019URL configur\xE9e sur l\u2019h\xF4te DSH (par d\xE9faut : http://127.0.0.1:8080/inference). Le son passe par la route de transcription authentifi\xE9e de l\u2019h\xF4te DSH.",
1771
1758
  "dsh-live-voice.recognition.qwen.hostHelp": "Param\xE8tres communs \xE0 tout l\u2019h\xF4te pour le serveur Qwen3 ASR + TTS. Saisissez une URL de base HTTP ou HTTPS accessible depuis l\u2019h\xF4te DSH. Le navigateur y acc\xE8de via les routes authentifi\xE9es de DSH.",
1772
1759
  "dsh-live-voice.recognition.qwen.label": "Qwen3 ASR \u2014 API HTTP",
1760
+ "dsh-live-voice.recognition.silenceDetection.customHelp": "Saisissez un entier de 100 \xE0 10 000 ms. Enregistr\xE9 en quittant le champ. Les pauses courtes peuvent couper la parole ; la reconnaissance ajoute sa propre latence.",
1761
+ "dsh-live-voice.recognition.silenceDetection.customLabel": "Pause personnalis\xE9e (millisecondes)",
1773
1762
  "dsh-live-voice.recognition.silenceDetection.duration": "Pause avant l\u2019envoi : {milliseconds} ms",
1774
1763
  "dsh-live-voice.recognition.silenceDetection.help": "D\xE9termine la dur\xE9e de pause n\xE9cessaire avant que la parole captur\xE9e soit envoy\xE9e pour reconnaissance.",
1775
1764
  "dsh-live-voice.recognition.silenceDetection.label": "D\xE9tection du silence",
@@ -1807,6 +1796,8 @@ var fr = {
1807
1796
  "dsh-live-voice.settings.engine.refresh": "Actualiser les moteurs disponibles",
1808
1797
  "dsh-live-voice.settings.filters.title": "Filtrage",
1809
1798
  "dsh-live-voice.settings.general.title": "G\xE9n\xE9ral",
1799
+ "dsh-live-voice.settings.persistence.loadError": "Impossible de charger les param\xE8tres Live Voice depuis le serveur. Rechargez pour r\xE9essayer.",
1800
+ "dsh-live-voice.settings.persistence.saveError": "Impossible de sauvegarder les param\xE8tres Live Voice sur le serveur. R\xE9essayez.",
1810
1801
  "dsh-live-voice.settings.tabs.conversation": "Conversation",
1811
1802
  "dsh-live-voice.settings.tabs.recognition": "Reconnaissance vocale",
1812
1803
  "dsh-live-voice.settings.tabs.speak": "Synth\xE8se vocale",
@@ -1975,6 +1966,8 @@ var hi = {
1975
1966
  "dsh-live-voice.recognition.planned.vote": "\u091C\u0932\u094D\u0926 \u0906 \u0930\u0939\u093E \u0939\u0948 \u2014 \u0930\u093F\u092A\u0949\u091C\u093C\u093F\u091F\u0930\u0940 \u0915\u0947 \u0907\u0936\u094D\u092F\u0942 \u092E\u0947\u0902 \u0935\u094B\u091F \u0926\u0947\u0902",
1976
1967
  "dsh-live-voice.recognition.planned.voxtral": "Voxtral Realtime \u2014 \u091C\u0932\u094D\u0926 \u0906 \u0930\u0939\u093E \u0939\u0948",
1977
1968
  "dsh-live-voice.recognition.planned.webGpu": "\u092C\u094D\u0930\u093E\u0909\u091C\u093C\u0930 \u092E\u0947\u0902 WebGPU \u0907\u0928\u094D\u092B\u093C\u0930\u0947\u0902\u0938 \u2014 \u091C\u0932\u094D\u0926 \u0906 \u0930\u0939\u093E \u0939\u0948",
1969
+ "dsh-live-voice.recognition.presets.custom.description": "\u092E\u094C\u0928 \u0915\u0940 \u0905\u0935\u0927\u093F \u0938\u094D\u0935\u092F\u0902 \u091A\u0941\u0928\u0947\u0902\u0964",
1970
+ "dsh-live-voice.recognition.presets.custom.label": "\u0915\u0938\u094D\u091F\u092E",
1978
1971
  "dsh-live-voice.recognition.presets.long.description": "\u0932\u0902\u092C\u0947 \u0938\u094B\u091A-\u0935\u093F\u091A\u093E\u0930 \u0935\u093E\u0932\u0947 \u0935\u093F\u0930\u093E\u092E\u094B\u0902 \u0924\u0915 \u092A\u094D\u0930\u0924\u0940\u0915\u094D\u0937\u093E \u0915\u0930\u0947\u0902\u0964",
1979
1972
  "dsh-live-voice.recognition.presets.long.label": "\u0932\u0902\u092C\u093E",
1980
1973
  "dsh-live-voice.recognition.presets.natural.description": "\u0935\u093E\u0915\u094D\u092F\u093E\u0902\u0936\u094B\u0902 \u0915\u0947 \u092C\u0940\u091A \u0938\u093E\u092E\u093E\u0928\u094D\u092F \u0935\u093F\u0930\u093E\u092E \u0915\u0940 \u0905\u0928\u0941\u092E\u0924\u093F \u0926\u0947\u0902\u0964",
@@ -1987,6 +1980,8 @@ var hi = {
1987
1980
  "dsh-live-voice.recognition.qwen.endpointHelp": "HTTP API, \u0915\u0949\u0928\u094D\u092B\u093C\u093F\u0917\u0930 \u0915\u093F\u090F \u0917\u090F DSH \u0939\u094B\u0938\u094D\u091F URL \u092A\u0930 \u0939\u0948 (\u0921\u093F\u092B\u093C\u0949\u0932\u094D\u091F: http://127.0.0.1:8080/inference)\u0964 \u0911\u0921\u093F\u092F\u094B \u0915\u0947 \u0932\u093F\u090F \u092A\u094D\u0930\u092E\u093E\u0923\u0940\u0915\u0930\u0923 \u0935\u093E\u0932\u0947 DSH \u0939\u094B\u0938\u094D\u091F \u091F\u094D\u0930\u093E\u0902\u0938\u0915\u094D\u0930\u093F\u092A\u094D\u0936\u0928 \u0930\u0942\u091F \u0915\u093E \u0909\u092A\u092F\u094B\u0917 \u0939\u094B\u0924\u093E \u0939\u0948\u0964",
1988
1981
  "dsh-live-voice.recognition.qwen.hostHelp": "Qwen3 ASR + TTS \u0938\u0930\u094D\u0935\u0930 \u0915\u0940 \u092A\u0942\u0930\u0947 \u0939\u094B\u0938\u094D\u091F \u092A\u0930 \u0932\u093E\u0917\u0942 \u0939\u094B\u0928\u0947 \u0935\u093E\u0932\u0940 \u0938\u0947\u091F\u093F\u0902\u0917\u094D\u0938\u0964 \u0915\u094B\u0908 \u092D\u0940 HTTP \u092F\u093E HTTPS \u092C\u0947\u0938 URL \u0926\u0930\u094D\u091C \u0915\u0930\u0947\u0902 \u091C\u093F\u0938 \u0924\u0915 DSH \u0939\u094B\u0938\u094D\u091F \u092A\u0939\u0941\u0901\u091A \u0938\u0915\u0947\u0964 \u092C\u094D\u0930\u093E\u0909\u091C\u093C\u0930 \u092A\u094D\u0930\u092E\u093E\u0923\u0940\u0915\u0930\u0923 \u0935\u093E\u0932\u0947 DSH \u0930\u0942\u091F\u094B\u0902 \u0915\u0947 \u091C\u093C\u0930\u093F\u090F \u0907\u0938\u0947 \u090F\u0915\u094D\u0938\u0947\u0938 \u0915\u0930\u0924\u093E \u0939\u0948\u0964",
1989
1982
  "dsh-live-voice.recognition.qwen.label": "Qwen3 ASR \u2014 HTTP API",
1983
+ "dsh-live-voice.recognition.silenceDetection.customHelp": "100 \u0938\u0947 10,000 ms \u0924\u0915 \u092A\u0942\u0930\u094D\u0923\u093E\u0902\u0915 \u0926\u0930\u094D\u091C \u0915\u0930\u0947\u0902\u0964 \u092B\u093C\u0940\u0932\u094D\u0921 \u091B\u094B\u0921\u093C\u0928\u0947 \u092A\u0930 \u0938\u0939\u0947\u091C\u093E \u091C\u093E\u0924\u093E \u0939\u0948\u0964 \u091B\u094B\u091F\u0947 \u0935\u093F\u0930\u093E\u092E \u092C\u094B\u0932\u0928\u0947 \u0915\u094B \u0916\u0902\u0921\u094B\u0902 \u092E\u0947\u0902 \u092C\u093E\u0901\u091F \u0938\u0915\u0924\u0947 \u0939\u0948\u0902; \u092A\u0939\u091A\u093E\u0928 \u092E\u0947\u0902 \u0905\u0924\u093F\u0930\u093F\u0915\u094D\u0924 \u0938\u092E\u092F \u0932\u0917\u0924\u093E \u0939\u0948\u0964",
1984
+ "dsh-live-voice.recognition.silenceDetection.customLabel": "\u0915\u0938\u094D\u091F\u092E \u0935\u093F\u0930\u093E\u092E (\u092E\u093F\u0932\u0940\u0938\u0947\u0915\u0902\u0921)",
1990
1985
  "dsh-live-voice.recognition.silenceDetection.duration": "\u092D\u0947\u091C\u0928\u0947 \u0938\u0947 \u092A\u0939\u0932\u0947 \u0915\u093E \u0935\u093F\u0930\u093E\u092E: {milliseconds} \u092E\u093F\u0932\u0940\u0938\u0947\u0915\u0902\u0921",
1991
1986
  "dsh-live-voice.recognition.silenceDetection.help": "\u0924\u092F \u0915\u0930\u0924\u093E \u0939\u0948 \u0915\u093F \u0930\u093F\u0915\u0949\u0930\u094D\u0921 \u0915\u0940 \u0917\u0908 \u0906\u0935\u093E\u091C\u093C \u0915\u094B \u092A\u0939\u091A\u093E\u0928 \u0915\u0947 \u0932\u093F\u090F \u092D\u0947\u091C\u0928\u0947 \u0938\u0947 \u092A\u0939\u0932\u0947 \u0935\u093F\u0930\u093E\u092E \u0915\u093F\u0924\u0928\u0940 \u0926\u0947\u0930 \u0930\u0939\u0928\u093E \u091A\u093E\u0939\u093F\u090F\u0964",
1992
1987
  "dsh-live-voice.recognition.silenceDetection.label": "\u092E\u094C\u0928 \u0915\u093E \u092A\u0924\u093E \u0932\u0917\u093E\u0928\u093E",
@@ -2024,6 +2019,8 @@ var hi = {
2024
2019
  "dsh-live-voice.settings.engine.refresh": "\u0909\u092A\u0932\u092C\u094D\u0927 \u0907\u0902\u091C\u0928 \u0930\u0940\u092B\u093C\u094D\u0930\u0947\u0936 \u0915\u0930\u0947\u0902",
2025
2020
  "dsh-live-voice.settings.filters.title": "\u092B\u093C\u093F\u0932\u094D\u091F\u0930\u093F\u0902\u0917",
2026
2021
  "dsh-live-voice.settings.general.title": "\u0938\u093E\u092E\u093E\u0928\u094D\u092F",
2022
+ "dsh-live-voice.settings.persistence.loadError": "\u0938\u0930\u094D\u0935\u0930 \u0938\u0947 Live Voice \u0938\u0947\u091F\u093F\u0902\u0917 \u0932\u094B\u0921 \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u0940\u0902\u0964 \u092B\u093F\u0930 \u092A\u094D\u0930\u092F\u093E\u0938 \u0915\u0930\u0928\u0947 \u0915\u0947 \u0932\u093F\u090F \u092A\u0947\u091C \u0930\u0940\u0932\u094B\u0921 \u0915\u0930\u0947\u0902\u0964",
2023
+ "dsh-live-voice.settings.persistence.saveError": "\u0938\u0930\u094D\u0935\u0930 \u092A\u0930 Live Voice \u0938\u0947\u091F\u093F\u0902\u0917 \u0938\u0947\u0935 \u0928\u0939\u0940\u0902 \u0939\u094B \u0938\u0915\u0940\u0902\u0964 \u092B\u093F\u0930 \u092A\u094D\u0930\u092F\u093E\u0938 \u0915\u0930\u0947\u0902\u0964",
2027
2024
  "dsh-live-voice.settings.tabs.conversation": "\u092C\u093E\u0924\u091A\u0940\u0924",
2028
2025
  "dsh-live-voice.settings.tabs.recognition": "\u0935\u093E\u0915\u094D \u092A\u0939\u091A\u093E\u0928",
2029
2026
  "dsh-live-voice.settings.tabs.speak": "\u0935\u093E\u091A\u0928",
@@ -2192,6 +2189,8 @@ var ptBR = {
2192
2189
  "dsh-live-voice.recognition.planned.vote": "Em breve \u2014 vote nas issues do reposit\xF3rio",
2193
2190
  "dsh-live-voice.recognition.planned.voxtral": "Voxtral Realtime \u2014 Em breve",
2194
2191
  "dsh-live-voice.recognition.planned.webGpu": "Infer\xEAncia WebGPU no navegador \u2014 Em breve",
2192
+ "dsh-live-voice.recognition.presets.custom.description": "Escolha a dura\xE7\xE3o do sil\xEAncio.",
2193
+ "dsh-live-voice.recognition.presets.custom.label": "Personalizada",
2195
2194
  "dsh-live-voice.recognition.presets.long.description": "Aguarda durante pausas de pensamento mais longas.",
2196
2195
  "dsh-live-voice.recognition.presets.long.label": "Longa",
2197
2196
  "dsh-live-voice.recognition.presets.natural.description": "Permite pausas normais entre frases.",
@@ -2204,6 +2203,8 @@ var ptBR = {
2204
2203
  "dsh-live-voice.recognition.qwen.endpointHelp": "API HTTP na URL configurada do host DSH (padr\xE3o: http://127.0.0.1:8080/inference). O \xE1udio usa a rota de transcri\xE7\xE3o autenticada do host DSH.",
2205
2204
  "dsh-live-voice.recognition.qwen.hostHelp": "Configura\xE7\xF5es de todo o host para o servidor Qwen3 ASR + TTS. Insira qualquer URL base HTTP ou HTTPS acess\xEDvel a partir do host DSH. O navegador acessa atrav\xE9s de rotas autenticadas do DSH.",
2206
2205
  "dsh-live-voice.recognition.qwen.label": "Qwen3 ASR \u2014 API HTTP",
2206
+ "dsh-live-voice.recognition.silenceDetection.customHelp": "Insira um n\xFAmero inteiro de 100 a 10.000 ms. Salvo ao sair do campo. Pausas curtas podem dividir a fala; o reconhecimento acrescenta sua pr\xF3pria lat\xEAncia.",
2207
+ "dsh-live-voice.recognition.silenceDetection.customLabel": "Pausa personalizada (milissegundos)",
2207
2208
  "dsh-live-voice.recognition.silenceDetection.duration": "Pausa antes de enviar: {milliseconds} ms",
2208
2209
  "dsh-live-voice.recognition.silenceDetection.help": "Controla a dura\xE7\xE3o m\xEDnima da pausa antes de enviar a fala capturada para reconhecimento.",
2209
2210
  "dsh-live-voice.recognition.silenceDetection.label": "Detec\xE7\xE3o de sil\xEAncio",
@@ -2241,6 +2242,8 @@ var ptBR = {
2241
2242
  "dsh-live-voice.settings.engine.refresh": "Atualizar mecanismos dispon\xEDveis",
2242
2243
  "dsh-live-voice.settings.filters.title": "Filtragem",
2243
2244
  "dsh-live-voice.settings.general.title": "Gerais",
2245
+ "dsh-live-voice.settings.persistence.loadError": "N\xE3o foi poss\xEDvel carregar as configura\xE7\xF5es do Live Voice do servidor. Recarregue para tentar novamente.",
2246
+ "dsh-live-voice.settings.persistence.saveError": "N\xE3o foi poss\xEDvel salvar as configura\xE7\xF5es do Live Voice no servidor. Tente novamente.",
2244
2247
  "dsh-live-voice.settings.tabs.conversation": "Conversa",
2245
2248
  "dsh-live-voice.settings.tabs.recognition": "Reconhecimento de voz",
2246
2249
  "dsh-live-voice.settings.tabs.speak": "Fala",
@@ -2409,6 +2412,8 @@ var zh = {
2409
2412
  "dsh-live-voice.recognition.planned.vote": "\u5373\u5C06\u63A8\u51FA \u2014 \u8BF7\u5728\u4ED3\u5E93\u8BAE\u9898\u4E2D\u6295\u7968",
2410
2413
  "dsh-live-voice.recognition.planned.voxtral": "Voxtral Realtime \u2014 \u5373\u5C06\u63A8\u51FA",
2411
2414
  "dsh-live-voice.recognition.planned.webGpu": "\u6D4F\u89C8\u5668 WebGPU \u63A8\u7406 \u2014 \u5373\u5C06\u63A8\u51FA",
2415
+ "dsh-live-voice.recognition.presets.custom.description": "\u81EA\u884C\u9009\u62E9\u9759\u97F3\u65F6\u957F\u3002",
2416
+ "dsh-live-voice.recognition.presets.custom.label": "\u81EA\u5B9A\u4E49",
2412
2417
  "dsh-live-voice.recognition.presets.long.description": "\u7B49\u5F85\u8F83\u957F\u7684\u601D\u8003\u505C\u987F\u3002",
2413
2418
  "dsh-live-voice.recognition.presets.long.label": "\u957F",
2414
2419
  "dsh-live-voice.recognition.presets.natural.description": "\u5141\u8BB8\u77ED\u8BED\u4E4B\u95F4\u51FA\u73B0\u81EA\u7136\u505C\u987F\u3002",
@@ -2421,6 +2426,8 @@ var zh = {
2421
2426
  "dsh-live-voice.recognition.qwen.endpointHelp": "HTTP API \u4F4D\u4E8E\u914D\u7F6E\u7684 DSH \u4E3B\u673A URL\uFF08\u9ED8\u8BA4\uFF1Ahttp://127.0.0.1:8080/inference\uFF09\u3002\u97F3\u9891\u901A\u8FC7\u9700\u8981\u8EAB\u4EFD\u9A8C\u8BC1\u7684 DSH \u4E3B\u673A\u8F6C\u5199\u8DEF\u7531\u53D1\u9001\u3002",
2422
2427
  "dsh-live-voice.recognition.qwen.hostHelp": "Qwen3 ASR + TTS \u670D\u52A1\u5668\u7684\u4E3B\u673A\u7EA7\u8BBE\u7F6E\u3002\u8BF7\u8F93\u5165 DSH \u4E3B\u673A\u53EF\u8BBF\u95EE\u7684\u4EFB\u610F HTTP \u6216 HTTPS \u57FA\u7840 URL\u3002\u6D4F\u89C8\u5668\u901A\u8FC7\u9700\u8981\u8EAB\u4EFD\u9A8C\u8BC1\u7684 DSH \u8DEF\u7531\u8BBF\u95EE\u8BE5\u670D\u52A1\u5668\u3002",
2423
2428
  "dsh-live-voice.recognition.qwen.label": "Qwen3 ASR \u2014 HTTP API",
2429
+ "dsh-live-voice.recognition.silenceDetection.customHelp": "\u8BF7\u8F93\u5165 100 \u81F3 10,000 ms \u7684\u6574\u6570\u3002\u79BB\u5F00\u8F93\u5165\u6846\u65F6\u4FDD\u5B58\u3002\u77ED\u505C\u987F\u53EF\u80FD\u5207\u5206\u8BED\u97F3\uFF1B\u8BC6\u522B\u8FD8\u9700\u8981\u989D\u5916\u65F6\u95F4\u3002",
2430
+ "dsh-live-voice.recognition.silenceDetection.customLabel": "\u81EA\u5B9A\u4E49\u505C\u987F\uFF08\u6BEB\u79D2\uFF09",
2424
2431
  "dsh-live-voice.recognition.silenceDetection.duration": "\u53D1\u9001\u524D\u505C\u987F\uFF1A{milliseconds} \u6BEB\u79D2",
2425
2432
  "dsh-live-voice.recognition.silenceDetection.help": "\u8BBE\u7F6E\u505C\u987F\u6301\u7EED\u591A\u4E45\u540E\uFF0C\u5C06\u91C7\u96C6\u7684\u8BED\u97F3\u53D1\u9001\u8FDB\u884C\u8BC6\u522B\u3002",
2426
2433
  "dsh-live-voice.recognition.silenceDetection.label": "\u9759\u97F3\u68C0\u6D4B",
@@ -2458,6 +2465,8 @@ var zh = {
2458
2465
  "dsh-live-voice.settings.engine.refresh": "\u5237\u65B0\u53EF\u7528\u5F15\u64CE",
2459
2466
  "dsh-live-voice.settings.filters.title": "\u8FC7\u6EE4",
2460
2467
  "dsh-live-voice.settings.general.title": "\u5E38\u89C4",
2468
+ "dsh-live-voice.settings.persistence.loadError": "\u65E0\u6CD5\u4ECE\u670D\u52A1\u5668\u52A0\u8F7D Live Voice \u8BBE\u7F6E\u3002\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5\u3002",
2469
+ "dsh-live-voice.settings.persistence.saveError": "\u65E0\u6CD5\u5728\u670D\u52A1\u5668\u4E0A\u4FDD\u5B58 Live Voice \u8BBE\u7F6E\u3002\u8BF7\u91CD\u8BD5\u3002",
2461
2470
  "dsh-live-voice.settings.tabs.conversation": "\u5BF9\u8BDD",
2462
2471
  "dsh-live-voice.settings.tabs.recognition": "\u8BED\u97F3\u8BC6\u522B",
2463
2472
  "dsh-live-voice.settings.tabs.speak": "\u8BED\u97F3\u5408\u6210",
@@ -2831,43 +2840,99 @@ function CheckboxField({
2831
2840
  }
2832
2841
 
2833
2842
  // src/shared/design-system/forms/NumberField.tsx
2843
+ var import_react12 = __toESM(require("react"), 1);
2844
+
2845
+ // src/shared/design-system/forms/DraftField.tsx
2834
2846
  var import_react11 = __toESM(require("react"), 1);
2835
- function NumberField({ label, ...props }) {
2836
- return /* @__PURE__ */ import_react11.default.createElement("label", null, label, /* @__PURE__ */ import_react11.default.createElement("input", { ...props, type: "number" }));
2847
+ function DraftField({
2848
+ label,
2849
+ value,
2850
+ defaultValue,
2851
+ onCommit,
2852
+ multiline,
2853
+ onInput,
2854
+ onChange,
2855
+ onFocus,
2856
+ onBlur,
2857
+ ...props
2858
+ }) {
2859
+ const saved = String(value ?? defaultValue ?? "");
2860
+ const [draft, setDraft] = import_react11.default.useState(saved);
2861
+ const editing = import_react11.default.useRef(false);
2862
+ const dirty = import_react11.default.useRef(false);
2863
+ import_react11.default.useEffect(() => {
2864
+ if (!editing.current) setDraft(saved);
2865
+ }, [saved]);
2866
+ const inputProps = {
2867
+ ...props,
2868
+ value: draft,
2869
+ onFocus: (event) => {
2870
+ editing.current = true;
2871
+ onFocus?.(event);
2872
+ },
2873
+ onInput: (event) => {
2874
+ editing.current = true;
2875
+ dirty.current = true;
2876
+ setDraft(event.currentTarget.value);
2877
+ onInput?.(event);
2878
+ },
2879
+ onChange: (event) => {
2880
+ editing.current = true;
2881
+ dirty.current = true;
2882
+ setDraft(event.currentTarget.value);
2883
+ onChange?.(event);
2884
+ },
2885
+ onBlur: (event) => {
2886
+ editing.current = false;
2887
+ const raw = event.currentTarget.value;
2888
+ const changed = dirty.current;
2889
+ dirty.current = false;
2890
+ setDraft(saved);
2891
+ const valid = multiline || props.type !== "number" || raw.trim() !== "" && event.currentTarget.validity.valid;
2892
+ if (changed && valid) onCommit?.(raw);
2893
+ onBlur?.(event);
2894
+ }
2895
+ };
2896
+ return /* @__PURE__ */ import_react11.default.createElement("label", null, label, multiline ? /* @__PURE__ */ import_react11.default.createElement("textarea", { ...inputProps }) : /* @__PURE__ */ import_react11.default.createElement("input", { ...inputProps }));
2897
+ }
2898
+
2899
+ // src/shared/design-system/forms/NumberField.tsx
2900
+ function NumberField(props) {
2901
+ return /* @__PURE__ */ import_react12.default.createElement(DraftField, { ...props, type: "number" });
2837
2902
  }
2838
2903
 
2839
2904
  // src/shared/design-system/forms/SelectField.tsx
2840
- var import_react12 = __toESM(require("react"), 1);
2905
+ var import_react13 = __toESM(require("react"), 1);
2841
2906
  function SelectField({ label, options, ...props }) {
2842
- return /* @__PURE__ */ import_react12.default.createElement("label", null, label, /* @__PURE__ */ import_react12.default.createElement("select", { ...props }, options.map((option) => /* @__PURE__ */ import_react12.default.createElement("option", { key: option.value, value: option.value, disabled: option.disabled }, option.label))));
2907
+ return /* @__PURE__ */ import_react13.default.createElement("label", null, label, /* @__PURE__ */ import_react13.default.createElement("select", { ...props }, options.map((option) => /* @__PURE__ */ import_react13.default.createElement("option", { key: option.value, value: option.value, disabled: option.disabled }, option.label))));
2843
2908
  }
2844
2909
 
2845
2910
  // src/shared/design-system/forms/TextAreaField.tsx
2846
- var import_react13 = __toESM(require("react"), 1);
2847
- function TextAreaField({ label, ...props }) {
2848
- return /* @__PURE__ */ import_react13.default.createElement("label", null, label, /* @__PURE__ */ import_react13.default.createElement("textarea", { ...props }));
2911
+ var import_react14 = __toESM(require("react"), 1);
2912
+ function TextAreaField(props) {
2913
+ return /* @__PURE__ */ import_react14.default.createElement(DraftField, { ...props, multiline: true });
2849
2914
  }
2850
2915
 
2851
2916
  // src/shared/design-system/forms/TextField.tsx
2852
- var import_react14 = __toESM(require("react"), 1);
2853
- function TextField({ label, ...props }) {
2854
- return /* @__PURE__ */ import_react14.default.createElement("label", null, label, /* @__PURE__ */ import_react14.default.createElement("input", { ...props, type: "text" }));
2917
+ var import_react15 = __toESM(require("react"), 1);
2918
+ function TextField(props) {
2919
+ return /* @__PURE__ */ import_react15.default.createElement(DraftField, { ...props, type: "text" });
2855
2920
  }
2856
2921
 
2857
2922
  // src/shared/design-system/layout/SettingsCard.tsx
2858
- var import_react15 = __toESM(require("react"), 1);
2923
+ var import_react16 = __toESM(require("react"), 1);
2859
2924
  function SettingsCard({
2860
2925
  children,
2861
2926
  className = ""
2862
2927
  }) {
2863
- return /* @__PURE__ */ import_react15.default.createElement("div", { className: ["dlv-settings-card", className].filter(Boolean).join(" ") }, children);
2928
+ return /* @__PURE__ */ import_react16.default.createElement("div", { className: ["dlv-settings-card", className].filter(Boolean).join(" ") }, children);
2864
2929
  }
2865
2930
 
2866
2931
  // src/shared/design-system/layout/SettingsSection.tsx
2867
- var import_react16 = __toESM(require("react"), 1);
2932
+ var import_react17 = __toESM(require("react"), 1);
2868
2933
 
2869
2934
  // src/shared/design-system/layout/SettingsSubcard.tsx
2870
- var import_react17 = __toESM(require("react"), 1);
2935
+ var import_react18 = __toESM(require("react"), 1);
2871
2936
  function SettingsSubcard({
2872
2937
  title,
2873
2938
  children,
@@ -2875,13 +2940,13 @@ function SettingsSubcard({
2875
2940
  ariaLabel,
2876
2941
  icon = "settings"
2877
2942
  }) {
2878
- return /* @__PURE__ */ import_react17.default.createElement("details", { className: "dlv-settings-subcard", open }, /* @__PURE__ */ import_react17.default.createElement("summary", { "aria-label": ariaLabel }, /* @__PURE__ */ import_react17.default.createElement(Icon, { name: icon, className: "dlv-settings-subcard-icon" }), /* @__PURE__ */ import_react17.default.createElement("span", null, title), /* @__PURE__ */ import_react17.default.createElement(Icon, { name: "chevron", className: "dlv-settings-subcard-chevron" })), /* @__PURE__ */ import_react17.default.createElement("div", { className: "dlv-settings-subcard-body" }, children));
2943
+ return /* @__PURE__ */ import_react18.default.createElement("details", { className: "dlv-settings-subcard", open }, /* @__PURE__ */ import_react18.default.createElement("summary", { "aria-label": ariaLabel }, /* @__PURE__ */ import_react18.default.createElement(Icon, { name: icon, className: "dlv-settings-subcard-icon" }), /* @__PURE__ */ import_react18.default.createElement("span", null, title), /* @__PURE__ */ import_react18.default.createElement(Icon, { name: "chevron", className: "dlv-settings-subcard-chevron" })), /* @__PURE__ */ import_react18.default.createElement("div", { className: "dlv-settings-subcard-body" }, children));
2879
2944
  }
2880
2945
 
2881
2946
  // src/shared/design-system/layout/SettingsTabs.tsx
2882
- var import_react18 = __toESM(require("react"), 1);
2947
+ var import_react19 = __toESM(require("react"), 1);
2883
2948
  function SettingsTabs({ label, tabs, active, idPrefix, onChange }) {
2884
- const refs = import_react18.default.useRef([]);
2949
+ const refs = import_react19.default.useRef([]);
2885
2950
  const activate = (index) => {
2886
2951
  const tab = tabs[index];
2887
2952
  if (!tab) return;
@@ -2898,7 +2963,7 @@ function SettingsTabs({ label, tabs, active, idPrefix, onChange }) {
2898
2963
  event.preventDefault();
2899
2964
  activate(next);
2900
2965
  };
2901
- return /* @__PURE__ */ import_react18.default.createElement("div", { className: "dlv-settings-tabs", role: "tablist", "aria-label": label }, tabs.map((tab, index) => /* @__PURE__ */ import_react18.default.createElement(
2966
+ return /* @__PURE__ */ import_react19.default.createElement("div", { className: "dlv-settings-tabs", role: "tablist", "aria-label": label }, tabs.map((tab, index) => /* @__PURE__ */ import_react19.default.createElement(
2902
2967
  "button",
2903
2968
  {
2904
2969
  key: tab.id,
@@ -2916,8 +2981,8 @@ function SettingsTabs({ label, tabs, active, idPrefix, onChange }) {
2916
2981
  onClick: () => onChange(tab.id),
2917
2982
  onKeyDown: (event) => onKeyDown(event, index)
2918
2983
  },
2919
- tab.icon ? /* @__PURE__ */ import_react18.default.createElement(Icon, { name: tab.icon, className: "dlv-settings-tab-icon" }) : null,
2920
- /* @__PURE__ */ import_react18.default.createElement("span", null, tab.label)
2984
+ tab.icon ? /* @__PURE__ */ import_react19.default.createElement(Icon, { name: tab.icon, className: "dlv-settings-tab-icon" }) : null,
2985
+ /* @__PURE__ */ import_react19.default.createElement("span", null, tab.label)
2921
2986
  )));
2922
2987
  }
2923
2988
 
@@ -3277,7 +3342,8 @@ var WhisperHttpRecognitionEngine = class {
3277
3342
  constructor({
3278
3343
  globals = globalThis,
3279
3344
  meter,
3280
- voiceDetectionPreset = "natural",
3345
+ voiceDetectionPreset = defaultSettings.voiceDetectionPreset,
3346
+ voiceDetectionCustomSilenceMs = defaultSettings.voiceDetectionCustomSilenceMs,
3281
3347
  maxUtteranceSeconds = 60
3282
3348
  } = {}) {
3283
3349
  this.g = globals;
@@ -3285,11 +3351,12 @@ var WhisperHttpRecognitionEngine = class {
3285
3351
  this.session = null;
3286
3352
  this.lang = "pt-BR";
3287
3353
  this.voiceDetectionPreset = voiceDetectionPreset;
3354
+ this.voiceDetectionCustomSilenceMs = voiceDetectionCustomSilenceMs;
3288
3355
  this.maxUtteranceSeconds = maxUtteranceSeconds;
3289
3356
  this.route = ROUTE;
3290
3357
  }
3291
3358
  get segmentation() {
3292
- return voiceDetectionPresets[this.voiceDetectionPreset] || voiceDetectionPresets.natural;
3359
+ return { silenceMs: voiceDetectionSilenceMs(this) };
3293
3360
  }
3294
3361
  async capability() {
3295
3362
  try {
@@ -3787,16 +3854,16 @@ var QwenHttpSpeakingEngine = class extends HostAudioSpeakingEngine {
3787
3854
  };
3788
3855
 
3789
3856
  // src/modules/settings/components/createLiveVoiceSettings.tsx
3790
- var import_react45 = __toESM(require("react"), 1);
3857
+ var import_react46 = __toESM(require("react"), 1);
3791
3858
 
3792
3859
  // src/modules/settings/components/LiveVoiceSettings.tsx
3793
- var import_react44 = __toESM(require("react"), 1);
3860
+ var import_react45 = __toESM(require("react"), 1);
3794
3861
 
3795
3862
  // src/modules/settings/hooks/useAudioDevices.ts
3796
- var import_react20 = __toESM(require("react"), 1);
3863
+ var import_react21 = __toESM(require("react"), 1);
3797
3864
  function useAudioDevices() {
3798
- const [audioDevices, setAudioDevices] = import_react20.default.useState([]);
3799
- import_react20.default.useEffect(() => {
3865
+ const [audioDevices, setAudioDevices] = import_react21.default.useState([]);
3866
+ import_react21.default.useEffect(() => {
3800
3867
  let active = true;
3801
3868
  const refresh = async () => {
3802
3869
  try {
@@ -3826,17 +3893,17 @@ function createDeviceOptions(devices, kind, systemDefault, fallback) {
3826
3893
  }
3827
3894
 
3828
3895
  // src/modules/conversation/hooks/useConversationActions.ts
3829
- var import_react21 = __toESM(require("react"), 1);
3896
+ var import_react22 = __toESM(require("react"), 1);
3830
3897
  function useConversationActions(controller) {
3831
- const [error, setError] = import_react21.default.useState("");
3832
- const alive = import_react21.default.useRef(true);
3833
- import_react21.default.useEffect(
3898
+ const [error, setError] = import_react22.default.useState("");
3899
+ const alive = import_react22.default.useRef(true);
3900
+ import_react22.default.useEffect(
3834
3901
  () => () => {
3835
3902
  alive.current = false;
3836
3903
  },
3837
3904
  []
3838
3905
  );
3839
- const invoke = import_react21.default.useCallback(
3906
+ const invoke = import_react22.default.useCallback(
3840
3907
  (name, ...args) => {
3841
3908
  setError("");
3842
3909
  try {
@@ -3849,26 +3916,26 @@ function useConversationActions(controller) {
3849
3916
  },
3850
3917
  [controller]
3851
3918
  );
3852
- const clearError = import_react21.default.useCallback(() => setError(""), []);
3919
+ const clearError = import_react22.default.useCallback(() => setError(""), []);
3853
3920
  return { invoke, error, clearError };
3854
3921
  }
3855
3922
 
3856
3923
  // src/modules/conversation/hooks/useConversationController.ts
3857
- var import_react22 = __toESM(require("react"), 1);
3924
+ var import_react23 = __toESM(require("react"), 1);
3858
3925
  function useConversationController(controller) {
3859
- const subscribe = import_react22.default.useCallback(
3926
+ const subscribe = import_react23.default.useCallback(
3860
3927
  (listener) => controller.subscribe(listener),
3861
3928
  [controller]
3862
3929
  );
3863
- const read = import_react22.default.useCallback(() => controller.getSnapshot(), [controller]);
3864
- return import_react22.default.useSyncExternalStore(subscribe, read, read);
3930
+ const read = import_react23.default.useCallback(() => controller.getSnapshot(), [controller]);
3931
+ return import_react23.default.useSyncExternalStore(subscribe, read, read);
3865
3932
  }
3866
3933
 
3867
3934
  // src/modules/settings/hooks/useReleaseStatus.ts
3868
- var import_react23 = __toESM(require("react"), 1);
3935
+ var import_react24 = __toESM(require("react"), 1);
3869
3936
 
3870
3937
  // src/modules/settings/services/releases.ts
3871
- var CURRENT_VERSION = "0.3.1";
3938
+ var CURRENT_VERSION = "0.3.2";
3872
3939
  var TESTED_DSH_VERSION = "0.2.0-rc.2";
3873
3940
  var REPOSITORY_URL = "https://github.com/victorwads/dsh-live-voice";
3874
3941
  var RELEASES_URL = `${REPOSITORY_URL}/releases`;
@@ -3929,9 +3996,16 @@ function writeCached(storage, value) {
3929
3996
  } catch {
3930
3997
  }
3931
3998
  }
3999
+ var releaseMemory = /* @__PURE__ */ new Map();
4000
+ var memoryStorage = {
4001
+ getItem: (key) => releaseMemory.get(key) ?? null,
4002
+ setItem: (key, value) => {
4003
+ releaseMemory.set(key, value);
4004
+ }
4005
+ };
3932
4006
  async function checkLatestRelease({
3933
4007
  fetchImpl = globalThis.window?.fetch?.bind(globalThis.window),
3934
- storage = globalThis.window?.localStorage,
4008
+ storage = memoryStorage,
3935
4009
  now = Date.now()
3936
4010
  } = {}) {
3937
4011
  const cached = readCached(storage, now);
@@ -3963,10 +4037,10 @@ function hasNewerRelease(release, currentVersion = CURRENT_VERSION) {
3963
4037
 
3964
4038
  // src/modules/settings/hooks/useReleaseStatus.ts
3965
4039
  function useReleaseStatus() {
3966
- const [latestRelease, setLatestRelease] = import_react23.default.useState(
4040
+ const [latestRelease, setLatestRelease] = import_react24.default.useState(
3967
4041
  null
3968
4042
  );
3969
- import_react23.default.useEffect(() => {
4043
+ import_react24.default.useEffect(() => {
3970
4044
  let active = true;
3971
4045
  void checkLatestRelease().then((result) => {
3972
4046
  if (active) setLatestRelease(result.release);
@@ -3979,10 +4053,10 @@ function useReleaseStatus() {
3979
4053
  }
3980
4054
 
3981
4055
  // src/modules/settings/sections/conversation/ConversationSettingsSection.tsx
3982
- var import_react29 = __toESM(require("react"), 1);
4056
+ var import_react30 = __toESM(require("react"), 1);
3983
4057
 
3984
4058
  // src/modules/settings/sections/conversation/DeliverySettings.tsx
3985
- var import_react24 = __toESM(require("react"), 1);
4059
+ var import_react25 = __toESM(require("react"), 1);
3986
4060
  function DeliverySettings({
3987
4061
  settings,
3988
4062
  updateSettings
@@ -3990,7 +4064,7 @@ function DeliverySettings({
3990
4064
  const { scoped } = useLanguage((ctx) => ctx.settings);
3991
4065
  const mode = settings.sendingMode || "manual";
3992
4066
  const seconds = [2, 3, 4, 5, 6, 8, 10];
3993
- return /* @__PURE__ */ import_react24.default.createElement(import_react24.default.Fragment, null, /* @__PURE__ */ import_react24.default.createElement(
4067
+ return /* @__PURE__ */ import_react25.default.createElement(import_react25.default.Fragment, null, /* @__PURE__ */ import_react25.default.createElement(
3994
4068
  SelectField,
3995
4069
  {
3996
4070
  label: scoped.delivery.label(),
@@ -4002,7 +4076,7 @@ function DeliverySettings({
4002
4076
  ],
4003
4077
  onChange: (event) => updateSettings({ sendingMode: event.target.value })
4004
4078
  }
4005
- ), mode !== "manual" ? /* @__PURE__ */ import_react24.default.createElement(
4079
+ ), mode !== "manual" ? /* @__PURE__ */ import_react25.default.createElement(
4006
4080
  SelectField,
4007
4081
  {
4008
4082
  label: scoped.autoSend.delay(),
@@ -4010,17 +4084,17 @@ function DeliverySettings({
4010
4084
  options: seconds.map((value) => ({ value: String(value), label: String(value) })),
4011
4085
  onChange: (event) => updateSettings({ autoSendDelaySeconds: Number(event.target.value) })
4012
4086
  }
4013
- ) : /* @__PURE__ */ import_react24.default.createElement("p", { className: "dlv-setting-description" }, scoped.delivery.manualLabel()));
4087
+ ) : /* @__PURE__ */ import_react25.default.createElement("p", { className: "dlv-setting-description" }, scoped.delivery.manualLabel()));
4014
4088
  }
4015
4089
 
4016
4090
  // src/modules/settings/sections/conversation/HoldToTalkSettings.tsx
4017
- var import_react25 = __toESM(require("react"), 1);
4091
+ var import_react26 = __toESM(require("react"), 1);
4018
4092
  function HoldToTalkSettings({
4019
4093
  settings,
4020
4094
  updateSettings
4021
4095
  }) {
4022
4096
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4023
- return /* @__PURE__ */ import_react25.default.createElement(
4097
+ return /* @__PURE__ */ import_react26.default.createElement(
4024
4098
  CheckboxField,
4025
4099
  {
4026
4100
  label: recognition.holdToTalk.enabled(),
@@ -4032,14 +4106,14 @@ function HoldToTalkSettings({
4032
4106
  }
4033
4107
 
4034
4108
  // src/modules/settings/sections/conversation/VoiceModeSettings.tsx
4035
- var import_react26 = __toESM(require("react"), 1);
4109
+ var import_react27 = __toESM(require("react"), 1);
4036
4110
  function VoiceModeSettings({
4037
4111
  settings,
4038
4112
  updateSettings
4039
4113
  }) {
4040
4114
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4041
4115
  const mode = settings.mode || "speaker";
4042
- return /* @__PURE__ */ import_react26.default.createElement(import_react26.default.Fragment, null, /* @__PURE__ */ import_react26.default.createElement(
4116
+ return /* @__PURE__ */ import_react27.default.createElement(import_react27.default.Fragment, null, /* @__PURE__ */ import_react27.default.createElement(
4043
4117
  SelectField,
4044
4118
  {
4045
4119
  label: recognition.mode.label(),
@@ -4050,16 +4124,16 @@ function VoiceModeSettings({
4050
4124
  ],
4051
4125
  onChange: (event) => updateSettings({ mode: event.target.value })
4052
4126
  }
4053
- ), /* @__PURE__ */ import_react26.default.createElement("p", { className: "dlv-setting-description" }, mode === "headphones" ? recognition.headphoneMode.help() : recognition.speakerMode.help()));
4127
+ ), /* @__PURE__ */ import_react27.default.createElement("p", { className: "dlv-setting-description" }, mode === "headphones" ? recognition.headphoneMode.help() : recognition.speakerMode.help()));
4054
4128
  }
4055
4129
 
4056
4130
  // src/modules/settings/sections/conversation/ConversationDelaySettings.tsx
4057
- var import_react27 = __toESM(require("react"), 1);
4131
+ var import_react28 = __toESM(require("react"), 1);
4058
4132
  var DELAYS = [1, 2, 3, 4, 5, 6, 8, 10];
4059
4133
  function ConversationDelaySettings({ settings, updateSettings }) {
4060
4134
  const { scoped: commons } = useLanguage((ctx) => ctx.commons);
4061
4135
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
4062
- return /* @__PURE__ */ import_react27.default.createElement(import_react27.default.Fragment, null, /* @__PURE__ */ import_react27.default.createElement(
4136
+ return /* @__PURE__ */ import_react28.default.createElement(import_react28.default.Fragment, null, /* @__PURE__ */ import_react28.default.createElement(
4063
4137
  SelectField,
4064
4138
  {
4065
4139
  label: speak.responseDelay.label(),
@@ -4070,41 +4144,41 @@ function ConversationDelaySettings({ settings, updateSettings }) {
4070
4144
  })),
4071
4145
  onChange: (event) => updateSettings({ assistantSpeechDelaySeconds: Number(event.target.value) })
4072
4146
  }
4073
- ), /* @__PURE__ */ import_react27.default.createElement("p", { className: "dlv-setting-description" }, speak.responseDelay.help()));
4147
+ ), /* @__PURE__ */ import_react28.default.createElement("p", { className: "dlv-setting-description" }, speak.responseDelay.help()));
4074
4148
  }
4075
4149
 
4076
4150
  // src/modules/settings/sections/speak/PlaybackPolicySettings.tsx
4077
- var import_react28 = __toESM(require("react"), 1);
4151
+ var import_react29 = __toESM(require("react"), 1);
4078
4152
  function PlaybackPolicySettings({ settings, updateSettings }) {
4079
4153
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
4080
- return /* @__PURE__ */ import_react28.default.createElement(import_react28.default.Fragment, null, /* @__PURE__ */ import_react28.default.createElement(
4154
+ return /* @__PURE__ */ import_react29.default.createElement(import_react29.default.Fragment, null, /* @__PURE__ */ import_react29.default.createElement(
4081
4155
  CheckboxField,
4082
4156
  {
4083
4157
  label: speak.autoPlayback.label(),
4084
4158
  checked: settings.announceAssistantMessages !== false,
4085
4159
  onChange: (event) => updateSettings({ announceAssistantMessages: event.target.checked })
4086
4160
  }
4087
- ), /* @__PURE__ */ import_react28.default.createElement("p", null, speak.autoPlayback.help()), /* @__PURE__ */ import_react28.default.createElement(
4161
+ ), /* @__PURE__ */ import_react29.default.createElement("p", null, speak.autoPlayback.help()), /* @__PURE__ */ import_react29.default.createElement(
4088
4162
  CheckboxField,
4089
4163
  {
4090
4164
  label: speak.interruption.enabled(),
4091
4165
  checked: Boolean(settings.interruptSpeechOnUserMessage),
4092
4166
  onChange: (event) => updateSettings({ interruptSpeechOnUserMessage: event.target.checked })
4093
4167
  }
4094
- ), /* @__PURE__ */ import_react28.default.createElement("p", null, settings.interruptSpeechOnUserMessage ? speak.interruption.enabledHelp() : speak.interruption.disabledHelp()));
4168
+ ), /* @__PURE__ */ import_react29.default.createElement("p", null, settings.interruptSpeechOnUserMessage ? speak.interruption.enabledHelp() : speak.interruption.disabledHelp()));
4095
4169
  }
4096
4170
 
4097
4171
  // src/modules/settings/sections/conversation/ConversationSettingsSection.tsx
4098
4172
  function ConversationSettingsSection(props) {
4099
4173
  const { scoped: settingsLanguage } = useLanguage((ctx) => ctx.settings);
4100
- return /* @__PURE__ */ import_react29.default.createElement(SettingsCard, null, /* @__PURE__ */ import_react29.default.createElement("div", { className: "dlv-settings-card-body" }, /* @__PURE__ */ import_react29.default.createElement(SettingsSubcard, { title: settingsLanguage.general.title(), icon: "send" }, /* @__PURE__ */ import_react29.default.createElement("div", { className: "dlv-conversation-fields" }, /* @__PURE__ */ import_react29.default.createElement(DeliverySettings, { ...props }), /* @__PURE__ */ import_react29.default.createElement(HoldToTalkSettings, { ...props }), /* @__PURE__ */ import_react29.default.createElement(VoiceModeSettings, { ...props }), /* @__PURE__ */ import_react29.default.createElement(PlaybackPolicySettings, { ...props }), /* @__PURE__ */ import_react29.default.createElement(ConversationDelaySettings, { ...props })))));
4174
+ return /* @__PURE__ */ import_react30.default.createElement(SettingsCard, null, /* @__PURE__ */ import_react30.default.createElement("div", { className: "dlv-settings-card-body" }, /* @__PURE__ */ import_react30.default.createElement(SettingsSubcard, { title: settingsLanguage.general.title(), icon: "send" }, /* @__PURE__ */ import_react30.default.createElement("div", { className: "dlv-conversation-fields" }, /* @__PURE__ */ import_react30.default.createElement(DeliverySettings, { ...props }), /* @__PURE__ */ import_react30.default.createElement(HoldToTalkSettings, { ...props }), /* @__PURE__ */ import_react30.default.createElement(VoiceModeSettings, { ...props }), /* @__PURE__ */ import_react30.default.createElement(PlaybackPolicySettings, { ...props }), /* @__PURE__ */ import_react30.default.createElement(ConversationDelaySettings, { ...props })))));
4101
4175
  }
4102
4176
 
4103
4177
  // src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx
4104
- var import_react32 = __toESM(require("react"), 1);
4178
+ var import_react33 = __toESM(require("react"), 1);
4105
4179
 
4106
4180
  // src/modules/core/qwen/QwenSettings.tsx
4107
- var import_react30 = __toESM(require("react"), 1);
4181
+ var import_react31 = __toESM(require("react"), 1);
4108
4182
  var BASE = "/api/dsh-live-voice/qwen";
4109
4183
  var UNLOADED = "dsh-live-voice.speak.qwen.restartRequired";
4110
4184
  async function qwenSettingsRequest(path, { method = "GET", config, signal } = {}, fetchImpl = globalThis.fetch) {
@@ -4133,15 +4207,15 @@ function QwenSettings({ controller }) {
4133
4207
  const { scoped: commons } = useLanguage((ctx) => ctx.commons);
4134
4208
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4135
4209
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
4136
- const [draft, setDraft] = import_react30.default.useState({
4210
+ const [draft, setDraft] = import_react31.default.useState({
4137
4211
  baseUrl: "http://127.0.0.1:8080/",
4138
4212
  timeoutMs: 3e5
4139
4213
  });
4140
- const [busy, setBusy] = import_react30.default.useState(true);
4141
- const [loaded, setLoaded] = import_react30.default.useState(false);
4142
- const [error, setError] = import_react30.default.useState("");
4143
- const [message2, setMessage] = import_react30.default.useState("");
4144
- const active = import_react30.default.useRef(null);
4214
+ const [busy, setBusy] = import_react31.default.useState(true);
4215
+ const [loaded, setLoaded] = import_react31.default.useState(false);
4216
+ const [error, setError] = import_react31.default.useState("");
4217
+ const [message2, setMessage] = import_react31.default.useState("");
4218
+ const active = import_react31.default.useRef(null);
4145
4219
  const translateStatus = (value) => ({
4146
4220
  "dsh-live-voice.commons.connection.unsaved": commons.connection.unsaved(),
4147
4221
  "dsh-live-voice.speak.qwen.saved": speak.qwen.saved(),
@@ -4151,7 +4225,7 @@ function QwenSettings({ controller }) {
4151
4225
  "dsh-live-voice.speak.qwen.requestFailed": speak.qwen.requestFailed(),
4152
4226
  "dsh-live-voice.speak.qwen.healthFailed": speak.qwen.healthFailed()
4153
4227
  })[value] ?? value;
4154
- async function run(action) {
4228
+ async function run(action, config = draft) {
4155
4229
  active.current?.abort();
4156
4230
  const abort = new AbortController();
4157
4231
  active.current = abort;
@@ -4169,7 +4243,7 @@ function QwenSettings({ controller }) {
4169
4243
  await controller.endConversation?.();
4170
4244
  const value = await qwenSettingsRequest("/config", {
4171
4245
  method: "PUT",
4172
- config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
4246
+ config: { ...config, timeoutMs: Number(config.timeoutMs) },
4173
4247
  signal: abort.signal
4174
4248
  });
4175
4249
  if (!abort.signal.aborted) {
@@ -4180,7 +4254,7 @@ function QwenSettings({ controller }) {
4180
4254
  } else {
4181
4255
  const value = await qwenSettingsRequest("/test", {
4182
4256
  method: "POST",
4183
- config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
4257
+ config: { ...config, timeoutMs: Number(config.timeoutMs) },
4184
4258
  signal: abort.signal
4185
4259
  });
4186
4260
  if (!abort.signal.aborted) {
@@ -4195,30 +4269,33 @@ function QwenSettings({ controller }) {
4195
4269
  if (!abort.signal.aborted) setBusy(false);
4196
4270
  }
4197
4271
  }
4198
- import_react30.default.useEffect(() => {
4272
+ import_react31.default.useEffect(() => {
4199
4273
  void run("load");
4200
4274
  return () => active.current?.abort();
4201
4275
  }, []);
4202
- const field = (label, key, type = "text") => /* @__PURE__ */ import_react30.default.createElement("label", null, label, /* @__PURE__ */ import_react30.default.createElement(
4203
- "input",
4204
- {
4205
- type,
4206
- value: draft[key],
4207
- disabled: busy || !loaded,
4208
- autoComplete: "off",
4209
- ...type === "number" ? { min: 1e3, max: 6e5, step: 1 } : {},
4210
- onChange: (event) => {
4211
- setDraft({ ...draft, [key]: event.target.value });
4212
- setMessage("dsh-live-voice.commons.connection.unsaved");
4213
- setError("");
4276
+ const field = (label, key, type = "text") => {
4277
+ const Field = type === "number" ? NumberField : TextField;
4278
+ return /* @__PURE__ */ import_react31.default.createElement(
4279
+ Field,
4280
+ {
4281
+ label,
4282
+ value: draft[key],
4283
+ disabled: busy || !loaded,
4284
+ autoComplete: "off",
4285
+ ...type === "number" ? { min: 1e3, max: 6e5, step: 1 } : {},
4286
+ onCommit: (value) => {
4287
+ const config = { ...draft, [key]: value };
4288
+ setDraft(config);
4289
+ void run("save", config);
4290
+ }
4214
4291
  }
4215
- }
4216
- ));
4217
- return /* @__PURE__ */ import_react30.default.createElement(import_react30.default.Fragment, null, /* @__PURE__ */ import_react30.default.createElement("p", null, recognition.qwen.hostHelp()), field(speak.qwen.endpoint(), "baseUrl"), field(commons.connection.timeout(), "timeoutMs", "number"), /* @__PURE__ */ import_react30.default.createElement("div", { className: "dlv-settings-actions" }, /* @__PURE__ */ import_react30.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("save") }, speak.qwen.save()), /* @__PURE__ */ import_react30.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("test") }, speak.qwen.test()), /* @__PURE__ */ import_react30.default.createElement("button", { type: "button", disabled: busy, onClick: () => run("load") }, commons.connection.reload())), busy && /* @__PURE__ */ import_react30.default.createElement("p", { role: "status" }, commons.connection.contactingHost()), message2 && /* @__PURE__ */ import_react30.default.createElement("p", { role: "status" }, translateStatus(message2)), error && /* @__PURE__ */ import_react30.default.createElement("p", { role: "alert" }, translateStatus(error)));
4292
+ );
4293
+ };
4294
+ return /* @__PURE__ */ import_react31.default.createElement(import_react31.default.Fragment, null, /* @__PURE__ */ import_react31.default.createElement("p", null, recognition.qwen.hostHelp()), field(speak.qwen.endpoint(), "baseUrl"), field(commons.connection.timeout(), "timeoutMs", "number"), /* @__PURE__ */ import_react31.default.createElement("div", { className: "dlv-settings-actions" }, /* @__PURE__ */ import_react31.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("save") }, speak.qwen.save()), /* @__PURE__ */ import_react31.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("test") }, speak.qwen.test()), /* @__PURE__ */ import_react31.default.createElement("button", { type: "button", disabled: busy, onClick: () => run("load") }, commons.connection.reload())), busy && /* @__PURE__ */ import_react31.default.createElement("p", { role: "status" }, commons.connection.contactingHost()), message2 && /* @__PURE__ */ import_react31.default.createElement("p", { role: "status" }, translateStatus(message2)), error && /* @__PURE__ */ import_react31.default.createElement("p", { role: "alert" }, translateStatus(error)));
4218
4295
  }
4219
4296
 
4220
4297
  // src/modules/recognition/engines/whisper/WhisperSettings.tsx
4221
- var import_react31 = __toESM(require("react"), 1);
4298
+ var import_react32 = __toESM(require("react"), 1);
4222
4299
  var BASE2 = "/api/dsh-live-voice/whisper";
4223
4300
  var UNLOADED2 = "dsh-live-voice.recognition.whisper.restartRequired";
4224
4301
  async function whisperSettingsRequest(path, { method = "GET", config, signal } = {}, fetchImpl = globalThis.fetch) {
@@ -4247,16 +4324,16 @@ function WhisperSettings({ controller }) {
4247
4324
  const { scoped: commons } = useLanguage((ctx) => ctx.commons);
4248
4325
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4249
4326
  const { scoped: settings } = useLanguage((ctx) => ctx.settings);
4250
- const [draft, setDraft] = import_react31.default.useState({
4327
+ const [draft, setDraft] = import_react32.default.useState({
4251
4328
  url: "http://127.0.0.1:8080/inference",
4252
4329
  healthUrl: "/health",
4253
4330
  timeoutMs: 3e4
4254
4331
  });
4255
- const [busy, setBusy] = import_react31.default.useState(true);
4256
- const [loaded, setLoaded] = import_react31.default.useState(false);
4257
- const [error, setError] = import_react31.default.useState("");
4258
- const [message2, setMessage] = import_react31.default.useState("");
4259
- const active = import_react31.default.useRef(null);
4332
+ const [busy, setBusy] = import_react32.default.useState(true);
4333
+ const [loaded, setLoaded] = import_react32.default.useState(false);
4334
+ const [error, setError] = import_react32.default.useState("");
4335
+ const [message2, setMessage] = import_react32.default.useState("");
4336
+ const active = import_react32.default.useRef(null);
4260
4337
  const translateStatus = (value) => ({
4261
4338
  "dsh-live-voice.commons.connection.unsaved": commons.connection.unsaved(),
4262
4339
  "dsh-live-voice.recognition.whisper.saved": recognition.whisper.saved(),
@@ -4266,7 +4343,7 @@ function WhisperSettings({ controller }) {
4266
4343
  "dsh-live-voice.recognition.whisper.requestFailed": recognition.whisper.requestFailed(),
4267
4344
  "dsh-live-voice.recognition.whisper.healthFailed": recognition.whisper.healthFailed()
4268
4345
  })[value] ?? value;
4269
- async function run(action) {
4346
+ async function run(action, config = draft) {
4270
4347
  active.current?.abort();
4271
4348
  const abort = new AbortController();
4272
4349
  active.current = abort;
@@ -4284,7 +4361,7 @@ function WhisperSettings({ controller }) {
4284
4361
  await controller.endConversation?.();
4285
4362
  const value = await whisperSettingsRequest("/config", {
4286
4363
  method: "PUT",
4287
- config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
4364
+ config: { ...config, timeoutMs: Number(config.timeoutMs) },
4288
4365
  signal: abort.signal
4289
4366
  });
4290
4367
  if (!abort.signal.aborted) {
@@ -4295,7 +4372,7 @@ function WhisperSettings({ controller }) {
4295
4372
  } else {
4296
4373
  const value = await whisperSettingsRequest("/test", {
4297
4374
  method: "POST",
4298
- config: { ...draft, timeoutMs: Number(draft.timeoutMs) },
4375
+ config: { ...config, timeoutMs: Number(config.timeoutMs) },
4299
4376
  signal: abort.signal
4300
4377
  });
4301
4378
  if (!abort.signal.aborted) {
@@ -4310,28 +4387,29 @@ function WhisperSettings({ controller }) {
4310
4387
  if (!abort.signal.aborted) setBusy(false);
4311
4388
  }
4312
4389
  }
4313
- import_react31.default.useEffect(() => {
4390
+ import_react32.default.useEffect(() => {
4314
4391
  void run("load");
4315
4392
  return () => active.current?.abort();
4316
4393
  }, []);
4317
- function field(label, key, type = "text") {
4318
- return /* @__PURE__ */ import_react31.default.createElement("label", null, label, /* @__PURE__ */ import_react31.default.createElement(
4319
- "input",
4394
+ const field = (label, key, type = "text") => {
4395
+ const Field = type === "number" ? NumberField : TextField;
4396
+ return /* @__PURE__ */ import_react32.default.createElement(
4397
+ Field,
4320
4398
  {
4321
- type,
4399
+ label,
4322
4400
  value: draft[key],
4323
4401
  disabled: busy || !loaded,
4324
4402
  autoComplete: "off",
4325
4403
  ...type === "number" ? { min: 100, max: 3e5, step: 1 } : {},
4326
- onChange: (event) => {
4327
- setDraft({ ...draft, [key]: event.target.value });
4328
- setMessage("dsh-live-voice.commons.connection.unsaved");
4329
- setError("");
4404
+ onCommit: (value) => {
4405
+ const config = { ...draft, [key]: value };
4406
+ setDraft(config);
4407
+ void run("save", config);
4330
4408
  }
4331
4409
  }
4332
- ));
4333
- }
4334
- return /* @__PURE__ */ import_react31.default.createElement(import_react31.default.Fragment, null, /* @__PURE__ */ import_react31.default.createElement("p", null, settings.whisper.hostHelp()), field(commons.connection.endpoint(), "url"), field(commons.connection.healthEndpoint(), "healthUrl"), field(commons.connection.timeout(), "timeoutMs", "number"), /* @__PURE__ */ import_react31.default.createElement("div", { className: "dlv-settings-actions" }, /* @__PURE__ */ import_react31.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("save") }, recognition.whisper.save()), /* @__PURE__ */ import_react31.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("test") }, commons.connection.test()), /* @__PURE__ */ import_react31.default.createElement("button", { type: "button", disabled: busy, onClick: () => run("load") }, commons.connection.reload())), busy && /* @__PURE__ */ import_react31.default.createElement("p", { role: "status" }, commons.connection.contactingHost()), message2 && /* @__PURE__ */ import_react31.default.createElement("p", { role: "status" }, translateStatus(message2)), error && /* @__PURE__ */ import_react31.default.createElement("p", { role: "alert" }, translateStatus(error)));
4410
+ );
4411
+ };
4412
+ return /* @__PURE__ */ import_react32.default.createElement(import_react32.default.Fragment, null, /* @__PURE__ */ import_react32.default.createElement("p", null, settings.whisper.hostHelp()), field(commons.connection.endpoint(), "url"), field(commons.connection.healthEndpoint(), "healthUrl"), field(commons.connection.timeout(), "timeoutMs", "number"), /* @__PURE__ */ import_react32.default.createElement("div", { className: "dlv-settings-actions" }, /* @__PURE__ */ import_react32.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("save") }, recognition.whisper.save()), /* @__PURE__ */ import_react32.default.createElement("button", { type: "button", disabled: busy || !loaded, onClick: () => run("test") }, commons.connection.test()), /* @__PURE__ */ import_react32.default.createElement("button", { type: "button", disabled: busy, onClick: () => run("load") }, commons.connection.reload())), busy && /* @__PURE__ */ import_react32.default.createElement("p", { role: "status" }, commons.connection.contactingHost()), message2 && /* @__PURE__ */ import_react32.default.createElement("p", { role: "status" }, translateStatus(message2)), error && /* @__PURE__ */ import_react32.default.createElement("p", { role: "alert" }, translateStatus(error)));
4335
4413
  }
4336
4414
 
4337
4415
  // src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx
@@ -4348,7 +4426,7 @@ function RecognitionEngineSettings({
4348
4426
  const setEngine = (next) => updateSettings(
4349
4427
  next === "browser" && settings.recognitionLang === "auto" ? { recognitionEngine: next, recognitionLang: "pt-BR" } : { recognitionEngine: next }
4350
4428
  );
4351
- return /* @__PURE__ */ import_react32.default.createElement(import_react32.default.Fragment, null, /* @__PURE__ */ import_react32.default.createElement(
4429
+ return /* @__PURE__ */ import_react33.default.createElement(import_react33.default.Fragment, null, /* @__PURE__ */ import_react33.default.createElement(
4352
4430
  SelectField,
4353
4431
  {
4354
4432
  label: recognition.engine.label(),
@@ -4372,7 +4450,7 @@ function RecognitionEngineSettings({
4372
4450
  ],
4373
4451
  onChange: (event) => setEngine(event.target.value)
4374
4452
  }
4375
- ), /* @__PURE__ */ import_react32.default.createElement(
4453
+ ), /* @__PURE__ */ import_react33.default.createElement(
4376
4454
  SelectField,
4377
4455
  {
4378
4456
  label: recognition.microphone.device(),
@@ -4385,7 +4463,7 @@ function RecognitionEngineSettings({
4385
4463
  ),
4386
4464
  onChange: (event) => updateSettings({ inputDeviceId: event.target.value })
4387
4465
  }
4388
- ), /* @__PURE__ */ import_react32.default.createElement(
4466
+ ), /* @__PURE__ */ import_react33.default.createElement(
4389
4467
  SelectField,
4390
4468
  {
4391
4469
  label: recognition.language.label(),
@@ -4397,29 +4475,29 @@ function RecognitionEngineSettings({
4397
4475
  ],
4398
4476
  onChange: (event) => updateSettings({ recognitionLang: event.target.value })
4399
4477
  }
4400
- ), engine === "browser" && /* @__PURE__ */ import_react32.default.createElement(import_react32.default.Fragment, null, /* @__PURE__ */ import_react32.default.createElement("p", null, recognition.browser.help()), /* @__PURE__ */ import_react32.default.createElement(
4478
+ ), engine === "browser" && /* @__PURE__ */ import_react33.default.createElement(import_react33.default.Fragment, null, /* @__PURE__ */ import_react33.default.createElement("p", null, recognition.browser.help()), /* @__PURE__ */ import_react33.default.createElement(
4401
4479
  CheckboxField,
4402
4480
  {
4403
4481
  label: recognition.browser.localProcessing(),
4404
4482
  checked: settings.recognitionProcessLocally !== false,
4405
4483
  onChange: (event) => updateSettings({ recognitionProcessLocally: event.target.checked })
4406
4484
  }
4407
- ), settings.recognitionProcessLocally !== false ? /* @__PURE__ */ import_react32.default.createElement(
4485
+ ), settings.recognitionProcessLocally !== false ? /* @__PURE__ */ import_react33.default.createElement(
4408
4486
  CheckboxField,
4409
4487
  {
4410
4488
  label: recognition.browser.autoInstallPack(),
4411
4489
  checked: settings.recognitionAutoInstall !== false,
4412
4490
  onChange: (event) => updateSettings({ recognitionAutoInstall: event.target.checked })
4413
4491
  }
4414
- ) : /* @__PURE__ */ import_react32.default.createElement("p", { role: "status" }, recognition.browser.remoteServiceWarning())), engine === "whisper-http" && /* @__PURE__ */ import_react32.default.createElement(SettingsSubcard, { title: commons.connection.title(), icon: "link" }, /* @__PURE__ */ import_react32.default.createElement("p", null, recognition.whisper.captureHelp()), /* @__PURE__ */ import_react32.default.createElement(WhisperSettings, { controller })), engine === "qwen-http" && /* @__PURE__ */ import_react32.default.createElement(SettingsSubcard, { title: commons.connection.title(), icon: "link" }, /* @__PURE__ */ import_react32.default.createElement("p", null, recognition.qwen.captureHelp()), settings.engine !== "qwen-http" && /* @__PURE__ */ import_react32.default.createElement(QwenSettings, { controller })));
4492
+ ) : /* @__PURE__ */ import_react33.default.createElement("p", { role: "status" }, recognition.browser.remoteServiceWarning())), engine === "whisper-http" && /* @__PURE__ */ import_react33.default.createElement(SettingsSubcard, { title: commons.connection.title(), icon: "link" }, /* @__PURE__ */ import_react33.default.createElement("p", null, recognition.whisper.captureHelp()), /* @__PURE__ */ import_react33.default.createElement(WhisperSettings, { controller })), engine === "qwen-http" && /* @__PURE__ */ import_react33.default.createElement(SettingsSubcard, { title: commons.connection.title(), icon: "link" }, /* @__PURE__ */ import_react33.default.createElement("p", null, recognition.qwen.captureHelp()), settings.engine !== "qwen-http" && /* @__PURE__ */ import_react33.default.createElement(QwenSettings, { controller })));
4415
4493
  }
4416
4494
 
4417
4495
  // src/modules/settings/sections/recognition/RecognitionFilterSettings.tsx
4418
- var import_react33 = __toESM(require("react"), 1);
4496
+ var import_react34 = __toESM(require("react"), 1);
4419
4497
  function RecognitionFilterSettings({ settings, updateSettings }) {
4420
4498
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4421
4499
  const enabled = settings.recognitionFilterEnabled !== false;
4422
- return /* @__PURE__ */ import_react33.default.createElement(SettingsSubcard, { title: recognition.minimumWords.label(), icon: "filter" }, /* @__PURE__ */ import_react33.default.createElement(
4500
+ return /* @__PURE__ */ import_react34.default.createElement(SettingsSubcard, { title: recognition.minimumWords.label(), icon: "filter" }, /* @__PURE__ */ import_react34.default.createElement(
4423
4501
  CheckboxField,
4424
4502
  {
4425
4503
  label: recognition.minimumWords.enabled(),
@@ -4427,7 +4505,7 @@ function RecognitionFilterSettings({ settings, updateSettings }) {
4427
4505
  checked: enabled,
4428
4506
  onChange: (event) => updateSettings({ recognitionFilterEnabled: event.target.checked })
4429
4507
  }
4430
- ), /* @__PURE__ */ import_react33.default.createElement(
4508
+ ), /* @__PURE__ */ import_react34.default.createElement(
4431
4509
  NumberField,
4432
4510
  {
4433
4511
  label: recognition.minimumWords.label(),
@@ -4435,8 +4513,8 @@ function RecognitionFilterSettings({ settings, updateSettings }) {
4435
4513
  max: 20,
4436
4514
  value: settings.recognitionMinimumWords ?? 2,
4437
4515
  disabled: !enabled,
4438
- onChange: (event) => {
4439
- const value = Number(event.target.value);
4516
+ onCommit: (raw) => {
4517
+ const value = Number(raw);
4440
4518
  if (Number.isInteger(value) && value >= 1 && value <= 20)
4441
4519
  updateSettings({ recognitionMinimumWords: value });
4442
4520
  }
@@ -4445,23 +4523,24 @@ function RecognitionFilterSettings({ settings, updateSettings }) {
4445
4523
  }
4446
4524
 
4447
4525
  // src/modules/settings/sections/recognition/RecognitionSettingsSection.tsx
4448
- var import_react37 = __toESM(require("react"), 1);
4526
+ var import_react38 = __toESM(require("react"), 1);
4449
4527
 
4450
4528
  // src/modules/settings/sections/recognition/SilenceDetectionSettings.tsx
4451
- var import_react34 = __toESM(require("react"), 1);
4529
+ var import_react35 = __toESM(require("react"), 1);
4452
4530
  function SilenceDetectionSettings({ settings, updateSettings }) {
4453
4531
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4532
+ const savedCustomMs = normalizeCustomSilenceMs(settings.voiceDetectionCustomSilenceMs);
4454
4533
  if (!usesPluginVoiceDetection(settings.recognitionEngine)) return null;
4455
- const selected = settings.voiceDetectionPreset || "natural";
4456
- return /* @__PURE__ */ import_react34.default.createElement(
4534
+ const selected = settings.voiceDetectionPreset || defaultSettings.voiceDetectionPreset;
4535
+ return /* @__PURE__ */ import_react35.default.createElement(
4457
4536
  SettingsSubcard,
4458
4537
  {
4459
4538
  title: recognition.silenceDetection.label(),
4460
4539
  ariaLabel: recognition.silenceDetection.title(),
4461
4540
  icon: "pause"
4462
4541
  },
4463
- /* @__PURE__ */ import_react34.default.createElement("p", null, recognition.silenceDetection.help()),
4464
- /* @__PURE__ */ import_react34.default.createElement(
4542
+ /* @__PURE__ */ import_react35.default.createElement("p", null, recognition.silenceDetection.help()),
4543
+ /* @__PURE__ */ import_react35.default.createElement(
4465
4544
  NumberField,
4466
4545
  {
4467
4546
  label: recognition.maxUtterance.label(),
@@ -4469,22 +4548,22 @@ function SilenceDetectionSettings({ settings, updateSettings }) {
4469
4548
  max: 300,
4470
4549
  step: 1,
4471
4550
  value: settings.recognitionMaxUtteranceSeconds ?? 60,
4472
- onChange: (event) => {
4473
- const value = Number(event.target.value);
4551
+ onCommit: (raw) => {
4552
+ const value = Number(raw);
4474
4553
  if (Number.isInteger(value) && value >= 10 && value <= 300)
4475
4554
  updateSettings({ recognitionMaxUtteranceSeconds: value });
4476
4555
  }
4477
4556
  }
4478
4557
  ),
4479
- /* @__PURE__ */ import_react34.default.createElement("small", null, recognition.maxUtterance.help()),
4480
- /* @__PURE__ */ import_react34.default.createElement(
4558
+ /* @__PURE__ */ import_react35.default.createElement("small", null, recognition.maxUtterance.help()),
4559
+ /* @__PURE__ */ import_react35.default.createElement(
4481
4560
  "div",
4482
4561
  {
4483
4562
  className: "dlv-preset-group",
4484
4563
  role: "radiogroup",
4485
4564
  "aria-label": recognition.silenceDetection.pauseLabel()
4486
4565
  },
4487
- Object.entries(voiceDetectionPresets).map(([value, preset]) => /* @__PURE__ */ import_react34.default.createElement("label", { key: value, className: "dlv-preset" }, /* @__PURE__ */ import_react34.default.createElement(
4566
+ [...Object.keys(voiceDetectionPresets), "custom"].map((value) => /* @__PURE__ */ import_react35.default.createElement("label", { key: value, className: "dlv-preset" }, /* @__PURE__ */ import_react35.default.createElement(
4488
4567
  "input",
4489
4568
  {
4490
4569
  type: "radio",
@@ -4493,16 +4572,31 @@ function SilenceDetectionSettings({ settings, updateSettings }) {
4493
4572
  checked: selected === value,
4494
4573
  onChange: () => updateSettings({ voiceDetectionPreset: value })
4495
4574
  }
4496
- ), /* @__PURE__ */ import_react34.default.createElement("span", null, /* @__PURE__ */ import_react34.default.createElement("strong", null, recognition.presets[value].label()), /* @__PURE__ */ import_react34.default.createElement("small", null, recognition.presets[value].description()))))
4575
+ ), /* @__PURE__ */ import_react35.default.createElement("span", null, /* @__PURE__ */ import_react35.default.createElement("strong", null, recognition.presets[value].label()), /* @__PURE__ */ import_react35.default.createElement("small", null, recognition.presets[value].description()))))
4497
4576
  ),
4498
- /* @__PURE__ */ import_react34.default.createElement("p", { className: "dlv-vad-summary" }, recognition.silenceDetection.duration({
4499
- milliseconds: voiceDetectionPresets[selected]?.silenceMs || voiceDetectionPresets.natural.silenceMs
4577
+ selected === "custom" && /* @__PURE__ */ import_react35.default.createElement(import_react35.default.Fragment, null, /* @__PURE__ */ import_react35.default.createElement(
4578
+ NumberField,
4579
+ {
4580
+ label: recognition.silenceDetection.customLabel(),
4581
+ min: customSilenceMinMs,
4582
+ max: customSilenceMaxMs,
4583
+ step: 1,
4584
+ value: savedCustomMs,
4585
+ onCommit: (draft) => {
4586
+ const value = Number(draft);
4587
+ if (draft.trim() && Number.isInteger(value) && value >= customSilenceMinMs && value <= customSilenceMaxMs)
4588
+ updateSettings({ voiceDetectionCustomSilenceMs: value });
4589
+ }
4590
+ }
4591
+ ), /* @__PURE__ */ import_react35.default.createElement("small", null, recognition.silenceDetection.customHelp())),
4592
+ /* @__PURE__ */ import_react35.default.createElement("p", { className: "dlv-vad-summary" }, recognition.silenceDetection.duration({
4593
+ milliseconds: voiceDetectionSilenceMs(settings)
4500
4594
  }))
4501
4595
  );
4502
4596
  }
4503
4597
 
4504
4598
  // src/modules/settings/sections/recognition/VoiceCommandSettings.tsx
4505
- var import_react35 = __toESM(require("react"), 1);
4599
+ var import_react36 = __toESM(require("react"), 1);
4506
4600
  var COMMANDS = [
4507
4601
  ["send", "voiceCommandSend", "send message, send it"],
4508
4602
  ["queue", "voiceCommandQueue", "queue message, queue it"],
@@ -4515,53 +4609,53 @@ var COMMANDS = [
4515
4609
  function VoiceCommandSettings({ settings, updateSettings }) {
4516
4610
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4517
4611
  const enabled = settings.voiceCommandsEnabled !== false;
4518
- return /* @__PURE__ */ import_react35.default.createElement(SettingsSubcard, { title: recognition.commands.title(), icon: "send" }, /* @__PURE__ */ import_react35.default.createElement(
4612
+ return /* @__PURE__ */ import_react36.default.createElement(SettingsSubcard, { title: recognition.commands.title(), icon: "send" }, /* @__PURE__ */ import_react36.default.createElement(
4519
4613
  CheckboxField,
4520
4614
  {
4521
4615
  label: recognition.commands.enabled(),
4522
4616
  checked: enabled,
4523
4617
  onChange: (event) => updateSettings({ voiceCommandsEnabled: event.target.checked })
4524
4618
  }
4525
- ), /* @__PURE__ */ import_react35.default.createElement("p", { className: "dlv-setting-description" }, recognition.voiceCommands.help()), COMMANDS.map(([label, key, fallback]) => /* @__PURE__ */ import_react35.default.createElement(
4619
+ ), /* @__PURE__ */ import_react36.default.createElement("p", { className: "dlv-setting-description" }, recognition.voiceCommands.help()), COMMANDS.map(([label, key, fallback]) => /* @__PURE__ */ import_react36.default.createElement(
4526
4620
  TextAreaField,
4527
4621
  {
4528
4622
  key,
4529
4623
  rows: 2,
4530
4624
  maxLength: 1e3,
4531
4625
  label: recognition.commands[label](),
4532
- value: settings[key] || fallback,
4626
+ value: settings[key] ?? fallback,
4533
4627
  disabled: !enabled,
4534
- onChange: (event) => updateSettings({ [key]: event.target.value })
4628
+ onCommit: (value) => updateSettings({ [key]: value })
4535
4629
  }
4536
4630
  )));
4537
4631
  }
4538
4632
 
4539
4633
  // src/modules/settings/sections/recognition/RecognitionStatus.tsx
4540
- var import_react36 = __toESM(require("react"), 1);
4634
+ var import_react37 = __toESM(require("react"), 1);
4541
4635
  function RecognitionStatus({ capabilities = {} }) {
4542
4636
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
4543
- return /* @__PURE__ */ import_react36.default.createElement(import_react36.default.Fragment, null, capabilities.capture?.supported === false ? /* @__PURE__ */ import_react36.default.createElement("p", { role: "status" }, recognition.microphone.failure({ reason: capabilities.capture.reason || "" })) : capabilities.capture?.permission === "prompt" ? /* @__PURE__ */ import_react36.default.createElement("p", { role: "status" }, recognition.microphone.permissionHelp()) : null, capabilities.recognition?.supported === false && /* @__PURE__ */ import_react36.default.createElement("p", { role: "status" }, capabilities.recognition.reason));
4637
+ return /* @__PURE__ */ import_react37.default.createElement(import_react37.default.Fragment, null, capabilities.capture?.supported === false ? /* @__PURE__ */ import_react37.default.createElement("p", { role: "status" }, recognition.microphone.failure({ reason: capabilities.capture.reason || "" })) : capabilities.capture?.permission === "prompt" ? /* @__PURE__ */ import_react37.default.createElement("p", { role: "status" }, recognition.microphone.permissionHelp()) : null, capabilities.recognition?.supported === false && /* @__PURE__ */ import_react37.default.createElement("p", { role: "status" }, capabilities.recognition.reason));
4544
4638
  }
4545
4639
 
4546
4640
  // src/modules/settings/sections/recognition/RecognitionSettingsSection.tsx
4547
4641
  function RecognitionSettingsSection(props) {
4548
4642
  const { scoped: settingsLanguage } = useLanguage((ctx) => ctx.settings);
4549
- return /* @__PURE__ */ import_react37.default.createElement(SettingsCard, null, /* @__PURE__ */ import_react37.default.createElement("div", { className: "dlv-settings-card-body" }, /* @__PURE__ */ import_react37.default.createElement(RecognitionStatus, { ...props }), /* @__PURE__ */ import_react37.default.createElement(SettingsSubcard, { title: settingsLanguage.general.title(), icon: "mic" }, /* @__PURE__ */ import_react37.default.createElement(RecognitionEngineSettings, { ...props })), /* @__PURE__ */ import_react37.default.createElement(VoiceCommandSettings, { ...props }), /* @__PURE__ */ import_react37.default.createElement(RecognitionFilterSettings, { ...props }), /* @__PURE__ */ import_react37.default.createElement(SilenceDetectionSettings, { ...props })));
4643
+ return /* @__PURE__ */ import_react38.default.createElement(SettingsCard, null, /* @__PURE__ */ import_react38.default.createElement("div", { className: "dlv-settings-card-body" }, /* @__PURE__ */ import_react38.default.createElement(RecognitionStatus, { ...props }), /* @__PURE__ */ import_react38.default.createElement(SettingsSubcard, { title: settingsLanguage.general.title(), icon: "mic" }, /* @__PURE__ */ import_react38.default.createElement(RecognitionEngineSettings, { ...props })), /* @__PURE__ */ import_react38.default.createElement(VoiceCommandSettings, { ...props }), /* @__PURE__ */ import_react38.default.createElement(RecognitionFilterSettings, { ...props }), /* @__PURE__ */ import_react38.default.createElement(SilenceDetectionSettings, { ...props })));
4550
4644
  }
4551
4645
 
4552
4646
  // src/modules/settings/sections/speak/OutputFilterSettings.tsx
4553
- var import_react38 = __toESM(require("react"), 1);
4647
+ var import_react39 = __toESM(require("react"), 1);
4554
4648
  function OutputFilterSettings({ settings, updateSettings }) {
4555
4649
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
4556
4650
  const enabled = settings.outputCodeFilterEnabled !== false;
4557
- return /* @__PURE__ */ import_react38.default.createElement(SettingsSubcard, { title: speak.filters.code.enabled(), icon: "filter" }, /* @__PURE__ */ import_react38.default.createElement(
4651
+ return /* @__PURE__ */ import_react39.default.createElement(SettingsSubcard, { title: speak.filters.code.enabled(), icon: "filter" }, /* @__PURE__ */ import_react39.default.createElement(
4558
4652
  CheckboxField,
4559
4653
  {
4560
4654
  label: speak.filters.code.enabled(),
4561
4655
  checked: enabled,
4562
4656
  onChange: (event) => updateSettings({ outputCodeFilterEnabled: event.target.checked })
4563
4657
  }
4564
- ), /* @__PURE__ */ import_react38.default.createElement(
4658
+ ), /* @__PURE__ */ import_react39.default.createElement(
4565
4659
  NumberField,
4566
4660
  {
4567
4661
  label: speak.filters.code.maxLines(),
@@ -4569,34 +4663,34 @@ function OutputFilterSettings({ settings, updateSettings }) {
4569
4663
  max: 100,
4570
4664
  value: settings.outputCodeMaxLines ?? 5,
4571
4665
  disabled: !enabled,
4572
- onChange: (event) => {
4573
- const value = Number(event.target.value);
4666
+ onCommit: (raw) => {
4667
+ const value = Number(raw);
4574
4668
  if (Number.isInteger(value) && value >= 0 && value <= 100)
4575
4669
  updateSettings({ outputCodeMaxLines: value });
4576
4670
  }
4577
4671
  }
4578
- ), /* @__PURE__ */ import_react38.default.createElement(
4672
+ ), /* @__PURE__ */ import_react39.default.createElement(
4579
4673
  TextField,
4580
4674
  {
4581
4675
  label: speak.filters.code.replacement(),
4582
- defaultValue: settings.outputCodeNotice || speak.filters.code.notice(),
4676
+ value: settings.outputCodeNotice ?? speak.filters.code.notice(),
4583
4677
  disabled: !enabled,
4584
- onBlur: (event) => updateSettings({ outputCodeNotice: event.target.value })
4678
+ onCommit: (value) => updateSettings({ outputCodeNotice: value })
4585
4679
  }
4586
4680
  ));
4587
4681
  }
4588
4682
 
4589
4683
  // src/modules/settings/sections/speak/SpeakSettingsSection.tsx
4590
- var import_react41 = __toESM(require("react"), 1);
4684
+ var import_react42 = __toESM(require("react"), 1);
4591
4685
 
4592
4686
  // src/modules/settings/sections/speak/SpeechEngineSettings.tsx
4593
- var import_react39 = __toESM(require("react"), 1);
4687
+ var import_react40 = __toESM(require("react"), 1);
4594
4688
  function SpeechEngineSettings({ settings, capabilities, updateSettings }) {
4595
4689
  const { scoped: commons } = useLanguage((ctx) => ctx.commons);
4596
4690
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
4597
4691
  const engine = settings.engine || "browser";
4598
4692
  const browserVoices = capabilities?.browser?.voices || [];
4599
- return /* @__PURE__ */ import_react39.default.createElement(import_react39.default.Fragment, null, /* @__PURE__ */ import_react39.default.createElement(
4693
+ return /* @__PURE__ */ import_react40.default.createElement(import_react40.default.Fragment, null, /* @__PURE__ */ import_react40.default.createElement(
4600
4694
  SelectField,
4601
4695
  {
4602
4696
  label: speak.engine.label(),
@@ -4620,7 +4714,7 @@ function SpeechEngineSettings({ settings, capabilities, updateSettings }) {
4620
4714
  ],
4621
4715
  onChange: (event) => updateSettings({ engine: event.target.value })
4622
4716
  }
4623
- ), engine === "browser" && /* @__PURE__ */ import_react39.default.createElement(
4717
+ ), engine === "browser" && /* @__PURE__ */ import_react40.default.createElement(
4624
4718
  SelectField,
4625
4719
  {
4626
4720
  label: speak.browser.voice(),
@@ -4634,7 +4728,7 @@ function SpeechEngineSettings({ settings, capabilities, updateSettings }) {
4634
4728
  ],
4635
4729
  onChange: (event) => updateSettings({ voice: event.target.value })
4636
4730
  }
4637
- ), engine === "qwen-http" && /* @__PURE__ */ import_react39.default.createElement(
4731
+ ), engine === "qwen-http" && /* @__PURE__ */ import_react40.default.createElement(
4638
4732
  SelectField,
4639
4733
  {
4640
4734
  label: speak.qwen.voice(),
@@ -4642,7 +4736,7 @@ function SpeechEngineSettings({ settings, capabilities, updateSettings }) {
4642
4736
  options: qwenVoices,
4643
4737
  onChange: (event) => updateSettings({ voice: event.target.value })
4644
4738
  }
4645
- ), /* @__PURE__ */ import_react39.default.createElement(
4739
+ ), /* @__PURE__ */ import_react40.default.createElement(
4646
4740
  NumberField,
4647
4741
  {
4648
4742
  label: speak.rate.label(),
@@ -4650,9 +4744,9 @@ function SpeechEngineSettings({ settings, capabilities, updateSettings }) {
4650
4744
  max: 3,
4651
4745
  step: 0.1,
4652
4746
  value: settings.rate ?? 1,
4653
- onChange: (event) => updateSettings({ rate: Number(event.target.value) })
4747
+ onCommit: (value) => updateSettings({ rate: Number(value) })
4654
4748
  }
4655
- ), /* @__PURE__ */ import_react39.default.createElement(
4749
+ ), /* @__PURE__ */ import_react40.default.createElement(
4656
4750
  NumberField,
4657
4751
  {
4658
4752
  label: speak.segmentGap.label(),
@@ -4660,13 +4754,13 @@ function SpeechEngineSettings({ settings, capabilities, updateSettings }) {
4660
4754
  max: 2e3,
4661
4755
  step: 50,
4662
4756
  value: settings.segmentGapMs ?? 400,
4663
- onChange: (event) => updateSettings({ segmentGapMs: Number(event.target.value) })
4757
+ onCommit: (value) => updateSettings({ segmentGapMs: Number(value) })
4664
4758
  }
4665
- ), capabilities?.[engine === "qwen-http" ? "qwen" : engine]?.supported === false && /* @__PURE__ */ import_react39.default.createElement("p", { role: "status" }, capabilities?.[engine === "qwen-http" ? "qwen" : engine]?.reason || commons.engine.failure()));
4759
+ ), capabilities?.[engine === "qwen-http" ? "qwen" : engine]?.supported === false && /* @__PURE__ */ import_react40.default.createElement("p", { role: "status" }, capabilities?.[engine === "qwen-http" ? "qwen" : engine]?.reason || commons.engine.failure()));
4666
4760
  }
4667
4761
 
4668
4762
  // src/modules/settings/sections/speak/SpeechAdvancedSettings.tsx
4669
- var import_react40 = __toESM(require("react"), 1);
4763
+ var import_react41 = __toESM(require("react"), 1);
4670
4764
  function SpeechAdvancedSettings({
4671
4765
  controller,
4672
4766
  settings,
@@ -4684,7 +4778,7 @@ function SpeechAdvancedSettings({
4684
4778
  commons.systemDefault(),
4685
4779
  speak.output.fallbackName()
4686
4780
  );
4687
- return /* @__PURE__ */ import_react40.default.createElement(import_react40.default.Fragment, null, settings.engine !== "say" ? /* @__PURE__ */ import_react40.default.createElement(
4781
+ return /* @__PURE__ */ import_react41.default.createElement(import_react41.default.Fragment, null, settings.engine !== "say" ? /* @__PURE__ */ import_react41.default.createElement(
4688
4782
  SelectField,
4689
4783
  {
4690
4784
  label: speak.output.device(),
@@ -4692,45 +4786,45 @@ function SpeechAdvancedSettings({
4692
4786
  options: outputOptions,
4693
4787
  onChange: (event) => updateSettings({ outputDeviceId: event.target.value })
4694
4788
  }
4695
- ) : /* @__PURE__ */ import_react40.default.createElement("p", { className: "dlv-setting-description" }, speak.macos.outputHelp()), settings.engine === "qwen-http" && /* @__PURE__ */ import_react40.default.createElement(SettingsSubcard, { title: speak.qwen.connection(), icon: "link" }, /* @__PURE__ */ import_react40.default.createElement(QwenSettings, { controller })), /* @__PURE__ */ import_react40.default.createElement(SettingsSubcard, { title: speak.agentContext.label(), icon: "settings" }, /* @__PURE__ */ import_react40.default.createElement(
4789
+ ) : /* @__PURE__ */ import_react41.default.createElement("p", { className: "dlv-setting-description" }, speak.macos.outputHelp()), settings.engine === "qwen-http" && /* @__PURE__ */ import_react41.default.createElement(SettingsSubcard, { title: speak.qwen.connection(), icon: "link" }, /* @__PURE__ */ import_react41.default.createElement(QwenSettings, { controller })), /* @__PURE__ */ import_react41.default.createElement(SettingsSubcard, { title: speak.agentContext.label(), icon: "settings" }, /* @__PURE__ */ import_react41.default.createElement(
4696
4790
  CheckboxField,
4697
4791
  {
4698
4792
  label: speak.agentContext.enabled(),
4699
4793
  checked: settings.agentVoiceContextEnabled !== false,
4700
4794
  onChange: (event) => updateSettings({ agentVoiceContextEnabled: event.target.checked })
4701
4795
  }
4702
- ), /* @__PURE__ */ import_react40.default.createElement(
4796
+ ), /* @__PURE__ */ import_react41.default.createElement(
4703
4797
  TextAreaField,
4704
4798
  {
4705
4799
  label: speak.agentContext.label(),
4706
- value: settings.agentVoiceContext || defaultAgentVoiceContext,
4800
+ value: settings.agentVoiceContext ?? defaultAgentVoiceContext,
4707
4801
  disabled: settings.agentVoiceContextEnabled === false,
4708
- onChange: (event) => updateSettings({ agentVoiceContext: event.target.value })
4802
+ onCommit: (value) => updateSettings({ agentVoiceContext: value })
4709
4803
  }
4710
- ), /* @__PURE__ */ import_react40.default.createElement(
4804
+ ), /* @__PURE__ */ import_react41.default.createElement(
4711
4805
  "button",
4712
4806
  {
4713
4807
  type: "button",
4714
4808
  onClick: () => updateSettings({ agentVoiceContext: defaultAgentVoiceContext })
4715
4809
  },
4716
4810
  speak.agentContext.restore()
4717
- )), /* @__PURE__ */ import_react40.default.createElement("p", null, speak.engine.playbackHelp()), /* @__PURE__ */ import_react40.default.createElement("div", { className: "dlv-settings-actions" }, /* @__PURE__ */ import_react40.default.createElement("button", { type: "button", onClick: () => invoke("speak", speak.output.testPhrase()) }, speak.output.test()), speaking && /* @__PURE__ */ import_react40.default.createElement("button", { type: "button", onClick: () => invoke("stopSpeech") }, speak.output.stopTest()), /* @__PURE__ */ import_react40.default.createElement("button", { type: "button", onClick: () => invoke("refreshCapabilities") }, settingsLanguage.engine.refresh())));
4811
+ )), /* @__PURE__ */ import_react41.default.createElement("p", null, speak.engine.playbackHelp()), /* @__PURE__ */ import_react41.default.createElement("div", { className: "dlv-settings-actions" }, /* @__PURE__ */ import_react41.default.createElement("button", { type: "button", onClick: () => invoke("speak", speak.output.testPhrase()) }, speak.output.test()), speaking && /* @__PURE__ */ import_react41.default.createElement("button", { type: "button", onClick: () => invoke("stopSpeech") }, speak.output.stopTest()), /* @__PURE__ */ import_react41.default.createElement("button", { type: "button", onClick: () => invoke("refreshCapabilities") }, settingsLanguage.engine.refresh())));
4718
4812
  }
4719
4813
 
4720
4814
  // src/modules/settings/sections/speak/SpeakSettingsSection.tsx
4721
4815
  function SpeakSettingsSection(props) {
4722
4816
  const { scoped: settingsLanguage } = useLanguage((ctx) => ctx.settings);
4723
- return /* @__PURE__ */ import_react41.default.createElement(SettingsCard, null, /* @__PURE__ */ import_react41.default.createElement("div", { className: "dlv-settings-card-body" }, /* @__PURE__ */ import_react41.default.createElement(SettingsSubcard, { title: settingsLanguage.general.title(), icon: "speaker" }, /* @__PURE__ */ import_react41.default.createElement(SpeechEngineSettings, { ...props }), /* @__PURE__ */ import_react41.default.createElement(SpeechAdvancedSettings, { ...props })), /* @__PURE__ */ import_react41.default.createElement(OutputFilterSettings, { ...props })));
4817
+ return /* @__PURE__ */ import_react42.default.createElement(SettingsCard, null, /* @__PURE__ */ import_react42.default.createElement("div", { className: "dlv-settings-card-body" }, /* @__PURE__ */ import_react42.default.createElement(SettingsSubcard, { title: settingsLanguage.general.title(), icon: "speaker" }, /* @__PURE__ */ import_react42.default.createElement(SpeechEngineSettings, { ...props }), /* @__PURE__ */ import_react42.default.createElement(SpeechAdvancedSettings, { ...props })), /* @__PURE__ */ import_react42.default.createElement(OutputFilterSettings, { ...props })));
4724
4818
  }
4725
4819
 
4726
4820
  // src/modules/settings/components/SettingsHeader.tsx
4727
- var import_react43 = __toESM(require("react"), 1);
4821
+ var import_react44 = __toESM(require("react"), 1);
4728
4822
 
4729
4823
  // src/modules/settings/components/VersionBadges.tsx
4730
- var import_react42 = __toESM(require("react"), 1);
4824
+ var import_react43 = __toESM(require("react"), 1);
4731
4825
  function VersionBadges() {
4732
4826
  const { scoped: commons } = useLanguage((ctx) => ctx.commons);
4733
- return /* @__PURE__ */ import_react42.default.createElement("div", { className: "dlv-version-badges", "aria-label": commons.version.title() }, /* @__PURE__ */ import_react42.default.createElement(
4827
+ return /* @__PURE__ */ import_react43.default.createElement("div", { className: "dlv-version-badges", "aria-label": commons.version.title() }, /* @__PURE__ */ import_react43.default.createElement(
4734
4828
  "a",
4735
4829
  {
4736
4830
  className: "dlv-shields-badge",
@@ -4740,9 +4834,9 @@ function VersionBadges() {
4740
4834
  "aria-label": commons.version.link({ version: CURRENT_VERSION }),
4741
4835
  title: commons.version.label({ version: CURRENT_VERSION })
4742
4836
  },
4743
- /* @__PURE__ */ import_react42.default.createElement("img", { src: LIVE_VOICE_BADGE_URL, alt: "" }),
4744
- /* @__PURE__ */ import_react42.default.createElement("span", null, `v${CURRENT_VERSION}`)
4745
- ), /* @__PURE__ */ import_react42.default.createElement(
4837
+ /* @__PURE__ */ import_react43.default.createElement("img", { src: LIVE_VOICE_BADGE_URL, alt: "" }),
4838
+ /* @__PURE__ */ import_react43.default.createElement("span", null, `v${CURRENT_VERSION}`)
4839
+ ), /* @__PURE__ */ import_react43.default.createElement(
4746
4840
  "a",
4747
4841
  {
4748
4842
  className: "dlv-shields-badge",
@@ -4752,8 +4846,8 @@ function VersionBadges() {
4752
4846
  "aria-label": commons.version.compatibilityLink({ version: TESTED_DSH_VERSION }),
4753
4847
  title: commons.version.compatibility({ version: TESTED_DSH_VERSION })
4754
4848
  },
4755
- /* @__PURE__ */ import_react42.default.createElement("img", { src: DSH_BADGE_URL, alt: "" }),
4756
- /* @__PURE__ */ import_react42.default.createElement("span", null, `v${TESTED_DSH_VERSION}`)
4849
+ /* @__PURE__ */ import_react43.default.createElement("img", { src: DSH_BADGE_URL, alt: "" }),
4850
+ /* @__PURE__ */ import_react43.default.createElement("span", null, `v${TESTED_DSH_VERSION}`)
4757
4851
  ));
4758
4852
  }
4759
4853
 
@@ -4762,7 +4856,7 @@ function SettingsHeader({ onClose }) {
4762
4856
  const { scoped: commons } = useLanguage((ctx) => ctx.commons);
4763
4857
  const { scoped: settings } = useLanguage((ctx) => ctx.settings);
4764
4858
  const { latestRelease, updateAvailable } = useReleaseStatus();
4765
- return /* @__PURE__ */ import_react43.default.createElement("div", { className: "dlv-settings-heading" }, /* @__PURE__ */ import_react43.default.createElement("h3", null, commons.pluginName()), /* @__PURE__ */ import_react43.default.createElement(VersionBadges, null), /* @__PURE__ */ import_react43.default.createElement("span", { className: "dlv-heading-divider", "aria-hidden": true }), updateAvailable && latestRelease ? /* @__PURE__ */ import_react43.default.createElement(import_react43.default.Fragment, null, /* @__PURE__ */ import_react43.default.createElement(
4859
+ return /* @__PURE__ */ import_react44.default.createElement("div", { className: "dlv-settings-heading" }, /* @__PURE__ */ import_react44.default.createElement("h3", null, commons.pluginName()), /* @__PURE__ */ import_react44.default.createElement(VersionBadges, null), /* @__PURE__ */ import_react44.default.createElement("span", { className: "dlv-heading-divider", "aria-hidden": true }), updateAvailable && latestRelease ? /* @__PURE__ */ import_react44.default.createElement(import_react44.default.Fragment, null, /* @__PURE__ */ import_react44.default.createElement(
4766
4860
  "a",
4767
4861
  {
4768
4862
  className: "dlv-version-badge dlv-update-badge",
@@ -4772,9 +4866,9 @@ function SettingsHeader({ onClose }) {
4772
4866
  "aria-label": commons.update.link({ version: latestRelease.tag }),
4773
4867
  title: commons.update.version({ version: latestRelease.tag })
4774
4868
  },
4775
- /* @__PURE__ */ import_react43.default.createElement("span", { className: "dlv-update-icon", "aria-hidden": true }, "\u2191"),
4869
+ /* @__PURE__ */ import_react44.default.createElement("span", { className: "dlv-update-icon", "aria-hidden": true }, "\u2191"),
4776
4870
  commons.update.label()
4777
- ), /* @__PURE__ */ import_react43.default.createElement("span", { className: "dlv-heading-divider", "aria-hidden": true })) : null, /* @__PURE__ */ import_react43.default.createElement(
4871
+ ), /* @__PURE__ */ import_react44.default.createElement("span", { className: "dlv-heading-divider", "aria-hidden": true })) : null, /* @__PURE__ */ import_react44.default.createElement(
4778
4872
  "a",
4779
4873
  {
4780
4874
  className: "dlv-version-badge dlv-star-badge",
@@ -4786,7 +4880,7 @@ function SettingsHeader({ onClose }) {
4786
4880
  },
4787
4881
  "\u2605 ",
4788
4882
  commons.repository.starLabel()
4789
- ), onClose ? /* @__PURE__ */ import_react43.default.createElement(IconButton, { icon: "close", label: settings.close(), onClick: onClose }) : null);
4883
+ ), onClose ? /* @__PURE__ */ import_react44.default.createElement(IconButton, { icon: "close", label: settings.close(), onClick: onClose }) : null);
4790
4884
  }
4791
4885
 
4792
4886
  // src/modules/settings/components/LiveVoiceSettings.tsx
@@ -4795,8 +4889,8 @@ function LiveVoiceSettings({ controller, onClose }) {
4795
4889
  const { scoped: settingsLanguage } = useLanguage((ctx) => ctx.settings);
4796
4890
  const state = useConversationController(controller);
4797
4891
  const { invoke, error, clearError } = useConversationActions(controller);
4798
- const [activeTab, setActiveTab] = import_react44.default.useState("conversation");
4799
- const tabsId = import_react44.default.useId();
4892
+ const [activeTab, setActiveTab] = import_react45.default.useState("conversation");
4893
+ const tabsId = import_react45.default.useId();
4800
4894
  const tabs = [
4801
4895
  { id: "speech", label: settingsLanguage.tabs.speak(), icon: "speaker" },
4802
4896
  {
@@ -4818,7 +4912,7 @@ function LiveVoiceSettings({ controller, onClose }) {
4818
4912
  invoke,
4819
4913
  updateSettings: (next) => invoke("updateSettings", next)
4820
4914
  };
4821
- return /* @__PURE__ */ import_react44.default.createElement("section", { className: "dlv-settings", "aria-label": settingsLanguage.title() }, /* @__PURE__ */ import_react44.default.createElement(SettingsHeader, { onClose }), /* @__PURE__ */ import_react44.default.createElement(
4915
+ return /* @__PURE__ */ import_react45.default.createElement("section", { className: "dlv-settings", "aria-label": settingsLanguage.title() }, /* @__PURE__ */ import_react45.default.createElement(SettingsHeader, { onClose }), /* @__PURE__ */ import_react45.default.createElement(
4822
4916
  SettingsTabs,
4823
4917
  {
4824
4918
  label: settingsLanguage.title(),
@@ -4827,7 +4921,7 @@ function LiveVoiceSettings({ controller, onClose }) {
4827
4921
  idPrefix: tabsId,
4828
4922
  onChange: setActiveTab
4829
4923
  }
4830
- ), tabs.map((tab) => /* @__PURE__ */ import_react44.default.createElement(
4924
+ ), tabs.map((tab) => /* @__PURE__ */ import_react45.default.createElement(
4831
4925
  "div",
4832
4926
  {
4833
4927
  key: tab.id,
@@ -4837,8 +4931,8 @@ function LiveVoiceSettings({ controller, onClose }) {
4837
4931
  "aria-labelledby": `${tabsId}-tab-${tab.id}`,
4838
4932
  hidden: activeTab !== tab.id
4839
4933
  },
4840
- tab.id === "speech" ? /* @__PURE__ */ import_react44.default.createElement(SpeakSettingsSection, { ...sectionProps }) : tab.id === "recognition" ? /* @__PURE__ */ import_react44.default.createElement(RecognitionSettingsSection, { ...sectionProps }) : /* @__PURE__ */ import_react44.default.createElement(ConversationSettingsSection, { ...sectionProps })
4841
- )), /* @__PURE__ */ import_react44.default.createElement(
4934
+ tab.id === "speech" ? /* @__PURE__ */ import_react45.default.createElement(SpeakSettingsSection, { ...sectionProps }) : tab.id === "recognition" ? /* @__PURE__ */ import_react45.default.createElement(RecognitionSettingsSection, { ...sectionProps }) : /* @__PURE__ */ import_react45.default.createElement(ConversationSettingsSection, { ...sectionProps })
4935
+ )), /* @__PURE__ */ import_react45.default.createElement(
4842
4936
  ErrorMessage,
4843
4937
  {
4844
4938
  error: error || state.error,
@@ -4855,17 +4949,64 @@ function LiveVoiceSettings({ controller, onClose }) {
4855
4949
  // src/modules/settings/components/createLiveVoiceSettings.tsx
4856
4950
  function createLiveVoiceSettings(..._legacyArguments) {
4857
4951
  return function SettingsPanel({ controller, onClose }) {
4858
- return /* @__PURE__ */ import_react45.default.createElement(LiveVoiceSettings, { controller, onClose });
4952
+ return /* @__PURE__ */ import_react46.default.createElement(LiveVoiceSettings, { controller, onClose });
4953
+ };
4954
+ }
4955
+
4956
+ // src/modules/settings/models/settingsStorage.ts
4957
+ var SETTINGS_PATH = "/api/dsh-live-voice/settings";
4958
+ function createSettingsClient(fetchImpl = globalThis.fetch) {
4959
+ let settings = normalizeSettings({});
4960
+ let queue = Promise.resolve();
4961
+ const listeners = /* @__PURE__ */ new Set();
4962
+ async function request(method, patch) {
4963
+ const response = await fetchImpl(SETTINGS_PATH, {
4964
+ method,
4965
+ credentials: "same-origin",
4966
+ cache: "no-store",
4967
+ ...patch === void 0 ? {} : { headers: { "content-type": "application/json" }, body: JSON.stringify(patch) }
4968
+ });
4969
+ const body = await response.json();
4970
+ if (!response.ok || body?.ok !== true) throw new Error("settings-request-failed");
4971
+ settings = normalizeSettings(body.value);
4972
+ for (const listener of listeners) listener(settings);
4973
+ return settings;
4974
+ }
4975
+ const ready = request("GET");
4976
+ void ready.catch(() => {
4977
+ });
4978
+ return {
4979
+ ready,
4980
+ getSnapshot: () => settings,
4981
+ subscribe(listener) {
4982
+ listeners.add(listener);
4983
+ return () => {
4984
+ listeners.delete(listener);
4985
+ };
4986
+ },
4987
+ save(patch) {
4988
+ const operation = queue.then(async () => {
4989
+ await ready;
4990
+ return request("PUT", patch);
4991
+ });
4992
+ queue = operation.then(
4993
+ () => {
4994
+ },
4995
+ () => {
4996
+ }
4997
+ );
4998
+ return operation;
4999
+ }
4859
5000
  };
4860
5001
  }
4861
5002
 
4862
5003
  // src/modules/conversation/components/ConversationControls.tsx
4863
- var import_react47 = __toESM(require("react"), 1);
5004
+ var import_react48 = __toESM(require("react"), 1);
4864
5005
 
4865
5006
  // src/modules/conversation/components/MicrophoneButton.tsx
4866
- var import_react46 = __toESM(require("react"), 1);
5007
+ var import_react47 = __toESM(require("react"), 1);
4867
5008
  function MicrophoneButton(props) {
4868
- return /* @__PURE__ */ import_react46.default.createElement(IconButton, { ...props, className: "dlv-mic", icon: "mic" });
5009
+ return /* @__PURE__ */ import_react47.default.createElement(IconButton, { ...props, className: "dlv-mic", icon: "mic" });
4869
5010
  }
4870
5011
 
4871
5012
  // src/modules/conversation/components/ConversationControls.tsx
@@ -4882,14 +5023,14 @@ function ConversationControls({ controller }) {
4882
5023
  const unavailable = capability?.supported === false || capture?.supported === false;
4883
5024
  const reason = capture?.supported === false ? capture.reason : capability?.reason;
4884
5025
  const label = pending ? recognition.microphone.checking() : unavailable ? reason || recognition.status.unavailable() : commons.conversation.start();
4885
- return /* @__PURE__ */ import_react47.default.createElement(import_react47.default.Fragment, null, /* @__PURE__ */ import_react47.default.createElement(
5026
+ return /* @__PURE__ */ import_react48.default.createElement(import_react48.default.Fragment, null, /* @__PURE__ */ import_react48.default.createElement(
4886
5027
  MicrophoneButton,
4887
5028
  {
4888
5029
  label,
4889
5030
  disabled: pending,
4890
5031
  onClick: () => invoke(unavailable ? "explainRecognition" : "startConversation")
4891
5032
  }
4892
- ), /* @__PURE__ */ import_react47.default.createElement(
5033
+ ), /* @__PURE__ */ import_react48.default.createElement(
4893
5034
  ErrorMessage,
4894
5035
  {
4895
5036
  error,
@@ -4901,10 +5042,10 @@ function ConversationControls({ controller }) {
4901
5042
  }
4902
5043
 
4903
5044
  // src/modules/conversation/components/ConversationStatusBar.tsx
4904
- var import_react51 = __toESM(require("react"), 1);
5045
+ var import_react52 = __toESM(require("react"), 1);
4905
5046
 
4906
5047
  // src/modules/conversation/components/DeliveryModeButton.tsx
4907
- var import_react48 = __toESM(require("react"), 1);
5048
+ var import_react49 = __toESM(require("react"), 1);
4908
5049
  function nextDeliveryMode(mode) {
4909
5050
  return mode === "queue" ? "steer" : mode === "steer" ? "manual" : "queue";
4910
5051
  }
@@ -4917,7 +5058,7 @@ function DeliveryModeButton({
4917
5058
  const description = mode === "steer" ? settings.delivery.steerDescription() : mode === "queue" ? commons.queue() : commons.manual();
4918
5059
  const status = settings.delivery.status({ mode: description });
4919
5060
  const visible = mode === "steer" ? commons.send() : mode === "queue" ? commons.delivery.queueBadge() : commons.toggle.offBadge();
4920
- return /* @__PURE__ */ import_react48.default.createElement(
5061
+ return /* @__PURE__ */ import_react49.default.createElement(
4921
5062
  IconButton,
4922
5063
  {
4923
5064
  className: "dlv-live-toggle",
@@ -4933,18 +5074,18 @@ function DeliveryModeButton({
4933
5074
  }
4934
5075
 
4935
5076
  // src/modules/conversation/components/PlaybackControls.tsx
4936
- var import_react49 = __toESM(require("react"), 1);
5077
+ var import_react50 = __toESM(require("react"), 1);
4937
5078
  function PlaybackControls({ state, invoke }) {
4938
5079
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
4939
5080
  const capabilities = state.capabilities?.[state.settings?.engine] ?? {};
4940
- return /* @__PURE__ */ import_react49.default.createElement(import_react49.default.Fragment, null, state.speechSegmentsRemaining > 1 ? /* @__PURE__ */ import_react49.default.createElement(
5081
+ return /* @__PURE__ */ import_react50.default.createElement(import_react50.default.Fragment, null, state.speechSegmentsRemaining > 1 ? /* @__PURE__ */ import_react50.default.createElement(
4941
5082
  IconButton,
4942
5083
  {
4943
5084
  label: speak.playback.next(),
4944
5085
  icon: "skipNext",
4945
5086
  onClick: () => invoke("skipSpeechSegment")
4946
5087
  }
4947
- ) : null, state.speechSegmentsRemaining > 0 && capabilities.pause ? /* @__PURE__ */ import_react49.default.createElement(
5088
+ ) : null, state.speechSegmentsRemaining > 0 && capabilities.pause ? /* @__PURE__ */ import_react50.default.createElement(
4948
5089
  IconButton,
4949
5090
  {
4950
5091
  label: speak.playback.pause(),
@@ -4952,14 +5093,14 @@ function PlaybackControls({ state, invoke }) {
4952
5093
  disabled: !state.speaking || state.paused,
4953
5094
  onClick: () => invoke("pauseSpeech")
4954
5095
  }
4955
- ) : null, state.paused && capabilities.resume ? /* @__PURE__ */ import_react49.default.createElement(
5096
+ ) : null, state.paused && capabilities.resume ? /* @__PURE__ */ import_react50.default.createElement(
4956
5097
  IconButton,
4957
5098
  {
4958
5099
  label: speak.playback.resume(),
4959
5100
  icon: "play",
4960
5101
  onClick: () => invoke("resumeSpeech")
4961
5102
  }
4962
- ) : null, state.speechSegmentsRemaining > 0 ? /* @__PURE__ */ import_react49.default.createElement(
5103
+ ) : null, state.speechSegmentsRemaining > 0 ? /* @__PURE__ */ import_react50.default.createElement(
4963
5104
  IconButton,
4964
5105
  {
4965
5106
  label: speak.playback.stopAll(),
@@ -4970,10 +5111,10 @@ function PlaybackControls({ state, invoke }) {
4970
5111
  }
4971
5112
 
4972
5113
  // src/modules/conversation/components/Waveform.tsx
4973
- var import_react50 = __toESM(require("react"), 1);
5114
+ var import_react51 = __toESM(require("react"), 1);
4974
5115
  function Waveform({ controller, enabled }) {
4975
- const ref = import_react50.default.useRef(null);
4976
- import_react50.default.useEffect(() => {
5116
+ const ref = import_react51.default.useRef(null);
5117
+ import_react51.default.useEffect(() => {
4977
5118
  const canvas = ref.current;
4978
5119
  const context = canvas?.getContext("2d");
4979
5120
  if (!canvas || !context) return;
@@ -5025,7 +5166,7 @@ function Waveform({ controller, enabled }) {
5025
5166
  window.removeEventListener("resize", resize);
5026
5167
  };
5027
5168
  }, [controller, enabled]);
5028
- return /* @__PURE__ */ import_react50.default.createElement("canvas", { ref, className: "dlv-wave", "aria-hidden": true });
5169
+ return /* @__PURE__ */ import_react51.default.createElement("canvas", { ref, className: "dlv-wave", "aria-hidden": true });
5029
5170
  }
5030
5171
 
5031
5172
  // src/modules/conversation/components/conversationStatus.ts
@@ -5056,8 +5197,8 @@ function ConversationStatusBar({
5056
5197
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
5057
5198
  const state = useConversationController(controller);
5058
5199
  const { invoke, error, clearError } = useConversationActions(controller);
5059
- const [now, setNow] = import_react51.default.useState(Date.now());
5060
- import_react51.default.useEffect(() => {
5200
+ const [now, setNow] = import_react52.default.useState(Date.now());
5201
+ import_react52.default.useEffect(() => {
5061
5202
  if (!state.autoSendAt) return;
5062
5203
  setNow(Date.now());
5063
5204
  const timer = setInterval(() => setNow(Date.now()), 200);
@@ -5081,33 +5222,33 @@ function ConversationStatusBar({
5081
5222
  }) : speak.autoPlayback.status({
5082
5223
  state: autoPlayback ? commons.on() : commons.off()
5083
5224
  });
5084
- return /* @__PURE__ */ import_react51.default.createElement(
5225
+ return /* @__PURE__ */ import_react52.default.createElement(
5085
5226
  "div",
5086
5227
  {
5087
5228
  className: overlay ? "dlv-bar-wrap dlv-question-overlay" : "dlv-bar-wrap",
5088
5229
  style: overlay ? overlayStyle : void 0
5089
5230
  },
5090
- /* @__PURE__ */ import_react51.default.createElement("div", { className: "dlv-pill", role: "group", "aria-label": commons.controls.title() }, capture && !state.conversation ? /* @__PURE__ */ import_react51.default.createElement(
5231
+ /* @__PURE__ */ import_react52.default.createElement("div", { className: "dlv-pill", role: "group", "aria-label": commons.controls.title() }, capture && !state.conversation ? /* @__PURE__ */ import_react52.default.createElement(
5091
5232
  IconButton,
5092
5233
  {
5093
5234
  label: recognition.dictation.cancel(),
5094
5235
  icon: "close",
5095
5236
  onClick: () => invoke("cancelDictation")
5096
5237
  }
5097
- ) : null, state.conversation ? /* @__PURE__ */ import_react51.default.createElement(
5238
+ ) : null, state.conversation ? /* @__PURE__ */ import_react52.default.createElement(
5098
5239
  IconButton,
5099
5240
  {
5100
5241
  label: commons.conversation.end(),
5101
5242
  icon: "close",
5102
5243
  onClick: () => invoke("endConversation")
5103
5244
  }
5104
- ) : null, /* @__PURE__ */ import_react51.default.createElement(Waveform, { controller, enabled: Boolean(state.listening) }), /* @__PURE__ */ import_react51.default.createElement("span", { className: "dlv-status", role: "status", "aria-live": "polite" }, status), /* @__PURE__ */ import_react51.default.createElement(
5245
+ ) : null, /* @__PURE__ */ import_react52.default.createElement(Waveform, { controller, enabled: Boolean(state.listening) }), /* @__PURE__ */ import_react52.default.createElement("span", { className: "dlv-status", role: "status", "aria-live": "polite" }, status), /* @__PURE__ */ import_react52.default.createElement(
5105
5246
  DeliveryModeButton,
5106
5247
  {
5107
5248
  mode: state.settings.sendingMode || "manual",
5108
5249
  onChange: (sendingMode) => invoke("updateSettings", { sendingMode })
5109
5250
  }
5110
- ), /* @__PURE__ */ import_react51.default.createElement(
5251
+ ), /* @__PURE__ */ import_react52.default.createElement(
5111
5252
  IconButton,
5112
5253
  {
5113
5254
  className: `dlv-live-toggle${state.speechSegmentsRemaining > 0 ? " dlv-live-toggle-expanded" : ""}`,
@@ -5119,21 +5260,21 @@ function ConversationStatusBar({
5119
5260
  "aria-checked": autoPlayback,
5120
5261
  onClick: () => invoke("updateSettings", { announceAssistantMessages: !autoPlayback })
5121
5262
  }
5122
- ), remaining ? /* @__PURE__ */ import_react51.default.createElement(
5263
+ ), remaining ? /* @__PURE__ */ import_react52.default.createElement(
5123
5264
  IconButton,
5124
5265
  {
5125
5266
  label: settings.autoSend.cancel(),
5126
5267
  icon: "close",
5127
5268
  onClick: () => invoke("cancelAutoSend")
5128
5269
  }
5129
- ) : null, state.conversation && !capture ? /* @__PURE__ */ import_react51.default.createElement(
5270
+ ) : null, state.conversation && !capture ? /* @__PURE__ */ import_react52.default.createElement(
5130
5271
  IconButton,
5131
5272
  {
5132
5273
  label: recognition.microphone.takeControl(),
5133
5274
  icon: "mic",
5134
5275
  onClick: () => invoke("startConversation")
5135
5276
  }
5136
- ) : null, capture ? /* @__PURE__ */ import_react51.default.createElement(
5277
+ ) : null, capture ? /* @__PURE__ */ import_react52.default.createElement(
5137
5278
  IconButton,
5138
5279
  {
5139
5280
  className: "dlv-live-toggle dlv-mic-state",
@@ -5147,8 +5288,8 @@ function ConversationStatusBar({
5147
5288
  "data-muted": state.muted ? "true" : "false",
5148
5289
  onClick: () => invoke(state.muted ? "resumeListeningInput" : "muteListening")
5149
5290
  }
5150
- ) : null, /* @__PURE__ */ import_react51.default.createElement(PlaybackControls, { state, invoke })),
5151
- /* @__PURE__ */ import_react51.default.createElement(
5291
+ ) : null, /* @__PURE__ */ import_react52.default.createElement(PlaybackControls, { state, invoke })),
5292
+ /* @__PURE__ */ import_react52.default.createElement(
5152
5293
  ErrorMessage,
5153
5294
  {
5154
5295
  error: error || state.error,
@@ -5164,7 +5305,7 @@ function ConversationStatusBar({
5164
5305
  }
5165
5306
 
5166
5307
  // src/modules/conversation/components/SpeakButton.tsx
5167
- var import_react52 = __toESM(require("react"), 1);
5308
+ var import_react53 = __toESM(require("react"), 1);
5168
5309
  function SpeakButton({
5169
5310
  active = false,
5170
5311
  disabled = false,
@@ -5173,7 +5314,7 @@ function SpeakButton({
5173
5314
  }) {
5174
5315
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
5175
5316
  const resolvedLabel = label ?? (active ? speak.playback.stop() : speak.playback.message());
5176
- return /* @__PURE__ */ import_react52.default.createElement(
5317
+ return /* @__PURE__ */ import_react53.default.createElement(
5177
5318
  IconButton,
5178
5319
  {
5179
5320
  className: "dlv-speaker",
@@ -5312,7 +5453,7 @@ var styles = `
5312
5453
  .dlv-settings label{display:grid;gap:6px;font-size:14px}.dlv-settings label.dlv-check{display:flex;align-items:center;gap:8px}.dlv-settings label.dlv-check input{width:auto}
5313
5454
  .dlv-settings :is(input,select,textarea){box-sizing:border-box;width:100%;padding:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;font:inherit}
5314
5455
  .dlv-settings small,.dlv-settings p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}.dlv-settings .dlv-setting-description{line-height:1.65;margin:3px 0 5px}.dlv-settings .dlv-conversation-fields{display:grid;gap:20px;padding:4px 0}
5315
- .dlv-preset-group{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}.dlv-settings label.dlv-preset{display:flex;align-items:flex-start;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;cursor:pointer}.dlv-settings label.dlv-preset:has(input:checked){border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}.dlv-settings label.dlv-preset input{width:auto;margin-top:3px}.dlv-preset span{display:grid;gap:3px}.dlv-vad-summary{font-weight:500}
5456
+ .dlv-preset-group{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.dlv-settings label.dlv-preset{display:flex;align-items:flex-start;gap:8px;padding:10px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;cursor:pointer}.dlv-settings label.dlv-preset:has(input:checked){border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1)}.dlv-settings label.dlv-preset input{width:auto;margin-top:3px}.dlv-preset span{display:grid;gap:3px}.dlv-vad-summary{font-weight:500}
5316
5457
  .dlv-settings-actions{display:flex;flex-wrap:wrap;gap:8px}.dlv-settings-actions button{padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:inherit;cursor:pointer}.dlv-settings-actions button:disabled{opacity:.45;cursor:default}
5317
5458
  @media(max-width:480px){.dlv-settings-heading{gap:6px}.dlv-heading-divider{min-width:4px}.dlv-settings-heading .dlv-star-badge{padding:3px 6px;font-size:10px}.dlv-preset-group{grid-template-columns:1fr}.dlv-pill{flex-wrap:wrap}.dlv-wave{flex-basis:90px}.dlv-status{flex-basis:100px}}
5318
5459
  `;
@@ -5324,6 +5465,8 @@ function apply(ctx) {
5324
5465
  const SettingsPanel = createLiveVoiceSettings(t, ctx.locale);
5325
5466
  const controllers = /* @__PURE__ */ new Map();
5326
5467
  const retiring = /* @__PURE__ */ new Set();
5468
+ const preferences = createSettingsClient();
5469
+ const settingsControllers = /* @__PURE__ */ new Set();
5327
5470
  let disposed = false;
5328
5471
  let voiceModeActive = false;
5329
5472
  const publishVoiceContext = (entry, active = entry?.controller?.getSnapshot().conversation === true) => {
@@ -5350,16 +5493,19 @@ function apply(ctx) {
5350
5493
  "recognitionProcessLocally",
5351
5494
  "recognitionAutoInstall",
5352
5495
  "voiceDetectionPreset",
5496
+ "voiceDetectionCustomSilenceMs",
5353
5497
  "recognitionMaxUtteranceSeconds"
5354
5498
  ]);
5355
5499
  const changesRecognition = (next) => Object.keys(next).some((key) => recognitionSettingKeys.has(key));
5356
5500
  const recognitionFor = (settings, meter) => settings.recognitionEngine === "qwen-http" ? new QwenHttpRecognitionEngine({
5357
5501
  meter,
5358
5502
  voiceDetectionPreset: settings.voiceDetectionPreset,
5503
+ voiceDetectionCustomSilenceMs: settings.voiceDetectionCustomSilenceMs,
5359
5504
  maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds
5360
5505
  }) : settings.recognitionEngine === "whisper-http" ? new WhisperHttpRecognitionEngine({
5361
5506
  meter,
5362
5507
  voiceDetectionPreset: settings.voiceDetectionPreset,
5508
+ voiceDetectionCustomSilenceMs: settings.voiceDetectionCustomSilenceMs,
5363
5509
  maxUtteranceSeconds: settings.recognitionMaxUtteranceSeconds
5364
5510
  }) : new BrowserRecognitionEngine({
5365
5511
  processLocally: settings.recognitionProcessLocally,
@@ -5369,6 +5515,38 @@ function apply(ctx) {
5369
5515
  if (!disposed && !controller.disposed)
5370
5516
  controller.patch({ error: error?.message || String(error) });
5371
5517
  });
5518
+ async function savePreferences(controller, next) {
5519
+ try {
5520
+ return await preferences.save(next);
5521
+ } catch {
5522
+ const error = new Error(t("dsh-live-voice.settings.persistence.saveError"));
5523
+ if (!disposed && !controller.disposed) controller.patch({ error: error.message });
5524
+ throw error;
5525
+ }
5526
+ }
5527
+ const unsubscribePreferences = preferences.subscribe((settings) => {
5528
+ if (disposed) return;
5529
+ for (const entry of controllers.values()) {
5530
+ if (entry.closed) continue;
5531
+ const previous = entry.controller.getSnapshot().settings;
5532
+ if ([...recognitionSettingKeys].some((key) => previous[key] !== settings[key]))
5533
+ entry.controller.replaceRecognition(recognitionFor(settings, entry.controller.meter));
5534
+ entry.applySettings(settings);
5535
+ }
5536
+ for (const c of settingsControllers) {
5537
+ if (c.disposed) continue;
5538
+ const previous = c.getSnapshot().settings;
5539
+ if ([...recognitionSettingKeys].some((key) => previous[key] !== settings[key]))
5540
+ c.replaceRecognition(recognitionFor(settings, c.meter));
5541
+ c.applySavedSettings(settings);
5542
+ for (const engine of Object.values(c.engines)) engine.lang = settings.lang;
5543
+ run(c, c.refreshCapabilities());
5544
+ }
5545
+ });
5546
+ ctx.effect(
5547
+ () => () => unsubscribePreferences(),
5548
+ "dsh-live-voice: remove preferences subscription"
5549
+ );
5372
5550
  function retire(entry) {
5373
5551
  if (entry.closed) return;
5374
5552
  entry.closed = true;
@@ -5397,6 +5575,10 @@ function apply(ctx) {
5397
5575
  key,
5398
5576
  draft: "",
5399
5577
  pendingDraft: void 0,
5578
+ publishedDraft: void 0,
5579
+ publishedRevision: void 0,
5580
+ pendingRevision: void 0,
5581
+ staleDrafts: /* @__PURE__ */ new Set(),
5400
5582
  composers: /* @__PURE__ */ new Map(),
5401
5583
  refs: 0,
5402
5584
  buttons: 0,
@@ -5404,14 +5586,7 @@ function apply(ctx) {
5404
5586
  closed: false,
5405
5587
  lastVoiceContextBody: null
5406
5588
  };
5407
- let settings = {};
5408
- try {
5409
- settings = normalizeSettings(
5410
- JSON.parse(localStorage.getItem("dsh-live-voice.settings") || "{}")
5411
- );
5412
- } catch {
5413
- settings = normalizeSettings(null);
5414
- }
5589
+ const settings = preferences.getSnapshot();
5415
5590
  const engineBrowser = new BrowserSpeakingEngine({ lang: settings.lang || "pt-BR" });
5416
5591
  const engineSay = new HostAudioSpeakingEngine({
5417
5592
  endpoint: "/api/dsh-live-voice/say/speech",
@@ -5466,8 +5641,12 @@ function apply(ctx) {
5466
5641
  if (disposed || entry.closed) return;
5467
5642
  const owner = [...entry.composers.values()].at(-1);
5468
5643
  if (!owner) return;
5644
+ if (entry.pendingDraft === void 0) entry.staleDrafts.clear();
5645
+ entry.staleDrafts.add(entry.draft);
5646
+ if (entry.publishedDraft !== void 0) entry.staleDrafts.add(entry.publishedDraft);
5469
5647
  entry.draft = text;
5470
5648
  entry.pendingDraft = text;
5649
+ entry.pendingRevision = entry.publishedRevision;
5471
5650
  owner.actions.setDraft(text);
5472
5651
  }
5473
5652
  },
@@ -5540,14 +5719,7 @@ function apply(ctx) {
5540
5719
  controller.updateSettings = (next) => {
5541
5720
  if (disposed || entry.closed) return;
5542
5721
  entry.applySettings(next);
5543
- const settings2 = controller.getSnapshot().settings;
5544
- try {
5545
- localStorage.setItem("dsh-live-voice.settings", JSON.stringify(settings2));
5546
- } catch {
5547
- }
5548
- for (const other of controllers.values()) {
5549
- if (other !== entry && !other.closed) other.applySettings(settings2);
5550
- }
5722
+ return savePreferences(controller, next);
5551
5723
  };
5552
5724
  for (const method of ["stopListening", "cancelDictation", "endConversation", "stopSpeech"]) {
5553
5725
  const original = controller[method].bind(controller);
@@ -5572,7 +5744,12 @@ function apply(ctx) {
5572
5744
  return ownership.run(
5573
5745
  controller,
5574
5746
  [...controllers.values()].map((other) => other.controller).concat([...retiring]),
5575
- () => {
5747
+ async () => {
5748
+ try {
5749
+ await preferences.ready;
5750
+ } catch {
5751
+ throw new Error(t("dsh-live-voice.settings.persistence.loadError"));
5752
+ }
5576
5753
  if (disposed || entry.closed || !entry.refs || request !== entry.request) return;
5577
5754
  if (method !== "speak" && !entry.composers.size) return;
5578
5755
  const result = original(...args);
@@ -5583,12 +5760,16 @@ function apply(ctx) {
5583
5760
  };
5584
5761
  }
5585
5762
  controllers.set(key, entry);
5763
+ void preferences.ready.catch(() => {
5764
+ if (!entry.closed)
5765
+ controller.patch({ error: t("dsh-live-voice.settings.persistence.loadError") });
5766
+ });
5586
5767
  run(controller, controller.refreshCapabilities());
5587
5768
  return entry;
5588
5769
  }
5589
5770
  function useEntry(sessionId, kind) {
5590
- const [entry, setEntry] = import_react53.default.useState(null);
5591
- import_react53.default.useLayoutEffect(() => {
5771
+ const [entry, setEntry] = import_react54.default.useState(null);
5772
+ import_react54.default.useLayoutEffect(() => {
5592
5773
  if (disposed) return;
5593
5774
  const current = get(sessionId);
5594
5775
  current.refs++;
@@ -5608,14 +5789,14 @@ function apply(ctx) {
5608
5789
  function useComposer(entry, props) {
5609
5790
  const subscribedInput = props.useInput?.((value) => value);
5610
5791
  const input = subscribedInput ?? props.input;
5611
- const token = import_react53.default.useRef({});
5612
- import_react53.default.useLayoutEffect(() => {
5792
+ const token = import_react54.default.useRef({});
5793
+ import_react54.default.useLayoutEffect(() => {
5613
5794
  if (!entry || entry.closed || disposed) return;
5614
5795
  return () => {
5615
5796
  entry.composers.delete(token.current);
5616
5797
  };
5617
5798
  }, [entry]);
5618
- import_react53.default.useLayoutEffect(() => {
5799
+ import_react54.default.useLayoutEffect(() => {
5619
5800
  if (!entry || entry.closed || disposed) return;
5620
5801
  if (!input || typeof props.inputActions?.setDraft !== "function") return;
5621
5802
  entry.composers.set(token.current, {
@@ -5641,13 +5822,17 @@ function apply(ctx) {
5641
5822
  if (voiceModeActive && !entry.controller.getSnapshot().conversation)
5642
5823
  run(entry.controller, entry.controller.startConversation());
5643
5824
  }, [entry, input, props.inputActions]);
5644
- import_react53.default.useLayoutEffect(() => {
5825
+ import_react54.default.useLayoutEffect(() => {
5645
5826
  if (!entry || entry.closed || disposed || !input) return;
5646
5827
  const published = typeof input.draft === "string" ? input.draft : "";
5828
+ entry.publishedDraft = published;
5829
+ entry.publishedRevision = input.draftRev;
5830
+ const newerRevision = Number.isInteger(input.draftRev) && Number.isInteger(entry.pendingRevision) && input.draftRev > entry.pendingRevision;
5647
5831
  if (entry.pendingDraft === void 0) entry.draft = published;
5648
- else if (published === entry.pendingDraft) {
5832
+ else if (published === entry.pendingDraft || newerRevision || !entry.staleDrafts.has(published)) {
5649
5833
  entry.draft = published;
5650
5834
  entry.pendingDraft = void 0;
5835
+ entry.staleDrafts.clear();
5651
5836
  }
5652
5837
  entry.controller.composerChanged(entry.draft);
5653
5838
  });
@@ -5655,19 +5840,12 @@ function apply(ctx) {
5655
5840
  function Buttons(props) {
5656
5841
  const entry = useEntry(props.sessionId, "buttons");
5657
5842
  useComposer(entry, props);
5658
- return entry ? /* @__PURE__ */ import_react53.default.createElement(ConversationControls, { controller: entry.controller }) : null;
5843
+ return entry ? /* @__PURE__ */ import_react54.default.createElement(ConversationControls, { controller: entry.controller }) : null;
5659
5844
  }
5660
5845
  function Settings() {
5661
- const [controller, setController] = import_react53.default.useState(null);
5662
- import_react53.default.useEffect(() => {
5663
- let settings;
5664
- try {
5665
- settings = normalizeSettings(
5666
- JSON.parse(localStorage.getItem("dsh-live-voice.settings") || "{}")
5667
- );
5668
- } catch {
5669
- settings = normalizeSettings(null);
5670
- }
5846
+ const [controller, setController] = import_react54.default.useState(null);
5847
+ import_react54.default.useEffect(() => {
5848
+ const settings = preferences.getSnapshot();
5671
5849
  const browser = new BrowserSpeakingEngine({ lang: settings.lang });
5672
5850
  const qwen = new QwenHttpSpeakingEngine({ lang: settings.lang });
5673
5851
  const meter = new MicrophoneMeter();
@@ -5693,9 +5871,21 @@ function apply(ctx) {
5693
5871
  c.speak = (...args) => ownership.run(
5694
5872
  c,
5695
5873
  [...controllers.values()].map((entry) => entry.controller).concat([...retiring]),
5696
- () => speak(...args)
5874
+ async () => {
5875
+ try {
5876
+ await preferences.ready;
5877
+ } catch {
5878
+ throw new Error(t("dsh-live-voice.settings.persistence.loadError"));
5879
+ }
5880
+ if (!disposed && !c.disposed) return speak(...args);
5881
+ }
5697
5882
  );
5698
5883
  const update = c.updateSettings.bind(c);
5884
+ c.applySavedSettings = update;
5885
+ settingsControllers.add(c);
5886
+ void preferences.ready.catch(
5887
+ () => c.patch({ error: t("dsh-live-voice.settings.persistence.loadError") })
5888
+ );
5699
5889
  let settingsRevision = 0;
5700
5890
  c.updateSettings = async (next) => {
5701
5891
  const revision = ++settingsRevision;
@@ -5704,10 +5894,11 @@ function apply(ctx) {
5704
5894
  qwen.lang = c.getSnapshot().settings.lang;
5705
5895
  const settings2 = c.getSnapshot().settings;
5706
5896
  if (changesRecognition(next)) c.replaceRecognition(recognitionFor(settings2, c.meter));
5707
- localStorage.setItem("dsh-live-voice.settings", JSON.stringify(settings2));
5897
+ const saved = savePreferences(c, next);
5708
5898
  run(c, c.refreshCapabilities());
5709
5899
  const active = [...controllers.values()];
5710
- await Promise.allSettled([
5900
+ const [savedSettings] = await Promise.all([
5901
+ saved,
5711
5902
  c.endConversation(),
5712
5903
  ...active.map((entry) => entry.controller.endConversation())
5713
5904
  ]);
@@ -5715,8 +5906,10 @@ function apply(ctx) {
5715
5906
  for (const entry of controllers.values()) {
5716
5907
  if (entry.closed) continue;
5717
5908
  if (changesRecognition(next))
5718
- entry.controller.replaceRecognition(recognitionFor(settings2, entry.controller.meter));
5719
- entry.applySettings(settings2);
5909
+ entry.controller.replaceRecognition(
5910
+ recognitionFor(savedSettings, entry.controller.meter)
5911
+ );
5912
+ entry.applySettings(savedSettings);
5720
5913
  run(entry.controller, entry.controller.refreshCapabilities());
5721
5914
  }
5722
5915
  };
@@ -5726,24 +5919,25 @@ function apply(ctx) {
5726
5919
  refresh();
5727
5920
  return () => {
5728
5921
  document.removeEventListener("dsh-live-voice:capabilitieschanged", refresh);
5922
+ settingsControllers.delete(c);
5729
5923
  void c.dispose();
5730
5924
  };
5731
5925
  }, []);
5732
- return controller ? /* @__PURE__ */ import_react53.default.createElement(SettingsPanel, { controller }) : null;
5926
+ return controller ? /* @__PURE__ */ import_react54.default.createElement(SettingsPanel, { controller }) : null;
5733
5927
  }
5734
5928
  registerSettingsSlot(ctx, Settings, () => t("dsh-live-voice.commons.pluginName"));
5735
5929
  function Dock(props) {
5736
5930
  const entry = useEntry(props.sessionId, "dock");
5737
5931
  useComposer(entry, props);
5738
- return entry ? /* @__PURE__ */ import_react53.default.createElement(ConversationStatusBar, { controller: entry.controller }) : null;
5932
+ return entry ? /* @__PURE__ */ import_react54.default.createElement(ConversationStatusBar, { controller: entry.controller }) : null;
5739
5933
  }
5740
5934
  function QuestionStatusView({ entry }) {
5741
- const snapshot = import_react53.default.useSyncExternalStore(
5935
+ const snapshot = import_react54.default.useSyncExternalStore(
5742
5936
  entry.controller.subscribe,
5743
5937
  entry.controller.getSnapshot
5744
5938
  );
5745
- const [overlayStyle, setOverlayStyle] = import_react53.default.useState();
5746
- import_react53.default.useLayoutEffect(() => {
5939
+ const [overlayStyle, setOverlayStyle] = import_react54.default.useState();
5940
+ import_react54.default.useLayoutEffect(() => {
5747
5941
  if (!snapshot.answeringQuestion) return;
5748
5942
  const seat = document.querySelector("[data-composer-seat]");
5749
5943
  if (!seat) return;
@@ -5765,7 +5959,7 @@ function apply(ctx) {
5765
5959
  }, [snapshot.answeringQuestion]);
5766
5960
  const target = typeof document === "undefined" ? null : document.body;
5767
5961
  return snapshot.answeringQuestion && target && overlayStyle ? (0, import_react_dom.createPortal)(
5768
- /* @__PURE__ */ import_react53.default.createElement(
5962
+ /* @__PURE__ */ import_react54.default.createElement(
5769
5963
  ConversationStatusBar,
5770
5964
  {
5771
5965
  controller: entry.controller,
@@ -5779,19 +5973,19 @@ function apply(ctx) {
5779
5973
  }
5780
5974
  function QuestionStatus(props) {
5781
5975
  const entry = useEntry(props.sessionId, "question-status");
5782
- return entry ? /* @__PURE__ */ import_react53.default.createElement(QuestionStatusView, { entry }) : null;
5976
+ return entry ? /* @__PURE__ */ import_react54.default.createElement(QuestionStatusView, { entry }) : null;
5783
5977
  }
5784
5978
  function ActionView({ entry, messageId }) {
5785
- const snapshot = import_react53.default.useSyncExternalStore(
5979
+ const snapshot = import_react54.default.useSyncExternalStore(
5786
5980
  entry.controller.subscribe,
5787
5981
  entry.controller.getSnapshot
5788
5982
  );
5789
- const chat = import_react53.default.useSyncExternalStore(entry.subscribeChat, entry.readChat);
5983
+ const chat = import_react54.default.useSyncExternalStore(entry.subscribeChat, entry.readChat);
5790
5984
  const message2 = addressedTurn(assistantMessages(chat), messageId);
5791
5985
  const capability = snapshot.capabilities[snapshot.settings.engine];
5792
5986
  const active = snapshot.speaking && message2.id === snapshot.activeMessageId;
5793
5987
  const unavailable = capability?.supported !== true;
5794
- return /* @__PURE__ */ import_react53.default.createElement(
5988
+ return /* @__PURE__ */ import_react54.default.createElement(
5795
5989
  SpeakButton,
5796
5990
  {
5797
5991
  active,
@@ -5806,7 +6000,7 @@ function apply(ctx) {
5806
6000
  }
5807
6001
  function Action(props) {
5808
6002
  const entry = useEntry(props.sessionId, "action");
5809
- return entry ? /* @__PURE__ */ import_react53.default.createElement(ActionView, { entry, messageId: props.messageId }) : null;
6003
+ return entry ? /* @__PURE__ */ import_react54.default.createElement(ActionView, { entry, messageId: props.messageId }) : null;
5810
6004
  }
5811
6005
  ctx.effect(() => {
5812
6006
  const style = document.createElement("style");