@olares/lares-mobile 0.15.11 → 0.15.12

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.11",
3
+ "version": "0.15.12",
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.11",
31
+ "@olares/lares-core": "0.15.12",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -89,6 +89,7 @@
89
89
  @files="attachFiles"
90
90
  @hold-start="onHoldStart"
91
91
  @hold-end="onHoldEnd"
92
+ @voice-action="voiceAction"
92
93
  @close-model="modelSheet = false"
93
94
  @choose-model="chooseModel"
94
95
  @close-effort="effortSheet = false"
@@ -108,11 +109,12 @@ import { pickHostPorts } from "@olares/lares-core/larepass/host";
108
109
  import { connectChat } from "./runtime.js";
109
110
  import { createT } from "./i18n.js";
110
111
  import { failText, withPendingUser } from "./chat/format.js";
111
- import { createVoiceCapture } from "./chat/voice.js";
112
+ import { captureSource } from "@olares/lares-core/voice/input";
113
+ import { createRealtimeConversation } from "@olares/lares-core/voice/realtime-conversation";
114
+ import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
112
115
  import { createAnswerPlayer } from "./chat/playback.js";
113
116
  import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
114
117
  import { wavBlob } from "@olares/lares-core/voice/client";
115
- import { captureSource } from "@olares/lares-core/voice/input";
116
118
  import { browserPlays } from "@olares/lares-core/voice/playback";
117
119
  import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
