@olares/lares-core 0.15.1 → 0.15.2
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/drive/paths.js +3 -0
- package/files/preview-groups.js +1 -1
- package/files/preview.js +28 -5
- package/i18n/preview.js +4 -2
- 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 +37 -1
- package/router/stt.js +13 -22
- 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/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/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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olares/lares-core",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.2",
|
|
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";
|
|
@@ -75,7 +76,42 @@ export function healthPayload(env = process.env) {
|
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
function isModelsGet(req) {
|
|
80
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
81
|
+
if (method !== "GET" && method !== "HEAD") return false;
|
|
82
|
+
const u = new URL(req.url ?? "/", "http://x");
|
|
83
|
+
return llmShimSuffix(u.pathname).replace(/\/+$/, "") === "models";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function serveCachedModels(req, res) {
|
|
87
|
+
void catalogCache.get().then(
|
|
88
|
+
({ payload }) => {
|
|
89
|
+
if (res.headersSent) {
|
|
90
|
+
res.destroy();
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if ((req.method ?? "GET").toUpperCase() === "HEAD") {
|
|
94
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
95
|
+
res.end();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
sendJson(res, 200, payload);
|
|
99
|
+
},
|
|
100
|
+
(err) => {
|
|
101
|
+
if (res.headersSent) {
|
|
102
|
+
res.destroy();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
sendError(res, err, "llm_proxy_failed");
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
78
110
|
export function proxyToRouter(req, res, env = process.env) {
|
|
111
|
+
if (isModelsGet(req)) {
|
|
112
|
+
serveCachedModels(req, res);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
79
115
|
const routerUrl = routerGatewayUrl(env);
|
|
80
116
|
const rawUrl = req.url ?? "/";
|
|
81
117
|
const u = new URL(rawUrl, "http://x");
|
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/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)) {
|