CR: multimodal preview component, drop pdf-parse dep
This commit is contained in:
@@ -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
1119
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
104
src/components/ui/MultimodalPreview.tsx
Normal file
104
src/components/ui/MultimodalPreview.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user