@olares/lares-mobile 0.15.6 → 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.6",
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.6",
31
+ "@olares/lares-core": "0.15.9",
32
32
  "markdown-it": "^14.3.0"
33
33
  }
34
34
  }
package/src/App.vue CHANGED
@@ -14,6 +14,7 @@
14
14
  :effort-name="effortName"
15
15
  @create="newChat"
16
16
  @pick-session="pickSession"
17
+ @pick-workspace="pickWorkspace"
17
18
  @retry="retry"
18
19
  @open-file="openFile"
19
20
  @answer="answerQuestion"
@@ -77,16 +78,15 @@
77
78
  @move-session="moveSession"
78
79
  @rename-session="renameSession"
79
80
  @archive-session="archiveSession"
80
- @pick-workspace="pickWorkspace"
81
81
  @retry="retry"
82
82
  @open-file="openFile"
83
83
  @answer="answerQuestion"
84
84
  @update-draft="updateDraft"
85
85
  @send="send"
86
+ @queue-action="updateQueue"
86
87
  @open-model="openModelSheet"
87
88
  @open-effort="openEffortSheet"
88
89
  @files="attachFiles"
89
- @voice="toggleVoice"
90
90
  @hold-start="onHoldStart"
91
91
  @hold-end="onHoldEnd"
92
92
  @close-model="modelSheet = false"
@@ -98,6 +98,7 @@
98
98
  @open-commands="loadCommands"
99
99
  @open-references="loadReferences"
100
100
  @pick-command="pickCommand"
101
+ @toggle-speak="toggleSpeak"
101
102
  />
102
103
  </div>
103
104
  </template>
@@ -108,6 +109,12 @@ import { connectChat } from "./runtime.js";
108
109
  import { createT } from "./i18n.js";
109
110
  import { failText, withPendingUser } from "./chat/format.js";
110
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";
111
118
  import { appendDraftMentions } from "@olares/lares-core/files/mention";
112
119
  import { FileIntake, partitionDocumentsBySize, splitComposerFiles } from "@olares/lares-core/files/intake";
113
120
  import {
@@ -117,16 +124,28 @@ import {
117
124
  draftImagesUploading,
118
125
  } from "@olares/lares-core/files/draft-images";
119
126
  import { DEFAULT_MAX_UPLOAD_BYTES } from "@olares/lares-core/files/limits";
120
- 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";
121
138
  import { messageFromCode } from "@olares/lares-core/i18n/t";
122
139
  import { parseAskUserQuestions, singleSelectAnswer } from "@olares/lares-core/larepass/questions";
123
140
  import { applyCommand } from "@olares/lares-core/larepass/commands";
124
141
  import { permissionOption, presetDescription, presetLabel } from "@olares/lares-core/larepass/permissions";
125
- import { DEFAULT_BUSY_ENTER } from "@olares/lares-core/larepass/submission-settings";
142
+ import { DEFAULT_BUSY_ENTER, resolveSubmitMode } from "@olares/lares-core/larepass/submission-settings";
126
143
  import { subscribeConversationSettings } from "@olares/lares-core/larepass/settings";
127
144
  import { markSeen, pruneSeen, readSeen, SEEN_STORAGE_KEY, unseenSessions } from "@olares/lares-core/larepass/unread";
128
145
  import { fileName } from "@olares/lares-core/files/filename";
129
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";
130
149
  import { chatDevice as resolveChatDevice } from "./layout.js";
131
150
  import LaresMobileShell from "./mobile/MobileShell.vue";
132
151
  import LaresDesktopShell from "./desktop/DesktopShell.vue";
@@ -161,6 +180,8 @@ export default {
161
180
  // Vue drops an undeclared prop into attrs, so a port missing from this list
162
181
  // never reaches `ports` below no matter what the host passes.
163
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 },
164
185
  },
