@olares/lares-core 0.15.1 → 0.15.4
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/identity.js +27 -0
- package/brand/manifest.js +2 -2
- package/drive/paths.js +3 -0
- package/files/preview-groups.js +1 -1
- package/files/preview.js +28 -5
- package/files/upload-client.js +34 -9
- package/i18n/preview.js +4 -2
- package/icons/mark.js +26 -9
- package/package.json +5 -1
- package/router/catalog-cache.d.ts +29 -0
- package/router/catalog-cache.js +144 -0
- package/router/catalog-sync.js +44 -8
- package/router/models.d.ts +1 -0
- package/router/models.js +5 -2
- package/router/search.js +3 -12
- package/router/shim.js +78 -2
- package/router/stt.js +13 -22
- package/voice/client.js +26 -10
- package/workspace/path.js +16 -1
package/brand/identity.js
CHANGED
|
@@ -20,7 +20,9 @@ export function identityPrompt() {
|
|
|
20
20
|
`You are ${PRODUCT_NAME}, a helpful assistant running on ${PLATFORM_NAME}.`,
|
|
21
21
|
`Prefer olares-cli for ${PLATFORM_NAME} platform tasks when skills apply.`,
|
|
22
22
|
`olares-cli is on PATH; edge login materializes HOME / OLARES_CLI_* for bash when the user opens ${PRODUCT_NAME} via the ${PLATFORM_NAME} entrance.`,
|
|
23
|
+
"For image, video, audio, or 3D generation and creation, follow the lares-media-create skill.",
|
|
23
24
|
"Use read/write/edit for files; use background jobs for long shell work.",
|
|
25
|
+
"User-visible files belong in the session workspace (cwd) as relative paths. Do not write them under /app: that is the application overlay, and the chat preview cannot open it.",
|
|
24
26
|
"For the current date or time, run `date`; the process TZ is the user's configured Olares time zone.",
|
|
25
27
|
"Images the user attaches are already in context; read_image is only for image files that exist on disk, never for an attachment.",
|
|
26
28
|
`If asked who you are, answer as ${PRODUCT_NAME} on ${PLATFORM_NAME}.`,
|
|
@@ -47,7 +49,9 @@ export function agentsMarkdown() {
|
|
|
47
49
|
You are helping inside an ${PLATFORM_NAME} workspace via ${PRODUCT_NAME}.
|
|
48
50
|
|
|
49
51
|
- Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
|
|
52
|
+
- For image / video / audio / 3D generation, follow the lares-media-create skill.
|
|
50
53
|
- Stay inside the workspace for file edits unless the user explicitly asks otherwise.
|
|
54
|
+
- Write files the user should open as workspace-relative paths (session cwd). \`/app\` is the running application and is not previewable.
|
|
51
55
|
- Prefer structured fs tools (read / write / edit) over shell for file work.
|
|
52
56
|
- Long-running shell work can use background jobs; check results with job_output.
|
|
53
57
|
- \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
|
|
@@ -59,6 +63,29 @@ You are helping inside an ${PLATFORM_NAME} workspace via ${PRODUCT_NAME}.
|
|
|
59
63
|
export const LEGACY_AGENTS_SEEDS = [
|
|
60
64
|
`# AGENTS.md
|
|
61
65
|
|
|
66
|
+
You are helping inside an Olares workspace via Lares.
|
|
67
|
+
|
|
68
|
+
- Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
|
|
69
|
+
- For image / video / audio / 3D generation, follow the lares-media-create skill.
|
|
70
|
+
- Stay inside the workspace for file edits unless the user explicitly asks otherwise.
|
|
71
|
+
- Prefer structured fs tools (read / write / edit) over shell for file work.
|
|
72
|
+
- Long-running shell work can use background jobs; check results with job_output.
|
|
73
|
+
- \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
|
|
74
|
+
- If asked who you are, you are Lares on Olares.
|
|
75
|
+
`,
|
|
76
|
+
`# AGENTS.md
|
|
77
|
+
|
|
78
|
+
You are helping inside an Olares workspace via Lares.
|
|
79
|
+
|
|
80
|
+
- Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
|
|
81
|
+
- Stay inside the workspace for file edits unless the user explicitly asks otherwise.
|
|
82
|
+
- Prefer structured fs tools (read / write / edit) over shell for file work.
|
|
83
|
+
- Long-running shell work can use background jobs; check results with job_output.
|
|
84
|
+
- \`@path\` in a user message is workspace-relative, not absolute; \`/id\` names a skill.
|
|
85
|
+
- If asked who you are, you are Lares on Olares.
|
|
86
|
+
`,
|
|
87
|
+
`# AGENTS.md
|
|
88
|
+
|
|
62
89
|
You are helping inside an Olares workspace via Dina.
|
|
63
90
|
|
|
64
91
|
- Prefer olares-cli skills for platform tasks (market, cluster, files, router, …).
|
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/drive/paths.js
CHANGED
|
@@ -29,6 +29,9 @@ const DATA_NAMES = new Map([
|
|
|
29
29
|
["audio/aac", "download.aac"],
|
|
30
30
|
["audio/flac", "download.flac"],
|
|
31
31
|
["application/pdf", "download.pdf"],
|
|
32
|
+
["model/gltf-binary", "download.glb"],
|
|
33
|
+
["model/gltf+json", "download.gltf"],
|
|
34
|
+
["model/obj", "download.obj"],
|
|
32
35
|
]);
|
|
33
36
|
|
|
34
37
|
function dataUrlFilename(mediaType) {
|
package/files/preview-groups.js
CHANGED
package/files/preview.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
resolveExistingWorkspacePath,
|
|
8
8
|
resolveWorkspaceRoot,
|
|
9
9
|
workspaceCandidate,
|
|
10
|
+
workspaceFileAlias,
|
|
10
11
|
} from "../workspace/path.js";
|
|
11
12
|
import { materializeFilesFile } from "./preview-cache.js";
|
|
12
13
|
|
|
@@ -36,6 +37,11 @@ const AUDIO_TYPES = new Map([
|
|
|
36
37
|
[".aac", "audio/aac"],
|
|
37
38
|
[".flac", "audio/flac"],
|
|
38
39
|
]);
|
|
40
|
+
const MODEL3D_TYPES = new Map([
|
|
41
|
+
[".glb", "model/gltf-binary"],
|
|
42
|
+
[".gltf", "model/gltf+json"],
|
|
43
|
+
[".obj", "model/obj"],
|
|
44
|
+
]);
|
|
39
45
|
const TEXT_EXTENSIONS = new Set([
|
|
40
46
|
"",
|
|
41
47
|
".txt", ".log", ".json", ".csv", ".tsv", ".yaml", ".yml", ".toml", ".xml",
|
|
@@ -109,6 +115,7 @@ export function previewTypeForName(name) {
|
|
|
109
115
|
if (IMAGE_TYPES.has(extension)) return { kind: "image", mediaType: IMAGE_TYPES.get(extension) };
|
|
110
116
|
if (VIDEO_TYPES.has(extension)) return { kind: "video", mediaType: VIDEO_TYPES.get(extension) };
|
|
111
117
|
if (AUDIO_TYPES.has(extension)) return { kind: "audio", mediaType: AUDIO_TYPES.get(extension) };
|
|
118
|
+
if (MODEL3D_TYPES.has(extension)) return { kind: "model3d", mediaType: MODEL3D_TYPES.get(extension) };
|
|
112
119
|
if (extension === ".pdf") return { kind: "pdf", mediaType: "application/pdf" };
|
|
113
120
|
if (extension === ".md" || extension === ".markdown") {
|
|
114
121
|
return { kind: "markdown", mediaType: "text/markdown; charset=utf-8" };
|
|
@@ -122,8 +129,24 @@ export async function resolveWorkspaceFile(workspacePath, requestedPath) {
|
|
|
122
129
|
throw new HttpError("path_invalid", 400, "file path is required");
|
|
123
130
|
}
|
|
124
131
|
const root = await resolveWorkspaceRoot(workspacePath);
|
|
125
|
-
|
|
126
|
-
|
|
132
|
+
let candidate;
|
|
133
|
+
try {
|
|
134
|
+
candidate = workspaceCandidate(root, requestedPath);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const alias = workspaceFileAlias(root, requestedPath);
|
|
137
|
+
if (!alias || error?.code !== "path_forbidden") throw error;
|
|
138
|
+
try {
|
|
139
|
+
candidate = workspaceCandidate(root, alias);
|
|
140
|
+
} catch {
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const absolutePath = await resolveExistingWorkspacePath(root, candidate).catch((error) => {
|
|
145
|
+
if (workspaceFileAlias(root, requestedPath) && error?.code === "file_not_found") {
|
|
146
|
+
throw new HttpError("path_forbidden", 403, "path leaves the session workspace");
|
|
147
|
+
}
|
|
148
|
+
throw error;
|
|
149
|
+
});
|
|
127
150
|
const info = await stat(absolutePath).catch(() => {
|
|
128
151
|
throw new HttpError("file_not_found", 404, "file was not found");
|
|
129
152
|
});
|
|
@@ -284,12 +307,12 @@ async function sendFile(req, res, file, disposition) {
|
|
|
284
307
|
}
|
|
285
308
|
|
|
286
309
|
export async function sendRawFile(req, res, file, deps) {
|
|
287
|
-
if (!["image", "video", "audio", "pdf"].includes(file.kind)) {
|
|
310
|
+
if (!["image", "video", "audio", "pdf", "model3d"].includes(file.kind)) {
|
|
288
311
|
throw new HttpError("preview_unsupported", 415, "raw preview is not supported for this file");
|
|
289
312
|
}
|
|
290
313
|
// Video and audio are range-streamed, so their total size is not browser
|
|
291
|
-
// memory pressure. Images and
|
|
292
|
-
if (["image", "pdf"].includes(file.kind) && file.size > MAX_RAW_BYTES) {
|
|
314
|
+
// memory pressure. Images, PDFs, and 3D meshes are consumed as whole documents.
|
|
315
|
+
if (["image", "pdf", "model3d"].includes(file.kind) && file.size > MAX_RAW_BYTES) {
|
|
293
316
|
throw new HttpError("file_too_large", 413, `file exceeds ${MAX_RAW_BYTES} bytes`);
|
|
294
317
|
}
|
|
295
318
|
return sendFile(req, res, await ensurePreviewBytes(file, deps), "inline");
|
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/i18n/preview.js
CHANGED
|
@@ -15,7 +15,8 @@ export const ZH = {
|
|
|
15
15
|
retry: "重试",
|
|
16
16
|
truncated: "文件较大,仅显示前 1 MB 内容。",
|
|
17
17
|
unsupportedTitle: "暂不支持预览这种文件",
|
|
18
|
-
unsupported: "目前支持图片、PDF、音视频、Markdown 和文本文件。",
|
|
18
|
+
unsupported: "目前支持图片、PDF、音视频、3D 模型、Markdown 和文本文件。",
|
|
19
|
+
model3dHint: "3D 预览在电脑端打开。可先下载此文件。",
|
|
19
20
|
"error.file_not_found": "文件不存在或已被移动",
|
|
20
21
|
"error.file_changed": "文件在打开时发生变化,请重试",
|
|
21
22
|
"error.path_not_file": "这个路径不是文件,无法预览",
|
|
@@ -44,7 +45,8 @@ export const EN = {
|
|
|
44
45
|
retry: "Retry",
|
|
45
46
|
truncated: "This file is large; only the first 1 MB is shown.",
|
|
46
47
|
unsupportedTitle: "This file type cannot be previewed yet",
|
|
47
|
-
unsupported: "Images, PDF, audio, video, Markdown, and text files are supported.",
|
|
48
|
+
unsupported: "Images, PDF, audio, video, 3D models, Markdown, and text files are supported.",
|
|
49
|
+
model3dHint: "3D preview is available on desktop. You can download this file.",
|
|
48
50
|
"error.file_not_found": "The file no longer exists",
|
|
49
51
|
"error.file_changed": "The file changed while it was opening; try again",
|
|
50
52
|
"error.path_not_file": "This path is not a file, so it cannot be previewed",
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-core",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.4",
|
|
4
4
|
"description": "Lares 共享业务核心:Router、workspace、文件/媒体、Olares 身份(无 UI、无平台壳)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -44,6 +44,10 @@
|
|
|
44
44
|
"./i18n/preview": "./i18n/preview.js",
|
|
45
45
|
"./i18n/mobile": "./i18n/mobile.js",
|
|
46
46
|
"./router/catalog": "./router/catalog.js",
|
|
47
|
+
"./router/catalog-cache": {
|
|
48
|
+
"types": "./router/catalog-cache.d.ts",
|
|
49
|
+
"default": "./router/catalog-cache.js"
|
|
50
|
+
},
|
|
47
51
|
"./router/gateway": "./router/gateway.js",
|
|
48
52
|
"./router/models": {
|
|
49
53
|
"types": "./router/models.d.ts",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RouterCatalogRow } from "./catalog.js";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_TTL_MS: number;
|
|
4
|
+
|
|
5
|
+
export function catalogSeedPath(dataDir?: string): string | null;
|
|
6
|
+
export function writeCatalogSeed(payload: unknown, dataDir?: string): void;
|
|
7
|
+
|
|
8
|
+
export interface CatalogSnapshot {
|
|
9
|
+
payload: unknown;
|
|
10
|
+
rows: RouterCatalogRow[];
|
|
11
|
+
fetchedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class CatalogCache {
|
|
15
|
+
constructor(options?: {
|
|
16
|
+
ttlMs?: number;
|
|
17
|
+
fetch?: typeof fetch;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
dataDir?: string;
|
|
20
|
+
});
|
|
21
|
+
reset(): void;
|
|
22
|
+
invalidate(): void;
|
|
23
|
+
seed(payload: unknown): void;
|
|
24
|
+
snapshot(): CatalogSnapshot;
|
|
25
|
+
restore(snap: CatalogSnapshot | { payload: unknown; rows?: RouterCatalogRow[]; fetchedAt?: number } | null): void;
|
|
26
|
+
get(): Promise<CatalogSnapshot>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const catalogCache: CatalogCache;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { routerCatalogRows } from "./catalog.js";
|
|
4
|
+
import { routerGatewayUrl, routerHeaders } from "./gateway.js";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_TTL_MS = 15_000;
|
|
7
|
+
|
|
8
|
+
export function catalogSeedPath(dataDir = process.env.LARES_DATA_DIR?.trim()) {
|
|
9
|
+
return dataDir ? join(dataDir, "router-catalog.json") : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function writeCatalogSeed(payload, dataDir) {
|
|
13
|
+
const path = catalogSeedPath(dataDir);
|
|
14
|
+
if (!path || payload == null || typeof payload !== "object") return;
|
|
15
|
+
writeFileSync(path, JSON.stringify(payload), { mode: 0o600 });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readCatalogSeed(dataDir) {
|
|
19
|
+
const path = catalogSeedPath(dataDir);
|
|
20
|
+
if (!path) return null;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
23
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
24
|
+
} catch {
|
|
25
|
+
// Missing or unreadable seed is a cold start, not a failure.
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* In-process Router /models cache. TTL plus coalesced upstream fetches; a
|
|
32
|
+
* seed file lets the dsh child reuse the boot process's catalog read.
|
|
33
|
+
*/
|
|
34
|
+
export class CatalogCache {
|
|
35
|
+
/**
|
|
36
|
+
* @param {{
|
|
37
|
+
* ttlMs?: number,
|
|
38
|
+
* fetch?: typeof fetch,
|
|
39
|
+
* now?: () => number,
|
|
40
|
+
* dataDir?: string,
|
|
41
|
+
* }} [options]
|
|
42
|
+
*/
|
|
43
|
+
constructor(options = {}) {
|
|
44
|
+
this.ttlMs = options.ttlMs && options.ttlMs > 0 ? options.ttlMs : DEFAULT_TTL_MS;
|
|
45
|
+
this.fetchImpl = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
46
|
+
this.now = options.now ?? Date.now;
|
|
47
|
+
this.dataDir = options.dataDir;
|
|
48
|
+
this.reset();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
reset() {
|
|
52
|
+
this.payload = null;
|
|
53
|
+
this.rows = [];
|
|
54
|
+
this.fetchedAt = 0;
|
|
55
|
+
this.inflight = null;
|
|
56
|
+
this.primed = false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
invalidate() {
|
|
60
|
+
this.fetchedAt = 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {unknown} payload
|
|
65
|
+
*/
|
|
66
|
+
seed(payload) {
|
|
67
|
+
this.primed = true;
|
|
68
|
+
this.accept(payload);
|
|
69
|
+
writeCatalogSeed(payload, this.dataDir);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
snapshot() {
|
|
73
|
+
return { payload: this.payload, rows: this.rows, fetchedAt: this.fetchedAt };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Revert to a prior snapshot after a later consumer (settings, default
|
|
78
|
+
* selection) failed so search / STT / chat keep one catalog.
|
|
79
|
+
* @param {{ payload: unknown, rows?: unknown[], fetchedAt?: number } | null} snap
|
|
80
|
+
*/
|
|
81
|
+
restore(snap) {
|
|
82
|
+
if (snap?.payload == null) {
|
|
83
|
+
this.payload = null;
|
|
84
|
+
this.rows = [];
|
|
85
|
+
this.fetchedAt = 0;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.payload = snap.payload;
|
|
89
|
+
this.rows = Array.isArray(snap.rows) ? snap.rows : routerCatalogRows(snap.payload);
|
|
90
|
+
this.fetchedAt = Number.isFinite(snap.fetchedAt) ? snap.fetchedAt : 0;
|
|
91
|
+
this.primed = true;
|
|
92
|
+
writeCatalogSeed(snap.payload, this.dataDir);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async get() {
|
|
96
|
+
if (this.payload && this.fetchedAt !== 0 && this.now() - this.fetchedAt < this.ttlMs) {
|
|
97
|
+
return this.snapshot();
|
|
98
|
+
}
|
|
99
|
+
if (this.inflight) return this.inflight;
|
|
100
|
+
const pending = this.load();
|
|
101
|
+
const shared = pending.finally(() => {
|
|
102
|
+
if (this.inflight === shared) this.inflight = null;
|
|
103
|
+
});
|
|
104
|
+
this.inflight = shared;
|
|
105
|
+
return shared;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async load() {
|
|
109
|
+
if (!this.primed) {
|
|
110
|
+
this.primed = true;
|
|
111
|
+
const seeded = readCatalogSeed(this.dataDir);
|
|
112
|
+
if (seeded) {
|
|
113
|
+
this.accept(seeded);
|
|
114
|
+
return this.snapshot();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const response = await this.fetchImpl(`${routerGatewayUrl()}/models?include_not_ready=true`, {
|
|
118
|
+
method: "GET",
|
|
119
|
+
headers: routerHeaders(),
|
|
120
|
+
signal: AbortSignal.timeout(15_000),
|
|
121
|
+
});
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
throw Object.assign(new Error(`Router /models returned ${response.status}`), {
|
|
124
|
+
code: "router_unavailable",
|
|
125
|
+
status: 503,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const payload = await response.json();
|
|
129
|
+
this.accept(payload);
|
|
130
|
+
writeCatalogSeed(payload, this.dataDir);
|
|
131
|
+
return this.snapshot();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {unknown} payload
|
|
136
|
+
*/
|
|
137
|
+
accept(payload) {
|
|
138
|
+
this.payload = payload;
|
|
139
|
+
this.rows = routerCatalogRows(payload);
|
|
140
|
+
this.fetchedAt = this.now();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const catalogCache = new CatalogCache();
|
package/router/catalog-sync.js
CHANGED
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
import { createInFlightCoalescer } from "../tools/async.js";
|
|
2
|
+
import { catalogCache } from "./catalog-cache.js";
|
|
2
3
|
import {
|
|
3
4
|
LLM_SETTINGS_NS,
|
|
4
5
|
ROUTER_PROVIDER_ID,
|
|
5
6
|
catalogSettingsOps,
|
|
7
|
+
chatModelsFromRouterCatalog,
|
|
6
8
|
defaultNeedsRepair,
|
|
7
|
-
fetchChatModels,
|
|
8
9
|
listedCatalog,
|
|
9
10
|
parseDefaultModelRequest,
|
|
10
11
|
pickDefaultModel,
|
|
11
12
|
unregisteredRouterFailure,
|
|
12
13
|
} from "./models.js";
|
|
14
|
+
import { forgetSttModel } from "./stt.js";
|
|
13
15
|
|
|
14
16
|
function messageOf(err) {
|
|
15
17
|
return err instanceof Error ? err.message : String(err);
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
let adapterRevision = 0;
|
|
21
|
+
/** @type {Set<(revision: number) => void>} */
|
|
22
|
+
const revisionListeners = new Set();
|
|
23
|
+
|
|
24
|
+
export function catalogRevision() {
|
|
25
|
+
return adapterRevision;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {(revision: number) => void} listener */
|
|
29
|
+
export function onCatalogRevision(listener) {
|
|
30
|
+
revisionListeners.add(listener);
|
|
31
|
+
return () => revisionListeners.delete(listener);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bumpRevision() {
|
|
35
|
+
adapterRevision += 1;
|
|
36
|
+
for (const listener of revisionListeners) listener(adapterRevision);
|
|
37
|
+
}
|
|
38
|
+
|
|
18
39
|
/**
|
|
19
40
|
* @param {{
|
|
20
41
|
* mutateSettings: (ns: string, ops: unknown[]) => Promise<void>,
|
|
@@ -23,13 +44,23 @@ function messageOf(err) {
|
|
|
23
44
|
* }} ports
|
|
24
45
|
*/
|
|
25
46
|
async function performRefresh(ports) {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
const previous = catalogCache.snapshot();
|
|
48
|
+
catalogCache.invalidate();
|
|
49
|
+
try {
|
|
50
|
+
const { payload } = await catalogCache.get();
|
|
51
|
+
const models = chatModelsFromRouterCatalog(payload);
|
|
52
|
+
await ports.mutateSettings(LLM_SETTINGS_NS, catalogSettingsOps(models));
|
|
53
|
+
const current = ports.currentSelection();
|
|
54
|
+
if (defaultNeedsRepair(current, models)) {
|
|
55
|
+
await ports.saveSelection({ provider: ROUTER_PROVIDER_ID, model: pickDefaultModel(models).id });
|
|
56
|
+
}
|
|
57
|
+
forgetSttModel();
|
|
58
|
+
bumpRevision();
|
|
59
|
+
return models;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
catalogCache.restore(previous);
|
|
62
|
+
throw err;
|
|
31
63
|
}
|
|
32
|
-
return models;
|
|
33
64
|
}
|
|
34
65
|
|
|
35
66
|
const coalesceRefresh = createInFlightCoalescer();
|
|
@@ -83,5 +114,10 @@ export async function saveDefault(ports, request) {
|
|
|
83
114
|
}
|
|
84
115
|
|
|
85
116
|
export function catalogState(ports, listed) {
|
|
86
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
default: currentDefault(ports),
|
|
119
|
+
models: listed.models,
|
|
120
|
+
failures: listed.failures,
|
|
121
|
+
revision: catalogRevision(),
|
|
122
|
+
};
|
|
87
123
|
}
|
package/router/models.d.ts
CHANGED
package/router/models.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { writeCatalogSeed } from "./catalog-cache.js";
|
|
1
2
|
import { routerCatalogRows } from "./catalog.js";
|
|
2
3
|
import { routerAuthHeaders, routerGatewayUrl, routerShimBaseUrl } from "./gateway.js";
|
|
3
4
|
|
|
@@ -72,7 +73,7 @@ export async function fetchChatModels() {
|
|
|
72
73
|
*/
|
|
73
74
|
export async function fetchRouterModels(env) {
|
|
74
75
|
const routerUrl = String(env.routerUrl ?? "").replace(/\/+$/, "") || routerGatewayUrl();
|
|
75
|
-
const res = await fetch(`${routerUrl}/models`, {
|
|
76
|
+
const res = await fetch(`${routerUrl}/models?include_not_ready=true`, {
|
|
76
77
|
method: "GET",
|
|
77
78
|
headers: {
|
|
78
79
|
...routerAuthHeaders(env.routerApiKey, env.olaresAppId),
|
|
@@ -81,7 +82,9 @@ export async function fetchRouterModels(env) {
|
|
|
81
82
|
signal: AbortSignal.timeout(15_000),
|
|
82
83
|
});
|
|
83
84
|
if (!res.ok) throw new Error(`Router /models returned ${res.status}`);
|
|
84
|
-
|
|
85
|
+
const payload = await res.json();
|
|
86
|
+
writeCatalogSeed(payload, env.dataDir);
|
|
87
|
+
return modelsFromRouterCatalog(payload);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
export function parseDefaultModelRequest(request) {
|
package/router/search.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { catalogCache } from "./catalog-cache.js";
|
|
1
2
|
import { routerCatalogRows } from "./catalog.js";
|
|
2
3
|
import { routerGatewayUrl, routerHeaders } from "./gateway.js";
|
|
3
4
|
|
|
@@ -57,18 +58,8 @@ export function searchSourcesFromRouter(payload) {
|
|
|
57
58
|
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
58
59
|
|
|
59
60
|
export async function fetchRouterSearchModels() {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
headers: routerHeaders(),
|
|
63
|
-
signal: AbortSignal.timeout(15_000),
|
|
64
|
-
});
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
throw Object.assign(new Error(`Router /models returned ${response.status}`), {
|
|
67
|
-
code: "router_unavailable",
|
|
68
|
-
status: 503,
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
return searchModelsFromRouterCatalog(await response.json());
|
|
61
|
+
const { payload } = await catalogCache.get();
|
|
62
|
+
return searchModelsFromRouterCatalog(payload);
|
|
72
63
|
}
|
|
73
64
|
|
|
74
65
|
/**
|
package/router/shim.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { request as httpRequest } from "node:http";
|
|
2
2
|
import { request as httpsRequest } from "node:https";
|
|
3
|
-
import { readBody, sendError } from "../tools/http.js";
|
|
3
|
+
import { readBody, sendError, sendJson } from "../tools/http.js";
|
|
4
|
+
import { catalogCache } from "./catalog-cache.js";
|
|
4
5
|
import { carriesWebpImage, transcodeWebpImages } from "../media/router-images.js";
|
|
5
6
|
import { routerAuthHeaders, routerGatewayUrl } from "./gateway.js";
|
|
6
7
|
import { STT_MAX_AUDIO_BYTES } from "./stt.js";
|
|
@@ -42,7 +43,9 @@ export function shimBudget(suffix) {
|
|
|
42
43
|
};
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/** @returns {Record<string, string>} */
|
|
45
47
|
export function shimRequestHeaders(incoming, env = process.env) {
|
|
48
|
+
/** @type {Record<string, string>} */
|
|
46
49
|
const headers = {};
|
|
47
50
|
for (const [key, value] of Object.entries(incoming)) {
|
|
48
51
|
if (value == null || DROPPED_REQ.has(key.toLowerCase())) continue;
|
|
@@ -75,7 +78,72 @@ export function healthPayload(env = process.env) {
|
|
|
75
78
|
};
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
function isModelsGet(req) {
|
|
82
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
83
|
+
if (method !== "GET" && method !== "HEAD") return false;
|
|
84
|
+
const u = new URL(req.url ?? "/", "http://x");
|
|
85
|
+
return llmShimSuffix(u.pathname).replace(/\/+$/, "") === "models";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function serveCachedModels(req, res) {
|
|
89
|
+
void catalogCache.get().then(
|
|
90
|
+
({ payload }) => {
|
|
91
|
+
if (res.headersSent) {
|
|
92
|
+
res.destroy();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if ((req.method ?? "GET").toUpperCase() === "HEAD") {
|
|
96
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
97
|
+
res.end();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
sendJson(res, 200, payload);
|
|
101
|
+
},
|
|
102
|
+
(err) => {
|
|
103
|
+
if (res.headersSent) {
|
|
104
|
+
res.destroy();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
sendError(res, err, "llm_proxy_failed");
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
}
|
|
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
|
+
|
|
78
142
|
export function proxyToRouter(req, res, env = process.env) {
|
|
143
|
+
if (isModelsGet(req)) {
|
|
144
|
+
serveCachedModels(req, res);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
79
147
|
const routerUrl = routerGatewayUrl(env);
|
|
80
148
|
const rawUrl = req.url ?? "/";
|
|
81
149
|
const u = new URL(rawUrl, "http://x");
|
|
@@ -85,6 +153,7 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
85
153
|
const headers = shimRequestHeaders(req.headers, env);
|
|
86
154
|
const transport = target.protocol === "https:" ? httpsRequest : httpRequest;
|
|
87
155
|
const method = (req.method ?? "GET").toUpperCase();
|
|
156
|
+
const cancellation = downstreamCancellation(req, res);
|
|
88
157
|
|
|
89
158
|
const run = async () => {
|
|
90
159
|
let body;
|
|
@@ -96,16 +165,22 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
96
165
|
if (!budget.audio && carriesWebpImage(body)) body = await transcodeWebpImages(body);
|
|
97
166
|
headers["content-length"] = String(body.length);
|
|
98
167
|
}
|
|
168
|
+
if (cancellation.signal.aborted) return;
|
|
99
169
|
|
|
100
170
|
const upstream = transport(
|
|
101
171
|
target,
|
|
102
|
-
{ method, headers, timeout: budget.timeoutMs },
|
|
172
|
+
{ method, headers, timeout: budget.timeoutMs, signal: cancellation.signal },
|
|
103
173
|
(up) => {
|
|
174
|
+
if (cancellation.signal.aborted) {
|
|
175
|
+
up.destroy();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
104
178
|
res.writeHead(up.statusCode ?? 502, shimResponseHeaders(up.headers));
|
|
105
179
|
up.pipe(res);
|
|
106
180
|
},
|
|
107
181
|
);
|
|
108
182
|
upstream.on("error", (err) => {
|
|
183
|
+
if (cancellation.signal.aborted) return;
|
|
109
184
|
if (res.headersSent) {
|
|
110
185
|
res.destroy();
|
|
111
186
|
return;
|
|
@@ -125,6 +200,7 @@ export function proxyToRouter(req, res, env = process.env) {
|
|
|
125
200
|
};
|
|
126
201
|
|
|
127
202
|
void run().catch((err) => {
|
|
203
|
+
if (cancellation.signal.aborted) return;
|
|
128
204
|
if (res.headersSent) {
|
|
129
205
|
res.destroy();
|
|
130
206
|
return;
|
package/router/stt.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import {
|
|
3
|
-
import { routerCatalogRows } from "./catalog.js";
|
|
2
|
+
import { catalogCache } from "./catalog-cache.js";
|
|
4
3
|
import { routerShimBaseUrl } from "./gateway.js";
|
|
5
4
|
|
|
6
5
|
const STT_HINTS = /whisper|\bstt\b|\basr\b|transcri/i;
|
|
@@ -67,30 +66,23 @@ export function audioFilenameForContentType(contentType) {
|
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
const REQUEST_TIMEOUT_MS = 180_000;
|
|
70
|
-
const CATALOG_TIMEOUT_MS = 15_000;
|
|
71
69
|
const RETRY_BACKOFF_MS = [1_000, 3_000];
|
|
72
70
|
const RESOLVED_TTL_MS = 120_000;
|
|
73
71
|
|
|
74
|
-
const coalesceCatalog = createInFlightCoalescer();
|
|
75
|
-
/** @type {{ expires: number, rows: { id: string, mode: string | null }[] } | null} */
|
|
76
|
-
let catalog = null;
|
|
77
|
-
|
|
78
|
-
async function fetchCatalog() {
|
|
79
|
-
const res = await fetch(`${routerShimBaseUrl()}/models`, {
|
|
80
|
-
headers: { accept: "application/json" },
|
|
81
|
-
signal: AbortSignal.timeout(CATALOG_TIMEOUT_MS),
|
|
82
|
-
});
|
|
83
|
-
if (!res.ok) throw new VoiceError("voice_model_unavailable", 503, `Router /models returned ${res.status}`);
|
|
84
|
-
const rows = routerCatalogRows(await res.json()).map(({ id, mode }) => ({ id, mode }));
|
|
85
|
-
catalog = { expires: Date.now() + RESOLVED_TTL_MS, rows };
|
|
86
|
-
return rows;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
72
|
/** @returns {Promise<{ id: string, mode: string | null }[]>} */
|
|
90
73
|
export async function listModels(options) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
74
|
+
try {
|
|
75
|
+
if (options?.refresh) catalogCache.invalidate();
|
|
76
|
+
const { rows } = await catalogCache.get();
|
|
77
|
+
return rows.map(({ id, mode }) => ({ id, mode }));
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const status = err && typeof err === "object" && "status" in err ? Number(err.status) : 503;
|
|
80
|
+
throw new VoiceError(
|
|
81
|
+
"voice_model_unavailable",
|
|
82
|
+
status || 503,
|
|
83
|
+
err instanceof Error ? err.message : String(err),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
94
86
|
}
|
|
95
87
|
|
|
96
88
|
/** @returns {Promise<string[]>} */
|
|
@@ -119,7 +111,6 @@ export async function resolveSttModel(preferred, options) {
|
|
|
119
111
|
|
|
120
112
|
export function forgetSttModel() {
|
|
121
113
|
resolved = null;
|
|
122
|
-
catalog = null;
|
|
123
114
|
}
|
|
124
115
|
|
|
125
116
|
/** @param {Record<string, string>} fields @param {{ filename: string, contentType: string, bytes: Buffer }} file */
|
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
|
}
|
package/workspace/path.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lstatSync } from "node:fs";
|
|
2
2
|
import { mkdir, realpath, stat } from "node:fs/promises";
|
|
3
|
-
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { HttpError } from "../tools/http.js";
|
|
5
5
|
|
|
6
6
|
export function isInsideWorkspace(root, target) {
|
|
@@ -14,6 +14,21 @@ export async function resolveWorkspaceRoot(workspacePath) {
|
|
|
14
14
|
});
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Preview only serves the session workspace. Agents sometimes write
|
|
19
|
+
* `/app/name.ext` (the application overlay) while the open chip still points
|
|
20
|
+
* there; alias that basename into the workspace. Nested overlay paths
|
|
21
|
+
* (`/app/packages/x`) and any other filesystem root (`/tmp/x`) stay forbidden.
|
|
22
|
+
*/
|
|
23
|
+
export function workspaceFileAlias(root, requestedPath) {
|
|
24
|
+
if (typeof requestedPath !== "string" || !isAbsolute(requestedPath)) return null;
|
|
25
|
+
if (isInsideWorkspace(root, requestedPath)) return null;
|
|
26
|
+
if (dirname(requestedPath) !== "/app") return null;
|
|
27
|
+
const base = basename(requestedPath);
|
|
28
|
+
if (!base || base === "." || base === "..") return null;
|
|
29
|
+
return base;
|
|
30
|
+
}
|
|
31
|
+
|
|
17
32
|
export function workspaceCandidate(root, requestedPath) {
|
|
18
33
|
const candidate = resolve(root, requestedPath);
|
|
19
34
|
if (!isInsideWorkspace(root, candidate)) {
|