CR: multimodal preview component, drop pdf-parse dep
This commit is contained in:
@@ -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
1119
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
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