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"'); +});