'use client'; import { useEffect, useRef, useState } from 'react'; import Image from 'next/image'; import ReactMarkdown from 'react-markdown'; import rehypeKatex from 'rehype-katex'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; import { Anchor, Blockquote, Checkbox, Code, Divider, List, Table, Text, Title, } from '@mantine/core'; import { ToolCallDisplay } from './ToolCallDisplay'; import classes from './MarkdownMessage.module.css'; // Import KaTeX CSS for LaTeX rendering import 'katex/dist/katex.min.css'; interface MarkdownMessageProps { content: string; isStreaming?: boolean; } interface ParsedToolCall { toolName: string; args: Record; result: string; } interface ContentSegment { type: 'text' | 'tool'; content?: string; toolCall?: ParsedToolCall; } /** * Parse content to extract tool calls and regular text segments */ function parseContentWithToolCalls(content: string): ContentSegment[] { const segments: ContentSegment[] = []; const toolPattern = /([\s\S]*?)/g; let lastIndex = 0; let match; while ((match = toolPattern.exec(content)) !== null) { // Add text before this tool call if (match.index > lastIndex) { const textBefore = content.slice(lastIndex, match.index).trim(); if (textBefore) { segments.push({ type: 'text', content: textBefore }); } } // Parse the tool call const toolName = match[1]; let args: Record = {}; try { args = JSON.parse(match[2]); } catch { // Invalid JSON, use empty args } const result = match[3].trim(); segments.push({ type: 'tool', toolCall: { toolName, args, result }, }); lastIndex = match.index + match[0].length; } // Add remaining text after last tool call if (lastIndex < content.length) { const remainingText = content.slice(lastIndex).trim(); if (remainingText) { segments.push({ type: 'text', content: remainingText }); } } // If no tool calls found, return the whole content as text if (segments.length === 0 && content.trim()) { segments.push({ type: 'text', content }); } return segments; } /** * 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, '''); } /** * Strip tool call markers from content for streaming display */ function stripToolMarkers(content: string): string { return content.replace(//g, '').replace(//g, ''); } function MarkdownContent({ content }: { content: string }) { return ( ( {children} ), h2: ({ children }) => ( {children} ), h3: ({ children }) => ( {children} ), h4: ({ children }) => ( {children} ), h5: ({ children }) => ( {children} ), h6: ({ children }) => ( {children} ), // Paragraphs and text p: ({ children }) => ( {children} ), strong: ({ children }) => ( {children} ), em: ({ children }) => ( {children} ), del: ({ children }) => ( {children} ), // Code code: ({ className, children }) => { const isInline = !className; if (isInline) { return {children}; } return ( {children} ); }, pre: ({ children }) =>
{children}
, // Lists ul: ({ children, className }) => { // Check if this is a task list (GFM) if (className?.includes('contains-task-list')) { return
    {children}
; } return ( {children} ); }, ol: ({ children }) => ( {children} ), li: ({ children, className }) => { // Task list items have a checkbox if (className?.includes('task-list-item')) { return
  • {children}
  • ; } return {children}; }, input: ({ checked, type }) => { if (type === 'checkbox') { return ; } return null; }, // Links and media a: ({ href, children }) => ( {children} ), img: ({ src, alt }) => { const imgSrc = typeof src === 'string' ? src : ''; return ( {alt ); }, // Block elements blockquote: ({ children }) =>
    {children}
    , hr: () => , // Tables (GFM) table: ({ children }) => ( {children}
    ), thead: ({ children }) => {children}, tbody: ({ children }) => {children}, tr: ({ children }) => {children}, th: ({ children }) => {children}, td: ({ children }) => {children}, // Allow the fade-in spans to pass through span: ({ className, children }) => {children}, }} > {content}
    ); } export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) { const processedContent = useStreamingContent(content, isStreaming); // During streaming, just show the raw content (with tool markers stripped) if (isStreaming) { return (
    ); } // When not streaming, parse and render tool calls const segments = parseContentWithToolCalls(content); return (
    {segments.map((segment, index) => { if (segment.type === 'tool' && segment.toolCall) { return ( ); } return ; })}
    ); }