diff --git a/chat-ui/src/components/chat-input.tsx b/chat-ui/src/components/chat-input.tsx index b4106f8..19347a3 100644 --- a/chat-ui/src/components/chat-input.tsx +++ b/chat-ui/src/components/chat-input.tsx @@ -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" /> diff --git a/chat-ui/src/components/drop-overlay.tsx b/chat-ui/src/components/drop-overlay.tsx index 4de76db..66f614c 100644 --- a/chat-ui/src/components/drop-overlay.tsx +++ b/chat-ui/src/components/drop-overlay.tsx @@ -15,7 +15,7 @@ export function DropOverlay({ visible }: { visible: boolean }) {

Drop files here

- Images, PDFs, and text files are supported + Images, videos, PDFs, and text files are supported

diff --git a/chat-ui/src/hooks/use-attachments.ts b/chat-ui/src/hooks/use-attachments.ts index 5bdd5fe..1500d12 100644 --- a/chat-ui/src/hooks/use-attachments.ts +++ b/chat-ui/src/hooks/use-attachments.ts @@ -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" }; @@ -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; diff --git a/src/chat/__tests__/message-builder.test.ts b/src/chat/__tests__/message-builder.test.ts index 6c38f39..22044bf 100644 --- a/src/chat/__tests__/message-builder.test.ts +++ b/src/chat/__tests__/message-builder.test.ts @@ -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>; + expect(content.length).toBe(2); + + const videoBlock = content[0]; + expect(videoBlock?.type).toBe("video"); + const source = videoBlock?.source as Record; + 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")); diff --git a/src/chat/__tests__/upload.test.ts b/src/chat/__tests__/upload.test.ts index e5f7cb5..ba77d1f 100644 --- a/src/chat/__tests__/upload.test.ts +++ b/src/chat/__tests__/upload.test.ts @@ -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); @@ -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); @@ -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); }); @@ -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); }); @@ -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"); }); @@ -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"); }); diff --git a/src/chat/message-builder.ts b/src/chat/message-builder.ts index 50f40be..e3673f1 100644 --- a/src/chat/message-builder.ts +++ b/src/chat/message-builder.ts @@ -1,6 +1,6 @@ // 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"; @@ -8,7 +8,7 @@ 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; @@ -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); @@ -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({ diff --git a/src/chat/upload.ts b/src/chat/upload.ts index ff63ea5..0057a29 100644 --- a/src/chat/upload.ts +++ b/src/chat/upload.ts @@ -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, @@ -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) { @@ -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, diff --git a/src/chat/validators.ts b/src/chat/validators.ts index b5ae7e7..73310d9 100644 --- a/src/chat/validators.ts +++ b/src/chat/validators.ts @@ -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([ @@ -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 }; @@ -77,15 +80,27 @@ 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; } @@ -93,7 +108,7 @@ export function isAllowedMimeType(mimeType: string, filename: string): boolean { 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") { @@ -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." }; @@ -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 }; } @@ -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"; }