mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-02 22:02:13 +08:00
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:
@@ -12,10 +12,15 @@ import {
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} 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 {
|
||||
type ComponentProps,
|
||||
Children,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
@@ -23,7 +28,11 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type { Citation } from "@/core/citations";
|
||||
import { extractDomainFromUrl } from "@/core/citations";
|
||||
import {
|
||||
extractDomainFromUrl,
|
||||
isExternalUrl,
|
||||
syntheticCitationFromLink,
|
||||
} from "@/core/citations";
|
||||
import { Shimmer } from "./shimmer";
|
||||
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
|
||||
* Used across message-list-item and message-group to show loading citations
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
ArtifactHeader,
|
||||
ArtifactTitle,
|
||||
} 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 {
|
||||
SelectContent,
|
||||
@@ -33,19 +33,14 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import {
|
||||
buildCitationMap,
|
||||
isExternalUrl,
|
||||
parseCitations,
|
||||
removeAllCitations,
|
||||
syntheticCitationFromLink,
|
||||
} from "@/core/citations";
|
||||
import type { Citation } from "@/core/citations";
|
||||
import { removeAllCitations, useParsedCitations } from "@/core/citations";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
import { env } from "@/env";
|
||||
import { cn, externalLinkClass } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
@@ -96,15 +91,11 @@ export function ArtifactFileDetail({
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
// Parse citations and get clean content for code editor
|
||||
const cleanContent = useMemo(() => {
|
||||
if (language === "markdown" && content) {
|
||||
return parseCitations(content).cleanContent;
|
||||
}
|
||||
return content;
|
||||
}, [content, language]);
|
||||
|
||||
// Get content without ANY citations for copy/download
|
||||
const parsed = useParsedCitations(
|
||||
language === "markdown" ? (content ?? "") : "",
|
||||
);
|
||||
const cleanContent =
|
||||
language === "markdown" && content ? parsed.cleanContent : (content ?? "");
|
||||
const contentWithoutCitations = useMemo(() => {
|
||||
if (language === "markdown" && content) {
|
||||
return removeAllCitations(content);
|
||||
@@ -260,6 +251,8 @@ export function ArtifactFileDetail({
|
||||
threadId={threadId}
|
||||
content={content}
|
||||
language={language ?? "text"}
|
||||
cleanContent={parsed.cleanContent}
|
||||
citationMap={parsed.citationMap}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
@@ -285,21 +278,16 @@ export function ArtifactFilePreview({
|
||||
threadId,
|
||||
content,
|
||||
language,
|
||||
cleanContent,
|
||||
citationMap,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
content: 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") {
|
||||
return (
|
||||
<div className="size-full px-4">
|
||||
@@ -307,45 +295,13 @@ export function ArtifactFilePreview({
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{
|
||||
a: ({
|
||||
href,
|
||||
children,
|
||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (!href) return <span>{children}</span>;
|
||||
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>
|
||||
);
|
||||
},
|
||||
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<CitationAwareLink
|
||||
{...props}
|
||||
citationMap={citationMap}
|
||||
syntheticExternal
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{cleanContent ?? ""}
|
||||
|
||||
@@ -25,7 +25,11 @@ import { CodeBlock } from "@/components/ai-elements/code-block";
|
||||
import { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
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 {
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -124,7 +128,7 @@ export function MessageGroup({
|
||||
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{parseCitations(step.reasoning ?? "").cleanContent}
|
||||
{getCleanContent(step.reasoning ?? "")}
|
||||
</MessageResponse>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -177,10 +181,7 @@ export function MessageGroup({
|
||||
remarkPlugins={streamdownPlugins.remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{
|
||||
parseCitations(lastReasoningStep.reasoning ?? "")
|
||||
.cleanContent
|
||||
}
|
||||
{getCleanContent(lastReasoningStep.reasoning ?? "")}
|
||||
</MessageResponse>
|
||||
}
|
||||
></ChainOfThoughtStep>
|
||||
@@ -215,12 +216,8 @@ function ToolCall({
|
||||
const { thread } = useThread();
|
||||
const threadIsLoading = thread.isLoading;
|
||||
|
||||
// Move useMemo to top level to comply with React Hooks rules
|
||||
const fileContent = typeof args.content === "string" ? args.content : "";
|
||||
const { citations } = useMemo(
|
||||
() => parseCitations(fileContent),
|
||||
[fileContent],
|
||||
);
|
||||
const { citations } = useParsedCitations(fileContent);
|
||||
|
||||
if (name === "web_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -370,9 +367,8 @@ function ToolCall({
|
||||
const isMarkdown =
|
||||
path?.toLowerCase().endsWith(".md") ||
|
||||
path?.toLowerCase().endsWith(".markdown");
|
||||
const hasCitationsBlock = fileContent.includes("<citations>");
|
||||
const showCitationsLoading =
|
||||
isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
|
||||
isMarkdown && threadIsLoading && hasCitationsBlock(fileContent) && isLast;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
|
||||
28
frontend/src/core/citations/use-parsed-citations.ts
Normal file
28
frontend/src/core/citations/use-parsed-citations.ts
Normal 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]);
|
||||
}
|
||||
@@ -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, "");
|
||||
|
||||
Reference in New Issue
Block a user