165
186
  data() {
166
187
  return {
@@ -168,8 +189,9 @@ export default {
168
189
  stopping: false,
169
190
  starting: false,
170
191
  hostGen: 0,
171
- modelGen: 0,
192
+ modelCatalogGen: 0,
172
193
  running: false,
194
+ runningSince: 0,
173
195
  sessionId: "",
174
196
  draft: "",
175
197
  draftBySession: {},
@@ -189,18 +211,20 @@ export default {
189
211
  sessions: [],
190
212
  sessionsReady: false,
191
213
  historyLoading: false,
192
- models: null,
214
+ modelCatalog: null,
193
215
  modelError: "",
194
216
  modelPending: "",
195
217
  modelSheet: false,
196
218
  effortSheet: false,
197
- effortByModel: {},
198
219
  upload: { pending: 0, failures: [] },
199
220
  draftImages: [],
200
221
  voicePhase: "idle",
201
222
  voiceElapsed: 0,
202
223
  voiceError: "",
203
224
  voiceLanguage: "",
225
+ voiceSpeak: false,
226
+ speakBusy: false,
227
+ speakNotice: "",
204
228
  intakeUnsub: null,
205
229
  question: null,
206
230
  questionBusy: false,
@@ -271,6 +295,7 @@ export default {
271
295
  stopping: this.stopping,
272
296
  starting: this.starting,
273
297
  running: this.running,
298
+ runningSince: this.runningSince,
274
299
  sessionId: this.sessionId,
275
300
  sessionTitle: this.sessionTitle,
276
301
  draft: this.draft,
@@ -284,6 +309,7 @@ export default {
284
309
  previewMode: this.previewMode,
285
310
  previewMediaSrc: this.previewMediaSrc,
286
311
  previewDownloadHref: this.previewDownloadHref,
312
+ previewFilesHref: this.previewFilesHref,
287
313
  panel: this.panel,
288
314
  sessions: this.sessions,
289
315
  sessionsReady: this.sessionsReady,
@@ -337,6 +363,9 @@ export default {
337
363
  queueBusy: this.queueBusy,
338
364
  busyEnter: this.busyEnter,
339
365
  directory: this.directory,
366
+ voiceSpeak: this.voiceSpeak,
367
+ speakBusy: this.speakBusy,
368
+ speakNotice: this.speakNotice,
340
369
  };
341
370
  },
342
371
  unseen() {
@@ -386,18 +415,21 @@ export default {
386
415
  modelLabel() {
387
416
  return this.currentListedModel?.name || this.models?.default?.model || this.t("model.select");
388
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
+ },
389
428
  reasoning() {
390
429
  return reasoningOfModel(this.currentListedModel);
391
430
  },
392
431
  effortId() {
393
- if (this.chatDevice === "desktop") {
394
- return currentEffortId(this.models?.default, this.reasoning);
395
- }
396
- if (!(this.modelCurrent in this.effortByModel)) {
397
- return currentEffortId(this.models?.default, this.reasoning);
398
- }
399
- const stored = this.effortByModel[this.modelCurrent];
400
- return stored === "" ? undefined : stored;
432
+ return currentEffortId(this.models?.default, this.reasoning);
401
433
  },
402
434
  effortLabel() {
403
435
  return this.effortName({ id: this.effortId, name: this.effortId });
@@ -422,15 +454,27 @@ export default {
422
454
  previewMediaSrc() {
423
455
  const data = this.preview.data;
424
456
  if (!data) return "";
425
- 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
+ );
426
462
  },
427
463
  previewDownloadHref() {
428
464
  if (!this.preview.path) return "";
429
465
  return this.runtime.downloadUrl(this.preview.path);
430
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
+ },
431
475
  previewHref() {
432
476
  return (path) => {
433
- const href = this.runtime.mediaUrl(path);
477
+ const href = this.runtime.mediaUrl(path, undefined, "big");
434
478
  try {
435
479
  return new URL(href, globalThis.location?.origin || "http://localhost").href;
436
480
  } catch {
@@ -448,7 +492,7 @@ export default {
448
492
  return this.models?.default ? selectionKey(this.models.default) : "";
449
493
  },
450
494
  modelBusy() {
451
- return Boolean(this.modelPending) || (this.modelSheet && !this.models);
495
+ return Boolean(this.modelPending) || (this.modelSheet && !this.modelCatalog);
452
496
  },
453
497
  modelBlocked() {
454
498
  return this.chatDevice === "desktop" && this.models?.routable === false;
@@ -474,28 +518,33 @@ export default {
474
518
  created() {
475
519
  // Per-path request tokens: reactive state would re-render on every read.
476
520
  this.previewGen = new Map();
521
+ this.previewOpenGen = 0;
477
522
  this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
478
523
  this.imageUploadControllers = new Map();
524
+ this.player = createAnswerPlayer();
525
+ this.replySpeaker = createReplySpeaker();
526
+ this.speakGen = 0;
479
527
  this.voiceCap = createVoiceCapture({
480
528
  transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
481
- getDraft: () => this.draft,
482
- setDraft: (text) => {
483
- this.draft = text;
484
- },
485
529
  onPhase: (phase, extra = {}) => {
486
530
  this.voicePhase = phase;
487
531
  if (extra.elapsed != null) this.voiceElapsed = extra.elapsed;
488
532
  if (extra.error != null) this.voiceError = extra.error;
489
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
+ },
490
545
  });
491
546
  this.bindIntake(this.sessionId);
492
547
  this.bindRuntime();
493
- try {
494
- const raw = JSON.parse(localStorage.getItem("lares.mobile.effort") || "{}");
495
- if (raw && typeof raw === "object") this.effortByModel = raw;
496
- } catch {
497
- this.effortByModel = {};
498
- }
499
548
  try {
500
549
  this.seen = readSeen(JSON.parse(localStorage.getItem(SEEN_STORAGE_KEY) || "null"));
501
550
  } catch {
@@ -532,7 +581,11 @@ export default {
532
581
  },
533
582
  methods: {
534
583
  mediaUrl(item) {
535
- 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
+ );
536
589
  },
537
590
  scrollTop(height, view) {
538
591
  return this.runtime.scrollTop(height, view);
@@ -575,9 +628,21 @@ export default {
575
628
  this.commands = null;
576
629
  this.commandsError = "";
577
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
+ });
578
641
  this.sessionId = snap.sessionId;
579
642
  this.items = snap.items;
580
643
  this.running = snap.running;
644
+ this.runningSince = snap.runningSince || 0;
645
+ if (answer) void this.speakReply(answer, ++this.speakGen);
581
646
  this.failed = snap.failed;
582
647
  this.error = snap.error;
583
648
  this.sessions = snap.sessions ?? this.sessions;
@@ -598,9 +663,6 @@ export default {
598
663
  if (!this.question) this.questionBusy = false;
599
664
  this.approval = snap.approval || null;
600
665
  if (!this.approval) this.approvalBusy = false;
601
- if (sessionChanged && this.chatDevice === "desktop") {
602
- void this.loadSessionModels(snap.sessionId);
603
- }
604
666
  if (this.pendingUser && snap.items.some((row) => row.type === "user" && row.text === this.pendingUser)) {
605
667
  this.pendingUser = "";
606
668
  }
@@ -688,17 +750,19 @@ export default {
688
750
  this.$refs.shell?.focusComposer?.();
689
751
  },
690
752
  async hydrateFiles(paths) {
691
- const missing = [...new Set(paths)].filter((path) => !(path in this.previews));
753
+ const missing = [...new Set(paths)].filter((path) => !Object.hasOwn(this.previews, path));
692
754
  if (!missing.length) return;
693
- const next = { ...this.previews };
755
+ const sessionId = this.sessionId;
756
+ const loaded = Object.create(null);
694
757
  await Promise.all(missing.map(async (path) => {
695
758
  try {
696
- next[path] = await this.runtime.preview(path);
759
+ loaded[path] = await this.runtime.preview(path);
697
760
  } catch {
698
- next[path] = null;
761
+ loaded[path] = null;
699
762
  }
700
763
  }));
701
- this.previews = next;
764
+ if (this.sessionId !== sessionId) return;
765
+ this.previews = { ...this.previews, ...loaded };
702
766
  this.pinLog();
703
767
  },
704
768
  async answerQuestion(label, row) {
@@ -735,13 +799,33 @@ export default {
735
799
  this.approvalBusy = false;
736
800
  }
737
801
  },
738
- openFile(path) {
802
+ openPreviewTab(path) {
739
803
  const { tabs, lru, evicted } = openTab({ tabs: this.previewTabs, lru: this.previewLru }, path);
740
804
  this.previewTabs = tabs;
741
805
  this.previewLru = lru;
742
806
  if (evicted) this.dropPreviewContent(evicted.path);
743
807
  this.previewPath = path;
744
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);
745
829
  return this.loadPreview(path);
746
830
  },
747
831
  showChat() {
@@ -763,6 +847,7 @@ export default {
763
847
  if (this.previewPath) this.closePreviewTab(this.previewPath);
764
848
  },
765
849
  resetPreview() {
850
+ this.previewOpenGen += 1;
766
851
  this.previewTabs = [];
767
852
  this.previewLru = [];
768
853
  this.previewPath = "";
@@ -813,6 +898,13 @@ export default {
813
898
  this.previewGen.set(canonical, gen);
814
899
  this.setPreviewContent(canonical, gen, { status: "ready", data, error: "" });
815
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
+ }
816
908
  this.setPreviewContent(path, gen, {
817
909
  status: "error",
818
910
  data: null,
@@ -859,7 +951,7 @@ export default {
859
951
  this.pendingUser = "";
860
952
  this.failed = "";
861
953
  this.error = "";
862
- this.models = null;
954
+ this.modelCatalog = null;
863
955
  this.modelError = "";
864
956
  this.modelPending = "";
865
957
  this.modelSheet = false;
@@ -912,58 +1004,36 @@ export default {
912
1004
  },
913
1005
  async loadComposerMeta() {
914
1006
  const settings = this.runtime.settings;
915
- if (!settings) return;
916
- if (this.chatDevice === "desktop") {
917
- await Promise.all([
918
- this.loadSessionModels(this.sessionId),
919
- settings.conversation().then((section) => {
920
- this.busyEnter = section.busyEnter;
921
- }).catch(() => {
922
- this.busyEnter = DEFAULT_BUSY_ENTER;
923
- }),
924
- ]);
925
- } else {
926
- try {
927
- this.models = await settings.models();
928
- this.modelError = "";
929
- } catch (err) {
930
- this.modelError = err instanceof Error ? err.message : String(err);
931
- }
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
+ }));
932
1014
  }
933
- try {
934
- const voice = await settings.voice();
935
- this.voiceLanguage = voice.config?.language || "";
936
- } catch {
937
- 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
+ }));
938
1023
  }
1024
+ await Promise.all(jobs);
939
1025
  },
940
- async loadSessionModels(sessionId) {
941
- const gen = ++this.modelGen;
942
- if (!sessionId) {
943
- this.models = null;
944
- return;
945
- }
946
- this.modelError = "";
947
- const loaded = await this.runtime.sessionModels(sessionId);
948
- 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;
949
1030
  if (!loaded.ok) {
950
- this.models = null;
1031
+ this.modelCatalog = null;
951
1032
  this.modelError = loaded.error?.message || loaded.error?.code || "models";
952
1033
  return;
953
1034
  }
954
- const value = loaded.value ?? {};
955
- this.models = {
956
- default: value.current,
957
- routable: value.routable !== false,
958
- failures: value.failures ?? [],
959
- models: (value.groups ?? []).flatMap((group) => (
960
- (group.models ?? []).map((model) => ({
961
- ...model,
962
- provider: group.id,
963
- providerName: group.name || group.id,
964
- }))
965
- )),
966
- };
1035
+ this.modelCatalog = loaded.value ?? null;
1036
+ this.modelError = "";
967
1037
  },
968
1038
  openEffortSheet() {
969
1039
  if (this.effortDisabled) return;
@@ -974,71 +1044,34 @@ export default {
974
1044
  const text = this.t(`effort.${row.id}`);
975
1045
  return text === `effort.${row.id}` ? (row.name || row.id) : text;
976
1046
  },
977
- persistEffort() {
978
- try {
979
- localStorage.setItem("lares.mobile.effort", JSON.stringify(this.effortByModel));
980
- } catch {
981
- // storage may be blocked
982
- }
983
- },
984
1047
  async chooseEffort(id) {
985
- this.effortSheet = false;
986
- if (!this.modelCurrent) return;
987
- if (this.chatDevice === "desktop") {
988
- if (id === this.effortId || this.modelBusy) return;
989
- this.modelPending = `${this.modelCurrent}\u0000${id ?? ""}`;
990
- const current = this.models?.default;
991
- try {
992
- const selected = await this.runtime.selectModel({
993
- provider: current.provider,
994
- model: current.model,
995
- ...(id === undefined ? {} : { reasoningEffort: id }),
996
- });
997
- if (!selected.ok) {
998
- this.modelError = selected.error?.message || selected.error?.code || "model";
999
- return;
1000
- }
1001
- this.models = { ...this.models, default: selected.value?.selected ?? current };
1002
- } finally {
1003
- 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";
1004
1057
  }
1005
- return;
1058
+ } finally {
1059
+ this.modelPending = "";
1006
1060
  }
1007
- this.effortByModel = { ...this.effortByModel, [this.modelCurrent]: id ?? "" };
1008
- this.persistEffort();
1009
1061
  },
1010
1062
  openModelSheet() {
1011
1063
  this.modelSheet = true;
1012
- if (!this.models) void this.loadComposerMeta();
1064
+ if (!this.modelCatalog) void this.loadModelCatalog();
1013
1065
  },
1014
1066
  async chooseModel(model) {
1015
1067
  const key = this.modelKey(model);
1016
- if (key === this.modelCurrent || this.modelBusy) {
1017
- this.modelSheet = false;
1018
- return;
1019
- }
1020
- this.modelSheet = false;
1068
+ if (key === this.modelCurrent || this.modelBusy) return;
1069
+ if (this.chatDevice === "desktop") this.modelSheet = false;
1021
1070
  this.modelPending = key;
1022
1071
  this.modelError = "";
1023
1072
  try {
1024
- if (this.chatDevice === "desktop") {
1025
- const reasoning = reasoningOfModel(model);
1026
- const selection = {
1027
- provider: model.provider,
1028
- model: model.id,
1029
- ...(reasoning?.defaultEffort === undefined
1030
- ? {}
1031
- : { reasoningEffort: reasoning.defaultEffort }),
1032
- };
1033
- const selected = await this.runtime.selectModel(selection);
1034
- if (!selected.ok) throw new Error(selected.error?.message || selected.error?.code || "model");
1035
- this.models = { ...this.models, default: selected.value?.selected ?? selection };
1036
- } else {
1037
- this.models = await this.runtime.settings.setDefaultModel({
1038
- provider: model.provider,
1039
- model: model.id,
1040
- });
1041
- }
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");
1042
1075
  } catch (err) {
1043
1076
  this.modelError = err instanceof Error ? err.message : String(err);
1044
1077
  } finally {
@@ -1136,15 +1169,71 @@ export default {
1136
1169
  this.draftImages = [];
1137
1170
  this.imagesBySession = {};
1138
1171
  },
1139
- onHoldStart() {
1140
- 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);
1141
1190
  },
1142
- onHoldEnd() {
1143
- if (this.voicePhase === "recording") this.voiceCap?.stop();
1144
- else this.voiceCap?.stop(true);
1191
+ showSpeakProblem(err) {
1192
+ this.speakNotice = speakProblemText(this.t, err);
1145
1193
  },
1146
- toggleVoice() {
1147
- this.voiceCap?.toggle();
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
+ }
1221
+ },
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
+ }
1148
1237
  },
1149
1238
  toggleHistory() {
1150
1239
  if (this.panel === "history") {
@@ -1290,9 +1379,31 @@ export default {
1290
1379
  if (!sessionId) return;
1291
1380
  return this.runtime.archiveSession(sessionId);
1292
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
+ },
1293
1403
  async send(mode = "queue") {
1294
1404
  const text = this.draft.trim();
1295
1405
  if (!this.canSend) return;
1406
+ this.player?.unlock();
1296
1407
  const sessionId = this.sessionId;
1297
1408
  const images = this.draftImages.filter((row) => draftImageStatus(row) === "ready");
1298
1409
  const wasRunning = this.running;