|
import { ReactNode, useCallback, useEffect, useMemo, useRef, memo, useState } from 'react' |
|
import { Message } from '@/api/lightrag' |
|
import useTheme from '@/hooks/useTheme' |
|
import Button from '@/components/ui/Button' |
|
import { cn } from '@/lib/utils' |
|
|
|
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' |
|
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' |
|
import { oneLight, oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism' |
|
|
|
import { LoaderIcon, CopyIcon } from 'lucide-react' |
|
import { useTranslation } from 'react-i18next' |
|
|
|
export type MessageWithError = Message & { |
|
id: string |
|
isError?: boolean |
|
|
|
|
|
|
|
|
|
mermaidRendered?: boolean |
|
} |
|
|
|
|
|
export const ChatMessage = ({ message }: { message: MessageWithError }) => { |
|
const { t } = useTranslation() |
|
const { theme } = useTheme() |
|
const [katexPlugin, setKatexPlugin] = useState<any>(null) |
|
|
|
|
|
useEffect(() => { |
|
const loadKaTeX = async () => { |
|
try { |
|
const [{ default: rehypeKatex }] = await Promise.all([ |
|
import('rehype-katex'), |
|
import('katex/dist/katex.min.css') |
|
]) |
|
setKatexPlugin(() => rehypeKatex) |
|
} catch (error) { |
|
console.error('Failed to load KaTeX:', error) |
|
} |
|
} |
|
loadKaTeX() |
|
}, []) |
|
const handleCopyMarkdown = useCallback(async () => { |
|
if (message.content) { |
|
try { |
|
await navigator.clipboard.writeText(message.content) |
|
} catch (err) { |
|
console.error(t('chat.copyError'), err) |
|
} |
|
} |
|
}, [message, t]) |
|
|
|
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`} |
|
> |
|
<div className="relative"> |
|
<ReactMarkdown |
|
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]:overflow-x-auto" |
|
remarkPlugins={[remarkGfm, remarkMath]} |
|
rehypePlugins={[ |
|
...(katexPlugin ? [[ |
|
katexPlugin, |
|
{ |
|
errorColor: theme === 'dark' ? '#ef4444' : '#dc2626', |
|
throwOnError: false, |
|
displayMode: false |
|
} |
|
] as any] : []), |
|
rehypeReact |
|
]} |
|
skipHtml={false} |
|
// Memoize the components object to prevent unnecessary re-renders of ReactMarkdown children |
|
components={useMemo(() => ({ |
|
code: (props: any) => ( // Add type annotation if needed, e.g., props: CodeProps from 'react-markdown/lib/ast-to-react' |
|
<CodeHighlight |
|
{...props} |
|
renderAsDiagram={message.mermaidRendered ?? false} |
|
/> |
|
), |
|
p: ({ children }: { children?: ReactNode }) => <p className="my-2">{children}</p>, |
|
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])} // Dependency ensures update if mermaid state changes |
|
> |
|
{message.content} |
|
</ReactMarkdown> |
|
{message.role === 'assistant' && message.content && message.content.length > 0 && ( // Added check for message.content existence |
|
<Button |
|
onClick={handleCopyMarkdown} |
|
className="absolute right-0 bottom-0 size-6 rounded-md opacity-20 transition-opacity hover:opacity-100" |
|
tooltip={t('retrievePanel.chatMessage.copyTooltip')} |
|
variant="default" |
|
size="icon" |
|
> |
|
<CopyIcon className="size-4" /> {/* Explicit size */} |
|
</Button> |
|
)} |
|
</div> |
|
{message.content === '' && <LoaderIcon className="animate-spin duration-2000" />} {/* Check for empty string specifically */} |
|
</div> |
|
) |
|
} |
|
|
|
|
|
|
|
interface CodeHighlightProps { |
|
inline?: boolean |
|
className?: string |
|
children?: ReactNode |
|
node?: Element |
|
renderAsDiagram?: boolean |
|
} |
|
|
|
|
|
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(''); |
|
|
|
return !textContent.includes('\n') || textContent.length < 40; |
|
}; |
|
|
|
|
|
|
|
const isLargeJson = (language: string | undefined, content: string | undefined): boolean => { |
|
if (!content || language !== 'json') return false; |
|
return content.length > 5000; |
|
}; |
|
|
|
|
|
const CodeHighlight = memo(({ className, children, node, renderAsDiagram = false, ...props }: CodeHighlightProps) => { |
|
const { theme } = useTheme(); |
|
const [hasRendered, setHasRendered] = useState(false); |
|
const match = className?.match(/language-(\w+)/); |
|
const language = match ? match[1] : undefined; |
|
const inline = isInlineCode(node); |
|
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>; |
|
} |
|
|
|
|
|
|
|
return !inline ? ( |
|
<SyntaxHighlighter |
|
style={theme === 'dark' ? oneDark : oneLight} |
|
PreTag="div" // Use div for block code |
|
language={language} |
|
{...props} |
|
> |
|
{contentStr} |
|
</SyntaxHighlighter> |
|
) : ( |
|
|
|
<code |
|
className={cn(className, 'mx-1 rounded-sm bg-muted px-1 py-0.5 font-mono text-sm')} // Add font-mono to ensure monospaced font is used |
|
{...props} |
|
> |
|
{children} |
|
</code> |
|
); |
|
}); |
|
|
|
|
|
CodeHighlight.displayName = 'CodeHighlight'; |
|
|