CR: multimodal preview component, drop pdf-parse dep

This commit is contained in:
starmorph
2025-05-19 16:08:01 -07:00
parent 224d0bab2d
commit f3b616572b
5 changed files with 197 additions and 1180 deletions

View File

@@ -41,7 +41,6 @@
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"nuqs": "^2.4.1", "nuqs": "^2.4.1",
"pdfjs-dist": "^5.2.133",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.0.1", "react-markdown": "^10.0.1",

1119
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -39,6 +39,7 @@ import {
} from "../ui/tooltip"; } from "../ui/tooltip";
import { fileToImageBlock, fileToPDFBlock } from "@/lib/multimodal-utils"; import { fileToImageBlock, fileToPDFBlock } from "@/lib/multimodal-utils";
import type { Base64ContentBlock } from "@langchain/core/messages"; import type { Base64ContentBlock } from "@langchain/core/messages";
import { MultimodalPreview } from "../ui/MultimodalPreview";
function StickyToBottomContent(props: { function StickyToBottomContent(props: {
content: ReactNode; content: ReactNode;
@@ -192,7 +193,6 @@ export function Thread() {
] as Message["content"], ] as Message["content"],
}; };
console.log("Message content:", newHumanMessage.content);
const toolMessages = ensureToolCallsHaveResponses(stream.messages); const toolMessages = ensureToolCallsHaveResponses(stream.messages);
stream.submit( stream.submit(
@@ -237,7 +237,7 @@ export function Thread() {
} }
if (imageFiles.length) { if (imageFiles.length) {
console.log("imageFiles", imageFiles);
const imageBlocks = await Promise.all(imageFiles.map(fileToImageBlock)); const imageBlocks = await Promise.all(imageFiles.map(fileToImageBlock));
setImageUrlList((prev) => [...prev, ...imageBlocks]); setImageUrlList((prev) => [...prev, ...imageBlocks]);
} }
@@ -526,54 +526,27 @@ export function Thread() {
> >
{imageUrlList.length > 0 && ( {imageUrlList.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) => { {imageUrlList.map((imageBlock, idx) => (
const imageUrlString = `data:${imageBlock.mime_type};base64,${imageBlock.data}`; <MultimodalPreview
return (
<div
className="relative"
key={idx} key={idx}
> block={imageBlock}
<img removable
src={imageUrlString} onRemove={() => setImageUrlList(imageUrlList.filter((_, i) => i !== idx))}
alt="uploaded" size="md"
className="h-16 w-16 rounded-md object-cover"
/> />
<CircleX ))}
className="absolute top-[2px] right-[2px] size-4 cursor-pointer rounded-full bg-gray-500 text-white"
onClick={() =>
setImageUrlList(
imageUrlList.filter((_, i) => i !== idx),
)
}
/>
</div>
);
})}
</div> </div>
)} )}
{pdfUrlList.length > 0 && ( {pdfUrlList.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) => ( {pdfUrlList.map((pdfBlock, idx) => (
<div <MultimodalPreview
className="relative flex items-center gap-2 rounded rounded-md border-1 border-teal-700 bg-gray-100 bg-teal-900 px-2 py-1 py-2 text-white"
key={idx} key={idx}
> block={pdfBlock}
<span className="max-w-xs truncate text-sm"> removable
{String( onRemove={() => setPdfUrlList(pdfUrlList.filter((_, i) => i !== idx))}
pdfBlock.metadata?.filename ?? size="md"
pdfBlock.metadata?.name ??
"",
)}
</span>
<CircleX
className="size-4 cursor-pointer text-teal-600 hover:text-teal-500"
onClick={() =>
setPdfUrlList(
pdfUrlList.filter((_, i) => i !== idx),
)
}
/> />
</div>
))} ))}
</div> </div>
)} )}

View File

