mirror of
https://github.com/arc53/DocsGPT.git
synced 2025-11-29 08:33:20 +00:00
feat[mermaid]:integration of mermaid
This commit is contained in:
1311
frontend/package-lock.json
generated
1311
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@
|
||||
"chart.js": "^4.4.4",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"mermaid": "^11.6.0",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
@@ -40,6 +41,7 @@
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/mermaid": "^9.1.0",
|
||||
"@types/react": "^18.0.27",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-helmet": "^6.1.11",
|
||||
|
||||
297
frontend/src/components/MermaidRenderer.tsx
Normal file
297
frontend/src/components/MermaidRenderer.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import mermaid from 'mermaid';
|
||||
import CopyButton from './CopyButton';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { selectStatus } from '../conversation/conversationSlice';
|
||||
|
||||
interface MermaidRendererProps {
|
||||
code: string;
|
||||
isDarkTheme: boolean;
|
||||
}
|
||||
|
||||
const MermaidRenderer: React.FC<MermaidRendererProps> = ({
|
||||
code,
|
||||
isDarkTheme,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const status = useSelector(selectStatus);
|
||||
const [svgContent, setSvgContent] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCode, setShowCode] = useState<boolean>(false);
|
||||
const [showDownloadMenu, setShowDownloadMenu] = useState<boolean>(false);
|
||||
const downloadMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading' || !code) return;
|
||||
// Initialize mermaid with the current theme
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDarkTheme ? 'dark' : 'default',
|
||||
securityLevel: 'loose',
|
||||
});
|
||||
const renderDiagram = async (): Promise<void> => {
|
||||
try {
|
||||
// Generate unique ID
|
||||
const id = `mermaid-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
// Render the diagram
|
||||
const { svg } = await mermaid.render(id, code);
|
||||
setSvgContent(svg);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Mermaid rendering error:', err);
|
||||
setError(
|
||||
`Failed to render Mermaid diagram: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
setSvgContent('');
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
}, [code, isDarkTheme, status]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
downloadMenuRef.current &&
|
||||
!downloadMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDownloadMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showDownloadMenu]);
|
||||
|
||||
// Function to download as SVG
|
||||
const downloadSvg = (): void => {
|
||||
if (!svgContent) return;
|
||||
|
||||
// Add XML declaration and ensure proper namespaces
|
||||
let enhancedSvg = svgContent;
|
||||
if (!enhancedSvg.includes('xmlns=')) {
|
||||
enhancedSvg = enhancedSvg.replace(
|
||||
'<svg',
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"',
|
||||
);
|
||||
}
|
||||
|
||||
// Add explicit width and height if missing
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(enhancedSvg, 'image/svg+xml');
|
||||
const svgElement = svgDoc.documentElement;
|
||||
|
||||
if (
|
||||
!svgElement.hasAttribute('width') &&
|
||||
svgElement.hasAttribute('viewBox')
|
||||
) {
|
||||
const viewBox = svgElement.getAttribute('viewBox')?.split(' ') || [];
|
||||
if (viewBox.length === 4) {
|
||||
svgElement.setAttribute('width', viewBox[2]);
|
||||
svgElement.setAttribute('height', viewBox[3]);
|
||||
}
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const finalSvgString = serializer.serializeToString(svgDoc);
|
||||
|
||||
const blob = new Blob([finalSvgString], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'diagram.svg';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Function to download as PNG
|
||||
const downloadPng = (): void => {
|
||||
if (!svgContent) return;
|
||||
|
||||
// Parse the SVG
|
||||
const parser = new DOMParser();
|
||||
const svgDoc = parser.parseFromString(svgContent, 'image/svg+xml');
|
||||
const svgElement = svgDoc.documentElement;
|
||||
|
||||
// Ensure SVG has dimensions
|
||||
let width = parseInt(svgElement.getAttribute('width') || '0');
|
||||
let height = parseInt(svgElement.getAttribute('height') || '0');
|
||||
|
||||
// If dimensions are missing, try to get from viewBox
|
||||
if (!width || !height) {
|
||||
const viewBox = svgElement.getAttribute('viewBox')?.split(' ') || [];
|
||||
if (viewBox.length === 4) {
|
||||
width = parseInt(viewBox[2]);
|
||||
height = parseInt(viewBox[3]);
|
||||
svgElement.setAttribute('width', width.toString());
|
||||
svgElement.setAttribute('height', height.toString());
|
||||
} else {
|
||||
width = 800;
|
||||
height = 600;
|
||||
svgElement.setAttribute('width', width.toString());
|
||||
svgElement.setAttribute('height', height.toString());
|
||||
}
|
||||
}
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const svgString = serializer.serializeToString(svgDoc);
|
||||
|
||||
// Create an Image object
|
||||
const img = new Image();
|
||||
|
||||
img.onload = function (): void {
|
||||
// Create a canvas with proper dimensions
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
console.error('Could not get canvas context');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill with white background
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw the image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Convert to PNG and download
|
||||
const pngUrl = canvas.toDataURL('image/png');
|
||||
const link = document.createElement('a');
|
||||
link.download = 'diagram.png';
|
||||
link.href = pngUrl;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Set the image source to the SVG
|
||||
const svgBlob = new Blob([svgString], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
img.src = url;
|
||||
};
|
||||
|
||||
const downloadMmd = (): void => {
|
||||
const blob = new Blob([code], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'diagram.mmd';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const downloadOptions = [
|
||||
{ label: 'Download as SVG', action: downloadSvg },
|
||||
{ label: 'Download as PNG', action: downloadPng },
|
||||
{ label: 'Download as MMD', action: downloadMmd },
|
||||
];
|
||||
|
||||
const showDiagramOptions = status !== 'loading' && !error;
|
||||
const errorRender = status !== 'loading' && error;
|
||||
|
||||
return (
|
||||
<div className="group relative rounded-lg overflow-hidden border border-light-silver dark:border-raisin-black">
|
||||
<div className="flex justify-between items-center px-2 py-1 bg-platinum dark:bg-eerie-black-2">
|
||||
<span className="text-xs font-medium text-just-black dark:text-chinese-white">
|
||||
mermaid
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyButton text={String(code).replace(/\n$/, '')} />
|
||||
|
||||
{showDiagramOptions && (
|
||||
<div className="relative" ref={downloadMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowDownloadMenu(!showDownloadMenu)}
|
||||
className="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded flex items-center h-full"
|
||||
title="Download options"
|
||||
>
|
||||
Download <span className="ml-1">▼</span>
|
||||
</button>
|
||||
{showDownloadMenu && (
|
||||
<div className="absolute right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded shadow-lg z-10 w-40">
|
||||
<ul>
|
||||
{downloadOptions.map((option, index) => (
|
||||
<li key={index}>
|
||||
<button
|
||||
onClick={() => {
|
||||
option.action();
|
||||
setShowDownloadMenu(false);
|
||||
}}
|
||||
className="text-xs px-4 py-2 w-full text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDiagramOptions && (
|
||||
<button
|
||||
onClick={() => setShowCode(!showCode)}
|
||||
className={`text-xs px-2 py-1 rounded flex items-center h-full ${
|
||||
showCode
|
||||
? 'bg-blue-200 dark:bg-blue-800'
|
||||
: 'bg-gray-100 dark:bg-gray-700'
|
||||
}`}
|
||||
title="View Code"
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === 'loading' ? (
|
||||
<div className="p-4 bg-white dark:bg-eerie-black flex justify-center items-center">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Loading diagram...
|
||||
</div>
|
||||
</div>
|
||||
) : errorRender ? (
|
||||
<div className="border-2 border-red-400 dark:border-red-700 rounded m-2">
|
||||
<div className="bg-red-100 dark:bg-red-900/30 px-4 py-2 text-red-800 dark:text-red-300 text-sm whitespace-normal break-words overflow-auto">
|
||||
{error}
|
||||
</div>
|
||||
{/* <pre className="p-4 whitespace-pre-wrap overflow-auto bg-white dark:bg-eerie-black text-just-black dark:text-chinese-white">
|
||||
{code}
|
||||
</pre> */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col md:flex-row">
|
||||
{/* Show the diagram */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`p-4 bg-white dark:bg-eerie-black flex justify-center items-center ${
|
||||
showCode ? 'md:w-1/2' : 'w-full'
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||
/>
|
||||
|
||||
{/* Show the code when button is clicked */}
|
||||
{showCode && (
|
||||
<pre className="p-4 whitespace-pre-wrap overflow-auto bg-white dark:bg-eerie-black text-just-black dark:text-chinese-white border-t md:border-t-0 md:border-l border-light-silver dark:border-raisin-black md:w-1/2">
|
||||
{code}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MermaidRenderer;
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import classes from './ConversationBubble.module.css';
|
||||
import { FEEDBACK, MESSAGE_TYPE } from './conversationModels';
|
||||
import { ToolCallsType } from './types';
|
||||
import MermaidRenderer from '../components/MermaidRenderer';
|
||||
|
||||
const DisableSourceFE = import.meta.env.VITE_DISABLE_SOURCE_FE || false;
|
||||
|
||||
@@ -101,26 +102,25 @@ const ConversationBubble = forwardRef<
|
||||
let bubble;
|
||||
const renderAttachments = () => {
|
||||
if (!attachments || attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{attachments.map((attachment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center rounded-md bg-gray-100 px-2 py-1 text-sm dark:bg-gray-700"
|
||||
>
|
||||
<svg
|
||||
className="mr-1 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
<svg
|
||||
className="mr-1 h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
|
||||
/>
|
||||
</svg>
|
||||
<span>{attachment.fileName}</span>
|
||||
@@ -792,6 +792,15 @@ function Thought({
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
if (language === 'mermaid') {
|
||||
return (
|
||||
<MermaidRenderer
|
||||
isDarkTheme={isDarkTheme}
|
||||
code={String(children)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return match ? (
|
||||
<div className="group relative rounded-[14px] overflow-hidden border border-light-silver dark:border-raisin-black">
|
||||
<div className="flex justify-between items-center px-2 py-1 bg-platinum dark:bg-eerie-black-2">
|
||||
|
||||
Reference in New Issue
Block a user