@olares/lares-mobile 0.15.23 → 0.15.25
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 +20 -3
- package/src/chat/ChatBar.vue +4 -0
- package/src/chat/Transcript.vue +11 -12
- package/src/chat/VoiceInput.vue +0 -5
- package/src/desktop/DesktopQueue.vue +1 -3
- package/src/desktop/DesktopShell.vue +1 -0
- package/src/host.js +2 -0
- package/src/mobile/MobileShell.vue +26 -1
- package/src/motion.css +16 -0
- package/src/settings/AgentSettings.vue +4 -1
- package/src/settings/Sheet.vue +19 -0
- package/src/settings/VoiceKeys.vue +74 -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.25",
|
|
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.25",
|
|
32
32
|
"markdown-it": "^14.3.0"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/App.vue
CHANGED
|
@@ -124,6 +124,7 @@ import { failText, withPendingUser } from "./chat/format.js";
|
|
|
124
124
|
import { captureSource } from "@olares/lares-core/voice/input";
|
|
125
125
|
import { laresVoiceConfig, laresVoiceAvailability } from "@olares/lares-core/voice/lares-policy";
|
|
126
126
|
import { createVoiceConversation } from "@olares/lares-core/voice/conversation";
|
|
127
|
+
import { createUnifiedConversation } from "@olares/lares-core/voice/unified-conversation";
|
|
127
128
|
import { createAnswerPlayer } from "./chat/playback.js";
|
|
128
129
|
import { createReplySpeaker, createSentencePlayer, readoutState, speakable } from "@olares/lares-core/voice/speech";
|
|
129
130
|
import { prepareBrowserCapture } from "@olares/lares-core/voice/browser-capture";
|
|
@@ -213,6 +214,7 @@ export default {
|
|
|
213
214
|
draftBySession: {},
|
|
214
215
|
imagesBySession: {},
|
|
215
216
|
failed: "",
|
|
217
|
+
actionNotice: "",
|
|
216
218
|
error: "",
|
|
217
219
|
items: [],
|
|
218
220
|
pendingUser: "",
|
|
@@ -325,6 +327,7 @@ export default {
|
|
|
325
327
|
draft: this.draft,
|
|
326
328
|
failed: this.failed,
|
|
327
329
|
failText: this.failText,
|
|
330
|
+
actionNotice: this.actionNotice,
|
|
328
331
|
viewItems: this.viewItems,
|
|
329
332
|
historyLoading: this.historyLoading,
|
|
330
333
|
previews: this.previews,
|
|
@@ -704,6 +707,7 @@ export default {
|
|
|
704
707
|
this.noteSeen(this.sessionId);
|
|
705
708
|
this.noteSeen(snap.sessionId);
|
|
706
709
|
this.pendingUser = "";
|
|
710
|
+
this.actionNotice = "";
|
|
707
711
|
this.previews = {};
|
|
708
712
|
this.resetPreview();
|
|
709
713
|
this.commands = null;
|
|
@@ -1037,6 +1041,7 @@ export default {
|
|
|
1037
1041
|
this.resetPreview();
|
|
1038
1042
|
this.pendingUser = "";
|
|
1039
1043
|
this.failed = "";
|
|
1044
|
+
this.actionNotice = "";
|
|
1040
1045
|
this.error = "";
|
|
1041
1046
|
this.modelCatalog = null;
|
|
1042
1047
|
this.modelCatalogError = "";
|
|
@@ -1317,7 +1322,13 @@ export default {
|
|
|
1317
1322
|
const device = settings.config.device === "headset" ? "headset" : "device";
|
|
1318
1323
|
onDevice(device);
|
|
1319
1324
|
if (settings.config.backend === "realtime" && continuous) throw new Error("voice_manual_realtime_unavailable");
|
|
1320
|
-
this.reader.stop(); this.player?.stop();
|
|
1325
|
+
this.reader.stop(); this.player?.stop(); this.player?.unlock();
|
|
1326
|
+
if (config.transport !== "legacy") {
|
|
1327
|
+
return createUnifiedConversation({ request: (path, options) => this.runtime.voiceRequest(path, options), socket: (ticket) => this.runtime.voiceSocket(ticket),
|
|
1328
|
+
device: captureSource("mobile", config.device), readAloud: continuous && config.speak, preset: config.preset, presentationSession: true,
|
|
1329
|
+
endpointSilenceMs: config.endpointSilenceMs, idleTimeoutMs: config.idleTimeoutMs, recoveryTimeoutMs: config.recoveryTimeoutMs,
|
|
1330
|
+
onTranscript: continuous ? null : onTranscript, mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter) });
|
|
1331
|
+
}
|
|
1321
1332
|
let conversation;
|
|
1322
1333
|
const reader = createSentencePlayer({
|
|
1323
1334
|
stream: (value) => playAudioStream({ ...value, request: (path, options) => this.runtime.voiceRequest(path, options), headers: conversation.headers, socket: (ticket) => this.runtime.voiceSocket(ticket, "/api/lares/voice/stream") }),
|
|
@@ -1533,8 +1544,12 @@ export default {
|
|
|
1533
1544
|
const text = this.draft.trim();
|
|
1534
1545
|
if (!this.canSend) return;
|
|
1535
1546
|
this.player?.unlock();
|
|
1547
|
+
const continuous = this.voicePanelState?.mode === "continuous";
|
|
1548
|
+
if (this.voiceSpeak && continuous) {
|
|
1549
|
+
try { await this.voicePanel.openReadout(); }
|
|
1550
|
+
catch (error) { this.voiceError = error?.code || error?.message || "voice_failed"; }
|
|
1551
|
+
} else if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
|
|
1536
1552
|
this.reader.stop();
|
|
1537
|
-
if (this.voiceCall?.connection === "open") await this.voicePanel.pause();
|
|
1538
1553
|
const sessionId = this.sessionId;
|
|
1539
1554
|
const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
|
|
1540
1555
|
const wasRunning = this.running;
|
|
@@ -1580,10 +1595,12 @@ export default {
|
|
|
1580
1595
|
async updateQueue(itemId, action) {
|
|
1581
1596
|
if (!itemId || this.queueBusy) return;
|
|
1582
1597
|
this.queueBusy = itemId;
|
|
1598
|
+
this.actionNotice = "";
|
|
1583
1599
|
try {
|
|
1584
1600
|
const changed = await this.runtime.updateQueue(itemId, action);
|
|
1585
1601
|
if (!changed.ok) {
|
|
1586
|
-
|
|
1602
|
+
const detail = changed.error?.message || changed.error?.code || "queue";
|
|
1603
|
+
this.actionNotice = this.t("chat.failed", { error: detail });
|
|
1587
1604
|
}
|
|
1588
1605
|
} finally {
|
|
1589
1606
|
this.queueBusy = "";
|
package/src/chat/ChatBar.vue
CHANGED
package/src/chat/Transcript.vue
CHANGED
|
@@ -34,10 +34,6 @@
|
|
|
34
34
|
:data-type="row.type"
|
|
35
35
|
:data-pending="row.pending ? 'true' : 'false'"
|
|
36
36
|
>
|
|
37
|
-
<div v-if="hasReadout(row)" role="status" class="lares-message-readout">
|
|
38
|
-
<span>{{ speechLabel }}</span>
|
|
39
|
-
<button type="button" class="lares-message-stop" @click="readout.stop()">{{ t(readout.status === 'error' ? 'readout.dismiss' : 'readout.stop') }}</button>
|
|
40
|
-
</div>
|
|
41
37
|
<div v-if="row.type === 'user'" class="lares-msg" data-role="user">{{ row.text }}</div>
|
|
42
38
|
<LaresStageRow
|
|
43
39
|
v-else-if="row.type === 'context'"
|
|
@@ -75,16 +71,19 @@
|
|
|
75
71
|
:title="retryLabel(row)"
|
|
76
72
|
:body="retryBody(row)"
|
|
77
73
|
/>
|
|
78
|
-
<div
|
|
79
|
-
v-else-if="row.type === 'assistant' && row.pending"
|
|
80
|
-
class="lares-md"
|
|
81
|
-
data-pending="true"
|
|
82
|
-
>{{ row.text }}</div>
|
|
83
74
|
<template v-else-if="row.type === 'assistant'">
|
|
84
|
-
<div class="lares-md"
|
|
85
|
-
<
|
|
86
|
-
|
|
75
|
+
<div v-if="row.pending" class="lares-md" data-pending="true">{{ row.text }}</div>
|
|
76
|
+
<template v-else>
|
|
77
|
+
<div class="lares-md" v-html="renderMarkdown(row.text)" />
|
|
78
|
+
<p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
|
|
79
|
+
</template>
|
|
80
|
+
<div v-if="hasReadout(row)" role="status" class="lares-message-readout">
|
|
81
|
+
<span>{{ speechLabel }}</span>
|
|
82
|
+
<button type="button" class="lares-message-stop" @click="readout.stop()">{{ t(readout.status === 'error' ? 'readout.dismiss' : 'readout.stop') }}</button>
|
|
83
|
+
</div>
|
|
84
|
+
<button v-if="!row.pending && !readout && isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
|
|
87
85
|
<LaresMessageActions
|
|
86
|
+
v-if="!row.pending"
|
|
88
87
|
:text="row.text"
|
|
89
88
|
:reaction="reactions[actionKey(index)] || ''"
|
|
90
89
|
:metrics="row.metrics || null"
|
package/src/chat/VoiceInput.vue
CHANGED
|
@@ -6,20 +6,17 @@
|
|
|
6
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
7
|
<span>{{ t(ui.label) }}</span><span v-if="ui.timed" class="lares-inline-time" aria-hidden="true"> · {{ ui.elapsed }}</span>
|
|
8
8
|
</button>
|
|
9
|
-
<span v-if="!ui.error" class="lares-inline-status" :data-visible="['speaking', 'working'].includes(ui.phase)" role="status" aria-live="polite" aria-atomic="true">{{ status }}</span>
|
|
10
9
|
</div>
|
|
11
10
|
</template>
|
|
12
11
|
<script>
|
|
13
12
|
import { createHoldGesture } from '@olares/lares-core/voice/hold';
|
|
14
13
|
import { voiceInputPresentation } from '@olares/lares-core/voice/presentation';
|
|
15
|
-
import { messageFromCode } from '@olares/lares-core/i18n/t';
|
|
16
14
|
export default {
|
|
17
15
|
name: 'LaresVoiceInput', props: { view: { type: Object, required: true }, t: { type: Function, required: true } }, emits: ['action'],
|
|
18
16
|
data: () => ({ now: Date.now(), clock: null, gesture: null }),
|
|
19
17
|
computed: {
|
|
20
18
|
ui() { return voiceInputPresentation(this.view, this.now); },
|
|
21
19
|
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
20
|
},
|
|
24
21
|
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
22
|
mounted() { this.$refs.main?.focus(); this.clock = setInterval(() => { this.now = Date.now(); }, 250); window.addEventListener('blur', this.cancel); window.addEventListener('pointerdown', this.additionalPointer, true); },
|
|
@@ -44,6 +41,4 @@ export default {
|
|
|
44
41
|
.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
42
|
[data-phase=recording] .lares-inline-main { color: var(--q-blue-default, #3377ff); }
|
|
46
43
|
[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] { 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
44
|
</style>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
3
|
-
<div v-if="rows.length" class="desktop-queue">
|
|
2
|
+
<div v-if="rows.length" class="desktop-queue lares-queue-in">
|
|
4
3
|
<div class="desktop-queue__panel">
|
|
5
4
|
<button
|
|
6
5
|
v-if="rows.length > 1"
|
|
@@ -70,7 +69,6 @@
|
|
|
70
69
|
</ul>
|
|
71
70
|
</div>
|
|
72
71
|
</div>
|
|
73
|
-
</Transition>
|
|
74
72
|
</template>
|
|
75
73
|
|
|
76
74
|
<script>
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
<span>{{ state.failText }}</span>
|
|
36
36
|
<button type="button" :disabled="state.starting" @click="$emit('retry')">{{ t("probe.retry") }}</button>
|
|
37
37
|
</div>
|
|
38
|
+
<p v-else-if="state.actionNotice" class="desktop-shell__failure" role="status">{{ state.actionNotice }}</p>
|
|
38
39
|
<div v-if="newSessionHero" class="desktop-shell__brand">
|
|
39
40
|
<div class="desktop-shell__logo" :style="logoStyle" aria-hidden="true" />
|
|
40
41
|
<h1>{{ t("shell.title") }}</h1>
|
package/src/host.js
CHANGED
|
@@ -5,6 +5,7 @@ import { normalizeReferenceCandidates, referencesUrl } from "@olares/lares-core/
|
|
|
5
5
|
import { muxWsUrl, remoteStreamOpen, workspaceBaseline } from "@olares/lares-core/larepass/mux";
|
|
6
6
|
import { RESPOND_PATH } from "@olares/lares-core/larepass/rpc";
|
|
7
7
|
import { createHostSettings } from "@olares/lares-core/larepass/settings";
|
|
8
|
+
import { createVoiceKeysClient } from "@olares/lares-core/voice/keys-client";
|
|
8
9
|
import {
|
|
9
10
|
downloadFileUrl,
|
|
10
11
|
isPreviewPayload,
|
|
@@ -340,5 +341,6 @@ export function createHostClient(ports = {}) {
|
|
|
340
341
|
},
|
|
341
342
|
consumeMux,
|
|
342
343
|
settings: createHostSettings(request),
|
|
344
|
+
voiceKeys: createVoiceKeysClient((path, init) => request(path, init)),
|
|
343
345
|
};
|
|
344
346
|
}
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
/>
|
|
52
52
|
<div class="lares-shell__conversation" :data-hero="newSessionHero ? 'true' : 'false'">
|
|
53
53
|
<p v-if="state.failed" class="lares-shell__status">{{ state.failText }}</p>
|
|
54
|
+
<p v-else-if="state.actionNotice" class="lares-shell__status" role="status">{{ state.actionNotice }}</p>
|
|
54
55
|
<button
|
|
55
56
|
v-if="state.failed"
|
|
56
57
|
type="button"
|
|
@@ -176,7 +177,6 @@
|
|
|
176
177
|
|
|
177
178
|
<LaresSheet :open="state.modelSheet" :title="t('model.menuAria')" @close="$emit('close-model')">
|
|
178
179
|
<p v-if="state.modelCatalogError" class="lares-shell__status" role="status">{{ state.modelCatalogError }}</p>
|
|
179
|
-
<button type="button" @click="state.refreshModels()">{{ t('agent.refresh') }}</button>
|
|
180
180
|
<p v-if="state.modelError" class="lares-shell__status">{{ state.modelError }}</p>
|
|
181
181
|
<p v-else-if="!state.models" class="lares-shell__hint">{{ t("agent.loading") }}</p>
|
|
182
182
|
<p v-else-if="!state.modelGroups.length" class="lares-shell__hint">{{ t("model.empty") }}</p>
|
|
@@ -202,6 +202,9 @@
|
|
|
202
202
|
@click="$emit('choose-effort', row.id)"
|
|
203
203
|
/>
|
|
204
204
|
</template>
|
|
205
|
+
<template #footer>
|
|
206
|
+
<button type="button" class="lares-shell__refresh" @click="state.refreshModels()">{{ t('agent.refresh') }}</button>
|
|
207
|
+
</template>
|
|
205
208
|
</LaresSheet>
|
|
206
209
|
<Transition name="lares-preview">
|
|
207
210
|
<LaresPreview
|
|
@@ -383,6 +386,28 @@ export default {
|
|
|
383
386
|
color: var(--q-blue-default);
|
|
384
387
|
}
|
|
385
388
|
|
|
389
|
+
.lares-shell__refresh {
|
|
390
|
+
display: flex;
|
|
391
|
+
align-items: center;
|
|
392
|
+
justify-content: center;
|
|
393
|
+
width: 100%;
|
|
394
|
+
height: 48px;
|
|
395
|
+
margin: 0;
|
|
396
|
+
border: 0;
|
|
397
|
+
border-radius: 0;
|
|
398
|
+
padding: 0;
|
|
399
|
+
background: var(--q-yellow-default);
|
|
400
|
+
color: var(--q-ink-1);
|
|
401
|
+
font: inherit;
|
|
402
|
+
font-size: 16px;
|
|
403
|
+
font-weight: 500;
|
|
404
|
+
text-align: center;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.lares-shell__refresh:active {
|
|
408
|
+
background: var(--q-yellow-pressed);
|
|
409
|
+
}
|
|
410
|
+
|
|
386
411
|
.lares-shell__heading {
|
|
387
412
|
margin: 0;
|
|
388
413
|
padding: 12px 16px 4px;
|
package/src/motion.css
CHANGED
|
@@ -101,6 +101,20 @@
|
|
|
101
101
|
.lares-panel-leave-to {
|
|
102
102
|
opacity: 0;
|
|
103
103
|
transform: translateY(8px);
|
|
104
|
+
pointer-events: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* A panel that is still arriving or leaving is not a click target. */
|
|
108
|
+
.lares-panel-leave-active {
|
|
109
|
+
pointer-events: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@keyframes lares-queue-in {
|
|
113
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.lares-queue-in {
|
|
117
|
+
animation: lares-queue-in var(--lares-duration-normal, 220ms) var(--lares-ease-out, cubic-bezier(0.32, 0.72, 0, 1));
|
|
104
118
|
}
|
|
105
119
|
|
|
106
120
|
/* Preview tab strip — slide in from the header edge. */
|
|
@@ -151,6 +165,8 @@
|
|
|
151
165
|
transition: none;
|
|
152
166
|
}
|
|
153
167
|
|
|
168
|
+
.lares-queue-in { animation: none; }
|
|
169
|
+
|
|
154
170
|
.lares-preview-enter-from,
|
|
155
171
|
.lares-preview-leave-to,
|
|
156
172
|
.lares-view-enter-from,
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
</select>
|
|
75
75
|
</label>
|
|
76
76
|
<p v-if="voiceError" class="lares-agent__hint" data-status="error">{{ voiceError }}</p>
|
|
77
|
+
<VoiceKeys :key="`keys-${hostKey}`" :client="voiceKeys" :t="tVoice" />
|
|
77
78
|
</section>
|
|
78
79
|
<section class="lares-agent__group">
|
|
79
80
|
<h2>{{ tSearch("settings.title") }}</h2>
|
|
@@ -138,6 +139,7 @@ import { adoptHost } from "../runtime.js";
|
|
|
138
139
|
import { createT } from "../i18n.js";
|
|
139
140
|
import { chatDevice, DEVICE_DESKTOP } from "../layout.js";
|
|
140
141
|
import VoicePreferences from "./VoicePreferences.vue";
|
|
142
|
+
import VoiceKeys from "./VoiceKeys.vue";
|
|
141
143
|
import LaresSettingRow from "./SettingRow.vue";
|
|
142
144
|
import LaresSheet from "./Sheet.vue";
|
|
143
145
|
import LaresSelect from "./Select.vue";
|
|
@@ -154,7 +156,7 @@ function itemLabel(items, id) {
|
|
|
154
156
|
|
|
155
157
|
export default {
|
|
156
158
|
name: "LaresAgentSettings",
|
|
157
|
-
components: { VoicePreferences, LaresSettingRow, LaresSheet, LaresSelect },
|
|
159
|
+
components: { VoicePreferences, VoiceKeys, LaresSettingRow, LaresSheet, LaresSelect },
|
|
158
160
|
expose: ["reload", "beforeLeave"],
|
|
159
161
|
props: {
|
|
160
162
|
locale: { type: String, default: "en" },
|
|
@@ -205,6 +207,7 @@ export default {
|
|
|
205
207
|
return hostSessionKey(this.ports);
|
|
206
208
|
},
|
|
207
209
|
ttsFetch() { return createHostClient(this.ports).ttsFetch; },
|
|
210
|
+
voiceKeys() { return createHostClient(this.ports).voiceKeys; },
|
|
208
211
|
settings() {
|
|
209
212
|
return createHostClient(this.ports).settings;
|
|
210
213
|
},
|
package/src/settings/Sheet.vue
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
<div class="lares-sheet__body">
|
|
20
20
|
<slot />
|
|
21
21
|
</div>
|
|
22
|
+
<footer v-if="$slots.footer" class="lares-sheet__foot">
|
|
23
|
+
<slot name="footer" />
|
|
24
|
+
</footer>
|
|
22
25
|
</div>
|
|
23
26
|
</div>
|
|
24
27
|
</Teleport>
|
|
@@ -84,6 +87,11 @@ export default {
|
|
|
84
87
|
transform: translateY(0);
|
|
85
88
|
}
|
|
86
89
|
|
|
90
|
+
.lares-sheet:has(.lares-sheet__foot) .lares-sheet__panel {
|
|
91
|
+
height: 45%;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
}
|
|
94
|
+
|
|
87
95
|
.lares-sheet__head {
|
|
88
96
|
display: flex;
|
|
89
97
|
align-items: center;
|
|
@@ -129,6 +137,17 @@ export default {
|
|
|
129
137
|
-webkit-overflow-scrolling: touch;
|
|
130
138
|
}
|
|
131
139
|
|
|
140
|
+
.lares-sheet:has(.lares-sheet__foot) .lares-sheet__body {
|
|
141
|
+
flex: 1 1 auto;
|
|
142
|
+
padding-bottom: 8px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.lares-sheet__foot {
|
|
146
|
+
flex-shrink: 0;
|
|
147
|
+
padding: 0 0 env(safe-area-inset-bottom, 0px);
|
|
148
|
+
background: var(--q-yellow-default);
|
|
149
|
+
}
|
|
150
|
+
|
|
132
151
|
@media (prefers-reduced-motion: reduce) {
|
|
133
152
|
.lares-sheet__backdrop,
|
|
134
153
|
.lares-sheet__panel {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="lares-voice-keys">
|
|
3
|
+
<h3>{{ t('settings.group.keys') }}</h3>
|
|
4
|
+
<p class="lares-agent__hint">{{ t('settings.keys.hint') }}</p>
|
|
5
|
+
<ul v-if="keys && keys.length" class="lares-voice-keys__list">
|
|
6
|
+
<li v-for="key in keys" :key="key.id">
|
|
7
|
+
<span>{{ key.name }}<small>{{ key.models.join(', ') }}</small></span>
|
|
8
|
+
<button type="button" :disabled="busy" @click="revoking = key">{{ t('settings.keys.revoke') }}</button>
|
|
9
|
+
</li>
|
|
10
|
+
</ul>
|
|
11
|
+
<p v-else-if="keys" class="lares-agent__hint">{{ t('settings.keys.empty') }}</p>
|
|
12
|
+
<label>{{ t('settings.keys.name') }}
|
|
13
|
+
<input v-model="name" type="text" maxlength="64" :placeholder="t('settings.keys.namePlaceholder')" :disabled="busy" />
|
|
14
|
+
</label>
|
|
15
|
+
<fieldset :disabled="busy">
|
|
16
|
+
<legend>{{ t('settings.keys.models') }}</legend>
|
|
17
|
+
<label v-for="model in allModels" :key="model"><input type="checkbox" :checked="models.includes(model)" @change="toggle(model)" />{{ model }}</label>
|
|
18
|
+
</fieldset>
|
|
19
|
+
<p v-if="agentSelected" role="note" data-status="error">{{ t('settings.keys.agentWarning') }}</p>
|
|
20
|
+
<button type="button" :disabled="busy || !name.trim() || !models.length" @click="issue">{{ t(busy ? 'settings.keys.issuing' : 'settings.keys.issue') }}</button>
|
|
21
|
+
<p v-if="error" role="alert" data-status="error">{{ error }}</p>
|
|
22
|
+
<div v-if="issued" class="lares-voice-keys__issued" role="dialog">
|
|
23
|
+
<p>{{ t('settings.keys.issued') }}</p>
|
|
24
|
+
<code>{{ issued.key }}</code>
|
|
25
|
+
<button type="button" @click="copy">{{ t(copied ? 'settings.keys.copied' : 'settings.keys.copy') }}</button>
|
|
26
|
+
<button type="button" @click="issued = null">{{ t('settings.keys.done') }}</button>
|
|
27
|
+
</div>
|
|
28
|
+
<div v-if="revoking" class="lares-voice-keys__confirm" role="dialog">
|
|
29
|
+
<p>{{ t('settings.keys.revokeConfirm', { name: revoking.name }) }}</p>
|
|
30
|
+
<button type="button" :disabled="busy" @click="revoke">{{ t('settings.keys.revoke') }}</button>
|
|
31
|
+
<button type="button" @click="revoking = null">{{ t('settings.keys.done') }}</button>
|
|
32
|
+
</div>
|
|
33
|
+
</section>
|
|
34
|
+
</template>
|
|
35
|
+
<script>
|
|
36
|
+
import { markRaw } from "vue";
|
|
37
|
+
import { messageFromCode } from "@olares/lares-core/i18n/t";
|
|
38
|
+
import { VOICE_KEY_AGENT_MODELS, VOICE_KEY_DEFAULT_MODELS, VOICE_KEY_MODELS } from "@olares/lares-core/voice/keys-client";
|
|
39
|
+
export default {
|
|
40
|
+
props: { client: { type: Object, required: true }, t: { type: Function, required: true } },
|
|
41
|
+
data() {
|
|
42
|
+
return { keys: null, name: "", models: [...VOICE_KEY_DEFAULT_MODELS], issued: null, copied: false, revoking: null, busy: false, error: "", allModels: markRaw([...VOICE_KEY_MODELS]) };
|
|
43
|
+
},
|
|
44
|
+
computed: { agentSelected() { return this.models.some((model) => VOICE_KEY_AGENT_MODELS.includes(model)); } },
|
|
45
|
+
mounted() { void this.load(); },
|
|
46
|
+
methods: {
|
|
47
|
+
fail(err) { this.error = messageFromCode(this.t, err?.message || "", "error.voice_key_failed"); },
|
|
48
|
+
async load() { try { this.keys = await this.client.list(); } catch (err) { this.fail(err); } },
|
|
49
|
+
toggle(model) { this.models = this.models.includes(model) ? this.models.filter((value) => value !== model) : VOICE_KEY_MODELS.filter((value) => value === model || this.models.includes(value)); },
|
|
50
|
+
async issue() {
|
|
51
|
+
this.busy = true; this.error = "";
|
|
52
|
+
try { this.issued = await this.client.issue({ name: this.name, models: this.models }); this.copied = false; this.name = ""; this.models = [...VOICE_KEY_DEFAULT_MODELS]; await this.load(); }
|
|
53
|
+
catch (err) { this.fail(err); }
|
|
54
|
+
finally { this.busy = false; }
|
|
55
|
+
},
|
|
56
|
+
async revoke() {
|
|
57
|
+
this.busy = true; this.error = "";
|
|
58
|
+
try { await this.client.revoke(this.revoking.id); this.revoking = null; await this.load(); }
|
|
59
|
+
catch (err) { this.fail(err); }
|
|
60
|
+
finally { this.busy = false; }
|
|
61
|
+
},
|
|
62
|
+
async copy() { try { await navigator.clipboard.writeText(this.issued.key); this.copied = true; } catch (err) { this.fail(err); } },
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
</script>
|
|
66
|
+
<style scoped>
|
|
67
|
+
.lares-voice-keys { display: flex; flex-direction: column; gap: 8px; }
|
|
68
|
+
.lares-voice-keys__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
69
|
+
.lares-voice-keys__list li { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
70
|
+
.lares-voice-keys__list small { display: block; opacity: 0.7; }
|
|
71
|
+
.lares-voice-keys fieldset { display: flex; flex-wrap: wrap; gap: 12px; border: 0; margin: 0; padding: 0; }
|
|
72
|
+
.lares-voice-keys code { overflow-wrap: anywhere; user-select: all; }
|
|
73
|
+
.lares-voice-keys [data-status="error"] { color: var(--lares-danger, #d4380d); }
|
|
74
|
+
</style>
|