@olares/lares-mobile 0.15.22 → 0.15.24

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.22",
3
+ "version": "0.15.24",
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.22",
31
+ "@olares/lares-core": "0.15.24",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -101,6 +101,7 @@
101
101
  @open-references="loadReferences"
102
102
  @pick-command="pickCommand"
103
103
  @toggle-speak="toggleSpeak"
104
+ @dismiss-speak="speakNotice = ''"
104
105
  />
105
106
  <LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
106
107
  @close="runVoicePanel('close')" @edit="voicePanel.edit($event)" @preset="voicePanel.selectPreset($event, true).catch(() => {})"
@@ -124,7 +125,7 @@ import { captureSource } from "@olares/lares-core/voice/input";
124
125
  import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
125
126
  import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
126
127
  import { createAnswerPlayer } from "./chat/playback.js";
127
- import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
128
+ import { createReplySpeaker, createSentencePlayer, readoutState, speakable } from "@olares/lares-core/voice/speech";
128
129
  import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
129
130
  import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
130
131
  import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
@@ -558,6 +559,7 @@ export default {
558
559
  this.imageUploadControllers = new Map();
559
560
  this.player = createAnswerPlayer();
560
561
  this.voicePanel = createVoicePanel({
562
+ armOutput: () => this.player?.unlock(),
561
563
  create: (options) => this.createMobileVoiceConversation(options),
562
564
  prepareInput: (config) => config?.device === "headset" ? null : prepareBrowserCapture(),
563
565
  loadConfig: async () => (await this.runtime.settings.voice(false, { diagnostics: false })).config,
@@ -1275,6 +1277,7 @@ export default {
1275
1277
  await this.openDictation();
1276
1278
  },
1277
1279
  async openDictation(preset) {
1280
+ this.voicePanel.arm();
1278
1281
  if (document.hidden || navigator.onLine === false) return;
1279
1282
  if (!this.voiceSettings) {
1280
1283
  try { this.voiceSettings = markRaw(await this.runtime.settings.voice(false, { diagnostics: false })); }
@@ -1288,6 +1291,7 @@ export default {
1288
1291
  await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
1289
1292
  },
1290
1293
  async onHoldStart() {
1294
+ this.voicePanel.arm();
1291
1295
  if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
1292
1296
  await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
1293
1297
  return;
@@ -1299,10 +1303,11 @@ export default {
1299
1303
  await this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null });
1300
1304
  },
1301
1305
  onVoiceAction({ action, value }) {
1306
+ if (action === "start" || action === "end" || action === "close") this.speakNotice = "";
1302
1307
  if (action === "retryReadAloud") { void this.voicePanel.retryReadAloud(); return; }
1303
1308
  if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
1304
1309
  if (action === "setCancelArmed") { this.voicePanel.setCancelArmed(value); return; }
1305
- 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(() => {});
1306
1311
  },
1307
1312
  runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1308
1313
  async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
@@ -1312,7 +1317,7 @@ export default {
1312
1317
  const device = settings.config.device === "headset" ? "headset" : "device";
1313
1318
  onDevice(device);
1314
1319
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1315
- this.reader.stop(); this.player?.stop(); this.player?.unlock();
1320
+ this.reader.stop(); this.player?.stop();
1316
1321
  let conversation;
1317
1322
  const reader = createSentencePlayer({
1318
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") }),
@@ -1320,7 +1325,8 @@ export default {
1320
1325
  play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1321
1326
  halt: () => this.player.stop(), silence: () => {},
1322
1327
  onState: (playing) => conversation?.playbackState(playing),
1323
- 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"); },
1324
1330
  });
1325
1331
  conversation = createVoiceConversation({
1326
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") }),
@@ -1350,6 +1356,7 @@ export default {
1350
1356
  this.speakNotice = speakProblemText(this.t, err);
1351
1357
  },
1352
1358
  async toggleSpeak() {
1359
+ this.player?.unlock();
1353
1360
  if (this.speakBusy) return;
1354
1361
  this.speakNotice = "";
1355
1362
  if (typeof this.runtime.settings?.saveVoice !== "function") {
@@ -1525,9 +1532,13 @@ export default {
1525
1532
  async send(mode = "queue") {
1526
1533
  const text = this.draft.trim();
1527
1534
  if (!this.canSend) return;
1528
- this.reader.stop();
1529
- if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1530
1535
  this.player?.unlock();
1536
+ const continuous = this.voicePanelState?.mode === "continuous";
1537
+ if (this.voiceSpeak && continuous) {
1538
+ try { await this.voicePanel.openReadout(); }
1539
+ catch (error) { this.voiceError = error?.code || error?.message || "voice_failed"; }
1540
+ } else if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1541
+ this.reader.stop();
1531
1542
  const sessionId = this.sessionId;
1532
1543
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1533
1544
  const wasRunning = this.running;
@@ -1,6 +1,11 @@
1
1
  <template>
2
2
  <form class="lares-composer" :class="{ 'lares-composer--hero': hero }" @submit.prevent="onSend">
3
- <div v-if="voiceView?.readAloudError" class="lares-composer__alert" role="alert">
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">
4
9
  <p>{{ t("settings.readAloudSyncFailed") }}</p>
5
10
  <button type="button" @click="$emit('voice-action', { action: 'retryReadAloud' })">{{ t("dictation.retry") }}</button>
6
11
  </div>
@@ -164,6 +169,8 @@
164
169
  </template>
165
170
 
166
171
  <script>
172
+ import { messageFromCode } from "@olares/lares-core/i18n/t";
173
+ import { voiceCallFailure } from "@olares/lares-core/voice/presentation";
167
174
  import LaresVoiceInput from "./VoiceInput.vue";
168
175
  import {
169
176
  REFERENCE_DEBOUNCE_MS,
@@ -242,6 +249,12 @@ export default {
242
249
  overlayPointerActive() {
243
250
  return this.referenceOpen || this.commandsOpen;
244
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
+ },
245
258
  },
246
259
  watch: {
247
260
  draft(value) {
@@ -453,7 +466,8 @@ export default {
453
466
  .lares-composer__alert {
454
467
  display: flex;
455
468
  align-items: center;
456
- gap: 12px;
469
+ flex-wrap: wrap;
470
+ gap: 8px 12px;
457
471
  padding: 8px 12px;
458
472
  border-radius: 12px;
459
473
  background: var(--q-background-1);
@@ -480,6 +494,11 @@ export default {
480
494
  font-weight: 600;
481
495
  }
482
496
 
497
+ .lares-composer__alert button.lares-composer__alert-quiet {
498
+ background: transparent;
499
+ color: var(--q-ink-2);
500
+ }
501
+
483
502
  .lares-composer__card {
484
503
  position: relative;
485
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(() => {});
@@ -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;