import type { Message } from "@langchain/langgraph-sdk"; import { FileIcon } from "lucide-react"; import { useParams } from "next/navigation"; import { memo, useMemo } from "react"; import rehypeKatex from "rehype-katex"; import { CitationLink, CitationsLoadingIndicator, } 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 { resolveArtifactURL } from "@/core/artifacts/utils"; import { type Citation, buildCitationMap, isCitationsBlockIncomplete, parseCitations, removeAllCitations, } from "@/core/citations"; import { extractContentFromMessage, extractReasoningContentFromMessage, parseUploadedFiles, type UploadedFile, } from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { humanMessagePlugins, 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; }) { const isHuman = message.type === "human"; return ( ); } /** * Custom link component that handles citations and external links * Only links in citationMap are rendered as CitationLink badges * Other links (project URLs, regular links) are rendered as plain links */ function MessageLink({ href, children, citationMap, isHuman, }: React.AnchorHTMLAttributes & { citationMap: Map; isHuman: boolean; }) { if (!href) return {children}; const citation = citationMap.get(href); // Only render as CitationLink badge if it's a citation (in citationMap) and not human message if (citation && !isHuman) { return ( {children} ); } // All other links render as plain links return ( {children} ); } /** * Custom image component that handles artifact URLs */ function MessageImage({ src, alt, threadId, maxWidth = "90%", ...props }: React.ImgHTMLAttributes & { threadId: string; maxWidth?: string; }) { if (!src) return null; const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`); if (typeof src !== "string") { return ; } const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src; return ( ); } function MessageContent_({ className, message, isLoading = false, }: { className?: string; message: Message; isLoading?: boolean; }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; const { thread_id } = useParams<{ thread_id: string }>(); // Extract and parse citations and uploaded files from message content const { citations, cleanContent, uploadedFiles, isLoadingCitations } = useMemo(() => { const reasoningContent = extractReasoningContentFromMessage(message); const rawContent = extractContentFromMessage(message); // When only reasoning content exists (no main content), also parse citations if (!isLoading && reasoningContent && !rawContent) { const { citations, cleanContent } = parseCitations(reasoningContent); return { citations, cleanContent, uploadedFiles: [], isLoadingCitations: false, }; } // For human messages, parse uploaded files first if (isHuman && rawContent) { const { files, cleanContent: contentWithoutFiles } = parseUploadedFiles(rawContent); const { citations, cleanContent: finalContent } = parseCitations(contentWithoutFiles); return { citations, cleanContent: finalContent, uploadedFiles: files, isLoadingCitations: false, }; } const { citations, cleanContent } = parseCitations(rawContent ?? ""); const isLoadingCitations = isLoading && isCitationsBlockIncomplete(rawContent ?? ""); return { citations, cleanContent, uploadedFiles: [], isLoadingCitations }; }, [isLoading, message, isHuman]); const citationMap = useMemo(() => buildCitationMap(citations), [citations]); // Shared markdown components const markdownComponents = useMemo(() => ({ a: (props: React.AnchorHTMLAttributes) => ( ), img: (props: React.ImgHTMLAttributes) => ( ), }), [citationMap, thread_id, isHuman]); // Render message response // Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text const messageResponse = cleanContent ? ( {cleanContent} ) : null; // Uploaded files list const filesList = uploadedFiles.length > 0 && thread_id ? ( ) : null; // Citations loading indicator const citationsLoadingIndicator = isLoadingCitations ? ( ) : null; // Human messages with uploaded files: render outside bubble if (isHuman && uploadedFiles.length > 0) { return ( {filesList} {messageResponse && ( {messageResponse} )} ); } // Default rendering return ( {filesList} {messageResponse} {citationsLoadingIndicator} ); } /** * Get file extension and check helpers */ const getFileExt = (filename: string) => filename.split(".").pop()?.toLowerCase() ?? ""; const FILE_TYPE_MAP: 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", }; const IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"]; function getFileTypeLabel(filename: string): string { const ext = getFileExt(filename); return FILE_TYPE_MAP[ext] ?? (ext.toUpperCase() || "FILE"); } function isImageFile(filename: string): boolean { return IMAGE_EXTENSIONS.includes(getFileExt(filename)); } /** * Uploaded files list component */ function UploadedFilesList({ files, threadId }: { files: UploadedFile[]; threadId: string }) { if (files.length === 0) return null; return ( {files.map((file, index) => ( ))} ); } /** * Single uploaded file card component */ function UploadedFileCard({ file, threadId }: { file: UploadedFile; threadId: string }) { if (!threadId) return null; const isImage = isImageFile(file.filename); const fileUrl = resolveArtifactURL(file.path, threadId); if (isImage) { return ( ); } return ( {file.filename} {getFileTypeLabel(file.filename)} {file.size} ); } const MessageContent = memo(MessageContent_);