fix(frontend): fix Mermaid preview failure in historical messages (#3196)

* fix(frontend): render historical mermaid diagrams

* fix(frontend): address mermaid review feedback

* Stabilize cancel lifecycle test

* fix(frontend): handle mermaid fence variants

* fix(frontend): normalize mermaid arrow spacing

* fix(frontend): handle mermaid CRLF fences

* chore: keep mermaid fix frontend-scoped

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Admire 2026-05-28 18:20:02 +08:00 committed by GitHub
parent 737abc0e45
commit 2fdfff0db3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 341 additions and 2 deletions

View File

@ -7,7 +7,10 @@ import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
import {
preprocessStreamdownMarkdown,
streamdownPlugins,
} from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { CitationLink } from "../citations/citation-link";
@ -33,6 +36,10 @@ export function MarkdownContent({
remarkPlugins = streamdownPlugins.remarkPlugins,
components: componentsFromProps,
}: MarkdownContentProps) {
const normalizedContent = useMemo(
() => preprocessStreamdownMarkdown(content),
[content],
);
const components = useMemo(() => {
return {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
@ -70,7 +77,7 @@ export function MarkdownContent({
rehypePlugins={rehypePlugins}
components={components}
>
{content}
{normalizedContent}
</MessageResponse>
);
}

View File

@ -1 +1,3 @@
export * from "./mermaid";
export * from "./preprocess";
export * from "./plugins";

View File

@ -0,0 +1,98 @@
const MERMAID_OPENING_FENCE_RE =
/^[ \t]{0,3}(`{3,}|~{3,})[ \t]*mermaid(?:[ \t].*)?$/i;
const WINDOWS_LINE_ENDING_RE = /\r\n?/g;
const LABELLED_DOTTED_ARROW_RE =
/^(\s*)(.+?)\s*--\s*("[^"\n]+"|'[^'\n]+')\s*-\.->\s*(.+?)\s*$/;
function normalizeMermaidCode(code: string): string {
return code
.split("\n")
.map((line) =>
line.replace(
LABELLED_DOTTED_ARROW_RE,
(
_match,
indent: string,
source: string,
label: string,
target: string,
) => `${indent}${source} -. ${label} .-> ${target}`,
),
)
.join("\n");
}
function isClosingFence(line: string, fence: string): boolean {
const trimmedLine = line.trimEnd();
const indentationLength = trimmedLine.length - trimmedLine.trimStart().length;
const fenceMarker = trimmedLine.slice(indentationLength);
const fenceChar = fence.charAt(0);
if (indentationLength > 3 || !fenceMarker.startsWith(fenceChar)) {
return false;
}
return (
fenceMarker.length >= fence.length &&
[...fenceMarker].every((char) => char === fenceChar)
);
}
export function normalizeMermaidMarkdown(markdown: string): string {
const lines = markdown.replace(WINDOWS_LINE_ENDING_RE, "\n").split("\n");
const normalizedLines: string[] = [];
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
const openingFenceMatch = MERMAID_OPENING_FENCE_RE.exec(line);
if (!openingFenceMatch) {
normalizedLines.push(line);
continue;
}
const openingFence = openingFenceMatch[1];
if (openingFence === undefined) {
normalizedLines.push(line);
continue;
}
const codeLines: string[] = [];
let closingLine: string | undefined;
let cursor = index + 1;
for (; cursor < lines.length; cursor += 1) {
const candidateLine = lines[cursor]!;
if (isClosingFence(candidateLine, openingFence)) {
closingLine = candidateLine;
break;
}
codeLines.push(candidateLine);
}
if (closingLine === undefined) {
normalizedLines.push(line, ...codeLines);
index = cursor - 1;
continue;
}
normalizedLines.push(line);
if (codeLines.length > 0) {
normalizedLines.push(
...normalizeMermaidCode(codeLines.join("\n")).split("\n"),
);
}
normalizedLines.push(closingLine);
index = cursor;
}
return normalizedLines.join("\n");
}

View File

@ -0,0 +1,11 @@
import { normalizeMermaidMarkdown } from "./mermaid";
const MERMAID_BLOCK_HINT_RE = /mermaid/i;
export function preprocessStreamdownMarkdown(markdown: string): string {
if (!MERMAID_BLOCK_HINT_RE.test(markdown) || !markdown.includes("-.->")) {
return markdown;
}
return normalizeMermaidMarkdown(markdown);
}

View File

@ -0,0 +1,91 @@
import { expect, test } from "@playwright/test";
import {
mockLangGraphAPI,
MOCK_RUN_ID,
MOCK_THREAD_ID,
} from "./utils/mock-api";
const mermaidContent = `Here is a relationship diagram.
\`\`\`mermaid
flowchart TD
A[Lin<br/>protagonist]
F[Gu<br/>daughter]
A -- "sealed memory" -.-> F
\`\`\`
`;
test("historical run messages preview labelled dotted Mermaid arrows", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Mermaid history",
updated_at: "2026-05-24T04:47:01.123949+00:00",
},
],
});
await page.route(/\/api\/langgraph\/threads\/[^/]+\/runs(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
run_id: MOCK_RUN_ID,
thread_id: MOCK_THREAD_ID,
status: "success",
created_at: "2026-05-24T04:46:42.565307+00:00",
updated_at: "2026-05-24T04:47:01.123949+00:00",
},
]),
}),
);
await page.route(
`**/api/threads/${MOCK_THREAD_ID}/runs/${MOCK_RUN_ID}/messages`,
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [
{
thread_id: MOCK_THREAD_ID,
run_id: MOCK_RUN_ID,
event_type: "llm.ai.response",
category: "message",
content: {
content: mermaidContent,
additional_kwargs: {},
response_metadata: {},
type: "ai",
name: null,
id: "lc_run--issue-3193",
tool_calls: [],
invalid_tool_calls: [],
},
seq: 720,
created_at: "2026-05-24T04:47:01.123949+00:00",
metadata: {
caller: "lead_agent",
content_is_json: true,
content_is_dict: true,
},
},
],
has_more: false,
}),
}),
);
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByLabel("Mermaid chart")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("Mermaid Error:")).toHaveCount(0);
});

