@olares/lares-core 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.
Files changed (82) hide show
  1. package/README.md +23 -0
  2. package/brand/identity.d.ts +9 -0
  3. package/brand/identity.js +82 -0
  4. package/brand/manifest.js +15 -0
  5. package/drive/download.js +40 -0
  6. package/drive/execute.js +143 -0
  7. package/drive/files-path.js +55 -0
  8. package/drive/ls.js +126 -0
  9. package/drive/paths.js +320 -0
  10. package/drive/tools.js +206 -0
  11. package/drive/url-download.js +314 -0
  12. package/files/deliverables.js +32 -0
  13. package/files/disposition.js +20 -0
  14. package/files/filename.js +22 -0
  15. package/files/intake.js +217 -0
  16. package/files/limits.js +1 -0
  17. package/files/markdown.js +88 -0
  18. package/files/mention.js +64 -0
  19. package/files/preview-cache.js +100 -0
  20. package/files/preview-groups.js +21 -0
  21. package/files/preview-workspace.js +342 -0
  22. package/files/preview.js +305 -0
  23. package/files/upload-client.js +40 -0
  24. package/files/upload-http.js +22 -0
  25. package/files/upload.js +104 -0
  26. package/i18n/chat-model-switch.js +41 -0
  27. package/i18n/chat-model.js +40 -0
  28. package/i18n/files.js +39 -0
  29. package/i18n/mobile.js +115 -0
  30. package/i18n/preview.js +57 -0
  31. package/i18n/search.js +33 -0
  32. package/i18n/t.js +22 -0
  33. package/i18n/voice.js +70 -0
  34. package/icons/mark.js +22 -0
  35. package/larepass/chat.js +134 -0
  36. package/larepass/context-provenance.js +65 -0
  37. package/larepass/host.js +80 -0
  38. package/larepass/mux.js +194 -0
  39. package/larepass/questions.js +16 -0
  40. package/larepass/rpc.js +60 -0
  41. package/larepass/runtime.js +395 -0
  42. package/larepass/session-cache.js +104 -0
  43. package/larepass/settings.js +100 -0
  44. package/larepass/stage-copy.js +16 -0
  45. package/larepass/tool-row.js +132 -0
  46. package/larepass/transcript.js +332 -0
  47. package/media/ffmpeg-run.js +117 -0
  48. package/media/router-images.js +63 -0
  49. package/olares/entrance.js +45 -0
  50. package/olares/identity.js +190 -0
  51. package/olares/session-identity.js +48 -0
  52. package/olares/trusted-host.js +37 -0
  53. package/package.json +111 -0
  54. package/router/catalog-sync.js +87 -0
  55. package/router/catalog.d.ts +15 -0
  56. package/router/catalog.js +73 -0
  57. package/router/gateway.d.ts +4 -0
  58. package/router/gateway.js +21 -0
  59. package/router/models-client.js +39 -0
  60. package/router/models.d.ts +16 -0
  61. package/router/models.js +133 -0
  62. package/router/search.js +142 -0
  63. package/router/session-model.js +143 -0
  64. package/router/shim.js +134 -0
  65. package/router/stt.js +200 -0
  66. package/search/client.js +38 -0
  67. package/search/config.js +98 -0
  68. package/search/menu.js +22 -0
  69. package/tools/async.js +34 -0
  70. package/tools/crypto-uuid.js +26 -0
  71. package/tools/http.js +123 -0
  72. package/tools/json-file.js +18 -0
  73. package/voice/client.js +90 -0
  74. package/voice/config.js +59 -0
  75. package/voice/languages.js +29 -0
  76. package/voice/recorder.js +43 -0
  77. package/voice/service.js +83 -0
  78. package/workspace/env.js +13 -0
  79. package/workspace/home.js +9 -0
  80. package/workspace/path.js +80 -0
  81. package/workspace/seed.js +13 -0
  82. package/workspace/session.js +42 -0
