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:
ruitanglin
2026-02-09 16:24:01 +08:00
parent 59c8fec7e7
commit 8747873b8d
27 changed files with 1043 additions and 894 deletions

View File

@@ -1,289 +0,0 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import {
cn,
externalLinkClass,
externalLinkClassNoUnderline,
} from "@/lib/utils";
import { ExternalLinkIcon } from "lucide-react";
import {
type AnchorHTMLAttributes,
type ComponentProps,
type ImgHTMLAttributes,
type ReactElement,
type ReactNode,
Children,
} from "react";
import type { Citation } from "@/core/citations";
import {
extractDomainFromUrl,
isExternalUrl,
syntheticCitationFromLink,
} from "@/core/citations";
import { Shimmer } from "./shimmer";
import { useI18n } from "@/core/i18n/hooks";
type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
const InlineCitationCardBody = ({
className,
...props
}: ComponentProps<"div">) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
}) => (
<div className={cn("space-y-1", className)} {...props}>
{title && (
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
)}
{url && (
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
)}
{description && (
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
{description}
</p>
)}
{children}
</div>
);
/**
* Shared CitationLink component that renders a citation as a hover card badge
* Used across message-list-item, artifact-file-detail, and message-group
*
* When citation is provided, displays title and snippet from the citation.
* When citation is omitted, falls back to displaying the domain name extracted from href.
*/
export type CitationLinkProps = {
citation?: Citation;
href: string;
children: React.ReactNode;
};
export const CitationLink = ({
citation,
href,
children,
}: CitationLinkProps) => {
const domain = extractDomainFromUrl(href);
// Priority: citation.title > children (if meaningful) > domain
// - citation.title: from parsed <citations> block, most accurate
// - children: from markdown link text [Text](url), used when no citation data
// - domain: fallback when both above are unavailable
// Skip children if it's a generic placeholder like "Source"
const childrenText = typeof children === "string" ? children : null;
const isGenericText = childrenText === "Source" || childrenText === "来源";
const displayText = citation?.title || (!isGenericText && childrenText) || domain;
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"
>
{displayText}
<ExternalLinkIcon className="size-3" />
</Badge>
</a>
</HoverCardTrigger>
<InlineCitationCardBody>
<div className="p-3">
<InlineCitationSource
title={citation?.title || domain}
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>
);
};
/**
* 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>
);
};
/**
* Options for creating markdown components that render links as citations.
* Used by message list (all modes: Flash/Thinking/Pro/Ultra), artifact preview, and CoT.
*/
export type CreateCitationMarkdownComponentsOptions = {
citationMap: Map<string, Citation>;
isHuman?: boolean;
isLoadingCitations?: boolean;
syntheticExternal?: boolean;
/** Optional custom img component (e.g. MessageImage with threadId). Omit for artifact. */
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => ReactNode;
};
/**
* Create markdown `components` (a, optional img) that use CitationAwareLink.
* Reused across message-list-item (all modes), artifact-file-detail, and any CoT markdown.
*/
export function createCitationMarkdownComponents(
options: CreateCitationMarkdownComponentsOptions,
): {
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => ReactElement;
img?: (props: ImgHTMLAttributes<HTMLImageElement> & { threadId?: string; maxWidth?: string }) => ReactNode;
} {
const {
citationMap,
isHuman = false,
isLoadingCitations = false,
syntheticExternal = false,
img,
} = options;
const a = (props: AnchorHTMLAttributes<HTMLAnchorElement>) => (
<CitationAwareLink
{...props}
citationMap={citationMap}
isHuman={isHuman}
isLoadingCitations={isLoadingCitations}
syntheticExternal={syntheticExternal}
/>
);
return img ? { a, img } : { a };
}
/**
* Shared CitationsLoadingIndicator component
* Used across message-list-item and message-group to show loading citations
*/
export type CitationsLoadingIndicatorProps = {
citations: Citation[];
className?: string;
};
export const CitationsLoadingIndicator = ({
citations,
className,
}: CitationsLoadingIndicatorProps) => {
const { t } = useI18n();
return (
<div className={cn("flex flex-col gap-2", className)}>
<Shimmer duration={2.5} className="text-sm">
{citations.length > 0
? t.citations.loadingCitationsWithCount(citations.length)
: t.citations.loadingCitations}
</Shimmer>
{citations.length > 0 && (
<div className="flex flex-wrap gap-2">
{citations.map((citation) => (
<Badge
key={citation.id}
variant="secondary"
className="animate-fade-in gap-1 rounded-full px-2.5 py-1 text-xs font-normal"
>
<Shimmer duration={2} as="span">
{citation.title || extractDomainFromUrl(citation.url)}
</Shimmer>
</Badge>
))}
</div>
)}
</div>
);
};

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}

