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 552d1c3a9a
commit 1e2675beb3
14 changed files with 522 additions and 468 deletions

View File

@@ -2,7 +2,6 @@ import {
Code2Icon,
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
EyeIcon,
LoaderIcon,
PackageIcon,
@@ -22,13 +21,7 @@ import {
ArtifactHeader,
ArtifactTitle,
} from "@/components/ai-elements/artifact";
import {
InlineCitationCard,
InlineCitationCardBody,
InlineCitationSource,
} from "@/components/ai-elements/inline-citation";
import { Badge } from "@/components/ui/badge";
import { HoverCardTrigger } from "@/components/ui/hover-card";
import { CitationLink } from "@/components/ai-elements/inline-citation";
import { Select, SelectItem } from "@/components/ui/select";
import {
SelectContent,
@@ -42,9 +35,8 @@ import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
buildCitationMap,
extractDomainFromUrl,
parseCitations,
type Citation,
removeAllCitations,
} from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
@@ -110,6 +102,14 @@ export function ArtifactFileDetail({
return content;
}, [content, language]);
// Get content without ANY citations for copy/download
const contentWithoutCitations = useMemo(() => {
if (language === "markdown" && content) {
return removeAllCitations(content);
}
return content;
}, [content, language]);
const [viewMode, setViewMode] = useState<"code" | "preview">("code");
const [isInstalling, setIsInstalling] = useState(false);
@@ -220,7 +220,7 @@ export function ArtifactFileDetail({
disabled={!content}
onClick={async () => {
try {
await navigator.clipboard.writeText(content ?? "");
await navigator.clipboard.writeText(contentWithoutCitations ?? "");
toast.success(t.clipboard.copiedToClipboard);
} catch (error) {
toast.error("Failed to copy to clipboard");
@@ -293,7 +293,6 @@ export function ArtifactFilePreview({
const parsed = parseCitations(content ?? "");
const map = buildCitationMap(parsed.citations);
return {
citations: parsed.citations,
cleanContent: parsed.cleanContent,
citationMap: map,
};
@@ -318,9 +317,9 @@ export function ArtifactFilePreview({
const citation = citationMap.get(href);
if (citation) {
return (
<ArtifactCitationLink citation={citation} href={href}>
<CitationLink citation={citation} href={href}>
{children}
</ArtifactCitationLink>
</CitationLink>
);
}
@@ -330,7 +329,7 @@ export function ArtifactFilePreview({
if (isExternalLink) {
return (
<ExternalLinkBadge href={href}>{children}</ExternalLinkBadge>
<CitationLink href={href}>{children}</CitationLink>
);
}
@@ -359,105 +358,3 @@ export function ArtifactFilePreview({
return null;
}
/**
* Citation link component for artifact preview (with full citation data)
*/
function ArtifactCitationLink({
citation,
href,
children,
}: {
citation: Citation;
href: string;
children: React.ReactNode;
}) {
const domain = extractDomainFromUrl(href);
return (
<InlineCitationCard>
<HoverCardTrigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
onClick={(e) => e.stopPropagation()}
>
<Badge
variant="secondary"
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
>
{children ?? domain}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<InlineCitationCardBody>
<div className="p-3">
<InlineCitationSource
title={citation.title}
url={href}
description={citation.snippet}
/>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit source
<ExternalLinkIcon className="size-3" />
</a>
</div>
</InlineCitationCardBody>
</InlineCitationCard>
);
}
/**
* External link badge component for artifact preview
*/
function ExternalLinkBadge({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
const domain = extractDomainFromUrl(href);
return (
<InlineCitationCard>
<HoverCardTrigger asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<Badge
variant="secondary"
className="hover:bg-secondary/80 mx-0.5 cursor-pointer gap-1 rounded-full px-2 py-0.5 text-xs font-normal"
>
{children ?? domain}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<InlineCitationCardBody>
<div className="p-3">
<InlineCitationSource title={domain} url={href} />
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary mt-2 inline-flex items-center gap-1 text-xs hover:underline"
>
Visit source
<ExternalLinkIcon className="size-3" />
</a>
</div>
</InlineCitationCardBody>
</InlineCitationCard>
);
}