From 2884683f99d538fe0032447e81ede8b2ab40dac6 Mon Sep 17 00:00:00 2001 From: starmorph Date: Mon, 19 May 2025 16:25:44 -0700 Subject: [PATCH] CR: use-file-upload-hook --- src/components/thread/index.tsx | 213 +++----------------------------- src/components/thread/utils.ts | 25 +--- src/hooks/use-file-upload.tsx | 181 +++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 220 deletions(-) create mode 100644 src/hooks/use-file-upload.tsx diff --git a/src/components/thread/index.tsx b/src/components/thread/index.tsx index feaac90..e0fa3e0 100644 --- a/src/components/thread/index.tsx +++ b/src/components/thread/index.tsx @@ -40,6 +40,7 @@ import { import { fileToImageBlock, fileToPDFBlock } from "@/lib/multimodal-utils"; import type { Base64ContentBlock } from "@langchain/core/messages"; import { MultimodalPreview } from "../ui/MultimodalPreview"; +import { useFileUpload } from "@/hooks/use-file-upload"; function StickyToBottomContent(props: { content: ReactNode; @@ -117,7 +118,14 @@ export function Thread() { parseAsBoolean.withDefault(false), ); const [input, setInput] = useState(""); - const [contentBlocks, setContentBlocks] = useState([]); + const { + contentBlocks, + setContentBlocks, + handleFileUpload, + dropRef, + removeBlock, + resetBlocks, + } = useFileUpload(); const [firstTokenReceived, setFirstTokenReceived] = useState(false); const isLargeScreen = useMediaQuery("(min-width: 1024px)"); @@ -127,36 +135,6 @@ export function Thread() { const lastError = useRef(undefined); - const dropRef = useRef(null); - - useEffect(() => { - if (!stream.error) { - lastError.current = undefined; - return; - } - try { - const message = (stream.error as any).message; - if (!message || lastError.current === message) { - // Message has already been logged. do not modify ref, return early. - return; - } - - // Message is defined, and it has not been logged yet. Save it, and send the error - lastError.current = message; - toast.error("An error occurred. Please try again.", { - description: ( -

- Error: {message} -

- ), - richColors: true, - closeButton: true, - }); - } catch { - // no-op - } - }, [stream.error]); - // TODO: this should be part of the useStream hook const prevMessageLength = useRef(0); useEffect(() => { @@ -206,79 +184,12 @@ export function Thread() { setContentBlocks([]); }; - const SUPPORTED_IMAGE_TYPES = [ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - ]; - const SUPPORTED_FILE_TYPES = [...SUPPORTED_IMAGE_TYPES, "application/pdf"]; - - const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => { - if (SUPPORTED_IMAGE_TYPES.includes(file.type)) { - return blocks.some( - (b) => - b.type === "image" && - b.metadata?.name === file.name && - b.mime_type === file.type, - ); - } - if (file.type === "application/pdf") { - return blocks.some( - (b) => - b.type === "file" && - b.mime_type === "application/pdf" && - b.metadata?.filename === file.name, - ); - } - return false; - }; - - const handleFileUpload = async (e: ChangeEvent) => { - 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 imageFiles = uniqueFiles.filter((file) => - SUPPORTED_IMAGE_TYPES.includes(file.type), - ); - const pdfFiles = uniqueFiles.filter( - (file) => file.type === "application/pdf", - ); - - const imageBlocks = imageFiles.length - ? await Promise.all(imageFiles.map(fileToImageBlock)) - : []; - const pdfBlocks = pdfFiles.length - ? await Promise.all(pdfFiles.map(fileToPDFBlock)) - : []; - setContentBlocks((prev) => [...prev, ...imageBlocks, ...pdfBlocks]); - e.target.value = ""; - }; + const chatStarted = !!threadId || !!messages.length; + const hasNoAIOrToolMessages = !messages.find( + (m) => m.type === "ai" || m.type === "tool", + ); + // Restore handleRegenerate const handleRegenerate = ( parentCheckpoint: Checkpoint | null | undefined, ) => { @@ -291,90 +202,6 @@ export function Thread() { }); }; - const chatStarted = !!threadId || !!messages.length; - const hasNoAIOrToolMessages = !messages.find( - (m) => m.type === "ai" || m.type === "tool", - ); - - useEffect(() => { - if (!dropRef.current) return; - - const handleDragOver = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const handleDrop = async (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - - 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 imageFiles = uniqueFiles.filter((file) => - SUPPORTED_IMAGE_TYPES.includes(file.type), - ); - const pdfFiles = uniqueFiles.filter( - (file) => file.type === "application/pdf", - ); - - const imageBlocks: Base64ContentBlock[] = imageFiles.length - ? await Promise.all(imageFiles.map(fileToImageBlock)) - : []; - const pdfBlocks: Base64ContentBlock[] = pdfFiles.length - ? await Promise.all(pdfFiles.map(fileToPDFBlock)) - : []; - setContentBlocks((prev) => [...prev, ...imageBlocks, ...pdfBlocks]); - }; - - const handleDragEnter = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const handleDragLeave = (e: DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const element = dropRef.current; - element.addEventListener("dragover", handleDragOver); - element.addEventListener("drop", handleDrop); - element.addEventListener("dragenter", handleDragEnter); - element.addEventListener("dragleave", handleDragLeave); - - return () => { - element.removeEventListener("dragover", handleDragOver); - element.removeEventListener("drop", handleDrop); - element.removeEventListener("dragenter", handleDragEnter); - element.removeEventListener("dragleave", handleDragLeave); - }; - }); - return (
@@ -576,11 +403,7 @@ export function Thread() { key={idx} block={imageBlock} removable - onRemove={() => - setContentBlocks( - contentBlocks.filter((_, i) => i !== idx), - ) - } + onRemove={() => removeBlock(idx)} size="md" /> ))} @@ -602,11 +425,7 @@ export function Thread() { key={idx} block={pdfBlock} removable - onRemove={() => - setContentBlocks( - contentBlocks.filter((_, i) => i !== idx), - ) - } + onRemove={() => removeBlock(idx)} size="md" /> ))} diff --git a/src/components/thread/utils.ts b/src/components/thread/utils.ts index f0e464b..30de868 100644 --- a/src/components/thread/utils.ts +++ b/src/components/thread/utils.ts @@ -11,28 +11,7 @@ export function getContentString(content: Message["content"]): string { const texts = content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text); - if (texts.length > 0) return texts.join(" "); - // Handle multimodal: fallback to first non-text type - if (Array.isArray(content) && content.length > 0) { - const first = content[0]; - if (typeof first === "object" && first !== null && "type" in first) { - switch (first.type) { - case "image_url": - return "Image"; - default: - return "Other"; - } - } - } - return "Multimodal message"; + return texts.join(" "); } -export function getContentImageUrls(content: Message["content"]): string[] { - if (typeof content === "string") return []; - return content - .filter((c) => c.type === "image_url") - .map((c) => { - if (typeof c.image_url === "string") return c.image_url; - return c.image_url.url; - }); -} + diff --git a/src/hooks/use-file-upload.tsx b/src/hooks/use-file-upload.tsx new file mode 100644 index 0000000..f52bbd6 --- /dev/null +++ b/src/hooks/use-file-upload.tsx @@ -0,0 +1,181 @@ +import { useState, useRef, useEffect, ChangeEvent } from "react"; +import { toast } from "sonner"; +import type { Base64ContentBlock } from "@langchain/core/messages"; +import { fileToImageBlock, fileToPDFBlock } from "@/lib/multimodal-utils"; + +export const SUPPORTED_IMAGE_TYPES = [ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]; +export const SUPPORTED_FILE_TYPES = [...SUPPORTED_IMAGE_TYPES, "application/pdf"]; + +interface UseFileUploadOptions { + initialBlocks?: Base64ContentBlock[]; +} + +export function useFileUpload({ initialBlocks = [] }: UseFileUploadOptions = {}) { + const [contentBlocks, setContentBlocks] = useState(initialBlocks); + const dropRef = useRef(null); + + const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => { + if (SUPPORTED_IMAGE_TYPES.includes(file.type)) { + return blocks.some( + (b) => + b.type === "image" && + b.metadata?.name === file.name && + b.mime_type === file.type, + ); + } + if (file.type === "application/pdf") { + return blocks.some( + (b) => + b.type === "file" && + b.mime_type === "application/pdf" && + b.metadata?.filename === file.name, + ); + } + return false; + }; + + const handleFileUpload = async (e: ChangeEvent) => { + 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 imageFiles = uniqueFiles.filter((file) => + SUPPORTED_IMAGE_TYPES.includes(file.type), + ); + const pdfFiles = uniqueFiles.filter( + (file) => file.type === "application/pdf", + ); + + const imageBlocks = imageFiles.length + ? await Promise.all(imageFiles.map(fileToImageBlock)) + : []; + const pdfBlocks = pdfFiles.length + ? await Promise.all(pdfFiles.map(fileToPDFBlock)) + : []; + setContentBlocks((prev) => [...prev, ...imageBlocks, ...pdfBlocks]); + e.target.value = ""; + }; + + // Drag and drop handlers + useEffect(() => { + if (!dropRef.current) return; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = async (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + + 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 imageFiles = uniqueFiles.filter((file) => + SUPPORTED_IMAGE_TYPES.includes(file.type), + ); + const pdfFiles = uniqueFiles.filter( + (file) => file.type === "application/pdf", + ); + + const imageBlocks: Base64ContentBlock[] = imageFiles.length + ? await Promise.all(imageFiles.map(fileToImageBlock)) + : []; + const pdfBlocks: Base64ContentBlock[] = pdfFiles.length + ? await Promise.all(pdfFiles.map(fileToPDFBlock)) + : []; + setContentBlocks((prev) => [...prev, ...imageBlocks, ...pdfBlocks]); + }; + + const handleDragEnter = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const element = dropRef.current; + element.addEventListener("dragover", handleDragOver); + element.addEventListener("drop", handleDrop); + element.addEventListener("dragenter", handleDragEnter); + element.addEventListener("dragleave", handleDragLeave); + + return () => { + element.removeEventListener("dragover", handleDragOver); + element.removeEventListener("drop", handleDrop); + element.removeEventListener("dragenter", handleDragEnter); + element.removeEventListener("dragleave", handleDragLeave); + }; + }, [contentBlocks]); + + const removeBlock = (idx: number) => { + setContentBlocks((prev) => prev.filter((_, i) => i !== idx)); + }; + + const resetBlocks = () => setContentBlocks([]); + + return { + contentBlocks, + setContentBlocks, + handleFileUpload, + dropRef, + removeBlock, + resetBlocks, + }; +} \ No newline at end of file