@olares/lares-mobile 0.15.13 → 0.15.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/App.vue +42 -37
- package/src/chat/Composer.vue +21 -22
- package/src/chat/Transcript.vue +13 -1
- package/src/chat/VoiceInput.vue +50 -0
- package/src/chat/VoicePanel.vue +75 -48
- package/src/host.js +2 -1
- package/src/mobile/MobileShell.vue +4 -0
- package/src/settings/AgentSettings.vue +24 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.14",
|
|
4
4
|
"description": "Lares chat component for LarePass",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"vue": "^3.2.0"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@olares/lares-core": "0.15.
|
|
31
|
+
"@olares/lares-core": "0.15.14",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -88,8 +88,7 @@
|
|
|
88
88
|
@open-effort="openEffortSheet"
|
|
89
89
|
@files="attachFiles"
|
|
90
90
|
@hold-start="onHoldStart"
|
|
91
|
-
@
|
|
92
|
-
@voice-action="voiceAction"
|
|
91
|
+
@voice-action="onVoiceAction"
|
|
93
92
|
@close-model="modelSheet = false"
|
|
94
93
|
@choose-model="chooseModel"
|
|
95
94
|
@close-effort="effortSheet = false"
|
|
@@ -102,12 +101,13 @@
|
|
|
102
101
|
@toggle-speak="toggleSpeak"
|
|
103
102
|
/>
|
|
104
103
|
<LaresVoicePanel v-if="voicePanelState" :view="voicePanelState" :t="t"
|
|
105
|
-
@close="runVoicePanel('close')" @
|
|
106
|
-
@complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')"
|
|
104
|
+
@close="runVoicePanel('close')" @edit="voicePanel.edit($event)" @preset="voicePanel.selectPreset($event, true).catch(() => {})"
|
|
105
|
+
@complete="runVoicePanel('complete')" @discard="runVoicePanel('discard')" />
|
|
107
106
|
</div>
|
|
108
107
|
</template>
|
|
109
108
|
|
|
110
109
|
<script>
|
|
110
|
+
import { subscribeVoiceSettings } from "@olares/lares-core/larepass/settings";
|
|
111
111
|
import { createVoicePanel } from "@olares/lares-core/voice/panel";
|
|
112
112
|
import { mergeVoiceTranscript, subscribeVoiceHistory } from "@olares/lares-core/voice/projection";
|
|
113
113
|
import LaresVoicePanel from "./chat/VoicePanel.vue";
|
|
@@ -116,7 +116,6 @@ import { connectChat } from "./runtime.js";
|
|
|
116
116
|
import { createT } from "./i18n.js";
|
|
117
117
|
import { failText, withPendingUser } from "./chat/format.js";
|
|
118
118
|
import { captureSource } from "@olares/lares-core/voice/input";
|
|
119
|
-
import { createRealtimeConversation } from "@olares/lares-core/voice/realtime-conversation";
|
|
120
119
|
import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
|
|
121
120
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
122
121
|
import { createReplySpeaker, createSentencePlayer, speakable } from "@olares/lares-core/voice/speech";
|
|
@@ -343,6 +342,7 @@ export default {
|
|
|
343
342
|
draftImages: this.draftImages,
|
|
344
343
|
voiceDefaultMode: this.voiceDefaultMode,
|
|
345
344
|
voiceCall: this.voiceCall,
|
|
345
|
+
voicePanelState: this.voicePanelState,
|
|
346
346
|
voicePhase: this.voicePhase,
|
|
347
347
|
voiceElapsed: this.voiceElapsed,
|
|
348
348
|
voiceErrorText: this.voiceErrorText,
|
|
@@ -541,6 +541,8 @@ export default {
|
|
|
541
541
|
this.player = createAnswerPlayer();
|
|
542
542
|
this.voicePanel = createVoicePanel({
|
|
543
543
|
create: (options) => this.createMobileVoiceConversation(options),
|
|
544
|
+
loadConfig: async () => (await this.runtime.settings.voice(true)).config,
|
|
545
|
+
process: async ({ text, preset, config, signal }) => { const response = await this.runtime.voiceRequest("/api/lares/voice/dictation/clean", { method: "POST", body: { text, preset, config: { dictationProfiles: config.dictationProfiles } }, signal }); if (!response.ok) throw new Error(response.body?.error?.code || "voice_cleanup_failed"); return response.body.text; },
|
|
544
546
|
complete: async ({ sessionId, text }) => {
|
|
545
547
|
const previous = sessionId === (this.sessionId || null) ? this.draft : this.draftBySession[sessionId || "@pending"] || "";
|
|
546
548
|
const value = [previous, text].filter(Boolean).join(" ");
|
|
@@ -552,6 +554,7 @@ export default {
|
|
|
552
554
|
void Promise.resolve(this.runtime.openSession(sessionId)).catch((error) => { this.voiceError = error.message; this.voiceAdoptingSession = null; });
|
|
553
555
|
},
|
|
554
556
|
});
|
|
557
|
+
this.voiceSettingsUnsub = subscribeVoiceSettings((value) => { this.voiceDefaultMode = value.config.mobileMode; });
|
|
555
558
|
this.voicePanelState = this.voicePanel.getSnapshot();
|
|
556
559
|
this.voicePanelUnsub = this.voicePanel.subscribe(() => {
|
|
557
560
|
const view = this.voicePanel.getSnapshot(); this.voicePanelState = view;
|
|
@@ -614,6 +617,7 @@ export default {
|
|
|
614
617
|
this.imageUploadControllers?.clear?.();
|
|
615
618
|
void this.voicePanel?.dispose().catch(() => {});
|
|
616
619
|
this.voicePanelUnsub?.();
|
|
620
|
+
this.voiceSettingsUnsub?.();
|
|
617
621
|
this.voiceHistoryUnsub?.();
|
|
618
622
|
this.conversationUnsub?.();
|
|
619
623
|
this.intakeUnsub?.();
|
|
@@ -1218,45 +1222,46 @@ export default {
|
|
|
1218
1222
|
this.draftImages = [];
|
|
1219
1223
|
this.imagesBySession = {};
|
|
1220
1224
|
},
|
|
1221
|
-
async onHoldStart(
|
|
1222
|
-
|
|
1225
|
+
async onHoldStart() {
|
|
1226
|
+
const settings = await this.runtime.settings.voice(true);
|
|
1227
|
+
this.voiceDefaultMode = settings.config.mobileMode;
|
|
1228
|
+
await this.voicePanel.open({ mode: this.voicePanelState?.mode === "single" && this.voicePanelState.raw ? "single" : this.voiceDefaultMode, sessionId: this.sessionId || null });
|
|
1229
|
+
},
|
|
1230
|
+
onVoiceAction({ action, value }) {
|
|
1231
|
+
if (action === "stopPlayback") { void this.voicePanel.conversation?.controller.stopPlayback().catch(() => {}); return; }
|
|
1232
|
+
if (action === "setCancelArmed") { this.voicePanel.setCancelArmed(value); return; }
|
|
1233
|
+
if (["holdStart", "holdEnd", "finish", "start", "close"].includes(action)) void Promise.resolve(this.voicePanel[action](value)).catch(() => {});
|
|
1223
1234
|
},
|
|
1224
|
-
openVoiceMode(mode) { void this.voicePanel.open({ mode }).catch(() => {}); },
|
|
1225
1235
|
runVoicePanel(action) { void this.voicePanel[action]().catch(() => {}); },
|
|
1226
|
-
async createMobileVoiceConversation({ mode, onTranscript, onDevice }) {
|
|
1227
|
-
const settings =
|
|
1236
|
+
async createMobileVoiceConversation({ mode, onTranscript, onDevice, config }) {
|
|
1237
|
+
const settings = { config };
|
|
1228
1238
|
const continuous = mode === "continuous";
|
|
1229
1239
|
onDevice(captureSource("mobile", settings.config.device));
|
|
1230
|
-
if (settings.config.backend === "realtime" && continuous)
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1240
|
+
if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
|
|
1241
|
+
this.reader.stop(); this.player?.stop(); this.player?.unlock();
|
|
1242
|
+
let conversation;
|
|
1243
|
+
const reader = createSentencePlayer({
|
|
1244
|
+
send: (value) => conversation.speak(value),
|
|
1245
|
+
play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
|
|
1246
|
+
halt: () => this.player.stop(), silence: () => {},
|
|
1247
|
+
onState: (playing) => conversation?.playbackState(playing),
|
|
1248
|
+
fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
|
|
1249
|
+
});
|
|
1250
|
+
conversation = createVoiceConversation({
|
|
1251
|
+
captureConfig: { model: config.model, language: config.language },
|
|
1252
|
+
request: (path, options) => this.runtime.voiceRequest(path, options),
|
|
1253
|
+
transcribe: (blob, signal) => this.runtime.transcribe(blob, config.language, signal, config.model),
|
|
1254
|
+
device: captureSource("mobile", settings.config.device),
|
|
1255
|
+
readAloud: continuous && settings.config.speak,
|
|
1256
|
+
preset: continuous && settings.config.preset === "dictation" ? "assistant" : settings.config.preset, endpointSilenceMs: settings.config.endpointSilenceMs, idleTimeoutMs: settings.config.idleTimeoutMs, recoveryTimeoutMs: settings.config.recoveryTimeoutMs,
|
|
1257
|
+
presentationSession: true,
|
|
1258
|
+
onTranscript: continuous ? null : onTranscript,
|
|
1259
|
+
mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1260
|
+
playback: { stop: () => { reader.stop(); this.player.stop(); } },
|
|
1261
|
+
onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
|
|
1234
1262
|
});
|
|
1235
|
-
this.reader.stop(); this.player?.stop(); this.player?.unlock();
|
|
1236
|
-
let conversation;
|
|
1237
|
-
const reader = createSentencePlayer({
|
|
1238
|
-
send: (value) => conversation.speak(value),
|
|
1239
|
-
play: (sentence) => sentence.bluetooth === "played" ? Promise.resolve() : this.player.play(wavBlob(sentence.audio)),
|
|
1240
|
-
halt: () => this.player.stop(), silence: () => {},
|
|
1241
|
-
onState: (playing) => conversation?.playbackState(playing),
|
|
1242
|
-
fail: (error) => { this.showSpeakProblem(error); void conversation.controller.disconnect("voice_playback_failed"); },
|
|
1243
|
-
});
|
|
1244
|
-
conversation = createVoiceConversation({
|
|
1245
|
-
request: (path, options) => this.runtime.voiceRequest(path, options),
|
|
1246
|
-
transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
|
|
1247
|
-
device: captureSource("mobile", settings.config.device),
|
|
1248
|
-
readAloud: continuous && settings.config.speak,
|
|
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,
|
|
1252
|
-
mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
|
|
1253
|
-
playback: { stop: () => { reader.stop(); this.player.stop(); } },
|
|
1254
|
-
onReply: (reply) => { const value = speakable(reply, settings.config.streaming); if (value) reader.follow(value); },
|
|
1255
|
-
});
|
|
1256
1263
|
return conversation;
|
|
1257
1264
|
},
|
|
1258
|
-
onHoldEnd() { this.voicePanel.conversation?.controller.stopRecording(); },
|
|
1259
|
-
async voiceAction(action) { await this.voicePanel.action(action).catch(() => {}); },
|
|
1260
1265
|
bindVoiceHistory(sessionId) {
|
|
1261
1266
|
this.voiceHistoryUnsub?.(); this.voiceRecords = [];
|
|
1262
1267
|
if (!sessionId) return;
|
package/src/chat/Composer.vue
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
class="lares-composer__card"
|
|
5
5
|
data-composer-card
|
|
6
6
|
:data-hero="hero ? 'true' : 'false'"
|
|
7
|
-
:data-mode="
|
|
7
|
+
:data-mode="voiceView?.inputActive ? 'voice' : 'text'"
|
|
8
8
|
:data-expanded="expanded ? 'true' : 'false'"
|
|
9
|
-
:data-holding="holding ? 'true' : 'false'"
|
|
9
|
+
:data-holding="voiceView?.holding ? 'true' : 'false'"
|
|
10
|
+
:data-cancel-armed="Boolean(voiceView?.cancelArmed)"
|
|
10
11
|
>
|
|
11
12
|
<div v-if="referenceOpen" ref="referenceMenu" class="lares-composer__menu" role="listbox">
|
|
12
13
|
<p v-if="referencesError" class="lares-composer__menu-status">{{ referencesError }}</p>
|
|
@@ -95,7 +96,8 @@
|
|
|
95
96
|
<path d="M12 5v14M5 12h14" />
|
|
96
97
|
</svg>
|
|
97
98
|
</button>
|
|
98
|
-
<
|
|
99
|
+
<LaresVoiceInput v-if="voiceView?.inputActive" :view="voiceView" :t="t" @action="$emit('voice-action', $event)" />
|
|
100
|
+
<textarea v-show="!voiceView?.inputActive"
|
|
99
101
|
|
|
100
102
|
ref="input"
|
|
101
103
|
:value="draft"
|
|
@@ -123,18 +125,19 @@
|
|
|
123
125
|
class="lares-composer__icon"
|
|
124
126
|
data-kind="voice"
|
|
125
127
|
:disabled="sending"
|
|
126
|
-
:aria-label="t('call.start')"
|
|
127
|
-
aria-
|
|
128
|
+
:aria-label="t(voiceView?.inputActive ? 'input.keyboard' : voiceDefaultMode === 'single' ? 'settings.mode.single' : 'call.start')"
|
|
129
|
+
:aria-expanded="Boolean(voiceView?.inputActive)"
|
|
128
130
|
@click="onVoiceClick"
|
|
129
131
|
>
|
|
130
|
-
<svg class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
|
|
132
|
+
<svg v-if="!voiceView?.inputActive" class="lares-composer__wave" viewBox="0 0 24 24" aria-hidden="true">
|
|
131
133
|
<path d="M5 8v8" />
|
|
132
134
|
<path d="M12 5v14" />
|
|
133
135
|
<path d="M19 8v8" />
|
|
134
136
|
</svg>
|
|
137
|
+
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="3" /><path d="M7 9h1m3 0h1m3 0h1M7 12h1m3 0h1m3 0h1M8 15h8" /></svg>
|
|
135
138
|
</button>
|
|
136
139
|
<button
|
|
137
|
-
v-if="expanded"
|
|
140
|
+
v-if="expanded && !voiceView?.inputActive"
|
|
138
141
|
type="submit"
|
|
139
142
|
class="lares-composer__icon"
|
|
140
143
|
data-kind="send"
|
|
@@ -145,11 +148,10 @@
|
|
|
145
148
|
<path d="M12 19V5M5 12.5 12 5l7 7.5" />
|
|
146
149
|
</svg>
|
|
147
150
|
</button>
|
|
148
|
-
<button type="button" class="lares-composer__voice-entry" aria-haspopup="dialog" @click="$emit('hold-start', { mode: 'single' })">{{ t('settings.mode.single') }}</button>
|
|
149
151
|
</div>
|
|
150
152
|
</div>
|
|
151
153
|
</div>
|
|
152
|
-
|
|
154
|
+
|
|
153
155
|
<p v-for="item in failures" :key="item.id" class="lares-composer__hint" data-status="error">
|
|
154
156
|
{{ t("upload.failed", { name: item.name, reason: t(`error.${item.code}`) }) }}
|
|
155
157
|
</p>
|
|
@@ -157,6 +159,7 @@
|
|
|
157
159
|
</template>
|
|
158
160
|
|
|
159
161
|
<script>
|
|
162
|
+
import LaresVoiceInput from "./VoiceInput.vue";
|
|
160
163
|
import {
|
|
161
164
|
REFERENCE_DEBOUNCE_MS,
|
|
162
165
|
activeReference,
|
|
@@ -169,12 +172,14 @@ import { draftImageStatus } from "@olares/lares-core/files/draft-images";
|
|
|
169
172
|
|
|
170
173
|
export default {
|
|
171
174
|
name: "LaresComposer",
|
|
175
|
+
components: { LaresVoiceInput },
|
|
172
176
|
props: {
|
|
173
177
|
draft: { type: String, default: "" },
|
|
174
178
|
sending: { type: Boolean, default: false },
|
|
175
179
|
canSend: { type: Boolean, default: false },
|
|
176
180
|
attachPending: { type: Boolean, default: false },
|
|
177
181
|
attachDisabled: { type: Boolean, default: false },
|
|
182
|
+
voiceView: { type: Object, default: null },
|
|
178
183
|
voiceCall: { type: Object, default: null },
|
|
179
184
|
voicePhase: { type: String, default: "idle" },
|
|
180
185
|
voiceElapsed: { type: Number, default: 0 },
|
|
@@ -197,7 +202,6 @@ export default {
|
|
|
197
202
|
],
|
|
198
203
|
data() {
|
|
199
204
|
return {
|
|
200
|
-
holding: false,
|
|
201
205
|
commandsOpen: false,
|
|
202
206
|
commandTyped: false,
|
|
203
207
|
commandIndex: 0,
|
|
@@ -216,7 +220,7 @@ export default {
|
|
|
216
220
|
return this.t("chat.placeholder");
|
|
217
221
|
},
|
|
218
222
|
expanded() {
|
|
219
|
-
return this.
|
|
223
|
+
return !this.voiceView?.inputActive && (this.draft.trim().length > 0 || this.draftImages.length > 0);
|
|
220
224
|
},
|
|
221
225
|
referenceMenuRows() {
|
|
222
226
|
return referenceRows(this.references, this.t);
|
|
@@ -241,7 +245,6 @@ export default {
|
|
|
241
245
|
this.autosize();
|
|
242
246
|
},
|
|
243
247
|
beforeUnmount() {
|
|
244
|
-
this.clearHold();
|
|
245
248
|
window.clearTimeout(this.referenceTimer);
|
|
246
249
|
window.removeEventListener("pointerdown", this.onTokenPointer, true);
|
|
247
250
|
},
|
|
@@ -379,13 +382,11 @@ export default {
|
|
|
379
382
|
}
|
|
380
383
|
},
|
|
381
384
|
onVoiceClick() {
|
|
382
|
-
this
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
this.
|
|
386
|
-
this.holdKind = "";
|
|
385
|
+
this.closeReferences(); this.commandsOpen = false; this.commandTyped = false;
|
|
386
|
+
this.$refs.input?.blur();
|
|
387
|
+
if (this.voiceView?.inputActive) { this.$emit("voice-action", { action: "close" }); this.$nextTick(() => this.$refs.input?.focus()); }
|
|
388
|
+
else this.$emit("hold-start", { mode: this.voiceDefaultMode });
|
|
387
389
|
},
|
|
388
|
-
detachHold() {},
|
|
389
390
|
pickFiles() {
|
|
390
391
|
this.$refs.picker?.click();
|
|
391
392
|
},
|
|
@@ -410,8 +411,6 @@ export default {
|
|
|
410
411
|
</script>
|
|
411
412
|
|
|
412
413
|
<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
414
|
|
|
416
415
|
.lares-composer {
|
|
417
416
|
display: flex;
|
|
@@ -553,6 +552,8 @@ export default {
|
|
|
553
552
|
background: var(--q-blue-alpha);
|
|
554
553
|
}
|
|
555
554
|
|
|
555
|
+
.lares-composer__card[data-cancel-armed="true"] { border-color: var(--q-red-default); background: var(--q-background-1); }
|
|
556
|
+
|
|
556
557
|
.lares-composer__attachments {
|
|
557
558
|
display: flex;
|
|
558
559
|
gap: 8px;
|
|
@@ -810,8 +811,6 @@ export default {
|
|
|
810
811
|
</style>
|
|
811
812
|
|
|
812
813
|
<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
814
|
|
|
816
815
|
.lares-composer__voice-mode { margin: 8px 12px; border: 0; border-radius: 8px; padding: 6px; background: transparent; color: inherit; font: inherit; }
|
|
817
816
|
.lares-composer__hint button { min-height: 44px; border: 0; border-radius: 12px; padding: 8px 12px; background: var(--lares-bg-secondary, #f0f0f0); color: inherit; font: inherit; }
|
package/src/chat/Transcript.vue
CHANGED
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
<template v-else-if="row.type === 'assistant'">
|
|
80
80
|
<div class="lares-md" v-html="renderMarkdown(row.text)" />
|
|
81
81
|
<p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
|
|
82
|
+
<button v-if="isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
|
|
82
83
|
<LaresMessageActions
|
|
83
84
|
:text="row.text"
|
|
84
85
|
:reaction="reactions[actionKey(index)] || ''"
|
|
@@ -125,6 +126,7 @@ export default {
|
|
|
125
126
|
running: { type: Boolean, default: false },
|
|
126
127
|
runningSince: { type: Number, default: 0 },
|
|
127
128
|
loading: { type: Boolean, default: false },
|
|
129
|
+
voiceView: { type: Object, default: null },
|
|
128
130
|
previews: { type: Object, default: () => ({}) },
|
|
129
131
|
mediaUrl: { type: Function, required: true },
|
|
130
132
|
scrollTop: { type: Function, required: true },
|
|
@@ -137,7 +139,7 @@ export default {
|
|
|
137
139
|
questionTakeover: { type: Boolean, default: false },
|
|
138
140
|
t: { type: Function, required: true },
|
|
139
141
|
},
|
|
140
|
-
emits: ["open", "media", "answer"],
|
|
142
|
+
emits: ["open", "media", "answer", "stop-speaking"],
|
|
141
143
|
data() {
|
|
142
144
|
return {
|
|
143
145
|
applyingScroll: false,
|
|
@@ -181,6 +183,12 @@ export default {
|
|
|
181
183
|
clearInterval(this.clock);
|
|
182
184
|
},
|
|
183
185
|
methods: {
|
|
186
|
+
isSpeaking(row) {
|
|
187
|
+
const snapshot = this.voiceView?.snapshot;
|
|
188
|
+
if (snapshot?.playback !== "playing") return false;
|
|
189
|
+
const latest = this.items.findLast(item => item.type === "assistant" && (item.voiceId || item.voicePlayback?.voiceId) === snapshot.voiceId);
|
|
190
|
+
return latest && (row.id || row.key) === (latest.id || latest.key);
|
|
191
|
+
},
|
|
184
192
|
syncClock() {
|
|
185
193
|
clearInterval(this.clock);
|
|
186
194
|
this.clock = 0;
|
|
@@ -559,3 +567,7 @@ export default {
|
|
|
559
567
|
color: var(--q-blue-default);
|
|
560
568
|
}
|
|
561
569
|
</style>
|
|
570
|
+
|
|
571
|
+
<style scoped>
|
|
572
|
+
.lares-message-stop { border: 0; background: transparent; color: var(--q-ink-2, #777); font: inherit; font-size: 12px; padding: 6px 0; cursor: pointer; }
|
|
573
|
+
</style>
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="lares-inline-voice" :data-phase="ui.phase" @keydown="onKey" @pointerdown.capture="gesture.additionalPointer">
|
|
3
|
+
<span v-if="view.holding" class="lares-inline-cancel" :data-armed="view.cancelArmed" aria-hidden="true">{{ t(view.cancelArmed ? 'gesture.cancelRelease' : 'gesture.slideCancel') }}</span>
|
|
4
|
+
<button ref="main" type="button" class="lares-inline-main" :disabled="ui.disabled" :aria-pressed="view.mode === 'continuous' ? view.holding : undefined" :aria-label="t(ui.label)"
|
|
5
|
+
@pointerdown="down" @pointermove="gesture.pointerMove" @pointerup="gesture.pointerUp" @pointercancel="gesture.cancel" @lostpointercapture="gesture.cancel" @keydown="keyDown" @keyup="keyUp" @blur="gesture.cancel" @click="click">
|
|
6
|
+
<svg v-if="view.mode === 'single' && ui.recording && levels.length" class="lares-inline-level" viewBox="0 0 32 24" aria-hidden="true"><line v-for="(level, index) in levels" :key="index" :x1="index * 5 + 3" :x2="index * 5 + 3" :y1="12 - Math.max(2, Math.min(10, level * 500))" :y2="12 + Math.max(2, Math.min(10, level * 500))" /></svg>
|
|
7
|
+
<span>{{ t(ui.label) }}</span><span v-if="ui.timed" class="lares-inline-time" aria-hidden="true"> · {{ ui.elapsed }}</span>
|
|
8
|
+
</button>
|
|
9
|
+
<span class="lares-inline-status" :data-error="Boolean(ui.error)" :data-visible="['speaking', 'working'].includes(ui.phase)" role="status" aria-live="polite" aria-atomic="true">{{ status }}</span>
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
<script>
|
|
13
|
+
import { createHoldGesture } from '@olares/lares-core/voice/hold';
|
|
14
|
+
import { voiceInputPresentation } from '@olares/lares-core/voice/presentation';
|
|
15
|
+
import { messageFromCode } from '@olares/lares-core/i18n/t';
|
|
16
|
+
export default {
|
|
17
|
+
name: 'LaresVoiceInput', props: { view: { type: Object, required: true }, t: { type: Function, required: true } }, emits: ['action'],
|
|
18
|
+
data: () => ({ now: Date.now(), clock: null, gesture: null }),
|
|
19
|
+
computed: {
|
|
20
|
+
ui() { return voiceInputPresentation(this.view, this.now); },
|
|
21
|
+
levels() { return (this.view.levels || []).slice(-6); },
|
|
22
|
+
status() { return this.ui.error ? messageFromCode(this.t, this.ui.error, 'error.voice_failed') : this.t(this.ui.status); },
|
|
23
|
+
},
|
|
24
|
+
created() { this.gesture = createHoldGesture({ start: () => this.action('holdStart'), end: value => this.action('holdEnd', value), onCancelChange: value => this.action('setCancelArmed', value), isActive: () => this.view.holding }); },
|
|
25
|
+
mounted() { this.$refs.main?.focus(); this.clock = setInterval(() => { this.now = Date.now(); }, 250); window.addEventListener('blur', this.cancel); window.addEventListener('pointerdown', this.additionalPointer, true); },
|
|
26
|
+
beforeUnmount() { this.gesture.cancel(); clearInterval(this.clock); window.removeEventListener('blur', this.cancel); window.removeEventListener('pointerdown', this.additionalPointer, true); },
|
|
27
|
+
methods: {
|
|
28
|
+
action(action, value) { this.$emit('action', { action, value }); },
|
|
29
|
+
down(event) { if (this.view.mode === 'continuous') this.gesture.pointerDown(event); },
|
|
30
|
+
keyDown(event) { if (this.view.mode === 'continuous') { this.gesture.keyDown(event); if (['Space', 'Enter'].includes(event.code)) event.stopPropagation(); } },
|
|
31
|
+
keyUp(event) { if (this.view.mode === 'continuous') this.gesture.keyUp(event); },
|
|
32
|
+
click(event) { if (this.view.mode === 'single') this.action(this.ui.error ? 'start' : 'finish'); else if (event.detail === 0) this.gesture.activate(); },
|
|
33
|
+
onKey(event) { if (event.key !== 'Escape') return; event.preventDefault(); event.stopPropagation(); if (!event.repeat && !this.gesture.escape()) this.action('close'); },
|
|
34
|
+
cancel() { this.gesture.cancel(); }, additionalPointer(event) { this.gesture.additionalPointer(event); },
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
</script>
|
|
38
|
+
<style scoped>
|
|
39
|
+
.lares-inline-voice { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; min-height: 40px; }
|
|
40
|
+
.lares-inline-main { display: flex; align-items: center; justify-content: center; width: 100%; min-height: 40px; padding: 6px 0; border: 0; background: transparent; color: inherit; font: inherit; font-size: 16px; font-weight: 600; touch-action: none; user-select: none; -webkit-touch-callout: none; cursor: pointer; }
|
|
41
|
+
.lares-inline-main:focus-visible { outline: 2px solid var(--q-blue-default, #3377ff); outline-offset: 2px; border-radius: 8px; }
|
|
42
|
+
.lares-inline-time { font-variant-numeric: tabular-nums; white-space: pre; }
|
|
43
|
+
.lares-inline-level { width: 28px; height: 24px; margin-right: 8px; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
|
|
44
|
+
.lares-inline-cancel { position: absolute; bottom: calc(100% + 18px); left: 50%; transform: translateX(-50%); white-space: nowrap; font-size: 13px; padding: 7px 12px; border-radius: 10px; background: var(--q-background-1, white); box-shadow: 0 2px 12px #0001; pointer-events: none; }
|
|
45
|
+
[data-phase=recording] .lares-inline-main { color: var(--q-blue-default, #3377ff); }
|
|
46
|
+
[data-phase=cancel] .lares-inline-main, .lares-inline-cancel[data-armed=true] { color: var(--q-red-default, #c23d32); }
|
|
47
|
+
.lares-inline-status { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
|
48
|
+
.lares-inline-status[data-visible=true], .lares-inline-status[data-error=true] { width: auto; height: auto; clip-path: none; bottom: calc(100% + 16px); background: var(--q-background-1, white); padding: 6px; font-size: 12px; color: var(--q-ink-2, #777); }
|
|
49
|
+
.lares-inline-status[data-error=true] { color: var(--q-red-default, #c23d32); }
|
|
50
|
+
</style>
|
package/src/chat/VoicePanel.vue
CHANGED
|
@@ -1,65 +1,92 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<dialog ref="dialog" class="lares-voice-
|
|
3
|
-
<div class="lares-voice-
|
|
4
|
-
<header><h2 id="lares-mobile-voice-title">{{ t(
|
|
5
|
-
<
|
|
6
|
-
<
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
<
|
|
17
|
-
<
|
|
18
|
-
|
|
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>
|
|
2
|
+
<dialog ref="dialog" class="lares-voice-panel" :data-mode="view.mode" :data-phase="ui.phase" :data-preview="ui.preview" aria-labelledby="lares-mobile-voice-title" @cancel.prevent="$emit('close')" @click="onBackdrop">
|
|
3
|
+
<div class="lares-voice-panel-body">
|
|
4
|
+
<header><h2 id="lares-mobile-voice-title">{{ t(`settings.mode.${view.mode}`) }}</h2><button type="button" class="lares-voice-close" :aria-label="t('panel.close')" @click="$emit('close')">×</button></header>
|
|
5
|
+
<section class="lares-voice-content">
|
|
6
|
+
<p class="lares-voice-status" role="status" aria-live="polite" aria-atomic="true">{{ status }}</p>
|
|
7
|
+
<template v-if="ui.preview">
|
|
8
|
+
<details ref="menu" class="lares-voice-menu">
|
|
9
|
+
<summary :aria-label="t('dictation.preset')">{{ t(`dictation.${view.preset}`) }} ▾</summary>
|
|
10
|
+
<div class="lares-voice-menu-body">
|
|
11
|
+
<select :aria-label="t('dictation.preset')" :value="view.preset" :disabled="view.busy" @change="choose($event.target.value)"><option v-for="id in presets" :key="id" :value="id">{{ t(`dictation.${id}`) }}</option></select>
|
|
12
|
+
<button type="button" @click="showOriginal = !showOriginal; closeMenu()">{{ t('dictation.original') }}</button>
|
|
13
|
+
<template v-if="ui.error"><button type="button" :disabled="view.busy" @click="choose(view.preset)">{{ t('dictation.retry') }}</button><button type="button" :disabled="view.busy" @click="choose('raw')">{{ t('dictation.useOriginal') }}</button></template>
|
|
14
|
+
</div>
|
|
15
|
+
</details>
|
|
16
|
+
<textarea class="lares-voice-draft" :aria-label="t('panel.draft')" :value="view.draft" :disabled="ui.pending" @input="$emit('edit', $event.target.value)" />
|
|
17
|
+
<div v-if="showOriginal" class="lares-voice-original" :aria-label="t('dictation.original')">{{ view.raw }}</div>
|
|
18
|
+
</template>
|
|
29
19
|
</section>
|
|
20
|
+
<footer class="lares-voice-panel-actions">
|
|
21
|
+
<button type="button" class="lares-voice-primary" :disabled="view.busy || !view.draft.trim()" @click="$emit('complete')">{{ t('dictation.insert') }}</button>
|
|
22
|
+
<button type="button" :disabled="view.busy" @click="$emit('discard')">{{ t('dictation.discard') }}</button>
|
|
23
|
+
</footer>
|
|
30
24
|
</div>
|
|
31
25
|
</dialog>
|
|
32
26
|
</template>
|
|
33
27
|
<script>
|
|
28
|
+
import { messageFromCode } from '@olares/lares-core/i18n/t';
|
|
29
|
+
import { DICTATION_PRESETS } from '@olares/lares-core/voice/dictation';
|
|
30
|
+
import { voicePanelPresentation } from '@olares/lares-core/voice/presentation';
|
|
34
31
|
export default {
|
|
35
32
|
name: 'LaresVoicePanel', props: { view: { type: Object, required: true }, t: { type: Function, required: true } },
|
|
36
|
-
emits: ['close', '
|
|
37
|
-
data: () => ({
|
|
33
|
+
emits: ['close', 'complete', 'discard', 'edit', 'preset'],
|
|
34
|
+
data: () => ({ previousFocus: null, presets: DICTATION_PRESETS, showOriginal: false }),
|
|
38
35
|
computed: {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
36
|
+
ui() { return voicePanelPresentation(this.view); },
|
|
37
|
+
status() { return this.ui.error ? messageFromCode(this.t, this.ui.error, "error.voice_failed") : this.t(this.ui.status); },
|
|
38
|
+
},
|
|
39
|
+
watch: { 'view.previewVisible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
|
|
40
|
+
mounted() {
|
|
41
|
+
this.updateViewport(); window.addEventListener('resize', this.updateViewport);
|
|
42
|
+
window.visualViewport?.addEventListener('resize', this.updateViewport); window.visualViewport?.addEventListener('scroll', this.updateViewport);
|
|
43
|
+
},
|
|
44
|
+
beforeUnmount() {
|
|
45
|
+
this.$refs.dialog?.close(); this.restoreFocus();
|
|
46
|
+
window.removeEventListener('resize', this.updateViewport); window.visualViewport?.removeEventListener('resize', this.updateViewport); window.visualViewport?.removeEventListener('scroll', this.updateViewport);
|
|
43
47
|
},
|
|
44
|
-
watch: { 'view.visible': { immediate: true, handler() { this.$nextTick(this.sync); } } },
|
|
45
|
-
beforeUnmount() { clearInterval(this.clock); this.$refs.dialog?.close(); this.previousFocus?.focus?.(); },
|
|
46
48
|
methods: {
|
|
47
|
-
|
|
49
|
+
updateViewport() {
|
|
50
|
+
const dialog = this.$refs.dialog; if (!dialog) return;
|
|
51
|
+
const viewport = window.visualViewport; const height = viewport?.height || window.innerHeight;
|
|
52
|
+
dialog.style.setProperty('--voice-view-height', `${height}px`);
|
|
53
|
+
dialog.style.setProperty('--voice-bottom', `${Math.max(0, window.innerHeight - height - (viewport?.offsetTop || 0))}px`);
|
|
54
|
+
},
|
|
55
|
+
sync() {
|
|
56
|
+
const dialog = this.$refs.dialog; if (!dialog) return;
|
|
57
|
+
if (this.view.previewVisible) { if (!dialog.open) { this.previousFocus = document.querySelector('.lares-composer__icon[data-kind=voice]') || document.activeElement; dialog.showModal(); } this.updateViewport(); }
|
|
58
|
+
else if (dialog.open) { dialog.close(); this.$nextTick(this.restoreFocus); this.showOriginal = false; }
|
|
59
|
+
},
|
|
48
60
|
onBackdrop(event) { if (event.target === this.$refs.dialog) this.$emit('close'); },
|
|
49
|
-
|
|
61
|
+
closeMenu() { if (this.$refs.menu) this.$refs.menu.open = false; },
|
|
62
|
+
choose(preset) { if (this.view.edited && !confirm(this.t('dictation.replaceEdited'))) return; this.closeMenu(); this.$emit('preset', preset); },
|
|
63
|
+
restoreFocus() { const target = this.previousFocus?.isConnected ? this.previousFocus : document.querySelector('.lares-composer__icon[data-kind=voice]'); target?.focus?.(); },
|
|
50
64
|
},
|
|
51
65
|
};
|
|
52
66
|
</script>
|
|
53
67
|
<style scoped>
|
|
54
|
-
.lares-voice-
|
|
55
|
-
.lares-voice-
|
|
56
|
-
.lares-voice-
|
|
57
|
-
header { display: flex;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
.lares-voice-
|
|
63
|
-
.lares-voice-
|
|
64
|
-
.lares-voice-
|
|
68
|
+
.lares-voice-panel { position: fixed; inset: auto; margin: 0; box-sizing: border-box; padding: 0; border: 1px solid #8883; border-radius: 20px; color: var(--color-text-primary, #202124); background: var(--color-background-primary, white); width: min(400px, calc(var(--voice-view-width, 100vw) - 24px)); max-width: none; max-height: calc(var(--voice-view-height, 100dvh) * .85); box-shadow: 0 12px 48px #0002; overflow: hidden; }
|
|
69
|
+
.lares-voice-panel::backdrop { background: #10182826; }
|
|
70
|
+
.lares-voice-panel-body { padding: 16px; display: flex; flex-direction: column; box-sizing: border-box; max-height: calc(var(--voice-view-height, 100dvh) * .85 - 2px); }
|
|
71
|
+
.lares-voice-panel header { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 16px; }
|
|
72
|
+
.lares-voice-panel h2 { font-size: 16px; line-height: 24px; margin: 0; font-weight: 600; }
|
|
73
|
+
.lares-voice-panel button { font: inherit; color: inherit; cursor: pointer; border: 1px solid #8883; border-radius: 12px; padding: 10px 16px; min-height: 44px; box-sizing: border-box; background: transparent; }
|
|
74
|
+
.lares-voice-panel button:focus-visible, .lares-voice-panel summary:focus-visible, .lares-voice-panel textarea:focus-visible, .lares-voice-panel select:focus-visible { outline: 2px solid #3478f6; outline-offset: 2px; }
|
|
75
|
+
.lares-voice-panel button:disabled { opacity: .45; cursor: default; }
|
|
76
|
+
.lares-voice-panel .lares-voice-close { border: 0; padding: 0; font-size: 24px; min-width: 44px; }
|
|
77
|
+
.lares-voice-content { text-align: center; display: flex; flex-direction: column; min-height: 0; overflow: auto; }
|
|
78
|
+
.lares-voice-status { margin: 12px 0; min-height: 24px; line-height: 24px; font-size: 14px; }
|
|
79
|
+
.lares-voice-panel-actions { display: flex; flex: none; justify-content: center; align-items: center; gap: 10px; margin-top: 12px; }
|
|
80
|
+
.lares-voice-panel textarea { display: block; flex: 1 1 180px; width: 100%; box-sizing: border-box; min-height: 80px; height: 180px; max-height: 32dvh; margin: 12px 0 0; padding: 12px; color: inherit; background: #88808; font: inherit; line-height: 1.6; border: 1px solid #8883; border-radius: 12px; resize: none; overflow: auto; }
|
|
81
|
+
.lares-voice-panel select { font: inherit; padding: 8px 12px; border-radius: 10px; color: inherit; background: transparent; max-width: 100%; }
|
|
82
|
+
.lares-voice-menu { flex: none; text-align: start; font-size: 13px; }
|
|
83
|
+
.lares-voice-menu summary { display: inline-block; list-style: none; cursor: pointer; padding: 6px 10px; border-radius: 8px; background: #8881; }
|
|
84
|
+
.lares-voice-menu summary::-webkit-details-marker { display: none; }
|
|
85
|
+
.lares-voice-menu-body { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 0; }
|
|
86
|
+
.lares-voice-menu-body button { font-size: 12px; }
|
|
87
|
+
.lares-voice-original { text-align: start; white-space: pre-wrap; overflow: auto; max-height: 100px; padding: 12px; margin-top: 8px; background: #8881; border-radius: 8px; font-size: 13px; }
|
|
88
|
+
|
|
89
|
+
.lares-voice-panel { color: var(--q-ink-1, #202124); background: var(--q-background-1, white); inset: auto 0 var(--voice-bottom, 0px); margin: 0 auto; width: min(100vw, 560px); border-radius: 24px 24px 0 0; border-bottom: 0; }
|
|
90
|
+
.lares-voice-panel-body { padding: 16px 20px max(16px, env(safe-area-inset-bottom)); }
|
|
91
|
+
.lares-voice-panel .lares-voice-primary { background: var(--q-ink-1, var(--color-text-primary, #202124)); color: var(--q-background-1, var(--color-background-primary, white)); border-color: transparent; }
|
|
65
92
|
</style>
|
package/src/host.js
CHANGED
|
@@ -275,8 +275,9 @@ export function createHostClient(ports = {}) {
|
|
|
275
275
|
return new WebSocket(muxWsUrl(urlFor(`${VOICE_API}/realtime`), pageOrigin()), ["lares-voice", `lares-ticket.${ticket}`, ...(Array.isArray(auth) ? auth : auth ? [auth] : [])]);
|
|
276
276
|
},
|
|
277
277
|
voiceRequest(path, options) { return request(path, options); },
|
|
278
|
-
transcribe(blob, language, signal) {
|
|
278
|
+
transcribe(blob, language, signal, model) {
|
|
279
279
|
return postTranscribe(blob, language, signal, {
|
|
280
|
+
model,
|
|
280
281
|
baseUrl: urlFor(VOICE_API),
|
|
281
282
|
request: direct,
|
|
282
283
|
});
|
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
:can-send="state.canSend"
|
|
73
73
|
:attach-pending="state.upload.pending > 0"
|
|
74
74
|
:attach-disabled="!state.sessionId || state.historyLoading || state.starting"
|
|
75
|
+
:voice-view="state.voicePanelState"
|
|
75
76
|
:voice-call="state.voiceCall"
|
|
76
77
|
:voice-default-mode="state.voiceDefaultMode"
|
|
77
78
|
:voice-phase="state.voicePhase"
|
|
@@ -104,6 +105,8 @@
|
|
|
104
105
|
<div v-else key="chat" class="lares-shell__chat">
|
|
105
106
|
<LaresTranscript
|
|
106
107
|
ref="transcript"
|
|
108
|
+
:voice-view="state.voicePanelState"
|
|
109
|
+
@stop-speaking="$emit('voice-action', { action: 'stopPlayback' })"
|
|
107
110
|
:items="state.viewItems"
|
|
108
111
|
:running="state.running"
|
|
109
112
|
:running-since="state.runningSince"
|
|
@@ -136,6 +139,7 @@
|
|
|
136
139
|
:can-send="state.canSend"
|
|
137
140
|
:attach-pending="state.upload.pending > 0"
|
|
138
141
|
:attach-disabled="!state.sessionId || state.historyLoading || state.starting"
|
|
142
|
+
:voice-view="state.voicePanelState"
|
|
139
143
|
:voice-call="state.voiceCall"
|
|
140
144
|
:voice-default-mode="state.voiceDefaultMode"
|
|
141
145
|
:voice-phase="state.voicePhase"
|
|
@@ -61,6 +61,24 @@
|
|
|
61
61
|
@click="openSheet('voiceLang')"
|
|
62
62
|
/>
|
|
63
63
|
</div>
|
|
64
|
+
<template v-if="voice?.config?.dictationProfiles">
|
|
65
|
+
<label class="lares-agent__hint">{{ tVoice('dictation.preset') }}
|
|
66
|
+
<select :value="voice.config.dictationPreset" :disabled="busy" @change="patchVoice('dictationPreset', $event.target.value)"><option v-for="id in dictationPresets" :key="id" :value="id">{{ tVoice(`dictation.${id}`) }}</option></select>
|
|
67
|
+
</label>
|
|
68
|
+
<label class="lares-agent__hint">{{ tVoice('dictation.configure') }}
|
|
69
|
+
<select v-model="dictationEditor"><option v-for="id in dictationPresets" :key="id" :value="id">{{ tVoice(`dictation.${id}`) }}</option></select>
|
|
70
|
+
</label>
|
|
71
|
+
<template v-if="dictationEditor !== 'raw'">
|
|
72
|
+
<label class="lares-agent__hint">{{ tVoice('dictation.model') }}
|
|
73
|
+
<select :value="voice.config.dictationProfiles[dictationEditor].model" :disabled="busy" @change="patchDictation('model', $event.target.value)"><option value="">—</option><option v-for="id in voice.chatModels || []" :key="id" :value="id">{{ id }}</option></select>
|
|
74
|
+
</label>
|
|
75
|
+
<label class="lares-agent__hint">{{ tVoice('dictation.instructions') }}
|
|
76
|
+
<textarea :key="dictationEditor + voice.config.dictationProfiles[dictationEditor].instructions" :value="voice.config.dictationProfiles[dictationEditor].instructions" :disabled="busy" maxlength="8000" @change="patchDictation('instructions', $event.target.value)" />
|
|
77
|
+
</label>
|
|
78
|
+
<button type="button" :disabled="busy" @click="resetDictation">{{ tVoice('dictation.reset') }}</button>
|
|
79
|
+
</template>
|
|
80
|
+
<p v-else class="lares-agent__hint">{{ tVoice('dictation.noLlm') }}</p>
|
|
81
|
+
</template>
|
|
64
82
|
<label class="lares-agent__hint">{{ tVoice('settings.preset') }}
|
|
65
83
|
<select :value="voice?.config?.preset || 'assistant'" @change="patchVoice('preset', $event.target.value)">
|
|
66
84
|
<option v-for="preset in ['dictation', 'chat', 'assistant', 'direct']" :key="preset" :value="preset">{{ tVoice(`settings.preset.${preset}`) }}</option>
|
|
@@ -91,6 +109,7 @@
|
|
|
91
109
|
<option value="continuous">{{ tVoice('settings.mode.continuous') }}</option>
|
|
92
110
|
</select>
|
|
93
111
|
</label>
|
|
112
|
+
<label class="lares-agent__hint"><input type="checkbox" :checked="voice?.config?.speak === true" :disabled="busy" @change="patchVoice('speak', $event.target.checked)" /> {{ tVoice('settings.speak.title') }}</label>
|
|
94
113
|
<label class="lares-agent__hint">{{ tVoice('settings.device') }}
|
|
95
114
|
<select :value="voice?.config?.device || 'auto'" @change="patchVoice('device', $event.target.value)">
|
|
96
115
|
<option v-for="device in ['auto', 'device', 'headset']" :key="device" :value="device">{{ tVoice(`settings.device.${device}`) }}</option>
|
|
@@ -140,6 +159,7 @@
|
|
|
140
159
|
<script>
|
|
141
160
|
import { t as translate } from "@olares/lares-core/i18n/t";
|
|
142
161
|
import { EN as MODEL_EN, ZH as MODEL_ZH } from "@olares/lares-core/i18n/chat-model";
|
|
162
|
+
import { DICTATION_PRESETS, DICTATION_INSTRUCTIONS } from "@olares/lares-core/voice/dictation";
|
|
143
163
|
import { EN as VOICE_EN, ZH as VOICE_ZH } from "@olares/lares-core/i18n/voice";
|
|
144
164
|
import { EN as SEARCH_EN, ZH as SEARCH_ZH } from "@olares/lares-core/i18n/search";
|
|
145
165
|
import { groupModelsByProvider, selectionKey } from "@olares/lares-core/router/session-model";
|
|
@@ -198,6 +218,8 @@ export default {
|
|
|
198
218
|
search: remembered.search,
|
|
199
219
|
conversation: remembered.conversation,
|
|
200
220
|
modelError: "",
|
|
221
|
+
dictationEditor: "clean",
|
|
222
|
+
dictationPresets: DICTATION_PRESETS,
|
|
201
223
|
voiceError: "",
|
|
202
224
|
searchError: "",
|
|
203
225
|
conversationError: "",
|
|
@@ -443,6 +465,8 @@ export default {
|
|
|
443
465
|
this.pending = "";
|
|
444
466
|
}
|
|
445
467
|
},
|
|
468
|
+
patchDictation(key, value) { return this.patchVoice("dictationProfiles", { ...this.voice.config.dictationProfiles, [this.dictationEditor]: { ...this.voice.config.dictationProfiles[this.dictationEditor], [key]: value } }); },
|
|
469
|
+
resetDictation() { return this.patchDictation("instructions", DICTATION_INSTRUCTIONS[this.dictationEditor]); },
|
|
446
470
|
async patchVoice(key, value) {
|
|
447
471
|
if (!this.voice || this.busy) return;
|
|
448
472
|
const previous = this.voice;
|