118
120
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
@@ -218,11 +220,15 @@ export default {
218
220
  effortSheet: false,
219
221
  upload: { pending: 0, failures: [] },
220
222
  draftImages: [],
223
+ voiceCall: null,
224
+ voiceHistory: null,
225
+ voiceCaption: "",
221
226
  voicePhase: "idle",
222
227
  voiceElapsed: 0,
223
228
  voiceError: "",
224
229
  voiceLanguage: "",
225
230
  voiceSpeak: false,
231
+ voiceDefaultMode: "continuous",
226
232
  voiceStreaming: false,
227
233
  speakBusy: false,
228
234
  speakNotice: "",
@@ -329,6 +335,8 @@ export default {
329
335
  effortDisabled: this.effortDisabled,
330
336
  upload: this.upload,
331
337
  draftImages: this.draftImages,
338
+ voiceDefaultMode: this.voiceDefaultMode,
339
+ voiceCall: this.voiceCall,
332
340
  voicePhase: this.voicePhase,
333
341
  voiceElapsed: this.voiceElapsed,
334
342
  voiceErrorText: this.voiceErrorText,
@@ -538,25 +546,7 @@ export default {
538
546
  silence: () => void this.runtime.interruptSpeech?.(),
539
547
  fail: (err) => this.showSpeakProblem(err),
540
548
  });
541
- this.voiceCap = createVoiceCapture({
542
- transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
543
- onPhase: (phase, extra = {}) => {
544
- this.voicePhase = phase;
545
- if (extra.elapsed != null) this.voiceElapsed = extra.elapsed;
546
- if (extra.error != null) this.voiceError = extra.error;
547
- },
548
- onTrace: (event, fields) => {
549
- void this.runtime.reportLog?.("voice", event, fields);
550
- },
551
- submit: (text) => {
552
- void this.runtime.reportLog?.("voice", "submit", {
553
- chars: String(text ?? "").trim().length,
554
- running: Boolean(this.running),
555
- session: Boolean(this.sessionId),
556
- });
557
- void this.sendTranscript(text);
558
- },
559
- });
549
+
560
550
  this.bindIntake(this.sessionId);
561
551
  this.bindRuntime();
562
552
  try {
@@ -572,6 +562,13 @@ export default {
572
562
  this.conversationUnsub = subscribeConversationSettings((section) => {
573
563
  this.busyEnter = section.busyEnter;
574
564
  });
565
+ this.voiceVisibilityHandler = () => {
566
+ if (document.hidden) void this.voiceConversation?.controller.disconnect("voice_background");
567
+ };
568
+ document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
569
+ this.voiceClock = setInterval(() => {
570
+ if (this.voicePhase === "recording") this.voiceElapsed += 1;
571
+ }, 1000);
575
572
  this.retry();
576
573
  },
577
574
  activated() {
@@ -581,11 +578,14 @@ export default {
581
578
  this.captureLog();
582
579
  },
583
580
  beforeUnmount() {
581
+ document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
582
+ clearInterval(this.voiceClock);
584
583
  this.captureLog();
585
584
  this.noteSeen(this.sessionId);
586
585
  for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
587
586
  this.imageUploadControllers?.clear?.();
588
- this.voiceCap?.dispose();
587
+ void this.voiceConversation?.stop().catch(() => {});
588
+ this.voiceUnsub?.();
589
589
  this.conversationUnsub?.();
590
590
  this.intakeUnsub?.();
591
591
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
@@ -642,10 +642,12 @@ export default {
642
642
  this.commands = null;
643
643
  this.commandsError = "";
644
644
  }
645
- if (sessionChanged) {
645
+ if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
646
+ void this.voiceConversation?.stop().catch(() => {});
646
647
  this.reader.stop();
647
648
  this.player?.stop();
648
649
  }
650
+ if (sessionChanged) this.voiceAdoptingSession = null;
649
651
  const utterance = this.replySpeaker.observe({
650
652
  sessionChanged,
651
653
  wasRunning: this.running,
@@ -656,7 +658,7 @@ export default {
656
658
  this.items = snap.items;
657
659
  this.running = snap.running;
658
660
  this.runningSince = snap.runningSince || 0;
659
- const target = this.voiceSpeak ? speakable(utterance, this.voiceStreaming) : null;
661
+ const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance, this.voiceStreaming) : null;
660
662
  if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
661
663
  this.failed = snap.failed;
662
664
  this.error = snap.error;
@@ -951,7 +953,7 @@ export default {
951
953
  this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
952
954
  },
953
955
  switchHost() {
954
- this.voiceCap?.stop?.(true);
956
+ void this.voiceConversation?.stop().catch(() => {});
955
957
  for (const controller of this.imageUploadControllers.values()) controller.abort();
956
958
  this.imageUploadControllers.clear();
957
959
  this.releaseDraftImages();
@@ -1030,6 +1032,7 @@ export default {
1030
1032
  if (settings) {
1031
1033
  jobs.push(settings.voice().then((voice) => {
1032
1034
  this.voiceLanguage = voice.config?.language || "";
1035
+ this.voiceDefaultMode = voice.config?.mobileMode || "continuous";
1033
1036
  this.voiceSpeak = voice.config?.speak === true;
1034
1037
  this.voiceStreaming = voice.config?.streaming === true;
1035
1038
  }).catch(() => {
@@ -1186,24 +1189,90 @@ export default {
1186
1189
  this.draftImages = [];
1187
1190
  this.imagesBySession = {};
1188
1191
  },
1189
- onHoldStart(detail) {
1190
- void this.runtime.reportLog?.("voice", "hold-start", {
1191
- via: detail?.via || "",
1192
- phase: this.voicePhase,
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 || "";
1193
1253
  });
1194
- if (captureSource("mobile") !== "device") return;
1195
- if (this.voicePhase === "idle" || this.voicePhase === "error") void this.voiceCap?.start();
1196
- this.reader.stop();
1197
- this.player?.stop();
1198
- this.player?.unlock();
1199
- void this.runtime.interruptSpeech?.();
1254
+ await conversation.start({ sessionId: target || null, continuous });
1255
+ if (this.voiceReleased && !continuous) conversation.controller.stopRecording();
1200
1256
  },
1201
- onHoldEnd(detail) {
1202
- void this.runtime.reportLog?.("voice", "hold-end", {
1203
- via: detail?.via || "",
1204
- phase: this.voicePhase,
1205
- });
1206
- this.voiceCap?.stop(false);
1257
+ onHoldEnd() {
1258
+ this.voiceReleased = true;
1259
+ if (!this.voiceConversation?.controller.getSnapshot().continuous) this.voiceConversation?.controller.stopRecording();
1260
+ },
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; }
1207
1276
  },
1208
1277
  showSpeakProblem(err) {
1209
1278
  this.speakNotice = speakProblemText(this.t, err);
@@ -1381,27 +1450,6 @@ export default {
1381
1450
  if (!sessionId) return;
1382
1451
  return this.runtime.archiveSession(sessionId);
1383
1452
  },
1384
- async sendTranscript(text) {
1385
- const spoken = String(text ?? "").trim();
1386
- if (!spoken || !this.sessionId || this.modelBlocked || this.historyLoading || this.upload?.pending) return;
1387
- const mode = resolveSubmitMode(this.running, false, true, this.busyEnter);
1388
- this.sending = true;
1389
- this.pendingUser = this.running ? "" : spoken;
1390
- this.runtime.pinToBottom();
1391
- this.pinLog();
1392
- try {
1393
- const sent = await this.runtime.send([{ type: "text", text: spoken }], mode);
1394
- if (!sent.ok) {
1395
- this.pendingUser = "";
1396
- this.failed = sent.error?.message || sent.error?.code || "send";
1397
- }
1398
- } catch (err) {
1399
- this.pendingUser = "";
1400
- this.failed = err instanceof Error ? err.message : "send";
1401
- } finally {
1402
- this.sending = false;
1403
- }
1404
- },
1405
1453
  async send(mode = "queue") {
1406
1454
  const text = this.draft.trim();
1407
1455
  if (!this.canSend) return;
@@ -82,6 +82,10 @@
82
82
  </button>
83
83
  </figure>
84
84
  </div>
85
+ <select v-if="inputMode === 'voice' && !voiceCall" :value="voiceSelection || voiceDefaultMode" @change="voiceSelection = $event.target.value" class="lares-composer__voice-mode" :aria-label="t('settings.mode')">
86
+ <option value="single">{{ t('settings.mode.single') }}</option>
87
+ <option value="continuous">{{ t('settings.mode.continuous') }}</option>
88
+ </select>
85
89
  <div class="lares-composer__line" :data-expanded="expanded ? 'true' : 'false'">
86
90
  <button
87
91
  type="button"
@@ -117,8 +121,7 @@
117
121
  :data-phase="voicePhase"
118
122
  :disabled="voiceBlocked"
119
123
  :aria-label="voiceLabel"
120
- @pointerdown="onVoiceDown"
121
- @touchstart.prevent="onVoiceTouchStart"
124
+ @click="onVoiceClick"
122
125
  @contextmenu.prevent
123
126
  >
124
127
  <span v-if="voicePhase === 'preparing' || voicePhase === 'transcribing'" class="lares-composer__talk-status">
@@ -181,6 +184,25 @@
181
184
  </div>
182
185
  </div>
183
186
  </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>
184
206
  <p v-if="voiceError && voicePhase === 'error'" class="lares-composer__hint" data-status="error">{{ voiceError }}</p>
185
207
  <p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
186
208
  {{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
@@ -189,7 +211,7 @@
189
211
  </template>
190
212
 
191
213
  <script>
192
- import { formatElapsed } from "./voice.js";
214
+ import { formatElapsed } from "@olares/lares-core/voice/recorder";
193
215
  import {
194
216
  REFERENCE_DEBOUNCE_MS,
195
217
  activeReference,
@@ -208,6 +230,7 @@ export default {
208
230
  canSend: { type: Boolean, default: false },
209
231
  attachPending: { type: Boolean, default: false },
210
232
  attachDisabled: { type: Boolean, default: false },
233
+ voiceCall: { type: Object, default: null },
211
234
  voicePhase: { type: String, default: "idle" },
212
235
  voiceElapsed: { type: Number, default: 0 },
213
236
  voiceError: { type: String, default: "" },
@@ -219,11 +242,12 @@ export default {
219
242
  commands: { type: Array, default: null },
220
243
  commandsError: { type: String, default: "" },
221
244
  hero: { type: Boolean, default: false },
245
+ voiceDefaultMode: { type: String, default: "continuous" },
222
246
  inputMode: { type: String, default: "text" },
223
247
  t: { type: Function, required: true },
224
248
  },
225
249
  emits: [
226
- "update:draft", "update:inputMode", "send", "files", "voice", "hold-start", "hold-end",
250
+ "update:draft", "update:inputMode", "send", "files", "voice", "voice-action", "hold-start", "hold-end",
227
251
  "open-commands", "open-references", "pick-command", "remove-image",
228
252
  ],
229
253
  data() {
@@ -232,6 +256,8 @@ export default {
232
256
  commandsOpen: false,
233
257
  commandTyped: false,
234
258
  commandIndex: 0,
259
+ levels: [],
260
+ voiceSelection: "",
235
261
  referenceOpen: false,
236
262
  referenceToken: null,
237
263
  referenceIndex: 0,
@@ -244,10 +270,10 @@ export default {
244
270
  },
245
271
  talkLabel() {
246
272
  if (this.voicePhase === "preparing") return this.t("composer.opening");
247
- if (this.voicePhase === "recording") return `${this.t("composer.hold")} · ${this.elapsed}`;
273
+ if (this.voicePhase === "recording") return `${this.t("call.listening")} · ${this.elapsed}`;
248
274
  if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
249
275
  if (this.holding) return this.t("composer.hold");
250
- return this.t("composer.holdToTalk");
276
+ return this.t("call.start");
251
277
  },
252
278
  voiceBusy() {
253
279
  return this.voicePhase === "preparing"
@@ -264,9 +290,9 @@ export default {
264
290
  voiceLabel() {
265
291
  if (this.voicePhase === "error" && this.voiceError) return this.voiceError;
266
292
  if (this.voicePhase === "preparing") return this.t("composer.opening");
267
- if (this.voicePhase === "recording") return this.t("composer.hold");
293
+ if (this.voicePhase === "recording") return this.t("call.listening");
268
294
  if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
269
- return this.t("composer.voice");
295
+ return this.t("call.start");
270
296
  },
271
297
  voiceBlocked() {
272
298
  return this.voicePhase === "transcribing" || this.sending;
@@ -285,6 +311,7 @@ export default {
285
311
  },
286
312
  },
287
313
  watch: {
314
+ "voiceCall.inputLevel"(level) { if (level != null) this.levels = [...this.levels.slice(-15), level]; },
288
315
  draft(value) {
289
316
  this.autosize();
290
317
  if (value && this.inputMode === "voice" && !this.voiceBusy) this.setMode("text");
@@ -447,55 +474,15 @@ export default {
447
474
  event.preventDefault();
448
475
  }
449
476
  },
450
- onVoiceDown(event) {
451
- if (event.pointerType === "touch") return;
452
- if (event.button > 0 || this.voiceBlocked || this.holding) return;
453
- event.preventDefault();
454
- event.currentTarget.setPointerCapture?.(event.pointerId);
455
- this.beginHold("mouse");
456
- },
457
- onVoiceTouchStart() {
458
- if (this.voiceBlocked || this.holdKind === "touch") return;
459
- this.beginHold("touch");
460
- },
461
- beginHold(kind) {
462
- const starting = !this.holding;
463
- this.detachHold();
464
- this.holding = true;
465
- this.holdKind = kind;
466
- if (starting) this.$refs.input?.blur();
467
- const end = (event) => {
468
- if (kind === "touch" && event.type.startsWith("pointer")) return;
469
- if (kind === "mouse" && event.type.startsWith("touch")) return;
470
- this.detachHold();
471
- if (!this.holding) return;
472
- this.holding = false;
473
- this.holdKind = "";
474
- this.$emit("hold-end", { via: event.type });
475
- };
476
- this.holdEnd = end;
477
- if (kind === "touch") {
478
- window.addEventListener("touchend", end);
479
- window.addEventListener("touchcancel", end);
480
- } else {
481
- window.addEventListener("pointerup", end, true);
482
- window.addEventListener("pointercancel", end, true);
483
- }
484
- if (starting) this.$emit("hold-start", { via: kind });
485
- },
486
- detachHold() {
487
- if (!this.holdEnd) return;
488
- window.removeEventListener("touchend", this.holdEnd);
489
- window.removeEventListener("touchcancel", this.holdEnd);
490
- window.removeEventListener("pointerup", this.holdEnd, true);
491
- window.removeEventListener("pointercancel", this.holdEnd, true);
492
- this.holdEnd = null;
477
+ onVoiceClick() {
478
+ if (this.voiceBusy) return;
479
+ this.$emit("hold-start", { mode: this.voiceSelection || undefined });
493
480
  },
494
481
  clearHold() {
495
- this.detachHold();
496
482
  this.holding = false;
497
483
  this.holdKind = "";
498
484
  },
485
+ detachHold() {},
499
486
  pickFiles() {
500
487
  this.$refs.picker?.click();
501
488
  },
@@ -915,3 +902,8 @@ export default {
915
902
  }
916
903
  }
917
904
  </style>
905
+
906
+ <style scoped>
907
+ .lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
908
+ .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
+ </style>
package/src/host.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  rawFileUrl,
13
13
  } from "@olares/lares-core/files/preview-workspace";
14
14
  import { FILES_UPLOAD_PATH, uploadFile } from "@olares/lares-core/files/upload-client";
15
- import { API as VOICE_API, postTranscribe } from "@olares/lares-core/voice/client";
15
+ import { API as VOICE_API, postTranscribe, voiceHeaders } from "@olares/lares-core/voice/client";
16
16
  import { postReport, REPORT_PATH } from "@olares/lares-core/log/report";
17
17
 
18
18
  function pageOrigin() {
@@ -270,6 +270,11 @@ export function createHostClient(ports = {}) {
270
270
  request: direct,
271
271
  });
272
272
  },
273
+ voiceSocket(ticket) {
274
+ const auth = socketProtocols(ports);
275
+ return new WebSocket(muxWsUrl(urlFor(`${VOICE_API}/realtime`), pageOrigin()), ["lares-voice", `lares-ticket.${ticket}`, ...(Array.isArray(auth) ? auth : auth ? [auth] : [])]);
276
+ },
277
+ voiceRequest(path, options) { return request(path, options); },
273
278
  transcribe(blob, language, signal) {
274
279
  return postTranscribe(blob, language, signal, {
275
280
  baseUrl: urlFor(VOICE_API),
@@ -286,6 +291,7 @@ export function createHostClient(ports = {}) {
286
291
  async speak({ key, text, done, part }) {
287
292
  const res = await request(`${VOICE_API}/speak`, {
288
293
  method: "POST",
294
+ headers: voiceHeaders("device"),
289
295
  body: Number.isInteger(part) ? { key, text, done, part } : { key, text, done },
290
296
  });
291
297
  const body = res?.body;
@@ -302,7 +308,7 @@ export function createHostClient(ports = {}) {
302
308
  throw Object.assign(new Error(message), { code });
303
309
  },
304
310
  async interruptSpeech() {
305
- const res = await request(`${VOICE_API}/playback/stop`, { method: "POST" });
311
+ const res = await request(`${VOICE_API}/playback/stop`, { method: "POST", headers: voiceHeaders("device") });
306
312
  return Boolean(res?.ok);
307
313
  },
308
314
  async openMux(signal) {
@@ -72,6 +72,8 @@
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-call="state.voiceCall"
76
+ :voice-default-mode="state.voiceDefaultMode"
75
77
  :voice-phase="state.voicePhase"
76
78
  :voice-elapsed="state.voiceElapsed"
77
79
  :voice-error="state.voiceErrorText"
@@ -91,6 +93,7 @@
91
93
  @voice="$emit('voice')"
92
94
  @hold-start="$emit('hold-start', $event)"
93
95
  @hold-end="$emit('hold-end', $event)"
96
+ @voice-action="$emit('voice-action', $event)"
94
97
  @open-commands="$emit('open-commands')"
95
98
  @open-references="$emit('open-references', $event)"
96
99
  @pick-command="$emit('pick-command', $event)"
@@ -133,6 +136,8 @@
133
136
  :can-send="state.canSend"
134
137
  :attach-pending="state.upload.pending > 0"
135
138
  :attach-disabled="!state.sessionId || state.historyLoading || state.starting"
139
+ :voice-call="state.voiceCall"
140
+ :voice-default-mode="state.voiceDefaultMode"
136
141
  :voice-phase="state.voicePhase"
137
142
  :voice-elapsed="state.voiceElapsed"
138
143
  :voice-error="state.voiceErrorText"
@@ -152,6 +157,7 @@
152
157
  @voice="$emit('voice')"
153
158
  @hold-start="$emit('hold-start', $event)"
154
159
  @hold-end="$emit('hold-end', $event)"
160
+ @voice-action="$emit('voice-action', $event)"
155
161
  @open-commands="$emit('open-commands')"
156
162
  @open-references="$emit('open-references', $event)"
157
163
  @pick-command="$emit('pick-command', $event)"
@@ -249,7 +255,7 @@ export default {
249
255
  emits: [
250
256
  "history", "create", "toggle-speak", "close-history", "pick-session", "retry", "open-file",
251
257
  "answer", "update-draft", "send", "queue-action", "open-model", "open-effort", "files",
252
- "voice", "hold-start", "hold-end", "close-model", "choose-model",
258
+ "voice", "voice-action", "hold-start", "hold-end", "close-model", "choose-model",
253
259
  "close-effort", "choose-effort", "close-preview",
254
260
  "open-commands", "open-references", "pick-command", "remove-image",
255
261
  "refresh-workspaces", "browse-workspaces", "browse-directory",
@@ -41,7 +41,6 @@
41
41
  <p class="lares-agent__hint">{{ t("settings.input.busyEnter.hint") }}</p>
42
42
  <p v-if="conversationError" class="lares-agent__hint" data-status="error">{{ conversationError }}</p>
43
43
  </section>
44
- <!-- Voice input parked.
45
44
  <section class="lares-agent__group">
46
45
  <h2>{{ tVoice("settings.title") }}</h2>
47
46
  <div class="lares-agent__list">
@@ -62,9 +61,43 @@
62
61
  @click="openSheet('voiceLang')"
63
62
  />
64
63
  </div>
64
+ <label class="lares-agent__hint">{{ tVoice('settings.preset') }}
65
+ <select :value="voice?.config?.preset || 'assistant'" @change="patchVoice('preset', $event.target.value)">
66
+ <option v-for="preset in ['dictation', 'chat', 'assistant', 'direct']" :key="preset" :value="preset">{{ tVoice(`settings.preset.${preset}`) }}</option>
67
+ </select>
68
+ </label>
69
+ <label class="lares-agent__hint">{{ tVoice('settings.chatModel') }}
70
+ <select :value="voice?.config?.chatModel || 'default-chat'" @change="patchVoice('chatModel', $event.target.value)">
71
+ <option value="default-chat">default-chat</option>
72
+ <option v-for="model in voice?.chatModels || []" :key="model" :value="model">{{ model }}</option>
73
+ </select>
74
+ </label>
75
+ <label class="lares-agent__hint">{{ tVoice('settings.backend') }}
76
+ <select :value="voice?.config?.backend || 'pipeline'" @change="patchVoice('backend', $event.target.value)">
77
+ <option value="pipeline">{{ tVoice('settings.backend.pipeline') }}</option>
78
+ <option v-if="voice?.realtimeReady?.includes(voice?.config?.realtimeModel)" value="realtime">Realtime</option>
79
+ </select>
80
+ </label>
81
+ <label v-if="voice?.realtimeModels?.length" class="lares-agent__hint">{{ tVoice('settings.realtime.model') }}
82
+ <select :value="voice?.config?.realtimeModel || ''" @change="patchVoice('realtimeModel', $event.target.value)">
83
+ <option value="" disabled>—</option>
84
+ <option v-for="model in voice.realtimeModels" :key="model" :value="model">{{ model }}</option>
85
+ </select>
86
+ </label>
87
+ <p class="lares-agent__hint">{{ tVoice('settings.realtime.hint') }}</p>
88
+ <label class="lares-agent__hint">{{ tVoice('settings.mobileMode') }}
89
+ <select :value="voice?.config?.mobileMode || 'continuous'" @change="patchVoice('mobileMode', $event.target.value)">
90
+ <option value="single">{{ tVoice('settings.mode.single') }}</option>
91
+ <option value="continuous">{{ tVoice('settings.mode.continuous') }}</option>
92
+ </select>
93
+ </label>
94
+ <label class="lares-agent__hint">{{ tVoice('settings.device') }}
95
+ <select :value="voice?.config?.device || 'auto'" @change="patchVoice('device', $event.target.value)">
96
+ <option v-for="device in ['auto', 'device', 'headset']" :key="device" :value="device">{{ tVoice(`settings.device.${device}`) }}</option>
97
+ </select>
98
+ </label>
65
99
  <p v-if="voiceError" class="lares-agent__hint" data-status="error">{{ voiceError }}</p>
66
100
  </section>
67
- -->
68
101
  <section class="lares-agent__group">
69
102
  <h2>{{ tSearch("settings.title") }}</h2>
70
103
  <LaresSelect
package/src/chat/voice.js DELETED
@@ -1,269 +0,0 @@
1
- import {
2
- formatElapsed,
3
- isRecordingTooShort,
4
- pickRecordingMime,
5
- } from "@olares/lares-core/voice/recorder";
6
-
7
- export { formatElapsed };
8
-
9
- /** Phone mic. Bare booleans are exact and iOS rejects the headset profile. */
10
- const DEVICE_AUDIO = {
11
- channelCount: { ideal: 1 },
12
- echoCancellation: { ideal: true },
13
- noiseSuppression: { ideal: true },
14
- autoGainControl: { ideal: true },
15
- };
16
-
17
- const CHUNK_MS = 250;
18
-
19
- function micFailure(err) {
20
- const name = err?.name || "";
21
- if (name === "NotAllowedError" || name === "PermissionDeniedError") return "voice_permission_denied";
22
- if (name === "SecurityError") return "voice_unsupported";
23
- return "voice_failed";
24
- }
25
-
26
- async function openDeviceMic(mediaDevices) {
27
- try {
28
- return await mediaDevices.getUserMedia({ audio: DEVICE_AUDIO });
29
- } catch (err) {
30
- if (micFailure(err) !== "voice_failed") throw err;
31
- return mediaDevices.getUserMedia({ audio: true });
32
- }
33
- }
34
-
35
- export function createVoiceCapture({
36
- transcribe,
37
- submit,
38
- onPhase,
39
- onTrace,
40
- now = Date.now,
41
- media,
42
- }) {
43
- const mediaDevices = media?.mediaDevices ?? globalThis.navigator?.mediaDevices;
44
- const Recorder = media?.MediaRecorder ?? globalThis.MediaRecorder;
45
- let recorder = null;
46
- let stream = null;
47
- let chunks = [];
48
- let started = 0;
49
- let timer = 0;
50
- let errorTimer = 0;
51
- let finishTimer = 0;
52
- let cancel = false;
53
- let released = false;
54
- let finished = false;
55
- let dead = false;
56
- let abort = null;
57
- let phase = "idle";
58
-
59
- const emit = (next, extra = {}) => {
60
- phase = next;
61
- onPhase?.(next, extra);
62
- };
63
-
64
- const trace = (event, fields) => {
65
- try {
66
- onTrace?.(event, fields);
67
- } catch {
68
- /* a trace must not change the take */
69
- }
70
- };
71
-
72
- const stopTimer = () => {
73
- if (timer) {
74
- clearInterval(timer);
75
- timer = 0;
76
- }
77
- };
78
-
79
- const release = () => {
80
- stream?.getTracks().forEach((track) => track.stop());
81
- stream = null;
82
- recorder = null;
83
- };
84
-
85
- const fail = (code) => {
86
- if (dead) return;
87
- if (errorTimer) clearTimeout(errorTimer);
88
- emit("error", { error: code, elapsed: 0 });
89
- errorTimer = setTimeout(() => {
90
- errorTimer = 0;
91
- if (!dead && phase === "error") emit("idle", { error: "", elapsed: 0 });
92
- }, 3200);
93
- };
94
-
95
- const finish = async () => {
96
- if (finished || dead) return;
97
- finished = true;
98
- stopTimer();
99
- const took = now() - started;
100
- const type = recorder?.mimeType || chunks[0]?.type || "audio/webm";
101
- const blobChunks = chunks;
102
- chunks = [];
103
- release();
104
- if (cancel) {
105
- emit("idle", { error: "", elapsed: 0 });
106
- return;
107
- }
108
- const blob = new Blob(blobChunks, { type });
109
- trace("blob", { bytes: blob.size, type, ms: took, chunks: blobChunks.length });
110
- if (isRecordingTooShort(took, blob.size)) {
111
- trace("too-short", { bytes: blob.size, ms: took });
112
- fail("voice_too_short");
113
- return;
114
- }
115
- emit("transcribing", { elapsed: took, error: "" });
116
- abort = new AbortController();
117
- try {
118
- const text = String(await transcribe(blob, abort.signal) ?? "").trim();
119
- if (dead || cancel) return;
120
- if (!text) {
121
- trace("no-input", {});
122
- fail("voice_no_input");
123
- return;
124
- }
125
- trace("transcript", { chars: text.length, head: text.slice(0, 40) });
126
- emit("idle", { error: "", elapsed: 0 });
127
- submit?.(text);
128
- } catch (err) {
129
- if (dead || abort.signal.aborted || cancel) return;
130
- const code = err instanceof Error ? err.message : "voice_failed";
131
- trace("transcribe-error", { code });
132
- fail(code);
133
- } finally {
134
- abort = null;
135
- }
136
- };
137
-
138
- const scheduleFinish = () => {
139
- if (finishTimer) clearTimeout(finishTimer);
140
- finishTimer = setTimeout(() => {
141
- finishTimer = 0;
142
- void finish();
143
- }, 30);
144
- };
145
-
146
- const endRecorder = () => {
147
- if (!recorder || recorder.state === "inactive") {
148
- scheduleFinish();
149
- return;
150
- }
151
- try {
152
- recorder.requestData?.();
153
- } catch {
154
- /* the final dataavailable still arrives from stop() */
155
- }
156
- try {
157
- recorder.stop();
158
- } catch {
159
- scheduleFinish();
160
- }
161
- };
162
-
163
- return {
164
- get phase() {
165
- return phase;
166
- },
167
- async start() {
168
- if (dead || phase === "preparing" || phase === "recording" || phase === "transcribing") return;
169
- if (typeof Recorder === "undefined" || !mediaDevices?.getUserMedia) {
170
- fail("voice_unsupported");
171
- return;
172
- }
173
- cancel = false;
174
- released = false;
175
- finished = false;
176
- trace("open", {});
177
- emit("preparing", { elapsed: 0, error: "" });
178
- let next;
179
- try {
180
- next = await openDeviceMic(mediaDevices);
181
- } catch (err) {
182
- const code = micFailure(err);
183
- trace("mic-fail", { code, name: err?.name || "" });
184
- if (!dead) fail(code);
185
- return;
186
- }
187
- if (dead || cancel) {
188
- next.getTracks().forEach((track) => track.stop());
189
- if (!dead && phase === "preparing") emit("idle", { error: "", elapsed: 0 });
190
- return;
191
- }
192
- stream = next;
193
- chunks = [];
194
- const mime = pickRecordingMime(Recorder);
195
- try {
196
- recorder = new Recorder(stream, mime ? { mimeType: mime } : undefined);
197
- } catch {
198
- release();
199
- fail("voice_unsupported");
200
- return;
201
- }
202
- recorder.ondataavailable = (event) => {
203
- if (event.data?.size) chunks.push(event.data);
204
- if (recorder?.state === "inactive") scheduleFinish();
205
- };
206
- recorder.onstop = () => scheduleFinish();
207
- started = now();
208
- try {
209
- recorder.start(CHUNK_MS);
210
- } catch {
211
- trace("record-fail", { mime });
212
- release();
213
- fail("voice_unsupported");
214
- return;
215
- }
216
- trace("recording", { mime: recorder.mimeType || mime || "" });
217
- if (released || cancel) {
218
- endRecorder();
219
- return;
220
- }
221
- emit("recording", { elapsed: 0, error: "" });
222
- timer = setInterval(() => emit("recording", { elapsed: now() - started, error: "" }), 200);
223
- },
224
- /**
225
- * Finger-up finishes the take. The mic opens asynchronously, so the finger
226
- * often lifts while still preparing — that is not a cancel.
227
- */
228
- stop(aborting = false) {
229
- if (phase === "transcribing") {
230
- if (aborting) {
231
- cancel = true;
232
- abort?.abort();
233
- }
234
- return;
235
- }
236
- if (phase !== "preparing" && phase !== "recording") {
237
- trace("stop-ignored", { aborting: Boolean(aborting), phase });
238
- return;
239
- }
240
- trace("stop", {
241
- aborting: Boolean(aborting),
242
- phase,
243
- recorder: recorder?.state || "none",
244
- });
245
- if (aborting) {
246
- cancel = true;
247
- released = false;
248
- if (recorder && recorder.state !== "inactive") recorder.stop();
249
- return;
250
- }
251
- released = true;
252
- stopTimer();
253
- if (recorder && recorder.state !== "inactive") {
254
- emit("transcribing", { elapsed: now() - started, error: "" });
255
- endRecorder();
256
- }
257
- },
258
- dispose() {
259
- dead = true;
260
- cancel = true;
261
- if (errorTimer) clearTimeout(errorTimer);
262
- if (finishTimer) clearTimeout(finishTimer);
263
- stopTimer();
264
- abort?.abort();
265
- if (recorder && recorder.state !== "inactive") recorder.stop();
266
- release();
267
- },
268
- };
269
- }