@olares/lares-mobile 0.15.0

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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # `@olares/lares-mobile`
2
+
3
+ LarePass 只依赖这一个包。对话、Host、设置都在内部走 `@olares/lares-core`,宿主不要再 import 或 link core。
4
+
5
+ ```vue
6
+ <template>
7
+ <LaresApp :locale="locale" :env="env" />
8
+ </template>
9
+ <script setup>
10
+ import { LaresApp } from "@olares/lares-mobile";
11
+ </script>
12
+ ```
13
+
14
+ 设置页同样只挂 `LaresAgentSettings`。
15
+
16
+ `env` 由宿主注入(`PROTOCOL` / `LARES_SUB_DOMAIN` / `ACCOUNT_DOMAIN` / `IS_PC_TEST`)。PC 预览走 `/laresHost` 代理时设 `IS_PC_TEST`。需要自定义鉴权请求时再传 `request`。
17
+
18
+ 本地联调(包未上 registry 时):
19
+
20
+ ```bash
21
+ # dina
22
+ npm link --workspace=@olares/lares-mobile
23
+
24
+ # TermiPass/packages/app
25
+ npm link @olares/lares-mobile
26
+ ```
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@olares/lares-mobile",
3
+ "version": "0.15.0",
4
+ "description": "Lares chat component for LarePass",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/beclab/lares.git",
9
+ "directory": "packages/mobile"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public",
13
+ "registry": "https://registry.npmjs.org/"
14
+ },
15
+ "main": "./src/index.js",
16
+ "types": "./src/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./src/index.d.ts",
20
+ "default": "./src/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "src"
26
+ ],
27
+ "peerDependencies": {
28
+ "vue": "^3.2.0"
29
+ },
30
+ "dependencies": {
31
+ "@olares/lares-core": "0.15.0",
32
+ "markdown-it": "^14.3.0"
33
+ }
34
+ }
package/src/App.vue ADDED
@@ -0,0 +1,586 @@
1
+ <template>
2
+ <div class="lares-shell">
3
+ <LaresChatBar
4
+ :t="t"
5
+ :title="sessionTitle"
6
+ :starting="starting"
7
+ @history="toggleHistory"
8
+ @create="newChat"
9
+ />
10
+ <LaresHistoryPanel
11
+ :open="panel === 'history'"
12
+ :sessions="sessions"
13
+ :session-id="sessionId"
14
+ :ready="sessionsReady"
15
+ :t="t"
16
+ @close="panel = ''"
17
+ @pick="pickSession"
18
+ />
19
+ <p v-if="failed" class="lares-shell__status">{{ failText }}</p>
20
+ <button v-if="failed" type="button" class="lares-shell__retry" :disabled="starting" @click="retry">
21
+ {{ t("probe.retry") }}
22
+ </button>
23
+ <LaresTranscript
24
+ ref="transcript"
25
+ :items="viewItems"
26
+ :running="running"
27
+ :loading="historyLoading || starting"
28
+ :previews="previews"
29
+ :media-url="mediaUrl"
30
+ :scroll-top="scrollTop"
31
+ :remember-scroll="rememberScroll"
32
+ :sticking="sticking"
33
+ :session-id="sessionId"
34
+ :question="question"
35
+ :question-busy="questionBusy"
36
+ :t="t"
37
+ @open="openFile"
38
+ @media="pinLog"
39
+ @answer="answerQuestion"
40
+ />
41
+ <LaresComposer
42
+ :draft="draft"
43
+ :sending="sending"
44
+ :can-send="canSend"
45
+ :model-label="modelLabel"
46
+ :model-busy="modelBusy"
47
+ :effort-label="effortLabel"
48
+ :effort-disabled="effortDisabled"
49
+ :attach-pending="upload.pending > 0"
50
+ :attach-disabled="!sessionId || historyLoading || starting"
51
+ :voice-phase="voicePhase"
52
+ :voice-elapsed="voiceElapsed"
53
+ :voice-error="voiceErrorText"
54
+ :failures="upload.failures"
55
+ :t="t"
56
+ @update:draft="draft = $event"
57
+ @send="send"
58
+ @model="openModelSheet"
59
+ @effort="openEffortSheet"
60
+ @files="attachFiles"
61
+ @voice="toggleVoice"
62
+ @hold-start="onHoldStart"
63
+ @hold-end="onHoldEnd"
64
+ />
65
+ <LaresSheet :open="modelSheet" :title="t('model.menuAria')" @close="modelSheet = false">
66
+ <p v-if="modelError" class="lares-shell__status">{{ modelError }}</p>
67
+ <p v-else-if="!models" class="lares-shell__hint">{{ t("agent.loading") }}</p>
68
+ <p v-else-if="!modelGroups.length" class="lares-shell__hint">{{ t("model.empty") }}</p>
69
+ <template v-for="section in modelGroups" :key="section.provider">
70
+ <p class="lares-shell__heading">{{ section.provider }}</p>
71
+ <LaresSettingRow
72
+ v-for="item in section.models"
73
+ :key="modelKey(item)"
74
+ :label="item.name"
75
+ :checked="modelKey(item) === modelCurrent"
76
+ :disabled="modelBusy"
77
+ @click="chooseModel(item)"
78
+ />
79
+ </template>
80
+ </LaresSheet>
81
+ <LaresSheet :open="effortSheet" :title="t('reasoning.title')" @close="effortSheet = false">
82
+ <p v-if="!effortRows.length" class="lares-shell__hint">{{ t("reasoning.default") }}</p>
83
+ <LaresSettingRow
84
+ v-for="row in effortRows"
85
+ :key="row.key"
86
+ :label="effortName(row)"
87
+ :checked="row.id === effortId"
88
+ :disabled="modelBusy"
89
+ @click="chooseEffort(row.id)"
90
+ />
91
+ </LaresSheet>
92
+ <LaresPreview
93
+ v-if="preview.path"
94
+ :path="preview.path"
95
+ :session-id="sessionId"
96
+ :status="preview.status"
97
+ :data="preview.data"
98
+ :error="preview.error"
99
+ :media-src="previewMediaSrc"
100
+ :download-href="previewDownloadHref"
101
+ :href-for="previewHref"
102
+ :t="t"
103
+ @close="closePreview"
104
+ @retry="openFile(preview.path)"
105
+ @open="openFile"
106
+ />
107
+ </div>
108
+ </template>
109
+
110
+ <script>
111
+ import { connectChat } from "./runtime.js";
112
+ import { createT } from "./i18n.js";
113
+ import { failText, withPendingUser } from "./chat/format.js";
114
+ import { createVoiceCapture } from "./chat/voice.js";
115
+ import { appendDraftMentions } from "@olares/lares-core/files/mention";
116
+ import { FileIntake, partitionDocumentsBySize } from "@olares/lares-core/files/intake";
117
+ import { DEFAULT_MAX_UPLOAD_BYTES } from "@olares/lares-core/files/limits";
118
+ import { groupModelsByProvider, effortMenuRows, reasoningOfModel, currentEffortId, selectionKey } from "@olares/lares-core/router/session-model";
119
+ import { messageFromCode } from "@olares/lares-core/i18n/t";
120
+ import { parseAskUserQuestions, singleSelectAnswer } from "@olares/lares-core/larepass/questions";
121
+ import LaresChatBar from "./chat/ChatBar.vue";
122
+ import LaresHistoryPanel from "./chat/HistoryPanel.vue";
123
+ import LaresTranscript from "./chat/Transcript.vue";
124
+ import LaresComposer from "./chat/Composer.vue";
125
+ import LaresPreview from "./preview/Preview.vue";
126
+ import LaresSheet from "./settings/Sheet.vue";
127
+ import LaresSettingRow from "./settings/SettingRow.vue";
128
+ import "./styles.css";
129
+
130
+ export default {
131
+ name: "LaresApp",
132
+ components: { LaresChatBar, LaresHistoryPanel, LaresTranscript, LaresComposer, LaresPreview, LaresSheet, LaresSettingRow },
133
+ props: {
134
+ locale: { type: String, default: "en" },
135
+ baseUrl: { type: String, default: undefined },
136
+ proxyPrefix: { type: String, default: undefined },
137
+ request: { type: Function, default: undefined },
138
+ env: { type: Object, default: undefined },
139
+ },
140
+ data() {
141
+ return {
142
+ sending: false,
143
+ starting: false,
144
+ running: false,
145
+ sessionId: "",
146
+ draft: "",
147
+ failed: "",
148
+ error: "",
149
+ items: [],
150
+ pendingUser: "",
151
+ unsub: null,
152
+ previews: {},
153
+ preview: { path: "", status: "idle", data: null, error: "" },
154
+ panel: "",
155
+ sessions: [],
156
+ sessionsReady: false,
157
+ historyLoading: false,
158
+ models: null,
159
+ modelError: "",
160
+ modelPending: "",
161
+ modelSheet: false,
162
+ effortSheet: false,
163
+ effortByModel: {},
164
+ upload: { pending: 0, failures: [] },
165
+ voicePhase: "idle",
166
+ voiceElapsed: 0,
167
+ voiceError: "",
168
+ voiceLanguage: "",
169
+ intakeUnsub: null,
170
+ question: null,
171
+ questionBusy: false,
172
+ };
173
+ },
174
+ computed: {
175
+ ports() {
176
+ return {
177
+ baseUrl: this.baseUrl,
178
+ proxyPrefix: this.proxyPrefix,
179
+ request: this.request,
180
+ env: this.env,
181
+ };
182
+ },
183
+ runtime() {
184
+ return connectChat(this.ports);
185
+ },
186
+ t() {
187
+ return createT(this.locale);
188
+ },
189
+ canSend() {
190
+ return Boolean(this.draft.trim()) && Boolean(this.sessionId) && !this.running && !this.historyLoading && this.upload.pending === 0;
191
+ },
192
+ sessionTitle() {
193
+ const row = this.sessions.find((item) => item.sessionId === this.sessionId);
194
+ return row?.title || this.t("history.untitled");
195
+ },
196
+ currentListedModel() {
197
+ for (const group of this.modelGroups) {
198
+ for (const model of group.models) {
199
+ if (this.modelKey(model) === this.modelCurrent) return model;
200
+ }
201
+ }
202
+ return null;
203
+ },
204
+ modelLabel() {
205
+ return this.currentListedModel?.name || this.models?.default?.model || this.t("model.select");
206
+ },
207
+ reasoning() {
208
+ return reasoningOfModel(this.currentListedModel);
209
+ },
210
+ effortId() {
211
+ if (!(this.modelCurrent in this.effortByModel)) {
212
+ return currentEffortId(this.models?.default, this.reasoning);
213
+ }
214
+ const stored = this.effortByModel[this.modelCurrent];
215
+ return stored === "" ? undefined : stored;
216
+ },
217
+ effortLabel() {
218
+ return this.effortName({ id: this.effortId, name: this.effortId });
219
+ },
220
+ effortDisabled() {
221
+ return !this.reasoning;
222
+ },
223
+ effortRows() {
224
+ return effortMenuRows(this.reasoning, this.t("reasoning.default"));
225
+ },
226
+ viewItems() {
227
+ return withPendingUser(this.items, this.pendingUser);
228
+ },
229
+ failText() {
230
+ return failText(this.t, this.failed, this.error);
231
+ },
232
+ previewMediaSrc() {
233
+ const data = this.preview.data;
234
+ if (!data) return "";
235
+ return this.runtime.mediaUrl(data.path, data.modifiedAt);
236
+ },
237
+ previewDownloadHref() {
238
+ if (!this.preview.path) return "";
239
+ return this.runtime.downloadUrl(this.preview.path);
240
+ },
241
+ previewHref() {
242
+ return (path) => {
243
+ const href = this.runtime.mediaUrl(path);
244
+ try {
245
+ return new URL(href, globalThis.location?.origin || "http://localhost").href;
246
+ } catch {
247
+ return href;
248
+ }
249
+ };
250
+ },
251
+ filePaths() {
252
+ return this.items.filter((row) => row.type === "files").flatMap((row) => row.paths);
253
+ },
254
+ modelGroups() {
255
+ return groupModelsByProvider(this.models?.models ?? []);
256
+ },
257
+ modelCurrent() {
258
+ return this.models?.default ? selectionKey(this.models.default) : "";
259
+ },
260
+ modelBusy() {
261
+ return Boolean(this.modelPending) || (this.modelSheet && !this.models);
262
+ },
263
+ voiceErrorText() {
264
+ return this.voiceError ? messageFromCode(this.t, this.voiceError, "error.voice_failed") : "";
265
+ },
266
+ },
267
+ watch: {
268
+ filePaths: {
269
+ immediate: true,
270
+ handler(paths) {
271
+ this.hydrateFiles(paths);
272
+ },
273
+ },
274
+ sessionId(id) {
275
+ this.bindIntake(id);
276
+ },
277
+ },
278
+ created() {
279
+ this.intake = new FileIntake((file, sessionId, options) => this.runtime.upload(file, options, sessionId));
280
+ this.voiceCap = createVoiceCapture({
281
+ transcribe: (blob, signal) => this.runtime.transcribe(blob, this.voiceLanguage, signal),
282
+ getDraft: () => this.draft,
283
+ setDraft: (text) => {
284
+ this.draft = text;
285
+ },
286
+ onPhase: (phase, extra = {}) => {
287
+ this.voicePhase = phase;
288
+ if (extra.elapsed != null) this.voiceElapsed = extra.elapsed;
289
+ if (extra.error != null) this.voiceError = extra.error;
290
+ },
291
+ });
292
+ this.bindIntake(this.sessionId);
293
+ this.unsub = this.runtime.subscribe((snap) => this.applySnap(snap));
294
+ try {
295
+ const raw = JSON.parse(localStorage.getItem("lares.mobile.effort") || "{}");
296
+ if (raw && typeof raw === "object") this.effortByModel = raw;
297
+ } catch {
298
+ this.effortByModel = {};
299
+ }
300
+ },
301
+ mounted() {
302
+ this.retry();
303
+ },
304
+ activated() {
305
+ this.restoreLog();
306
+ },
307
+ deactivated() {
308
+ this.captureLog();
309
+ },
310
+ beforeUnmount() {
311
+ this.captureLog();
312
+ this.voiceCap?.dispose();
313
+ this.intakeUnsub?.();
314
+ if (this.sessionId) this.intake?.cancelSession(this.sessionId);
315
+ this.unsub?.();
316
+ this.unsub = null;
317
+ },
318
+ methods: {
319
+ mediaUrl(item) {
320
+ return this.runtime.mediaUrl(item.path, item.modifiedAt);
321
+ },
322
+ scrollTop(height, view) {
323
+ return this.runtime.scrollTop(height, view);
324
+ },
325
+ rememberScroll(top, height, view) {
326
+ this.runtime.rememberScroll(top, height, view);
327
+ },
328
+ sticking() {
329
+ return this.runtime.sticking();
330
+ },
331
+ restoreLog() {
332
+ this.$refs.transcript?.restoreLog?.();
333
+ },
334
+ captureLog() {
335
+ this.$refs.transcript?.captureLog?.();
336
+ },
337
+ pinLog() {
338
+ this.$refs.transcript?.scheduleLogPin?.();
339
+ },
340
+ applySnap(snap) {
341
+ if (snap.sessionId !== this.sessionId) {
342
+ this.pendingUser = "";
343
+ this.previews = {};
344
+ }
345
+ this.sessionId = snap.sessionId;
346
+ this.items = snap.items;
347
+ this.running = snap.running;
348
+ this.failed = snap.failed;
349
+ this.error = snap.error;
350
+ this.sessions = snap.sessions ?? this.sessions;
351
+ this.sessionsReady = Boolean(snap.sessionsReady);
352
+ this.historyLoading = Boolean(snap.historyLoading);
353
+ this.question = snap.question || null;
354
+ if (!this.question) this.questionBusy = false;
355
+ if (this.pendingUser && snap.items.some((row) => row.type === "user" && row.text === this.pendingUser)) {
356
+ this.pendingUser = "";
357
+ }
358
+ this.pinLog();
359
+ },
360
+ async hydrateFiles(paths) {
361
+ const missing = [...new Set(paths)].filter((path) => !(path in this.previews));
362
+ if (!missing.length) return;
363
+ const next = { ...this.previews };
364
+ await Promise.all(missing.map(async (path) => {
365
+ try {
366
+ next[path] = await this.runtime.preview(path);
367
+ } catch {
368
+ next[path] = null;
369
+ }
370
+ }));
371
+ this.previews = next;
372
+ this.pinLog();
373
+ },
374
+ async answerQuestion(label, row) {
375
+ const questions = this.question?.questions?.length
376
+ ? this.question.questions
377
+ : parseAskUserQuestions(row?.argsRaw);
378
+ const id = questions[0]?.id;
379
+ if (!id || this.questionBusy) return;
380
+ this.questionBusy = true;
381
+ try {
382
+ const result = await this.runtime.answerQuestion(singleSelectAnswer(id, label).answers);
383
+ if (!result?.ok) this.questionBusy = false;
384
+ } catch {
385
+ this.questionBusy = false;
386
+ }
387
+ },
388
+ async openFile(path) {
389
+ this.preview = { path, status: "loading", data: null, error: "" };
390
+ try {
391
+ const data = await this.runtime.preview(path);
392
+ this.previews = { ...this.previews, [path]: data };
393
+ this.preview = { path, status: "ready", data, error: "" };
394
+ } catch (err) {
395
+ this.preview = {
396
+ path,
397
+ status: "error",
398
+ data: null,
399
+ error: err instanceof Error ? err.message : "file_preview_failed",
400
+ };
401
+ }
402
+ },
403
+ closePreview() {
404
+ this.preview = { path: "", status: "idle", data: null, error: "" };
405
+ },
406
+ async retry() {
407
+ this.starting = true;
408
+ try {
409
+ await this.runtime.start();
410
+ await this.loadComposerMeta();
411
+ } finally {
412
+ this.starting = false;
413
+ }
414
+ },
415
+ modelKey(model) {
416
+ return selectionKey({ provider: model.provider, model: model.id });
417
+ },
418
+ bindIntake(sessionId) {
419
+ this.intakeUnsub?.();
420
+ this.intakeUnsub = null;
421
+ if (!sessionId || !this.intake) {
422
+ this.upload = { pending: 0, failures: [] };
423
+ return;
424
+ }
425
+ this.upload = this.intake.getSnapshot(sessionId);
426
+ this.intakeUnsub = this.intake.subscribe(sessionId, () => {
427
+ this.upload = this.intake.getSnapshot(sessionId);
428
+ });
429
+ },
430
+ async loadComposerMeta() {
431
+ const settings = this.runtime.settings;
432
+ if (!settings) return;
433
+ try {
434
+ this.models = await settings.models();
435
+ this.modelError = "";
436
+ } catch (err) {
437
+ this.modelError = err instanceof Error ? err.message : String(err);
438
+ }
439
+ try {
440
+ const voice = await settings.voice();
441
+ this.voiceLanguage = voice.config?.language || "";
442
+ } catch {
443
+ this.voiceLanguage = "";
444
+ }
445
+ },
446
+ openEffortSheet() {
447
+ if (this.effortDisabled) return;
448
+ this.effortSheet = true;
449
+ },
450
+ effortName(row) {
451
+ if (row?.id == null) return this.t("reasoning.default");
452
+ const text = this.t(`effort.${row.id}`);
453
+ return text === `effort.${row.id}` ? (row.name || row.id) : text;
454
+ },
455
+ persistEffort() {
456
+ try {
457
+ localStorage.setItem("lares.mobile.effort", JSON.stringify(this.effortByModel));
458
+ } catch {
459
+ // storage may be blocked
460
+ }
461
+ },
462
+ chooseEffort(id) {
463
+ this.effortSheet = false;
464
+ if (!this.modelCurrent) return;
465
+ this.effortByModel = { ...this.effortByModel, [this.modelCurrent]: id ?? "" };
466
+ this.persistEffort();
467
+ },
468
+ openModelSheet() {
469
+ this.modelSheet = true;
470
+ if (!this.models) void this.loadComposerMeta();
471
+ },
472
+ async chooseModel(model) {
473
+ const key = this.modelKey(model);
474
+ if (key === this.modelCurrent || this.modelBusy) {
475
+ this.modelSheet = false;
476
+ return;
477
+ }
478
+ this.modelSheet = false;
479
+ this.modelPending = key;
480
+ this.modelError = "";
481
+ try {
482
+ this.models = await this.runtime.settings.setDefaultModel({
483
+ provider: model.provider,
484
+ model: model.id,
485
+ });
486
+ } catch (err) {
487
+ this.modelError = err instanceof Error ? err.message : String(err);
488
+ } finally {
489
+ this.modelPending = "";
490
+ }
491
+ },
492
+ attachFiles(files) {
493
+ const sessionId = this.sessionId;
494
+ if (!sessionId || !files?.length) return;
495
+ const incoming = [...files];
496
+ const { accepted, oversized } = partitionDocumentsBySize(incoming, DEFAULT_MAX_UPLOAD_BYTES);
497
+ for (const file of oversized) this.intake.reportFailure(sessionId, file, "file_too_large");
498
+ void this.intake.uploadFiles(sessionId, accepted, (paths) => {
499
+ this.draft = appendDraftMentions(this.draft, paths);
500
+ });
501
+ },
502
+ onHoldStart() {
503
+ if (this.voicePhase === "idle" || this.voicePhase === "error") this.voiceCap?.start();
504
+ },
505
+ onHoldEnd() {
506
+ if (this.voicePhase === "recording") this.voiceCap?.stop();
507
+ else this.voiceCap?.stop(true);
508
+ },
509
+ toggleVoice() {
510
+ this.voiceCap?.toggle();
511
+ },
512
+ toggleHistory() {
513
+ if (this.panel === "history") {
514
+ this.panel = "";
515
+ return;
516
+ }
517
+ this.panel = "history";
518
+ this.runtime.refreshSessions();
519
+ },
520
+ pickSession(sessionId) {
521
+ this.panel = "";
522
+ if (!sessionId || sessionId === this.sessionId) return;
523
+ this.pendingUser = "";
524
+ this.previews = {};
525
+ this.runtime.openSession(sessionId);
526
+ },
527
+ newChat() {
528
+ this.panel = "";
529
+ this.pendingUser = "";
530
+ this.previews = {};
531
+ this.runtime.createSession();
532
+ },
533
+ async send() {
534
+ const text = this.draft.trim();
535
+ if (!this.canSend) return;
536
+ this.sending = true;
537
+ this.draft = "";
538
+ this.pendingUser = text;
539
+ this.runtime.pinToBottom();
540
+ this.pinLog();
541
+ try {
542
+ const sent = await this.runtime.send(text);
543
+ if (!sent.ok) {
544
+ this.pendingUser = "";
545
+ this.draft = text;
546
+ this.failed = sent.error?.message || sent.error?.code || "send";
547
+ }
548
+ } finally {
549
+ this.sending = false;
550
+ }
551
+ },
552
+ },
553
+ };
554
+ </script>
555
+
556
+ <style scoped>
557
+ .lares-shell__status,
558
+ .lares-shell__hint {
559
+ margin: 0 20px;
560
+ font-size: 13px;
561
+ color: var(--q-ink-3);
562
+ }
563
+
564
+ .lares-shell__status {
565
+ color: var(--q-orange-default);
566
+ }
567
+
568
+ .lares-shell__retry {
569
+ align-self: flex-start;
570
+ margin: 0 20px;
571
+ border: 0;
572
+ border-radius: 8px;
573
+ padding: 8px 12px;
574
+ background: var(--q-blue-alpha);
575
+ color: var(--q-blue-default);
576
+ }
577
+
578
+ .lares-shell__heading {
579
+ margin: 0;
580
+ padding: 12px 16px 4px;
581
+ color: var(--q-ink-3);
582
+ font-size: 12px;
583
+ font-weight: 500;
584
+ line-height: 16px;
585
+ }
586
+ </style>