diff --git a/surfsense_web/components/assistant-ui/markdown-code-block.tsx b/surfsense_web/components/assistant-ui/markdown-code-block.tsx
index e6c735d1e..88b0916b8 100644
--- a/surfsense_web/components/assistant-ui/markdown-code-block.tsx
+++ b/surfsense_web/components/assistant-ui/markdown-code-block.tsx
@@ -6,6 +6,7 @@ import { memo, useEffect, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism";
+import { MermaidDiagram } from "@/components/assistant-ui/mermaid-diagram";
import { Button } from "@/components/ui/button";
import { cn, copyToClipboard } from "@/lib/utils";
@@ -40,6 +41,7 @@ function MarkdownCodeBlockComponent({
isDarkMode,
}: MarkdownCodeBlockProps) {
const [hasCopied, setHasCopied] = useState(false);
+ const normalizedLanguage = language.toLowerCase();
useEffect(() => {
if (!hasCopied) return;
@@ -47,7 +49,7 @@ function MarkdownCodeBlockComponent({
return () => clearTimeout(timer);
}, [hasCopied]);
- return (
+ const codeBlock = (
{language}
@@ -78,6 +80,12 @@ function MarkdownCodeBlockComponent({
);
+
+ if (normalizedLanguage === "mermaid") {
+ return
;
+ }
+
+ return codeBlock;
}
export const MarkdownCodeBlock = memo(MarkdownCodeBlockComponent);
diff --git a/surfsense_web/components/assistant-ui/mermaid-diagram.tsx b/surfsense_web/components/assistant-ui/mermaid-diagram.tsx
new file mode 100644
index 000000000..50f1dc6de
--- /dev/null
+++ b/surfsense_web/components/assistant-ui/mermaid-diagram.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { CheckIcon, CopyIcon } from "lucide-react";
+import mermaid from "mermaid";
+import { memo, type ReactNode, useEffect, useId, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { copyToClipboard } from "@/lib/utils";
+
+type MermaidDiagramProps = {
+ source: string;
+ isDarkMode: boolean;
+ fallback: ReactNode;
+};
+
+let mermaidInitialized = false;
+
+function initializeMermaid() {
+ if (mermaidInitialized) return;
+
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "strict",
+ htmlLabels: false,
+ flowchart: { htmlLabels: false },
+ sequence: { useMaxWidth: true },
+ });
+
+ mermaidInitialized = true;
+}
+
+function MermaidDiagramComponent({
+ source,
+ isDarkMode,
+ fallback,
+}: MermaidDiagramProps) {
+ const id = useId();
+ const [svg, setSvg] = useState
(null);
+ const [hasError, setHasError] = useState(false);
+ const [hasCopied, setHasCopied] = useState(false);
+
+ useEffect(() => {
+ let isCurrent = true;
+
+ const renderId = `mermaid-${id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
+
+ setSvg(null);
+ setHasError(false);
+
+ (async () => {
+ try {
+ initializeMermaid();
+
+ // فقط theme اینجا تنظیم میشه (نه re-init کامل)
+ mermaid.initialize({
+ startOnLoad: false,
+ securityLevel: "strict",
+ htmlLabels: false,
+ theme: isDarkMode ? "dark" : "default",
+ flowchart: { htmlLabels: false },
+ sequence: { useMaxWidth: true },
+ });
+
+ await mermaid.parse(source);
+
+ const { svg } = await mermaid.render(renderId, source);
+
+ if (isCurrent) {
+ setSvg(svg);
+ }
+ } catch (error) {
+ console.error("[mermaid] Failed to render diagram", error);
+
+ if (isCurrent) {
+ setHasError(true);
+ }
+ }
+ })();
+
+ return () => {
+ isCurrent = false;
+ };
+ }, [id, isDarkMode, source]);
+
+ useEffect(() => {
+ if (!hasCopied) return;
+
+ const timer = setTimeout(() => setHasCopied(false), 2000);
+ return () => clearTimeout(timer);
+ }, [hasCopied]);
+
+ if (hasError) return fallback;
+
+ return (
+
+
+ mermaid
+
+
+
+
+
+ {svg ? (
+ // biome-ignore lint/performance/noImgElement: svg is in-memory string
+
}`})
+ ) : (
+
+ )}
+
+
+ );
+}
+
+export const MermaidDiagram = memo(MermaidDiagramComponent);
\ No newline at end of file