ruitanglin acbf2fb453 fix(citations): use markdown link text as fallback for display
When citation data is not available, use the markdown link text
(children) as display text instead of just the domain. This ensures
that links like [OpenJudge](github.com/...) show 'OpenJudge' instead
of just 'github.com'.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-06 15:06:51 +08:00

403 lines
10 KiB
TypeScript

"use client";
import { Badge } from "@/components/ui/badge";
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { ExternalLinkIcon, ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import type { Citation } from "@/core/citations";
import { extractDomainFromUrl } from "@/core/citations";
import { Shimmer } from "./shimmer";
import { useI18n } from "@/core/i18n/hooks";
export type InlineCitationProps = ComponentProps<"span">;
export const InlineCitation = ({
className,
...props
}: InlineCitationProps) => (
<span
className={cn("group inline items-center gap-1", className)}
{...props}
/>
);
export type InlineCitationTextProps = ComponentProps<"span">;
export const InlineCitationText = ({
className,
...props
}: InlineCitationTextProps) => (
<span
className={cn("transition-colors group-hover:bg-accent", className)}
{...props}
/>
);
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
sources: string[];
};
export const InlineCitationCardTrigger = ({
sources,
className,
...props
}: InlineCitationCardTriggerProps) => (
<HoverCardTrigger asChild>
<Badge
className={cn("ml-1 rounded-full", className)}
variant="secondary"
{...props}
>
{sources[0] ? (
<>
{new URL(sources[0]).hostname}{" "}
{sources.length > 1 && `+${sources.length - 1}`}
</>
) : (
"unknown"
)}
</Badge>
</HoverCardTrigger>
);
export type InlineCitationCardBodyProps = ComponentProps<"div">;
export const InlineCitationCardBody = ({
className,
...props
}: InlineCitationCardBodyProps) => (
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
);
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
const useCarouselApi = () => {
const context = useContext(CarouselApiContext);
return context;
};
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
export const InlineCitationCarousel = ({
className,
children,
...props
}: InlineCitationCarouselProps) => {
const [api, setApi] = useState<CarouselApi>();
return (
<CarouselApiContext.Provider value={api}>
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
{children}
</Carousel>
</CarouselApiContext.Provider>
);
};
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
export const InlineCitationCarouselContent = (
props: InlineCitationCarouselContentProps
) => <CarouselContent {...props} />;
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
export const InlineCitationCarouselItem = ({
className,
...props
}: InlineCitationCarouselItemProps) => (
<CarouselItem
className={cn("w-full space-y-2 p-4 pl-8", className)}
{...props}
/>
);
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
export const InlineCitationCarouselHeader = ({
className,
...props
}: InlineCitationCarouselHeaderProps) => (
<div
className={cn(
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
className
)}
{...props}
/>
);
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
export const InlineCitationCarouselIndex = ({
children,
className,
...props
}: InlineCitationCarouselIndexProps) => {
const api = useCarouselApi();
const [current, setCurrent] = useState(0);
const [count, setCount] = useState(0);
useEffect(() => {
if (!api) {
return;
}
setCount(api.scrollSnapList().length);
setCurrent(api.selectedScrollSnap() + 1);
api.on("select", () => {
setCurrent(api.selectedScrollSnap() + 1);
});
}, [api]);
return (
<div
className={cn(
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
className
)}
{...props}
>
{children ?? `${current}/${count}`}
</div>
);
};
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
export const InlineCitationCarouselPrev = ({
className,
...props
}: InlineCitationCarouselPrevProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollPrev();
}
}, [api]);
return (
<button
aria-label="Previous"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
export const InlineCitationCarouselNext = ({
className,
...props
}: InlineCitationCarouselNextProps) => {
const api = useCarouselApi();
const handleClick = useCallback(() => {
if (api) {
api.scrollNext();
}
}, [api]);
return (
<button
aria-label="Next"
className={cn("shrink-0", className)}
onClick={handleClick}
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
</button>
);
};
export type InlineCitationSourceProps = ComponentProps<"div"> & {
title?: string;
url?: string;
description?: string;
};
export const InlineCitationSource = ({
title,
url,
description,
className,
children,
...props
}: InlineCitationSourceProps) => (
<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>
);
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
export const InlineCitationQuote = ({
children,
className,
...props
}: InlineCitationQuoteProps) => (
<blockquote
className={cn(
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
className
)}
{...props}
>
{children}
</blockquote>
);
/**
* 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>
);
};
/**
* 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>
);
};