Compare commits
3 Commits
docs/azure
...
feat/merma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f67dd1b1b7 | ||
|
|
4136dda7c7 | ||
|
|
c53bdc1fef |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -56,6 +56,7 @@ bower_components/
|
||||
.clineignore
|
||||
.cursor
|
||||
.aider*
|
||||
CLAUDE.md
|
||||
|
||||
# Floobits
|
||||
.floo
|
||||
@@ -124,4 +125,4 @@ helm/**/.values.yaml
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# SAML Idp cert
|
||||
*.cert
|
||||
*.cert
|
||||
@@ -72,7 +72,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"micromark-extension-llm-math": "^3.1.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useMemo, useRef, useEffect } from 'react';
|
||||
import React, { memo, useMemo, useRef, useEffect, lazy, Suspense } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useToastContext, useCodeBlockContext } from '~/Providers';
|
||||
@@ -9,6 +9,16 @@ import useLocalize from '~/hooks/useLocalize';
|
||||
import { handleDoubleClick } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
// Loading fallback component for lazy-loaded Mermaid diagrams
|
||||
const MermaidLoadingFallback = memo(() => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-border-light bg-surface-primary p-4 text-center text-text-secondary dark:border-border-heavy dark:bg-surface-primary-alt">
|
||||
{localize('com_ui_loading_diagram')}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type TCodeProps = {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
@@ -23,6 +33,7 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMath = lang === 'math';
|
||||
const isMermaid = lang === 'mermaid';
|
||||
const isSingleLine = typeof children === 'string' && children.split('\n').length === 1;
|
||||
|
||||
const { getNextIndex, resetCounter } = useCodeBlockContext();
|
||||
@@ -34,6 +45,13 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
||||
|
||||
if (isMath) {
|
||||
return <>{children}</>;
|
||||
} else if (isMermaid && typeof children === 'string') {
|
||||
const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram'));
|
||||
return (
|
||||
<Suspense fallback={<MermaidLoadingFallback />}>
|
||||
<SandpackMermaidDiagram content={children} />
|
||||
</Suspense>
|
||||
);
|
||||
} else if (isSingleLine) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
@@ -55,9 +73,17 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
||||
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className ?? '');
|
||||
const lang = match && match[1];
|
||||
const isMermaid = lang === 'mermaid';
|
||||
|
||||
if (lang === 'math') {
|
||||
return children;
|
||||
} else if (isMermaid && typeof children === 'string') {
|
||||
const SandpackMermaidDiagram = lazy(() => import('./SandpackMermaidDiagram'));
|
||||
return (
|
||||
<Suspense fallback={<MermaidLoadingFallback />}>
|
||||
<SandpackMermaidDiagram content={children} />
|
||||
</Suspense>
|
||||
);
|
||||
} else if (typeof children === 'string' && children.split('\n').length === 1) {
|
||||
return (
|
||||
<code onDoubleClick={handleDoubleClick} className={className}>
|
||||
|
||||
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal file
455
client/src/components/Chat/Messages/Content/MermaidDiagram.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, {
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch';
|
||||
import { cn } from '~/utils';
|
||||
import { ThemeContext, isDark } from '~/hooks/ThemeContext';
|
||||
import { ClipboardIcon, CheckIcon, ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
|
||||
|
||||
interface InlineMermaidProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const InlineMermaidDiagram = memo(({ content, className }: InlineMermaidProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [svgContent, setSvgContent] = useState<string>('');
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [wasAutoCorrected, setWasAutoCorrected] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const isDarkMode = isDark(theme);
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
|
||||
const diagramKey = useMemo(
|
||||
() => `${content.trim()}-${isDarkMode ? 'dark' : 'light'}`,
|
||||
[content, isDarkMode],
|
||||
);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy diagram content:', err);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomIn(0.2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomOut(0.2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleResetZoom = useCallback(() => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.resetTransform();
|
||||
transformRef.current.centerView(1, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoized to prevent re-renders when content/theme changes
|
||||
const fixCommonSyntaxIssues = useMemo(() => {
|
||||
return (text: string) => {
|
||||
let fixed = text;
|
||||
|
||||
fixed = fixed.replace(/--\s+>/g, '-->');
|
||||
fixed = fixed.replace(/--\s+\|/g, '--|');
|
||||
fixed = fixed.replace(/\|\s+-->/g, '|-->');
|
||||
fixed = fixed.replace(/\[([^[\]]*)"([^[\]]*)"([^[\]]*)\]/g, '[$1$2$3]');
|
||||
fixed = fixed.replace(/subgraph([A-Za-z])/g, 'subgraph $1');
|
||||
|
||||
return fixed;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTryFix = useCallback(() => {
|
||||
const fixedContent = fixCommonSyntaxIssues(content);
|
||||
if (fixedContent !== content) {
|
||||
// Currently just copies the fixed version to clipboard
|
||||
navigator.clipboard.writeText(fixedContent).then(() => {
|
||||
setError(t('com_mermaid_fix_copied'));
|
||||
});
|
||||
}
|
||||
}, [content, fixCommonSyntaxIssues, t]);
|
||||
|
||||
// Use ref to track timeout to prevent stale closures
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Clear previous SVG content
|
||||
setSvgContent('');
|
||||
|
||||
const cleanContent = content.trim();
|
||||
|
||||
setError(null);
|
||||
setWasAutoCorrected(false);
|
||||
setIsRendered(false);
|
||||
setIsLoading(false);
|
||||
|
||||
if (!cleanContent) {
|
||||
setError(t('com_mermaid_error_no_content'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Debounce rendering to avoid flickering during rapid content changes
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!isCancelled) {
|
||||
renderDiagram();
|
||||
}
|
||||
}, 300);
|
||||
|
||||
async function renderDiagram() {
|
||||
if (isCancelled) return;
|
||||
|
||||
try {
|
||||
if (
|
||||
!cleanContent.match(
|
||||
/^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitgraph|mindmap|timeline|quadrant|block-beta|sankey|xychart|gitgraph)/i,
|
||||
)
|
||||
) {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_type'));
|
||||
setWasAutoCorrected(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Dynamic import to reduce bundle size
|
||||
setIsLoading(true);
|
||||
const mermaid = await import('mermaid').then((m) => m.default);
|
||||
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize with error suppression to avoid console spam
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkMode ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
logLevel: 'fatal',
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
},
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
let result;
|
||||
let contentToRender = cleanContent;
|
||||
|
||||
try {
|
||||
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
result = await mermaid.render(id, contentToRender);
|
||||
} catch (_renderError) {
|
||||
const fixedContent = fixCommonSyntaxIssues(cleanContent);
|
||||
if (fixedContent !== cleanContent) {
|
||||
try {
|
||||
const fixedId = `mermaid-fixed-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
result = await mermaid.render(fixedId, fixedContent);
|
||||
contentToRender = fixedContent;
|
||||
setWasAutoCorrected(true);
|
||||
} catch (_fixedRenderError) {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_syntax_auto_correct'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_invalid_syntax'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if component was unmounted during async render
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result && result.svg) {
|
||||
let processedSvg = result.svg;
|
||||
|
||||
// Enhance SVG for better zoom/pan interaction
|
||||
processedSvg = processedSvg.replace(
|
||||
'<svg',
|
||||
'<svg style="width: 100%; height: auto;" preserveAspectRatio="xMidYMid meet"',
|
||||
);
|
||||
|
||||
// Sanitize SVG content to prevent XSS attacks
|
||||
const sanitizedSvg = DOMPurify.sanitize(processedSvg, {
|
||||
USE_PROFILES: { svg: true, svgFilters: true },
|
||||
ADD_TAGS: ['foreignObject'],
|
||||
ADD_ATTR: ['preserveAspectRatio'],
|
||||
FORBID_TAGS: ['script', 'object', 'embed', 'iframe'],
|
||||
FORBID_ATTR: ['onerror', 'onload', 'onclick'],
|
||||
});
|
||||
|
||||
if (!isCancelled) {
|
||||
setSvgContent(sanitizedSvg);
|
||||
setIsRendered(true);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
if (!isCancelled) {
|
||||
setError(t('com_mermaid_error_no_svg'));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Mermaid rendering error:', err);
|
||||
if (!isCancelled) {
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t('com_mermaid_error_rendering_failed', 'Failed to render diagram');
|
||||
setError(t('com_mermaid_error_rendering_failed', { '0': errorMessage }));
|
||||
setWasAutoCorrected(false);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [diagramKey, content, isDarkMode, fixCommonSyntaxIssues, t]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (error) {
|
||||
const fixedContent = fixCommonSyntaxIssues(content);
|
||||
const canTryFix = fixedContent !== content;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'my-4 overflow-auto rounded-lg border border-red-300 bg-red-50',
|
||||
'dark:border-red-700 dark:bg-red-900/20',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="p-4 text-red-600 dark:text-red-400">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<strong>{t('com_mermaid_error')}</strong> {error}
|
||||
{canTryFix && (
|
||||
<div className={cn('mt-2 text-sm text-red-500 dark:text-red-300')}>
|
||||
💡 {t('com_mermaid_error_fixes_detected')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex gap-2">
|
||||
{canTryFix && (
|
||||
<button
|
||||
onClick={handleTryFix}
|
||||
className={cn(
|
||||
'rounded border px-3 py-1 text-xs transition-colors',
|
||||
'border-blue-300 bg-blue-100 text-blue-700 hover:bg-blue-200',
|
||||
'dark:border-blue-700 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800',
|
||||
)}
|
||||
title={t('com_mermaid_copy_potential_fix')}
|
||||
>
|
||||
{t('com_mermaid_try_fix')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'rounded border px-3 py-1 text-xs transition-colors',
|
||||
'border-gray-300 bg-gray-100 text-gray-700 hover:bg-gray-200',
|
||||
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700',
|
||||
)}
|
||||
title={t('com_mermaid_copy_code')}
|
||||
>
|
||||
{isCopied ? `✓ ${t('com_mermaid_copied')}` : t('com_mermaid_copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 pt-0">
|
||||
<pre className="overflow-x-auto rounded bg-gray-100 p-2 text-sm dark:bg-gray-800">
|
||||
<code className="language-mermaid">{content}</code>
|
||||
</pre>
|
||||
{canTryFix && (
|
||||
<div className="mt-3 rounded border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-950">
|
||||
<div className={cn('mb-2 text-sm font-medium text-blue-800 dark:text-blue-200')}>
|
||||
{t('com_mermaid_suggested_fix')}
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded border bg-white p-2 text-sm dark:bg-gray-800">
|
||||
<code className="language-mermaid">{fixedContent}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={diagramKey}
|
||||
className={cn(
|
||||
'relative my-4 overflow-auto rounded-lg border border-border-light bg-surface-primary',
|
||||
'dark:border-border-heavy dark:bg-surface-primary-alt',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isRendered && wasAutoCorrected && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-2 top-2 z-10 rounded-md px-2 py-1 text-xs',
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
'border border-yellow-300 dark:border-yellow-700',
|
||||
'shadow-sm',
|
||||
)}
|
||||
>
|
||||
✨ {t('com_mermaid_auto_fixed')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isRendered && svgContent && (
|
||||
<div className="absolute right-2 top-2 z-10 flex gap-1">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_zoom_in')}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_zoom_out')}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResetZoom}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_reset_zoom')}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'rounded-md p-2 transition-all duration-200',
|
||||
'hover:bg-surface-hover active:bg-surface-active',
|
||||
'text-text-secondary hover:text-text-primary',
|
||||
'border border-border-light dark:border-border-heavy',
|
||||
'bg-surface-primary dark:bg-surface-primary-alt',
|
||||
'shadow-sm hover:shadow-md',
|
||||
)}
|
||||
title={t('com_mermaid_copy_code')}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<ClipboardIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4">
|
||||
{(isLoading || !isRendered) && (
|
||||
<div className="animate-pulse text-center text-text-secondary">
|
||||
{t('com_mermaid_rendering')}
|
||||
</div>
|
||||
)}
|
||||
{isRendered && svgContent && (
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={0.1}
|
||||
maxScale={4}
|
||||
limitToBounds={false}
|
||||
centerOnInit={true}
|
||||
wheel={{ step: 0.1 }}
|
||||
panning={{ velocityDisabled: true }}
|
||||
alignmentAnimation={{ disabled: true }}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
minHeight: '200px',
|
||||
maxHeight: '600px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mermaid-container flex min-h-[200px] items-center justify-center"
|
||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InlineMermaidDiagram.displayName = 'InlineMermaidDiagram';
|
||||
|
||||
export default InlineMermaidDiagram;
|
||||
@@ -0,0 +1,289 @@
|
||||
import React, { memo, useMemo, useEffect } from 'react';
|
||||
import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled';
|
||||
import dedent from 'dedent';
|
||||
import { cn } from '~/utils';
|
||||
import { sharedOptions } from '~/utils/artifacts';
|
||||
|
||||
interface SandpackMermaidDiagramProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Minimal dependencies for Mermaid only
|
||||
const mermaidDependencies = {
|
||||
mermaid: '^11.8.1',
|
||||
'react-zoom-pan-pinch': '^3.7.0',
|
||||
};
|
||||
|
||||
// Lean mermaid template with inline SVG icons
|
||||
const leanMermaidTemplate = dedent`
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
TransformWrapper,
|
||||
TransformComponent,
|
||||
ReactZoomPanPinchRef,
|
||||
} from "react-zoom-pan-pinch";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
// Inline SVG icons
|
||||
const ZoomInIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
<line x1="11" y1="8" x2="11" y2="14"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ZoomOutIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35"/>
|
||||
<line x1="8" y1="11" x2="14" y2="11"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ResetIcon = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="1 4 1 10 7 10"/>
|
||||
<polyline points="23 20 23 14 17 14"/>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
const MermaidDiagram: React.FC<MermaidDiagramProps> = ({ content }) => {
|
||||
const mermaidRef = useRef<HTMLDivElement>(null);
|
||||
const transformRef = useRef<ReactZoomPanPinchRef>(null);
|
||||
const [isRendered, setIsRendered] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "default",
|
||||
securityLevel: "loose",
|
||||
flowchart: {
|
||||
useMaxWidth: true,
|
||||
htmlLabels: true,
|
||||
curve: "basis",
|
||||
},
|
||||
});
|
||||
|
||||
const renderDiagram = async () => {
|
||||
if (mermaidRef.current) {
|
||||
try {
|
||||
const id = "mermaid-" + Date.now();
|
||||
const { svg } = await mermaid.render(id, content);
|
||||
mermaidRef.current.innerHTML = svg;
|
||||
|
||||
const svgElement = mermaidRef.current.querySelector("svg");
|
||||
if (svgElement) {
|
||||
svgElement.style.width = "100%";
|
||||
svgElement.style.height = "100%";
|
||||
}
|
||||
setIsRendered(true);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Mermaid rendering error:", err);
|
||||
setError(err.message || "Failed to render diagram");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [content]);
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomIn(0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.zoomOut(0.2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (transformRef.current) {
|
||||
transformRef.current.resetTransform();
|
||||
transformRef.current.centerView(1, 0);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '16px', color: '#ef4444', backgroundColor: '#fee2e2', borderRadius: '8px', border: '1px solid #fecaca' }}>
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', height: '100%', width: '100%', backgroundColor: '#f9fafb' }}>
|
||||
<TransformWrapper
|
||||
ref={transformRef}
|
||||
initialScale={1}
|
||||
minScale={0.1}
|
||||
maxScale={4}
|
||||
wheel={{ step: 0.1 }}
|
||||
centerOnInit={true}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperStyle={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={mermaidRef}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '300px',
|
||||
padding: '20px',
|
||||
}}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
|
||||
{isRendered && (
|
||||
<div style={{ position: 'absolute', bottom: '8px', right: '8px', display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
padding: '8px',
|
||||
backgroundColor: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
title="Reset zoom"
|
||||
>
|
||||
<ResetIcon />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MermaidDiagram;
|
||||
`;
|
||||
|
||||
const wrapLeanMermaidDiagram = (content: string) => {
|
||||
return dedent`
|
||||
import React from 'react';
|
||||
import MermaidDiagram from './MermaidDiagram';
|
||||
|
||||
export default function App() {
|
||||
const content = \`${content.replace(/`/g, '\\`')}\`;
|
||||
return <MermaidDiagram content={content} />;
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
const getLeanMermaidFiles = (content: string) => {
|
||||
return {
|
||||
'/App.tsx': wrapLeanMermaidDiagram(content),
|
||||
'/MermaidDiagram.tsx': leanMermaidTemplate,
|
||||
};
|
||||
};
|
||||
|
||||
const SandpackMermaidDiagram = memo(({ content, className }: SandpackMermaidDiagramProps) => {
|
||||
const files = useMemo(() => getLeanMermaidFiles(content), [content]);
|
||||
const sandpackProps = useMemo(
|
||||
() => ({
|
||||
customSetup: {
|
||||
dependencies: mermaidDependencies,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Force iframe to respect container height
|
||||
useEffect(() => {
|
||||
const fixIframeHeight = () => {
|
||||
const container = document.querySelector('.sandpack-mermaid-diagram');
|
||||
if (container) {
|
||||
const iframe = container.querySelector('iframe');
|
||||
if (iframe && iframe.style.height && iframe.style.height !== '100%') {
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.minHeight = '100%';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fix
|
||||
fixIframeHeight();
|
||||
|
||||
// Fix on any DOM changes
|
||||
const observer = new MutationObserver(fixIframeHeight);
|
||||
const container = document.querySelector('.sandpack-mermaid-diagram');
|
||||
if (container) {
|
||||
observer.observe(container, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: ['style'],
|
||||
});
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<SandpackProvider files={files} options={sharedOptions} template="react-ts" {...sandpackProps}>
|
||||
<SandpackPreview
|
||||
showOpenInCodeSandbox={false}
|
||||
showRefreshButton={false}
|
||||
showSandpackErrorOverlay={true}
|
||||
/>
|
||||
</SandpackProvider>
|
||||
);
|
||||
});
|
||||
|
||||
SandpackMermaidDiagram.displayName = 'SandpackMermaidDiagram';
|
||||
|
||||
export default SandpackMermaidDiagram;
|
||||
@@ -300,6 +300,26 @@
|
||||
"com_error_moderation": "It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We're unable to proceed with this specific topic. If you have any other questions or topics you'd like to explore, please edit your message, or create a new conversation.",
|
||||
"com_error_no_base_url": "No base URL found. Please provide one and try again.",
|
||||
"com_error_no_user_key": "No key found. Please provide a key and try again.",
|
||||
"com_mermaid_auto_fixed": "Auto-fixed",
|
||||
"com_mermaid_copy": "Copy",
|
||||
"com_mermaid_copy_potential_fix": "Copy potential fix to clipboard",
|
||||
"com_mermaid_copy_code": "Copy mermaid code",
|
||||
"com_mermaid_copied": "Copied",
|
||||
"com_mermaid_error": "Mermaid Error:",
|
||||
"com_mermaid_error_fixes_detected": "Potential fixes detected: spacing issues in arrows or labels",
|
||||
"com_mermaid_error_invalid_syntax": "Invalid diagram syntax - check arrow formatting and node labels",
|
||||
"com_mermaid_error_invalid_syntax_auto_correct": "Invalid diagram syntax - syntax errors found but unable to auto-correct",
|
||||
"com_mermaid_error_invalid_type": "Invalid Mermaid syntax - diagram must start with a valid diagram type (flowchart, graph, sequenceDiagram, etc.)",
|
||||
"com_mermaid_error_no_content": "No diagram content provided",
|
||||
"com_mermaid_error_no_svg": "No SVG generated - rendering failed unexpectedly",
|
||||
"com_mermaid_error_rendering_failed": "Rendering failed: {{0}}",
|
||||
"com_mermaid_fix_copied": "Potential fix copied to clipboard. Common issues found and corrected.",
|
||||
"com_mermaid_rendering": "Rendering diagram...",
|
||||
"com_mermaid_suggested_fix": "Suggested Fix:",
|
||||
"com_mermaid_try_fix": "Try Fix",
|
||||
"com_mermaid_zoom_in": "Zoom in",
|
||||
"com_mermaid_zoom_out": "Zoom out",
|
||||
"com_mermaid_reset_zoom": "Reset zoom",
|
||||
"com_files_filter": "Filter files...",
|
||||
"com_files_no_results": "No results.",
|
||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
||||
@@ -837,6 +857,7 @@
|
||||
"com_ui_librechat_code_api_subtitle": "Secure. Multi-language. Input/Output Files.",
|
||||
"com_ui_librechat_code_api_title": "Run AI Code",
|
||||
"com_ui_loading": "Loading...",
|
||||
"com_ui_loading_diagram": "Loading diagram...",
|
||||
"com_ui_locked": "Locked",
|
||||
"com_ui_logo": "{{0}} Logo",
|
||||
"com_ui_low": "Low",
|
||||
|
||||
@@ -371,4 +371,130 @@ p.whitespace-pre-wrap a, li a {
|
||||
|
||||
.dark p.whitespace-pre-wrap a, .dark li a {
|
||||
color: #52a0ff;
|
||||
}
|
||||
}
|
||||
|
||||
/* .sandpack-mermaid-diagram {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram > div {
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-stack {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
flex: 1 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
flex: 1 !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-container {
|
||||
height: 100% !important;
|
||||
min-height: inherit !important;
|
||||
flex: 1 !important;
|
||||
background: transparent !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-iframe {
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-actions {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-preview-container::after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram [style*="height: 346px"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram iframe[style*="height"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram [style*="height:"] {
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram iframe {
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
position: absolute !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-stack {
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper,
|
||||
.sandpack-mermaid-diagram .sp-stack,
|
||||
.sandpack-mermaid-diagram .sp-preview,
|
||||
.sandpack-mermaid-diagram .sp-preview-container {
|
||||
max-height: none !important;
|
||||
height: 100% !important;
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .p-4 > div {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper {
|
||||
height: 100% !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram iframe[style*="height"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram [style*="height:"] {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.sandpack-mermaid-diagram .sp-wrapper,
|
||||
.sandpack-mermaid-diagram .sp-stack,
|
||||
.sandpack-mermaid-diagram .sp-preview,
|
||||
.sandpack-mermaid-diagram .sp-preview-container {
|
||||
max-height: none !important;
|
||||
} */
|
||||
@@ -113,8 +113,8 @@ export default defineConfig(({ command }) => ({
|
||||
if (id.includes('i18next') || id.includes('react-i18next')) {
|
||||
return 'i18n';
|
||||
}
|
||||
if (id.includes('lodash')) {
|
||||
return 'utilities';
|
||||
if (id.includes('node_modules/lodash-es')) {
|
||||
return 'lodash-es';
|
||||
}
|
||||
if (id.includes('date-fns')) {
|
||||
return 'date-utils';
|
||||
@@ -231,6 +231,7 @@ export default defineConfig(({ command }) => ({
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.join(__dirname, 'src/'),
|
||||
lodash: 'lodash-es',
|
||||
$fonts: path.resolve(__dirname, 'public/fonts'),
|
||||
'micromark-extension-math': 'micromark-extension-llm-math',
|
||||
},
|
||||
|
||||
8
package-lock.json
generated
8
package-lock.json
generated
@@ -2511,7 +2511,7 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lucide-react": "^0.394.0",
|
||||
"match-sorter": "^6.3.4",
|
||||
"micromark-extension-llm-math": "^3.1.0",
|
||||
@@ -35295,6 +35295,12 @@
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.camelcase": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||
|
||||
Reference in New Issue
Block a user