mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-08 22:21:23 +00:00
Render mermaid (#170)
This commit is contained in:
parent
08469f8ca3
commit
6c39ff9c2a
6 changed files with 1118 additions and 0 deletions
|
|
@ -47,6 +47,7 @@
|
|||
"import-in-the-middle": "^1.14.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.509.0",
|
||||
"mermaid": "^11.7.0",
|
||||
"next": "^15.3.3",
|
||||
"next-intl": "^4.1.0",
|
||||
"next-themes": "^0.4.6",
|
||||
|
|
@ -79,6 +80,7 @@
|
|||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^22.15.27",
|
||||
"@types/pg": "^8.15.2",
|
||||
"@types/react": "^19.1.6",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { Message } from '@/actions/analytics';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { formatTimestamp } from '@/lib/formatters';
|
||||
import { useAutoScroll } from '@/hooks/useAutoScroll';
|
||||
import { CodeBlock } from '@/components/ui/CodeBlock';
|
||||
|
||||
// Custom component to render links as plain text to avoid broken/nonsensical links
|
||||
const PlainTextLink = ({ children }: { children?: React.ReactNode }) => {
|
||||
|
|
@ -146,6 +147,7 @@ export const Messages = ({ messages }: MessagesProps) => {
|
|||
<ReactMarkdown
|
||||
components={{
|
||||
a: PlainTextLink,
|
||||
code: CodeBlock,
|
||||
}}
|
||||
>
|
||||
{message.text}
|
||||
|
|
|
|||
|
|
@ -274,4 +274,103 @@
|
|||
@apply italic;
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-diagram {
|
||||
& svg {
|
||||
@apply max-w-full h-auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Allow pointer events on interactive elements within the SVG */
|
||||
& svg a,
|
||||
& svg button,
|
||||
& svg [role='button'] {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Ensure mermaid diagrams respect theme colors */
|
||||
& .node rect,
|
||||
& .node circle,
|
||||
& .node ellipse,
|
||||
& .node polygon {
|
||||
@apply fill-background stroke-border;
|
||||
}
|
||||
|
||||
& .node .label {
|
||||
@apply fill-foreground;
|
||||
}
|
||||
|
||||
& .edgePath .path {
|
||||
@apply stroke-foreground;
|
||||
}
|
||||
|
||||
& .edgeLabel {
|
||||
@apply fill-background;
|
||||
}
|
||||
|
||||
& .cluster rect {
|
||||
@apply fill-muted stroke-border;
|
||||
}
|
||||
|
||||
& .titleText {
|
||||
@apply fill-foreground;
|
||||
}
|
||||
|
||||
/* Sequence diagram specific styles */
|
||||
& .actor {
|
||||
@apply fill-background stroke-border;
|
||||
}
|
||||
|
||||
& .actor-line {
|
||||
@apply stroke-border;
|
||||
}
|
||||
|
||||
& .messageLine0,
|
||||
& .messageLine1 {
|
||||
@apply stroke-foreground;
|
||||
}
|
||||
|
||||
& .messageText {
|
||||
@apply fill-foreground;
|
||||
}
|
||||
|
||||
& .loopText {
|
||||
@apply fill-foreground;
|
||||
}
|
||||
|
||||
/* Flowchart specific styles */
|
||||
& .flowchart-link {
|
||||
@apply stroke-foreground;
|
||||
}
|
||||
|
||||
/* Gantt chart specific styles */
|
||||
& .section0,
|
||||
& .section1,
|
||||
& .section2,
|
||||
& .section3 {
|
||||
@apply fill-muted;
|
||||
}
|
||||
|
||||
& .task0,
|
||||
& .task1,
|
||||
& .task2,
|
||||
& .task3 {
|
||||
@apply fill-primary;
|
||||
}
|
||||
|
||||
& .taskText0,
|
||||
& .taskText1,
|
||||
& .taskText2,
|
||||
& .taskText3 {
|
||||
@apply fill-primary-foreground;
|
||||
}
|
||||
|
||||
& .grid .tick {
|
||||
@apply stroke-border;
|
||||
}
|
||||
|
||||
& .grid .tick text {
|
||||
@apply fill-muted-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
apps/web/src/components/ui/CodeBlock.tsx
Normal file
46
apps/web/src/components/ui/CodeBlock.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
import type { Element } from 'hast';
|
||||
|
||||
interface CodeBlockProps extends React.HTMLAttributes<HTMLElement> {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
node?: Element;
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
children,
|
||||
className,
|
||||
inline,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
// Extract language from className (format: "language-xxx")
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
// Convert children to string
|
||||
const code = String(children).replace(/\n$/, '');
|
||||
|
||||
// If it's inline code or not mermaid, render as regular code
|
||||
if (inline || language !== 'mermaid') {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// If it's a mermaid code block, render with MermaidDiagram
|
||||
if (language === 'mermaid') {
|
||||
return <MermaidDiagram chart={code} className="my-4" />;
|
||||
}
|
||||
|
||||
// Fallback to regular code block
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
140
apps/web/src/components/ui/MermaidDiagram.tsx
Normal file
140
apps/web/src/components/ui/MermaidDiagram.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
|
||||
interface MermaidDiagramProps {
|
||||
chart: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MermaidDiagram = ({
|
||||
chart,
|
||||
className = '',
|
||||
}: MermaidDiagramProps) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [svgContent, setSvgContent] = useState<string>('');
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset mounted flag when component mounts
|
||||
isMountedRef.current = true;
|
||||
|
||||
const renderDiagram = async () => {
|
||||
if (!chart.trim()) {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setSvgContent('');
|
||||
}
|
||||
|
||||
const mermaid = (await import('mermaid')).default;
|
||||
|
||||
// Check if component is still mounted after async import
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// Configure mermaid with base theme
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: 'base',
|
||||
suppressErrorRendering: true,
|
||||
});
|
||||
|
||||
// Generate unique ID
|
||||
const id = `mermaid-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Render the diagram
|
||||
const { svg } = await mermaid.render(id, chart);
|
||||
|
||||
// Check if component is still mounted after async render
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
setSvgContent(svg);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
// Only update state if component is still mounted
|
||||
if (isMountedRef.current) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'Failed to render diagram',
|
||||
);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
renderDiagram();
|
||||
|
||||
// Cleanup function to mark component as unmounted
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [chart]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mermaid-diagram bg-background border border-border rounded-md p-4 overflow-x-auto ${className}`}
|
||||
style={{
|
||||
color: 'inherit',
|
||||
minHeight: '100px',
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<div className="text-yellow-800 text-sm font-medium mb-2">
|
||||
Mermaid Diagram Error
|
||||
</div>
|
||||
<div className="text-yellow-700 text-xs font-mono">{error}</div>
|
||||
<details className="mt-2">
|
||||
<summary className="text-yellow-600 text-xs cursor-pointer hover:text-yellow-800">
|
||||
Show diagram source
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-yellow-50 p-2 rounded border border-yellow-200 overflow-x-auto">
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && !error && (
|
||||
<div className="flex items-center justify-center min-h-[100px]">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-current border-t-transparent rounded-full" />
|
||||
<span className="text-sm">Rendering diagram...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{svgContent && !isLoading && !error && (
|
||||
<div className="relative">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: svgContent }}
|
||||
className={`mermaid-svg-container transition-all duration-300 cursor-pointer ${
|
||||
isExpanded ? '' : 'max-h-96 overflow-hidden'
|
||||
}`}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
title={isExpanded ? 'Click to collapse' : 'Click to expand'}
|
||||
/>
|
||||
{!isExpanded && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
<div className="mt-2 text-center">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-2 py-1 rounded border border-border hover:bg-muted"
|
||||
>
|
||||
{isExpanded ? '↑ Collapse diagram' : '↓ Expand diagram'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
829
pnpm-lock.yaml
generated
829
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue