@olares/lares-mobile 0.15.21 → 0.15.23

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.21",
3
+ "version": "0.15.23",
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.21",
31
+ "@olares/lares-core": "0.15.23",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -101,11 +101,8 @@
101
101
  @open-references="loadReferences"
102
102
  @pick-command="pickCommand"
103
103
  @toggle-speak="toggleSpeak"
104
+ @dismiss-speak="speakNotice = ''"
104
105
  />
105
- <div v-if="voicePanelState?.readAloudError" class="lares-speak-notice" role="alert">
106
- {{ voiceReadAloudError }}
107
- <button type="button" @click="voicePanel.retryReadAloud()">{{ t('dictation.retry') }}</button>
108
- </div>
109
106
  <LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
110
107
  @close="runVoicePanel('close')" @edit="voicePanel.edit($event)" @preset="voicePanel.selectPreset($event, true).catch(() => {})"
111
108
  @complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" />
@@ -128,7 +125,7 @@ import { captureSource } from "@olares/lares-core/voice/input";
128
125
  import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
129
126
  import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
130
127
  import { createAnswerPlayer } from "./chat/playback.js";
131
- import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
128
+ import { createReplySpeaker, createSentencePlayer, readoutState, speakable } from "@olares/lares-core/voice/speech";
132
129
  import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
133
130
  import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
134
131
  import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
