'use client'; 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; } /** * Parsed segment with streaming state */ interface ParsedSegment { type: 'text' | 'thinking' | 'toolGroup'; content?: string; toolCalls?: ToolCall[]; isStreaming?: boolean; // True if this segment is still being streamed } /** * Clean text content by removing text-based tool call patterns */ function cleanTextContent(text: string): string { return text .replace(/\w+\[ARGS\]\{[^}]*\}/g, '') // Remove tool_name[ARGS]{...} patterns .replace(/[\s\S]*?<\/tool_call>/g, '') // Remove ... .replace(/\{[\s\S]*?"(?:tool|function)"[\s\S]*?\}/g, '') // Remove JSON tool objects .replace(/\n{3,}/g, '\n\n') // Collapse multiple newlines .trim(); } /** * Parse content into segments, handling both complete and incomplete (streaming) blocks */ function parseContentRealtime(content: string, isStreaming: boolean): ParsedSegment[] { const segments: ParsedSegment[] = []; // Check for incomplete thinking block (opened but not closed) const hasOpenThink = content.includes(''); const hasCloseThink = content.includes(''); const isThinkingIncomplete = hasOpenThink && !hasCloseThink; // Check for incomplete tool call const hasOpenTool = content.includes(''); const lastToolStartIndex = content.lastIndexOf('([\s\S]*?)/g; let lastIndex = 0; let match; let currentToolGroup: ToolCall[] = []; // Helper to flush tool group const flushToolGroup = (streaming = false) => { if (currentToolGroup.length > 0) { segments.push({ type: 'toolGroup', toolCalls: [...currentToolGroup], isStreaming: streaming, }); currentToolGroup = []; } }; // Helper to add text segment const addTextSegment = (text: string, streaming = false) => { const cleaned = cleanTextContent(text); if (cleaned) { flushToolGroup(); // Flush any pending tools before text segments.push({ type: 'text', content: cleaned, isStreaming: streaming }); } }; while ((match = combinedPattern.exec(content)) !== null) { // Add text before this match if (match.index > lastIndex) { addTextSegment(content.slice(lastIndex, match.index)); } if (match[1]) { // Complete thinking block flushToolGroup(); const thinkingContent = match[2].trim(); if (thinkingContent) { segments.push({ type: 'thinking', content: thinkingContent, isStreaming: false }); } } else if (match[3]) { // Complete tool call - add to current group const toolName = match[3]; let args: Record = {}; try { args = JSON.parse(match[4]); } catch { // Invalid JSON, use empty args } const result = match[5].trim(); currentToolGroup.push({ toolName, args, result }); } lastIndex = match.index + match[0].length; } // Handle remaining content after last complete match const remaining = content.slice(lastIndex); if (isThinkingIncomplete) { // We have an open tag - extract the thinking content flushToolGroup(); const thinkStartIndex = remaining.indexOf(''); if (thinkStartIndex !== -1) { // Text before const textBefore = remaining.slice(0, thinkStartIndex); addTextSegment(textBefore); // Thinking content (everything after ) const thinkingContent = remaining.slice(thinkStartIndex + 7).trim(); if (thinkingContent) { segments.push({ type: 'thinking', content: thinkingContent, isStreaming: true }); } else { // Empty thinking block that just started segments.push({ type: 'thinking', content: '', isStreaming: true }); } } } else if (isToolIncomplete) { // We have an incomplete tool call // First, add any complete text before the incomplete tool const toolStartMatch = remaining.match(//); if (toolStartMatch && toolStartMatch.index !== undefined) { const textBefore = remaining.slice(0, toolStartMatch.index); addTextSegment(textBefore); // Parse the incomplete tool call const toolName = toolStartMatch[1]; let args: Record = {}; try { args = JSON.parse(toolStartMatch[2]); } catch { // Invalid JSON } // Result is everything after the start marker (still streaming) const resultStart = toolStartMatch.index + toolStartMatch[0].length; const partialResult = remaining.slice(resultStart).trim(); currentToolGroup.push({ toolName, args, result: partialResult || 'Loading...' }); flushToolGroup(true); // Mark as streaming } else { addTextSegment(remaining, isStreaming); } } else { // No incomplete blocks - just add remaining text addTextSegment(remaining, isStreaming && remaining.length > 0); } // Flush any remaining tool group flushToolGroup(); return segments; } function MarkdownContent({ content }: { content: string }) { if (!content.trim()) { return null; } 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 custom spans to pass through span: ({ className, children }) => {children}, }} > {content}
    ); } export function MarkdownMessage({ content, isStreaming = false }: MarkdownMessageProps) { // Parse content into segments (works for both streaming and complete content) const segments = parseContentRealtime(content, isStreaming); // If no segments, show nothing if (segments.length === 0) { return null; } return (
    {segments.map((segment, index) => { if (segment.type === 'thinking') { return ( ); } if (segment.type === 'toolGroup' && segment.toolCalls) { return ( ); } return ; })}
    ); }