@olares/lares-mobile 0.15.17 → 0.15.19

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.17",
3
+ "version": "0.15.19",
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.17",
31
+ "@olares/lares-core": "0.15.19",
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,9 @@
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";
118
+ import { markRaw } from "vue";
110
119
  import { subscribeVoiceSettings } from "@olares/lares-core/larepass/settings";
111
120
  import { createVoicePanel } from "@olares/lares-core/voice/panel";
112
121
  import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
@@ -116,10 +125,13 @@ import { connectChat } from "./runtime.js";
116
125
  import { createT } from "./i18n.js";
117
126
  import { failText, withPendingUser } from "./chat/format.js";
118
127
  import { captureSource } from "@olares/lares-core/voice/input";
128
+ import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
119
129
  import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
120
130
  import { createAnswerPlayer } from "./chat/playback.js";
121
131
  import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
122
- import { wavBlob } from "@olares/lares-core/voice/client";
132
+ import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
133
+ import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
134
+ import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
123
135
  import { browserPlays } from "@olares/lares-core/voice/playback";
124
136
  import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
125
137
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
@@ -220,6 +232,8 @@ export default {
220
232
  historyLoading: false,
221
233
  modelCatalog: null,
222
234
  modelError: "",
235
+ modelCatalogError: "",
236
+ modelCatalogLoading: false,
223
237
  modelPending: "",
224
238
  modelSheet: false,
225
239
  effortSheet: false,
@@ -234,6 +248,7 @@ export default {
234
248
  voiceLanguage: "",
235
249
  voiceSpeak: false,
236
250
  voiceDefaultMode: "continuous",
251
+ voiceSettings: null,
237
252
  voiceStreaming: false,
238
253
  speakBusy: false,
239
254
  speakNotice: "",
@@ -326,7 +341,9 @@ export default {
326
341
  sessions: this.sessions,
327
342
  sessionsReady: this.sessionsReady,
328
343
  models: this.models,
344
+ refreshModels: () => this.loadModelCatalog(true),
329
345
  modelError: this.modelError,
346
+ modelCatalogError: this.modelCatalogError,
330
347
  modelGroups: this.modelGroups,
331
348
  modelCurrent: this.modelCurrent,
332
349
  modelLabel: this.modelLabel,
@@ -343,6 +360,8 @@ export default {
343
360
  voiceDefaultMode: this.voiceDefaultMode,
344
361
  voiceCall: this.voiceCall,
345
362
  voicePanelState: this.voicePanelState,
363
+ voiceReady: Boolean(this.voiceSettings),
364
+ voiceDictation: !this.voiceSettings || laresVoiceAvailability(this.voiceSettings).dictation,
346
365
  voicePhase: this.voicePhase,
347
366
  voiceElapsed: this.voiceElapsed,
348
367
  voiceErrorText: this.voiceErrorText,
@@ -427,8 +446,11 @@ export default {
427
446
  }
428
447
  return null;
429
448
  },
449
+ modelStatus() {
450
+ return { models: this.models?.models ?? [], current: this.models?.default, status: this.modelCatalogLoading || !this.modelCatalog ? "loading" : "ready", failures: this.models?.failures, error: this.modelCatalogError };
451
+ },
430
452
  modelLabel() {
431
- return this.currentListedModel?.name || this.models?.default?.model || this.t("model.select");
453
+ return modelStateLabel(this.modelStatus, this.locale || "en");
432
454
  },
433
455
  liveModelSelection() {
434
456
  const row = this.sessions.find((item) => item.sessionId === this.sessionId);
@@ -507,11 +529,12 @@ export default {
507
529
  return this.models?.default ? selectionKey(this.models.default) : "";
508
530
  },
509
531
  modelBusy() {
510
- return Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
532
+ return this.modelCatalogLoading || Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
511
533
  },
512
534
  modelBlocked() {
513
- return this.chatDevice === "desktop" && this.models?.routable === false;
535
+ return !modelState(this.modelStatus).valid;
514
536
  },
537
+ voiceReadAloudError() { return this.t("settings.readAloudSyncFailed"); },
515
538
  voiceErrorText() {
516
539
  return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
517
540
  },
@@ -541,7 +564,8 @@ export default {
541
564
  this.player = createAnswerPlayer();
542
565
  this.voicePanel = createVoicePanel({
543
566
  create: (options) => this.createMobileVoiceConversation(options),
544
- loadConfig: async () => (await this.runtime.settings.voice(true)).config,
567
+ prepareInput: (config) => config?.device === "headset" ? null : prepareBrowserCapture(),
568
+ loadConfig: async () => (await this.runtime.settings.voice(false, { diagnostics: false })).config,
545
569
  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
570
  complete: async ({ sessionId, text }) => {
547
571
  const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
@@ -554,7 +578,13 @@ export default {
554
578
  void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
555
579
  },
556
580
  });
557
- this.voiceSettingsUnsub = subscribeVoiceSettings((value) => { this.voiceDefaultMode = value.config.mobileMode; });
581
+ this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
582
+ this.voiceDefaultMode = value.config.mobileMode; this.voiceSettings = markRaw(value);
583
+ const next = value.config.speak === true;
584
+ if (this.voiceSpeak && !next) this.reader?.stop();
585
+ this.voiceSpeak = next;
586
+ void this.voicePanel.setReadAloud(next);
587
+ });
558
588
  this.voicePanelState = this.voicePanel.getSnapshot();
559
589
  this.voicePanelUnsub = this.voicePanel.subscribe(() => {
560
590
  const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
@@ -563,6 +593,7 @@ export default {
563
593
  });
564
594
  this.replySpeaker = createReplySpeaker();
565
595
  this.reader = createSentencePlayer({
596
+ 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
597
  send: async (request) => {
567
598
  const result = await this.runtime.speak?.(request);
568
599
  if (!result) throw Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" });
@@ -593,9 +624,12 @@ export default {
593
624
  this.busyEnter = section.busyEnter;
594
625
  });
595
626
  this.voiceVisibilityHandler = () => {
596
- if (document.hidden) void this.voicePanel.pause().catch(() => {});
627
+ if (document.hidden) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); }
597
628
  };
629
+ this.voiceOfflineHandler = () => { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); };
630
+ window.addEventListener("offline", this.voiceOfflineHandler);
598
631
  document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
632
+ window.addEventListener("keydown", this.onVoiceShortcut, true);
599
633
  this.voiceClock = setInterval(() => {
600
634
  if (this.voicePhase === "recording") this.voiceElapsed += 1;
601
635
  }, 1000);
@@ -605,11 +639,14 @@ export default {
605
639
  this.restoreLog();
606
640
  },
607
641
  deactivated() {
642
+ this.reader.stop();
608
643
  void this.voicePanel?.pause().catch(() => {});
609
644
  this.captureLog();
610
645
  },
611
646
  beforeUnmount() {
647
+ window.removeEventListener("offline", this.voiceOfflineHandler);
612
648
  document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
649
+ window.removeEventListener("keydown", this.onVoiceShortcut, true);
613
650
  clearInterval(this.voiceClock);
614
651
  this.captureLog();
615
652
  this.noteSeen(this.sessionId);
@@ -691,8 +728,8 @@ export default {
691
728
  this.items = snap.items;
692
729
  this.running = snap.running;
693
730
  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}` });
731
+ const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, true) : null;
732
+ 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
733
  this.failed = snap.failed;
697
734
  this.error = snap.error;
698
735
  this.sessions = snap.sessions ?? this.sessions;
@@ -972,6 +1009,7 @@ export default {
972
1009
  try {
973
1010
  await this.runtime.start({ workspaces: this.chatDevice === "desktop" });
974
1011
  if (gen !== this.hostGen) return;
1012
+ void this.runtime.settings.voice(false, { diagnostics: false }).then(value => { if (gen === this.hostGen) { this.voiceSettings = markRaw(value); this.voiceDefaultMode = value.config.mobileMode; } }).catch(() => {});
975
1013
  if (this.chatDevice !== "desktop") await this.runtime.refreshWorkspaces();
976
1014
  await this.loadComposerMeta();
977
1015
  } finally {
@@ -986,6 +1024,7 @@ export default {
986
1024
  this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
987
1025
  },
988
1026
  switchHost() {
1027
+ this.voiceSettings = null;
989
1028
  void this.voicePanel?.end().catch(() => {});
990
1029
  for (const controller of this.imageUploadControllers.values()) controller.abort();
991
1030
  this.imageUploadControllers.clear();
@@ -1002,6 +1041,8 @@ export default {
1002
1041
  this.failed = "";
1003
1042
  this.error = "";
1004
1043
  this.modelCatalog = null;
1044
+ this.modelCatalogError = "";
1045
+ this.modelCatalogLoading = false;
1005
1046
  this.modelError = "";
1006
1047
  this.modelPending = "";
1007
1048
  this.modelSheet = false;
@@ -1076,17 +1117,21 @@ export default {
1076
1117
  }
1077
1118
  await Promise.all(jobs);
1078
1119
  },
1079
- async loadModelCatalog() {
1120
+ async loadModelCatalog(refresh = false) {
1080
1121
  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;
1122
+ this.modelCatalogLoading = true;
1123
+ try {
1124
+ if (refresh) await this.runtime.refreshModelCatalog();
1125
+ const loaded = await this.runtime.modelCatalog();
1126
+ if (gen !== this.modelCatalogGen) return;
1127
+ if (!loaded.ok) throw new Error(loaded.error?.message || loaded.error?.code || "models");
1128
+ this.modelCatalog = loaded.value ?? null;
1129
+ this.modelCatalogError = "";
1130
+ } catch (error) {
1131
+ if (gen === this.modelCatalogGen) this.modelCatalogError = error.message;
1132
+ } finally {
1133
+ if (gen === this.modelCatalogGen) this.modelCatalogLoading = false;
1087
1134
  }
1088
- this.modelCatalog = loaded.value ?? null;
1089
- this.modelError = "";
1090
1135
  },
1091
1136
  openEffortSheet() {
1092
1137
  if (this.effortDisabled) return;
@@ -1114,7 +1159,7 @@ export default {
1114
1159
  },
1115
1160
  openModelSheet() {
1116
1161
  this.modelSheet = true;
1117
- if (!this.modelCatalog) void this.loadModelCatalog();
1162
+ if (!this.modelCatalog || this.modelCatalogError || this.models?.failures?.length) void this.loadModelCatalog(true);
1118
1163
  },
1119
1164
  async chooseModel(model) {
1120
1165
  const key = this.modelKey(model);
@@ -1222,10 +1267,40 @@ export default {
1222
1267
  this.draftImages = [];
1223
1268
  this.imagesBySession = {};
1224
1269
  },
1270
+ async onVoiceShortcut(event) {
1271
+ if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
1272
+ if (event.key === "Escape" && !event.defaultPrevented) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); return; }
1273
+ const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
1274
+ if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
1275
+ event.preventDefault();
1276
+ await this.openDictation(action);
1277
+ },
1278
+ async startDictation() {
1279
+ await this.openDictation();
1280
+ },
1281
+ async openDictation(preset) {
1282
+ if (document.hidden || navigator.onLine === false) return;
1283
+ if (!this.voiceSettings) {
1284
+ try { this.voiceSettings = markRaw(await this.runtime.settings.voice(false, { diagnostics: false })); }
1285
+ catch { return; }
1286
+ }
1287
+ if (!laresVoiceAvailability(this.voiceSettings).dictation) return;
1288
+ const view = this.voicePanel.getSnapshot();
1289
+ if (view.busy) return;
1290
+ if (view.visible && view.mode === "single") { if (view.snapshot?.capture === "recording") await this.voicePanel.finish().catch(() => {}); return; }
1291
+ await this.voicePanel.pause().catch(() => {});
1292
+ await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
1293
+ },
1225
1294
  async onHoldStart() {
1226
- const settings = await this.runtime.settings.voice(true);
1295
+ if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
1296
+ await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
1297
+ return;
1298
+ }
1299
+ const settings = await this.runtime.settings.voice(false, { diagnostics: false });
1227
1300
  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 });
1301
+ const available = laresVoiceAvailability(settings, !this.modelBlocked);
1302
+ if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
1303
+ await this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null });
1229
1304
  },
1230
1305
  onVoiceAction({ action, value }) {
1231
1306
  if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
@@ -1234,13 +1309,16 @@ export default {
1234
1309
  },
1235
1310
  runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1236
1311
  async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
1312
+ config = laresVoiceConfig(config, mode);
1237
1313
  const settings = { config };
1238
1314
  const continuous = mode === "continuous";
1239
- onDevice(captureSource("mobile", settings.config.device));
1315
+ const device = settings.config.device === "headset" ? "headset" : "device";
1316
+ onDevice(device);
1240
1317
  if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1241
1318
  this.reader.stop(); this.player?.stop(); this.player?.unlock();
1242
1319
  let conversation;
1243
1320
  const reader = createSentencePlayer({
1321
+ 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
1322
  send: (value) => conversation.speak(value),
1245
1323
  play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1246
1324
  halt: () => this.player.stop(), silence: () => {},
@@ -1248,17 +1326,18 @@ export default {
1248
1326
  fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
1249
1327
  });
1250
1328
  conversation = createVoiceConversation({
1329
+ 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
1330
  captureConfig: { model: config.model, language: config.language },
1252
1331
  request: (path, options) => this.runtime.voiceRequest(path, options),
1253
1332
  transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model),
1254
- device: captureSource("mobile", settings.config.device),
1333
+ device,
1255
1334
  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,
1335
+ preset: settings.config.preset, backend: settings.config.backend, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1257
1336
  presentationSession: true,
1258
1337
  onTranscript: continuous ? null : onTranscript,
1259
1338
  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); },
1339
+ playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
1340
+ onReply: (reply) => { const value = speakable(reply, device === "device"); if (value) reader.follow(value); },
1262
1341
  });
1263
1342
  return conversation;
1264
1343
  },
@@ -1449,6 +1528,8 @@ export default {
1449
1528
  async send(mode = "queue") {
1450
1529
  const text = this.draft.trim();
1451
1530
  if (!this.canSend) return;
1531
+ this.reader.stop();
1532
+ if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
1452
1533
  this.player?.unlock();
1453
1534
  const sessionId = this.sessionId;
1454
1535
  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>