talon-agent 3.35.0 → 3.37.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.
- package/package.json +1 -1
- package/src/core/plugin/mcp.ts +13 -0
- package/src/core/plugin/registry.ts +12 -1
- package/src/core/plugin/types.ts +12 -0
- package/src/core/weaver/typing-loop.ts +39 -8
- package/src/frontend/native/chat-wire.ts +6 -2
- package/src/frontend/native/emit.ts +30 -11
- package/src/frontend/native/handlers.ts +79 -13
- package/src/frontend/native/history.ts +30 -6
- package/src/frontend/native/media.ts +271 -15
- package/src/frontend/native/protocol.ts +34 -1
- package/src/frontend/native/queue.ts +4 -7
- package/src/frontend/native/routes/chats.ts +45 -12
- package/src/frontend/native/routes/host.ts +17 -5
- package/src/frontend/native/routes/params.ts +21 -0
- package/src/frontend/native/runtime.ts +16 -4
- package/src/frontend/native/server.ts +2 -35
- package/src/frontend/native/status.ts +3 -1
- package/src/frontend/native/turn.ts +39 -17
- package/src/plugins/playwright/index.ts +18 -8
- package/src/storage/db.ts +7 -1
- package/src/storage/history.ts +4 -1
- package/src/storage/repositories/history-repo.ts +47 -1
- package/src/storage/sql/db.sql +6 -0
- package/src/storage/sql/history.sql +11 -10
- package/src/storage/sql/schema.sql +6 -1
- package/src/storage/sql/statements.generated.ts +21 -11
- package/src/util/paths.ts +11 -0
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media handling — the id → path registry the `/media` route serves from,
|
|
3
|
-
*
|
|
3
|
+
* the uploads directory client attachments land in, and the MIME table both
|
|
4
|
+
* the upload path and the `/media` route classify files with.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
7
|
+
import { createWriteStream, type WriteStream } from "node:fs";
|
|
8
|
+
import { mkdir, unlink } from "node:fs/promises";
|
|
9
|
+
import { basename, extname, join } from "node:path";
|
|
10
|
+
import { pipeline } from "node:stream/promises";
|
|
11
|
+
import type { Readable } from "node:stream";
|
|
8
12
|
import { dirs } from "../../util/paths.js";
|
|
13
|
+
import type { ClientAttachment } from "./protocol.js";
|
|
9
14
|
import type { NativeRuntime } from "./runtime.js";
|
|
10
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Upload ceiling. Bodies stream straight to disk, so this bounds what a
|
|
18
|
+
* client may park in the uploads dir rather than what has to fit in memory —
|
|
19
|
+
* high enough for the archives and media people actually attach.
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
|
|
22
|
+
|
|
11
23
|
/** Register a file for serving and return its short media id. */
|
|
12
24
|
export function registerMedia(
|
|
13
25
|
runtime: NativeRuntime,
|
|
@@ -24,20 +36,264 @@ export function mediaUrl(mediaId: string): string {
|
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
39
|
+
* Extension → MIME. Deliberately small: the types a companion client is
|
|
40
|
+
* likely to attach (images it renders inline, archives, documents, code,
|
|
41
|
+
* audio/video), with `application/octet-stream` for everything else — which
|
|
42
|
+
* is a correct answer for a download, just not a descriptive one.
|
|
43
|
+
*/
|
|
44
|
+
const MIME_BY_EXT: Record<string, string> = {
|
|
45
|
+
// Images (the set `isImageType` renders inline).
|
|
46
|
+
".png": "image/png",
|
|
47
|
+
".jpg": "image/jpeg",
|
|
48
|
+
".jpeg": "image/jpeg",
|
|
49
|
+
".gif": "image/gif",
|
|
50
|
+
".webp": "image/webp",
|
|
51
|
+
".bmp": "image/bmp",
|
|
52
|
+
".svg": "image/svg+xml",
|
|
53
|
+
".heic": "image/heic",
|
|
54
|
+
".heif": "image/heif",
|
|
55
|
+
".avif": "image/avif",
|
|
56
|
+
".ico": "image/x-icon",
|
|
57
|
+
".tif": "image/tiff",
|
|
58
|
+
".tiff": "image/tiff",
|
|
59
|
+
// Archives.
|
|
60
|
+
".zip": "application/zip",
|
|
61
|
+
".gz": "application/gzip",
|
|
62
|
+
".tgz": "application/gzip",
|
|
63
|
+
".bz2": "application/x-bzip2",
|
|
64
|
+
".xz": "application/x-xz",
|
|
65
|
+
".zst": "application/zstd",
|
|
66
|
+
".tar": "application/x-tar",
|
|
67
|
+
".7z": "application/x-7z-compressed",
|
|
68
|
+
".rar": "application/vnd.rar",
|
|
69
|
+
// Documents.
|
|
70
|
+
".pdf": "application/pdf",
|
|
71
|
+
".doc": "application/msword",
|
|
72
|
+
".docx":
|
|
73
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
74
|
+
".xls": "application/vnd.ms-excel",
|
|
75
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
76
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
77
|
+
".pptx":
|
|
78
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
79
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
80
|
+
".rtf": "application/rtf",
|
|
81
|
+
".epub": "application/epub+zip",
|
|
82
|
+
// Text + code.
|
|
83
|
+
".txt": "text/plain",
|
|
84
|
+
".md": "text/markdown",
|
|
85
|
+
".csv": "text/csv",
|
|
86
|
+
".tsv": "text/tab-separated-values",
|
|
87
|
+
".log": "text/plain",
|
|
88
|
+
".json": "application/json",
|
|
89
|
+
".jsonl": "application/x-ndjson",
|
|
90
|
+
".xml": "application/xml",
|
|
91
|
+
".yaml": "application/yaml",
|
|
92
|
+
".yml": "application/yaml",
|
|
93
|
+
".toml": "application/toml",
|
|
94
|
+
".html": "text/html",
|
|
95
|
+
".css": "text/css",
|
|
96
|
+
".js": "text/javascript",
|
|
97
|
+
".mjs": "text/javascript",
|
|
98
|
+
".ts": "text/x-typescript",
|
|
99
|
+
".tsx": "text/x-typescript",
|
|
100
|
+
".py": "text/x-python",
|
|
101
|
+
".rs": "text/x-rust",
|
|
102
|
+
".go": "text/x-go",
|
|
103
|
+
".java": "text/x-java",
|
|
104
|
+
".c": "text/x-c",
|
|
105
|
+
".h": "text/x-c",
|
|
106
|
+
".cpp": "text/x-c++",
|
|
107
|
+
".sh": "application/x-sh",
|
|
108
|
+
".sql": "application/sql",
|
|
109
|
+
".patch": "text/x-diff",
|
|
110
|
+
".diff": "text/x-diff",
|
|
111
|
+
// Audio / video.
|
|
112
|
+
".mp3": "audio/mpeg",
|
|
113
|
+
".m4a": "audio/mp4",
|
|
114
|
+
".wav": "audio/wav",
|
|
115
|
+
".ogg": "audio/ogg",
|
|
116
|
+
".opus": "audio/opus",
|
|
117
|
+
".flac": "audio/flac",
|
|
118
|
+
".mp4": "video/mp4",
|
|
119
|
+
".mov": "video/quicktime",
|
|
120
|
+
".webm": "video/webm",
|
|
121
|
+
".mkv": "video/x-matroska",
|
|
122
|
+
".avi": "video/x-msvideo",
|
|
123
|
+
// Fonts.
|
|
124
|
+
".ttf": "font/ttf",
|
|
125
|
+
".otf": "font/otf",
|
|
126
|
+
".woff": "font/woff",
|
|
127
|
+
".woff2": "font/woff2",
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Best-effort MIME type for a path, by extension. */
|
|
131
|
+
export function contentTypeFor(filePath: string): string {
|
|
132
|
+
return (
|
|
133
|
+
MIME_BY_EXT[extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether a MIME type is one clients render inline as an image. */
|
|
138
|
+
export function isImageType(mimeType: string): boolean {
|
|
139
|
+
// SVG is an image by type but a script host by nature — clients render it
|
|
140
|
+
// through the same <img> path, so keep it inline-able; the bytes are only
|
|
141
|
+
// ever served back to the authenticated client that uploaded them.
|
|
142
|
+
return mimeType.startsWith("image/");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Sanitise an uploaded file name to a single safe path segment. Strips any
|
|
147
|
+
* directory component (`../`, absolute paths, Windows separators) and any
|
|
148
|
+
* character outside a conservative allowlist, so an upload can never escape
|
|
149
|
+
* the uploads dir no matter what the client sends.
|
|
150
|
+
*/
|
|
151
|
+
export function safeUploadName(filename: string): string {
|
|
152
|
+
const base = basename(filename.replace(/\\/g, "/"));
|
|
153
|
+
const safe = base.replace(/[^\w.-]+/g, "_").replace(/^\.+/, "");
|
|
154
|
+
return safe || "upload";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The absolute path an upload with this name lands at (unique per call). */
|
|
158
|
+
function uploadDest(runtime: NativeRuntime, filename: string): string {
|
|
159
|
+
return join(
|
|
160
|
+
dirs.uploads,
|
|
161
|
+
`${Date.now()}-${runtime.nextId().toString(36)}-${safeUploadName(filename)}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Stream an upload body straight to the uploads dir — never buffering the
|
|
167
|
+
* whole file in memory, so a multi-hundred-megabyte archive costs a socket
|
|
168
|
+
* and a file handle rather than the heap. Aborts (and cleans up the partial
|
|
169
|
+
* file) as soon as `maxBytes` is exceeded.
|
|
29
170
|
*/
|
|
30
|
-
export async function
|
|
171
|
+
export async function saveUploadStream(
|
|
31
172
|
runtime: NativeRuntime,
|
|
32
173
|
filename: string,
|
|
33
|
-
|
|
34
|
-
|
|
174
|
+
body: Readable,
|
|
175
|
+
maxBytes: number,
|
|
176
|
+
): Promise<{ path: string; size: number }> {
|
|
35
177
|
await mkdir(dirs.uploads, { recursive: true });
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
178
|
+
const dest = uploadDest(runtime, filename);
|
|
179
|
+
const sink = createWriteStream(dest);
|
|
180
|
+
let size = 0;
|
|
181
|
+
let tooLarge = false;
|
|
182
|
+
try {
|
|
183
|
+
await pipeline(
|
|
184
|
+
body,
|
|
185
|
+
async function* (source: AsyncIterable<Buffer>) {
|
|
186
|
+
for await (const chunk of source) {
|
|
187
|
+
size += chunk.length;
|
|
188
|
+
if (size > maxBytes) {
|
|
189
|
+
tooLarge = true;
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Upload exceeds the ${Math.floor(maxBytes / (1024 * 1024))} MB limit`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
yield chunk;
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
sink,
|
|
198
|
+
);
|
|
199
|
+
} catch (err) {
|
|
200
|
+
// Wait for the sink to actually close before removing the partial file:
|
|
201
|
+
// the pipeline can reject before the fd exists, and an unlink that races
|
|
202
|
+
// it leaves the file on disk once the open finally lands.
|
|
203
|
+
await closed(sink);
|
|
204
|
+
await unlink(dest).catch(() => {});
|
|
205
|
+
if (tooLarge) throw err;
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Upload failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (size === 0) {
|
|
211
|
+
await closed(sink);
|
|
212
|
+
await unlink(dest).catch(() => {});
|
|
213
|
+
throw new Error("Empty upload");
|
|
214
|
+
}
|
|
215
|
+
return { path: dest, size };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Resolve once a write stream is closed, destroying it if it still is not. */
|
|
219
|
+
function closed(sink: WriteStream): Promise<void> {
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
if (sink.closed) return resolve();
|
|
222
|
+
sink.once("close", () => resolve());
|
|
223
|
+
sink.destroy();
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Register a saved upload for serving and project it to its wire shape. The
|
|
229
|
+
* declared content type wins when it is specific; otherwise the extension
|
|
230
|
+
* decides, so a client that sends `application/octet-stream` for everything
|
|
231
|
+
* still gets a usefully typed attachment back.
|
|
232
|
+
*/
|
|
233
|
+
export function describeAttachment(
|
|
234
|
+
runtime: NativeRuntime,
|
|
235
|
+
file: { path: string; name: string; size: number; contentType?: string },
|
|
236
|
+
): ClientAttachment {
|
|
237
|
+
const declared = (file.contentType ?? "").split(";")[0]?.trim().toLowerCase();
|
|
238
|
+
const mimeType =
|
|
239
|
+
declared && declared !== "application/octet-stream"
|
|
240
|
+
? declared
|
|
241
|
+
: contentTypeFor(file.name || file.path);
|
|
242
|
+
const mediaId = registerMedia(runtime, file.path);
|
|
243
|
+
const attachment: ClientAttachment = {
|
|
244
|
+
path: file.path,
|
|
245
|
+
name: safeUploadName(file.name || basename(file.path)),
|
|
246
|
+
size: file.size,
|
|
247
|
+
mimeType,
|
|
248
|
+
url: mediaUrl(mediaId),
|
|
249
|
+
image: isImageType(mimeType),
|
|
250
|
+
};
|
|
251
|
+
// Remember it by media id: `/send` resolves the client's references through
|
|
252
|
+
// this registry instead of trusting paths off the wire.
|
|
253
|
+
runtime.uploads.set(mediaId, attachment);
|
|
254
|
+
return attachment;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The media id inside a `/media?id=…` reference, or undefined. */
|
|
258
|
+
export function mediaIdFrom(url: string | undefined): string | undefined {
|
|
259
|
+
if (!url) return undefined;
|
|
260
|
+
const match = /[?&]id=([^&]+)/.exec(url);
|
|
261
|
+
return match?.[1] ? decodeURIComponent(match[1]) : undefined;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Resolve one attachment reference from a client into the record this daemon
|
|
266
|
+
* minted at upload time. Unknown ids resolve to undefined — a client cannot
|
|
267
|
+
* name a file the daemon did not write to its own uploads dir.
|
|
268
|
+
*/
|
|
269
|
+
export function resolveUpload(
|
|
270
|
+
runtime: NativeRuntime,
|
|
271
|
+
ref: { url?: string; path?: string } | undefined,
|
|
272
|
+
): ClientAttachment | undefined {
|
|
273
|
+
if (!ref) return undefined;
|
|
274
|
+
const byId = mediaIdFrom(ref.url);
|
|
275
|
+
if (byId) {
|
|
276
|
+
const known = runtime.uploads.get(byId);
|
|
277
|
+
if (known) return known;
|
|
278
|
+
}
|
|
279
|
+
// Legacy single-file clients send only the on-disk path back. Match it
|
|
280
|
+
// against what was uploaded this run rather than accepting it outright.
|
|
281
|
+
if (ref.path) {
|
|
282
|
+
for (const entry of runtime.uploads.values()) {
|
|
283
|
+
if (entry.path === ref.path) return entry;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Re-describe an attachment persisted in history: the stored record already
|
|
291
|
+
* has the name/size/type, it just needs a fresh media id (ids are per-daemon
|
|
292
|
+
* run) so the bytes are fetchable again after a restart.
|
|
293
|
+
*/
|
|
294
|
+
export function rehydrateAttachment(
|
|
295
|
+
runtime: NativeRuntime,
|
|
296
|
+
stored: ClientAttachment,
|
|
297
|
+
): ClientAttachment {
|
|
298
|
+
return { ...stored, url: mediaUrl(registerMedia(runtime, stored.path)) };
|
|
43
299
|
}
|
|
@@ -52,6 +52,31 @@ export type ClientToolCall = {
|
|
|
52
52
|
output?: string;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* One file attached to a message — an image, an archive, a document, or
|
|
57
|
+
* anything else the user dropped on the composer. Additive in v1: older
|
|
58
|
+
* clients that only know `imagePath` keep rendering the first image.
|
|
59
|
+
*
|
|
60
|
+
* `path` is the absolute on-disk location in the daemon's uploads dir; it is
|
|
61
|
+
* what the model is pointed at so it can read the file itself. `url` is the
|
|
62
|
+
* relative bridge path a client fetches the bytes from (`/media?id=…`),
|
|
63
|
+
* resolved against its own base URL + token.
|
|
64
|
+
*/
|
|
65
|
+
export type ClientAttachment = {
|
|
66
|
+
/** Absolute path on the daemon host (handed to the model). */
|
|
67
|
+
path: string;
|
|
68
|
+
/** Original file name, for display. */
|
|
69
|
+
name: string;
|
|
70
|
+
/** Size in bytes. */
|
|
71
|
+
size: number;
|
|
72
|
+
/** Best-effort MIME type, `application/octet-stream` when unknown. */
|
|
73
|
+
mimeType: string;
|
|
74
|
+
/** Relative bridge path the bytes are served from (`/media?id=…`). */
|
|
75
|
+
url: string;
|
|
76
|
+
/** True when the client should render this inline as an image. */
|
|
77
|
+
image: boolean;
|
|
78
|
+
};
|
|
79
|
+
|
|
55
80
|
/** A single rendered message in a conversation. */
|
|
56
81
|
export type ClientMessage = {
|
|
57
82
|
id: string;
|
|
@@ -68,6 +93,12 @@ export type ClientMessage = {
|
|
|
68
93
|
* Present on photo messages the bot sends; `text` carries any caption.
|
|
69
94
|
*/
|
|
70
95
|
imagePath?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Every file attached to this message, in the order the user staged them.
|
|
98
|
+
* `imagePath` stays in sync with the first image here so clients written
|
|
99
|
+
* against the single-image shape keep working unchanged.
|
|
100
|
+
*/
|
|
101
|
+
attachments?: ClientAttachment[];
|
|
71
102
|
/** Tools that ran during this assistant turn (history hydration). */
|
|
72
103
|
tools?: ClientToolCall[];
|
|
73
104
|
/** Turn stats attached once the turn ended (history hydration). */
|
|
@@ -109,8 +140,10 @@ export type ContextInfo = {
|
|
|
109
140
|
*/
|
|
110
141
|
export type QueuedMessage = {
|
|
111
142
|
text: string;
|
|
112
|
-
/** True when
|
|
143
|
+
/** True when one or more files are attached to the queued send. */
|
|
113
144
|
hasAttachment: boolean;
|
|
145
|
+
/** How many files are attached (additive; older clients read the flag). */
|
|
146
|
+
attachmentCount?: number;
|
|
114
147
|
};
|
|
115
148
|
|
|
116
149
|
/** A conversation in the sidebar. */
|
|
@@ -9,7 +9,7 @@ import type { NativeRuntime, QueuedEntry } from "./runtime.js";
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Set (or replace) a chat's queued follow-up and sync it to every client via
|
|
12
|
-
* chat_updated. Empty text with no
|
|
12
|
+
* chat_updated. Empty text with no attachments clears the queue.
|
|
13
13
|
*/
|
|
14
14
|
export function setQueued(
|
|
15
15
|
runtime: NativeRuntime,
|
|
@@ -19,14 +19,11 @@ export function setQueued(
|
|
|
19
19
|
const entry = runtime.chats.get(chatId);
|
|
20
20
|
if (!entry) return;
|
|
21
21
|
const text = next.text.trim();
|
|
22
|
-
|
|
22
|
+
const attachments = next.attachments;
|
|
23
|
+
if (!text && attachments.length === 0) {
|
|
23
24
|
if (!runtime.queuedByChat.delete(chatId)) return;
|
|
24
25
|
} else {
|
|
25
|
-
runtime.queuedByChat.set(chatId, {
|
|
26
|
-
text,
|
|
27
|
-
imagePath: next.imagePath,
|
|
28
|
-
attachmentPath: next.attachmentPath,
|
|
29
|
-
});
|
|
26
|
+
runtime.queuedByChat.set(chatId, { text, attachments });
|
|
30
27
|
}
|
|
31
28
|
broadcastChatUpdated(runtime, entry);
|
|
32
29
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { RouteHost } from "./host.js";
|
|
2
2
|
import type { BridgeRoutes } from "./table.js";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import {
|
|
4
|
+
asAttachmentRefs,
|
|
5
|
+
asPositiveInt,
|
|
6
|
+
asString,
|
|
7
|
+
deviceIdParam,
|
|
8
|
+
} from "./params.js";
|
|
9
|
+
import { log, logWarn } from "../../../util/log.js";
|
|
6
10
|
|
|
7
11
|
export function chatRoutes(
|
|
8
12
|
host: RouteHost,
|
|
@@ -87,27 +91,56 @@ export function chatRoutes(
|
|
|
87
91
|
const body = await readJson(req);
|
|
88
92
|
const id = asString(body.chatId) ?? "";
|
|
89
93
|
const text = asString(body.text) ?? "";
|
|
94
|
+
// Multi-file clients send `attachments`; the single-image shape older
|
|
95
|
+
// clients send is folded into the same list by the handler.
|
|
96
|
+
const attachments = asAttachmentRefs(body.attachments);
|
|
90
97
|
const imagePath = asString(body.imagePath);
|
|
91
98
|
const attachmentPath = asString(body.attachmentPath);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
99
|
+
const hasAttachment =
|
|
100
|
+
attachments.length > 0 || Boolean(attachmentPath || imagePath);
|
|
101
|
+
// Text may be empty when a file is attached; require one or the other.
|
|
102
|
+
if (!id || (!text.trim() && !hasAttachment))
|
|
95
103
|
return json(res, 400, {
|
|
96
104
|
ok: false,
|
|
97
105
|
error: "chatId and text (or an attachment) required",
|
|
98
106
|
});
|
|
99
|
-
h.send(id, text, { imagePath, attachmentPath });
|
|
107
|
+
h.send(id, text, { attachments, imagePath, attachmentPath });
|
|
100
108
|
json(res, 202, { ok: true });
|
|
101
109
|
},
|
|
102
110
|
"POST /upload": async ({ req, res, url }) => {
|
|
103
111
|
const filename = url.searchParams.get("filename") ?? "upload";
|
|
104
112
|
const contentType =
|
|
105
113
|
req.headers["content-type"] ?? "application/octet-stream";
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
const startedAt = Date.now();
|
|
115
|
+
try {
|
|
116
|
+
const attachment = await h.upload(filename, contentType, req);
|
|
117
|
+
// Uploads are the one client action whose failure used to be visible
|
|
118
|
+
// only in the app: the route answered in JSON and logged nothing, so
|
|
119
|
+
// "attaching a file doesn't work — check the logs" had nothing to
|
|
120
|
+
// read. Both outcomes are logged now, with the size and duration
|
|
121
|
+
// that distinguish a rejected upload from a dropped connection.
|
|
122
|
+
log(
|
|
123
|
+
"native",
|
|
124
|
+
`upload ok: ${attachment.name} (${attachment.size} bytes, ${attachment.mimeType}) in ${Date.now() - startedAt}ms`,
|
|
125
|
+
);
|
|
126
|
+
// `imagePath` mirrors the URL for clients written against the
|
|
127
|
+
// single-image upload response.
|
|
128
|
+
json(res, 200, { ok: true, ...attachment, imagePath: attachment.url });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// The client can only act on this if it knows why: too large (pick a
|
|
131
|
+
// smaller file), empty (nothing was read), or a server-side failure.
|
|
132
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
133
|
+
const status = /limit/i.test(error)
|
|
134
|
+
? 413
|
|
135
|
+
: /empty/i.test(error)
|
|
136
|
+
? 400
|
|
137
|
+
: 500;
|
|
138
|
+
logWarn(
|
|
139
|
+
"native",
|
|
140
|
+
`upload failed (${status}) for ${filename} after ${Date.now() - startedAt}ms: ${error}`,
|
|
141
|
+
);
|
|
142
|
+
json(res, status, { ok: false, error });
|
|
143
|
+
}
|
|
111
144
|
},
|
|
112
145
|
"GET /media": ({ res, url }) =>
|
|
113
146
|
host.serveMedia(res, url.searchParams.get("id") ?? ""),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Readable } from "node:stream";
|
|
3
|
+
import type { AttachmentRef } from "./params.js";
|
|
2
4
|
import type {
|
|
3
5
|
BackendOption,
|
|
6
|
+
ClientAttachment,
|
|
4
7
|
BridgeEvent,
|
|
5
8
|
BridgeStatus,
|
|
6
9
|
ClientChat,
|
|
@@ -18,7 +21,13 @@ import type {
|
|
|
18
21
|
import type { ConfigSnapshot } from "../settings.js";
|
|
19
22
|
|
|
20
23
|
/** Optional attachment references carried alongside a sent message. */
|
|
21
|
-
type SendOptions = {
|
|
24
|
+
export type SendOptions = {
|
|
25
|
+
/**
|
|
26
|
+
* Every file attached to the message, as the client's references to its
|
|
27
|
+
* own uploads. `imagePath` and `attachmentPath` below are the single-file
|
|
28
|
+
* shape older clients send; the handler folds them into the same list.
|
|
29
|
+
*/
|
|
30
|
+
attachments?: AttachmentRef[];
|
|
22
31
|
/** Relative bridge path to render inline (e.g. `/media?id=…`). */
|
|
23
32
|
imagePath?: string;
|
|
24
33
|
/** Absolute on-disk path handed to the model so it can read the file. */
|
|
@@ -41,12 +50,16 @@ export type BridgeServerHandlers = {
|
|
|
41
50
|
search(query: string, chatId?: string): SearchResult[];
|
|
42
51
|
/** Fire-and-forget: streams its results back through `broadcast`. */
|
|
43
52
|
send(id: string, text: string, opts?: SendOptions): void;
|
|
44
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* Stream an uploaded file to disk and return its wire description. The body
|
|
55
|
+
* is consumed as it arrives (never buffered whole), so the size ceiling is
|
|
56
|
+
* disk, not memory.
|
|
57
|
+
*/
|
|
45
58
|
upload(
|
|
46
59
|
filename: string,
|
|
47
60
|
contentType: string,
|
|
48
|
-
|
|
49
|
-
): Promise<
|
|
61
|
+
body: Readable,
|
|
62
|
+
): Promise<ClientAttachment>;
|
|
50
63
|
listModels(
|
|
51
64
|
id?: string,
|
|
52
65
|
):
|
|
@@ -139,7 +152,6 @@ export type RouteHost = {
|
|
|
139
152
|
fingerprint: () => string | null;
|
|
140
153
|
json: (res: ServerResponse, code: number, body: unknown) => void;
|
|
141
154
|
readJson: (req: IncomingMessage) => Promise<Record<string, unknown>>;
|
|
142
|
-
readRaw: (req: IncomingMessage, max: number) => Promise<Buffer>;
|
|
143
155
|
corsHeaders: () => Record<string, string>;
|
|
144
156
|
streamFile: (
|
|
145
157
|
res: ServerResponse,
|
|
@@ -27,3 +27,24 @@ export function asPositiveInt(v: string | null): number | undefined {
|
|
|
27
27
|
const n = Number(v);
|
|
28
28
|
return Number.isInteger(n) && n > 0 ? n : undefined;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** A client's reference to a file it already uploaded. */
|
|
32
|
+
export type AttachmentRef = { url?: string; path?: string };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Coerce the `attachments` array on a `/send` body to reference shape. Only
|
|
36
|
+
* the identifying fields are read: name, size and type are whatever the
|
|
37
|
+
* daemon recorded when it wrote the file, never what the client claims now.
|
|
38
|
+
*/
|
|
39
|
+
export function asAttachmentRefs(v: unknown): AttachmentRef[] {
|
|
40
|
+
if (!Array.isArray(v)) return [];
|
|
41
|
+
const refs: AttachmentRef[] = [];
|
|
42
|
+
for (const item of v) {
|
|
43
|
+
if (typeof item !== "object" || item === null) continue;
|
|
44
|
+
const row = item as Record<string, unknown>;
|
|
45
|
+
const url = asString(row.url);
|
|
46
|
+
const path = asString(row.path);
|
|
47
|
+
if (url || path) refs.push({ url, path });
|
|
48
|
+
}
|
|
49
|
+
return refs;
|
|
50
|
+
}
|
|
@@ -12,7 +12,12 @@ import type { TalonConfig } from "../../util/config.js";
|
|
|
12
12
|
import type { Gateway } from "../../core/engine/gateway.js";
|
|
13
13
|
import { getMeshService, type MeshService } from "../../core/mesh/index.js";
|
|
14
14
|
import { NativeChats } from "./chats.js";
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
BridgeEvent,
|
|
17
|
+
ClientAttachment,
|
|
18
|
+
ClientToolCall,
|
|
19
|
+
ContextInfo,
|
|
20
|
+
} from "./protocol.js";
|
|
16
21
|
|
|
17
22
|
/** One tool call observed during a running turn, kept for mid-turn replay. */
|
|
18
23
|
export type LiveToolEntry = {
|
|
@@ -21,11 +26,10 @@ export type LiveToolEntry = {
|
|
|
21
26
|
done?: boolean;
|
|
22
27
|
};
|
|
23
28
|
|
|
24
|
-
/** A chat's single queued follow-up, with its
|
|
29
|
+
/** A chat's single queued follow-up, with its attachments intact. */
|
|
25
30
|
export type QueuedEntry = {
|
|
26
31
|
text: string;
|
|
27
|
-
|
|
28
|
-
attachmentPath?: string;
|
|
32
|
+
attachments: ClientAttachment[];
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
export type NativeRuntime = {
|
|
@@ -51,6 +55,13 @@ export type NativeRuntime = {
|
|
|
51
55
|
* frontends behave).
|
|
52
56
|
*/
|
|
53
57
|
readonly media: Map<string, string>;
|
|
58
|
+
/**
|
|
59
|
+
* Uploads this daemon run has accepted, keyed by their media id. `/send`
|
|
60
|
+
* resolves a client's attachment references through here rather than
|
|
61
|
+
* trusting the paths in the request body, so a message can only ever point
|
|
62
|
+
* the model at a file this daemon itself wrote to the uploads dir.
|
|
63
|
+
*/
|
|
64
|
+
readonly uploads: Map<string, ClientAttachment>;
|
|
54
65
|
/**
|
|
55
66
|
* Live context-window fill per chat, refreshed at the end of each turn.
|
|
56
67
|
* Cached (not computed inline) so the sync `toClientChat` projection stays
|
|
@@ -99,6 +110,7 @@ export function createNativeRuntime(
|
|
|
99
110
|
chats: new NativeChats(),
|
|
100
111
|
mesh: getMeshService(),
|
|
101
112
|
media: new Map(),
|
|
113
|
+
uploads: new Map(),
|
|
102
114
|
contextByChat: new Map(),
|
|
103
115
|
liveTurns: new Map(),
|
|
104
116
|
queuedByChat: new Map(),
|
|
@@ -24,9 +24,9 @@ import { createServer as createTlsServer } from "node:https";
|
|
|
24
24
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
25
25
|
import { createReadStream } from "node:fs";
|
|
26
26
|
import { stat } from "node:fs/promises";
|
|
27
|
-
import { extname } from "node:path";
|
|
28
27
|
import { log, logError, logDebug, logWarn } from "../../util/log.js";
|
|
29
28
|
import { formatFingerprint, type BridgeTlsIdentity } from "./tls.js";
|
|
29
|
+
import { contentTypeFor } from "./media.js";
|
|
30
30
|
import { type BridgeEvent } from "./protocol.js";
|
|
31
31
|
import { buildRoutes } from "./routes/index.js";
|
|
32
32
|
import type { BridgeServerHandlers, RouteHost } from "./routes/host.js";
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type RouteHandler,
|
|
39
39
|
} from "./routes/table.js";
|
|
40
40
|
|
|
41
|
-
export type { BridgeServerHandlers } from "./routes/host.js";
|
|
41
|
+
export type { BridgeServerHandlers, SendOptions } from "./routes/host.js";
|
|
42
42
|
export { BRIDGE_ROUTE_AUTH, type BridgeRouteKey } from "./routes/table.js";
|
|
43
43
|
|
|
44
44
|
const SSE_PING_MS = 25_000;
|
|
@@ -352,7 +352,6 @@ export class BridgeServer {
|
|
|
352
352
|
fingerprint: () => this.getFingerprint(),
|
|
353
353
|
json: (res, code, body) => this.json(res, code, body),
|
|
354
354
|
readJson: (req) => this.readJson(req),
|
|
355
|
-
readRaw: (req, max) => this.readRaw(req, max),
|
|
356
355
|
corsHeaders: () => this.corsHeaders(),
|
|
357
356
|
streamFile: (res, file) => this.streamFile(res, file),
|
|
358
357
|
serveMedia: (res, id) => this.serveMedia(res, id),
|
|
@@ -591,17 +590,6 @@ export class BridgeServer {
|
|
|
591
590
|
}
|
|
592
591
|
|
|
593
592
|
/** Read a raw request body (binary-safe) up to `max` bytes. */
|
|
594
|
-
private async readRaw(req: IncomingMessage, max: number): Promise<Buffer> {
|
|
595
|
-
const chunks: Buffer[] = [];
|
|
596
|
-
let total = 0;
|
|
597
|
-
for await (const chunk of req) {
|
|
598
|
-
total += (chunk as Buffer).length;
|
|
599
|
-
if (total > max) throw new Error("Upload too large");
|
|
600
|
-
chunks.push(chunk as Buffer);
|
|
601
|
-
}
|
|
602
|
-
return Buffer.concat(chunks);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
593
|
private async readJson(
|
|
606
594
|
req: IncomingMessage,
|
|
607
595
|
): Promise<Record<string, unknown>> {
|
|
@@ -621,24 +609,3 @@ export class BridgeServer {
|
|
|
621
609
|
return parsed as Record<string, unknown>;
|
|
622
610
|
}
|
|
623
611
|
}
|
|
624
|
-
|
|
625
|
-
/** Minimal image content-type map for the media endpoint. */
|
|
626
|
-
function contentTypeFor(filePath: string): string {
|
|
627
|
-
switch (extname(filePath).toLowerCase()) {
|
|
628
|
-
case ".png":
|
|
629
|
-
return "image/png";
|
|
630
|
-
case ".jpg":
|
|
631
|
-
case ".jpeg":
|
|
632
|
-
return "image/jpeg";
|
|
633
|
-
case ".gif":
|
|
634
|
-
return "image/gif";
|
|
635
|
-
case ".webp":
|
|
636
|
-
return "image/webp";
|
|
637
|
-
case ".bmp":
|
|
638
|
-
return "image/bmp";
|
|
639
|
-
case ".svg":
|
|
640
|
-
return "image/svg+xml";
|
|
641
|
-
default:
|
|
642
|
-
return "application/octet-stream";
|
|
643
|
-
}
|
|
644
|
-
}
|