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",
"next-themes": "^0.4.4",
"nuqs": "^2.4.1",
"pdfjs-dist": "^5.2.133",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"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";
import { fileToImageBlock, fileToPDFBlock } from "@/lib/multimodal-utils";
import type { Base64ContentBlock } from "@langchain/core/messages";
import { MultimodalPreview } from "../ui/MultimodalPreview";
function StickyToBottomContent(props: {
content: ReactNode;
@@ -192,7 +193,6 @@ export function Thread() {
] as Message["content"],
};
console.log("Message content:", newHumanMessage.content);
const toolMessages = ensureToolCallsHaveResponses(stream.messages);
stream.submit(
@@ -237,7 +237,7 @@ export function Thread() {
}
if (imageFiles.length) {
console.log("imageFiles", imageFiles);
const imageBlocks = await Promise.all(imageFiles.map(fileToImageBlock));
setImageUrlList((prev) => [...prev, ...imageBlocks]);
}
@@ -526,54 +526,27 @@ export function Thread() {
>
{imageUrlList.length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
{imageUrlList.map((imageBlock, idx) => {
const imageUrlString = `data:${imageBlock.mime_type};base64,${imageBlock.data}`;
return (
<div
className="relative"
key={idx}
>
<img
src={imageUrlString}
alt="uploaded"
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>
);
})}
{imageUrlList.map((imageBlock, idx) => (
<MultimodalPreview
key={idx}
block={imageBlock}
removable
onRemove={() => setImageUrlList(imageUrlList.filter((_, i) => i !== idx))}
size="md"
/>
))}
</div>
)}
{pdfUrlList.length > 0 && (
<div className="flex flex-wrap gap-2 p-3.5 pb-0">
{pdfUrlList.map((pdfBlock, idx) => (
<div
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"
<MultimodalPreview
key={idx}
>
<span className="max-w-xs truncate text-sm">
{String(
pdfBlock.metadata?.filename ??
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>
block={pdfBlock}
removable
onRemove={() => setPdfUrlList(pdfUrlList.filter((_, i) => i !== idx))}
size="md"
/>
))}
</div>
)}

View File

@@ -1,10 +1,12 @@
import { useStreamContext } from "@/providers/Stream";
import { Message } from "@langchain/langgraph-sdk";
import { useState } from "react";
import { getContentImageUrls, getContentString } from "../utils";
import {getContentString } from "../utils";
import { cn } from "@/lib/utils";
import { Textarea } from "@/components/ui/textarea";
import { BranchSwitcher, CommandBar } from "./shared";
import { MultimodalPreview } from "@/components/ui/MultimodalPreview";
import type { Base64ContentBlock } from "@langchain/core/messages";
function EditableContent({
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({
message,
isLoading,
@@ -46,7 +77,6 @@ export function HumanMessage({
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState("");
const contentString = getContentString(message.content);
const contentImageUrls = getContentImageUrls(message.content);
const handleSubmitEdit = () => {
setIsEditing(false);
@@ -89,60 +119,14 @@ 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.map((block, idx) => {
// Type guard for image block
const isImageBlock =
typeof block === "object" &&
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"
/>
{message.content.reduce<React.ReactNode[]>((acc, block, idx) => {
if (isBase64ContentBlock(block)) {
acc.push(
<MultimodalPreview key={idx} block={block} size="md" />
);
}
// Type guard for file block (PDF)
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;
})}
return acc;
}, [])}
</div>
)}
{/* 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>
);
};