@olares/lares-mobile 0.15.12 → 0.15.14

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.14",
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.14",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -88,8 +88,7 @@
88
88
  @open-effort="openEffortSheet"
89
89
  @files="attachFiles"
90
90
  @hold-start="onHoldStart"
91
- @hold-end="onHoldEnd"
92
- @voice-action="voiceAction"
91
+ @voice-action="onVoiceAction"
93
92
  @close-model="modelSheet = false"
94
93
  @choose-model="chooseModel"
95
94
  @close-effort="effortSheet = false"
@@ -101,16 +100,22 @@
101
100
  @pick-command="pickCommand"
102
101
  @toggle-speak="toggleSpeak"
103
102
  />
103
+ <LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
104
+ @close="runVoicePanel('close')" @edit="voicePanel.edit($event)" @preset="voicePanel.selectPreset($event, true).catch(() => {})"
105
+ @complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" />
104
106
  </div>
105
107
  </template>
106
108
 
107
109
  <script>
110
+ import { subscribeVoiceSettings } from "@olares/lares-core/larepass/settings";
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";
111
117
  import { failText, withPendingUser } from "./chat/format.js";
112
118
  import { captureSource } from "@olares/lares-core/voice/input";
113
- import { createRealtimeConversation } from "@olares/lares-core/voice/realtime-conversation";
114
119
  import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
115
120
  import { createAnswerPlayer } from "./chat/playback.js";
116
121
  import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
@@ -171,7 +176,7 @@ function imageData(file) {
171
176
 
172
177
  export default {
173
178
  name: "LaresApp",
174
- components: { LaresMobileShell, LaresDesktopShell },
179
+ components: { LaresMobileShell, LaresDesktopShell, LaresVoicePanel },
175
180
  props: {
176
181
  locale: { type: String, default: "en" },
177
182
  device: { type: String, default: "mobile" },
@@ -221,8 +226,8 @@ export default {
221
226
  upload: { pending: 0, failures: [] },
222
227
  draftImages: [],
223
228
  voiceCall: null,
224
- voiceHistory: null,
225
- voiceCaption: "",
229
+ voicePanelState: null,
230
+ voiceRecords: [],
226
231
  voicePhase: "idle",
227
232
  voiceElapsed: 0,
228
233
  voiceError: "",
@@ -337,6 +342,7 @@ export default {
337
342
  draftImages: this.draftImages,
338
343
  voiceDefaultMode: this.voiceDefaultMode,
339
344
  voiceCall: this.voiceCall,
345
+ voicePanelState: this.voicePanelState,
340
346
  voicePhase: this.voicePhase,
341
347
  voiceElapsed: this.voiceElapsed,
342
348
  voiceErrorText: this.voiceErrorText,
@@ -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,28 @@ 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
+ loadConfig: async () => (await this.runtime.settings.voice(true)).config,
545
+ 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
+ complete: async ({ sessionId, text }) => {
547
+ const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
548
+ const value = [previous, text].filter(Boolean).join(" ");
549
+ this.draftBySession = { ...this.draftBySession, [sessionId || "@pending"]: value };
550
+ if (sessionId === (this.sessionId || null)) this.draft = value;
551
+ },
552
+ onSession: (sessionId) => {
553
+ this.voiceAdoptingSession = sessionId;
554
+ void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
555
+ },
556
+ });
557
+ this.voiceSettingsUnsub = subscribeVoiceSettings((value) => { this.voiceDefaultMode = value.config.mobileMode; });
558
+ this.voicePanelState = this.voicePanel.getSnapshot();
559
+ this.voicePanelUnsub = this.voicePanel.subscribe(() => {
560
+ const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
561
+ this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
562
+ this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
563
+ });
534
564
  this.replySpeaker = createReplySpeaker();
535
565
  this.reader = createSentencePlayer({
536
566
  send: async (request) => {
@@ -563,7 +593,7 @@ export default {
563
593
  this.busyEnter = section.busyEnter;
564
594
  });
565
595
  this.voiceVisibilityHandler = () => {
566
- if (document.hidden) void this.voiceConversation?.controller.disconnect("voice_background");
596
+ if (document.hidden) void this.voicePanel.pause().catch(() => {});
567
597
  };
568
598
  document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
569
599
  this.voiceClock = setInterval(() => {
@@ -575,6 +605,7 @@ export default {
575
605
  this.restoreLog();
576
606
  },
577
607
  deactivated() {
608
+ void this.voicePanel?.pause().catch(() => {});
578
609
  this.captureLog();
579
610
  },
580
611
  beforeUnmount() {
@@ -584,8 +615,10 @@ export default {
584
615
  this.noteSeen(this.sessionId);
585
616
  for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
586
617
  this.imageUploadControllers?.clear?.();
587
- void this.voiceConversation?.stop().catch(() => {});
588
- this.voiceUnsub?.();
618
+ void this.voicePanel?.dispose().catch(() => {});
619
+ this.voicePanelUnsub?.();
620
+ this.voiceSettingsUnsub?.();
621
+ this.voiceHistoryUnsub?.();
589
622
  this.conversationUnsub?.();
590
623
  this.intakeUnsub?.();
591
624
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
@@ -643,7 +676,7 @@ export default {
643
676
  this.commandsError = "";
644
677
  }
645
678
  if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
646
- void this.voiceConversation?.stop().catch(() => {});
679
+ void this.voicePanel?.pause().catch(() => {});
647
680
  this.reader.stop();
648
681
  this.player?.stop();
649
682
  }
@@ -953,7 +986,7 @@ export default {
953
986
  this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
954
987
  },
955
988
  switchHost() {
956
- void this.voiceConversation?.stop().catch(() => {});
989
+ void this.voicePanel?.end().catch(() => {});
957
990
  for (const controller of this.imageUploadControllers.values()) controller.abort();
958
991
  this.imageUploadControllers.clear();
959
992
  this.releaseDraftImages();
@@ -1189,90 +1222,53 @@ export default {
1189
1222
  this.draftImages = [];
1190
1223
  this.imagesBySession = {};
1191
1224
  },
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
- }
1212
- this.reader.stop(); this.player?.stop(); this.player?.unlock();
1213
- let conversation;
1214
- const reader = createSentencePlayer({
1215
- send: (value) => conversation.speak(value),
1216
- play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1217
- halt: () => this.player.stop(), silence: () => {},
1218
- onState: (playing) => conversation?.playbackState(playing),
1219
- fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
1220
- });
1221
- conversation = createVoiceConversation({
1222
- request: (path, options) => this.runtime.voiceRequest(path, options),
1223
- transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
1224
- device: captureSource("mobile", settings.config.device),
1225
- 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
- },
1233
- mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1234
- playback: { stop: () => { reader.stop(); this.player.stop(); } },
1235
- onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
1236
- });
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 || "";
1225
+ async onHoldStart() {
1226
+ const settings = await this.runtime.settings.voice(true);
1227
+ 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 });
1229
+ },
1230
+ onVoiceAction({ action, value }) {
1231
+ if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
1232
+ if (action === "setCancelArmed") { this.voicePanel.setCancelArmed(value); return; }
1233
+ if (["holdStart", "holdEnd", "finish", "start", "close"].includes(action)) void Promise.resolve(this.voicePanel[action](value)).catch(() => {});
1234
+ },
1235
+ runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1236
+ async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
1237
+ const settings = { config };
1238
+ const continuous = mode === "continuous";
1239
+ onDevice(captureSource("mobile", settings.config.device));
1240
+ if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
1241
+ this.reader.stop(); this.player?.stop(); this.player?.unlock();
1242
+ let conversation;
1243
+ const reader = createSentencePlayer({
1244
+ send: (value) => conversation.speak(value),
1245
+ play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1246
+ halt: () => this.player.stop(), silence: () => {},
1247
+ onState: (playing) => conversation?.playbackState(playing),
1248
+ fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
1253
1249
  });
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();
1250
+ conversation = createVoiceConversation({
1251
+ captureConfig: { model: config.model, language: config.language },
1252
+ request: (path, options) => this.runtime.voiceRequest(path, options),
1253
+ transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model),
1254
+ device: captureSource("mobile", settings.config.device),
1255
+ 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,
1257
+ presentationSession: true,
1258
+ onTranscript: continuous ? null : onTranscript,
1259
+ 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); },
1262
+ });
1263
+ return conversation;
1260
1264
  },
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; }
1265
+ bindVoiceHistory(sessionId) {
1266
+ this.voiceHistoryUnsub?.(); this.voiceRecords = [];
1267
+ if (!sessionId) return;
1268
+ this.voiceHistoryUnsub = subscribeVoiceHistory({ sessionId, request: (path, options) => this.runtime.voiceRequest(path, options),
1269
+ onRecords: (rows) => { if (this.sessionId === sessionId) this.voiceRecords = rows; },
1270
+ onError: (error) => { this.voiceError = error.message; },
1271
+ });
1276
1272
  },