@@ -534,7 +531,6 @@ export default {
534
531
  modelBlocked() {
535
532
  return !modelState(this.modelStatus).valid;
536
533
  },
537
- voiceReadAloudError() { return this.t("settings.readAloudSyncFailed"); },
538
534
  voiceErrorText() {
539
535
  return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
540
536
  },
@@ -563,6 +559,7 @@ export default {
563
559
  this.imageUploadControllers = new Map();
564
560
  this.player = createAnswerPlayer();
565
561
  this.voicePanel = createVoicePanel({
562
+ armOutput: () => this.player?.unlock(),
566
563
  create: (options) => this.createMobileVoiceConversation(options),
567
564
  prepareInput: (config) => config?.device === "headset" ? null : prepareBrowserCapture(),
568
565
  loadConfig: async () => (await this.runtime.settings.voice(false, { diagnostics: false })).config,
@@ -1280,6 +1277,7 @@ export default {
1280
1277
  await this.openDictation();
1281
1278
  },
1282
1279
  async openDictation(preset) {
1280
+ this.voicePanel.arm();
1283
1281
  if (document.hidden || navigator.onLine === false) return;
1284
1282
  if (!this.voiceSettings) {
1285
1283
  try { this.voiceSettings = markRaw(await this.runtime.settings.voice(false, { diagnostics: false })); }
@@ -1293,6 +1291,7 @@ export default {
1293
1291
  await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
1294
1292
  },
1295
1293
  async onHoldStart() {
1294
+ this.voicePanel.arm();
1296
1295
  if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
1297
1296
  await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
1298
1297
  return;
@@ -1304,9 +1303,11 @@ export default {
1304
1303
  await this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null });
1305
1304
  },
1306
1305
  onVoiceAction({ action, value }) {
1306
+ if (action === "start" || action === "end" || action === "close") this.speakNotice = "";
1307
+ if (action === "retryReadAloud") { void this.voicePanel.retryReadAloud(); return; }
1307
1308
  if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
1308
1309
  if (action === "setCancelArmed") { this.voicePanel.setCancelArmed(value); return; }
1309
- if (["holdStart", "holdEnd", "finish", "start", "close"].includes(action)) void Promise.resolve(this.voicePanel[action](value)).catch(() => {});
1310
+ if (["holdStart", "holdEnd", "finish", "start", "close", "end"].includes(action)) void Promise.resolve(this.voicePanel[action](value)).catch(() => {});
1310
1311
  },
1311
1312
  runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1312
1313
  async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
@@ -1316,7 +1317,7 @@ export default {
1316
1317
  const device = settings.config.device === "headset" ? "headset" : "device";
1317
1318
  onDevice(device);
1318
1319
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1319
- this.reader.stop(); this.player?.stop(); this.player?.unlock();
1320
+ this.reader.stop(); this.player?.stop();
1320
1321
  let conversation;
1321
1322
  const reader = createSentencePlayer({
1322
1323
  stream: (value) => playAudioStream({ ...value, request: (path, options) => this.runtime.voiceRequest(path, options), headers: conversation.headers, socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }),
@@ -1324,7 +1325,8 @@ export default {
1324
1325
  play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1325
1326
  halt: () => this.player.stop(), silence: () => {},
1326
1327
  onState: (playing) => conversation?.playbackState(playing),
1327
- fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
1328
+ onOutput: (value) => readoutState.publish(value?.status === "error" ? null : value),
1329
+ fail: (error) => { void conversation.controller.disconnect(error?.code || error?.message || "voice_playback_failed"); },
1328
1330
  });
1329
1331
  conversation = createVoiceConversation({
1330
1332
  streamInput: (stream, callbacks) => createStreamingCapture(stream, { ...callbacks, request: (path, options) => this.runtime.voiceRequest(path, options), headers: conversation.headers, model: config.model, language: config.language, socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }),
@@ -1354,6 +1356,7 @@ export default {
1354
1356
  this.speakNotice = speakProblemText(this.t, err);
1355
1357
  },
1356
1358
  async toggleSpeak() {
1359
+ this.player?.unlock();
1357
1360
  if (this.speakBusy) return;
1358
1361
  this.speakNotice = "";
1359
1362
  if (typeof this.runtime.settings?.saveVoice !== "function") {
@@ -1529,9 +1532,9 @@ export default {
1529
1532
  async send(mode = "queue") {
1530
1533
  const text = this.draft.trim();
1531
1534
  if (!this.canSend) return;
1535
+ this.player?.unlock();
1532
1536
  this.reader.stop();
1533
1537
  if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1534
- this.player?.unlock();
1535
1538
  const sessionId = this.sessionId;
1536
1539
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1537
1540
  const wasRunning = this.running;
@@ -1,5 +1,14 @@
1
1
  <template>
2
2
  <form class="lares-composer" :class="{ 'lares-composer--hero': hero }" @submit.prevent="onSend">
3
+ <div v-if="callFailure" class="lares-composer__alert" role="alert">
4
+ <p>{{ callFailureText }}</p>
5
+ <button type="button" :disabled="voiceView.busy" @click="$emit('voice-action', { action: 'start' })">{{ t(callFailure.action === 'resume' ? 'call.resume' : 'call.restart') }}</button>
6
+ <button type="button" class="lares-composer__alert-quiet" :disabled="voiceView.busy" @click="$emit('voice-action', { action: 'end' })">{{ t('panel.close') }}</button>
7
+ </div>
8
+ <div v-else-if="voiceView?.readAloudError" class="lares-composer__alert" role="alert">
9
+ <p>{{ t("settings.readAloudSyncFailed") }}</p>
10
+ <button type="button" @click="$emit('voice-action', { action: 'retryReadAloud' })">{{ t("dictation.retry") }}</button>
11
+ </div>
3
12
  <div
4
13
  class="lares-composer__card"
5
14
  data-composer-card
@@ -160,6 +169,8 @@
160
169
  </template>
161
170
 
162
171
  <script>
172
+ import { messageFromCode } from "@olares/lares-core/i18n/t";
173
+ import { voiceCallFailure } from "@olares/lares-core/voice/presentation";
163
174
  import LaresVoiceInput from "./VoiceInput.vue";
164
175
  import {
165
176
  REFERENCE_DEBOUNCE_MS,
@@ -238,6 +249,12 @@ export default {
238
249
  overlayPointerActive() {
239
250
  return this.referenceOpen || this.commandsOpen;
240
251
  },
252
+ callFailure() {
253
+ return this.voiceView?.inputActive ? voiceCallFailure(this.voiceView) : null;
254
+ },
255
+ callFailureText() {
256
+ return this.callFailure ? messageFromCode(this.t, this.callFailure.code, "error.voice_failed") : "";
257
+ },
241
258
  },
242
259
  watch: {
243
260
  draft(value) {
@@ -446,6 +463,42 @@ export default {
446
463
  padding: 0;
447
464
  }
448
465
 
466
+ .lares-composer__alert {
467
+ display: flex;
468
+ align-items: center;
469
+ flex-wrap: wrap;
470
+ gap: 8px 12px;
471
+ padding: 8px 12px;
472
+ border-radius: 12px;
473
+ background: var(--q-background-1);
474
+ color: var(--q-orange-default);
475
+ font-size: 13px;
476
+ line-height: 18px;
477
+ }
478
+
479
+ .lares-composer__alert p {
480
+ flex: 1;
481
+ min-width: 0;
482
+ margin: 0;
483
+ }
484
+
485
+ .lares-composer__alert button {
486
+ flex: none;
487
+ border: 0;
488
+ border-radius: 8px;
489
+ padding: 6px 10px;
490
+ background: var(--q-blue-alpha);
491
+ color: var(--q-blue-default);
492
+ font: inherit;
493
+ font-size: 13px;
494
+ font-weight: 600;
495
+ }
496
+
497
+ .lares-composer__alert button.lares-composer__alert-quiet {
498
+ background: transparent;
499
+ color: var(--q-ink-2);
500
+ }
501
+
449
502
  .lares-composer__card {
450
503
  position: relative;
451
504
  display: flex;
@@ -6,7 +6,7 @@
6
6
  <svg v-if="view.mode === 'single' && ui.recording && levels.length" class="lares-inline-level" viewBox="0 0 32 24" aria-hidden="true"><line v-for="(level, index) in levels" :key="index" :x1="index * 5 + 3" :x2="index * 5 + 3" :y1="12 - Math.max(2, Math.min(10, level * 500))" :y2="12 + Math.max(2, Math.min(10, level * 500))" /></svg>
7
7
  <span>{{ t(ui.label) }}</span><span v-if="ui.timed" class="lares-inline-time" aria-hidden="true"> · {{ ui.elapsed }}</span>
8
8
  </button>
9
- <span class="lares-inline-status" :data-error="Boolean(ui.error)" :data-visible="['speaking', 'working'].includes(ui.phase)" role="status" aria-live="polite" aria-atomic="true">{{ status }}</span>
9
+ <span v-if="!ui.error" class="lares-inline-status" :data-visible="['speaking', 'working'].includes(ui.phase)" role="status" aria-live="polite" aria-atomic="true">{{ status }}</span>
10
10
  </div>
11
11
  </template>
12
12
  <script>
@@ -45,6 +45,5 @@ export default {
45
45
  [data-phase=recording] .lares-inline-main { color: var(--q-blue-default, #3377ff); }
46
46
  [data-phase=cancel] .lares-inline-main, .lares-inline-cancel[data-armed=true] { color: var(--q-red-default, #c23d32); }
47
47
  .lares-inline-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
48
- .lares-inline-status[data-visible=true], .lares-inline-status[data-error=true] { width: auto; height: auto; clip-path: none; bottom: calc(100% + 16px); background: var(--q-background-1, white); padding: 6px; font-size: 12px; color: var(--q-ink-2, #777); }
49
- .lares-inline-status[data-error=true] { color: var(--q-red-default, #c23d32); }
48
+ .lares-inline-status[data-visible=true] { width: auto; height: auto; clip-path: none; bottom: calc(100% + 16px); background: var(--q-background-1, white); padding: 6px; font-size: 12px; color: var(--q-ink-2, #777); }
50
49
  </style>
@@ -1,3 +1,5 @@
1
+ import { primePlaybackContext } from "@olares/lares-core/voice/pcm-player";
2
+
1
3
  const SILENT_WAV = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=";
2
4
 
3
5
  /**
@@ -20,6 +22,7 @@ export function createAnswerPlayer() {
20
22
  };
21
23
  return {
22
24
  unlock() {
25
+ primePlaybackContext();
23
26
  if (!audio || !audio.paused) return;
24
27
  audio.src = SILENT_WAV;
25
28
  void audio.play().catch(() => {});
@@ -133,6 +133,10 @@
133
133
  :t="t"
134
134
  @action="(itemId, action) => $emit('queue-action', itemId, action)"
135
135
  />
136
+ <div v-if="state.voicePanelState?.readAloudError" class="desktop-composer__alert" role="alert">
137
+ <p>{{ t("settings.readAloudSyncFailed") }}</p>
138
+ <button type="button" @click="$emit('voice-action', { action: 'retryReadAloud' })">{{ t("dictation.retry") }}</button>
139
+ </div>
136
140
 
137
141
  <Transition name="lares-panel" mode="out-in">
138
142
  <div v-if="state.approval" key="approval" class="desktop-composer__approval">
@@ -910,6 +914,9 @@ export default {
910
914
  margin: 0 auto;
911
915
  padding: 8px 0 20px;
912
916
  }
917
+ .desktop-composer__alert { display:flex; align-items:center; gap:12px; margin:0 0 8px; padding:8px 12px; border-radius:12px; background:var(--q-background-1); color:var(--q-orange-default); font-size:13px; line-height:18px; }
918
+ .desktop-composer__alert p { flex:1; min-width:0; margin:0; }
919
+ .desktop-composer__alert button { flex:none; border:0; border-radius:8px; padding:6px 10px; background:var(--q-blue-alpha); color:var(--q-blue-default); font:inherit; font-size:13px; font-weight:600; cursor:pointer; }
913
920
  .desktop-composer__model-row { display:flex; justify-content:flex-end; gap:2px; min-height:28px; margin-bottom:6px; }
914
921
  .desktop-composer[data-hero="true"] .desktop-composer__model-row { justify-content:space-between; }
915
922
  .desktop-composer__model,
@@ -15,7 +15,10 @@
15
15
  @effort="$emit('open-model')"
16
16
  @speak="$emit('toggle-speak')"
17
17
  />
18
- <p v-if="state.speakNotice" class="lares-speak-notice" role="status">{{ state.speakNotice }}</p>
18
+ <p v-if="state.speakNotice" class="lares-speak-notice" role="status">
19
+ <span>{{ state.speakNotice }}</span>
20
+ <button type="button" @click="$emit('dismiss-speak')">{{ t('readout.dismiss') }}</button>
21
+ </p>
19
22
  <LaresHistoryPanel
20
23
  :open="state.panel === 'history'"
21
24
  :sessions="state.sessions"
@@ -268,6 +271,7 @@ export default {
268
271
  "close-directory", "choose-directory", "create-folder", "toggle-hidden",
269
272
  "rename-session", "archive-session",
270
273
  "move-session",
274
+ "dismiss-speak",
271
275
  ],
272
276
  data() {
273
277
  return { composerMode: "text" };
@@ -340,12 +344,35 @@ export default {
340
344
  }
341
345
 
342
346
  .lares-speak-notice {
347
+ display: flex;
348
+ align-items: center;
349
+ gap: 12px;
343
350
  margin: 0 16px 8px;
351
+ padding: 8px 12px;
352
+ border-radius: 12px;
353
+ background: var(--q-orange-alpha);
344
354
  color: var(--q-orange-default);
345
355
  font-size: 14px;
346
356
  line-height: 20px;
347
357
  }
348
358
 
359
+ .lares-speak-notice span {
360
+ flex: 1;
361
+ min-width: 0;
362
+ }
363
+
364
+ .lares-speak-notice button {
365
+ flex: none;
366
+ border: 0;
367
+ border-radius: 8px;
368
+ padding: 6px 10px;
369
+ background: transparent;
370
+ color: var(--q-ink-2);
371
+ font: inherit;
372
+ font-size: 13px;
373
+ font-weight: 600;
374
+ }
375
+
349
376
  .lares-shell__retry {
350
377
  align-self: flex-start;
351
378
  margin: 0 20px;