组件
AI 来源引用 AI Sources
用于展示 AI 联网检索(SearchGPT / Perplexity)或 RAG 知识库问答参考来源的折叠卡片组与行内悬停角标。
基础用法
在回答正文中通过行内数字角标 AiCitation 标识引用点(鼠标悬停预览摘要),并在文末使用 AiSources 折叠卡片组聚合参考网页:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/ai-sources安装基础依赖与动效库
pnpm add radix-ui lucide-react clsx tailwind-merge复制组件源码到
components/ui/ai-sources.tsx"use client"
import * as React from "react"
import { Collapsible as CollapsiblePrimitive, HoverCard as HoverCardPrimitive } from "radix-ui"
import {
ChevronDownIcon,
ExternalLinkIcon,
GlobeIcon,
SearchIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
/** Extracts the host from an absolute http(s) URL; relative links have none. */
function getHostname(href?: string) {
return href?.match(/^https?:\/\/([^/?#:]+)/i)?.[1]
}
const AiSourcesContext = React.createContext<{ count?: number }>({})
/* -------------------------------------------------------------------------- */
/* AiSources */
/* -------------------------------------------------------------------------- */
export interface AiSourcesProps
extends Omit<React.ComponentProps<"div">, "onOpenChange"> {
/** 检索到的参考来源总数,未自定义标题时显示在折叠按钮中。 */
count?: number
/** 默认是否展开参考来源列表。 @default false */
defaultOpen?: boolean
/** 是否展开参考来源列表(受控)。 */
open?: boolean
/** 展开/折叠状态改变时的回调函数。 */
onOpenChange?: (open: boolean) => void
/** 是否禁用折叠展开交互。 */
disabled?: boolean
}
/** 专用于搜索或 RAG 知识库参考来源的折叠卡片组。 */
function AiSources({
className,
defaultOpen = false,
open,
onOpenChange,
disabled,
count,
children,
...props
}: AiSourcesProps) {
return (
<AiSourcesContext.Provider value={{ count }}>
<CollapsiblePrimitive.Root
defaultOpen={defaultOpen}
open={open}
onOpenChange={onOpenChange}
disabled={disabled}
asChild
>
<div
data-slot="ai-sources"
className={cn("flex flex-col gap-1 text-xs text-muted-foreground", className)}
{...props}
>
{children}
</div>
</CollapsiblePrimitive.Root>
</AiSourcesContext.Provider>
)
}
export interface AiSourcesHeaderProps
extends React.ComponentProps<typeof CollapsiblePrimitive.Trigger> {
/** Custom count or header label. */
label?: React.ReactNode
}
function AiSourcesHeader({
className,
label,
children,
...props
}: AiSourcesHeaderProps) {
const { count } = React.useContext(AiSourcesContext)
const custom = label ?? children
return (
<CollapsiblePrimitive.Trigger
data-slot="ai-sources-header"
className={cn(
"group inline-flex w-fit items-center gap-1.5 rounded-md px-1.5 py-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/35 disabled:pointer-events-none disabled:opacity-50",
className
)}
{...props}
>
<SearchIcon className="size-3.5 text-muted-foreground/80" />
<span>{custom ?? "参考来源"}</span>
{custom === undefined && count !== undefined ? (
<span className="rounded-sm bg-muted px-1 font-mono text-[10px] leading-4 tabular-nums">
{count}
</span>
) : null}
<ChevronDownIcon className="size-3 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[state=open]:rotate-180 motion-reduce:transition-none" />
</CollapsiblePrimitive.Trigger>
)
}
function AiSourcesContent({
className,
children,
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Content>) {
return (
<CollapsiblePrimitive.Content
data-slot="ai-sources-content"
className={cn(
"overflow-hidden duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none",
className
)}
{...props}
>
<div className="pt-1">{children}</div>
</CollapsiblePrimitive.Content>
)
}
/** Staggers the entrance of each source every time the list mounts. */
function AiSourcesList({
className,
children,
...props
}: React.ComponentProps<"div">) {
let order = 0
return (
<div
data-slot="ai-sources-list"
className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3", className)}
{...props}
>
{React.Children.map(children, (child) => {
if (!React.isValidElement<{ style?: React.CSSProperties }>(child)) {
return child
}
const delay = `${Math.min(order++, 8) * 45}ms`
return React.cloneElement(child, {
style: {
"--tw-animation-delay": delay,
...child.props.style,
} as React.CSSProperties,
})
})}
</div>
)
}
export interface AiSourceItemProps extends React.ComponentProps<"a"> {
/** Numerical index/citation identifier (1, 2, 3...). */
index?: number | string
/** Source website title or document name. */
title: string
/** Domain name or source category. */
domain?: string
/** Optional snippet or excerpt from the source. */
snippet?: string
/** Optional icon or favicon URL. */
favicon?: string
}
function SourceFavicon({ src }: { src?: string }) {
const [failed, setFailed] = React.useState(false)
if (!src || failed) {
return <GlobeIcon className="size-3.5 shrink-0 text-muted-foreground" />
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
className="size-3.5 shrink-0 rounded-xs object-contain"
onError={() => setFailed(true)}
/>
)
}
function AiSourceItem({
className,
index,
title,
domain,
snippet,
favicon,
href,
target = "_blank",
rel = "noreferrer noopener",
...props
}: AiSourceItemProps) {
return (
<a
data-slot="ai-source-item"
href={href}
target={target}
rel={rel}
className={cn(
"group relative flex flex-col gap-1 rounded-md border bg-background p-2.5 text-left text-xs outline-none transition-colors hover:bg-muted/50 focus-visible:ring-[3px] focus-visible:ring-ring/35",
"fill-mode-both animation-duration-300 motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1",
className
)}
{...props}
>
<div className="flex items-center gap-1.5">
{index !== undefined && (
<span className="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-muted px-1 font-mono text-[10px] font-medium tabular-nums text-muted-foreground">
{index}
</span>
)}
<SourceFavicon src={favicon} />
<span className="truncate text-muted-foreground transition-colors group-hover:text-foreground">
{domain ?? getHostname(href) ?? "参考网页"}
</span>
<ExternalLinkIcon className="ml-auto size-3 shrink-0 -translate-x-0.5 opacity-0 transition-[opacity,translate] duration-200 group-hover:translate-x-0 group-hover:opacity-60" />
</div>
<div className="line-clamp-1 font-medium text-foreground">{title}</div>
{snippet && (
<p className="line-clamp-2 text-[11px] leading-relaxed text-muted-foreground">
{snippet}
</p>
)}
</a>
)
}
/* -------------------------------------------------------------------------- */
/* AiCitation */
/* -------------------------------------------------------------------------- */
export interface AiCitationProps
extends React.ComponentProps<typeof HoverCardPrimitive.Trigger> {
/** Numeric reference or source index (e.g. 1, 2). */
index: number | string
/** Source title. */
title?: string
/** Domain or publisher name. */
domain?: string
/** Brief excerpt or highlight. */
snippet?: string
/** Link URL to original source. */
href?: string
/** Favicon or logo image URL. */
favicon?: string
}
/** An inline citation badge with a rich HoverCard preview on hover or focus. */
function AiCitation({
className,
index,
title,
domain,
snippet,
href,
favicon,
children,
...props
}: AiCitationProps) {
const source = domain ?? getHostname(href) ?? "来源引用"
return (
<HoverCardPrimitive.Root openDelay={120} closeDelay={80}>
<HoverCardPrimitive.Trigger asChild {...props}>
<button
type="button"
data-slot="ai-citation"
aria-label={`引用 ${index}${title ? `:${title}` : ""}`}
className={cn(
"relative -top-0.5 mx-0.5 inline-flex h-4 min-w-4 cursor-pointer select-none items-center justify-center rounded-sm bg-muted px-1 align-baseline font-mono text-[10px] font-medium leading-none tabular-nums text-muted-foreground outline-none transition-colors hover:bg-foreground hover:text-background focus-visible:ring-2 focus-visible:ring-ring/50 data-[state=open]:bg-foreground data-[state=open]:text-background",
className
)}
>
{children ?? index}
</button>
</HoverCardPrimitive.Trigger>
<HoverCardPrimitive.Portal>
<HoverCardPrimitive.Content
data-slot="ai-citation-popover"
align="center"
sideOffset={6}
className="z-50 w-72 rounded-lg border bg-popover p-3 text-popover-foreground shadow-md outline-none duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-[0.97] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-[0.97] data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1"
>
<div className="flex flex-col gap-1.5 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<span className="flex h-4 min-w-4 shrink-0 items-center justify-center rounded-sm bg-muted px-1 font-mono text-[10px] font-medium tabular-nums">
{index}
</span>
<SourceFavicon src={favicon} />
<span className="truncate font-medium">{source}</span>
</div>
{title && <div className="font-medium leading-5 text-foreground">{title}</div>}
{snippet && (
<p className="line-clamp-3 text-[11px] leading-relaxed text-muted-foreground">
{snippet}
</p>
)}
{href && (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="mt-1 inline-flex w-fit items-center gap-1 text-[11px] font-medium text-foreground underline-offset-4 hover:underline"
>
<span>打开原文</span>
<ExternalLinkIcon className="size-3" />
</a>
)}
</div>
</HoverCardPrimitive.Content>
</HoverCardPrimitive.Portal>
</HoverCardPrimitive.Root>
)
}
export {
AiCitation,
AiSourceItem,
AiSources,
AiSourcesContent,
AiSourcesHeader,
AiSourcesList,
}
属性 Props
AiSources (来源折叠根容器)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| count | number | — | 检索到的参考来源总数。`AiSourcesHeader` 未自定义 `label` 时会在标题旁显示该数量。 |
| open | boolean | — | 受控模式下来源列表的展开状态。 |
| defaultOpen | boolean | false | 非受控模式下来源列表的初始展开状态。 |
| onOpenChange | (open: boolean) => void | — | 来源卡片组展开/收起状态改变时的回调函数。 |
| disabled | boolean | false | 是否禁用折叠展开交互。 |
AiSourcesHeader (折叠触发按钮)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| label | React.ReactNode | — | 自定义头部文本(如「参考来源 (3)」)。 |
AiSourceItem (单项来源卡片)
呈现为带外链跳转特性的交互卡片,支持以下配置:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| index | number | string | — | 数字编号或引用标识符(如 `1`、`2`)。 |
| title | string | — | 参考网页或内部文档标题。 |
| domain | string | — | 来源网站域名或知识库分类标识(如 `react.dev` 或 `内部知识库`)。 |
| snippet | string | — | 从源网页中检索提取的高亮段落或摘要说明。 |
| favicon | string | — | 网站图标 Favicon URL 或自定义图标路径。 |
| href | string | — | 点击后新标签页跳转的目标原始网页 URL。 |
AiCitation (行内悬停引用角标)
基于 Radix HoverCard 实现,鼠标悬停即弹出带有标题、域名、摘要及直达外链的预览气泡:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| index | number | string | — | 引用的数字编号(如 `[1]`)。 |
| title | string | — | 气泡中显示的来源标题。 |
| domain | string | — | 来源域名或所属知识库名。 |
| snippet | string | — | 气泡中展示的段落摘要文本。 |
| href | string | — | 源文档 URL,提供快捷跳转按钮。 |
| favicon | string | — | 来源 Favicon 图标。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onOpenChange | (open: boolean) => void | — | 来源面板展开或收起时触发。 |
| onClick | (event: React.MouseEvent<HTMLAnchorElement>) => void | — | 点击来源卡片或直达链接时触发。 |
使用场景与设计规范
AiSources 专用于 AI 联网搜索(Search-augmented Generation)和企业级 RAG 知识库场景:
- 何时使用:
- 联网搜索问答(如 Perplexity / SearchGPT 模式);
- 基于企业文档、Wiki 或代码库的 RAG 知识检索问答;
- 需要对生成事实进行权威溯源与防止模型幻觉的场景。
- 何时不应使用:
- 基于模型常识的普通对话,若没有检索工具介入无需堆砌无意义引用;
- 纯系统静态配置项说明。
- 设计最佳实践:
- 行内精准标记:在每一个由外部事实支撑的断言后放置
AiCitation,用户无需滑到页面最底部即可直接悬停核查证据; - 网格整齐排列:在底部聚合时,使用
AiSourcesList的自适应网格(Grid),卡片限制行数(line-clamp),保持版面整洁; - 外链安全属性:外跳链接统一默认添加
rel="noreferrer noopener"与target="_blank"。
- 行内精准标记:在每一个由外部事实支撑的断言后放置
场景示例
企业 RAG 知识库检索溯源
内部知识库场景:来源为文档而非网页,标题自定义为「引用了 N 篇内部文档」,默认收起:
Loading…
边生成边引用
回答流式输出时,每写完一句就在句尾弹出引用角标,来源卡片随之逐条错峰进入列表:
Loading…
无障碍与交互 Accessibility
- HoverCard 无障碍规范:
AiCitation触发器是原生<button>,可通过键盘聚焦,并带有「引用 N:标题」的无障碍名称;聚焦或悬停都会打开预览卡片;- 具备
openDelay(120ms) 与closeDelay(80ms) 防抖保护,避免用户光标掠过时频繁闪烁。
- 键盘导航支持:
- Tab:按顺序聚焦行内引用角标与来源卡片;
- Enter / Space:展开折叠面板或在悬停气泡中打开源文档外链。
- 高对比度焦点环:
- 来源卡片与角标在获得焦点时均具备标准的
focus-visible:ring-2高亮轮廓。
- 来源卡片与角标在获得焦点时均具备标准的