import type { Message } from "@langchain/langgraph-sdk"; import { ExternalLinkIcon, FileIcon } from "lucide-react"; import { useParams } from "next/navigation"; import { memo, useMemo } from "react"; import rehypeKatex from "rehype-katex"; import remarkMath from "remark-math"; import { InlineCitationCard, InlineCitationCardBody, InlineCitationSource, } from "@/components/ai-elements/inline-citation"; import { Message as AIElementMessage, MessageContent as AIElementMessageContent, MessageResponse as AIElementMessageResponse, MessageToolbar, } from "@/components/ai-elements/message"; import { Badge } from "@/components/ui/badge"; import { HoverCardTrigger } from "@/components/ui/hover-card"; import { resolveArtifactURL } from "@/core/artifacts/utils"; import { type Citation, buildCitationMap, extractDomainFromUrl, parseCitations, } from "@/core/citations"; import { extractContentFromMessage, extractReasoningContentFromMessage, parseUploadedFiles, type UploadedFile, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { streamdownPlugins } from "@/core/streamdown"; import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; export function MessageListItem({ className, message, isLoading, }: { className?: string; message: Message; isLoading?: boolean; }) { return (
); } function MessageContent_({ className, message, isLoading = false, }: { className?: string; message: Message; isLoading?: boolean; }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; // Extract and parse citations and uploaded files from message content const { citations, cleanContent, uploadedFiles } = useMemo(() => { const reasoningContent = extractReasoningContentFromMessage(message); const rawContent = extractContentFromMessage(message); if (!isLoading && reasoningContent && !rawContent) { return { citations: [], cleanContent: reasoningContent, uploadedFiles: [], }; } // For human messages, first parse uploaded files if (isHuman && rawContent) { const { files, cleanContent: contentWithoutFiles } = parseUploadedFiles(rawContent); const { citations, cleanContent: finalContent } = parseCitations(contentWithoutFiles); return { citations, cleanContent: finalContent, uploadedFiles: files }; } const { citations, cleanContent } = parseCitations(rawContent ?? ""); return { citations, cleanContent, uploadedFiles: [] }; }, [isLoading, message, isHuman]); // Build citation map for quick URL lookup const citationMap = useMemo(() => buildCitationMap(citations), [citations]); const { thread_id } = useParams<{ thread_id: string }>(); // For human messages with uploaded files, render files outside the bubble if (isHuman && uploadedFiles.length > 0) { return (
{/* Uploaded files outside the message bubble */} {/* Message content inside the bubble (only if there's text) */} {cleanContent && ( ) => { if (!href) { return {children}; } // Check if this link matches a citation const citation = citationMap.get(href); if (citation) { return ( {children} ); } // Regular external link return ( {children} ); }, img: ({ src, alt, }: React.ImgHTMLAttributes) => { if (!src) return null; if (typeof src !== "string") { return ( {alt} ); } let url = src; if (src.startsWith("/mnt/")) { url = resolveArtifactURL(src, thread_id); } return ( {alt} ); }, }} > {cleanContent} )}
); } // Default rendering for non-human messages or human messages without files return ( {/* Uploaded files for human messages - show first */} {uploadedFiles.length > 0 && thread_id && ( )} {/* Message content - always show if present */} {cleanContent && ( ) => { if (!href) { return {children}; } // Check if this link matches a citation const citation = citationMap.get(href); if (citation) { return ( {children} ); } // Regular external link return ( {children} ); }, img: ({ src, alt }: React.ImgHTMLAttributes) => { if (!src) return null; if (typeof src !== "string") { return ( {alt} ); } let url = src; if (src.startsWith("/mnt/")) { url = resolveArtifactURL(src, thread_id); } return ( {alt} ); }, }} > {cleanContent} )} ); } /** * Get file type label from filename extension */ function getFileTypeLabel(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase() ?? ""; const typeMap: Record = { json: "JSON", csv: "CSV", txt: "TXT", md: "Markdown", py: "Python", js: "JavaScript", ts: "TypeScript", tsx: "TSX", jsx: "JSX", html: "HTML", css: "CSS", xml: "XML", yaml: "YAML", yml: "YAML", pdf: "PDF", png: "PNG", jpg: "JPG", jpeg: "JPEG", gif: "GIF", svg: "SVG", zip: "ZIP", tar: "TAR", gz: "GZ", }; return (typeMap[ext] ?? ext.toUpperCase()) || "FILE"; } /** * Check if a file is an image based on extension */ function isImageFile(filename: string): boolean { const ext = filename.split(".").pop()?.toLowerCase() ?? ""; return ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"].includes(ext); } /** * Uploaded files list component that displays files as cards or image thumbnails (Claude-style) */ function UploadedFilesList({ files, threadId, }: { files: UploadedFile[]; threadId: string; }) { if (files.length === 0) return null; return (
{files.map((file, index) => ( ))}
); } /** * Single uploaded file card component (Claude-style) * Shows image thumbnail for image files, file card for others */ function UploadedFileCard({ file, threadId, }: { file: UploadedFile; threadId: string; }) { const typeLabel = getFileTypeLabel(file.filename); const isImage = isImageFile(file.filename); // Don't render if threadId is invalid if (!threadId) { return null; } // Build URL - browser will handle encoding automatically const imageUrl = resolveArtifactURL(file.path, threadId); // For image files, show thumbnail if (isImage) { return ( {file.filename} ); } // For non-image files, show file card return (
{file.filename}
{typeLabel} {file.size}
); } /** * Citation link component that renders as a hover card badge */ function CitationLink({ citation, href, children, }: { citation: Citation; href: string; children: React.ReactNode; }) { const domain = extractDomainFromUrl(href); return ( e.stopPropagation()} > {children ?? domain}
Visit source
); } const MessageContent = memo(MessageContent_);