import type { CitationsMetadata, Message } from '@/api/lightrag' import useTheme from '@/hooks/useTheme' import { cn } from '@/lib/utils' import { type ReactNode, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { remarkFootnotes } from '@/utils/remarkFootnotes' import mermaid from 'mermaid' import ReactMarkdown from 'react-markdown' import rehypeRaw from 'rehype-raw' import rehypeReact from 'rehype-react' import remarkGfm from 'remark-gfm' import remarkMath from 'remark-math' import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' import { oneDark, oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism' import { BrainIcon, ChevronDownIcon, LoaderIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { CitationMarker } from './CitationMarker' // KaTeX configuration options interface interface KaTeXOptions { errorColor?: string throwOnError?: boolean displayMode?: boolean strict?: boolean trust?: boolean errorCallback?: (error: string, latex: string) => void } export type MessageWithError = Message & { id: string // Unique identifier for stable React keys isError?: boolean isThinking?: boolean // Flag to indicate if the message is in a "thinking" state /** * Indicates if the mermaid diagram in this message has been rendered. * Used to persist the rendering state across updates and prevent flickering. */ mermaidRendered?: boolean /** * Indicates if the LaTeX formulas in this message are complete and ready for rendering. * Used to prevent red error text during streaming of incomplete LaTeX formulas. */ latexRendered?: boolean } /** * Helper component to render text with citation markers as interactive HoverCards. * Parses [n] and [n,m] patterns and replaces them with CitationMarker components. */ function TextWithCitations({ children, citationsMetadata, }: { children: ReactNode citationsMetadata?: CitationsMetadata }) { // If no citation metadata or children is not a string, render as-is if (!citationsMetadata || typeof children !== 'string') { return <>{children} } const text = children // Match citation patterns like [1], [2], [1,2], etc. const citationPattern = /\[(\d+(?:,\d+)*)\]/g const parts: ReactNode[] = [] let lastIndex = 0 let match: RegExpExecArray | null let keyIndex = 0 while ((match = citationPattern.exec(text)) !== null) { // Add text before the citation if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)) } // Parse reference IDs from the marker const markerText = match[0] const refIds = match[1].split(',').map((id) => id.trim()) // Find matching marker data for confidence const markerData = citationsMetadata.markers?.find((m) => m.marker === markerText) const confidence = markerData?.confidence ?? 0.5 // Add the citation marker component parts.push( ) lastIndex = match.index + match[0].length } // Add remaining text if (lastIndex < text.length) { parts.push(text.slice(lastIndex)) } // If no citations found, return original text if (parts.length === 0) { return <>{children} } return <>{parts} } // Restore original component definition and export export const ChatMessage = ({ message, isTabActive = true, }: { message: MessageWithError isTabActive?: boolean }) => { const { t } = useTranslation() const { theme } = useTheme() const [katexPlugin, setKatexPlugin] = useState<((options?: KaTeXOptions) => any) | null>(null) const [isThinkingExpanded, setIsThinkingExpanded] = useState(false) // Directly use props passed from the parent. const { thinkingContent, displayContent, thinkingTime, isThinking } = message // Reset expansion state when new thinking starts useEffect(() => { if (isThinking) { // When thinking starts, always reset to collapsed state setIsThinkingExpanded(false) } }, [isThinking, message.id]) // The content to display is now non-ambiguous. const finalThinkingContent = thinkingContent // For user messages, displayContent will be undefined, so we fall back to content. // For assistant messages, we prefer displayContent but fallback to content for backward compatibility const finalDisplayContent = message.role === 'user' ? message.content : displayContent !== undefined ? displayContent : message.content || '' // Load KaTeX rehype plugin dynamically // Note: KaTeX extensions (mhchem, copy-tex) are imported statically in main.tsx useEffect(() => { const loadKaTeX = async () => { try { const { default: rehypeKatex } = await import('rehype-katex') setKatexPlugin(() => rehypeKatex) } catch (error) { console.error('Failed to load KaTeX plugin:', error) setKatexPlugin(null) } } loadKaTeX() }, []) // Get citationsMetadata from message for use in markdown components const citationsMetadata = message.citationsMetadata const mainMarkdownComponents = useMemo( () => ({ code: (props: any) => { const { inline, className, children, ...restProps } = props const match = /language-(\w+)/.exec(className || '') const language = match ? match[1] : undefined // Handle math blocks ($$...$$) - provide better container and styling if (language === 'math' && !inline) { return (
{children}
) } // Handle inline math ($...$) - ensure proper inline display if (language === 'math' && inline) { return ( {children} ) } // Handle all other code (inline and block) return ( {children} ) }, // Custom text renderer that handles citation markers [n] // Transforms plain text [1], [2], [1,2] into interactive CitationMarker components text: ({ children }: { children?: ReactNode }) => ( {children} ), p: ({ children }: { children?: ReactNode }) =>
{children}
, h1: ({ children }: { children?: ReactNode }) => (

{children}

), h2: ({ children }: { children?: ReactNode }) => (

{children}

), h3: ({ children }: { children?: ReactNode }) => (

{children}

), h4: ({ children }: { children?: ReactNode }) => (

{children}

), ul: ({ children }: { children?: ReactNode }) => ( ), ol: ({ children }: { children?: ReactNode }) => (
    {children}
), li: ({ children }: { children?: ReactNode }) =>
  • {children}
  • , }), [message.mermaidRendered, message.role, citationsMetadata] ) const thinkingMarkdownComponents = useMemo( () => ({ code: (props: any) => ( ), }), [message.mermaidRendered, message.role] ) return (
    {/* Thinking Pill - collapsible bubble UI */} {message.role === 'assistant' && (isThinking || thinkingTime !== null) && (
    {/* Pill Header - always visible */} {/* Expandable Content */} {isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (
    {isThinking && (
    {t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
    )} { if (process.env.NODE_ENV === 'development') { console.warn('KaTeX error in thinking:', error, latex) } }, }, ] as any, ] : []), rehypeReact, ]} skipHtml={false} components={thinkingMarkdownComponents} > {finalThinkingContent}
    )}
    )} {/* Main content display */} {finalDisplayContent && (
    .base]:overflow-x-auto [&_sup]:text-[0.75em] [&_sup]:align-[0.1em] [&_sup]:leading-[0] [&_sub]:text-[0.75em] [&_sub]:align-[-0.2em] [&_sub]:leading-[0] [&_mark]:bg-yellow-200 [&_mark]:dark:bg-yellow-800 [&_u]:underline [&_del]:line-through [&_ins]:underline [&_ins]:decoration-green-500 [&_.footnotes]:mt-8 [&_.footnotes]:pt-4 [&_.footnotes]:border-t [&_.footnotes_ol]:text-sm [&_.footnotes_li]:my-1 ${ message.role === 'user' ? 'text-primary-foreground' : 'text-foreground' } ${ message.role === 'user' ? '[&_.footnotes]:border-primary-foreground/30 [&_a[href^="#fn"]]:text-primary-foreground [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary-foreground [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline' : '[&_.footnotes]:border-border [&_a[href^="#fn"]]:text-primary [&_a[href^="#fn"]]:no-underline [&_a[href^="#fn"]]:hover:underline [&_a[href^="#fnref"]]:text-primary [&_a[href^="#fnref"]]:no-underline [&_a[href^="#fnref"]]:hover:underline' }`} remarkPlugins={[remarkGfm, remarkFootnotes, remarkMath]} rehypePlugins={[ rehypeRaw, ...(katexPlugin && (message.latexRendered ?? true) ? [ [ katexPlugin, { errorColor: theme === 'dark' ? '#ef4444' : '#dc2626', throwOnError: false, displayMode: false, strict: false, trust: true, // Add silent error handling to avoid console noise errorCallback: (error: string, latex: string) => { // Only show detailed errors in development environment if (process.env.NODE_ENV === 'development') { console.warn( 'KaTeX rendering error in main content:', error, 'for LaTeX:', latex ) } }, }, ] as any, ] : []), rehypeReact, ]} skipHtml={false} components={mainMarkdownComponents} > {finalDisplayContent}
    )} {/* Loading indicator - only show in active tab */} {isTabActive && (() => { // More comprehensive loading state check const hasVisibleContent = finalDisplayContent && finalDisplayContent.trim() !== '' const isLoadingState = !hasVisibleContent && !isThinking && !thinkingTime return isLoadingState && })()}
    ) } // Remove the incorrect memo export line interface CodeHighlightProps { inline?: boolean className?: string children?: ReactNode renderAsDiagram?: boolean // Flag to indicate if rendering as diagram should be attempted messageRole?: 'user' | 'assistant' // Message role for context-aware styling } // Check if it is a large JSON const isLargeJson = (language: string | undefined, content: string | undefined): boolean => { if (!content || language !== 'json') return false return content.length > 5000 // JSON larger than 5KB is considered large JSON } // Memoize the CodeHighlight component const CodeHighlight = memo( ({ inline, className, children, renderAsDiagram = false, messageRole, ...props }: CodeHighlightProps) => { const { theme } = useTheme() const [hasRendered, setHasRendered] = useState(false) // State to track successful render const match = className?.match(/language-(\w+)/) const language = match ? match[1] : undefined const mermaidRef = useRef(null) const debounceTimerRef = useRef | null>(null) // Use ReturnType for better typing // Get the content string, check if it is a large JSON const contentStr = String(children || '').replace(/\n$/, '') const isLargeJsonBlock = isLargeJson(language, contentStr) // Handle Mermaid rendering with debounce useEffect(() => { // Effect should run when renderAsDiagram becomes true or hasRendered changes. // The actual rendering logic inside checks language and hasRendered state. if (renderAsDiagram && !hasRendered && language === 'mermaid' && mermaidRef.current) { const container = mermaidRef.current // Capture ref value // Clear previous timer if dependencies change before timeout (e.g., renderAsDiagram flips quickly) if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } debounceTimerRef.current = setTimeout(() => { if (!container) return // Container might have unmounted // Double check hasRendered state inside timeout, in case it changed rapidly if (hasRendered) return try { // Initialize mermaid config mermaid.initialize({ startOnLoad: false, theme: theme === 'dark' ? 'dark' : 'default', securityLevel: 'loose', suppressErrorRendering: true, }) // Show loading indicator container.innerHTML = '
    ' // Preprocess mermaid content const rawContent = String(children).replace(/\n$/, '').trim() // Heuristic check for potentially complete graph definition const looksPotentiallyComplete = rawContent.length > 10 && (rawContent.startsWith('graph') || rawContent.startsWith('sequenceDiagram') || rawContent.startsWith('classDiagram') || rawContent.startsWith('stateDiagram') || rawContent.startsWith('gantt') || rawContent.startsWith('pie') || rawContent.startsWith('flowchart') || rawContent.startsWith('erDiagram')) if (!looksPotentiallyComplete) { console.log( 'Mermaid content might be incomplete, skipping render attempt:', rawContent ) // Optionally keep loading indicator or show a message // container.innerHTML = '

    Waiting for complete diagram...

    '; return } const processedContent = rawContent .split('\n') .map((line) => { const trimmedLine = line.trim() if (trimmedLine.startsWith('subgraph')) { const parts = trimmedLine.split(' ') if (parts.length > 1) { const title = parts.slice(1).join(' ').replace(/["']/g, '') return `subgraph "${title}"` } } return trimmedLine }) .filter((line) => !line.trim().startsWith('linkStyle')) .join('\n') const mermaidId = `mermaid-${Date.now()}` mermaid .render(mermaidId, processedContent) .then(({ svg, bindFunctions }) => { // Check ref and hasRendered state again inside async callback if (mermaidRef.current === container && !hasRendered) { container.innerHTML = svg setHasRendered(true) // Mark as rendered successfully if (bindFunctions) { try { bindFunctions(container) } catch (bindError) { console.error('Mermaid bindFunctions error:', bindError) container.innerHTML += '

    Diagram interactions might be limited.

    ' } } } else if (mermaidRef.current !== container) { console.log('Mermaid container changed before rendering completed.') } }) .catch((error) => { console.error('Mermaid rendering promise error (debounced):', error) console.error('Failed content (debounced):', processedContent) if (mermaidRef.current === container) { const errorMessage = error instanceof Error ? error.message : String(error) const errorPre = document.createElement('pre') errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words' errorPre.textContent = `Mermaid diagram error: ${errorMessage}\n\nContent:\n${processedContent}` container.innerHTML = '' container.appendChild(errorPre) } }) } catch (error) { console.error('Mermaid synchronous error (debounced):', error) console.error('Failed content (debounced):', String(children)) if (mermaidRef.current === container) { const errorMessage = error instanceof Error ? error.message : String(error) const errorPre = document.createElement('pre') errorPre.className = 'text-red-500 text-xs whitespace-pre-wrap break-words' errorPre.textContent = `Mermaid diagram setup error: ${errorMessage}` container.innerHTML = '' container.appendChild(errorPre) } } }, 300) // Debounce delay } // Cleanup function to clear the timer on unmount or before re-running effect return () => { if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) } } // Dependencies: renderAsDiagram ensures effect runs when diagram should be shown. // Dependencies include all values used inside the effect to satisfy exhaustive-deps. // The !hasRendered check prevents re-execution of render logic after success. }, [renderAsDiagram, hasRendered, language, children, theme]) // Add children and theme back // For large JSON, skip syntax highlighting completely and use a simple pre tag if (isLargeJsonBlock) { return (
              {contentStr}
            
    ) } // Render based on language type // If it's a mermaid language block and rendering as diagram is not requested (e.g., incomplete stream), display as plain text if (language === 'mermaid' && !renderAsDiagram) { return ( {contentStr} ) } // If it's a mermaid language block and the message is complete, render as diagram if (language === 'mermaid') { // Container for Mermaid diagram return
    } // ReactMarkdown determines inline vs block based on markdown syntax // Inline code: `code` (no className with language) // Block code: ```language (has className like "language-js") // If there's no language className and no explicit inline prop, it's likely inline code const isInline = inline ?? !className?.startsWith('language-') // Generate dynamic inline code styles based on message role and theme const getInlineCodeStyles = () => { if (messageRole === 'user') { // User messages have dark background (bg-primary), need light inline code return theme === 'dark' ? 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30' : 'bg-primary-foreground/20 text-primary-foreground border border-primary-foreground/30' } else { // Assistant messages have light background (bg-muted), need contrasting inline code return theme === 'dark' ? 'bg-muted-foreground/20 text-muted-foreground border border-muted-foreground/30' : 'bg-slate-200 text-slate-800 border border-slate-300' } } // Handle non-Mermaid code blocks return !isInline ? ( {contentStr} ) : ( // Handle inline code with context-aware styling {children} ) } ) // Assign display name for React DevTools CodeHighlight.displayName = 'CodeHighlight'