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