@olares/lares-mobile 0.15.17 → 0.15.19

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.
@@ -42,7 +42,7 @@
42
42
  <LaresPcPopover
43
43
  v-model="modelOpen"
44
44
  class="desktop-composer__model-slot"
45
- :width="260"
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
- <p v-if="state.modelError" class="desktop-composer__menu-status">{{ state.modelError }}</p>
64
- <p v-else-if="!state.models" class="desktop-composer__menu-status">{{ t("agent.loading") }}</p>
65
- <template v-for="group in state.modelGroups" :key="group.provider">
66
- <p class="desktop-composer__menu-label">{{ group.name || group.provider }}</p>
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="model in group.models"
69
- :key="modelKey(model)"
107
+ v-for="row in state.effortRows"
108
+ :key="row.key"
70
109
  type="button"
71
110
  class="desktop-composer__option"
72
- :data-selected="modelKey(model) === state.modelCurrent"
73
- @click="chooseModel(model)"
111
+ :data-selected="row.id === state.effortId"
112
+ @click="chooseEffort(row.id)"
74
113
  >
75
- <span>{{ model.name }}</span>
76
- <CheckIcon v-if="modelKey(model) === state.modelCurrent" />
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.effortOpen = false;
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-slot .desktop-composer__model span { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
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-composer__send-slot { margin-left:auto; }
915
- .desktop-composer__meter + .desktop-composer__send-slot { margin-left:0; }
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(`${VOICE_API}/realtime`), pageOrigin()), ["lares-voice", `lares-ticket.${ticket}`, ...(Array.isArray(auth) ? auth : auth ? [auth] : [])]);
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.title')"
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
- <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>
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.title");
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.settings.saveVoice({ [key]: value });
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>