@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.
@@ -0,0 +1,139 @@
1
+ import { TranscriptQueue } from "@olares/lares-core/voice/client";
2
+ import {
3
+ formatElapsed,
4
+ isRecordingTooShort,
5
+ pickRecordingMime,
6
+ RECORDING_AUDIO,
7
+ } from "@olares/lares-core/voice/recorder";
8
+
9
+ export { formatElapsed };
10
+
11
+ export function createVoiceCapture({ transcribe, getDraft, setDraft, onPhase }) {
12
+ const queue = new TranscriptQueue();
13
+ let recorder = null;
14
+ let stream = null;
15
+ let chunks = [];
16
+ let started = 0;
17
+ let timer = 0;
18
+ let cancel = false;
19
+ let dead = false;
20
+ let abort = null;
21
+ let phase = "idle";
22
+
23
+ const emit = (next, extra = {}) => {
24
+ phase = next;
25
+ onPhase?.(next, extra);
26
+ };
27
+
28
+ const stopTimer = () => {
29
+ if (timer) {
30
+ clearInterval(timer);
31
+ timer = 0;
32
+ }
33
+ };
34
+
35
+ const release = () => {
36
+ stream?.getTracks().forEach((track) => track.stop());
37
+ stream = null;
38
+ recorder = null;
39
+ };
40
+
41
+ const fail = (code) => {
42
+ if (dead) return;
43
+ emit("error", { error: code, elapsed: 0 });
44
+ setTimeout(() => {
45
+ if (!dead && phase === "error") emit("idle", { error: "", elapsed: 0 });
46
+ }, 3200);
47
+ };
48
+
49
+ const finish = async () => {
50
+ stopTimer();
51
+ const took = Date.now() - started;
52
+ const type = recorder?.mimeType || "audio/webm";
53
+ const blobChunks = chunks;
54
+ chunks = [];
55
+ release();
56
+ if (dead) return;
57
+ if (cancel) {
58
+ emit("idle", { elapsed: 0 });
59
+ return;
60
+ }
61
+ const blob = new Blob(blobChunks, { type });
62
+ if (isRecordingTooShort(took, blob.size)) {
63
+ fail("voice_too_short");
64
+ return;
65
+ }
66
+ emit("transcribing", { elapsed: took });
67
+ abort = new AbortController();
68
+ try {
69
+ const text = await transcribe(blob, abort.signal);
70
+ if (dead) return;
71
+ emit("idle", { elapsed: 0 });
72
+ queue.apply(text, true, getDraft(), setDraft);
73
+ } catch (err) {
74
+ if (dead || abort.signal.aborted) return;
75
+ fail(err instanceof Error ? err.message : "voice_failed");
76
+ } finally {
77
+ abort = null;
78
+ }
79
+ };
80
+
81
+ return {
82
+ get phase() {
83
+ return phase;
84
+ },
85
+ async start() {
86
+ if (dead || phase === "recording" || phase === "transcribing") return;
87
+ if (typeof MediaRecorder === "undefined" || !navigator.mediaDevices?.getUserMedia) {
88
+ fail("voice_unsupported");
89
+ return;
90
+ }
91
+ cancel = false;
92
+ let next;
93
+ try {
94
+ next = await navigator.mediaDevices.getUserMedia({ audio: RECORDING_AUDIO });
95
+ } catch {
96
+ fail("voice_permission_denied");
97
+ return;
98
+ }
99
+ if (dead || cancel) {
100
+ next.getTracks().forEach((track) => track.stop());
101
+ return;
102
+ }
103
+ stream = next;
104
+ chunks = [];
105
+ const mime = pickRecordingMime();
106
+ recorder = new MediaRecorder(stream, mime ? { mimeType: mime } : undefined);
107
+ recorder.ondataavailable = (event) => {
108
+ if (event.data?.size) chunks.push(event.data);
109
+ };
110
+ recorder.onstop = () => {
111
+ void finish();
112
+ };
113
+ started = Date.now();
114
+ emit("recording", { elapsed: 0, error: "" });
115
+ recorder.start();
116
+ timer = setInterval(() => emit("recording", { elapsed: Date.now() - started }), 200);
117
+ },
118
+ stop(aborting = false) {
119
+ cancel = Boolean(aborting);
120
+ if (recorder && recorder.state !== "inactive") recorder.stop();
121
+ else {
122
+ stopTimer();
123
+ release();
124
+ emit("idle", { elapsed: 0 });
125
+ }
126
+ },
127
+ toggle() {
128
+ if (phase === "recording") this.stop(false);
129
+ else if (phase === "idle" || phase === "error") void this.start();
130
+ },
131
+ dispose() {
132
+ dead = true;
133
+ stopTimer();
134
+ abort?.abort();
135
+ if (recorder && recorder.state !== "inactive") recorder.stop();
136
+ release();
137
+ },
138
+ };
139
+ }
package/src/host.js ADDED
@@ -0,0 +1,162 @@
1
+ import { hostConfigFromEnv, hostUrl, MODELS_PATH, probeHost } from "@olares/lares-core/larepass/host";
2
+ import { callRpc, consumeMux, ensureSession, loadTranscript, MUX_PATH, sendPrompt } from "@olares/lares-core/larepass/chat";
3
+ import { muxWsUrl } from "@olares/lares-core/larepass/mux";
4
+ import { RESPOND_PATH } from "@olares/lares-core/larepass/rpc";
5
+ import { createHostSettings } from "@olares/lares-core/larepass/settings";
6
+ import { downloadFileUrl, previewMetaUrl, rawFileUrl } from "@olares/lares-core/files/preview-workspace";
7
+ import { FILES_UPLOAD_PATH, uploadFile } from "@olares/lares-core/files/upload-client";
8
+ import { API as VOICE_API, postTranscribe } from "@olares/lares-core/voice/client";
9
+
10
+ function pageOrigin() {
11
+ try {
12
+ return globalThis.location?.origin || "http://localhost";
13
+ } catch {
14
+ return "http://localhost";
15
+ }
16
+ }
17
+
18
+ function openMuxSocket(httpUrl, signal) {
19
+ const Socket = globalThis.WebSocket;
20
+ if (typeof Socket !== "function") {
21
+ return Promise.resolve({
22
+ ok: false,
23
+ status: "error",
24
+ error: { code: "no-websocket", message: "WebSocket is not available" },
25
+ });
26
+ }
27
+ return new Promise((resolve) => {
28
+ let settled = false;
29
+ const socket = new Socket(muxWsUrl(httpUrl, pageOrigin()));
30
+ const finish = (result) => {
31
+ if (settled) return;
32
+ settled = true;
33
+ signal?.removeEventListener("abort", onAbort);
34
+ resolve(result);
35
+ };
36
+ const onAbort = () => {
37
+ try {
38
+ socket.close();
39
+ } catch {
40
+ // already closing
41
+ }
42
+ };
43
+ signal?.addEventListener("abort", onAbort, { once: true });
44
+ if (signal?.aborted) {
45
+ onAbort();
46
+ finish({ ok: false, status: "error", error: { code: "aborted", message: "mux aborted" } });
47
+ return;
48
+ }
49
+ socket.addEventListener("open", () => {
50
+ finish({ ok: true, http: 101, status: 101, body: socket });
51
+ }, { once: true });
52
+ socket.addEventListener("error", () => {
53
+ finish({
54
+ ok: false,
55
+ status: "unreachable",
56
+ error: { code: "unreachable", message: "mux websocket failed" },
57
+ });
58
+ }, { once: true });
59
+ });
60
+ }
61
+
62
+ export async function defaultRequest(url, init = {}) {
63
+ const res = await fetch(url, {
64
+ method: init.method ?? "GET",
65
+ body: init.body === undefined
66
+ ? undefined
67
+ : typeof init.body === "string"
68
+ ? init.body
69
+ : JSON.stringify(init.body),
70
+ credentials: "include",
71
+ redirect: "manual",
72
+ signal: init.signal,
73
+ headers: {
74
+ ...(init.body === undefined ? {} : { "content-type": "application/json" }),
75
+ ...init.headers,
76
+ },
77
+ });
78
+ const text = await res.text();
79
+ let body;
80
+ if (text) {
81
+ try {
82
+ body = JSON.parse(text);
83
+ } catch {
84
+ body = text;
85
+ }
86
+ }
87
+ return {
88
+ ok: res.status >= 200 && res.status < 300,
89
+ status: res.status,
90
+ body,
91
+ };
92
+ }
93
+
94
+ export function createHostClient(ports = {}) {
95
+ const fromEnv = hostConfigFromEnv(ports.env);
96
+ const baseUrl = ports.baseUrl ?? fromEnv.baseUrl;
97
+ const proxyPrefix = ports.proxyPrefix ?? fromEnv.proxyPrefix;
98
+ const send = ports.request ?? defaultRequest;
99
+ const urlFor = (path) => hostUrl({ baseUrl, proxyPrefix, path });
100
+ const request = (path, init) => send(urlFor(path), init);
101
+ const rpc = (method, payload) => callRpc(request, method, payload);
102
+ return {
103
+ urlFor,
104
+ probe() {
105
+ if (!proxyPrefix && !baseUrl) {
106
+ return Promise.resolve({ status: "missing" });
107
+ }
108
+ return probeHost((path) => request(path), MODELS_PATH);
109
+ },
110
+ rpc,
111
+ ensureSession() {
112
+ return ensureSession(rpc);
113
+ },
114
+ history(sessionId) {
115
+ return loadTranscript(rpc, sessionId);
116
+ },
117
+ prompt(sessionId, text) {
118
+ return sendPrompt(rpc, sessionId, text);
119
+ },
120
+ async respond(message) {
121
+ const res = await request(RESPOND_PATH, { method: "POST", body: message });
122
+ const body = res?.body;
123
+ if (body && typeof body === "object" && typeof body.accepted === "boolean") return body;
124
+ return { accepted: Boolean(res?.ok), reason: res?.ok ? undefined : "bad-response" };
125
+ },
126
+ async preview(sessionId, path) {
127
+ const res = await request(previewMetaUrl(sessionId, path));
128
+ const http = Number(res?.status) || 0;
129
+ const body = res?.body;
130
+ if (http < 200 || http >= 300) {
131
+ const code = body?.error?.code || "file_preview_failed";
132
+ throw new Error(code);
133
+ }
134
+ return body;
135
+ },
136
+ mediaUrl(sessionId, path, modifiedAt) {
137
+ return urlFor(rawFileUrl(sessionId, path, modifiedAt));
138
+ },
139
+ downloadUrl(sessionId, path) {
140
+ return urlFor(downloadFileUrl(sessionId, path));
141
+ },
142
+ upload(sessionId, file, options) {
143
+ return uploadFile(file, sessionId, { ...options, url: urlFor(FILES_UPLOAD_PATH) });
144
+ },
145
+ transcribe(blob, language, signal) {
146
+ return postTranscribe(blob, language, signal, { baseUrl: urlFor(VOICE_API) });
147
+ },
148
+ async openMux(signal) {
149
+ try {
150
+ return await openMuxSocket(urlFor(MUX_PATH), signal);
151
+ } catch (err) {
152
+ return {
153
+ ok: false,
154
+ status: "unreachable",
155
+ error: { code: "unreachable", message: err instanceof Error ? err.message : String(err) },
156
+ };
157
+ }
158
+ },
159
+ consumeMux,
160
+ settings: createHostSettings(request),
161
+ };
162
+ }
package/src/i18n.js ADDED
@@ -0,0 +1,15 @@
1
+ import { t as translate } from "@olares/lares-core/i18n/t";
2
+ import { EN, ZH } from "@olares/lares-core/i18n/mobile";
3
+ import { EN as PREVIEW_EN, ZH as PREVIEW_ZH } from "@olares/lares-core/i18n/preview";
4
+ import { EN as FILES_EN, ZH as FILES_ZH } from "@olares/lares-core/i18n/files";
5
+ import { EN as VOICE_EN, ZH as VOICE_ZH } from "@olares/lares-core/i18n/voice";
6
+ import { EN as MODEL_EN, ZH as MODEL_ZH } from "@olares/lares-core/i18n/chat-model-switch";
7
+
8
+ const catalog = {
9
+ zh: { ...PREVIEW_ZH, ...FILES_ZH, ...VOICE_ZH, ...MODEL_ZH, ...ZH },
10
+ en: { ...PREVIEW_EN, ...FILES_EN, ...VOICE_EN, ...MODEL_EN, ...EN },
11
+ };
12
+
13
+ export function createT(locale) {
14
+ return (key, params) => translate(catalog, locale, key, params);
15
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { DefineComponent } from "vue";
2
+
3
+ export type HostRequestInit = {
4
+ method?: string;
5
+ body?: unknown;
6
+ signal?: AbortSignal;
7
+ headers?: Record<string, string>;
8
+ };
9
+
10
+ export type HostResponse = {
11
+ ok: boolean;
12
+ status: number;
13
+ body?: unknown;
14
+ };
15
+
16
+ export type HostEnv = {
17
+ PROTOCOL?: string;
18
+ LARES_SUB_DOMAIN?: string;
19
+ ACCOUNT_DOMAIN?: string;
20
+ IS_PC_TEST?: string | boolean | number;
21
+ };
22
+
23
+ export type HostPorts = {
24
+ baseUrl?: string;
25
+ proxyPrefix?: string;
26
+ env?: HostEnv;
27
+ request?: (url: string, init?: HostRequestInit) => Promise<HostResponse>;
28
+ };
29
+
30
+ export const PC_TEST_PROXY: string;
31
+
32
+ export function hostConfigFromEnv(env?: HostEnv): {
33
+ baseUrl: string;
34
+ proxyPrefix: string;
35
+ };
36
+
37
+ export function defaultRequest(url: string, init?: HostRequestInit): Promise<HostResponse>;
38
+
39
+ export function createHostClient(ports?: HostPorts): {
40
+ urlFor: (path: string) => string;
41
+ probe: () => Promise<{ status: string; http?: number; error?: string }>;
42
+ ensureSession: () => Promise<{ ok: boolean; value?: { sessionId: string } }>;
43
+ history: (sessionId: string) => Promise<{ ok: boolean; value?: unknown }>;
44
+ prompt: (sessionId: string, text: string) => Promise<{ ok: boolean }>;
45
+ };
46
+
47
+ export const LaresApp: DefineComponent<{
48
+ locale?: string;
49
+ baseUrl?: string;
50
+ proxyPrefix?: string;
51
+ env?: HostEnv;
52
+ request?: (url: string, init?: HostRequestInit) => Promise<HostResponse>;
53
+ }>;
54
+
55
+ export const LaresAgentSettings: DefineComponent<{
56
+ locale?: string;
57
+ baseUrl?: string;
58
+ proxyPrefix?: string;
59
+ env?: HostEnv;
60
+ request?: (url: string, init?: HostRequestInit) => Promise<HostResponse>;
61
+ }>;
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { default as LaresApp } from "./App.vue";
2
+ export { default as LaresAgentSettings } from "./settings/AgentSettings.vue";
3
+ export { createHostClient, defaultRequest } from "./host.js";
4
+ export { hostConfigFromEnv, PC_TEST_PROXY } from "@olares/lares-core/larepass/host";
@@ -0,0 +1,220 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div class="lares-preview" role="dialog" aria-modal="true" :aria-label="t('preview')">
4
+ <header class="lares-preview__bar">
5
+ <button type="button" class="lares-preview__back" @click="$emit('close')">{{ t("chat") }}</button>
6
+ <strong class="lares-preview__name" :title="path">{{ name }}</strong>
7
+ <a
8
+ v-if="downloadHref"
9
+ class="lares-preview__link"
10
+ :href="downloadHref"
11
+ download
12
+ >{{ t("download") }}</a>
13
+ </header>
14
+ <div v-if="status === 'loading'" class="lares-preview__hint">{{ t("loading") }}</div>
15
+ <div v-else-if="status === 'error'" class="lares-preview__hint">
16
+ <p>{{ failText }}</p>
17
+ <button type="button" class="lares-preview__link" @click="$emit('retry')">{{ t("retry") }}</button>
18
+ </div>
19
+ <div v-else-if="data?.kind === 'image'" class="lares-preview__media">
20
+ <img :src="mediaSrc" :alt="name" />
21
+ </div>
22
+ <div v-else-if="data?.kind === 'video'" class="lares-preview__media">
23
+ <video :src="mediaSrc" controls playsinline />
24
+ </div>
25
+ <div v-else-if="data?.kind === 'audio'" class="lares-preview__media">
26
+ <audio :src="mediaSrc" controls />
27
+ </div>
28
+ <iframe
29
+ v-else-if="data?.kind === 'pdf'"
30
+ class="lares-preview__pdf"
31
+ :src="mediaSrc"
32
+ :title="name"
33
+ />
34
+ <div
35
+ v-else-if="data?.kind === 'markdown'"
36
+ class="lares-preview__markdown"
37
+ v-html="markdownHtml"
38
+ @click="onMarkdownClick"
39
+ />
40
+ <pre v-else-if="data?.kind === 'text'" class="lares-preview__text">{{ data.text }}</pre>
41
+ <div v-else class="lares-preview__hint">
42
+ <p>{{ t("unsupportedTitle") }}</p>
43
+ <p>{{ t("unsupported") }}</p>
44
+ </div>
45
+ <p v-if="data?.truncated" class="lares-preview__hint">{{ t("truncated") }}</p>
46
+ </div>
47
+ </Teleport>
48
+ </template>
49
+
50
+ <script>
51
+ import { fileName, workspaceLinkClickPath } from "@olares/lares-core/files/preview-workspace";
52
+ import { rewriteWorkspaceTargets } from "@olares/lares-core/files/markdown";
53
+ import { messageFromCode } from "@olares/lares-core/i18n/t";
54
+ import { renderMarkdown } from "../chat/markdown.js";
55
+
56
+ export default {
57
+ name: "LaresPreview",
58
+ props: {
59
+ path: { type: String, required: true },
60
+ sessionId: { type: String, default: "" },
61
+ status: { type: String, default: "loading" },
62
+ data: { type: Object, default: null },
63
+ error: { type: String, default: "" },
64
+ mediaSrc: { type: String, default: "" },
65
+ downloadHref: { type: String, default: "" },
66
+ hrefFor: { type: Function, default: null },
67
+ t: { type: Function, required: true },
68
+ },
69
+ emits: ["close", "retry", "open"],
70
+ computed: {
71
+ name() {
72
+ return this.data?.name || fileName(this.path);
73
+ },
74
+ failText() {
75
+ return messageFromCode(this.t, this.error, "failed");
76
+ },
77
+ markdownHtml() {
78
+ const text = this.data?.text ?? "";
79
+ const rewritten = this.hrefFor
80
+ ? rewriteWorkspaceTargets(text, this.data?.path ?? this.path, this.hrefFor)
81
+ : text;
82
+ return renderMarkdown(rewritten);
83
+ },
84
+ },
85
+ methods: {
86
+ onMarkdownClick(event) {
87
+ if (!this.sessionId) return;
88
+ const path = workspaceLinkClickPath(this.sessionId, event);
89
+ if (path === null) return;
90
+ event.preventDefault();
91
+ this.$emit("open", path);
92
+ },
93
+ },
94
+ };
95
+ </script>
96
+
97
+ <style scoped>
98
+ .lares-preview {
99
+ position: fixed;
100
+ inset: 0;
101
+ z-index: 50;
102
+ display: flex;
103
+ flex-direction: column;
104
+ min-width: 0;
105
+ min-height: 0;
106
+ padding-top: env(safe-area-inset-top, 0px);
107
+ padding-bottom: env(safe-area-inset-bottom, 0px);
108
+ background: var(--q-background-1);
109
+ color: var(--q-ink-1);
110
+ }
111
+
112
+ .lares-preview__bar {
113
+ display: flex;
114
+ gap: 8px;
115
+ align-items: center;
116
+ flex-shrink: 0;
117
+ height: 56px;
118
+ padding: 0 12px;
119
+ }
120
+
121
+ .lares-preview__name {
122
+ flex: 1;
123
+ min-width: 0;
124
+ overflow: hidden;
125
+ text-overflow: ellipsis;
126
+ white-space: nowrap;
127
+ font-size: 16px;
128
+ font-weight: 500;
129
+ }
130
+
131
+ .lares-preview__back,
132
+ .lares-preview__link {
133
+ border: 0;
134
+ background: none;
135
+ font: inherit;
136
+ color: var(--q-blue-default);
137
+ text-decoration: none;
138
+ }
139
+
140
+ .lares-preview__hint,
141
+ .lares-preview__text,
142
+ .lares-preview__markdown {
143
+ margin: 0;
144
+ padding: 12px 20px;
145
+ font-size: 14px;
146
+ color: var(--q-ink-2);
147
+ }
148
+
149
+ .lares-preview__text,
150
+ .lares-preview__markdown {
151
+ flex: 1;
152
+ min-height: 0;
153
+ overflow: auto;
154
+ color: var(--q-ink-1);
155
+ }
156
+
157
+ .lares-preview__text {
158
+ white-space: pre-wrap;
159
+ word-break: break-word;
160
+ }
161
+
162
+ .lares-preview__markdown :deep(p),
163
+ .lares-preview__markdown :deep(pre) {
164
+ margin: 0 0 10px;
165
+ }
166
+
167
+ .lares-preview__markdown :deep(ul),
168
+ .lares-preview__markdown :deep(ol) {
169
+ margin: 4px 0 12px;
170
+ padding: 0 0 0 1.35em;
171
+ }
172
+
173
+ .lares-preview__markdown :deep(ul) {
174
+ list-style: disc outside;
175
+ }
176
+
177
+ .lares-preview__markdown :deep(ol) {
178
+ list-style: decimal outside;
179
+ }
180
+
181
+ .lares-preview__markdown :deep(li + li) {
182
+ margin-top: 6px;
183
+ }
184
+
185
+ .lares-preview__markdown :deep(img) {
186
+ max-width: 100%;
187
+ height: auto;
188
+ }
189
+
190
+ .lares-preview__media {
191
+ display: grid;
192
+ flex: 1;
193
+ min-width: 0;
194
+ min-height: 0;
195
+ place-items: center;
196
+ overflow: auto;
197
+ padding: 16px 20px;
198
+ }
199
+
200
+ .lares-preview__media img,
201
+ .lares-preview__media video {
202
+ max-width: 100%;
203
+ max-height: 100%;
204
+ min-width: 0;
205
+ min-height: 0;
206
+ border-radius: 10px;
207
+ object-fit: contain;
208
+ }
209
+
210
+ .lares-preview__pdf {
211
+ flex: 1;
212
+ min-height: 0;
213
+ width: 100%;
214
+ border: 0;
215
+ }
216
+
217
+ .lares-preview__media audio {
218
+ width: min(100%, 520px);
219
+ }
220
+ </style>
package/src/runtime.js ADDED
@@ -0,0 +1,12 @@
1
+ import { createChatRuntime } from "@olares/lares-core/larepass/runtime";
2
+ import { createHostClient } from "./host.js";
3
+
4
+ let current = null;
5
+
6
+ export function connectChat(ports) {
7
+ const client = createHostClient(ports);
8
+ const key = client.urlFor("/api");
9
+ if (current?.key === key) return current;
10
+ current = Object.assign(createChatRuntime(client), { key });
11
+ return current;
12
+ }