@olares/lares-mobile 0.15.25 → 0.15.26

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.25",
3
+ "version": "0.15.26",
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.25",
31
+ "@olares/lares-core": "0.15.26",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -580,6 +580,7 @@ export default {
580
580
  });
581
581
  this.voiceSettingsUnsub = subscribeVoiceSettings((value) => {
582
582
  this.voiceDefaultMode = value.config.mobileMode; this.voiceSettings = markRaw(value);
583
+ this.voiceStreaming = value.config.streaming === true;
583
584
  const next = value.config.speak === true;
584
585
  if (this.voiceSpeak && !next) this.reader?.stop();
585
586
  this.voiceSpeak = next;
@@ -730,7 +731,7 @@ export default {
730
731
  this.running = snap.running;
731
732
  this.runningSince = snap.runningSince || 0;
732
733
  if (this.voiceCall) this.reader.stop();
733
- const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, true) : null;
734
+ const target = this.voiceSpeak && !this.voiceCall && !document.hidden ? speakable(utterance, this.voiceStreaming) : null;
734
735
  if (target) this.reader.follow({ ...target, sessionId: snap.sessionId, source: { sessionId: snap.sessionId, turn: target.turn, step: target.step, attemptId: target.attemptId }, key: `${snap.sessionId}:${target.key}` });
735
736
  this.failed = snap.failed;
736
737
  this.error = snap.error;
@@ -1351,7 +1352,7 @@ export default {
1351
1352
  onTranscript: continuous ? null : onTranscript,
1352
1353
  mode: () => resolveSubmitMode(this.running, false, true, this.busyEnter),
1353
1354
  playback: { get canPause() { return reader.canPause; }, get key() { return reader.key; }, pause: () => reader.pause(), resume: () => reader.resume(), stop: () => { reader.stop(); this.player.stop(); } },
1354
- onReply: (reply) => { const value = speakable(reply, device === "device"); if (value) reader.follow(value); },
1355
+ onReply: (reply) => { const value = speakable(reply, config.streaming === true); if (value) reader.follow(value); },
1355
1356
  });
1356
1357
  return conversation;
1357
1358
  },
@@ -388,6 +388,7 @@ export default {
388
388
 
389
389
  .lares-shell__refresh {
390
390
  display: flex;
391
+ flex: none;
391
392
  align-items: center;
392
393
  justify-content: center;
393
394
  width: 100%;
@@ -44,35 +44,105 @@
44
44
  <section class="lares-agent__group">
45
45
  <h2>{{ tVoice("settings.title") }}</h2>
46
46
  <div class="lares-agent__list">
47
- <LaresSettingRow
48
- :label="tVoice('settings.stt.model')"
49
- :value="voiceModelLabel"
50
- chevron
51
- boxed
52
- :disabled="Boolean(pending)"
53
- @click="openSheet('voiceModel')"
54
- />
55
- <LaresSettingRow
56
- :label="tVoice('settings.language.title')"
57
- :value="voiceLangLabel"
58
- chevron
59
- boxed
60
- :disabled="Boolean(pending)"
61
- @click="openSheet('voiceLang')"
62
- />
47
+ <template v-if="isDesktop">
48
+ <label class="lares-agent__field">
49
+ <span>{{ tVoice('settings.stt.model') }}</span>
50
+ <LaresSelect
51
+ :value="voiceModelId"
52
+ :sections="voiceModelSections"
53
+ :disabled="busy || !voice"
54
+ @select="chooseVoiceModel"
55
+ />
56
+ </label>
57
+ <label class="lares-agent__field">
58
+ <span>{{ tVoice('settings.language.title') }}</span>
59
+ <LaresSelect
60
+ :value="voiceLangId"
61
+ :sections="voiceLanguageSections"
62
+ :disabled="busy || !voice"
63
+ @select="chooseVoiceLanguage"
64
+ />
65
+ </label>
66
+ </template>
67
+ <template v-else>
68
+ <LaresSettingRow
69
+ :label="tVoice('settings.stt.model')"
70
+ :value="voiceModelLabel"
71
+ chevron
72
+ boxed
73
+ :disabled="Boolean(pending)"
74
+ @click="openSheet('voiceModel')"
75
+ />
76
+ <LaresSettingRow
77
+ :label="tVoice('settings.language.title')"
78
+ :value="voiceLangLabel"
79
+ chevron
80
+ boxed
81
+ :disabled="Boolean(pending)"
82
+ @click="openSheet('voiceLang')"
83
+ />
84
+ </template>
63
85
  </div>
64
86
  <VoicePreferences :key="hostKey" ref="voicePreferences" :settings="settings" :tts-fetch="ttsFetch" :snapshot="voice" :device="device" :t="tVoice" :bind-before-leave="bindBeforeLeave" @change="voice = $event" />
65
- <label class="lares-agent__hint">{{ tVoice('settings.mobileMode') }}
66
- <select :value="voice?.config?.mobileMode || 'continuous'" @change="patchVoice('mobileMode', $event.target.value)">
67
- <option value="single">{{ tVoice('settings.mode.single') }}</option>
68
- <option value="continuous">{{ tVoice('settings.mode.continuous') }}</option>
69
- </select>
70
- </label>
71
- <label class="lares-agent__hint">{{ tVoice('settings.device') }}
72
- <select :value="voice?.config?.device || 'auto'" @change="patchVoice('device', $event.target.value)">
73
- <option v-for="device in ['auto', 'device', 'headset']" :key="device" :value="device">{{ tVoice(`settings.device.${device}`) }}</option>
74
- </select>
75
- </label>
87
+ <h3>{{ tVoice("settings.conversation") }}</h3>
88
+ <div class="lares-agent__list">
89
+ <template v-if="isDesktop">
90
+ <label class="lares-agent__field">
91
+ <span>{{ tVoice('settings.mobileMode') }}</span>
92
+ <LaresSelect
93
+ :value="voice?.config?.mobileMode || 'continuous'"
94
+ :sections="mobileModeSections"
95
+ :disabled="busy || !voice"
96
+ @select="(item) => patchVoice('mobileMode', item.id)"
97
+ />
98
+ </label>
99
+ <label class="lares-agent__field">
100
+ <span>{{ tVoice('settings.device') }}</span>
101
+ <LaresSelect
102
+ :value="voice?.config?.device || 'auto'"
103
+ :sections="deviceSections"
104
+ :disabled="busy || !voice"
105
+ @select="(item) => patchVoice('device', item.id)"
106
+ />
107
+ </label>
108
+ <label class="lares-agent__field">
109
+ <span>{{ tVoice('settings.mode.title') }}</span>
110
+ <LaresSelect
111
+ :value="voice?.config?.streaming ? 'streaming' : 'turns'"
112
+ :sections="readingSections"
113
+ :disabled="busy || !voice || !reading"
114
+ @select="(item) => chooseReading(item.id)"
115
+ />
116
+ </label>
117
+ </template>
118
+ <template v-else>
119
+ <LaresSettingRow
120
+ :label="tVoice('settings.mobileMode')"
121
+ :value="voiceChoiceLabel('mobileMode')"
122
+ chevron
123
+ boxed
124
+ :disabled="Boolean(pending) || !voice"
125
+ @click="openSheet('voiceMobileMode')"
126
+ />
127
+ <LaresSettingRow
128
+ :label="tVoice('settings.device')"
129
+ :value="voiceChoiceLabel('device')"
130
+ chevron
131
+ boxed
132
+ :disabled="Boolean(pending) || !voice"
133
+ @click="openSheet('voiceDevice')"
134
+ />
135
+ <LaresSettingRow
136
+ :label="tVoice('settings.mode.title')"
137
+ :value="voiceChoiceLabel('reading')"
138
+ chevron
139
+ boxed
140
+ :disabled="Boolean(pending) || !voice || !reading"
141
+ @click="openSheet('voiceReading')"
142
+ />
143
+ </template>
144
+ </div>
145
+ <p class="lares-agent__hint">{{ readingHint }}</p>
76
146
  <p v-if="voiceError" class="lares-agent__hint" data-status="error">{{ voiceError }}</p>
77
147
  <VoiceKeys :key="`keys-${hostKey}`" :client="voiceKeys" :t="tVoice" />
78
148
  </section>
@@ -106,7 +176,7 @@
106
176
  :key="item.id"
107
177
  :label="item.label"
108
178
  :checked="item.id === sheetCurrent"
109
- :disabled="busy"
179
+ :disabled="busy || item.disabled"
110
180
  @click="pick(item)"
111
181
  />
112
182
  </template>
@@ -182,6 +252,7 @@ export default {
182
252
  voiceError: "",
183
253
  searchError: "",
184
254
  conversationError: "",
255
+ reading: null,
185
256
  };
186
257
  },
187
258
  computed: {
@@ -247,13 +318,31 @@ export default {
247
318
  }
248
319
  return this.models.default?.model || "—";
249
320
  },
321
+ voiceModelId() {
322
+ return voiceMenuValue(this.voice?.config?.model);
323
+ },
324
+ voiceLangId() {
325
+ return voiceMenuValue(this.voice?.config?.language);
326
+ },
327
+ voiceModelSections() {
328
+ return [{ items: this.voiceModels }];
329
+ },
330
+ voiceLanguageSections() {
331
+ return [{ items: this.voiceLanguages }];
332
+ },
250
333
  voiceModelLabel() {
251
334
  if (!this.voice) return "—";
252
- return itemLabel(this.voiceModels, voiceMenuValue(this.voice.config?.model));
335
+ return itemLabel(this.voiceModels, this.voiceModelId);
253
336
  },
254
337
  voiceLangLabel() {
255
338
  if (!this.voice) return "—";
256
- return itemLabel(this.voiceLanguages, voiceMenuValue(this.voice.config?.language));
339
+ return itemLabel(this.voiceLanguages, this.voiceLangId);
340
+ },
341
+ readingHint() {
342
+ if (this.reading && !this.reading.allowed) return this.tVoice("settings.mode.hint");
343
+ const type = this.reading?.gpu?.type;
344
+ if (type) return this.tVoice("settings.mode.gpu", { type: this.tVoice(`settings.gpu.${type}`) });
345
+ return this.tVoice("settings.mode.hint");
257
346
  },
258
347
  searchMenuId() {
259
348
  return searchMenuValue(this.search);
@@ -283,10 +372,27 @@ export default {
283
372
  ],
284
373
  }];
