mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-01 16:10:37 +00:00
* fix(frontend): resolve relative links in messages to thread artifacts An assistant message that links a file it wrote with a relative destination, e.g. [Report](report.md), rendered a plain relative href. The browser resolved it against the chat route to /workspace/chats/report.md, so clicking it opened a "thread" whose id is the file name and the Gateway rejected it (422). Only /mnt/ links were rewritten, and the clarification and present_files message text did not get the thread-aware link renderer at all. - A relative link (no scheme, no leading /, no .. segment) that suffix-matches exactly one thread artifact now opens that artifact in a new tab, using the rule message images already use. When zero or several artifacts match it renders as text instead of a link to a broken chat URL. Links outside the message list are unchanged. - The artifact list reaches the link renderer through a context: Streamdown memoizes its root without comparing `components`, so a renderer built before the list arrives would keep the stale list. - /mnt/ artifact links get the normal link styling (they rendered as unstyled text before). - A reopened thread rebuilds its values from the POST /history head, which carries messages but not `artifacts`. The message list and the artifact panel now add the /mnt/ paths from present_files calls in the messages, so links resolve and the panel lists the files after reload. Co-Authored-By: Claude Code <noreply@anthropic.com> * fix(frontend): address artifact link review --------- Co-authored-by: huangzz <110116@nd.com> Co-authored-by: Claude Code <noreply@anthropic.com>
209 lines
7.5 KiB
TypeScript
209 lines
7.5 KiB
TypeScript
import { describe, expect, it } from "@rstest/core";
|
|
import { createElement } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import {
|
|
createMarkdownLinkComponent,
|
|
isSafeHref,
|
|
MessageArtifactPathsContext,
|
|
} from "@/components/workspace/messages/markdown-link";
|
|
|
|
describe("isSafeHref", () => {
|
|
it("allows web URLs and same-origin paths", () => {
|
|
expect(isSafeHref("https://example.com/path")).toBe(true);
|
|
expect(isSafeHref("http://example.com/path")).toBe(true);
|
|
expect(isSafeHref("/workspace/chats/1")).toBe(true);
|
|
expect(isSafeHref("#section")).toBe(true);
|
|
});
|
|
|
|
it("allows scheme-less relative references", () => {
|
|
expect(isSafeHref("report.md")).toBe(true);
|
|
expect(isSafeHref("./report.md")).toBe(true);
|
|
expect(isSafeHref("../assets/chart.png")).toBe(true);
|
|
});
|
|
|
|
it("allows non-executing contact schemes", () => {
|
|
expect(isSafeHref("mailto:someone@example.com")).toBe(true);
|
|
expect(isSafeHref("tel:+15551234567")).toBe(true);
|
|
});
|
|
|
|
it("rejects executable, local, and protocol-relative URLs", () => {
|
|
expect(isSafeHref("javascript:alert(1)")).toBe(false);
|
|
expect(isSafeHref("java\tscript:alert(1)")).toBe(false);
|
|
expect(isSafeHref("data:text/html,<script>alert(1)</script>")).toBe(false);
|
|
expect(isSafeHref("file:///etc/passwd")).toBe(false);
|
|
expect(isSafeHref("//example.com/path")).toBe(false);
|
|
expect(isSafeHref("\\\\evil.com")).toBe(false);
|
|
expect(isSafeHref(undefined)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// Render-level coverage: these tests exercise the component itself, so
|
|
// removing (or inverting) the isSafeHref guard inside MarkdownLink fails
|
|
// them even though isSafeHref stays untouched.
|
|
describe("MarkdownLink rendering", () => {
|
|
const MarkdownLink = createMarkdownLinkComponent();
|
|
|
|
it("renders an unsafe href as a disabled span, never an anchor", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(MarkdownLink, { href: "javascript:alert(1)" }, "click me"),
|
|
);
|
|
expect(html).not.toContain("<a");
|
|
expect(html).toContain("<span");
|
|
expect(html).toContain("click me");
|
|
expect(html).not.toContain("href=");
|
|
expect(html).toContain('aria-label="Unsafe link omitted"');
|
|
});
|
|
|
|
it("blocks unsafe hrefs before the citation branch", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(
|
|
MarkdownLink,
|
|
{ href: "javascript:alert(1)" },
|
|
"citation:evil",
|
|
),
|
|
);
|
|
expect(html).not.toContain("<a");
|
|
expect(html).not.toContain("href=");
|
|
});
|
|
|
|
it("renders a safe https href as a hardened anchor", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(
|
|
MarkdownLink,
|
|
{ href: "https://example.com/report" },
|
|
"report",
|
|
),
|
|
);
|
|
expect(html).toContain("<a");
|
|
expect(html).toContain('href="https://example.com/report"');
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
});
|
|
|
|
it("renders citation labels when children are React element arrays", () => {
|
|
const html = renderToStaticMarkup(
|
|
createElement(
|
|
MarkdownLink,
|
|
{ href: "https://example.com/report" },
|
|
createElement("span", { key: "prefix" }, "citation:"),
|
|
createElement("span", { key: "title" }, "Test Source"),
|
|
),
|
|
);
|
|
expect(html).not.toContain("citation:");
|
|
expect(html).toContain("Test Source");
|
|
});
|
|
|
|
it("renders a scheme-less relative href as a navigable anchor", () => {
|
|
const tests = ["report.md", "./report.md", "../assets/chart.png"];
|
|
for (const href of tests) {
|
|
const html = renderToStaticMarkup(
|
|
createElement(MarkdownLink, { href }, "doc"),
|
|
);
|
|
expect(html).toContain("<a");
|
|
expect(html).toContain(`href="${href}"`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Inside the message list a relative link must open the thread artifact it
|
|
// names. Left as a relative href, the browser resolves it against the chat
|
|
// route (/workspace/chats/<file>), which then loads the file name as a thread.
|
|
describe("MarkdownLink relative artifact links", () => {
|
|
const artifacts = [
|
|
"/mnt/user-data/outputs/final report.md",
|
|
"/mnt/user-data/outputs/first/shared.md",
|
|
"/mnt/user-data/outputs/second/shared.md",
|
|
"/mnt/user-data/outputs/notes.md",
|
|
];
|
|
const MessageLink = createMarkdownLinkComponent("thread-1");
|
|
const reportURL =
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/final%20report.md";
|
|
const notesURL =
|
|
"/api/threads/thread-1/artifacts/mnt/user-data/outputs/notes.md";
|
|
|
|
function render(href: string, paths: readonly string[] | null = artifacts) {
|
|
const link = createElement(MessageLink, { href }, "doc");
|
|
return renderToStaticMarkup(
|
|
paths === null
|
|
? link
|
|
: createElement(
|
|
MessageArtifactPathsContext.Provider,
|
|
{ value: paths },
|
|
link,
|
|
),
|
|
);
|
|
}
|
|
|
|
it("rewrites a uniquely matching relative link to the artifact URL", () => {
|
|
// Markdown destinations reach the renderer percent-encoded.
|
|
const html = render("final%20report.md");
|
|
expect(html).toContain(`href="${reportURL}"`);
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain('rel="noopener noreferrer"');
|
|
expect(html).toContain("text-primary");
|
|
expect(render("./final report.md")).toContain(`href="${reportURL}"`);
|
|
expect(render("././outputs/notes.md")).toContain(`href="${notesURL}"`);
|
|
});
|
|
|
|
it("keeps ?query and #fragment suffixes on the rewritten URL", () => {
|
|
expect(render("notes.md?v=2#part-1")).toContain(
|
|
`href="${notesURL}?v=2#part-1"`,
|
|
);
|
|
});
|
|
|
|
it("renders ambiguous or unmatched links as text, never a relative href", () => {
|
|
for (const href of ["shared.md", "missing.md", "./missing.md"]) {
|
|
const html = render(href);
|
|
expect(html).not.toContain("<a");
|
|
expect(html).not.toContain("href=");
|
|
expect(html).toContain("<span");
|
|
expect(html).toContain("doc");
|
|
}
|
|
// The artifact list is empty until the thread state loads.
|
|
expect(render("notes.md", [])).not.toContain("href=");
|
|
});
|
|
|
|
it("renders traversal paths inert and preserves non-artifact links", () => {
|
|
for (const href of ["../notes.md", "..%2fnotes.md"]) {
|
|
const html = render(href);
|
|
expect(html).not.toContain("<a");
|
|
expect(html).not.toContain("href=");
|
|
expect(html).not.toContain("/api/threads/");
|
|
}
|
|
const external = render("https://example.com/notes.md");
|
|
expect(external).toContain('href="https://example.com/notes.md"');
|
|
expect(external).toContain('target="_blank"');
|
|
expect(render("#notes.md")).toContain('href="#notes.md"');
|
|
expect(render("mailto:a@example.com")).toContain(
|
|
'href="mailto:a@example.com"',
|
|
);
|
|
expect(render("/workspace/chats/new")).toContain(
|
|
'href="/workspace/chats/new"',
|
|
);
|
|
});
|
|
|
|
it("styles /mnt/ artifact links like other links", () => {
|
|
const html = render("/mnt/user-data/outputs/chart.png?v=2#detail");
|
|
expect(html).toContain(
|
|
'href="/api/threads/thread-1/artifacts/mnt/user-data/outputs/chart.png?v=2#detail"',
|
|
);
|
|
expect(html).toContain('target="_blank"');
|
|
expect(html).toContain("text-primary");
|
|
});
|
|
|
|
it("leaves relative links alone outside the message list", () => {
|
|
expect(render("missing.md", null)).toContain('href="missing.md"');
|
|
const GenericLink = createMarkdownLinkComponent();
|
|
expect(
|
|
renderToStaticMarkup(
|
|
createElement(
|
|
MessageArtifactPathsContext.Provider,
|
|
{ value: artifacts },
|
|
createElement(GenericLink, { href: "notes.md" }, "doc"),
|
|
),
|
|
),
|
|
).toContain('href="notes.md"');
|
|
});
|
|
});
|