diff --git a/.gitignore b/.gitignore index 461eef9d2c..b3cb4e19c5 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,4 @@ helm/**/.values.yaml /.openai/ /.tabnine/ /.codeium +CLAUDE.md diff --git a/client/src/components/Artifacts/Mermaid.tsx b/client/src/components/Artifacts/Mermaid.tsx deleted file mode 100644 index f7291998a4..0000000000 --- a/client/src/components/Artifacts/Mermaid.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react'; -import mermaid from 'mermaid'; -import { Button } from '@librechat/client'; -import { TransformWrapper, TransformComponent, ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; -import { ZoomIn, ZoomOut, RefreshCw } from 'lucide-react'; - -interface MermaidDiagramProps { - content: string; -} - -/** Note: this is just for testing purposes, don't actually use this component */ -const MermaidDiagram: React.FC = ({ content }) => { - const mermaidRef = useRef(null); - const transformRef = useRef(null); - const [isRendered, setIsRendered] = useState(false); - - useEffect(() => { - mermaid.initialize({ - startOnLoad: false, - theme: 'base', - securityLevel: 'sandbox', - themeVariables: { - background: '#282C34', - primaryColor: '#333842', - secondaryColor: '#333842', - tertiaryColor: '#333842', - primaryTextColor: '#ABB2BF', - secondaryTextColor: '#ABB2BF', - lineColor: '#636D83', - fontSize: '16px', - nodeBorder: '#636D83', - mainBkg: '#282C34', - altBackground: '#282C34', - textColor: '#ABB2BF', - edgeLabelBackground: '#282C34', - clusterBkg: '#282C34', - clusterBorder: '#636D83', - labelBoxBkgColor: '#333842', - labelBoxBorderColor: '#636D83', - labelTextColor: '#ABB2BF', - }, - flowchart: { - curve: 'basis', - nodeSpacing: 50, - rankSpacing: 50, - diagramPadding: 8, - htmlLabels: true, - useMaxWidth: true, - padding: 15, - wrappingWidth: 200, - }, - }); - - const renderDiagram = async () => { - if (mermaidRef.current) { - try { - const { svg } = await mermaid.render('mermaid-diagram', content); - mermaidRef.current.innerHTML = svg; - - const svgElement = mermaidRef.current.querySelector('svg'); - if (svgElement) { - svgElement.style.width = '100%'; - svgElement.style.height = '100%'; - - const pathElements = svgElement.querySelectorAll('path'); - pathElements.forEach((path) => { - path.style.strokeWidth = '1.5px'; - }); - - const rectElements = svgElement.querySelectorAll('rect'); - rectElements.forEach((rect) => { - const parent = rect.parentElement; - if (parent && parent.classList.contains('node')) { - rect.style.stroke = '#636D83'; - rect.style.strokeWidth = '1px'; - } else { - rect.style.stroke = 'none'; - } - }); - } - setIsRendered(true); - } catch (error) { - console.error('Mermaid rendering error:', error); - mermaidRef.current.innerHTML = 'Error rendering diagram'; - } - } - }; - - renderDiagram(); - }, [content]); - - const centerAndFitDiagram = () => { - if (transformRef.current && mermaidRef.current) { - const { centerView, zoomToElement } = transformRef.current; - zoomToElement(mermaidRef.current as HTMLElement); - centerView(1, 0); - } - }; - - useEffect(() => { - if (isRendered) { - centerAndFitDiagram(); - } - }, [isRendered]); - - const handlePanning = () => { - if (transformRef.current) { - const { state, instance } = (transformRef.current as ReactZoomPanPinchRef | undefined) ?? {}; - if (!state || !instance) { - return; - } - const { scale, positionX, positionY } = state; - const { wrapperComponent, contentComponent } = instance; - - if (wrapperComponent && contentComponent) { - const wrapperRect = wrapperComponent.getBoundingClientRect(); - const contentRect = contentComponent.getBoundingClientRect(); - const maxX = wrapperRect.width - contentRect.width * scale; - const maxY = wrapperRect.height - contentRect.height * scale; - - let newX = positionX; - let newY = positionY; - - if (newX > 0) { - newX = 0; - } - if (newY > 0) { - newY = 0; - } - if (newX < maxX) { - newX = maxX; - } - if (newY < maxY) { - newY = maxY; - } - - if (newX !== positionX || newY !== positionY) { - instance.setTransformState(scale, newX, newY); - } - } - } - }; - - return ( -
- - {({ zoomIn, zoomOut }) => ( - <> - -
- -
- - - -
- - )} - -
- ); -}; - -export default MermaidDiagram; diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 7ade775647..53cfb09fd0 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -16,7 +16,6 @@ import { langSubset, preprocessLaTeX } from '~/utils'; import { unicodeCitation } from '~/components/Web'; import { code, a, p } from './MarkdownComponents'; import store from '~/store'; - type TContentProps = { content: string; isLatestMessage: boolean; diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index de1e82443e..cd16078aca 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -3,6 +3,7 @@ import { useRecoilValue } from 'recoil'; import { useToastContext } from '@librechat/client'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; +import SandpackMermaidDiagram from './SandpackMermaidDiagram'; import useHasAccess from '~/hooks/Roles/useHasAccess'; import { useFileDownload } from '~/data-provider'; import { useCodeBlockContext } from '~/Providers'; @@ -24,6 +25,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(); @@ -35,6 +37,8 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps if (isMath) { return <>{children}; + } else if (isMermaid && typeof children === 'string') { + return ; } else if (isSingleLine) { return ( @@ -56,9 +60,12 @@ 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') { + return ; } else if (typeof children === 'string' && children.split('\n').length === 1) { return ( diff --git a/client/src/components/Chat/Messages/Content/SandpackMermaidDiagram.tsx b/client/src/components/Chat/Messages/Content/SandpackMermaidDiagram.tsx new file mode 100644 index 0000000000..8e16035869 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/SandpackMermaidDiagram.tsx @@ -0,0 +1,297 @@ +import React, { memo, useMemo, useState, useEffect } from 'react'; +import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled'; +import { cn } from '~/utils'; +import { sharedOptions } from '~/utils/artifacts'; +import CodeBlock from '~/components/Messages/Content/CodeBlock'; + +interface SandpackMermaidDiagramProps { + content: string; + className?: string; + fallbackToCodeBlock?: boolean; +} + +const mermaidDependencies = { + mermaid: '^11.4.1', + 'react-zoom-pan-pinch': '^3.6.1', + 'copy-to-clipboard': '^3.3.3', +}; + +const mermaidTemplate = ` +import React, { useEffect, useRef, useState } from "react"; +import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"; +import mermaid from "mermaid"; +import copy from "copy-to-clipboard"; + +export default function MermaidDiagram({ content }) { + const containerRef = useRef(null); + const [svgContent, setSvgContent] = useState(""); + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + copy(content, { format: 'text/plain' }); + setCopied(true); + setTimeout(() => setCopied(false), 3000); + }; + + useEffect(() => { + mermaid.initialize({ + startOnLoad: false, + theme: "default" + }); + + mermaid.render("mermaid-diagram-" + Math.random().toString(36).substr(2, 9), content) + .then(({ svg }) => { + // Remove fixed width/height attributes from SVG + let processedSvg = svg.replace(/\\s(width|height)="[^"]*"/g, ''); + + // Add responsive styling + processedSvg = processedSvg.replace( + ' { + console.error("Mermaid error:", err); + // Show the mermaid code with error message when parsing fails + const errorHtml = + '
' + + '
' + + 'Mermaid Syntax Error: ' + (err.message || 'Invalid diagram syntax') + + '
' + + '
' +
+              content.replace(//g, '>') +
+            '
' + + '
'; + setSvgContent(errorHtml); + }); + }, [content]); + + return ( +
+ + {({ zoomIn, zoomOut, resetTransform }) => ( + <> + +
+ +
+ + + + +
+ + )} + +
+ ); +} +`; + +const SandpackMermaidDiagram = memo( + ({ content, className, fallbackToCodeBlock }: SandpackMermaidDiagramProps) => { + const mermaidContent = content || 'graph TD\n A[No content provided] --> B[Error]'; + const [hasError, setHasError] = useState(false); + + const files = useMemo( + () => ({ + '/App.tsx': `import React from 'react'; +import MermaidDiagram from './MermaidDiagram'; + +export default function App() { + const content = \`${mermaidContent.replace(/`/g, '\\`')}\`; + return ; +}`, + '/MermaidDiagram.tsx': mermaidTemplate, + }), + [mermaidContent], + ); + + const key = useMemo(() => { + let hash = 0; + for (let i = 0; i < mermaidContent.length; i++) { + hash = (hash << 5) - hash + mermaidContent.charCodeAt(i); + } + return hash.toString(36); + }, [mermaidContent]); + + // Check if mermaid content is valid by attempting to parse it + useEffect(() => { + if (!fallbackToCodeBlock) return; + + // Simple validation check for common mermaid syntax errors + const trimmedContent = mermaidContent.trim(); + const hasValidStart = + /^(graph|flowchart|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph)/i.test( + trimmedContent, + ); + + if (!hasValidStart || trimmedContent.includes('Mermaid Syntax Error')) { + setHasError(true); + } + }, [mermaidContent, fallbackToCodeBlock]); + + // If there's an error and fallback is enabled, show as code block + if (fallbackToCodeBlock && hasError) { + return ; + } + + return ( +
+ + + +
+ ); + }, +); + +SandpackMermaidDiagram.displayName = 'SandpackMermaidDiagram'; + +export default SandpackMermaidDiagram; diff --git a/client/src/hooks/Artifacts/useArtifactProps.ts b/client/src/hooks/Artifacts/useArtifactProps.ts index 6de90f5893..0c1ad91e3d 100644 --- a/client/src/hooks/Artifacts/useArtifactProps.ts +++ b/client/src/hooks/Artifacts/useArtifactProps.ts @@ -2,12 +2,37 @@ import { useMemo } from 'react'; import { removeNullishValues } from 'librechat-data-provider'; import type { Artifact } from '~/common'; import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts'; -import { getMermaidFiles } from '~/utils/mermaid'; export default function useArtifactProps({ artifact }: { artifact: Artifact }) { const [fileKey, files] = useMemo(() => { if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) { - return ['App.tsx', getMermaidFiles(artifact.content ?? '')]; + const mermaidFiles = { + 'App.tsx': `import React from 'react'; +import MermaidDiagram from './MermaidDiagram'; + +export default function App() { + const content = \`${(artifact.content ?? '').replace(/`/g, '\\`')}\`; + return ; +}`, + 'MermaidDiagram.tsx': `import React, { useEffect, useRef } from 'react'; +import mermaid from 'mermaid'; + +export default function MermaidDiagram({ content }) { + const ref = useRef(null); + + useEffect(() => { + if (ref.current) { + mermaid.initialize({ startOnLoad: true, theme: 'default' }); + mermaid.render('mermaid-diagram', content).then(({ svg }) => { + ref.current.innerHTML = svg; + }); + } + }, [content]); + + return
; +}`, + }; + return ['App.tsx', mermaidFiles]; } const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language); diff --git a/client/src/utils/mermaid.ts b/client/src/utils/mermaid.ts deleted file mode 100644 index bc95eb1f1d..0000000000 --- a/client/src/utils/mermaid.ts +++ /dev/null @@ -1,234 +0,0 @@ -import dedent from 'dedent'; - -const mermaid = dedent(`import React, { useEffect, useRef, useState } from "react"; -import { - TransformWrapper, - TransformComponent, - ReactZoomPanPinchRef, -} from "react-zoom-pan-pinch"; -import mermaid from "mermaid"; -import { ZoomIn, ZoomOut, RefreshCw } from "lucide-react"; -import { Button } from "/components/ui/button"; - -interface MermaidDiagramProps { - content: string; -} - -const MermaidDiagram: React.FC = ({ content }) => { - const mermaidRef = useRef(null); - const transformRef = useRef(null); - const [isRendered, setIsRendered] = useState(false); - - useEffect(() => { - mermaid.initialize({ - startOnLoad: false, - theme: "base", - themeVariables: { - background: "#282C34", - primaryColor: "#333842", - secondaryColor: "#333842", - tertiaryColor: "#333842", - primaryTextColor: "#ABB2BF", - secondaryTextColor: "#ABB2BF", - lineColor: "#636D83", - fontSize: "16px", - nodeBorder: "#636D83", - mainBkg: '#282C34', - altBackground: '#282C34', - textColor: '#ABB2BF', - edgeLabelBackground: '#282C34', - clusterBkg: '#282C34', - clusterBorder: "#636D83", - labelBoxBkgColor: "#333842", - labelBoxBorderColor: "#636D83", - labelTextColor: "#ABB2BF", - }, - flowchart: { - curve: "basis", - nodeSpacing: 50, - rankSpacing: 50, - diagramPadding: 8, - htmlLabels: true, - useMaxWidth: true, - padding: 15, - wrappingWidth: 200, - }, - }); - - const renderDiagram = async () => { - if (mermaidRef.current) { - try { - const { svg } = await mermaid.render("mermaid-diagram", content); - mermaidRef.current.innerHTML = svg; - - const svgElement = mermaidRef.current.querySelector("svg"); - if (svgElement) { - svgElement.style.width = "100%"; - svgElement.style.height = "100%"; - - const pathElements = svgElement.querySelectorAll("path"); - pathElements.forEach((path) => { - path.style.strokeWidth = "1.5px"; - }); - - const rectElements = svgElement.querySelectorAll("rect"); - rectElements.forEach((rect) => { - const parent = rect.parentElement; - if (parent && parent.classList.contains("node")) { - rect.style.stroke = "#636D83"; - rect.style.strokeWidth = "1px"; - } else { - rect.style.stroke = "none"; - } - }); - } - setIsRendered(true); - } catch (error) { - console.error("Mermaid rendering error:", error); - mermaidRef.current.innerHTML = "Error rendering diagram"; - } - } - }; - - renderDiagram(); - }, [content]); - - const centerAndFitDiagram = () => { - if (transformRef.current && mermaidRef.current) { - const { centerView, zoomToElement } = transformRef.current; - zoomToElement(mermaidRef.current as HTMLElement); - centerView(1, 0); - } - }; - - useEffect(() => { - if (isRendered) { - centerAndFitDiagram(); - } - }, [isRendered]); - - const handlePanning = () => { - if (transformRef.current) { - const { state, instance } = transformRef.current; - if (!state) { - return; - } - const { scale, positionX, positionY } = state; - const { wrapperComponent, contentComponent } = instance; - - if (wrapperComponent && contentComponent) { - const wrapperRect = wrapperComponent.getBoundingClientRect(); - const contentRect = contentComponent.getBoundingClientRect(); - const maxX = wrapperRect.width - contentRect.width * scale; - const maxY = wrapperRect.height - contentRect.height * scale; - - let newX = positionX; - let newY = positionY; - - if (newX > 0) { - newX = 0; - } - if (newY > 0) { - newY = 0; - } - if (newX < maxX) { - newX = maxX; - } - if (newY < maxY) { - newY = maxY; - } - - if (newX !== positionX || newY !== positionY) { - instance.setTransformState(scale, newX, newY); - } - } - } - }; - - return ( -
- - {({ zoomIn, zoomOut }) => ( - <> - -
- -
- - - -
- - )} - -
- ); -}; - -export default MermaidDiagram;`); - -const wrapMermaidDiagram = (content: string) => { - return dedent(`import React from 'react'; -import MermaidDiagram from '/components/ui/MermaidDiagram'; - -export default App = () => ( - -); -`); -}; - -export const getMermaidFiles = (content: string) => { - return { - 'App.tsx': wrapMermaidDiagram(content), - 'index.tsx': dedent(`import React, { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; -import "./styles.css"; - -import App from "./App"; - -const root = createRoot(document.getElementById("root")); -root.render(); -;`), - '/components/ui/MermaidDiagram.tsx': mermaid, - }; -};