mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-12 18:14:46 +08:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" }]]}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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";
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -115,12 +115,6 @@ export interface Translations {
|
||||
startConversation: string;
|
||||
};
|
||||
|
||||
// Citations
|
||||
citations: {
|
||||
loadingCitations: string;
|
||||
loadingCitationsWithCount: (count: number) => string;
|
||||
};
|
||||
|
||||
// Chats
|
||||
chats: {
|
||||
searchChats: string;
|
||||
|
||||
@@ -164,12 +164,6 @@ export const zhCN: Translations = {
|
||||
startConversation: "开始新的对话以查看消息",
|
||||
},
|
||||
|
||||
// Citations
|
||||
citations: {
|
||||
loadingCitations: "正在整理引用...",
|
||||
loadingCitationsWithCount: (count: number) => `正在整理 ${count} 个引用...`,
|
||||
},
|
||||
|
||||
// Chats
|
||||
chats: {
|
||||
searchChats: "搜索对话",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user