CR fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useStreamContext } from "@/providers/Stream";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { useState } from "react";
|
||||
import {getContentString } from "../utils";
|
||||
import { getContentString } from "../utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { BranchSwitcher, CommandBar } from "./shared";
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user