mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-24 14:44:46 +08:00
fix(frontend): citations display + refactor link/citation utils
- Citations: no underline while streaming (message links); artifact markdown external links as citation cards - Refactor: add isExternalUrl, syntheticCitationFromLink in core/citations; shared externalLinkClass in lib/utils; simplify message-list-item and artifact-file-detail link rendering 修复引用展示并抽离链接/引用工具 - 引用:流式输出时链接不这下划线;Artifact 内 Markdown 外链以引用卡片展示 - 重构:core/citations 新增 isExternalUrl、syntheticCitationFromLink;lib/utils 共享 externalLinkClass;精简消息与 Artifact 中的链接渲染逻辑 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,15 +35,17 @@ import { useArtifactContent } from "@/core/artifacts/hooks";
|
|||||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||||
import {
|
import {
|
||||||
buildCitationMap,
|
buildCitationMap,
|
||||||
|
isExternalUrl,
|
||||||
parseCitations,
|
parseCitations,
|
||||||
removeAllCitations,
|
removeAllCitations,
|
||||||
|
syntheticCitationFromLink,
|
||||||
} from "@/core/citations";
|
} from "@/core/citations";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn, externalLinkClass } from "@/lib/utils";
|
||||||
|
|
||||||
import { Tooltip } from "../tooltip";
|
import { Tooltip } from "../tooltip";
|
||||||
|
|
||||||
@@ -309,11 +311,7 @@ export function ArtifactFilePreview({
|
|||||||
href,
|
href,
|
||||||
children,
|
children,
|
||||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (!href) {
|
if (!href) return <span>{children}</span>;
|
||||||
return <span>{children}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only render as CitationLink badge if it's a citation (in citationMap)
|
|
||||||
const citation = citationMap.get(href);
|
const citation = citationMap.get(href);
|
||||||
if (citation) {
|
if (citation) {
|
||||||
return (
|
return (
|
||||||
@@ -322,14 +320,27 @@ export function ArtifactFilePreview({
|
|||||||
</CitationLink>
|
</CitationLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isExternalUrl(href)) {
|
||||||
// All other links (including project URLs) render as plain links
|
const linkText =
|
||||||
|
typeof children === "string"
|
||||||
|
? children
|
||||||
|
: String(React.Children.toArray(children).join("")).trim() ||
|
||||||
|
href;
|
||||||
|
return (
|
||||||
|
<CitationLink
|
||||||
|
citation={syntheticCitationFromLink(href, linkText)}
|
||||||
|
href={href}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CitationLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary underline underline-offset-2 hover:no-underline"
|
className={externalLinkClass}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ import {
|
|||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown";
|
import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown";
|
||||||
import { cn } from "@/lib/utils";
|
import {
|
||||||
|
cn,
|
||||||
|
externalLinkClass,
|
||||||
|
externalLinkClassNoUnderline,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
|
|
||||||
@@ -79,15 +83,18 @@ export function MessageListItem({
|
|||||||
* Custom link component that handles citations and external links
|
* Custom link component that handles citations and external links
|
||||||
* Only links in citationMap are rendered as CitationLink badges
|
* Only links in citationMap are rendered as CitationLink badges
|
||||||
* Other links (project URLs, regular links) are rendered as plain links
|
* Other links (project URLs, regular links) are rendered as plain links
|
||||||
|
* During citation loading (streaming), non-citation links are rendered without underline so they match final citation style (p3)
|
||||||
*/
|
*/
|
||||||
function MessageLink({
|
function MessageLink({
|
||||||
href,
|
href,
|
||||||
children,
|
children,
|
||||||
citationMap,
|
citationMap,
|
||||||
isHuman,
|
isHuman,
|
||||||
|
isLoadingCitations,
|
||||||
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||||
citationMap: Map<string, Citation>;
|
citationMap: Map<string, Citation>;
|
||||||
isHuman: boolean;
|
isHuman: boolean;
|
||||||
|
isLoadingCitations?: boolean;
|
||||||
}) {
|
}) {
|
||||||
if (!href) return <span>{children}</span>;
|
if (!href) return <span>{children}</span>;
|
||||||
|
|
||||||
@@ -102,13 +109,13 @@ function MessageLink({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// All other links render as plain links
|
const noUnderline = !isHuman && isLoadingCitations;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-primary underline underline-offset-2 hover:no-underline"
|
className={noUnderline ? externalLinkClassNoUnderline : externalLinkClass}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
@@ -201,12 +208,17 @@ function MessageContent_({
|
|||||||
// Shared markdown components
|
// Shared markdown components
|
||||||
const markdownComponents = useMemo(() => ({
|
const markdownComponents = useMemo(() => ({
|
||||||
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||||
<MessageLink {...props} citationMap={citationMap} isHuman={isHuman} />
|
<MessageLink
|
||||||
|
{...props}
|
||||||
|
citationMap={citationMap}
|
||||||
|
isHuman={isHuman}
|
||||||
|
isLoadingCitations={isLoadingCitations}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
|
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
|
||||||
),
|
),
|
||||||
}), [citationMap, thread_id, isHuman]);
|
}), [citationMap, thread_id, isHuman, isLoadingCitations]);
|
||||||
|
|
||||||
// Render message response
|
// Render message response
|
||||||
// Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text
|
// Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export {
|
export {
|
||||||
parseCitations,
|
|
||||||
buildCitationMap,
|
buildCitationMap,
|
||||||
extractDomainFromUrl,
|
extractDomainFromUrl,
|
||||||
isCitationsBlockIncomplete,
|
isCitationsBlockIncomplete,
|
||||||
|
isExternalUrl,
|
||||||
|
parseCitations,
|
||||||
removeAllCitations,
|
removeAllCitations,
|
||||||
|
syntheticCitationFromLink,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
export type { Citation, ParseCitationsResult } from "./utils";
|
export type { Citation, ParseCitationsResult } from "./utils";
|
||||||
|
|||||||
@@ -118,6 +118,25 @@ export function buildCitationMap(
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Extract the domain name from a URL for display
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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). */
|
||||||
|
export const externalLinkClassNoUnderline = "text-primary hover:underline";
|
||||||
|
|||||||
Reference in New Issue
Block a user