View File

@@ -1,13 +0,0 @@
export {
contentWithoutCitationsFromParsed,
extractDomainFromUrl,
isExternalUrl,
parseCitations,
removeAllCitations,
shouldShowCitationLoading,
syntheticCitationFromLink,
} from "./utils";
export { useParsedCitations } from "./use-parsed-citations";
export type { UseParsedCitationsResult } from "./use-parsed-citations";
export type { Citation, ParseCitationsResult } from "./utils";

View File

@@ -1,28 +0,0 @@
"use client";
import { useMemo } from "react";
import { 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.
*/
export function useParsedCitations(content: string): UseParsedCitationsResult {
return useMemo(() => {
const parsed = parseCitations(content ?? "");
const citationMap = new Map<string, Citation>();
for (const c of parsed.citations) citationMap.set(c.url, c);
return {
citations: parsed.citations,
cleanContent: parsed.cleanContent,
citationMap,
};
}, [content]);
}

View File

@@ -1,226 +0,0 @@
/**
* Citation parsing and display helpers.
* Display rule: never show half-finished citations. Use shouldShowCitationLoading
* and show only the loading indicator until the block is complete and all
* [cite-N] refs are replaced.
*/
/**
* Citation data structure representing a source reference
*/
export interface Citation {
id: string;
title: string;
url: string;
snippet: string;
}
/**
* Result of parsing citations from content
*/
export interface ParseCitationsResult {
citations: Citation[];
cleanContent: string;
}
/**
* Parse citation lines (one JSON object per line) into Citation array.
* Deduplicates by URL. Used for both complete and incomplete (streaming) blocks.
*/
function parseCitationLines(
blockContent: string,
seenUrls: Set<string>,
): Citation[] {
const out: Citation[] = [];
const lines = blockContent.split("\n");
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed?.startsWith("{")) continue;
try {
const citation = JSON.parse(trimmed) as Citation;
if (citation.id && citation.url && !seenUrls.has(citation.url)) {
seenUrls.add(citation.url);
out.push({
id: citation.id,
title: citation.title || "",
url: citation.url,
snippet: citation.snippet || "",
});
}
} catch {
// Skip invalid JSON lines - can happen during streaming
}
}
return out;
}
/**
* Parse citations block from message content.
* Shared by all modes (Flash / Thinking / Pro / Ultra); supports incomplete
* <citations> blocks during SSE streaming (parses whatever complete JSON lines
* have arrived so far so [cite-N] can be linked progressively).
*
* The citations block format:
* <citations>
* {"id": "cite-1", "title": "Page Title", "url": "https://example.com", "snippet": "Description"}
* {"id": "cite-2", "title": "Another Page", "url": "https://example2.com", "snippet": "Description"}
* </citations>
*
* @param content - The raw message content that may contain a citations block
* @returns Object containing parsed citations array and content with citations block removed
*/
export function parseCitations(content: string): ParseCitationsResult {
if (!content) {
return { citations: [], cleanContent: content };
}
const citations: Citation[] = [];
const seenUrls = new Set<string>();
// 1) Complete blocks: <citations>...</citations>
const citationsRegex = /<citations>([\s\S]*?)<\/citations>/g;
let match;
while ((match = citationsRegex.exec(content)) !== null) {
citations.push(...parseCitationLines(match[1] ?? "", seenUrls));
}
// 2) Incomplete block during streaming: <citations>... (no closing tag yet)
if (content.includes("<citations>") && !content.includes("</citations>")) {
const openMatch = content.match(/<citations>([\s\S]*)$/);
if (openMatch?.[1] != null) {
citations.push(...parseCitationLines(openMatch[1], seenUrls));
}
}
let cleanContent = removeCitationsBlocks(content);
// Convert [cite-N] references to markdown links
// Example: [cite-1] -> [Title](url)
if (citations.length > 0) {
// Build a map from citation id to citation object
const idMap = new Map<string, Citation>();
for (const citation of citations) {
idMap.set(citation.id, citation);
}
// Replace all [cite-N] patterns with markdown links
cleanContent = cleanContent.replace(/\[cite-(\d+)\]/g, (match, num) => {
const citeId = `cite-${num}`;
const citation = idMap.get(citeId);
if (citation) {
// Use title if available, otherwise use domain
const linkText = citation.title || extractDomainFromUrl(citation.url);
return `[${linkText}](${citation.url})`;
}
// If citation not found, keep the original text
return match;
});
}
return { citations, cleanContent };
}
/**
* Whether the URL is external (http/https).
*/
export function isExternalUrl(url: string): boolean {
return url.startsWith("http://") || url.startsWith("https://");
}
/**
* Build a synthetic Citation from a link (e.g. in artifact markdown without <citations> block).
*/
export function syntheticCitationFromLink(href: string, title: string): Citation {
return {
id: `artifact-cite-${href}`,
title: title || href,
url: href,
snippet: "",
};
}
/**
* Extract the domain name from a URL for display
*
* @param url - Full URL string
* @returns Domain name or the original URL if parsing fails
*/
export function extractDomainFromUrl(url: string): string {
try {
const urlObj = new URL(url);
// Remove 'www.' prefix if present
return urlObj.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
/**
* 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>"));
}
/** Pattern for [cite-1], [cite-2], ... that should be replaced by parseCitations. */
const UNREPLACED_CITE_REF = /\[cite-\d+\]/;
/**
* Whether cleanContent still contains unreplaced [cite-N] refs (half-finished citations).
* When true, callers must not render this content and should show loading instead.
*/
export function hasUnreplacedCitationRefs(cleanContent: string): boolean {
return Boolean(cleanContent && UNREPLACED_CITE_REF.test(cleanContent));
}
/**
* Single source of truth: true when body must not be rendered (show loading instead).
* Use after parseCitations: pass raw content, parsed cleanContent, and isLoading.
* Never show body when cleanContent still has [cite-N] (e.g. refs arrived before
* <citations> block in stream); also show loading while streaming with citation block.
*/
export function shouldShowCitationLoading(
rawContent: string,
cleanContent: string,
isLoading: boolean,
): boolean {
if (hasUnreplacedCitationRefs(cleanContent)) return true;
return isLoading && hasCitationsBlock(rawContent);
}
/**
* Strip citation markdown links from already-cleaned content (from parseCitations).
* Use when you already have ParseCitationsResult to avoid parsing twice.
*/
export function contentWithoutCitationsFromParsed(
parsed: ParseCitationsResult,
): string {
const citationUrls = new Set(parsed.citations.map((c) => c.url));
const withoutLinks = parsed.cleanContent.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(fullMatch, _text, url) => (citationUrls.has(url) ? "" : fullMatch),
);
return withoutLinks.replace(/\n{3,}/g, "\n\n").trim();
}
/**
* Remove ALL citations from content (blocks, [cite-N], and citation links).
* Used for copy/download. For display you typically use parseCitations/useParsedCitations.
*/
export function removeAllCitations(content: string): string {
if (!content) return content;
return contentWithoutCitationsFromParsed(parseCitations(content));
}

View File

@@ -167,13 +167,6 @@ export const enUS: Translations = {
startConversation: "Start a conversation to see messages here",
},
// Citations
citations: {
loadingCitations: "Organizing citations...",
loadingCitationsWithCount: (count: number) =>
`Organizing ${count} citation${count === 1 ? "" : "s"}...`,
},
// Chats
chats: {
searchChats: "Search chats",

View File

@@ -115,12 +115,6 @@ export interface Translations {
startConversation: string;
};
// Citations
citations: {
loadingCitations: string;
loadingCitationsWithCount: (count: number) => string;
};
// Chats
chats: {
searchChats: string;

View File

@@ -164,12 +164,6 @@ export const zhCN: Translations = {
startConversation: "开始新的对话以查看消息",
},
// Citations
citations: {
loadingCitations: "正在整理引用...",
loadingCitationsWithCount: (count: number) => `正在整理 ${count} 个引用...`,
},
// Chats
chats: {
searchChats: "搜索对话",

View File

@@ -8,5 +8,5 @@ export function cn(...inputs: ClassValue[]) {
/** Shared class for external links (underline by default). */
export const externalLinkClass =
"text-primary underline underline-offset-2 hover:no-underline";
/** For streaming / loading state when link may be a citation (no underline). */
/** Link style without underline by default (e.g. for streaming/loading). */
export const externalLinkClassNoUnderline = "text-primary hover:underline";