View File

@ -0,0 +1,130 @@
import { expect, test } from "vitest";
import { normalizeMermaidMarkdown } from "@/core/streamdown/mermaid";
import { preprocessStreamdownMarkdown } from "@/core/streamdown/preprocess";
test("normalizes labelled dotted arrows inside mermaid fences", () => {
const markdown = [
"```mermaid",
"flowchart TD",
' A -- "sealed memory" -.-> F',
' B -- "resonance" -.-> A',
"```",
].join("\n");
expect(normalizeMermaidMarkdown(markdown)).toBe(
[
"```mermaid",
"flowchart TD",
' A -. "sealed memory" .-> F',
' B -. "resonance" .-> A',
"```",
].join("\n"),
);
});
test("does not rewrite non-mermaid code fences", () => {
const markdown = ["```text", 'A -- "sealed memory" -.-> F', "```"].join("\n");
expect(normalizeMermaidMarkdown(markdown)).toBe(markdown);
});
test("preserves mermaid fence metadata", () => {
const markdown = [
'```mermaid title="relationships"',
'A -- "sealed memory" -.-> F',
"```",
].join("\n");
expect(normalizeMermaidMarkdown(markdown)).toBe(
[
'```mermaid title="relationships"',
'A -. "sealed memory" .-> F',
"```",
].join("\n"),
);
});
test("normalizes labelled dotted arrows with inconsistent spacing", () => {
const markdown = [
"```mermaid",
'A--"sealed memory"-.->F',
'B --"resonance"-.-> A',
'C-- "handoff" -.->D',
"```",
].join("\n");
expect(normalizeMermaidMarkdown(markdown)).toBe(
[
"```mermaid",
'A -. "sealed memory" .-> F',
'B -. "resonance" .-> A',
'C -. "handoff" .-> D',
"```",
].join("\n"),
);
});
test("normalizes mermaid fences with CRLF line endings", () => {
const markdown = ["```mermaid", 'A--"sealed memory"-.->F', "```"].join(
"\r\n",
);
expect(normalizeMermaidMarkdown(markdown)).toBe(
["```mermaid", 'A -. "sealed memory" .-> F', "```"].join("\n"),
);
});
test("preserves empty mermaid fences", () => {
const markdown = ["```mermaid", "```"].join("\n");
expect(normalizeMermaidMarkdown(markdown)).toBe(markdown);
});
test("normalizes labelled dotted arrows inside tilde mermaid fences", () => {
const markdown = ["~~~mermaid", 'A -- "sealed memory" -.-> F', "~~~"].join(
"\n",
);
expect(normalizeMermaidMarkdown(markdown)).toBe(
["~~~mermaid", 'A -. "sealed memory" .-> F', "~~~"].join("\n"),
);
});
test("normalizes mermaid fences with longer backtick closing fences", () => {
const markdown = ["```mermaid", 'A -- "sealed memory" -.-> F', "````"].join(
"\n",
);
expect(normalizeMermaidMarkdown(markdown)).toBe(
["```mermaid", 'A -. "sealed memory" .-> F', "````"].join("\n"),
);
});
test("normalizes mermaid fences with longer tilde closing fences", () => {
const markdown = ["~~~mermaid", 'A -- "sealed memory" -.-> F', "~~~~"].join(
"\n",
);
expect(normalizeMermaidMarkdown(markdown)).toBe(
["~~~mermaid", 'A -. "sealed memory" .-> F', "~~~~"].join("\n"),
);
});
test("preprocesses markdown only when mermaid normalization can apply", () => {
const textOnlyMarkdown = 'A -- "sealed memory" -.-> F';
const plainMermaidMarkdown = ["```mermaid", "A --> F", "```"].join("\n");
const labelledMermaidMarkdown = [
"```mermaid",
'A -- "sealed memory" -.-> F',
"```",
].join("\n");
expect(preprocessStreamdownMarkdown(textOnlyMarkdown)).toBe(textOnlyMarkdown);
expect(preprocessStreamdownMarkdown(plainMermaidMarkdown)).toBe(
plainMermaidMarkdown,
);
expect(preprocessStreamdownMarkdown(labelledMermaidMarkdown)).toBe(
["```mermaid", 'A -. "sealed memory" .-> F', "```"].join("\n"),
);
});