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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 19:57:39 +08:00
parent fd25e5732f
commit 66a0d45627
5 changed files with 107 additions and 3 deletions

View File

@ -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(
<MermaidRenderer
code={[
'flowchart TB',
' A["企业建站"] --> 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('<script')
expect(surface.innerHTML).not.toContain('onerror')
})
})

View File

@ -34,6 +34,7 @@ describe('MermaidRenderer', () => {
expect(previewButton).toBeInTheDocument()
expect(initializeMock).toHaveBeenCalledWith(expect.objectContaining({
theme: 'base',
flowchart: expect.objectContaining({ htmlLabels: false }),
themeVariables: expect.objectContaining({ darkMode: false }),
suppressErrorRendering: true,
}))

View File

@ -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) }}
/>
</div>
@ -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) }}
/>
</div>
</div>

View File

@ -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(<MarkdownRenderer content={'```mermaid\ngraph TB\nA-->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(<MarkdownRenderer content={'```mermaid\ngraph TB\nA-->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(<MarkdownRenderer content={'```\ngraph TB\nA-->B\n```'} />)

View File

@ -68,6 +68,20 @@ function shouldRenderAsMermaid(block: CodeBlock): boolean {
return looksLikeMermaid(block.code)
}
function MermaidStreamingPlaceholder() {
return (
<div
data-testid="mermaid-streaming-placeholder"
className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)]/50 bg-[var(--color-surface-container-low)] py-8"
>
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
Generating diagram...
</div>
</div>
)
}
const renderer = new marked.Renderer()
let pendingCodeBlocks: CodeBlock[] = []
@ -533,7 +547,11 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ content, varian
part.type === 'html' ? (
<div key={i} dangerouslySetInnerHTML={{ __html: part.content }} />
) : shouldRenderAsMermaid(part.block) ? (
<MermaidRenderer key={part.block.id} code={part.block.code} />
streaming ? (
<MermaidStreamingPlaceholder key={part.block.id} />
) : (
<MermaidRenderer key={part.block.id} code={part.block.code} />
)
) : (
<div key={part.block.id} className="my-4">
<CodeViewer