This commit is contained in:
starmorph
2025-05-19 16:20:16 -07:00
parent 087587dad6
commit 52379f58a4
4 changed files with 161 additions and 95 deletions

View File

@@ -117,8 +117,7 @@ export function Thread() {
parseAsBoolean.withDefault(false),
);
const [input, setInput] = useState("");
const [imageUrlList, setImageUrlList] = useState<Base64ContentBlock[]>([]);
const [pdfUrlList, setPdfUrlList] = useState<Base64ContentBlock[]>([]);
const [contentBlocks, setContentBlocks] = useState<Base64ContentBlock[]>([]);
const [firstTokenReceived, setFirstTokenReceived] = useState(false);
const isLargeScreen = useMediaQuery("(min-width: 1024px)");
@@ -174,12 +173,7 @@ export function Thread() {
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (
(input.trim().length === 0 &&
imageUrlList.length === 0 &&
pdfUrlList.length === 0) ||
isLoading
)
if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading)
return;
setFirstTokenReceived(false);
@@ -188,12 +182,10 @@ export function Thread() {
type: "human",
content: [
...(input.trim().length > 0 ? [{ type: "text", text: input }] : []),
...pdfUrlList,
...imageUrlList,
...contentBlocks,
] as Message["content"],
};
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
stream.submit(
{ messages: [...toolMessages, newHumanMessage] },
@@ -211,19 +203,33 @@ export function Thread() {
);
setInput("");
setImageUrlList([]);
setPdfUrlList([]);
setContentBlocks([]);
};
const SUPPORTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
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, images: Base64ContentBlock[], pdfs: Base64ContentBlock[]) => {
const isDuplicate = (file: File, blocks: Base64ContentBlock[]) => {
if (SUPPORTED_IMAGE_TYPES.includes(file.type)) {
return images.some(img => img.metadata?.name === file.name && img.mime_type === 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 pdfs.some(pdf => pdf.metadata?.filename === file.name);
return blocks.some(
(b) =>
b.type === "file" &&
b.mime_type === "application/pdf" &&
b.metadata?.filename === file.name,
);
}
return false;
};
@@ -232,10 +238,18 @@ export function Thread() {
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, imageUrlList, pdfUrlList));
const uniqueFiles = validFiles.filter((file) => !isDuplicate(file, imageUrlList, pdfUrlList));
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(
@@ -244,22 +258,24 @@ export function Thread() {
}
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.`,
`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 imageFiles = uniqueFiles.filter((file) =>
SUPPORTED_IMAGE_TYPES.includes(file.type),
);
const pdfFiles = uniqueFiles.filter(
(file) => file.type === "application/pdf",
);
if (imageFiles.length) {
const imageBlocks = await Promise.all(imageFiles.map(fileToImageBlock));
setImageUrlList((prev) => [...prev, ...imageBlocks]);
}
if (pdfFiles.length) {
const pdfBlocks = await Promise.all(pdfFiles.map(fileToPDFBlock));
setPdfUrlList((prev) => [...prev, ...pdfBlocks]);
}
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 = "";
};
@@ -295,10 +311,18 @@ export function Thread() {
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, imageUrlList, pdfUrlList));
const uniqueFiles = validFiles.filter((file) => !isDuplicate(file, imageUrlList, pdfUrlList));
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(
@@ -307,26 +331,24 @@ export function Thread() {
}
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.`,
`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");
if (imageFiles.length) {
const imageBlocks: Base64ContentBlock[] = await Promise.all(
imageFiles.map(fileToImageBlock),
const imageFiles = uniqueFiles.filter((file) =>
SUPPORTED_IMAGE_TYPES.includes(file.type),
);
setImageUrlList((prev) => [...prev, ...imageBlocks]);
}
if (pdfFiles.length) {
const pdfBlocks: Base64ContentBlock[] = await Promise.all(
pdfFiles.map(fileToPDFBlock),
const pdfFiles = uniqueFiles.filter(
(file) => file.type === "application/pdf",
);
setPdfUrlList((prev) => [...prev, ...pdfBlocks]);
}
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) => {
@@ -544,27 +566,47 @@ export function Thread() {
onSubmit={handleSubmit}
className="mx-auto grid max-w-3xl grid-rows-[1fr_auto] gap-2"
>
{imageUrlList.length > 0 && (
{contentBlocks.filter((b) => b.type === "image").length >
0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
{imageUrlList.map((imageBlock, idx) => (
{contentBlocks
.filter((b) => b.type === "image")
.map((imageBlock, idx) => (
<MultimodalPreview
key={idx}
block={imageBlock}
removable
onRemove={() => setImageUrlList(imageUrlList.filter((_, i) => i !== idx))}
onRemove={() =>
setContentBlocks(
contentBlocks.filter((_, i) => i !== idx),
)
}
size="md"
/>
))}
</div>
)}
{pdfUrlList.length > 0 && (
{contentBlocks.filter(
(b) =>
b.type === "file" && b.mime_type === "application/pdf",
).length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
{pdfUrlList.map((pdfBlock, idx) => (
{contentBlocks
.filter(
(b) =>
b.type === "file" &&
b.mime_type === "application/pdf",
)
.map((pdfBlock, idx) => (
<MultimodalPreview
key={idx}
block={pdfBlock}
removable
onRemove={() => setPdfUrlList(pdfUrlList.filter((_, i) => i !== idx))}
onRemove={() =>
setContentBlocks(
contentBlocks.filter((_, i) => i !== idx),
)
}
size="md"
/>
))}
@@ -637,9 +679,7 @@ export function Thread() {
className="shadow-md transition-all"
disabled={
isLoading ||
(!input.trim() &&
imageUrlList.length === 0 &&
pdfUrlList.length === 0)
(!input.trim() && contentBlocks.length === 0)
}
>
Send

View File

@@ -36,7 +36,8 @@ function EditableContent({
// Type guard for Base64ContentBlock
function isBase64ContentBlock(block: unknown): block is Base64ContentBlock {
if (typeof block !== "object" || block === null || !("type" in block)) return false;
if (typeof block !== "object" || block === null || !("type" in block))
return false;
// file type (legacy)
if (
(block as { type: unknown }).type === "file" &&
@@ -119,14 +120,21 @@ export function HumanMessage({
{/* Render images and files if no text */}
{Array.isArray(message.content) && message.content.length > 0 && (
<div className="flex flex-col items-end gap-2">
{message.content.reduce<React.ReactNode[]>((acc, block, idx) => {
{message.content.reduce<React.ReactNode[]>(
(acc, block, idx) => {
if (isBase64ContentBlock(block)) {
acc.push(
<MultimodalPreview key={idx} block={block} size="md" />
<MultimodalPreview
key={idx}
block={block}
size="md"
/>,
);
}
return acc;
}, [])}
},
[],
)}
</div>
)}
{/* Render text if present, otherwise fallback to file/image name */}

View File

@@ -23,7 +23,8 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
md: "h-16 w-16 text-lg",
lg: "h-24 w-24 text-xl",
};
const iconSize: string = typeof sizeMap[size] === "string" ? sizeMap[size] : sizeMap["md"];
const iconSize: string =
typeof sizeMap[size] === "string" ? sizeMap[size] : sizeMap["md"];
// Image block
if (
@@ -37,7 +38,9 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
if (size === "sm") imgClass = "rounded-md object-cover h-10 w-10 text-base";
if (size === "lg") imgClass = "rounded-md object-cover h-24 w-24 text-xl";
return (
<div className={`relative inline-block${className ? ` ${className}` : ''}`}>
<div
className={`relative inline-block${className ? ` ${className}` : ""}`}
>
<img
src={url}
alt={String(block.metadata?.name || "uploaded image")}
@@ -63,12 +66,25 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
block.source_type === "base64" &&
block.mime_type === "application/pdf"
) {
const filename = block.metadata?.filename || block.metadata?.name || "PDF file";
const fileClass = `relative flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2${className ? ` ${className}` : ''}`;
const filename =
block.metadata?.filename || block.metadata?.name || "PDF file";
const fileClass = `relative flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2${className ? ` ${className}` : ""}`;
return (
<div className={fileClass}>
<File className={"text-teal-700 flex-shrink-0 " + (size === "sm" ? "h-5 w-5" : "h-7 w-7")} />
<span className={"truncate text-sm text-gray-800 " + (size === "sm" ? "max-w-[80px]" : "max-w-[160px]")}>{String(filename)}</span>
<File
className={
"flex-shrink-0 text-teal-700 " +
(size === "sm" ? "h-5 w-5" : "h-7 w-7")
}
/>
<span
className={
"truncate text-sm text-gray-800 " +
(size === "sm" ? "max-w-[80px]" : "max-w-[160px]")
}
>
{String(filename)}
</span>
{removable && (
<button
type="button"
@@ -84,7 +100,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
}
// Fallback for unknown types
const fallbackClass = `flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2 text-gray-500${className ? ` ${className}` : ''}`;
const fallbackClass = `flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2 text-gray-500${className ? ` ${className}` : ""}`;
return (
<div className={fallbackClass}>
<File className="h-5 w-5 flex-shrink-0" />

View File

@@ -1,4 +1,5 @@
import type { Base64ContentBlock } from "@langchain/core/messages";
import { toast } from "sonner";
// Returns a Promise of a typed multimodal block for images
export async function fileToImageBlock(
@@ -6,9 +7,10 @@ export async function fileToImageBlock(
): Promise<Base64ContentBlock> {
const supportedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
if (!supportedTypes.includes(file.type)) {
throw new Error(
toast.error(
`Unsupported image type: ${file.type}. Supported types are: ${supportedTypes.join(", ")}`,
);
return Promise.reject(new Error(`Unsupported image type: ${file.type}`));
}
const data = await fileToBase64(file);
return {