@olares/lares-mobile 0.15.17 → 0.15.18
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 +106 -26
- package/src/chat/Composer.vue +32 -20
- package/src/chat/Transcript.vue +19 -2
- package/src/chat/VoicePanel.vue +1 -0
- package/src/desktop/DesktopComposer.vue +137 -48
- package/src/desktop/DesktopShell.vue +3 -1
- package/src/host.js +13 -2
- package/src/index.d.ts +2 -0
- package/src/mobile/MobileShell.vue +2 -0
- package/src/settings/AgentSettings.vue +12 -57
- package/src/settings/TtsSettings.vue +60 -0
- package/src/settings/VoicePreferences.vue +153 -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.18",
|
|
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.18",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
@rename-session="renameSession"
|
|
52
52
|
@fork-session="forkSession"
|
|
53
53
|
@archive-session="archiveSession"
|
|
54
|
+
@dictate="startDictation"
|
|
55
|
+
@voice-action="onVoiceAction"
|
|
54
56
|
/>
|
|
55
57
|
<LaresMobileShell
|
|
56
58
|
v-else
|
|
@@ -100,6 +102,10 @@
|
|
|
100
102
|
@pick-command="pickCommand"
|
|
101
103
|
@toggle-speak="toggleSpeak"
|
|
102
104
|
/>
|
|
105
|
+
<div v-if="voicePanelState?.readAloudError" class="lares-speak-notice" role="alert">
|
|
106
|
+
{{ voiceReadAloudError }}
|
|
107
|
+
<button type="button" @click="voicePanel.retryReadAloud()">{{ t('dictation.retry') }}</button>
|
|
108
|
+
</div>
|
|
103
109
|
<LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
|
|
104
110
|
@close="runVoicePanel('close')" @edit="voicePanel.edit($event)" @preset="voicePanel.selectPreset($event, true).catch(() => {})"
|
|
105
111
|
@complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" />
|
|
@@ -107,6 +113,8 @@
|
|
|
107
113
|
</template>
|
|
108
114
|
|
|
109
115
|
<script>
|
|
116
|
+
import { modelState, modelStateLabel } from "@olares/lares-core/router/model-state";
|
|
117
|
+
import { matchVoiceShortcut } from "@olares/lares-core/voice/shortcuts";
|
|
110
118
|
import { subscribeVoiceSettings } from "@olares/lares-core/larepass/settings";
|
|
111
119
|
import { createVoicePanel } from "@olares/lares-core/voice/panel";
|
|
112
120
|
import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
|
|
@@ -116,10 +124,13 @@ import { connectChat } from "./runtime.js";
|
|
|
116
124
|
import { createT } from "./i18n.js";
|
|
117
125
|
import { failText, withPendingUser } from "./chat/format.js";
|
|
118
126
|
import { captureSource } from "@olares/lares-core/voice/input";
|
|
127
|
+
import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
|
|
119
128
|
import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
|
|
120
129
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
121
130
|
import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
|
|
122
|
-
import {
|
|
131
|
+
import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
|
|
132
|
+
import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
|
|
133
|
+
import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
|
|
123
134
|
import { browserPlays } from "@olares/lares-core/voice/playback";
|
|
124
135
|
import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
|
|
125
136
|
import { appendDraftMentions } from "@olares/lares-core/files/mention";
|
|
@@ -220,6 +231,8 @@ export default {
|
|
|
220
231
|
historyLoading: false,
|
|
221
232
|
modelCatalog: null,
|
|
222
233
|
modelError: "",
|
|
234
|
+
modelCatalogError: "",
|
|
235
|
+
modelCatalogLoading: false,
|
|
223
236
|
modelPending: "",
|
|
224
237
|
modelSheet: false,
|
|
225
238
|
effortSheet: false,
|
|
@@ -234,6 +247,7 @@ export default {
|
|
|
234
247
|
voiceLanguage: "",
|
|
235
248
|
voiceSpeak: false,
|
|
236
249
|
voiceDefaultMode: "continuous",
|
|
250
|
+
voiceSettings: null,
|
|
237
251
|
voiceStreaming: false,
|
|
238
252
|
speakBusy: false,
|
|
239
253
|
speakNotice: "",
|
|
@@ -326,7 +340,9 @@ export default {
|
|
|
326
340
|
sessions: this.sessions,
|
|
327
341
|
sessionsReady: this.sessionsReady,
|
|
328
342
|
models: this.models,
|
|
343
|
+
refreshModels: () => this.loadModelCatalog(true),
|
|
329
344
|
modelError: this.modelError,
|
|
345
|
+
modelCatalogError: this.modelCatalogError,
|
|
330
346
|
modelGroups: this.modelGroups,
|
|
331
347
|
modelCurrent: this.modelCurrent,
|
|
332
348
|
modelLabel: this.modelLabel,
|
|
@@ -343,6 +359,8 @@ export default {
|
|
|
343
359
|
voiceDefaultMode: this.voiceDefaultMode,
|
|
344
360
|
voiceCall: this.voiceCall,
|
|
345
361
|
voicePanelState: this.voicePanelState,
|
|
362
|
+
voiceReady: Boolean(this.voiceSettings),
|
|
363
|
+
voiceDictation: !this.voiceSettings || laresVoiceAvailability(this.voiceSettings).dictation,
|
|
346
364
|
voicePhase: this.voicePhase,
|
|
347
365
|
voiceElapsed: this.voiceElapsed,
|
|
348
366
|
voiceErrorText: this.voiceErrorText,
|
|
@@ -427,8 +445,11 @@ export default {
|
|
|
427
445
|
}
|
|
428
446
|
return null;
|
|
429
447
|
},
|
|
448
|
+
modelStatus() {
|
|
449
|
+
return { models: this.models?.models ?? [], current: this.models?.default, status: this.modelCatalogLoading || !this.modelCatalog ? "loading" : "ready", failures: this.models?.failures, error: this.modelCatalogError };
|
|
450
|
+
},
|
|
430
451
|
modelLabel() {
|
|
431
|
-
return this.
|
|
452
|
+
return modelStateLabel(this.modelStatus, this.locale || "en");
|
|
432
453
|
},
|
|
433
454
|
liveModelSelection() {
|
|
434
455
|
const row = this.sessions.find((item) => item.sessionId === this.sessionId);
|
|
@@ -507,11 +528,12 @@ export default {
|
|
|
507
528
|
return this.models?.default ? selectionKey(this.models.default) : "";
|
|
508
529
|
},
|
|
509
530
|
modelBusy() {
|
|
510
|
-
return Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
|
|
531
|
+
return this.modelCatalogLoading || Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
|
|
511
532
|
},
|
|
512
533
|
modelBlocked() {
|
|
513
|
-
return this.
|
|
534
|
+
return !modelState(this.modelStatus).valid;
|
|
514
535
|
},
|
|
536
|
+
voiceReadAloudError() { return this.t("settings.readAloudSyncFailed"); },
|
|
515
537
|
voiceErrorText() {
|
|
516
538
|
return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
|
|
517
539
|
},
|
|
@@ -541,7 +563,8 @@ export default {
|
|
|
541
563
|
this.player = createAnswerPlayer();
|
|
542
564
|
this.voicePanel = createVoicePanel({
|
|
543
565
|
create: (options) => this.createMobileVoiceConversation(options),
|
|
544
|
-
|
|
566
|
+
prepareInput: (config) => config?.device === "headset" ? null : prepareBrowserCapture(),
|
|
567
|
+
loadConfig: async () => (await this.runtime.settings.voice(false, { diagnostics: false })).config,
|
|
545
568
|
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
569
|
complete: async ({ sessionId, text }) => {
|
|
547
570
|
const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
|
|
@@ -554,7 +577,13 @@ export default {
|
|
|
554
577
|
void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
|
|
555
578
|
},
|
|
556
579
|
});
|
|
557
|
-
this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
|
|
580
|
+
this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
|
|
581
|
+
this.voiceDefaultMode = value.config.mobileMode; this.voiceSettings = value;
|
|
582
|
+
const next = value.config.speak === true;
|
|
583
|
+
if (this.voiceSpeak && !next) this.reader?.stop();
|
|
584
|
+
this.voiceSpeak = next;
|
|
585
|
+
void this.voicePanel.setReadAloud(next);
|
|
586
|
+
});
|
|
558
587
|
this.voicePanelState = this.voicePanel.getSnapshot();
|
|
559
588
|
this.voicePanelUnsub = this.voicePanel.subscribe(() => {
|
|
560
589
|
const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
|
|
@@ -563,6 +592,7 @@ export default {
|
|
|
563
592
|
});
|
|
564
593
|
this.replySpeaker = createReplySpeaker();
|
|
565
594
|
this.reader = createSentencePlayer({
|
|
595
|
+
stream: (value) => playAudioStream({ ...value, request: (path, options) => this.runtime.voiceRequest(path, options), headers: voiceHeaders(captureSource("mobile", this.voiceSettings?.config?.device)), socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }),
|
|
566
596
|
send: async (request) => {
|
|
567
597
|
const result = await this.runtime.speak?.(request);
|
|
568
598
|
if (!result) throw Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" });
|
|
@@ -593,9 +623,12 @@ export default {
|
|
|
593
623
|
this.busyEnter = section.busyEnter;
|
|
594
624
|
});
|
|
595
625
|
this.voiceVisibilityHandler = () => {
|
|
596
|
-
if (document.hidden) void this.voicePanel.pause().catch(() => {});
|
|
626
|
+
if (document.hidden) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); }
|
|
597
627
|
};
|
|
628
|
+
this.voiceOfflineHandler = () => { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); };
|
|
629
|
+
window.addEventListener("offline", this.voiceOfflineHandler);
|
|
598
630
|
document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
631
|
+
window.addEventListener("keydown", this.onVoiceShortcut, true);
|
|
599
632
|
this.voiceClock = setInterval(() => {
|
|
600
633
|
if (this.voicePhase === "recording") this.voiceElapsed += 1;
|
|
601
634
|
}, 1000);
|
|
@@ -605,11 +638,14 @@ export default {
|
|
|
605
638
|
this.restoreLog();
|
|
606
639
|
},
|
|
607
640
|
deactivated() {
|
|
641
|
+
this.reader.stop();
|
|
608
642
|
void this.voicePanel?.pause().catch(() => {});
|
|
609
643
|
this.captureLog();
|
|
610
644
|
},
|
|
611
645
|
beforeUnmount() {
|
|
646
|
+
window.removeEventListener("offline", this.voiceOfflineHandler);
|
|
612
647
|
document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
648
|
+
window.removeEventListener("keydown", this.onVoiceShortcut, true);
|
|
613
649
|
clearInterval(this.voiceClock);
|
|
614
650
|
this.captureLog();
|
|
615
651
|
this.noteSeen(this.sessionId);
|
|
@@ -691,8 +727,8 @@ export default {
|
|
|
691
727
|
this.items = snap.items;
|
|
692
728
|
this.running = snap.running;
|
|
693
729
|
this.runningSince = snap.runningSince || 0;
|
|
694
|
-
const target = this.voiceSpeak && !this.voiceCall ? speakable(utterance,
|
|
695
|
-
if (target) this.reader.follow({ ...target, key: `${snap.sessionId}:${target.key}` });
|
|
730
|
+
const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, true) : null;
|
|
731
|
+
if (target) this.reader.follow({ ...target, sessionId: snap.sessionId, source: { sessionId: snap.sessionId, turn: target.turn, step: target.step, attemptId: target.attemptId }, key: `${snap.sessionId}:${target.key}` });
|
|
696
732
|
this.failed = snap.failed;
|
|
697
733
|
this.error = snap.error;
|
|
698
734
|
this.sessions = snap.sessions ?? this.sessions;
|
|
@@ -972,6 +1008,7 @@ export default {
|
|
|
972
1008
|
try {
|
|
973
1009
|
await this.runtime.start({ workspaces: this.chatDevice === "desktop" });
|
|
974
1010
|
if (gen !== this.hostGen) return;
|
|
1011
|
+
void this.runtime.settings.voice(false, { diagnostics: false }).then(value => { if (gen === this.hostGen) { this.voiceSettings = value; this.voiceDefaultMode = value.config.mobileMode; } }).catch(() => {});
|
|
975
1012
|
if (this.chatDevice !== "desktop") await this.runtime.refreshWorkspaces();
|
|
976
1013
|
await this.loadComposerMeta();
|
|
977
1014
|
} finally {
|
|
@@ -986,6 +1023,7 @@ export default {
|
|
|
986
1023
|
this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
|
|
987
1024
|
},
|
|
988
1025
|
switchHost() {
|
|
1026
|
+
this.voiceSettings = null;
|
|
989
1027
|
void this.voicePanel?.end().catch(() => {});
|
|
990
1028
|
for (const controller of this.imageUploadControllers.values()) controller.abort();
|
|
991
1029
|
this.imageUploadControllers.clear();
|
|
@@ -1002,6 +1040,8 @@ export default {
|
|
|
1002
1040
|
this.failed = "";
|
|
1003
1041
|
this.error = "";
|
|
1004
1042
|
this.modelCatalog = null;
|
|
1043
|
+
this.modelCatalogError = "";
|
|
1044
|
+
this.modelCatalogLoading = false;
|
|
1005
1045
|
this.modelError = "";
|
|
1006
1046
|
this.modelPending = "";
|
|
1007
1047
|
this.modelSheet = false;
|
|
@@ -1076,17 +1116,21 @@ export default {
|
|
|
1076
1116
|
}
|
|
1077
1117
|
await Promise.all(jobs);
|
|
1078
1118
|
},
|
|
1079
|
-
async loadModelCatalog() {
|
|
1119
|
+
async loadModelCatalog(refresh = false) {
|
|
1080
1120
|
const gen = ++this.modelCatalogGen;
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
this.modelCatalog
|
|
1085
|
-
|
|
1086
|
-
|
|
1121
|
+
this.modelCatalogLoading = true;
|
|
1122
|
+
try {
|
|
1123
|
+
if (refresh) await this.runtime.refreshModelCatalog();
|
|
1124
|
+
const loaded = await this.runtime.modelCatalog();
|
|
1125
|
+
if (gen !== this.modelCatalogGen) return;
|
|
1126
|
+
if (!loaded.ok) throw new Error(loaded.error?.message || loaded.error?.code || "models");
|
|
1127
|
+
this.modelCatalog = loaded.value ?? null;
|
|
1128
|
+
this.modelCatalogError = "";
|
|
1129
|
+
} catch (error) {
|
|
1130
|
+
if (gen === this.modelCatalogGen) this.modelCatalogError = error.message;
|
|
1131
|
+
} finally {
|
|
1132
|
+
if (gen === this.modelCatalogGen) this.modelCatalogLoading = false;
|
|
1087
1133
|
}
|
|
1088
|
-
this.modelCatalog = loaded.value ?? null;
|
|
1089
|
-
this.modelError = "";
|
|
1090
1134
|
},
|
|
1091
1135
|
openEffortSheet() {
|
|
1092
1136
|
if (this.effortDisabled) return;
|
|
@@ -1114,7 +1158,7 @@ export default {
|
|
|
1114
1158
|
},
|
|
1115
1159
|
openModelSheet() {
|
|
1116
1160
|
this.modelSheet = true;
|
|
1117
|
-
if (!this.modelCatalog) void this.loadModelCatalog();
|
|
1161
|
+
if (!this.modelCatalog || this.modelCatalogError || this.models?.failures?.length) void this.loadModelCatalog(true);
|
|
1118
1162
|
},
|
|
1119
1163
|
async chooseModel(model) {
|
|
1120
1164
|
const key = this.modelKey(model);
|
|
@@ -1222,10 +1266,40 @@ export default {
|
|
|
1222
1266
|
this.draftImages = [];
|
|
1223
1267
|
this.imagesBySession = {};
|
|
1224
1268
|
},
|
|
1269
|
+
async onVoiceShortcut(event) {
|
|
1270
|
+
if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
|
|
1271
|
+
if (event.key === "Escape" && !event.defaultPrevented) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); return; }
|
|
1272
|
+
const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
|
|
1273
|
+
if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
|
|
1274
|
+
event.preventDefault();
|
|
1275
|
+
await this.openDictation(action);
|
|
1276
|
+
},
|
|
1277
|
+
async startDictation() {
|
|
1278
|
+
await this.openDictation();
|
|
1279
|
+
},
|
|
1280
|
+
async openDictation(preset) {
|
|
1281
|
+
if (document.hidden || navigator.onLine === false) return;
|
|
1282
|
+
if (!this.voiceSettings) {
|
|
1283
|
+
try { this.voiceSettings = await this.runtime.settings.voice(false, { diagnostics: false }); }
|
|
1284
|
+
catch { return; }
|
|
1285
|
+
}
|
|
1286
|
+
if (!laresVoiceAvailability(this.voiceSettings).dictation) return;
|
|
1287
|
+
const view = this.voicePanel.getSnapshot();
|
|
1288
|
+
if (view.busy) return;
|
|
1289
|
+
if (view.visible && view.mode === "single") { if (view.snapshot?.capture === "recording") await this.voicePanel.finish().catch(() => {}); return; }
|
|
1290
|
+
await this.voicePanel.pause().catch(() => {});
|
|
1291
|
+
await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
|
|
1292
|
+
},
|
|
1225
1293
|
async onHoldStart() {
|
|
1226
|
-
|
|
1294
|
+
if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
|
|
1295
|
+
await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
const settings = await this.runtime.settings.voice(false, { diagnostics: false });
|
|
1227
1299
|
this.voiceDefaultMode = settings.config.mobileMode;
|
|
1228
|
-
|
|
1300
|
+
const available = laresVoiceAvailability(settings, !this.modelBlocked);
|
|
1301
|
+
if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
|
|
1302
|
+
await this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null });
|
|
1229
1303
|
},
|
|
1230
1304
|
onVoiceAction({ action, value }) {
|
|
1231
1305
|
if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
|
|
@@ -1234,13 +1308,16 @@ export default {
|
|
|
1234
1308
|
},
|
|
1235
1309
|
runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
|
|
1236
1310
|
async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
|
|
1311
|
+
config = laresVoiceConfig(config, mode);
|
|
1237
1312
|
const settings = { config };
|
|
1238
1313
|
const continuous = mode === "continuous";
|
|
1239
|
-
|
|
1314
|
+
const device = settings.config.device === "headset" ? "headset" : "device";
|
|
1315
|
+
onDevice(device);
|
|
1240
1316
|
if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
|
|
1241
1317
|
this.reader.stop(); this.player?.stop(); this.player?.unlock();
|
|
1242
1318
|
let conversation;
|
|
1243
1319
|
const reader = createSentencePlayer({
|
|
1320
|
+
stream: device === "device" ? (value) => playAudioStream({ ...value, request: (path, options) => this.runtime.voiceRequest(path, options), headers: conversation.headers, socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }) : undefined,
|
|
1244
1321
|
send: (value) => conversation.speak(value),
|
|
1245
1322
|
play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
|
|
1246
1323
|
halt: () => this.player.stop(), silence: () => {},
|
|
@@ -1248,17 +1325,18 @@ export default {
|
|
|
1248
1325
|
fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
|
|
1249
1326
|
});
|
|
1250
1327
|
conversation = createVoiceConversation({
|
|
1328
|
+
streamInput: (stream, callbacks) => createStreamingCapture(stream, { ...callbacks, request: (path, options) => this.runtime.voiceRequest(path, options), headers: conversation.headers, model: config.model, language: config.language, socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }),
|
|
1251
1329
|
captureConfig: { model: config.model, language: config.language },
|
|
1252
1330
|
request: (path, options) => this.runtime.voiceRequest(path, options),
|
|
1253
1331
|
transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model),
|
|
1254
|
-
device
|
|
1332
|
+
device,
|
|
1255
1333
|
readAloud: continuous && settings.config.speak,
|
|
1256
|
-
preset:
|
|
1334
|
+
preset: settings.config.preset, backend: settings.config.backend, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1257
1335
|
presentationSession: true,
|
|
1258
1336
|
onTranscript: continuous ? null : onTranscript,
|
|
1259
1337
|
mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1260
|
-
playback: { stop: () => { reader.stop(); this.player.stop(); } },
|
|
1261
|
-
onReply: (reply) => { const value = speakable(reply,
|
|
1338
|
+
playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
|
|
1339
|
+
onReply: (reply) => { const value = speakable(reply, device === "device"); if (value) reader.follow(value); },
|
|
1262
1340
|
});
|
|
1263
1341
|
return conversation;
|
|
1264
1342
|
},
|
|
@@ -1449,6 +1527,8 @@ export default {
|
|
|
1449
1527
|
async send(mode = "queue") {
|
|
1450
1528
|
const text = this.draft.trim();
|
|
1451
1529
|
if (!this.canSend) return;
|
|
1530
|
+
this.reader.stop();
|
|
1531
|
+
if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
|
|
1452
1532
|
this.player?.unlock();
|
|
1453
1533
|
const sessionId = this.sessionId;
|
|
1454
1534
|
const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
|
package/src/chat/Composer.vue
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
:data-hero="hero ? 'true' : 'false'"
|
|
7
7
|
:data-mode="voiceView?.inputActive ? 'voice' : 'text'"
|
|
8
8
|
:data-expanded="expanded ? 'true' : 'false'"
|
|
9
|
+
:data-attachments="draftImages.length > 0"
|
|
9
10
|
:data-holding="voiceView?.holding ? 'true' : 'false'"
|
|
10
11
|
:data-cancel-armed="Boolean(voiceView?.cancelArmed)"
|
|
11
12
|
>
|
|
@@ -137,7 +138,7 @@
|
|
|
137
138
|
<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>
|
|
138
139
|
</button>
|
|
139
140
|
<button
|
|
140
|
-
v-if="
|
|
141
|
+
v-if="hasContent && !voiceView?.inputActive"
|
|
141
142
|
type="submit"
|
|
142
143
|
class="lares-composer__icon"
|
|
143
144
|
data-kind="send"
|
|
@@ -202,6 +203,9 @@ export default {
|
|
|
202
203
|
],
|
|
203
204
|
data() {
|
|
204
205
|
return {
|
|
206
|
+
inputMultiline: false,
|
|
207
|
+
inputWidth: 0,
|
|
208
|
+
inputResizeObserver: null,
|
|
205
209
|
commandsOpen: false,
|
|
206
210
|
commandTyped: false,
|
|
207
211
|
commandIndex: 0,
|
|
@@ -219,8 +223,11 @@ export default {
|
|
|
219
223
|
if (this.hero) return this.t("chat.placeholderNewSession");
|
|
220
224
|
return this.t("chat.placeholder");
|
|
221
225
|
},
|
|
226
|
+
hasContent() {
|
|
227
|
+
return this.draft.trim().length > 0 || this.draftImages.length > 0;
|
|
228
|
+
},
|
|
222
229
|
expanded() {
|
|
223
|
-
return !this.voiceView?.inputActive &&
|
|
230
|
+
return !this.voiceView?.inputActive && this.inputMultiline;
|
|
224
231
|
},
|
|
225
232
|
referenceMenuRows() {
|
|
226
233
|
return referenceRows(this.references, this.t);
|
|
@@ -236,6 +243,9 @@ export default {
|
|
|
236
243
|
draft(value) {
|
|
237
244
|
this.autosize();
|
|
238
245
|
},
|
|
246
|
+
"voiceView.inputActive"() {
|
|
247
|
+
this.autosize();
|
|
248
|
+
},
|
|
239
249
|
overlayPointerActive(active) {
|
|
240
250
|
if (active) window.addEventListener("pointerdown", this.onTokenPointer, true);
|
|
241
251
|
else window.removeEventListener("pointerdown", this.onTokenPointer, true);
|
|
@@ -243,8 +253,16 @@ export default {
|
|
|
243
253
|
},
|
|
244
254
|
mounted() {
|
|
245
255
|
this.autosize();
|
|
256
|
+
this.inputResizeObserver = new ResizeObserver(([entry]) => {
|
|
257
|
+
const width = entry.contentRect.width;
|
|
258
|
+
if (width <= 0 || width === this.inputWidth) return;
|
|
259
|
+
this.inputWidth = width;
|
|
260
|
+
this.autosize();
|
|
261
|
+
});
|
|
262
|
+
this.inputResizeObserver.observe(this.$refs.input);
|
|
246
263
|
},
|
|
247
264
|
beforeUnmount() {
|
|
265
|
+
this.inputResizeObserver?.disconnect();
|
|
248
266
|
window.clearTimeout(this.referenceTimer);
|
|
249
267
|
window.removeEventListener("pointerdown", this.onTokenPointer, true);
|
|
250
268
|
},
|
|
@@ -398,9 +416,13 @@ export default {
|
|
|
398
416
|
autosize() {
|
|
399
417
|
this.$nextTick(() => {
|
|
400
418
|
const el = this.$refs.input;
|
|
401
|
-
if (!el) return;
|
|
402
|
-
el.style.height = "
|
|
403
|
-
el.style.
|
|
419
|
+
if (!el || !el.getClientRects().length) return;
|
|
420
|
+
el.style.height = "36px";
|
|
421
|
+
el.style.overflowY = "hidden";
|
|
422
|
+
const height = Math.max(36, el.scrollHeight);
|
|
423
|
+
this.inputMultiline = height > 36;
|
|
424
|
+
el.style.height = `${Math.min(height, 176)}px`;
|
|
425
|
+
el.style.overflowY = height > 176 ? "auto" : "hidden";
|
|
404
426
|
});
|
|
405
427
|
},
|
|
406
428
|
focus() {
|
|
@@ -542,9 +564,9 @@ export default {
|
|
|
542
564
|
white-space: nowrap;
|
|
543
565
|
}
|
|
544
566
|
|
|
545
|
-
.lares-composer__card[data-expanded="true"]
|
|
567
|
+
.lares-composer__card[data-expanded="true"],
|
|
568
|
+
.lares-composer__card[data-attachments="true"] {
|
|
546
569
|
border-radius: 24px;
|
|
547
|
-
padding: 16px 14px 12px;
|
|
548
570
|
}
|
|
549
571
|
|
|
550
572
|
.lares-composer__card[data-holding="true"] {
|
|
@@ -636,15 +658,7 @@ export default {
|
|
|
636
658
|
}
|
|
637
659
|
|
|
638
660
|
.lares-composer__line[data-expanded="true"] {
|
|
639
|
-
|
|
640
|
-
row-gap: 14px;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
.lares-composer__line[data-expanded="true"] .lares-composer__input {
|
|
644
|
-
order: -1;
|
|
645
|
-
flex: 1 0 100%;
|
|
646
|
-
min-height: 26px;
|
|
647
|
-
padding: 0;
|
|
661
|
+
align-items: flex-end;
|
|
648
662
|
}
|
|
649
663
|
|
|
650
664
|
.lares-composer__input,
|
|
@@ -659,6 +673,8 @@ export default {
|
|
|
659
673
|
}
|
|
660
674
|
|
|
661
675
|
.lares-composer__input {
|
|
676
|
+
box-sizing: border-box;
|
|
677
|
+
height: 36px;
|
|
662
678
|
max-height: 176px;
|
|
663
679
|
resize: none;
|
|
664
680
|
overflow-y: auto;
|
|
@@ -719,10 +735,6 @@ export default {
|
|
|
719
735
|
gap: 12px;
|
|
720
736
|
}
|
|
721
737
|
|
|
722
|
-
.lares-composer__line[data-expanded="true"] .lares-composer__tools {
|
|
723
|
-
margin-left: auto;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
738
|
.lares-composer__picker {
|
|
727
739
|
display: none;
|
|
728
740
|
}
|
package/src/chat/Transcript.vue
CHANGED
|
@@ -34,6 +34,10 @@
|
|
|
34
34
|
:data-type="row.type"
|
|
35
35
|
:data-pending="row.pending ? 'true' : 'false'"
|
|
36
36
|
>
|
|
37
|
+
<div v-if="hasReadout(row)" role="status" class="lares-message-readout">
|
|
38
|
+
<span>{{ speechLabel }}</span>
|
|
39
|
+
<button type="button" class="lares-message-stop" @click="readout.stop()">{{ t(readout.status === 'error' ? 'readout.dismiss' : 'readout.stop') }}</button>
|
|
40
|
+
</div>
|
|
37
41
|
<div v-if="row.type === 'user'" class="lares-msg" data-role="user">{{ row.text }}</div>
|
|
38
42
|
<LaresStageRow
|
|
39
43
|
v-else-if="row.type === 'context'"
|
|
@@ -79,7 +83,7 @@
|
|
|
79
83
|
<template v-else-if="row.type === 'assistant'">
|
|
80
84
|
<div class="lares-md" v-html="renderMarkdown(row.text)" />
|
|
81
85
|
<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>
|
|
86
|
+
<button v-if="!readout && isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
|
|
83
87
|
<LaresMessageActions
|
|
84
88
|
:text="row.text"
|
|
85
89
|
:reaction="reactions[actionKey(index)] || ''"
|
|
@@ -107,6 +111,7 @@
|
|
|
107
111
|
</template>
|
|
108
112
|
|
|
109
113
|
<script>
|
|
114
|
+
import { readoutState } from "@olares/lares-core/voice/speech";
|
|
110
115
|
import { partitionPreviews } from "@olares/lares-core/files/preview-groups";
|
|
111
116
|
import { parseAskUserQuestions } from "@olares/lares-core/larepass/questions";
|
|
112
117
|
import { hidesRunningClock, processFoldLabel, transcriptBlocks } from "@olares/lares-core/larepass/process-fold";
|
|
@@ -142,6 +147,7 @@ export default {
|
|
|
142
147
|
emits: ["open", "media", "answer", "stop-speaking"],
|
|
143
148
|
data() {
|
|
144
149
|
return {
|
|
150
|
+
readout: null, readoutUnsub: null,
|
|
145
151
|
applyingScroll: false,
|
|
146
152
|
logRo: null,
|
|
147
153
|
logPin: 0,
|
|
@@ -153,6 +159,7 @@ export default {
|
|
|
153
159
|
};
|
|
154
160
|
},
|
|
155
161
|
computed: {
|
|
162
|
+
speechLabel() { return this.t(`readout.${this.readout?.status}`); },
|
|
156
163
|
runningText() {
|
|
157
164
|
return runningLabel(this.runningSince, this.t, this.now);
|
|
158
165
|
},
|
|
@@ -172,17 +179,25 @@ export default {
|
|
|
172
179
|
runningSince: "syncClock",
|
|
173
180
|
},
|
|
174
181
|
mounted() {
|
|
182
|
+
this.readout = readoutState.getSnapshot();
|
|
183
|
+
this.readoutUnsub = readoutState.subscribe(() => { this.readout = readoutState.getSnapshot(); });
|
|
175
184
|
this.bindLog();
|
|
176
185
|
this.restoreLog();
|
|
177
186
|
this.syncClock();
|
|
178
187
|
},
|
|
179
188
|
beforeUnmount() {
|
|
189
|
+
this.readoutUnsub?.();
|
|
180
190
|
this.captureLog();
|
|
181
191
|
this.unbindLog();
|
|
182
192
|
if (this.logPin) cancelAnimationFrame(this.logPin);
|
|
183
193
|
clearInterval(this.clock);
|
|
184
194
|
},
|
|
185
195
|
methods: {
|
|
196
|
+
hasReadout(row) {
|
|
197
|
+
if (!this.readout || this.readout.sessionId !== this.sessionId || row.type !== "assistant" || row.turn !== this.readout.turn) return false;
|
|
198
|
+
const latest = this.items.findLast(item => item.type === "assistant" && item.turn === this.readout.turn);
|
|
199
|
+
return latest && (row.id || row.key) === (latest.id || latest.key);
|
|
200
|
+
},
|
|
186
201
|
isSpeaking(row) {
|
|
187
202
|
const snapshot = this.voiceView?.snapshot;
|
|
188
203
|
if (snapshot?.playback !== "playing") return false;
|
|
@@ -569,5 +584,7 @@ export default {
|
|
|
569
584
|
</style>
|
|
570
585
|
|
|
571
586
|
<style scoped>
|
|
572
|
-
.lares-message-
|
|
587
|
+
.lares-message-readout { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-block: 8px; color: var(--q-ink-2, #777); font-size: 13px; }
|
|
588
|
+
.lares-message-stop { appearance: none; border: 1px solid var(--q-stroke-2, #8885); border-radius: 8px; background: transparent; color: inherit; font: inherit; padding: 5px 10px; cursor: pointer; }
|
|
589
|
+
.lares-message-stop:focus-visible { outline: 2px solid var(--q-blue-default, #467cff); outline-offset: 2px; }
|
|
573
590
|
</style>
|
package/src/chat/VoicePanel.vue
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
</div>
|
|
15
15
|
</details>
|
|
16
16
|
<textarea class="lares-voice-draft" :aria-label="t('panel.draft')" :value="view.draft" :disabled="ui.pending" @input="$emit('edit', $event.target.value)" />
|
|
17
|
+
<button v-if="view.stage === 'processing' && view.raw" type="button" @click="choose('raw')">{{ t('dictation.useOriginal') }}</button>
|
|
17
18
|
<div v-if="showOriginal" class="lares-voice-original" :aria-label="t('dictation.original')">{{ view.raw }}</div>
|
|
18
19
|
</template>
|
|
19
20
|
</section>
|