@olares/lares-mobile 0.15.7 → 0.15.10
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 +2 -2
- package/src/App.vue +254 -144
- package/src/chat/ChatBar.vue +134 -33
- package/src/chat/Composer.vue +245 -213
- package/src/chat/Deliverables.vue +83 -11
- package/src/chat/HistoryPanel.vue +40 -1
- package/src/chat/QuestionCard.vue +3 -3
- package/src/chat/Transcript.vue +162 -4
- package/src/chat/format.js +11 -0
- package/src/chat/playback.js +41 -0
- package/src/chat/voice.js +169 -39
- package/src/desktop/DesktopPreview.vue +10 -2
- package/src/desktop/DesktopShell.vue +2 -0
- package/src/host.js +131 -21
- package/src/index.d.ts +5 -0
- package/src/mobile/MobileNewSession.vue +6 -117
- package/src/mobile/MobileShell.vue +57 -69
- package/src/preview/Preview.vue +30 -16
- package/src/preview/PreviewBody.vue +4 -1
- package/src/settings/AgentSettings.vue +16 -8
- package/src/settings/Sheet.vue +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-mobile",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.10",
|
|
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.
|
|
31
|
+
"@olares/lares-core": "0.15.10",
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
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) => !(
|
|
753
|
+
const missing = [...new Set(paths)].filter((path) => !Object.hasOwn(this.previews, path));
|
|
693
754
|
if (!missing.length) return;
|
|
694
|
-
const
|
|
755
|
+
const sessionId = this.sessionId;
|
|
756
|
+
const loaded = Object.create(null);
|
|
695
757
|
await Promise.all(missing.map(async (path) => {
|
|
696
758
|
try {
|
|
697
|
-
|
|
759
|
+
loaded[path] = await this.runtime.preview(path);
|
|
698
760
|
} catch {
|
|
699
|
-
|
|
761
|
+
loaded[path] = null;
|
|
700
762
|
}
|
|
701
763
|
}));
|
|
702
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
917
|
-
if (this.chatDevice === "desktop") {
|
|
918
|
-
|
|
919
|
-
this.
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
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
|
|
942
|
-
const gen = ++this.
|
|
943
|
-
|
|
944
|
-
|
|
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.
|
|
1031
|
+
this.modelCatalog = null;
|
|
952
1032
|
this.modelError = loaded.error?.message || loaded.error?.code || "models";
|
|
953
1033
|
return;
|
|
954
1034
|
}
|
|
955
|
-
|
|
956
|
-
this.
|
|
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
|
-
|
|
987
|
-
if (!this.
|
|
988
|
-
if (this.chatDevice === "desktop")
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1026
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1144
|
-
if (this.
|
|
1145
|
-
|
|
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
|
-
|
|
1148
|
-
this.
|
|
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;
|