refactor(frontend): consolidate citation logic, slim exports and impl

- SafeCitationContent: add loadingOnly and renderBody props.
  - loadingOnly: show only loading indicator or null (e.g. write_file step).
  - renderBody(parsed): custom body renderer (e.g. artifact preview).

- message-group write_file: use SafeCitationContent(content, isLoading,
  rehypePlugins, loadingOnly) instead of local useParsedCitations +
  shouldShowCitationLoading + CitationsLoadingIndicator. Pass rehypePlugins
  into ToolCall.

- artifact-file-detail markdown preview: use SafeCitationContent with
  renderBody((p) => <ArtifactFilePreview ... cleanContent={p.cleanContent}
  citationMap={p.citationMap} />). Remove local shouldShowCitationLoading
  and CitationsLoadingIndicator branch.

- core/citations: inline buildCitationMap into use-parsed-citations, remove
  from utils; stop exporting hasCitationsBlock (internal to shouldShowCitationLoading).

- inline-citation: make InlineCitationCard, InlineCitationCardBody,
  InlineCitationSource file-private (no longer exported).

Co-authored-by: Cursor <cursoragent@cursor.com>

---
refactor(前端): 收拢引用逻辑、精简导出与实现

- SafeCitationContent 新增 loadingOnly、renderBody。
  - loadingOnly:仅显示加载或 null(如 write_file 步骤)。
  - renderBody(parsed):自定义正文渲染(如 artifact 预览)。

- message-group write_file:改用 SafeCitationContent(loadingOnly),去掉
  本地 useParsedCitations + shouldShowCitationLoading + CitationsLoadingIndicator,
  并向 ToolCall 传入 rehypePlugins。

- artifact-file-detail 的 markdown 预览:改用 SafeCitationContent +
  renderBody 渲染 ArtifactFilePreview,去掉本地加载判断与
  CitationsLoadingIndicator 分支。

- core/citations:buildCitationMap 内联到 use-parsed-citations 并从 utils
  删除;hasCitationsBlock 不再导出(仅 shouldShowCitationLoading 内部使用)。

- inline-citation:InlineCitationCard/Body/Source 改为文件内私有,不再导出。
This commit is contained in:
ruitanglin
2026-02-09 15:58:59 +08:00
parent 53509eaeb1
commit 59c8fec7e7
7 changed files with 73 additions and 86 deletions

View File

