diff --git a/.github/images/mermaid-web-render.png b/.github/images/mermaid-web-render.png new file mode 100644 index 000000000..1da564265 Binary files /dev/null and b/.github/images/mermaid-web-render.png differ diff --git a/web/package-lock.json b/web/package-lock.json index 7c06e36ba..a6ad9e513 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -41,6 +41,7 @@ "js-md5": "^0.8.3", "js-yaml": "^4.1.1", "lucide-react": "^0.561.0", + "mermaid": "^11.12.2", "motion": "^12.23.24", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", diff --git a/web/package.json b/web/package.json index 4c9091313..fb51df77a 100644 --- a/web/package.json +++ b/web/package.json @@ -48,6 +48,7 @@ "js-md5": "^0.8.3", "js-yaml": "^4.1.1", "lucide-react": "^0.561.0", + "mermaid": "^11.12.2", "motion": "^12.23.24", "nanoid": "^5.1.6", "radix-ui": "^1.4.3", diff --git a/web/src/components/ai-elements/code-block.tsx b/web/src/components/ai-elements/code-block.tsx index 9f234c95c..f240b5a5a 100644 --- a/web/src/components/ai-elements/code-block.tsx +++ b/web/src/components/ai-elements/code-block.tsx @@ -16,6 +16,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { MermaidDiagram } from "./mermaid-diagram"; import { type ComponentProps, createContext, @@ -70,6 +71,8 @@ const DOWNLOAD_EXTENSION_BY_LANGUAGE: Record = { yml: "yml", markdown: "md", md: "md", + mermaid: "mmd", + mmd: "mmd", python: "py", py: "py", go: "go", @@ -306,25 +309,27 @@ export const CodeBlock = ({ hadLineNumbers, numbers, } = useMemo(() => sanitizeCodeForLineNumbers(code ?? ""), [code]); + const normalizedLanguage = language?.toLowerCase(); + const isMermaid = normalizedLanguage === "mermaid"; const copyText = sanitizedCode; const wantLineNumbers = showLineNumbers || hadLineNumbers; const cacheKey = useMemo(() => { - if (!language) { + if (!normalizedLanguage || isMermaid) { return null; } return getHighlightCacheKey( sanitizedCode, - language, + normalizedLanguage, wantLineNumbers, numbers, ); - }, [sanitizedCode, language, wantLineNumbers, numbers]); + }, [isMermaid, normalizedLanguage, sanitizedCode, wantLineNumbers, numbers]); useEffect(() => { let cancelled = false; setHtml(""); setDarkHtml(""); - if (!language || !cacheKey) { + if (!normalizedLanguage || !cacheKey) { return () => { cancelled = true; }; @@ -337,7 +342,7 @@ export const CodeBlock = ({ cancelled = true; }; } - highlightCode(sanitizedCode, language, wantLineNumbers, numbers).then( + highlightCode(sanitizedCode, normalizedLanguage, wantLineNumbers, numbers).then( (highlighted) => { if (cancelled || !highlighted) { return; @@ -351,7 +356,7 @@ export const CodeBlock = ({ return () => { cancelled = true; }; - }, [cacheKey, language, numbers, sanitizedCode, wantLineNumbers]); + }, [cacheKey, normalizedLanguage, numbers, sanitizedCode, wantLineNumbers]); // Keep fallback layout close to highlighted output to minimize height deltas. const contentClassName = [ @@ -435,31 +440,37 @@ export const CodeBlock = ({ )} >
- {html ? ( -
+ {isMermaid ? ( + ) : ( -
-
-                    {copyText}
-                  
-
- )} - {darkHtml ? ( -
- ) : ( -
-
-                    {copyText}
-                  
-
+ <> + {html ? ( +
+ ) : ( +
+
+                        {copyText}
+                      
+
+ )} + {darkHtml ? ( +
+ ) : ( +
+
+                        {copyText}
+                      
+
+ )} + )}
diff --git a/web/src/components/ai-elements/mermaid-diagram.tsx b/web/src/components/ai-elements/mermaid-diagram.tsx new file mode 100644 index 000000000..f792478a8 --- /dev/null +++ b/web/src/components/ai-elements/mermaid-diagram.tsx @@ -0,0 +1,193 @@ +"use client"; + +import type { Theme } from "@/hooks/use-theme"; +import { cn } from "@/lib/utils"; +import { AlertTriangleIcon, Loader2Icon } from "lucide-react"; +import { type HTMLAttributes, useEffect, useId, useRef, useState } from "react"; + +type MermaidDiagramProps = HTMLAttributes & { + code: string; +}; + +type MermaidModule = typeof import("mermaid"); +type MermaidBindFunctions = (element: Element) => void; + +let mermaidModulePromise: Promise | null = null; +let mermaidRenderNonce = 0; + +const loadMermaidModule = async (): Promise => { + if (!mermaidModulePromise) { + mermaidModulePromise = import("mermaid"); + } + return mermaidModulePromise; +}; + +const getErrorMessage = (error: unknown): string => { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return "Unable to render Mermaid diagram. Showing the source below."; +}; + +const getDocumentTheme = (): Theme => { + if (typeof document === "undefined") { + return "light"; + } + return document.documentElement.classList.contains("dark") ? "dark" : "light"; +}; + +const useDocumentTheme = (): Theme => { + const [theme, setTheme] = useState(() => getDocumentTheme()); + + useEffect(() => { + if (typeof document === "undefined") { + return; + } + + const root = document.documentElement; + const updateTheme = () => { + setTheme(root.classList.contains("dark") ? "dark" : "light"); + }; + + updateTheme(); + + const observer = new MutationObserver(updateTheme); + observer.observe(root, { + attributes: true, + attributeFilter: ["class"], + }); + + return () => observer.disconnect(); + }, []); + + return theme; +}; + +export function MermaidDiagram({ + code, + className, + ...props +}: MermaidDiagramProps) { + const theme = useDocumentTheme(); + const containerRef = useRef(null); + const bindFunctionsRef = useRef(null); + const renderId = useId().replaceAll(":", ""); + const [svg, setSvg] = useState(""); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + const source = code.trim(); + + setSvg(""); + setError(null); + bindFunctionsRef.current = null; + + if (!source) { + setError("Mermaid diagram is empty."); + return () => { + cancelled = true; + }; + } + + const renderDiagram = async () => { + try { + const mermaid = (await loadMermaidModule()).default; + + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme: theme === "dark" ? "dark" : "default", + fontFamily: + "Inter Variable, Inter, -apple-system, BlinkMacSystemFont, sans-serif", + }); + + const diagramId = `mermaid-${renderId}-${mermaidRenderNonce++}`; + const rendered = await mermaid.render(diagramId, source); + + if (cancelled) { + return; + } + + bindFunctionsRef.current = rendered.bindFunctions ?? null; + setSvg(rendered.svg); + setError(null); + } catch (renderError) { + if (cancelled) { + return; + } + + bindFunctionsRef.current = null; + setSvg(""); + setError(getErrorMessage(renderError)); + } + }; + + void renderDiagram(); + + return () => { + cancelled = true; + }; + }, [code, renderId, theme]); + + useEffect(() => { + if (!svg || !bindFunctionsRef.current || !containerRef.current) { + return; + } + + bindFunctionsRef.current(containerRef.current); + bindFunctionsRef.current = null; + }, [svg]); + + if (error) { + return ( +
+
+ +
+
Couldn't render Mermaid diagram
+
+ {error} +
+
+
+
+          {code}
+        
+
+ ); + } + + if (!svg) { + return ( +
+ + Rendering Mermaid diagram... +
+ ); + } + + return ( +
+ ); +} diff --git a/web/src/index.css b/web/src/index.css index 06601e57e..d77a06433 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -365,6 +365,21 @@ body { font-size: 0.75rem; } + [data-mermaid-diagram] { + overflow-x: auto; + } + + [data-mermaid-diagram] svg { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; + } + + [data-mermaid-diagram] .label { + color: var(--foreground); + } + /* ===== Animations ===== */ @keyframes glow-pulse { 0%,