mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-05-30 20:38:09 +00:00
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:
parent
737abc0e45
commit
2fdfff0db3
@ -7,7 +7,10 @@ import {
|
|||||||
MessageResponse,
|
MessageResponse,
|
||||||
type MessageResponseProps,
|
type MessageResponseProps,
|
||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import {
|
||||||
|
preprocessStreamdownMarkdown,
|
||||||
|
streamdownPlugins,
|
||||||
|
} from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
@ -33,6 +36,10 @@ export function MarkdownContent({
|
|||||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
remarkPlugins = streamdownPlugins.remarkPlugins,
|
||||||
components: componentsFromProps,
|
components: componentsFromProps,
|
||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
|
const normalizedContent = useMemo(
|
||||||
|
() => preprocessStreamdownMarkdown(content),
|
||||||
|
[content],
|
||||||
|
);
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
@ -70,7 +77,7 @@ export function MarkdownContent({
|
|||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
components={components}
|
components={components}
|
||||||
>
|
>
|
||||||
{content}
|
{normalizedContent}
|
||||||
</MessageResponse>
|
</MessageResponse>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1 +1,3 @@
|
|||||||
|
export * from "./mermaid";
|
||||||
|
export * from "./preprocess";
|
||||||
export * from "./plugins";
|
export * from "./plugins";
|
||||||
|
|||||||
98
frontend/src/core/streamdown/mermaid.ts
Normal file
98
frontend/src/core/streamdown/mermaid.ts
Normal 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");
|
||||||
|
}
|
||||||
11
frontend/src/core/streamdown/preprocess.ts
Normal file
11
frontend/src/core/streamdown/preprocess.ts
Normal 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);
|
||||||
|
}
|
||||||
91
frontend/tests/e2e/thread-history-mermaid.spec.ts
Normal file
91
frontend/tests/e2e/thread-history-mermaid.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
130
frontend/tests/unit/core/streamdown/mermaid.test.ts
Normal file
130
frontend/tests/unit/core/streamdown/mermaid.test.ts
Normal 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"),
|
||||||
|
);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user