mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
fd25e5732f
commit
66a0d45627
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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```'} />)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user