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.
This commit is contained in:
Ryker_Feng 2026-07-03 18:03:43 +08:00 committed by GitHub
parent b81334ccfe
commit 8a26b5c9a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 596 additions and 46 deletions

View File

@ -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<string>();
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 (
<div className="size-full px-4">
<div className="size-full overflow-auto px-4 py-3">
<SafeStreamdown
className="size-full"
className="min-w-0"
{...artifactMarkdownPlugins}
components={{ a: ArtifactLink }}
>
{content ?? ""}
</SafeStreamdown>
<CitationSourcesPanel sources={citationSources} className="mb-4" />
</div>
);
}

View File

@ -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 (
<details
className={cn(
"not-prose border-border/60 bg-muted/20 mt-2 rounded-md border text-xs",
className,
)}
>
<summary className="text-muted-foreground hover:text-foreground flex cursor-pointer list-none items-center gap-2 px-3 py-2 transition-colors [&::-webkit-details-marker]:hidden">
<BookOpenTextIcon className="size-3.5 shrink-0" />
<span className="font-medium">
{t.citations.sourcesSummary(sources.length)}
</span>
</summary>
<ol className="border-border/60 divide-border/60 max-h-80 divide-y overflow-y-auto overscroll-contain border-t">
{sources.map((source, index) => (
<li key={source.id} className="flex min-w-0 items-center gap-2 p-2">
<span className="text-muted-foreground w-5 shrink-0 text-right tabular-nums">
{index + 1}
</span>
<a
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="hover:bg-muted flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1 transition-colors"
>
<span className="min-w-0 flex-1">
<span className="text-foreground block truncate font-medium">
{source.title}
</span>
<span className="text-muted-foreground block truncate">
{source.domain}
</span>
</span>
<span className="text-muted-foreground shrink-0">
{t.citations.citeCount(source.count)}
</span>
<ExternalLinkIcon className="text-muted-foreground size-3.5 shrink-0" />
</a>
<CitationSourceCopyButton source={source} />
</li>
))}
</ol>
</details>
);
}
function CitationSourceCopyButton({ source }: { source: CitationSource }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const resetTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<button
type="button"
className="text-muted-foreground hover:bg-muted hover:text-foreground shrink-0 rounded p-1.5 transition-colors"
aria-label={copied ? copiedLabel : copyLabel}
title={copied ? copiedLabel : copyLabel}
data-copied-label={copiedLabel}
onClick={handleCopy}
>
{copied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<CopyIcon className="size-3.5" />
)}
</button>
);
}

View File

@ -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<HTMLAnchorElement>) => {
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return <CitationLink {...props}>{text}</CitationLink>;
}
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(props.href);
return (
<a
{...rest}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
/>
);
},
a: createMarkdownLinkComponent(),
...componentsFromProps,
};
}, [componentsFromProps]);

View File

@ -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<HTMLAnchorElement>) {
if (typeof props.children === "string") {
const match = /^citation:(.+)$/.exec(props.children);
if (match) {
const [, text] = match;
return (
<CitationLink {...props} href={href}>
{text}
</CitationLink>
);
}
}
if (threadId && href?.startsWith("/mnt/")) {
return (
<a
{...props}
href={resolveArtifactURL(href, threadId)}
target="_blank"
rel="noopener noreferrer"
/>
);
}
const { className, target, rel, ...rest } = props;
const external = isExternalUrl(href);
return (
<a
{...rest}
href={href}
className={cn(
"text-primary decoration-primary/30 hover:decoration-primary/60 underline underline-offset-2 transition-colors",
className,
)}
target={target ?? (external ? "_blank" : undefined)}
rel={rel ?? (external ? "noopener noreferrer" : undefined)}
/>
);
};
}

View File

@ -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<HTMLImageElement>) => (
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("/mnt/")) {
const url = resolveArtifactURL(href, threadId);
return (
<a
{...props}
href={url}
target="_blank"
rel="noopener noreferrer"
/>
);
}
return <a {...props} href={href} />;
},
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}
/>
<CitationSourcesPanel sources={citationSources} />
</AIElementMessageContent>
);
}

View File

@ -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 (?<!!) to skip image links (![citation:…])
// without eating the boundary char, so back-to-back citations both match. The
// URL sub-pattern consumes either non-paren chars or a balanced (…) group, so
// disambiguation URLs like .../Foo_(a)_(b) survive rather than truncating at
// the first inner paren.
const CITATION_LINK_RE =
/(?<!!)\[citation:\s*([^\]]+?)\]\((https?:\/\/(?:[^\s()]|\([^\s()]*\))+)\)/gi;
const GENERIC_CITATION_TITLES = new Set(["source", "来源"]);
export function extractCitationSources(markdown: string): CitationSource[] {
if (!markdown) {
return [];
}
const searchable = maskCode(markdown);
const sourcesByUrl = new Map<string, CitationSource>();
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, " ");
}

View File

@ -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?",

View File

@ -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;

View File

@ -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: "今天我能为你做些什么?",

View File

@ -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 }),
),
);
}

View File

@ -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)",
);
});
});