From 8a26b5c9a4053881add70b7dc728d1ed20d62c07 Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:03:43 +0800 Subject: [PATCH] feat(frontend): add citation sources evidence panel (#3907) * feat(frontend): add citation sources evidence panel Inline [citation:Title](URL) links render as badges, but with many citations the reader has no consolidated, deduplicated list of what a message or report actually drew on. Add a collapsible "sources" panel that extracts citation links from AI messages and markdown artifacts, dedupes by URL, counts occurrences, and offers per-source copy of a reusable markdown reference. - extractCitationSources: parse [citation:...](url) links, skip images and fenced code, dedupe by normalized URL, fall back to domain for generic labels - CitationSourcesPanel: collapsible list with per-source cite counts, internal scroll for long lists, and copy-to-clipboard reference button - Wire panel into AI message content and markdown artifact preview - Add en-US/zh-CN citation strings and types - Unit tests for extraction and panel rendering * fix(frontend): preserve default link styling in message content override MessageContent_ passes a custom `a` renderer to MarkdownContent, whose default `a` (primary underline + external target/rel) is overridden because MarkdownContent spreads props components last. Restore that styling/external behavior in the fallback branch so normal links in messages aren't regressed, while keeping citation: and /mnt/ handling. * fix(frontend): harden citation source extraction and dedupe link renderer Address review findings on the citation sources panel: - Use a non-consuming lookbehind so back-to-back citations no longer drop every other source. - Match balanced parenthetical groups in URLs so disambiguation links like .../Foo_(a)_(b) are no longer truncated. - Mask inline code (and unclosed streaming fences) so example citations in code aren't scraped as real sources; masking preserves indices. - Extract a shared createMarkdownLinkComponent factory used by both message content and markdown content, removing the duplicated `a` renderer. --- .../artifacts/artifact-file-detail.tsx | 12 +- .../citations/citation-sources-panel.tsx | 138 +++++++++++++++++ .../workspace/messages/markdown-content.tsx | 31 +--- .../workspace/messages/markdown-link.tsx | 58 +++++++ .../workspace/messages/message-list-item.tsx | 24 ++- frontend/src/core/citations/sources.ts | 123 +++++++++++++++ frontend/src/core/i18n/locales/en-US.ts | 9 ++ frontend/src/core/i18n/locales/types.ts | 8 + frontend/src/core/i18n/locales/zh-CN.ts | 8 + .../citations/citation-sources-panel.test.ts | 88 +++++++++++ .../tests/unit/core/citations/sources.test.ts | 143 ++++++++++++++++++ 11 files changed, 596 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/workspace/citations/citation-sources-panel.tsx create mode 100644 frontend/src/components/workspace/messages/markdown-link.tsx create mode 100644 frontend/src/core/citations/sources.ts create mode 100644 frontend/tests/unit/components/workspace/citations/citation-sources-panel.test.ts create mode 100644 frontend/tests/unit/core/citations/sources.test.ts diff --git a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx index 3907266bd..546a9d8a5 100644 --- a/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -39,6 +39,7 @@ import { } from "@/core/artifacts/preview"; import { urlOfArtifact } from "@/core/artifacts/utils"; import { useAuth } from "@/core/auth/AuthProvider"; +import { extractCitationSources } from "@/core/citations/sources"; import { writeTextToClipboard } from "@/core/clipboard"; import { useI18n } from "@/core/i18n/hooks"; import { findToolCallResult } from "@/core/messages/utils"; @@ -55,6 +56,7 @@ import { env } from "@/env"; import { cn } from "@/lib/utils"; import { ArtifactLink } from "../citations/artifact-link"; +import { CitationSourcesPanel } from "../citations/citation-sources-panel"; import { useThread } from "../messages/context"; import { Tooltip } from "../tooltip"; @@ -436,6 +438,11 @@ export function ArtifactFilePreview({ [scrollKey], ); const [htmlPreviewUrl, setHtmlPreviewUrl] = useState(); + const citationSources = useMemo( + () => + language === "markdown" ? extractCitationSources(content ?? "") : [], + [content, language], + ); useEffect(() => { scrollPositionRef.current = { x: 0, y: 0 }; @@ -503,14 +510,15 @@ export function ArtifactFilePreview({ if (language === "markdown") { return ( -
+
{content ?? ""} +
); } diff --git a/frontend/src/components/workspace/citations/citation-sources-panel.tsx b/frontend/src/components/workspace/citations/citation-sources-panel.tsx new file mode 100644 index 000000000..8acc70d5b --- /dev/null +++ b/frontend/src/components/workspace/citations/citation-sources-panel.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { + BookOpenTextIcon, + CheckIcon, + CopyIcon, + ExternalLinkIcon, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +import { + formatCitationMarkdownReference, + type CitationSource, +} from "@/core/citations/sources"; +import { writeTextToClipboard } from "@/core/clipboard"; +import { useI18n } from "@/core/i18n/hooks"; +import { cn } from "@/lib/utils"; + +export function CitationSourcesPanel({ + className, + sources, +}: { + className?: string; + sources: CitationSource[]; +}) { + const { t } = useI18n(); + + if (sources.length === 0) { + return null; + } + + return ( +
+ + + + {t.citations.sourcesSummary(sources.length)} + + +
    + {sources.map((source, index) => ( +
  1. + + {index + 1} + + + + + {source.title} + + + {source.domain} + + + + {t.citations.citeCount(source.count)} + + + + +
  2. + ))} +
+
+ ); +} + +function CitationSourceCopyButton({ source }: { source: CitationSource }) { + const { t } = useI18n(); + const [copied, setCopied] = useState(false); + const resetTimerRef = useRef | null>(null); + const copyLabel = t.citations.copyReference(source.title); + const copiedLabel = t.citations.copiedReference(source.title); + + useEffect(() => { + return () => { + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + } + }; + }, []); + + const handleCopy = useCallback(() => { + void (async () => { + const didCopy = await writeTextToClipboard( + formatCitationMarkdownReference(source), + ); + if (!didCopy) { + toast.error(t.clipboard.failedToCopyToClipboard); + return; + } + + setCopied(true); + toast.success(t.clipboard.copiedToClipboard); + if (resetTimerRef.current) { + clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = setTimeout(() => { + setCopied(false); + resetTimerRef.current = null; + }, 2000); + })().catch(() => { + toast.error(t.clipboard.failedToCopyToClipboard); + }); + }, [ + source, + t.clipboard.copiedToClipboard, + t.clipboard.failedToCopyToClipboard, + ]); + + return ( + + ); +} diff --git a/frontend/src/components/workspace/messages/markdown-content.tsx b/frontend/src/components/workspace/messages/markdown-content.tsx index 203c54e2f..6772f5f61 100644 --- a/frontend/src/components/workspace/messages/markdown-content.tsx +++ b/frontend/src/components/workspace/messages/markdown-content.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo } from "react"; -import type { AnchorHTMLAttributes } from "react"; import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown"; import { @@ -9,13 +8,8 @@ import { streamdownPluginsWithoutRawHtml, } from "@/core/streamdown"; import { SafeMessageResponse } from "@/core/streamdown/components"; -import { cn } from "@/lib/utils"; -import { CitationLink } from "../citations/citation-link"; - -function isExternalUrl(href: string | undefined): boolean { - return !!href && /^https?:\/\//.test(href); -} +import { createMarkdownLinkComponent } from "./markdown-link"; export type MarkdownContentProps = { content: string; @@ -46,28 +40,7 @@ export function MarkdownContent({ }, [rehypePlugins]); const components = useMemo(() => { return { - a: (props: AnchorHTMLAttributes) => { - if (typeof props.children === "string") { - const match = /^citation:(.+)$/.exec(props.children); - if (match) { - const [, text] = match; - return {text}; - } - } - const { className, target, rel, ...rest } = props; - const external = isExternalUrl(props.href); - return ( - - ); - }, + a: createMarkdownLinkComponent(), ...componentsFromProps, }; }, [componentsFromProps]); diff --git a/frontend/src/components/workspace/messages/markdown-link.tsx b/frontend/src/components/workspace/messages/markdown-link.tsx new file mode 100644 index 000000000..22f2cf419 --- /dev/null +++ b/frontend/src/components/workspace/messages/markdown-link.tsx @@ -0,0 +1,58 @@ +import type { AnchorHTMLAttributes } from "react"; + +import { resolveArtifactURL } from "@/core/artifacts/utils"; +import { cn } from "@/lib/utils"; + +import { CitationLink } from "../citations/citation-link"; + +function isExternalUrl(href: string | undefined): boolean { + return !!href && /^https?:\/\//.test(href); +} + +/** + * Builds the `a` renderer shared by message content and generic markdown. + * Passing a `threadId` also resolves `/mnt/` artifact links; without it those + * links fall through to the default external-link handling. + */ +export function createMarkdownLinkComponent(threadId?: string) { + return function MarkdownLink({ + href, + ...props + }: AnchorHTMLAttributes) { + if (typeof props.children === "string") { + const match = /^citation:(.+)$/.exec(props.children); + if (match) { + const [, text] = match; + return ( + + {text} + + ); + } + } + if (threadId && href?.startsWith("/mnt/")) { + return ( + + ); + } + const { className, target, rel, ...rest } = props; + const external = isExternalUrl(href); + return ( + + ); + }; +} diff --git a/frontend/src/components/workspace/messages/message-list-item.tsx b/frontend/src/components/workspace/messages/message-list-item.tsx index 4f12da94e..de4786a6f 100644 --- a/frontend/src/components/workspace/messages/message-list-item.tsx +++ b/frontend/src/components/workspace/messages/message-list-item.tsx @@ -11,7 +11,6 @@ import { useMemo, useState, useEffect, - type AnchorHTMLAttributes, type ImgHTMLAttributes, } from "react"; @@ -33,6 +32,7 @@ import { type FeedbackData, } from "@/core/api/feedback"; import { resolveArtifactURL } from "@/core/artifacts/utils"; +import { extractCitationSources } from "@/core/citations/sources"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, @@ -45,9 +45,11 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { SafeReasoningContent } from "@/core/streamdown/components"; import { cn } from "@/lib/utils"; +import { CitationSourcesPanel } from "../citations/citation-sources-panel"; import { CopyButton } from "../copy-button"; import { MarkdownContent } from "./markdown-content"; +import { createMarkdownLinkComponent } from "./markdown-link"; function FeedbackButtons({ threadId, @@ -274,20 +276,7 @@ function MessageContent_({ img: (props: ImgHTMLAttributes) => ( ), - a: ({ href, ...props }: AnchorHTMLAttributes) => { - if (href?.startsWith("/mnt/")) { - const url = resolveArtifactURL(href, threadId); - return ( - - ); - } - return ; - }, + a: createMarkdownLinkComponent(threadId), }), [threadId], ); @@ -313,6 +302,10 @@ function MessageContent_({ } return rawContent ?? ""; }, [rawContent, isHuman]); + const citationSources = useMemo( + () => (isHuman ? [] : extractCitationSources(contentToDisplay)), + [contentToDisplay, isHuman], + ); const filesList = files && files.length > 0 ? ( @@ -400,6 +393,7 @@ function MessageContent_({ className="my-3" components={components} /> + ); } diff --git a/frontend/src/core/citations/sources.ts b/frontend/src/core/citations/sources.ts new file mode 100644 index 000000000..e3c0fcdbd --- /dev/null +++ b/frontend/src/core/citations/sources.ts @@ -0,0 +1,123 @@ +export type CitationOccurrence = { + index: number; + title: string; +}; + +export type CitationSource = { + id: string; + title: string; + url: string; + domain: string; + count: number; + occurrences: CitationOccurrence[]; +}; + +// Uses a non-consuming lookbehind (?(); + + for (const match of searchable.matchAll(CITATION_LINK_RE)) { + const rawTitle = (match[1] ?? "").trim(); + const rawUrl = match[2] ?? ""; + const url = normalizeUrl(rawUrl); + if (!url) { + continue; + } + + const domain = extractDomain(url); + const title = normalizeTitle(rawTitle, domain); + const index = match.index ?? 0; + const existing = sourcesByUrl.get(url); + + if (existing) { + existing.count += 1; + existing.occurrences.push({ index, title }); + continue; + } + + sourcesByUrl.set(url, { + id: url, + title, + url, + domain, + count: 1, + occurrences: [{ index, title }], + }); + } + + return Array.from(sourcesByUrl.values()); +} + +export function formatCitationMarkdownReference( + source: CitationSource, +): string { + return `[${source.title}](${source.url})`; +} + +function normalizeTitle(title: string, domain: string): string { + const compact = title.replace(/\s+/g, " ").trim(); + if (!compact || GENERIC_CITATION_TITLES.has(compact.toLowerCase())) { + return domain; + } + return compact; +} + +function normalizeUrl(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") { + return null; + } + return url.href; + } catch { + return null; + } +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./i, ""); + } catch { + return url; + } +} + +// Blanks out code regions so example citations inside code aren't scraped as +// real sources, while preserving string length (and newlines) so occurrence +// indices stay aligned with the original markdown. +function maskCode(markdown: string): string { + return maskInlineCode(maskFencedCodeBlocks(markdown)); +} + +function maskFencedCodeBlocks(markdown: string): string { + // Match a fenced block up to its matching closing fence, or — while the + // message is still streaming — to end of input when the fence is unclosed. + return markdown.replace( + /(^|\n)(`{3,}|~{3,})[^\n]*(?:\n[\s\S]*?\n\2[^\n]*(?=\n|$)|[\s\S]*$)/g, + maskKeepingNewlines, + ); +} + +function maskInlineCode(markdown: string): string { + // Only mask closed spans: an unclosed backtick run renders as literal text, + // so a citation after it is a real, rendered link and must not be masked. + return markdown.replace(/(`+)[\s\S]*?\1/g, maskKeepingNewlines); +} + +function maskKeepingNewlines(block: string): string { + return block.replace(/[^\n]/g, " "); +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index ebefa05b9..b660b4024 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -78,6 +78,15 @@ export const enUS: Translations = { linkCopied: "Link copied to clipboard", }, + // Citations + citations: { + sourcesSummary: (count) => + `Used ${count} ${count === 1 ? "source" : "sources"}`, + citeCount: (count) => `${count} ${count === 1 ? "cite" : "cites"}`, + copyReference: (title) => `Copy ${title} reference`, + copiedReference: (title) => `Copied ${title} reference`, + }, + // Input Box inputBox: { placeholder: "How can I assist you today?", diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 71de5c34f..6f5ad0356 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -63,6 +63,14 @@ export interface Translations { linkCopied: string; }; + // Citations + citations: { + sourcesSummary: (count: number) => string; + citeCount: (count: number) => string; + copyReference: (title: string) => string; + copiedReference: (title: string) => string; + }; + // Input Box inputBox: { placeholder: string; diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index 18e2f271e..fdf62189a 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -78,6 +78,14 @@ export const zhCN: Translations = { linkCopied: "链接已复制到剪贴板", }, + // Citations + citations: { + sourcesSummary: (count) => `使用了 ${count} 个来源`, + citeCount: (count) => `${count} 次引用`, + copyReference: (title) => `复制 ${title} 引用`, + copiedReference: (title) => `已复制 ${title} 引用`, + }, + // Input Box inputBox: { placeholder: "今天我能为你做些什么?", diff --git a/frontend/tests/unit/components/workspace/citations/citation-sources-panel.test.ts b/frontend/tests/unit/components/workspace/citations/citation-sources-panel.test.ts new file mode 100644 index 000000000..03b1ab9cf --- /dev/null +++ b/frontend/tests/unit/components/workspace/citations/citation-sources-panel.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@rstest/core"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CitationSourcesPanel } from "@/components/workspace/citations/citation-sources-panel"; +import type { CitationSource } from "@/core/citations/sources"; +import { I18nContext } from "@/core/i18n/context"; + +const sources: CitationSource[] = [ + { + id: "https://example.com/a", + title: "Paper A", + url: "https://example.com/a", + domain: "example.com", + count: 2, + occurrences: [ + { index: 10, title: "Paper A" }, + { index: 90, title: "Paper A" }, + ], + }, + { + id: "https://news.example.org/report", + title: "Report B", + url: "https://news.example.org/report", + domain: "news.example.org", + count: 1, + occurrences: [{ index: 120, title: "Report B" }], + }, +]; + +describe("CitationSourcesPanel", () => { + it("renders nothing when there are no sources", () => { + expect(renderPanel([], "en-US")).toBe(""); + }); + + it("renders a compact source list with occurrence counts", () => { + const html = renderPanel(sources, "en-US"); + + expect(html).toContain("Used 2 sources"); + expect(html).toContain("Paper A"); + expect(html).toContain("example.com"); + expect(html).toContain("2 cites"); + expect(html).toContain("Report B"); + expect(html).toContain("news.example.org"); + expect(html).toContain('href="https://news.example.org/report"'); + }); + + it("constrains long source lists inside an internal scroll area", () => { + const html = renderPanel(sources, "en-US"); + + expect(html).toContain("max-h-80"); + expect(html).toContain("overflow-y-auto"); + expect(html).toContain("overscroll-contain"); + }); + + it("uses localized summary and cite labels", () => { + const html = renderPanel(sources, "zh-CN"); + + expect(html).toContain("使用了 2 个来源"); + expect(html).toContain("2 次引用"); + expect(html).toContain("复制 Paper A 引用"); + }); + + it("renders accessible copied-state labels for copy feedback", () => { + const html = renderPanel(sources, "en-US"); + + expect(html).toContain("Copy Paper A reference"); + expect(html).toContain('data-copied-label="Copied Paper A reference"'); + }); +}); + +function renderPanel( + panelSources: CitationSource[], + initialLocale: "en-US" | "zh-CN", +) { + return renderToStaticMarkup( + createElement( + I18nContext.Provider, + { + value: { + locale: initialLocale, + setLocale: () => undefined, + }, + }, + createElement(CitationSourcesPanel, { sources: panelSources }), + ), + ); +} diff --git a/frontend/tests/unit/core/citations/sources.test.ts b/frontend/tests/unit/core/citations/sources.test.ts new file mode 100644 index 000000000..df04403fd --- /dev/null +++ b/frontend/tests/unit/core/citations/sources.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "@rstest/core"; + +import { + extractCitationSources, + formatCitationMarkdownReference, +} from "@/core/citations/sources"; + +describe("extractCitationSources", () => { + it("extracts citation markdown links in first-seen order", () => { + const markdown = [ + "Deep research needs evidence [citation:Paper A](https://example.com/a).", + "A second claim cites [citation:Report B](https://news.example.org/report?x=1).", + ].join("\n"); + const firstIndex = markdown.indexOf("[citation:Paper A]"); + const secondIndex = markdown.indexOf("[citation:Report B]"); + + expect(extractCitationSources(markdown)).toEqual([ + { + id: "https://example.com/a", + title: "Paper A", + url: "https://example.com/a", + domain: "example.com", + count: 1, + occurrences: [{ index: firstIndex, title: "Paper A" }], + }, + { + id: "https://news.example.org/report?x=1", + title: "Report B", + url: "https://news.example.org/report?x=1", + domain: "news.example.org", + count: 1, + occurrences: [{ index: secondIndex, title: "Report B" }], + }, + ]); + }); + + it("deduplicates repeated citation URLs and preserves occurrence titles", () => { + const markdown = [ + "First [citation:Original Title](https://example.com/research).", + "Later [citation:Updated Title](https://example.com/research).", + ].join("\n"); + const firstIndex = markdown.indexOf("[citation:Original Title]"); + const secondIndex = markdown.indexOf("[citation:Updated Title]"); + + expect(extractCitationSources(markdown)).toEqual([ + { + id: "https://example.com/research", + title: "Original Title", + url: "https://example.com/research", + domain: "example.com", + count: 2, + occurrences: [ + { index: firstIndex, title: "Original Title" }, + { index: secondIndex, title: "Updated Title" }, + ], + }, + ]); + }); + + it("ignores normal links, image links, and citations inside fenced code", () => { + const markdown = [ + "[Normal](https://example.com/normal)", + "![citation:Image](https://example.com/image.png)", + "```md", + "[citation:Example](https://example.com/example)", + "```", + "Real source [citation:Real](https://example.com/real).", + ].join("\n"); + const realIndex = markdown.indexOf("[citation:Real]"); + + expect(extractCitationSources(markdown)).toEqual([ + { + id: "https://example.com/real", + title: "Real", + url: "https://example.com/real", + domain: "example.com", + count: 1, + occurrences: [{ index: realIndex, title: "Real" }], + }, + ]); + }); + + it("keeps every source when citations are directly adjacent", () => { + const markdown = + "[citation:A](https://example.com/a)[citation:B](https://example.com/b)[citation:C](https://example.com/c)"; + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/a", + "https://example.com/b", + "https://example.com/c", + ]); + }); + + it("keeps URLs that contain multiple balanced parenthetical groups", () => { + const markdown = "[citation:W](https://en.wikipedia.org/wiki/Foo_(a)_(b))"; + + expect(extractCitationSources(markdown)[0]).toMatchObject({ + url: "https://en.wikipedia.org/wiki/Foo_(a)_(b)", + domain: "en.wikipedia.org", + }); + }); + + it("ignores citations inside inline code spans", () => { + const markdown = + "Example: `[citation:X](https://x.com/inline)` then real [citation:Real](https://example.com/real)."; + + expect(extractCitationSources(markdown).map((s) => s.url)).toEqual([ + "https://example.com/real", + ]); + }); + + it("ignores citations inside an unclosed fenced code block", () => { + const markdown = [ + "Streaming output:", + "```md", + "[citation:Streaming](https://example.com/streaming)", + ].join("\n"); + + expect(extractCitationSources(markdown)).toEqual([]); + }); + + it("uses the source domain when the citation label is generic", () => { + const markdown = "See [citation:Source](https://www.example.com/path)."; + + expect(extractCitationSources(markdown)[0]).toMatchObject({ + title: "example.com", + domain: "example.com", + url: "https://www.example.com/path", + }); + }); +}); + +describe("formatCitationMarkdownReference", () => { + it("formats a source as a reusable markdown reference", () => { + const [source] = extractCitationSources( + "Evidence [citation:Paper A](https://example.com/a).", + ); + + expect(formatCitationMarkdownReference(source!)).toBe( + "[Paper A](https://example.com/a)", + ); + }); +});