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

View File

@@ -1,7 +1,7 @@
import { useStreamContext } from "@/providers/Stream"; import { useStreamContext } from "@/providers/Stream";
import { Message } from "@langchain/langgraph-sdk"; import { Message } from "@langchain/langgraph-sdk";
import { useState } from "react"; import { useState } from "react";
import {getContentString } from "../utils"; import { getContentString } from "../utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared"; import { BranchSwitcher, CommandBar } from "./shared";
@@ -36,7 +36,8 @@ function EditableContent({
// Type guard for Base64ContentBlock // Type guard for Base64ContentBlock
function isBase64ContentBlock(block: unknown): block is 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) // file type (legacy)
if ( if (
(block as { type: unknown }).type === "file" && (block as { type: unknown }).type === "file" &&
@@ -119,14 +120,21 @@ export function HumanMessage({
{/* Render images and files if no text */} {/* Render images and files if no text */}
{Array.isArray(message.content) && message.content.length > 0 && ( {Array.isArray(message.content) && message.content.length > 0 && (
<div className="flex flex-col items-end gap-2"> <div className="flex flex-col items-end gap-2">
{message.content.reduce<React.ReactNode[]>((acc, block, idx) => { {message.content.reduce<React.ReactNode[]>(
if (isBase64ContentBlock(block)) { (acc, block, idx) => {
acc.push( if (isBase64ContentBlock(block)) {
<MultimodalPreview key={idx} block={block} size="md" /> acc.push(
); <MultimodalPreview
} key={idx}
return acc; block={block}
}, [])} size="md"
/>,
);
}
return acc;
},
[],
)}
</div> </div>
)} )}
{/* Render text if present, otherwise fallback to file/image name */} {/* 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", md: "h-16 w-16 text-lg",
lg: "h-24 w-24 text-xl", 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 // Image block
if ( 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 === "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"; if (size === "lg") imgClass = "rounded-md object-cover h-24 w-24 text-xl";
return ( return (
<div className={`relative inline-block${className ? ` ${className}` : ''}`}> <div
className={`relative inline-block${className ? ` ${className}` : ""}`}
>
<img <img
src={url} src={url}
alt={String(block.metadata?.name || "uploaded image")} alt={String(block.metadata?.name || "uploaded image")}
@@ -63,12 +66,25 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
block.source_type === "base64" && block.source_type === "base64" &&
block.mime_type === "application/pdf" block.mime_type === "application/pdf"
) { ) {
const filename = block.metadata?.filename || block.metadata?.name || "PDF file"; const filename =
const fileClass = `relative flex items-center gap-2 rounded-md border bg-gray-100 px-3 py-2${className ? ` ${className}` : ''}`; 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 ( return (
<div className={fileClass}> <div className={fileClass}>
<File className={"text-teal-700 flex-shrink-0 " + (size === "sm" ? "h-5 w-5" : "h-7 w-7")} /> <File
<span className={"truncate text-sm text-gray-800 " + (size === "sm" ? "max-w-[80px]" : "max-w-[160px]")}>{String(filename)}</span> 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 && ( {removable && (
<button <button
type="button" type="button"
@@ -84,7 +100,7 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
} }
// Fallback for unknown types // 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 ( return (
<div className={fallbackClass}> <div className={fallbackClass}>
<File className="h-5 w-5 flex-shrink-0" /> <File className="h-5 w-5 flex-shrink-0" />
@@ -101,4 +117,4 @@ export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
)} )}
</div> </div>
); );
}; };

View File

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