refactor(frontend): simplify and deduplicate Citation-related code

- Extract removeCitationsBlocks in utils, reuse in parseCitations and removeAllCitations
- Add hasCitationsBlock; isCitationsBlockIncomplete now uses it
- Add useParsedCitations hook (parseCitations + buildCitationMap) for message/artifact
- Add CitationAwareLink to unify link rendering (message-list-item + artifact-file-detail)
- Add getCleanContent helper; message-group uses it and useParsedCitations
- ArtifactFileDetail: single useParsedCitations, pass cleanContent/citationMap to Preview
- Stop exporting buildCitationMap and removeCitationsBlocks from citations index
- Remove duplicate MessageLink and inline link logic in artifact preview

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
LofiSu
2026-02-09 12:13:06 +08:00
parent f0423b88ac
commit 30e1760211
7 changed files with 202 additions and 191 deletions

View File

@@ -5,7 +5,7 @@ import { memo, useMemo } from "react";
import rehypeKatex from "rehype-katex";
import {
CitationLink,
CitationAwareLink,
CitationsLoadingIndicator,
} from "@/components/ai-elements/inline-citation";
import {
@@ -17,11 +17,9 @@ import {
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import {
type Citation,
buildCitationMap,
isCitationsBlockIncomplete,
parseCitations,
removeAllCitations,
useParsedCitations,
} from "@/core/citations";
import {
extractContentFromMessage,
@@ -31,11 +29,7 @@ import {
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown";
import {
cn,
externalLinkClass,
externalLinkClassNoUnderline,
} from "@/lib/utils";
import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
@@ -79,49 +73,6 @@ export function MessageListItem({
);
}
/**
* Custom link component that handles citations and external links
* Only links in citationMap are rendered as CitationLink badges
* Other links (project URLs, regular links) are rendered as plain links
* During citation loading (streaming), non-citation links are rendered without underline so they match final citation style (p3)
*/
function MessageLink({
href,
children,
citationMap,
isHuman,
isLoadingCitations,
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
citationMap: Map<string, Citation>;
isHuman: boolean;
isLoadingCitations?: boolean;
}) {
if (!href) return <span>{children}</span>;
const citation = citationMap.get(href);
// Only render as CitationLink badge if it's a citation (in citationMap) and not human message
if (citation && !isHuman) {
return (
<CitationLink citation={citation} href={href}>
{children}
</CitationLink>
);
}
const noUnderline = !isHuman && isLoadingCitations;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={noUnderline ? externalLinkClassNoUnderline : externalLinkClass}
>
{children}
</a>
);
}
/**
* Custom image component that handles artifact URLs
*/
@@ -165,50 +116,44 @@ function MessageContent_({
const isHuman = message.type === "human";
const { thread_id } = useParams<{ thread_id: string }>();
// Extract and parse citations and uploaded files from message content
const { citations, cleanContent, uploadedFiles, isLoadingCitations } =
useMemo(() => {
const reasoningContent = extractReasoningContentFromMessage(message);
const rawContent = extractContentFromMessage(message);
// Content to parse for citations (and optionally uploaded files)
const { contentToParse, uploadedFiles, isLoadingCitations } = useMemo(() => {
const reasoningContent = extractReasoningContentFromMessage(message);
const rawContent = extractContentFromMessage(message);
// When only reasoning content exists (no main content), also parse citations
if (!isLoading && reasoningContent && !rawContent) {
const { citations, cleanContent } = parseCitations(reasoningContent);
return {
citations,
cleanContent,
uploadedFiles: [],
isLoadingCitations: false,
};
}
if (!isLoading && reasoningContent && !rawContent) {
return {
contentToParse: reasoningContent,
uploadedFiles: [] as UploadedFile[],
isLoadingCitations: false,
};
}
// For human messages, parse uploaded files first
if (isHuman && rawContent) {
const { files, cleanContent: contentWithoutFiles } =
parseUploadedFiles(rawContent);
const { citations, cleanContent: finalContent } =
parseCitations(contentWithoutFiles);
return {
citations,
cleanContent: finalContent,
uploadedFiles: files,
isLoadingCitations: false,
};
}
if (isHuman && rawContent) {
const { files, cleanContent: contentWithoutFiles } =
parseUploadedFiles(rawContent);
return {
contentToParse: contentWithoutFiles,
uploadedFiles: files,
isLoadingCitations: false,
};
}
const { citations, cleanContent } = parseCitations(rawContent ?? "");
const isLoadingCitations =
isLoading && isCitationsBlockIncomplete(rawContent ?? "");
return {
contentToParse: rawContent ?? "",
uploadedFiles: [] as UploadedFile[],
isLoadingCitations:
isLoading && isCitationsBlockIncomplete(rawContent ?? ""),
};
}, [isLoading, message, isHuman]);
return { citations, cleanContent, uploadedFiles: [], isLoadingCitations };
}, [isLoading, message, isHuman]);
const citationMap = useMemo(() => buildCitationMap(citations), [citations]);
const { citations, cleanContent, citationMap } =
useParsedCitations(contentToParse);
// Shared markdown components
const markdownComponents = useMemo(() => ({
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<MessageLink
<CitationAwareLink
{...props}
citationMap={citationMap}
isHuman={isHuman}