@olares/lares-mobile 0.15.31 → 0.15.33

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.31",
3
+ "version": "0.15.33",
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.33",
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();
@@ -1322,20 +1327,27 @@ export default {
1322
1327
  const view = this.voicePanel.getSnapshot();
1323
1328
  if (view.busy) return;
1324
1329
  if (view.visible && view.mode === "single") { if (view.snapshot?.capture === "recording") await this.voicePanel.finish().catch(() => {}); return; }
1325
- await this.voicePanel.pause().catch(() => {});
1326
1330
  await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
1327
1331
  },
1328
- async onHoldStart() {
1332
+ onHoldStart() {
1329
1333
  this.voicePanel.arm();
1330
1334
  if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
1331
- await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
1332
- return;
1335
+ return this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
1333
1336
  }
1337
+ const settings = this.voiceSettings;
1338
+ if (!settings) return this.openVoiceFromSettings();
1339
+ this.voiceDefaultMode = settings.config.mobileMode;
1340
+ const available = laresVoiceAvailability(settings, !this.modelBlocked);
1341
+ if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
1342
+ return this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null, configSnapshot: settings.config });
1343
+ },
1344
+ async openVoiceFromSettings() {
1334
1345
  const settings = await this.runtime.settings.voice(false, { diagnostics: false });
1346
+ this.voiceSettings = markRaw(settings);
1335
1347
  this.voiceDefaultMode = settings.config.mobileMode;
1336
1348
  const available = laresVoiceAvailability(settings, !this.modelBlocked);
1337
1349
  if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
1338
- await this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null });
1350
+ return this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null, configSnapshot: settings.config });
1339
1351
  },
1340
1352
  onVoiceAction({ action, value }) {
1341
1353
  if (action === "start" || action === "end" || action === "close") this.speakNotice = "";
@@ -1345,19 +1357,19 @@ export default {
1345
1357
  if (["holdStart", "holdEnd", "finish", "start", "close", "end"].includes(action)) void Promise.resolve(this.voicePanel[action](value)).catch(() => {});
1346
1358
  },
1347
1359
  runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1348
- async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
1360
+ async createMobileVoiceConversation({ mode, onTranscript, onDevice, config, preparation }) {
1349
1361
  config = laresVoiceConfig(config, mode);
1350
1362
  const settings = { config };
1351
1363
  const continuous = mode === "continuous";
1352
1364
  const device = settings.config.device === "headset" ? "headset" : "device";
1353
1365
  onDevice(device);
1354
1366
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1355
- this.reader.stop(); this.player?.stop(); this.player?.unlock();
1367
+ this.suspendReadout(); this.player?.stop(); this.player?.unlock();
1356
1368
  if (config.transport !== "legacy") {
1357
1369
  return createUnifiedConversation({ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1358
1370
  device: captureSource("mobile", config.device), readAloud: continuous && config.speak, preset: config.preset, presentationSession: true,
1359
1371
  endpointSilenceMs: config.endpointSilenceMs, idleTimeoutMs: config.idleTimeoutMs, recoveryTimeoutMs: config.recoveryTimeoutMs,
1360
- onTranscript: continuous ? null : onTranscript, mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1372
+ onTranscript: continuous ? null : onTranscript, mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter), preparation,
1361
1373
  transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model) });
1362
1374
  }
1363
1375
  let conversation;
@@ -1379,7 +1391,7 @@ export default {
1379
1391
  readAloud: continuous && settings.config.speak,
1380
1392
  preset: settings.config.preset, backend: settings.config.backend, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1381
1393
  presentationSession: true,
1382
- onTranscript: continuous ? null : onTranscript,
1394
+ onTranscript: continuous ? null : onTranscript, preparation,
1383
1395
  mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1384
1396
  playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
1385
1397
  onReply: (reply) => { const value = speakable(reply, config.streaming === true); if (value) reader.follow(value); },
@@ -1394,6 +1406,34 @@ export default {
1394
1406
  onError: (error) => { this.voiceError = error.message; },
1395
1407
  });
1396
1408
  },
