diff --git a/frontend/package.json b/frontend/package.json
index e76ad94e1..281b2a92b 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -79,6 +79,7 @@
"react-resizable-panels": "^4.4.1",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0",
+ "rehype-slug": "^6.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"shiki": "3.15.0",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 746da8bb6..d9aeb250a 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -188,6 +188,9 @@ importers:
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
+ rehype-slug:
+ specifier: ^6.0.0
+ version: 6.0.0
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
@@ -3698,6 +3701,9 @@ packages:
hast-util-from-parse5@8.0.3:
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:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
@@ -4979,6 +4985,9 @@ packages:
rehype-recma@1.0.0:
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:
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
@@ -9540,6 +9549,10 @@ snapshots:
vfile-location: 5.0.3
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:
dependencies:
'@types/hast': 3.0.4
@@ -11238,6 +11251,14 @@ snapshots:
transitivePeerDependencies:
- 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:
dependencies:
'@types/mdast': 4.0.4
diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
index f98a8e84e..97a208968 100644
--- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
+++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx
@@ -42,7 +42,6 @@ import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils";
import { installSkill } from "@/core/skills/api";
-import { streamdownPlugins } from "@/core/streamdown";
import { SafeStreamdown } from "@/core/streamdown/components";
import {
canBrowserPreviewFile,
@@ -59,6 +58,7 @@ import { useThread } from "../messages/context";
import { Tooltip } from "../tooltip";
import { useArtifacts } from "./context";
+import { artifactMarkdownPlugins } from "./markdown-preview-plugins";
const WRITE_FILE_PREVIEW_REFRESH_INTERVAL_MS = 3000;
@@ -464,7 +464,7 @@ export function ArtifactFilePreview({
{content ?? ""}
diff --git a/frontend/src/components/workspace/artifacts/markdown-preview-plugins.ts b/frontend/src/components/workspace/artifacts/markdown-preview-plugins.ts
new file mode 100644
index 000000000..45acb7a93
--- /dev/null
+++ b/frontend/src/components/workspace/artifacts/markdown-preview-plugins.ts
@@ -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"],
+};
diff --git a/frontend/tests/e2e/artifact-preview.spec.ts b/frontend/tests/e2e/artifact-preview.spec.ts
index 05ddee4fe..ea8439d37 100644
--- a/frontend/tests/e2e/artifact-preview.spec.ts
+++ b/frontend/tests/e2e/artifact-preview.spec.ts
@@ -8,6 +8,7 @@ const JSON_ARTIFACT_PATH = "/artifact-fixtures/report.json";
const IN_PROGRESS_THREAD_ID = "00000000-0000-0000-0000-000000003119";
const COMPLETE_THREAD_ID = "00000000-0000-0000-0000-000000003120";
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";
function writeFileMessages({
@@ -140,6 +141,69 @@ test.describe("Artifact preview stability", () => {
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 ({
page,
}) => {
diff --git a/frontend/tests/unit/core/streamdown-plugins.test.ts b/frontend/tests/unit/core/streamdown-plugins.test.ts
new file mode 100644
index 000000000..a2f8a5e39
--- /dev/null
+++ b/frontend/tests/unit/core/streamdown-plugins.test.ts
@@ -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"');
+});