dsh-live-voice 0.0.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -35,6 +35,7 @@ __export(index_exports, {
35
35
  });
36
36
  module.exports = __toCommonJS(index_exports);
37
37
  var import_react = __toESM(require("react"), 1);
38
+ var import_react_dom = require("react-dom");
38
39
 
39
40
  // src/core/transcript.ts
40
41
  var TranscriptDraft = class {
@@ -122,8 +123,24 @@ var defaultSettings = Object.freeze({
122
123
  lang: "pt-BR",
123
124
  recognitionLang: "pt-BR",
124
125
  voice: "",
126
+ inputDeviceId: "",
127
+ outputDeviceId: "",
128
+ recognitionFilterEnabled: true,
129
+ recognitionMinimumWords: 2,
130
+ voiceCommandsEnabled: true,
131
+ voiceCommandSend: "send, send message",
132
+ voiceCommandQueue: "queue, queue message",
133
+ voiceCommandEnd: "end, end conversation",
134
+ voiceCommandMute: "mute, stop listening",
135
+ voiceCommandResume: "resume, start listening",
136
+ voiceCommandStopSpeaking: "stop talking, stop speaking, shut up",
137
+ voiceCommandClear: "clear all, clear message",
138
+ outputCodeFilterEnabled: true,
139
+ outputCodeMaxLines: 5,
140
+ outputCodeNotice: "Look the code on out conversation",
125
141
  rate: 1
126
142
  });
143
+ 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;
127
144
  function normalizeSettings(value) {
128
145
  const source = value && typeof value === "object" && !Array.isArray(value) ? value : {};
129
146
  return {
@@ -134,17 +151,92 @@ function normalizeSettings(value) {
134
151
  voiceDetectionPreset: Object.hasOwn(voiceDetectionPresets, source.voiceDetectionPreset) ? source.voiceDetectionPreset : defaultSettings.voiceDetectionPreset,
135
152
  announceAssistantMessages: typeof source.announceAssistantMessages === "boolean" ? source.announceAssistantMessages : defaultSettings.announceAssistantMessages,
136
153
  interruptSpeechOnUserMessage: typeof source.interruptSpeechOnUserMessage === "boolean" ? source.interruptSpeechOnUserMessage : defaultSettings.interruptSpeechOnUserMessage,
137
- sendingMode: ["manual", "automatic"].includes(source.sendingMode) ? source.sendingMode : defaultSettings.sendingMode,
154
+ sendingMode: source.sendingMode === "automatic" ? "queue" : ["manual", "queue", "steer"].includes(source.sendingMode) ? source.sendingMode : defaultSettings.sendingMode,
138
155
  autoSendDelaySeconds: Number.isInteger(source.autoSendDelaySeconds) && source.autoSendDelaySeconds >= 2 && source.autoSendDelaySeconds <= 10 ? source.autoSendDelaySeconds : defaultSettings.autoSendDelaySeconds,
139
156
  assistantSpeechDelaySeconds: Number.isInteger(source.assistantSpeechDelaySeconds) && source.assistantSpeechDelaySeconds >= 1 && source.assistantSpeechDelaySeconds <= 10 ? source.assistantSpeechDelaySeconds : defaultSettings.assistantSpeechDelaySeconds,
140
157
  mode: ["speaker", "headphones"].includes(source.mode) ? source.mode : defaultSettings.mode,
141
158
  lang: typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.lang,
142
159
  recognitionLang: source.recognitionLang === "auto" || typeof source.recognitionLang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.recognitionLang) ? source.recognitionLang : typeof source.lang === "string" && /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/.test(source.lang) ? source.lang : defaultSettings.recognitionLang,
143
160
  voice: typeof source.voice === "string" && source.voice.length <= 200 && !source.voice.includes("\0") ? source.voice : "",
161
+ inputDeviceId: typeof source.inputDeviceId === "string" && source.inputDeviceId.length <= 500 && !source.inputDeviceId.includes("\0") ? source.inputDeviceId : "",
162
+ outputDeviceId: typeof source.outputDeviceId === "string" && source.outputDeviceId.length <= 500 && !source.outputDeviceId.includes("\0") ? source.outputDeviceId : "",
163
+ recognitionFilterEnabled: typeof source.recognitionFilterEnabled === "boolean" ? source.recognitionFilterEnabled : defaultSettings.recognitionFilterEnabled,
164
+ recognitionMinimumWords: Number.isInteger(source.recognitionMinimumWords) && source.recognitionMinimumWords >= 1 && source.recognitionMinimumWords <= 20 ? source.recognitionMinimumWords : defaultSettings.recognitionMinimumWords,
165
+ voiceCommandsEnabled: typeof source.voiceCommandsEnabled === "boolean" ? source.voiceCommandsEnabled : defaultSettings.voiceCommandsEnabled,
166
+ voiceCommandSend: normalizeCommandPhrases(
167
+ source.voiceCommandSend,
168
+ defaultSettings.voiceCommandSend
169
+ ),
170
+ voiceCommandQueue: normalizeCommandPhrases(
171
+ source.voiceCommandQueue,
172
+ defaultSettings.voiceCommandQueue
173
+ ),
174
+ voiceCommandEnd: normalizeCommandPhrases(
175
+ source.voiceCommandEnd,
176
+ defaultSettings.voiceCommandEnd
177
+ ),
178
+ voiceCommandMute: normalizeCommandPhrases(
179
+ source.voiceCommandMute,
180
+ defaultSettings.voiceCommandMute
181
+ ),
182
+ voiceCommandResume: normalizeCommandPhrases(
183
+ source.voiceCommandResume,
184
+ defaultSettings.voiceCommandResume
185
+ ),
186
+ voiceCommandStopSpeaking: normalizeCommandPhrases(
187
+ source.voiceCommandStopSpeaking,
188
+ defaultSettings.voiceCommandStopSpeaking
189
+ ),
190
+ voiceCommandClear: normalizeCommandPhrases(
191
+ source.voiceCommandClear,
192
+ defaultSettings.voiceCommandClear
193
+ ),
194
+ outputCodeFilterEnabled: typeof source.outputCodeFilterEnabled === "boolean" ? source.outputCodeFilterEnabled : defaultSettings.outputCodeFilterEnabled,
195
+ outputCodeMaxLines: Number.isInteger(source.outputCodeMaxLines) && source.outputCodeMaxLines >= 0 && source.outputCodeMaxLines <= 100 ? source.outputCodeMaxLines : defaultSettings.outputCodeMaxLines,
196
+ outputCodeNotice: typeof source.outputCodeNotice === "string" && source.outputCodeNotice.trim() && source.outputCodeNotice.length <= 300 && !source.outputCodeNotice.includes("\0") ? source.outputCodeNotice.trim() : defaultSettings.outputCodeNotice,
144
197
  rate: Number.isFinite(source.rate) && source.rate >= 0.1 && source.rate <= 3 ? source.rate : 1
145
198
  };
146
199
  }
147
200
 
201
+ // src/core/filters.ts
202
+ var words = (text) => String(text || "").trim().match(/[\p{L}\p{N}]+(?:['’_-][\p{L}\p{N}]+)*/gu) || [];
203
+ function normalizeVoiceCommand(text) {
204
+ return String(text || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase("en-US").replace(/[^\p{L}\p{N}]+/gu, " ").trim().replace(/\s+/g, " ");
205
+ }
206
+ function matchVoiceCommand(text, commands) {
207
+ const candidate = normalizeVoiceCommand(text);
208
+ if (!candidate) return null;
209
+ for (const [action, phrases] of Object.entries(commands || {})) {
210
+ if (String(phrases || "").split(/[,\r\n]+/).some((phrase) => normalizeVoiceCommand(phrase) === candidate))
211
+ return action;
212
+ }
213
+ return null;
214
+ }
215
+ function hasMinimumWords(text, minimum = 2) {
216
+ return words(text).length >= minimum;
217
+ }
218
+ function splitSpeechOutput(text, options = {}) {
219
+ const filtered = filterSpeechOutput(text, options).trim();
220
+ if (!filtered) return [];
221
+ return filtered.split(/\r?\n+/u).map((segment) => segment.trim()).filter(Boolean);
222
+ }
223
+ function hasUnclosedCodeFence(text) {
224
+ return (String(text || "").match(/```/g) || []).length % 2 === 1;
225
+ }
226
+ function filterSpeechOutput(text, {
227
+ filterCodeBlocks = true,
228
+ codeBlockMaxLines = 5,
229
+ codeBlockNotice = "Look the code on out conversation"
230
+ } = {}) {
231
+ const source = String(text || "");
232
+ if (!filterCodeBlocks) return source;
233
+ return source.replace(/```[^\n]*\n?([\s\S]*?)```/g, (block, body) => {
234
+ const normalized = body.replace(/\n$/, "");
235
+ const lineCount = normalized ? normalized.split(/\r?\n/).length : 0;
236
+ return lineCount <= codeBlockMaxLines ? normalized : codeBlockNotice;
237
+ });
238
+ }
239
+
148
240
  // src/core/coordinator.ts
149
241
  var message = (error) => error?.message || String(error);
150
242
  function cancellable(promise, signal) {
@@ -179,20 +271,27 @@ var VoiceCoordinator = class {
179
271
  conversation: false,
180
272
  listening: false,
181
273
  recognizing: false,
274
+ muted: false,
182
275
  speaking: false,
183
276
  paused: false,
184
277
  starting: false,
185
278
  error: null,
186
279
  activeMessageId: null,
280
+ answeringQuestion: false,
187
281
  autoSendAt: null,
282
+ speechSegmentsRemaining: 0,
188
283
  capabilities: {},
189
284
  settings: normalizeSettings(settings)
190
285
  };
191
286
  this.autoSendTimer = null;
192
287
  this.autoSendDraft = null;
288
+ this.interruptionTimer = null;
289
+ this.interruptionTranscriptConfirmed = false;
290
+ this.interruptionPausedSpeech = false;
193
291
  this.assistantSpeechTimer = null;
194
292
  this.assistantSpeechNotBefore = 0;
195
293
  this.recognition.lang = this.snapshot.settings.recognitionLang;
294
+ this.meter.deviceId = this.snapshot.settings.inputDeviceId;
196
295
  this.getSnapshot = () => this.snapshot;
197
296
  this.subscribe = (listener) => {
198
297
  if (this.disposed) return () => {
@@ -232,6 +331,7 @@ var VoiceCoordinator = class {
232
331
  updateSettings(next) {
233
332
  if (this.disposed) return;
234
333
  const settings = normalizeSettings({ ...this.snapshot.settings, ...next });
334
+ this.meter.deviceId = settings.inputDeviceId;
235
335
  Object.assign(this.recognition, {
236
336
  lang: settings.recognitionLang,
237
337
  processLocally: settings.recognitionProcessLocally,
@@ -239,7 +339,7 @@ var VoiceCoordinator = class {
239
339
  voiceDetectionPreset: settings.voiceDetectionPreset
240
340
  });
241
341
  this.patch({ settings, error: null });
242
- if (settings.sendingMode !== "automatic") this.cancelAutoSend();
342
+ if (settings.sendingMode === "manual") this.cancelAutoSend();
243
343
  if (Object.hasOwn(next, "announceAssistantMessages") && !settings.announceAssistantMessages) {
244
344
  this.queue = [];
245
345
  this.assistantSpeechNotBefore = 0;
@@ -300,6 +400,21 @@ var VoiceCoordinator = class {
300
400
  startConversation() {
301
401
  return this.startListening(true);
302
402
  }
403
+ muteListening() {
404
+ if (!this.snapshot.settings.voiceCommandsEnabled) return this.stopListening();
405
+ this.cancelAutoSend();
406
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
407
+ this.transcript.reset();
408
+ this.patch({ muted: true, recognizing: false });
409
+ }
410
+ resumeListeningInput() {
411
+ if (this.snapshot.listening || this.snapshot.starting) {
412
+ this.transcript.reset();
413
+ this.patch({ muted: false, recognizing: false });
414
+ return;
415
+ }
416
+ return this.startListening(this.snapshot.conversation);
417
+ }
303
418
  startListening(conversation = this.snapshot.conversation) {
304
419
  if (this.disposed) return Promise.resolve();
305
420
  this.patch({ error: null });
@@ -369,7 +484,7 @@ var VoiceCoordinator = class {
369
484
  } else
370
485
  this.assistantSpeechNotBefore = Date.now() + this.snapshot.settings.assistantSpeechDelaySeconds * 1e3;
371
486
  this.patch({ recognizing: active });
372
- if (active && this.snapshot.settings.mode === "headphones") void this.pauseSpeech();
487
+ this._handleSpeechInterruption(active);
373
488
  if (!active) this._drain();
374
489
  },
375
490
  onError: (error) => {
@@ -404,16 +519,99 @@ var VoiceCoordinator = class {
404
519
  }
405
520
  });
406
521
  }
522
+ _interruptionTranscriptQualifies(text) {
523
+ return hasMinimumWords(text, 1);
524
+ }
525
+ _handleSpeechInterruption(active) {
526
+ if (this.snapshot.settings.mode !== "headphones") return;
527
+ if (active) {
528
+ if (!this.interruptionTranscriptConfirmed || this.interruptionTimer !== null || !this.snapshot.speaking || this.snapshot.paused)
529
+ return;
530
+ this.interruptionTimer = setTimeout(() => {
531
+ this.interruptionTimer = null;
532
+ if (this.interruptionTranscriptConfirmed && this.snapshot.recognizing && this.snapshot.speaking && !this.snapshot.paused) {
533
+ this.interruptionPausedSpeech = true;
534
+ void this.pauseSpeech().then(() => {
535
+ if (this.interruptionPausedSpeech && !this.snapshot.recognizing && this.snapshot.paused) {
536
+ this.interruptionPausedSpeech = false;
537
+ void this.resumeSpeech();
538
+ }
539
+ });
540
+ }
541
+ }, this.snapshot.settings.assistantSpeechDelaySeconds * 1e3);
542
+ return;
543
+ }
544
+ if (this.interruptionTimer !== null) clearTimeout(this.interruptionTimer);
545
+ this.interruptionTimer = null;
546
+ this.interruptionTranscriptConfirmed = false;
547
+ if (this.interruptionPausedSpeech && this.snapshot.paused) {
548
+ this.interruptionPausedSpeech = false;
549
+ void this.resumeSpeech();
550
+ }
551
+ }
407
552
  onResult({ final = "", interim = "" }) {
408
553
  if (this.disposed || !this.snapshot.listening && !this.snapshot.starting) return;
409
- if (this.snapshot.speaking && !this.snapshot.paused) {
410
- if (this.snapshot.settings.mode === "speaker") return;
411
- if (final || interim) void this.pauseSpeech();
554
+ if (this.snapshot.speaking && !this.snapshot.paused && this.snapshot.settings.mode === "speaker")
555
+ return;
556
+ if (this.snapshot.settings.mode === "headphones" && this.snapshot.speaking && this._interruptionTranscriptQualifies(final || interim)) {
557
+ this.interruptionTranscriptConfirmed = true;
558
+ this._handleSpeechInterruption(true);
559
+ }
560
+ if (typeof this.composer.handleQuestionResult === "function" && this.composer.handleQuestionResult({ final, interim })) {
561
+ this.cancelAutoSend();
562
+ this.transcript.reset();
563
+ this.patch({ recognizing: !!interim });
564
+ return;
412
565
  }
413
566
  if (final) {
567
+ const command = this.snapshot.settings.voiceCommandsEnabled && matchVoiceCommand(final, {
568
+ send: this.snapshot.settings.voiceCommandSend,
569
+ queue: this.snapshot.settings.voiceCommandQueue,
570
+ end: this.snapshot.settings.voiceCommandEnd,
571
+ mute: this.snapshot.settings.voiceCommandMute,
572
+ resume: this.snapshot.settings.voiceCommandResume,
573
+ stopSpeaking: this.snapshot.settings.voiceCommandStopSpeaking,
574
+ clear: this.snapshot.settings.voiceCommandClear
575
+ });
576
+ if (command) {
577
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
578
+ this.transcript.reset();
579
+ this.cancelAutoSend();
580
+ this.patch({ recognizing: false });
581
+ if (command === "end") void this.endConversation();
582
+ else if (command === "mute") this.muteListening();
583
+ else if (command === "resume") this.resumeListeningInput();
584
+ else if (command === "stopSpeaking") {
585
+ this.updateSettings({ announceAssistantMessages: false });
586
+ void this.stopSpeech();
587
+ } else if (command === "clear") {
588
+ this.cancelAutoSend();
589
+ this.transcript.reset();
590
+ this.composer.setDraft("");
591
+ } else if (this.snapshot.muted) return;
592
+ else {
593
+ const sendingMode = command === "send" ? "steer" : "queue";
594
+ this.updateSettings({ sendingMode });
595
+ if (this.composer.getDraft().trim() && typeof this.composer.submit === "function")
596
+ this.composer.submit(sendingMode);
597
+ }
598
+ return;
599
+ }
600
+ if (this.snapshot.muted) {
601
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
602
+ this.transcript.reset();
603
+ this.patch({ recognizing: false });
604
+ return;
605
+ }
606
+ if (this.snapshot.settings.recognitionFilterEnabled && !hasMinimumWords(final, this.snapshot.settings.recognitionMinimumWords)) {
607
+ this.composer.setDraft(this.transcript.update(this.composer.getDraft(), "", true));
608
+ this.transcript.reset();
609
+ this.patch({ recognizing: false });
610
+ return;
611
+ }
414
612
  const next = this.transcript.update(this.composer.getDraft(), final, true);
415
613
  this.composer.setDraft(next);
416
- if (this.snapshot.settings.sendingMode === "automatic") this.scheduleAutoSend(next);
614
+ if (this.snapshot.settings.sendingMode !== "manual") this.scheduleAutoSend(next);
417
615
  }
418
616
  if (interim) {
419
617
  this.cancelAutoSend();
@@ -435,8 +633,15 @@ var VoiceCoordinator = class {
435
633
  const expected = this.autoSendDraft;
436
634
  this.autoSendDraft = null;
437
635
  this.patch({ autoSendAt: null });
438
- if (!this.disposed && this.snapshot.settings.sendingMode === "automatic" && expected === this.composer.getDraft())
439
- this.composer.submit();
636
+ if (!this.disposed && this.snapshot.settings.sendingMode !== "manual" && expected === this.composer.getDraft()) {
637
+ try {
638
+ this.composer.submit(this.snapshot.settings.sendingMode === "steer" ? "steer" : "queue");
639
+ this.transcript.reset();
640
+ this.recognition.reset?.();
641
+ } catch (error) {
642
+ this.patch({ error: message(error) });
643
+ }
644
+ }
440
645
  }, delay);
441
646
  }
442
647
  cancelAutoSend() {
@@ -474,6 +679,11 @@ var VoiceCoordinator = class {
474
679
  this.patch({ conversation: false });
475
680
  await Promise.all([this.stopListening(), this.stopSpeech(false)]);
476
681
  }
682
+ _syncSpeechSegments() {
683
+ const speechSegmentsRemaining = this.queue.length + (this.snapshot.speaking ? 1 : 0);
684
+ if (speechSegmentsRemaining !== this.snapshot.speechSegmentsRemaining)
685
+ this.patch({ speechSegmentsRemaining });
686
+ }
477
687
  _suppressPending() {
478
688
  for (const id2 of this.unfinished) this.suppressed.add(id2);
479
689
  if (this.snapshot.activeMessageId !== null) this.suppressed.add(this.snapshot.activeMessageId);
@@ -481,10 +691,15 @@ var VoiceCoordinator = class {
481
691
  this.queue = [];
482
692
  }
483
693
  async stopSpeech(resumeListening = true) {
694
+ if (this.interruptionTimer !== null) clearTimeout(this.interruptionTimer);
695
+ this.interruptionTimer = null;
696
+ this.interruptionTranscriptConfirmed = false;
697
+ this.interruptionPausedSpeech = false;
484
698
  const epoch = ++this.speechEpoch;
485
699
  ++this.controlEpoch;
486
700
  this._suppressPending();
487
701
  this.patch({ speaking: false, paused: false, activeMessageId: null });
702
+ this._syncSpeechSegments();
488
703
  const stopped = this.speechBarrier.then(async () => {
489
704
  const results = await Promise.allSettled(
490
705
  Object.values(this.engines).map((engine) => Promise.resolve().then(() => engine.stop()))
@@ -538,13 +753,23 @@ var VoiceCoordinator = class {
538
753
  const stopped = this.stopSpeech(false);
539
754
  const epoch = this.speechEpoch;
540
755
  await stopped;
541
- if (!this.disposed && epoch === this.speechEpoch && !this.speechStopError)
542
- return this.play(text, messageId);
756
+ if (this.disposed || epoch !== this.speechEpoch || this.speechStopError) return;
757
+ const segments = splitSpeechOutput(text, {
758
+ filterCodeBlocks: this.snapshot.settings.outputCodeFilterEnabled,
759
+ codeBlockMaxLines: this.snapshot.settings.outputCodeMaxLines,
760
+ codeBlockNotice: this.snapshot.settings.outputCodeNotice
761
+ });
762
+ const first = segments.shift();
763
+ if (!first) return;
764
+ this.queue.push(...segments.map((segment) => ({ text: segment, id: messageId, manual: true })));
765
+ this._syncSpeechSegments();
766
+ return this.play(first, messageId, true);
543
767
  }
544
- async play(text, messageId) {
768
+ async play(text, messageId, manual = false) {
545
769
  if (this.disposed || !text.trim()) return;
546
770
  if (this.snapshot.speaking) {
547
- this.queue.push({ text, id: messageId });
771
+ this.queue.push({ text, id: messageId, manual });
772
+ this._syncSpeechSegments();
548
773
  return;
549
774
  }
550
775
  const epoch = ++this.speechEpoch;
@@ -555,6 +780,7 @@ var VoiceCoordinator = class {
555
780
  return;
556
781
  }
557
782
  this.patch({ speaking: true, paused: false, activeMessageId: messageId, error: null });
783
+ this._syncSpeechSegments();
558
784
  let failed = false;
559
785
  try {
560
786
  await this.speechBarrier;
@@ -567,7 +793,8 @@ var VoiceCoordinator = class {
567
793
  if (epoch !== this.speechEpoch || this.disposed) return;
568
794
  await engine.speak(text, {
569
795
  voice: this.snapshot.settings.voice || void 0,
570
- rate: this.snapshot.settings.rate
796
+ rate: this.snapshot.settings.rate,
797
+ outputDeviceId: this.snapshot.settings.outputDeviceId
571
798
  });
572
799
  } catch (error) {
573
800
  if (epoch === this.speechEpoch) {
@@ -578,6 +805,7 @@ var VoiceCoordinator = class {
578
805
  } finally {
579
806
  if (epoch === this.speechEpoch && !this.disposed) {
580
807
  this.patch({ speaking: false, paused: false, activeMessageId: null });
808
+ this._syncSpeechSegments();
581
809
  if (!failed && this.queue.length) this._drain();
582
810
  else if (this.snapshot.conversation && !this.snapshot.listening && !this.snapshot.starting)
583
811
  await this._startInput(true);
@@ -589,7 +817,7 @@ var VoiceCoordinator = class {
589
817
  this.assistantSpeechTimer = null;
590
818
  }
591
819
  _drain() {
592
- if (this.disposed || !this.snapshot.conversation || !this.snapshot.settings.announceAssistantMessages || this.snapshot.speaking || this.snapshot.recognizing || this.snapshot.starting || !this.queue.length)
820
+ if (this.disposed || !this.snapshot.conversation && !this.queue[0]?.manual || !this.snapshot.settings.announceAssistantMessages && !this.queue[0]?.manual || this.snapshot.speaking || this.snapshot.recognizing || this.snapshot.starting || !this.queue.length)
593
821
  return;
594
822
  const wait = this.assistantSpeechNotBefore - Date.now();
595
823
  if (wait > 0) {
@@ -603,7 +831,7 @@ var VoiceCoordinator = class {
603
831
  this._cancelAssistantSpeechTimer();
604
832
  this.assistantSpeechNotBefore = 0;
605
833
  const next = this.queue.shift();
606
- void this.play(next.text, next.id);
834
+ void this.play(next.text, next.id, next.manual === true);
607
835
  }
608
836
  /** Baselines survive conversation toggles; cancelled message IDs remain suppressed. */
609
837
  observeMessage(id2, text, { complete = false, baseline = false } = {}) {
@@ -620,17 +848,19 @@ var VoiceCoordinator = class {
620
848
  return;
621
849
  }
622
850
  const remaining = text.slice(offset);
623
- const boundary = complete ? remaining.length : Math.max(
624
- remaining.lastIndexOf(". "),
625
- remaining.lastIndexOf("? "),
626
- remaining.lastIndexOf("! "),
627
- remaining.lastIndexOf("\n")
628
- ) + 1;
851
+ const boundary = complete ? remaining.length : remaining.lastIndexOf("\n") + 1;
629
852
  if (boundary <= 0) return;
630
- const chunk = remaining.slice(0, boundary).trim();
853
+ if (this.snapshot.settings.outputCodeFilterEnabled && hasUnclosedCodeFence(remaining.slice(0, boundary)) && !complete)
854
+ return;
855
+ const chunk = filterSpeechOutput(remaining.slice(0, boundary).trim(), {
856
+ filterCodeBlocks: this.snapshot.settings.outputCodeFilterEnabled,
857
+ codeBlockMaxLines: this.snapshot.settings.outputCodeMaxLines,
858
+ codeBlockNotice: this.snapshot.settings.outputCodeNotice
859
+ }).trim();
631
860
  this.consumed.set(id2, offset + boundary);
632
861
  if (chunk) {
633
862
  this.queue.push({ text: chunk, id: id2 });
863
+ this._syncSpeechSegments();
634
864
  this._drain();
635
865
  }
636
866
  }
@@ -640,7 +870,7 @@ var VoiceCoordinator = class {
640
870
  this._cancelAssistantSpeechTimer();
641
871
  this.disposed = true;
642
872
  this.listeners.clear();
643
- await this.endConversation();
873
+ await this.endConversation(true);
644
874
  }
645
875
  };
646
876
 
@@ -657,7 +887,7 @@ var VoiceOwnership = class {
657
887
  const acquired = this.tail.then(async () => {
658
888
  if (this.closed || epoch !== this.epoch || owner.disposed) return;
659
889
  await Promise.all(
660
- owners.filter((other) => other !== owner).map((other) => other.endConversation())
890
+ owners.filter((other) => other !== owner).map((other) => other.endConversation(true))
661
891
  );
662
892
  if (this.closed || epoch !== this.epoch || owner.disposed) return;
663
893
  outcome = Promise.resolve(action());
@@ -681,6 +911,7 @@ var VoiceOwnership = class {
681
911
  var MicrophoneMeter = class {
682
912
  constructor(globals = globalThis) {
683
913
  this.g = globals;
914
+ this.deviceId = "";
684
915
  this.epoch = 0;
685
916
  this.current = null;
686
917
  this.stream = this.context = this.source = this.analyser = this.samples = null;
@@ -745,7 +976,12 @@ var MicrophoneMeter = class {
745
976
  const capture = async () => {
746
977
  try {
747
978
  const stream = await this.g.navigator.mediaDevices.getUserMedia({
748
- audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
979
+ audio: {
980
+ ...this.deviceId ? { deviceId: { exact: this.deviceId } } : {},
981
+ echoCancellation: true,
982
+ noiseSuppression: true,
983
+ autoGainControl: true
984
+ }
749
985
  });
750
986
  job.stream = stream;
751
987
  if (!valid()) {
@@ -1271,6 +1507,26 @@ var BrowserRecognitionEngine = class {
1271
1507
  };
1272
1508
  recognition.start();
1273
1509
  }
1510
+ reset() {
1511
+ const session = this.session;
1512
+ const recognition = session?.recognition;
1513
+ if (!session || !recognition) return false;
1514
+ this._detach(recognition);
1515
+ session.recognition = null;
1516
+ try {
1517
+ recognition.abort();
1518
+ } catch {
1519
+ }
1520
+ this._activity(session, false);
1521
+ if (this.session !== session || session.signal?.aborted) return false;
1522
+ try {
1523
+ this._begin(session);
1524
+ return true;
1525
+ } catch (error) {
1526
+ this._fail(session, error);
1527
+ return false;
1528
+ }
1529
+ }
1274
1530
  async stop() {
1275
1531
  const session = this.session;
1276
1532
  if (!session) return;
@@ -1522,7 +1778,7 @@ var QwenHttpSpeakingEngine = class {
1522
1778
  };
1523
1779
  }
1524
1780
  }
1525
- async speak(text, { rate = 1, signal, lang = this.lang, voice = "aiden" } = {}) {
1781
+ async speak(text, { rate = 1, signal, lang = this.lang, voice = "aiden", outputDeviceId = "" } = {}) {
1526
1782
  if (typeof text !== "string") throw new TypeError("Speech text must be a string.");
1527
1783
  if (!Number.isFinite(rate) || rate < 0.1 || rate > 3)
1528
1784
  throw new RangeError("Speech rate must be between 0.1 and 3.");
@@ -1555,6 +1811,8 @@ var QwenHttpSpeakingEngine = class {
1555
1811
  operation.url = this.g.URL.createObjectURL(blob);
1556
1812
  const audio = operation.audio = new this.g.Audio(operation.url);
1557
1813
  audio.playbackRate = rate;
1814
+ if (outputDeviceId && typeof audio.setSinkId === "function")
1815
+ await audio.setSinkId(outputDeviceId);
1558
1816
  await new Promise((resolve, reject) => {
1559
1817
  const done = () => {
1560
1818
  cleanup();
@@ -1854,7 +2112,7 @@ function createQwenSettings(React2) {
1854
2112
  h(
1855
2113
  "p",
1856
2114
  null,
1857
- "Host-wide settings for the resident Qwen3 ASR + TTS server. Only unauthenticated loopback HTTP is allowed; the browser reaches it through authenticated DSH routes."
2115
+ "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."
1858
2116
  ),
1859
2117
  field("Qwen API base URL", "baseUrl"),
1860
2118
  field("Request timeout (ms)", "timeoutMs", "number"),
@@ -1906,12 +2164,14 @@ function createComponents(React2) {
1906
2164
  };
1907
2165
  const paths = {
1908
2166
  mic: "M9 5a3 3 0 0 1 6 0v7a3 3 0 0 1-6 0V5M6 10v2a6 6 0 0 0 12 0v-2M12 18v4M8 22h8",
2167
+ micOff: "M9 9v3a3 3 0 0 0 5.12 2.12M15 9V5a3 3 0 0 0-5.64-1.42M6 10v2a6 6 0 0 0 9.5 4.88M18 10v2a6 6 0 0 1-.5 2.4M12 18v4M8 22h8M3 3l18 18",
1909
2168
  speaker: "M3 9h4l6-5v16l-6-5H3V9M17 8a6 6 0 0 1 0 8M20 5a10 10 0 0 1 0 14",
1910
2169
  close: "M6 6l12 12M18 6L6 18",
1911
2170
  stop: "M6 6h12v12H6z",
1912
2171
  pause: "M8 5v14M16 5v14",
1913
2172
  play: "M7 4l13 8-13 8z",
1914
2173
  send: "M3 11.5L21 3l-8.5 18-2-7.5L3 11.5zm7.5 2L21 3",
2174
+ queue: "M5 6h14M5 12h10M5 18h6M18 15v6M15 18h6",
1915
2175
  speakerOff: "M3 9h4l6-5v16l-6-5H3V9M17 9l5 6M22 9l-5 6"
1916
2176
  };
1917
2177
  return h("svg", common, h("path", { d: paths[name] || paths.mic }));
@@ -2052,7 +2312,7 @@ function createComponents(React2) {
2052
2312
  }, [controller, enabled]);
2053
2313
  return h("canvas", { ref, className: "dlv-wave", "aria-hidden": true });
2054
2314
  }
2055
- function RecordingBar({ controller }) {
2315
+ function RecordingBar({ controller, questionOnly = false, overlay = false, overlayStyle }) {
2056
2316
  const state = useController(controller);
2057
2317
  const [now, setNow] = React2.useState(Date.now());
2058
2318
  React2.useEffect(() => {
@@ -2063,13 +2323,17 @@ function createComponents(React2) {
2063
2323
  }, [state.autoSendAt]);
2064
2324
  const [invoke, error, clearError] = useActions(controller);
2065
2325
  const capture = state.starting || state.listening || state.recognizing;
2326
+ if (questionOnly && !state.answeringQuestion) return null;
2066
2327
  if (!state.conversation && !capture && !state.speaking && !state.paused && !state.error && !error)
2067
2328
  return null;
2068
2329
  const remaining = state.autoSendAt ? Math.max(1, Math.ceil((state.autoSendAt - now) / 1e3)) : null;
2069
- const status = remaining ? `Sending in ${remaining}\u2026` : state.starting ? "Starting microphone\u2026" : state.paused ? "Speech paused" : state.speaking ? "Speaking" : state.recognizing ? "Recognizing speech\u2026" : state.listening ? "Listening \u2014 waiting for speech" : state.conversation ? "Conversation idle" : "Voice ready";
2330
+ const status = state.answeringQuestion && state.recognizing ? "Recognizing answer\u2026" : state.answeringQuestion && state.listening ? "Listening for your answer\u2026" : remaining ? `Sending in ${remaining}\u2026` : state.starting ? "Starting microphone\u2026" : state.paused ? "Speech paused" : state.speaking ? "Speaking" : state.recognizing ? "Recognizing speech\u2026" : state.listening ? "Listening \u2014 waiting for speech" : state.conversation ? "Conversation idle" : "Voice ready";
2070
2331
  return h(
2071
2332
  "div",
2072
- { className: "dlv-bar-wrap" },
2333
+ {
2334
+ className: overlay ? "dlv-bar-wrap dlv-question-overlay" : "dlv-bar-wrap",
2335
+ style: overlay ? overlayStyle : void 0
2336
+ },
2073
2337
  h(
2074
2338
  "div",
2075
2339
  { className: "dlv-pill", role: "group", "aria-label": "Voice controls" },
@@ -2087,22 +2351,23 @@ function createComponents(React2) {
2087
2351
  h("span", { className: "dlv-status", role: "status", "aria-live": "polite" }, status),
2088
2352
  h(Button, {
2089
2353
  className: "dlv-live-toggle",
2090
- label: "Automatic sending",
2091
- title: `Automatic sending: ${state.settings.sendingMode === "automatic" ? "on" : "off"}`,
2092
- icon: "send",
2093
- visibleLabel: state.settings.sendingMode === "automatic" ? "ON" : "OFF",
2094
- role: "switch",
2095
- "aria-checked": state.settings.sendingMode === "automatic",
2354
+ label: "Automatic delivery mode",
2355
+ title: `Automatic delivery: ${state.settings.sendingMode === "steer" ? "send to the running agent" : state.settings.sendingMode === "queue" ? "queue" : "off"}`,
2356
+ icon: state.settings.sendingMode === "queue" ? "queue" : "send",
2357
+ visibleLabel: state.settings.sendingMode === "steer" ? "SEND" : state.settings.sendingMode === "queue" ? "QUEUE" : "OFF",
2358
+ "aria-label": `Automatic delivery: ${state.settings.sendingMode || "manual"}`,
2359
+ "aria-pressed": ["queue", "steer"].includes(state.settings.sendingMode),
2360
+ "data-mode": state.settings.sendingMode || "manual",
2096
2361
  onClick: () => invoke("updateSettings", {
2097
- sendingMode: state.settings.sendingMode === "automatic" ? "manual" : "automatic"
2362
+ sendingMode: !["queue", "steer"].includes(state.settings.sendingMode) ? "queue" : state.settings.sendingMode === "queue" ? "steer" : "manual"
2098
2363
  })
2099
2364
  }),
2100
2365
  h(Button, {
2101
- className: "dlv-live-toggle",
2366
+ className: `dlv-live-toggle${state.speechSegmentsRemaining > 0 ? " dlv-live-toggle-expanded" : ""}`,
2102
2367
  label: "Automatic assistant speech",
2103
- title: `Automatic assistant speech: ${state.settings.announceAssistantMessages !== false ? "on" : "off"}`,
2368
+ title: `Automatic assistant speech: ${state.settings.announceAssistantMessages !== false ? "on" : "off"}${state.speechSegmentsRemaining > 0 ? ` \u2014 ${state.speechSegmentsRemaining} speech segment${state.speechSegmentsRemaining === 1 ? "" : "s"} remaining` : ""}`,
2104
2369
  icon: state.settings.announceAssistantMessages !== false ? "speaker" : "speakerOff",
2105
- visibleLabel: state.settings.announceAssistantMessages !== false ? "ON" : "OFF",
2370
+ visibleLabel: state.settings.announceAssistantMessages === false ? "OFF" : state.speechSegmentsRemaining > 0 ? String(state.speechSegmentsRemaining) : "ON",
2106
2371
  role: "switch",
2107
2372
  "aria-checked": state.settings.announceAssistantMessages !== false,
2108
2373
  onClick: () => invoke("updateSettings", {
@@ -2120,9 +2385,14 @@ function createComponents(React2) {
2120
2385
  onClick: () => invoke("startConversation")
2121
2386
  }) : null,
2122
2387
  capture ? h(Button, {
2123
- label: "Stop listening",
2124
- icon: "stop",
2125
- onClick: () => invoke("stopListening")
2388
+ className: "dlv-live-toggle dlv-mic-state",
2389
+ label: state.muted ? "Resume listening" : "Ignore composer input",
2390
+ title: `Microphone input: ${state.muted ? "ignoring" : "listening"}`,
2391
+ icon: state.muted ? "micOff" : "mic",
2392
+ visibleLabel: state.muted ? "IGNORING" : "LISTENING",
2393
+ "aria-pressed": Boolean(state.muted),
2394
+ "data-muted": state.muted ? "true" : "false",
2395
+ onClick: () => invoke(state.muted ? "resumeListeningInput" : "muteListening")
2126
2396
  }) : null,
2127
2397
  state.speaking && !state.paused && state.capabilities[state.settings.engine]?.pause ? h(Button, {
2128
2398
  label: "Pause speech",
@@ -2164,6 +2434,57 @@ function createComponents(React2) {
2164
2434
  const [invoke, error, clearError] = useActions(controller);
2165
2435
  const settings = state.settings || {};
2166
2436
  const capabilities = state.capabilities || {};
2437
+ const tabsId = React2.useId();
2438
+ const tabs = [
2439
+ { id: "conversation", label: "Conversation" },
2440
+ { id: "speech", label: "Speech" },
2441
+ { id: "recognition", label: "Speech recognition" }
2442
+ ];
2443
+ const tabRefs = React2.useRef([]);
2444
+ const [activeTab, setActiveTab] = React2.useState("conversation");
2445
+ const activateTab = (index) => {
2446
+ const tab = tabs[index];
2447
+ if (!tab) return;
2448
+ setActiveTab(tab.id);
2449
+ tabRefs.current[index]?.focus();
2450
+ };
2451
+ const handleTabKeyDown = (event, index) => {
2452
+ let nextIndex;
2453
+ if (event.key === "ArrowRight") nextIndex = (index + 1) % tabs.length;
2454
+ else if (event.key === "ArrowLeft") nextIndex = (index - 1 + tabs.length) % tabs.length;
2455
+ else if (event.key === "Home") nextIndex = 0;
2456
+ else if (event.key === "End") nextIndex = tabs.length - 1;
2457
+ else return;
2458
+ event.preventDefault();
2459
+ activateTab(nextIndex);
2460
+ };
2461
+ const [audioDevices, setAudioDevices] = React2.useState([]);
2462
+ React2.useEffect(() => {
2463
+ let active = true;
2464
+ const refresh = async () => {
2465
+ try {
2466
+ const devices = await globalThis.navigator?.mediaDevices?.enumerateDevices?.();
2467
+ if (active) setAudioDevices(Array.from(devices || []));
2468
+ } catch {
2469
+ if (active) setAudioDevices([]);
2470
+ }
2471
+ };
2472
+ void refresh();
2473
+ globalThis.navigator?.mediaDevices?.addEventListener?.("devicechange", refresh);
2474
+ return () => {
2475
+ active = false;
2476
+ globalThis.navigator?.mediaDevices?.removeEventListener?.("devicechange", refresh);
2477
+ };
2478
+ }, []);
2479
+ const deviceOptions = (kind, fallback) => [
2480
+ { value: "", label: "System default" },
2481
+ ...audioDevices.filter(
2482
+ (device) => device.kind === kind && device.deviceId && device.deviceId !== "default"
2483
+ ).map((device, index) => ({
2484
+ value: device.deviceId,
2485
+ label: device.label || `${fallback} ${index + 1}`
2486
+ }))
2487
+ ];
2167
2488
  const field = (label, key, options) => h(
2168
2489
  "label",
2169
2490
  { key },
@@ -2186,11 +2507,16 @@ function createComponents(React2) {
2186
2507
  )
2187
2508
  )
2188
2509
  );
2189
- const card = (title, children, open = false) => h(
2190
- "details",
2191
- { className: "dlv-settings-card", open },
2192
- h("summary", null, title),
2193
- h("div", { className: "dlv-settings-card-body" }, ...children)
2510
+ const panel = (id2, children) => h(
2511
+ "div",
2512
+ {
2513
+ id: `${tabsId}-panel-${id2}`,
2514
+ className: "dlv-settings-panel",
2515
+ role: "tabpanel",
2516
+ "aria-labelledby": `${tabsId}-tab-${id2}`,
2517
+ hidden: activeTab !== id2
2518
+ },
2519
+ ...children
2194
2520
  );
2195
2521
  const subcard = (title, children, open = false) => h(
2196
2522
  "details",
@@ -2204,9 +2530,41 @@ function createComponents(React2) {
2204
2530
  h("h3", null, "Live Voice"),
2205
2531
  onClose ? h(Button, { label: "Close voice settings", icon: "close", onClick: onClose }) : null,
2206
2532
  h(
2207
- "details",
2208
- { className: "dlv-settings-card" },
2209
- h("summary", null, "Speech output"),
2533
+ "div",
2534
+ { className: "dlv-settings-tabs", role: "tablist", "aria-label": "Live Voice settings" },
2535
+ tabs.map((tab, index) => {
2536
+ const selected = activeTab === tab.id;
2537
+ return h(
2538
+ "button",
2539
+ {
2540
+ key: tab.id,
2541
+ ref: (element) => {
2542
+ tabRefs.current[index] = element;
2543
+ },
2544
+ id: `${tabsId}-tab-${tab.id}`,
2545
+ type: "button",
2546
+ role: "tab",
2547
+ className: "dlv-settings-tab",
2548
+ "aria-selected": selected,
2549
+ "aria-controls": `${tabsId}-panel-${tab.id}`,
2550
+ "data-active": selected ? "true" : void 0,
2551
+ tabIndex: selected ? 0 : -1,
2552
+ onClick: () => setActiveTab(tab.id),
2553
+ onKeyDown: (event) => handleTabKeyDown(event, index)
2554
+ },
2555
+ tab.label
2556
+ );
2557
+ })
2558
+ ),
2559
+ h(
2560
+ "div",
2561
+ {
2562
+ id: `${tabsId}-panel-speech`,
2563
+ className: "dlv-settings-panel",
2564
+ role: "tabpanel",
2565
+ "aria-labelledby": `${tabsId}-tab-speech`,
2566
+ hidden: activeTab !== "speech"
2567
+ },
2210
2568
  h(
2211
2569
  "div",
2212
2570
  { className: "dlv-settings-card-body" },
@@ -2227,22 +2585,22 @@ function createComponents(React2) {
2227
2585
  disabled: capabilities.browser?.supported === false
2228
2586
  }
2229
2587
  ]),
2230
- settings.engine === "browser" ? field("Local browser voice", "voice", [
2231
- { value: "", label: "Automatic local voice" },
2232
- ...(capabilities.browser?.voices || []).map((voice) => ({
2233
- value: voice.voiceURI || voice.name,
2234
- label: `${voice.name} \u2014 ${voice.lang || "unknown language"}`
2235
- }))
2236
- ]) : settings.engine === "say" ? h(
2237
- "label",
2588
+ settings.engine !== "say" ? field("Output device", "outputDeviceId", deviceOptions("audiooutput", "Audio output")) : h("small", null, "macOS say uses the output selected on the DSH host."),
2589
+ settings.engine === "browser" ? h(
2590
+ React2.Fragment,
2238
2591
  null,
2239
- "macOS say voice (empty uses system default)",
2240
- h("input", {
2241
- type: "text",
2242
- value: settings.voice || "",
2243
- onChange: (event) => invoke("updateSettings", { voice: event.target.value }),
2244
- autoComplete: "off"
2245
- })
2592
+ h(
2593
+ "small",
2594
+ null,
2595
+ "Browser speech synthesis may ignore the selected output device; this browser API normally follows the system default."
2596
+ ),
2597
+ field("Local browser voice", "voice", [
2598
+ { value: "", label: "Automatic local voice" },
2599
+ ...(capabilities.browser?.voices || []).map((voice) => ({
2600
+ value: voice.voiceURI || voice.name,
2601
+ label: `${voice.name} \u2014 ${voice.lang || "unknown language"}`
2602
+ }))
2603
+ ])
2246
2604
  ) : null,
2247
2605
  settings.engine === "qwen-http" ? h(
2248
2606
  React2.Fragment,
@@ -2257,6 +2615,47 @@ function createComponents(React2) {
2257
2615
  h(QwenSettings, { key: "qwen-output-settings", controller })
2258
2616
  ])
2259
2617
  ) : null,
2618
+ subcard("Filtering", [
2619
+ h(
2620
+ "label",
2621
+ { key: "output-code-filter", className: "dlv-check" },
2622
+ h("input", {
2623
+ type: "checkbox",
2624
+ checked: settings.outputCodeFilterEnabled !== false,
2625
+ onChange: (event) => invoke("updateSettings", { outputCodeFilterEnabled: event.target.checked })
2626
+ }),
2627
+ " Filter Markdown code blocks before speaking"
2628
+ ),
2629
+ h(
2630
+ "label",
2631
+ { key: "output-code-lines" },
2632
+ "Read code blocks up to this many lines",
2633
+ h("input", {
2634
+ type: "number",
2635
+ min: 0,
2636
+ max: 100,
2637
+ value: settings.outputCodeMaxLines ?? 5,
2638
+ disabled: settings.outputCodeFilterEnabled === false,
2639
+ onChange: (event) => {
2640
+ const value = Number(event.target.value);
2641
+ if (Number.isInteger(value) && value >= 0 && value <= 100)
2642
+ invoke("updateSettings", { outputCodeMaxLines: value });
2643
+ }
2644
+ })
2645
+ ),
2646
+ h(
2647
+ "label",
2648
+ { key: "output-code-notice" },
2649
+ "Replacement phrase for larger code blocks",
2650
+ h("input", {
2651
+ type: "text",
2652
+ maxLength: 300,
2653
+ value: settings.outputCodeNotice || "Look the code on out conversation",
2654
+ disabled: settings.outputCodeFilterEnabled === false,
2655
+ onChange: (event) => invoke("updateSettings", { outputCodeNotice: event.target.value })
2656
+ })
2657
+ )
2658
+ ]),
2260
2659
  h(
2261
2660
  "label",
2262
2661
  null,
@@ -2279,13 +2678,48 @@ function createComponents(React2) {
2279
2678
  "p",
2280
2679
  null,
2281
2680
  "Qwen synthesis runs on the DSH host and the generated WAV plays in this browser. macOS say plays on the host; Browser speech plays on this device."
2681
+ ),
2682
+ ...["qwen-http", "say", "browser"].filter((id2) => capabilities[id2]?.supported === false).map(
2683
+ (id2) => h(
2684
+ "p",
2685
+ { key: id2, role: "status" },
2686
+ `${id2 === "qwen-http" ? "Qwen3 local" : id2 === "say" ? "macOS say" : "Browser speech"}: ${capabilities[id2].reason}`
2687
+ )
2688
+ ),
2689
+ h(
2690
+ "div",
2691
+ { className: "dlv-settings-actions" },
2692
+ h(
2693
+ "button",
2694
+ {
2695
+ type: "button",
2696
+ disabled: capabilities[settings.engine]?.supported !== true || state.speaking,
2697
+ onClick: () => invoke("speak", "DSH Live Voice. The selected speech output is working.")
2698
+ },
2699
+ state.speaking ? "Testing speech\u2026" : "Test selected speech output"
2700
+ ),
2701
+ state.speaking || state.paused ? h(
2702
+ "button",
2703
+ { type: "button", onClick: () => invoke("stopSpeech") },
2704
+ "Stop speech test"
2705
+ ) : null,
2706
+ h(
2707
+ "button",
2708
+ { type: "button", onClick: () => invoke("refreshCapabilities") },
2709
+ "Refresh available engines"
2710
+ )
2282
2711
  )
2283
2712
  )
2284
2713
  ),
2285
2714
  h(
2286
- "details",
2287
- { className: "dlv-settings-card" },
2288
- h("summary", null, "Speech recognition"),
2715
+ "div",
2716
+ {
2717
+ id: `${tabsId}-panel-recognition`,
2718
+ className: "dlv-settings-panel",
2719
+ role: "tabpanel",
2720
+ "aria-labelledby": `${tabsId}-tab-recognition`,
2721
+ hidden: activeTab !== "recognition"
2722
+ },
2289
2723
  h(
2290
2724
  "div",
2291
2725
  { className: "dlv-settings-card-body" },
@@ -2307,6 +2741,12 @@ function createComponents(React2) {
2307
2741
  h("option", { value: "whisper-http" }, "Whisper HTTP \u2014 DSH host")
2308
2742
  )
2309
2743
  ),
2744
+ field("Input device", "inputDeviceId", deviceOptions("audioinput", "Microphone")),
2745
+ settings.recognitionEngine === "browser" ? h(
2746
+ "small",
2747
+ null,
2748
+ "Browser SpeechRecognition may use the browser or system default microphone instead of this selection."
2749
+ ) : null,
2310
2750
  field("Recognition language", "recognitionLang", [
2311
2751
  ...settings.recognitionEngine !== "browser" ? [{ value: "auto", label: "Automatic \u2014 detect language" }] : [],
2312
2752
  { value: "pt-BR", label: "Portugu\xEAs (Brasil)" },
@@ -2351,6 +2791,89 @@ function createComponents(React2) {
2351
2791
  h(QwenSettings, { key: "qwen-recognition-settings", controller })
2352
2792
  ]) : null,
2353
2793
  h("p", null, "Provider settings change with the selected recognition engine."),
2794
+ subcard("Voice commands", [
2795
+ h(
2796
+ "label",
2797
+ { key: "voice-commands-enabled", className: "dlv-check" },
2798
+ h("input", {
2799
+ type: "checkbox",
2800
+ checked: settings.voiceCommandsEnabled !== false,
2801
+ onChange: (event) => invoke("updateSettings", { voiceCommandsEnabled: event.target.checked })
2802
+ }),
2803
+ " Enable exact voice commands"
2804
+ ),
2805
+ h(
2806
+ "small",
2807
+ { key: "voice-command-help" },
2808
+ "Separate phrases with commas. Matching ignores capitalization, accents, punctuation, and extra spaces. The entire final chunk must match."
2809
+ ),
2810
+ ...[
2811
+ ["Send to running agent", "voiceCommandSend", "send, send message"],
2812
+ ["Put in queue", "voiceCommandQueue", "queue, queue message"],
2813
+ ["End conversation", "voiceCommandEnd", "end, end conversation"],
2814
+ ["Mute composer input", "voiceCommandMute", "mute, stop listening"],
2815
+ ["Resume composer input", "voiceCommandResume", "resume, start listening"],
2816
+ [
2817
+ "Stop assistant speech",
2818
+ "voiceCommandStopSpeaking",
2819
+ "stop talking, stop speaking, shut up"
2820
+ ],
2821
+ ["Clear composer", "voiceCommandClear", "clear all, clear message"]
2822
+ ].map(
2823
+ ([label, key, fallback]) => h(
2824
+ "label",
2825
+ { key },
2826
+ label,
2827
+ h("textarea", {
2828
+ rows: 2,
2829
+ maxLength: 1e3,
2830
+ defaultValue: settings[key] || fallback,
2831
+ disabled: settings.voiceCommandsEnabled === false,
2832
+ onBlur: (event) => invoke("updateSettings", { [key]: event.target.value })
2833
+ })
2834
+ )
2835
+ )
2836
+ ]),
2837
+ subcard("Filtering", [
2838
+ h(
2839
+ "label",
2840
+ { key: "recognition-filter", className: "dlv-check" },
2841
+ h("input", {
2842
+ type: "checkbox",
2843
+ checked: settings.recognitionFilterEnabled !== false,
2844
+ onChange: (event) => invoke("updateSettings", { recognitionFilterEnabled: event.target.checked })
2845
+ }),
2846
+ " Ignore short final transcription chunks"
2847
+ ),
2848
+ h(
2849
+ "label",
2850
+ { key: "recognition-minimum-words" },
2851
+ "Minimum words per final chunk",
2852
+ h("input", {
2853
+ type: "number",
2854
+ min: 1,
2855
+ max: 20,
2856
+ value: settings.recognitionMinimumWords ?? 2,
2857
+ disabled: settings.recognitionFilterEnabled === false,
2858
+ onChange: (event) => {
2859
+ const value = Number(event.target.value);
2860
+ if (Number.isInteger(value) && value >= 1 && value <= 20)
2861
+ invoke("updateSettings", { recognitionMinimumWords: value });
2862
+ }
2863
+ }),
2864
+ h(
2865
+ "small",
2866
+ null,
2867
+ "Final chunks with fewer words are ignored before they reach the composer or automatic delivery."
2868
+ )
2869
+ )
2870
+ ]),
2871
+ capabilities.capture?.supported === false ? h("p", { role: "status" }, `Microphone: ${capabilities.capture.reason}`) : capabilities.capture?.permission === "prompt" ? h(
2872
+ "p",
2873
+ { role: "status" },
2874
+ "Microphone permission will be requested only when you start dictation or a voice conversation."
2875
+ ) : null,
2876
+ capabilities.recognition?.supported === false ? h("p", { role: "status" }, capabilities.recognition.reason) : null,
2354
2877
  usesPluginVoiceDetection(settings.recognitionEngine) ? h(
2355
2878
  "details",
2356
2879
  { className: "dlv-settings-subcard", "aria-label": "Silence detection settings" },
@@ -2399,135 +2922,100 @@ function createComponents(React2) {
2399
2922
  ) : null
2400
2923
  )
2401
2924
  ),
2402
- card(
2403
- "Conversation",
2404
- [
2405
- h(
2406
- "label",
2407
- { key: "announce", className: "dlv-check" },
2408
- h("input", {
2409
- type: "checkbox",
2410
- checked: settings.announceAssistantMessages !== false,
2411
- onChange: (event) => invoke("updateSettings", { announceAssistantMessages: event.target.checked })
2412
- }),
2413
- " Automatically speak new assistant messages"
2414
- ),
2415
- h(
2416
- "p",
2417
- { key: "policy" },
2418
- "During a voice conversation, assistant phrases are announced automatically. Playback waits while you are speaking."
2419
- ),
2420
- h(
2421
- "label",
2422
- { key: "interrupt-message", className: "dlv-check" },
2423
- h("input", {
2424
- type: "checkbox",
2425
- checked: settings.interruptSpeechOnUserMessage === true,
2426
- onChange: (event) => invoke("updateSettings", { interruptSpeechOnUserMessage: event.target.checked })
2427
- }),
2428
- " Stop assistant speech when I send a message"
2429
- ),
2925
+ panel("conversation", [
2926
+ h(
2927
+ "label",
2928
+ { key: "announce", className: "dlv-check" },
2929
+ h("input", {
2930
+ type: "checkbox",
2931
+ checked: settings.announceAssistantMessages !== false,
2932
+ onChange: (event) => invoke("updateSettings", { announceAssistantMessages: event.target.checked })
2933
+ }),
2934
+ " Automatically speak new assistant messages"
2935
+ ),
2936
+ h(
2937
+ "p",
2938
+ { key: "policy" },
2939
+ "During a voice conversation, assistant phrases are announced automatically. Playback waits while you are speaking."
2940
+ ),
2941
+ h(
2942
+ "label",
2943
+ { key: "interrupt-message", className: "dlv-check" },
2944
+ h("input", {
2945
+ type: "checkbox",
2946
+ checked: settings.interruptSpeechOnUserMessage === true,
2947
+ onChange: (event) => invoke("updateSettings", { interruptSpeechOnUserMessage: event.target.checked })
2948
+ }),
2949
+ " Stop assistant speech when I send a message"
2950
+ ),
2951
+ h(
2952
+ "p",
2953
+ { key: "interrupt-message-description", className: "dlv-setting-description" },
2954
+ settings.interruptSpeechOnUserMessage ? "Sending or steering a new user message stops current or paused assistant speech." : "Sending another message does not stop the assistant audio you are already hearing."
2955
+ ),
2956
+ field("Listening mode", "mode", [
2957
+ { value: "speaker", label: "Speakers \u2014 gated listening" },
2958
+ { value: "headphones", label: "Headphones \u2014 open microphone" }
2959
+ ]),
2960
+ h(
2961
+ "p",
2962
+ { key: "mode-description", className: "dlv-setting-description" },
2963
+ settings.mode === "headphones" ? "Open microphone keeps listening while responses play. When your speech is detected, playback pauses and resumes only when you choose." : "Gated listening releases the microphone while responses play, preventing speaker audio from being recognized. Use Take microphone to interrupt."
2964
+ ),
2965
+ h(
2966
+ "label",
2967
+ { key: "speech-delay" },
2968
+ "Assistant response delay",
2430
2969
  h(
2431
- "p",
2432
- { key: "interrupt-message-description", className: "dlv-setting-description" },
2433
- settings.interruptSpeechOnUserMessage ? "Sending or steering a new user message stops current or paused assistant speech." : "Sending another message does not stop the assistant audio you are already hearing."
2970
+ "select",
2971
+ {
2972
+ value: String(settings.assistantSpeechDelaySeconds || 3),
2973
+ onChange: (event) => invoke("updateSettings", {
2974
+ assistantSpeechDelaySeconds: Number(event.target.value)
2975
+ })
2976
+ },
2977
+ [1, 2, 3, 4, 5, 6, 8, 10].map(
2978
+ (seconds) => h("option", { key: seconds, value: String(seconds) }, seconds + " seconds")
2979
+ )
2434
2980
  ),
2435
- field("Listening mode", "mode", [
2436
- { value: "speaker", label: "Speakers \u2014 gated listening" },
2437
- { value: "headphones", label: "Headphones \u2014 open microphone" }
2438
- ]),
2439
2981
  h(
2440
- "p",
2441
- { key: "mode-description", className: "dlv-setting-description" },
2442
- settings.mode === "headphones" ? "Open microphone keeps listening while responses play. When your speech is detected, playback pauses and resumes only when you choose." : "Gated listening releases the microphone while responses play, preventing speaker audio from being recognized. Use Take microphone to interrupt."
2443
- ),
2982
+ "small",
2983
+ null,
2984
+ "After you stop speaking, automatic assistant playback waits for this much continuous silence. Speaking again restarts the wait."
2985
+ )
2986
+ ),
2987
+ field("Sending mode", "sendingMode", [
2988
+ { value: "manual", label: "Off \u2014 review and send manually" },
2989
+ { value: "queue", label: "Queue \u2014 automatically add after silence" },
2990
+ { value: "steer", label: "Steer \u2014 automatically send to the running agent" }
2991
+ ]),
2992
+ settings.sendingMode !== "manual" ? h(
2993
+ "label",
2994
+ { key: "delay" },
2995
+ "Send after silence",
2444
2996
  h(
2445
- "label",
2446
- { key: "speech-delay" },
2447
- "Assistant response delay",
2448
- h(
2449
- "select",
2450
- {
2451
- value: String(settings.assistantSpeechDelaySeconds || 3),
2452
- onChange: (event) => invoke("updateSettings", {
2453
- assistantSpeechDelaySeconds: Number(event.target.value)
2454
- })
2455
- },
2456
- [1, 2, 3, 4, 5, 6, 8, 10].map(
2457
- (seconds) => h("option", { key: seconds, value: String(seconds) }, seconds + " seconds")
2458
- )
2459
- ),
2460
- h(
2461
- "small",
2462
- null,
2463
- "After you stop speaking, automatic assistant playback waits for this much continuous silence. Speaking again restarts the wait."
2997
+ "select",
2998
+ {
2999
+ value: String(settings.autoSendDelaySeconds || 4),
3000
+ onChange: (event) => invoke("updateSettings", {
3001
+ autoSendDelaySeconds: Number(event.target.value)
3002
+ })
3003
+ },
3004
+ [2, 3, 4, 5, 6, 8, 10].map(
3005
+ (seconds) => h("option", { key: seconds, value: String(seconds) }, seconds + " seconds")
2464
3006
  )
2465
3007
  ),
2466
- field("Sending mode", "sendingMode", [
2467
- { value: "manual", label: "Manual \u2014 review and send" },
2468
- { value: "automatic", label: "Automatic \u2014 send after silence" }
2469
- ]),
2470
- settings.sendingMode === "automatic" ? h(
2471
- "label",
2472
- { key: "delay" },
2473
- "Send after silence",
2474
- h(
2475
- "select",
2476
- {
2477
- value: String(settings.autoSendDelaySeconds || 4),
2478
- onChange: (event) => invoke("updateSettings", {
2479
- autoSendDelaySeconds: Number(event.target.value)
2480
- })
2481
- },
2482
- [2, 3, 4, 5, 6, 8, 10].map(
2483
- (seconds) => h("option", { key: seconds, value: String(seconds) }, seconds + " seconds")
2484
- )
2485
- ),
2486
- h(
2487
- "small",
2488
- null,
2489
- "Countdown starts after a final recognized phrase. New speech or edits cancel it."
2490
- )
2491
- ) : h(
2492
- "p",
2493
- { key: "manual", className: "dlv-setting-description" },
2494
- "Recognized text stays in the composer until you use the normal DSH Send control."
3008
+ h(
3009
+ "small",
3010
+ null,
3011
+ "Countdown starts after a final recognized phrase. New speech or edits cancel it."
2495
3012
  )
2496
- ],
2497
- false
2498
- ),
2499
- capabilities.capture?.supported === false ? h("p", { role: "status" }, `Microphone: ${capabilities.capture.reason}`) : capabilities.capture?.permission === "prompt" ? h(
2500
- "p",
2501
- { role: "status" },
2502
- "Microphone permission will be requested only when you start dictation or a voice conversation."
2503
- ) : null,
2504
- capabilities.recognition?.supported === false ? h("p", { role: "status" }, capabilities.recognition.reason) : null,
2505
- ...["qwen-http", "say", "browser"].filter((id2) => capabilities[id2]?.supported === false).map(
2506
- (id2) => h(
3013
+ ) : h(
2507
3014
  "p",
2508
- { key: id2, role: "status" },
2509
- `${id2 === "qwen-http" ? "Qwen3 local" : id2 === "say" ? "macOS say" : "Browser speech"}: ${capabilities[id2].reason}`
2510
- )
2511
- ),
2512
- h(
2513
- "div",
2514
- { className: "dlv-settings-actions" },
2515
- h(
2516
- "button",
2517
- {
2518
- type: "button",
2519
- disabled: capabilities[settings.engine]?.supported !== true || state.speaking,
2520
- onClick: () => invoke("speak", "DSH Live Voice. The selected speech output is working.")
2521
- },
2522
- state.speaking ? "Testing speech\u2026" : "Test selected speech output"
2523
- ),
2524
- state.speaking || state.paused ? h("button", { type: "button", onClick: () => invoke("stopSpeech") }, "Stop speech test") : null,
2525
- h(
2526
- "button",
2527
- { type: "button", onClick: () => invoke("refreshCapabilities") },
2528
- "Refresh available engines"
3015
+ { key: "manual", className: "dlv-setting-description" },
3016
+ "Recognized text stays in the composer until you use the normal DSH Send control."
2529
3017
  )
2530
- ),
3018
+ ]),
2531
3019
  h(ErrorText, {
2532
3020
  error: error || state.error,
2533
3021
  onDismiss: () => {
@@ -2552,23 +3040,27 @@ var styles = `
2552
3040
  .dlv-icon-button:disabled{opacity:.4;cursor:default}
2553
3041
  .dlv-icon-button:focus-visible,.dlv-settings :is(input,select):focus-visible{outline:2px solid var(--dsw-alias-label-primary);outline-offset:3px}
2554
3042
  .dlv-bar-wrap{width:100%;min-width:0}
3043
+ .dlv-question-overlay{position:fixed;z-index:10000;bottom:8px;transform:translateX(-50%);max-width:calc(100vw - 32px);pointer-events:none}
3044
+ .dlv-question-overlay .dlv-pill{pointer-events:auto}
2555
3045
  .dlv-pill{display:flex;align-items:center;box-sizing:border-box;gap:10px;min-height:52px;border-radius:26px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);padding:0 14px;width:100%;max-width:720px;margin:0 auto;box-shadow:0 8px 24px rgba(0,0,0,.18)}
2556
3046
  .dlv-pill-button{width:34px;height:34px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary)}
2557
3047
  .dlv-pill-button:hover{background:var(--dsw-alias-bg-layer-2)}
2558
- .dlv-live-toggle{width:auto;min-width:58px;height:34px;padding:0 9px;gap:5px;border:1px solid var(--dsw-alias-border-l2);border-radius:17px;color:var(--dsw-alias-label-primary)}
2559
- .dlv-live-toggle svg{width:18px;height:18px}
2560
- .dlv-toggle-state{min-width:22px;font-size:10px;font-weight:700;line-height:1;letter-spacing:.04em;text-align:left}
2561
- .dlv-live-toggle:hover{background:var(--dsw-alias-bg-layer-2)}
2562
- .dlv-live-toggle[aria-checked=false]{color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l1);opacity:.72}
3048
+ .dlv-live-toggle{width:34px;height:34px;padding:0 7px;gap:0;overflow:hidden;border:1px solid var(--dsw-alias-border-l2);border-radius:17px;color:var(--dsw-alias-label-primary);transition:width .16s ease,gap .16s ease}
3049
+ .dlv-live-toggle svg{width:18px;height:18px;flex:none}
3050
+ .dlv-toggle-state{max-width:0;opacity:0;overflow:hidden;white-space:nowrap;font-size:10px;font-weight:700;line-height:1;letter-spacing:.04em;text-align:left;transition:max-width .16s ease,opacity .12s ease}
3051
+ .dlv-live-toggle:hover,.dlv-live-toggle:focus-visible,.dlv-live-toggle-expanded{width:78px;gap:5px;background:var(--dsw-alias-bg-layer-2)}
3052
+ .dlv-live-toggle:hover .dlv-toggle-state,.dlv-live-toggle:focus-visible .dlv-toggle-state,.dlv-live-toggle-expanded .dlv-toggle-state{max-width:38px;opacity:1}
3053
+ .dlv-live-toggle[aria-checked=false],.dlv-live-toggle[data-mode=manual],.dlv-mic-state[data-muted=true]{color:var(--dsw-alias-label-tertiary);border-color:var(--dsw-alias-border-l1);opacity:.55}
3054
+ .dlv-mic-state:hover,.dlv-mic-state:focus-visible{width:102px}.dlv-mic-state:hover .dlv-toggle-state,.dlv-mic-state:focus-visible .dlv-toggle-state{max-width:64px}
2563
3055
  .dlv-live-toggle[aria-checked=true]{background:var(--dsw-alias-bg-layer-2)}
2564
3056
  .dlv-wave{display:block;flex:1 1 180px;min-width:30px;width:100%;height:40px;color:var(--dsw-alias-label-primary)}
2565
3057
  .dlv-status{flex:1 1 120px;min-width:0;font-size:13px;line-height:1.4;color:var(--dsw-alias-label-secondary)}
2566
3058
  .dlv-error{color:var(--dsw-alias-state-error-primary);overflow-wrap:anywhere;font-size:13px;max-width:720px;margin:8px auto}
2567
3059
  .dlv-settings{box-sizing:border-box;padding:18px;width:100%;display:grid;gap:16px;max-width:640px;color:var(--dsw-alias-label-primary)}
2568
3060
  .dlv-settings h3,.dlv-settings p{margin:0}
2569
- .dlv-settings-group,.dlv-settings-card{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;min-width:0}.dlv-settings-group{display:grid;gap:14px;padding:16px}.dlv-settings-group legend{padding:0 6px;font-weight:600;color:var(--dsw-alias-label-primary)}.dlv-settings-card>summary,.dlv-settings-subcard>summary{cursor:pointer;font-weight:650;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:12px}.dlv-settings-card>summary::-webkit-details-marker,.dlv-settings-subcard>summary::-webkit-details-marker{display:none}.dlv-settings-card>summary:after,.dlv-settings-subcard>summary:after{content:"\u203A";transform:rotate(90deg);transition:transform .15s}.dlv-settings-card:not([open])>summary:after,.dlv-settings-subcard:not([open])>summary:after{transform:rotate(0)}.dlv-settings-card-body{display:grid;gap:12px;padding:0 12px 12px}.dlv-settings-subcard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;min-width:0}.dlv-settings-subcard>summary{padding:10px}.dlv-settings-subcard-body{display:grid;gap:10px;padding:0 10px 10px}
3061
+ .dlv-settings-tabs{border-bottom:.5px solid var(--dsw-alias-border-l2);display:flex;align-items:flex-end;gap:22px;overflow-x:auto}.dlv-settings-tab{position:relative;flex:none;padding:7px 1px 9px;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font:inherit;font-size:13px;line-height:20px;cursor:pointer}.dlv-settings-tab:hover,.dlv-settings-tab[data-active=true]{color:var(--dsw-alias-label-primary)}.dlv-settings-tab[data-active=true]:after,.dlv-settings-tab:focus-visible:after{content:"";position:absolute;right:0;bottom:-1px;left:0;height:2px;border-radius:2px 2px 0 0;background:var(--dsw-alias-label-primary)}.dlv-settings-tab:focus-visible{border-radius:2px;outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px;color:var(--dsw-alias-label-primary)}.dlv-settings-panel{min-width:0;padding-top:2px}.dlv-settings-panel[hidden]{display:none}.dlv-settings-group{margin:0;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;min-width:0;display:grid;gap:14px;padding:16px}.dlv-settings-group legend{padding:0 6px;font-weight:600;color:var(--dsw-alias-label-primary)}.dlv-settings-card-body{display:grid;gap:12px;padding:12px 0}.dlv-settings-subcard{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;min-width:0}.dlv-settings-subcard>summary{cursor:pointer;font-weight:650;list-style:none;display:flex;align-items:center;justify-content:space-between;padding:10px}.dlv-settings-subcard>summary::-webkit-details-marker{display:none}.dlv-settings-subcard>summary:after{content:"\u203A";transform:rotate(90deg);transition:transform .15s}.dlv-settings-subcard:not([open])>summary:after{transform:rotate(0)}.dlv-settings-subcard-body{display:grid;gap:10px;padding:0 10px 10px}
2570
3062
  .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}
2571
- .dlv-settings :is(input,select){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}
3063
+ .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}
2572
3064
  .dlv-settings small,.dlv-settings p{font-size:13px;color:var(--dsw-alias-label-secondary);line-height:1.5}
2573
3065
  .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}
2574
3066
  .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}
