@olares/lares-mobile 0.15.10 → 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.10",
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.10",
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
- import { createReplySpeaker } from "@olares/lares-core/voice/speech";
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,16 @@ 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",
232
+ voiceStreaming: false,
226
233
  speakBusy: false,
227
234
  speakNotice: "",
228
235
  intakeUnsub: null,
@@ -328,6 +335,8 @@ export default {
328
335
  effortDisabled: this.effortDisabled,
329
336
  upload: this.upload,
330
337
  draftImages: this.draftImages,
338
+ voiceDefaultMode: this.voiceDefaultMode,
339
+ voiceCall: this.voiceCall,
331
340
  voicePhase: this.voicePhase,
332
341
  voiceElapsed: this.voiceElapsed,
333
342
  voiceErrorText: this.voiceErrorText,
@@ -523,26 +532,21 @@ export default {
523
532
  this.imageUploadControllers = new Map();
524
533
  this.player = createAnswerPlayer();
525
534
  this.replySpeaker = createReplySpeaker();
526
- this.speakGen = 0;
527
- this.voiceCap = createVoiceCapture({
528
- transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
529
- onPhase: (phase, extra = {}) => {
530
- this.voicePhase = phase;
531
- if (extra.elapsed != null) this.voiceElapsed = extra.elapsed;
532
- if (extra.error != null) this.voiceError = extra.error;
535
+ this.reader = createSentencePlayer({
536
+ send: async (request) => {
537
+ const result = await this.runtime.speak?.(request);
538
+ if (!result) throw Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" });
539
+ return result;
533
540
  },
534
- onTrace: (event, fields) => {
535
- void this.runtime.reportLog?.("voice", event, fields);
536
- },
537
- submit: (text) => {
538
- void this.runtime.reportLog?.("voice", "submit", {
539
- chars: String(text ?? "").trim().length,
540
- running: Boolean(this.running),
541
- session: Boolean(this.sessionId),
542
- });
543
- void this.sendTranscript(text);
541
+ play: (sentence) => {
542
+ if (!browserPlays("mobile", sentence.bluetooth)) return Promise.resolve(false);
543
+ return this.player?.play(wavBlob(sentence.audio));
544
544
  },
545
+ halt: () => this.player?.stop(),
546
+ silence: () => void this.runtime.interruptSpeech?.(),
547
+ fail: (err) => this.showSpeakProblem(err),
545
548
  });
549
+
546
550
  this.bindIntake(this.sessionId);
547
551
  this.bindRuntime();
548
552
  try {
@@ -558,6 +562,13 @@ export default {
558
562
  this.conversationUnsub = subscribeConversationSettings((section) => {
559
563
  this.busyEnter = section.busyEnter;
560
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);
561
572
  this.retry();
562
573
  },
563
574
  activated() {
@@ -567,11 +578,14 @@ export default {
567
578
  this.captureLog();
568
579
  },
569
580
  beforeUnmount() {
581
+ document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
582
+ clearInterval(this.voiceClock);
570
583
  this.captureLog();
571
584
  this.noteSeen(this.sessionId);
572
585
  for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
573
586
  this.imageUploadControllers?.clear?.();
574
- this.voiceCap?.dispose();
587
+ void this.voiceConversation?.stop().catch(() => {});
588
+ this.voiceUnsub?.();
575
589
  this.conversationUnsub?.();
576
590
  this.intakeUnsub?.();
577
591
  if (this.sessionId) this.intake?.cancelSession(this.sessionId);
@@ -628,21 +642,24 @@ export default {
628
642
  this.commands = null;
629
643
  this.commandsError = "";
630
644
  }
631
- if (sessionChanged) {
632
- this.speakGen += 1;
645
+ if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
646
+ void this.voiceConversation?.stop().catch(() => {});
647
+ this.reader.stop();
633
648
  this.player?.stop();
634
649
  }
635
- const answer = this.replySpeaker.observe({
650
+ if (sessionChanged) this.voiceAdoptingSession = null;
651
+ const utterance = this.replySpeaker.observe({
636
652
  sessionChanged,
637
653
  wasRunning: this.running,
638
654
  running: snap.running,
639
- reply: snap.reply,
655
+ utterance: snap.utterance,
640
656
  });
641
657
  this.sessionId = snap.sessionId;
642
658
  this.items = snap.items;
643
659
  this.running = snap.running;
644
660
  this.runningSince = snap.runningSince || 0;
645
- if (answer) void this.speakReply(answer, ++this.speakGen);
661
+ const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance, this.voiceStreaming) : null;
662
+ if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
646
663
  this.failed = snap.failed;
647
664
  this.error = snap.error;
648
665
  this.sessions = snap.sessions ?? this.sessions;
@@ -936,7 +953,7 @@ export default {
936
953
  this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
937
954
  },
938
955
  switchHost() {
939
- this.voiceCap?.stop?.(true);
956
+ void this.voiceConversation?.stop().catch(() => {});
940
957
  for (const controller of this.imageUploadControllers.values()) controller.abort();
941
958
  this.imageUploadControllers.clear();
942
959
  this.releaseDraftImages();
@@ -1015,10 +1032,13 @@ export default {
1015
1032
  if (settings) {
1016
1033
  jobs.push(settings.voice().then((voice) => {
1017
1034
  this.voiceLanguage = voice.config?.language || "";
1035
+ this.voiceDefaultMode = voice.config?.mobileMode || "continuous";
1018
1036
  this.voiceSpeak = voice.config?.speak === true;
1037
+ this.voiceStreaming = voice.config?.streaming === true;
1019
1038
  }).catch(() => {
1020
1039
  this.voiceLanguage = "";
1021
1040
  this.voiceSpeak = false;
1041
+ this.voiceStreaming = false;
1022
1042
  }));
1023
1043
  }
1024
1044
  await Promise.all(jobs);
@@ -1169,24 +1189,90 @@ export default {
1169
1189
  this.draftImages = [];
1170
1190
  this.imagesBySession = {};
1171
1191
  },
1172
- onHoldStart(detail) {
1173
- void this.runtime.reportLog?.("voice", "hold-start", {
1174
- via: detail?.via || "",
1175
- 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 || "";
1176
1253
  });
1177
- if (captureSource("mobile") !== "device") return;
1178
- if (this.voicePhase === "idle" || this.voicePhase === "error") void this.voiceCap?.start();
1179
- this.speakGen += 1;
1180
- this.player?.stop();
1181
- this.player?.unlock();
1182
- void this.runtime.interruptSpeech?.();
1254
+ await conversation.start({ sessionId: target || null, continuous });
1255
+ if (this.voiceReleased && !continuous) conversation.controller.stopRecording();
1183
1256
  },
1184
- onHoldEnd(detail) {
1185
- void this.runtime.reportLog?.("voice", "hold-end", {
1186
- via: detail?.via || "",
1187
- phase: this.voicePhase,
1188
- });
1189
- 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; }
1190
1276
  },
1191
1277
  showSpeakProblem(err) {
1192
1278
  this.speakNotice = speakProblemText(this.t, err);
@@ -1203,13 +1289,14 @@ export default {
1203
1289
  this.voiceSpeak = next;
1204
1290
  this.speakBusy = true;
1205
1291
  if (!next) {
1206
- this.speakGen += 1;
1292
+ this.reader.stop();
1207
1293
  this.player?.stop();
1208
1294
  void this.runtime.interruptSpeech?.();
1209
1295
  }
1210
1296
  try {
1211
1297
  const voice = await this.runtime.settings.saveVoice({ speak: next });
1212
1298
  this.voiceSpeak = voice?.config ? voice.config.speak === true : next;
1299
+ this.voiceStreaming = voice?.config?.streaming === true;
1213
1300
  const problem = this.voiceSpeak ? speakToggleNotice(voice?.ttsModels) : "";
1214
1301
  this.speakNotice = problem ? this.t(`error.${problem}`) : "";
1215
1302
  } catch (err) {
@@ -1219,22 +1306,6 @@ export default {
1219
1306
  this.speakBusy = false;
1220
1307
  }
1221
1308
  },
1222
- async speakReply(text, gen) {
1223
- if (!this.voiceSpeak) return;
1224
- try {
1225
- const result = await this.runtime.speak?.(text);
1226
- if (gen !== this.speakGen) return;
1227
- if (!result?.audio) {
1228
- this.showSpeakProblem(Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" }));
1229
- return;
1230
- }
1231
- if (!browserPlays("mobile", result.bluetooth)) return;
1232
- await this.player?.play(wavBlob(result.audio));
1233
- } catch (err) {
1234
- if (gen !== this.speakGen) return;
1235
- this.showSpeakProblem(err);
1236
- }
1237
- },
1238
1309
  toggleHistory() {
1239
1310
  if (this.panel === "history") {
1240
1311
  this.panel = "";
@@ -1379,27 +1450,6 @@ export default {
1379
1450
  if (!sessionId) return;
1380
1451
  return this.runtime.archiveSession(sessionId);
1381
1452
  },
1382
- async sendTranscript(text) {
1383
- const spoken = String(text ?? "").trim();
1384
- if (!spoken || !this.sessionId || this.modelBlocked || this.historyLoading || this.upload?.pending) return;
1385
- const mode = resolveSubmitMode(this.running, false, true, this.busyEnter);
1386
- this.sending = true;
1387
- this.pendingUser = this.running ? "" : spoken;
1388
- this.runtime.pinToBottom();
1389
- this.pinLog();
1390
- try {
1391
- const sent = await this.runtime.send([{ type: "text", text: spoken }], mode);
1392
- if (!sent.ok) {
1393
- this.pendingUser = "";
1394
- this.failed = sent.error?.message || sent.error?.code || "send";
1395
- }
1396
- } catch (err) {
1397
- this.pendingUser = "";
1398
- this.failed = err instanceof Error ? err.message : "send";
1399
- } finally {
1400
- this.sending = false;
1401
- }
1402
- },
1403
1453
  async send(mode = "queue") {
1404
1454
  const text = this.draft.trim();
1405
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>
@@ -8,10 +8,15 @@ const SILENT_WAV = "data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAE
8
8
  export function createAnswerPlayer() {
9
9
  const audio = typeof Audio === "function" ? new Audio() : null;
10
10
  let url = "";
11
- const release = () => {
12
- if (!url) return;
13
- URL.revokeObjectURL(url);
14
- url = "";
11
+ let settle = null;
12
+ const finish = (played) => {
13
+ const done = settle;
14
+ settle = null;
15
+ if (url) {
16
+ URL.revokeObjectURL(url);
17
+ url = "";
18
+ }
19
+ done?.(played);
15
20
  };
16
21
  return {
17
22
  unlock() {
@@ -19,23 +24,27 @@ export function createAnswerPlayer() {
19
24
  audio.src = SILENT_WAV;
20
25
  void audio.play().catch(() => {});
21
26
  },
27
+ /** Resolves when the clip ends (true), or is stopped or fails (false). */
22
28
  async play(blob) {
23
29
  if (!audio || !blob) return false;
24
- release();
30
+ finish(false);
25
31
  url = URL.createObjectURL(blob);
26
32
  audio.src = url;
27
- audio.onended = release;
33
+ const ended = new Promise((resolve) => {
34
+ settle = resolve;
35
+ });
36
+ audio.onended = () => finish(true);
37
+ audio.onerror = () => finish(false);
28
38
  try {
29
39
  await audio.play();
30
- return true;
31
40
  } catch {
32
- release();
33
- return false;
41
+ finish(false);
34
42
  }
43
+ return ended;
35
44
  },
36
45
  stop() {
37
46
  audio?.pause();
38
- release();
47
+ finish(false);
39
48
  },
40
49
  };
41
50
  }
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),
@@ -278,13 +283,19 @@ export function createHostClient(ports = {}) {
278
283
  },
279
284
  reportLog(scope, event, fields) {
280
285
  return postReport({ scope, event, ...(fields ?? {}) }, {
281
- request,
286
+ request: direct,
282
287
  url: urlFor(REPORT_PATH),
283
288
  });
284
289
  },
285
- async speak(text) {
286
- const res = await request(`${VOICE_API}/speak`, { method: "POST", body: { text } });
290
+ /** Hands over a streaming answer's text; with `part`, also returns that sentence's audio. */
291
+ async speak({ key, text, done, part }) {
292
+ const res = await request(`${VOICE_API}/speak`, {
293
+ method: "POST",
294
+ headers: voiceHeaders("device"),
295
+ body: Number.isInteger(part) ? { key, text, done, part } : { key, text, done },
296
+ });
287
297
  const body = res?.body;
298
+ if (res?.ok && body && !Number.isInteger(part)) return { sentences: Number(body.sentences) || 0 };
288
299
  if (res?.ok && body && typeof body.audio === "string") {
289
300
  return {
290
301
  bluetooth: body.bluetooth === "played" ? "played" : "skipped",
@@ -297,7 +308,7 @@ export function createHostClient(ports = {}) {
297
308
  throw Object.assign(new Error(message), { code });
298
309
  },
299
310
  async interruptSpeech() {
300
- const res = await request(`${VOICE_API}/playback/stop`, { method: "POST" });
311
+ const res = await request(`${VOICE_API}/playback/stop`, { method: "POST", headers: voiceHeaders("device") });
301
312
  return Boolean(res?.ok);
302
313
  },
303
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
- }