chore: 移除所有 Citations 相关逻辑,为后续重构做准备

- Backend: 删除 lead_agent / general_purpose 中的 citations_format 与引用相关 reminder;artifacts 下载不再对 markdown 做 citation 清洗,统一走 FileResponse,保留 Response 用于二进制 inline
- Frontend: 删除 core/citations 模块、inline-citation、safe-citation-content;新增 MarkdownContent 仅做 Markdown 渲染;消息/artifact 预览与复制均使用原始 content
- i18n: 移除 citations 命名空间(loadingCitations、loadingCitationsWithCount)
- 技能与 demo: 措辞改为 references,demo 数据去掉 <citations> 块
- 文档: 更新 CLAUDE/AGENTS/README 描述,新增按文件 diff 的代码变更总结

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
LofiSu
2026-02-09 16:24:01 +08:00
parent cef8d389fd
commit 46048c76ce
27 changed files with 1043 additions and 894 deletions

View File

@@ -8,7 +8,6 @@ import {
SquareArrowOutUpRightIcon,
XIcon,
} from "lucide-react";
import * as React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Streamdown } from "streamdown";
@@ -21,7 +20,6 @@ import {
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import { createCitationMarkdownComponents } from "@/components/ai-elements/inline-citation";
import { Select, SelectItem } from "@/components/ui/select";
import {
SelectContent,
@@ -33,12 +31,6 @@ 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 type { Citation } from "@/core/citations";
import {
contentWithoutCitationsFromParsed,
removeAllCitations,
useParsedCitations,
} from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
@@ -48,9 +40,6 @@ import { cn } from "@/lib/utils";
import { Tooltip } from "../tooltip";
import { SafeCitationContent } from "../messages/safe-citation-content";
import { useThread } from "../messages/context";
import { useArtifacts } from "./context";
export function ArtifactFileDetail({
@@ -92,22 +81,13 @@ export function ArtifactFileDetail({
const previewable = useMemo(() => {
return (language === "html" && !isWriteFile) || language === "markdown";
}, [isWriteFile, language]);
const { thread } = useThread();
const { content } = useArtifactContent({
threadId,
filepath: filepathFromProps,
enabled: isCodeFile && !isWriteFile,
});
const parsed = useParsedCitations(
language === "markdown" ? (content ?? "") : "",
);
const cleanContent =
language === "markdown" && content ? parsed.cleanContent : (content ?? "");
const contentWithoutCitations =
language === "markdown" && content
? contentWithoutCitationsFromParsed(parsed)
: (content ?? "");
const displayContent = content ?? "";
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false);
@@ -219,7 +199,7 @@ export function ArtifactFileDetail({
disabled={!content}
onClick={async () => {
try {
await navigator.clipboard.writeText(contentWithoutCitations ?? "");
await navigator.clipboard.writeText(displayContent ?? "");
toast.success(t.clipboard.copiedToClipboard);
} catch (error) {
toast.error("Failed to copy to clipboard");
@@ -255,27 +235,17 @@ export function ArtifactFileDetail({
viewMode === "preview" &&
language === "markdown" &&
content && (
<SafeCitationContent
content={content}
isLoading={thread.isLoading}
rehypePlugins={streamdownPlugins.rehypePlugins}
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}
/>
)}
<ArtifactFilePreview
filepath={filepath}
threadId={threadId}
content={displayContent}
language={language ?? "text"}
/>
)}
{isCodeFile && viewMode === "code" && (
<CodeEditor
className="size-full resize-none rounded-none border-none"
value={cleanContent ?? ""}
value={displayContent ?? ""}
readonly
/>
)}
@@ -295,29 +265,17 @@ export function ArtifactFilePreview({
threadId,
content,
language,
cleanContent,
citationMap,
}: {
filepath: string;
threadId: string;
content: string;
language: string;
cleanContent: string;
citationMap: Map<string, Citation>;
}) {
if (language === "markdown") {
const components = createCitationMarkdownComponents({
citationMap,
syntheticExternal: true,
});
return (
<div className="size-full px-4">
<Streamdown
className="size-full"
{...streamdownPlugins}
components={components}
>
{cleanContent ?? ""}
<Streamdown className="size-full" {...streamdownPlugins}>
{content ?? ""}
</Streamdown>
</div>
);

View File

@@ -0,0 +1,42 @@
"use client";
import type { ImgHTMLAttributes } from "react";
import type { ReactNode } from "react";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import { streamdownPlugins } from "@/core/streamdown";
export type MarkdownContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
isHuman?: boolean;
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => ReactNode;
};
/** Renders markdown content. */
export function MarkdownContent({
content,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
img,
}: MarkdownContentProps) {
if (!content) return null;
const components = img ? { img } : undefined;
return (
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{content}
</MessageResponse>
);
}

View File

@@ -39,9 +39,7 @@ import { useArtifacts } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { useThread } from "./context";
import { SafeCitationContent } from "./safe-citation-content";
import { MarkdownContent } from "./markdown-content";
export function MessageGroup({
className,
@@ -120,7 +118,7 @@ export function MessageGroup({
<ChainOfThoughtStep
key={step.id}
label={
<SafeCitationContent
<MarkdownContent
content={step.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
@@ -128,12 +126,7 @@ export function MessageGroup({
}
></ChainOfThoughtStep>
) : (
<ToolCall
key={step.id}
{...step}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
<ToolCall key={step.id} {...step} isLoading={isLoading} />
),
)}
{lastToolCallStep && (
@@ -143,7 +136,6 @@ export function MessageGroup({
{...lastToolCallStep}
isLast={true}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
/>
</FlipDisplay>
)}
@@ -178,7 +170,7 @@ export function MessageGroup({
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
<SafeCitationContent
<MarkdownContent
content={lastReasoningStep.reasoning ?? ""}
isLoading={isLoading}
rehypePlugins={rehypePlugins}
@@ -201,7 +193,6 @@ function ToolCall({
result,
isLast = false,
isLoading = false,
rehypePlugins,
}: {
id?: string;
messageId?: string;
@@ -210,15 +201,10 @@ function ToolCall({
result?: string | Record<string, unknown>;
isLast?: boolean;
isLoading?: boolean;
rehypePlugins: ReturnType<typeof useRehypeSplitWordsIntoSpans>;
}) {
const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
const { thread } = useThread();
const threadIsLoading = thread.isLoading;
const fileContent = typeof args.content === "string" ? args.content : "";
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
@@ -364,42 +350,27 @@ function ToolCall({
}, 100);
}
const isMarkdown =
path?.toLowerCase().endsWith(".md") ||
path?.toLowerCase().endsWith(".markdown");
return (
<>
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</ChainOfThoughtStep>
{isMarkdown && (
<SafeCitationContent
content={fileContent}
isLoading={threadIsLoading && isLast}
rehypePlugins={rehypePlugins}
loadingOnly
className="mt-2 ml-8"
/>
<ChainOfThoughtStep
key={id}
className="cursor-pointer"
label={description}
icon={NotebookPenIcon}
onClick={() => {
select(
new URL(
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
).toString(),
);
setOpen(true);
}}
>
{path && (
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
)}
</>
</ChainOfThoughtStep>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })

View File

@@ -12,7 +12,6 @@ import {
} from "@/components/ai-elements/message";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { removeAllCitations } from "@/core/citations";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
@@ -24,7 +23,7 @@ import { humanMessagePlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
import { SafeCitationContent } from "./safe-citation-content";
import { MarkdownContent } from "./markdown-content";
export function MessageListItem({
className,
@@ -54,11 +53,11 @@ export function MessageListItem({
>
<div className="flex gap-1">
<CopyButton
clipboardData={removeAllCitations(
clipboardData={
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
""
)}
}
/>
</div>
</MessageToolbar>
@@ -154,7 +153,7 @@ function MessageContent_({
return (
<AIElementMessageContent className={className}>
{filesList}
<SafeCitationContent
<MarkdownContent
content={contentToParse}
isLoading={isLoading}
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}

View File

@@ -26,7 +26,7 @@ import { StreamingIndicator } from "../streaming-indicator";
import { MessageGroup } from "./message-group";
import { MessageListItem } from "./message-list-item";
import { SafeCitationContent } from "./safe-citation-content";
import { MarkdownContent } from "./markdown-content";
import { MessageListSkeleton } from "./skeleton";
import { SubtaskCard } from "./subtask-card";
@@ -69,7 +69,7 @@ export function MessageList({
const message = group.messages[0];
if (message && hasContent(message)) {
return (
<SafeCitationContent
<MarkdownContent
key={group.id}
content={extractContentFromMessage(message)}
isLoading={thread.isLoading}
@@ -89,7 +89,7 @@ export function MessageList({
return (
<div className="w-full" key={group.id}>
{group.messages[0] && hasContent(group.messages[0]) && (
<SafeCitationContent
<MarkdownContent
content={extractContentFromMessage(group.messages[0])}
isLoading={thread.isLoading}
rehypePlugins={rehypePlugins}

View File

@@ -1,85 +0,0 @@
"use client";
import type { ImgHTMLAttributes } from "react";
import type { ReactNode } from "react";
import { useMemo } from "react";
import {
CitationsLoadingIndicator,
createCitationMarkdownComponents,
} from "@/components/ai-elements/inline-citation";
import {
MessageResponse,
type MessageResponseProps,
} from "@/components/ai-elements/message";
import {
shouldShowCitationLoading,
useParsedCitations,
type UseParsedCitationsResult,
} from "@/core/citations";
import { streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
export type SafeCitationContentProps = {
content: string;
isLoading: boolean;
rehypePlugins: MessageResponseProps["rehypePlugins"];
className?: string;
remarkPlugins?: MessageResponseProps["remarkPlugins"];
isHuman?: boolean;
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. */
export function SafeCitationContent({
content,
isLoading,
rehypePlugins,
className,
remarkPlugins = streamdownPlugins.remarkPlugins,
isHuman = false,
img,
loadingOnly = false,
renderBody,
}: SafeCitationContentProps) {
const parsed = useParsedCitations(content);
const { citations, cleanContent, citationMap } = parsed;
const showLoading = shouldShowCitationLoading(content, cleanContent, isLoading);
const components = useMemo(
() =>
createCitationMarkdownComponents({
citationMap,
isHuman,
isLoadingCitations: false,
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 (
<MessageResponse
className={className}
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={components}
>
{cleanContent}
</MessageResponse>
);
}

View File

@@ -29,7 +29,7 @@ import { cn } from "@/lib/utils";
import { FlipDisplay } from "../flip-display";
import { SafeCitationContent } from "./safe-citation-content";
import { MarkdownContent } from "./markdown-content";
export function SubtaskCard({
className,
@@ -153,7 +153,7 @@ export function SubtaskCard({
<ChainOfThoughtStep
label={
task.result ? (
<SafeCitationContent
<MarkdownContent
content={task.result}
isLoading={false}
rehypePlugins={rehypePlugins}