mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 18:46:17 +00:00
* 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.
124 lines
3.4 KiB
TypeScript
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, " ");
|
|
}
|