mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-03 06:12:14 +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,
|
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
|
||||||
|
|||||||
@@ -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 ?? ""}
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
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 = 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, "");
|
||||||
|
|||||||
Reference in New Issue
Block a user