Files
agent-chat-ui/src/hooks/use-file-upload.tsx
2025-05-20 16:06:30 -07:00

271 lines
8.4 KiB
TypeScript

import { useState, useRef, useEffect, ChangeEvent } from "react";
import { toast } from "sonner";
import type { Base64ContentBlock } from "@langchain/core/messages";
import { fileToContentBlock } from "@/lib/multimodal-utils";
export const SUPPORTED_FILE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"application/pdf",
];
interface UseFileUploadOptions {
initialBlocks?: Base64ContentBlock[];
}
export function useFileUpload({
initialBlocks = [],
}: UseFileUploadOptions = {}) {
const [contentBlocks, setContentBlocks] =
useState<Base64ContentBlock[]>(initialBlocks);
const dropRef = useRef<HTMLDivElement>(null);
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => {
if (file.type === "application/pdf") {
return blocks.some(
(b) =>
b.type === "file" &&
b.mime_type === "application/pdf" &&
b.metadata?.filename === file.name,
);
}
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
return blocks.some(
(b) =>
b.type === "image" &&
b.metadata?.name === file.name &&
b.mime_type === file.type,
);
}
return false;
};
const handleFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const fileArray = Array.from(files);
const validFiles = fileArray.filter((file) =>
SUPPORTED_FILE_TYPES.includes(file.type),
);
const invalidFiles = fileArray.filter(
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
);
const duplicateFiles = validFiles.filter((file) =>
isDuplicate(file, contentBlocks),
);
const uniqueFiles = validFiles.filter(
(file) => !isDuplicate(file, contentBlocks),
);
if (invalidFiles.length > 0) {
toast.error(
"You have uploaded invalid file type. Please upload a JPEG, PNG, GIF, WEBP image or a PDF.",
);
}
if (duplicateFiles.length > 0) {
toast.error(
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
);
}
const newBlocks = uniqueFiles.length
? await Promise.all(uniqueFiles.map(fileToContentBlock))
: [];
setContentBlocks((prev) => [...prev, ...newBlocks]);
e.target.value = "";
};
// Drag and drop handlers
useEffect(() => {
if (!dropRef.current) return;
// Global drag events with counter for robust dragOver state
const handleWindowDragEnter = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
dragCounter.current += 1;
setDragOver(true);
}
};
const handleWindowDragLeave = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
dragCounter.current -= 1;
if (dragCounter.current <= 0) {
setDragOver(false);
dragCounter.current = 0;
}
}
};
const handleWindowDrop = async (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragOver(false);
if (!e.dataTransfer) return;
const files = Array.from(e.dataTransfer.files);
const validFiles = files.filter((file) =>
SUPPORTED_FILE_TYPES.includes(file.type),
);
const invalidFiles = files.filter(
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
);
const duplicateFiles = validFiles.filter((file) =>
isDuplicate(file, contentBlocks),
);
const uniqueFiles = validFiles.filter(
(file) => !isDuplicate(file, contentBlocks),
);
if (invalidFiles.length > 0) {
toast.error(
"You have uploaded invalid file type. Please upload a JPEG, PNG, GIF, WEBP image or a PDF.",
);
}
if (duplicateFiles.length > 0) {
toast.error(
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
);
}
const newBlocks = uniqueFiles.length
? await Promise.all(uniqueFiles.map(fileToContentBlock))
: [];
setContentBlocks((prev) => [...prev, ...newBlocks]);
};
const handleWindowDragEnd = (e: DragEvent) => {
dragCounter.current = 0;
setDragOver(false);
};
window.addEventListener("dragenter", handleWindowDragEnter);
window.addEventListener("dragleave", handleWindowDragLeave);
window.addEventListener("drop", handleWindowDrop);
window.addEventListener("dragend", handleWindowDragEnd);
// Prevent default browser behavior for dragover globally
const handleWindowDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
window.addEventListener("dragover", handleWindowDragOver);
// Remove element-specific drop event (handled globally)
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
};
const element = dropRef.current;
element.addEventListener("dragover", handleDragOver);
element.addEventListener("dragenter", handleDragEnter);
element.addEventListener("dragleave", handleDragLeave);
return () => {
element.removeEventListener("dragover", handleDragOver);
element.removeEventListener("dragenter", handleDragEnter);
element.removeEventListener("dragleave", handleDragLeave);
window.removeEventListener("dragenter", handleWindowDragEnter);
window.removeEventListener("dragleave", handleWindowDragLeave);
window.removeEventListener("drop", handleWindowDrop);
window.removeEventListener("dragend", handleWindowDragEnd);
window.removeEventListener("dragover", handleWindowDragOver);
dragCounter.current = 0;
};
}, [contentBlocks]);
const removeBlock = (idx: number) => {
setContentBlocks((prev) => prev.filter((_, i) => i !== idx));
};
const resetBlocks = () => setContentBlocks([]);
/**
* Handle paste event for files (images, PDFs)
* Can be used as onPaste={handlePaste} on a textarea or input
*/
const handlePaste = async (
e: React.ClipboardEvent<HTMLTextAreaElement | HTMLInputElement>,
) => {
const items = e.clipboardData.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.kind === "file") {
const file = item.getAsFile();
if (file) files.push(file);
}
}
if (files.length === 0) {
return;
}
e.preventDefault();
const validFiles = files.filter((file) =>
SUPPORTED_FILE_TYPES.includes(file.type),
);
const invalidFiles = files.filter(
(file) => !SUPPORTED_FILE_TYPES.includes(file.type),
);
const isDuplicate = (file: File) => {
if (file.type === "application/pdf") {
return contentBlocks.some(
(b) =>
b.type === "file" &&
b.mime_type === "application/pdf" &&
b.metadata?.filename === file.name,
);
}
if (SUPPORTED_FILE_TYPES.includes(file.type)) {
return contentBlocks.some(
(b) =>
b.type === "image" &&
b.metadata?.name === file.name &&
b.mime_type === file.type,
);
}
return false;
};
const duplicateFiles = validFiles.filter(isDuplicate);
const uniqueFiles = validFiles.filter((file) => !isDuplicate(file));
if (invalidFiles.length > 0) {
toast.error(
"You have pasted an invalid file type. Please paste a JPEG, PNG, GIF, WEBP image or a PDF.",
);
}
if (duplicateFiles.length > 0) {
toast.error(
`Duplicate file(s) detected: ${duplicateFiles.map((f) => f.name).join(", ")}. Each file can only be uploaded once per message.`,
);
}
if (uniqueFiles.length > 0) {
const newBlocks = await Promise.all(uniqueFiles.map(fileToContentBlock));
setContentBlocks((prev) => [...prev, ...newBlocks]);
}
};
return {
contentBlocks,
setContentBlocks,
handleFileUpload,
dropRef,
removeBlock,
resetBlocks,
dragOver,
handlePaste,
};
}