@olares/lares-mobile 0.15.31 → 0.15.33
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 +59 -19
- package/src/chat/Composer.vue +4 -39
- package/src/chat/Transcript.vue +12 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.33",
|
|
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.33",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -595,6 +595,8 @@ export default {
|
|
|
595
595
|
const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
|
|
596
596
|
this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
|
|
597
597
|
this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
|
|
598
|
+
// The call no longer owns the answer: a cancelled take gives it back.
|
|
599
|
+
if (this.voiceSuspended && view.snapshot?.connection !== "open") this.resumeSuspendedReadout();
|
|
598
600
|
});
|
|
599
601
|
this.replySpeaker = createReplySpeaker();
|
|
600
602
|
this.reader = createSentencePlayer({
|
|
@@ -648,7 +650,7 @@ export default {
|
|
|
648
650
|
this.voicePageShowHandler = () => {
|
|
649
651
|
if (this.voiceReadoutPaused) { this.voiceReadoutPaused = false; this.reader.resume(); }
|
|
650
652
|
};
|
|
651
|
-
this.voiceOfflineHandler = () => { this.
|
|
653
|
+
this.voiceOfflineHandler = () => { this.stopReadout(); void this.voicePanel.pause().catch(() => {}); };
|
|
652
654
|
window.addEventListener("offline", this.voiceOfflineHandler);
|
|
653
655
|
document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
654
656
|
window.addEventListener("pagehide", this.voicePageHideHandler);
|
|
@@ -663,7 +665,7 @@ export default {
|
|
|
663
665
|
this.restoreLog();
|
|
664
666
|
},
|
|
665
667
|
deactivated() {
|
|
666
|
-
this.
|
|
668
|
+
this.stopReadout();
|
|
667
669
|
void this.voicePanel?.pause().catch(() => {});
|
|
668
670
|
this.captureLog();
|
|
669
671
|
},
|
|
@@ -742,7 +744,7 @@ export default {
|
|
|
742
744
|
}
|
|
743
745
|
if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
|
|
744
746
|
void this.voicePanel?.pause().catch(() => {});
|
|
745
|
-
this.
|
|
747
|
+
this.stopReadout();
|
|
746
748
|
this.player?.stop();
|
|
747
749
|
}
|
|
748
750
|
if (sessionChanged) this.voiceAdoptingSession = null;
|
|
@@ -759,9 +761,12 @@ export default {
|
|
|
759
761
|
// A paused call is not speaking. Only an open call owns the reply;
|
|
760
762
|
// leaving it set after the keyboard returns is how later answers stay silent.
|
|
761
763
|
const voiceSpeaking = this.voiceCall?.connection === "open";
|
|
762
|
-
if (voiceSpeaking) this.
|
|
764
|
+
if (voiceSpeaking) this.suspendReadout();
|
|
763
765
|
const target = this.voiceSpeak && !voiceSpeaking && !document.hidden ? speakable(utterance, this.voiceStreaming) : null;
|
|
764
|
-
if (target) this.
|
|
766
|
+
if (target) this.followReadout({ ...target, sessionId: snap.sessionId, source: { sessionId: snap.sessionId, turn: target.turn, step: target.step, attemptId: target.attemptId }, key: `${snap.sessionId}:${target.key}` });
|
|
767
|
+
// A cancelled voice take did not own the answer: play it again. A take
|
|
768
|
+
// that submitted a turn grew the transcript, so the call owns it.
|
|
769
|
+
if (!voiceSpeaking) this.resumeSuspendedReadout();
|
|
765
770
|
this.failed = snap.failed;
|
|
766
771
|
this.error = snap.error;
|
|
767
772
|
this.sessions = snap.sessions ?? this.sessions;
|
|
@@ -1302,7 +1307,7 @@ export default {
|
|
|
1302
1307
|
},
|
|
1303
1308
|
async onVoiceShortcut(event) {
|
|
1304
1309
|
if (this.chatDevice !== "desktop" || document.hidden || navigator.onLine === false || event.composedPath?.().some(node => node?.hasAttribute?.("data-voice-shortcut-recorder"))) return;
|
|
1305
|
-
if (event.key === "Escape" && !event.defaultPrevented) { this.
|
|
1310
|
+
if (event.key === "Escape" && !event.defaultPrevented) { this.stopReadout(); void this.voicePanel.pause().catch(() => {}); return; }
|
|
1306
1311
|
const action = matchVoiceShortcut(event, this.voiceSettings?.config?.dictationShortcuts);
|
|
1307
1312
|
if (!action || !laresVoiceAvailability(this.voiceSettings).dictation) return;
|
|
1308
1313
|
event.preventDefault();
|
|
@@ -1322,20 +1327,27 @@ export default {
|
|
|
1322
1327
|
const view = this.voicePanel.getSnapshot();
|
|
1323
1328
|
if (view.busy) return;
|
|
1324
1329
|
if (view.visible && view.mode === "single") { if (view.snapshot?.capture === "recording") await this.voicePanel.finish().catch(() => {}); return; }
|
|
1325
|
-
await this.voicePanel.pause().catch(() => {});
|
|
1326
1330
|
await this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null, preset: preset && preset !== "default" ? preset : undefined, configSnapshot: this.voiceSettings.config }).catch(() => {});
|
|
1327
1331
|
},
|
|
1328
|
-
|
|
1332
|
+
onHoldStart() {
|
|
1329
1333
|
this.voicePanel.arm();
|
|
1330
1334
|
if (this.voicePanelState?.mode === "single" && this.voicePanelState.raw) {
|
|
1331
|
-
|
|
1332
|
-
return;
|
|
1335
|
+
return this.voicePanel.open({ mode: "single", sessionId: this.sessionId || null });
|
|
1333
1336
|
}
|
|
1337
|
+
const settings = this.voiceSettings;
|
|
1338
|
+
if (!settings) return this.openVoiceFromSettings();
|
|
1339
|
+
this.voiceDefaultMode = settings.config.mobileMode;
|
|
1340
|
+
const available = laresVoiceAvailability(settings, !this.modelBlocked);
|
|
1341
|
+
if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
|
|
1342
|
+
return this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null, configSnapshot: settings.config });
|
|
1343
|
+
},
|
|
1344
|
+
async openVoiceFromSettings() {
|
|
1334
1345
|
const settings = await this.runtime.settings.voice(false, { diagnostics: false });
|
|
1346
|
+
this.voiceSettings = markRaw(settings);
|
|
1335
1347
|
this.voiceDefaultMode = settings.config.mobileMode;
|
|
1336
1348
|
const available = laresVoiceAvailability(settings, !this.modelBlocked);
|
|
1337
1349
|
if (!(this.voiceDefaultMode === "single" ? available.dictation : available.conversation)) { this.voiceError = "voice_model_unavailable"; return; }
|
|
1338
|
-
|
|
1350
|
+
return this.voicePanel.open({ mode: this.voiceDefaultMode, sessionId: this.sessionId || null, configSnapshot: settings.config });
|
|
1339
1351
|
},
|
|
1340
1352
|
onVoiceAction({ action, value }) {
|
|
1341
1353
|
if (action === "start" || action === "end" || action === "close") this.speakNotice = "";
|
|
@@ -1345,19 +1357,19 @@ export default {
|
|
|
1345
1357
|
if (["holdStart", "holdEnd", "finish", "start", "close", "end"].includes(action)) void Promise.resolve(this.voicePanel[action](value)).catch(() => {});
|
|
1346
1358
|
},
|
|
1347
1359
|
runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
|
|
1348
|
-
async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
|
|
1360
|
+
async createMobileVoiceConversation({ mode, onTranscript, onDevice, config, preparation }) {
|
|
1349
1361
|
config = laresVoiceConfig(config, mode);
|
|
1350
1362
|
const settings = { config };
|
|
1351
1363
|
const continuous = mode === "continuous";
|
|
1352
1364
|
const device = settings.config.device === "headset" ? "headset" : "device";
|
|
1353
1365
|
onDevice(device);
|
|
1354
1366
|
if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
|
|
1355
|
-
this.
|
|
1367
|
+
this.suspendReadout(); this.player?.stop(); this.player?.unlock();
|
|
1356
1368
|
if (config.transport !== "legacy") {
|
|
1357
1369
|
return createUnifiedConversation({ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
|
|
1358
1370
|
device: captureSource("mobile", config.device), readAloud: continuous && config.speak, preset: config.preset, presentationSession: true,
|
|
1359
1371
|
endpointSilenceMs: config.endpointSilenceMs, idleTimeoutMs: config.idleTimeoutMs, recoveryTimeoutMs: config.recoveryTimeoutMs,
|
|
1360
|
-
onTranscript: continuous ? null : onTranscript, mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1372
|
+
onTranscript: continuous ? null : onTranscript, mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter), preparation,
|
|
1361
1373
|
transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model) });
|
|
1362
1374
|
}
|
|
1363
1375
|
let conversation;
|
|
@@ -1379,7 +1391,7 @@ export default {
|
|
|
1379
1391
|
readAloud: continuous && settings.config.speak,
|
|
1380
1392
|
preset: settings.config.preset, backend: settings.config.backend, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1381
1393
|
presentationSession: true,
|
|
1382
|
-
onTranscript: continuous ? null : onTranscript,
|
|
1394
|
+
onTranscript: continuous ? null : onTranscript, preparation,
|
|
1383
1395
|
mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1384
1396
|
playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
|
|
1385
1397
|
onReply: (reply) => { const value = speakable(reply, config.streaming === true); if (value) reader.follow(value); },
|
|
@@ -1394,6 +1406,34 @@ export default {
|
|
|
1394
1406
|
onError: (error) => { this.voiceError = error.message; },
|
|
1395
1407
|
});
|
|
1396
1408
|
},
|
|
1409
|
+
followReadout(value) {
|
|
1410
|
+
if (!value) return;
|
|
1411
|
+
this.lastReadout = value;
|
|
1412
|
+
this.reader.follow(value);
|
|
1413
|
+
},
|
|
1414
|
+
suspendReadout() {
|
|
1415
|
+
const key = this.reader?.key;
|
|
1416
|
+
if (!key) return;
|
|
1417
|
+
if (this.lastReadout?.key === key) {
|
|
1418
|
+
this.voiceSuspended = this.lastReadout;
|
|
1419
|
+
this.voiceSuspendedItems = this.items?.length ?? 0;
|
|
1420
|
+
}
|
|
1421
|
+
this.reader.suspend?.();
|
|
1422
|
+
},
|
|
1423
|
+
resumeSuspendedReadout() {
|
|
1424
|
+
const suspended = this.voiceSuspended;
|
|
1425
|
+
if (!suspended) return;
|
|
1426
|
+
this.voiceSuspended = null;
|
|
1427
|
+
const taken = (this.items?.length ?? 0) > this.voiceSuspendedItems;
|
|
1428
|
+
if (taken) this.reader.consume(suspended.key);
|
|
1429
|
+
else if (suspended.sessionId === this.sessionId && this.voiceSpeak && !document.hidden) this.followReadout(suspended);
|
|
1430
|
+
},
|
|
1431
|
+
stopReadout() {
|
|
1432
|
+
this.voiceSuspended = null;
|
|
1433
|
+
this.voiceSuspendedItems = 0;
|
|
1434
|
+
this.lastReadout = null;
|
|
1435
|
+
this.reader.stop();
|
|
1436
|
+
},
|
|
1397
1437
|
setReadoutAwake(active) {
|
|
1398
1438
|
this.readoutActive = Boolean(active);
|
|
1399
1439
|
if (this.readoutActive) void this.readoutWake?.hold();
|
|
@@ -1415,7 +1455,7 @@ export default {
|
|
|
1415
1455
|
this.voiceSpeak = next;
|
|
1416
1456
|
this.speakBusy = true;
|
|
1417
1457
|
if (!next) {
|
|
1418
|
-
this.
|
|
1458
|
+
this.stopReadout();
|
|
1419
1459
|
this.player?.stop();
|
|
1420
1460
|
void this.runtime.interruptSpeech?.();
|
|
1421
1461
|
}
|
|
@@ -1580,10 +1620,10 @@ export default {
|
|
|
1580
1620
|
const text = this.draft.trim();
|
|
1581
1621
|
if (!this.canSend) return;
|
|
1582
1622
|
this.player?.unlock();
|
|
1583
|
-
// A typed reply is the chat's.
|
|
1584
|
-
//
|
|
1623
|
+
// A typed reply is the chat's. Stop the old readout before pausing the
|
|
1624
|
+
// call, so the pause cannot hand a suspended answer back for a moment.
|
|
1625
|
+
this.stopReadout();
|
|
1585
1626
|
if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
|
|
1586
|
-
this.reader.stop();
|
|
1587
1627
|
const sessionId = this.sessionId;
|
|
1588
1628
|
const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
|
|
1589
1629
|
const wasRunning = this.running;
|
package/src/chat/Composer.vue
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
<p>{{ t("settings.readAloudSyncFailed") }}</p>
|
|
10
10
|
<button type="button" @click="$emit('voice-action', { action: 'retryReadAloud' })">{{ t("dictation.retry") }}</button>
|
|
11
11
|
</div>
|
|
12
|
+
<div v-else-if="voiceError && !voiceView?.inputActive" class="lares-composer__alert" role="alert">
|
|
13
|
+
<p>{{ voiceError }}</p>
|
|
14
|
+
</div>
|
|
12
15
|
<div
|
|
13
16
|
class="lares-composer__card"
|
|
14
17
|
data-composer-card
|
|
@@ -93,10 +96,6 @@
|
|
|
93
96
|
</button>
|
|
94
97
|
</figure>
|
|
95
98
|
</div>
|
|
96
|
-
<div v-if="playbackChrome" class="lares-composer__playback" role="status">
|
|
97
|
-
<span>{{ t(playbackChrome.status) }}</span>
|
|
98
|
-
<button type="button" @click="stopPlayback">{{ t(playbackChrome.stopLabel) }}</button>
|
|
99
|
-
</div>
|
|
100
99
|
<div class="lares-composer__line" :data-expanded="expanded ? 'true' : 'false'">
|
|
101
100
|
<button
|
|
102
101
|
type="button"
|
|
@@ -174,7 +173,7 @@
|
|
|
174
173
|
|
|
175
174
|
<script>
|
|
176
175
|
import { messageFromCode } from "@olares/lares-core/i18n/t";
|
|
177
|
-
import { voiceCallFailure
|
|
176
|
+
import { voiceCallFailure } from "@olares/lares-core/voice/presentation";
|
|
178
177
|
import LaresVoiceInput from "./VoiceInput.vue";
|
|
179
178
|
import {
|
|
180
179
|
REFERENCE_DEBOUNCE_MS,
|
|
@@ -256,9 +255,6 @@ export default {
|
|
|
256
255
|
callFailure() {
|
|
257
256
|
return this.voiceView?.inputActive ? voiceCallFailure(this.voiceView) : null;
|
|
258
257
|
},
|
|
259
|
-
playbackChrome() {
|
|
260
|
-
return voicePlaybackChrome(this.voiceView);
|
|
261
|
-
},
|
|
262
258
|
callFailureText() {
|
|
263
259
|
return this.callFailure ? messageFromCode(this.t, this.callFailure.code, "error.voice_failed") : "";
|
|
264
260
|
},
|
|
@@ -291,9 +287,6 @@ export default {
|
|
|
291
287
|
window.removeEventListener("pointerdown", this.onTokenPointer, true);
|
|
292
288
|
},
|
|
293
289
|
methods: {
|
|
294
|
-
stopPlayback() {
|
|
295
|
-
this.$emit("voice-action", { action: "stopPlayback" });
|
|
296
|
-
},
|
|
297
290
|
onInput(event) {
|
|
298
291
|
const value = event.target.value;
|
|
299
292
|
this.$emit("update:draft", value);
|
|
@@ -713,34 +706,6 @@ export default {
|
|
|
713
706
|
stroke-linecap: round;
|
|
714
707
|
}
|
|
715
708
|
|
|
716
|
-
.lares-composer__playback {
|
|
717
|
-
display: flex;
|
|
718
|
-
align-items: center;
|
|
719
|
-
justify-content: space-between;
|
|
720
|
-
gap: 12px;
|
|
721
|
-
padding: 4px 4px 8px;
|
|
722
|
-
color: var(--q-ink-2);
|
|
723
|
-
font-size: 13px;
|
|
724
|
-
line-height: 20px;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
.lares-composer__playback button {
|
|
728
|
-
flex: none;
|
|
729
|
-
min-height: 32px;
|
|
730
|
-
padding: 4px 12px;
|
|
731
|
-
border: 1px solid var(--q-separator);
|
|
732
|
-
border-radius: 999px;
|
|
733
|
-
background: transparent;
|
|
734
|
-
color: var(--q-ink-1);
|
|
735
|
-
font: inherit;
|
|
736
|
-
font-size: 13px;
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
.lares-composer__playback button:focus-visible {
|
|
740
|
-
outline: 2px solid var(--q-blue-default, #3377ff);
|
|
741
|
-
outline-offset: 2px;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
709
|
.lares-composer__line {
|
|
745
710
|
display: flex;
|
|
746
711
|
width: 100%;
|
package/src/chat/Transcript.vue
CHANGED
|
@@ -81,7 +81,10 @@
|
|
|
81
81
|
<span>{{ speechLabel }}</span>
|
|
82
82
|
<button type="button" class="lares-message-stop" @click="readout.stop()">{{ t(readout.status === 'error' ? 'readout.dismiss' : 'readout.stop') }}</button>
|
|
83
83
|
</div>
|
|
84
|
-
<
|
|
84
|
+
<div v-else-if="ownsPlayback(row)" role="status" class="lares-message-readout">
|
|
85
|
+
<span>{{ t(playbackChrome.status) }}</span>
|
|
86
|
+
<button type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t(playbackChrome.stopLabel) }}</button>
|
|
87
|
+
</div>
|
|
85
88
|
<LaresMessageActions
|
|
86
89
|
v-if="!row.pending"
|
|
87
90
|
:text="row.text"
|
|
@@ -111,6 +114,7 @@
|
|
|
111
114
|
|
|
112
115
|
<script>
|
|
113
116
|
import { readoutState } from "@olares/lares-core/voice/speech";
|
|
117
|
+
import { voicePlaybackChrome } from "@olares/lares-core/voice/presentation";
|
|
114
118
|
import { partitionPreviews } from "@olares/lares-core/files/preview-groups";
|
|
115
119
|
import { parseAskUserQuestions } from "@olares/lares-core/larepass/questions";
|
|
116
120
|
import { hidesRunningClock, processFoldLabel, transcriptBlocks } from "@olares/lares-core/larepass/process-fold";
|
|
@@ -159,6 +163,7 @@ export default {
|
|
|
159
163
|
},
|
|
160
164
|
computed: {
|
|
161
165
|
speechLabel() { return this.t(`readout.${this.readout?.status}`); },
|
|
166
|
+
playbackChrome() { return voicePlaybackChrome(this.voiceView); },
|
|
162
167
|
runningText() {
|
|
163
168
|
return runningLabel(this.runningSince, this.t, this.now);
|
|
164
169
|
},
|
|
@@ -197,11 +202,12 @@ export default {
|
|
|
197
202
|
const latest = this.items.findLast(item => item.type === "assistant" && item.turn === this.readout.turn);
|
|
198
203
|
return latest && (row.id || row.key) === (latest.id || latest.key);
|
|
199
204
|
},
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
+
ownsPlayback(row) {
|
|
206
|
+
if (!this.playbackChrome || row.type !== "assistant") return false;
|
|
207
|
+
const voiceId = this.voiceView?.snapshot?.voiceId;
|
|
208
|
+
const matched = voiceId && this.items.findLast((item) => item.type === "assistant" && (item.voiceId || item.voicePlayback?.voiceId) === voiceId);
|
|
209
|
+
const target = matched || this.items.findLast((item) => item.type === "assistant");
|
|
210
|
+
return Boolean(target) && (row.id || row.key) === (target.id || target.key);
|
|
205
211
|
},
|
|
206
212
|
syncClock() {
|
|
207
213
|
clearInterval(this.clock);
|