mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-22 14:08:28 +00:00
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:
parent
b81334ccfe
commit
8a26b5c9a4
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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]);
|
||||
|
||||
58
frontend/src/components/workspace/messages/markdown-link.tsx
Normal file
58
frontend/src/components/workspace/messages/markdown-link.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
123
frontend/src/core/citations/sources.ts
Normal file
123
frontend/src/core/citations/sources.ts
Normal 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, " ");
|
||||
}
|
||||
@ -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?",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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: "今天我能为你做些什么?",
|
||||
|
||||
@ -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 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
143
frontend/tests/unit/core/citations/sources.test.ts
Normal file
143
frontend/tests/unit/core/citations/sources.test.ts
Normal 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)",
|
||||
"",
|
||||
"```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)",
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user