@olares/lares-mobile 0.15.17 → 0.15.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/App.vue +106 -26
- package/src/chat/Composer.vue +32 -20
- package/src/chat/Transcript.vue +19 -2
- package/src/chat/VoicePanel.vue +1 -0
- package/src/desktop/DesktopComposer.vue +137 -48
- package/src/desktop/DesktopShell.vue +3 -1
- package/src/host.js +13 -2
- package/src/index.d.ts +2 -0
- package/src/mobile/MobileShell.vue +2 -0
- package/src/settings/AgentSettings.vue +12 -57
- package/src/settings/TtsSettings.vue +60 -0
- package/src/settings/VoicePreferences.vue +153 -0
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
<LaresPcPopover
|
|
43
43
|
v-model="modelOpen"
|
|
44
44
|
class="desktop-composer__model-slot"
|
|
45
|
-
:width="
|
|
45
|
+
:width="280"
|
|
46
46
|
placement="top-end"
|
|
47
47
|
>
|
|
48
48
|
<template #trigger="{ toggle }">
|
|
@@ -52,61 +52,71 @@
|
|
|
52
52
|
:disabled="state.modelBusy"
|
|
53
53
|
aria-haspopup="menu"
|
|
54
54
|
:aria-expanded="modelOpen"
|
|
55
|
+
:aria-label="modelSwitchLabel"
|
|
55
56
|
@mousedown.prevent
|
|
56
57
|
@click="toggle"
|
|
57
58
|
>
|
|
58
|
-
<span>{{ state.modelLabel }}</span>
|
|
59
|
+
<span class="desktop-composer__model-name">{{ state.modelLabel }}</span>
|
|
60
|
+
<span v-if="!state.effortDisabled" class="desktop-composer__model-effort">{{ state.effortLabel }}</span>
|
|
59
61
|
<ChevronIcon />
|
|
60
62
|
</button>
|
|
61
63
|
</template>
|
|
62
|
-
<div class="desktop-composer__menu">
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
<div class="desktop-composer__menu" @keydown="onModelMenuKey">
|
|
65
|
+
<template v-if="modelPane === 'root'">
|
|
66
|
+
<button type="button" class="desktop-composer__drill" @click="modelPane = 'model'">
|
|
67
|
+
<span>{{ t("menu.model") }}</span>
|
|
68
|
+
<span class="desktop-composer__drill-value">{{ state.modelLabel }}</span>
|
|
69
|
+
<ChevronIcon class="desktop-composer__drill-caret" />
|
|
70
|
+
</button>
|
|
71
|
+
<button v-if="!state.effortDisabled" type="button" class="desktop-composer__drill" @click="modelPane = 'effort'">
|
|
72
|
+
<span>{{ t("menu.effort") }}</span>
|
|
73
|
+
<span class="desktop-composer__drill-value">{{ state.effortLabel }}</span>
|
|
74
|
+
<ChevronIcon class="desktop-composer__drill-caret" />
|
|
75
|
+
</button>
|
|
76
|
+
</template>
|
|
77
|
+
<template v-else-if="modelPane === 'model'">
|
|
78
|
+
<button type="button" class="desktop-composer__drill" @click="modelPane = 'root'">
|
|
79
|
+
<ChevronIcon class="desktop-composer__back-caret" />
|
|
80
|
+
<span>{{ t("menu.model") }}</span>
|
|
81
|
+
</button>
|
|
82
|
+
<p v-if="state.modelCatalogError" class="desktop-composer__menu-status" role="status">{{ state.modelCatalogError }}</p>
|
|
83
|
+
<button type="button" class="desktop-composer__option" @click="state.refreshModels()">{{ t('agent.refresh') }}</button>
|
|
84
|
+
<p v-if="state.modelError" class="desktop-composer__menu-status">{{ state.modelError }}</p>
|
|
85
|
+
<p v-else-if="!state.models" class="desktop-composer__menu-status">{{ t("agent.loading") }}</p>
|
|
86
|
+
<template v-for="group in state.modelGroups" :key="group.provider">
|
|
87
|
+
<p class="desktop-composer__menu-label">{{ group.name || group.provider }}</p>
|
|
88
|
+
<button
|
|
89
|
+
v-for="model in group.models"
|
|
90
|
+
:key="modelKey(model)"
|
|
91
|
+
type="button"
|
|
92
|
+
class="desktop-composer__option"
|
|
93
|
+
:data-selected="modelKey(model) === state.modelCurrent"
|
|
94
|
+
@click="chooseModel(model)"
|
|
95
|
+
>
|
|
96
|
+
<span>{{ model.name }}</span>
|
|
97
|
+
<CheckIcon v-if="modelKey(model) === state.modelCurrent" />
|
|
98
|
+
</button>
|
|
99
|
+
</template>
|
|
100
|
+
</template>
|
|
101
|
+
<template v-else>
|
|
102
|
+
<button type="button" class="desktop-composer__drill" @click="modelPane = 'root'">
|
|
103
|
+
<ChevronIcon class="desktop-composer__back-caret" />
|
|
104
|
+
<span>{{ t("menu.effort") }}</span>
|
|
105
|
+
</button>
|
|
67
106
|
<button
|
|
68
|
-
v-for="
|
|
69
|
-
:key="
|
|
107
|
+
v-for="row in state.effortRows"
|
|
108
|
+
:key="row.key"
|
|
70
109
|
type="button"
|
|
71
110
|
class="desktop-composer__option"
|
|
72
|
-
:data-selected="
|
|
73
|
-
@click="
|
|
111
|
+
:data-selected="row.id === state.effortId"
|
|
112
|
+
@click="chooseEffort(row.id)"
|
|
74
113
|
>
|
|
75
|
-
<span>{{
|
|
76
|
-
<CheckIcon v-if="
|
|
114
|
+
<span>{{ effortName(row) }}</span>
|
|
115
|
+
<CheckIcon v-if="row.id === state.effortId" />
|
|
77
116
|
</button>
|
|
78
117
|
</template>
|
|
79
118
|
</div>
|
|
80
119
|
</LaresPcPopover>
|
|
81
|
-
<LaresPcPopover v-if="!state.effortDisabled" v-model="effortOpen" :width="180" placement="top-end">
|
|
82
|
-
<template #trigger="{ toggle }">
|
|
83
|
-
<button
|
|
84
|
-
type="button"
|
|
85
|
-
class="desktop-composer__model"
|
|
86
|
-
:disabled="state.modelBusy"
|
|
87
|
-
aria-haspopup="menu"
|
|
88
|
-
:aria-expanded="effortOpen"
|
|
89
|
-
@mousedown.prevent
|
|
90
|
-
@click="toggle"
|
|
91
|
-
>
|
|
92
|
-
<span>{{ state.effortLabel }}</span>
|
|
93
|
-
<ChevronIcon />
|
|
94
|
-
</button>
|
|
95
|
-
</template>
|
|
96
|
-
<div class="desktop-composer__menu">
|
|
97
|
-
<button
|
|
98
|
-
v-for="row in state.effortRows"
|
|
99
|
-
:key="row.key"
|
|
100
|
-
type="button"
|
|
101
|
-
class="desktop-composer__option"
|
|
102
|
-
:data-selected="row.id === state.effortId"
|
|
103
|
-
@click="chooseEffort(row.id)"
|
|
104
|
-
>
|
|
105
|
-
<span>{{ effortName(row) }}</span>
|
|
106
|
-
<CheckIcon v-if="row.id === state.effortId" />
|
|
107
|
-
</button>
|
|
108
|
-
</div>
|
|
109
|
-
</LaresPcPopover>
|
|
110
120
|
</div>
|
|
111
121
|
|
|
112
122
|
<LaresDesktopTodos :rows="state.todos || []" :t="t" />
|
|
@@ -243,7 +253,14 @@
|
|
|
243
253
|
</button>
|
|
244
254
|
</figure>
|
|
245
255
|
</div>
|
|
256
|
+
<LaresVoiceInput
|
|
257
|
+
v-if="dictating"
|
|
258
|
+
:view="state.voicePanelState"
|
|
259
|
+
:t="t"
|
|
260
|
+
@action="$emit('voice-action', $event)"
|
|
261
|
+
/>
|
|
246
262
|
<textarea
|
|
263
|
+
v-show="!dictating"
|
|
247
264
|
ref="input"
|
|
248
265
|
:value="state.draft"
|
|
249
266
|
class="desktop-composer__input"
|
|
@@ -371,6 +388,20 @@
|
|
|
371
388
|
:breakdown="state.contextBreakdown"
|
|
372
389
|
:t="t"
|
|
373
390
|
/>
|
|
391
|
+
<div class="desktop-composer__submit">
|
|
392
|
+
<LaresPcTooltip v-if="showMic" :label="micLabel" placement="top">
|
|
393
|
+
<button
|
|
394
|
+
type="button"
|
|
395
|
+
class="desktop-composer__circle"
|
|
396
|
+
data-kind="dictate"
|
|
397
|
+
:disabled="micDisabled"
|
|
398
|
+
:aria-label="micLabel"
|
|
399
|
+
@mousedown.prevent
|
|
400
|
+
@click="dictate"
|
|
401
|
+
>
|
|
402
|
+
<MicIcon />
|
|
403
|
+
</button>
|
|
404
|
+
</LaresPcTooltip>
|
|
374
405
|
<LaresPcTooltip
|
|
375
406
|
class="desktop-composer__send-slot"
|
|
376
407
|
:label="state.running ? t('chat.stop') : t('chat.send')"
|
|
@@ -388,6 +419,7 @@
|
|
|
388
419
|
<ArrowIcon v-else />
|
|
389
420
|
</button>
|
|
390
421
|
</LaresPcTooltip>
|
|
422
|
+
</div>
|
|
391
423
|
</div>
|
|
392
424
|
</div>
|
|
393
425
|
</Transition>
|
|
@@ -443,6 +475,7 @@ import { resolveSubmitMode } from "@olares/lares-core/larepass/submission-settin
|
|
|
443
475
|
import { DEFAULT_WORKSPACE_TITLE } from "@olares/lares-core/workspace/constants";
|
|
444
476
|
import { activeReferenceToken, insertReference } from "@olares/lares-core/larepass/references";
|
|
445
477
|
import { draftImageStatus } from "@olares/lares-core/files/draft-images";
|
|
478
|
+
import LaresVoiceInput from "../chat/VoiceInput.vue";
|
|
446
479
|
import LaresDesktopQueue from "./DesktopQueue.vue";
|
|
447
480
|
import LaresDesktopQuestions from "./DesktopQuestions.vue";
|
|
448
481
|
import LaresDesktopContextMeter from "./DesktopContextMeter.vue";
|
|
@@ -473,6 +506,15 @@ const StopIcon = {
|
|
|
473
506
|
},
|
|
474
507
|
};
|
|
475
508
|
const CloseIcon = icon("CloseIcon", "m7 7 10 10M17 7 7 17");
|
|
509
|
+
const MicIcon = {
|
|
510
|
+
name: "MicIcon",
|
|
511
|
+
render() {
|
|
512
|
+
return h("svg", { viewBox: "0 0 24 24", "aria-hidden": "true" }, [
|
|
513
|
+
h("rect", { x: 9, y: 2, width: 6, height: 12, rx: 3 }),
|
|
514
|
+
h("path", { d: "M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8" }),
|
|
515
|
+
]);
|
|
516
|
+
},
|
|
517
|
+
};
|
|
476
518
|
|
|
477
519
|
const SHIELD = "M12 3.5 19 6v5.4c0 4.2-2.8 7.4-7 9.1-4.2-1.7-7-4.9-7-9.1V6l7-2.5Z";
|
|
478
520
|
/** One shield, three glyphs: what this preset lets a tool do. */
|
|
@@ -496,7 +538,7 @@ const PermissionIcon = {
|
|
|
496
538
|
|
|
497
539
|
export default {
|
|
498
540
|
name: "LaresDesktopComposer",
|
|
499
|
-
components: { LaresDesktopContextMeter, LaresDesktopGoal, LaresDesktopQuestions, LaresDesktopQueue, LaresDesktopTodos, LaresPcDialog, LaresPcPopover, LaresPcTooltip, ChevronIcon, CheckIcon, PlusIcon, PaperclipIcon, ArrowIcon, StopIcon, CloseIcon, PermissionIcon },
|
|
541
|
+
components: { LaresVoiceInput, LaresDesktopContextMeter, LaresDesktopGoal, LaresDesktopQuestions, LaresDesktopQueue, LaresDesktopTodos, LaresPcDialog, LaresPcPopover, LaresPcTooltip, ChevronIcon, CheckIcon, PlusIcon, PaperclipIcon, ArrowIcon, StopIcon, CloseIcon, MicIcon, PermissionIcon },
|
|
500
542
|
props: {
|
|
501
543
|
state: { type: Object, required: true },
|
|
502
544
|
t: { type: Function, required: true },
|
|
@@ -507,12 +549,13 @@ export default {
|
|
|
507
549
|
emits: [
|
|
508
550
|
"update-draft", "send", "stop", "steer-queue", "files", "choose-model", "choose-effort", "answer-approval", "answer-questions",
|
|
509
551
|
"choose-permission", "disable-plan", "goal-action", "open-commands", "pick-command", "queue-action", "remove-image", "pick-workspace",
|
|
552
|
+
"dictate", "voice-action",
|
|
510
553
|
],
|
|
511
554
|
data() {
|
|
512
555
|
return {
|
|
513
556
|
modelOpen: false,
|
|
557
|
+
modelPane: "root",
|
|
514
558
|
workspaceOpen: false,
|
|
515
|
-
effortOpen: false,
|
|
516
559
|
permissionOpen: false,
|
|
517
560
|
permissionConfirm: false,
|
|
518
561
|
permissionAcknowledged: false,
|
|
@@ -557,6 +600,25 @@ export default {
|
|
|
557
600
|
attachDisabled() {
|
|
558
601
|
return !this.state.sessionId || this.state.historyLoading || this.state.starting;
|
|
559
602
|
},
|
|
603
|
+
modelSwitchLabel() {
|
|
604
|
+
const model = this.state.modelLabel;
|
|
605
|
+
const label = this.state.effortDisabled ? model : `${model}, ${this.state.effortLabel}`;
|
|
606
|
+
return this.t("model.switchAria", { label });
|
|
607
|
+
},
|
|
608
|
+
dictating() {
|
|
609
|
+
const view = this.state.voicePanelState;
|
|
610
|
+
return Boolean(view?.inputActive && view.mode === "single");
|
|
611
|
+
},
|
|
612
|
+
showMic() {
|
|
613
|
+
return this.state.voiceDictation !== false;
|
|
614
|
+
},
|
|
615
|
+
micLabel() {
|
|
616
|
+
return this.t(this.state.voiceReady ? "settings.mode.single" : "mic.checking");
|
|
617
|
+
},
|
|
618
|
+
micDisabled() {
|
|
619
|
+
const view = this.state.voicePanelState;
|
|
620
|
+
return !this.state.voiceReady || view?.busy || (view?.visible && view?.mode === "single");
|
|
621
|
+
},
|
|
560
622
|
visibleCommands() {
|
|
561
623
|
const rows = this.state.commands || [];
|
|
562
624
|
if (!this.commandTyped) return rows;
|
|
@@ -585,6 +647,15 @@ export default {
|
|
|
585
647
|
},
|
|
586
648
|
},
|
|
587
649
|
watch: {
|
|
650
|
+
modelOpen(open) {
|
|
651
|
+
if (open) {
|
|
652
|
+
this.modelPane = "root";
|
|
653
|
+
window.addEventListener("keydown", this.onModelMenuKey, true);
|
|
654
|
+
if (this.state.modelCatalogError) void this.state.refreshModels();
|
|
655
|
+
} else {
|
|
656
|
+
window.removeEventListener("keydown", this.onModelMenuKey, true);
|
|
657
|
+
}
|
|
658
|
+
},
|
|
588
659
|
"state.draft"() {
|
|
589
660
|
this.autosize();
|
|
590
661
|
},
|
|
@@ -599,6 +670,7 @@ export default {
|
|
|
599
670
|
beforeUnmount() {
|
|
600
671
|
window.clearTimeout(this.referenceTimer);
|
|
601
672
|
window.removeEventListener("mousedown", this.onComposerOverlayPointer, true);
|
|
673
|
+
window.removeEventListener("keydown", this.onModelMenuKey, true);
|
|
602
674
|
},
|
|
603
675
|
methods: {
|
|
604
676
|
pickWorkspace(workspaceId) {
|
|
@@ -611,9 +683,15 @@ export default {
|
|
|
611
683
|
this.$emit("choose-model", model);
|
|
612
684
|
},
|
|
613
685
|
chooseEffort(id) {
|
|
614
|
-
this.
|
|
686
|
+
this.modelOpen = false;
|
|
615
687
|
this.$emit("choose-effort", id);
|
|
616
688
|
},
|
|
689
|
+
onModelMenuKey(event) {
|
|
690
|
+
if (event.key !== "Escape" || !this.modelOpen || this.modelPane === "root") return;
|
|
691
|
+
event.preventDefault();
|
|
692
|
+
event.stopPropagation();
|
|
693
|
+
this.modelPane = "root";
|
|
694
|
+
},
|
|
617
695
|
choosePermission(value) {
|
|
618
696
|
this.permissionOpen = false;
|
|
619
697
|
if (value === this.state.permissionCurrent) return;
|
|
@@ -809,6 +887,9 @@ export default {
|
|
|
809
887
|
primary() {
|
|
810
888
|
this.$emit(this.state.running ? "stop" : "send");
|
|
811
889
|
},
|
|
890
|
+
dictate() {
|
|
891
|
+
this.$emit("dictate");
|
|
892
|
+
},
|
|
812
893
|
autosize() {
|
|
813
894
|
this.$nextTick(() => {
|
|
814
895
|
const input = this.$refs.input;
|
|
@@ -844,8 +925,15 @@ export default {
|
|
|
844
925
|
.desktop-composer__workspace,
|
|
845
926
|
.desktop-composer__model-slot .desktop-composer__model { min-width:0; max-width:100%; }
|
|
846
927
|
.desktop-composer__workspace span,
|
|
847
|
-
.desktop-composer__model-
|
|
928
|
+
.desktop-composer__model-name { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
929
|
+
.desktop-composer__model-effort { flex:none; color:var(--q-ink-3); }
|
|
930
|
+
.desktop-composer__drill { display:flex; align-items:center; gap:8px; width:100%; min-height:34px; border:0; border-radius:6px; padding:7px 8px; background:transparent; color:var(--q-ink-1); font:inherit; font-size:14px; text-align:left; cursor:pointer; }
|
|
931
|
+
.desktop-composer__drill:hover { background:var(--q-background-hover); }
|
|
932
|
+
.desktop-composer__drill-value { min-width:0; flex:1; overflow:hidden; color:var(--q-ink-3); text-align:right; text-overflow:ellipsis; white-space:nowrap; }
|
|
933
|
+
.desktop-composer__drill-caret { transform:rotate(-90deg); }
|
|
934
|
+
.desktop-composer__back-caret { transform:rotate(90deg); }
|
|
848
935
|
.desktop-composer__model svg,
|
|
936
|
+
.desktop-composer__drill svg,
|
|
849
937
|
.desktop-composer__permission svg,
|
|
850
938
|
.desktop-composer__circle svg,
|
|
851
939
|
.desktop-composer__send svg,
|
|
@@ -911,8 +999,9 @@ export default {
|
|
|
911
999
|
/* The meter rides with the send button; it renders nothing without an occupancy,
|
|
912
1000
|
in which case the send button keeps the auto margin itself. */
|
|
913
1001
|
.desktop-composer__meter { margin-left:auto; }
|
|
914
|
-
.desktop-
|
|
915
|
-
.desktop-composer__meter + .desktop-
|
|
1002
|
+
.desktop-composer__submit { margin-left:auto; display:flex; align-items:center; gap:8px; }
|
|
1003
|
+
.desktop-composer__meter + .desktop-composer__submit { margin-left:0; }
|
|
1004
|
+
.desktop-composer__submit .desktop-composer__send-slot { margin-left:0; }
|
|
916
1005
|
.desktop-composer__send { display:grid; width:28px; height:28px; place-items:center; border-radius:50%; background:var(--q-blue-default); color:var(--q-ink-on-brand); }
|
|
917
1006
|
.desktop-composer button:disabled { opacity:.45; cursor:default; }
|
|
918
1007
|
.desktop-composer__menu { margin:-2px; }
|
|
@@ -85,6 +85,8 @@
|
|
|
85
85
|
@open-references="$emit('open-references', $event)"
|
|
86
86
|
@pick-command="$emit('pick-command', $event)"
|
|
87
87
|
@queue-action="onQueueAction"
|
|
88
|
+
@dictate="$emit('dictate')"
|
|
89
|
+
@voice-action="$emit('voice-action', $event)"
|
|
88
90
|
/>
|
|
89
91
|
<Transition name="lares-desk-preview">
|
|
90
92
|
<LaresDesktopPreview
|
|
@@ -154,7 +156,7 @@ export default {
|
|
|
154
156
|
"refresh-workspaces", "browse-workspaces", "browse-directory",
|
|
155
157
|
"close-directory", "choose-directory", "create-folder", "toggle-hidden", "rename-workspace",
|
|
156
158
|
"delete-workspace", "move-workspace", "move-session", "rename-session",
|
|
157
|
-
"fork-session", "archive-session",
|
|
159
|
+
"fork-session", "archive-session", "dictate", "voice-action",
|
|
158
160
|
],
|
|
159
161
|
computed: {
|
|
160
162
|
newSessionHero() {
|
package/src/host.js
CHANGED
|
@@ -175,6 +175,7 @@ export async function defaultRequest(url, init = {}) {
|
|
|
175
175
|
...init.headers,
|
|
176
176
|
},
|
|
177
177
|
});
|
|
178
|
+
if (init.responseType === "blob" && res.ok && !/json/i.test(res.headers.get("content-type") || "")) return { ok: true, status: res.status, body: await res.blob() };
|
|
178
179
|
const text = await res.text();
|
|
179
180
|
let body;
|
|
180
181
|
if (text) {
|
|
@@ -207,6 +208,16 @@ export function createHostClient(ports = {}) {
|
|
|
207
208
|
);
|
|
208
209
|
return {
|
|
209
210
|
urlFor,
|
|
211
|
+
async ttsFetch(path, init = {}) {
|
|
212
|
+
const audio = path.endsWith("/audio/speech");
|
|
213
|
+
const result = await request(path, { ...init, ...(audio ? { responseType: "blob" } : {}) });
|
|
214
|
+
return { ok: result.ok, status: result.status, json: async () => result.body,
|
|
215
|
+
blob: async () => {
|
|
216
|
+
if (result.body instanceof Blob) return result.body;
|
|
217
|
+
if (result.body instanceof ArrayBuffer || ArrayBuffer.isView(result.body)) return new Blob([result.body], { type: "audio/wav" });
|
|
218
|
+
throw new Error("tts_preview_failed");
|
|
219
|
+
} };
|
|
220
|
+
},
|
|
210
221
|
probe() {
|
|
211
222
|
if (ports.missing) return Promise.resolve({ status: "missing" });
|
|
212
223
|
return probeHost((path) => request(path), MODELS_PATH);
|
|
@@ -270,9 +281,9 @@ export function createHostClient(ports = {}) {
|
|
|
270
281
|
request: direct,
|
|
271
282
|
});
|
|
272
283
|
},
|
|
273
|
-
voiceSocket(ticket) {
|
|
284
|
+
voiceSocket(ticket, path = `${VOICE_API}/realtime`) {
|
|
274
285
|
const auth = socketProtocols(ports);
|
|
275
|
-
return new WebSocket(muxWsUrl(urlFor(
|
|
286
|
+
return new WebSocket(muxWsUrl(urlFor(path), pageOrigin()), ["lares-voice", `lares-ticket.${ticket}`, ...(Array.isArray(auth) ? auth : auth ? [auth] : [])]);
|
|
276
287
|
},
|
|
277
288
|
voiceRequest(path, options) { return request(path, options); },
|
|
278
289
|
transcribe(blob, language, signal, model) {
|
package/src/index.d.ts
CHANGED
|
@@ -86,4 +86,6 @@ export const LaresAgentSettings: DefineComponent<{
|
|
|
86
86
|
env?: HostEnv;
|
|
87
87
|
request?: (url: string, init?: HostRequestInit) => Promise<HostResponse>;
|
|
88
88
|
missing?: boolean;
|
|
89
|
+
/** Register the settings close/navigation guard; exposed beforeLeave() is the imperative alternative. */
|
|
90
|
+
bindBeforeLeave?: (listener: () => Promise<boolean>) => (() => void);
|
|
89
91
|
}>;
|
|
@@ -172,6 +172,8 @@
|
|
|
172
172
|
</div>
|
|
173
173
|
|
|
174
174
|
<LaresSheet :open="state.modelSheet" :title="t('model.menuAria')" @close="$emit('close-model')">
|
|
175
|
+
<p v-if="state.modelCatalogError" class="lares-shell__status" role="status">{{ state.modelCatalogError }}</p>
|
|
176
|
+
<button type="button" @click="state.refreshModels()">{{ t('agent.refresh') }}</button>
|
|
175
177
|
<p v-if="state.modelError" class="lares-shell__status">{{ state.modelError }}</p>
|
|
176
178
|
<p v-else-if="!state.models" class="lares-shell__hint">{{ t("agent.loading") }}</p>
|
|
177
179
|
<p v-else-if="!state.modelGroups.length" class="lares-shell__hint">{{ t("model.empty") }}</p>
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
<h2>{{ tVoice("settings.title") }}</h2>
|
|
46
46
|
<div class="lares-agent__list">
|
|
47
47
|
<LaresSettingRow
|
|
48
|
-
:label="tVoice('settings.model
|
|
48
|
+
:label="tVoice('settings.stt.model')"
|
|
49
49
|
:value="voiceModelLabel"
|
|
50
50
|
chevron
|
|
51
51
|
boxed
|
|
@@ -61,55 +61,13 @@
|
|
|
61
61
|
@click="openSheet('voiceLang')"
|
|
62
62
|
/>
|
|
63
63
|
</div>
|
|
64
|
-
<
|
|
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>
|
|
82
|
-
<label class="lares-agent__hint">{{ tVoice('settings.preset') }}
|
|
83
|
-
<select :value="voice?.config?.preset || 'assistant'" @change="patchVoice('preset', $event.target.value)">
|
|
84
|
-
<option v-for="preset in ['dictation', 'chat', 'assistant', 'direct']" :key="preset" :value="preset">{{ tVoice(`settings.preset.${preset}`) }}</option>
|
|
85
|
-
</select>
|
|
86
|
-
</label>
|
|
87
|
-
<label class="lares-agent__hint">{{ tVoice('settings.chatModel') }}
|
|
88
|
-
<select :value="voice?.config?.chatModel || 'default-chat'" @change="patchVoice('chatModel', $event.target.value)">
|
|
89
|
-
<option value="default-chat">default-chat</option>
|
|
90
|
-
<option v-for="model in voice?.chatModels || []" :key="model" :value="model">{{ model }}</option>
|
|
91
|
-
</select>
|
|
92
|
-
</label>
|
|
93
|
-
<label class="lares-agent__hint">{{ tVoice('settings.backend') }}
|
|
94
|
-
<select :value="voice?.config?.backend || 'pipeline'" @change="patchVoice('backend', $event.target.value)">
|
|
95
|
-
<option value="pipeline">{{ tVoice('settings.backend.pipeline') }}</option>
|
|
96
|
-
<option v-if="voice?.realtimeReady?.includes(voice?.config?.realtimeModel)" value="realtime">Realtime</option>
|
|
97
|
-
</select>
|
|
98
|
-
</label>
|
|
99
|
-
<label v-if="voice?.realtimeModels?.length" class="lares-agent__hint">{{ tVoice('settings.realtime.model') }}
|
|
100
|
-
<select :value="voice?.config?.realtimeModel || ''" @change="patchVoice('realtimeModel', $event.target.value)">
|
|
101
|
-
<option value="" disabled>—</option>
|
|
102
|
-
<option v-for="model in voice.realtimeModels" :key="model" :value="model">{{ model }}</option>
|
|
103
|
-
</select>
|
|
104
|
-
</label>
|
|
105
|
-
<p class="lares-agent__hint">{{ tVoice('settings.realtime.hint') }}</p>
|
|
64
|
+
<VoicePreferences :key="hostKey" ref="voicePreferences" :settings="settings" :tts-fetch="ttsFetch" :snapshot="voice" :device="device" :t="tVoice" :bind-before-leave="bindBeforeLeave" @change="voice = $event" />
|
|
106
65
|
<label class="lares-agent__hint">{{ tVoice('settings.mobileMode') }}
|
|
107
66
|
<select :value="voice?.config?.mobileMode || 'continuous'" @change="patchVoice('mobileMode', $event.target.value)">
|
|
108
67
|
<option value="single">{{ tVoice('settings.mode.single') }}</option>
|
|
109
68
|
<option value="continuous">{{ tVoice('settings.mode.continuous') }}</option>
|
|
110
69
|
</select>
|
|
111
70
|
</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>
|
|
113
71
|
<label class="lares-agent__hint">{{ tVoice('settings.device') }}
|
|
114
72
|
<select :value="voice?.config?.device || 'auto'" @change="patchVoice('device', $event.target.value)">
|
|
115
73
|
<option v-for="device in ['auto', 'device', 'headset']" :key="device" :value="device">{{ tVoice(`settings.device.${device}`) }}</option>
|
|
@@ -159,7 +117,6 @@
|
|
|
159
117
|
<script>
|
|
160
118
|
import { t as translate } from "@olares/lares-core/i18n/t";
|
|
161
119
|
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";
|
|
163
120
|
import { EN as VOICE_EN, ZH as VOICE_ZH } from "@olares/lares-core/i18n/voice";
|
|
164
121
|
import { EN as SEARCH_EN, ZH as SEARCH_ZH } from "@olares/lares-core/i18n/search";
|
|
165
122
|
import { groupModelsByProvider, selectionKey } from "@olares/lares-core/router/session-model";
|
|
@@ -180,6 +137,7 @@ import { createHostClient } from "../host.js";
|
|
|
180
137
|
import { adoptHost } from "../runtime.js";
|
|
181
138
|
import { createT } from "../i18n.js";
|
|
182
139
|
import { chatDevice, DEVICE_DESKTOP } from "../layout.js";
|
|
140
|
+
import VoicePreferences from "./VoicePreferences.vue";
|
|
183
141
|
import LaresSettingRow from "./SettingRow.vue";
|
|
184
142
|
import LaresSheet from "./Sheet.vue";
|
|
185
143
|
import LaresSelect from "./Select.vue";
|
|
@@ -196,8 +154,8 @@ function itemLabel(items, id) {
|
|
|
196
154
|
|
|
197
155
|
export default {
|
|
198
156
|
name: "LaresAgentSettings",
|
|
199
|
-
components: { LaresSettingRow, LaresSheet, LaresSelect },
|
|
200
|
-
expose: ["reload"],
|
|
157
|
+
components: { VoicePreferences, LaresSettingRow, LaresSheet, LaresSelect },
|
|
158
|
+
expose: ["reload", "beforeLeave"],
|
|
201
159
|
props: {
|
|
202
160
|
locale: { type: String, default: "en" },
|
|
203
161
|
device: { type: String, default: "mobile" },
|
|
@@ -206,6 +164,7 @@ export default {
|
|
|
206
164
|
request: { type: Function, default: undefined },
|
|
207
165
|
env: { type: Object, default: undefined },
|
|
208
166
|
missing: { type: Boolean, default: false },
|
|
167
|
+
bindBeforeLeave: { type: Function, default: undefined },
|
|
209
168
|
},
|
|
210
169
|
data() {
|
|
211
170
|
const remembered = rememberedSettings();
|
|
@@ -218,8 +177,6 @@ export default {
|
|
|
218
177
|
search: remembered.search,
|
|
219
178
|
conversation: remembered.conversation,
|
|
220
179
|
modelError: "",
|
|
221
|
-
dictationEditor: "clean",
|
|
222
|
-
dictationPresets: DICTATION_PRESETS,
|
|
223
180
|
voiceError: "",
|
|
224
181
|
searchError: "",
|
|
225
182
|
conversationError: "",
|
|
@@ -247,6 +204,7 @@ export default {
|
|
|
247
204
|
hostKey() {
|
|
248
205
|
return hostSessionKey(this.ports);
|
|
249
206
|
},
|
|
207
|
+
ttsFetch() { return createHostClient(this.ports).ttsFetch; },
|
|
250
208
|
settings() {
|
|
251
209
|
return createHostClient(this.ports).settings;
|
|
252
210
|
},
|
|
@@ -324,7 +282,7 @@ export default {
|
|
|
324
282
|
},
|
|
325
283
|
sheetTitle() {
|
|
326
284
|
if (this.panel === "model") return this.tModel("settings.title");
|
|
327
|
-
if (this.panel === "voiceModel") return this.tVoice("settings.model
|
|
285
|
+
if (this.panel === "voiceModel") return this.tVoice("settings.stt.model");
|
|
328
286
|
if (this.panel === "voiceLang") return this.tVoice("settings.language.title");
|
|
329
287
|
if (this.panel === "search") return this.tSearch("settings.default");
|
|
330
288
|
return "";
|
|
@@ -417,7 +375,9 @@ export default {
|
|
|
417
375
|
if (panel === "search") return searchMenuValue(this.search);
|
|
418
376
|
return "";
|
|
419
377
|
},
|
|
378
|
+
beforeLeave() { return this.$refs.voicePreferences?.beforeLeave() ?? Promise.resolve(true); },
|
|
420
379
|
reload() {
|
|
380
|
+
void this.$refs.voicePreferences?.reload().catch(() => {});
|
|
421
381
|
return this.load(true);
|
|
422
382
|
},
|
|
423
383
|
async load(force) {
|
|
@@ -437,7 +397,7 @@ export default {
|
|
|
437
397
|
this.conversationError = "";
|
|
438
398
|
const [models, voice, search, conversation] = await Promise.allSettled([
|
|
439
399
|
this.settings.models({ force }),
|
|
440
|
-
this.settings.voice(force),
|
|
400
|
+
this.settings.voice(force, { diagnostics: false }),
|
|
441
401
|
this.settings.search({ force }),
|
|
442
402
|
this.settings.conversation({ force }),
|
|
443
403
|
]);
|
|
@@ -465,18 +425,13 @@ export default {
|
|
|
465
425
|
this.pending = "";
|
|
466
426
|
}
|
|
467
427
|
},
|
|
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]); },
|
|
470
428
|
async patchVoice(key, value) {
|
|
471
429
|
if (!this.voice || this.busy) return;
|
|
472
|
-
const previous = this.voice;
|
|
473
|
-
this.voice = { ...this.voice, config: { ...this.voice.config, [key]: value } };
|
|
474
430
|
this.pending = `voice:${key}`;
|
|
475
431
|
this.voiceError = "";
|
|
476
432
|
try {
|
|
477
|
-
this.voice = await this.
|
|
433
|
+
this.voice = await this.$refs.voicePreferences.patch({ [key]: value });
|
|
478
434
|
} catch {
|
|
479
|
-
this.voice = previous;
|
|
480
435
|
this.voiceError = this.tVoice("settings.saveFailed");
|
|
481
436
|
} finally {
|
|
482
437
|
this.pending = "";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="lares-tts-settings">
|
|
3
|
+
<h3>{{ t('settings.tts.title') }}</h3>
|
|
4
|
+
<label>{{ t('settings.tts.model') }}
|
|
5
|
+
<select :value="view.value.config.ttsModel || ''" :disabled="state.saving" @change="save(controller.selectModel($event.target.value))">
|
|
6
|
+
<option value="">{{ t('settings.model.auto') }}</option>
|
|
7
|
+
<option v-for="id in models" :key="id" :value="id">{{ id }}</option>
|
|
8
|
+
</select>
|
|
9
|
+
</label>
|
|
10
|
+
<p v-if="view.errors.ttsModel" role="alert">{{ t('settings.saveFailed') }}</p>
|
|
11
|
+
<p v-if="!state.model">{{ t('preferences.modelsMissing') }}</p>
|
|
12
|
+
<p v-else-if="state.loading" role="status">{{ t('tts.loading') }}</p>
|
|
13
|
+
<p v-else-if="state.error" role="alert">{{ t('tts.loadFailed') }} <button type="button" @click="controller.load()">{{ t('dictation.retry') }}</button></p>
|
|
14
|
+
<label v-else-if="state.voices.length || state.unavailable">{{ t('tts.voice') }}
|
|
15
|
+
<select :value="state.selected" :disabled="state.saving" @change="save(controller.selectVoice($event.target.value))">
|
|
16
|
+
<option value="">{{ t('tts.default') }}</option>
|
|
17
|
+
<option v-if="state.unavailable" :value="state.selected" disabled>{{ state.selected }} · {{ t('preferences.unavailable') }}</option>
|
|
18
|
+
<option v-for="voice in state.voices" :key="voice.id" :value="voice.id">{{ voice.label }}</option>
|
|
19
|
+
</select>
|
|
20
|
+
</label>
|
|
21
|
+
<p v-else>{{ t('tts.noPresets') }}</p>
|
|
22
|
+
<p v-if="view.errors.ttsVoiceByModel" role="alert">{{ t('settings.saveFailed') }}</p>
|
|
23
|
+
<div class="lares-tts-settings__preview">
|
|
24
|
+
<button type="button" :disabled="!state.model || state.loading || Boolean(state.error) || state.saving || state.unavailable || state.blocked" @click="controller.audition()">{{ t(state.playing ? 'tts.stopPreview' : 'tts.preview') }}</button>
|
|
25
|
+
<p role="status">{{ t(state.blocked ? 'tts.busy' : 'tts.previewHint') }}</p>
|
|
26
|
+
</div>
|
|
27
|
+
<p v-if="state.previewError" role="alert">{{ t('tts.previewFailed') }}</p>
|
|
28
|
+
<label class="lares-tts-settings__speak"><input type="checkbox" :checked="view.value.config.speak === true" :disabled="Boolean(view.pending.speak)" @change="save(preferences.patch({ speak: $event.target.checked }))" />{{ t('settings.speak.title') }}</label>
|
|
29
|
+
<p v-if="view.errors.speak" role="alert">{{ t('settings.saveFailed') }}</p>
|
|
30
|
+
</section>
|
|
31
|
+
</template>
|
|
32
|
+
<script>
|
|
33
|
+
import { markRaw } from "vue";
|
|
34
|
+
import { createTtsSettings } from "@olares/lares-core/voice/tts-settings";
|
|
35
|
+
import { fetchTtsVoices } from "@olares/lares-core/voice/tts-voice";
|
|
36
|
+
import { previewTts } from "@olares/lares-core/voice/tts-preview";
|
|
37
|
+
export default {
|
|
38
|
+
props: { preferences: { type: Object, required: true }, view: { type: Object, required: true }, t: { type: Function, required: true }, fetcher: { type: Function, required: true } },
|
|
39
|
+
data() {
|
|
40
|
+
const controller = markRaw(createTtsSettings({ preferences: this.preferences, list: (model, options) => fetchTtsVoices(model, { ...options, fetcher: this.fetcher }), preview: take => previewTts(take, { fetcher: this.fetcher }) }));
|
|
41
|
+
return { controller, state: controller.getSnapshot() };
|
|
42
|
+
},
|
|
43
|
+
computed: { models() { return [...new Set([this.view.value.config.ttsModel, ...(this.view.value.ttsModels || [])].filter(Boolean))]; } },
|
|
44
|
+
mounted() { this.off = this.controller.subscribe(() => { this.state = this.controller.getSnapshot(); }); void this.controller.load(); },
|
|
45
|
+
beforeUnmount() { this.off?.(); this.controller.dispose(); },
|
|
46
|
+
methods: { save(operation) { void operation.catch(() => {}); } },
|
|
47
|
+
};
|
|
48
|
+
</script>
|
|
49
|
+
<style scoped>
|
|
50
|
+
.lares-tts-settings { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
|
51
|
+
.lares-tts-settings label { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
|
52
|
+
.lares-tts-settings select { width: 100%; min-width: 0; max-width: 100%; font: inherit; color: inherit; border: 1px solid var(--q-separator); border-radius: 8px; background: var(--q-background-2, transparent); padding: 10px; }
|
|
53
|
+
.lares-tts-settings button:disabled { opacity: .45; cursor: default; }
|
|
54
|
+
.lares-tts-settings button:focus-visible, .lares-tts-settings select:focus-visible { outline: 2px solid var(--q-blue-default, #3978f6); outline-offset: 2px; }
|
|
55
|
+
.lares-tts-settings button { font: inherit; color: inherit; border: 1px solid var(--q-separator); border-radius: 8px; background: transparent; padding: 8px 14px; }
|
|
56
|
+
.lares-tts-settings p { margin: 0; font-size: 13px; color: var(--q-ink-3); }
|
|
57
|
+
.lares-tts-settings [role="alert"] { color: var(--q-orange-default); }
|
|
58
|
+
.lares-tts-settings label.lares-tts-settings__speak { flex-direction: row; align-items: center; }
|
|
59
|
+
.lares-tts-settings__preview { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }
|
|
60
|
+
</style>
|