285
374
  },
375
+ mobileModeSections() {
376
+ return [{ items: ["single", "continuous"].map((id) => ({ id, label: this.tVoice(`settings.mode.${id}`) })) }];
377
+ },
378
+ deviceSections() {
379
+ return [{ items: ["auto", "device", "headset"].map((id) => ({ id, label: this.tVoice(`settings.device.${id}`) })) }];
380
+ },
381
+ readingSections() {
382
+ return [{
383
+ items: [
384
+ { id: "turns", label: this.tVoice("settings.mode.turns") },
385
+ { id: "streaming", label: this.tVoice("settings.mode.streaming"), disabled: !this.reading?.allowed },
386
+ ],
387
+ }];
388
+ },
286
389
  sheetTitle() {
287
390
  if (this.panel === "model") return this.tModel("settings.title");
288
391
  if (this.panel === "voiceModel") return this.tVoice("settings.stt.model");
289
392
  if (this.panel === "voiceLang") return this.tVoice("settings.language.title");
393
+ if (this.panel === "voiceMobileMode") return this.tVoice("settings.mobileMode");
394
+ if (this.panel === "voiceDevice") return this.tVoice("settings.device");
395
+ if (this.panel === "voiceReading") return this.tVoice("settings.mode.title");
290
396
  if (this.panel === "search") return this.tSearch("settings.default");
291
397
  return "";
292
398
  },
@@ -301,6 +407,9 @@ export default {
301
407
  if (this.panel === "voiceLang") {
302
408
  return [{ items: this.voiceLanguages.map((item) => ({ id: item.id, label: item.label })) }];
303
409
  }
410
+ if (this.panel === "voiceMobileMode") return this.mobileModeSections;
411
+ if (this.panel === "voiceDevice") return this.deviceSections;
412
+ if (this.panel === "voiceReading") return this.readingSections;
304
413
  if (this.panel === "search") return this.searchSelectSections;
305
414
  return [];
306
415
  },
@@ -360,7 +469,16 @@ export default {
360
469
  if (!ready) return;
361
470
  this.panel = panel;
362
471
  },
