@olares/lares-mobile 0.15.7 → 0.15.9

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.7",
3
+ "version": "0.15.9",
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.7",
31
+ "@olares/lares-core": "0.15.9",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -78,16 +78,15 @@
78
78
  @move-session="moveSession"
79
79
  @rename-session="renameSession"
80
80
  @archive-session="archiveSession"
81
- @pick-workspace="pickWorkspace"
82
81
  @retry="retry"
83
82
  @open-file="openFile"
84
83
  @answer="answerQuestion"
85
84
  @update-draft="updateDraft"
86
85
  @send="send"
86
+ @queue-action="updateQueue"
87
87
  @open-model="openModelSheet"
88
88
  @open-effort="openEffortSheet"
89
89
  @files="attachFiles"
90
- @voice="toggleVoice"
91
90
  @hold-start="onHoldStart"
92
91
  @hold-end="onHoldEnd"
93
92
  @close-model="modelSheet = false"
@@ -99,6 +98,7 @@
99
98
  @open-commands="loadCommands"
100
99
  @open-references="loadReferences"
101
100
  @pick-command="pickCommand"
101
+ @toggle-speak="toggleSpeak"
102
102
  />
103
103
  </div>
104
104
  </template>
@@ -109,6 +109,12 @@ import { connectChat } from "./runtime.js";
109
109
  import { createT } from "./i18n.js";
110
110
  import { failText, withPendingUser } from "./chat/format.js";
111
111
  import { createVoiceCapture } from "./chat/voice.js";
112
+ import { createAnswerPlayer } from "./chat/playback.js";
113
+ import { createReplySpeaker } from "@olares/lares-core/voice/speech";
114
+ import { wavBlob } from "@olares/lares-core/voice/client";
115
+ import { captureSource } from "@olares/lares-core/voice/input";
116
+ import { browserPlays } from "@olares/lares-core/voice/playback";
117
+ import { speakProblemText, speakToggleNotice } from "@olares/lares-core/voice/problem";
112
118
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
113
119
  import { FileIntake, partitionDocumentsBySize, splitComposerFiles } from "@olares/lares-core/files/intake";
