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 (
);
}
let url = src;
if (src.startsWith("/mnt/")) {
url = resolveArtifactURL(src, thread_id);
}
return (
);
},
}}
>
{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 (
);
}
let url = src;
if (src.startsWith("/mnt/")) {
url = resolveArtifactURL(src, thread_id);
}
return (
);
},
}}
>
{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 (
);
}
// 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}
);
}
const MessageContent = memo(MessageContent_);