@olares/lares-mobile 0.15.11 → 0.15.13
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 +2 -2
- package/src/App.vue +102 -63
- package/src/chat/Composer.vue +23 -122
- package/src/chat/Transcript.vue +4 -1
- package/src/chat/VoicePanel.vue +65 -0
- package/src/host.js +8 -2
- package/src/mobile/MobileShell.vue +7 -1
- package/src/settings/AgentSettings.vue +35 -2
- package/src/chat/voice.js +0 -269
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.13",
|
|
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.
|
|
31
|
+
"@olares/lares-core": "0.15.13",
|
|
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"
|
|
@@ -100,19 +101,26 @@
|
|
|
100
101
|
@pick-command="pickCommand"
|
|
101
102
|
@toggle-speak="toggleSpeak"
|
|
102
103
|
/>
|
|
104
|
+
<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" />
|
|
103
107
|
</div>
|
|
104
108
|
</template>
|
|
105
109
|
|
|
106
110
|
<script>
|
|
111
|
+
import { createVoicePanel } from "@olares/lares-core/voice/panel";
|
|
112
|
+
import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
|
|
113
|
+
import LaresVoicePanel from "./chat/VoicePanel.vue";
|
|
107
114
|
import { pickHostPorts } from "@olares/lares-core/larepass/host";
|
|
108
115
|
import { connectChat } from "./runtime.js";
|
|
109
116
|
import { createT } from "./i18n.js";
|
|
110
117
|
import { failText, withPendingUser } from "./chat/format.js";
|
|
111
|
-
import {
|
|
118
|
+
import { captureSource } from "@olares/lares-core/voice/input";
|
|
119
|
+
import { createRealtimeConversation } from "@olares/lares-core/voice/realtime-conversation";
|
|
120
|
+
import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
|
|
112
121
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
113
122
|
import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
|
|
114
123
|
import { wavBlob } from "@olares/lares-core/voice/client";
|
|
115
|
-
import { captureSource } from "@olares/lares-core/voice/input";
|
|
116
124
|
import { browserPlays } from "@olares/lares-core/voice/playback";
|
|
117
125
|
import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
|
|
118
126
|
import { appendDraftMentions } from "@olares/lares-core/files/mention";
|
|
@@ -169,7 +177,7 @@ function imageData(file) {
|
|
|
169
177
|
|
|
170
178
|
export default {
|
|
171
179
|
name: "LaresApp",
|
|
172
|
-
components: { LaresMobileShell, LaresDesktopShell },
|
|
180
|
+
components: { LaresMobileShell, LaresDesktopShell, LaresVoicePanel },
|
|
173
181
|
props: {
|
|
174
182
|
locale: { type: String, default: "en" },
|
|
175
183
|
device: { type: String, default: "mobile" },
|
|
@@ -218,11 +226,15 @@ export default {
|
|
|
218
226
|
effortSheet: false,
|
|
219
227
|
upload: { pending: 0, failures: [] },
|
|
220
228
|
draftImages: [],
|
|
229
|
+
voiceCall: null,
|
|
230
|
+
voicePanelState: null,
|
|
231
|
+
voiceRecords: [],
|
|
221
232
|
voicePhase: "idle",
|
|
222
233
|
voiceElapsed: 0,
|
|
223
234
|
voiceError: "",
|
|
224
235
|
voiceLanguage: "",
|
|
225
236
|
voiceSpeak: false,
|
|
237
|
+
voiceDefaultMode: "continuous",
|
|
226
238
|
voiceStreaming: false,
|
|
227
239
|
speakBusy: false,
|
|
228
240
|
speakNotice: "",
|
|
@@ -329,6 +341,8 @@ export default {
|
|
|
329
341
|
effortDisabled: this.effortDisabled,
|
|
330
342
|
upload: this.upload,
|
|
331
343
|
draftImages: this.draftImages,
|
|
344
|
+
voiceDefaultMode: this.voiceDefaultMode,
|
|
345
|
+
voiceCall: this.voiceCall,
|
|
332
346
|
voicePhase: this.voicePhase,
|
|
333
347
|
voiceElapsed: this.voiceElapsed,
|
|
334
348
|
voiceErrorText: this.voiceErrorText,
|
|
@@ -442,7 +456,7 @@ export default {
|
|
|
442
456
|
return effortMenuRows(this.reasoning, this.t("reasoning.default"));
|
|
443
457
|
},
|
|
444
458
|
viewItems() {
|
|
445
|
-
return withPendingUser(this.items, this.pendingUser);
|
|
459
|
+
return mergeVoiceTranscript(withPendingUser(this.items, this.pendingUser), this.voiceRecords);
|
|
446
460
|
},
|
|
447
461
|
failText() {
|
|
448
462
|
return failText(this.t, this.failed, this.error);
|
|
@@ -510,6 +524,8 @@ export default {
|
|
|
510
524
|
},
|
|
511
525
|
},
|
|
512
526
|
sessionId(id) {
|
|
527
|
+
this.bindVoiceHistory(id);
|
|
528
|
+
void this.voicePanel?.bind(id || null).catch(() => {});
|
|
513
529
|
this.bindIntake(id);
|
|
514
530
|
},
|
|
515
531
|
hostKey() {
|
|
@@ -523,6 +539,25 @@ export default {
|
|
|
523
539
|
this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
|
|
524
540
|
this.imageUploadControllers = new Map();
|
|
525
541
|
this.player = createAnswerPlayer();
|
|
542
|
+
this.voicePanel = createVoicePanel({
|
|
543
|
+
create: (options) => this.createMobileVoiceConversation(options),
|
|
544
|
+
complete: async ({ sessionId, text }) => {
|
|
545
|
+
const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
|
|
546
|
+
const value = [previous, text].filter(Boolean).join(" ");
|
|
547
|
+
this.draftBySession = { ...this.draftBySession, [sessionId || "@pending"]: value };
|
|
548
|
+
if (sessionId === (this.sessionId || null)) this.draft = value;
|
|
549
|
+
},
|
|
550
|
+
onSession: (sessionId) => {
|
|
551
|
+
this.voiceAdoptingSession = sessionId;
|
|
552
|
+
void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
this.voicePanelState = this.voicePanel.getSnapshot();
|
|
556
|
+
this.voicePanelUnsub = this.voicePanel.subscribe(() => {
|
|
557
|
+
const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
|
|
558
|
+
this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
|
|
559
|
+
this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
|
|
560
|
+
});
|
|
526
561
|
this.replySpeaker = createReplySpeaker();
|
|
527
562
|
this.reader = createSentencePlayer({
|
|
528
563
|
send: async (request) => {
|
|
@@ -538,25 +573,7 @@ export default {
|
|
|
538
573
|
silence: () => void this.runtime.interruptSpeech?.(),
|
|
539
574
|
fail: (err) => this.showSpeakProblem(err),
|
|
540
575
|
});
|
|
541
|
-
|
|
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
|
-
});
|
|
576
|
+
|
|
560
577
|
this.bindIntake(this.sessionId);
|
|
561
578
|
this.bindRuntime();
|
|
562
579
|
try {
|
|
@@ -572,20 +589,32 @@ export default {
|
|
|
572
589
|
this.conversationUnsub = subscribeConversationSettings((section) => {
|
|
573
590
|
this.busyEnter = section.busyEnter;
|
|
574
591
|
});
|
|
592
|
+
this.voiceVisibilityHandler = () => {
|
|
593
|
+
if (document.hidden) void this.voicePanel.pause().catch(() => {});
|
|
594
|
+
};
|
|
595
|
+
document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
596
|
+
this.voiceClock = setInterval(() => {
|
|
597
|
+
if (this.voicePhase === "recording") this.voiceElapsed += 1;
|
|
598
|
+
}, 1000);
|
|
575
599
|
this.retry();
|
|
576
600
|
},
|
|
577
601
|
activated() {
|
|
578
602
|
this.restoreLog();
|
|
579
603
|
},
|
|
580
604
|
deactivated() {
|
|
605
|
+
void this.voicePanel?.pause().catch(() => {});
|
|
581
606
|
this.captureLog();
|
|
582
607
|
},
|
|
583
608
|
beforeUnmount() {
|
|
609
|
+
document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
610
|
+
clearInterval(this.voiceClock);
|
|
584
611
|
this.captureLog();
|
|
585
612
|
this.noteSeen(this.sessionId);
|
|
586
613
|
for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
|
|
587
614
|
this.imageUploadControllers?.clear?.();
|
|
588
|
-
this.
|
|
615
|
+
void this.voicePanel?.dispose().catch(() => {});
|
|
616
|
+
this.voicePanelUnsub?.();
|
|
617
|
+
this.voiceHistoryUnsub?.();
|
|
589
618
|
this.conversationUnsub?.();
|
|
590
619
|
this.intakeUnsub?.();
|
|
591
620
|
if (this.sessionId) this.intake?.cancelSession(this.sessionId);
|
|
@@ -642,10 +671,12 @@ export default {
|
|
|
642
671
|
this.commands = null;
|
|
643
672
|
this.commandsError = "";
|
|
644
673
|
}
|
|
645
|
-
if (sessionChanged) {
|
|
674
|
+
if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
|
|
675
|
+
void this.voicePanel?.pause().catch(() => {});
|
|
646
676
|
this.reader.stop();
|
|
647
677
|
this.player?.stop();
|
|
648
678
|
}
|
|
679
|
+
if (sessionChanged) this.voiceAdoptingSession = null;
|
|
649
680
|
const utterance = this.replySpeaker.observe({
|
|
650
681
|
sessionChanged,
|
|
651
682
|
wasRunning: this.running,
|
|
@@ -656,7 +687,7 @@ export default {
|
|
|
656
687
|
this.items = snap.items;
|
|
657
688
|
this.running = snap.running;
|
|
658
689
|
this.runningSince = snap.runningSince || 0;
|
|
659
|
-
const target = this.voiceSpeak ? speakable(utterance, this.voiceStreaming) : null;
|
|
690
|
+
const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance, this.voiceStreaming) : null;
|
|
660
691
|
if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
|
|
661
692
|
this.failed = snap.failed;
|
|
662
693
|
this.error = snap.error;
|
|
@@ -951,7 +982,7 @@ export default {
|
|
|
951
982
|
this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
|
|
952
983
|
},
|
|
953
984
|
switchHost() {
|
|
954
|
-
this.
|
|
985
|
+
void this.voicePanel?.end().catch(() => {});
|
|
955
986
|
for (const controller of this.imageUploadControllers.values()) controller.abort();
|
|
956
987
|
this.imageUploadControllers.clear();
|
|
957
988
|
this.releaseDraftImages();
|
|
@@ -1030,6 +1061,7 @@ export default {
|
|
|
1030
1061
|
if (settings) {
|
|
1031
1062
|
jobs.push(settings.voice().then((voice) => {
|
|
1032
1063
|
this.voiceLanguage = voice.config?.language || "";
|
|
1064
|
+
this.voiceDefaultMode = voice.config?.mobileMode || "continuous";
|
|
1033
1065
|
this.voiceSpeak = voice.config?.speak === true;
|
|
1034
1066
|
this.voiceStreaming = voice.config?.streaming === true;
|
|
1035
1067
|
}).catch(() => {
|
|
@@ -1186,24 +1218,52 @@ export default {
|
|
|
1186
1218
|
this.draftImages = [];
|
|
1187
1219
|
this.imagesBySession = {};
|
|
1188
1220
|
},
|
|
1189
|
-
onHoldStart(
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1221
|
+
async onHoldStart({ mode } = {}) {
|
|
1222
|
+
await this.voicePanel.open({ mode: mode || this.voiceDefaultMode || "continuous", sessionId: this.sessionId || null });
|
|
1223
|
+
},
|
|
1224
|
+
openVoiceMode(mode) { void this.voicePanel.open({ mode }).catch(() => {}); },
|
|
1225
|
+
runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
|
|
1226
|
+
async createMobileVoiceConversation({ mode, onTranscript, onDevice }) {
|
|
1227
|
+
const settings = await this.runtime.settings.voice(true);
|
|
1228
|
+
const continuous = mode === "continuous";
|
|
1229
|
+
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,
|
|
1193
1234
|
});
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
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
|
+
return conversation;
|
|
1200
1257
|
},
|
|
1201
|
-
onHoldEnd(
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1258
|
+
onHoldEnd() { this.voicePanel.conversation?.controller.stopRecording(); },
|
|
1259
|
+
async voiceAction(action) { await this.voicePanel.action(action).catch(() => {}); },
|
|
1260
|
+
bindVoiceHistory(sessionId) {
|
|
1261
|
+
this.voiceHistoryUnsub?.(); this.voiceRecords = [];
|
|
1262
|
+
if (!sessionId) return;
|
|
1263
|
+
this.voiceHistoryUnsub = subscribeVoiceHistory({ sessionId, request: (path, options) => this.runtime.voiceRequest(path, options),
|
|
1264
|
+
onRecords: (rows) => { if (this.sessionId === sessionId) this.voiceRecords = rows; },
|
|
1265
|
+
onError: (error) => { this.voiceError = error.message; },
|
|
1205
1266
|
});
|
|
1206
|
-
this.voiceCap?.stop(false);
|
|
1207
1267
|
},
|
|
1208
1268
|
showSpeakProblem(err) {
|
|
1209
1269
|
this.speakNotice = speakProblemText(this.t, err);
|
|
@@ -1381,27 +1441,6 @@ export default {
|
|
|
1381
1441
|
if (!sessionId) return;
|
|
1382
1442
|
return this.runtime.archiveSession(sessionId);
|
|
1383
1443
|
},
|
|
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
1444
|
async send(mode = "queue") {
|
|
1406
1445
|
const text = this.draft.trim();
|
|
1407
1446
|
if (!this.canSend) return;
|
package/src/chat/Composer.vue
CHANGED
|
@@ -96,7 +96,7 @@
|
|
|
96
96
|
</svg>
|
|
97
97
|
</button>
|
|
98
98
|
<textarea
|
|
99
|
-
|
|
99
|
+
|
|
100
100
|
ref="input"
|
|
101
101
|
:value="draft"
|
|
102
102
|
class="lares-composer__input"
|
|
@@ -110,25 +110,6 @@
|
|
|
110
110
|
@select="onCaretMove"
|
|
111
111
|
@contextmenu="onContextMenu"
|
|
112
112
|
/>
|
|
113
|
-
<button
|
|
114
|
-
v-show="inputMode === 'voice'"
|
|
115
|
-
type="button"
|
|
116
|
-
class="lares-composer__talk"
|
|
117
|
-
:data-phase="voicePhase"
|
|
118
|
-
:disabled="voiceBlocked"
|
|
119
|
-
:aria-label="voiceLabel"
|
|
120
|
-
@pointerdown="onVoiceDown"
|
|
121
|
-
@touchstart.prevent="onVoiceTouchStart"
|
|
122
|
-
@contextmenu.prevent
|
|
123
|
-
>
|
|
124
|
-
<span v-if="voicePhase === 'preparing' || voicePhase === 'transcribing'" class="lares-composer__talk-status">
|
|
125
|
-
<svg class="lares-composer__spin" viewBox="0 0 16 16" aria-hidden="true">
|
|
126
|
-
<circle cx="8" cy="8" r="6" />
|
|
127
|
-
</svg>
|
|
128
|
-
<span>{{ talkLabel }}</span>
|
|
129
|
-
</span>
|
|
130
|
-
<span v-else>{{ talkLabel }}</span>
|
|
131
|
-
</button>
|
|
132
113
|
<div class="lares-composer__tools">
|
|
133
114
|
<input
|
|
134
115
|
ref="picker"
|
|
@@ -138,13 +119,13 @@
|
|
|
138
119
|
@change="onFiles"
|
|
139
120
|
/>
|
|
140
121
|
<button
|
|
141
|
-
v-if="inputMode === 'text'"
|
|
142
122
|
type="button"
|
|
143
123
|
class="lares-composer__icon"
|
|
144
124
|
data-kind="voice"
|
|
145
125
|
:disabled="sending"
|
|
146
|
-
:aria-label="t('
|
|
147
|
-
|
|
126
|
+
:aria-label="t('call.start')"
|
|
127
|
+
aria-haspopup="dialog"
|
|
128
|
+
@click="onVoiceClick"
|
|
148
129
|
>
|
|
149
130
|
<svg class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
|
|
150
131
|
<path d="M5 8v8" />
|
|
@@ -164,24 +145,11 @@
|
|
|
164
145
|
<path d="M12 19V5M5 12.5 12 5l7 7.5" />
|
|
165
146
|
</svg>
|
|
166
147
|
</button>
|
|
167
|
-
<button
|
|
168
|
-
v-if="inputMode === 'voice'"
|
|
169
|
-
type="button"
|
|
170
|
-
class="lares-composer__icon"
|
|
171
|
-
data-kind="keyboard"
|
|
172
|
-
:disabled="voiceBusy"
|
|
173
|
-
:aria-label="t('composer.keyboard')"
|
|
174
|
-
@click="setMode('text')"
|
|
175
|
-
>
|
|
176
|
-
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
177
|
-
<rect x="3.5" y="6" width="17" height="12" rx="2" />
|
|
178
|
-
<path d="M7 10h2M11 10h2M15 10h2M8 14h8" />
|
|
179
|
-
</svg>
|
|
180
|
-
</button>
|
|
148
|
+
<button type="button" class="lares-composer__voice-entry" aria-haspopup="dialog" @click="$emit('hold-start', { mode: 'single' })">{{ t('settings.mode.single') }}</button>
|
|
181
149
|
</div>
|
|
182
150
|
</div>
|
|
183
151
|
</div>
|
|
184
|
-
<p v-if="
|
|
152
|
+
<p v-if="voiceCall?.connection === 'paused'" class="lares-composer__hint" role="status">{{ t('panel.paused') }}</p>
|
|
185
153
|
<p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
|
|
186
154
|
{{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
|
|
187
155
|
</p>
|
|
@@ -189,7 +157,6 @@
|
|
|
189
157
|
</template>
|
|
190
158
|
|
|
191
159
|
<script>
|
|
192
|
-
import { formatElapsed } from "./voice.js";
|
|
193
160
|
import {
|
|
194
161
|
REFERENCE_DEBOUNCE_MS,
|
|
195
162
|
activeReference,
|
|
@@ -208,6 +175,7 @@ export default {
|
|
|
208
175
|
canSend: { type: Boolean, default: false },
|
|
209
176
|
attachPending: { type: Boolean, default: false },
|
|
210
177
|
attachDisabled: { type: Boolean, default: false },
|
|
178
|
+
voiceCall: { type: Object, default: null },
|
|
211
179
|
voicePhase: { type: String, default: "idle" },
|
|
212
180
|
voiceElapsed: { type: Number, default: 0 },
|
|
213
181
|
voiceError: { type: String, default: "" },
|
|
@@ -219,11 +187,12 @@ export default {
|
|
|
219
187
|
commands: { type: Array, default: null },
|
|
220
188
|
commandsError: { type: String, default: "" },
|
|
221
189
|
hero: { type: Boolean, default: false },
|
|
190
|
+
voiceDefaultMode: { type: String, default: "continuous" },
|
|
222
191
|
inputMode: { type: String, default: "text" },
|
|
223
192
|
t: { type: Function, required: true },
|
|
224
193
|
},
|
|
225
194
|
emits: [
|
|
226
|
-
"update:draft", "update:inputMode", "send", "files", "voice", "hold-start", "hold-end",
|
|
195
|
+
"update:draft", "update:inputMode", "send", "files", "voice", "voice-action", "hold-start", "hold-end",
|
|
227
196
|
"open-commands", "open-references", "pick-command", "remove-image",
|
|
228
197
|
],
|
|
229
198
|
data() {
|
|
@@ -239,21 +208,6 @@ export default {
|
|
|
239
208
|
};
|
|
240
209
|
},
|
|
241
210
|
computed: {
|
|
242
|
-
elapsed() {
|
|
243
|
-
return formatElapsed(this.voiceElapsed);
|
|
244
|
-
},
|
|
245
|
-
talkLabel() {
|
|
246
|
-
if (this.voicePhase === "preparing") return this.t("composer.opening");
|
|
247
|
-
if (this.voicePhase === "recording") return `${this.t("composer.hold")} · ${this.elapsed}`;
|
|
248
|
-
if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
|
|
249
|
-
if (this.holding) return this.t("composer.hold");
|
|
250
|
-
return this.t("composer.holdToTalk");
|
|
251
|
-
},
|
|
252
|
-
voiceBusy() {
|
|
253
|
-
return this.voicePhase === "preparing"
|
|
254
|
-
|| this.voicePhase === "recording"
|
|
255
|
-
|| this.voicePhase === "transcribing";
|
|
256
|
-
},
|
|
257
211
|
attachLabel() {
|
|
258
212
|
return this.attachPending ? this.t("button.uploading", { count: 1 }) : this.t("composer.attach");
|
|
259
213
|
},
|
|
@@ -261,16 +215,6 @@ export default {
|
|
|
261
215
|
if (this.hero) return this.t("chat.placeholderNewSession");
|
|
262
216
|
return this.t("chat.placeholder");
|
|
263
217
|
},
|
|
264
|
-
voiceLabel() {
|
|
265
|
-
if (this.voicePhase === "error" && this.voiceError) return this.voiceError;
|
|
266
|
-
if (this.voicePhase === "preparing") return this.t("composer.opening");
|
|
267
|
-
if (this.voicePhase === "recording") return this.t("composer.hold");
|
|
268
|
-
if (this.voicePhase === "transcribing") return this.t("mic.transcribing");
|
|
269
|
-
return this.t("composer.voice");
|
|
270
|
-
},
|
|
271
|
-
voiceBlocked() {
|
|
272
|
-
return this.voicePhase === "transcribing" || this.sending;
|
|
273
|
-
},
|
|
274
218
|
expanded() {
|
|
275
219
|
return this.inputMode === "text" && (this.draft.trim().length > 0 || this.draftImages.length > 0);
|
|
276
220
|
},
|
|
@@ -287,7 +231,6 @@ export default {
|
|
|
287
231
|
watch: {
|
|
288
232
|
draft(value) {
|
|
289
233
|
this.autosize();
|
|
290
|
-
if (value && this.inputMode === "voice" && !this.voiceBusy) this.setMode("text");
|
|
291
234
|
},
|
|
292
235
|
overlayPointerActive(active) {
|
|
293
236
|
if (active) window.addEventListener("pointerdown", this.onTokenPointer, true);
|
|
@@ -383,18 +326,6 @@ export default {
|
|
|
383
326
|
imageStatus(image) {
|
|
384
327
|
return draftImageStatus(image);
|
|
385
328
|
},
|
|
386
|
-
setMode(mode) {
|
|
387
|
-
if (mode === this.inputMode) return;
|
|
388
|
-
if (mode === "text" && this.voiceBusy) return;
|
|
389
|
-
if (mode === "voice") {
|
|
390
|
-
this.closeReferences();
|
|
391
|
-
this.commandsOpen = false;
|
|
392
|
-
this.commandTyped = false;
|
|
393
|
-
this.$refs.input?.blur();
|
|
394
|
-
}
|
|
395
|
-
this.$emit("update:inputMode", mode);
|
|
396
|
-
if (mode === "text") this.$nextTick(() => this.focus());
|
|
397
|
-
},
|
|
398
329
|
onSend() {
|
|
399
330
|
if (this.referenceOpen || this.commandsOpen) return;
|
|
400
331
|
if (this.canSend) this.$emit("send");
|
|
@@ -447,55 +378,14 @@ export default {
|
|
|
447
378
|
event.preventDefault();
|
|
448
379
|
}
|
|
449
380
|
},
|
|
450
|
-
|
|
451
|
-
|
|
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;
|
|
381
|
+
onVoiceClick() {
|
|
382
|
+
this.$emit("hold-start", { mode: "continuous" });
|
|
493
383
|
},
|
|
494
384
|
clearHold() {
|
|
495
|
-
this.detachHold();
|
|
496
385
|
this.holding = false;
|
|
497
386
|
this.holdKind = "";
|
|
498
387
|
},
|
|
388
|
+
detachHold() {},
|
|
499
389
|
pickFiles() {
|
|
500
390
|
this.$refs.picker?.click();
|
|
501
391
|
},
|
|
@@ -520,6 +410,9 @@ export default {
|
|
|
520
410
|
</script>
|
|
521
411
|
|
|
522
412
|
<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
|
+
|
|
523
416
|
.lares-composer {
|
|
524
417
|
display: flex;
|
|
525
418
|
flex-direction: column;
|
|
@@ -915,3 +808,11 @@ export default {
|
|
|
915
808
|
}
|
|
916
809
|
}
|
|
917
810
|
</style>
|
|
811
|
+
|
|
812
|
+
<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
|
+
|
|
816
|
+
.lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
|
|
817
|
+
.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; }
|
|
818
|
+
</style>
|
package/src/chat/Transcript.vue
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
</li>
|
|
30
30
|
<li
|
|
31
31
|
v-for="{ row, index } in shown(block)"
|
|
32
|
-
:key="index"
|
|
32
|
+
:key="row.id || row.key || index"
|
|
33
33
|
class="lares-log__item"
|
|
34
34
|
:data-type="row.type"
|
|
35
35
|
:data-pending="row.pending ? 'true' : 'false'"
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
>{{ row.text }}</div>
|
|
79
79
|
<template v-else-if="row.type === 'assistant'">
|
|
80
80
|
<div class="lares-md" v-html="renderMarkdown(row.text)" />
|
|
81
|
+
<p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
|
|
81
82
|
<LaresMessageActions
|
|
82
83
|
:text="row.text"
|
|
83
84
|
:reaction="reactions[actionKey(index)] || ''"
|
|
@@ -483,6 +484,8 @@ export default {
|
|
|
483
484
|
overflow-wrap: anywhere;
|
|
484
485
|
}
|
|
485
486
|
|
|
487
|
+
.lares-voice-summary { border-left: 2px solid var(--q-blue-default); padding-left: 12px; font-size: 14px; opacity: .8; }
|
|
488
|
+
|
|
486
489
|
.lares-md[data-pending="true"] {
|
|
487
490
|
white-space: pre-wrap;
|
|
488
491
|
opacity: 0.92;
|
|
@@ -0,0 +1,65 @@
|
|
|
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>
|
|
29
|
+
</section>
|
|
30
|
+
</div>
|
|
31
|
+
</dialog>
|
|
32
|
+
</template>
|
|
33
|
+
<script>
|
|
34
|
+
export default {
|
|
35
|
+
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 }),
|
|
38
|
+
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'); },
|
|
43
|
+
},
|
|
44
|
+
watch: { 'view.visible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
|
|
45
|
+
beforeUnmount() { clearInterval(this.clock); this.$refs.dialog?.close(); this.previousFocus?.focus?.(); },
|
|
46
|
+
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?.(); } },
|
|
48
|
+
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(); },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
</script>
|
|
53
|
+
<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; }
|
|
65
|
+
</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
|
-
}
|