@olares/lares-mobile 0.15.24 → 0.15.26

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.24",
3
+ "version": "0.15.26",
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.24",
31
+ "@olares/lares-core": "0.15.26",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -124,6 +124,7 @@ import { failText, withPendingUser } from "./chat/format.js";
124
124
  import { captureSource } from "@olares/lares-core/voice/input";
125
125
  import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
126
126
  import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
127
+ import { createUnifiedConversation } from "@olares/lares-core/voice/unified-conversation";
127
128
  import { createAnswerPlayer } from "./chat/playback.js";
128
129
  import { createReplySpeaker, createSentencePlayer, readoutState, speakable } from "@olares/lares-core/voice/speech";
129
130
  import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
@@ -213,6 +214,7 @@ export default {
213
214
  draftBySession: {},
214
215
  imagesBySession: {},
215
216
  failed: "",
217
+ actionNotice: "",
216
218
  error: "",
217
219
  items: [],
218
220
  pendingUser: "",
@@ -325,6 +327,7 @@ export default {
325
327
  draft: this.draft,
326
328
  failed: this.failed,
327
329
  failText: this.failText,
330
+ actionNotice: this.actionNotice,
328
331
  viewItems: this.viewItems,
329
332
  historyLoading: this.historyLoading,
330
333
  previews: this.previews,
@@ -577,6 +580,7 @@ export default {
577
580
  });
578
581
  this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
579
582
  this.voiceDefaultMode = value.config.mobileMode; this.voiceSettings = markRaw(value);
583
+ this.voiceStreaming = value.config.streaming === true;
580
584
  const next = value.config.speak === true;
581
585
  if (this.voiceSpeak && !next) this.reader?.stop();
582
586
  this.voiceSpeak = next;
@@ -704,6 +708,7 @@ export default {
704
708
  this.noteSeen(this.sessionId);
705
709
  this.noteSeen(snap.sessionId);
706
710
  this.pendingUser = "";
711
+ this.actionNotice = "";
707
712
  this.previews = {};
708
713
  this.resetPreview();
709
714
  this.commands = null;
@@ -726,7 +731,7 @@ export default {
726
731
  this.running = snap.running;
727
732
  this.runningSince = snap.runningSince || 0;
728
733
  if (this.voiceCall) this.reader.stop();
729
- const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, true) : null;
734
+ const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, this.voiceStreaming) : null;
730
735
  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}` });
731
736
  this.failed = snap.failed;
732
737
  this.error = snap.error;
@@ -1037,6 +1042,7 @@ export default {
1037
1042
  this.resetPreview();
1038
1043
  this.pendingUser = "";
1039
1044
  this.failed = "";
1045
+ this.actionNotice = "";
1040
1046
  this.error = "";
1041
1047
  this.modelCatalog = null;
1042
1048
  this.modelCatalogError = "";
@@ -1317,7 +1323,13 @@ export default {
1317
1323
  const device = settings.config.device === "headset" ? "headset" : "device";
1318
1324
  onDevice(device);
1319
1325
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1320
- this.reader.stop(); this.player?.stop();
1326
+ this.reader.stop(); this.player?.stop(); this.player?.unlock();
1327
+ if (config.transport !== "legacy") {
1328
+ return createUnifiedConversation({ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1329
+ device: captureSource("mobile", config.device), readAloud: continuous && config.speak, preset: config.preset, presentationSession: true,
1330
+ endpointSilenceMs: config.endpointSilenceMs, idleTimeoutMs: config.idleTimeoutMs, recoveryTimeoutMs: config.recoveryTimeoutMs,
1331
+ onTranscript: continuous ? null : onTranscript, mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter) });
1332
+ }
1321
1333
  let conversation;
1322
1334
  const reader = createSentencePlayer({
1323
1335
  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") }),
@@ -1340,7 +1352,7 @@ export default {
1340
1352
  onTranscript: continuous ? null : onTranscript,
1341
1353
  mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1342
1354
  playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
1343
- onReply: (reply) => { const value = speakable(reply, device === "device"); if (value) reader.follow(value); },
1355
+ onReply: (reply) => { const value = speakable(reply, config.streaming === true); if (value) reader.follow(value); },
1344
1356
  });
1345
1357
  return conversation;
1346
1358
  },
@@ -1584,10 +1596,12 @@ export default {
1584
1596
  async updateQueue(itemId, action) {
1585
1597
  if (!itemId || this.queueBusy) return;
1586
1598
  this.queueBusy = itemId;
1599
+ this.actionNotice = "";
1587
1600
  try {
1588
1601
  const changed = await this.runtime.updateQueue(itemId, action);
1589
1602
  if (!changed.ok) {
1590
- this.failed = changed.error?.message || changed.error?.code || "queue";
1603
+ const detail = changed.error?.message || changed.error?.code || "queue";
1604
+ this.actionNotice = this.t("chat.failed", { error: detail });
1591
1605
  }
1592
1606
  } finally {
1593
1607
  this.queueBusy = "";
@@ -163,6 +163,10 @@ export default {
163
163
  line-height: 20px;
164
164
  }
165
165
 
166
+ .lares-bar__segment:only-child {
167
+ max-width: none;
168
+ }
169
+
166
170
  .lares-bar__segment + .lares-bar__segment::before {
167
171
  content: "";
168
172
  width: 1px;
@@ -34,10 +34,6 @@
34
34
  :data-type="row.type"
35
35
  :data-pending="row.pending ? 'true' : 'false'"
36
36
  >
37
- <div v-if="hasReadout(row)" role="status" class="lares-message-readout">
38
- <span>{{ speechLabel }}</span>
39
- <button type="button" class="lares-message-stop" @click="readout.stop()">{{ t(readout.status === 'error' ? 'readout.dismiss' : 'readout.stop') }}</button>
40
- </div>
41
37
  <div v-if="row.type === 'user'" class="lares-msg" data-role="user">{{ row.text }}</div>
42
38
  <LaresStageRow
43
39
  v-else-if="row.type === 'context'"
@@ -75,16 +71,19 @@
75
71
  :title="retryLabel(row)"
76
72
  :body="retryBody(row)"
77
73
  />
78
- <div
79
- v-else-if="row.type === 'assistant' && row.pending"
80
- class="lares-md"
81
- data-pending="true"
82
- >{{ row.text }}</div>
83
74
  <template v-else-if="row.type === 'assistant'">
84
- <div class="lares-md" v-html="renderMarkdown(row.text)" />
85
- <p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
86
- <button v-if="!readout && isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
75
+ <div v-if="row.pending" class="lares-md" data-pending="true">{{ row.text }}</div>
76
+ <template v-else>
77
+ <div class="lares-md" v-html="renderMarkdown(row.text)" />
78
+ <p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
79
+ </template>
80
+ <div v-if="hasReadout(row)" role="status" class="lares-message-readout">
81
+ <span>{{ speechLabel }}</span>
82
+ <button type="button" class="lares-message-stop" @click="readout.stop()">{{ t(readout.status === 'error' ? 'readout.dismiss' : 'readout.stop') }}</button>
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>
87
85
  <LaresMessageActions
86
+ v-if="!row.pending"
88
87
  :text="row.text"
89
88
  :reaction="reactions[actionKey(index)] || ''"
90
89
  :metrics="row.metrics || null"
@@ -6,20 +6,17 @@
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 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
9
  </div>
11
10
  </template>
12
11
  <script>
13
12
  import { createHoldGesture } from '@olares/lares-core/voice/hold';
14
13
  import { voiceInputPresentation } from '@olares/lares-core/voice/presentation';
15
- import { messageFromCode } from '@olares/lares-core/i18n/t';
16
14
  export default {
17
15
  name: 'LaresVoiceInput', props: { view: { type: Object, required: true }, t: { type: Function, required: true } }, emits: ['action'],
18
16
  data: () => ({ now: Date.now(), clock: null, gesture: null }),
19
17
  computed: {
20
18
  ui() { return voiceInputPresentation(this.view, this.now); },
21
19
  levels() { return (this.view.levels || []).slice(-6); },
22
- status() { return this.ui.error ? messageFromCode(this.t, this.ui.error, 'error.voice_failed') : this.t(this.ui.status); },
23
20
  },
24
21
  created() { this.gesture = createHoldGesture({ start: () => this.action('holdStart'), end: value => this.action('holdEnd', value), onCancelChange: value => this.action('setCancelArmed', value), isActive: () => this.view.holding }); },
25
22
  mounted() { this.$refs.main?.focus(); this.clock = setInterval(() => { this.now = Date.now(); }, 250); window.addEventListener('blur', this.cancel); window.addEventListener('pointerdown', this.additionalPointer, true); },
@@ -44,6 +41,4 @@ export default {
44
41
  .lares-inline-cancel { position: absolute; bottom: calc(100% + 18px); left: 50%; transform: translateX(-50%); white-space: nowrap; font-size: 13px; padding: 7px 12px; border-radius: 10px; background: var(--q-background-1, white); box-shadow: 0 2px 12px #0001; pointer-events: none; }
45
42
  [data-phase=recording] .lares-inline-main { color: var(--q-blue-default, #3377ff); }
46
43
  [data-phase=cancel] .lares-inline-main, .lares-inline-cancel[data-armed=true] { color: var(--q-red-default, #c23d32); }
47
- .lares-inline-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
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); }
49
44
  </style>
@@ -1,6 +1,5 @@
1
1
  <template>
2
- <Transition name="lares-panel">
3
- <div v-if="rows.length" class="desktop-queue">
2
+ <div v-if="rows.length" class="desktop-queue lares-queue-in">
4
3
  <div class="desktop-queue__panel">
5
4
  <button
6
5
  v-if="rows.length > 1"
@@ -70,7 +69,6 @@
70
69
  </ul>
71
70
  </div>
72
71
  </div>
73
- </Transition>
74
72
  </template>
75
73
 
76
74
  <script>
@@ -35,6 +35,7 @@
35
35
  <span>{{ state.failText }}</span>
36
36
  <button type="button" :disabled="state.starting" @click="$emit('retry')">{{ t("probe.retry") }}</button>
37
37
  </div>
38
+ <p v-else-if="state.actionNotice" class="desktop-shell__failure" role="status">{{ state.actionNotice }}</p>
38
39
  <div v-if="newSessionHero" class="desktop-shell__brand">
39
40
  <div class="desktop-shell__logo" :style="logoStyle" aria-hidden="true" />
40
41
  <h1>{{ t("shell.title") }}</h1>
package/src/host.js CHANGED
@@ -5,6 +5,7 @@ import { normalizeReferenceCandidates, referencesUrl } from "@olares/lares-core/
5
5
  import { muxWsUrl, remoteStreamOpen, workspaceBaseline } from "@olares/lares-core/larepass/mux";
6
6
  import { RESPOND_PATH } from "@olares/lares-core/larepass/rpc";
7
7
  import { createHostSettings } from "@olares/lares-core/larepass/settings";
8
+ import { createVoiceKeysClient } from "@olares/lares-core/voice/keys-client";
8
9
  import {
9
10
  downloadFileUrl,
10
11
  isPreviewPayload,
@@ -340,5 +341,6 @@ export function createHostClient(ports = {}) {
340
341
  },
341
342
  consumeMux,
342
343
  settings: createHostSettings(request),
344
+ voiceKeys: createVoiceKeysClient((path, init) => request(path, init)),
343
345
  };
344
346
  }
@@ -51,6 +51,7 @@
51
51
  />
52
52
  <div class="lares-shell__conversation" :data-hero="newSessionHero ? 'true' : 'false'">
53
53
  <p v-if="state.failed" class="lares-shell__status">{{ state.failText }}</p>
54
+ <p v-else-if="state.actionNotice" class="lares-shell__status" role="status">{{ state.actionNotice }}</p>
54
55
  <button
55
56
  v-if="state.failed"
56
57
  type="button"
@@ -176,7 +177,6 @@
176
177
 
177
178
  <LaresSheet :open="state.modelSheet" :title="t('model.menuAria')" @close="$emit('close-model')">
178
179
  <p v-if="state.modelCatalogError" class="lares-shell__status" role="status">{{ state.modelCatalogError }}</p>
179
- <button type="button" @click="state.refreshModels()">{{ t('agent.refresh') }}</button>
180
180
  <p v-if="state.modelError" class="lares-shell__status">{{ state.modelError }}</p>
181
181
  <p v-else-if="!state.models" class="lares-shell__hint">{{ t("agent.loading") }}</p>
182
182
  <p v-else-if="!state.modelGroups.length" class="lares-shell__hint">{{ t("model.empty") }}</p>
@@ -202,6 +202,9 @@
202
202
  @click="$emit('choose-effort', row.id)"
203
203
  />
204
204
  </template>
205
+ <template #footer>
206
+ <button type="button" class="lares-shell__refresh" @click="state.refreshModels()">{{ t('agent.refresh') }}</button>
207
+ </template>
205
208
  </LaresSheet>
206
209
  <Transition name="lares-preview">
207
210
  <LaresPreview
@@ -383,6 +386,29 @@ export default {
383
386
  color: var(--q-blue-default);
384
387
  }
385
388
 
389
+ .lares-shell__refresh {
390
+ display: flex;
391
+ flex: none;
392
+ align-items: center;
393
+ justify-content: center;
394
+ width: 100%;
395
+ height: 48px;
396
+ margin: 0;
397
+ border: 0;
398
+ border-radius: 0;
399
+ padding: 0;
400
+ background: var(--q-yellow-default);
401
+ color: var(--q-ink-1);
402
+ font: inherit;
403
+ font-size: 16px;
404
+ font-weight: 500;
405
+ text-align: center;
406
+ }
407
+
408
+ .lares-shell__refresh:active {
409
+ background: var(--q-yellow-pressed);
410
+ }
411
+
386
412
  .lares-shell__heading {
387
413
  margin: 0;
388
414
  padding: 12px 16px 4px;
package/src/motion.css CHANGED
@@ -101,6 +101,20 @@
101
101
  .lares-panel-leave-to {
102
102
  opacity: 0;
103
103
  transform: translateY(8px);
104
+ pointer-events: none;
105
+ }
106
+
107
+ /* A panel that is still arriving or leaving is not a click target. */
108
+ .lares-panel-leave-active {
109
+ pointer-events: none;
110
+ }
111
+
112
+ @keyframes lares-queue-in {
113
+ from { opacity: 0; transform: translateY(8px); }
114
+ }
115
+
116
+ .lares-queue-in {
117
+ animation: lares-queue-in var(--lares-duration-normal, 220ms) var(--lares-ease-out, cubic-bezier(0.32, 0.72, 0, 1));
104
118
  }
105
119
 
106
120
  /* Preview tab strip — slide in from the header edge. */
@@ -151,6 +165,8 @@
151
165
  transition: none;
152
166
  }
153
167
 
168
+ .lares-queue-in { animation: none; }
169
+
154
170
  .lares-preview-enter-from,
155
171
  .lares-preview-leave-to,
156
172
  .lares-view-enter-from,