mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-06-13 10:55:59 +00:00
fix(frontend): render user messages as plain text and cap blockquote nesting (#3502)
* fix(frontend): render user messages as plain text and cap blockquote nesting User messages are typed or pasted plain text, not authored Markdown, but they were rendered through the full Streamdown pipeline. Pasted source files got fragmented (indented chunks become code blocks, paragraphs collapse and lose indentation), "$...$" spans were KaTeX-ified, and a message with thousands of nested ">" markers overflowed the call stack in marked's recursive blockquote lexer, permanently crashing the thread on every load. Render human message content verbatim with pre-wrap instead, and cap blockquote nesting at 100 levels at the Streamdown chokepoint so model output cannot trigger the same recursion either. Closes #3500 * fix(frontend): absorb marked lexer crashes with a render fallback boundary Review found two gaps in the nesting cap: marked's list and blockquote tokenizers are mutually recursive, so a list marker in front of the quote chain ("- > > > ...") bypassed the blockquote-only regex and still overflowed the stack; and the line-based rewrite was fence-blind, silently truncating literal ">" runs inside code blocks. Add an error boundary around Streamdown that renders the raw content as plain pre-wrap text when rendering throws (retrying on the next content change), keep the cap as a fast path for the dominant pure-">" case, and make it skip fenced and indented code lines.
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { type ComponentProps } from "react";
|
||||
import { Component, useMemo, type ComponentProps, type ReactNode } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { installClipboardFallback } from "@/core/clipboard";
|
||||
import { capBlockquoteNesting } from "@/core/streamdown/preprocess";
|
||||
|
||||
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
@@ -12,6 +13,61 @@ if (typeof document !== "undefined") {
|
||||
installClipboardFallback();
|
||||
}
|
||||
|
||||
export function ClipboardSafeStreamdown(props: ClipboardSafeStreamdownProps) {
|
||||
return <Streamdown {...props} />;
|
||||
// marked (used by Streamdown to split content into blocks) has mutually
|
||||
// recursive tokenizers — blockquote/list nesting a couple thousand levels
|
||||
// deep overflows the call stack during render and would otherwise take down
|
||||
// the whole route. When rendering a message throws, fall back to showing
|
||||
// that message as plain pre-formatted text instead.
|
||||
class StreamdownFallbackBoundary extends Component<
|
||||
{ raw: ClipboardSafeStreamdownProps["children"]; children: ReactNode },
|
||||
{ errored: boolean; prevRaw: ClipboardSafeStreamdownProps["children"] }
|
||||
> {
|
||||
state = { errored: false, prevRaw: this.props.raw };
|
||||
|
||||
static getDerivedStateFromError() {
|
||||
return { errored: true };
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(
|
||||
props: { raw: ClipboardSafeStreamdownProps["children"] },
|
||||
state: {
|
||||
errored: boolean;
|
||||
prevRaw: ClipboardSafeStreamdownProps["children"];
|
||||
},
|
||||
) {
|
||||
// Retry rendering when the content changes (e.g. the next streaming chunk).
|
||||
if (props.raw !== state.prevRaw) {
|
||||
return { errored: false, prevRaw: props.raw };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.errored) {
|
||||
return (
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{typeof this.props.raw === "string" ? this.props.raw : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function ClipboardSafeStreamdown({
|
||||
children,
|
||||
...props
|
||||
}: ClipboardSafeStreamdownProps) {
|
||||
// Fast path for the dominant pathological input (pure ">" chains) so the
|
||||
// error boundary below rarely has to absorb a full stack overflow.
|
||||
const safeChildren = useMemo(
|
||||
() =>
|
||||
typeof children === "string" ? capBlockquoteNesting(children) : children,
|
||||
[children],
|
||||
);
|
||||
return (
|
||||
<StreamdownFallbackBoundary raw={children}>
|
||||
<Streamdown {...props}>{safeChildren}</Streamdown>
|
||||
</StreamdownFallbackBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import { Loader } from "@/components/ai-elements/loader";
|
||||
import {
|
||||
Message as AIElementMessage,
|
||||
MessageContent as AIElementMessageContent,
|
||||
MessageResponse as AIElementMessageResponse,
|
||||
MessageToolbar,
|
||||
} from "@/components/ai-elements/message";
|
||||
import {
|
||||
@@ -44,7 +43,6 @@ import {
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||
import { humanMessagePlugins } from "@/core/streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { CopyButton } from "../copy-button";
|
||||
@@ -300,17 +298,10 @@ function MessageContent_({
|
||||
}
|
||||
|
||||
if (isHuman) {
|
||||
const messageResponse = contentToDisplay ? (
|
||||
<AIElementMessageResponse
|
||||
className="break-words"
|
||||
remarkPlugins={humanMessagePlugins.remarkPlugins}
|
||||
rehypePlugins={humanMessagePlugins.rehypePlugins}
|
||||
components={components}
|
||||
parseIncompleteMarkdown={false}
|
||||
>
|
||||
{contentToDisplay}
|
||||
</AIElementMessageResponse>
|
||||
) : null;
|
||||
// Composer input is plain text, not authored Markdown. Parsing it as
|
||||
// Markdown mangles pasted code/logs (indented lines become code blocks,
|
||||
// "$...$" spans become math) and lets pathological input crash the page
|
||||
// through marked's recursive blockquote lexer, so render it verbatim.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -319,9 +310,11 @@ function MessageContent_({
|
||||
)}
|
||||
>
|
||||
{filesList}
|
||||
{messageResponse && (
|
||||
{contentToDisplay && (
|
||||
<AIElementMessageContent className="w-full max-w-full">
|
||||
{messageResponse}
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{contentToDisplay}
|
||||
</div>
|
||||
</AIElementMessageContent>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -36,15 +36,3 @@ export const reasoningPlugins = {
|
||||
(p) => p !== rehypeRaw,
|
||||
) as StreamdownProps["rehypePlugins"],
|
||||
};
|
||||
|
||||
// Plugins for human messages - no autolink to prevent URL bleeding into adjacent text
|
||||
export const humanMessagePlugins = {
|
||||
remarkPlugins: [
|
||||
// Use remark-gfm without autolink literals by not including it
|
||||
// Only include math support for human messages
|
||||
[remarkMath, { singleDollarTextMath: true }],
|
||||
] as StreamdownProps["remarkPlugins"],
|
||||
rehypePlugins: [
|
||||
[rehypeKatex, { output: "html" }],
|
||||
] as StreamdownProps["rehypePlugins"],
|
||||
};
|
||||
|
||||
@@ -2,6 +2,59 @@ import { normalizeMermaidMarkdown } from "./mermaid";
|
||||
|
||||
const MERMAID_BLOCK_HINT_RE = /mermaid/i;
|
||||
|
||||
// marked's blockquote tokenizer (used by Streamdown to split content into
|
||||
// memoizable blocks) recurses once per nesting level and overflows the call
|
||||
// stack at roughly 2,000 levels, replacing the whole chat route with an error
|
||||
// page. 100 levels is far beyond any legitimate content while keeping a wide
|
||||
// margin below the crash threshold.
|
||||
const MAX_BLOCKQUOTE_DEPTH = 100;
|
||||
const DEEP_BLOCKQUOTE_HINT_RE = new RegExp(
|
||||
`^(?:[ \\t]*>){${MAX_BLOCKQUOTE_DEPTH + 1}}`,
|
||||
"m",
|
||||
);
|
||||
// Only up to 3 leading spaces can start a blockquote; 4+ (or a tab) is an
|
||||
// indented code block, where ">" runs are literal content.
|
||||
const BLOCKQUOTE_PREFIX_RE = /^ {0,3}(?:[ \t]*>)+/;
|
||||
const CODE_FENCE_RE = /^ {0,3}(?:```|~~~)/;
|
||||
const INDENTED_CODE_RE = /^(?: {4}|\t)/;
|
||||
|
||||
export function capBlockquoteNesting(markdown: string): string {
|
||||
if (!DEEP_BLOCKQUOTE_HINT_RE.test(markdown)) {
|
||||
return markdown;
|
||||
}
|
||||
|
||||
let insideFence = false;
|
||||
return markdown
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (CODE_FENCE_RE.test(line)) {
|
||||
insideFence = !insideFence;
|
||||
return line;
|
||||
}
|
||||
// ">" runs inside fenced or indented code blocks are literal text, not
|
||||
// nesting — rewriting them would silently corrupt code content.
|
||||
if (insideFence || INDENTED_CODE_RE.test(line)) {
|
||||
return line;
|
||||
}
|
||||
const match = BLOCKQUOTE_PREFIX_RE.exec(line);
|
||||
if (!match) {
|
||||
return line;
|
||||
}
|
||||
const prefix = match[0];
|
||||
let depth = 0;
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (prefix[i] === ">") {
|
||||
depth += 1;
|
||||
if (depth > MAX_BLOCKQUOTE_DEPTH) {
|
||||
return line.slice(0, i) + line.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function preprocessStreamdownMarkdown(markdown: string): string {
|
||||
if (!MERMAID_BLOCK_HINT_RE.test(markdown) || !markdown.includes("-.->")) {
|
||||
return markdown;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
const C_SOURCE = `#include <stdio.h>
|
||||
#include <signal.h>
|
||||
|
||||
static volatile int connected = 0;
|
||||
|
||||
static void daemon_handle_signal(int sig) {
|
||||
if (sig == SIGTERM) {
|
||||
connected = 0;
|
||||
|
||||
printf("daemon stop requested\\n");
|
||||
return;
|
||||
}
|
||||
|
||||
printf("ignored signal %d\\n", sig);
|
||||
}`;
|
||||
|
||||
function threadWithMessages(
|
||||
humanText: string,
|
||||
aiText = "ack",
|
||||
): Parameters<typeof mockLangGraphAPI>[1] {
|
||||
return {
|
||||
threads: [
|
||||
{
|
||||
thread_id: MOCK_THREAD_ID,
|
||||
title: "Plain text rendering",
|
||||
updated_at: "2025-06-01T12:00:00Z",
|
||||
messages: [
|
||||
{
|
||||
type: "human",
|
||||
id: "msg-human-plain-text",
|
||||
content: [{ type: "text", text: humanText }],
|
||||
},
|
||||
{
|
||||
type: "ai",
|
||||
id: "msg-ai-plain-text",
|
||||
content: aiText,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function collectPageErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => {
|
||||
errors.push(`${error.name}: ${error.message}`);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
test.describe("User message plain-text rendering", () => {
|
||||
test("pasted source code renders verbatim as one block", async ({ page }) => {
|
||||
mockLangGraphAPI(page, threadWithMessages(C_SOURCE));
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(page.getByText("ack")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// The pasted file must not be split into Markdown code-block widgets.
|
||||
await expect(
|
||||
page.locator('[data-code-block-container="true"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Indentation and line structure must be preserved verbatim.
|
||||
const bubble = page.locator(".is-user");
|
||||
const text = await bubble.innerText();
|
||||
expect(text).toContain("#include <stdio.h>");
|
||||
expect(text).toContain(" if (sig == SIGTERM) {");
|
||||
expect(text).toContain(' printf("daemon stop requested\\n");');
|
||||
});
|
||||
|
||||
test("dollar signs are not parsed as math", async ({ page }) => {
|
||||
const message = "this costs $5 and $10 in total";
|
||||
mockLangGraphAPI(page, threadWithMessages(message));
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(page.getByText("ack")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await expect(page.locator(".is-user")).toContainText(message);
|
||||
await expect(page.locator(".is-user .katex")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("deeply nested blockquote markers in a user message do not crash the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors = collectPageErrors(page);
|
||||
mockLangGraphAPI(page, threadWithMessages("> ".repeat(3000) + "hi"));
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(page.getByText("ack")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
await expect(page.locator(".is-user")).toContainText("> > >");
|
||||
});
|
||||
|
||||
test("deeply nested blockquote markers in an AI message do not crash the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const pageErrors = collectPageErrors(page);
|
||||
mockLangGraphAPI(
|
||||
page,
|
||||
threadWithMessages("hello", "> ".repeat(3000) + "deep"),
|
||||
);
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(page.getByText("hello")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
// The capped blockquote chain still renders (100 levels of indentation can
|
||||
// squeeze the innermost element to zero width, so assert presence, not
|
||||
// visibility).
|
||||
await expect(page.getByText("deep")).toBeAttached();
|
||||
});
|
||||
|
||||
test("list-prefixed deep nesting in an AI message falls back to plain text instead of crashing", async ({
|
||||
page,
|
||||
}) => {
|
||||
// marked's list and blockquote tokenizers are mutually recursive, so a
|
||||
// list marker in front of the quote chain bypasses the nesting cap; the
|
||||
// render error boundary must absorb it.
|
||||
const pageErrors = collectPageErrors(page);
|
||||
mockLangGraphAPI(
|
||||
page,
|
||||
threadWithMessages("hello", "- " + "> ".repeat(3000) + "deep-list"),
|
||||
);
|
||||
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await expect(page.getByText("hello")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
expect(pageErrors).toEqual([]);
|
||||
await expect(page.getByText("deep-list")).toBeAttached();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
capBlockquoteNesting,
|
||||
preprocessStreamdownMarkdown,
|
||||
} from "@/core/streamdown/preprocess";
|
||||
|
||||
test("capBlockquoteNesting returns normal content unchanged", () => {
|
||||
const input = "# Title\n\n> a quote\n>> nested\n\nsome `code`";
|
||||
expect(capBlockquoteNesting(input)).toBe(input);
|
||||
});
|
||||
|
||||
test("capBlockquoteNesting keeps nesting at or below the cap untouched", () => {
|
||||
const input = "> ".repeat(100) + "hi";
|
||||
expect(capBlockquoteNesting(input)).toBe(input);
|
||||
});
|
||||
|
||||
test("capBlockquoteNesting caps pathological nesting and preserves content", () => {
|
||||
const result = capBlockquoteNesting("> ".repeat(5000) + "hi");
|
||||
expect((result.match(/>/g) ?? []).length).toBe(100);
|
||||
expect(result.endsWith("hi")).toBe(true);
|
||||
});
|
||||
|
||||
test("capBlockquoteNesting handles markers without spaces", () => {
|
||||
const result = capBlockquoteNesting(">".repeat(5000) + "hi");
|
||||
expect((result.match(/>/g) ?? []).length).toBe(100);
|
||||
expect(result.endsWith("hi")).toBe(true);
|
||||
});
|
||||
|
||||
test("capBlockquoteNesting leaves fenced code content untouched", () => {
|
||||
const literal = ">".repeat(150);
|
||||
const input = `${"> ".repeat(3000)}hi\n\`\`\`text\n${literal}\n\`\`\``;
|
||||
const result = capBlockquoteNesting(input);
|
||||
expect(result.split("\n")[2]).toBe(literal);
|
||||
});
|
||||
|
||||
test("capBlockquoteNesting leaves indented code blocks untouched", () => {
|
||||
const literal = " " + ">".repeat(150);
|
||||
const input = `${"> ".repeat(3000)}hi\n\n${literal}`;
|
||||
const result = capBlockquoteNesting(input);
|
||||
expect(result.split("\n")[2]).toBe(literal);
|
||||
});
|
||||
|
||||
test("capBlockquoteNesting only rewrites pathological lines", () => {
|
||||
const normal = "> normal quote";
|
||||
const deep = "> ".repeat(3000) + "deep";
|
||||
const result = capBlockquoteNesting(`${normal}\n${deep}\nplain`);
|
||||
const lines = result.split("\n");
|
||||
expect(lines[0]).toBe(normal);
|
||||
expect((lines[1]?.match(/>/g) ?? []).length).toBe(100);
|
||||
expect(lines[2]).toBe("plain");
|
||||
});
|
||||
|
||||
test("preprocessStreamdownMarkdown leaves non-mermaid content unchanged", () => {
|
||||
const input = "just some text";
|
||||
expect(preprocessStreamdownMarkdown(input)).toBe(input);
|
||||
});
|
||||
Reference in New Issue
Block a user