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:
LofiSu
2026-02-09 04:03:15 +08:00
parent d72aad8063
commit 2d70aaa969
5 changed files with 69 additions and 19 deletions

View File

@@ -35,15 +35,17 @@ import { useArtifactContent } from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import {
buildCitationMap,
isExternalUrl,
parseCitations,
removeAllCitations,
syntheticCitationFromLink,
} from "@/core/citations";
import { useI18n } from "@/core/i18n/hooks";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
import { env } from "@/env";
import { cn } from "@/lib/utils";
import { cn, externalLinkClass } from "@/lib/utils";
import { Tooltip } from "../tooltip";
@@ -309,11 +311,7 @@ export function ArtifactFilePreview({
href,
children,
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (!href) {
return <span>{children}</span>;
}
// Only render as CitationLink badge if it's a citation (in citationMap)
if (!href) return <span>{children}</span>;
const citation = citationMap.get(href);
if (citation) {
return (
@@ -322,14 +320,27 @@ export function ArtifactFilePreview({
</CitationLink>
);
}
// All other links (including project URLs) render as plain links
if (isExternalUrl(href)) {
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 (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:no-underline"
className={externalLinkClass}
>
{children}
</a>

View File

@@ -31,7 +31,11 @@ import {
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { humanMessagePlugins, streamdownPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
import {
cn,
externalLinkClass,
externalLinkClassNoUnderline,
} from "@/lib/utils";
import { CopyButton } from "../copy-button";
@@ -79,20 +83,23 @@ export function MessageListItem({
* Custom link component that handles citations and external links
* Only links in citationMap are rendered as CitationLink badges
* 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({
href,
children,
citationMap,
isHuman,
isLoadingCitations,
}: React.AnchorHTMLAttributes<HTMLAnchorElement> & {
citationMap: Map<string, Citation>;
isHuman: boolean;
isLoadingCitations?: boolean;
}) {
if (!href) return <span>{children}</span>;
const citation = citationMap.get(href);
// Only render as CitationLink badge if it's a citation (in citationMap) and not human message
if (citation && !isHuman) {
return (
@@ -102,13 +109,13 @@ function MessageLink({
);
}
// All other links render as plain links
const noUnderline = !isHuman && isLoadingCitations;
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2 hover:no-underline"
className={noUnderline ? externalLinkClassNoUnderline : externalLinkClass}
>
{children}
</a>
@@ -201,12 +208,17 @@ function MessageContent_({
// Shared markdown components
const markdownComponents = useMemo(() => ({
a: (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
<MessageLink {...props} citationMap={citationMap} isHuman={isHuman} />
<MessageLink
{...props}
citationMap={citationMap}
isHuman={isHuman}
isLoadingCitations={isLoadingCitations}
/>
),
img: (props: React.ImgHTMLAttributes<HTMLImageElement>) => (
<MessageImage {...props} threadId={thread_id} maxWidth={isHuman ? "full" : "90%"} />
),
}), [citationMap, thread_id, isHuman]);
}), [citationMap, thread_id, isHuman, isLoadingCitations]);
// Render message response
// Human messages use humanMessagePlugins (no autolink) to prevent URL bleeding into adjacent text

View File

@@ -1,9 +1,11 @@
export {
parseCitations,
buildCitationMap,
extractDomainFromUrl,
isCitationsBlockIncomplete,
isExternalUrl,
parseCitations,
removeAllCitations,
syntheticCitationFromLink,
} from "./utils";
export type { Citation, ParseCitationsResult } from "./utils";

View File

@@ -118,6 +118,25 @@ export function buildCitationMap(
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
*

View File

@@ -1,6 +1,12 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
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";