@olares/lares-mobile 0.15.30 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-mobile",
3
- "version": "0.15.30",
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.30",
31
+ "@olares/lares-core": "0.15.32",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -127,6 +127,7 @@ import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
127
127
  import { createUnifiedConversation } from "@olares/lares-core/voice/unified-conversation";
128
128
  import { createAnswerPlayer } from "./chat/playback.js";
129
129
  import { createReplySpeaker, createSentencePlayer, readoutState, speakable } from "@olares/lares-core/voice/speech";
130
+ import { createScreenWakeLock } from "@olares/lares-core/voice/wake-lock";
130
131
  import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
131
132
  import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
132
133
  import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
@@ -561,6 +562,9 @@ export default {
561
562
  this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
562
563
  this.imageUploadControllers = new Map();
563
564
  this.player = createAnswerPlayer();
565
+ // A locked screen suspends the WebView and cuts a long answer short; hold
566
+ // the screen for as long as a readout is actually playing.
567
+ this.readoutWake = createScreenWakeLock();
564
568
  this.voicePanel = createVoicePanel({
565
569
  armOutput: () => this.player?.unlock(),
566
570
  create: (options) => this.createMobileVoiceConversation(options),
@@ -591,6 +595,8 @@ export default {
591
595
  const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
592
596
  this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
593
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();
594
600
  });
595
601
  this.replySpeaker = createReplySpeaker();
596
602
  this.reader = createSentencePlayer({
@@ -607,6 +613,7 @@ export default {
607
613
  halt: () => this.player?.stop(),
608
614
  silence: (source) => void this.runtime.interruptSpeech?.(source),
609
615
  fail: (err) => this.showSpeakProblem(err),
616
+ onState: (active) => this.setReadoutAwake(active),
610
617
  });
611
618
 
612
619
  this.bindIntake(this.sessionId);
@@ -624,12 +631,30 @@ export default {
624
631
  this.conversationUnsub = subscribeConversationSettings((section) => {
625
632
  this.busyEnter = section.busyEnter;
626
633
  });
634
+ this.voiceReadoutPaused = false;
627
635
  this.voiceVisibilityHandler = () => {
628
- if (document.hidden) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); }
636
+ if (document.hidden) {
637
+ // A locked screen or an app switch is a pause, not the end of the
638
+ // answer: stopping here marks the utterance read and it never resumes.
639
+ if (this.reader.canPause && this.reader.pause()) this.voiceReadoutPaused = true;
640
+ void this.voicePanel.pause().catch(() => {});
641
+ return;
642
+ }
643
+ if (this.readoutActive) void this.readoutWake?.hold();
644
+ if (this.voiceReadoutPaused) { this.voiceReadoutPaused = false; this.reader.resume(); }
645
+ };
646
+ this.voicePageHideHandler = () => {
647
+ if (this.reader.canPause && this.reader.pause()) this.voiceReadoutPaused = true;
648
+ void this.voicePanel.pause().catch(() => {});
629
649
  };
630
- this.voiceOfflineHandler = () => { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); };
650
+ this.voicePageShowHandler = () => {
651
+ if (this.voiceReadoutPaused) { this.voiceReadoutPaused = false; this.reader.resume(); }
652
+ };
653
+ this.voiceOfflineHandler = () => { this.stopReadout(); void this.voicePanel.pause().catch(() => {}); };
631
654
  window.addEventListener("offline", this.voiceOfflineHandler);
632
655
  document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
656
+ window.addEventListener("pagehide", this.voicePageHideHandler);
657
+ window.addEventListener("pageshow", this.voicePageShowHandler);
633
658
  window.addEventListener("keydown", this.onVoiceShortcut, true);
634
659
  this.voiceClock = setInterval(() => {
635
660
  if (this.voicePhase === "recording") this.voiceElapsed += 1;
@@ -640,13 +665,16 @@ export default {
640
665
  this.restoreLog();
641
666
  },
642
667
  deactivated() {
643
- this.reader.stop();
668
+ this.stopReadout();
644
669
  void this.voicePanel?.pause().catch(() => {});
645
670
  this.captureLog();
646
671
  },
647
672
  beforeUnmount() {
648
673
  window.removeEventListener("offline", this.voiceOfflineHandler);
649
674
  document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
675
+ window.removeEventListener("pagehide", this.voicePageHideHandler);
676
+ window.removeEventListener("pageshow", this.voicePageShowHandler);
677
+ this.readoutWake?.release();
650
678
  window.removeEventListener("keydown", this.onVoiceShortcut, true);
651
679
  clearInterval(this.voiceClock);
652
680
  this.captureLog();
@@ -716,7 +744,7 @@ export default {
716
744
  }
717
745
  if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
718
746
  void this.voicePanel?.pause().catch(() => {});
719
- this.reader.stop();
747
+ this.stopReadout();
720
748
  this.player?.stop();
721
749
  }
722
750
  if (sessionChanged) this.voiceAdoptingSession = null;
@@ -733,9 +761,12 @@ export default {
733
761
  // A paused call is not speaking. Only an open call owns the reply;
734
762
  // leaving it set after the keyboard returns is how later answers stay silent.
735
763
  const voiceSpeaking = this.voiceCall?.connection === "open";
736
- if (voiceSpeaking) this.reader.stop();
764
+ if (voiceSpeaking) this.suspendReadout();
737
765
  const target = this.voiceSpeak && !voiceSpeaking && !document.hidden ? speakable(utterance, this.voiceStreaming) : null;
738
- 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();
739
770
  this.failed = snap.failed;
740
771
  this.error = snap.error;
741
772
  this.sessions = snap.sessions ?? this.sessions;
@@ -1276,7 +1307,7 @@ export default {
1276
1307
  },
1277
1308
  async onVoiceShortcut(event) {
1278
1309
  if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
1279
- 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; }
1280
1311
  const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
1281
1312
  if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
1282
1313
  event.preventDefault();
@@ -1326,7 +1357,7 @@ export default {
1326
1357
  const device = settings.config.device === "headset" ? "headset" : "device";
1327
1358
  onDevice(device);
1328
1359
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1329
- this.reader.stop(); this.player?.stop(); this.player?.unlock();
1360
+ this.suspendReadout(); this.player?.stop(); this.player?.unlock();
1330
1361
  if (config.transport !== "legacy") {
1331
1362
  return createUnifiedConversation({ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1332
1363
  device: captureSource("mobile", config.device), readAloud: continuous && config.speak, preset: config.preset, presentationSession: true,
@@ -1340,7 +1371,7 @@ export default {
1340
1371
  send: (value) => conversation.speak(value),
1341
1372
  play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1342
1373
  halt: () => this.player.stop(), silence: () => {},
1343
- onState: (playing) => conversation?.playbackState(playing),
1374
+ onState: (playing) => { conversation?.playbackState(playing); this.setReadoutAwake(playing); },
1344
1375
  onOutput: (value) => readoutState.publish(value?.status === "error" ? null : value),
1345
1376
  fail: (error) => { void conversation.controller.disconnect(error?.code || error?.message || "voice_playback_failed"); },
1346
1377
  });
@@ -1368,6 +1399,39 @@ export default {
1368
1399
  onError: (error) => { this.voiceError = error.message; },
1369
1400
  });
1370
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
+ },
1430
+ setReadoutAwake(active) {
1431
+ this.readoutActive = Boolean(active);
1432
+ if (this.readoutActive) void this.readoutWake?.hold();
1433
+ else this.readoutWake?.release();
1434
+ },
1371
1435
  showSpeakProblem(err) {
1372
1436
  this.speakNotice = speakProblemText(this.t, err);
1373
1437
  },
@@ -1384,7 +1448,7 @@ export default {
1384
1448
  this.voiceSpeak = next;
1385
1449
  this.speakBusy = true;
1386
1450
  if (!next) {
1387
- this.reader.stop();
1451
+ this.stopReadout();
1388
1452
  this.player?.stop();
1389
1453
  void this.runtime.interruptSpeech?.();
1390
1454
  }
@@ -1549,10 +1613,10 @@ export default {
1549
1613
  const text = this.draft.trim();
1550
1614
  if (!this.canSend) return;
1551
1615
  this.player?.unlock();
1552
- // A typed reply is the chat's. An open voice call would keep the reader
1553
- // 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();
1554
1619
  if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1555
- this.reader.stop();
1556
1620
  const sessionId = this.sessionId;
1557
1621
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1558
1622
  const wasRunning = this.running;
@@ -17,6 +17,10 @@
17
17
  <p v-if="view.errors.ttsModel" class="lares-tts__alert" role="alert">{{ t('settings.saveFailed') }}</p>
18
18
  <p v-if="!state.model" class="lares-tts__hint">{{ t('preferences.modelsMissing') }}</p>
19
19
  <p v-else-if="state.loading" class="lares-tts__hint" role="status">{{ t('tts.loading') }}</p>
20
+ <div v-if="state.stale" class="lares-tts__status" role="status">
21
+ <p>{{ t('tts.stale') }}</p>
22
+ <button type="button" @click="controller.load()">{{ t('dictation.retry') }}</button>
23
+ </div>
20
24
  <div v-else-if="state.error" class="lares-tts__status" role="alert">
21
25
  <p>{{ t('tts.loadFailed') }}</p>
22
26
  <button type="button" @click="controller.load()">{{ t('dictation.retry') }}</button>