@@ -1,10 +1,12 @@
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 { getContentImageUrls, 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";
import { MultimodalPreview } from "@/components/ui/MultimodalPreview";
import type { Base64ContentBlock } from "@langchain/core/messages";
function EditableContent({ function EditableContent({
value, value,
@@ -32,6 +34,35 @@ function EditableContent({
); );
} }
// Type guard for Base64ContentBlock
function isBase64ContentBlock(block: unknown): block is Base64ContentBlock {
if (typeof block !== "object" || block === null || !("type" in block)) return false;
// file type (legacy)
if (
(block as { type: unknown }).type === "file" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
((block as { mime_type: string }).mime_type.startsWith("image/") ||
(block as { mime_type: string }).mime_type === "application/pdf")
) {
return true;
}
// image type (new)
if (
(block as { type: unknown }).type === "image" &&
"source_type" in block &&
(block as { source_type: unknown }).source_type === "base64" &&
"mime_type" in block &&
typeof (block as { mime_type?: unknown }).mime_type === "string" &&
(block as { mime_type: string }).mime_type.startsWith("image/")
) {
return true;
}
return false;
}
export function HumanMessage({ export function HumanMessage({
message, message,
isLoading, isLoading,
@@ -46,7 +77,6 @@ export function HumanMessage({
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const contentString = getContentString(message.content); const contentString = getContentString(message.content);
const contentImageUrls = getContentImageUrls(message.content);
const handleSubmitEdit = () => { const handleSubmitEdit = () => {
setIsEditing(false); setIsEditing(false);
@@ -89,60 +119,14 @@ 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.map((block, idx) => { {message.content.reduce<React.ReactNode[]>((acc, block, idx) => {
// Type guard for image block if (isBase64ContentBlock(block)) {
const isImageBlock = acc.push(
typeof block === "object" && <MultimodalPreview key={idx} block={block} size="md" />
block !== null &&
"type" in block &&
(block as any).type === "image" &&
"source_type" in block &&
(block as any).source_type === "base64" &&
"mime_type" in block &&
"data" in block;
if (isImageBlock) {
const imgBlock = block as {
type: string;
source_type: string;
mime_type: string;
data: string;
metadata?: { name?: string };
};
const url = `data:${imgBlock.mime_type};base64,${imgBlock.data}`;
return (
<img
key={idx}
src={url}
alt={imgBlock.metadata?.name || "uploaded image"}
className="bg-muted h-16 w-16 rounded-md object-cover"
/>
); );
} }
// Type guard for file block (PDF) return acc;
const isPdfBlock = }, [])}
typeof block === "object" &&
block !== null &&
"type" in block &&
(block as any).type === "file" &&
"mime_type" in block &&
(block as any).mime_type === "application/pdf";
if (isPdfBlock) {
const pdfBlock = block as {
metadata?: { filename?: string; name?: string };
};
return (
<div
key={idx}
className="bg-muted ml-auto w-fit rounded-3xl px-4 py-2 text-right whitespace-pre-wrap"
>
{pdfBlock.metadata?.filename ||
pdfBlock.metadata?.name ||
"PDF file"}
</div>
);
}
return null;
})}
</div> </div>
)} )}
{/* Render text if present, otherwise fallback to file/image name */} {/* Render text if present, otherwise fallback to file/image name */}

View File

@@ -0,0 +1,104 @@
import React from "react";
import { File, Image as ImageIcon, X as XIcon } from "lucide-react";
import type { Base64ContentBlock } from "@langchain/core/messages";
export interface MultimodalPreviewProps {
block: Base64ContentBlock;
removable?: boolean;
onRemove?: () => void;
className?: string;
size?: "sm" | "md" | "lg";
}
export const MultimodalPreview: React.FC<MultimodalPreviewProps> = ({
block,
removable = false,
onRemove,
className = "",
size = "md",
}) => {
// Sizing
const sizeMap = {
sm: "h-10 w-10 text-base",
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"];
// Image block
if (
block.type === "image" &&
block.source_type === "base64" &&
typeof block.mime_type === "string" &&
block.mime_type.startsWith("image/")
) {
const url = `data:${block.mime_type};base64,${block.data}`;
let imgClass: string = "rounded-md object-cover h-16 w-16 text-lg";
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}` : ''}`}>
<img
src={url}
alt={String(block.metadata?.name || "uploaded image")}
className={imgClass}
/>
{removable && (
<button
type="button"
className="absolute top-1 right-1 z-10 rounded-full bg-gray-500 text-white hover:bg-gray-700"
onClick={onRemove}
aria-label="Remove image"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
}
// PDF block
if (
block.type === "file" &&
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}` : ''}`;
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>
{removable && (
<button
type="button"
className="ml-2 rounded-full bg-gray-200 p-1 text-teal-700 hover:bg-gray-300"
onClick={onRemove}
aria-label="Remove PDF"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
}
// 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}` : ''}`;
return (
<div className={fallbackClass}>
<File className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-xs">Unsupported file type</span>
{removable && (
<button
type="button"
className="ml-2 rounded-full bg-gray-200 p-1 text-gray-500 hover:bg-gray-300"
onClick={onRemove}
aria-label="Remove file"
>
<XIcon className="h-4 w-4" />
</button>
)}
</div>
);
};