talon-agent 3.35.0 → 3.36.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/frontend/native/chat-wire.ts +6 -2
- package/src/frontend/native/emit.ts +30 -11
- package/src/frontend/native/handlers.ts +62 -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 +30 -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/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/package.json
CHANGED
|
@@ -14,14 +14,18 @@ import type { ChatEntry } from "./chats.js";
|
|
|
14
14
|
import type { ClientChat, QueuedMessage } from "./protocol.js";
|
|
15
15
|
import type { NativeRuntime } from "./runtime.js";
|
|
16
16
|
|
|
17
|
-
/** Project the stored queue entry to its wire shape (text +
|
|
17
|
+
/** Project the stored queue entry to its wire shape (text + attachments). */
|
|
18
18
|
function toQueued(
|
|
19
19
|
runtime: NativeRuntime,
|
|
20
20
|
chatId: string,
|
|
21
21
|
): QueuedMessage | undefined {
|
|
22
22
|
const q = runtime.queuedByChat.get(chatId);
|
|
23
23
|
if (!q) return undefined;
|
|
24
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
text: q.text,
|
|
26
|
+
hasAttachment: q.attachments.length > 0,
|
|
27
|
+
attachmentCount: q.attachments.length,
|
|
28
|
+
};
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export function toClientChat(
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
BOT_SENDER_ID,
|
|
13
13
|
USER_SENDER_ID,
|
|
14
14
|
type ClientButton,
|
|
15
|
+
type ClientAttachment,
|
|
15
16
|
type ClientMessage,
|
|
16
17
|
} from "./protocol.js";
|
|
17
18
|
import type { NativeRuntime } from "./runtime.js";
|
|
@@ -104,43 +105,61 @@ export function emitPhoto(
|
|
|
104
105
|
return id;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** The sidebar preview for a message whose real content is its files. */
|
|
109
|
+
function attachmentPreview(attachments: ClientAttachment[]): string {
|
|
110
|
+
if (attachments.length === 1) {
|
|
111
|
+
const only = attachments[0]!;
|
|
112
|
+
return only.image ? "[photo]" : `[${only.name}]`;
|
|
113
|
+
}
|
|
114
|
+
return `[${attachments.length} files]`;
|
|
115
|
+
}
|
|
116
|
+
|
|
107
117
|
/** Persist + broadcast a user message; returns its numeric id so the turn
|
|
108
118
|
* hands the model that same id (as `[msg_id:N]`) to react/reply to.
|
|
109
|
-
*
|
|
119
|
+
* Attached files ride along on the message and are persisted with it. */
|
|
110
120
|
export function emitUser(
|
|
111
121
|
runtime: NativeRuntime,
|
|
112
122
|
entry: ChatEntry,
|
|
113
123
|
text: string,
|
|
114
|
-
|
|
115
|
-
attachmentPath?: string,
|
|
124
|
+
attachments: ClientAttachment[] = [],
|
|
116
125
|
): number {
|
|
117
126
|
const id = runtime.nextId();
|
|
118
127
|
const ts = Date.now();
|
|
128
|
+
// `imagePath` stays in sync with the first image so clients written against
|
|
129
|
+
// the single-image shape keep rendering it.
|
|
130
|
+
const firstImage = attachments.find((a) => a.image);
|
|
119
131
|
const message: ClientMessage = {
|
|
120
132
|
id: String(id),
|
|
121
133
|
chatId: entry.id,
|
|
122
134
|
role: "user",
|
|
123
135
|
text,
|
|
124
136
|
ts,
|
|
125
|
-
...(
|
|
137
|
+
...(firstImage ? { imagePath: firstImage.url } : {}),
|
|
138
|
+
...(attachments.length ? { attachments } : {}),
|
|
126
139
|
};
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
140
|
+
// Persist the attachment records so the files re-render on history reload
|
|
141
|
+
// (rehydrated in `history.ts`) instead of vanishing to a text-only
|
|
142
|
+
// placeholder. mediaType/filePath keep the pre-multi-file row shape
|
|
143
|
+
// populated from the first attachment. Caption stays as text.
|
|
144
|
+
const first = attachments[0];
|
|
130
145
|
pushMessage(entry.id, {
|
|
131
146
|
msgId: id,
|
|
132
147
|
senderId: USER_SENDER_ID,
|
|
133
148
|
senderName: "User",
|
|
134
149
|
text,
|
|
135
150
|
timestamp: ts,
|
|
136
|
-
...(
|
|
151
|
+
...(first
|
|
137
152
|
? {
|
|
138
|
-
mediaType: "photo" as const,
|
|
139
|
-
|
|
153
|
+
mediaType: first.image ? ("photo" as const) : ("document" as const),
|
|
154
|
+
filePath: first.path,
|
|
155
|
+
attachments,
|
|
140
156
|
}
|
|
141
157
|
: {}),
|
|
142
158
|
});
|
|
143
|
-
runtime.chats.touch(
|
|
159
|
+
runtime.chats.touch(
|
|
160
|
+
entry.id,
|
|
161
|
+
attachments.length ? text || attachmentPreview(attachments) : text,
|
|
162
|
+
);
|
|
144
163
|
maybeAutoTitle(runtime, entry, text);
|
|
145
164
|
runtime.broadcast({ kind: "message", chatId: entry.id, message });
|
|
146
165
|
broadcastChatUpdated(runtime, entry);
|
|
@@ -18,7 +18,12 @@ import {
|
|
|
18
18
|
} from "./extensions.js";
|
|
19
19
|
import { historyPage, searchHistory } from "./history.js";
|
|
20
20
|
import { readLogEntries } from "./logs.js";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
describeAttachment,
|
|
23
|
+
MAX_UPLOAD_BYTES,
|
|
24
|
+
resolveUpload,
|
|
25
|
+
saveUploadStream,
|
|
26
|
+
} from "./media.js";
|
|
22
27
|
import {
|
|
23
28
|
effortLevels,
|
|
24
29
|
listBackends,
|
|
@@ -30,11 +35,40 @@ import {
|
|
|
30
35
|
import { setQueued } from "./queue.js";
|
|
31
36
|
import { resetChat } from "./reset.js";
|
|
32
37
|
import type { NativeRuntime } from "./runtime.js";
|
|
33
|
-
import type { BridgeServerHandlers } from "./server.js";
|
|
38
|
+
import type { BridgeServerHandlers, SendOptions } from "./server.js";
|
|
39
|
+
import type { ClientAttachment } from "./protocol.js";
|
|
34
40
|
import { configSnapshot, applyConfigUpdate } from "./settings.js";
|
|
35
41
|
import { bridgeStatus, broadcastStatus } from "./status.js";
|
|
36
42
|
import { interruptTurn, isBusy, liveTurnEvents, startTurn } from "./turn.js";
|
|
37
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Turn a `/send` body's attachment references into the records this daemon
|
|
46
|
+
* minted at upload time. Both wire shapes land here: the `attachments` list
|
|
47
|
+
* multi-file clients send, and the `imagePath`/`attachmentPath` pair older
|
|
48
|
+
* single-image clients send. References the daemon cannot account for are
|
|
49
|
+
* dropped — a message can only ever point the model at a file the daemon
|
|
50
|
+
* itself wrote to its uploads dir.
|
|
51
|
+
*/
|
|
52
|
+
function resolveAttachments(
|
|
53
|
+
runtime: NativeRuntime,
|
|
54
|
+
opts: SendOptions | undefined,
|
|
55
|
+
): ClientAttachment[] {
|
|
56
|
+
const refs = [...(opts?.attachments ?? [])];
|
|
57
|
+
if (!refs.length && (opts?.imagePath || opts?.attachmentPath)) {
|
|
58
|
+
refs.push({ url: opts.imagePath, path: opts.attachmentPath });
|
|
59
|
+
}
|
|
60
|
+
const resolved: ClientAttachment[] = [];
|
|
61
|
+
for (const ref of refs) {
|
|
62
|
+
const found = resolveUpload(runtime, ref);
|
|
63
|
+
// Same file referenced twice (a re-send of a queued message, say) stays
|
|
64
|
+
// one attachment.
|
|
65
|
+
if (found && !resolved.some((a) => a.path === found.path)) {
|
|
66
|
+
resolved.push(found);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return resolved;
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
export function buildBridgeHandlers(
|
|
39
73
|
runtime: NativeRuntime,
|
|
40
74
|
): BridgeServerHandlers {
|
|
@@ -49,29 +83,44 @@ export function buildBridgeHandlers(
|
|
|
49
83
|
search: (query, chatId) => searchHistory(runtime, query, chatId),
|
|
50
84
|
send: (id, text, opts) => {
|
|
51
85
|
const entry = chats.get(id) ?? chats.ensure(id);
|
|
86
|
+
// Resolve the client's references into the records this daemon minted
|
|
87
|
+
// at upload time — dropping anything it can't account for.
|
|
88
|
+
const attachments = resolveAttachments(runtime, opts);
|
|
52
89
|
// A turn is already running for this chat — don't interrupt it. Park the
|
|
53
90
|
// message as the single queued follow-up (synced to every client); it
|
|
54
91
|
// auto-sends when the running turn ends. `isBusy` reads `liveTurns`,
|
|
55
92
|
// which `runTurn` sets synchronously, so even a rapid second /send from
|
|
56
93
|
// any client is caught here rather than starting a concurrent turn.
|
|
57
94
|
if (isBusy(runtime, entry.id)) {
|
|
58
|
-
setQueued(runtime, entry.id, {
|
|
59
|
-
text,
|
|
60
|
-
imagePath: opts?.imagePath,
|
|
61
|
-
attachmentPath: opts?.attachmentPath,
|
|
62
|
-
});
|
|
95
|
+
setQueued(runtime, entry.id, { text, attachments });
|
|
63
96
|
return;
|
|
64
97
|
}
|
|
65
|
-
startTurn(runtime, entry, text,
|
|
98
|
+
startTurn(runtime, entry, text, { attachments });
|
|
66
99
|
},
|
|
67
100
|
queueMessage: (id, text) => {
|
|
68
|
-
// Edit/replace the queued follow-up
|
|
101
|
+
// Edit/replace the queued follow-up's text, keeping whatever files were
|
|
102
|
+
// queued with it. Empty text cancels the whole follow-up, attachments
|
|
103
|
+
// included — the client's queue editor offers no other way to drop it.
|
|
69
104
|
const entry = chats.get(id);
|
|
70
|
-
if (entry)
|
|
105
|
+
if (!entry) return;
|
|
106
|
+
const attachments = text.trim()
|
|
107
|
+
? (runtime.queuedByChat.get(entry.id)?.attachments ?? [])
|
|
108
|
+
: [];
|
|
109
|
+
setQueued(runtime, entry.id, { text, attachments });
|
|
71
110
|
},
|
|
72
|
-
upload: async (filename,
|
|
73
|
-
const
|
|
74
|
-
|
|
111
|
+
upload: async (filename, contentType, body) => {
|
|
112
|
+
const saved = await saveUploadStream(
|
|
113
|
+
runtime,
|
|
114
|
+
filename,
|
|
115
|
+
body,
|
|
116
|
+
MAX_UPLOAD_BYTES,
|
|
117
|
+
);
|
|
118
|
+
return describeAttachment(runtime, {
|
|
119
|
+
path: saved.path,
|
|
120
|
+
name: filename,
|
|
121
|
+
size: saved.size,
|
|
122
|
+
contentType,
|
|
123
|
+
});
|
|
75
124
|
},
|
|
76
125
|
listModels: (chatId) => listModels(runtime, chatId),
|
|
77
126
|
setModel: (id, model) => setModel(runtime, id, model),
|
|
@@ -3,18 +3,21 @@
|
|
|
3
3
|
* (attached images get a fresh /media URL, assistant rows their turn meta).
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { basename } from "node:path";
|
|
6
7
|
import {
|
|
7
8
|
getRecentHistory,
|
|
8
9
|
getHistoryBefore,
|
|
9
10
|
searchHistoryMessages,
|
|
10
11
|
type HistoryMessage,
|
|
12
|
+
type MessageAttachment,
|
|
11
13
|
} from "../../storage/history.js";
|
|
12
14
|
import { isDeliveryTool } from "../../core/tools/index.js";
|
|
13
15
|
import type { ChatEntry } from "./chats.js";
|
|
14
16
|
import { refreshContext } from "./context.js";
|
|
15
|
-
import {
|
|
17
|
+
import { contentTypeFor, rehydrateAttachment } from "./media.js";
|
|
16
18
|
import {
|
|
17
19
|
historyToClientMessage,
|
|
20
|
+
type ClientAttachment,
|
|
18
21
|
type ClientMessage,
|
|
19
22
|
type SearchResult,
|
|
20
23
|
} from "./protocol.js";
|
|
@@ -29,11 +32,32 @@ function hydrateHistoryRow(
|
|
|
29
32
|
row: HistoryMessage,
|
|
30
33
|
): ClientMessage {
|
|
31
34
|
const msg = historyToClientMessage(row, chatId);
|
|
32
|
-
// Re-hydrate
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Re-hydrate attached files: media ids are per-daemon-run, so each stored
|
|
36
|
+
// attachment is re-registered and handed a fresh /media URL. Rows written
|
|
37
|
+
// before multi-file attachments carry only mediaType + filePath, which is
|
|
38
|
+
// projected into the same one-element list so every client renders history
|
|
39
|
+
// the same way regardless of when it was recorded.
|
|
40
|
+
const stored: ClientAttachment[] = row.attachments?.length
|
|
41
|
+
? row.attachments.map((a: MessageAttachment) =>
|
|
42
|
+
rehydrateAttachment(runtime, { ...a, url: "" }),
|
|
43
|
+
)
|
|
44
|
+
: row.filePath &&
|
|
45
|
+
(row.mediaType === "photo" || row.mediaType === "document")
|
|
46
|
+
? [
|
|
47
|
+
rehydrateAttachment(runtime, {
|
|
48
|
+
path: row.filePath,
|
|
49
|
+
name: basename(row.filePath),
|
|
50
|
+
size: 0,
|
|
51
|
+
mimeType: contentTypeFor(row.filePath),
|
|
52
|
+
image: row.mediaType === "photo",
|
|
53
|
+
url: "",
|
|
54
|
+
}),
|
|
55
|
+
]
|
|
56
|
+
: [];
|
|
57
|
+
if (stored.length) {
|
|
58
|
+
msg.attachments = stored;
|
|
59
|
+
const firstImage = stored.find((a) => a.image);
|
|
60
|
+
if (firstImage) msg.imagePath = firstImage.url;
|
|
37
61
|
}
|
|
38
62
|
// Re-hydrate turn meta (tool timeline + stats) for assistant rows.
|
|
39
63
|
if (msg.role === "assistant") {
|
|
@@ -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
|
}
|