CR: use-file-upload-hook
This commit is contained in:
@@ -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<Base64ContentBlock[]>([]);
|
||||
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<string | undefined>(undefined);
|
||||
|
||||
const dropRef = useRef<HTMLDivElement>(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: (
|
||||
<p>
|
||||
<strong>Error:</strong> <code>{message}</code>
|
||||
</p>
|
||||
),
|
||||
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<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 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 (
|
||||
<div className="flex h-screen w-full overflow-hidden">
|
||||
<div className="relative hidden lg:flex">
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
181
src/hooks/use-file-upload.tsx
Normal file
181
src/hooks/use-file-upload.tsx
Normal file
@@ -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<Base64ContentBlock[]>(initialBlocks);
|
||||
const dropRef = useRef<HTMLDivElement>(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<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 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user