From 33a13b701bcae7e321e32ee84b2be64c69176098 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 31 May 2026 21:43:12 +0800 Subject: [PATCH] feat(webui): render source links with favicons --- webui/src/components/MarkdownTextRenderer.tsx | 132 ++++++++++++++++++ .../src/tests/markdown-text-renderer.test.tsx | 21 +++ 2 files changed, 153 insertions(+) diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx index 6f3f19bdf..2a4c410c7 100644 --- a/webui/src/components/MarkdownTextRenderer.tsx +++ b/webui/src/components/MarkdownTextRenderer.tsx @@ -30,6 +30,13 @@ type MarkdownAstNode = { }; }; +type CitationLink = { + href: string; + origin: string; + title: string; + initials: string; +}; + const SAFE_INLINE_HTML_TAGS = new Set(["mark", "sub", "sup"]); function extensionOf(value: string): string { @@ -179,6 +186,115 @@ function nodeText(value: ReactNode): string { .join(""); } +function citationParts(value: ReactNode): { text: string; href?: string } { + let text = ""; + let href: string | undefined; + for (const child of Children.toArray(value)) { + if (typeof child === "string" || typeof child === "number") { + text += String(child); + continue; + } + if (!isValidElement(child)) { + continue; + } + const props = child.props as { href?: unknown; children?: ReactNode }; + if (!href && typeof props.href === "string" && /^https?:\/\//i.test(props.href)) { + href = props.href; + } + const nested = citationParts(props.children); + text += nested.text; + href ||= nested.href; + } + return { text, href }; +} + +function cleanCitationText(value: string): string { + return value + .replace(/\s+/g, " ") + .replace(/^[\s"'“”‘’]+|[\s"'“”‘’]+$/g, "") + .trim(); +} + +function citationInitials(value: string): string { + const clean = value + .replace(/^https?:\/\//i, "") + .replace(/^www\./i, "") + .replace(/\.[a-z]{2,}$/i, ""); + const parts = clean.split(/[\s.-]+/).filter(Boolean); + return (parts.length > 1 ? parts.slice(0, 2).map((part) => part[0]).join("") : clean.slice(0, 2)) + .toUpperCase(); +} + +function sourceLinkFromChildren(children: ReactNode): CitationLink | null { + const { text: rawText, href } = citationParts(children); + if (!href) return null; + + let url: URL; + try { + url = new URL(href); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + + const strippedUrl = rawText + .replace(/\s+/g, " ") + .replace(href, "") + .replace(url.toString(), "") + .replace(/https?:\/\/\S+/i, "") + .trim(); + if (!strippedUrl || strippedUrl.length < 4) return null; + + const sourceMatch = /^(.*?)\s*(?:[—–]| - |:)\s*(.+)$/.exec(strippedUrl); + const sourceLabel = sourceMatch?.[1] ? cleanCitationText(sourceMatch[1]) : undefined; + const title = cleanCitationText(sourceMatch?.[2] ?? strippedUrl); + if (!title || /^https?:\/\//i.test(title)) return null; + + return { + href, + origin: url.origin, + title, + initials: citationInitials(sourceLabel || url.hostname), + }; +} + +function CitationRow({ citation }: { citation: CitationLink }) { + return ( + + + {citation.initials} + { + event.currentTarget.style.display = "none"; + }} + /> + + + {citation.title} + + + ); +} + function isRenderedCodeBlock(value: ReactNode): boolean { if (!isValidElement(value)) return false; const props = value.props as { code?: unknown }; @@ -296,6 +412,22 @@ export default function MarkdownTextRenderer({ ); }, + li({ children: markdownChildren, className: itemClassName, node: _node }) { + void _node; + const citation = sourceLinkFromChildren(markdownChildren); + if (citation) { + return ( +
  • + +
  • + ); + } + return ( +
  • + {markdownChildren} +
  • + ); + }, input({ type, checked }) { if (type !== "checkbox") return null; return ( diff --git a/webui/src/tests/markdown-text-renderer.test.tsx b/webui/src/tests/markdown-text-renderer.test.tsx index b0282b756..3c76f5976 100644 --- a/webui/src/tests/markdown-text-renderer.test.tsx +++ b/webui/src/tests/markdown-text-renderer.test.tsx @@ -68,6 +68,27 @@ describe("MarkdownTextRenderer", () => { expect(screen.queryByRole("img", { name: "index.html" })).not.toBeInTheDocument(); }); + it("renders source-style link lists as citation rows", () => { + render( + + { + "Sources:\n\n- Polymarket — “When will GPT-5.6 be released?”\n https://polymarket.com/event/when-will-gpt-5pt6-be-released\n- Polymarket — “GPT-5.6 released by...?”\n https://polymarket.com/event/gpt-5pt6-released-by" + } + , + ); + + expect( + screen.getByRole("link", { name: "Open source: When will GPT-5.6 be released?" }), + ).toHaveAttribute( + "href", + "https://polymarket.com/event/when-will-gpt-5pt6-be-released", + ); + expect( + screen.getByRole("link", { name: "Open source: GPT-5.6 released by...?" }), + ).toHaveAttribute("href", "https://polymarket.com/event/gpt-5pt6-released-by"); + expect(screen.queryByText("Polymarket · polymarket.com")).not.toBeInTheDocument(); + }); + it("renders media attachments without an extra preview/code wrapper", () => { render(![Diagram](/api/media/sig/payload));