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 (
+