@olares/lares-mobile 0.15.31 → 0.15.32

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 (2) hide show
  1. package/package.json +2 -2
  2. package/src/App.vue +44 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-mobile",
3
- "version": "0.15.31",
3
+ "version": "0.15.32",
4
4
  "description": "Lares chat component for LarePass",
5
5
  "type": "module",
6
6
  "repository": {
@@ -28,7 +28,7 @@
28
28
  "vue": "^3.2.0"
29
29
  },
30
30
  "dependencies": {
31
- "@olares/lares-core": "0.15.31",
31
+ "@olares/lares-core": "0.15.32",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -595,6 +595,8 @@ export default {
595
595
  const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
596
596
  this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
597
597
  this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
598
+ // The call no longer owns the answer: a cancelled take gives it back.
599
+ if (this.voiceSuspended && view.snapshot?.connection !== "open") this.resumeSuspendedReadout();
598
600
  });
599
601
  this.replySpeaker = createReplySpeaker();
600
602
  this.reader = createSentencePlayer({
@@ -648,7 +650,7 @@ export default {
648
650
  this.voicePageShowHandler = () => {
649
651
  if (this.voiceReadoutPaused) { this.voiceReadoutPaused = false; this.reader.resume(); }
650
652
  };
651
- this.voiceOfflineHandler = () => { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); };
653
+ this.voiceOfflineHandler = () => { this.stopReadout(); void this.voicePanel.pause().catch(() => {}); };
652
654
  window.addEventListener("offline", this.voiceOfflineHandler);
653
655
  document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
654
656
  window.addEventListener("pagehide", this.voicePageHideHandler);
@@ -663,7 +665,7 @@ export default {
663
665
  this.restoreLog();
664
666
  },
665
667
  deactivated() {
666
- this.reader.stop();
668
+ this.stopReadout();
667
669
  void this.voicePanel?.pause().catch(() => {});
668
670
  this.captureLog();
669
671
  },
@@ -742,7 +744,7 @@ export default {
742
744
  }
743
745
  if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
744
746
  void this.voicePanel?.pause().catch(() => {});
745
- this.reader.stop();
747
+ this.stopReadout();
746
748
  this.player?.stop();
747
749
  }
748
750
  if (sessionChanged) this.voiceAdoptingSession = null;
@@ -759,9 +761,12 @@ export default {
759
761
  // A paused call is not speaking. Only an open call owns the reply;
760
762
  // leaving it set after the keyboard returns is how later answers stay silent.
761
763
  const voiceSpeaking = this.voiceCall?.connection === "open";
762
- if (voiceSpeaking) this.reader.stop();
764
+ if (voiceSpeaking) this.suspendReadout();
763
765
  const target = this.voiceSpeak && !voiceSpeaking && !document.hidden ? speakable(utterance, this.voiceStreaming) : null;
764
- if (target) this.reader.follow({ ...target, sessionId: snap.sessionId, source: { sessionId: snap.sessionId, turn: target.turn, step: target.step, attemptId: target.attemptId }, key: `${snap.sessionId}:${target.key}` });
766
+ if (target) this.followReadout({ ...target, sessionId: snap.sessionId, source: { sessionId: snap.sessionId, turn: target.turn, step: target.step, attemptId: target.attemptId }, key: `${snap.sessionId}:${target.key}` });
767
+ // A cancelled voice take did not own the answer: play it again. A take
768
+ // that submitted a turn grew the transcript, so the call owns it.
769
+ if (!voiceSpeaking) this.resumeSuspendedReadout();
765
770
  this.failed = snap.failed;
766
771
  this.error = snap.error;
767
772
  this.sessions = snap.sessions ?? this.sessions;
@@ -1302,7 +1307,7 @@ export default {
1302
1307
  },
1303
1308
  async onVoiceShortcut(event) {
1304
1309
  if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
1305
- if (event.key === "Escape" && !event.defaultPrevented) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); return; }
1310
+ if (event.key === "Escape" && !event.defaultPrevented) { this.stopReadout(); void this.voicePanel.pause().catch(() => {}); return; }
1306
1311
  const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
1307
1312
  if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
1308
1313
  event.preventDefault();
@@ -1352,7 +1357,7 @@ export default {
1352
1357
  const device = settings.config.device === "headset" ? "headset" : "device";
1353
1358
  onDevice(device);
1354
1359
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1355
- this.reader.stop(); this.player?.stop(); this.player?.unlock();
1360
+ this.suspendReadout(); this.player?.stop(); this.player?.unlock();
1356
1361
  if (config.transport !== "legacy") {
1357
1362
  return createUnifiedConversation({ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1358
1363
  device: captureSource("mobile", config.device), readAloud: continuous && config.speak, preset: config.preset, presentationSession: true,
@@ -1394,6 +1399,34 @@ export default {
1394
1399
  onError: (error) => { this.voiceError = error.message; },
1395
1400
  });
1396
1401
  },
1402
+ followReadout(value) {
1403
+ if (!value) return;
1404
+ this.lastReadout = value;
1405
+ this.reader.follow(value);
1406
+ },
1407
+ suspendReadout() {
1408
+ const key = this.reader?.key;
1409
+ if (!key) return;
1410
+ if (this.lastReadout?.key === key) {
1411
+ this.voiceSuspended = this.lastReadout;
1412
+ this.voiceSuspendedItems = this.items?.length ?? 0;
1413
+ }
1414
+ this.reader.suspend?.();
1415
+ },
1416
+ resumeSuspendedReadout() {
1417
+ const suspended = this.voiceSuspended;
1418
+ if (!suspended) return;
1419
+ this.voiceSuspended = null;
1420
+ const taken = (this.items?.length ?? 0) > this.voiceSuspendedItems;
1421
+ if (taken) this.reader.consume(suspended.key);
1422
+ else if (suspended.sessionId === this.sessionId && this.voiceSpeak && !document.hidden) this.followReadout(suspended);
1423
+ },
1424
+ stopReadout() {
1425
+ this.voiceSuspended = null;
1426
+ this.voiceSuspendedItems = 0;
1427
+ this.lastReadout = null;
1428
+ this.reader.stop();
1429
+ },
1397
1430
  setReadoutAwake(active) {
1398
1431
  this.readoutActive = Boolean(active);
1399
1432
  if (this.readoutActive) void this.readoutWake?.hold();
@@ -1415,7 +1448,7 @@ export default {
1415
1448
  this.voiceSpeak = next;
1416
1449
  this.speakBusy = true;
1417
1450
  if (!next) {
1418
- this.reader.stop();
1451
+ this.stopReadout();
1419
1452
  this.player?.stop();
1420
1453
  void this.runtime.interruptSpeech?.();
1421
1454
  }
@@ -1580,10 +1613,10 @@ export default {
1580
1613
  const text = this.draft.trim();
1581
1614
  if (!this.canSend) return;
1582
1615
  this.player?.unlock();
1583
- // A typed reply is the chat's. An open voice call would keep the reader
1584
- // quiet and then not speak this text, so pause it and let the reader take the answer.
1616
+ // A typed reply is the chat's. Stop the old readout before pausing the
1617
+ // call, so the pause cannot hand a suspended answer back for a moment.
1618
+ this.stopReadout();
1585
1619
  if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1586
- this.reader.stop();
1587
1620
  const sessionId = this.sessionId;
1588
1621
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1589
1622
  const wasRunning = this.running;