mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 17:06:05 +00:00
fix(frontend): sync streaming list markers with content (#4525)
* fix(frontend): sync streaming list markers with content * fix(frontend): preserve streaming ordered-list counters
This commit is contained in:
parent
88f9f81877
commit
1bb93f9517
@ -16,8 +16,9 @@ import {
|
||||
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
||||
import {
|
||||
preprocessStreamdownMarkdown,
|
||||
rehypeStreamingListItems,
|
||||
streamdownPluginsWithoutRawHtml,
|
||||
streamdownWordAnimation,
|
||||
streamdownSmoothStreamingAnimation,
|
||||
} from "@/core/streamdown";
|
||||
import {
|
||||
SafeMessageResponse,
|
||||
@ -241,8 +242,13 @@ export function MarkdownContent({
|
||||
const effectiveRehypePlugins = useMemo(() => {
|
||||
const base = streamdownPluginsWithoutRawHtml.rehypePlugins ?? [];
|
||||
const extra = rehypePlugins ?? [];
|
||||
return [...base, ...extra] as ClipboardSafeStreamdownProps["rehypePlugins"];
|
||||
}, [rehypePlugins]);
|
||||
const streaming = isStreamingRender ? [rehypeStreamingListItems] : [];
|
||||
return [
|
||||
...base,
|
||||
...extra,
|
||||
...streaming,
|
||||
] as ClipboardSafeStreamdownProps["rehypePlugins"];
|
||||
}, [isStreamingRender, rehypePlugins]);
|
||||
const components = useMemo(() => {
|
||||
const baseComponents = {
|
||||
a: createMarkdownLinkComponent(),
|
||||
@ -267,7 +273,7 @@ export function MarkdownContent({
|
||||
rehypePlugins={effectiveRehypePlugins}
|
||||
components={toStreamdownComponents(components)}
|
||||
parseIncompleteMarkdown={isLoading}
|
||||
animated={streamdownWordAnimation}
|
||||
animated={streamdownSmoothStreamingAnimation}
|
||||
isAnimating={isLoading}
|
||||
>
|
||||
{normalizedContent}
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { code } from "@streamdown/code";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { Root } from "hast";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import type { StreamdownProps } from "streamdown";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
const katexOptions = {
|
||||
output: "html",
|
||||
@ -37,6 +39,52 @@ export const streamdownWordAnimation = {
|
||||
sep: "word",
|
||||
} as const satisfies Exclude<StreamdownProps["animated"], boolean | undefined>;
|
||||
|
||||
export const streamdownSmoothStreamingAnimation = {
|
||||
...streamdownWordAnimation,
|
||||
// Streamdown defaults to 40ms per new word. A large chunk can therefore
|
||||
// delay later list-item text by seconds while its native marker is already
|
||||
// visible. Smooth content reveal owns the pacing here, so start every new
|
||||
// word's fade together with its surrounding marker.
|
||||
stagger: 0,
|
||||
} as const satisfies Exclude<StreamdownProps["animated"], boolean | undefined>;
|
||||
|
||||
/**
|
||||
* Keeps native list markers in step with Streamdown's word reveal.
|
||||
*
|
||||
* A trailing `2.` or `-` is parsed as an empty list item while content is
|
||||
* streaming. Hide that transient item, then mark it for a matching marker
|
||||
* animation as soon as its first child arrives. Keep mid-list empty items in
|
||||
* the box tree so ordered-list counters never renumber later items.
|
||||
*/
|
||||
export function rehypeStreamingListItems() {
|
||||
return (tree: Root) => {
|
||||
visit(tree, "element", (node, index, parent) => {
|
||||
if (node.tagName !== "li") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.children.length === 0) {
|
||||
const isTrailingListItem =
|
||||
index !== undefined &&
|
||||
parent?.type === "element" &&
|
||||
(parent.tagName === "ol" || parent.tagName === "ul") &&
|
||||
!parent.children
|
||||
.slice(index + 1)
|
||||
.some(
|
||||
(sibling) =>
|
||||
sibling.type === "element" && sibling.tagName === "li",
|
||||
);
|
||||
if (isTrailingListItem) {
|
||||
node.properties.hidden = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
node.properties["data-streaming-list-item"] = "true";
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export const streamdownPluginsWithoutRawHtml = {
|
||||
plugins: streamdownPlugins.plugins,
|
||||
remarkPlugins: streamdownPlugins.remarkPlugins,
|
||||
|
||||
@ -72,6 +72,16 @@
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@keyframes streaming-list-marker-fade-in {
|
||||
from {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
[data-streaming-list-item]::marker {
|
||||
animation: streaming-list-marker-fade-in 200ms ease both;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans:
|
||||
ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it } from "@rstest/core";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("MarkdownContent streaming list transitions (DOM)", () => {
|
||||
it("reveals the same list item with marker animation when content arrives", async () => {
|
||||
const { container, rerender } = render(
|
||||
<MarkdownContent content={"1. First\n\n2."} isLoading={true} />,
|
||||
);
|
||||
const initialItems = container.querySelectorAll<HTMLLIElement>(
|
||||
'[data-streamdown="list-item"]',
|
||||
);
|
||||
const firstItem = initialItems[0]!;
|
||||
const pendingItem = initialItems[1]!;
|
||||
|
||||
expect(pendingItem.hidden).toBe(true);
|
||||
expect(pendingItem.hasAttribute("data-streaming-list-item")).toBe(false);
|
||||
|
||||
rerender(
|
||||
<MarkdownContent content={"1. First\n\n2. Second"} isLoading={true} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(pendingItem.textContent).toContain("Second");
|
||||
});
|
||||
const revealedItems = container.querySelectorAll<HTMLLIElement>(
|
||||
'[data-streamdown="list-item"]',
|
||||
);
|
||||
|
||||
expect(revealedItems[0]).toBe(firstItem);
|
||||
expect(revealedItems[1]).toBe(pendingItem);
|
||||
expect(pendingItem.hidden).toBe(false);
|
||||
expect(pendingItem.getAttribute("data-streaming-list-item")).toBe("true");
|
||||
});
|
||||
});
|
||||
@ -98,6 +98,7 @@ describe("MarkdownContent streaming animation", () => {
|
||||
expect(html).toContain("data-sd-animate");
|
||||
expect(html).toContain("--sd-animation:sd-fadeIn");
|
||||
expect(html).toContain("--sd-duration:200ms");
|
||||
expect(html).not.toContain("--sd-delay");
|
||||
expect(html).not.toContain("animate-fade-in");
|
||||
});
|
||||
|
||||
@ -108,6 +109,53 @@ describe("MarkdownContent streaming animation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("MarkdownContent streaming lists", () => {
|
||||
it("hides a trailing empty ordered-list item until its content arrives", () => {
|
||||
const html = renderMarkdown(["1. First", "", "2."].join("\n"), true);
|
||||
|
||||
expect(html).toContain('data-streaming-list-item="true"');
|
||||
expect(html).toContain('data-streamdown="list-item" hidden=""');
|
||||
});
|
||||
|
||||
it("hides a trailing empty unordered-list item until its content arrives", () => {
|
||||
const html = renderMarkdown("-", true);
|
||||
|
||||
expect(html).toContain('data-streamdown="unordered-list"');
|
||||
expect(html).toContain('data-streamdown="list-item" hidden=""');
|
||||
expect(html).not.toContain('data-streaming-list-item="true"');
|
||||
});
|
||||
|
||||
it("keeps a mid-list empty item visible so ordered counters stay stable", () => {
|
||||
const html = renderMarkdown(
|
||||
["1. First", "2.", "3. Third"].join("\n"),
|
||||
true,
|
||||
);
|
||||
const listItems = html.match(/<li[^>]*>/g);
|
||||
|
||||
expect(listItems).toHaveLength(3);
|
||||
expect(listItems?.[1]).not.toContain("hidden");
|
||||
expect(html).not.toContain('hidden=""');
|
||||
});
|
||||
|
||||
it("marks nested list items without hiding existing content", () => {
|
||||
const html = renderMarkdown(
|
||||
["1. Parent", " - Nested child"].join("\n"),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(html.match(/data-streaming-list-item="true"/g)).toHaveLength(2);
|
||||
expect(html).not.toContain('hidden=""');
|
||||
});
|
||||
|
||||
it("leaves completed list markup outside streaming treatment", () => {
|
||||
const html = renderMarkdown(["1. First", "", "2."].join("\n"), false);
|
||||
|
||||
expect(html).toContain('data-streamdown="ordered-list"');
|
||||
expect(html).not.toContain("data-streaming-list-item");
|
||||
expect(html).not.toContain('hidden=""');
|
||||
});
|
||||
});
|
||||
|
||||
describe("MarkdownContent strikethrough", () => {
|
||||
it("preserves single tildes in temperature ranges", () => {
|
||||
const html = renderMarkdown("周六23~30℃;周日22~30℃", false);
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
reasoningPlugins,
|
||||
streamdownPlugins,
|
||||
streamdownRenderingPlugins,
|
||||
streamdownSmoothStreamingAnimation,
|
||||
streamdownWordAnimation,
|
||||
} from "@/core/streamdown/plugins";
|
||||
|
||||
@ -25,6 +26,15 @@ test("streaming word animation uses Streamdown's stable incremental animation",
|
||||
});
|
||||
});
|
||||
|
||||
test("smooth message streaming does not add a cumulative word delay", () => {
|
||||
expect(streamdownSmoothStreamingAnimation).toEqual({
|
||||
animation: "fadeIn",
|
||||
duration: 200,
|
||||
sep: "word",
|
||||
stagger: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("streamdownPlugins includes rehypeRaw", () => {
|
||||
expect(streamdownPlugins.rehypePlugins).toContain(rehypeRaw);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user