@olares/lares-mobile 0.15.17 → 0.15.19
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 +107 -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.19",
|
|
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.19",
|
|
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,9 @@
|
|
|
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";
|
|
118
|
+
import { markRaw } from "vue";
|
|
110
119
|
import { subscribeVoiceSettings } from "@olares/lares-core/larepass/settings";
|
|
111
120
|
import { createVoicePanel } from "@olares/lares-core/voice/panel";
|
|
112
121
|
import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
|
|
@@ -116,10 +125,13 @@ import { connectChat } from "./runtime.js";
|
|
|
116
125
|
import { createT } from "./i18n.js";
|
|
117
126
|
import { failText, withPendingUser } from "./chat/format.js";
|
|
118
127
|
import { captureSource } from "@olares/lares-core/voice/input";
|
|
128
|
+
import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
|
|
119
129
|
import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
|
|
120
130
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
121
131
|
import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
|
|
122
|
-
import {
|
|
132
|
+
import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
|
|
133
|
+
import { playAudioStream, createStreamingCapture } from "@olares/lares-core/voice/stream-client";
|
|
134
|
+
import { voiceHeaders, wavBlob } from "@olares/lares-core/voice/client";
|
|
123
135
|
import { browserPlays } from "@olares/lares-core/voice/playback";
|
|
124
136
|
import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
|
|
125
137
|
import { appendDraftMentions } from "@olares/lares-core/files/mention";
|
|
@@ -220,6 +232,8 @@ export default {
|
|
|
220
232
|
historyLoading: false,
|
|
221
233
|
modelCatalog: null,
|
|
222
234
|
modelError: "",
|
|
235
|
+
modelCatalogError: "",
|
|
236
|
+
modelCatalogLoading: false,
|
|
223
237
|
modelPending: "",
|
|
224
238
|
modelSheet: false,
|
|
225
239
|
effortSheet: false,
|
|
@@ -234,6 +248,7 @@ export default {
|
|
|
234
248
|
voiceLanguage: "",
|
|
235
249
|
voiceSpeak: false,
|
|
236
250
|
voiceDefaultMode: "continuous",
|
|
251
|
+
voiceSettings: null,
|
|
237
252
|
voiceStreaming: false,
|
|
238
253
|
speakBusy: false,
|
|
239
254
|
speakNotice: "",
|
|
@@ -326,7 +341,9 @@ export default {
|
|
|
326
341
|
sessions: this.sessions,
|
|
327
342
|
sessionsReady: this.sessionsReady,
|
|
328
343
|
models: this.models,
|
|
344
|
+
refreshModels: () => this.loadModelCatalog(true),
|
|
329
345
|
modelError: this.modelError,
|
|
346
|
+
modelCatalogError: this.modelCatalogError,
|
|
330
347
|
modelGroups: this.modelGroups,
|
|
331
348
|
modelCurrent: this.modelCurrent,
|
|
332
349
|
modelLabel: this.modelLabel,
|
|
@@ -343,6 +360,8 @@ export default {
|
|
|
343
360
|
voiceDefaultMode: this.voiceDefaultMode,
|
|
344
361
|
voiceCall: this.voiceCall,
|
|
345
362
|
voicePanelState: this.voicePanelState,
|
|
363
|
+
voiceReady: Boolean(this.voiceSettings),
|
|
364
|
+
voiceDictation: !this.voiceSettings || laresVoiceAvailability(this.voiceSettings).dictation,
|
|
346
365
|
voicePhase: this.voicePhase,
|
|
347
366
|
voiceElapsed: this.voiceElapsed,
|
|
348
367
|
voiceErrorText: this.voiceErrorText,
|
|
@@ -427,8 +446,11 @@ export default {
|
|
|
427
446
|
}
|
|
428
447
|
return null;
|
|
429
448
|
},
|
|
449
|
+
modelStatus() {
|
|
450
|
+
return { models: this.models?.models ?? [], current: this.models?.default, status: this.modelCatalogLoading || !this.modelCatalog ? "loading" : "ready", failures: this.models?.failures, error: this.modelCatalogError };
|
|
451
|
+
},
|
|
430
452
|
modelLabel() {
|
|
431
|
-
return this.
|
|
453
|
+
return modelStateLabel(this.modelStatus, this.locale || "en");
|
|
432
454
|
},
|
|
433
455
|
liveModelSelection() {
|
|
434
456
|
const row = this.sessions.find((item) => item.sessionId === this.sessionId);
|
|
@@ -507,11 +529,12 @@ export default {
|
|
|
507
529
|
return this.models?.default ? selectionKey(this.models.default) : "";
|
|
508
530
|
},
|
|
509
531
|
modelBusy() {
|
|
510
|
-
return Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
|
|
532
|
+
return this.modelCatalogLoading || Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
|
|
511
533
|
},
|
|
512
534
|
modelBlocked() {
|
|
513
|
-
return this.
|
|
535
|
+
return !modelState(this.modelStatus).valid;
|
|
514
536
|
},
|
|
537
|
+
voiceReadAloudError() { return this.t("settings.readAloudSyncFailed"); },
|
|
515
538
|
voiceErrorText() {
|
|
516
539
|
return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
|
|
517
540
|
},
|
|
@@ -541,7 +564,8 @@ export default {
|
|
|
541
564
|
this.player = createAnswerPlayer();
|
|
542
565
|
this.voicePanel = createVoicePanel({
|
|
543
566
|
create: (options) => this.createMobileVoiceConversation(options),
|
|
544
|
-
|
|
567
|
+
prepareInput: (config) => config?.device === "headset" ? null : prepareBrowserCapture(),
|
|
568
|
+
loadConfig: async () => (await this.runtime.settings.voice(false, { diagnostics: false })).config,
|
|
545
569
|
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
570
|
complete: async ({ sessionId, text }) => {
|
|
547
571
|
const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
|
|
@@ -554,7 +578,13 @@ export default {
|
|
|
554
578
|
void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
|
|
555
579
|
},
|
|
556
580
|
});
|
|
557
|
-
this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
|
|
581
|
+
this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
|
|
582
|
+
this.voiceDefaultMode = value.config.mobileMode; this.voiceSettings = markRaw(value);
|
|
583
|
+
const next = value.config.speak === true;
|
|
584
|
+
if (this.voiceSpeak && !next) this.reader?.stop();
|
|
585
|
+
this.voiceSpeak = next;
|
|
586
|
+
void this.voicePanel.setReadAloud(next);
|
|
587
|
+
});
|
|
558
588
|
this.voicePanelState = this.voicePanel.getSnapshot();
|
|
559
589
|
this.voicePanelUnsub = this.voicePanel.subscribe(() => {
|
|
560
590
|
const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
|
|
@@ -563,6 +593,7 @@ export default {
|
|
|
563
593
|
});
|
|
564
594
|
this.replySpeaker = createReplySpeaker();
|
|
565
595
|
this.reader = createSentencePlayer({
|
|
596
|
+
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
597
|
send: async (request) => {
|
|
567
598
|
const result = await this.runtime.speak?.(request);
|
|
568
599
|
if (!result) throw Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" });
|
|
@@ -593,9 +624,12 @@ export default {
|
|
|
593
624
|
this.busyEnter = section.busyEnter;
|
|
594
625
|
});
|
|
595
626
|
this.voiceVisibilityHandler = () => {
|
|
596
|
-
if (document.hidden) void this.voicePanel.pause().catch(() => {});
|
|
627
|
+
if (document.hidden) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); }
|
|
597
628
|
};
|
|
629
|
+
this.voiceOfflineHandler = () => { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); };
|
|
630
|
+
window.addEventListener("offline", this.voiceOfflineHandler);
|
|
598
631
|
document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
632
|
+
window.addEventListener("keydown", this.onVoiceShortcut, true);
|
|
599
633
|
this.voiceClock = setInterval(() => {
|
|
600
634
|
if (this.voicePhase === "recording") this.voiceElapsed += 1;
|
|
601
635
|
}, 1000);
|
|
@@ -605,11 +639,14 @@ export default {
|
|
|
605
639
|
this.restoreLog();
|
|
606
640
|
},
|
|
607
641
|
deactivated() {
|
|
642
|
+
this.reader.stop();
|
|
608
643
|
void this.voicePanel?.pause().catch(() => {});
|
|
609
644
|
this.captureLog();
|
|
610
645
|
},
|
|
611
646
|
beforeUnmount() {
|
|
647
|
+
window.removeEventListener("offline", this.voiceOfflineHandler);
|
|
612
648
|
document.removeEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
649
|
+
window.removeEventListener("keydown", this.onVoiceShortcut, true);
|
|
613
650
|
clearInterval(this.voiceClock);
|
|
614
651
|
this.captureLog();
|
|
615
652
|
this.noteSeen(this.sessionId);
|
|
@@ -691,8 +728,8 @@ export default {
|
|
|
691
728
|
this.items = snap.items;
|
|
692
729
|
this.running = snap.running;
|
|
693
730
|
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}` });
|
|
731
|
+
const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, true) : null;
|
|
732
|
+
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
733
|
this.failed = snap.failed;
|
|
697
734
|
this.error = snap.error;
|
|
698
735
|
this.sessions = snap.sessions ?? this.sessions;
|
|
@@ -972,6 +1009,7 @@ export default {
|
|
|
972
1009
|
try {
|
|
973
1010
|
await this.runtime.start({ workspaces: this.chatDevice === "desktop" });
|
|
974
1011
|
if (gen !== this.hostGen) return;
|
|
1012
|
+
void this.runtime.settings.voice(false, { diagnostics: false }).then(value => { if (gen === this.hostGen) { this.voiceSettings = markRaw(value); this.voiceDefaultMode = value.config.mobileMode; } }).catch(() => {});
|
|
975
1013
|
if (this.chatDevice !== "desktop") await this.runtime.refreshWorkspaces();
|
|
976
1014
|
await this.loadComposerMeta();
|
|
977
1015
|
} finally {
|
|
@@ -986,6 +1024,7 @@ export default {
|
|
|
986
1024
|
this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
|
|
987
1025
|
},
|
|
988
1026
|
switchHost() {
|
|
1027
|
+
this.voiceSettings = null;
|
|
989
1028
|
void this.voicePanel?.end().catch(() => {});
|
|
990
1029
|
for (const controller of this.imageUploadControllers.values()) controller.abort();
|
|
991
1030
|
this.imageUploadControllers.clear();
|
|
@@ -1002,6 +1041,8 @@ export default {
|
|
|
1002
1041
|
this.failed = "";
|
|
1003
1042
|
this.error = "";
|
|
1004
1043
|
this.modelCatalog = null;
|
|
1044
|
+
this.modelCatalogError = "";
|
|
1045
|
+
this.modelCatalogLoading = false;
|
|
1005
1046
|
this.modelError = "";
|
|
1006
1047
|
this.modelPending = "";
|
|
1007
1048
|
this.modelSheet = false;
|
|
@@ -1076,17 +1117,21 @@ export default {
|
|
|
1076
1117
|
}
|
|
1077
1118
|
await Promise.all(jobs);
|
|
1078
1119
|
},
|
|
1079
|
-
async loadModelCatalog() {
|
|
1120
|
+
async loadModelCatalog(refresh = false) {
|
|
1080
1121
|
const gen = ++this.modelCatalogGen;
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
this.modelCatalog
|
|
1085
|
-
|
|
1086
|
-
|
|
1122
|
+
this.modelCatalogLoading = true;
|
|
1123
|
+
try {
|
|
1124
|
+
if (refresh) await this.runtime.refreshModelCatalog();
|
|
1125
|
+
const loaded = await this.runtime.modelCatalog();
|
|
1126
|
+
if (gen !== this.modelCatalogGen) return;
|
|
1127
|
+
if (!loaded.ok) throw new Error(loaded.error?.message || loaded.error?.code || "models");
|
|
1128
|
+
this.modelCatalog = loaded.value ?? null;
|
|
1129
|
+
this.modelCatalogError = "";
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
if (gen === this.modelCatalogGen) this.modelCatalogError = error.message;
|
|
1132
|
+
} finally {
|
|
1133
|
+
if (gen === this.modelCatalogGen) this.modelCatalogLoading = false;
|
|
1087
1134
|
}
|
|
1088
|
-
this.modelCatalog = loaded.value ?? null;
|
|
1089
|
-
this.modelError = "";
|
|
1090
1135
|
},
|
|
1091
1136
|
openEffortSheet() {
|
|
1092
1137
|
if (this.effortDisabled) return;
|
|
@@ -1114,7 +1159,7 @@ export default {
|
|
|
1114
1159
|
},
|
|
1115
1160
|
openModelSheet() {
|
|
1116
1161
|
this.modelSheet = true;
|
|
1117
|
-
if (!this.modelCatalog) void this.loadModelCatalog();
|
|
1162
|
+
if (!this.modelCatalog || this.modelCatalogError || this.models?.failures?.length) void this.loadModelCatalog(true);
|
|
1118
1163
|
},
|
|
1119
1164
|
async chooseModel(model) {
|
|
1120
1165
|
const key = this.modelKey(model);
|
|
@@ -1222,10 +1267,40 @@ export default {
|
|
|
1222
1267
|
this.draftImages = [];
|
|
1223
1268
|
this.imagesBySession = {};
|
|
1224
1269
|
},
|
|
1270
|
+
async onVoiceShortcut(event) {
|
|
1271
|
+
if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
|
|
1272
|
+
if (event.key === "Escape" && !event.defaultPrevented) { this.reader.stop(); void this.voicePanel.pause().catch(() => {}); return; }
|
|
1273
|
+
const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
|
|
1274
|
+
if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
|
|
1275
|
+
event.preventDefault();
|
|
1276
|
+
await this.openDictation(action);
|
|
1277
|
+
},
|
|
1278
|
+
async startDictation() {
|
|
1279
|
+
await this.openDictation();
|
|
1280
|
+
},
|
|
1281
|
+
async openDictation(preset) {
|
|
1282
|
+
if (document.hidden || navigator.onLine === false) return;
|
|
1283
|
+
if (!this.voiceSettings) {
|
|
1284
|
+
try { this.voiceSettings = markRaw(await this.runtime.settings.voice(false, { diagnostics: false })); }
|
|
1285
|
+
catch { return; }
|
|
1286
|
+
}
|
|
1287
|
+
if (!laresVoiceAvailability(this.voiceSettings).dictation) return;
|
|
1288
|
+
const view = this.voicePanel.getSnapshot();
|
|
1289
|
+
if (view.busy) return;
|
|
1290
|
+
if (view.visible && view.mode === "single") { if (view.snapshot?.capture === "recording") await this.voicePanel.finish().catch(() => {}); return; }
|
|
1291
|
+
await this.voicePanel.pause().catch(() => {});
|
|
1292
|
+
await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
|
|
1293
|
+
},
|
|
1225
1294
|
async onHoldStart() {
|
|
1226
|
-
|
|
1295
|
+
if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
|
|
1296
|
+
await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const settings = await this.runtime.settings.voice(false, { diagnostics: false });
|
|
1227
1300
|
this.voiceDefaultMode = settings.config.mobileMode;
|
|
1228
|
-
|
|
1301
|
+
const available = laresVoiceAvailability(settings, !this.modelBlocked);
|
|
1302
|
+
if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
|
|
1303
|
+
await this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null });
|
|
1229
1304
|
},
|
|
1230
1305
|
onVoiceAction({ action, value }) {
|
|
1231
1306
|
if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
|
|
@@ -1234,13 +1309,16 @@ export default {
|
|
|
1234
1309
|
},
|
|
1235
1310
|
runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
|
|
1236
1311
|
async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
|
|
1312
|
+
config = laresVoiceConfig(config, mode);
|
|
1237
1313
|
const settings = { config };
|
|
1238
1314
|
const continuous = mode === "continuous";
|
|
1239
|
-
|
|
1315
|
+
const device = settings.config.device === "headset" ? "headset" : "device";
|
|
1316
|
+
onDevice(device);
|
|
1240
1317
|
if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
|
|
1241
1318
|
this.reader.stop(); this.player?.stop(); this.player?.unlock();
|
|
1242
1319
|
let conversation;
|
|
1243
1320
|
const reader = createSentencePlayer({
|
|
1321
|
+
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
1322
|
send: (value) => conversation.speak(value),
|
|
1245
1323
|
play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
|
|
1246
1324
|
halt: () => this.player.stop(), silence: () => {},
|
|
@@ -1248,17 +1326,18 @@ export default {
|
|
|
1248
1326
|
fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
|
|
1249
1327
|
});
|
|
1250
1328
|
conversation = createVoiceConversation({
|
|
1329
|
+
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
1330
|
captureConfig: { model: config.model, language: config.language },
|
|
1252
1331
|
request: (path, options) => this.runtime.voiceRequest(path, options),
|
|
1253
1332
|
transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model),
|
|
1254
|
-
device
|
|
1333
|
+
device,
|
|
1255
1334
|
readAloud: continuous && settings.config.speak,
|
|
1256
|
-
preset:
|
|
1335
|
+
preset: settings.config.preset, backend: settings.config.backend, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1257
1336
|
presentationSession: true,
|
|
1258
1337
|
onTranscript: continuous ? null : onTranscript,
|
|
1259
1338
|
mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1260
|
-
playback: { stop: () => { reader.stop(); this.player.stop(); } },
|
|
1261
|
-
onReply: (reply) => { const value = speakable(reply,
|
|
1339
|
+
playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
|
|
1340
|
+
onReply: (reply) => { const value = speakable(reply, device === "device"); if (value) reader.follow(value); },
|
|
1262
1341
|
});
|
|
1263
1342
|
return conversation;
|
|
1264
1343
|
},
|
|
@@ -1449,6 +1528,8 @@ export default {
|
|
|
1449
1528
|
async send(mode = "queue") {
|
|
1450
1529
|
const text = this.draft.trim();
|
|
1451
1530
|
if (!this.canSend) return;
|
|
1531
|
+
this.reader.stop();
|
|
1532
|
+
if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
|
|
1452
1533
|
this.player?.unlock();
|
|
1453
1534
|
const sessionId = this.sessionId;
|
|
1454
1535
|
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>
|