'use client'; import { useEffect, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import { Anchor, Blockquote, Code, List, Text, Title } from '@mantine/core'; import classes from './MarkdownMessage.module.css'; interface MarkdownMessageProps { content: string; isStreaming?: boolean; } /** * Hook that tracks content changes and returns content with fade-in markers for new text */ function useStreamingContent(content: string, isStreaming: boolean) { const [processedContent, setProcessedContent] = useState(content); const prevContentRef = useRef(''); const fadeIdRef = useRef(0); useEffect(() => { if (!isStreaming) { // When not streaming, just use the content directly (no animation spans) setProcessedContent(content); prevContentRef.current = content; return; } const prevContent = prevContentRef.current; const prevLength = prevContent.length; const currentLength = content.length; if (currentLength > prevLength) { // New content arrived - wrap it in a fade-in span const existingContent = content.slice(0, prevLength); const newContent = content.slice(prevLength); const fadeId = fadeIdRef.current++; // Wrap new content in a span with fade-in class // Use a unique key to force re-render of the animation const wrappedNew = `${escapeHtml(newContent)}`; setProcessedContent(existingContent + wrappedNew); prevContentRef.current = content; } else if (currentLength < prevLength) { // Content was reset setProcessedContent(content); prevContentRef.current = content; fadeIdRef.current = 0; } }, [content, isStreaming]); // When streaming ends, clean up the spans and show plain content useEffect(() => { if (!isStreaming && content) { setProcessedContent(content); prevContentRef.current = content; } }, [isStreaming, content]); return processedContent; } /** * Escape HTML special characters to prevent XSS */ function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) { const processedContent = useStreamingContent(content, isStreaming); return (
( {children} ), h1: ({ children }) => ( {children} ), h2: ({ children }) => ( {children} ), h3: ({ children }) => ( {children} ), code: ({ className, children }) => { const isInline = !className; if (isInline) { return {children}; } return ( {children} ); }, pre: ({ children }) =>
{children}
, ul: ({ children }) => ( {children} ), ol: ({ children }) => ( {children} ), li: ({ children }) => {children}, a: ({ href, children }) => ( {children} ), blockquote: ({ children }) =>
{children}
, strong: ({ children }) => ( {children} ), em: ({ children }) => ( {children} ), // Allow the fade-in spans to pass through span: ({ className, children }) => {children}, }} > {processedContent}
); }