📊 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:
Danny Avila 2025-12-26 19:53:06 -05:00 committed by GitHub
parent 43c2c20dd7
commit 3503b7caeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 2321 additions and 9 deletions

View file

@ -0,0 +1,2 @@
export { useMermaid, default } from './useMermaid';
export { useDebouncedMermaid } from './useDebouncedMermaid';

View 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;

View 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;

View file

@ -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';