@olares/lares-mobile 0.15.16 → 0.15.18

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.16",
3
+ "version": "0.15.18",
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.16",
31
+ "@olares/lares-core": "0.15.18",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -51,6 +51,8 @@
51
51
  @rename-session="renameSession"
52
52
  @fork-session="forkSession"
53
53
  @archive-session="archiveSession"
54
+ @dictate="startDictation"
55
+ @voice-action="onVoiceAction"
54
56
  />
55
57
  <LaresMobileShell
56
58
  v-else
@@ -100,6 +102,10 @@
100
102
  @pick-command="pickCommand"
101
103
  @toggle-speak="toggleSpeak"
102
104
  />
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>
103
109
  <LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
104
110
  @close="runVoicePanel('close')" @edit="voicePanel.edit($event)" @preset="voicePanel.selectPreset($event, true).catch(() => {})"
105
111
  @complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" />
@@ -107,6 +113,8 @@
107
113
  </template>
108
114
 
109
115
  <script>
116
+ import { modelState, modelStateLabel } from "@olares/lares-core/router/model-state";
117
+ import { matchVoiceShortcut } from "@olares/lares-core/voice/shortcuts";
110
118
  import { subscribeVoiceSettings } from "@olares/lares-core/larepass/settings";
111
119
  import { createVoicePanel } from "@olares/lares-core/voice/panel";
112
120
  import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
@@ -116,10 +124,13 @@ import { connectChat } from "./runtime.js";
116
124
  import { createT } from "./i18n.js";
117
125
  import { failText, withPendingUser } from "./chat/format.js";
118
126
  import { captureSource } from "@olares/lares-core/voice/input";
127
+ import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
119
128
  import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
120
129
  import { createAnswerPlayer } from "./chat/playback.js";
121
130
  import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
122
- import { wavBlob } from "@olares/lares-core/voice/client";
131
+ import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
132
+ import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
133
+ import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
123
134
  import { browserPlays } from "@olares/lares-core/voice/playback";
124
135
  import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
125
136
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
@@ -220,6 +231,8 @@ export default {
220
231
  historyLoading: false,
221
232
  modelCatalog: null,
222
233
  modelError: "",
234
+ modelCatalogError: "",
235
+ modelCatalogLoading: false,
223
236
  modelPending: "",
224
237
  modelSheet: false,
225
238
  effortSheet: false,
@@ -234,6 +247,7 @@ export default {
234
247
  voiceLanguage: "",
235
248
  voiceSpeak: false,
236
249
  voiceDefaultMode: "continuous",
250
+ voiceSettings: null,
237
251
  voiceStreaming: false,
238
252
  speakBusy: false,
239
253
  speakNotice: "",
@@ -326,7 +340,9 @@ export default {
326
340
  sessions: this.sessions,
327
341
  sessionsReady: this.sessionsReady,
328
342
  models: this.models,
343
+ refreshModels: () => this.loadModelCatalog(true),
329
344
  modelError: this.modelError,
345
+ modelCatalogError: this.modelCatalogError,
330
346
  modelGroups: this.modelGroups,
331
347
  modelCurrent: this.modelCurrent,
332
348
  modelLabel: this.modelLabel,
@@ -343,6 +359,8 @@ export default {
343
359
  voiceDefaultMode: this.voiceDefaultMode,
344
360
  voiceCall: this.voiceCall,
345
361
  voicePanelState: this.voicePanelState,
362
+ voiceReady: Boolean(this.voiceSettings),
363
+ voiceDictation: !this.voiceSettings || laresVoiceAvailability(this.voiceSettings).dictation,
346
364
  voicePhase: this.voicePhase,
347
365
  voiceElapsed: this.voiceElapsed,
348
366
  voiceErrorText: this.voiceErrorText,
@@ -427,8 +445,11 @@ export default {
427
445
  }
428
446
  return null;
429
447
  },
448
+ modelStatus() {
449
+ return { models: this.models?.models ?? [], current: this.models?.default, status: this.modelCatalogLoading || !this.modelCatalog ? "loading" : "ready", failures: this.models?.failures, error: this.modelCatalogError };
450
+ },
430
451
  modelLabel() {
431
- return this.currentListedModel?.name || this.models?.default?.model || this.t("model.select");
452
+ return modelStateLabel(this.modelStatus, this.locale || "en");
432
453
  },
433
454
  liveModelSelection() {
434
455
  const row = this.sessions.find((item) => item.sessionId === this.sessionId);
@@ -507,11 +528,12 @@ export default {
507
528
  return this.models?.default ? selectionKey(this.models.default) : "";
508
529
  },
509
530
  modelBusy() {
510
- return Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
531
+ return this.modelCatalogLoading || Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
511
532
  },
512
533
  modelBlocked() {
513
- return this.chatDevice === "desktop" && this.models?.routable === false;
534
+ return !modelState(this.modelStatus).valid;
514
535
  },
536
+ voiceReadAloudError() { return this.t("settings.readAloudSyncFailed"); },
515
537
  voiceErrorText() {
516
538
  return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
517
539
  },
@@ -541,7 +563,8 @@ export default {
541
563
  this.player = createAnswerPlayer();
542
564
  this.voicePanel = createVoicePanel({
543
565
  create: (options) => this.createMobileVoiceConversation(options),
544
- loadConfig: async () => (await this.runtime.settings.voice(true)).config,
566
+ prepareInput: (config) => config?.device === "headset" ? null : prepareBrowserCapture(),
567
+ loadConfig: async () => (await this.runtime.settings.voice(false, { diagnostics: false })).config,
545
568
  process: async ({ text, preset, config, signal }) => { const response = await this.runtime.voiceRequest("/api/lares/voice/dictation/clean", { method: "POST", body: { text, preset, config: { dictationProfiles: config.dictationProfiles } }, signal }); if (!response.ok) throw new Error(response.body?.error?.code || "voice_cleanup_failed"); return response.body.text; },
546
569
  complete: async ({ sessionId, text }) => {
547
570
  const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
@@ -554,7 +577,13 @@ export default {
554
577
  void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
555
578
  },
556
579
  });
557
- this.voiceSettingsUnsub = subscribeVoiceSettings((value) => { this.voiceDefaultMode = value.config.mobileMode; });
580
+ this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
581
+ this.voiceDefaultMode = value.config.mobileMode; this.voiceSettings = value;
582
+ const next = value.config.speak === true;
583
+ if (this.voiceSpeak && !next) this.reader?.stop();
584
+ this.voiceSpeak = next;
585
+ void this.voicePanel.setReadAloud(next);
586
+ });
558
587
  this.voicePanelState = this.voicePanel.getSnapshot();
559
588
  this.voicePanelUnsub = this.voicePanel.subscribe(() => {
560
589
  const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
@@ -563,6 +592,7 @@ export default {
563
592
  });
564
593
  this.replySpeaker = createReplySpeaker();
565
594
  this.reader = createSentencePlayer({
595
+ stream: (value) => playAudioStream({ ...value, request: (path, options) => this.runtime.voiceRequest(path, options), headers: voiceHeaders(captureSource("mobile", this.voiceSettings?.config?.device)), socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }),
566
596
  send: async (request) => {
567
597
  const result = await this.runtime.speak?.(request);
568
598
  if (!result) throw Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" });
@@ -593,9 +623,12 @@ export default {
593
623
  this.busyEnter = section.busyEnter;
594
624
  });
595
625
  this.voiceVisibilityHandler = () => {
596
- if (document.hidden) void this.voicePanel.pause().catch(() => {});
626
+ if (document.hidden) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); }
597
627
  };
628
+ this.voiceOfflineHandler = () => { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); };
629
+ window.addEventListener("offline", this.voiceOfflineHandler);
598
630
  document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
631
+ window.addEventListener("keydown", this.onVoiceShortcut, true);
599
632
  this.voiceClock = setInterval(() => {
600
633
  if (this.voicePhase === "recording") this.voiceElapsed += 1;
601
634
  }, 1000);
@@ -605,11 +638,14 @@ export default {
605
638
  this.restoreLog();
606
639
  },
607
640
  deactivated() {
641
+ this.reader.stop();
608
642
  void this.voicePanel?.pause().catch(() => {});
609
643
  this.captureLog();
610
644
  },
611
645
  beforeUnmount() {
646
+ window.removeEventListener("offline", this.voiceOfflineHandler);
612
647
  document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
648
+ window.removeEventListener("keydown", this.onVoiceShortcut, true);
613
649
  clearInterval(this.voiceClock);
614
650
  this.captureLog();
615
651
  this.noteSeen(this.sessionId);
@@ -691,8 +727,8 @@ export default {
691
727
  this.items = snap.items;
692
728
  this.running = snap.running;
693
729
  this.runningSince = snap.runningSince || 0;
694
- const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance, this.voiceStreaming) : null;
695
- if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
730
+ const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, true) : null;
731
+ 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}` });
696
732
  this.failed = snap.failed;
697
733
  this.error = snap.error;
698
734
  this.sessions = snap.sessions ?? this.sessions;
@@ -972,6 +1008,7 @@ export default {
972
1008
  try {
973
1009
  await this.runtime.start({ workspaces: this.chatDevice === "desktop" });
974
1010
  if (gen !== this.hostGen) return;
1011
+ void this.runtime.settings.voice(false, { diagnostics: false }).then(value => { if (gen === this.hostGen) { this.voiceSettings = value; this.voiceDefaultMode = value.config.mobileMode; } }).catch(() => {});
975
1012
  if (this.chatDevice !== "desktop") await this.runtime.refreshWorkspaces();
976
1013
  await this.loadComposerMeta();
977
1014
  } finally {
@@ -986,6 +1023,7 @@ export default {
986
1023
  this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
987
1024
  },
988
1025
  switchHost() {
1026
+ this.voiceSettings = null;
989
1027
  void this.voicePanel?.end().catch(() => {});
990
1028
  for (const controller of this.imageUploadControllers.values()) controller.abort();
991
1029
  this.imageUploadControllers.clear();
@@ -1002,6 +1040,8 @@ export default {
1002
1040
  this.failed = "";
1003
1041
  this.error = "";
1004
1042
  this.modelCatalog = null;
1043
+ this.modelCatalogError = "";
1044
+ this.modelCatalogLoading = false;
1005
1045
  this.modelError = "";
1006
1046
  this.modelPending = "";
1007
1047
  this.modelSheet = false;
@@ -1076,17 +1116,21 @@ export default {
1076
1116
  }
1077
1117
  await Promise.all(jobs);
1078
1118
  },
1079
- async loadModelCatalog() {
1119
+ async loadModelCatalog(refresh = false) {
1080
1120
  const gen = ++this.modelCatalogGen;
1081
- const loaded = await this.runtime.modelCatalog();
1082
- if (gen !== this.modelCatalogGen) return;
1083
- if (!loaded.ok) {
1084
- this.modelCatalog = null;
1085
- this.modelError = loaded.error?.message || loaded.error?.code || "models";
1086
- return;
1121
+ this.modelCatalogLoading = true;
1122
+ try {
1123
+ if (refresh) await this.runtime.refreshModelCatalog();
1124
+ const loaded = await this.runtime.modelCatalog();
1125
+ if (gen !== this.modelCatalogGen) return;
1126
+ if (!loaded.ok) throw new Error(loaded.error?.message || loaded.error?.code || "models");
1127
+ this.modelCatalog = loaded.value ?? null;
1128
+ this.modelCatalogError = "";
1129
+ } catch (error) {
1130
+ if (gen === this.modelCatalogGen) this.modelCatalogError = error.message;
1131
+ } finally {
1132
+ if (gen === this.modelCatalogGen) this.modelCatalogLoading = false;
1087
1133
  }
1088
- this.modelCatalog = loaded.value ?? null;
1089
- this.modelError = "";
1090
1134
  },
1091
1135
  openEffortSheet() {
1092
1136
  if (this.effortDisabled) return;
@@ -1114,7 +1158,7 @@ export default {
1114
1158
  },
1115
1159
  openModelSheet() {
1116
1160
  this.modelSheet = true;
1117
- if (!this.modelCatalog) void this.loadModelCatalog();
1161
+ if (!this.modelCatalog || this.modelCatalogError || this.models?.failures?.length) void this.loadModelCatalog(true);
1118
1162
  },
1119
1163
  async chooseModel(model) {
1120
1164
  const key = this.modelKey(model);
@@ -1222,10 +1266,40 @@ export default {
1222
1266
  this.draftImages = [];
1223
1267
  this.imagesBySession = {};
1224
1268
  },
1269
+ async onVoiceShortcut(event) {
1270
+ if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
1271
+ if (event.key === "Escape" && !event.defaultPrevented) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); return; }
1272
+ const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
1273
+ if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
1274
+ event.preventDefault();
1275
+ await this.openDictation(action);
1276
+ },
1277
+ async startDictation() {
1278
+ await this.openDictation();
1279
+ },
1280
+ async openDictation(preset) {
1281
+ if (document.hidden || navigator.onLine === false) return;
1282
+ if (!this.voiceSettings) {
1283
+ try { this.voiceSettings = await this.runtime.settings.voice(false, { diagnostics: false }); }
1284
+ catch { return; }
1285
+ }
1286
+ if (!laresVoiceAvailability(this.voiceSettings).dictation) return;
1287
+ const view = this.voicePanel.getSnapshot();
1288
+ if (view.busy) return;
1289
+ if (view.visible && view.mode === "single") { if (view.snapshot?.capture === "recording") await this.voicePanel.finish().catch(() => {}); return; }
1290
+ await this.voicePanel.pause().catch(() => {});
1291
+ await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
1292
+ },
1225
1293
  async onHoldStart() {
1226
- const settings = await this.runtime.settings.voice(true);
1294
+ if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
1295
+ await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
1296
+ return;
1297
+ }
1298
+ const settings = await this.runtime.settings.voice(false, { diagnostics: false });
1227
1299
  this.voiceDefaultMode = settings.config.mobileMode;
1228
- await this.voicePanel.open({ mode: this.voicePanelState?.mode === "single" && this.voicePanelState.raw ? "single" : this.voiceDefaultMode, sessionId: this.sessionId || null });
1300
+ const available = laresVoiceAvailability(settings, !this.modelBlocked);
1301
+ if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
1302
+ await this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null });
1229
1303
  },
1230
1304
  onVoiceAction({ action, value }) {
1231
1305
  if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
@@ -1234,13 +1308,16 @@ export default {
1234
1308
  },
1235
1309
  runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1236
1310
  async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
1311
+ config = laresVoiceConfig(config, mode);
1237
1312
  const settings = { config };
1238
1313
  const continuous = mode === "continuous";
1239
- onDevice(captureSource("mobile", settings.config.device));
1314
+ const device = settings.config.device === "headset" ? "headset" : "device";
1315
+ onDevice(device);
1240
1316
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1241
1317
  this.reader.stop(); this.player?.stop(); this.player?.unlock();
1242
1318
  let conversation;
1243
1319
  const reader = createSentencePlayer({
1320
+ stream: device === "device" ? (value) => playAudioStream({ ...value, request: (path, options) => this.runtime.voiceRequest(path, options), headers: conversation.headers, socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }) : undefined,
1244
1321
  send: (value) => conversation.speak(value),
1245
1322
  play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1246
1323
  halt: () => this.player.stop(), silence: () => {},
@@ -1248,17 +1325,18 @@ export default {
1248
1325
  fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
1249
1326
  });
1250
1327
  conversation = createVoiceConversation({
1328
+ 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") }),
1251
1329
  captureConfig: { model: config.model, language: config.language },
1252
1330
  request: (path, options) => this.runtime.voiceRequest(path, options),
1253
1331
  transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model),
1254
- device: captureSource("mobile", settings.config.device),
1332
+ device,
1255
1333
  readAloud: continuous && settings.config.speak,
1256
- preset: continuous && settings.config.preset === "dictation" ? "assistant" : settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1334
+ preset: settings.config.preset, backend: settings.config.backend, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1257
1335
  presentationSession: true,
1258
1336
  onTranscript: continuous ? null : onTranscript,
1259
1337
  mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1260
- playback: { stop: () => { reader.stop(); this.player.stop(); } },
1261
- onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
1338
+ playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
1339
+ onReply: (reply) => { const value = speakable(reply, device === "device"); if (value) reader.follow(value); },
1262
1340
  });
1263
1341
  return conversation;
1264
1342
  },
@@ -1449,6 +1527,8 @@ export default {
1449
1527
  async send(mode = "queue") {
1450
1528
  const text = this.draft.trim();
1451
1529
  if (!this.canSend) return;
1530
+ this.reader.stop();
1531
+ if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1452
1532
  this.player?.unlock();
1453
1533
  const sessionId = this.sessionId;
1454
1534
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
@@ -6,6 +6,7 @@
6
6
  :data-hero="hero ? 'true' : 'false'"
7
7
  :data-mode="voiceView?.inputActive ? 'voice' : 'text'"
8
8
  :data-expanded="expanded ? 'true' : 'false'"
9
+ :data-attachments="draftImages.length > 0"
9
10
  :data-holding="voiceView?.holding ? 'true' : 'false'"
10
11
  :data-cancel-armed="Boolean(voiceView?.cancelArmed)"
11
12
  >
@@ -137,7 +138,7 @@
137
138
  <svg v-else viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="3" /><path d="M7 9h1m3 0h1m3 0h1M7 12h1m3 0h1m3 0h1M8 15h8" /></svg>
138
139
  </button>
139
140
  <button
140
- v-if="expanded && !voiceView?.inputActive"
141
+ v-if="hasContent && !voiceView?.inputActive"
141
142
  type="submit"
142
143
  class="lares-composer__icon"
143
144
  data-kind="send"
@@ -202,6 +203,9 @@ export default {
202
203
  ],
203
204
  data() {
204
205
  return {
206
+ inputMultiline: false,
207
+ inputWidth: 0,
208
+ inputResizeObserver: null,
205
209
  commandsOpen: false,
206
210
  commandTyped: false,
207
211
  commandIndex: 0,
@@ -219,8 +223,11 @@ export default {
219
223
  if (this.hero) return this.t("chat.placeholderNewSession");
220
224
  return this.t("chat.placeholder");
221
225
  },
226
+ hasContent() {
227
+ return this.draft.trim().length > 0 || this.draftImages.length > 0;
228
+ },
222
229
  expanded() {
223
- return !this.voiceView?.inputActive && (this.draft.trim().length > 0 || this.draftImages.length > 0);
230
+ return !this.voiceView?.inputActive && this.inputMultiline;
224
231
  },
225
232
  referenceMenuRows() {
226
233
  return referenceRows(this.references, this.t);
@@ -236,6 +243,9 @@ export default {
236
243
  draft(value) {
237
244
  this.autosize();
238
245
  },
246
+ "voiceView.inputActive"() {
247
+ this.autosize();
248
+ },
239
249
  overlayPointerActive(active) {
240
250
  if (active) window.addEventListener("pointerdown", this.onTokenPointer, true);
241
251
  else window.removeEventListener("pointerdown", this.onTokenPointer, true);
@@ -243,8 +253,16 @@ export default {
243
253
  },
244
254
  mounted() {
245
255
  this.autosize();
256
+ this.inputResizeObserver = new ResizeObserver(([entry]) => {
257
+ const width = entry.contentRect.width;
258
+ if (width <= 0 || width === this.inputWidth) return;
259
+ this.inputWidth = width;
260
+ this.autosize();
261
+ });
262
+ this.inputResizeObserver.observe(this.$refs.input);
246
263
  },
247
264
  beforeUnmount() {
265
+ this.inputResizeObserver?.disconnect();
248
266
  window.clearTimeout(this.referenceTimer);
249
267
  window.removeEventListener("pointerdown", this.onTokenPointer, true);
250
268
  },
@@ -398,9 +416,13 @@ export default {
398
416
  autosize() {
399
417
  this.$nextTick(() => {
400
418
  const el = this.$refs.input;
401
- if (!el) return;
402
- el.style.height = "auto";
403
- el.style.height = `${Math.min(el.scrollHeight, 176)}px`;
419
+ if (!el || !el.getClientRects().length) return;
420
+ el.style.height = "36px";
421
+ el.style.overflowY = "hidden";
422
+ const height = Math.max(36, el.scrollHeight);
423
+ this.inputMultiline = height > 36;
424
+ el.style.height = `${Math.min(height, 176)}px`;
425
+ el.style.overflowY = height > 176 ? "auto" : "hidden";
404
426
  });
405
427
  },
406
428
  focus() {
@@ -542,9 +564,9 @@ export default {
542
564
  white-space: nowrap;
543
565
  }
544
566
 
545
- .lares-composer__card[data-expanded="true"] {
567
+ .lares-composer__card[data-expanded="true"],
568
+ .lares-composer__card[data-attachments="true"] {
546
569
  border-radius: 24px;
547
- padding: 16px 14px 12px;
548
570
  }
549
571
 
550
572
  .lares-composer__card[data-holding="true"] {
@@ -636,15 +658,7 @@ export default {
636
658
  }
637
659
 
638
660
  .lares-composer__line[data-expanded="true"] {
639
- flex-wrap: wrap;
640
- row-gap: 14px;
641
- }
642
-
643
- .lares-composer__line[data-expanded="true"] .lares-composer__input {
644
- order: -1;
645
- flex: 1 0 100%;
646
- min-height: 26px;
647
- padding: 0;
661
+ align-items: flex-end;
648
662
  }
649
663
 
650
664
  .lares-composer__input,
@@ -659,6 +673,8 @@ export default {
659
673
  }
660
674
 
661
675
  .lares-composer__input {
676
+ box-sizing: border-box;
677
+ height: 36px;
662
678
  max-height: 176px;
663
679
  resize: none;
664
680
  overflow-y: auto;
@@ -719,10 +735,6 @@ export default {
719
735
  gap: 12px;
720
736
  }
721
737
 
722
- .lares-composer__line[data-expanded="true"] .lares-composer__tools {
723
- margin-left: auto;
724
- }
725
-
726
738
  .lares-composer__picker {
727
739
  display: none;
728
740
  }
@@ -34,6 +34,10 @@
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>
37
41
  <div v-if="row.type === 'user'" class="lares-msg" data-role="user">{{ row.text }}</div>
38
42
  <LaresStageRow
39
43
  v-else-if="row.type === 'context'"
@@ -79,7 +83,7 @@
79
83
  <template v-else-if="row.type === 'assistant'">
80
84
  <div class="lares-md" v-html="renderMarkdown(row.text)" />
81
85
  <p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
82
- <button v-if="isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
86
+ <button v-if="!readout && isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
83
87
  <LaresMessageActions
84
88
  :text="row.text"
85
89
  :reaction="reactions[actionKey(index)] || ''"
@@ -107,6 +111,7 @@
107
111
  </template>
108
112
 
109
113
  <script>
114
+ import { readoutState } from "@olares/lares-core/voice/speech";
110
115
  import { partitionPreviews } from "@olares/lares-core/files/preview-groups";
111
116
  import { parseAskUserQuestions } from "@olares/lares-core/larepass/questions";
112
117
  import { hidesRunningClock, processFoldLabel, transcriptBlocks } from "@olares/lares-core/larepass/process-fold";
@@ -142,6 +147,7 @@ export default {
142
147
  emits: ["open", "media", "answer", "stop-speaking"],
143
148
  data() {
144
149
  return {
150
+ readout: null, readoutUnsub: null,
145
151
  applyingScroll: false,
146
152
  logRo: null,
147
153
  logPin: 0,
@@ -153,6 +159,7 @@ export default {
153
159
  };
154
160
  },
155
161
  computed: {
162
+ speechLabel() { return this.t(`readout.${this.readout?.status}`); },
156
163
  runningText() {
157
164
  return runningLabel(this.runningSince, this.t, this.now);
158
165
  },
@@ -172,17 +179,25 @@ export default {
172
179
  runningSince: "syncClock",
173
180
  },
174
181
  mounted() {
182
+ this.readout = readoutState.getSnapshot();
183
+ this.readoutUnsub = readoutState.subscribe(() => { this.readout = readoutState.getSnapshot(); });
175
184
  this.bindLog();
176
185
  this.restoreLog();
177
186
  this.syncClock();
178
187
  },
179
188
  beforeUnmount() {
189
+ this.readoutUnsub?.();
180
190
  this.captureLog();
181
191
  this.unbindLog();
182
192
  if (this.logPin) cancelAnimationFrame(this.logPin);
183
193
  clearInterval(this.clock);
184
194
  },
185
195
  methods: {
196
+ hasReadout(row) {
197
+ if (!this.readout || this.readout.sessionId !== this.sessionId || row.type !== "assistant" || row.turn !== this.readout.turn) return false;
198
+ const latest = this.items.findLast(item => item.type === "assistant" && item.turn === this.readout.turn);
199
+ return latest && (row.id || row.key) === (latest.id || latest.key);
200
+ },
186
201
  isSpeaking(row) {
187
202
  const snapshot = this.voiceView?.snapshot;
188
203
  if (snapshot?.playback !== "playing") return false;
@@ -569,5 +584,7 @@ export default {
569
584
  </style>
570
585
 
571
586
  <style scoped>
572
- .lares-message-stop { border: 0; background: transparent; color: var(--q-ink-2, #777); font: inherit; font-size: 12px; padding: 6px 0; cursor: pointer; }
587
+ .lares-message-readout { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-block: 8px; color: var(--q-ink-2, #777); font-size: 13px; }
588
+ .lares-message-stop { appearance: none; border: 1px solid var(--q-stroke-2, #8885); border-radius: 8px; background: transparent; color: inherit; font: inherit; padding: 5px 10px; cursor: pointer; }
589
+ .lares-message-stop:focus-visible { outline: 2px solid var(--q-blue-default, #467cff); outline-offset: 2px; }
573
590
  </style>
@@ -14,6 +14,7 @@
14
14
  </div>
15
15
  </details>
16
16
  <textarea class="lares-voice-draft" :aria-label="t('panel.draft')" :value="view.draft" :disabled="ui.pending" @input="$emit('edit', $event.target.value)" />
17
+ <button v-if="view.stage === 'processing' && view.raw" type="button" @click="choose('raw')">{{ t('dictation.useOriginal') }}</button>
17
18
  <div v-if="showOriginal" class="lares-voice-original" :aria-label="t('dictation.original')">{{ view.raw }}</div>
18
19
  </template>
19
20
  </section>