File size: 14,852 Bytes
d8300f7 9c49953 07eaad0 9c49953 245987a 8fa35c7 9c49953 77ca676 9c49953 d8300f7 9c49953 d8300f7 9c49953 d8300f7 77ca676 6b724e4 c6c74f0 9c49953 77ca676 9c49953 245987a 9c49953 ca74585 9c49953 ca74585 9c49953 4fe4fcf ca74585 9c49953 1f688ca 9c49953 6b724e4 9c49953 6b724e4 c6c74f0 6b724e4 9c49953 d8300f7 9c49953 245987a 9c49953 77ca676 9c49953 245987a 9c49953 1f688ca 245987a 9c49953 d8300f7 9c49953 8fa35c7 245987a d8300f7 8fa35c7 245987a 8fa35c7 245987a 8fa35c7 9c49953 5293b08 d8300f7 245987a d8300f7 245987a 5293b08 245987a d8300f7 245987a d8300f7 245987a d8300f7 245987a d8300f7 245987a 67607c4 245987a d8300f7 245987a d8300f7 245987a 87eb497 d8300f7 87eb497 d8300f7 245987a 9c49953 245987a d8300f7 245987a d8300f7 245987a d8300f7 245987a d8300f7 245987a 87eb497 245987a d8300f7 87eb497 245987a d8300f7 245987a 87eb497 d8300f7 87eb497 245987a d8300f7 245987a d8300f7 245987a d8300f7 5f7b577 245987a 5293b08 245987a d8300f7 ac8ad08 5293b08 ac8ad08 245987a 5293b08 245987a 9c49953 8fa35c7 245987a 9c49953 5293b08 8fa35c7 9c49953 245987a 07eaad0 5293b08 07eaad0 9c49953 245987a d8300f7 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 |
import { ReactNode, useCallback, useEffect, useMemo, useRef, memo, useState } from 'react' // Import useMemo
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 // Unique identifier for stable React keys
isError?: boolean
/**
* Indicates if the mermaid diagram in this message has been rendered.
* Used to persist the rendering state across updates and prevent flickering.
*/
mermaidRendered?: boolean
}
// Restore original component definition and export
export const ChatMessage = ({ message }: { message: MessageWithError }) => { // Remove isComplete prop
const { t } = useTranslation()
const { theme } = useTheme()
const [katexPlugin, setKatexPlugin] = useState<any>(null)
// Load KaTeX dynamically
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]) // Added t to dependency array
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>
)
}
// Remove the incorrect memo export line
interface CodeHighlightProps {
inline?: boolean
className?: string
children?: ReactNode
node?: Element // Keep node for inline check
renderAsDiagram?: boolean // Flag to indicate if rendering as diagram should be attempted
}
// 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('');
// Consider inline if it doesn't contain newline or is very short
return !textContent.includes('\n') || textContent.length < 40;
};
// 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(({ className, children, node, renderAsDiagram = false, ...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 inline = isInlineCode(node); // Use the helper function
const mermaidRef = useRef<HTMLDivElement>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | 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 = '<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>';
// 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 = '<p class="text-sm text-muted-foreground">Waiting for complete diagram...</p>';
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 += '<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); // 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 (
<pre className="whitespace-pre-wrap break-words bg-muted p-4 rounded-md overflow-x-auto text-sm font-mono">
{contentStr}
</pre>
);
}
// 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 (
<SyntaxHighlighter
style={theme === 'dark' ? oneDark : oneLight}
PreTag="div"
language="text" // Use text as language to avoid syntax highlighting errors
{...props}
>
{contentStr}
</SyntaxHighlighter>
);
}
// If it's a mermaid language block and the message is complete, render as diagram
if (language === 'mermaid') {
// Container for Mermaid diagram
return <div className="mermaid-diagram-container my-4 overflow-x-auto" ref={mermaidRef}></div>;
}
// Handle non-Mermaid code blocks
return !inline ? (
<SyntaxHighlighter
style={theme === 'dark' ? oneDark : oneLight}
PreTag="div" // Use div for block code
language={language}
{...props}
>
{contentStr}
</SyntaxHighlighter>
) : (
// Handle inline code
<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>
);
});
// Assign display name for React DevTools
CodeHighlight.displayName = 'CodeHighlight';
|