@@ -2593,6 +3085,11 @@ function assistantMessages(snapshot) {
2593
3085
  };
2594
3086
  }).sort((a, b) => a.turn - b.turn || a.step - b.step);
2595
3087
  }
3088
+ function pendingQuestionSpeech(interaction, index = 0) {
3089
+ if (!interaction || interaction.kind !== "question" || !Array.isArray(interaction.questions)) return "";
3090
+ const question = interaction.questions[index]?.question;
3091
+ return typeof question === "string" ? question.trim() : "";
3092
+ }
2596
3093
  function latestUserSequence(snapshot) {
2597
3094
  return Math.max(
2598
3095
  -1,
@@ -2606,13 +3103,14 @@ function addressedTurn(messages, messageId) {
2606
3103
  }
2607
3104
 
2608
3105
  // src/client/index.ts
2609
- var inject = ["slots", "connection", "uiConversation"];
3106
+ var inject = ["slots", "connection", "uiConversation", "uiSession"];
2610
3107
  function apply(ctx) {
2611
3108
  const e = import_react.default.createElement;
2612
3109
  const { MicrophoneButtons, RecordingBar, SpeakButton, SettingsPanel } = createComponents(import_react.default);
2613
3110
  const controllers = /* @__PURE__ */ new Map();
2614
3111
  const retiring = /* @__PURE__ */ new Set();
2615
3112
  let disposed = false;
3113
+ let voiceModeActive = false;
2616
3114
  const ownership = new VoiceOwnership();
2617
3115
  const recognitionFor = (settings, meter) => settings.recognitionEngine === "qwen-http" ? new QwenHttpRecognitionEngine({
2618
3116
  meter,
@@ -2635,6 +3133,10 @@ function apply(ctx) {
2635
3133
  entry.composers.clear();
2636
3134
  entry.unsubscribe?.();
2637
3135
  entry.unsubscribe = null;
3136
+ entry.unsubscribePendingQuestion?.();
3137
+ entry.unsubscribePendingQuestion = null;
3138
+ entry.questionCapture = null;
3139
+ entry.controller.patch({ answeringQuestion: false });
2638
3140
  entry.chatListeners.clear();
2639
3141
  if (controllers.get(entry.key) === entry) controllers.delete(entry.key);
2640
3142
  const done = run(entry.controller, entry.controller.dispose());
@@ -2674,9 +3176,32 @@ function apply(ctx) {
2674
3176
  meter,
2675
3177
  composer: {
2676
3178
  getDraft: () => entry.draft,
2677
- submit: () => {
3179
+ submit: (mode = "queue") => {
2678
3180
  const owner = [...entry.composers.values()].at(-1);
2679
- owner?.actions.submit?.();
3181
+ owner?.actions.submit?.(mode);
3182
+ },
3183
+ handleQuestionResult: ({ final, interim }) => {
3184
+ const capture = entry.questionCapture;
3185
+ if (!capture || capture.answering || !final && !interim) return false;
3186
+ if (final?.trim()) {
3187
+ capture.answering = true;
3188
+ capture.answers.push({
3189
+ id: capture.interaction.questions[capture.index].id,
3190
+ selected: [],
3191
+ custom: final.trim()
3192
+ });
3193
+ capture.index++;
3194
+ if (capture.index < capture.interaction.questions.length) {
3195
+ capture.answering = false;
3196
+ const text = pendingQuestionSpeech(capture.interaction, capture.index);
3197
+ if (text) run(entry.controller, entry.controller.speak(text, capture.interaction.key));
3198
+ } else {
3199
+ entry.questionCapture = null;
3200
+ entry.controller.patch({ answeringQuestion: false });
3201
+ run(entry.controller, capture.interaction.answer({ answers: capture.answers }));
3202
+ }
3203
+ }
3204
+ return true;
2680
3205
  },
2681
3206
  setDraft: (text) => {
2682
3207
  if (disposed || entry.closed) return;
@@ -2717,6 +3242,30 @@ function apply(ctx) {
2717
3242
  refresh();
2718
3243
  for (const listener of entry.chatListeners) listener();
2719
3244
  });
3245
+ const pendingInteractions = ctx.uiSession.pendingInteractions;
3246
+ const refreshPendingQuestion = (baseline = false) => {
3247
+ if (disposed || entry.closed) return;
3248
+ const interaction = pendingInteractions.getSnapshot().get(sessionId);
3249
+ const key2 = interaction?.kind === "question" ? interaction.key : null;
3250
+ if (!key2 || entry.questionCapture && entry.questionCapture.interaction.key !== key2) {
3251
+ entry.questionCapture = null;
3252
+ controller.patch({ answeringQuestion: false });
3253
+ }
3254
+ if (baseline) {
3255
+ entry.pendingQuestionKey = key2;
3256
+ return;
3257
+ }
3258
+ if (!key2 || key2 === entry.pendingQuestionKey) return;
3259
+ entry.pendingQuestionKey = key2;
3260
+ const text = pendingQuestionSpeech(interaction);
3261
+ if (text && controller.getSnapshot().conversation) {
3262
+ entry.questionCapture = { interaction, index: 0, answers: [], answering: false };
3263
+ controller.patch({ answeringQuestion: true });
3264
+ run(controller, controller.speak(text, key2));
3265
+ }
3266
+ };
3267
+ refreshPendingQuestion(true);
3268
+ entry.unsubscribePendingQuestion = pendingInteractions.subscribe(refreshPendingQuestion);
2720
3269
  const update = controller.updateSettings.bind(controller);
2721
3270
  controller.updateSettings = (next) => {
2722
3271
  if (disposed || entry.closed) return;
@@ -2736,6 +3285,7 @@ function apply(ctx) {
2736
3285
  const original = controller[method].bind(controller);
2737
3286
  controller[method] = (...args) => {
2738
3287
  entry.request++;
3288
+ if (method === "endConversation" && args[0] !== true) voiceModeActive = false;
2739
3289
  return original(...args);
2740
3290
  };
2741
3291
  }
@@ -2744,6 +3294,7 @@ function apply(ctx) {
2744
3294
  controller[method] = (...args) => {
2745
3295
  if (disposed || entry.closed || !entry.refs) return Promise.resolve();
2746
3296
  if (method !== "speak" && !entry.composers.size) return Promise.resolve();
3297
+ if (method === "startConversation") voiceModeActive = true;
2747
3298
  const request = ++entry.request;
2748
3299
  return ownership.run(
2749
3300
  controller,
@@ -2787,19 +3338,14 @@ function apply(ctx) {
2787
3338
  if (!entry || entry.closed || disposed) return;
2788
3339
  return () => {
2789
3340
  entry.composers.delete(token.current);
2790
- if (!entry.composers.size) {
2791
- entry.request++;
2792
- queueMicrotask(() => {
2793
- if (!entry.closed && !entry.composers.size)
2794
- run(entry.controller, entry.controller.endConversation());
2795
- });
2796
- }
2797
3341
  };
2798
3342
  }, [entry]);
2799
3343
  import_react.default.useLayoutEffect(() => {
2800
3344
  if (!entry || entry.closed || disposed) return;
2801
3345
  if (!input || typeof props.inputActions?.setDraft !== "function") return;
2802
3346
  entry.composers.set(token.current, { actions: props.inputActions });
3347
+ if (voiceModeActive && !entry.controller.getSnapshot().conversation)
3348
+ run(entry.controller, entry.controller.startConversation());
2803
3349
  }, [entry, input, props.inputActions]);
2804
3350
  import_react.default.useLayoutEffect(() => {
2805
3351
  if (!entry || entry.closed || disposed || !input) return;
@@ -2900,6 +3446,47 @@ function apply(ctx) {
2900
3446
  useComposer(entry, props);
2901
3447
  return entry ? e(RecordingBar, { controller: entry.controller }) : null;
2902
3448
  }
3449
+ function QuestionStatusView({ entry }) {
3450
+ const snapshot = import_react.default.useSyncExternalStore(
3451
+ entry.controller.subscribe,
3452
+ entry.controller.getSnapshot
3453
+ );
3454
+ const [overlayStyle, setOverlayStyle] = import_react.default.useState();
3455
+ import_react.default.useLayoutEffect(() => {
3456
+ if (!snapshot.answeringQuestion) return;
3457
+ const seat = document.querySelector("[data-composer-seat]");
3458
+ if (!seat) return;
3459
+ const update = () => {
3460
+ const rect = seat.getBoundingClientRect();
3461
+ setOverlayStyle({
3462
+ left: rect.left + rect.width / 2,
3463
+ width: Math.min(720, Math.max(0, rect.width - 32))
3464
+ });
3465
+ };
3466
+ update();
3467
+ const observer = typeof ResizeObserver === "function" ? new ResizeObserver(update) : null;
3468
+ observer?.observe(seat);
3469
+ window.addEventListener("resize", update);
3470
+ return () => {
3471
+ observer?.disconnect();
3472
+ window.removeEventListener("resize", update);
3473
+ };
3474
+ }, [snapshot.answeringQuestion]);
3475
+ const target = typeof document === "undefined" ? null : document.body;
3476
+ return snapshot.answeringQuestion && target && overlayStyle ? (0, import_react_dom.createPortal)(
3477
+ e(RecordingBar, {
3478
+ controller: entry.controller,
3479
+ questionOnly: true,
3480
+ overlay: true,
3481
+ overlayStyle
3482
+ }),
3483
+ target
3484
+ ) : null;
3485
+ }
3486
+ function QuestionStatus(props) {
3487
+ const entry = useEntry(props.sessionId, "question-status");
3488
+ return entry ? e(QuestionStatusView, { entry }) : null;
3489
+ }
2903
3490
  function ActionView({ entry, messageId }) {
2904
3491
  const snapshot = import_react.default.useSyncExternalStore(
2905
3492
  entry.controller.subscribe,
@@ -2934,6 +3521,7 @@ function apply(ctx) {
2934
3521
  for (const [name, id2, order, component] of [
2935
3522
  ["conversation.input.right", "live-voice-controls", 6, Buttons],
2936
3523
  ["conversation.input.dock", "live-voice-status", -100, Dock],
3524
+ ["conversation.session.header.utilities", "live-voice-question-status", 100, QuestionStatus],
2937
3525
  ["conversation.chat.assistant-actions", "live-voice-speak", 5, Action]
2938
3526
  ])
2939
3527
  ctx.slots.inject(