472
+ chooseVoiceModel(item) { return this.patchVoice("model", voiceValueFromMenu(item.id)); },
473
+ chooseVoiceLanguage(item) { return this.patchVoice("language", voiceValueFromMenu(item.id)); },
474
+ voiceChoiceLabel(kind) {
475
+ if (!this.voice) return "—";
476
+ if (kind === "mobileMode") return this.tVoice(`settings.mode.${this.voice.config?.mobileMode || "continuous"}`);
477
+ if (kind === "device") return this.tVoice(`settings.device.${this.voice.config?.device || "auto"}`);
478
+ return this.tVoice(`settings.mode.${this.voice.config?.streaming ? "streaming" : "turns"}`);
479
+ },
363
480
  async pick(item, panel = this.panel) {
481
+ if (item.disabled) return;
364
482
  if (item.id === this.sheetCurrentFor(panel)) {
365
483
  this.panel = "";
366
484
  return;
@@ -369,12 +487,18 @@ export default {
369
487
  if (panel === "model") await this.chooseModel(item.model);
370
488
  if (panel === "voiceModel") await this.patchVoice("model", voiceValueFromMenu(item.id));
371
489
  if (panel === "voiceLang") await this.patchVoice("language", voiceValueFromMenu(item.id));
490
+ if (panel === "voiceMobileMode") await this.patchVoice("mobileMode", item.id);
491
+ if (panel === "voiceDevice") await this.patchVoice("device", item.id);
492
+ if (panel === "voiceReading") this.chooseReading(item.id);
372
493
  if (panel === "search") await this.chooseSearch(item.id);
373
494
  },
374
495
  sheetCurrentFor(panel) {
375
496
  if (panel === "model") return this.modelCurrent;
376
497
  if (panel === "voiceModel") return voiceMenuValue(this.voice?.config?.model);
377
498
  if (panel === "voiceLang") return voiceMenuValue(this.voice?.config?.language);
499
+ if (panel === "voiceMobileMode") return this.voice?.config?.mobileMode || "continuous";
500
+ if (panel === "voiceDevice") return this.voice?.config?.device || "auto";
501
+ if (panel === "voiceReading") return this.voice?.config?.streaming ? "streaming" : "turns";
378
502
  if (panel === "search") return searchMenuValue(this.search);
379
503
  return "";
380
504
  },
@@ -390,7 +514,10 @@ export default {
390
514
  this.voice = remembered.voice ?? this.voice;
391
515
  this.search = remembered.search ?? this.search;
392
516
  this.conversation = remembered.conversation ?? this.conversation;
393
- if (this.models && this.voice && this.search && this.conversation) return;
517
+ if (this.models && this.voice && this.search && this.conversation) {
518
+ void this.loadReading();
519
+ return;
520
+ }
394
521
  }
395
522
  this.panel = "";
396
523
  this.loading = true;
@@ -413,6 +540,21 @@ export default {
413
540
  if (conversation.status === "fulfilled") this.conversation = conversation.value;
414
541
  else this.conversationError = conversation.reason instanceof Error ? conversation.reason.message : String(conversation.reason);
415
542
  this.loading = false;
543
+ void this.loadReading();
544
+ },
545
+ async loadReading() {
546
+ try {
547
+ this.reading = await this.settings.readingMode();
548
+ } catch {
549
+ this.reading = { streaming: false, allowed: false, gpu: null };
550
+ }
551
+ },
552
+ chooseReading(value) {
553
+ if (value === "streaming" && !this.reading?.allowed) {
554
+ this.voiceError = this.tVoice("error.voice_streaming_time_slice");
555
+ return;
556
+ }
557
+ void this.patchVoice("streaming", value === "streaming");
416
558
  },
417
559
  async chooseModel(model) {
418
560
  const selection = { provider: model.provider, model: model.id };
@@ -434,8 +576,10 @@ export default {
434
576
  this.voiceError = "";
435
577
  try {
436
578
  this.voice = await this.$refs.voicePreferences.patch({ [key]: value });
437
- } catch {
438
- this.voiceError = this.tVoice("settings.saveFailed");
579
+ } catch (err) {
580
+ const code = err?.code;
581
+ const text = code ? this.tVoice(`error.${code}`) : "";
582
+ this.voiceError = text && text !== `error.${code}` ? text : this.tVoice("settings.saveFailed");
439
583
  } finally {
440
584
  this.pending = "";
441
585
  }
@@ -547,22 +691,45 @@ export default {
547
691
  gap: 8px;
548
692
  }
549
693
 
550
- .lares-agent__group h2 {
694
+ .lares-agent__group h2,
695
+ .lares-agent__group h3 {
551
696
  margin: 0;
552
- font-size: 18px;
553
697
  font-weight: 500;
698
+ color: var(--q-ink-1);
699
+ }
700
+
701
+ .lares-agent__group h2 {
702
+ font-size: 18px;
554
703
  line-height: 24px;
555
704
  }
556
705
 
706
+ .lares-agent__group h3 {
707
+ margin-top: 8px;
708
+ color: var(--q-ink-3);
709
+ font-size: 14px;
710
+ line-height: 18px;
711
+ }
712
+
557
713
  .lares-agent__list {
558
714
  display: flex;
559
715
  flex-direction: column;
560
716
  gap: 12px;
561
717
  }
562
718
 
719
+ .lares-agent__field {
720
+ display: flex;
721
+ flex-direction: column;
722
+ gap: 8px;
723
+ min-width: 0;
724
+ color: var(--q-ink-1);
725
+ font-size: 15px;
726
+ line-height: 22px;
727
+ }
728
+
563
729
  .lares-agent__hint {
564
730
  margin: 0;
565
731
  font-size: 15px;
732
+ line-height: 22px;
566
733
  color: var(--q-ink-3);
567
734
  }
568
735
 
@@ -27,7 +27,7 @@
27
27
  class="lares-select__option"
28
28
  role="option"
29
29
  :aria-selected="item.id === value"
30
- :disabled="disabled"
30
+ :disabled="disabled || item.disabled"
31
31
  @click="choose(item)"
32
32
  >
33
33
  <span>{{ item.label }}</span>
@@ -75,6 +75,7 @@ export default {
75
75
  },
76
76
  methods: {
77
77
  choose(item) {
78
+ if (this.disabled || item.disabled) return;
78
79
  this.open = false;
79
80
  this.$emit("select", item);
80
81
  },
@@ -177,6 +178,11 @@ export default {
177
178
  background: var(--q-background-hover);
178
179
  }
179
180
 
181
+ .lares-select__option:disabled {
182
+ cursor: default;
183
+ opacity: 0.45;
184
+ }
185
+
180
186
  .lares-select__option[aria-selected="true"] span {
181
187
  color: var(--q-blue-default);
182
188
  }
@@ -145,7 +145,7 @@ export default {
145
145
  .lares-sheet__foot {
146
146
  flex-shrink: 0;
147
147
  padding: 0 0 env(safe-area-inset-bottom, 0px);
148
- background: var(--q-yellow-default);
148
+ background: var(--q-background-1);
149
149
  }
150
150
 
151
151
  @media (prefers-reduced-motion: reduce) {
@@ -1,32 +1,64 @@
1
1
  <template>
2
- <section class="lares-tts-settings">
2
+ <section class="lares-tts">
3
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>
4
+ <label v-if="isDesktop" class="lares-tts__field">
5
+ <span>{{ t('settings.tts.model') }}</span>
6
+ <LaresSelect :value="modelId" :sections="modelSections" :disabled="state.saving" @select="chooseModel" />
9
7
  </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>
8
+ <LaresSettingRow
9
+ v-else
10
+ :label="t('settings.tts.model')"
11
+ :value="modelLabel"
12
+ chevron
13
+ boxed
14
+ :disabled="state.saving"
15
+ @click="sheet = 'model'"
16
+ />
17
+ <p v-if="view.errors.ttsModel" class="lares-tts__alert" role="alert">{{ t('settings.saveFailed') }}</p>
18
+ <p v-if="!state.model" class="lares-tts__hint">{{ t('preferences.modelsMissing') }}</p>
19
+ <p v-else-if="state.loading" class="lares-tts__hint" role="status">{{ t('tts.loading') }}</p>
20
+ <div v-else-if="state.error" class="lares-tts__status" role="alert">
21
+ <p>{{ t('tts.loadFailed') }}</p>
22
+ <button type="button" @click="controller.load()">{{ t('dictation.retry') }}</button>
23
+ </div>
24
+ <template v-else-if="state.voices.length || state.unavailable">
25
+ <label v-if="isDesktop" class="lares-tts__field">
26
+ <span>{{ t('tts.voice') }}</span>
27
+ <LaresSelect :value="voiceId" :sections="voiceSections" :disabled="state.saving" @select="chooseVoice" />
28
+ </label>
29
+ <LaresSettingRow
30
+ v-else
31
+ :label="t('tts.voice')"
32
+ :value="voiceLabel"
33
+ chevron
34
+ boxed
35
+ :disabled="state.saving"
36
+ @click="sheet = 'voice'"
37
+ />
38
+ </template>
39
+ <p v-else class="lares-tts__hint">{{ t('tts.noPresets') }}</p>
40
+ <p v-if="view.errors.ttsVoiceByModel" class="lares-tts__alert" role="alert">{{ t('settings.saveFailed') }}</p>
41
+ <div class="lares-tts__action">
42
+ <p class="lares-tts__hint">{{ t(state.blocked ? 'tts.busy' : 'tts.previewHint') }}</p>
43
+ <button type="button" :disabled="previewDisabled" @click="controller.audition()">{{ t(state.playing ? 'tts.stopPreview' : 'tts.preview') }}</button>
26
44
  </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>
45
+ <p v-if="state.previewError" class="lares-tts__alert" role="alert">{{ t('tts.previewFailed') }}</p>
46
+ <label class="lares-tts__switch">
47
+ <span>{{ t('settings.speak.title') }}</span>
48
+ <input type="checkbox" role="switch" :checked="view.value.config.speak === true" :disabled="Boolean(view.pending.speak)" :aria-checked="view.value.config.speak === true ? 'true' : 'false'" @change="save(preferences.patch({ speak: $event.target.checked }))" />
49
+ </label>
50
+ <p class="lares-tts__hint">{{ t('settings.tts.hint') }}</p>
51
+ <p v-if="view.errors.speak" class="lares-tts__alert" role="alert">{{ t('settings.saveFailed') }}</p>
52
+ <LaresSheet v-if="!isDesktop" :open="Boolean(sheet)" :title="sheetTitle" @close="sheet = ''">
53
+ <LaresSettingRow
54
+ v-for="item in sheetItems"
55
+ :key="item.id || 'auto'"
56
+ :label="item.label"
57
+ :checked="item.id === sheetCurrent"
58
+ :disabled="state.saving || item.disabled"
59
+ @click="chooseSheet(item)"
60
+ />
61
+ </LaresSheet>
30
62
  </section>
31
63
  </template>
32
64
  <script>
@@ -34,27 +66,216 @@ import { markRaw } from "vue";
34
66
  import { createTtsSettings } from "@olares/lares-core/voice/tts-settings";
35
67
  import { fetchTtsVoices } from "@olares/lares-core/voice/tts-voice";
36
68
  import { previewTts } from "@olares/lares-core/voice/tts-preview";
69
+ import LaresSettingRow from "./SettingRow.vue";
70
+ import LaresSelect from "./Select.vue";
71
+ import LaresSheet from "./Sheet.vue";
37
72
  export default {
38
- props: { preferences: { type: Object, required: true }, view: { type: Object, required: true }, t: { type: Function, required: true }, fetcher: { type: Function, required: true } },
73
+ components: { LaresSettingRow, LaresSelect, LaresSheet },
74
+ props: { preferences: { type: Object, required: true }, view: { type: Object, required: true }, t: { type: Function, required: true }, fetcher: { type: Function, required: true }, device: { type: String, default: "mobile" } },
39
75
  data() {
40
76
  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() };
77
+ return { controller, state: controller.getSnapshot(), sheet: "" };
78
+ },
79
+ computed: {
80
+ isDesktop() { return this.device === "desktop"; },
81
+ models() { return [...new Set([this.view.value.config.ttsModel, ...(this.view.value.ttsModels || [])].filter(Boolean))]; },
82
+ modelId() { return this.view.value.config.ttsModel || ""; },
83
+ modelLabel() { return this.modelId ? this.modelId : this.t("settings.model.auto"); },
84
+ modelSections() { return [{ items: [{ id: "", label: this.t("settings.model.auto") }, ...this.models.map(id => ({ id, label: id }))] }]; },
85
+ voiceId() { return this.state.selected || ""; },
86
+ voiceItems() {
87
+ return [
88
+ { id: "", label: this.t("tts.default") },
89
+ ...(this.state.unavailable && this.state.selected ? [{ id: this.state.selected, label: `${this.state.selected} · ${this.t("preferences.unavailable")}`, disabled: true }] : []),
90
+ ...this.state.voices.map(voice => ({ id: voice.id, label: voice.label })),
91
+ ];
92
+ },
93
+ voiceSections() { return [{ items: this.voiceItems }]; },
94
+ voiceLabel() { return this.voiceItems.find(item => item.id === this.voiceId)?.label || this.t("tts.default"); },
95
+ previewDisabled() { return !this.state.model || this.state.loading || Boolean(this.state.error) || this.state.saving || this.state.unavailable || this.state.blocked; },
96
+ sheetTitle() { return this.t(this.sheet === "voice" ? "tts.voice" : "settings.tts.model"); },
97
+ sheetItems() { return this.sheet === "voice" ? this.voiceItems : this.modelSections[0].items; },
98
+ sheetCurrent() { return this.sheet === "voice" ? this.voiceId : this.modelId; },
42
99
  },
43
- computed: { models() { return [...new Set([this.view.value.config.ttsModel, ...(this.view.value.ttsModels || [])].filter(Boolean))]; } },
44
100
  mounted() { this.off = this.controller.subscribe(() => { this.state = this.controller.getSnapshot(); }); void this.controller.load(); },
45
101
  beforeUnmount() { this.off?.(); this.controller.dispose(); },
46
- methods: { save(operation) { void operation.catch(() => {}); } },
102
+ methods: {
103
+ save(operation) { void operation.catch(() => {}); },
104
+ chooseModel(item) { this.save(this.controller.selectModel(item.id)); },
105
+ chooseVoice(item) { if (!item.disabled) this.save(this.controller.selectVoice(item.id)); },
106
+ chooseSheet(item) {
107
+ if (item.disabled || this.state.saving) return;
108
+ const sheet = this.sheet;
109
+ this.sheet = "";
110
+ if (item.id === (sheet === "voice" ? this.voiceId : this.modelId)) return;
111
+ if (sheet === "voice") this.chooseVoice(item);
112
+ else this.chooseModel(item);
113
+ },
114
+ },
47
115
  };
48
116
  </script>
49
117
  <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; }
118
+ .lares-tts {
119
+ display: flex;
120
+ flex-direction: column;
121
+ gap: 12px;
122
+ min-width: 0;
123
+ color: var(--q-ink-1);
124
+ font-size: 15px;
125
+ line-height: 22px;
126
+ }
127
+
128
+ .lares-tts h3 {
129
+ margin: 4px 0 0;
130
+ color: var(--q-ink-3);
131
+ font-size: 14px;
132
+ font-weight: 500;
133
+ line-height: 18px;
134
+ }
135
+
136
+ .lares-tts__field {
137
+ display: flex;
138
+ flex-direction: column;
139
+ gap: 8px;
140
+ min-width: 0;
141
+ }
142
+
143
+ .lares-tts__hint {
144
+ margin: 0;
145
+ color: var(--q-ink-3);
146
+ font-size: 15px;
147
+ line-height: 22px;
148
+ }
149
+
150
+ .lares-tts__alert {
151
+ margin: 0;
152
+ color: var(--q-orange-default);
153
+ font-size: 15px;
154
+ line-height: 22px;
155
+ }
156
+
157
+ .lares-tts__status {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: 12px;
161
+ min-height: 44px;
162
+ }
163
+
164
+ .lares-tts__status p {
165
+ flex: 1;
166
+ min-width: 0;
167
+ margin: 0;
168
+ color: var(--q-orange-default);
169
+ font-size: 15px;
170
+ line-height: 22px;
171
+ }
172
+
173
+ .lares-tts__status > button,
174
+ .lares-tts__action > button {
175
+ flex: none;
176
+ min-height: 36px;
177
+ border: 0;
178
+ border-radius: 12px;
179
+ padding: 0 14px;
180
+ background: var(--q-blue-alpha);
181
+ color: var(--q-blue-default);
182
+ font: inherit;
183
+ font-size: 15px;
184
+ font-weight: 500;
185
+ line-height: 20px;
186
+ cursor: pointer;
187
+ }
188
+
189
+ .lares-tts__status > button:disabled,
190
+ .lares-tts__action > button:disabled {
191
+ cursor: default;
192
+ opacity: 0.45;
193
+ }
194
+
195
+ .lares-tts__status > button:focus-visible,
196
+ .lares-tts__action > button:focus-visible {
197
+ outline: 2px solid var(--q-blue-default);
198
+ outline-offset: 2px;
199
+ }
200
+
201
+ .lares-tts__action,
202
+ .lares-tts__switch {
203
+ display: flex;
204
+ align-items: center;
205
+ justify-content: space-between;
206
+ gap: 12px;
207
+ min-height: 64px;
208
+ box-sizing: border-box;
209
+ border: 1px solid var(--q-separator);
210
+ border-radius: 20px;
211
+ padding: 12px 16px;
212
+ }
213
+
214
+ .lares-tts__action .lares-tts__hint {
215
+ flex: 1;
216
+ min-width: 0;
217
+ font-size: 15px;
218
+ line-height: 22px;
219
+ }
220
+
221
+ .lares-tts__switch > span {
222
+ flex: 1;
223
+ min-width: 0;
224
+ font-size: 16px;
225
+ line-height: 22px;
226
+ }
227
+
228
+ .lares-tts__switch {
229
+ cursor: pointer;
230
+ }
231
+
232
+ .lares-tts__switch input {
233
+ position: relative;
234
+ flex: none;
235
+ width: 44px;
236
+ height: 26px;
237
+ margin: 0;
238
+ border: 0;
239
+ border-radius: 999px;
240
+ background: var(--q-background-3);
241
+ appearance: none;
242
+ cursor: pointer;
243
+ }
244
+
245
+ .lares-tts__switch input::after {
246
+ content: "";
247
+ position: absolute;
248
+ top: 2px;
249
+ left: 2px;
250
+ width: 22px;
251
+ height: 22px;
252
+ border-radius: 50%;
253
+ background: var(--q-ink-1);
254
+ transition: transform var(--lares-duration-fast, 160ms) var(--lares-ease-out, ease);
255
+ }
256
+
257
+ .lares-tts__switch input:checked {
258
+ background: var(--q-blue-default);
259
+ }
260
+
261
+ .lares-tts__switch input:checked::after {
262
+ background: var(--q-ink-on-brand);
263
+ transform: translateX(18px);
264
+ }
265
+
266
+ .lares-tts__switch input:disabled {
267
+ cursor: default;
268
+ opacity: 0.45;
269
+ }
270
+
271
+ .lares-tts__switch input:focus-visible {
272
+ outline: 2px solid var(--q-blue-default);
273
+ outline-offset: 2px;
274
+ }
275
+
276
+ @media (prefers-reduced-motion: reduce) {
277
+ .lares-tts__switch input::after {
278
+ transition: none;
279
+ }
280
+ }
60
281
  </style>
@@ -1,34 +1,42 @@
1
1
  <template>
2
2
  <section class="lares-voice-keys">
3
3
  <h3>{{ t('settings.group.keys') }}</h3>
4
- <p class="lares-agent__hint">{{ t('settings.keys.hint') }}</p>
4
+ <p>{{ t('settings.keys.hint') }}</p>
5
5
  <ul v-if="keys && keys.length" class="lares-voice-keys__list">
6
6
  <li v-for="key in keys" :key="key.id">
7
- <span>{{ key.name }}<small>{{ key.models.join(', ') }}</small></span>
7
+ <span class="lares-voice-keys__name">{{ key.name }}<small>{{ key.models.join(', ') }}</small></span>
8
8
  <button type="button" :disabled="busy" @click="revoking = key">{{ t('settings.keys.revoke') }}</button>
9
9
  </li>
10
10
  </ul>
11
- <p v-else-if="keys" class="lares-agent__hint">{{ t('settings.keys.empty') }}</p>
12
- <label>{{ t('settings.keys.name') }}
11
+ <p v-else-if="keys">{{ t('settings.keys.empty') }}</p>
12
+ <label class="lares-voice-keys__field">
13
+ <span>{{ t('settings.keys.name') }}</span>
13
14
  <input v-model="name" type="text" maxlength="64" :placeholder="t('settings.keys.namePlaceholder')" :disabled="busy" />
14
15
  </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">
16
+ <div class="lares-voice-keys__models" role="group" :aria-label="t('settings.keys.models')">
17
+ <p>{{ t('settings.keys.models') }}</p>
18
+ <label v-for="model in allModels" :key="model">
19
+ <input type="checkbox" :checked="models.includes(model)" :disabled="busy" @change="toggle(model)" />
20
+ <span>{{ model }}</span>
21
+ </label>
22
+ </div>
23
+ <p v-if="agentSelected" role="note">{{ t('settings.keys.agentWarning') }}</p>
24
+ <button type="button" class="lares-voice-keys__issue" :disabled="busy || !name.trim() || !models.length" @click="issue">{{ t(busy ? 'settings.keys.issuing' : 'settings.keys.issue') }}</button>
25
+ <p v-if="error" role="alert">{{ error }}</p>
26
+ <div v-if="issued" class="lares-voice-keys__dialog" role="dialog">
23
27
  <p>{{ t('settings.keys.issued') }}</p>
24
28
  <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>
29
+ <div class="lares-voice-keys__actions">
30
+ <button type="button" @click="copy">{{ t(copied ? 'settings.keys.copied' : 'settings.keys.copy') }}</button>
31
+ <button type="button" class="is-primary" @click="issued = null">{{ t('settings.keys.done') }}</button>
32
+ </div>
27
33
  </div>
28
- <div v-if="revoking" class="lares-voice-keys__confirm" role="dialog">
34
+ <div v-if="revoking" class="lares-voice-keys__dialog" role="dialog">
29
35
  <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>
36
+ <div class="lares-voice-keys__actions">
37
+ <button type="button" :disabled="busy" @click="revoke">{{ t('settings.keys.revoke') }}</button>
38
+ <button type="button" @click="revoking = null">{{ t('settings.keys.done') }}</button>
39
+ </div>
32
40
  </div>
33
41
  </section>
34
42
  </template>
@@ -64,11 +72,196 @@ export default {
64
72
  };
65
73
  </script>
66
74
  <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); }
75
+ .lares-voice-keys {
76
+ display: flex;
77
+ flex-direction: column;
78
+ gap: 12px;
79
+ min-width: 0;
80
+ margin-top: 8px;
81
+ color: var(--q-ink-1);
82
+ font-size: 15px;
83
+ line-height: 22px;
84
+ }
85
+
86
+ .lares-voice-keys h3 {
87
+ margin: 4px 0 0;
88
+ color: var(--q-ink-3);
89
+ font-size: 14px;
90
+ font-weight: 500;
91
+ line-height: 18px;
92
+ }
93
+
94
+ .lares-voice-keys p {
95
+ margin: 0;
96
+ color: var(--q-ink-3);
97
+ font-size: 15px;
98
+ line-height: 22px;
99
+ }
100
+
101
+ .lares-voice-keys [role="alert"],
102
+ .lares-voice-keys [role="note"] {
103
+ color: var(--q-orange-default);
104
+ }
105
+
106
+ .lares-voice-keys__list {
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 12px;
110
+ margin: 0;
111
+ padding: 0;
112
+ list-style: none;
113
+ }
114
+
115
+ .lares-voice-keys__list li,
116
+ .lares-voice-keys__dialog {
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: space-between;
120
+ gap: 12px;
121
+ min-height: 64px;
122
+ box-sizing: border-box;
123
+ border: 1px solid var(--q-separator);
124
+ border-radius: 20px;
125
+ padding: 12px 16px;
126
+ }
127
+
128
+ .lares-voice-keys__name {
129
+ min-width: 0;
130
+ flex: 1;
131
+ font-size: 16px;
132
+ line-height: 22px;
133
+ }
134
+
135
+ .lares-voice-keys__name small {
136
+ display: block;
137
+ overflow: hidden;
138
+ color: var(--q-ink-3);
139
+ font-size: 13px;
140
+ line-height: 18px;
141
+ text-overflow: ellipsis;
142
+ }
143
+
144
+ .lares-voice-keys__field {
145
+ display: flex;
146
+ flex-direction: column;
147
+ gap: 8px;
148
+ }
149
+
150
+ .lares-voice-keys__field span,
151
+ .lares-voice-keys__models > p {
152
+ color: var(--q-ink-2);
153
+ }
154
+
155
+ .lares-voice-keys input[type="text"] {
156
+ box-sizing: border-box;
157
+ width: 100%;
158
+ min-height: 48px;
159
+ border: 1px solid var(--q-separator);
160
+ border-radius: 16px;
161
+ padding: 0 16px;
162
+ background: transparent;
163
+ color: inherit;
164
+ font: inherit;
165
+ font-size: 16px;
166
+ }
167
+
168
+ .lares-voice-keys input[type="text"]:focus-visible {
169
+ outline: 2px solid var(--q-blue-default);
170
+ outline-offset: 2px;
171
+ }
172
+
173
+ .lares-voice-keys__models {
174
+ overflow: hidden;
175
+ border: 1px solid var(--q-separator);
176
+ border-radius: 20px;
177
+ }
178
+
179
+ .lares-voice-keys__models > p {
180
+ margin: 0;
181
+ padding: 12px 16px 4px;
182
+ font-size: 14px;
183
+ font-weight: 500;
184
+ line-height: 18px;
185
+ }
186
+
187
+ .lares-voice-keys__models label {
188
+ display: flex;
189
+ align-items: center;
190
+ gap: 12px;
191
+ min-height: 48px;
192
+ padding: 0 16px;
193
+ border-top: 1px solid var(--q-separator);
194
+ font-size: 16px;
195
+ line-height: 22px;
196
+ }
197
+
198
+ .lares-voice-keys__models input {
199
+ width: 20px;
200
+ height: 20px;
201
+ margin: 0;
202
+ accent-color: var(--q-blue-default);
203
+ }
204
+
205
+ .lares-voice-keys button {
206
+ flex: none;
207
+ min-height: 36px;
208
+ border: 1px solid var(--q-separator);
209
+ border-radius: 12px;
210
+ padding: 0 14px;
211
+ background: transparent;
212
+ color: inherit;
213
+ font: inherit;
214
+ font-size: 15px;
215
+ cursor: pointer;
216
+ }
217
+
218
+ .lares-voice-keys button:disabled {
219
+ cursor: default;
220
+ opacity: 0.45;
221
+ }
222
+
223
+ .lares-voice-keys button:focus-visible {
224
+ outline: 2px solid var(--q-blue-default);
225
+ outline-offset: 2px;
226
+ }
227
+
228
+ .lares-voice-keys__issue,
229
+ .lares-voice-keys button.is-primary {
230
+ border: 0;
231
+ background: var(--q-blue-alpha);
232
+ color: var(--q-blue-default);
233
+ font-weight: 500;
234
+ }
235
+
236
+ .lares-voice-keys__issue {
237
+ min-height: 48px;
238
+ border-radius: 16px;
239
+ font-size: 16px;
240
+ }
241
+
242
+ .lares-voice-keys__dialog {
243
+ flex-direction: column;
244
+ align-items: stretch;
245
+ }
246
+
247
+ .lares-voice-keys__dialog p {
248
+ color: var(--q-ink-1);
249
+ }
250
+
251
+ .lares-voice-keys code {
252
+ overflow-wrap: anywhere;
253
+ border-radius: 12px;
254
+ padding: 12px;
255
+ background: var(--q-background-3);
256
+ font-size: 13px;
257
+ line-height: 18px;
258
+ user-select: all;
259
+ }
260
+
261
+ .lares-voice-keys__actions {
262
+ display: flex;
263
+ flex-wrap: wrap;
264
+ gap: 8px;
265
+ justify-content: flex-end;
266
+ }
74
267
  </style>
@@ -1,32 +1,48 @@
1
1
  <template>
2
2
  <div v-if="view.value" class="lares-voice-preferences">
3
- <label>{{ t('preferences.dictationStyle') }}
4
- <select :value="view.value.config.dictationPreset" :disabled="guardView.selecting || Boolean(view.pending.dictationPreset)" @change="selectPreset($event)">
5
- <option v-for="id in presets" :key="id" :value="id">{{ t(`dictation.${id}`) }}</option>
6
- </select>
3
+ <label v-if="device === 'desktop'" class="lares-voice-preferences__field">
4
+ <span>{{ t('preferences.dictationStyle') }}</span>
5
+ <LaresSelect :value="view.value.config.dictationPreset" :sections="presetSections" :disabled="guardView.selecting || Boolean(view.pending.dictationPreset)" @select="choosePreset" />
7
6
  </label>
7
+ <LaresSettingRow
8
+ v-else
9
+ :label="t('preferences.dictationStyle')"
10
+ :value="t(`dictation.${view.value.config.dictationPreset}`)"
11
+ chevron
12
+ boxed
13
+ :disabled="guardView.selecting || Boolean(view.pending.dictationPreset)"
14
+ @click="sheet = 'preset'"
15
+ />
8
16
  <p v-if="view.errors.dictationPreset" role="alert">{{ t('settings.saveFailed') }}</p>
9
17
  <p v-if="view.value.config.dictationPreset === 'raw'">{{ t('dictation.noLlm') }}</p>
10
18
  <div v-else class="lares-voice-preferences__editor">
11
- <label>{{ t('dictation.model') }}
12
- <select :value="profile.model" :disabled="profilePending" @change="preferences.edit(view.value.config.dictationPreset, { model: $event.target.value })">
13
- <option value="">{{ t('settings.model.none') }}</option>
14
- <option v-for="item in models" :key="item.id" :value="item.id" :disabled="item.disabled">{{ item.label }}</option>
15
- </select>
19
+ <label v-if="device === 'desktop'" class="lares-voice-preferences__field">
20
+ <span>{{ t('dictation.model') }}</span>
21
+ <LaresSelect :value="profile.model" :sections="modelSections" :disabled="profilePending" @select="chooseModel" />
16
22
  </label>
17
- <label>{{ t('dictation.instructions') }}
23
+ <LaresSettingRow
24
+ v-else
25
+ :label="t('dictation.model')"
26
+ :value="modelLabel"
27
+ chevron
28
+ boxed
29
+ :disabled="profilePending"
30
+ @click="sheet = 'model'"
31
+ />
32
+ <label class="lares-voice-preferences__field">
33
+ <span>{{ t('dictation.instructions') }}</span>
18
34
  <textarea ref="instructions" :value="profile.instructions" :disabled="profilePending" rows="6" maxlength="8000" @input="preferences.edit(view.value.config.dictationPreset, { instructions: $event.target.value })" />
19
35
  </label>
20
36
  <div class="lares-voice-preferences__actions">
21
37
  <button type="button" :disabled="profilePending" @click="preferences.restore(view.value.config.dictationPreset)">{{ t('dictation.reset') }}</button>
22
38
  <template v-if="preferences.dirty(view.value.config.dictationPreset)">
23
39
  <button type="button" :disabled="profilePending" @click="preferences.discard([view.value.config.dictationPreset])">{{ t('preferences.cancel') }}</button>
24
- <button type="button" :disabled="profilePending" @click="saveProfile">{{ t(profilePending ? 'preferences.saving' : 'preferences.save') }}</button>
40
+ <button type="button" class="is-primary" :disabled="profilePending" @click="saveProfile">{{ t(profilePending ? 'preferences.saving' : 'preferences.save') }}</button>
25
41
  </template>
26
42
  </div>
27
43
  <p v-if="view.errors[`profile:${view.value.config.dictationPreset}`]" role="alert">{{ t('settings.saveFailed') }}</p>
28
44
  </div>
29
- <TtsSettings :preferences="preferences" :view="view" :t="t" :fetcher="ttsFetch" />
45
+ <TtsSettings :preferences="preferences" :view="view" :t="t" :fetcher="ttsFetch" :device="device" />
30
46
  <section v-if="device === 'desktop'" class="lares-voice-preferences__shortcuts">
31
47
  <h3>{{ t('preferences.shortcuts') }}</h3>
32
48
  <p>{{ t('preferences.shortcutScope') }}</p>
@@ -50,9 +66,19 @@
50
66
  <p v-if="guardView.error" role="alert">{{ t('settings.saveFailed') }}</p>
51
67
  <button type="button" :disabled="guardView.saving" @click="guard.answer('continue')">{{ t('preferences.continue') }}</button>
52
68
  <button type="button" :disabled="guardView.saving" @click="guard.answer('discard')">{{ t('preferences.discard') }}</button>
53
- <button type="button" :disabled="guardView.saving" @click="guard.answer('save')">{{ t(guardView.saving ? 'preferences.saving' : 'preferences.save') }}</button>
69
+ <button type="button" class="is-primary" :disabled="guardView.saving" @click="guard.answer('save')">{{ t(guardView.saving ? 'preferences.saving' : 'preferences.save') }}</button>
54
70
  </div>
55
71
  </LaresSheet>
72
+ <LaresSheet v-if="device !== 'desktop'" :open="Boolean(sheet)" :title="sheetTitle" @close="sheet = ''">
73
+ <LaresSettingRow
74
+ v-for="item in sheetItems"
75
+ :key="item.id || 'none'"
76
+ :label="item.label"
77
+ :checked="item.id === sheetCurrent"
78
+ :disabled="item.disabled || (sheet === 'preset' ? guardView.selecting : profilePending)"
79
+ @click="chooseSheet(item)"
80
+ />
81
+ </LaresSheet>
56
82
  </div>
57
83
  <p v-else role="status">{{ t(view.loadError ? 'settings.loadFailed' : 'settings.loading') }}</p>
58
84
  </template>
@@ -63,10 +89,12 @@ import { createVoicePreferences, createPreferencesGuard } from "@olares/lares-co
63
89
  import { createVoiceShortcutRecorder, formatVoiceShortcut } from "@olares/lares-core/voice/shortcuts";
64
90
  import { DICTATION_PRESETS } from "@olares/lares-core/voice/dictation";
65
91
  import LaresSheet from "./Sheet.vue";
92
+ import LaresSettingRow from "./SettingRow.vue";
93
+ import LaresSelect from "./Select.vue";
66
94
  import TtsSettings from "./TtsSettings.vue";
67
95
 
68
96
  export default {
69
- components: { LaresSheet, TtsSettings },
97
+ components: { LaresSheet, LaresSettingRow, LaresSelect, TtsSettings },
70
98
  props: { ttsFetch: { type: Function, required: true }, settings: { type: Object, required: true }, snapshot: { type: Object, default: null }, device: { type: String, default: "mobile" }, t: { type: Function, required: true }, bindBeforeLeave: { type: Function, default: undefined } },
71
99
  expose: ["beforeLeave", "reload", "patch"],
72
100
  emits: ["change"],
@@ -76,10 +104,16 @@ export default {
76
104
  if (this.snapshot) preferences.accept(toRaw(this.snapshot));
77
105
  const guard = markRaw(createPreferencesGuard(preferences));
78
106
  const recorder = markRaw(createVoiceShortcutRecorder({ current: () => preferences.getSnapshot().value.config.dictationShortcuts, save: (id, key) => preferences.saveShortcut(id, key) }));
79
- return { preferences, guard, recorder, view: preferences.getSnapshot(), guardView: guard.getSnapshot(), shortcut: recorder.getSnapshot(), bindings: markRaw({}), presets: DICTATION_PRESETS, shortcutIds: ["default", ...DICTATION_PRESETS] };
107
+ return { preferences, guard, recorder, view: preferences.getSnapshot(), guardView: guard.getSnapshot(), shortcut: recorder.getSnapshot(), bindings: markRaw({}), presets: DICTATION_PRESETS, shortcutIds: ["default", ...DICTATION_PRESETS], sheet: "" };
80
108
  },
81
109
  computed: {
82
110
  profile() { return this.view.value && this.view.value.config.dictationPreset !== "raw" ? this.preferences.profile(this.view.value.config.dictationPreset) : null; },
111
+ presetSections() { return [{ items: this.presets.map(id => ({ id, label: this.t(`dictation.${id}`) })) }]; },
112
+ modelSections() { return [{ items: [{ id: "", label: this.t("settings.model.none") }, ...this.models.map(item => ({ id: item.id, label: item.label, disabled: item.disabled }))] }]; },
113
+ modelLabel() { return this.modelSections[0].items.find(item => item.id === (this.profile?.model || ""))?.label || this.t("settings.model.none"); },
114
+ sheetTitle() { return this.t(this.sheet === "model" ? "dictation.model" : "preferences.dictationStyle"); },
115
+ sheetItems() { return this.sheet === "model" ? this.modelSections[0].items : this.presetSections[0].items; },
116
+ sheetCurrent() { return this.sheet === "model" ? (this.profile?.model || "") : this.view.value?.config.dictationPreset; },
83
117
  profilePending() { return this.guardView.selecting || Boolean(this.view.pending[`profile:${this.view.value.config.dictationPreset}`]); },
84
118
  models() {
85
119
  const available = this.view.value?.chatModels || [];
@@ -117,10 +151,14 @@ export default {
117
151
  beforeLeave() { return this.guard.request(); },
118
152
  async patch(partial) { if (!this.view.value) await this.preferences.load(); return this.preferences.patch(partial); },
119
153
  reload() { return this.preferences.load(true); },
120
- async selectPreset(event) {
121
- const id = event.target.value;
122
- event.target.value = this.view.value.config.dictationPreset;
123
- await this.guard.selectPreset(id).catch(() => {});
154
+ choosePreset(item) { void this.guard.selectPreset(item.id).catch(() => {}); },
155
+ chooseModel(item) { if (!item.disabled) this.preferences.edit(this.view.value.config.dictationPreset, { model: item.id }); },
156
+ chooseSheet(item) {
157
+ if (item.disabled) return;
158
+ const sheet = this.sheet;
159
+ this.sheet = "";
160
+ if (sheet === "model") this.chooseModel(item);
161
+ else this.choosePreset(item);
124
162
  },
125
163
  async saveProfile() { try { await this.preferences.saveProfile(this.view.value.config.dictationPreset); this.$refs.instructions?.focus(); } catch { /* The shared store retains the draft and error. */ } },
126
164
  shortcutTitle(id) { return this.t(id === "default" ? "dictation.shortcut" : `dictation.${id}`); },
@@ -135,19 +173,29 @@ export default {
135
173
  </script>
136
174
 
137
175
  <style scoped>
138
- .lares-voice-preferences { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
139
- .lares-voice-preferences h3 { margin: 8px 0 0; font-size: 15px; font-weight: 500; }
140
- .lares-voice-preferences p { margin: 0; font-size: 13px; color: var(--q-ink-3); }
176
+ .lares-voice-preferences { display: flex; flex-direction: column; gap: 12px; min-width: 0; color: var(--q-ink-1); font-size: 15px; line-height: 22px; }
177
+ .lares-voice-preferences h3 { margin: 4px 0 0; color: var(--q-ink-3); font-size: 14px; font-weight: 500; line-height: 18px; }
178
+ .lares-voice-preferences p { margin: 0; color: var(--q-ink-3); font-size: 15px; line-height: 22px; }
141
179
  .lares-voice-preferences [role="alert"] { color: var(--q-orange-default); }
142
- .lares-voice-preferences__row { display: flex; gap: 8px; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid var(--q-separator); }
143
- .lares-voice-preferences__row > span { flex: 1; min-width: 0; }
180
+ .lares-voice-preferences__field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
181
+ .lares-voice-preferences__field > span { color: var(--q-ink-2); }
182
+ .lares-voice-preferences__row { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-height: 56px; padding: 12px 0; border-bottom: 1px solid var(--q-separator); }
183
+ .lares-voice-preferences__row > span { flex: 1; min-width: 0; font-size: 16px; line-height: 22px; }
144
184
  .lares-voice-preferences__actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
145
- .lares-voice-preferences button, .lares-voice-preferences select { font: inherit; color: inherit; background: var(--q-background-2, transparent); border: 1px solid var(--q-separator); border-radius: 8px; padding: 6px 8px; cursor: pointer; max-width: 100%; }
146
- .lares-voice-preferences button:disabled { opacity: .45; cursor: default; }
147
- .lares-voice-preferences button[data-recording="true"] { outline: 2px solid var(--q-blue-default, #3978f6); }
148
- .lares-voice-preferences button:focus-visible, .lares-voice-preferences textarea:focus-visible { outline: 2px solid var(--q-blue-default, #3978f6); outline-offset: 2px; }
149
- .lares-voice-preferences__editor, .lares-voice-preferences label { display: flex; flex-direction: column; gap: 10px; }
150
- .lares-voice-preferences textarea { box-sizing: border-box; width: 100%; min-height: 144px; padding: 10px; resize: vertical; color: inherit; background: transparent; border: 1px solid var(--q-separator); border-radius: 8px; font: inherit; line-height: 1.5; }
151
- .lares-voice-preferences__shortcuts, .lares-voice-preferences__question { display: flex; flex-direction: column; gap: 12px; }
152
- .lares-voice-preferences__question { padding: 12px 20px; }
185
+ .lares-voice-preferences__actions button,
186
+ .lares-voice-preferences__question button,
187
+ .lares-voice-preferences__shortcuts button { min-height: 36px; border: 1px solid var(--q-separator); border-radius: 12px; padding: 0 14px; background: transparent; color: inherit; font: inherit; font-size: 15px; line-height: 20px; cursor: pointer; }
188
+ .lares-voice-preferences__actions button:disabled,
189
+ .lares-voice-preferences__question button:disabled,
190
+ .lares-voice-preferences__shortcuts button:disabled { opacity: .45; cursor: default; }
191
+ .lares-voice-preferences button.is-primary { border: 0; background: var(--q-blue-alpha); color: var(--q-blue-default); font-weight: 500; }
192
+ .lares-voice-preferences button[data-recording="true"] { outline: 2px solid var(--q-blue-default); }
193
+ .lares-voice-preferences__actions button:focus-visible,
194
+ .lares-voice-preferences__question button:focus-visible,
195
+ .lares-voice-preferences__shortcuts button:focus-visible,
196
+ .lares-voice-preferences textarea:focus-visible { outline: 2px solid var(--q-blue-default); outline-offset: 2px; }
197
+ .lares-voice-preferences__editor, .lares-voice-preferences__shortcuts, .lares-voice-preferences__question { display: flex; flex-direction: column; gap: 12px; }
198
+ .lares-voice-preferences textarea { box-sizing: border-box; width: 100%; min-height: 144px; padding: 12px 16px; resize: vertical; color: inherit; background: transparent; border: 1px solid var(--q-separator); border-radius: 16px; font: inherit; font-size: 16px; line-height: 1.5; }
199
+ .lares-voice-preferences__question { padding: 4px 20px 12px; }
200
+ .lares-voice-preferences__question button { width: 100%; }
153
201
  </style>