114
120
  import {
@@ -118,16 +124,28 @@ import {
118
124
  draftImagesUploading,
119
125
  } from "@olares/lares-core/files/draft-images";
120
126
  import { DEFAULT_MAX_UPLOAD_BYTES } from "@olares/lares-core/files/limits";
121
- import { groupModelsByProvider, effortMenuRows, reasoningOfModel, currentEffortId, selectionKey } from "@olares/lares-core/router/session-model";
127
+ import {
128
+ groupModelsByProvider,
129
+ effortMenuRows,
130
+ reasoningOfModel,
131
+ currentEffortId,
132
+ selectionKey,
133
+ sessionModelSelection,
134
+ composerModelsFromCatalog,
135
+ modelSwitchSelection,
136
+ effortSwitchSelection,
137
+ } from "@olares/lares-core/router/session-model";
122
138
  import { messageFromCode } from "@olares/lares-core/i18n/t";
123
139
  import { parseAskUserQuestions, singleSelectAnswer } from "@olares/lares-core/larepass/questions";
124
140
  import { applyCommand } from "@olares/lares-core/larepass/commands";
125
141
  import { permissionOption, presetDescription, presetLabel } from "@olares/lares-core/larepass/permissions";
126
- import { DEFAULT_BUSY_ENTER } from "@olares/lares-core/larepass/submission-settings";
142
+ import { DEFAULT_BUSY_ENTER, resolveSubmitMode } from "@olares/lares-core/larepass/submission-settings";
127
143
  import { subscribeConversationSettings } from "@olares/lares-core/larepass/settings";
128
144
  import { markSeen, pruneSeen, readSeen, SEEN_STORAGE_KEY, unseenSessions } from "@olares/lares-core/larepass/unread";
129
145
  import { fileName } from "@olares/lares-core/files/filename";
130
146
  import { closeTab, openTab, touchTab } from "@olares/lares-core/files/preview-tabs";
147
+ import { isFilesPath } from "@olares/lares-core/drive/files-path";
148
+ import { filesAppUrl } from "@olares/lares-core/files/app-link";
131
149
  import { chatDevice as resolveChatDevice } from "./layout.js";
132
150
  import LaresMobileShell from "./mobile/MobileShell.vue";
133
151
  import LaresDesktopShell from "./desktop/DesktopShell.vue";
@@ -162,6 +180,8 @@ export default {
162
180
  // Vue drops an undeclared prop into attrs, so a port missing from this list
163
181
  // never reaches `ports` below no matter what the host passes.
164
182
  socketProtocol: { type: [Function, String, Array], default: undefined },
183
+ // Set only by laresPortsFromAccount when myApps has no Lares app.
184
+ missing: { type: Boolean, default: false },
165
185
  },
166
186
  data() {
167
187
  return {
@@ -169,8 +189,9 @@ export default {
169
189
  stopping: false,
170
190
  starting: false,
171
191
  hostGen: 0,
172
- modelGen: 0,
192
+ modelCatalogGen: 0,
173
193
  running: false,
194
+ runningSince: 0,
174
195
  sessionId: "",
175
196
  draft: "",
176
197
  draftBySession: {},
@@ -190,18 +211,20 @@ export default {
190
211
  sessions: [],
191
212
  sessionsReady: false,
192
213
  historyLoading: false,
193
- models: null,
214
+ modelCatalog: null,
194
215
  modelError: "",
195
216
  modelPending: "",
196
217
  modelSheet: false,
197
218
  effortSheet: false,
198
- effortByModel: {},
199
219
  upload: { pending: 0, failures: [] },
200
220
  draftImages: [],
201
221
  voicePhase: "idle",
202
222
  voiceElapsed: 0,
203
223
  voiceError: "",
204
224
  voiceLanguage: "",
225
+ voiceSpeak: false,
226
+ speakBusy: false,
227
+ speakNotice: "",
205
228
  intakeUnsub: null,
206
229
  question: null,
207
230
  questionBusy: false,
@@ -272,6 +295,7 @@ export default {
272
295
  stopping: this.stopping,
273
296
  starting: this.starting,
274
297
  running: this.running,
298
+ runningSince: this.runningSince,
275
299
  sessionId: this.sessionId,
276
300
  sessionTitle: this.sessionTitle,
277
301
  draft: this.draft,
@@ -285,6 +309,7 @@ export default {
285
309
  previewMode: this.previewMode,
286
310
  previewMediaSrc: this.previewMediaSrc,
287
311
  previewDownloadHref: this.previewDownloadHref,
312
+ previewFilesHref: this.previewFilesHref,
288
313
  panel: this.panel,
289
314
  sessions: this.sessions,
290
315
  sessionsReady: this.sessionsReady,
@@ -338,6 +363,9 @@ export default {
338
363
  queueBusy: this.queueBusy,
339
364
  busyEnter: this.busyEnter,
340
365
  directory: this.directory,
366
+ voiceSpeak: this.voiceSpeak,
367
+ speakBusy: this.speakBusy,
368
+ speakNotice: this.speakNotice,
341
369
  };
342
370
  },
343
371
  unseen() {
@@ -387,18 +415,21 @@ export default {
387
415
  modelLabel() {
388
416
  return this.currentListedModel?.name || this.models?.default?.model || this.t("model.select");
389
417
  },
418
+ liveModelSelection() {
419
+ const row = this.sessions.find((item) => item.sessionId === this.sessionId);
420
+ return row?.modelSelection ?? null;
421
+ },
422
+ models() {
423
+ return composerModelsFromCatalog(
424
+ this.modelCatalog,
425
+ sessionModelSelection(this.liveModelSelection, this.modelCatalog?.default),
426
+ );
427
+ },
390
428
  reasoning() {
391
429
  return reasoningOfModel(this.currentListedModel);
392
430
  },
393
431
  effortId() {
394
- if (this.chatDevice === "desktop") {
395
- return currentEffortId(this.models?.default, this.reasoning);
396
- }
397
- if (!(this.modelCurrent in this.effortByModel)) {
398
- return currentEffortId(this.models?.default, this.reasoning);
399
- }
400
- const stored = this.effortByModel[this.modelCurrent];
401
- return stored === "" ? undefined : stored;
432
+ return currentEffortId(this.models?.default, this.reasoning);
402
433
  },
403
434
  effortLabel() {
404
435
  return this.effortName({ id: this.effortId, name: this.effortId });
@@ -423,15 +454,27 @@ export default {
423
454
  previewMediaSrc() {
424
455
  const data = this.preview.data;
425
456
  if (!data) return "";
426
- return this.runtime.mediaUrl(data.path, data.modifiedAt);
457
+ return this.runtime.mediaUrl(
458
+ data.path,
459
+ data.modifiedAt,
460
+ data.kind === "image" ? "big" : undefined,
461
+ );
427
462
  },
428
463
  previewDownloadHref() {
429
464
  if (!this.preview.path) return "";
430
465
  return this.runtime.downloadUrl(this.preview.path);
431
466
  },
467
+ previewFilesHref() {
468
+ if (!this.preview.path) return "";
469
+ return filesAppUrl(this.preview.path, {
470
+ entrance: this.baseUrl,
471
+ accountDomain: this.env?.ACCOUNT_DOMAIN,
472
+ protocol: this.env?.PROTOCOL,
473
+ });
474
+ },
432
475
  previewHref() {
433
476
  return (path) => {
434
- const href = this.runtime.mediaUrl(path);
477
+ const href = this.runtime.mediaUrl(path, undefined, "big");
435
478
  try {
436
479
  return new URL(href, globalThis.location?.origin || "http://localhost").href;
437
480
  } catch {
@@ -449,7 +492,7 @@ export default {
449
492
  return this.models?.default ? selectionKey(this.models.default) : "";
450
493
  },
451
494
  modelBusy() {
452
- return Boolean(this.modelPending) || (this.modelSheet && !this.models);
495
+ return Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
453
496
  },
454
497
  modelBlocked() {
455
498
  return this.chatDevice === "desktop" && this.models?.routable === false;
@@ -475,28 +518,33 @@ export default {
475
518
  created() {
476
519
  // Per-path request tokens: reactive state would re-render on every read.
477
520
  this.previewGen = new Map();
521
+ this.previewOpenGen = 0;
478
522
  this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
479
523
  this.imageUploadControllers = new Map();
524
+ this.player = createAnswerPlayer();
525
+ this.replySpeaker = createReplySpeaker();
526
+ this.speakGen = 0;
480
527
  this.voiceCap = createVoiceCapture({
481
528
  transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
482
- getDraft: () => this.draft,
483
- setDraft: (text) => {
484
- this.draft = text;
485
- },
486
529
  onPhase: (phase, extra = {}) => {
487
530
  this.voicePhase = phase;
488
531
  if (extra.elapsed != null) this.voiceElapsed = extra.elapsed;
489
532
  if (extra.error != null) this.voiceError = extra.error;
490
533
  },
534
+ onTrace: (event, fields) => {
535
+ void this.runtime.reportLog?.("voice", event, fields);
536
+ },
537
+ submit: (text) => {
538
+ void this.runtime.reportLog?.("voice", "submit", {
539
+ chars: String(text ?? "").trim().length,
540
+ running: Boolean(this.running),
541
+ session: Boolean(this.sessionId),
542
+ });
543
+ void this.sendTranscript(text);
544
+ },
491
545
  });
492
546
  this.bindIntake(this.sessionId);
493
547
  this.bindRuntime();
494
- try {
495
- const raw = JSON.parse(localStorage.getItem("lares.mobile.effort") || "{}");
496
- if (raw && typeof raw === "object") this.effortByModel = raw;
497
- } catch {
498
- this.effortByModel = {};
499
- }
500
548
  try {
501
549
  this.seen = readSeen(JSON.parse(localStorage.getItem(SEEN_STORAGE_KEY) || "null"));
502
550
  } catch {
@@ -533,7 +581,11 @@ export default {
533
581
  },
534
582
  methods: {
535
583
  mediaUrl(item) {
536
- return this.runtime.mediaUrl(item.path, item.modifiedAt);
584
+ return this.runtime.mediaUrl(
585
+ item.path,
586
+ item.modifiedAt,
587
+ item.kind === "image" ? "big" : undefined,
588
+ );
537
589
  },
538
590
  scrollTop(height, view) {
539
591
  return this.runtime.scrollTop(height, view);
@@ -576,9 +628,21 @@ export default {
576
628
  this.commands = null;
577
629
  this.commandsError = "";
578
630
  }
631
+ if (sessionChanged) {
632
+ this.speakGen += 1;
633
+ this.player?.stop();
634
+ }
635
+ const answer = this.replySpeaker.observe({
636
+ sessionChanged,
637
+ wasRunning: this.running,
638
+ running: snap.running,
639
+ reply: snap.reply,
640
+ });
579
641
  this.sessionId = snap.sessionId;
580
642
  this.items = snap.items;
581
643
  this.running = snap.running;
644
+ this.runningSince = snap.runningSince || 0;
645
+ if (answer) void this.speakReply(answer, ++this.speakGen);
582
646
  this.failed = snap.failed;
583
647
  this.error = snap.error;
584
648
  this.sessions = snap.sessions ?? this.sessions;
@@ -599,9 +663,6 @@ export default {
599
663
  if (!this.question) this.questionBusy = false;
600
664
  this.approval = snap.approval || null;
601
665
  if (!this.approval) this.approvalBusy = false;
602
- if (sessionChanged && this.chatDevice === "desktop") {
603
- void this.loadSessionModels(snap.sessionId);
604
- }
605
666
  if (this.pendingUser && snap.items.some((row) => row.type === "user" && row.text === this.pendingUser)) {
606
667
  this.pendingUser = "";
607
668
  }
@@ -689,17 +750,19 @@ export default {
689
750
  this.$refs.shell?.focusComposer?.();
690
751
  },
691
752
  async hydrateFiles(paths) {
692
- const missing = [...new Set(paths)].filter((path) => !(path in this.previews));
753
+ const missing = [...new Set(paths)].filter((path) => !Object.hasOwn(this.previews, path));
693
754
  if (!missing.length) return;
694
- const next = { ...this.previews };
755
+ const sessionId = this.sessionId;
756
+ const loaded = Object.create(null);
695
757
  await Promise.all(missing.map(async (path) => {
696
758
  try {
697
- next[path] = await this.runtime.preview(path);
759
+ loaded[path] = await this.runtime.preview(path);
698
760
  } catch {
699
- next[path] = null;
761
+ loaded[path] = null;
700
762
  }
701
763
  }));
702
- this.previews = next;
764
+ if (this.sessionId !== sessionId) return;
765
+ this.previews = { ...this.previews, ...loaded };
703
766
  this.pinLog();
704
767
  },
705
768
  async answerQuestion(label, row) {
@@ -736,13 +799,33 @@ export default {
736
799
  this.approvalBusy = false;
737
800
  }
738
801
  },
739
- openFile(path) {
802
+ openPreviewTab(path) {
740
803
  const { tabs, lru, evicted } = openTab({ tabs: this.previewTabs, lru: this.previewLru }, path);
741
804
  this.previewTabs = tabs;
742
805
  this.previewLru = lru;
743
806
  if (evicted) this.dropPreviewContent(evicted.path);
744
807
  this.previewPath = path;
745
808
  this.previewMode = "preview";
809
+ },
810
+ async openFile(path) {
811
+ const openGen = ++this.previewOpenGen;
812
+ if (isFilesPath(path)) {
813
+ const gen = (this.previewGen.get(path) ?? 0) + 1;
814
+ this.previewGen.set(path, gen);
815
+ try {
816
+ const data = await this.runtime.preview(path);
817
+ if (this.previewGen.get(path) !== gen || this.previewOpenGen !== openGen) return;
818
+ this.openPreviewTab(path);
819
+ const canonical = this.adoptPreviewPath(path, data?.path);
820
+ this.previewGen.set(canonical, gen);
821
+ this.setPreviewContent(canonical, gen, { status: "ready", data, error: "" });
822
+ } catch {
823
+ // Keep the Produced card in place so another click retries with the
824
+ // browser's then-current credential.
825
+ }
826
+ return;
827
+ }
828
+ this.openPreviewTab(path);
746
829
  return this.loadPreview(path);
747
830
  },
748
831
  showChat() {
@@ -764,6 +847,7 @@ export default {
764
847
  if (this.previewPath) this.closePreviewTab(this.previewPath);
765
848
  },
766
849
  resetPreview() {
850
+ this.previewOpenGen += 1;
767
851
  this.previewTabs = [];
768
852
  this.previewLru = [];
769
853
  this.previewPath = "";
@@ -814,6 +898,13 @@ export default {
814
898
  this.previewGen.set(canonical, gen);
815
899
  this.setPreviewContent(canonical, gen, { status: "ready", data, error: "" });
816
900
  } catch (err) {
901
+ if (
902
+ isFilesPath(path)
903
+ && ["files_no_credential", "files_unauthenticated"].includes(err instanceof Error ? err.message : "")
904
+ ) {
905
+ this.closePreviewTab(path);
906
+ return;
907
+ }
817
908
  this.setPreviewContent(path, gen, {
818
909
  status: "error",
819
910
  data: null,
@@ -860,7 +951,7 @@ export default {
860
951
  this.pendingUser = "";
861
952
  this.failed = "";
862
953
  this.error = "";
863
- this.models = null;
954
+ this.modelCatalog = null;
864
955
  this.modelError = "";
865
956
  this.modelPending = "";
866
957
  this.modelSheet = false;
@@ -913,58 +1004,36 @@ export default {
913
1004
  },
914
1005
  async loadComposerMeta() {
915
1006
  const settings = this.runtime.settings;
916
- if (!settings) return;
917
- if (this.chatDevice === "desktop") {
918
- await Promise.all([
919
- this.loadSessionModels(this.sessionId),
920
- settings.conversation().then((section) => {
921
- this.busyEnter = section.busyEnter;
922
- }).catch(() => {
923
- this.busyEnter = DEFAULT_BUSY_ENTER;
924
- }),
925
- ]);
926
- } else {
927
- try {
928
- this.models = await settings.models();
929
- this.modelError = "";
930
- } catch (err) {
931
- this.modelError = err instanceof Error ? err.message : String(err);
932
- }
1007
+ const jobs = [this.loadModelCatalog()];
1008
+ if (settings && this.chatDevice === "desktop") {
1009
+ jobs.push(settings.conversation().then((section) => {
1010
+ this.busyEnter = section.busyEnter;
1011
+ }).catch(() => {
1012
+ this.busyEnter = DEFAULT_BUSY_ENTER;
1013
+ }));
933
1014
  }
934
- try {
935
- const voice = await settings.voice();
936
- this.voiceLanguage = voice.config?.language || "";
937
- } catch {
938
- this.voiceLanguage = "";
1015
+ if (settings) {
1016
+ jobs.push(settings.voice().then((voice) => {
1017
+ this.voiceLanguage = voice.config?.language || "";
1018
+ this.voiceSpeak = voice.config?.speak === true;
1019
+ }).catch(() => {
1020
+ this.voiceLanguage = "";
1021
+ this.voiceSpeak = false;
1022
+ }));
939
1023
  }
1024
+ await Promise.all(jobs);
940
1025
  },
941
- async loadSessionModels(sessionId) {
942
- const gen = ++this.modelGen;
943
- if (!sessionId) {
944
- this.models = null;
945
- return;
946
- }
947
- this.modelError = "";
948
- const loaded = await this.runtime.sessionModels(sessionId);
949
- if (gen !== this.modelGen || sessionId !== this.sessionId) return;
1026
+ async loadModelCatalog() {
1027
+ const gen = ++this.modelCatalogGen;
1028
+ const loaded = await this.runtime.modelCatalog();
1029
+ if (gen !== this.modelCatalogGen) return;
950
1030
  if (!loaded.ok) {
951
- this.models = null;
1031
+ this.modelCatalog = null;
952
1032
  this.modelError = loaded.error?.message || loaded.error?.code || "models";
953
1033
  return;
954
1034
  }
955
- const value = loaded.value ?? {};
956
- this.models = {
957
- default: value.current,
958
- routable: value.routable !== false,
959
- failures: value.failures ?? [],
960
- models: (value.groups ?? []).flatMap((group) => (
961
- (group.models ?? []).map((model) => ({
962
- ...model,
963
- provider: group.id,
964
- providerName: group.name || group.id,
965
- }))
966
- )),
967
- };
1035
+ this.modelCatalog = loaded.value ?? null;
1036
+ this.modelError = "";
968
1037
  },
969
1038
  openEffortSheet() {
970
1039
  if (this.effortDisabled) return;
@@ -975,71 +1044,34 @@ export default {
975
1044
  const text = this.t(`effort.${row.id}`);
976
1045
  return text === `effort.${row.id}` ? (row.name || row.id) : text;
977
1046
  },
978
- persistEffort() {
979
- try {
980
- localStorage.setItem("lares.mobile.effort", JSON.stringify(this.effortByModel));
981
- } catch {
982
- // storage may be blocked
983
- }
984
- },
985
1047
  async chooseEffort(id) {
986
- this.effortSheet = false;
987
- if (!this.modelCurrent) return;
988
- if (this.chatDevice === "desktop") {
989
- if (id === this.effortId || this.modelBusy) return;
990
- this.modelPending = `${this.modelCurrent}\u0000${id ?? ""}`;
991
- const current = this.models?.default;
992
- try {
993
- const selected = await this.runtime.selectModel({
994
- provider: current.provider,
995
- model: current.model,
996
- ...(id === undefined ? {} : { reasoningEffort: id }),
997
- });
998
- if (!selected.ok) {
999
- this.modelError = selected.error?.message || selected.error?.code || "model";
1000
- return;
1001
- }
1002
- this.models = { ...this.models, default: selected.value?.selected ?? current };
1003
- } finally {
1004
- this.modelPending = "";
1048
+ const current = this.models?.default;
1049
+ if (!current || id === this.effortId || this.modelBusy) return;
1050
+ if (this.chatDevice === "desktop") this.effortSheet = false;
1051
+ this.modelPending = `${this.modelCurrent}\u0000${id ?? ""}`;
1052
+ this.modelError = "";
1053
+ try {
1054
+ const selected = await this.runtime.selectModel(effortSwitchSelection(current, id));
1055
+ if (!selected.ok) {
1056
+ this.modelError = selected.error?.message || selected.error?.code || "model";
1005
1057
  }
1006
- return;
1058
+ } finally {
1059
+ this.modelPending = "";
1007
1060
  }
1008
- this.effortByModel = { ...this.effortByModel, [this.modelCurrent]: id ?? "" };
1009
- this.persistEffort();
1010
1061
  },
1011
1062
  openModelSheet() {
1012
1063
  this.modelSheet = true;
1013
- if (!this.models) void this.loadComposerMeta();
1064
+ if (!this.modelCatalog) void this.loadModelCatalog();
1014
1065
  },
1015
1066
  async chooseModel(model) {
1016
1067
  const key = this.modelKey(model);
1017
- if (key === this.modelCurrent || this.modelBusy) {
1018
- this.modelSheet = false;
1019
- return;
1020
- }
1021
- this.modelSheet = false;
1068
+ if (key === this.modelCurrent || this.modelBusy) return;
1069
+ if (this.chatDevice === "desktop") this.modelSheet = false;
1022
1070
  this.modelPending = key;
1023
1071
  this.modelError = "";
1024
1072
  try {
1025
- if (this.chatDevice === "desktop") {
1026
- const reasoning = reasoningOfModel(model);
1027
- const selection = {
1028
- provider: model.provider,
1029
- model: model.id,
1030
- ...(reasoning?.defaultEffort === undefined
1031
- ? {}
1032
- : { reasoningEffort: reasoning.defaultEffort }),
1033
- };
1034
- const selected = await this.runtime.selectModel(selection);
1035
- if (!selected.ok) throw new Error(selected.error?.message || selected.error?.code || "model");
1036
- this.models = { ...this.models, default: selected.value?.selected ?? selection };
1037
- } else {
1038
- this.models = await this.runtime.settings.setDefaultModel({
1039
- provider: model.provider,
1040
- model: model.id,
1041
- });
1042
- }
1073
+ const selected = await this.runtime.selectModel(modelSwitchSelection({ id: model.provider }, model));
1074
+ if (!selected.ok) throw new Error(selected.error?.message || selected.error?.code || "model");
1043
1075
  } catch (err) {
1044
1076
  this.modelError = err instanceof Error ? err.message : String(err);
1045
1077
  } finally {
@@ -1137,15 +1169,71 @@ export default {
1137
1169
  this.draftImages = [];
1138
1170
  this.imagesBySession = {};
1139
1171
  },
1140
- onHoldStart() {
1141
- if (this.voicePhase === "idle" || this.voicePhase === "error") this.voiceCap?.start();
1172
+ onHoldStart(detail) {
1173
+ void this.runtime.reportLog?.("voice", "hold-start", {
1174
+ via: detail?.via || "",
1175
+ phase: this.voicePhase,
1176
+ });
1177
+ if (captureSource("mobile") !== "device") return;
1178
+ if (this.voicePhase === "idle" || this.voicePhase === "error") void this.voiceCap?.start();
1179
+ this.speakGen += 1;
1180
+ this.player?.stop();
1181
+ this.player?.unlock();
1182
+ void this.runtime.interruptSpeech?.();
1183
+ },
1184
+ onHoldEnd(detail) {
1185
+ void this.runtime.reportLog?.("voice", "hold-end", {
1186
+ via: detail?.via || "",
1187
+ phase: this.voicePhase,
1188
+ });
1189
+ this.voiceCap?.stop(false);
1190
+ },
1191
+ showSpeakProblem(err) {
1192
+ this.speakNotice = speakProblemText(this.t, err);
1142
1193
  },
1143
- onHoldEnd() {
1144
- if (this.voicePhase === "recording") this.voiceCap?.stop();
1145
- else this.voiceCap?.stop(true);
1194
+ async toggleSpeak() {
1195
+ if (this.speakBusy) return;
1196
+ this.speakNotice = "";
1197
+ if (typeof this.runtime.settings?.saveVoice !== "function") {
1198
+ this.speakNotice = this.t("error.voice_speak_unready");
1199
+ return;
1200
+ }
1201
+ const next = !this.voiceSpeak;
1202
+ const previous = this.voiceSpeak;
1203
+ this.voiceSpeak = next;
1204
+ this.speakBusy = true;
1205
+ if (!next) {
1206
+ this.speakGen += 1;
1207
+ this.player?.stop();
1208
+ void this.runtime.interruptSpeech?.();
1209
+ }
1210
+ try {
1211
+ const voice = await this.runtime.settings.saveVoice({ speak: next });
1212
+ this.voiceSpeak = voice?.config ? voice.config.speak === true : next;
1213
+ const problem = this.voiceSpeak ? speakToggleNotice(voice?.ttsModels) : "";
1214
+ this.speakNotice = problem ? this.t(`error.${problem}`) : "";
1215
+ } catch (err) {
1216
+ this.voiceSpeak = previous;
1217
+ this.showSpeakProblem(err);
1218
+ } finally {
1219
+ this.speakBusy = false;
1220
+ }
1146
1221
  },
1147
- toggleVoice() {
1148
- this.voiceCap?.toggle();
1222
+ async speakReply(text, gen) {
1223
+ if (!this.voiceSpeak) return;
1224
+ try {
1225
+ const result = await this.runtime.speak?.(text);
1226
+ if (gen !== this.speakGen) return;
1227
+ if (!result?.audio) {
1228
+ this.showSpeakProblem(Object.assign(new Error("voice_speak_failed"), { code: "voice_speak_failed" }));
1229
+ return;
1230
+ }
1231
+ if (!browserPlays("mobile", result.bluetooth)) return;
1232
+ await this.player?.play(wavBlob(result.audio));
1233
+ } catch (err) {
1234
+ if (gen !== this.speakGen) return;
1235
+ this.showSpeakProblem(err);
1236
+ }
1149
1237
  },
1150
1238
  toggleHistory() {
1151
1239
  if (this.panel === "history") {
@@ -1291,9 +1379,31 @@ export default {
1291
1379
  if (!sessionId) return;
1292
1380
  return this.runtime.archiveSession(sessionId);
1293
1381
  },
1382
+ async sendTranscript(text) {
1383
+ const spoken = String(text ?? "").trim();
1384
+ if (!spoken || !this.sessionId || this.modelBlocked || this.historyLoading || this.upload?.pending) return;
1385
+ const mode = resolveSubmitMode(this.running, false, true, this.busyEnter);
1386
+ this.sending = true;
1387
+ this.pendingUser = this.running ? "" : spoken;
1388
+ this.runtime.pinToBottom();
1389
+ this.pinLog();
1390
+ try {
1391
+ const sent = await this.runtime.send([{ type: "text", text: spoken }], mode);
1392
+ if (!sent.ok) {
1393
+ this.pendingUser = "";
1394
+ this.failed = sent.error?.message || sent.error?.code || "send";
1395
+ }
1396
+ } catch (err) {
1397
+ this.pendingUser = "";
1398
+ this.failed = err instanceof Error ? err.message : "send";
1399
+ } finally {
1400
+ this.sending = false;
1401
+ }
1402
+ },
1294
1403
  async send(mode = "queue") {
1295
1404
  const text = this.draft.trim();
1296
1405
  if (!this.canSend) return;
1406
+ this.player?.unlock();
1297
1407
  const sessionId = this.sessionId;
1298
1408
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1299
1409
  const wasRunning = this.running;