mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
fix: add heading anchors to markdown artifact previews (#3805)
* fix: add markdown heading anchors for artifacts * fix: scope markdown anchors to artifact previews --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
7a6c4a994a
commit
b5cac5e721
@ -79,6 +79,7 @@
|
|||||||
"react-resizable-panels": "^4.4.1",
|
"react-resizable-panels": "^4.4.1",
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
|
"rehype-slug": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
"shiki": "3.15.0",
|
"shiki": "3.15.0",
|
||||||
|
|||||||
21
frontend/pnpm-lock.yaml
generated
21
frontend/pnpm-lock.yaml
generated
@ -188,6 +188,9 @@ importers:
|
|||||||
rehype-raw:
|
rehype-raw:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0
|
version: 7.0.0
|
||||||
|
rehype-slug:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
remark-gfm:
|
remark-gfm:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
@ -3698,6 +3701,9 @@ packages:
|
|||||||
hast-util-from-parse5@8.0.3:
|
hast-util-from-parse5@8.0.3:
|
||||||
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
|
||||||
|
|
||||||
|
hast-util-heading-rank@3.0.0:
|
||||||
|
resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==}
|
||||||
|
|
||||||
hast-util-is-element@3.0.0:
|
hast-util-is-element@3.0.0:
|
||||||
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
|
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
|
||||||
|
|
||||||
@ -4979,6 +4985,9 @@ packages:
|
|||||||
rehype-recma@1.0.0:
|
rehype-recma@1.0.0:
|
||||||
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
|
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
|
||||||
|
|
||||||
|
rehype-slug@6.0.0:
|
||||||
|
resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==}
|
||||||
|
|
||||||
remark-frontmatter@5.0.0:
|
remark-frontmatter@5.0.0:
|
||||||
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
|
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
|
||||||
|
|
||||||
@ -9540,6 +9549,10 @@ snapshots:
|
|||||||
vfile-location: 5.0.3
|
vfile-location: 5.0.3
|
||||||
web-namespaces: 2.0.1
|
web-namespaces: 2.0.1
|
||||||
|
|
||||||
|
hast-util-heading-rank@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
|
||||||
hast-util-is-element@3.0.0:
|
hast-util-is-element@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
@ -11238,6 +11251,14 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
rehype-slug@6.0.0:
|
||||||
|
dependencies:
|
||||||
|
'@types/hast': 3.0.4
|
||||||
|
github-slugger: 2.0.0
|
||||||
|
hast-util-heading-rank: 3.0.0
|
||||||
|
hast-util-to-string: 3.0.1
|
||||||
|
unist-util-visit: 5.1.0
|
||||||
|
|
||||||
remark-frontmatter@5.0.0:
|
remark-frontmatter@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
|
|||||||
@ -42,7 +42,6 @@ import { writeTextToClipboard } from "@/core/clipboard";
|
|||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { findToolCallResult } from "@/core/messages/utils";
|
import { findToolCallResult } from "@/core/messages/utils";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
|
||||||
import { SafeStreamdown } from "@/core/streamdown/components";
|
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||||
import {
|
import {
|
||||||
canBrowserPreviewFile,
|
canBrowserPreviewFile,
|
||||||
@ -59,6 +58,7 @@ import { useThread } from "../messages/context";
|
|||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
import { useArtifacts } from "./context";
|
import { useArtifacts } from "./context";
|
||||||
|
import { artifactMarkdownPlugins } from "./markdown-preview-plugins";
|
||||||
|
|
||||||
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
|
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
@ -464,7 +464,7 @@ export function ArtifactFilePreview({
|
|||||||
<div className="size-full px-4">
|
<div className="size-full px-4">
|
||||||
<SafeStreamdown
|
<SafeStreamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...artifactMarkdownPlugins}
|
||||||
components={{ a: ArtifactLink }}
|
components={{ a: ArtifactLink }}
|
||||||
>
|
>
|
||||||
{content ?? ""}
|
{content ?? ""}
|
||||||
|
|||||||
@ -0,0 +1,15 @@
|
|||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
|
||||||
|
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
||||||
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
|
|
||||||
|
const baseRehypePlugins = streamdownPlugins.rehypePlugins ?? [];
|
||||||
|
|
||||||
|
export const artifactMarkdownPlugins = {
|
||||||
|
...streamdownPlugins,
|
||||||
|
rehypePlugins: [
|
||||||
|
...baseRehypePlugins.slice(0, 1),
|
||||||
|
rehypeSlug,
|
||||||
|
...baseRehypePlugins.slice(1),
|
||||||
|
] as ClipboardSafeStreamdownProps["rehypePlugins"],
|
||||||
|
};
|
||||||
@ -8,6 +8,7 @@ const JSON_ARTIFACT_PATH = "/artifact-fixtures/report.json";
|
|||||||
const IN_PROGRESS_THREAD_ID = "00000000-0000-0000-0000-000000003119";
|
const IN_PROGRESS_THREAD_ID = "00000000-0000-0000-0000-000000003119";
|
||||||
const COMPLETE_THREAD_ID = "00000000-0000-0000-0000-000000003120";
|
const COMPLETE_THREAD_ID = "00000000-0000-0000-0000-000000003120";
|
||||||
const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003121";
|
const MARKDOWN_THREAD_ID = "00000000-0000-0000-0000-000000003121";
|
||||||
|
const MARKDOWN_ANCHOR_THREAD_ID = "00000000-0000-0000-0000-000000003123";
|
||||||
const JSON_THREAD_ID = "00000000-0000-0000-0000-000000003122";
|
const JSON_THREAD_ID = "00000000-0000-0000-0000-000000003122";
|
||||||
|
|
||||||
function writeFileMessages({
|
function writeFileMessages({
|
||||||
@ -140,6 +141,69 @@ test.describe("Artifact preview stability", () => {
|
|||||||
await expect(artifactsPanel.getByText("测试内容 1")).toBeVisible();
|
await expect(artifactsPanel.getByText("测试内容 1")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("scrolls markdown artifact preview to heading anchors", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const filler = Array.from(
|
||||||
|
{ length: 40 },
|
||||||
|
(_, index) => `填充段落 ${index + 1}`,
|
||||||
|
).join("\n\n");
|
||||||
|
|
||||||
|
mockLangGraphAPI(page, {
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
thread_id: MARKDOWN_ANCHOR_THREAD_ID,
|
||||||
|
title: "Markdown artifact anchor navigation",
|
||||||
|
messages: writeFileMessages({
|
||||||
|
path: MARKDOWN_ARTIFACT_PATH,
|
||||||
|
content: [
|
||||||
|
"# Report",
|
||||||
|
"",
|
||||||
|
"- [概述](#概述)",
|
||||||
|
"",
|
||||||
|
filler,
|
||||||
|
"",
|
||||||
|
"## 概述",
|
||||||
|
"",
|
||||||
|
"目标章节内容",
|
||||||
|
].join("\n"),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`/workspace/chats/${MARKDOWN_ANCHOR_THREAD_ID}`);
|
||||||
|
|
||||||
|
await expect(page.getByText(MARKDOWN_ARTIFACT_PATH)).toBeVisible({
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
await page.getByText(MARKDOWN_ARTIFACT_PATH).click();
|
||||||
|
|
||||||
|
const artifactsPanel = page.locator("#artifacts");
|
||||||
|
await expect(artifactsPanel.getByText("report.md")).toBeVisible();
|
||||||
|
|
||||||
|
const targetHeading = artifactsPanel.locator("h2#概述");
|
||||||
|
await expect(targetHeading).toHaveCount(1);
|
||||||
|
await artifactsPanel.getByRole("link", { name: "概述" }).click();
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
targetHeading.evaluate((element) => {
|
||||||
|
const panel = document.querySelector("#artifacts");
|
||||||
|
if (!panel) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const panelRect = panel.getBoundingClientRect();
|
||||||
|
const headingRect = element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
headingRect.top >= panelRect.top &&
|
||||||
|
headingRect.top <= panelRect.bottom
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test("renders code view for an in-progress non-preview write artifact", async ({
|
test("renders code view for an in-progress non-preview write artifact", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
42
frontend/tests/unit/core/streamdown-plugins.test.ts
Normal file
42
frontend/tests/unit/core/streamdown-plugins.test.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { expect, test } from "@rstest/core";
|
||||||
|
import { createElement } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
|
||||||
|
import { artifactMarkdownPlugins } from "@/components/workspace/artifacts/markdown-preview-plugins";
|
||||||
|
import { ArtifactLink } from "@/components/workspace/citations/artifact-link";
|
||||||
|
import { SafeStreamdown, streamdownPlugins } from "@/core/streamdown";
|
||||||
|
|
||||||
|
function renderArtifactMarkdown(content: string) {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(
|
||||||
|
SafeStreamdown,
|
||||||
|
{ ...artifactMarkdownPlugins, components: { a: ArtifactLink } },
|
||||||
|
content,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSharedMarkdown(content: string) {
|
||||||
|
return renderToStaticMarkup(
|
||||||
|
createElement(SafeStreamdown, streamdownPlugins, content),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("adds GitHub-style heading anchors to artifact markdown previews", () => {
|
||||||
|
const html = renderArtifactMarkdown(
|
||||||
|
["[概述](#概述)", "", "## 概述"].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('href="#%E6%A6%82%E8%BF%B0"');
|
||||||
|
expect(html).toContain('id="概述"');
|
||||||
|
expect(html).not.toContain("target=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not add heading anchors to the shared streamdown plugin config", () => {
|
||||||
|
const html = [
|
||||||
|
renderSharedMarkdown("## Summary"),
|
||||||
|
renderSharedMarkdown("## Summary"),
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
expect(html).not.toContain('id="summary"');
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user