mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-23 19:04:10 +01:00
📊 feat: Render Inline Mermaid Diagrams (#11112)
* chore: add mermaid, swr, ts-md5 packages * WIP: first pass, inline mermaid * feat: Enhance Mermaid component with zoom, pan, and error handling features * feat: Update Mermaid component styles for improved UI consistency * feat: Improve Mermaid rendering with enhanced debouncing and error handling * refactor: Update Mermaid component styles and enhance error handling in useMermaid hook * feat: Enhance security settings in useMermaid configuration to prevent DoS attacks * feat: Add dialog for expanded Mermaid view with zoom and pan controls * feat: Implement auto-scroll for streaming code in Mermaid component * feat: Replace loading spinner with reusable Spinner component in Mermaid * feat: Sanitize SVG output in useMermaid to enhance security * feat: Enhance SVG sanitization in useMermaid to support additional elements for text rendering * refactor: Enhance initial content check in useDebouncedMermaid for improved rendering logic * feat: Refactor Mermaid component to use Button component and enhance focus management for code toggling and copying * chore: remove unused key * refactor: initial content check in useDebouncedMermaid to detect significant content changes
This commit is contained in:
parent
43c2c20dd7
commit
3503b7caeb
10 changed files with 2321 additions and 9 deletions
2
client/src/hooks/Mermaid/index.ts
Normal file
2
client/src/hooks/Mermaid/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useMermaid, default } from './useMermaid';
|
||||
export { useDebouncedMermaid } from './useDebouncedMermaid';
|
||||
204
client/src/hooks/Mermaid/useDebouncedMermaid.ts
Normal file
204
client/src/hooks/Mermaid/useDebouncedMermaid.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useMermaid } from './useMermaid';
|
||||
|
||||
/**
|
||||
* Detect if mermaid content is likely incomplete (still streaming)
|
||||
*/
|
||||
const isLikelyStreaming = (content: string): boolean => {
|
||||
if (content.length < 15) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const incompletePatterns = [
|
||||
/\[\s*$/, // Ends with opening bracket: "A["
|
||||
/--+$/, // Ends with arrows: "A--"
|
||||
/>>+$/, // Ends with sequence arrow: "A>>"
|
||||
/-\|$/, // Ends with arrow: "A-|"
|
||||
/\|\s*$/, // Ends with pipe: "A|"
|
||||
/^\s*graph\s+[A-Z]*$/, // Just "graph TD" or "graph"
|
||||
/^\s*sequenceDiagram\s*$/, // Just "sequenceDiagram"
|
||||
/^\s*flowchart\s+[A-Z]*$/, // Just "flowchart TD"
|
||||
/^\s*classDiagram\s*$/, // Just "classDiagram"
|
||||
/^\s*stateDiagram\s*$/, // Just "stateDiagram"
|
||||
/^\s*erDiagram\s*$/, // Just "erDiagram"
|
||||
/^\s*gantt\s*$/, // Just "gantt"
|
||||
/^\s*pie\s*$/, // Just "pie"
|
||||
/:\s*$/, // Ends with colon (incomplete label)
|
||||
/"\s*$/, // Ends with unclosed quote
|
||||
];
|
||||
|
||||
return incompletePatterns.some((pattern) => pattern.test(content));
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if content looks complete (has closing structure)
|
||||
*/
|
||||
const looksComplete = (content: string): boolean => {
|
||||
const lines = content.trim().split('\n');
|
||||
if (lines.length < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Has complete node connections (flowchart/graph)
|
||||
const hasConnections =
|
||||
/[A-Za-z]\w*(\[.*?\]|\(.*?\)|\{.*?\})?(\s*--+>?\s*|\s*-+\.\s*|\s*==+>?\s*)[A-Za-z]\w*/.test(
|
||||
content,
|
||||
);
|
||||
|
||||
// Has sequence diagram messages
|
||||
const hasSequenceMessages = /\w+-+>>?\+?\w+:/.test(content);
|
||||
|
||||
// Has class diagram relations
|
||||
const hasClassRelations = /\w+\s*(<\|--|--|\.\.>|--\*|--o)\s*\w+/.test(content);
|
||||
|
||||
// Has state transitions
|
||||
const hasStateTransitions = /\[\*\]\s*-->|\w+\s*-->\s*\w+/.test(content);
|
||||
|
||||
// Has ER diagram relations
|
||||
const hasERRelations = /\w+\s*\|\|--o\{|\w+\s*}o--\|\|/.test(content);
|
||||
|
||||
// Has gantt tasks
|
||||
const hasGanttTasks = /^\s*\w+\s*:\s*\w+/.test(content);
|
||||
|
||||
return (
|
||||
hasConnections ||
|
||||
hasSequenceMessages ||
|
||||
hasClassRelations ||
|
||||
hasStateTransitions ||
|
||||
hasERRelations ||
|
||||
hasGanttTasks
|
||||
);
|
||||
};
|
||||
|
||||
interface UseDebouncedMermaidOptions {
|
||||
/** Mermaid diagram content */
|
||||
content: string;
|
||||
/** Unique identifier */
|
||||
id?: string;
|
||||
/** Custom theme */
|
||||
theme?: string;
|
||||
/** Delay before attempting render (ms) */
|
||||
delay?: number;
|
||||
/** Minimum content length before attempting render */
|
||||
minLength?: number;
|
||||
/** Key to force re-render (e.g., for retry functionality) */
|
||||
key?: number;
|
||||
}
|
||||
|
||||
export const useDebouncedMermaid = ({
|
||||
content,
|
||||
id,
|
||||
theme,
|
||||
delay = 500,
|
||||
minLength = 15,
|
||||
key = 0,
|
||||
}: UseDebouncedMermaidOptions) => {
|
||||
// Check if content looks complete on initial mount or when content changes significantly
|
||||
// Using refs to capture state and detect significant content changes (e.g., user edits message)
|
||||
const initialCheckRef = useRef<boolean | null>(null);
|
||||
const contentLengthRef = useRef(content.length);
|
||||
|
||||
// Reset check if content length changed significantly (more than 20% difference)
|
||||
const lengthDiff = Math.abs(content.length - contentLengthRef.current);
|
||||
const significantChange = lengthDiff > contentLengthRef.current * 0.2 && lengthDiff > 50;
|
||||
|
||||
if (initialCheckRef.current === null || significantChange) {
|
||||
contentLengthRef.current = content.length;
|
||||
initialCheckRef.current =
|
||||
content.length >= minLength && looksComplete(content) && !isLikelyStreaming(content);
|
||||
}
|
||||
const isInitiallyComplete = initialCheckRef.current;
|
||||
|
||||
const [debouncedContent, setDebouncedContent] = useState(content);
|
||||
const [shouldRender, setShouldRender] = useState(isInitiallyComplete);
|
||||
const [errorCount, setErrorCount] = useState(0);
|
||||
const [forceRender, setForceRender] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
const prevKeyRef = useRef(key);
|
||||
const hasRenderedRef = useRef(isInitiallyComplete);
|
||||
|
||||
// When key changes (retry), force immediate render
|
||||
useEffect(() => {
|
||||
if (key !== prevKeyRef.current) {
|
||||
prevKeyRef.current = key;
|
||||
setForceRender(true);
|
||||
setDebouncedContent(content);
|
||||
setShouldRender(true);
|
||||
setErrorCount(0);
|
||||
}
|
||||
}, [key, content]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip debounce logic if force render is active or already rendered initially
|
||||
if (forceRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we already rendered on mount, skip the initial debounce
|
||||
if (hasRenderedRef.current && shouldRender) {
|
||||
// Content changed after initial render, apply normal debounce for updates
|
||||
if (content !== debouncedContent) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
const effectiveDelay = looksComplete(content) ? delay / 2 : delay;
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDebouncedContent(content);
|
||||
}, effectiveDelay);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Don't render if too short or obviously incomplete
|
||||
if (content.length < minLength || (isLikelyStreaming(content) && !looksComplete(content))) {
|
||||
setShouldRender(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Use shorter delay if content looks complete
|
||||
const effectiveDelay = looksComplete(content) ? delay / 2 : delay;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setDebouncedContent(content);
|
||||
setShouldRender(true);
|
||||
hasRenderedRef.current = true;
|
||||
}, effectiveDelay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [content, delay, minLength, forceRender, shouldRender, debouncedContent]);
|
||||
|
||||
const result = useMermaid({
|
||||
content: shouldRender ? debouncedContent : '',
|
||||
id: id ? `${id}-${key}` : undefined,
|
||||
theme,
|
||||
});
|
||||
|
||||
// Track error count
|
||||
useEffect(() => {
|
||||
if (result.error) {
|
||||
setErrorCount((prev) => prev + 1);
|
||||
} else if (result.svg) {
|
||||
setErrorCount(0);
|
||||
setForceRender(false);
|
||||
}
|
||||
}, [result.error, result.svg]);
|
||||
|
||||
// Show error after multiple failures OR if forced render (retry) with error
|
||||
const shouldShowError = shouldRender && result.error && (errorCount > 2 || forceRender);
|
||||
|
||||
return {
|
||||
...result,
|
||||
isLoading: result.isLoading || !shouldRender,
|
||||
error: shouldShowError ? result.error : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export default useDebouncedMermaid;
|
||||
182
client/src/hooks/Mermaid/useMermaid.ts
Normal file
182
client/src/hooks/Mermaid/useMermaid.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import { useContext, useMemo, useState } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import useSWR from 'swr';
|
||||
import { Md5 } from 'ts-md5';
|
||||
import { ThemeContext, isDark } from '@librechat/client';
|
||||
import type { MermaidConfig } from 'mermaid';
|
||||
|
||||
// Constants
|
||||
const MD5_LENGTH_THRESHOLD = 10_000;
|
||||
const DEFAULT_ID_PREFIX = 'mermaid-diagram';
|
||||
|
||||
// Lazy load mermaid library (~2MB)
|
||||
let mermaidPromise: Promise<typeof import('mermaid').default> | null = null;
|
||||
|
||||
const loadMermaid = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = import('mermaid').then((mod) => mod.default);
|
||||
}
|
||||
|
||||
return mermaidPromise;
|
||||
};
|
||||
|
||||
interface UseMermaidOptions {
|
||||
/** Mermaid diagram content */
|
||||
content: string;
|
||||
/** Unique identifier for this diagram */
|
||||
id?: string;
|
||||
/** Custom mermaid theme */
|
||||
theme?: string;
|
||||
/** Custom mermaid configuration */
|
||||
config?: Partial<MermaidConfig>;
|
||||
}
|
||||
|
||||
interface UseMermaidReturn {
|
||||
/** The rendered SVG string */
|
||||
svg: string | undefined;
|
||||
/** Loading state */
|
||||
isLoading: boolean;
|
||||
/** Error object if rendering failed */
|
||||
error: Error | undefined;
|
||||
/** Whether content is being validated */
|
||||
isValidating: boolean;
|
||||
}
|
||||
|
||||
export const useMermaid = ({
|
||||
content,
|
||||
id = DEFAULT_ID_PREFIX,
|
||||
theme: customTheme,
|
||||
config,
|
||||
}: UseMermaidOptions): UseMermaidReturn => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const isDarkMode = isDark(theme);
|
||||
|
||||
// Store last valid SVG for fallback on errors
|
||||
const [validContent, setValidContent] = useState<string>('');
|
||||
|
||||
// Generate cache key based on content, theme, and ID
|
||||
const cacheKey = useMemo((): string => {
|
||||
// For large diagrams, use MD5 hash instead of full content
|
||||
const contentHash = content.length < MD5_LENGTH_THRESHOLD ? content : Md5.hashStr(content);
|
||||
|
||||
// Include theme mode in cache key to handle theme switches
|
||||
const themeKey = customTheme || (isDarkMode ? 'd' : 'l');
|
||||
|
||||
return [id, themeKey, contentHash].filter(Boolean).join('-');
|
||||
}, [content, id, isDarkMode, customTheme]);
|
||||
|
||||
// Generate unique diagram ID (mermaid requires unique IDs in the DOM)
|
||||
// Include cacheKey to regenerate when content/theme changes, preventing mermaid internal conflicts
|
||||
const diagramId = useMemo(() => {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(7);
|
||||
return `${id}-${timestamp}-${random}`;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [id, cacheKey]);
|
||||
|
||||
// Build mermaid configuration
|
||||
const mermaidConfig = useMemo((): MermaidConfig => {
|
||||
const defaultTheme = isDarkMode ? 'dark' : 'neutral';
|
||||
|
||||
return {
|
||||
startOnLoad: false,
|
||||
theme: (customTheme as MermaidConfig['theme']) || defaultTheme,
|
||||
// Spread custom config but override security settings after
|
||||
...config,
|
||||
// Security hardening - these MUST come last to prevent override
|
||||
securityLevel: 'strict', // Highest security: disables click, sanitizes text
|
||||
maxTextSize: config?.maxTextSize ?? 50000, // Limit text size to prevent DoS
|
||||
maxEdges: config?.maxEdges ?? 500, // Limit edges to prevent DoS
|
||||
};
|
||||
}, [customTheme, isDarkMode, config]);
|
||||
|
||||
// Fetch/render function
|
||||
const fetchSvg = async (): Promise<string> => {
|
||||
// SSR guard
|
||||
if (typeof window === 'undefined') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
// Load mermaid library (cached after first load)
|
||||
const mermaidInstance = await loadMermaid();
|
||||
|
||||
if (!mermaidInstance) {
|
||||
throw new Error('Failed to load mermaid library');
|
||||
}
|
||||
|
||||
// Validate syntax first and capture detailed error
|
||||
try {
|
||||
await mermaidInstance.parse(content);
|
||||
} catch (parseError) {
|
||||
// Extract meaningful error message from mermaid's parse error
|
||||
let errorMessage = 'Invalid mermaid syntax';
|
||||
if (parseError instanceof Error) {
|
||||
errorMessage = parseError.message;
|
||||
} else if (typeof parseError === 'string') {
|
||||
errorMessage = parseError;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Initialize with config
|
||||
mermaidInstance.initialize(mermaidConfig);
|
||||
|
||||
// Render to SVG
|
||||
const { svg } = await mermaidInstance.render(diagramId, content);
|
||||
|
||||
// Sanitize SVG output with DOMPurify for additional security
|
||||
const purify = DOMPurify();
|
||||
const sanitizedSvg = purify.sanitize(svg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
// Allow additional elements used by mermaid for text rendering
|
||||
ADD_TAGS: ['foreignObject', 'use', 'switch'],
|
||||
ADD_ATTR: [
|
||||
'dominant-baseline',
|
||||
'text-anchor',
|
||||
'requiredFeatures',
|
||||
'systemLanguage',
|
||||
'xmlns:xlink',
|
||||
],
|
||||
});
|
||||
|
||||
// Store as last valid content
|
||||
setValidContent(sanitizedSvg);
|
||||
|
||||
return sanitizedSvg;
|
||||
} catch (error) {
|
||||
console.error('Mermaid rendering error:', error);
|
||||
|
||||
// Return last valid content if available (graceful degradation)
|
||||
if (validContent) {
|
||||
return validContent;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Use SWR for caching and revalidation
|
||||
const { data, error, isLoading, isValidating } = useSWR<string, Error>(cacheKey, fetchSvg, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
dedupingInterval: 3000,
|
||||
errorRetryCount: 2,
|
||||
errorRetryInterval: 1000,
|
||||
shouldRetryOnError: true,
|
||||
});
|
||||
|
||||
return {
|
||||
svg: data,
|
||||
isLoading,
|
||||
error,
|
||||
isValidating,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMermaid;
|
||||
|
|
@ -9,6 +9,7 @@ export * from './Files';
|
|||
export * from './Generic';
|
||||
export * from './Input';
|
||||
export * from './MCP';
|
||||
export * from './Mermaid';
|
||||
export * from './Messages';
|
||||
export * from './Plugins';
|
||||
export * from './Prompts';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue