@olares/lares-mobile 0.15.12 → 0.15.13

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.12",
3
+ "version": "0.15.13",
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.12",
31
+ "@olares/lares-core": "0.15.13",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -101,10 +101,16 @@
101
101
  @pick-command="pickCommand"
102
102
  @toggle-speak="toggleSpeak"
103
103
  />
104
+ <LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
105
+ @close="runVoicePanel('close')" @mode="openVoiceMode" @start="runVoicePanel('start')" @pause="runVoicePanel('pause')"
106
+ @complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" @end="runVoicePanel('end')" @action="voiceAction" />
104
107
  </div>
105
108
  </template>
106
109
 
107
110
  <script>
111
+ import { createVoicePanel } from "@olares/lares-core/voice/panel";
112
+ import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
113
+ import LaresVoicePanel from "./chat/VoicePanel.vue";
108
114
  import { pickHostPorts } from "@olares/lares-core/larepass/host";
109
115
  import { connectChat } from "./runtime.js";
110
116
  import { createT } from "./i18n.js";
@@ -171,7 +177,7 @@ function imageData(file) {
171
177
 
172
178
  export default {
173
179
  name: "LaresApp",
174
- components: { LaresMobileShell, LaresDesktopShell },
180
+ components: { LaresMobileShell, LaresDesktopShell, LaresVoicePanel },
175
181
  props: {
176
182
  locale: { type: String, default: "en" },
177
183
  device: { type: String, default: "mobile" },
@@ -221,8 +227,8 @@ export default {
221
227
  upload: { pending: 0, failures: [] },
222
228
  draftImages: [],
223
229
  voiceCall: null,
224
- voiceHistory: null,
225
- voiceCaption: "",
230
+ voicePanelState: null,
231
+ voiceRecords: [],
226
232
  voicePhase: "idle",
227
233
  voiceElapsed: 0,
228
234
  voiceError: "",
@@ -450,7 +456,7 @@ export default {
450
456
  return effortMenuRows(this.reasoning, this.t("reasoning.default"));
451
457
  },
452
458
  viewItems() {
453
- return withPendingUser(this.items, this.pendingUser);
459
+ return mergeVoiceTranscript(withPendingUser(this.items, this.pendingUser), this.voiceRecords);
454
460
  },
455
461
  failText() {
456
462
  return failText(this.t, this.failed, this.error);
@@ -518,6 +524,8 @@ export default {
518
524
  },
519
525
  },
520
526
  sessionId(id) {
527
+ this.bindVoiceHistory(id);
528
+ void this.voicePanel?.bind(id || null).catch(() => {});
521
529
  this.bindIntake(id);
522
530
  },
523
531
  hostKey() {
@@ -531,6 +539,25 @@ export default {
531
539
  this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
532
540
  this.imageUploadControllers = new Map();
533
541
  this.player = createAnswerPlayer();
542
+ this.voicePanel = createVoicePanel({
543
+ create: (options) => this.createMobileVoiceConversation(options),
544
+ complete: async ({ sessionId, text }) => {
545
+ const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
546
+ const value = [previous, text].filter(Boolean).join(" ");
547
+ this.draftBySession = { ...this.draftBySession, [sessionId || "@pending"]: value };
548
+ if (sessionId === (this.sessionId || null)) this.draft = value;
549
+ },
550
+ onSession: (sessionId) => {
551
+ this.voiceAdoptingSession = sessionId;
552
+ void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
553
+ },
554
+ });
555
+ this.voicePanelState = this.voicePanel.getSnapshot();
556
+ this.voicePanelUnsub = this.voicePanel.subscribe(() => {
557
+ const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
558
+ this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
559
+ this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
560
+ });
534
561
  this.replySpeaker = createReplySpeaker();
535
562
  this.reader = createSentencePlayer({
536
563
  send: async (request) => {
@@ -563,7 +590,7 @@ export default {
563
590
  this.busyEnter = section.busyEnter;
564
591
  });
565
592
  this.voiceVisibilityHandler = () => {
566
- if (document.hidden) void this.voiceConversation?.controller.disconnect("voice_background");
593
+ if (document.hidden) void this.voicePanel.pause().catch(() => {});
567
594
  };
568
595
  document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
569
596
  this.voiceClock = setInterval(() => {
@@ -575,6 +602,7 @@ export default {
575
602
  this.restoreLog();
576
603
  },
577
604
  deactivated() {
605
+ void this.voicePanel?.pause().catch(() => {});
578
606
  this.captureLog();
579
607
  },
580
608
  beforeUnmount() {
@@ -584,8 +612,9 @@ export default {
584
612
  this.noteSeen(this.sessionId);
585
613
  for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
586
614
  this.imageUploadControllers?.clear?.();
587
- void this.voiceConversation?.stop().catch(() => {});
588
- this.voiceUnsub?.();
615
+ void this.voicePanel?.dispose().catch(() => {});
616
+ this.voicePanelUnsub?.();
617
+ this.voiceHistoryUnsub?.();
589
618
  this.conversationUnsub?.();
590
619
  this.intakeUnsub?.();
591
620
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
@@ -643,7 +672,7 @@ export default {
643
672
  this.commandsError = "";
644
673
  }
645
674
  if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
646
- void this.voiceConversation?.stop().catch(() => {});
675
+ void this.voicePanel?.pause().catch(() => {});
647
676
  this.reader.stop();
648
677
  this.player?.stop();
649
678
  }
@@ -953,7 +982,7 @@ export default {
953
982
  this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
954
983
  },
955
984
  switchHost() {
956
- void this.voiceConversation?.stop().catch(() => {});
985
+ void this.voicePanel?.end().catch(() => {});
957
986
  for (const controller of this.imageUploadControllers.values()) controller.abort();
958
987
  this.imageUploadControllers.clear();
959
988
  this.releaseDraftImages();
@@ -1189,26 +1218,20 @@ export default {
1189
1218
  this.draftImages = [];
1190
1219
  this.imagesBySession = {};
1191
1220
  },
1192
- async onHoldStart({ mode: selectedMode } = {}) {
1193
- this.voiceReleased = false;
1194
- if (this.voiceCall?.connection === "open") {
1195
- await this.voiceConversation.stop(); return;
1196
- }
1197
- const target = this.sessionId;
1198
- this.voicePhase = "preparing"; this.voiceElapsed = 0;
1199
- try {
1200
- const settings = await this.runtime.settings.voice(true);
1201
- if (this.sessionId !== target) return;
1202
- const continuous = (selectedMode || settings.config.mobileMode || "continuous") === "continuous";
1203
- if (settings.config.backend === "realtime" && continuous) {
1204
- const conversation = createRealtimeConversation({ readAloud: settings.config.speak, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1205
- request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1206
- device: captureSource("mobile", settings.config.device), playback: this.player,
1207
- onCaption: (text) => { this.voiceCaption = `${this.voiceCaption}${text}`.slice(-300); },
1208
- });
1209
- await this.attachVoiceConversation(conversation, target, true);
1210
- return;
1211
- }
1221
+ async onHoldStart({ mode } = {}) {
1222
+ await this.voicePanel.open({ mode: mode || this.voiceDefaultMode || "continuous", sessionId: this.sessionId || null });
1223
+ },
1224
+ openVoiceMode(mode) { void this.voicePanel.open({ mode }).catch(() => {}); },
1225
+ runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1226
+ async createMobileVoiceConversation({ mode, onTranscript, onDevice }) {
1227
+ const settings = await this.runtime.settings.voice(true);
1228
+ const continuous = mode === "continuous";
1229
+ onDevice(captureSource("mobile", settings.config.device));
1230
+ if (settings.config.backend === "realtime" && continuous) return createRealtimeConversation({ presentationSession: true,
1231
+ readAloud: settings.config.speak, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1232
+ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1233
+ device: captureSource("mobile", settings.config.device), playback: this.player,
1234
+ });
1212
1235
  this.reader.stop(); this.player?.stop(); this.player?.unlock();
1213
1236
  let conversation;
1214
1237
  const reader = createSentencePlayer({
@@ -1223,56 +1246,24 @@ export default {
1223
1246
  transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
1224
1247
  device: captureSource("mobile", settings.config.device),
1225
1248
  readAloud: continuous && settings.config.speak,
1226
- preset: settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1227
- onText: (text) => { this.voiceCaption = text; if (this.voiceCall) this.voiceCall = { ...this.voiceCall, caption: text }; },
1228
- onTranscript: continuous && settings.config.preset !== "dictation" ? null : ({ text }) => {
1229
- if (this.sessionId !== target) return;
1230
- this.draft = [this.draft, text].filter(Boolean).join(" ");
1231
- this.draftBySession = { ...this.draftBySession, [target || "@pending"]: this.draft };
1232
- },
1249
+ preset: continuous && settings.config.preset === "dictation" ? "assistant" : settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1250
+ presentationSession: true,
1251
+ onTranscript: continuous ? null : onTranscript,
1233
1252
  mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1234
1253
  playback: { stop: () => { reader.stop(); this.player.stop(); } },
1235
1254
  onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
1236
1255
  });
1237
- await this.attachVoiceConversation(conversation, target, continuous);
1238
- } catch (error) { this.voicePhase = "error"; this.voiceError = error.message; }
1239
- },
1240
- async attachVoiceConversation(conversation, target, continuous) {
1241
- this.voiceCaption = ""; this.voiceHistory = null;
1242
- this.voiceConversation = conversation;
1243
- this.voiceUnsub?.();
1244
- this.voiceUnsub = conversation.controller.subscribe(() => {
1245
- const state = conversation.controller.getSnapshot();
1246
- if (state.connection === "open" && !target && !this.sessionId && !this.voiceAdoptingSession && state.sessionId) {
1247
- this.voiceAdoptingSession = state.sessionId;
1248
- void Promise.resolve(this.runtime.openSession(state.sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
1249
- }
1250
- this.voiceCall = state.connection === "closed" ? null : { ...state, caption: this.voiceCaption, history: this.voiceHistory };
1251
- this.voicePhase = state.error ? "error" : state.capture;
1252
- this.voiceError = state.error || "";
1253
- });
1254
- await conversation.start({ sessionId: target || null, continuous });
1255
- if (this.voiceReleased && !continuous) conversation.controller.stopRecording();
1256
- },
1257
- onHoldEnd() {
1258
- this.voiceReleased = true;
1259
- if (!this.voiceConversation?.controller.getSnapshot().continuous) this.voiceConversation?.controller.stopRecording();
1256
+ return conversation;
1260
1257
  },
1261
- async voiceAction(action) {
1262
- try {
1263
- if (action === "mute") await this.voiceConversation?.setReadAloud(this.voiceCall.outputMuted);
1264
- if (action === "history") {
1265
- this.voiceHistory = (await this.voiceConversation?.records()).records;
1266
- if (this.voiceCall) this.voiceCall = { ...this.voiceCall, history: this.voiceHistory };
1267
- }
1268
- if (action === "clear-history") { await this.voiceConversation?.clearRecords(); this.voiceHistory = null; }
1269
- if (action === "input-pause") await this.voiceConversation?.controller.pauseInput();
1270
- if (action === "input-resume") this.voiceConversation?.controller.resumeInput();
1271
- if (action === "stop") await this.voiceConversation?.stop();
1272
- if (action === "playback") await this.voiceConversation?.controller.stopPlayback();
1273
- if (action === "cancel") await this.voiceConversation?.controller.cancelTask();
1274
- if (action === "resume") await this.voiceConversation?.resume();
1275
- } catch (error) { this.voiceError = error.message; }
1258
+ onHoldEnd() { this.voicePanel.conversation?.controller.stopRecording(); },
1259
+ async voiceAction(action) { await this.voicePanel.action(action).catch(() => {}); },
1260
+ bindVoiceHistory(sessionId) {
1261
+ this.voiceHistoryUnsub?.(); this.voiceRecords = [];
1262
+ if (!sessionId) return;
1263
+ this.voiceHistoryUnsub = subscribeVoiceHistory({ sessionId, request: (path, options) => this.runtime.voiceRequest(path, options),
1264
+ onRecords: (rows) => { if (this.sessionId === sessionId) this.voiceRecords = rows; },
1265
+ onError: (error) => { this.voiceError = error.message; },
1266
+ });
1276
1267
  },
1277
1268
  showSpeakProblem(err) {
1278
1269
  this.speakNotice = speakProblemText(this.t, err);
@@ -82,10 +82,6 @@
82
82
  </button>
83
83
  </figure>
84
84
  </div>
85
- <select v-if="inputMode === 'voice' && !voiceCall" :value="voiceSelection || voiceDefaultMode" @change="voiceSelection = $event.target.value" class="lares-composer__voice-mode" :aria-label="t('settings.mode')">
86
- <option value="single">{{ t('settings.mode.single') }}</option>
87
- <option value="continuous">{{ t('settings.mode.continuous') }}</option>
88
- </select>
89
85
  <div class="lares-composer__line" :data-expanded="expanded ? 'true' : 'false'">
90
86
  <button
91
87
  type="button"
@@ -100,7 +96,7 @@
100
96
  </svg>
101
97
  </button>
102
98
  <textarea
103
- v-show="inputMode === 'text'"
99
+
104
100
  ref="input"
105
101
  :value="draft"
106
102
  class="lares-composer__input"
@@ -114,24 +110,6 @@
114
110
  @select="onCaretMove"
115
111
  @contextmenu="onContextMenu"
116
112
  />
117
- <button
118
- v-show="inputMode === 'voice'"
119
- type="button"
120
- class="lares-composer__talk"
121
- :data-phase="voicePhase"
122
- :disabled="voiceBlocked"
123
- :aria-label="voiceLabel"
124
- @click="onVoiceClick"
125
- @contextmenu.prevent
126
- >
127
- <span v-if="voicePhase === 'preparing' || voicePhase === 'transcribing'" class="lares-composer__talk-status">
128
- <svg class="lares-composer__spin" viewBox="0 0 16 16" aria-hidden="true">
129
- <circle cx="8" cy="8" r="6" />
130
- </svg>
131
- <span>{{ talkLabel }}</span>
132
- </span>
133
- <span v-else>{{ talkLabel }}</span>
134
- </button>
135
113
  <div class="lares-composer__tools">
136
114
  <input
137
115
  ref="picker"
@@ -141,13 +119,13 @@
141
119
  @change="onFiles"
142
120
  />
143
121
  <button
144
- v-if="inputMode === 'text'"
145
122
  type="button"
146
123
  class="lares-composer__icon"
147
124
  data-kind="voice"
148
125
  :disabled="sending"
149
- :aria-label="t('composer.voice')"
150
- @click="setMode('voice')"
126
+ :aria-label="t('call.start')"
127
+ aria-haspopup="dialog"
128
+ @click="onVoiceClick"
151
129
  >
152
130
  <svg class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
153
131
  <path d="M5 8v8" />
@@ -167,43 +145,11 @@
167
145
  <path d="M12 19V5M5 12.5 12 5l7 7.5" />
168
146
  </svg>
169
147
  </button>
170
- <button
171
- v-if="inputMode === 'voice'"
172
- type="button"
173
- class="lares-composer__icon"
174
- data-kind="keyboard"
175
- :disabled="voiceBusy"
176
- :aria-label="t('composer.keyboard')"
177
- @click="setMode('text')"
178
- >
179
- <svg viewBox="0 0 24 24" aria-hidden="true">
180
- <rect x="3.5" y="6" width="17" height="12" rx="2" />
181
- <path d="M7 10h2M11 10h2M15 10h2M8 14h8" />
182
- </svg>
183
- </button>
148
+ <button type="button" class="lares-composer__voice-entry" aria-haspopup="dialog" @click="$emit('hold-start', { mode: 'single' })">{{ t('settings.mode.single') }}</button>
184
149
  </div>
185
150
  </div>
186
151
  </div>
187
- <div v-if="voiceCall" class="lares-composer__hint" role="status" aria-live="polite">
188
- <button v-if="voiceCall.continuous" type="button" @click="$emit('voice-action', 'mute')">{{ t(voiceCall.outputMuted ? 'call.unmute' : 'call.mute') }}</button>
189
- <button v-if="voiceCall.continuous" type="button" @click="$emit('voice-action', 'history')">{{ t('call.history') }}</button>
190
- <details v-if="voiceCall.history" open>
191
- <summary>{{ t('call.history') }}</summary>
192
- <p v-for="row in voiceCall.history" :key="row.id">{{ row.role }}: {{ row.text }}</p>
193
- <button type="button" @click="$emit('voice-action', 'clear-history')">{{ t('call.clearHistory') }}</button>
194
- </details>
195
- <svg v-if="voiceCall.capture === 'recording' && voiceCall.inputLevel != null" width="80" height="24" aria-hidden="true">
196
- <line v-for="(level, index) in levels" :key="index" :x1="index * 5" :x2="index * 5" :y1="12 - Math.min(11, level * 800)" :y2="12 + Math.min(11, level * 800)" stroke="currentColor" stroke-width="2" />
197
- </svg>
198
- <span v-if="voiceCall.caption">{{ voiceCall.caption }}</span>
199
- <span>{{ voiceCall.capture === 'transcribing' ? t('mic.transcribing') : voiceCall.capture === 'recording' ? t('call.listening') : voiceCall.inputPaused ? t('call.inputPaused') : voiceCall.playback !== 'idle' ? t('call.halfDuplex') : t('call.waiting') }}</span>
200
- <span v-if="voiceCall.playback !== 'idle'">{{ t('call.speaking') }}</span>
201
- <button v-if="!voiceCall.continuous && voiceCall.capture === 'recording'" type="button" @click="$emit('hold-end')">{{ t('mic.finish') }}</button>
202
- <button v-if="voiceCall.continuous && voiceCall.connection === 'open'" type="button" @click="$emit('voice-action', voiceCall.inputPaused ? 'input-resume' : 'input-pause')">{{ t(voiceCall.inputPaused ? 'call.resumeInput' : 'call.pauseInput') }}</button>
203
- <button type="button" @click="$emit('voice-action', voiceCall.connection === 'paused' ? 'resume' : 'stop')">{{ t(voiceCall.connection === 'paused' ? 'call.resume' : voiceCall.continuous ? 'call.end' : 'mic.cancel') }}</button>
204
- <button v-if="voiceCall.playback !== 'idle'" type="button" @click="$emit('voice-action', 'playback')">{{ t('call.stopPlayback') }}</button>
205
- </div>
206
- <p v-if="voiceError && voicePhase === 'error'" class="lares-composer__hint" data-status="error">{{ voiceError }}</p>
152
+ <p v-if="voiceCall?.connection === 'paused'" class="lares-composer__hint" role="status">{{ t('panel.paused') }}</p>
207
153
  <p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
208
154
  {{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
209
155
  </p>
@@ -211,7 +157,6 @@
211
157
  </template>
212
158
 
213
159
  <script>
214
- import { formatElapsed } from "@olares/lares-core/voice/recorder";
215
160
  import {
216
161
  REFERENCE_DEBOUNCE_MS,
217
162
  activeReference,
@@ -256,8 +201,6 @@ export default {
256
201
  commandsOpen: false,
257
202
  commandTyped: false,
258
203
  commandIndex: 0,
259
- levels: [],
260
- voiceSelection: "",
261
204
  referenceOpen: false,
262
205
  referenceToken: null,
263
206
  referenceIndex: 0,
@@ -265,21 +208,6 @@ export default {
265
208
  };
266
209
  },
267
210
  computed: {
268
- elapsed() {
269
- return formatElapsed(this.voiceElapsed);
270
- },
271
- talkLabel() {
272
- if (this.voicePhase === "preparing") return this.t("composer.opening");
273
- if (this.voicePhase === "recording") return `${this.t("call.listening")} · ${this.elapsed}`;
274
- if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
275
- if (this.holding) return this.t("composer.hold");
276
- return this.t("call.start");
277
- },
278
- voiceBusy() {
279
- return this.voicePhase === "preparing"
280
- || this.voicePhase === "recording"
281
- || this.voicePhase === "transcribing";
282
- },
283
211
  attachLabel() {
284
212
  return this.attachPending ? this.t("button.uploading", { count: 1 }) : this.t("composer.attach");
285
213
  },
@@ -287,16 +215,6 @@ export default {
287
215
  if (this.hero) return this.t("chat.placeholderNewSession");
288
216
  return this.t("chat.placeholder");
289
217
  },
290
- voiceLabel() {
291
- if (this.voicePhase === "error" && this.voiceError) return this.voiceError;
292
- if (this.voicePhase === "preparing") return this.t("composer.opening");
293
- if (this.voicePhase === "recording") return this.t("call.listening");
294
- if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
295
- return this.t("call.start");
296
- },
297
- voiceBlocked() {
298
- return this.voicePhase === "transcribing" || this.sending;
299
- },
300
218
  expanded() {
301
219
  return this.inputMode === "text" && (this.draft.trim().length > 0 || this.draftImages.length > 0);
302
220
  },
@@ -311,10 +229,8 @@ export default {
311
229
  },
312
230
  },
313
231
  watch: {
314
- "voiceCall.inputLevel"(level) { if (level != null) this.levels = [...this.levels.slice(-15), level]; },
315
232
  draft(value) {
316
233
  this.autosize();
317
- if (value && this.inputMode === "voice" && !this.voiceBusy) this.setMode("text");
318
234
  },
319
235
  overlayPointerActive(active) {
320
236
  if (active) window.addEventListener("pointerdown", this.onTokenPointer, true);
@@ -410,18 +326,6 @@ export default {
410
326
  imageStatus(image) {
411
327
  return draftImageStatus(image);
412
328
  },
413
- setMode(mode) {
414
- if (mode === this.inputMode) return;
415
- if (mode === "text" && this.voiceBusy) return;
416
- if (mode === "voice") {
417
- this.closeReferences();
418
- this.commandsOpen = false;
419
- this.commandTyped = false;
420
- this.$refs.input?.blur();
421
- }
422
- this.$emit("update:inputMode", mode);
423
- if (mode === "text") this.$nextTick(() => this.focus());
424
- },
425
329
  onSend() {
426
330
  if (this.referenceOpen || this.commandsOpen) return;
427
331
  if (this.canSend) this.$emit("send");
@@ -475,8 +379,7 @@ export default {
475
379
  }
476
380
  },
477
381
  onVoiceClick() {
478
- if (this.voiceBusy) return;
479
- this.$emit("hold-start", { mode: this.voiceSelection || undefined });
382
+ this.$emit("hold-start", { mode: "continuous" });
480
383
  },
481
384
  clearHold() {
482
385
  this.holding = false;
@@ -507,6 +410,9 @@ export default {
507
410
  </script>
508
411
 
509
412
  <style scoped>
413
+ .lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
414
+ .lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
415
+
510
416
  .lares-composer {
511
417
  display: flex;
512
418
  flex-direction: column;
@@ -904,6 +810,9 @@ export default {
904
810
  </style>
905
811
 
906
812
  <style scoped>
813
+ .lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
814
+ .lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
815
+
907
816
  .lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
908
817
  .lares-composer__hint button { min-height: 44px; border: 0; border-radius: 12px; padding: 8px 12px; background: var(--lares-bg-secondary, #f0f0f0); color: inherit; font: inherit; }
909
818
  </style>
@@ -29,7 +29,7 @@
29
29
  </li>
30
30
  <li
31
31
  v-for="{ row, index } in shown(block)"
32
- :key="index"
32
+ :key="row.id || row.key || index"
33
33
  class="lares-log__item"
34
34
  :data-type="row.type"
35
35
  :data-pending="row.pending ? 'true' : 'false'"
@@ -78,6 +78,7 @@
78
78
  >{{ row.text }}</div>
79
79
  <template v-else-if="row.type === 'assistant'">
80
80
  <div class="lares-md" v-html="renderMarkdown(row.text)" />
81
+ <p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
81
82
  <LaresMessageActions
82
83
  :text="row.text"
83
84
  :reaction="reactions[actionKey(index)] || ''"
@@ -483,6 +484,8 @@ export default {
483
484
  overflow-wrap: anywhere;
484
485
  }
485
486
 
487
+ .lares-voice-summary { border-left: 2px solid var(--q-blue-default); padding-left: 12px; font-size: 14px; opacity: .8; }
488
+
486
489
  .lares-md[data-pending="true"] {
487
490
  white-space: pre-wrap;
488
491
  opacity: 0.92;
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <dialog ref="dialog" class="lares-voice-sheet" aria-labelledby="lares-mobile-voice-title" @cancel.prevent="$emit('close')" @click="onBackdrop">
3
+ <div class="lares-voice-sheet__body">
4
+ <header><h2 id="lares-mobile-voice-title">{{ t('panel.title') }}</h2><button type="button" @click="$emit('close')">{{ t('panel.close') }}</button></header>
5
+ <div role="tablist" :aria-label="t('settings.mode')" @keydown="onTabKey">
6
+ <button v-for="mode in ['single', 'continuous']" :id="`mobile-voice-${mode}`" :key="mode" type="button" role="tab" :aria-selected="view.mode === mode" :tabindex="view.mode === mode ? 0 : -1" aria-controls="lares-mobile-voice-content" :disabled="view.busy" @click="$emit('mode', mode)">{{ t(`settings.mode.${mode}`) }}</button>
7
+ </div>
8
+ <section id="lares-mobile-voice-content" role="tabpanel" :aria-labelledby="`mobile-voice-${view.mode}`">
9
+ <p class="lares-voice-sheet__device">{{ t(!view.device ? 'settings.device.auto' : view.device === 'headset' ? 'settings.device.headset' : 'settings.device.device') }}</p>
10
+ <svg class="lares-voice-sheet__wave" viewBox="0 0 120 48" aria-hidden="true"><line v-for="(level, index) in levels" :key="index" :x1="index * 6 + 3" :x2="index * 6 + 3" :y1="24 - Math.max(2, Math.min(22, level * 800))" :y2="24 + Math.max(2, Math.min(22, level * 800))" /></svg>
11
+ <p role="timer" :aria-label="t('panel.elapsed')">{{ elapsed }}s</p>
12
+ <p role="status" aria-live="polite">{{ status }}</p>
13
+ <div v-if="view.mode === 'single'" class="lares-voice-sheet__draft" :aria-label="t('panel.draft')" aria-live="polite">{{ view.draft || t('panel.draftHint') }}</div>
14
+ <p v-else>{{ t('panel.messages') }}</p>
15
+ <div class="lares-voice-sheet__actions">
16
+ <button v-if="!active" type="button" :disabled="view.busy" @click="$emit('start')">{{ t(view.snapshot?.connection === 'paused' ? 'panel.continue' : 'panel.start') }}</button>
17
+ <button v-else type="button" :disabled="view.busy" @click="$emit('pause')">{{ t('panel.pause') }}</button>
18
+ <template v-if="view.mode === 'single'">
19
+ <button type="button" :disabled="view.busy || (!view.draft && view.snapshot?.capture !== 'recording')" @click="$emit('complete')">{{ t('panel.complete') }}</button>
20
+ <button type="button" :disabled="view.busy" @click="$emit('discard')">{{ t('panel.discard') }}</button>
21
+ </template>
22
+ <template v-if="active && view.mode === 'continuous'">
23
+ <button type="button" :disabled="view.busy" @click="$emit('action', 'mute')">{{ t(view.snapshot.outputMuted ? 'call.unmute' : 'call.mute') }}</button>
24
+ <button type="button" :disabled="view.busy" @click="$emit('action', 'input')">{{ t(view.snapshot.inputPaused ? 'call.resumeInput' : 'call.pauseInput') }}</button>
25
+ </template>
26
+ <button v-if="view.snapshot && view.snapshot.playback !== 'idle'" type="button" :disabled="view.busy" @click="$emit('action', 'playback')">{{ t('call.stopPlayback') }}</button>
27
+ <button v-if="view.snapshot" type="button" :disabled="view.busy" @click="$emit('end')">{{ t('call.end') }}</button>
28
+ </div>
29
+ </section>
30
+ </div>
31
+ </dialog>
32
+ </template>
33
+ <script>
34
+ export default {
35
+ name: 'LaresVoicePanel', props: { view: { type: Object, required: true }, t: { type: Function, required: true } },
36
+ emits: ['close', 'mode', 'start', 'pause', 'complete', 'discard', 'end', 'action'],
37
+ data: () => ({ now: Date.now(), clock: null, previousFocus: null }),
38
+ computed: {
39
+ active() { return this.view.snapshot?.connection === 'open'; },
40
+ levels() { return this.view.levels?.length ? this.view.levels : Array(20).fill(.003); },
41
+ elapsed() { return Math.max(0, Math.floor(((this.view.elapsedMs || 0) + (this.view.startedAt ? this.now - this.view.startedAt : 0)) / 1000)); },
42
+ status() { const state = this.view.snapshot; return this.view.error || state?.error ? this.t(`error.${this.view.error || state.error}`) : this.t(this.view.busy ? 'mic.preparing' : !this.active ? 'panel.paused' : state.capture === 'transcribing' ? 'mic.transcribing' : state.capture === 'recording' ? 'call.listening' : state.playback !== 'idle' ? 'call.halfDuplex' : 'call.waiting'); },
43
+ },
44
+ watch: { 'view.visible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
45
+ beforeUnmount() { clearInterval(this.clock); this.$refs.dialog?.close(); this.previousFocus?.focus?.(); },
46
+ methods: {
47
+ sync() { const dialog = this.$refs.dialog; if (!dialog) return; clearInterval(this.clock); if (this.view.visible) { if (!dialog.open) { this.previousFocus = document.activeElement; dialog.showModal(); } this.clock = setInterval(() => { this.now = Date.now(); }, 250); } else if (dialog.open) { dialog.close(); this.previousFocus?.focus?.(); } },
48
+ onBackdrop(event) { if (event.target === this.$refs.dialog) this.$emit('close'); },
49
+ onTabKey(event) { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const mode = event.key === 'Home' ? 'single' : event.key === 'End' ? 'continuous' : this.view.mode === 'single' ? 'continuous' : 'single'; this.$emit('mode', mode); this.$refs.dialog.querySelector(`#mobile-voice-${mode}`)?.focus(); },
50
+ },
51
+ };
52
+ </script>
53
+ <style scoped>
54
+ .lares-voice-sheet { color: var(--q-ink-1, #202124); background: var(--q-background-1, white); width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border: 0; padding: 0; }
55
+ .lares-voice-sheet::backdrop { background: #10182866; }
56
+ .lares-voice-sheet__body { padding: max(24px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); max-width: 560px; margin: auto; }
57
+ header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { font-size: 22px; }
58
+ button { color: inherit; background: transparent; border: 1px solid var(--q-separator, #ddd); border-radius: 14px; padding: 12px 16px; font: inherit; min-height: 44px; cursor: pointer; }
59
+ button:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; } button:disabled { opacity: .45; }
60
+ [role=tablist] { display: flex; gap: 8px; margin: 24px 0; } [role=tab] { flex: 1; } [aria-selected=true] { border-color: #3478f6; background: #3478f61a; }
61
+ section { text-align: center; } .lares-voice-sheet__device { font-size: 14px; opacity: .65; }
62
+ .lares-voice-sheet__wave { width: 160px; height: 64px; margin: 24px auto 0; stroke: #3478f6; stroke-width: 3; stroke-linecap: round; }
63
+ .lares-voice-sheet__draft { text-align: start; white-space: pre-wrap; min-height: 100px; max-height: 30dvh; overflow: auto; background: #8881; padding: 16px; border-radius: 12px; }
64
+ .lares-voice-sheet__actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 32px; }
65
+ </style>