| import { ReactNode, useEffect, useMemo, useRef, memo, useState } from 'react' |
| import { Message } from '@/api/lightrag' |
| import useTheme from '@/hooks/useTheme' |
| import { cn } from '@/lib/utils' |
|
|
| import ReactMarkdown from 'react-markdown' |
| import remarkGfm from 'remark-gfm' |
| import rehypeReact from 'rehype-react' |
| import rehypeRaw from 'rehype-raw' |
| import remarkMath from 'remark-math' |
| import mermaid from 'mermaid' |
| import { remarkFootnotes } from '@/utils/remarkFootnotes' |
|
|
|
|
| import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' |
| import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' |
|
|
| import { LoaderIcon, ChevronDownIcon } from 'lucide-react' |
| import { useTranslation } from 'react-i18next' |
|
|
| |
| interface KaTeXOptions { |
| errorColor?: string; |
| throwOnError?: boolean; |
| displayMode?: boolean; |
| strict?: boolean; |
| trust?: boolean; |
| errorCallback?: (error: string, latex: string) => void; |
| } |
|
|
| export type MessageWithError = Message & { |
| id: string |
| isError?: boolean |
| isThinking?: boolean |
| |
| |
| |
| |
| mermaidRendered?: boolean |
| |
| |
| |
| |
| latexRendered?: boolean |
| } |
|
|
| |
| 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<boolean>(false) |
|
|
| |
| const { thinkingContent, displayContent, thinkingTime, isThinking } = message |
|
|
| |
| useEffect(() => { |
| if (isThinking) { |
| |
| setIsThinkingExpanded(false) |
| } |
| }, [isThinking, message.id]) |
|
|
| |
| const finalThinkingContent = thinkingContent |
| |
| |
| const finalDisplayContent = message.role === 'user' |
| ? message.content |
| : (displayContent !== undefined ? displayContent : (message.content || '')) |
|
|
| |
| |
| 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(); |
| }, []); |
|
|
| const mainMarkdownComponents = useMemo(() => ({ |
| code: (props: any) => { |
| const { inline, className, children, ...restProps } = props; |
| const match = /language-(\w+)/.exec(className || ''); |
| const language = match ? match[1] : undefined; |
|
|
| |
| if (language === 'math' && !inline) { |
| return ( |
| <div className="katex-display-wrapper my-4 overflow-x-auto"> |
| <div className="text-current">{children}</div> |
| </div> |
| ); |
| } |
|
|
| |
| if (language === 'math' && inline) { |
| return ( |
| <span className="katex-inline-wrapper"> |
| <span className="text-current">{children}</span> |
| </span> |
| ); |
| } |
|
|
| |
| return ( |
| <CodeHighlight |
| inline={inline} |
| className={className} |
| {...restProps} |
| renderAsDiagram={message.mermaidRendered ?? false} |
| messageRole={message.role} |
| > |
| {children} |
| </CodeHighlight> |
| ); |
| }, |
| p: ({ children }: { children?: ReactNode }) => <div className="my-2">{children}</div>, |
| h1: ({ children }: { children?: ReactNode }) => <h1 className="text-xl font-bold mt-4 mb-2">{children}</h1>, |
| h2: ({ children }: { children?: ReactNode }) => <h2 className="text-lg font-bold mt-4 mb-2">{children}</h2>, |
| h3: ({ children }: { children?: ReactNode }) => <h3 className="text-base font-bold mt-3 mb-2">{children}</h3>, |
| h4: ({ children }: { children?: ReactNode }) => <h4 className="text-base font-semibold mt-3 mb-2">{children}</h4>, |
| ul: ({ children }: { children?: ReactNode }) => <ul className="list-disc pl-5 my-2">{children}</ul>, |
| ol: ({ children }: { children?: ReactNode }) => <ol className="list-decimal pl-5 my-2">{children}</ol>, |
| li: ({ children }: { children?: ReactNode }) => <li className="my-1">{children}</li> |
| }), [message.mermaidRendered, message.role]); |
|
|
| const thinkingMarkdownComponents = useMemo(() => ({ |
| code: (props: any) => (<CodeHighlight {...props} renderAsDiagram={message.mermaidRendered ?? false} messageRole={message.role} />) |
| }), [message.mermaidRendered, message.role]); |
|
|
| return ( |
| <div |
| className={`${ |
| message.role === 'user' |
| ? 'max-w-[80%] bg-primary text-primary-foreground' |
| : message.isError |
| ? 'w-[95%] bg-red-100 text-red-600 dark:bg-red-950 dark:text-red-400' |
| : 'w-[95%] bg-muted' |
| } rounded-lg px-4 py-2`} |
| > |
| {/* Thinking process display - only for assistant messages */} |
| {/* Always render to prevent layout shift when switching tabs */} |
| {message.role === 'assistant' && (isThinking || thinkingTime !== null) && ( |
| <div className={cn( |
| 'mb-2', |
| // Reduce visual priority in inactive tabs while maintaining layout |
| !isTabActive && 'opacity-50' |
| )}> |
| <div |
| className="flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-200 text-sm cursor-pointer select-none" |
| onClick={() => { |
| // Allow expansion when there's thinking content, even during thinking process |
| if (finalThinkingContent && finalThinkingContent.trim() !== '') { |
| setIsThinkingExpanded(!isThinkingExpanded) |
| } |
| }} |
| > |
| {isThinking ? ( |
| <> |
| {/* Only show spinner animation in active tab to save resources */} |
| {isTabActive && <LoaderIcon className="mr-2 size-4 animate-spin" />} |
| <span>{t('retrievePanel.chatMessage.thinking')}</span> |
| </> |
| ) : ( |
| typeof thinkingTime === 'number' && <span>{t('retrievePanel.chatMessage.thinkingTime', { time: thinkingTime })}</span> |
| )} |
| {/* Show chevron when there's thinking content, even during thinking process */} |
| {finalThinkingContent && finalThinkingContent.trim() !== '' && <ChevronDownIcon className={`ml-2 size-4 shrink-0 transition-transform ${isThinkingExpanded ? 'rotate-180' : ''}`} />} |
| </div> |
| {/* Show thinking content when expanded and content exists, even during thinking process */} |
| {isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && ( |
| <div className="mt-2 pl-4 border-l-2 border-primary/20 dark:border-primary/40 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2 [&_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-6 [&_.footnotes]:pt-3 [&_.footnotes]:border-t [&_.footnotes]:border-border [&_.footnotes_ol]:text-xs [&_.footnotes_li]:my-0.5 [&_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 text-foreground"> |
| {isThinking && ( |
| <div className="mb-2 text-xs text-gray-400 dark:text-gray-300 italic"> |
| {t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')} |
| </div> |
| )} |
| <ReactMarkdown |
| 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 thinking content:', error, 'for LaTeX:', latex); |
| } |
| } |
| }] as any] : []), |
| rehypeReact |
| ]} |
| skipHtml={false} |
| components={thinkingMarkdownComponents} |
| > |
| {finalThinkingContent} |
| </ReactMarkdown> |
| </div> |
| )} |
| </div> |
| )} |
| {} |
| {finalDisplayContent && ( |
| <div className="relative"> |
| <div className={`prose dark:prose-invert max-w-none text-sm break-words prose-headings:mt-4 prose-headings:mb-2 prose-p:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 [&_.katex]:text-current [&_.katex-display]:my-4 [&_.katex-display]:max-w-full [&_.katex-display_>.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' |
| }`}> |
| <ReactMarkdown |
| 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} |
| </ReactMarkdown> |
| </div> |
| </div> |
| )} |
| {} |
| {isTabActive && (() => { |
| |
| const hasVisibleContent = finalDisplayContent && finalDisplayContent.trim() !== ''; |
| const isLoadingState = !hasVisibleContent && !isThinking && !thinkingTime; |
| return isLoadingState && <LoaderIcon className="animate-spin duration-2000" /> |
| })()} |
| </div> |
| ) |
| } |
|
|
| |
|
|
| interface CodeHighlightProps { |
| inline?: boolean |
| className?: string |
| children?: ReactNode |
| renderAsDiagram?: boolean |
| messageRole?: 'user' | 'assistant' |
| } |
|
|
|
|
|
|
| |
| const isLargeJson = (language: string | undefined, content: string | undefined): boolean => { |
| if (!content || language !== 'json') return false; |
| return content.length > 5000; |
| }; |
|
|
| |
| const CodeHighlight = memo(({ inline, className, children, renderAsDiagram = false, messageRole, ...props }: CodeHighlightProps) => { |
| const { theme } = useTheme(); |
| const [hasRendered, setHasRendered] = useState(false); |
| const match = className?.match(/language-(\w+)/); |
| const language = match ? match[1] : undefined; |
| const mermaidRef = useRef<HTMLDivElement>(null); |
| const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); |
|
|
| |
| const contentStr = String(children || '').replace(/\n$/, ''); |
| const isLargeJsonBlock = isLargeJson(language, contentStr); |
|
|
| |
| useEffect(() => { |
| |
| |
| if (renderAsDiagram && !hasRendered && language === 'mermaid' && mermaidRef.current) { |
| const container = mermaidRef.current; |
|
|
| |
| if (debounceTimerRef.current) { |
| clearTimeout(debounceTimerRef.current); |
| } |
|
|
| debounceTimerRef.current = setTimeout(() => { |
| if (!container) return; |
|
|
| |
| if (hasRendered) return; |
|
|
| try { |
| |
| mermaid.initialize({ |
| startOnLoad: false, |
| theme: theme === 'dark' ? 'dark' : 'default', |
| securityLevel: 'loose', |
| suppressErrorRendering: true, |
| }); |
|
|
| |
| container.innerHTML = '<div class="flex justify-center items-center p-4"><svg class="animate-spin h-5 w-5 text-primary" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>'; |
|
|
| |
| const rawContent = String(children).replace(/\n$/, '').trim(); |
|
|
| |
| 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); |
| |
| |
| 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 }) => { |
| |
| if (mermaidRef.current === container && !hasRendered) { |
| container.innerHTML = svg; |
| setHasRendered(true); |
| if (bindFunctions) { |
| try { |
| bindFunctions(container); |
| } catch (bindError) { |
| console.error('Mermaid bindFunctions error:', bindError); |
| container.innerHTML += '<p class="text-orange-500 text-xs">Diagram interactions might be limited.</p>'; |
| } |
| } |
| } 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); |
| } |
|
|
| |
| return () => { |
| if (debounceTimerRef.current) { |
| clearTimeout(debounceTimerRef.current); |
| } |
| }; |
| |
| |
| |
| }, [renderAsDiagram, hasRendered, language, children, theme]); |
|
|
| |
| if (isLargeJsonBlock) { |
| return ( |
| <pre className="whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono"> |
| {contentStr} |
| </pre> |
| ); |
| } |
|
|
| |
| |
| if (language === 'mermaid' && !renderAsDiagram) { |
| return ( |
| <SyntaxHighlighter |
| style={theme === 'dark' ? oneDark : oneLight} |
| PreTag="div" |
| language="text" // Use text as language to avoid syntax highlighting errors |
| {...props} |
| > |
| {contentStr} |
| </SyntaxHighlighter> |
| ); |
| } |
|
|
| |
| if (language === 'mermaid') { |
| |
| return <div className="mermaid-diagram-container my-4 overflow-x-auto" ref={mermaidRef}></div>; |
| } |
|
|
|
|
| |
| |
| |
| |
| const isInline = inline ?? !className?.startsWith('language-'); |
|
|
| |
| const getInlineCodeStyles = () => { |
| if (messageRole === 'user') { |
| |
| 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 { |
| |
| 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'; |
| } |
| }; |
|
|
| |
| return !isInline ? ( |
| <SyntaxHighlighter |
| style={theme === 'dark' ? oneDark : oneLight} |
| PreTag="div" |
| language={language} |
| {...props} |
| > |
| {contentStr} |
| </SyntaxHighlighter> |
| ) : ( |
| |
| <code |
| className={cn( |
| className, |
| 'mx-1 rounded-sm px-1 py-0.5 font-mono text-sm', |
| getInlineCodeStyles() |
| )} |
| {...props} |
| > |
| {children} |
| </code> |
| ); |
| }); |
|
|
| |
| CodeHighlight.displayName = 'CodeHighlight'; |
|
|