@olares/lares-mobile 0.15.24 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olares/lares-mobile",
3
- "version": "0.15.24",
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.24",
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") }),
@@ -1584,10 +1595,12 @@ export default {
1584
1595
  async updateQueue(itemId, action) {
1585
1596
  if (!itemId || this.queueBusy) return;
1586
1597
  this.queueBusy = itemId;
1598
+ this.actionNotice = "";
1587
1599
  try {
1588
1600
  const changed = await this.runtime.updateQueue(itemId, action);
1589
1601
  if (!changed.ok) {
1590
- this.failed = changed.error?.message || changed.error?.code || "queue";
1602
+ const detail = changed.error?.message || changed.error?.code || "queue";
1603
+ this.actionNotice = this.t("chat.failed", { error: detail });
1591
1604
  }
1592
1605
  } finally {
1593
1606
  this.queueBusy = "";
@@ -163,6 +163,10 @@ export default {
163
163
  line-height: 20px;
164
164
  }
165
165
 
166
+ .lares-bar__segment:only-child {
167
+ max-width: none;
168
+ }
169
+
166
170
  .lares-bar__segment + .lares-bar__segment::before {
167
171
  content: "";
168
172
  width: 1px;
@@ -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" v-html="renderMarkdown(row.text)" />
85
- <p v-if="row.voiceSummary" class="lares-voice-summary">{{ row.voiceSummary }}</p>
86
- <button v-if="!readout && isSpeaking(row)" type="button" class="lares-message-stop" @click="$emit('stop-speaking')">{{ t('input.stopSpeaking') }}</button>
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"
@@ -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
- <Transition name="lares-panel">
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
  },
@@ -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>