diff --git a/lightrag_webui/src/components/retrieval/ChatMessage.tsx b/lightrag_webui/src/components/retrieval/ChatMessage.tsx index 53f1d2bc..a55d244a 100644 --- a/lightrag_webui/src/components/retrieval/ChatMessage.tsx +++ b/lightrag_webui/src/components/retrieval/ChatMessage.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useCallback } from 'react' +import { ReactNode, useCallback, useEffect, useState, useRef } from 'react' import { Message } from '@/api/lightrag' import useTheme from '@/hooks/useTheme' import Button from '@/components/ui/Button' @@ -8,6 +8,7 @@ import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' import rehypeReact from 'rehype-react' import remarkMath from 'remark-math' +import mermaid from 'mermaid' import type { Element } from 'hast' @@ -32,7 +33,7 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { console.error(t('chat.copyError'), err) } } - }, [message]) + }, [message, t]) // Added t to dependency array return (
{ >
          {
         >
           {message.content}
         
-        {message.role === 'assistant' && message.content.length > 0 && (
+        {message.role === 'assistant' && message.content && message.content.length > 0 && ( // Added check for message.content existence
           
         )}
       
- {message.content.length === 0 && } + {message.content === '' && } {/* Check for empty string specifically */}
) } @@ -77,39 +78,178 @@ interface CodeHighlightProps { inline?: boolean className?: string children?: ReactNode - node?: Element + node?: Element // Keep node for inline check } -const isInlineCode = (node: Element): boolean => { - const textContent = (node.children || []) +// Helper function remains the same +const isInlineCode = (node?: Element): boolean => { + if (!node || !node.children) return false; + const textContent = node.children .filter((child) => child.type === 'text') .map((child) => (child as any).value) - .join('') + .join(''); + // Consider inline if it doesn't contain newline or is very short + return !textContent.includes('\n') || textContent.length < 40; +}; - return !textContent.includes('\n') -} const CodeHighlight = ({ className, children, node, ...props }: CodeHighlightProps) => { - const { theme } = useTheme() - const match = className?.match(/language-(\w+)/) - const language = match ? match[1] : undefined - const inline = node ? isInlineCode(node) : false + const { theme } = useTheme(); + const match = className?.match(/language-(\w+)/); + const language = match ? match[1] : undefined; + const inline = isInlineCode(node); // Use the helper function + const mermaidRef = useRef(null); + const debounceTimerRef = useRef | null>(null); // Use ReturnType for better typing + // Handle Mermaid rendering with debounce + useEffect(() => { + // Clear any existing timer when dependencies change + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + + if (language === 'mermaid' && mermaidRef.current) { + const container = mermaidRef.current; // Capture ref value for use inside timeout/callbacks + + // Set a new timer to render after a short delay + debounceTimerRef.current = setTimeout(() => { + // Ensure container still exists when timer fires + if (!container) return; + + try { + // Initialize mermaid config (safe to call multiple times) + mermaid.initialize({ + startOnLoad: false, + theme: theme === 'dark' ? 'dark' : 'default', + securityLevel: 'loose', + }); + + // Show loading indicator while processing + container.innerHTML = '
'; + + // Preprocess mermaid content + const rawContent = String(children).replace(/\n$/, '').trim(); // Trim whitespace as well + + // Heuristic check for potentially complete graph definition + // Looks for graph type declaration and some content beyond it. + 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); + // Keep loading indicator or show a message + // container.innerHTML = '

Waiting for complete diagram...

'; + return; // Don't attempt to render potentially incomplete content + } + + const processedContent = rawContent + .split('\n') + .map(line => { + const trimmedLine = line.trim(); + // Keep subgraph processing + 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')) // Keep filtering linkStyle + .join('\n'); + + console.log("Rendering Mermaid with debounced, filtered content:", processedContent); + + const mermaidId = `mermaid-${Date.now()}`; + mermaid.render(mermaidId, processedContent) + .then(({ svg, bindFunctions }) => { + // Check ref again inside async callback + // Ensure the container is still the one we intended to update + if (mermaidRef.current === container) { + container.innerHTML = svg; + if (bindFunctions) { + try { // Add try-catch around bindFunctions as it can also throw + bindFunctions(container); + } catch (bindError) { + console.error('Mermaid bindFunctions error:', bindError); + // Optionally display a message in the container + container.innerHTML += `

Diagram interactions might be limited.

`; + } + } + } else { + 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); + // Make error display more robust + 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 = ''; // Clear previous content + 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 = ''; // Clear previous content + container.appendChild(errorPre); + } + } + }, 300); // 300ms debounce delay + } + + // Cleanup function to clear the timer + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, [language, children, theme]); // Dependencies + + // Render based on language type + if (language === 'mermaid') { + // Container for Mermaid diagram + return
; + } + + // Handle non-Mermaid code blocks return !inline ? ( {String(children).replace(/\n$/, '')} ) : ( + // Handle inline code {children} - ) -} + ); +};