diff --git a/desktop/src/components/chat/MermaidRenderer.integration.test.tsx b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx new file mode 100644 index 00000000..bb8564de --- /dev/null +++ b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import { MermaidRenderer } from './MermaidRenderer' +import { useUIStore } from '../../stores/uiStore' + +type SvgMeasurementPrototype = SVGElement & { + getBBox?: () => { x: number; y: number; width: number; height: number } + getComputedTextLength?: () => number +} + +describe('MermaidRenderer Mermaid integration', () => { + beforeEach(() => { + useUIStore.setState({ theme: 'white' }) + + const svgPrototype = SVGElement.prototype as SvgMeasurementPrototype + + if (!svgPrototype.getBBox) { + Object.defineProperty(svgPrototype, 'getBBox', { + configurable: true, + value: () => ({ + x: 0, + y: 0, + width: 120, + height: 24, + }), + }) + } + + if (!svgPrototype.getComputedTextLength) { + Object.defineProperty(svgPrototype, 'getComputedTextLength', { + configurable: true, + value: () => 96, + }) + } + }) + + it('keeps labels from real Mermaid flowchart SVG output', async () => { + render( + B["主旨系统"]', + ' B --> C["Codex 初期"]', + ].join('\n')} + />, + ) + + const surface = await screen.findByTestId('mermaid-diagram-surface') + + expect(surface).toHaveTextContent('企业建站') + expect(surface).toHaveTextContent('主旨系统') + expect(surface).toHaveTextContent('Codex 初期') + expect(surface.innerHTML).not.toContain(' { expect(previewButton).toBeInTheDocument() expect(initializeMock).toHaveBeenCalledWith(expect.objectContaining({ theme: 'base', + flowchart: expect.objectContaining({ htmlLabels: false }), themeVariables: expect.objectContaining({ darkMode: false }), suppressErrorRendering: true, })) diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index aeefe9d3..16cb1f36 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -74,6 +74,9 @@ function initMermaid(theme: ThemeMode) { mermaid.initialize({ startOnLoad: false, theme: 'base', + flowchart: { + htmlLabels: false, + }, themeVariables: { darkMode: isDark, background: surfaceColor, @@ -109,6 +112,13 @@ function initMermaid(theme: ThemeMode) { let mermaidIdCounter = 0 +function sanitizeMermaidSvg(svg: string) { + return DOMPurify.sanitize(svg, { + USE_PROFILES: { svg: true, svgFilters: true, html: true }, + ADD_TAGS: ['foreignObject'], + }) +} + function clampZoom(value: number) { return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value)) } @@ -353,7 +363,7 @@ export function MermaidRenderer({ code }: Props) { className="flex items-center justify-center overflow-auto bg-[var(--color-surface-container-lowest)] p-4 cursor-pointer" style={{ maxHeight: 400 }} onClick={handlePreview} - dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }} + dangerouslySetInnerHTML={{ __html: sanitizeMermaidSvg(svg) }} /> @@ -419,7 +429,7 @@ export function MermaidRenderer({ code }: Props) { style={previewCanvasStyle} data-dragging={isDraggingPreview ? 'true' : 'false'} aria-label="Mermaid preview canvas" - dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) }} + dangerouslySetInnerHTML={{ __html: sanitizeMermaidSvg(svg) }} /> diff --git a/desktop/src/components/markdown/MarkdownRenderer.test.tsx b/desktop/src/components/markdown/MarkdownRenderer.test.tsx index f0fe5571..3bb7ffc8 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.test.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.test.tsx @@ -95,6 +95,23 @@ describe('MarkdownRenderer', () => { expect(screen.queryByTestId('code-viewer')).not.toBeInTheDocument() }) + it('keeps mermaid blocks in a generating state while assistant text is streaming', () => { + render(B'} streaming />) + + expect(screen.getByTestId('mermaid-streaming-placeholder')).toHaveTextContent( + 'Generating diagram...', + ) + expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument() + expect(screen.queryByTestId('code-viewer')).not.toBeInTheDocument() + }) + + it('does not render completed mermaid blocks until streaming has finalized', () => { + render(B\n```'} streaming />) + + expect(screen.getByTestId('mermaid-streaming-placeholder')).toBeInTheDocument() + expect(screen.queryByTestId('mermaid-renderer')).not.toBeInTheDocument() + }) + it('detects mermaid diagrams even when the fence has no language tag', () => { render(B\n```'} />) diff --git a/desktop/src/components/markdown/MarkdownRenderer.tsx b/desktop/src/components/markdown/MarkdownRenderer.tsx index e29707d8..3c12ae7b 100644 --- a/desktop/src/components/markdown/MarkdownRenderer.tsx +++ b/desktop/src/components/markdown/MarkdownRenderer.tsx @@ -68,6 +68,20 @@ function shouldRenderAsMermaid(block: CodeBlock): boolean { return looksLikeMermaid(block.code) } +function MermaidStreamingPlaceholder() { + return ( +
+
+ progress_activity + Generating diagram... +
+
+ ) +} + const renderer = new marked.Renderer() let pendingCodeBlocks: CodeBlock[] = [] @@ -533,7 +547,11 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian part.type === 'html' ? (
) : shouldRenderAsMermaid(part.block) ? ( - + streaming ? ( + + ) : ( + + ) ) : (