@olares/lares-mobile 0.15.12 → 0.15.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/App.vue +65 -74
- package/src/chat/Composer.vue +13 -104
- package/src/chat/Transcript.vue +4 -1
- package/src/chat/VoicePanel.vue +65 -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.13",
|
|
4
4
|
"description": "Lares chat component for LarePass",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"vue": "^3.2.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@olares/lares-core": "0.15.
|
|
31
|
+
"@olares/lares-core": "0.15.13",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -101,10 +101,16 @@
|
|
|
101
101
|
@pick-command="pickCommand"
|
|
102
102
|
@toggle-speak="toggleSpeak"
|
|
103
103
|
/>
|
|
104
|
+
<LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
|
|
105
|
+
@close="runVoicePanel('close')" @mode="openVoiceMode" @start="runVoicePanel('start')" @pause="runVoicePanel('pause')"
|
|
106
|
+
@complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" @end="runVoicePanel('end')" @action="voiceAction" />
|
|
104
107
|
</div>
|
|
105
108
|
</template>
|
|
106
109
|
|
|
107
110
|
<script>
|
|
111
|
+
import { createVoicePanel } from "@olares/lares-core/voice/panel";
|
|
112
|
+
import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
|
|
113
|
+
import LaresVoicePanel from "./chat/VoicePanel.vue";
|
|
108
114
|
import { pickHostPorts } from "@olares/lares-core/larepass/host";
|
|
109
115
|
import { connectChat } from "./runtime.js";
|
|
110
116
|
import { createT } from "./i18n.js";
|
|
@@ -171,7 +177,7 @@ function imageData(file) {
|
|
|
171
177
|
|
|
172
178
|
export default {
|
|
173
179
|
name: "LaresApp",
|
|
174
|
-
components: { LaresMobileShell, LaresDesktopShell },
|
|
180
|
+
components: { LaresMobileShell, LaresDesktopShell, LaresVoicePanel },
|
|
175
181
|
props: {
|
|
176
182
|
locale: { type: String, default: "en" },
|
|
177
183
|
device: { type: String, default: "mobile" },
|
|
@@ -221,8 +227,8 @@ export default {
|
|
|
221
227
|
upload: { pending: 0, failures: [] },
|
|
222
228
|
draftImages: [],
|
|
223
229
|
voiceCall: null,
|
|
224
|
-
|
|
225
|
-
|
|
230
|
+
voicePanelState: null,
|
|
231
|
+
voiceRecords: [],
|
|
226
232
|
voicePhase: "idle",
|
|
227
233
|
voiceElapsed: 0,
|
|
228
234
|
voiceError: "",
|
|
@@ -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,25 @@ 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
|
+
complete: async ({ sessionId, text }) => {
|
|
545
|
+
const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
|
|
546
|
+
const value = [previous, text].filter(Boolean).join(" ");
|
|
547
|
+
this.draftBySession = { ...this.draftBySession, [sessionId || "@pending"]: value };
|
|
548
|
+
if (sessionId === (this.sessionId || null)) this.draft = value;
|
|
549
|
+
},
|
|
550
|
+
onSession: (sessionId) => {
|
|
551
|
+
this.voiceAdoptingSession = sessionId;
|
|
552
|
+
void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
this.voicePanelState = this.voicePanel.getSnapshot();
|
|
556
|
+
this.voicePanelUnsub = this.voicePanel.subscribe(() => {
|
|
557
|
+
const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
|
|
558
|
+
this.voiceCall = view.snapshot?.connection === "closed" ? null : view.snapshot;
|
|
559
|
+
this.voicePhase = view.snapshot?.capture || "idle"; this.voiceError = view.error || view.snapshot?.error || "";
|
|
560
|
+
});
|
|
534
561
|
this.replySpeaker = createReplySpeaker();
|
|
535
562
|
this.reader = createSentencePlayer({
|
|
536
563
|
send: async (request) => {
|
|
@@ -563,7 +590,7 @@ export default {
|
|
|
563
590
|
this.busyEnter = section.busyEnter;
|
|
564
591
|
});
|
|
565
592
|
this.voiceVisibilityHandler = () => {
|
|
566
|
-
if (document.hidden) void this.
|
|
593
|
+
if (document.hidden) void this.voicePanel.pause().catch(() => {});
|
|
567
594
|
};
|
|
568
595
|
document.addEventListener("visibilitychange", this.voiceVisibilityHandler);
|
|
569
596
|
this.voiceClock = setInterval(() => {
|
|
@@ -575,6 +602,7 @@ export default {
|
|
|
575
602
|
this.restoreLog();
|
|
576
603
|
},
|
|
577
604
|
deactivated() {
|
|
605
|
+
void this.voicePanel?.pause().catch(() => {});
|
|
578
606
|
this.captureLog();
|
|
579
607
|
},
|
|
580
608
|
beforeUnmount() {
|
|
@@ -584,8 +612,9 @@ export default {
|
|
|
584
612
|
this.noteSeen(this.sessionId);
|
|
585
613
|
for (const controller of this.imageUploadControllers?.values?.() ?? []) controller.abort();
|
|
586
614
|
this.imageUploadControllers?.clear?.();
|
|
587
|
-
void this.
|
|
588
|
-
this.
|
|
615
|
+
void this.voicePanel?.dispose().catch(() => {});
|
|
616
|
+
this.voicePanelUnsub?.();
|
|
617
|
+
this.voiceHistoryUnsub?.();
|
|
589
618
|
this.conversationUnsub?.();
|
|
590
619
|
this.intakeUnsub?.();
|
|
591
620
|
if (this.sessionId) this.intake?.cancelSession(this.sessionId);
|
|
@@ -643,7 +672,7 @@ export default {
|
|
|
643
672
|
this.commandsError = "";
|
|
644
673
|
}
|
|
645
674
|
if (sessionChanged && snap.sessionId !== this.voiceAdoptingSession) {
|
|
646
|
-
void this.
|
|
675
|
+
void this.voicePanel?.pause().catch(() => {});
|
|
647
676
|
this.reader.stop();
|
|
648
677
|
this.player?.stop();
|
|
649
678
|
}
|
|
@@ -953,7 +982,7 @@ export default {
|
|
|
953
982
|
this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
|
|
954
983
|
},
|
|
955
984
|
switchHost() {
|
|
956
|
-
void this.
|
|
985
|
+
void this.voicePanel?.end().catch(() => {});
|
|
957
986
|
for (const controller of this.imageUploadControllers.values()) controller.abort();
|
|
958
987
|
this.imageUploadControllers.clear();
|
|
959
988
|
this.releaseDraftImages();
|
|
@@ -1189,26 +1218,20 @@ export default {
|
|
|
1189
1218
|
this.draftImages = [];
|
|
1190
1219
|
this.imagesBySession = {};
|
|
1191
1220
|
},
|
|
1192
|
-
async onHoldStart({ mode
|
|
1193
|
-
this.
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
device: captureSource("mobile", settings.config.device), playback: this.player,
|
|
1207
|
-
onCaption: (text) => { this.voiceCaption = `${this.voiceCaption}${text}`.slice(-300); },
|
|
1208
|
-
});
|
|
1209
|
-
await this.attachVoiceConversation(conversation, target, true);
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1221
|
+
async onHoldStart({ mode } = {}) {
|
|
1222
|
+
await this.voicePanel.open({ mode: mode || this.voiceDefaultMode || "continuous", sessionId: this.sessionId || null });
|
|
1223
|
+
},
|
|
1224
|
+
openVoiceMode(mode) { void this.voicePanel.open({ mode }).catch(() => {}); },
|
|
1225
|
+
runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
|
|
1226
|
+
async createMobileVoiceConversation({ mode, onTranscript, onDevice }) {
|
|
1227
|
+
const settings = await this.runtime.settings.voice(true);
|
|
1228
|
+
const continuous = mode === "continuous";
|
|
1229
|
+
onDevice(captureSource("mobile", settings.config.device));
|
|
1230
|
+
if (settings.config.backend === "realtime" && continuous) return createRealtimeConversation({ presentationSession: true,
|
|
1231
|
+
readAloud: settings.config.speak, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1232
|
+
request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
|
|
1233
|
+
device: captureSource("mobile", settings.config.device), playback: this.player,
|
|
1234
|
+
});
|
|
1212
1235
|
this.reader.stop(); this.player?.stop(); this.player?.unlock();
|
|
1213
1236
|
let conversation;
|
|
1214
1237
|
const reader = createSentencePlayer({
|
|
@@ -1223,56 +1246,24 @@ export default {
|
|
|
1223
1246
|
transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
|
|
1224
1247
|
device: captureSource("mobile", settings.config.device),
|
|
1225
1248
|
readAloud: continuous && settings.config.speak,
|
|
1226
|
-
preset: settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1227
|
-
|
|
1228
|
-
onTranscript: continuous
|
|
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
|
-
},
|
|
1249
|
+
preset: continuous && settings.config.preset === "dictation" ? "assistant" : settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1250
|
+
presentationSession: true,
|
|
1251
|
+
onTranscript: continuous ? null : onTranscript,
|
|
1233
1252
|
mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1234
1253
|
playback: { stop: () => { reader.stop(); this.player.stop(); } },
|
|
1235
1254
|
onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
|
|
1236
1255
|
});
|
|
1237
|
-
|
|
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 || "";
|
|
1253
|
-
});
|
|
1254
|
-
await conversation.start({ sessionId: target || null, continuous });
|
|
1255
|
-
if (this.voiceReleased && !continuous) conversation.controller.stopRecording();
|
|
1256
|
-
},
|
|
1257
|
-
onHoldEnd() {
|
|
1258
|
-
this.voiceReleased = true;
|
|
1259
|
-
if (!this.voiceConversation?.controller.getSnapshot().continuous) this.voiceConversation?.controller.stopRecording();
|
|
1256
|
+
return conversation;
|
|
1260
1257
|
},
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
|
|
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; }
|
|
1258
|
+
onHoldEnd() { this.voicePanel.conversation?.controller.stopRecording(); },
|
|
1259
|
+
async voiceAction(action) { await this.voicePanel.action(action).catch(() => {}); },
|
|
1260
|
+
bindVoiceHistory(sessionId) {
|
|
1261
|
+
this.voiceHistoryUnsub?.(); this.voiceRecords = [];
|
|
1262
|
+
if (!sessionId) return;
|
|
1263
|
+
this.voiceHistoryUnsub = subscribeVoiceHistory({ sessionId, request: (path, options) => this.runtime.voiceRequest(path, options),
|
|
1264
|
+
onRecords: (rows) => { if (this.sessionId === sessionId) this.voiceRecords = rows; },
|
|
1265
|
+
onError: (error) => { this.voiceError = error.message; },
|
|
1266
|
+
});
|
|
1276
1267
|
},
|
|
1277
1268
|
showSpeakProblem(err) {
|
|
1278
1269
|
this.speakNotice = speakProblemText(this.t, err);
|
package/src/chat/Composer.vue
CHANGED
|
@@ -82,10 +82,6 @@
|
|
|
82
82
|
</button>
|
|
83
83
|
</figure>
|
|
84
84
|
</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
85
|
<div class="lares-composer__line" :data-expanded="expanded ? 'true' : 'false'">
|
|
90
86
|
<button
|
|
91
87
|
type="button"
|
|
@@ -100,7 +96,7 @@
|
|
|
100
96
|
</svg>
|
|
101
97
|
</button>
|
|
102
98
|
<textarea
|
|
103
|
-
|
|
99
|
+
|
|
104
100
|
ref="input"
|
|
105
101
|
:value="draft"
|
|
106
102
|
class="lares-composer__input"
|
|
@@ -114,24 +110,6 @@
|
|
|
114
110
|
@select="onCaretMove"
|
|
115
111
|
@contextmenu="onContextMenu"
|
|
116
112
|
/>
|
|
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
113
|
<div class="lares-composer__tools">
|
|
136
114
|
<input
|
|
137
115
|
ref="picker"
|
|
@@ -141,13 +119,13 @@
|
|
|
141
119
|
@change="onFiles"
|
|
142
120
|
/>
|
|
143
121
|
<button
|
|
144
|
-
v-if="inputMode === 'text'"
|
|
145
122
|
type="button"
|
|
146
123
|
class="lares-composer__icon"
|
|
147
124
|
data-kind="voice"
|
|
148
125
|
:disabled="sending"
|
|
149
|
-
:aria-label="t('
|
|
150
|
-
|
|
126
|
+
:aria-label="t('call.start')"
|
|
127
|
+
aria-haspopup="dialog"
|
|
128
|
+
@click="onVoiceClick"
|
|
151
129
|
>
|
|
152
130
|
<svg class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
|
|
153
131
|
<path d="M5 8v8" />
|
|
@@ -167,43 +145,11 @@
|
|
|
167
145
|
<path d="M12 19V5M5 12.5 12 5l7 7.5" />
|
|
168
146
|
</svg>
|
|
169
147
|
</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>
|
|
148
|
+
<button type="button" class="lares-composer__voice-entry" aria-haspopup="dialog" @click="$emit('hold-start', { mode: 'single' })">{{ t('settings.mode.single') }}</button>
|
|
184
149
|
</div>
|
|
185
150
|
</div>
|
|
186
151
|
</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>
|
|
152
|
+
<p v-if="voiceCall?.connection === 'paused'" class="lares-composer__hint" role="status">{{ t('panel.paused') }}</p>
|
|
207
153
|
<p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
|
|
208
154
|
{{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
|
|
209
155
|
</p>
|
|
@@ -211,7 +157,6 @@
|
|
|
211
157
|
</template>
|
|
212
158
|
|
|
213
159
|
<script>
|
|
214
|
-
import { formatElapsed } from "@olares/lares-core/voice/recorder";
|
|
215
160
|
import {
|
|
216
161
|
REFERENCE_DEBOUNCE_MS,
|
|
217
162
|
activeReference,
|
|
@@ -256,8 +201,6 @@ export default {
|
|
|
256
201
|
commandsOpen: false,
|
|
257
202
|
commandTyped: false,
|
|
258
203
|
commandIndex: 0,
|
|
259
|
-
levels: [],
|
|
260
|
-
voiceSelection: "",
|
|
261
204
|
referenceOpen: false,
|
|
262
205
|
referenceToken: null,
|
|
263
206
|
referenceIndex: 0,
|
|
@@ -265,21 +208,6 @@ export default {
|
|
|
265
208
|
};
|
|
266
209
|
},
|
|
267
210
|
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
211
|
attachLabel() {
|
|
284
212
|
return this.attachPending ? this.t("button.uploading", { count: 1 }) : this.t("composer.attach");
|
|
285
213
|
},
|
|
@@ -287,16 +215,6 @@ export default {
|
|
|
287
215
|
if (this.hero) return this.t("chat.placeholderNewSession");
|
|
288
216
|
return this.t("chat.placeholder");
|
|
289
217
|
},
|
|
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
218
|
expanded() {
|
|
301
219
|
return this.inputMode === "text" && (this.draft.trim().length > 0 || this.draftImages.length > 0);
|
|
302
220
|
},
|
|
@@ -311,10 +229,8 @@ export default {
|
|
|
311
229
|
},
|
|
312
230
|
},
|
|
313
231
|
watch: {
|
|
314
|
-
"voiceCall.inputLevel"(level) { if (level != null) this.levels = [...this.levels.slice(-15), level]; },
|
|
315
232
|
draft(value) {
|
|
316
233
|
this.autosize();
|
|
317
|
-
if (value && this.inputMode === "voice" && !this.voiceBusy) this.setMode("text");
|
|
318
234
|
},
|
|
319
235
|
overlayPointerActive(active) {
|
|
320
236
|
if (active) window.addEventListener("pointerdown", this.onTokenPointer, true);
|
|
@@ -410,18 +326,6 @@ export default {
|
|
|
410
326
|
imageStatus(image) {
|
|
411
327
|
return draftImageStatus(image);
|
|
412
328
|
},
|
|
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
329
|
onSend() {
|
|
426
330
|
if (this.referenceOpen || this.commandsOpen) return;
|
|
427
331
|
if (this.canSend) this.$emit("send");
|
|
@@ -475,8 +379,7 @@ export default {
|
|
|
475
379
|
}
|
|
476
380
|
},
|
|
477
381
|
onVoiceClick() {
|
|
478
|
-
|
|
479
|
-
this.$emit("hold-start", { mode: this.voiceSelection || undefined });
|
|
382
|
+
this.$emit("hold-start", { mode: "continuous" });
|
|
480
383
|
},
|
|
481
384
|
clearHold() {
|
|
482
385
|
this.holding = false;
|
|
@@ -507,6 +410,9 @@ export default {
|
|
|
507
410
|
</script>
|
|
508
411
|
|
|
509
412
|
<style scoped>
|
|
413
|
+
.lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
|
|
414
|
+
.lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
|
|
415
|
+
|
|
510
416
|
.lares-composer {
|
|
511
417
|
display: flex;
|
|
512
418
|
flex-direction: column;
|
|
@@ -904,6 +810,9 @@ export default {
|
|
|
904
810
|
</style>
|
|
905
811
|
|
|
906
812
|
<style scoped>
|
|
813
|
+
.lares-composer__voice-entry { border: 0; border-radius: 12px; padding: 6px 8px; background: var(--q-background-3); color: var(--q-ink-2); font: inherit; font-size: 12px; min-height: 36px; cursor: pointer; }
|
|
814
|
+
.lares-composer__voice-entry:focus-visible { outline: 2px solid var(--q-blue-default); }
|
|
815
|
+
|
|
907
816
|
.lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
|
|
908
817
|
.lares-composer__hint button { min-height: 44px; border: 0; border-radius: 12px; padding: 8px 12px; background: var(--lares-bg-secondary, #f0f0f0); color: inherit; font: inherit; }
|
|
909
818
|
</style>
|
package/src/chat/Transcript.vue
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
</li>
|
|
30
30
|
<li
|
|
31
31
|
v-for="{ row, index } in shown(block)"
|
|
32
|
-
:key="index"
|
|
32
|
+
:key="row.id || row.key || index"
|
|
33
33
|
class="lares-log__item"
|
|
34
34
|
:data-type="row.type"
|
|
35
35
|
:data-pending="row.pending ? 'true' : 'false'"
|
|
@@ -78,6 +78,7 @@
|
|
|
78
78
|
>{{ row.text }}</div>
|
|
79
79
|
<template v-else-if="row.type === 'assistant'">
|
|
80
80
|
<div class="lares-md" v-html="renderMarkdown(row.text)" />
|
|
81
|
+
<p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
|
|
81
82
|
<LaresMessageActions
|
|
82
83
|
:text="row.text"
|
|
83
84
|
:reaction="reactions[actionKey(index)] || ''"
|
|
@@ -483,6 +484,8 @@ export default {
|
|
|
483
484
|
overflow-wrap: anywhere;
|
|
484
485
|
}
|
|
485
486
|
|
|
487
|
+
.lares-voice-summary { border-left: 2px solid var(--q-blue-default); padding-left: 12px; font-size: 14px; opacity: .8; }
|
|
488
|
+
|
|
486
489
|
.lares-md[data-pending="true"] {
|
|
487
490
|
white-space: pre-wrap;
|
|
488
491
|
opacity: 0.92;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<dialog ref="dialog" class="lares-voice-sheet" aria-labelledby="lares-mobile-voice-title" @cancel.prevent="$emit('close')" @click="onBackdrop">
|
|
3
|
+
<div class="lares-voice-sheet__body">
|
|
4
|
+
<header><h2 id="lares-mobile-voice-title">{{ t('panel.title') }}</h2><button type="button" @click="$emit('close')">{{ t('panel.close') }}</button></header>
|
|
5
|
+
<div role="tablist" :aria-label="t('settings.mode')" @keydown="onTabKey">
|
|
6
|
+
<button v-for="mode in ['single', 'continuous']" :id="`mobile-voice-${mode}`" :key="mode" type="button" role="tab" :aria-selected="view.mode === mode" :tabindex="view.mode === mode ? 0 : -1" aria-controls="lares-mobile-voice-content" :disabled="view.busy" @click="$emit('mode', mode)">{{ t(`settings.mode.${mode}`) }}</button>
|
|
7
|
+
</div>
|
|
8
|
+
<section id="lares-mobile-voice-content" role="tabpanel" :aria-labelledby="`mobile-voice-${view.mode}`">
|
|
9
|
+
<p class="lares-voice-sheet__device">{{ t(!view.device ? 'settings.device.auto' : view.device === 'headset' ? 'settings.device.headset' : 'settings.device.device') }}</p>
|
|
10
|
+
<svg class="lares-voice-sheet__wave" viewBox="0 0 120 48" aria-hidden="true"><line v-for="(level, index) in levels" :key="index" :x1="index * 6 + 3" :x2="index * 6 + 3" :y1="24 - Math.max(2, Math.min(22, level * 800))" :y2="24 + Math.max(2, Math.min(22, level * 800))" /></svg>
|
|
11
|
+
<p role="timer" :aria-label="t('panel.elapsed')">{{ elapsed }}s</p>
|
|
12
|
+
<p role="status" aria-live="polite">{{ status }}</p>
|
|
13
|
+
<div v-if="view.mode === 'single'" class="lares-voice-sheet__draft" :aria-label="t('panel.draft')" aria-live="polite">{{ view.draft || t('panel.draftHint') }}</div>
|
|
14
|
+
<p v-else>{{ t('panel.messages') }}</p>
|
|
15
|
+
<div class="lares-voice-sheet__actions">
|
|
16
|
+
<button v-if="!active" type="button" :disabled="view.busy" @click="$emit('start')">{{ t(view.snapshot?.connection === 'paused' ? 'panel.continue' : 'panel.start') }}</button>
|
|
17
|
+
<button v-else type="button" :disabled="view.busy" @click="$emit('pause')">{{ t('panel.pause') }}</button>
|
|
18
|
+
<template v-if="view.mode === 'single'">
|
|
19
|
+
<button type="button" :disabled="view.busy || (!view.draft && view.snapshot?.capture !== 'recording')" @click="$emit('complete')">{{ t('panel.complete') }}</button>
|
|
20
|
+
<button type="button" :disabled="view.busy" @click="$emit('discard')">{{ t('panel.discard') }}</button>
|
|
21
|
+
</template>
|
|
22
|
+
<template v-if="active && view.mode === 'continuous'">
|
|
23
|
+
<button type="button" :disabled="view.busy" @click="$emit('action', 'mute')">{{ t(view.snapshot.outputMuted ? 'call.unmute' : 'call.mute') }}</button>
|
|
24
|
+
<button type="button" :disabled="view.busy" @click="$emit('action', 'input')">{{ t(view.snapshot.inputPaused ? 'call.resumeInput' : 'call.pauseInput') }}</button>
|
|
25
|
+
</template>
|
|
26
|
+
<button v-if="view.snapshot && view.snapshot.playback !== 'idle'" type="button" :disabled="view.busy" @click="$emit('action', 'playback')">{{ t('call.stopPlayback') }}</button>
|
|
27
|
+
<button v-if="view.snapshot" type="button" :disabled="view.busy" @click="$emit('end')">{{ t('call.end') }}</button>
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
</div>
|
|
31
|
+
</dialog>
|
|
32
|
+
</template>
|
|
33
|
+
<script>
|
|
34
|
+
export default {
|
|
35
|
+
name: 'LaresVoicePanel', props: { view: { type: Object, required: true }, t: { type: Function, required: true } },
|
|
36
|
+
emits: ['close', 'mode', 'start', 'pause', 'complete', 'discard', 'end', 'action'],
|
|
37
|
+
data: () => ({ now: Date.now(), clock: null, previousFocus: null }),
|
|
38
|
+
computed: {
|
|
39
|
+
active() { return this.view.snapshot?.connection === 'open'; },
|
|
40
|
+
levels() { return this.view.levels?.length ? this.view.levels : Array(20).fill(.003); },
|
|
41
|
+
elapsed() { return Math.max(0, Math.floor(((this.view.elapsedMs || 0) + (this.view.startedAt ? this.now - this.view.startedAt : 0)) / 1000)); },
|
|
42
|
+
status() { const state = this.view.snapshot; return this.view.error || state?.error ? this.t(`error.${this.view.error || state.error}`) : this.t(this.view.busy ? 'mic.preparing' : !this.active ? 'panel.paused' : state.capture === 'transcribing' ? 'mic.transcribing' : state.capture === 'recording' ? 'call.listening' : state.playback !== 'idle' ? 'call.halfDuplex' : 'call.waiting'); },
|
|
43
|
+
},
|
|
44
|
+
watch: { 'view.visible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
|
|
45
|
+
beforeUnmount() { clearInterval(this.clock); this.$refs.dialog?.close(); this.previousFocus?.focus?.(); },
|
|
46
|
+
methods: {
|
|
47
|
+
sync() { const dialog = this.$refs.dialog; if (!dialog) return; clearInterval(this.clock); if (this.view.visible) { if (!dialog.open) { this.previousFocus = document.activeElement; dialog.showModal(); } this.clock = setInterval(() => { this.now = Date.now(); }, 250); } else if (dialog.open) { dialog.close(); this.previousFocus?.focus?.(); } },
|
|
48
|
+
onBackdrop(event) { if (event.target === this.$refs.dialog) this.$emit('close'); },
|
|
49
|
+
onTabKey(event) { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const mode = event.key === 'Home' ? 'single' : event.key === 'End' ? 'continuous' : this.view.mode === 'single' ? 'continuous' : 'single'; this.$emit('mode', mode); this.$refs.dialog.querySelector(`#mobile-voice-${mode}`)?.focus(); },
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
</script>
|
|
53
|
+
<style scoped>
|
|
54
|
+
.lares-voice-sheet { color: var(--q-ink-1, #202124); background: var(--q-background-1, white); width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border: 0; padding: 0; }
|
|
55
|
+
.lares-voice-sheet::backdrop { background: #10182866; }
|
|
56
|
+
.lares-voice-sheet__body { padding: max(24px, env(safe-area-inset-top)) 20px max(24px, env(safe-area-inset-bottom)); max-width: 560px; margin: auto; }
|
|
57
|
+
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { font-size: 22px; }
|
|
58
|
+
button { color: inherit; background: transparent; border: 1px solid var(--q-separator, #ddd); border-radius: 14px; padding: 12px 16px; font: inherit; min-height: 44px; cursor: pointer; }
|
|
59
|
+
button:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; } button:disabled { opacity: .45; }
|
|
60
|
+
[role=tablist] { display: flex; gap: 8px; margin: 24px 0; } [role=tab] { flex: 1; } [aria-selected=true] { border-color: #3478f6; background: #3478f61a; }
|
|
61
|
+
section { text-align: center; } .lares-voice-sheet__device { font-size: 14px; opacity: .65; }
|
|
62
|
+
.lares-voice-sheet__wave { width: 160px; height: 64px; margin: 24px auto 0; stroke: #3478f6; stroke-width: 3; stroke-linecap: round; }
|
|
63
|
+
.lares-voice-sheet__draft { text-align: start; white-space: pre-wrap; min-height: 100px; max-height: 30dvh; overflow: auto; background: #8881; padding: 16px; border-radius: 12px; }
|
|
64
|
+
.lares-voice-sheet__actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 32px; }
|
|
65
|
+
</style>
|