'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 { ThinkingBlock } from './ThinkingBlock'; import { ToolCall, ToolCallGroup } 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' | 'thinking'; content?: string; toolCall?: ParsedToolCall; } /** * Parse content to extract thinking blocks, tool calls, and regular text segments */ function parseContentWithToolCalls(content: string): ContentSegment[] { const segments: ContentSegment[] = []; // Combined pattern for both thinking blocks and tool calls // This ensures we parse them in the order they appear const combinedPattern = /(([\s\S]*?)<\/think>)|([\s\S]*?)/g; let lastIndex = 0; let match; while ((match = combinedPattern.exec(content)) !== null) { // Add text before this match if (match.index > lastIndex) { const textBefore = content.slice(lastIndex, match.index).trim(); if (textBefore) { segments.push({ type: 'text', content: textBefore }); } } if (match[1]) { // This is a thinking block const thinkingContent = match[2].trim(); if (thinkingContent) { segments.push({ type: 'thinking', content: thinkingContent }); } } else if (match[3]) { // This is a tool call const toolName = match[3]; let args: Record = {}; try { args = JSON.parse(match[4]); } catch { // Invalid JSON, use empty args } const result = match[5].trim(); segments.push({ type: 'tool', toolCall: { toolName, args, result }, }); } lastIndex = match.index + match[0].length; } // Add remaining text after last match if (lastIndex < content.length) { const remainingText = content.slice(lastIndex).trim(); if (remainingText) { segments.push({ type: 'text', content: remainingText }); } } // If no special blocks found, return the whole content as text if (segments.length === 0 && content.trim()) { segments.push({ type: 'text', content }); } return segments; } /** * Grouped segment type - text, thinking block, or group of consecutive tool calls */ interface GroupedSegment { type: 'text' | 'toolGroup' | 'thinking'; content?: string; toolCalls?: ToolCall[]; } /** * Group consecutive tool call segments together, keep thinking blocks separate */ function groupConsecutiveToolCalls(segments: ContentSegment[]): GroupedSegment[] { const grouped: GroupedSegment[] = []; let currentToolGroup: ToolCall[] = []; for (const segment of segments) { if (segment.type === 'tool' && segment.toolCall) { // Add to current tool group currentToolGroup.push(segment.toolCall); } else { // Flush any pending tool group before adding other segment if (currentToolGroup.length > 0) { grouped.push({ type: 'toolGroup', toolCalls: currentToolGroup }); currentToolGroup = []; } if (segment.type === 'thinking' && segment.content) { // Add thinking block as-is grouped.push({ type: 'thinking', content: segment.content }); } else if (segment.content) { // Add text segment grouped.push({ type: 'text', content: segment.content }); } } } // Flush any remaining tool group if (currentToolGroup.length > 0) { grouped.push({ type: 'toolGroup', toolCalls: currentToolGroup }); } return grouped; } /** * 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 and thinking markers from content for streaming display */ function stripToolMarkers(content: string): string { return content .replace(//g, '') .replace(//g, '') .replace(/<\/?think>/g, ''); // Remove think tags but keep content visible during streaming } 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 (grouped) const segments = parseContentWithToolCalls(content); const groupedSegments = groupConsecutiveToolCalls(segments); return (
    {groupedSegments.map((segment, index) => { if (segment.type === 'thinking' && segment.content) { return ; } if (segment.type === 'toolGroup' && segment.toolCalls) { return ; } return ; })}
    ); }