diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index bf9e6f513..a42f8ba75 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -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) => { @@ -70,7 +77,7 @@ export function MarkdownContent({ rehypePlugins={rehypePlugins} components={components} > - {content} + {normalizedContent} ); } diff --git a/frontend/src/core/streamdown/index.ts b/frontend/src/core/streamdown/index.ts index 67bf44c32..f59529b67 100644 --- a/frontend/src/core/streamdown/index.ts +++ b/frontend/src/core/streamdown/index.ts @@ -1 +1,3 @@ +export * from "./mermaid"; +export * from "./preprocess"; export * from "./plugins"; diff --git a/frontend/src/core/streamdown/mermaid.ts b/frontend/src/core/streamdown/mermaid.ts new file mode 100644 index 000000000..98d13163b --- /dev/null +++ b/frontend/src/core/streamdown/mermaid.ts @@ -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"); +} diff --git a/frontend/src/core/streamdown/preprocess.ts b/frontend/src/core/streamdown/preprocess.ts new file mode 100644 index 000000000..6d0c9bfa7 --- /dev/null +++ b/frontend/src/core/streamdown/preprocess.ts @@ -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); +} diff --git a/frontend/tests/e2e/thread-history-mermaid.spec.ts b/frontend/tests/e2e/thread-history-mermaid.spec.ts new file mode 100644 index 000000000..990b11991 --- /dev/null +++ b/frontend/tests/e2e/thread-history-mermaid.spec.ts @@ -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
protagonist] + F[Gu
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); +}); diff --git a/frontend/tests/unit/core/streamdown/mermaid.test.ts b/frontend/tests/unit/core/streamdown/mermaid.test.ts new file mode 100644 index 000000000..71f22e851 --- /dev/null +++ b/frontend/tests/unit/core/streamdown/mermaid.test.ts @@ -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"), + ); +});