From 66a0d456270ff9e97923dbd7d457c034be85d5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 30 May 2026 19:57:39 +0800 Subject: [PATCH] fix: hold Mermaid rendering until streams finish #636 Streaming assistant output can expose incomplete Mermaid fences before the model has closed the diagram syntax. Keep those blocks in a generating state until the assistant message finalizes, then render the completed diagram. Also preserve Mermaid label content in sanitized SVG output so preview and expanded views keep node text visible. Constraint: marked treats unclosed fenced code as a code block during streaming Rejected: Keep retrying Mermaid render on partial syntax | users still see parse errors while valid output is incomplete Confidence: high Scope-risk: narrow Directive: Do not render parser-backed diagram components from streaming markdown without an explicit pending state Tested: bun run check:desktop Not-tested: live provider smoke after Codex host Crashpad renderer/utility dumps were observed --- .../chat/MermaidRenderer.integration.test.tsx | 58 +++++++++++++++++++ .../components/chat/MermaidRenderer.test.tsx | 1 + .../src/components/chat/MermaidRenderer.tsx | 14 ++++- .../markdown/MarkdownRenderer.test.tsx | 17 ++++++ .../components/markdown/MarkdownRenderer.tsx | 20 ++++++- 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 desktop/src/components/chat/MermaidRenderer.integration.test.tsx 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 ? ( + + ) : ( + + ) ) : (