mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-18 10:36:17 +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";
|
} from "@/core/artifacts/preview";
|
||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import { useAuth } from "@/core/auth/AuthProvider";
|
import { useAuth } from "@/core/auth/AuthProvider";
|
||||||
|
import { extractCitationSources } from "@/core/citations/sources";
|
||||||
import { writeTextToClipboard } from "@/core/clipboard";
|
import { writeTextToClipboard } from "@/core/clipboard";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { findToolCallResult } from "@/core/messages/utils";
|
import { findToolCallResult } from "@/core/messages/utils";
|
||||||
@ -55,6 +56,7 @@ import { env } from "@/env";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { ArtifactLink } from "../citations/artifact-link";
|
import { ArtifactLink } from "../citations/artifact-link";
|
||||||
|
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||||
import { useThread } from "../messages/context";
|
import { useThread } from "../messages/context";
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
@ -436,6 +438,11 @@ export function ArtifactFilePreview({
|
|||||||
[scrollKey],
|
[scrollKey],
|
||||||
);
|
);
|
||||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||||
|
const citationSources = useMemo(
|
||||||
|
() =>
|
||||||
|
language === "markdown" ? extractCitationSources(content ?? "") : [],
|
||||||
|
[content, language],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollPositionRef.current = { x: 0, y: 0 };
|
scrollPositionRef.current = { x: 0, y: 0 };
|
||||||
@ -503,14 +510,15 @@ export function ArtifactFilePreview({
|
|||||||
|
|
||||||
if (language === "markdown") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
<div className="size-full px-4">
|
<div className="size-full overflow-auto px-4 py-3">
|
||||||
<SafeStreamdown
|
<SafeStreamdown
|
||||||
className="size-full"
|
className="min-w-0"
|
||||||
{...artifactMarkdownPlugins}
|
{...artifactMarkdownPlugins}
|
||||||
components={{ a: ArtifactLink }}
|
components={{ a: ArtifactLink }}
|
||||||
>
|
>
|
||||||
{content ?? ""}
|
{content ?? ""}
|
||||||
</SafeStreamdown>
|
</SafeStreamdown>
|
||||||
|
<CitationSourcesPanel sources={citationSources} className="mb-4" />
|
||||||
</div>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { AnchorHTMLAttributes } from "react";
|
|
||||||
|
|
||||||
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
||||||
import {
|
import {
|
||||||
@ -9,13 +8,8 @@ import {
|
|||||||
streamdownPluginsWithoutRawHtml,
|
streamdownPluginsWithoutRawHtml,
|
||||||
} from "@/core/streamdown";
|
} from "@/core/streamdown";
|
||||||
import { SafeMessageResponse } from "@/core/streamdown/components";
|
import { SafeMessageResponse } from "@/core/streamdown/components";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||||
|
|
||||||
function isExternalUrl(href: string | undefined): boolean {
|
|
||||||
return !!href && /^https?:\/\//.test(href);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MarkdownContentProps = {
|
export type MarkdownContentProps = {
|
||||||
content: string;
|
content: string;
|
||||||
@ -46,28 +40,7 @@ export function MarkdownContent({
|
|||||||
}, [rehypePlugins]);
|
}, [rehypePlugins]);
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: createMarkdownLinkComponent(),
|
||||||
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)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
...componentsFromProps,
|
...componentsFromProps,
|
||||||
};
|
};
|
||||||
}, [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,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
type AnchorHTMLAttributes,
|
|
||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
@ -33,6 +32,7 @@ import {
|
|||||||
type FeedbackData,
|
type FeedbackData,
|
||||||
} from "@/core/api/feedback";
|
} from "@/core/api/feedback";
|
||||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||||
|
import { extractCitationSources } from "@/core/citations/sources";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@ -45,9 +45,11 @@ import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
|||||||
import { SafeReasoningContent } from "@/core/streamdown/components";
|
import { SafeReasoningContent } from "@/core/streamdown/components";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { CitationSourcesPanel } from "../citations/citation-sources-panel";
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
|
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
|
import { createMarkdownLinkComponent } from "./markdown-link";
|
||||||
|
|
||||||
function FeedbackButtons({
|
function FeedbackButtons({
|
||||||
threadId,
|
threadId,
|
||||||
@ -274,20 +276,7 @@ function MessageContent_({
|
|||||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
<MessageImage {...props} threadId={threadId} maxWidth="90%" />
|
||||||
),
|
),
|
||||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: createMarkdownLinkComponent(threadId),
|
||||||
if (href?.startsWith("/mnt/")) {
|
|
||||||
const url = resolveArtifactURL(href, threadId);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
{...props}
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <a {...props} href={href} />;
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[threadId],
|
[threadId],
|
||||||
);
|
);
|
||||||
@ -313,6 +302,10 @@ function MessageContent_({
|
|||||||
}
|
}
|
||||||
return rawContent ?? "";
|
return rawContent ?? "";
|
||||||
}, [rawContent, isHuman]);
|
}, [rawContent, isHuman]);
|
||||||
|
const citationSources = useMemo(
|
||||||
|
() => (isHuman ? [] : extractCitationSources(contentToDisplay)),
|
||||||
|
[contentToDisplay, isHuman],
|
||||||
|
);
|
||||||
|
|
||||||
const filesList =
|
const filesList =
|
||||||
files && files.length > 0 ? (
|
files && files.length > 0 ? (
|
||||||
@ -400,6 +393,7 @@ function MessageContent_({
|
|||||||
className="my-3"
|
className="my-3"
|
||||||
components={components}
|
components={components}
|
||||||
/>
|
/>
|
||||||
|
<CitationSourcesPanel sources={citationSources} />
|
||||||
</AIElementMessageContent>
|
</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",
|
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
|
// Input Box
|
||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: "How can I assist you today?",
|
placeholder: "How can I assist you today?",
|
||||||
|
|||||||
@ -63,6 +63,14 @@ export interface Translations {
|
|||||||
linkCopied: string;
|
linkCopied: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Citations
|
||||||
|
citations: {
|
||||||
|
sourcesSummary: (count: number) => string;
|
||||||
|
citeCount: (count: number) => string;
|
||||||
|
copyReference: (title: string) => string;
|
||||||
|
copiedReference: (title: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
// Input Box
|
// Input Box
|
||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
|||||||
@ -78,6 +78,14 @@ export const zhCN: Translations = {
|
|||||||
linkCopied: "链接已复制到剪贴板",
|
linkCopied: "链接已复制到剪贴板",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Citations
|
||||||
|
citations: {
|
||||||
|
sourcesSummary: (count) => `使用了 ${count} 个来源`,
|
||||||
|
citeCount: (count) => `${count} 次引用`,
|
||||||
|
copyReference: (title) => `复制 ${title} 引用`,
|
||||||
|
copiedReference: (title) => `已复制 ${title} 引用`,
|
||||||
|
},
|
||||||
|
|
||||||
// Input Box
|
// Input Box
|
||||||
inputBox: {
|
inputBox: {
|
||||||
placeholder: "今天我能为你做些什么?",
|
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