/** * MessageItem component * Renders individual chat messages with actions for assistant messages */ import { Copy, RotateCcw } from "lucide-react"; import { defaultRehypePlugins, Streamdown } from "streamdown"; import { Action, Actions } from "@/components/lighthouse/ai-elements/actions"; import { ChainOfThoughtDisplay } from "@/components/lighthouse/chain-of-thought-display"; import { extractChainOfThoughtEvents, extractMessageText, type Message, MESSAGE_ROLES, MESSAGE_STATUS, } from "@/components/lighthouse/chat-utils"; import { Loader } from "@/components/lighthouse/loader"; /** * Escapes angle-bracket placeholders like to HTML entities * so they display correctly instead of being interpreted as HTML tags. * * This processes the text while preserving: * - Content inside inline code (backticks) * - Content inside code blocks (triple backticks) */ function escapeAngleBracketPlaceholders(text: string): string { // HTML tags to preserve (not escape) const htmlTags = new Set([ "div", "span", "p", "a", "img", "br", "hr", "ul", "ol", "li", "table", "tr", "td", "th", "thead", "tbody", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "blockquote", "strong", "em", "b", "i", "u", "s", "sub", "sup", "details", "summary", ]); // Split by code blocks and inline code to preserve them // This regex captures: ```...``` blocks, `...` inline code, and everything else const parts = text.split(/(```[\s\S]*?```|`[^`]+`)/g); return parts .map((part) => { // If it's a code block or inline code, leave it untouched // Shiki/syntax highlighter handles escaping inside code blocks if (part.startsWith("```") || part.startsWith("`")) { return part; } // For regular text outside code, wrap placeholders in backticks return part.replace(/<([a-zA-Z][a-zA-Z0-9_-]*)>/g, (match, tagName) => { if (htmlTags.has(tagName.toLowerCase())) { return match; } return `\`<${tagName}>\``; }); }) .join(""); } interface MessageItemProps { message: Message; index: number; isLastMessage: boolean; status: string; onCopy: (text: string) => void; onRegenerate: () => void; } export function MessageItem({ message, index, isLastMessage, status, onCopy, onRegenerate, }: MessageItemProps) { const messageText = extractMessageText(message); // Check if this is the streaming assistant message const isStreamingAssistant = isLastMessage && message.role === MESSAGE_ROLES.ASSISTANT && status === MESSAGE_STATUS.STREAMING; // Use a composite key to ensure uniqueness even if IDs are duplicated temporarily const uniqueKey = `${message.id}-${index}-${message.role}`; // Extract chain-of-thought events from message parts const chainOfThoughtEvents = extractChainOfThoughtEvents(message); return (
{/* Chain of Thought for assistant messages */} {message.role === MESSAGE_ROLES.ASSISTANT && ( )} {/* Show loader only if streaming with no text AND no chain-of-thought events */} {isStreamingAssistant && !messageText && chainOfThoughtEvents.length === 0 ? ( ) : messageText ? (
{message.role === MESSAGE_ROLES.USER ? ( // User messages: render as plain text to preserve HTML-like tags

{messageText}

) : ( // Assistant messages: render with markdown support
, , etc. // This prevents them from being interpreted as HTML elements defaultRehypePlugins.katex, defaultRehypePlugins.harden, ]} isAnimating={isStreamingAssistant} > {escapeAngleBracketPlaceholders(messageText)}
)}
) : null}
{/* Actions for assistant messages */} {message.role === MESSAGE_ROLES.ASSISTANT && isLastMessage && messageText && status !== MESSAGE_STATUS.STREAMING && (
onCopy(messageText)} >
)}
); }