Ryker_Feng 8a26b5c9a4
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.
2026-07-03 18:03:43 +08:00

124 lines
3.4 KiB
TypeScript

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