feat(citations): add shared citation components and optimize code

## New Features
- Add `CitationLink` shared component for rendering citation hover cards
- Add `CitationsLoadingIndicator` component for showing loading state
- Add `removeAllCitations` utility to strip all citations from content
- Add backend support for removing citations when downloading markdown files
- Add i18n support for citation loading messages (en-US, zh-CN)

## Code Optimizations
- Remove duplicate `ExternalLinkBadge` component, reuse `CitationLink` instead
- Consolidate `remarkPlugins` config in `streamdownPlugins` to avoid duplication
- Remove unused imports: `Citation`, `buildCitationMap`, `extractDomainFromUrl`, etc.
- Remove unused `messages` parameter from `ToolCall` component
- Remove unused `isWriteFile` parameter from `ArtifactFilePreview` component
- Remove unused `useI18n` hook from `MessageContent` component

## Bug Fixes
- Fix `remarkGfm` plugin configuration that prevented table rendering
- Fix React Hooks rule violation: move `useMemo` to component top level
- Replace `||` with `??` for nullish coalescing in clipboard data

## Code Cleanup
- Remove debug console.log/info statements from:
  - `threads/hooks.ts`
  - `notification/hooks.ts`
  - `memory-settings-page.tsx`
- Fix import order in `message-group.tsx`

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ruitanglin
2026-02-04 11:56:10 +08:00
parent 94acb15c0c
commit c67f1af889
14 changed files with 522 additions and 468 deletions

View File

@@ -22,14 +22,17 @@ import {
ChainOfThoughtStep,
} from "@/components/ai-elements/chain-of-thought";
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 { useI18n } from "@/core/i18n/hooks";
import {
extractReasoningContentFromMessage,
findToolCallResult,
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { streamdownPlugins } from "@/core/streamdown";
import { extractTitleFromMarkdown } from "@/core/utils/markdown";
import { env } from "@/env";
import { cn } from "@/lib/utils";
@@ -38,6 +41,8 @@ import { useArtifacts } from "../artifacts";
import { FlipDisplay } from "../flip-display";
import { Tooltip } from "../tooltip";
import { useThread } from "./context";
export function MessageGroup({
className,
messages,
@@ -115,7 +120,7 @@ export function MessageGroup({
<ChainOfThoughtStep
key={step.id}
label={
<MessageResponse rehypePlugins={rehypePlugins}>
<MessageResponse remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins}>
{step.reasoning ?? ""}
</MessageResponse>
}
@@ -165,7 +170,7 @@ export function MessageGroup({
<ChainOfThoughtStep
key={lastReasoningStep.id}
label={
<MessageResponse rehypePlugins={rehypePlugins}>
<MessageResponse remarkPlugins={streamdownPlugins.remarkPlugins} rehypePlugins={rehypePlugins}>
{lastReasoningStep.reasoning ?? ""}
</MessageResponse>
}
@@ -198,6 +203,13 @@ function ToolCall({
const { t } = useI18n();
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
useArtifacts();
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]);
if (name === "web_search") {
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
if (typeof args.query === "string") {
@@ -353,29 +365,42 @@ function ToolCall({
setOpen(true);
}, 100);
}
// Check if this is a markdown file with citations
const isMarkdown = path?.toLowerCase().endsWith(".md") || path?.toLowerCase().endsWith(".markdown");
const hasCitationsBlock = fileContent.includes("<citations>");
const showCitationsLoading = isMarkdown && threadIsLoading && hasCitationsBlock && isLast;
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 && (
<Tooltip content={t.toolCalls.clickToViewContent}>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
<>
<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 && (
<Tooltip content={t.toolCalls.clickToViewContent}>
<ChainOfThoughtSearchResult className="cursor-pointer">
{path}
</ChainOfThoughtSearchResult>
</Tooltip>
)}
</ChainOfThoughtStep>
{showCitationsLoading && (
<div className="ml-8 mt-2">
<CitationsLoadingIndicator citations={citations} />
</div>
)}
</ChainOfThoughtStep>
</>
);
} else if (name === "bash") {
const description: string | undefined = (args as { description: string })