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:
ruitanglin
2026-02-09 12:13:06 +08:00
parent 302211696e
commit 175c1d2e3b
7 changed files with 202 additions and 191 deletions

View File

@@ -12,10 +12,15 @@ import {
HoverCardContent, HoverCardContent,
HoverCardTrigger, HoverCardTrigger,
} from "@/components/ui/hover-card"; } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils"; import {
cn,
externalLinkClass,
externalLinkClassNoUnderline,
} from "@/lib/utils";
import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { import {
type ComponentProps, type ComponentProps,
Children,
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
@@ -23,7 +28,11 @@ import {
useState, useState,
} from "react"; } from "react";
import type { Citation } from "@/core/citations"; import type { Citation } from "@/core/citations";
import { extractDomainFromUrl } from "@/core/citations"; import {
extractDomainFromUrl,
isExternalUrl,
syntheticCitationFromLink,
} from "@/core/citations";
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -360,6 +369,71 @@ export const CitationLink = ({
); );
}; };
/**
* Renders a link with optional citation badge. Use in markdown components (message + artifact).
* - citationMap: URL -> Citation; links in map render as CitationLink.
* - isHuman: when true, never render as CitationLink (plain link).
* - isLoadingCitations: when true and not human, non-citation links use no-underline style.
* - syntheticExternal: when true, external URLs not in citationMap render as CitationLink with synthetic citation.
*/
export type CitationAwareLinkProps = ComponentProps<"a"> & {
citationMap: Map<string, Citation>;
isHuman?: boolean;
isLoadingCitations?: boolean;
syntheticExternal?: boolean;
};
export const CitationAwareLink = ({
href,
children,
citationMap,
isHuman = false,
isLoadingCitations = false,
syntheticExternal = false,
className,
...rest
}: CitationAwareLinkProps) => {
if (!href) return <span>{children}</span>;
const citation = citationMap.get(href);
if (citation && !isHuman) {
return (
<CitationLink citation={citation} href={href}>
{children}
</CitationLink>
);
}
if (syntheticExternal && isExternalUrl(href)) {
const linkText =
typeof children === "string"
? children
: String(Children.toArray(children).join("")).trim() || href;
return (
<CitationLink
citation={syntheticCitationFromLink(href, linkText)}
href={href}
>
{children}
</CitationLink>
);
}
const noUnderline = !isHuman && isLoadingCitations;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={cn(noUnderline ? externalLinkClassNoUnderline : externalLinkClass, className)}
{...rest}
>
{children}
</a>
);
};
/** /**
* Shared CitationsLoadingIndicator component * Shared CitationsLoadingIndicator component
* Used across message-list-item and message-group to show loading citations * Used across message-list-item and message-group to show loading citations

View File

@@ -21,7 +21,7 @@ import {
ArtifactHeader, ArtifactHeader,
ArtifactTitle, ArtifactTitle,
} from "@/components/ai-elements/artifact"; } from "@/components/ai-elements/artifact";
import { CitationLink } from "@/components/ai-elements/inline-citation"; import { CitationAwareLink } from "@/components/ai-elements/inline-citation";
import { Select, SelectItem } from "@/components/ui/select"; import { Select, SelectItem } from "@/components/ui/select";
import { import {
SelectContent, SelectContent,
@@ -33,19 +33,14 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { CodeEditor } from "@/components/workspace/code-editor"; import { CodeEditor } from "@/components/workspace/code-editor";
import { useArtifactContent } from "@/core/artifacts/hooks"; import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import { import type { Citation } from "@/core/citations";
buildCitationMap, import { removeAllCitations, useParsedCitations } from "@/core/citations";
isExternalUrl,
parseCitations,
removeAllCitations,
syntheticCitationFromLink,
} from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api"; import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files"; import { checkCodeFile, getFileName } from "@/core/utils/files";
import { env } from "@/env"; import { env } from "@/env";
import { cn, externalLinkClass } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
@@ -96,15 +91,11 @@ export function ArtifactFileDetail({
enabled: isCodeFile && !isWriteFile, enabled: isCodeFile && !isWriteFile,
}); });
// Parse citations and get clean content for code editor const parsed = useParsedCitations(
const cleanContent = useMemo(() => { language === "markdown" ? (content ?? "") : "",
if (language === "markdown" && content) { );
return parseCitations(content).cleanContent; const cleanContent =
} language === "markdown" && content ? parsed.cleanContent : (content ?? "");
return content;
}, [content, language]);
// Get content without ANY citations for copy/download
const contentWithoutCitations = useMemo(() => { const contentWithoutCitations = useMemo(() => {
if (language === "markdown" && content) { if (language === "markdown" && content) {
return removeAllCitations(content); return removeAllCitations(content);
@@ -260,6 +251,8 @@ export function ArtifactFileDetail({
threadId={threadId} threadId={threadId}
content={content} content={content}
language={language ?? "text"} language={language ?? "text"}
cleanContent={parsed.cleanContent}
citationMap={parsed.citationMap}
/> />
)} )}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
@@ -285,21 +278,16 @@ export function ArtifactFilePreview({
threadId, threadId,
content, content,
language, language,
cleanContent,
citationMap,
}: { }: {
filepath: string; filepath: string;
threadId: string; threadId: string;
content: string; content: string;
language: string; language: string;
cleanContent: string;
citationMap: Map<string, Citation>;
}) { }) {
const { cleanContent, citationMap } = React.useMemo(() => {
const parsed = parseCitations(content ?? "");
const map = buildCitationMap(parsed.citations);
return {
cleanContent: parsed.cleanContent,
citationMap: map,
};
}, [content]);
if (language === "markdown") { if (language === "markdown") {
return ( return (
<div className="size-full px-4"> <div className="size-full px-4">
@@ -307,45 +295,13 @@ export function ArtifactFilePreview({
className="size-full" className="size-full"
{...streamdownPlugins} {...streamdownPlugins}
components={{ components={{
a: ({ a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
href, <CitationAwareLink
children, {...props}
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => { citationMap={citationMap}
if (!href) return <span>{children}</span>; syntheticExternal
const citation = citationMap.get(href); />
if (citation) { ),
return (
<CitationLink citation={citation} href={href}>
{children}
</CitationLink>
);
}
if (isExternalUrl(href)) {
const linkText =
typeof children === "string"
? children
: String(React.Children.toArray(children).join("")).trim() ||
href;
return (
<CitationLink
citation={syntheticCitationFromLink(href, linkText)}
href={href}
>
{children}
</CitationLink>
);
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className={externalLinkClass}
>
{children}
</a>
);
},
}} }}
> >
{cleanContent ?? ""} {cleanContent ?? ""}

View File

@@ -25,7 +25,11 @@ import { CodeBlock } from "@/components/ai-elements/code-block";
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation"; import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { parseCitations } from "@/core/citations"; import {
getCleanContent,
hasCitationsBlock,
useParsedCitations,
} from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
@@ -124,7 +128,7 @@ export function MessageGroup({
remarkPlugins={streamdownPlugins.remarkPlugins} remarkPlugins={streamdownPlugins.remarkPlugins}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
> >
{parseCitations(step.reasoning ?? "").cleanContent} {getCleanContent(step.reasoning ?? "")}
</MessageResponse> </MessageResponse>
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -177,10 +181,7 @@ export function MessageGroup({
remarkPlugins={streamdownPlugins.remarkPlugins} remarkPlugins={streamdownPlugins.remarkPlugins}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
> >
{ {getCleanContent(lastReasoningStep.reasoning ?? "")}
parseCitations(lastReasoningStep.reasoning ?? "")
.cleanContent
}
</MessageResponse> </MessageResponse>
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
@@ -215,12 +216,8 @@ function ToolCall({
const { thread } = useThread(); const { thread } = useThread();
const threadIsLoading = thread.isLoading; const threadIsLoading = thread.isLoading;
// Move useMemo to top level to comply with React Hooks rules
const fileContent = typeof args.content === "string" ? args.content : ""; const fileContent = typeof args.content === "string" ? args.content : "";
const { citations } = useMemo( const { citations } = useParsedCitations(fileContent);
() => parseCitations(fileContent),
[fileContent],
);
if (name === "web_search") { if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -370,9 +367,8 @@ function ToolCall({
const isMarkdown = const isMarkdown =
path?.toLowerCase().endsWith(".md") || path?.toLowerCase().endsWith(".md") ||
path?.toLowerCase().endsWith(".markdown"); path?.toLowerCase().endsWith(".markdown");
const hasCitationsBlock = fileContent.includes("<citations>");
const showCitationsLoading = const showCitationsLoading =
isMarkdown && threadIsLoading && hasCitationsBlock && isLast; isMarkdown && threadIsLoading && hasCitationsBlock(fileContent) && isLast;
return ( return (
<> <>

View File

@@ -5,7 +5,7 @@ import { memo, useMemo } from "react";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import { import {
CitationLink, CitationAwareLink,
CitationsLoadingIndicator, CitationsLoadingIndicator,
} from "@/components/ai-elements/inline-citation"; } from "@/components/ai-elements/inline-citation";
import { import {
@@ -17,11 +17,9 @@ import {
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils"; import { resolveArtifactURL } from "@/core/artifacts/utils";
import { import {
type Citation,
buildCitationMap,
isCitationsBlockIncomplete, isCitationsBlockIncomplete,
parseCitations,
removeAllCitations, removeAllCitations,
useParsedCitations,
} from "@/core/citations"; } from "@/core/citations";
import { import {
extractContentFromMessage, extractContentFromMessage,
@@ -31,11 +29,7 @@ import {
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown"; import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown";
import { import { cn } from "@/lib/utils";
cn,
externalLinkClass,
externalLinkClassNoUnderline,
} from "@/lib/utils";
import { CopyButton } from "../copy-button"; 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 * Custom image component that handles artifact URLs
*/ */
@@ -165,50 +116,44 @@ function MessageContent_({
const isHuman = message.type === "human"; const isHuman = message.type === "human";
const { thread_id } = useParams<{ thread_id: string }>(); const { thread_id } = useParams<{ thread_id: string }>();
// Extract and parse citations and uploaded files from message content // Content to parse for citations (and optionally uploaded files)
const { citations, cleanContent, uploadedFiles, isLoadingCitations } = const { contentToParse, uploadedFiles, isLoadingCitations } = useMemo(() => {
useMemo(() => { const reasoningContent = extractReasoningContentFromMessage(message);
const reasoningContent = extractReasoningContentFromMessage(message); const rawContent = extractContentFromMessage(message);
const rawContent = extractContentFromMessage(message);
// When only reasoning content exists (no main content), also parse citations if (!isLoading && reasoningContent && !rawContent) {
if (!isLoading && reasoningContent && !rawContent) { return {
const { citations, cleanContent } = parseCitations(reasoningContent); contentToParse: reasoningContent,
return { uploadedFiles: [] as UploadedFile[],
citations, isLoadingCitations: false,
cleanContent, };
uploadedFiles: [], }
isLoadingCitations: false,
};
}
// For human messages, parse uploaded files first if (isHuman && rawContent) {
if (isHuman && rawContent) { const { files, cleanContent: contentWithoutFiles } =
const { files, cleanContent: contentWithoutFiles } = parseUploadedFiles(rawContent);
parseUploadedFiles(rawContent); return {
const { citations, cleanContent: finalContent } = contentToParse: contentWithoutFiles,
parseCitations(contentWithoutFiles); uploadedFiles: files,
return { isLoadingCitations: false,
citations, };
cleanContent: finalContent, }
uploadedFiles: files,
isLoadingCitations: false,
};
}
const { citations, cleanContent } = parseCitations(rawContent ?? ""); return {
const isLoadingCitations = contentToParse: rawContent ?? "",
isLoading && isCitationsBlockIncomplete(rawContent ?? ""); uploadedFiles: [] as UploadedFile[],
isLoadingCitations:
isLoading && isCitationsBlockIncomplete(rawContent ?? ""),
};
}, [isLoading, message, isHuman]);
return { citations, cleanContent, uploadedFiles: [], isLoadingCitations }; const { citations, cleanContent, citationMap } =
}, [isLoading, message, isHuman]); useParsedCitations(contentToParse);
const citationMap = useMemo(() => buildCitationMap(citations), [citations]);
// Shared markdown components // Shared markdown components
const markdownComponents = useMemo(() => ({ const markdownComponents = useMemo(() => ({
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => ( a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<MessageLink <CitationAwareLink
{...props} {...props}
citationMap={citationMap} citationMap={citationMap}
isHuman={isHuman} isHuman={isHuman}

View File

@@ -1,6 +1,7 @@
export { export {
buildCitationMap,
extractDomainFromUrl, extractDomainFromUrl,
getCleanContent,
hasCitationsBlock,
isCitationsBlockIncomplete, isCitationsBlockIncomplete,
isExternalUrl, isExternalUrl,
parseCitations, parseCitations,
@@ -8,4 +9,6 @@ export {
syntheticCitationFromLink, syntheticCitationFromLink,
} from "./utils"; } from "./utils";
export { useParsedCitations } from "./use-parsed-citations";
export type { UseParsedCitationsResult } from "./use-parsed-citations";
export type { Citation, ParseCitationsResult } from "./utils"; export type { Citation, ParseCitationsResult } from "./utils";

View File

@@ -0,0 +1,28 @@
"use client";
import { useMemo } from "react";
import { buildCitationMap, parseCitations } from "./utils";
import type { Citation } from "./utils";
export interface UseParsedCitationsResult {
citations: Citation[];
cleanContent: string;
citationMap: Map<string, Citation>;
}
/**
* Parse content for citations and build citation map. Memoized by content.
* Use in message and artifact components to avoid repeating parseCitations + buildCitationMap.
*/
export function useParsedCitations(content: string): UseParsedCitationsResult {
return useMemo(() => {
const parsed = parseCitations(content ?? "");
const citationMap = buildCitationMap(parsed.citations);
return {
citations: parsed.citations,
cleanContent: parsed.cleanContent,
citationMap,
};
}, [content]);
}

View File

@@ -67,14 +67,7 @@ export function parseCitations(content: string): ParseCitationsResult {
} }
} }
// Remove ALL citations blocks from content (both complete and incomplete) cleanContent = removeCitationsBlocks(content);
cleanContent = content.replace(/<citations>[\s\S]*?<\/citations>/g, "").trim();
// Also remove incomplete citations blocks (during streaming)
// Match <citations> without closing tag or <citations> followed by anything until end of string
if (cleanContent.includes("<citations>")) {
cleanContent = cleanContent.replace(/<citations>[\s\S]*$/g, "").trim();
}
// Convert [cite-N] references to markdown links // Convert [cite-N] references to markdown links
// Example: [cite-1] -> [Title](url) // Example: [cite-1] -> [Title](url)
@@ -102,6 +95,13 @@ export function parseCitations(content: string): ParseCitationsResult {
return { citations, cleanContent }; return { citations, cleanContent };
} }
/**
* Return content with citations block removed and [cite-N] replaced by markdown links.
*/
export function getCleanContent(content: string): string {
return parseCitations(content ?? "").cleanContent;
}
/** /**
* Build a map from URL to Citation for quick lookup * Build a map from URL to Citation for quick lookup
* *
@@ -153,6 +153,26 @@ export function extractDomainFromUrl(url: string): string {
} }
} }
/**
* Remove all <citations> blocks from content (complete and incomplete).
* Does not remove [cite-N] or markdown links; use removeAllCitations for that.
*/
export function removeCitationsBlocks(content: string): string {
if (!content) return content;
let result = content.replace(/<citations>[\s\S]*?<\/citations>/g, "").trim();
if (result.includes("<citations>")) {
result = result.replace(/<citations>[\s\S]*$/g, "").trim();
}
return result;
}
/**
* Whether content contains a <citations> block (open tag).
*/
export function hasCitationsBlock(content: string): boolean {
return Boolean(content?.includes("<citations>"));
}
/** /**
* Check if content is still receiving the citations block (streaming) * Check if content is still receiving the citations block (streaming)
* This helps determine if we should wait before parsing * This helps determine if we should wait before parsing
@@ -161,15 +181,7 @@ export function extractDomainFromUrl(url: string): string {
* @returns true if citations block appears to be incomplete * @returns true if citations block appears to be incomplete
*/ */
export function isCitationsBlockIncomplete(content: string): boolean { export function isCitationsBlockIncomplete(content: string): boolean {
if (!content) { return hasCitationsBlock(content) && !content.includes("</citations>");
return false;
}
// Check if we have an opening tag but no closing tag
const hasOpenTag = content.includes("<citations>");
const hasCloseTag = content.includes("</citations>");
return hasOpenTag && !hasCloseTag;
} }
/** /**
@@ -188,11 +200,8 @@ export function removeAllCitations(content: string): string {
return content; return content;
} }
let result = content;
// Step 1: Remove all <citations> blocks (complete and incomplete) // Step 1: Remove all <citations> blocks (complete and incomplete)
result = result.replace(/<citations>[\s\S]*?<\/citations>/g, ""); let result = removeCitationsBlocks(content);
result = result.replace(/<citations>[\s\S]*$/g, "");
// Step 2: Remove all [cite-N] references // Step 2: Remove all [cite-N] references
result = result.replace(/\[cite-\d+\]/g, ""); result = result.replace(/\[cite-\d+\]/g, "");