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

@@ -1,6 +1,7 @@
export {
buildCitationMap,
extractDomainFromUrl,
getCleanContent,
hasCitationsBlock,
isCitationsBlockIncomplete,
isExternalUrl,
parseCitations,
@@ -8,4 +9,6 @@ export {
syntheticCitationFromLink,
} from "./utils";
export { useParsedCitations } from "./use-parsed-citations";
export type { UseParsedCitationsResult } from "./use-parsed-citations";
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 = 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();
}
cleanContent = removeCitationsBlocks(content);
// Convert [cite-N] references to markdown links
// Example: [cite-1] -> [Title](url)
@@ -102,6 +95,13 @@ export function parseCitations(content: string): ParseCitationsResult {
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
*
@@ -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)
* 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
*/
export function isCitationsBlockIncomplete(content: string): boolean {
if (!content) {
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;
return hasCitationsBlock(content) && !content.includes("</citations>");
}
/**
@@ -188,11 +200,8 @@ export function removeAllCitations(content: string): string {
return content;
}
let result = content;
// Step 1: Remove all <citations> blocks (complete and incomplete)
result = result.replace(/<citations>[\s\S]*?<\/citations>/g, "");
result = result.replace(/<citations>[\s\S]*$/g, "");
let result = removeCitationsBlocks(content);
// Step 2: Remove all [cite-N] references
result = result.replace(/\[cite-\d+\]/g, "");