@@ -29,35 +29,31 @@ import {
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>; type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => ( const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} /> <HoverCard closeDelay={0} openDelay={0} {...props} />
); );
export type InlineCitationCardBodyProps = ComponentProps<"div">; const InlineCitationCardBody = ({
export const InlineCitationCardBody = ({
className, className,
...props ...props
}: InlineCitationCardBodyProps) => ( }: ComponentProps<"div">) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} /> <HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
); );
export type InlineCitationSourceProps = ComponentProps<"div"> & { const InlineCitationSource = ({
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title, title,
url, url,
description, description,
className, className,
children, children,
...props ...props
}: InlineCitationSourceProps) => ( }: ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
}) => (
<div className={cn("space-y-1", className)} {...props}> <div className={cn("space-y-1", className)} {...props}>
{title && ( {title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4> <h4 className="truncate font-medium text-sm leading-tight">{title}</h4>

View File

@@ -32,13 +32,11 @@ import {
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 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 { CitationsLoadingIndicator } from "@/components/ai-elements/inline-citation";
import { urlOfArtifact } from "@/core/artifacts/utils"; import { urlOfArtifact } from "@/core/artifacts/utils";
import type { Citation } from "@/core/citations"; import type { Citation } from "@/core/citations";
import { import {
contentWithoutCitationsFromParsed, contentWithoutCitationsFromParsed,
removeAllCitations, removeAllCitations,
shouldShowCitationLoading,
useParsedCitations, useParsedCitations,
} from "@/core/citations"; } from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
@@ -50,6 +48,7 @@ import { cn } from "@/lib/utils";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { SafeCitationContent } from "../messages/safe-citation-content";
import { useThread } from "../messages/context"; import { useThread } from "../messages/context";
import { useArtifacts } from "./context"; import { useArtifacts } from "./context";
@@ -252,31 +251,27 @@ export function ArtifactFileDetail({
</div> </div>
</ArtifactHeader> </ArtifactHeader>
<ArtifactContent className="p-0"> <ArtifactContent className="p-0">
{previewable && viewMode === "preview" && ( {previewable &&
viewMode === "preview" &&
language === "markdown" && language === "markdown" &&
content && content && (
shouldShowCitationLoading( <SafeCitationContent
content,
parsed.cleanContent,
thread.isLoading,
) ? (
<div className="flex size-full items-center justify-center p-4">
<CitationsLoadingIndicator
citations={parsed.citations}
className="my-0"
/>
</div>
) : (
<ArtifactFilePreview
filepath={filepath}
threadId={threadId}
content={content} content={content}
language={language ?? "text"} isLoading={thread.isLoading}
cleanContent={parsed.cleanContent} rehypePlugins={streamdownPlugins.rehypePlugins}
citationMap={parsed.citationMap} className="flex size-full items-center justify-center p-4 my-0"
renderBody={(p) => (
<ArtifactFilePreview
filepath={filepath}
threadId={threadId}
content={content}
language={language ?? "text"}
cleanContent={p.cleanContent}
citationMap={p.citationMap}
/>
)}
/> />
) )}
)}
{isCodeFile && viewMode === "code" && ( {isCodeFile && viewMode === "code" && (
<CodeEditor <CodeEditor
className="size-full resize-none rounded-none border-none" className="size-full resize-none rounded-none border-none"

View File

@@ -22,10 +22,8 @@ import {
ChainOfThoughtStep, ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought"; } from "@/components/ai-elements/chain-of-thought";
import { CodeBlock } from "@/components/ai-elements/code-block"; import { CodeBlock } from "@/components/ai-elements/code-block";
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 { shouldShowCitationLoading, useParsedCitations } from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { import {
extractReasoningContentFromMessage, extractReasoningContentFromMessage,
@@ -130,7 +128,12 @@ export function MessageGroup({
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
) : ( ) : (
<ToolCall key={step.id} {...step} isLoading={isLoading} /> <ToolCall
key={step.id}
{...step}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
), ),
)} )}
{lastToolCallStep && ( {lastToolCallStep && (
@@ -140,6 +143,7 @@ export function MessageGroup({
{...lastToolCallStep} {...lastToolCallStep}
isLast={true} isLast={true}
isLoading={isLoading} isLoading={isLoading}
rehypePlugins={rehypePlugins}
/> />
</FlipDisplay> </FlipDisplay>
)} )}
@@ -197,6 +201,7 @@ function ToolCall({
result, result,
isLast = false, isLast = false,
isLoading = false, isLoading = false,
rehypePlugins,
}: { }: {
id?: string; id?: string;
messageId?: string; messageId?: string;
@@ -205,6 +210,7 @@ function ToolCall({
result?: string | Record<string, unknown>; result?: string | Record<string, unknown>;
isLast?: boolean; isLast?: boolean;
isLoading?: boolean; isLoading?: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } = const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
@@ -213,7 +219,6 @@ function ToolCall({
const threadIsLoading = thread.isLoading; const threadIsLoading = thread.isLoading;
const fileContent = typeof args.content === "string" ? args.content : ""; const fileContent = typeof args.content === "string" ? args.content : "";
const { citations, cleanContent } = useParsedCitations(fileContent);
if (name === "web_search") { if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -362,13 +367,6 @@ function ToolCall({
const isMarkdown = const isMarkdown =
path?.toLowerCase().endsWith(".md") || path?.toLowerCase().endsWith(".md") ||
path?.toLowerCase().endsWith(".markdown"); path?.toLowerCase().endsWith(".markdown");
const showCitationsLoading =
isMarkdown &&
shouldShowCitationLoading(
fileContent,
cleanContent,
threadIsLoading && isLast,
);
return ( return (
<> <>
@@ -392,10 +390,14 @@ function ToolCall({
</ChainOfThoughtSearchResult> </ChainOfThoughtSearchResult>
)} )}
</ChainOfThoughtStep> </ChainOfThoughtStep>
{showCitationsLoading && ( {isMarkdown && (
<div className="mt-2 ml-8"> <SafeCitationContent
<CitationsLoadingIndicator citations={citations} /> content={fileContent}
</div> isLoading={threadIsLoading && isLast}
rehypePlugins={rehypePlugins}
loadingOnly
className="mt-2 ml-8"
/>
)} )}
</> </>
); );

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import type { ImgHTMLAttributes } from "react"; import type { ImgHTMLAttributes } from "react";
import type { ReactNode } from "react";
import { useMemo } from "react"; import { useMemo } from "react";
import { import {
@@ -14,6 +15,7 @@ import {
import { import {
shouldShowCitationLoading, shouldShowCitationLoading,
useParsedCitations, useParsedCitations,
type UseParsedCitationsResult,
} from "@/core/citations"; } from "@/core/citations";
import { streamdownPlugins } from "@/core/streamdown"; import { streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -25,7 +27,11 @@ export type SafeCitationContentProps = {
className?: string; className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"]; remarkPlugins?: MessageResponseProps["remarkPlugins"];
isHuman?: boolean; isHuman?: boolean;
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => React.ReactNode; img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => ReactNode;
/** When true, only show loading indicator or null (e.g. write_file step). */
loadingOnly?: boolean;
/** When set, use instead of default MessageResponse (e.g. artifact preview). */
renderBody?: (parsed: UseParsedCitationsResult) => ReactNode;
}; };
/** Single place for citation-aware body: shows loading until citations complete (no half-finished refs), else body. */ /** Single place for citation-aware body: shows loading until citations complete (no half-finished refs), else body. */
@@ -37,20 +43,12 @@ export function SafeCitationContent({
remarkPlugins = streamdownPlugins.remarkPlugins, remarkPlugins = streamdownPlugins.remarkPlugins,
isHuman = false, isHuman = false,
img, img,
loadingOnly = false,
renderBody,
}: SafeCitationContentProps) { }: SafeCitationContentProps) {
const { citations, cleanContent, citationMap } = useParsedCitations(content); const parsed = useParsedCitations(content);
const { citations, cleanContent, citationMap } = parsed;
const showLoading = shouldShowCitationLoading(content, cleanContent, isLoading); const showLoading = shouldShowCitationLoading(content, cleanContent, isLoading);
if (showLoading) {
return (
<CitationsLoadingIndicator
citations={citations}
className={cn("my-2", className)}
/>
);
}
if (!cleanContent) return null;
const components = useMemo( const components = useMemo(
() => () =>
createCitationMarkdownComponents({ createCitationMarkdownComponents({
@@ -61,6 +59,19 @@ export function SafeCitationContent({
}), }),
[citationMap, isHuman, img], [citationMap, isHuman, img],
); );
if (showLoading) {
return (
<CitationsLoadingIndicator
citations={citations}
className={cn("my-2", className)}
/>
);
}
if (loadingOnly) return null;
if (renderBody) return renderBody(parsed);
if (!cleanContent) return null;
return ( return (
<MessageResponse <MessageResponse
className={className} className={className}

View File

@@ -1,7 +1,6 @@
export { export {
contentWithoutCitationsFromParsed, contentWithoutCitationsFromParsed,
extractDomainFromUrl, extractDomainFromUrl,
hasCitationsBlock,
isExternalUrl, isExternalUrl,
parseCitations, parseCitations,
removeAllCitations, removeAllCitations,

View File

@@ -2,7 +2,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { buildCitationMap, parseCitations } from "./utils"; import { parseCitations } from "./utils";
import type { Citation } from "./utils"; import type { Citation } from "./utils";
export interface UseParsedCitationsResult { export interface UseParsedCitationsResult {
@@ -13,12 +13,12 @@ export interface UseParsedCitationsResult {
/** /**
* Parse content for citations and build citation map. Memoized by content. * 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 { export function useParsedCitations(content: string): UseParsedCitationsResult {
return useMemo(() => { return useMemo(() => {
const parsed = parseCitations(content ?? ""); const parsed = parseCitations(content ?? "");
const citationMap = buildCitationMap(parsed.citations); const citationMap = new Map<string, Citation>();
for (const c of parsed.citations) citationMap.set(c.url, c);
return { return {
citations: parsed.citations, citations: parsed.citations,
cleanContent: parsed.cleanContent, cleanContent: parsed.cleanContent,

View File

@@ -120,22 +120,6 @@ export function parseCitations(content: string): ParseCitationsResult {
return { citations, cleanContent }; return { citations, cleanContent };
} }
/**
* Build a map from URL to Citation for quick lookup
*
* @param citations - Array of citations
* @returns Map with URL as key and Citation as value
*/
export function buildCitationMap(
citations: Citation[],
): Map<string, Citation> {
const map = new Map<string, Citation>();
for (const citation of citations) {
map.set(citation.url, citation);
}
return map;
}
/** /**
* Whether the URL is external (http/https). * Whether the URL is external (http/https).
*/ */