Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion chat-ui/src/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export function ChatInput({
multiple
className="hidden"
onChange={handleFileSelect}
accept="image/jpeg,image/png,image/gif,image/webp,application/pdf,text/*,.js,.ts,.tsx,.jsx,.py,.go,.rs,.rb,.java,.kt,.swift,.c,.cpp,.h,.hpp,.sh,.bash,.zsh,.toml,.ini,.sql,.json,.md,.csv,.html,.xml,.yaml,.yml"
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/avi,video/x-msvideo,video/mov,video/quicktime,video/x-matroska,.mp4,.avi,.mov,.mkv,application/pdf,text/*,.js,.ts,.tsx,.jsx,.py,.go,.rs,.rb,.java,.kt,.swift,.c,.cpp,.h,.hpp,.sh,.bash,.zsh,.toml,.ini,.sql,.json,.md,.csv,.html,.xml,.yaml,.yml"
/>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion chat-ui/src/components/drop-overlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function DropOverlay({ visible }: { visible: boolean }) {
<Upload className="h-10 w-10 text-primary" />
<p className="text-lg font-medium text-foreground">Drop files here</p>
<p className="text-sm text-muted-foreground">
Images, PDFs, and text files are supported
Images, videos, PDFs, and text files are supported
</p>
</div>
</div>
Expand Down
7 changes: 5 additions & 2 deletions chat-ui/src/hooks/use-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { toast } from "sonner";
const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
const MAX_FILES = 10;

function getMaxSizeForType(mimeType: string): { limit: number; label: string } {
function getMaxSizeForType(mimeType: string, filename: string): { limit: number; label: string } {
if (mimeType.startsWith("video/") || /\.(mp4|avi|mov|mkv)$/i.test(filename)) {
return { limit: 50 * 1024 * 1024, label: "Videos can be up to 50 MB" };
}
if (mimeType === "application/pdf") return { limit: 32 * 1024 * 1024, label: "PDFs can be up to 32 MB" };
if (mimeType.startsWith("image/")) return { limit: 10 * 1024 * 1024, label: "Images can be up to 10 MB" };
return { limit: 1 * 1024 * 1024, label: "Text files can be up to 1 MB" };
Expand Down Expand Up @@ -73,7 +76,7 @@ export function useAttachments(): {
toast.error("iOS HEIC photos are not supported. Please choose JPEG export from the Photos app.");
continue;
}
const sizeInfo = getMaxSizeForType(file.type);
const sizeInfo = getMaxSizeForType(file.type, file.name);
if (file.size > sizeInfo.limit) {
toast.error(`"${file.name}" is too large. ${sizeInfo.label}.`);
continue;
Expand Down
27 changes: 27 additions & 0 deletions src/chat/__tests__/message-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,33 @@ describe("buildUserMessageParam", () => {
expect(textBlock?.text).toBe("describe this");
});

test("text + video attachment produces a base64 video block", async () => {
const videoPath = join(tmpDir, "clip.mp4");
writeFileSync(videoPath, Buffer.from("fake-video-data"));

attachmentStore.create({
sessionId: "test-session-mb",
kind: "video",
filename: "clip.mp4",
mimeType: "video/mp4",
sizeBytes: 15,
storagePath: videoPath,
});
const rows = attachmentStore.getBySession("test-session-mb");
const attId = rows[0]?.id ?? "";

const msg = await buildUserMessageParam("describe this clip", [attId], attachmentStore);
const content = msg.content as unknown as Array<Record<string, unknown>>;
expect(content.length).toBe(2);

const videoBlock = content[0];
expect(videoBlock?.type).toBe("video");
const source = videoBlock?.source as Record<string, unknown>;
expect(source?.type).toBe("base64");
expect(source?.media_type).toBe("video/mp4");
expect(source?.data).toBe(Buffer.from("fake-video-data").toString("base64"));
});

test("text + PDF attachment produces DocumentBlockParam with base64", async () => {
const pdfPath = join(tmpDir, "doc.pdf");
writeFileSync(pdfPath, Buffer.from("fake-pdf-data"));
Expand Down
37 changes: 35 additions & 2 deletions src/chat/__tests__/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ describe("validators", () => {
expect(result.ok).toBe(true);
});

test("accepts MP4 video", () => {
const result = validateFile("video/mp4", 5 * 1024 * 1024, "clip.mp4");
expect(result.ok).toBe(true);
});

test("accepts MOV video with browser MIME type", () => {
const result = validateFile("video/quicktime", 5 * 1024 * 1024, "clip.mov");
expect(result.ok).toBe(true);
});

test("accepts PDF", () => {
const result = validateFile("application/pdf", 5 * 1024 * 1024, "document.pdf");
expect(result.ok).toBe(true);
Expand Down Expand Up @@ -107,6 +117,14 @@ describe("validators", () => {
}
});

test("rejects oversized video (> 50MB)", () => {
const result = validateFile("video/mp4", 51 * 1024 * 1024, "huge.mp4");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("video_too_large");
}
});

test("rejects oversized PDF (> 32MB)", () => {
const result = validateFile("application/pdf", 33 * 1024 * 1024, "huge.pdf");
expect(result.ok).toBe(false);
Expand Down Expand Up @@ -167,8 +185,8 @@ describe("validators", () => {
expect(result.ok).toBe(true);
});

test("rejects oversized request (> 40MB)", () => {
const result = validateRequestSize(41 * 1024 * 1024);
test("rejects oversized request (> 64MB)", () => {
const result = validateRequestSize(65 * 1024 * 1024);
expect(result.ok).toBe(false);
});

Expand Down Expand Up @@ -212,6 +230,10 @@ describe("validators", () => {
expect(isAllowedMimeType("application/pdf", "doc.pdf")).toBe(true);
});

test("allows video/quicktime for MOV files", () => {
expect(isAllowedMimeType("video/quicktime", "clip.mov")).toBe(true);
});

test("allows text/plain", () => {
expect(isAllowedMimeType("text/plain", "file.txt")).toBe(true);
});
Expand All @@ -238,6 +260,13 @@ describe("validators", () => {
expect(guessMimeFromName("doc.pdf")).toBe("application/pdf");
});

test("guesses video MIME types", () => {
expect(guessMimeFromName("clip.mp4")).toBe("video/mp4");
expect(guessMimeFromName("clip.avi")).toBe("video/avi");
expect(guessMimeFromName("clip.mov")).toBe("video/mov");
expect(guessMimeFromName("clip.mkv")).toBe("video/x-matroska");
});

test("guesses text/plain for .py", () => {
expect(guessMimeFromName("script.py")).toBe("text/plain");
});
Expand All @@ -260,6 +289,10 @@ describe("validators", () => {
expect(pickExtension("image/jpeg", "image")).toBe("jpg");
});

test("falls back to mime for mp4", () => {
expect(pickExtension("video/mp4", "video")).toBe("mp4");
});

test("falls back to bin for unknown", () => {
expect(pickExtension("application/octet-stream", "blob")).toBe("bin");
});
Expand Down
28 changes: 23 additions & 5 deletions src/chat/message-builder.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// Builds SDK-native MessageParam from user text + attachments.
// Attachments are converted to ImageBlockParam, DocumentBlockParam, or
// TextBlockParam depending on type. Text block goes last.
// Attachments are converted to image, video, or document content blocks.
// The user's text block goes last.

import type { SDKUserMessage } from "../agent/agent-sdk.ts";

type MessageParam = SDKUserMessage["message"];

import type { ChatAttachment, ChatAttachmentStore } from "./attachment-store.ts";
import { readAttachmentFileBase64, readAttachmentFileText } from "./storage.ts";
import { IMAGE_MIMES, PDF_MIME } from "./validators.ts";
import { IMAGE_MIMES, PDF_MIME, VIDEO_MIMES, normalizeMimeType } from "./validators.ts";

type ContentBlock = {
type: string;
Expand Down Expand Up @@ -125,10 +125,16 @@ async function buildMessageParamFromAttachments(text: string, attachments: ChatA

const content: ContentBlock[] = [];

// Images first, then documents, then text - matches Anthropic's recommended ordering
// Media first, then documents, then text.
const images = attachments.filter((a) => IMAGE_MIMES.has(a.mime_type ?? ""));
const videos = attachments.filter((a) => VIDEO_MIMES.has(normalizeMimeType(a.mime_type ?? "", a.filename ?? "")));
const pdfs = attachments.filter((a) => a.mime_type === PDF_MIME);
const textFiles = attachments.filter((a) => !IMAGE_MIMES.has(a.mime_type ?? "") && a.mime_type !== PDF_MIME);
const textFiles = attachments.filter(
(a) =>
!IMAGE_MIMES.has(a.mime_type ?? "") &&
!VIDEO_MIMES.has(normalizeMimeType(a.mime_type ?? "", a.filename ?? "")) &&
a.mime_type !== PDF_MIME,
);

for (const att of images) {
const data = await readAttachmentFileBase64(att.storage_path);
Expand All @@ -142,6 +148,18 @@ async function buildMessageParamFromAttachments(text: string, attachments: ChatA
});
}

for (const att of videos) {
const data = await readAttachmentFileBase64(att.storage_path);
content.push({
type: "video",
source: {
type: "base64",
media_type: normalizeMimeType(att.mime_type ?? "", att.filename ?? ""),
data,
},
});
}

for (const att of pdfs) {
const data = await readAttachmentFileBase64(att.storage_path);
content.push({
Expand Down
12 changes: 9 additions & 3 deletions src/chat/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { ChatSessionStore } from "./session-store.ts";
import { writeAttachmentFile } from "./storage.ts";
import {
MAX_FILES_PER_REQUEST,
guessMimeFromName,
normalizeMimeType,
pickExtension,
sanitizeFilename,
validateFile,
Expand Down Expand Up @@ -109,7 +109,7 @@ async function processFiles(

for (const item of files) {
const { file } = item;
const mime = file.type || guessMimeFromName(file.name) || "";
const mime = normalizeMimeType(file.type, file.name);
const validation = validateFile(mime, file.size, file.name);

if (!validation.ok) {
Expand All @@ -130,7 +130,13 @@ async function processFiles(
const buffer = Buffer.from(await file.arrayBuffer());
const storagePath = await writeAttachmentFile(sessionId, id, ext, buffer);

const kind = mime.startsWith("image/") ? "image" : mime === "application/pdf" ? "pdf" : "text";
const kind = mime.startsWith("image/")
? "image"
: mime.startsWith("video/")
? "video"
: mime === "application/pdf"
? "pdf"
: "text";

deps.attachmentStore.create({
id,
Expand Down
45 changes: 39 additions & 6 deletions src/chat/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

export const IMAGE_MIMES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);

export const VIDEO_MIMES = new Set(["video/mp4", "video/avi", "video/x-msvideo", "video/mov", "video/x-matroska"]);

export const PDF_MIME = "application/pdf";

export const TEXT_MIMES = new Set([
Expand Down Expand Up @@ -53,10 +55,11 @@ export const TEXT_EXTENSIONS = new Set([
]);

const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const MAX_VIDEO_BYTES = 50 * 1024 * 1024;
const MAX_PDF_BYTES = 32 * 1024 * 1024;
const MAX_TEXT_BYTES = 1 * 1024 * 1024;
export const MAX_FILES_PER_REQUEST = 10;
const MAX_REQUEST_BYTES = 40 * 1024 * 1024;
const MAX_REQUEST_BYTES = 64 * 1024 * 1024;

export type ValidationResult = { ok: true } | { ok: false; reason: string; message: string };

Expand All @@ -77,23 +80,35 @@ export function guessMimeFromName(filename: string): string | null {
if (ext === ".png") return "image/png";
if (ext === ".gif") return "image/gif";
if (ext === ".webp") return "image/webp";
if (ext === ".mp4") return "video/mp4";
if (ext === ".avi") return "video/avi";
if (ext === ".mov") return "video/mov";
if (ext === ".mkv") return "video/x-matroska";
if (ext === ".pdf") return "application/pdf";
if (TEXT_EXTENSIONS.has(ext)) return "text/plain";
return null;
}

export function normalizeMimeType(mimeType: string, filename: string): string {
const mime = mimeType || guessMimeFromName(filename) || "";
if (mime === "video/quicktime" && getExtension(filename) === ".mov") return "video/mov";
return mime;
}

export function isAllowedMimeType(mimeType: string, filename: string): boolean {
if (IMAGE_MIMES.has(mimeType)) return true;
if (mimeType === PDF_MIME) return true;
if (TEXT_MIMES.has(mimeType)) return true;
const mime = normalizeMimeType(mimeType, filename);
if (IMAGE_MIMES.has(mime)) return true;
if (VIDEO_MIMES.has(mime)) return true;
if (mime === PDF_MIME) return true;
if (TEXT_MIMES.has(mime)) return true;
if (hasTextExtension(filename)) return true;
return false;
}

export function validateFile(mimeType: string, sizeBytes: number, filename: string): ValidationResult {
if (sizeBytes === 0) return { ok: false, reason: "empty", message: "File is empty." };

const mime = mimeType || guessMimeFromName(filename);
const mime = normalizeMimeType(mimeType, filename);
if (!mime) return { ok: false, reason: "unknown_type", message: "Unknown file type." };

if (mime === "image/heic" || mime === "image/heif") {
Expand All @@ -118,6 +133,20 @@ export function validateFile(mimeType: string, sizeBytes: number, filename: stri
return { ok: true };
}

if (mime.startsWith("video/")) {
if (!VIDEO_MIMES.has(mime)) {
return {
ok: false,
reason: "unsupported_video_format",
message: "This video format is not supported. Convert to MP4, AVI, MOV, or MKV.",
};
}
if (sizeBytes > MAX_VIDEO_BYTES) {
return { ok: false, reason: "video_too_large", message: "Video is too large. Max 50 MB." };
}
return { ok: true };
}

if (mime === PDF_MIME) {
if (sizeBytes > MAX_PDF_BYTES) {
return { ok: false, reason: "pdf_too_large", message: "PDF is too large. Max 32 MB." };
Expand All @@ -137,7 +166,7 @@ export function validateFile(mimeType: string, sizeBytes: number, filename: stri

export function validateRequestSize(contentLength: number | null): ValidationResult {
if (contentLength !== null && contentLength > MAX_REQUEST_BYTES) {
return { ok: false, reason: "request_too_large", message: "Total upload too large. Max 40 MB." };
return { ok: false, reason: "request_too_large", message: "Total upload too large. Max 64 MB." };
}
return { ok: true };
}
Expand All @@ -156,6 +185,10 @@ export function pickExtension(mimeType: string, filename: string): string {
if (mimeType === "image/png") return "png";
if (mimeType === "image/gif") return "gif";
if (mimeType === "image/webp") return "webp";
if (mimeType === "video/mp4") return "mp4";
if (mimeType === "video/avi" || mimeType === "video/x-msvideo") return "avi";
if (mimeType === "video/mov" || mimeType === "video/quicktime") return "mov";
if (mimeType === "video/x-matroska") return "mkv";
if (mimeType === "application/pdf") return "pdf";
return "bin";
}