@saadnvd1/agent-os 0.3.672 → 0.3.673
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/app/api/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- package/components/Chat/ChatPanel.tsx +9 -2
- package/components/Chat/FileChip.tsx +67 -0
- package/components/Chat/Messages.tsx +8 -0
- package/components/Chat/Queue.tsx +6 -0
- package/components/Chat/composer/Attachments.tsx +16 -2
- package/components/Chat/composer/ComposerBody.tsx +35 -14
- package/components/Chat/composer/Toolbar.tsx +11 -8
- package/components/Chat/composer/editor.test.ts +1 -1
- package/components/Chat/composer/paste.ts +5 -4
- package/components/Chat/composer/useAttachFiles.ts +142 -0
- package/components/Chat/composer/useComposerEditor.ts +3 -3
- package/components/Chat/useDraft.ts +9 -2
- package/components/Draft/DraftPanel.tsx +7 -4
- package/components/Terminal/index.tsx +6 -5
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/sessions/queries.ts +2 -1
- package/lib/attachments/check.ts +50 -0
- package/lib/attachments/message.ts +49 -0
- package/lib/attachments/policy.ts +116 -0
- package/lib/attachments/store.ts +92 -0
- package/lib/attachments/types.ts +8 -0
- package/lib/chat/client-send.ts +6 -2
- package/lib/chat/driver.ts +4 -1
- package/lib/chat/drivers/claude.ts +2 -0
- package/lib/chat/events.ts +13 -1
- package/lib/chat/pending.ts +2 -1
- package/lib/chat/queued.ts +24 -7
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/worker/host.ts +7 -3
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/config/client-config-types.ts +14 -3
- package/lib/config/client-config.ts +1 -1
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/file-upload.ts +61 -27
- package/lib/intake/intake.ts +2 -17
- package/lib/intake/sniff.ts +1 -1
- package/lib/sessions/launch.ts +12 -6
- package/package.json +1 -1
- package/components/Chat/composer/useImageDrop.ts +0 -52
|
@@ -8,7 +8,8 @@ export interface PasteHandlers {
|
|
|
8
8
|
plain: boolean;
|
|
9
9
|
// Cmd/Ctrl+Shift+V was pressed just before this paste.
|
|
10
10
|
asText: boolean;
|
|
11
|
-
|
|
11
|
+
// Files pasted: images, documents, anything the composer may attach.
|
|
12
|
+
onFiles: (files: File[]) => void;
|
|
12
13
|
onLongPaste: (text: string, language?: string) => void;
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -58,9 +59,9 @@ export function handlePaste(
|
|
|
58
59
|
): boolean {
|
|
59
60
|
const data = event.clipboardData;
|
|
60
61
|
if (!data) return false;
|
|
61
|
-
const
|
|
62
|
-
if (
|
|
63
|
-
h.
|
|
62
|
+
const files = [...data.files];
|
|
63
|
+
if (files.length) {
|
|
64
|
+
h.onFiles(files);
|
|
64
65
|
return true;
|
|
65
66
|
}
|
|
66
67
|
const text = data.getData("text/plain").replace(/\r\n?/g, "\n");
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRef, useState } from "react";
|
|
4
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import {
|
|
7
|
+
clientConfigQuery,
|
|
8
|
+
useClientConfig,
|
|
9
|
+
} from "@/data/client-config/queries";
|
|
10
|
+
import {
|
|
11
|
+
acceptAttr,
|
|
12
|
+
tooBigMessage,
|
|
13
|
+
typeFor,
|
|
14
|
+
unsupportedMessage,
|
|
15
|
+
} from "@/lib/attachments/policy";
|
|
16
|
+
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
17
|
+
import { uploadFile } from "@/lib/file-upload";
|
|
18
|
+
import { isImage } from "./paste";
|
|
19
|
+
|
|
20
|
+
export interface Upload {
|
|
21
|
+
key: string;
|
|
22
|
+
file: ChatFile;
|
|
23
|
+
uploading?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readImage(file: File): Promise<ChatImage> {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const reader = new FileReader();
|
|
29
|
+
reader.onload = () => {
|
|
30
|
+
const [meta, data] = String(reader.result).split(",");
|
|
31
|
+
resolve({ mediaType: meta.slice(5, meta.indexOf(";")), data });
|
|
32
|
+
};
|
|
33
|
+
reader.onerror = reject;
|
|
34
|
+
reader.readAsDataURL(file);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const carriesFiles = (e: React.DragEvent) =>
|
|
39
|
+
[...e.dataTransfer.types].includes("Files");
|
|
40
|
+
|
|
41
|
+
let seq = 0;
|
|
42
|
+
|
|
43
|
+
// Files picked, pasted or dropped onto the composer, and the outline shown
|
|
44
|
+
// while something is dragged over it. Images are read in and sent with the
|
|
45
|
+
// message; any other file is uploaded to the session's attachments folder
|
|
46
|
+
// at once, so a wrong type or a too-big file fails here, not at send. The
|
|
47
|
+
// types and sizes come from the server (/api/client-config).
|
|
48
|
+
export function useAttachFiles({
|
|
49
|
+
scope,
|
|
50
|
+
imageCount,
|
|
51
|
+
onImages,
|
|
52
|
+
}: {
|
|
53
|
+
// The session's id, or "draft" for a chat that hasn't started.
|
|
54
|
+
scope: string;
|
|
55
|
+
imageCount: number;
|
|
56
|
+
onImages: (images: ChatImage[]) => void;
|
|
57
|
+
}) {
|
|
58
|
+
const queryClient = useQueryClient();
|
|
59
|
+
const types = useClientConfig().data?.attachments.types;
|
|
60
|
+
const [dragging, setDragging] = useState(false);
|
|
61
|
+
const [uploads, setUploads] = useState<Upload[]>([]);
|
|
62
|
+
const held = useRef(0);
|
|
63
|
+
held.current = imageCount + uploads.length;
|
|
64
|
+
|
|
65
|
+
const addFiles = async (picked: Iterable<File>) => {
|
|
66
|
+
// Copied first: a file input's live list is emptied right after the pick.
|
|
67
|
+
const list = [...picked];
|
|
68
|
+
let limits;
|
|
69
|
+
try {
|
|
70
|
+
limits = (await queryClient.fetchQuery(clientConfigQuery)).attachments;
|
|
71
|
+
} catch (e) {
|
|
72
|
+
toast.error(e instanceof Error ? e.message : "Couldn't attach");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const images: File[] = [];
|
|
76
|
+
const others: File[] = [];
|
|
77
|
+
for (const f of list) {
|
|
78
|
+
let error: string | null = null;
|
|
79
|
+
if (held.current + images.length + others.length >= limits.max)
|
|
80
|
+
error = `You can attach up to ${limits.max} files to one message.`;
|
|
81
|
+
else if (isImage(f)) {
|
|
82
|
+
if (f.size > limits.maxImageBytes)
|
|
83
|
+
error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
|
|
84
|
+
else images.push(f);
|
|
85
|
+
} else if (!typeFor(f.name, limits.types))
|
|
86
|
+
error = unsupportedMessage(f.name);
|
|
87
|
+
else if (f.size > limits.maxBytes)
|
|
88
|
+
error = tooBigMessage(f.name, f.size, limits.maxBytes);
|
|
89
|
+
else others.push(f);
|
|
90
|
+
if (error) toast.error(error);
|
|
91
|
+
}
|
|
92
|
+
if (images.length) onImages(await Promise.all(images.map(readImage)));
|
|
93
|
+
for (const f of others) {
|
|
94
|
+
const key = `up-${++seq}`;
|
|
95
|
+
const file = { path: "", name: f.name, size: f.size, mediaType: f.type };
|
|
96
|
+
setUploads((prev) => [...prev, { key, file, uploading: true }]);
|
|
97
|
+
const done = await uploadFile(f, scope);
|
|
98
|
+
if (done.ok)
|
|
99
|
+
setUploads((prev) =>
|
|
100
|
+
prev.map((u) => (u.key === key ? { key, file: done.file } : u))
|
|
101
|
+
);
|
|
102
|
+
else {
|
|
103
|
+
setUploads((prev) => prev.filter((u) => u.key !== key));
|
|
104
|
+
toast.error(done.error);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const dropProps = {
|
|
110
|
+
onDragOver: (e: React.DragEvent) => {
|
|
111
|
+
if (!carriesFiles(e)) return;
|
|
112
|
+
e.preventDefault();
|
|
113
|
+
e.dataTransfer.dropEffect = "copy";
|
|
114
|
+
setDragging(true);
|
|
115
|
+
},
|
|
116
|
+
onDragLeave: (e: React.DragEvent) => {
|
|
117
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null))
|
|
118
|
+
setDragging(false);
|
|
119
|
+
},
|
|
120
|
+
onDrop: (e: React.DragEvent) => {
|
|
121
|
+
if (!carriesFiles(e)) return;
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
setDragging(false);
|
|
124
|
+
void addFiles(e.dataTransfer.files);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
dragging,
|
|
130
|
+
dropProps,
|
|
131
|
+
addFiles,
|
|
132
|
+
uploads,
|
|
133
|
+
// Until the limits are read, the picker offers images only.
|
|
134
|
+
accept: types ? `image/*,${acceptAttr(types)}` : "image/*",
|
|
135
|
+
uploading: uploads.some((u) => u.uploading),
|
|
136
|
+
remove: (key: string) =>
|
|
137
|
+
setUploads((prev) => prev.filter((u) => u.key !== key)),
|
|
138
|
+
clear: () => setUploads([]),
|
|
139
|
+
restore: (files: ChatFile[]) =>
|
|
140
|
+
setUploads(files.map((file) => ({ key: `up-${++seq}`, file }))),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -13,7 +13,7 @@ import { docToMarkdown } from "@/lib/chat/markdown/serialize";
|
|
|
13
13
|
import { plainExtensions, richExtensions } from "./extensions";
|
|
14
14
|
import { loadMarkdown } from "./load";
|
|
15
15
|
import { handleKeyDown, type KeyHandlers } from "./keys";
|
|
16
|
-
import { handlePaste,
|
|
16
|
+
import { handlePaste, type PasteHandlers } from "./paste";
|
|
17
17
|
import { usePlainMode } from "./usePlainMode";
|
|
18
18
|
|
|
19
19
|
export type ComposerHandlers = Omit<
|
|
@@ -120,9 +120,9 @@ export function useComposerEditor({
|
|
|
120
120
|
asText,
|
|
121
121
|
});
|
|
122
122
|
},
|
|
123
|
-
// Dropped
|
|
123
|
+
// Dropped files are the composer's to take, not the document's.
|
|
124
124
|
handleDrop: (view, event) =>
|
|
125
|
-
|
|
125
|
+
(event.dataTransfer?.files.length ?? 0) > 0,
|
|
126
126
|
},
|
|
127
127
|
// Only edits change the text: not loading a draft, not setEditable.
|
|
128
128
|
onUpdate: ({ editor: ed, transaction }) => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
|
-
import type { ChatImage } from "@/lib/chat/events";
|
|
4
|
+
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
5
5
|
import type { TextAttachment } from "@/lib/chat/paste";
|
|
6
6
|
|
|
7
7
|
// `text` is the composer's markdown, so a reload restores its formatting.
|
|
@@ -9,6 +9,8 @@ interface Draft {
|
|
|
9
9
|
text: string;
|
|
10
10
|
images: ChatImage[];
|
|
11
11
|
files?: TextAttachment[];
|
|
12
|
+
// Attached files, saved on the server: references only.
|
|
13
|
+
uploads?: ChatFile[];
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const keyFor = (id: string) => `agentos:draft:${id}`;
|
|
@@ -39,7 +41,12 @@ export function useSaveDraft(id: string | undefined, draft: Draft): void {
|
|
|
39
41
|
clearTimeout(timer.current);
|
|
40
42
|
timer.current = setTimeout(() => {
|
|
41
43
|
const key = keyFor(id);
|
|
42
|
-
if (
|
|
44
|
+
if (
|
|
45
|
+
!draft.text.trim() &&
|
|
46
|
+
!draft.images.length &&
|
|
47
|
+
!draft.files?.length &&
|
|
48
|
+
!draft.uploads?.length
|
|
49
|
+
) {
|
|
43
50
|
localStorage.removeItem(key);
|
|
44
51
|
return;
|
|
45
52
|
}
|
|
@@ -5,7 +5,7 @@ import { useSnapshot } from "valtio";
|
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import { Composer } from "@/components/Chat/Composer";
|
|
7
7
|
import { Button } from "@/components/ui/button";
|
|
8
|
-
import type { ChatImage } from "@/lib/chat/events";
|
|
8
|
+
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
9
9
|
import type { Project } from "@/lib/db";
|
|
10
10
|
import { draftComposerKey, type Draft } from "@/lib/drafts";
|
|
11
11
|
import { getModelOptions } from "@/lib/model-catalog";
|
|
@@ -57,11 +57,12 @@ export function DraftPanel({
|
|
|
57
57
|
</div>
|
|
58
58
|
);
|
|
59
59
|
|
|
60
|
-
const send = async (text: string, images: ChatImage[]) => {
|
|
60
|
+
const send = async (text: string, images: ChatImage[], files: ChatFile[]) => {
|
|
61
61
|
try {
|
|
62
62
|
if (draft.openPr) {
|
|
63
63
|
if (!project) throw new Error("Pick a project for a task");
|
|
64
|
-
if (images.length
|
|
64
|
+
if (images.length || files.length)
|
|
65
|
+
throw new Error("A task takes text only");
|
|
65
66
|
const { session, queued } = await createTask.mutateAsync({
|
|
66
67
|
projectId: project.id,
|
|
67
68
|
prompt: text,
|
|
@@ -96,6 +97,7 @@ export function DraftPanel({
|
|
|
96
97
|
baseBranch: draft.baseBranch,
|
|
97
98
|
prompt: text,
|
|
98
99
|
images: images.length ? images : undefined,
|
|
100
|
+
files: files.length ? files : undefined,
|
|
99
101
|
});
|
|
100
102
|
// A resend whose first answer was lost: the terminal still needs its
|
|
101
103
|
// first prompt, and this is the text that was sent.
|
|
@@ -134,6 +136,7 @@ export function DraftPanel({
|
|
|
134
136
|
/>
|
|
135
137
|
<Composer
|
|
136
138
|
draftKey={draftComposerKey(draft.id)}
|
|
139
|
+
attachScope="draft"
|
|
137
140
|
running={false}
|
|
138
141
|
disabled={pending}
|
|
139
142
|
placeholder={
|
|
@@ -143,7 +146,7 @@ export function DraftPanel({
|
|
|
143
146
|
? "Describe the task"
|
|
144
147
|
: `Message ${project?.name ?? "a new chat"}`
|
|
145
148
|
}
|
|
146
|
-
onSend={(text, images) => void send(text, images)}
|
|
149
|
+
onSend={(text, images, files) => void send(text, images, files)}
|
|
147
150
|
onStop={() => {}}
|
|
148
151
|
models={getModelOptions(draft.agentType)}
|
|
149
152
|
model={draft.model}
|
|
@@ -19,7 +19,8 @@ import { useTerminalConnection, useTerminalSearch } from "./hooks";
|
|
|
19
19
|
import type { TerminalScrollState } from "./hooks";
|
|
20
20
|
import { useViewport } from "@/hooks/useViewport";
|
|
21
21
|
import { useFileDrop } from "@/hooks/useFileDrop";
|
|
22
|
-
import {
|
|
22
|
+
import { toast } from "sonner";
|
|
23
|
+
import { uploadFile } from "@/lib/file-upload";
|
|
23
24
|
import { FilePicker } from "@/components/FilePicker";
|
|
24
25
|
import type { AttachSpec } from "@/lib/hosts/attach";
|
|
25
26
|
|
|
@@ -121,11 +122,11 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(
|
|
|
121
122
|
async (file: File) => {
|
|
122
123
|
setIsUploading(true);
|
|
123
124
|
try {
|
|
124
|
-
const
|
|
125
|
-
if (
|
|
126
|
-
sendInput(path);
|
|
125
|
+
const done = await uploadFile(file);
|
|
126
|
+
if (done.ok) {
|
|
127
|
+
sendInput(done.file.path);
|
|
127
128
|
focus();
|
|
128
|
-
}
|
|
129
|
+
} else toast.error(done.error);
|
|
129
130
|
} catch (err) {
|
|
130
131
|
console.error("Failed to upload file:", err);
|
|
131
132
|
} finally {
|
package/data/chat/useChat.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ChatAccess,
|
|
10
10
|
ChatCommand,
|
|
11
11
|
ChatContext,
|
|
12
|
+
ChatFile,
|
|
12
13
|
ChatImage,
|
|
13
14
|
ChatItem,
|
|
14
15
|
ChatModel,
|
|
@@ -34,12 +35,13 @@ import { usePending } from "./usePending";
|
|
|
34
35
|
|
|
35
36
|
// A pending send as the socket's `send`, under its own id.
|
|
36
37
|
const sendFrame = (
|
|
37
|
-
p: Pick<PendingSend, "id" | "text" | "images" | "compact">
|
|
38
|
+
p: Pick<PendingSend, "id" | "text" | "images" | "files" | "compact">
|
|
38
39
|
) =>
|
|
39
40
|
JSON.stringify({
|
|
40
41
|
type: "send",
|
|
41
42
|
text: p.text,
|
|
42
43
|
images: p.images,
|
|
44
|
+
files: p.files,
|
|
43
45
|
clientId: p.id,
|
|
44
46
|
...(p.compact && { compact: true }),
|
|
45
47
|
});
|
|
@@ -400,8 +402,13 @@ export function useChat(
|
|
|
400
402
|
const send = useCallback(
|
|
401
403
|
// `compact`: the reader let a stale chat compact first (lib/chat/stale).
|
|
402
404
|
// It shows as a pending bubble at once, until it's in the chat.
|
|
403
|
-
(
|
|
404
|
-
|
|
405
|
+
(
|
|
406
|
+
text: string,
|
|
407
|
+
images?: ChatImage[],
|
|
408
|
+
compact?: boolean,
|
|
409
|
+
files?: ChatFile[]
|
|
410
|
+
) => {
|
|
411
|
+
const p = { id: clientId(), text, images, files, compact };
|
|
405
412
|
pend({ type: "add", send: p, now: Date.now() });
|
|
406
413
|
const ws = wsRef.current;
|
|
407
414
|
if (ws?.readyState !== WebSocket.OPEN) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { queryOptions, useQuery } from "@tanstack/react-query";
|
|
2
|
+
import type { ClientConfig } from "@/lib/config/client-config-types";
|
|
3
|
+
|
|
4
|
+
export const clientConfigKeys = { all: ["client-config"] as const };
|
|
5
|
+
|
|
6
|
+
// What the server says clients render with and are limited by; it only
|
|
7
|
+
// changes with a server release, so one read per page load.
|
|
8
|
+
export const clientConfigQuery = queryOptions({
|
|
9
|
+
queryKey: clientConfigKeys.all,
|
|
10
|
+
queryFn: async (): Promise<ClientConfig> => {
|
|
11
|
+
const res = await fetch("/api/client-config");
|
|
12
|
+
if (!res.ok) throw new Error("Couldn't read the server's limits");
|
|
13
|
+
return res.json();
|
|
14
|
+
},
|
|
15
|
+
staleTime: Infinity,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const useClientConfig = () => useQuery(clientConfigQuery);
|
package/data/sessions/queries.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import type { Session, Group } from "@/lib/db";
|
|
3
3
|
import type { AgentType } from "@/lib/providers";
|
|
4
|
-
import type { ChatAccess, ChatImage } from "@/lib/chat/events";
|
|
4
|
+
import type { ChatAccess, ChatFile, ChatImage } from "@/lib/chat/events";
|
|
5
5
|
import { sessionKeys } from "./keys";
|
|
6
6
|
import { usePollWhenOffline, usePushConnected } from "../push/connection";
|
|
7
7
|
|
|
@@ -192,6 +192,7 @@ export interface LaunchSessionInput {
|
|
|
192
192
|
baseBranch: string | null;
|
|
193
193
|
prompt: string;
|
|
194
194
|
images?: ChatImage[];
|
|
195
|
+
files?: ChatFile[];
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
interface LaunchSessionResponse {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { isText, sniff } from "../intake/sniff";
|
|
2
|
+
import {
|
|
3
|
+
ATTACHMENT_LIMITS,
|
|
4
|
+
tooBigMessage,
|
|
5
|
+
typeFor,
|
|
6
|
+
unsupportedMessage,
|
|
7
|
+
type AttachmentType,
|
|
8
|
+
} from "./policy";
|
|
9
|
+
|
|
10
|
+
export type UploadCheck =
|
|
11
|
+
| { ok: true; type: AttachmentType }
|
|
12
|
+
| { ok: false; status: 400 | 413 | 415; error: string };
|
|
13
|
+
|
|
14
|
+
const ZIP = [0x50, 0x4b, 0x03, 0x04];
|
|
15
|
+
const OLE = [0xd0, 0xcf, 0x11, 0xe0];
|
|
16
|
+
const OOXML_ZIP = new Set(["xlsx", "docx", "pptx", "ods", "odt"]);
|
|
17
|
+
const OLE_EXTS = new Set(["xls", "doc"]);
|
|
18
|
+
|
|
19
|
+
const starts = (b: Buffer, sig: number[]) => sig.every((v, i) => b[i] === v);
|
|
20
|
+
|
|
21
|
+
const bad = (name: string): UploadCheck => ({
|
|
22
|
+
ok: false,
|
|
23
|
+
status: 400,
|
|
24
|
+
error: `${name} doesn't look like a real .${name.split(".").pop()?.toLowerCase()} file.`,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Whether a file's name, size and bytes are something a chat accepts. */
|
|
28
|
+
export function checkUpload(name: string, data: Buffer): UploadCheck {
|
|
29
|
+
const type = typeFor(name);
|
|
30
|
+
if (!type) return { ok: false, status: 415, error: unsupportedMessage(name) };
|
|
31
|
+
const limit =
|
|
32
|
+
type.kind === "image"
|
|
33
|
+
? ATTACHMENT_LIMITS.maxImageBytes
|
|
34
|
+
: ATTACHMENT_LIMITS.maxBytes;
|
|
35
|
+
if (data.length > limit)
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
status: 413,
|
|
39
|
+
error: tooBigMessage(name, data.length, limit),
|
|
40
|
+
};
|
|
41
|
+
if (!data.length)
|
|
42
|
+
return { ok: false, status: 400, error: `${name} is empty.` };
|
|
43
|
+
let real: boolean;
|
|
44
|
+
if (type.kind === "image" || type.kind === "pdf")
|
|
45
|
+
real = sniff(data, name)?.mediaType === type.mediaType;
|
|
46
|
+
else if (OOXML_ZIP.has(type.ext)) real = starts(data, ZIP);
|
|
47
|
+
else if (OLE_EXTS.has(type.ext)) real = starts(data, OLE);
|
|
48
|
+
else real = isText(data);
|
|
49
|
+
return real ? { ok: true, type } : bad(name);
|
|
50
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import { ATTACHMENT_LIMITS, formatBytes } from "./policy";
|
|
3
|
+
import type { ChatFile } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the agent reads: the message plus where each file is. Every driver
|
|
7
|
+
* gets this, so any agent can open the files with its own tools (a
|
|
8
|
+
* spreadsheet through a script, a docx through pandoc). The chat keeps the
|
|
9
|
+
* plain text and shows the files as chips.
|
|
10
|
+
*/
|
|
11
|
+
export function agentText(text: string, files?: ChatFile[]): string {
|
|
12
|
+
if (!files?.length) return text;
|
|
13
|
+
const list = files
|
|
14
|
+
.map((f) => `- ${f.path} (${f.mediaType}, ${formatBytes(f.size)})`)
|
|
15
|
+
.join("\n");
|
|
16
|
+
return [
|
|
17
|
+
text,
|
|
18
|
+
`Attached files, saved on this machine. Open them with your own tools:\n${list}`,
|
|
19
|
+
]
|
|
20
|
+
.filter(Boolean)
|
|
21
|
+
.join("\n\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Claude reads a PDF itself, as a document block, when it's small enough. */
|
|
25
|
+
export function documentBlocks(files?: ChatFile[]) {
|
|
26
|
+
const out: {
|
|
27
|
+
type: "document";
|
|
28
|
+
title: string;
|
|
29
|
+
source: { type: "base64"; media_type: "application/pdf"; data: string };
|
|
30
|
+
}[] = [];
|
|
31
|
+
for (const f of files ?? []) {
|
|
32
|
+
if (f.mediaType !== "application/pdf") continue;
|
|
33
|
+
if (f.size > ATTACHMENT_LIMITS.pdfInlineBytes) continue;
|
|
34
|
+
try {
|
|
35
|
+
out.push({
|
|
36
|
+
type: "document",
|
|
37
|
+
title: f.name,
|
|
38
|
+
source: {
|
|
39
|
+
type: "base64",
|
|
40
|
+
media_type: "application/pdf",
|
|
41
|
+
data: fs.readFileSync(f.path).toString("base64"),
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
// Unreadable: the agent still has the path.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// What a chat may carry besides typed text, and how big. The server owns it:
|
|
2
|
+
// the upload route enforces it and /api/client-config hands it to clients, so
|
|
3
|
+
// no client keeps its own copy. No Node imports: the browser reads this too.
|
|
4
|
+
|
|
5
|
+
export type AttachmentKind = "image" | "pdf" | "sheet" | "doc" | "text";
|
|
6
|
+
|
|
7
|
+
export interface AttachmentType {
|
|
8
|
+
ext: string;
|
|
9
|
+
mediaType: string;
|
|
10
|
+
kind: AttachmentKind;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const t = (
|
|
14
|
+
kind: AttachmentKind,
|
|
15
|
+
mediaType: string,
|
|
16
|
+
...exts: string[]
|
|
17
|
+
): AttachmentType[] => exts.map((ext) => ({ ext, mediaType, kind }));
|
|
18
|
+
|
|
19
|
+
const OOXML = "application/vnd.openxmlformats-officedocument";
|
|
20
|
+
|
|
21
|
+
export const ATTACHMENT_TYPES: AttachmentType[] = [
|
|
22
|
+
...t("image", "image/png", "png"),
|
|
23
|
+
...t("image", "image/jpeg", "jpg", "jpeg"),
|
|
24
|
+
...t("image", "image/gif", "gif"),
|
|
25
|
+
...t("image", "image/webp", "webp"),
|
|
26
|
+
...t("pdf", "application/pdf", "pdf"),
|
|
27
|
+
...t("sheet", `${OOXML}.spreadsheetml.sheet`, "xlsx"),
|
|
28
|
+
...t("sheet", "application/vnd.ms-excel", "xls"),
|
|
29
|
+
...t("sheet", "text/csv", "csv"),
|
|
30
|
+
...t("sheet", "text/tab-separated-values", "tsv"),
|
|
31
|
+
...t("sheet", "application/vnd.oasis.opendocument.spreadsheet", "ods"),
|
|
32
|
+
...t("doc", `${OOXML}.wordprocessingml.document`, "docx"),
|
|
33
|
+
...t("doc", "application/msword", "doc"),
|
|
34
|
+
...t("doc", `${OOXML}.presentationml.presentation`, "pptx"),
|
|
35
|
+
...t("doc", "application/vnd.oasis.opendocument.text", "odt"),
|
|
36
|
+
...t("doc", "application/rtf", "rtf"),
|
|
37
|
+
...t("text", "text/plain", "txt", "log", "env"),
|
|
38
|
+
...t("text", "text/markdown", "md", "markdown"),
|
|
39
|
+
...t("text", "application/json", "json", "jsonl", "ipynb"),
|
|
40
|
+
...t(
|
|
41
|
+
"text",
|
|
42
|
+
"text/plain",
|
|
43
|
+
"yaml",
|
|
44
|
+
"yml",
|
|
45
|
+
"toml",
|
|
46
|
+
"xml",
|
|
47
|
+
"html",
|
|
48
|
+
"css",
|
|
49
|
+
"sql",
|
|
50
|
+
"ts",
|
|
51
|
+
"tsx",
|
|
52
|
+
"js",
|
|
53
|
+
"jsx",
|
|
54
|
+
"mjs",
|
|
55
|
+
"cjs",
|
|
56
|
+
"py",
|
|
57
|
+
"rb",
|
|
58
|
+
"go",
|
|
59
|
+
"rs",
|
|
60
|
+
"java",
|
|
61
|
+
"kt",
|
|
62
|
+
"swift",
|
|
63
|
+
"c",
|
|
64
|
+
"h",
|
|
65
|
+
"cpp",
|
|
66
|
+
"hpp",
|
|
67
|
+
"cs",
|
|
68
|
+
"php",
|
|
69
|
+
"diff",
|
|
70
|
+
"patch"
|
|
71
|
+
),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
export const ATTACHMENT_LIMITS = {
|
|
75
|
+
// Files and images on one message.
|
|
76
|
+
max: 8,
|
|
77
|
+
// One non-image file.
|
|
78
|
+
maxBytes: 25_000_000,
|
|
79
|
+
// One image, under the 5 MB a model accepts once base64'd.
|
|
80
|
+
maxImageBytes: 3_750_000,
|
|
81
|
+
// A PDF up to this goes to Claude as a document block; bigger ones, and
|
|
82
|
+
// every other file, are opened by the agent from their path.
|
|
83
|
+
pdfInlineBytes: 10_000_000,
|
|
84
|
+
types: ATTACHMENT_TYPES,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export type AttachmentLimits = typeof ATTACHMENT_LIMITS;
|
|
88
|
+
|
|
89
|
+
const BY_EXT = new Map(ATTACHMENT_TYPES.map((x) => [x.ext, x]));
|
|
90
|
+
|
|
91
|
+
export const extOf = (name: string): string =>
|
|
92
|
+
/\.([A-Za-z0-9]+)$/.exec(name)?.[1].toLowerCase() ?? "";
|
|
93
|
+
|
|
94
|
+
export const typeFor = (
|
|
95
|
+
name: string,
|
|
96
|
+
types: AttachmentType[] = ATTACHMENT_TYPES
|
|
97
|
+
): AttachmentType | null =>
|
|
98
|
+
types === ATTACHMENT_TYPES
|
|
99
|
+
? (BY_EXT.get(extOf(name)) ?? null)
|
|
100
|
+
: (types.find((x) => x.ext === extOf(name)) ?? null);
|
|
101
|
+
|
|
102
|
+
// For <input accept>: every extension, so the picker greys out the rest.
|
|
103
|
+
export const acceptAttr = (types: AttachmentType[] = ATTACHMENT_TYPES) =>
|
|
104
|
+
types.map((x) => `.${x.ext}`).join(",");
|
|
105
|
+
|
|
106
|
+
export const formatBytes = (n: number): string =>
|
|
107
|
+
n >= 1_000_000
|
|
108
|
+
? `${(n / 1_000_000).toFixed(1)} MB`
|
|
109
|
+
: `${Math.max(1, Math.round(n / 1000))} KB`;
|
|
110
|
+
|
|
111
|
+
// What a person is told when a file can't be attached.
|
|
112
|
+
export const unsupportedMessage = (name: string): string =>
|
|
113
|
+
`${name}: this file type can't be attached. Supported: PDF, spreadsheets (xlsx, xls, csv), Word (docx), text, markdown, JSON, code and images.`;
|
|
114
|
+
|
|
115
|
+
export const tooBigMessage = (name: string, size: number, limit: number) =>
|
|
116
|
+
`${name} is ${formatBytes(size)}; the limit is ${formatBytes(limit)}.`;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { dataRoot } from "../db/path";
|
|
4
|
+
import { ATTACHMENT_LIMITS, typeFor } from "./policy";
|
|
5
|
+
import type { ChatFile } from "./types";
|
|
6
|
+
|
|
7
|
+
// Outside every repo and worktree, so an agent's `git add -A` never picks a
|
|
8
|
+
// file up; a folder per session, `new` for a draft that has no session yet.
|
|
9
|
+
export const attachmentsRoot = () => path.join(dataRoot(), "attachments");
|
|
10
|
+
|
|
11
|
+
const segment = (s: string) => s.replace(/[^\w-]/g, "_");
|
|
12
|
+
|
|
13
|
+
/** A client's filename as one safe path segment, unique among `taken`. */
|
|
14
|
+
export function safeName(name: string, taken: Set<string>, n: number): string {
|
|
15
|
+
let base = path.basename(name.replace(/\\/g, "/"));
|
|
16
|
+
base = base
|
|
17
|
+
.replace(/[^a-zA-Z0-9.-]/g, "_")
|
|
18
|
+
.replace(/^\.+/, "")
|
|
19
|
+
.slice(0, 80);
|
|
20
|
+
if (!base) base = `file-${n}`;
|
|
21
|
+
let out = base;
|
|
22
|
+
for (let i = 2; taken.has(out); i++) {
|
|
23
|
+
const dot = base.lastIndexOf(".");
|
|
24
|
+
out =
|
|
25
|
+
dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
|
|
26
|
+
}
|
|
27
|
+
taken.add(out);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function scopeDir(scope: string): string {
|
|
32
|
+
return path.join(attachmentsRoot(), segment(scope) || "new");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveAttachment(
|
|
36
|
+
scope: string,
|
|
37
|
+
name: string,
|
|
38
|
+
data: Buffer,
|
|
39
|
+
mediaType: string
|
|
40
|
+
): ChatFile {
|
|
41
|
+
const dir = scopeDir(scope);
|
|
42
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
43
|
+
const taken = new Set(fs.readdirSync(dir));
|
|
44
|
+
const file = safeName(name, taken, taken.size + 1);
|
|
45
|
+
const full = path.join(dir, file);
|
|
46
|
+
fs.writeFileSync(full, data, { mode: 0o600 });
|
|
47
|
+
return { path: full, name: file, size: data.length, mediaType };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function inside(root: string, p: string): boolean {
|
|
51
|
+
const rel = path.relative(root, p);
|
|
52
|
+
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The files a client says it attached, kept only where they really are a
|
|
57
|
+
* file inside the attachments folder (symlinks followed): a client can name
|
|
58
|
+
* any path, and the agent is told to open what's named here. Size and type
|
|
59
|
+
* come from the disk, not the client.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveFiles(raw: unknown): ChatFile[] | undefined {
|
|
62
|
+
if (!Array.isArray(raw)) return undefined;
|
|
63
|
+
let root: string;
|
|
64
|
+
try {
|
|
65
|
+
root = fs.realpathSync(attachmentsRoot());
|
|
66
|
+
} catch {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const out: ChatFile[] = [];
|
|
70
|
+
for (const f of raw.slice(0, ATTACHMENT_LIMITS.max)) {
|
|
71
|
+
const p = (f as { path?: unknown } | null)?.path;
|
|
72
|
+
if (typeof p !== "string") continue;
|
|
73
|
+
try {
|
|
74
|
+
const real = fs.realpathSync(p);
|
|
75
|
+
const stat = fs.statSync(real);
|
|
76
|
+
if (!inside(root, real) || !stat.isFile()) continue;
|
|
77
|
+
out.push({
|
|
78
|
+
path: real,
|
|
79
|
+
name: path.basename(real),
|
|
80
|
+
size: stat.size,
|
|
81
|
+
mediaType: typeFor(real)?.mediaType ?? "application/octet-stream",
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// Gone or unreadable: dropped.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out.length ? out : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function removeSessionAttachments(sessionId: string): Promise<void> {
|
|
91
|
+
return fs.promises.rm(scopeDir(sessionId), { recursive: true, force: true });
|
|
92
|
+
}
|