@@ -0,0 +1,305 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import { basename, extname, relative } from "node:path";
3
+ import { isFilesPath, parseFilesPath } from "../drive/files-path.js";
4
+ import { statFilesFile } from "../drive/ls.js";
5
+ import { HttpError } from "../tools/http.js";
6
+ import {
7
+ resolveExistingWorkspacePath,
8
+ resolveWorkspaceRoot,
9
+ workspaceCandidate,
10
+ } from "../workspace/path.js";
11
+ import { materializeFilesFile } from "./preview-cache.js";
12
+
13
+ export const MAX_PREVIEW_TEXT_BYTES = 1024 * 1024;
14
+ export const MAX_RAW_BYTES = 200 * 1024 * 1024;
15
+
16
+ const IMAGE_TYPES = new Map([
17
+ [".png", "image/png"],
18
+ [".jpg", "image/jpeg"],
19
+ [".jpeg", "image/jpeg"],
20
+ [".gif", "image/gif"],
21
+ [".webp", "image/webp"],
22
+ [".bmp", "image/bmp"],
23
+ ]);
24
+ const VIDEO_TYPES = new Map([
25
+ [".mp4", "video/mp4"],
26
+ [".webm", "video/webm"],
27
+ [".mov", "video/quicktime"],
28
+ [".m4v", "video/x-m4v"],
29
+ [".ogv", "video/ogg"],
30
+ ]);
31
+ const AUDIO_TYPES = new Map([
32
+ [".mp3", "audio/mpeg"],
33
+ [".wav", "audio/wav"],
34
+ [".ogg", "audio/ogg"],
35
+ [".m4a", "audio/mp4"],
36
+ [".aac", "audio/aac"],
37
+ [".flac", "audio/flac"],
38
+ ]);
39
+ const TEXT_EXTENSIONS = new Set([
40
+ "",
41
+ ".txt", ".log", ".json", ".csv", ".tsv", ".yaml", ".yml", ".toml", ".xml",
42
+ ".html", ".htm", ".css", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
43
+ ".py", ".sh", ".bash", ".zsh", ".env", ".ini", ".cfg", ".conf", ".rs",
44
+ ".go", ".java", ".kt", ".c", ".h", ".cpp", ".hpp", ".sql", ".r", ".rb",
45
+ ".php", ".swift", ".vue", ".svelte", ".mdx", ".gitignore", ".dockerignore",
46
+ ".editorconfig", ".srt", ".vtt", ".ass", ".ssa",
47
+ ]);
48
+
49
+ export function previewQueryFromUrl(reqUrl) {
50
+ const url = new URL(reqUrl ?? "/", "http://x");
51
+ const path = url.searchParams.get("path");
52
+ if (!path) throw new HttpError("path_invalid", 400, "file path is required");
53
+ return { path, sessionId: url.searchParams.get("sessionId") };
54
+ }
55
+
56
+ function asPreviewHttpError(error) {
57
+ if (error instanceof HttpError) return error;
58
+ const code = error?.code;
59
+ const message = error instanceof Error ? error.message : String(error);
60
+ if (code === "file_not_found") return new HttpError("file_not_found", 404, "file was not found");
61
+ if (code === "path_not_file") return new HttpError("path_not_file", 415, "path is not a regular file");
62
+ if (code === "path_invalid") return new HttpError("path_invalid", 400, message);
63
+ return new HttpError("file_preview_failed", 502, message);
64
+ }
65
+
66
+ export async function resolveFilesPreviewFile(requestedPath, deps = {}) {
67
+ let source;
68
+ try {
69
+ source = parseFilesPath(requestedPath);
70
+ } catch (error) {
71
+ throw new HttpError("path_invalid", 400, error instanceof Error ? error.message : String(error));
72
+ }
73
+ try {
74
+ const info = await (deps.stat ?? statFilesFile)(source, deps);
75
+ return {
76
+ origin: "files",
77
+ path: info.path,
78
+ name: info.name,
79
+ size: info.size,
80
+ modifiedAt: info.modifiedAt,
81
+ ...previewTypeForName(info.name),
82
+ };
83
+ } catch (error) {
84
+ throw asPreviewHttpError(error);
85
+ }
86
+ }
87
+
88
+ export async function fileFromPreviewRequest(reqUrl, resolveWorkspace, deps = {}) {
89
+ const { path, sessionId } = previewQueryFromUrl(reqUrl);
90
+ if (isFilesPath(path)) return resolveFilesPreviewFile(path, deps);
91
+ const workspace = await resolveWorkspace(sessionId);
92
+ return resolveWorkspaceFile(workspace.path, path);
93
+ }
94
+
95
+ async function ensurePreviewBytes(file, deps = {}) {
96
+ if (file.absolutePath) return file;
97
+ if (file.origin !== "files") {
98
+ throw new HttpError("file_preview_failed", 500, "file has no local path");
99
+ }
100
+ try {
101
+ return await (deps.materialize ?? materializeFilesFile)(file, deps);
102
+ } catch (error) {
103
+ throw asPreviewHttpError(error);
104
+ }
105
+ }
106
+
107
+ export function previewTypeForName(name) {
108
+ const extension = extname(name).toLowerCase();
109
+ if (IMAGE_TYPES.has(extension)) return { kind: "image", mediaType: IMAGE_TYPES.get(extension) };
110
+ if (VIDEO_TYPES.has(extension)) return { kind: "video", mediaType: VIDEO_TYPES.get(extension) };
111
+ if (AUDIO_TYPES.has(extension)) return { kind: "audio", mediaType: AUDIO_TYPES.get(extension) };
112
+ if (extension === ".pdf") return { kind: "pdf", mediaType: "application/pdf" };
113
+ if (extension === ".md" || extension === ".markdown") {
114
+ return { kind: "markdown", mediaType: "text/markdown; charset=utf-8" };
115
+ }
116
+ if (TEXT_EXTENSIONS.has(extension)) return { kind: "text", mediaType: "text/plain; charset=utf-8" };
117
+ return { kind: "unsupported", mediaType: "application/octet-stream" };
118
+ }
119
+
120
+ export async function resolveWorkspaceFile(workspacePath, requestedPath) {
121
+ if (typeof requestedPath !== "string" || !requestedPath.trim() || requestedPath.includes("\0")) {
122
+ throw new HttpError("path_invalid", 400, "file path is required");
123
+ }
124
+ const root = await resolveWorkspaceRoot(workspacePath);
125
+ const candidate = workspaceCandidate(root, requestedPath);
126
+ const absolutePath = await resolveExistingWorkspacePath(root, candidate);
127
+ const info = await stat(absolutePath).catch(() => {
128
+ throw new HttpError("file_not_found", 404, "file was not found");
129
+ });
130
+ // Distinct from a missing file: a directory is a legitimate open target that
131
+ // this route simply does not serve, and the caller decides what to do with it.
132
+ if (!info.isFile()) throw new HttpError("path_not_file", 415, "path is not a regular file");
133
+ return {
134
+ absolutePath,
135
+ path: relative(root, absolutePath),
136
+ name: basename(absolutePath),
137
+ size: info.size,
138
+ modifiedAt: info.mtimeMs,
139
+ device: info.dev,
140
+ inode: info.ino,
141
+ ...previewTypeForName(absolutePath),
142
+ };
143
+ }
144
+
145
+ /**
146
+ * The truncation cut is a byte offset, so it can land inside a multi-byte
147
+ * character. Dropping that partial tail keeps a truncated preview from being
148
+ * reported as a non-text file.
149
+ */
150
+ function trimPartialUtf8(body) {
151
+ for (let back = 1; back <= 3 && back <= body.length; back += 1) {
152
+ const byte = body[body.length - back];
153
+ if ((byte & 0xc0) === 0x80) continue;
154
+ const width = byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
155
+ return width > back ? body.subarray(0, body.length - back) : body;
156
+ }
157
+ return body;
158
+ }
159
+
160
+ export async function buildPreview(file, deps) {
161
+ const metadata = {
162
+ path: file.path,
163
+ name: file.name,
164
+ kind: file.kind,
165
+ mediaType: file.mediaType,
166
+ size: file.size,
167
+ modifiedAt: file.modifiedAt,
168
+ };
169
+ const knownText = ["text", "markdown"].includes(file.kind);
170
+ if (!knownText && file.kind !== "unsupported") return metadata;
171
+ const local = await ensurePreviewBytes(file, deps);
172
+ const handle = await openVerified(local);
173
+ try {
174
+ const length = Math.min(local.size, MAX_PREVIEW_TEXT_BYTES + 1);
175
+ const buffer = Buffer.alloc(length);
176
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
177
+ const truncated = bytesRead > MAX_PREVIEW_TEXT_BYTES;
178
+ const read = buffer.subarray(0, Math.min(bytesRead, MAX_PREVIEW_TEXT_BYTES));
179
+ const body = truncated ? trimPartialUtf8(read) : read;
180
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(body);
181
+ if (!knownText && !looksLikeText(text)) return metadata;
182
+ return {
183
+ ...metadata,
184
+ kind: knownText ? file.kind : "text",
185
+ mediaType: knownText ? file.mediaType : "text/plain; charset=utf-8",
186
+ text,
187
+ truncated: local.size > MAX_PREVIEW_TEXT_BYTES,
188
+ };
189
+ } catch (error) {
190
+ if (error instanceof TypeError) {
191
+ if (!knownText) return metadata;
192
+ throw new HttpError("file_not_text", 415, "file is not valid UTF-8 text");
193
+ }
194
+ throw error;
195
+ } finally {
196
+ await handle.close();
197
+ }
198
+ }
199
+
200
+ export async function openVerified(file) {
201
+ let handle;
202
+ try {
203
+ handle = await open(file.absolutePath, "r");
204
+ const info = await handle.stat();
205
+ if (
206
+ info.dev !== file.device
207
+ || info.ino !== file.inode
208
+ || info.size !== file.size
209
+ || info.mtimeMs !== file.modifiedAt
210
+ ) {
211
+ throw new HttpError("file_changed", 409, "file changed while it was being opened");
212
+ }
213
+ return handle;
214
+ } catch (error) {
215
+ await handle?.close().catch(() => {});
216
+ if (error?.code === "ENOENT") {
217
+ throw new HttpError("file_not_found", 404, "file was not found");
218
+ }
219
+ throw error;
220
+ }
221
+ }
222
+
223
+ function looksLikeText(text) {
224
+ if (text.includes("\0")) return false;
225
+ let controls = 0;
226
+ for (const char of text) {
227
+ const code = char.codePointAt(0);
228
+ if (code < 32 && char !== "\n" && char !== "\r" && char !== "\t") controls += 1;
229
+ }
230
+ return controls <= Math.max(2, text.length / 100);
231
+ }
232
+
233
+ export function parseRange(value, size) {
234
+ if (value === undefined) return null;
235
+ const match = /^bytes=(\d*)-(\d*)$/.exec(String(value).trim());
236
+ if (!match || (!match[1] && !match[2]) || size <= 0) {
237
+ throw new HttpError("range_not_satisfiable", 416, "invalid byte range");
238
+ }
239
+ let start;
240
+ let end;
241
+ if (!match[1]) {
242
+ const suffix = Number(match[2]);
243
+ if (!Number.isSafeInteger(suffix) || suffix <= 0) {
244
+ throw new HttpError("range_not_satisfiable", 416, "invalid byte range");
245
+ }
246
+ start = Math.max(0, size - suffix);
247
+ end = size - 1;
248
+ } else {
249
+ start = Number(match[1]);
250
+ end = match[2] ? Number(match[2]) : size - 1;
251
+ }
252
+ if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start) {
253
+ throw new HttpError("range_not_satisfiable", 416, "invalid byte range");
254
+ }
255
+ return { start, end: Math.min(end, size - 1) };
256
+ }
257
+
258
+ async function sendFile(req, res, file, disposition) {
259
+ const range = parseRange(req.headers.range, file.size);
260
+ const handle = await openVerified(file);
261
+ const start = range?.start ?? 0;
262
+ const end = range?.end ?? Math.max(0, file.size - 1);
263
+ const headers = {
264
+ "accept-ranges": "bytes",
265
+ "cache-control": "private, no-cache",
266
+ "content-type": file.mediaType,
267
+ "content-length": String(file.size === 0 ? 0 : end - start + 1),
268
+ "content-disposition": `${disposition}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
269
+ "x-content-type-options": "nosniff",
270
+ };
271
+ if (range) headers["content-range"] = `bytes ${start}-${end}/${file.size}`;
272
+ res.writeHead(range ? 206 : 200, headers);
273
+ if (req.method === "HEAD" || file.size === 0) {
274
+ await handle.close();
275
+ res.end();
276
+ return;
277
+ }
278
+ const stream = handle.createReadStream({ start, end, autoClose: true });
279
+ stream.on("error", (error) => {
280
+ if (!res.headersSent) res.destroy(error);
281
+ else res.destroy();
282
+ });
283
+ stream.pipe(res);
284
+ }
285
+
286
+ export async function sendRawFile(req, res, file, deps) {
287
+ if (!["image", "video", "audio", "pdf"].includes(file.kind)) {
288
+ throw new HttpError("preview_unsupported", 415, "raw preview is not supported for this file");
289
+ }
290
+ // Video and audio are range-streamed, so their total size is not browser
291
+ // memory pressure. Images and PDFs are consumed as whole documents.
292
+ if (["image", "pdf"].includes(file.kind) && file.size > MAX_RAW_BYTES) {
293
+ throw new HttpError("file_too_large", 413, `file exceeds ${MAX_RAW_BYTES} bytes`);
294
+ }
295
+ return sendFile(req, res, await ensurePreviewBytes(file, deps), "inline");
296
+ }
297
+
298
+ /**
299
+ * Saving a file is not previewing it: the kind and size limits guard what a
300
+ * media element or iframe will be asked to hold, and neither says anything
301
+ * about what the user may keep a copy of.
302
+ */
303
+ export async function sendFileDownload(req, res, file, deps) {
304
+ return sendFile(req, res, await ensurePreviewBytes(file, deps), "attachment");
305
+ }
@@ -0,0 +1,40 @@
1
+ const UPLOAD_TIMEOUT_MS = 120_000;
2
+ export const FILES_UPLOAD_PATH = "/api/lares/files/upload";
3
+
4
+ function errorCode(payload) {
5
+ return payload && typeof payload === "object" && payload.error && typeof payload.error.code === "string"
6
+ ? payload.error.code
7
+ : "file_upload_failed";
8
+ }
9
+
10
+ export async function uploadFile(file, sessionId, options = {}) {
11
+ const controller = new AbortController();
12
+ const timer = setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
13
+ const onAbort = () => controller.abort(options.signal?.reason);
14
+ if (options.signal?.aborted) controller.abort(options.signal.reason);
15
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
16
+ try {
17
+ const response = await fetch(options.url ?? FILES_UPLOAD_PATH, {
18
+ method: "POST",
19
+ headers: {
20
+ "content-type": file.type || "application/octet-stream",
21
+ "x-lares-file-name": encodeURIComponent(file.name || "file"),
22
+ "x-lares-session-id": sessionId,
23
+ "x-lares-upload-request-id": options.requestId ?? crypto.randomUUID(),
24
+ },
25
+ body: file,
26
+ signal: controller.signal,
27
+ });
28
+ const payload = await response.json().catch(() => null);
29
+ if (!response.ok) {
30
+ const error = new Error(errorCode(payload));
31
+ error.status = response.status;
32
+ throw error;
33
+ }
34
+ if (!payload || typeof payload.path !== "string") throw new Error("file_upload_failed");
35
+ return payload;
36
+ } finally {
37
+ clearTimeout(timer);
38
+ options.signal?.removeEventListener("abort", onAbort);
39
+ }
40
+ }
@@ -0,0 +1,22 @@
1
+ import { HttpError } from "../tools/http.js";
2
+ import { decodeUploadFilename, sanitizeFilename, UPLOAD_REQUEST_ID } from "./upload.js";
3
+
4
+ export function parseUploadHeaders(headers) {
5
+ const sessionId = headers["x-lares-session-id"];
6
+ const requestId = headers["x-lares-upload-request-id"];
7
+ if (typeof requestId !== "string" || !UPLOAD_REQUEST_ID.test(requestId)) {
8
+ throw new HttpError("upload_request_invalid", 400, "valid upload request id is required");
9
+ }
10
+ const filename = decodeUploadFilename(headers["x-lares-file-name"]);
11
+ const mediaType = String(headers["content-type"] || "application/octet-stream").split(";", 1)[0];
12
+ return { sessionId, requestId, filename, mediaType, name: sanitizeFilename(filename) };
13
+ }
14
+
15
+ export function uploadSuccessBody(stored, filename, mediaType) {
16
+ return {
17
+ path: stored.path,
18
+ name: sanitizeFilename(filename),
19
+ size: stored.size,
20
+ mediaType,
21
+ };
22
+ }
@@ -0,0 +1,104 @@
1
+ import { createWriteStream, existsSync, openSync, rmSync, statSync } from "node:fs";
2
+ import { rename } from "node:fs/promises";
3
+ import { extname, join } from "node:path";
4
+ import { Transform } from "node:stream";
5
+ import { pipeline } from "node:stream/promises";
6
+ import { HttpError } from "../tools/http.js";
7
+ import { ensureWorkspaceDirectory } from "../workspace/path.js";
8
+ import { sanitizeFilename } from "./filename.js";
9
+ import { DEFAULT_MAX_UPLOAD_BYTES } from "./limits.js";
10
+
11
+ export { DEFAULT_MAX_UPLOAD_BYTES, sanitizeFilename };
12
+ export const UPLOAD_REQUEST_ID = /^[A-Za-z0-9_-]{16,80}$/;
13
+ const UPLOAD_DIRECTORY = [".lares", "uploads"];
14
+ /** More same-named files than any composer batch; past it the name is hostile. */
15
+ const MAX_NAME_ATTEMPTS = 200;
16
+
17
+ export function decodeUploadFilename(value) {
18
+ if (typeof value !== "string" || !value) return "file";
19
+ try {
20
+ return decodeURIComponent(value);
21
+ } catch {
22
+ throw new HttpError("filename_invalid", 400, "invalid file name");
23
+ }
24
+ }
25
+
26
+ /**
27
+ * `report.pdf` → `report-2.pdf`. The number goes before the extension so the
28
+ * file type survives, and the model reading the mention still sees the name the
29
+ * user recognizes.
30
+ */
31
+ export function numberedName(name, attempt) {
32
+ if (attempt <= 1) return name;
33
+ const extension = extname(name);
34
+ return `${name.slice(0, name.length - extension.length)}-${attempt}${extension}`;
35
+ }
36
+
37
+ /**
38
+ * Take the first free name for this upload. The exclusive `.part` create is the
39
+ * claim: two uploads of one name race here rather than over the final path, so
40
+ * each ends up with a number of its own.
41
+ */
42
+ function claimName(directory, filename) {
43
+ const name = sanitizeFilename(filename);
44
+ for (let attempt = 1; attempt <= MAX_NAME_ATTEMPTS; attempt += 1) {
45
+ const candidate = numberedName(name, attempt);
46
+ const absolutePath = join(directory, candidate);
47
+ if (existsSync(absolutePath)) continue;
48
+ const temporary = `${absolutePath}.part`;
49
+ try {
50
+ const fd = openSync(temporary, "wx", 0o600);
51
+ return {
52
+ absolutePath,
53
+ temporary,
54
+ fd,
55
+ relativePath: join(...UPLOAD_DIRECTORY, candidate),
56
+ };
57
+ } catch (error) {
58
+ if (error?.code !== "EEXIST") throw error;
59
+ }
60
+ }
61
+ throw new HttpError("file_upload_failed", 409, `no free name for "${name}"`);
62
+ }
63
+
64
+ function byteLimit(maxBytes) {
65
+ let total = 0;
66
+ return new Transform({
67
+ transform(chunk, _encoding, callback) {
68
+ total += chunk.length;
69
+ if (total > maxBytes) {
70
+ callback(new HttpError("file_too_large", 413, `file exceeds ${maxBytes} bytes`));
71
+ return;
72
+ }
73
+ callback(null, chunk);
74
+ },
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Stream one upload into the workspace under the name the user chose.
80
+ * @returns the workspace-relative path the composer mentions, and the bytes stored.
81
+ */
82
+ export async function saveUpload(req, workspacePath, filename, maxBytes = DEFAULT_MAX_UPLOAD_BYTES) {
83
+ const declared = Number(req.headers["content-length"]);
84
+ if (Number.isFinite(declared) && declared > maxBytes) {
85
+ throw new HttpError("file_too_large", 413, `file exceeds ${maxBytes} bytes`);
86
+ }
87
+
88
+ const { directory } = await ensureWorkspaceDirectory(workspacePath, UPLOAD_DIRECTORY);
89
+ const claim = claimName(directory, filename);
90
+ try {
91
+ await pipeline(
92
+ req,
93
+ byteLimit(maxBytes),
94
+ createWriteStream(claim.temporary, { fd: claim.fd, autoClose: true }),
95
+ );
96
+ const size = statSync(claim.temporary).size;
97
+ if (size === 0) throw new HttpError("file_empty", 400, "file is empty");
98
+ await rename(claim.temporary, claim.absolutePath);
99
+ return { path: claim.relativePath, size };
100
+ } catch (error) {
101
+ rmSync(claim.temporary, { force: true });
102
+ throw error;
103
+ }
104
+ }
@@ -0,0 +1,41 @@
1
+ export const ZH = {
2
+ "reasoning.default": "默认",
3
+ "reasoning.title": "推理强度",
4
+ "reasoning.switchAria": "切换推理强度,当前 {label}",
5
+ "effort.off": "关闭",
6
+ "effort.minimal": "最低",
7
+ "effort.low": "低",
8
+ "effort.medium": "中",
9
+ "effort.high": "高",
10
+ "effort.xhigh": "很高",
11
+ "effort.max": "最高",
12
+ "model.select": "选择模型",
13
+ "model.switchAria": "切换模型,当前 {label}",
14
+ "model.menuAria": "模型",
15
+ "model.refreshing": "正在刷新模型列表…",
16
+ "model.opFailed": "模型操作失败:{msg}",
17
+ "model.loadFailed": "{name} 加载失败:{msg}",
18
+ "model.empty": "没有可用的模型。",
19
+ "slot.model": "模型",
20
+ };
21
+
22
+ export const EN = {
23
+ "reasoning.default": "Default",
24
+ "reasoning.title": "Reasoning effort",
25
+ "reasoning.switchAria": "Switch reasoning effort, current {label}",
26
+ "effort.off": "Off",
27
+ "effort.minimal": "Minimal",
28
+ "effort.low": "Low",
29
+ "effort.medium": "Medium",
30
+ "effort.high": "High",
31
+ "effort.xhigh": "Very high",
32
+ "effort.max": "Max",
33
+ "model.select": "Select model",
34
+ "model.switchAria": "Switch model, current {label}",
35
+ "model.menuAria": "Model",
36
+ "model.refreshing": "Refreshing models…",
37
+ "model.opFailed": "Model action failed: {msg}",
38
+ "model.loadFailed": "{name} failed to load: {msg}",
39
+ "model.empty": "No models available.",
40
+ "slot.model": "Model",
41
+ };
@@ -0,0 +1,40 @@
1
+ export const ZH = {
2
+ "settings.nav": "模型配置",
3
+ "settings.title": "模型",
4
+ "settings.intro": "模型来自 Olares Router。选中的模型是新会话的默认模型;会话内可随时在输入框上方切换。",
5
+ "settings.loading": "正在读取模型列表…",
6
+ "settings.refresh": "刷新",
7
+ "settings.refreshing": "刷新中…",
8
+ "settings.router": "Router 控制台",
9
+ "settings.status.ready": "对话可用 · 默认模型 {model}",
10
+ "settings.status.notReady": "对话模型尚不可用,请先在 Olares 模型控制台配置对话模型",
11
+ "settings.empty": "Router 暂时没有可用的对话模型。",
12
+ "settings.default": "默认",
13
+ "settings.setDefault": "设为默认",
14
+ "settings.listAria": "可用模型",
15
+ "settings.loadFailed": "读取模型列表失败:{msg}",
16
+ "settings.refreshFailed": "刷新模型列表失败:{msg}",
17
+ "settings.saveFailed": "设置默认模型失败:{msg}",
18
+ "settings.providerFailed": "{name} 的模型列表读取失败:{msg}",
19
+ };
20
+
21
+ export const EN = {
22
+ "settings.nav": "Model configuration",
23
+ "settings.title": "Models",
24
+ "settings.intro":
25
+ "Models come from Olares Router. The selected one starts every new session; a session can switch above the composer at any time.",
26
+ "settings.loading": "Loading models…",
27
+ "settings.refresh": "Refresh",
28
+ "settings.refreshing": "Refreshing…",
29
+ "settings.router": "Router console",
30
+ "settings.status.ready": "Chat ready · default model {model}",
31
+ "settings.status.notReady": "Chat unavailable; configure a chat model in the Olares Model Console first",
32
+ "settings.empty": "Router serves no chat model right now.",
33
+ "settings.default": "Default",
34
+ "settings.setDefault": "Set as default",
35
+ "settings.listAria": "Available models",
36
+ "settings.loadFailed": "Failed to load models: {msg}",
37
+ "settings.refreshFailed": "Failed to refresh models: {msg}",
38
+ "settings.saveFailed": "Failed to set the default model: {msg}",
39
+ "settings.providerFailed": "Failed to list models for {name}: {msg}",
40
+ };
package/i18n/files.js ADDED
@@ -0,0 +1,39 @@
1
+ export const ZH = {
2
+ "button.idle": "添加图片或文件",
3
+ "button.uploading": "正在上传 {count} 个文件…",
4
+ "button.blocked": "当前无法添加文件",
5
+ "upload.blocked": "文件上传完成后才能发送",
6
+ "upload.failures": "文件上传失败提示",
7
+ "upload.failed": "{name} 上传失败:{reason}",
8
+ "upload.retry": "重试",
9
+ "upload.dismiss": "关闭",
10
+ "upload.unlinked": "已上传到 {path},但没能插入输入框",
11
+ "error.file_too_large": "文件超过 100 MB",
12
+ "error.file_empty": "不能上传空文件",
13
+ "error.file_upload_cancelled": "上传已取消",
14
+ "error.file_input_blocked": "当前无法添加该文件",
15
+ "error.upload_request_invalid": "上传请求无效",
16
+ "error.workspace_not_found": "当前会话没有可用工作区",
17
+ "error.workspace_unavailable": "当前工作区不可用",
18
+ "error.file_upload_failed": "文件上传失败,请重试",
19
+ };
20
+
21
+ export const EN = {
22
+ "button.idle": "Add images or files",
23
+ "button.uploading": "Uploading {count} file(s)…",
24
+ "button.blocked": "File input unavailable",
25
+ "upload.blocked": "Wait for file uploads before sending",
26
+ "upload.failures": "File upload failures",
27
+ "upload.failed": "{name} failed to upload: {reason}",
28
+ "upload.retry": "Retry",
29
+ "upload.dismiss": "Dismiss",
30
+ "upload.unlinked": "Uploaded to {path} but could not be inserted into the composer",
31
+ "error.file_too_large": "File exceeds 100 MB",
32
+ "error.file_empty": "Empty files cannot be uploaded",
33
+ "error.file_upload_cancelled": "Upload cancelled",
34
+ "error.file_input_blocked": "The file cannot be added right now",
35
+ "error.upload_request_invalid": "Invalid upload request",
36
+ "error.workspace_not_found": "No workspace is available for this session",
37
+ "error.workspace_unavailable": "The current workspace is unavailable",
38
+ "error.file_upload_failed": "File upload failed; please retry",
39
+ };