@olares/lares-mobile 0.15.13 → 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.13",
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.13",
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"
@@ -102,12 +101,13 @@
102
101
  @toggle-speak="toggleSpeak"
103
102
  />
104
103
  <LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
105
- @close="runVoicePanel('close')" @mode="openVoiceMode" @start="runVoicePanel('start')" @pause="runVoicePanel('pause')"
106
- @complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" @end="runVoicePanel('end')" @action="voiceAction" />
104
+ @close="runVoicePanel('close')" @edit="voicePanel.edit($event)" @preset="voicePanel.selectPreset($event, true).catch(() => {})"
105
+ @complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" />
107
106
  </div>
108
107
  </template>
109
108
 
110
109
  <script>
110
+ import { subscribeVoiceSettings } from "@olares/lares-core/larepass/settings";
111
111
  import { createVoicePanel } from "@olares/lares-core/voice/panel";
112
112
  import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
113
113
  import LaresVoicePanel from "./chat/VoicePanel.vue";
@@ -116,7 +116,6 @@ import { connectChat } from "./runtime.js";
116
116
  import { createT } from "./i18n.js";
117
117
  import { failText, withPendingUser } from "./chat/format.js";
118
118
  import { captureSource } from "@olares/lares-core/voice/input";
119
- import { createRealtimeConversation } from "@olares/lares-core/voice/realtime-conversation";
120
119
  import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
121
120
  import { createAnswerPlayer } from "./chat/playback.js";
122
121
  import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