1409
+ followReadout(value) {
1410
+ if (!value) return;
1411
+ this.lastReadout = value;
1412
+ this.reader.follow(value);
1413
+ },
1414
+ suspendReadout() {
1415
+ const key = this.reader?.key;
1416
+ if (!key) return;
1417
+ if (this.lastReadout?.key === key) {
1418
+ this.voiceSuspended = this.lastReadout;
1419
+ this.voiceSuspendedItems = this.items?.length ?? 0;
1420
+ }
1421
+ this.reader.suspend?.();
1422
+ },
1423
+ resumeSuspendedReadout() {
1424
+ const suspended = this.voiceSuspended;
1425
+ if (!suspended) return;
1426
+ this.voiceSuspended = null;
1427
+ const taken = (this.items?.length ?? 0) > this.voiceSuspendedItems;
1428
+ if (taken) this.reader.consume(suspended.key);
1429
+ else if (suspended.sessionId === this.sessionId && this.voiceSpeak && !document.hidden) this.followReadout(suspended);
1430
+ },
1431
+ stopReadout() {
1432
+ this.voiceSuspended = null;
1433
+ this.voiceSuspendedItems = 0;
1434
+ this.lastReadout = null;
1435
+ this.reader.stop();
1436
+ },
1397
1437
  setReadoutAwake(active) {
1398
1438
  this.readoutActive = Boolean(active);
1399
1439
  if (this.readoutActive) void this.readoutWake?.hold();
@@ -1415,7 +1455,7 @@ export default {
1415
1455
  this.voiceSpeak = next;
1416
1456
  this.speakBusy = true;
1417
1457
  if (!next) {
1418
- this.reader.stop();
1458
+ this.stopReadout();
1419
1459
  this.player?.stop();
1420
1460
  void this.runtime.interruptSpeech?.();
1421
1461
  }
@@ -1580,10 +1620,10 @@ export default {
1580
1620
  const text = this.draft.trim();
1581
1621
  if (!this.canSend) return;
1582
1622
  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.
1623
+ // A typed reply is the chat's. Stop the old readout before pausing the
1624
+ // call, so the pause cannot hand a suspended answer back for a moment.
1625
+ this.stopReadout();
1585
1626
  if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1586
- this.reader.stop();
1587
1627
  const sessionId = this.sessionId;
1588
1628
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1589
1629
  const wasRunning = this.running;
@@ -9,6 +9,9 @@
9
9
  <p>{{ t("settings.readAloudSyncFailed") }}</p>
10
10
  <button type="button" @click="$emit('voice-action', { action: 'retryReadAloud' })">{{ t("dictation.retry") }}</button>
11
11
  </div>
12
+ <div v-else-if="voiceError && !voiceView?.inputActive" class="lares-composer__alert" role="alert">
13
+ <p>{{ voiceError }}</p>
14
+ </div>
12
15
  <div
13
16
  class="lares-composer__card"
14
17
  data-composer-card
@@ -93,10 +96,6 @@
93
96
  </button>
94
97
  </figure>
95
98
  </div>
96
- <div v-if="playbackChrome" class="lares-composer__playback" role="status">
97
- <span>{{ t(playbackChrome.status) }}</span>
98
- <button type="button" @click="stopPlayback">{{ t(playbackChrome.stopLabel) }}</button>
99
- </div>
100
99
  <div class="lares-composer__line" :data-expanded="expanded ? 'true' : 'false'">
101
100
  <button
102
101
  type="button"
@@ -174,7 +173,7 @@
174
173
 
175
174
  <script>
176
175
  import { messageFromCode } from "@olares/lares-core/i18n/t";
177
- import { voiceCallFailure, voicePlaybackChrome } from "@olares/lares-core/voice/presentation";
176
+ import { voiceCallFailure } from "@olares/lares-core/voice/presentation";
178
177
  import LaresVoiceInput from "./VoiceInput.vue";
179
178
  import {
180
179
  REFERENCE_DEBOUNCE_MS,
@@ -256,9 +255,6 @@ export default {
256
255
  callFailure() {
257
256
  return this.voiceView?.inputActive ? voiceCallFailure(this.voiceView) : null;
258
257
  },
259
- playbackChrome() {
260
- return voicePlaybackChrome(this.voiceView);
261
- },
262
258
  callFailureText() {
263
259
  return this.callFailure ? messageFromCode(this.t, this.callFailure.code, "error.voice_failed") : "";
264
260
  },
@@ -291,9 +287,6 @@ export default {
291
287
  window.removeEventListener("pointerdown", this.onTokenPointer, true);
292
288
  },
293
289
  methods: {
294
- stopPlayback() {
295
- this.$emit("voice-action", { action: "stopPlayback" });
296
- },
297
290
  onInput(event) {
298
291
  const value = event.target.value;
299
292
  this.$emit("update:draft", value);
@@ -713,34 +706,6 @@ export default {
713
706
  stroke-linecap: round;
714
707
  }
715
708
 
716
- .lares-composer__playback {
717
- display: flex;
718
- align-items: center;
719
- justify-content: space-between;
720
- gap: 12px;
721
- padding: 4px 4px 8px;
722
- color: var(--q-ink-2);
723
- font-size: 13px;
724
- line-height: 20px;
725
- }
726
-
727
- .lares-composer__playback button {
728
- flex: none;
729
- min-height: 32px;
730
- padding: 4px 12px;
731
- border: 1px solid var(--q-separator);
732
- border-radius: 999px;
733
- background: transparent;
734
- color: var(--q-ink-1);
735
- font: inherit;
736
- font-size: 13px;
737
- }
738
-
739
- .lares-composer__playback button:focus-visible {
740
- outline: 2px solid var(--q-blue-default, #3377ff);
741
- outline-offset: 2px;
742
- }
743
-
744
709
  .lares-composer__line {
745
710
  display: flex;
746
711
  width: 100%;
@@ -81,7 +81,10 @@
81
81
  <span>{{ speechLabel }}</span>
82
82
  <button type="button" class="lares-message-stop" @click="readout.stop()">{{ t(readout.status === 'error' ? 'readout.dismiss' : 'readout.stop') }}</button>
83
83
  </div>
84
- <button v-if="!row.pending && !readout && isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
84
+ <div v-else-if="ownsPlayback(row)" role="status" class="lares-message-readout">
85
+ <span>{{ t(playbackChrome.status) }}</span>
86
+ <button type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t(playbackChrome.stopLabel) }}</button>
87
+ </div>
85
88
  <LaresMessageActions
86
89
  v-if="!row.pending"
87
90
  :text="row.text"
@@ -111,6 +114,7 @@
111
114
 
112
115
  <script>
113
116
  import { readoutState } from "@olares/lares-core/voice/speech";
117
+ import { voicePlaybackChrome } from "@olares/lares-core/voice/presentation";
114
118
  import { partitionPreviews } from "@olares/lares-core/files/preview-groups";
115
119
  import { parseAskUserQuestions } from "@olares/lares-core/larepass/questions";
116
120
  import { hidesRunningClock, processFoldLabel, transcriptBlocks } from "@olares/lares-core/larepass/process-fold";
@@ -159,6 +163,7 @@ export default {
159
163
  },
160
164
  computed: {
161
165
  speechLabel() { return this.t(`readout.${this.readout?.status}`); },
166
+ playbackChrome() { return voicePlaybackChrome(this.voiceView); },
162
167
  runningText() {
163
168
  return runningLabel(this.runningSince, this.t, this.now);
164
169
  },
@@ -197,11 +202,12 @@ export default {
197
202
  const latest = this.items.findLast(item => item.type === "assistant" && item.turn === this.readout.turn);
198
203
  return latest && (row.id || row.key) === (latest.id || latest.key);
199
204
  },
200
- isSpeaking(row) {
201
- const snapshot = this.voiceView?.snapshot;
202
- if (snapshot?.playback !== "playing") return false;
203
- const latest = this.items.findLast(item => item.type === "assistant" && (item.voiceId || item.voicePlayback?.voiceId) === snapshot.voiceId);
204
- return latest && (row.id || row.key) === (latest.id || latest.key);
205
+ ownsPlayback(row) {
206
+ if (!this.playbackChrome || row.type !== "assistant") return false;
207
+ const voiceId = this.voiceView?.snapshot?.voiceId;
208
+ const matched = voiceId && this.items.findLast((item) => item.type === "assistant" && (item.voiceId || item.voicePlayback?.voiceId) === voiceId);
209
+ const target = matched || this.items.findLast((item) => item.type === "assistant");
210
+ return Boolean(target) && (row.id || row.key) === (target.id || target.key);
205
211
  },
206
212
  syncClock() {
207
213
  clearInterval(this.clock);