1277
1273
  showSpeakProblem(err) {
1278
1274
  this.speakNotice = speakProblemText(this.t, err);
@@ -4,9 +4,10 @@
4
4
  class="lares-composer__card"
5
5
  data-composer-card
6
6
  :data-hero="hero ? 'true' : 'false'"
7
- :data-mode="inputMode"
7
+ :data-mode="voiceView?.inputActive ? 'voice' : 'text'"
8
8
  :data-expanded="expanded ? 'true' : 'false'"
9
- :data-holding="holding ? 'true' : 'false'"
9
+ :data-holding="voiceView?.holding ? 'true' : 'false'"
10
+ :data-cancel-armed="Boolean(voiceView?.cancelArmed)"
10
11
  >
11
12
  <div v-if="referenceOpen" ref="referenceMenu" class="lares-composer__menu" role="listbox">
12
13
  <p v-if="referencesError" class="lares-composer__menu-status">{{ referencesError }}</p>
@@ -82,10 +83,6 @@
82
83
  </button>
83
84
  </figure>
84
85
  </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
86
  <div class="lares-composer__line" :data-expanded="expanded ? 'true' : 'false'">
90
87
  <button
91
88
  type="button"
@@ -99,8 +96,9 @@
99
96
  <path d="M12 5v14M5 12h14" />
100
97
  </svg>
101
98
  </button>
102
- <textarea
103
- v-show="inputMode === 'text'"
99
+ <LaresVoiceInput v-if="voiceView?.inputActive" :view="voiceView" :t="t" @action="$emit('voice-action', $event)" />
100
+ <textarea v-show="!voiceView?.inputActive"
101
+
104
102
  ref="input"
105
103
  :value="draft"
106
104
  class="lares-composer__input"
@@ -114,24 +112,6 @@
114
112
  @select="onCaretMove"
115
113
  @contextmenu="onContextMenu"
116
114
  />
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
115
  <div class="lares-composer__tools">
136
116
  <input
137
117
  ref="picker"
@@ -141,22 +121,23 @@
141
121
  @change="onFiles"
142
122
  />
143
123
  <button
144
- v-if="inputMode === 'text'"
145
124
  type="button"
146
125
  class="lares-composer__icon"
147
126
  data-kind="voice"
148
127
  :disabled="sending"
149
- :aria-label="t('composer.voice')"
150
- @click="setMode('voice')"
128
+ :aria-label="t(voiceView?.inputActive ? 'input.keyboard' : voiceDefaultMode === 'single' ? 'settings.mode.single' : 'call.start')"
129
+ :aria-expanded="Boolean(voiceView?.inputActive)"
130
+ @click="onVoiceClick"
151
131
  >
152
- <svg class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
132
+ <svg v-if="!voiceView?.inputActive" class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
153
133
  <path d="M5 8v8" />
154
134
  <path d="M12 5v14" />
155
135
  <path d="M19 8v8" />
156
136
  </svg>
137
+ <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>
157
138
  </button>
158
139
  <button
159
- v-if="expanded"
140
+ v-if="expanded && !voiceView?.inputActive"
160
141
  type="submit"
161
142
  class="lares-composer__icon"
162
143
  data-kind="send"
@@ -167,43 +148,10 @@
167
148
  <path d="M12 19V5M5 12.5 12 5l7 7.5" />
168
149
  </svg>
169
150
  </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>
184
151
  </div>
185
152
  </div>
186
153
  </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>
154
+
207
155
  <p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
208
156
  {{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
209
157
  </p>
@@ -211,7 +159,7 @@
211
159
  </template>
212
160
 
213
161
  <script>
214
- import { formatElapsed } from "@olares/lares-core/voice/recorder";
162
+ import LaresVoiceInput from "./VoiceInput.vue";
215
163
  import {
216
164
  REFERENCE_DEBOUNCE_MS,
217
165
  activeReference,
@@ -224,12 +172,14 @@ import { draftImageStatus } from "@olares/lares-core/files/draft-images";
224
172
 
225
173
  export default {
226
174
  name: "LaresComposer",
175
+ components: { LaresVoiceInput },
227
176
  props: {
228
177
  draft: { type: String, default: "" },
229
178
  sending: { type: Boolean, default: false },
230
179
  canSend: { type: Boolean, default: false },
231
180
  attachPending: { type: Boolean, default: false },
232
181
  attachDisabled: { type: Boolean, default: false },
182
+ voiceView: { type: Object, default: null },
233
183
  voiceCall: { type: Object, default: null },
234
184
  voicePhase: { type: String, default: "idle" },
235
185
  voiceElapsed: { type: Number, default: 0 },
@@ -252,12 +202,9 @@ export default {
252
202
  ],
253
203
  data() {
254
204
  return {
255
- holding: false,
256
205
  commandsOpen: false,
257
206
  commandTyped: false,
258
207
  commandIndex: 0,
259
- levels: [],
260
- voiceSelection: "",
261
208
  referenceOpen: false,
262
209
  referenceToken: null,
263
210
  referenceIndex: 0,
@@ -265,21 +212,6 @@ export default {
265
212
  };
266
213
  },
267
214
  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
215
  attachLabel() {
284
216
  return this.attachPending ? this.t("button.uploading", { count: 1 }) : this.t("composer.attach");
285
217
  },
@@ -287,18 +219,8 @@ export default {
287
219
  if (this.hero) return this.t("chat.placeholderNewSession");
288
220
  return this.t("chat.placeholder");
289
221
  },
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
222
  expanded() {
301
- return this.inputMode === "text" && (this.draft.trim().length > 0 || this.draftImages.length > 0);
223
+ return !this.voiceView?.inputActive && (this.draft.trim().length > 0 || this.draftImages.length > 0);
302
224
  },
303
225
  referenceMenuRows() {
304
226
  return referenceRows(this.references, this.t);
@@ -311,10 +233,8 @@ export default {
311
233
  },
312
234
  },
313
235
  watch: {
314
- "voiceCall.inputLevel"(level) { if (level != null) this.levels = [...this.levels.slice(-15), level]; },
315
236
  draft(value) {
316
237
  this.autosize();
317
- if (value && this.inputMode === "voice" && !this.voiceBusy) this.setMode("text");
318
238
  },
319
239
  overlayPointerActive(active) {
320
240
  if (active) window.addEventListener("pointerdown", this.onTokenPointer, true);
@@ -325,7 +245,6 @@ export default {
325
245
  this.autosize();
326
246
  },
327
247
  beforeUnmount() {
328
- this.clearHold();
329
248
  window.clearTimeout(this.referenceTimer);
330
249
  window.removeEventListener("pointerdown", this.onTokenPointer, true);
331
250
  },
@@ -410,18 +329,6 @@ export default {
410
329
  imageStatus(image) {
411
330
  return draftImageStatus(image);
412
331
  },
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
332
  onSend() {
426
333
  if (this.referenceOpen || this.commandsOpen) return;
427
334
  if (this.canSend) this.$emit("send");
@@ -475,14 +382,11 @@ export default {
475
382
  }
476
383
  },
477
384
  onVoiceClick() {
478
- if (this.voiceBusy) return;
479
- this.$emit("hold-start", { mode: this.voiceSelection || undefined });
480
- },
481
- clearHold() {
482
- this.holding = false;
483
- this.holdKind = "";
385
+ this.closeReferences(); this.commandsOpen = false; this.commandTyped = false;
386
+ this.$refs.input?.blur();
387
+ if (this.voiceView?.inputActive) { this.$emit("voice-action", { action: "close" }); this.$nextTick(() => this.$refs.input?.focus()); }
388
+ else this.$emit("hold-start", { mode: this.voiceDefaultMode });
484
389
  },
485
- detachHold() {},
486
390
  pickFiles() {
487
391
  this.$refs.picker?.click();
488
392
  },
@@ -507,6 +411,7 @@ export default {
507
411
  </script>
508
412
 
509
413
  <style scoped>
414
+
510
415
  .lares-composer {
511
416
  display: flex;
512
417
  flex-direction: column;
@@ -647,6 +552,8 @@ export default {
647
552
  background: var(--q-blue-alpha);
648
553
  }
649
554
 
555
+ .lares-composer__card[data-cancel-armed="true"] { border-color: var(--q-red-default); background: var(--q-background-1); }
556
+
650
557
  .lares-composer__attachments {
651
558
  display: flex;
652
559
  gap: 8px;
@@ -904,6 +811,7 @@ export default {
904
811
  </style>
905
812
 
906
813
  <style scoped>
814
+
907
815
  .lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
908
816
  .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
817
  </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,8 @@
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>
82
+ <button v-if="isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
81
83
  <LaresMessageActions
82
84
  :text="row.text"
83
85
  :reaction="reactions[actionKey(index)] || ''"
@@ -124,6 +126,7 @@ export default {
124
126
  running: { type: Boolean, default: false },
125
127
  runningSince: { type: Number, default: 0 },
126
128
  loading: { type: Boolean, default: false },
129
+ voiceView: { type: Object, default: null },
127
130
  previews: { type: Object, default: () => ({}) },
128
131
  mediaUrl: { type: Function, required: true },
129
132
  scrollTop: { type: Function, required: true },
@@ -136,7 +139,7 @@ export default {
136
139
  questionTakeover: { type: Boolean, default: false },
137
140
  t: { type: Function, required: true },
138
141
  },
139
- emits: ["open", "media", "answer"],
142
+ emits: ["open", "media", "answer", "stop-speaking"],
140
143
  data() {
141
144
  return {
142
145
  applyingScroll: false,
@@ -180,6 +183,12 @@ export default {
180
183
  clearInterval(this.clock);
181
184
  },
182
185
  methods: {
186
+ isSpeaking(row) {
187
+ const snapshot = this.voiceView?.snapshot;
188
+ if (snapshot?.playback !== "playing") return false;
189
+ const latest = this.items.findLast(item => item.type === "assistant" && (item.voiceId || item.voicePlayback?.voiceId) === snapshot.voiceId);
190
+ return latest && (row.id || row.key) === (latest.id || latest.key);
191
+ },
183
192
  syncClock() {
184
193
  clearInterval(this.clock);
185
194
  this.clock = 0;
@@ -483,6 +492,8 @@ export default {
483
492
  overflow-wrap: anywhere;
484
493
  }
485
494
 
495
+ .lares-voice-summary { border-left: 2px solid var(--q-blue-default); padding-left: 12px; font-size: 14px; opacity: .8; }
496
+
486
497
  .lares-md[data-pending="true"] {
487
498
  white-space: pre-wrap;
488
499
  opacity: 0.92;
@@ -556,3 +567,7 @@ export default {
556
567
  color: var(--q-blue-default);
557
568
  }
558
569
  </style>
570
+
571
+ <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; }
573
+ </style>
@@ -0,0 +1,50 @@
1
+ <template>
2
+ <div class="lares-inline-voice" :data-phase="ui.phase" @keydown="onKey" @pointerdown.capture="gesture.additionalPointer">
3
+ <span v-if="view.holding" class="lares-inline-cancel" :data-armed="view.cancelArmed" aria-hidden="true">{{ t(view.cancelArmed ? 'gesture.cancelRelease' : 'gesture.slideCancel') }}</span>
4
+ <button ref="main" type="button" class="lares-inline-main" :disabled="ui.disabled" :aria-pressed="view.mode === 'continuous' ? view.holding : undefined" :aria-label="t(ui.label)"
5
+ @pointerdown="down" @pointermove="gesture.pointerMove" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel" @keydown="keyDown" @keyup="keyUp" @blur="gesture.cancel" @click="click">
6
+ <svg v-if="view.mode === 'single' && ui.recording && levels.length" class="lares-inline-level" viewBox="0 0 32 24" aria-hidden="true"><line v-for="(level, index) in levels" :key="index" :x1="index * 5 + 3" :x2="index * 5 + 3" :y1="12 - Math.max(2, Math.min(10, level * 500))" :y2="12 + Math.max(2, Math.min(10, level * 500))" /></svg>
7
+ <span>{{ t(ui.label) }}</span><span v-if="ui.timed" class="lares-inline-time" aria-hidden="true"> · {{ ui.elapsed }}</span>
8
+ </button>
9
+ <span class="lares-inline-status" :data-error="Boolean(ui.error)" :data-visible="['speaking', 'working'].includes(ui.phase)" role="status" aria-live="polite" aria-atomic="true">{{ status }}</span>
10
+ </div>
11
+ </template>
12
+ <script>
13
+ import { createHoldGesture } from '@olares/lares-core/voice/hold';
14
+ import { voiceInputPresentation } from '@olares/lares-core/voice/presentation';
15
+ import { messageFromCode } from '@olares/lares-core/i18n/t';
16
+ export default {
17
+ name: 'LaresVoiceInput', props: { view: { type: Object, required: true }, t: { type: Function, required: true } }, emits: ['action'],
18
+ data: () => ({ now: Date.now(), clock: null, gesture: null }),
19
+ computed: {
20
+ ui() { return voiceInputPresentation(this.view, this.now); },
21
+ levels() { return (this.view.levels || []).slice(-6); },
22
+ status() { return this.ui.error ? messageFromCode(this.t, this.ui.error, 'error.voice_failed') : this.t(this.ui.status); },
23
+ },
24
+ created() { this.gesture = createHoldGesture({ start: () => this.action('holdStart'), end: value => this.action('holdEnd', value), onCancelChange: value => this.action('setCancelArmed', value), isActive: () => this.view.holding }); },
25
+ mounted() { this.$refs.main?.focus(); this.clock = setInterval(() => { this.now = Date.now(); }, 250); window.addEventListener('blur', this.cancel); window.addEventListener('pointerdown', this.additionalPointer, true); },
26
+ beforeUnmount() { this.gesture.cancel(); clearInterval(this.clock); window.removeEventListener('blur', this.cancel); window.removeEventListener('pointerdown', this.additionalPointer, true); },
27
+ methods: {
28
+ action(action, value) { this.$emit('action', { action, value }); },
29
+ down(event) { if (this.view.mode === 'continuous') this.gesture.pointerDown(event); },
30
+ keyDown(event) { if (this.view.mode === 'continuous') { this.gesture.keyDown(event); if (['Space', 'Enter'].includes(event.code)) event.stopPropagation(); } },
31
+ keyUp(event) { if (this.view.mode === 'continuous') this.gesture.keyUp(event); },
32
+ click(event) { if (this.view.mode === 'single') this.action(this.ui.error ? 'start' : 'finish'); else if (event.detail === 0) this.gesture.activate(); },
33
+ onKey(event) { if (event.key !== 'Escape') return; event.preventDefault(); event.stopPropagation(); if (!event.repeat && !this.gesture.escape()) this.action('close'); },
34
+ cancel() { this.gesture.cancel(); }, additionalPointer(event) { this.gesture.additionalPointer(event); },
35
+ },
36
+ };
37
+ </script>
38
+ <style scoped>
39
+ .lares-inline-voice { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; min-height: 40px; }
40
+ .lares-inline-main { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 40px; padding: 6px 0; border: 0; background: transparent; color: inherit; font: inherit; font-size: 16px; font-weight: 600; touch-action: none; user-select: none; -webkit-touch-callout: none; cursor: pointer; }
41
+ .lares-inline-main:focus-visible { outline: 2px solid var(--q-blue-default, #3377ff); outline-offset: 2px; border-radius: 8px; }
42
+ .lares-inline-time { font-variant-numeric: tabular-nums; white-space: pre; }
43
+ .lares-inline-level { width: 28px; height: 24px; margin-right: 8px; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
44
+ .lares-inline-cancel { position: absolute; bottom: calc(100% + 18px); left: 50%; transform: translateX(-50%); white-space: nowrap; font-size: 13px; padding: 7px 12px; border-radius: 10px; background: var(--q-background-1, white); box-shadow: 0 2px 12px #0001; pointer-events: none; }
45
+ [data-phase=recording] .lares-inline-main { color: var(--q-blue-default, #3377ff); }
46
+ [data-phase=cancel] .lares-inline-main, .lares-inline-cancel[data-armed=true] { color: var(--q-red-default, #c23d32); }
47
+ .lares-inline-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
48
+ .lares-inline-status[data-visible=true], .lares-inline-status[data-error=true] { width: auto; height: auto; clip-path: none; bottom: calc(100% + 16px); background: var(--q-background-1, white); padding: 6px; font-size: 12px; color: var(--q-ink-2, #777); }
49
+ .lares-inline-status[data-error=true] { color: var(--q-red-default, #c23d32); }
50
+ </style>
@@ -0,0 +1,92 @@
1
+ <template>
2
+ <dialog ref="dialog" class="lares-voice-panel" :data-mode="view.mode" :data-phase="ui.phase" :data-preview="ui.preview" aria-labelledby="lares-mobile-voice-title" @cancel.prevent="$emit('close')" @click="onBackdrop">
3
+ <div class="lares-voice-panel-body">
4
+ <header><h2 id="lares-mobile-voice-title">{{ t(`settings.mode.${view.mode}`) }}</h2><button type="button" class="lares-voice-close" :aria-label="t('panel.close')" @click="$emit('close')">×</button></header>
5
+ <section class="lares-voice-content">
6
+ <p class="lares-voice-status" role="status" aria-live="polite" aria-atomic="true">{{ status }}</p>
7
+ <template v-if="ui.preview">
8
+ <details ref="menu" class="lares-voice-menu">
9
+ <summary :aria-label="t('dictation.preset')">{{ t(`dictation.${view.preset}`) }} ▾</summary>
10
+ <div class="lares-voice-menu-body">
11
+ <select :aria-label="t('dictation.preset')" :value="view.preset" :disabled="view.busy" @change="choose($event.target.value)"><option v-for="id in presets" :key="id" :value="id">{{ t(`dictation.${id}`) }}</option></select>
12
+ <button type="button" @click="showOriginal = !showOriginal; closeMenu()">{{ t('dictation.original') }}</button>
13
+ <template v-if="ui.error"><button type="button" :disabled="view.busy" @click="choose(view.preset)">{{ t('dictation.retry') }}</button><button type="button" :disabled="view.busy" @click="choose('raw')">{{ t('dictation.useOriginal') }}</button></template>
14
+ </div>
15
+ </details>
16
+ <textarea class="lares-voice-draft" :aria-label="t('panel.draft')" :value="view.draft" :disabled="ui.pending" @input="$emit('edit', $event.target.value)" />
17
+ <div v-if="showOriginal" class="lares-voice-original" :aria-label="t('dictation.original')">{{ view.raw }}</div>
18
+ </template>
19
+ </section>
20
+ <footer class="lares-voice-panel-actions">
21
+ <button type="button" class="lares-voice-primary" :disabled="view.busy || !view.draft.trim()" @click="$emit('complete')">{{ t('dictation.insert') }}</button>
22
+ <button type="button" :disabled="view.busy" @click="$emit('discard')">{{ t('dictation.discard') }}</button>
23
+ </footer>
24
+ </div>
25
+ </dialog>
26
+ </template>
27
+ <script>
28
+ import { messageFromCode } from '@olares/lares-core/i18n/t';
29
+ import { DICTATION_PRESETS } from '@olares/lares-core/voice/dictation';
30
+ import { voicePanelPresentation } from '@olares/lares-core/voice/presentation';
31
+ export default {
32
+ name: 'LaresVoicePanel', props: { view: { type: Object, required: true }, t: { type: Function, required: true } },
33
+ emits: ['close', 'complete', 'discard', 'edit', 'preset'],
34
+ data: () => ({ previousFocus: null, presets: DICTATION_PRESETS, showOriginal: false }),
35
+ computed: {
36
+ ui() { return voicePanelPresentation(this.view); },
37
+ status() { return this.ui.error ? messageFromCode(this.t, this.ui.error, "error.voice_failed") : this.t(this.ui.status); },
38
+ },
39
+ watch: { 'view.previewVisible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
40
+ mounted() {
41
+ this.updateViewport(); window.addEventListener('resize', this.updateViewport);
42
+ window.visualViewport?.addEventListener('resize', this.updateViewport); window.visualViewport?.addEventListener('scroll', this.updateViewport);
43
+ },
44
+ beforeUnmount() {
45
+ this.$refs.dialog?.close(); this.restoreFocus();
46
+ window.removeEventListener('resize', this.updateViewport); window.visualViewport?.removeEventListener('resize', this.updateViewport); window.visualViewport?.removeEventListener('scroll', this.updateViewport);
47
+ },
48
+ methods: {
49
+ updateViewport() {
50
+ const dialog = this.$refs.dialog; if (!dialog) return;
51
+ const viewport = window.visualViewport; const height = viewport?.height || window.innerHeight;
52
+ dialog.style.setProperty('--voice-view-height', `${height}px`);
53
+ dialog.style.setProperty('--voice-bottom', `${Math.max(0, window.innerHeight - height - (viewport?.offsetTop || 0))}px`);
54
+ },
55
+ sync() {
56
+ const dialog = this.$refs.dialog; if (!dialog) return;
57
+ if (this.view.previewVisible) { if (!dialog.open) { this.previousFocus = document.querySelector('.lares-composer__icon[data-kind=voice]') || document.activeElement; dialog.showModal(); } this.updateViewport(); }
58
+ else if (dialog.open) { dialog.close(); this.$nextTick(this.restoreFocus); this.showOriginal = false; }
59
+ },
60
+ onBackdrop(event) { if (event.target === this.$refs.dialog) this.$emit('close'); },
61
+ closeMenu() { if (this.$refs.menu) this.$refs.menu.open = false; },
62
+ choose(preset) { if (this.view.edited && !confirm(this.t('dictation.replaceEdited'))) return; this.closeMenu(); this.$emit('preset', preset); },
63
+ restoreFocus() { const target = this.previousFocus?.isConnected ? this.previousFocus : document.querySelector('.lares-composer__icon[data-kind=voice]'); target?.focus?.(); },
64
+ },
65
+ };
66
+ </script>
67
+ <style scoped>
68
+ .lares-voice-panel { position: fixed; inset: auto; margin: 0; box-sizing: border-box; padding: 0; border: 1px solid #8883; border-radius: 20px; color: var(--color-text-primary, #202124); background: var(--color-background-primary, white); width: min(400px, calc(var(--voice-view-width, 100vw) - 24px)); max-width: none; max-height: calc(var(--voice-view-height, 100dvh) * .85); box-shadow: 0 12px 48px #0002; overflow: hidden; }
69
+ .lares-voice-panel::backdrop { background: #10182826; }
70
+ .lares-voice-panel-body { padding: 16px; display: flex; flex-direction: column; box-sizing: border-box; max-height: calc(var(--voice-view-height, 100dvh) * .85 - 2px); }
71
+ .lares-voice-panel header { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 16px; }
72
+ .lares-voice-panel h2 { font-size: 16px; line-height: 24px; margin: 0; font-weight: 600; }
73
+ .lares-voice-panel button { font: inherit; color: inherit; cursor: pointer; border: 1px solid #8883; border-radius: 12px; padding: 10px 16px; min-height: 44px; box-sizing: border-box; background: transparent; }
74
+ .lares-voice-panel button:focus-visible, .lares-voice-panel summary:focus-visible, .lares-voice-panel textarea:focus-visible, .lares-voice-panel select:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; }
75
+ .lares-voice-panel button:disabled { opacity: .45; cursor: default; }
76
+ .lares-voice-panel .lares-voice-close { border: 0; padding: 0; font-size: 24px; min-width: 44px; }
77
+ .lares-voice-content { text-align: center; display: flex; flex-direction: column; min-height: 0; overflow: auto; }
78
+ .lares-voice-status { margin: 12px 0; min-height: 24px; line-height: 24px; font-size: 14px; }
79
+ .lares-voice-panel-actions { display: flex; flex: none; justify-content: center; align-items: center; gap: 10px; margin-top: 12px; }
80
+ .lares-voice-panel textarea { display: block; flex: 1 1 180px; width: 100%; box-sizing: border-box; min-height: 80px; height: 180px; max-height: 32dvh; margin: 12px 0 0; padding: 12px; color: inherit; background: #88808; font: inherit; line-height: 1.6; border: 1px solid #8883; border-radius: 12px; resize: none; overflow: auto; }
81
+ .lares-voice-panel select { font: inherit; padding: 8px 12px; border-radius: 10px; color: inherit; background: transparent; max-width: 100%; }
82
+ .lares-voice-menu { flex: none; text-align: start; font-size: 13px; }
83
+ .lares-voice-menu summary { display: inline-block; list-style: none; cursor: pointer; padding: 6px 10px; border-radius: 8px; background: #8881; }
84
+ .lares-voice-menu summary::-webkit-details-marker { display: none; }
85
+ .lares-voice-menu-body { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 0; }
86
+ .lares-voice-menu-body button { font-size: 12px; }
87
+ .lares-voice-original { text-align: start; white-space: pre-wrap; overflow: auto; max-height: 100px; padding: 12px; margin-top: 8px; background: #8881; border-radius: 8px; font-size: 13px; }
88
+
89
+ .lares-voice-panel { color: var(--q-ink-1, #202124); background: var(--q-background-1, white); inset: auto 0 var(--voice-bottom, 0px); margin: 0 auto; width: min(100vw, 560px); border-radius: 24px 24px 0 0; border-bottom: 0; }
90
+ .lares-voice-panel-body { padding: 16px 20px max(16px, env(safe-area-inset-bottom)); }
91
+ .lares-voice-panel .lares-voice-primary { background: var(--q-ink-1, var(--color-text-primary, #202124)); color: var(--q-background-1, var(--color-background-primary, white)); border-color: transparent; }
92
+ </style>
package/src/host.js CHANGED
@@ -275,8 +275,9 @@ export function createHostClient(ports = {}) {
275
275
  return new WebSocket(muxWsUrl(urlFor(`${VOICE_API}/realtime`), pageOrigin()), ["lares-voice", `lares-ticket.${ticket}`, ...(Array.isArray(auth) ? auth : auth ? [auth] : [])]);
276
276
  },
277
277
  voiceRequest(path, options) { return request(path, options); },
278
- transcribe(blob, language, signal) {
278
+ transcribe(blob, language, signal, model) {
279
279
  return postTranscribe(blob, language, signal, {
280
+ model,
280
281
  baseUrl: urlFor(VOICE_API),
281
282
  request: direct,
282
283
  });
@@ -72,6 +72,7 @@
72
72
  :can-send="state.canSend"
73
73
  :attach-pending="state.upload.pending > 0"
74
74
  :attach-disabled="!state.sessionId || state.historyLoading || state.starting"
75
+ :voice-view="state.voicePanelState"
75
76
  :voice-call="state.voiceCall"
76
77
  :voice-default-mode="state.voiceDefaultMode"
77
78
  :voice-phase="state.voicePhase"
@@ -104,6 +105,8 @@
104
105
  <div v-else key="chat" class="lares-shell__chat">
105
106
  <LaresTranscript
106
107
  ref="transcript"
108
+ :voice-view="state.voicePanelState"
109
+ @stop-speaking="$emit('voice-action', { action: 'stopPlayback' })"
107
110
  :items="state.viewItems"
108
111
  :running="state.running"
109
112
  :running-since="state.runningSince"
@@ -136,6 +139,7 @@
136
139
  :can-send="state.canSend"
137
140
  :attach-pending="state.upload.pending > 0"
138
141
  :attach-disabled="!state.sessionId || state.historyLoading || state.starting"
142
+ :voice-view="state.voicePanelState"
139
143
  :voice-call="state.voiceCall"
140
144
  :voice-default-mode="state.voiceDefaultMode"
141
145
  :voice-phase="state.voicePhase"
@@ -61,6 +61,24 @@
61
61
  @click="openSheet('voiceLang')"
62
62
  />
63
63
  </div>
64
+ <template v-if="voice?.config?.dictationProfiles">
65
+ <label class="lares-agent__hint">{{ tVoice('dictation.preset') }}
66
+ <select :value="voice.config.dictationPreset" :disabled="busy" @change="patchVoice('dictationPreset', $event.target.value)"><option v-for="id in dictationPresets" :key="id" :value="id">{{ tVoice(`dictation.${id}`) }}</option></select>
67
+ </label>
68
+ <label class="lares-agent__hint">{{ tVoice('dictation.configure') }}
69
+ <select v-model="dictationEditor"><option v-for="id in dictationPresets" :key="id" :value="id">{{ tVoice(`dictation.${id}`) }}</option></select>
70
+ </label>
71
+ <template v-if="dictationEditor !== 'raw'">
72
+ <label class="lares-agent__hint">{{ tVoice('dictation.model') }}
73
+ <select :value="voice.config.dictationProfiles[dictationEditor].model" :disabled="busy" @change="patchDictation('model', $event.target.value)"><option value="">—</option><option v-for="id in voice.chatModels || []" :key="id" :value="id">{{ id }}</option></select>
74
+ </label>
75
+ <label class="lares-agent__hint">{{ tVoice('dictation.instructions') }}
76
+ <textarea :key="dictationEditor + voice.config.dictationProfiles[dictationEditor].instructions" :value="voice.config.dictationProfiles[dictationEditor].instructions" :disabled="busy" maxlength="8000" @change="patchDictation('instructions', $event.target.value)" />
77
+ </label>
78
+ <button type="button" :disabled="busy" @click="resetDictation">{{ tVoice('dictation.reset') }}</button>
79
+ </template>
80
+ <p v-else class="lares-agent__hint">{{ tVoice('dictation.noLlm') }}</p>
81
+ </template>
64
82
  <label class="lares-agent__hint">{{ tVoice('settings.preset') }}
65
83
  <select :value="voice?.config?.preset || 'assistant'" @change="patchVoice('preset', $event.target.value)">
66
84
  <option v-for="preset in ['dictation', 'chat', 'assistant', 'direct']" :key="preset" :value="preset">{{ tVoice(`settings.preset.${preset}`) }}</option>
@@ -91,6 +109,7 @@
91
109
  <option value="continuous">{{ tVoice('settings.mode.continuous') }}</option>
92
110
  </select>
93
111
  </label>
112
+ <label class="lares-agent__hint"><input type="checkbox" :checked="voice?.config?.speak === true" :disabled="busy" @change="patchVoice('speak', $event.target.checked)" /> {{ tVoice('settings.speak.title') }}</label>
94
113
  <label class="lares-agent__hint">{{ tVoice('settings.device') }}
95
114
  <select :value="voice?.config?.device || 'auto'" @change="patchVoice('device', $event.target.value)">
96
115
  <option v-for="device in ['auto', 'device', 'headset']" :key="device" :value="device">{{ tVoice(`settings.device.${device}`) }}</option>
@@ -140,6 +159,7 @@
140
159
  <script>
141
160
  import { t as translate } from "@olares/lares-core/i18n/t";
142
161
  import { EN as MODEL_EN, ZH as MODEL_ZH } from "@olares/lares-core/i18n/chat-model";
162
+ import { DICTATION_PRESETS, DICTATION_INSTRUCTIONS } from "@olares/lares-core/voice/dictation";
143
163
  import { EN as VOICE_EN, ZH as VOICE_ZH } from "@olares/lares-core/i18n/voice";
144
164
  import { EN as SEARCH_EN, ZH as SEARCH_ZH } from "@olares/lares-core/i18n/search";
145
165
  import { groupModelsByProvider, selectionKey } from "@olares/lares-core/router/session-model";
@@ -198,6 +218,8 @@ export default {
198
218
  search: remembered.search,
199
219
  conversation: remembered.conversation,
200
220
  modelError: "",
221
+ dictationEditor: "clean",
222
+ dictationPresets: DICTATION_PRESETS,
201
223
  voiceError: "",
202
224
  searchError: "",
203
225
  conversationError: "",
@@ -443,6 +465,8 @@ export default {
443
465
  this.pending = "";
444
466
  }
445
467
  },
468
+ patchDictation(key, value) { return this.patchVoice("dictationProfiles", { ...this.voice.config.dictationProfiles, [this.dictationEditor]: { ...this.voice.config.dictationProfiles[this.dictationEditor], [key]: value } }); },
469
+ resetDictation() { return this.patchDictation("instructions", DICTATION_INSTRUCTIONS[this.dictationEditor]); },
446
470
  async patchVoice(key, value) {
447
471
  if (!this.voice || this.busy) return;
448
472
  const previous = this.voice;