@olares/lares-core 0.15.2 → 0.15.5
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/brand/manifest.js +2 -2
- package/files/upload-client.js +34 -9
- package/icons/mark.js +26 -9
- package/larepass/host.js +17 -0
- package/package.json +1 -1
- package/router/shim.js +41 -1
- package/voice/client.js +26 -10
package/brand/manifest.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PRODUCT_NAME, THEME_COLOR } from "./identity.js";
|
|
2
|
-
import { MARK_PATH } from "../icons/mark.js";
|
|
2
|
+
import { MARK_PATH, MARK_TYPE } from "../icons/mark.js";
|
|
3
3
|
|
|
4
4
|
export const MANIFEST_PATH = "/lares/manifest.webmanifest";
|
|
5
5
|
|
|
@@ -11,5 +11,5 @@ export const MANIFEST = {
|
|
|
11
11
|
scope: "/",
|
|
12
12
|
display: "fullscreen",
|
|
13
13
|
theme_color: THEME_COLOR,
|
|
14
|
-
icons: [{ src: MARK_PATH, sizes: "any", type:
|
|
14
|
+
icons: [{ src: MARK_PATH, sizes: "any", type: MARK_TYPE, purpose: "any" }],
|
|
15
15
|
};
|
package/files/upload-client.js
CHANGED
|
@@ -7,6 +7,31 @@ function errorCode(payload) {
|
|
|
7
7
|
: "file_upload_failed";
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* `fetch` can only authenticate with the Olares cookie, and a host whose page is
|
|
12
|
+
* cross-origin to the Host — a packaged LarePass desktop build is a `file://`
|
|
13
|
+
* page — has none, so the upload answers 401 while every RPC call succeeds.
|
|
14
|
+
* When the host injects a `request` port the upload rides the same auth as the
|
|
15
|
+
* rest of the client; without one this stays the plain cookie fetch.
|
|
16
|
+
*/
|
|
17
|
+
async function postUpload(options, url, headers, body, signal) {
|
|
18
|
+
if (typeof options.request === "function") {
|
|
19
|
+
const res = await options.request(url, { method: "POST", headers, body, signal });
|
|
20
|
+
return {
|
|
21
|
+
ok: Boolean(res?.ok),
|
|
22
|
+
status: Number(res?.status) || 0,
|
|
23
|
+
// The port already parsed the body; an empty one arrives as undefined.
|
|
24
|
+
payload: res?.body ?? null,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const res = await fetch(url, { method: "POST", headers, body, signal });
|
|
28
|
+
return {
|
|
29
|
+
ok: res.ok,
|
|
30
|
+
status: res.status,
|
|
31
|
+
payload: await res.json().catch(() => null),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
10
35
|
export async function uploadFile(file, sessionId, options = {}) {
|
|
11
36
|
const controller = new AbortController();
|
|
12
37
|
const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
|
@@ -14,21 +39,21 @@ export async function uploadFile(file, sessionId, options = {}) {
|
|
|
14
39
|
if (options.signal?.aborted) controller.abort(options.signal.reason);
|
|
15
40
|
else options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
16
41
|
try {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
42
|
+
const { ok, status, payload } = await postUpload(
|
|
43
|
+
options,
|
|
44
|
+
options.url ?? FILES_UPLOAD_PATH,
|
|
45
|
+
{
|
|
20
46
|
"content-type": file.type || "application/octet-stream",
|
|
21
47
|
"x-lares-file-name": encodeURIComponent(file.name || "file"),
|
|
22
48
|
"x-lares-session-id": sessionId,
|
|
23
49
|
"x-lares-upload-request-id": options.requestId ?? crypto.randomUUID(),
|
|
24
50
|
},
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!response.ok) {
|
|
51
|
+
file,
|
|
52
|
+
controller.signal,
|
|
53
|
+
);
|
|
54
|
+
if (!ok) {
|
|
30
55
|
const error = new Error(errorCode(payload));
|
|
31
|
-
error.status =
|
|
56
|
+
error.status = status;
|
|
32
57
|
throw error;
|
|
33
58
|
}
|
|
34
59
|
if (!payload || typeof payload.path !== "string") throw new Error("file_upload_failed");
|
package/icons/mark.js
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
import { PRODUCT_NAME } from "../brand/identity.js";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Product mark: favicon, PWA icon, CSS splash, and slot occupants.
|
|
5
|
-
*
|
|
4
|
+
* Product mark: favicon, PWA icon, CSS splash, and slot occupants. Consumers
|
|
5
|
+
* cache this URL, so the path and the SVG encoding are fixed — publishing the
|
|
6
|
+
* artwork at a new path leaves stale favicons behind.
|
|
7
|
+
* Geometry is the design delivery verbatim (ids renamed, aria-label injected).
|
|
8
|
+
* Keep it a vector — favicon rasterizers drop <image> data URIs.
|
|
6
9
|
*/
|
|
10
|
+
export const MARKET_ICON_URL = "https://app.cdn.olares.com/appstore/lares/icon_new.png";
|
|
11
|
+
|
|
7
12
|
export const MARK_PATH = "/lares/mark.svg";
|
|
13
|
+
export const MARK_TYPE = "image/svg+xml";
|
|
8
14
|
|
|
9
|
-
export const MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0
|
|
15
|
+
export const MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" fill="none" role="img" aria-label="${PRODUCT_NAME}">
|
|
10
16
|
<defs>
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
<
|
|
17
|
+
<filter id="lares-mark-shadow" x="0" y="0" width="256" height="257.829" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
18
|
+
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
|
19
|
+
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
|
20
|
+
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
|
21
|
+
<feOffset dy="1.82857"/>
|
|
22
|
+
<feGaussianBlur stdDeviation="0.914286"/>
|
|
23
|
+
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
|
|
24
|
+
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.5 0"/>
|
|
25
|
+
<feBlend mode="normal" in2="shape" result="effect1_innerShadow"/>
|
|
26
|
+
</filter>
|
|
27
|
+
<linearGradient id="lares-mark" x1="128" y1="0" x2="128" y2="256" gradientUnits="userSpaceOnUse">
|
|
28
|
+
<stop stop-color="#FFED52"/>
|
|
29
|
+
<stop offset="1" stop-color="#EBD304"/>
|
|
14
30
|
</linearGradient>
|
|
15
31
|
</defs>
|
|
16
|
-
<
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
<g filter="url(#lares-mark-shadow)">
|
|
33
|
+
<rect width="256" height="256" fill="url(#lares-mark)"/>
|
|
34
|
+
<path fill="#252121" d="M83.5926 125.858C85.7382 144.539 100.433 157.223 120.829 157.998L128.063 158.273H142.829C170.407 158.273 186.978 179.868 179.84 206.507H115.676C86.0222 206.506 68.2049 183.286 75.8798 154.643L83.5926 125.858ZM104.576 47.5429C133.193 47.5429 150.386 69.9505 142.98 97.5918L128.319 152.309C99.7025 152.309 82.5086 129.9 89.915 102.259L100.338 63.3618C102.534 55.1648 97.833 48.3464 89.5437 47.7079L87.3945 47.5429H104.576Z"/>
|
|
35
|
+
</g>
|
|
19
36
|
</svg>`;
|
|
20
37
|
|
|
21
38
|
/** Inline form for CSS `background-image`, which cannot reference a route that may 404 mid-boot. */
|
package/larepass/host.js
CHANGED
|
@@ -45,6 +45,23 @@ export function hostConfigFromEnv(env = {}) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Every key the host may hand down. The list is canonical: a host spreads these
|
|
50
|
+
* onto the components as props and `createHostClient` reads them back off the
|
|
51
|
+
* object, so a key that exists in one place and not the other is dropped in
|
|
52
|
+
* silence — which is how `socketProtocol` shipped inert once already.
|
|
53
|
+
*/
|
|
54
|
+
export const HOST_PORT_KEYS = ["baseUrl", "proxyPrefix", "env", "request", "socketProtocol"];
|
|
55
|
+
|
|
56
|
+
/** Collect the port keys off any carrier (a component instance included). */
|
|
57
|
+
export function pickHostPorts(source = {}) {
|
|
58
|
+
const ports = {};
|
|
59
|
+
for (const key of HOST_PORT_KEYS) {
|
|
60
|
+
ports[key] = source[key];
|
|
61
|
+
}
|
|
62
|
+
return ports;
|
|
63
|
+
}
|
|
64
|
+
|
|
48
65
|
/** Resolve the live Host origin. Explicit baseUrl/proxyPrefix win over env. */
|
|
49
66
|
export function hostTarget(ports = {}) {
|
|
50
67
|
const fromEnv = hostConfigFromEnv(ports.env);
|
package/package.json
CHANGED
package/router/shim.js
CHANGED
|
@@ -43,7 +43,9 @@ export function shimBudget(suffix) {
|
|
|
43
43
|
};
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** @returns {Record<string, string>} */
|
|
46
47
|
export function shimRequestHeaders(incoming, env = process.env) {
|
|
48
|
+
/** @type {Record<string, string>} */
|
|
47
49
|
const headers = {};
|
|
48
50
|
for (const [key, value] of Object.entries(incoming)) {
|
|
49
51
|
if (value == null || DROPPED_REQ.has(key.toLowerCase())) continue;
|
|
@@ -107,6 +109,36 @@ function serveCachedModels(req, res) {
|
|
|
107
109
|
);
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
function downstreamCancellation(req, res) {
|
|
113
|
+
const controller = new AbortController();
|
|
114
|
+
let finished = false;
|
|
115
|
+
|
|
116
|
+
const cleanup = () => {
|
|
117
|
+
req.off("aborted", cancel);
|
|
118
|
+
res.off("finish", finish);
|
|
119
|
+
res.off("close", close);
|
|
120
|
+
};
|
|
121
|
+
const cancel = () => {
|
|
122
|
+
if (!controller.signal.aborted) {
|
|
123
|
+
controller.abort(new Error("LLM downstream disconnected"));
|
|
124
|
+
}
|
|
125
|
+
cleanup();
|
|
126
|
+
};
|
|
127
|
+
const finish = () => {
|
|
128
|
+
finished = true;
|
|
129
|
+
cleanup();
|
|
130
|
+
};
|
|
131
|
+
const close = () => {
|
|
132
|
+
if (!finished) cancel();
|
|
133
|
+
else cleanup();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
req.once("aborted", cancel);
|
|
137
|
+
res.once("finish", finish);
|
|
138
|
+
res.once("close", close);
|
|
139
|
+
return controller;
|
|
140
|
+
}
|
|
141
|
+
|
|
110
142
|
export function proxyToRouter(req, res, env = process.env) {
|
|
111
143
|
if (isModelsGet(req)) {
|
|
112
144
|
serveCachedModels(req, res);
|
|
@@ -121,6 +153,7 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
121
153
|
const headers = shimRequestHeaders(req.headers, env);
|
|
122
154
|
const transport = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
123
155
|
const method = (req.method ?? "GET").toUpperCase();
|
|
156
|
+
const cancellation = downstreamCancellation(req, res);
|
|
124
157
|
|
|
125
158
|
const run = async () => {
|
|
126
159
|
let body;
|
|
@@ -132,16 +165,22 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
132
165
|
if (!budget.audio && carriesWebpImage(body)) body = await transcodeWebpImages(body);
|
|
133
166
|
headers["content-length"] = String(body.length);
|
|
134
167
|
}
|
|
168
|
+
if (cancellation.signal.aborted) return;
|
|
135
169
|
|
|
136
170
|
const upstream = transport(
|
|
137
171
|
target,
|
|
138
|
-
{ method, headers, timeout: budget.timeoutMs },
|
|
172
|
+
{ method, headers, timeout: budget.timeoutMs, signal: cancellation.signal },
|
|
139
173
|
(up) => {
|
|
174
|
+
if (cancellation.signal.aborted) {
|
|
175
|
+
up.destroy();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
140
178
|
res.writeHead(up.statusCode ?? 502, shimResponseHeaders(up.headers));
|
|
141
179
|
up.pipe(res);
|
|
142
180
|
},
|
|
143
181
|
);
|
|
144
182
|
upstream.on("error", (err) => {
|
|
183
|
+
if (cancellation.signal.aborted) return;
|
|
145
184
|
if (res.headersSent) {
|
|
146
185
|
res.destroy();
|
|
147
186
|
return;
|
|
@@ -161,6 +200,7 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
161
200
|
};
|
|
162
201
|
|
|
163
202
|
void run().catch((err) => {
|
|
203
|
+
if (cancellation.signal.aborted) return;
|
|
164
204
|
if (res.headersSent) {
|
|
165
205
|
res.destroy();
|
|
166
206
|
return;
|
package/voice/client.js
CHANGED
|
@@ -62,23 +62,39 @@ export function mergeTranscript(draft, transcript) {
|
|
|
62
62
|
return `${base} ${text}`;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
function transcribeErrorCode(payload) {
|
|
66
|
+
const code = payload && typeof payload === "object" ? payload.error?.code : undefined;
|
|
67
|
+
return typeof code === "string" ? code : "voice_failed";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The only voice call that carries a body, and so the only one that cannot go
|
|
72
|
+
* through `createHostSettings`. Like the upload it must accept the host's
|
|
73
|
+
* `request` port: a cross-origin host page sends no Olares cookie, so a plain
|
|
74
|
+
* `fetch` here answers 401 even though `/config`, `/status` and `/models` — which
|
|
75
|
+
* do ride the port — all succeed.
|
|
76
|
+
*/
|
|
65
77
|
export async function postTranscribe(blob, language, signal, options = {}) {
|
|
66
78
|
const query = language ? `?language=${encodeURIComponent(language)}` : "";
|
|
67
79
|
const base = String(options.baseUrl ?? API).replace(/\/$/, "");
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
signal
|
|
73
|
-
|
|
80
|
+
const url = `${base}/transcribe${query}`;
|
|
81
|
+
const headers = { "content-type": blob.type || "audio/webm" };
|
|
82
|
+
|
|
83
|
+
if (typeof options.request === "function") {
|
|
84
|
+
const res = await options.request(url, { method: "POST", headers, body: blob, signal });
|
|
85
|
+
if (!res?.ok) throw new Error(transcribeErrorCode(res?.body));
|
|
86
|
+
return String(res?.body?.text ?? "").trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const res = await fetch(url, { method: "POST", headers, body: blob, signal });
|
|
74
90
|
if (!res.ok) {
|
|
75
|
-
let
|
|
91
|
+
let payload = null;
|
|
76
92
|
try {
|
|
77
|
-
|
|
93
|
+
payload = await res.json();
|
|
78
94
|
} catch {
|
|
79
|
-
/*
|
|
95
|
+
/* fall back to the generic code */
|
|
80
96
|
}
|
|
81
|
-
throw new Error(
|
|
97
|
+
throw new Error(transcribeErrorCode(payload));
|
|
82
98
|
}
|
|
83
99
|
return String((await res.json())?.text ?? "").trim();
|
|
84
100
|
}
|