@@ -343,6 +342,7 @@ export default {
343
342
  draftImages: this.draftImages,
344
343
  voiceDefaultMode: this.voiceDefaultMode,
345
344
  voiceCall: this.voiceCall,
345
+ voicePanelState: this.voicePanelState,
346
346
  voicePhase: this.voicePhase,
347
347
  voiceElapsed: this.voiceElapsed,
348
348
  voiceErrorText: this.voiceErrorText,
@@ -541,6 +541,8 @@ export default {
541
541
  this.player = createAnswerPlayer();
542
542
  this.voicePanel = createVoicePanel({
543
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; },
544
546
  complete: async ({ sessionId, text }) => {
545
547
  const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
546
548
  const value = [previous, text].filter(Boolean).join(" ");
@@ -552,6 +554,7 @@ export default {
552
554
  void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
553
555
  },
554
556
  });
557
+ this.voiceSettingsUnsub = subscribeVoiceSettings((value) => { this.voiceDefaultMode = value.config.mobileMode; });
555
558
  this.voicePanelState = this.voicePanel.getSnapshot();
556
559
  this.voicePanelUnsub = this.voicePanel.subscribe(() => {
557
560
  const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
@@ -614,6 +617,7 @@ export default {
614
617
  this.imageUploadControllers?.clear?.();
615
618
  void this.voicePanel?.dispose().catch(() => {});
616
619
  this.voicePanelUnsub?.();
620
+ this.voiceSettingsUnsub?.();
617
621
  this.voiceHistoryUnsub?.();
618
622
  this.conversationUnsub?.();
619
623
  this.intakeUnsub?.();
@@ -1218,45 +1222,46 @@ export default {
1218
1222
  this.draftImages = [];
1219
1223
  this.imagesBySession = {};
1220
1224
  },
1221
- async onHoldStart({ mode } = {}) {
1222
- await this.voicePanel.open({ mode: mode || this.voiceDefaultMode || "continuous", sessionId: this.sessionId || null });
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(() => {});
1223
1234
  },
1224
- openVoiceMode(mode) { void this.voicePanel.open({ mode }).catch(() => {}); },
1225
1235
  runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
1226
- async createMobileVoiceConversation({ mode, onTranscript, onDevice }) {
1227
- const settings = await this.runtime.settings.voice(true);
1236
+ async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
1237
+ const settings = { config };
1228
1238
  const continuous = mode === "continuous";
1229
1239
  onDevice(captureSource("mobile", settings.config.device));
1230
- if (settings.config.backend === "realtime" && continuous) return createRealtimeConversation({ presentationSession: true,
1231
- readAloud: settings.config.speak, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1232
- request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
1233
- device: captureSource("mobile", settings.config.device), playback: this.player,
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"); },
1249
+ });
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); },
1234
1262
  });
1235
- this.reader.stop(); this.player?.stop(); this.player?.unlock();
1236
- let conversation;
1237
- const reader = createSentencePlayer({
1238
- send: (value) => conversation.speak(value),
1239
- play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
1240
- halt: () => this.player.stop(), silence: () => {},
1241
- onState: (playing) => conversation?.playbackState(playing),
1242
- fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
1243
- });
1244
- conversation = createVoiceConversation({
1245
- request: (path, options) => this.runtime.voiceRequest(path, options),
1246
- transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
1247
- device: captureSource("mobile", settings.config.device),
1248
- readAloud: continuous && settings.config.speak,
1249
- preset: continuous && settings.config.preset === "dictation" ? "assistant" : settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
1250
- presentationSession: true,
1251
- onTranscript: continuous ? null : onTranscript,
1252
- mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1253
- playback: { stop: () => { reader.stop(); this.player.stop(); } },
1254
- onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
1255
- });
1256
1263
  return conversation;
1257
1264
  },
1258
- onHoldEnd() { this.voicePanel.conversation?.controller.stopRecording(); },
1259
- async voiceAction(action) { await this.voicePanel.action(action).catch(() => {}); },
1260
1265
  bindVoiceHistory(sessionId) {
1261
1266
  this.voiceHistoryUnsub?.(); this.voiceRecords = [];
1262
1267
  if (!sessionId) return;
@@ -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>
@@ -95,7 +96,8 @@
95
96
  <path d="M12 5v14M5 12h14" />
96
97
  </svg>
97
98
  </button>
98
- <textarea
99
+ <LaresVoiceInput v-if="voiceView?.inputActive" :view="voiceView" :t="t" @action="$emit('voice-action', $event)" />
100
+ <textarea v-show="!voiceView?.inputActive"
99
101
 
100
102
  ref="input"
101
103
  :value="draft"
@@ -123,18 +125,19 @@
123
125
  class="lares-composer__icon"
124
126
  data-kind="voice"
125
127
  :disabled="sending"
126
- :aria-label="t('call.start')"
127
- aria-haspopup="dialog"
128
+ :aria-label="t(voiceView?.inputActive ? 'input.keyboard' : voiceDefaultMode === 'single' ? 'settings.mode.single' : 'call.start')"
129
+ :aria-expanded="Boolean(voiceView?.inputActive)"
128
130
  @click="onVoiceClick"
129
131
  >
130
- <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">
131
133
  <path d="M5 8v8" />
132
134
  <path d="M12 5v14" />
133
135
  <path d="M19 8v8" />
134
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>
135
138
  </button>
136
139
  <button
137
- v-if="expanded"
140
+ v-if="expanded && !voiceView?.inputActive"
138
141
  type="submit"
139
142
  class="lares-composer__icon"
140
143
  data-kind="send"
@@ -145,11 +148,10 @@
145
148
  <path d="M12 19V5M5 12.5 12 5l7 7.5" />
146
149
  </svg>
147
150
  </button>
148
- <button type="button" class="lares-composer__voice-entry" aria-haspopup="dialog" @click="$emit('hold-start', { mode: 'single' })">{{ t('settings.mode.single') }}</button>
149
151
  </div>
150
152
  </div>
151
153
  </div>
152
- <p v-if="voiceCall?.connection === 'paused'" class="lares-composer__hint" role="status">{{ t('panel.paused') }}</p>
154
+
153
155
  <p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
154
156
  {{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
155
157
  </p>
@@ -157,6 +159,7 @@
157
159
  </template>
158
160
 
159
161
  <script>
162
+ import LaresVoiceInput from "./VoiceInput.vue";
160
163
  import {
161
164
  REFERENCE_DEBOUNCE_MS,
162
165
  activeReference,
@@ -169,12 +172,14 @@ import { draftImageStatus } from "@olares/lares-core/files/draft-images";
169
172
 
170
173
  export default {
171
174
  name: "LaresComposer",
175
+ components: { LaresVoiceInput },
172
176
  props: {
173
177
  draft: { type: String, default: "" },
174
178
  sending: { type: Boolean, default: false },
175
179
  canSend: { type: Boolean, default: false },
176
180
  attachPending: { type: Boolean, default: false },
177
181
  attachDisabled: { type: Boolean, default: false },
182
+ voiceView: { type: Object, default: null },
178
183
  voiceCall: { type: Object, default: null },
179
184
  voicePhase: { type: String, default: "idle" },
180
185
  voiceElapsed: { type: Number, default: 0 },
@@ -197,7 +202,6 @@ export default {
197
202
  ],
198
203
  data() {
199
204
  return {
200
- holding: false,
201
205
  commandsOpen: false,
202
206
  commandTyped: false,
203
207
  commandIndex: 0,
@@ -216,7 +220,7 @@ export default {
216
220
  return this.t("chat.placeholder");
217
221
  },
218
222
  expanded() {
219
- 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);
220
224
  },
221
225
  referenceMenuRows() {
222
226
  return referenceRows(this.references, this.t);
@@ -241,7 +245,6 @@ export default {
241
245
  this.autosize();
242
246
  },
243
247
  beforeUnmount() {
244
- this.clearHold();
245
248
  window.clearTimeout(this.referenceTimer);
246
249
  window.removeEventListener("pointerdown", this.onTokenPointer, true);
247
250
  },
@@ -379,13 +382,11 @@ export default {
379
382
  }
380
383
  },
381
384
  onVoiceClick() {
382
- this.$emit("hold-start", { mode: "continuous" });
383
- },
384
- clearHold() {
385
- this.holding = false;
386
- 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 });
387
389
  },
388
- detachHold() {},
389
390
  pickFiles() {
390
391
  this.$refs.picker?.click();
391
392
  },
@@ -410,8 +411,6 @@ export default {
410
411
  </script>
411
412
 
412
413
  <style scoped>
413
- .lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
414
- .lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
415
414
 
416
415
  .lares-composer {
417
416
  display: flex;
@@ -553,6 +552,8 @@ export default {
553
552
  background: var(--q-blue-alpha);
554
553
  }
555
554
 
555
+ .lares-composer__card[data-cancel-armed="true"] { border-color: var(--q-red-default); background: var(--q-background-1); }
556
+
556
557
  .lares-composer__attachments {
557
558
  display: flex;
558
559
  gap: 8px;
@@ -810,8 +811,6 @@ export default {
810
811
  </style>
811
812
 
812
813
  <style scoped>
813
- .lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
814
- .lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
815
814
 
816
815
  .lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
817
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; }
@@ -79,6 +79,7 @@
79
79
  <template v-else-if="row.type === 'assistant'">
80
80
  <div class="lares-md" v-html="renderMarkdown(row.text)" />
81
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>
82
83
  <LaresMessageActions
83
84
  :text="row.text"
84
85
  :reaction="reactions[actionKey(index)] || ''"
@@ -125,6 +126,7 @@ export default {
125
126
  running: { type: Boolean, default: false },
126
127
  runningSince: { type: Number, default: 0 },
127
128
  loading: { type: Boolean, default: false },
129
+ voiceView: { type: Object, default: null },
128
130
  previews: { type: Object, default: () => ({}) },
129
131
  mediaUrl: { type: Function, required: true },
130
132
  scrollTop: { type: Function, required: true },
@@ -137,7 +139,7 @@ export default {
137
139
  questionTakeover: { type: Boolean, default: false },
138
140
  t: { type: Function, required: true },
139
141
  },
140
- emits: ["open", "media", "answer"],
142
+ emits: ["open", "media", "answer", "stop-speaking"],
141
143
  data() {
142
144
  return {
143
145
  applyingScroll: false,
@@ -181,6 +183,12 @@ export default {
181
183
  clearInterval(this.clock);
182
184
  },
183
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
+ },
184
192
  syncClock() {
185
193
  clearInterval(this.clock);
186
194
  this.clock = 0;
@@ -559,3 +567,7 @@ export default {
559
567
  color: var(--q-blue-default);
560
568
  }
561
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>
@@ -1,65 +1,92 @@
1
1
  <template>
2
- <dialog ref="dialog" class="lares-voice-sheet" aria-labelledby="lares-mobile-voice-title" @cancel.prevent="$emit('close')" @click="onBackdrop">
3
- <div class="lares-voice-sheet__body">
4
- <header><h2 id="lares-mobile-voice-title">{{ t('panel.title') }}</h2><button type="button" @click="$emit('close')">{{ t('panel.close') }}</button></header>
5
- <div role="tablist" :aria-label="t('settings.mode')" @keydown="onTabKey">
6
- <button v-for="mode in ['single', 'continuous']" :id="`mobile-voice-${mode}`" :key="mode" type="button" role="tab" :aria-selected="view.mode === mode" :tabindex="view.mode === mode ? 0 : -1" aria-controls="lares-mobile-voice-content" :disabled="view.busy" @click="$emit('mode', mode)">{{ t(`settings.mode.${mode}`) }}</button>
7
- </div>
8
- <section id="lares-mobile-voice-content" role="tabpanel" :aria-labelledby="`mobile-voice-${view.mode}`">
9
- <p class="lares-voice-sheet__device">{{ t(!view.device ? 'settings.device.auto' : view.device === 'headset' ? 'settings.device.headset' : 'settings.device.device') }}</p>
10
- <svg class="lares-voice-sheet__wave" viewBox="0 0 120 48" aria-hidden="true"><line v-for="(level, index) in levels" :key="index" :x1="index * 6 + 3" :x2="index * 6 + 3" :y1="24 - Math.max(2, Math.min(22, level * 800))" :y2="24 + Math.max(2, Math.min(22, level * 800))" /></svg>
11
- <p role="timer" :aria-label="t('panel.elapsed')">{{ elapsed }}s</p>
12
- <p role="status" aria-live="polite">{{ status }}</p>
13
- <div v-if="view.mode === 'single'" class="lares-voice-sheet__draft" :aria-label="t('panel.draft')" aria-live="polite">{{ view.draft || t('panel.draftHint') }}</div>
14
- <p v-else>{{ t('panel.messages') }}</p>
15
- <div class="lares-voice-sheet__actions">
16
- <button v-if="!active" type="button" :disabled="view.busy" @click="$emit('start')">{{ t(view.snapshot?.connection === 'paused' ? 'panel.continue' : 'panel.start') }}</button>
17
- <button v-else type="button" :disabled="view.busy" @click="$emit('pause')">{{ t('panel.pause') }}</button>
18
- <template v-if="view.mode === 'single'">
19
- <button type="button" :disabled="view.busy || (!view.draft && view.snapshot?.capture !== 'recording')" @click="$emit('complete')">{{ t('panel.complete') }}</button>
20
- <button type="button" :disabled="view.busy" @click="$emit('discard')">{{ t('panel.discard') }}</button>
21
- </template>
22
- <template v-if="active && view.mode === 'continuous'">
23
- <button type="button" :disabled="view.busy" @click="$emit('action', 'mute')">{{ t(view.snapshot.outputMuted ? 'call.unmute' : 'call.mute') }}</button>
24
- <button type="button" :disabled="view.busy" @click="$emit('action', 'input')">{{ t(view.snapshot.inputPaused ? 'call.resumeInput' : 'call.pauseInput') }}</button>
25
- </template>
26
- <button v-if="view.snapshot && view.snapshot.playback !== 'idle'" type="button" :disabled="view.busy" @click="$emit('action', 'playback')">{{ t('call.stopPlayback') }}</button>
27
- <button v-if="view.snapshot" type="button" :disabled="view.busy" @click="$emit('end')">{{ t('call.end') }}</button>
28
- </div>
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>
29
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>
30
24
  </div>
31
25
  </dialog>
32
26
  </template>
33
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';
34
31
  export default {
35
32
  name: 'LaresVoicePanel', props: { view: { type: Object, required: true }, t: { type: Function, required: true } },
36
- emits: ['close', 'mode', 'start', 'pause', 'complete', 'discard', 'end', 'action'],
37
- data: () => ({ now: Date.now(), clock: null, previousFocus: null }),
33
+ emits: ['close', 'complete', 'discard', 'edit', 'preset'],
34
+ data: () => ({ previousFocus: null, presets: DICTATION_PRESETS, showOriginal: false }),
38
35
  computed: {
39
- active() { return this.view.snapshot?.connection === 'open'; },
40
- levels() { return this.view.levels?.length ? this.view.levels : Array(20).fill(.003); },
41
- elapsed() { return Math.max(0, Math.floor(((this.view.elapsedMs || 0) + (this.view.startedAt ? this.now - this.view.startedAt : 0)) / 1000)); },
42
- status() { const state = this.view.snapshot; return this.view.error || state?.error ? this.t(`error.${this.view.error || state.error}`) : this.t(this.view.busy ? 'mic.preparing' : !this.active ? 'panel.paused' : state.capture === 'transcribing' ? 'mic.transcribing' : state.capture === 'recording' ? 'call.listening' : state.playback !== 'idle' ? 'call.halfDuplex' : 'call.waiting'); },
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);
43
47
  },
44
- watch: { 'view.visible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
45
- beforeUnmount() { clearInterval(this.clock); this.$refs.dialog?.close(); this.previousFocus?.focus?.(); },
46
48
  methods: {
47
- sync() { const dialog = this.$refs.dialog; if (!dialog) return; clearInterval(this.clock); if (this.view.visible) { if (!dialog.open) { this.previousFocus = document.activeElement; dialog.showModal(); } this.clock = setInterval(() => { this.now = Date.now(); }, 250); } else if (dialog.open) { dialog.close(); this.previousFocus?.focus?.(); } },
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
+ },
48
60
  onBackdrop(event) { if (event.target === this.$refs.dialog) this.$emit('close'); },
49
- onTabKey(event) { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const mode = event.key === 'Home' ? 'single' : event.key === 'End' ? 'continuous' : this.view.mode === 'single' ? 'continuous' : 'single'; this.$emit('mode', mode); this.$refs.dialog.querySelector(`#mobile-voice-${mode}`)?.focus(); },
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?.(); },
50
64
  },
51
65
  };
52
66
  </script>
53
67
  <style scoped>
54
- .lares-voice-sheet { color: var(--q-ink-1, #202124); background: var(--q-background-1, white); width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border: 0; padding: 0; }
55
- .lares-voice-sheet::backdrop { background: #10182866; }
56
- .lares-voice-sheet__body { padding: max(24px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); max-width: 560px; margin: auto; }
57
- header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { font-size: 22px; }
58
- button { color: inherit; background: transparent; border: 1px solid var(--q-separator, #ddd); border-radius: 14px; padding: 12px 16px; font: inherit; min-height: 44px; cursor: pointer; }
59
- button:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; } button:disabled { opacity: .45; }
60
- [role=tablist] { display: flex; gap: 8px; margin: 24px 0; } [role=tab] { flex: 1; } [aria-selected=true] { border-color: #3478f6; background: #3478f61a; }
61
- section { text-align: center; } .lares-voice-sheet__device { font-size: 14px; opacity: .65; }
62
- .lares-voice-sheet__wave { width: 160px; height: 64px; margin: 24px auto 0; stroke: #3478f6; stroke-width: 3; stroke-linecap: round; }
63
- .lares-voice-sheet__draft { text-align: start; white-space: pre-wrap; min-height: 100px; max-height: 30dvh; overflow: auto; background: #8881; padding: 16px; border-radius: 12px; }
64
- .lares-voice-sheet__actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 32px; }
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; }
65
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;