组件
AI 对话 AI Chat
专为 AI 对话、智能助手与 Agent 编排设计的组合式容器,支持流式智能滚屏、角色气泡、多阶段思考链与工具调用状态渲染。
基础用法
可直接发送消息:用户消息从右侧滑入,助手先显示等待指示,再流式输出回答并出现操作栏:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/ai-chat安装基础依赖与动效库
pnpm add radix-ui motion class-variance-authority lucide-react clsx tailwind-merge复制组件源码到
components/ui/ai-chat.tsx"use client"
import * as React from "react"
import { cva } from "class-variance-authority"
import { motion, useReducedMotion } from "motion/react"
import {
ArrowDownIcon,
BotIcon,
UserIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
AiPrompt,
AiPromptFooter,
AiPromptSubmit,
AiPromptTextarea,
AiPromptTools,
} from "@/components/ui/ai-prompt"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
type AiChatRole = "user" | "assistant" | "system"
type AiChatStatus = "idle" | "submitted" | "streaming" | "error"
/** Distance from the bottom, in pixels, still treated as "at the bottom". */
const BOTTOM_THRESHOLD = 24
const AiChatContext = React.createContext<{
atBottom: boolean
setAtBottom: React.Dispatch<React.SetStateAction<boolean>>
viewportRef: React.RefObject<HTMLDivElement | null>
scrollToBottom: (behavior?: ScrollBehavior) => void
} | null>(null)
const aiChatMessageVariants = cva(
"flex w-full gap-3 fill-mode-both animation-duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:animate-in motion-safe:fade-in-0",
{
variants: {
role: {
user: "justify-end motion-safe:slide-in-from-right-3",
assistant: "justify-start motion-safe:slide-in-from-bottom-2",
system: "justify-center",
},
},
defaultVariants: { role: "assistant" },
}
)
const aiChatMessageContentVariants = cva(
"min-w-0 text-sm leading-6",
{
variants: {
role: {
user:
"max-w-[82%] rounded-2xl rounded-br-md bg-primary px-4 py-2.5 text-primary-foreground",
assistant: "max-w-[88%] py-1 text-foreground",
system:
"max-w-[88%] rounded-md border bg-muted/40 px-3 py-2 text-center text-xs text-muted-foreground",
},
},
defaultVariants: { role: "assistant" },
}
)
function useAiChat() {
const context = React.useContext(AiChatContext)
if (!context) throw new Error("AI chat parts must be used inside <AiChat />")
return context
}
export interface AiChatProps extends React.ComponentProps<"section"> {}
/** A composable shell for AI messages, scrolling, and a prompt composer. */
function AiChat({ className, ...props }: AiChatProps) {
const viewportRef = React.useRef<HTMLDivElement>(null)
const [atBottom, setAtBottom] = React.useState(true)
const reduceMotion = useReducedMotion()
const scrollToBottom = React.useCallback(
(behavior: ScrollBehavior = "smooth") => {
viewportRef.current?.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: reduceMotion ? "auto" : behavior,
})
},
[reduceMotion]
)
return (
<AiChatContext.Provider
value={{ atBottom, setAtBottom, viewportRef, scrollToBottom }}
>
<section
data-slot="ai-chat"
className={cn(
"relative flex min-h-0 w-full flex-col overflow-hidden rounded-lg border bg-background",
className
)}
{...props}
/>
</AiChatContext.Provider>
)
}
export interface AiChatMessagesProps extends React.ComponentProps<"div"> {
/** Keep the newest content in view while the user is already at the bottom. @default true */
followOutput?: boolean
}
function AiChatMessages({
className,
followOutput = true,
children,
onScroll,
ref,
...props
}: AiChatMessagesProps) {
const { viewportRef, atBottom, setAtBottom, scrollToBottom } = useAiChat()
const contentRef = React.useRef<HTMLDivElement>(null)
const atBottomRef = React.useRef(atBottom)
atBottomRef.current = atBottom
React.useImperativeHandle(ref, () => viewportRef.current as HTMLDivElement)
React.useEffect(() => {
if (followOutput && atBottom) scrollToBottom("auto")
}, [children, followOutput, atBottom, scrollToBottom])
// Content can grow without a parent re-render (a streaming child, an image
// loading, a disclosure opening). Follow those size changes too.
React.useEffect(() => {
const content = contentRef.current
if (!followOutput || !content) return
const observer = new ResizeObserver(() => {
if (atBottomRef.current) scrollToBottom("auto")
})
observer.observe(content)
return () => observer.disconnect()
}, [followOutput, scrollToBottom])
return (
<div
ref={viewportRef}
data-slot="ai-chat-messages"
className={cn(
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5",
className
)}
onScroll={(event) => {
const element = event.currentTarget
setAtBottom(
element.scrollHeight - element.scrollTop - element.clientHeight <
BOTTOM_THRESHOLD
)
onScroll?.(event)
}}
{...props}
>
<div
ref={contentRef}
role="log"
aria-live="polite"
aria-relevant="additions"
className="mx-auto flex w-full max-w-3xl flex-col gap-5"
>
{children}
</div>
</div>
)
}
function AiChatEmptyState({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="ai-chat-empty-state"
className={cn(
"mx-auto flex min-h-64 max-w-sm flex-col items-center justify-center px-6 text-center duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-2",
className
)}
{...props}
/>
)
}
export interface AiChatMessageProps extends React.ComponentProps<"article"> {
/** Sender role, used for alignment, tone, and entrance direction. @default "assistant" */
role?: AiChatRole
}
function AiChatMessage({
className,
role = "assistant",
...props
}: AiChatMessageProps) {
return (
<article
data-slot="ai-chat-message"
data-role={role}
className={cn(aiChatMessageVariants({ role }), className)}
{...props}
/>
)
}
export interface AiChatAvatarProps extends React.ComponentProps<"span"> {
/** Avatar role. @default "assistant" */
role?: Exclude<AiChatRole, "system">
}
function AiChatAvatar({
className,
role = "assistant",
children,
...props
}: AiChatAvatarProps) {
const Icon = role === "user" ? UserIcon : BotIcon
return (
<Avatar
data-slot="ai-chat-avatar"
data-role={role}
size="sm"
className={cn(
"mt-0.5 size-7 border",
role === "user" && "order-2",
className
)}
{...props}
>
<AvatarFallback>
{children ?? <Icon className="size-3.5" />}
</AvatarFallback>
</Avatar>
)
}
export interface AiChatMessageContentProps
extends React.ComponentProps<"div"> {
/** Sender role, used for bubble styling. @default "assistant" */
role?: AiChatRole
}
function AiChatMessageContent({
className,
role = "assistant",
...props
}: AiChatMessageContentProps) {
return (
<div
data-slot="ai-chat-message-content"
data-role={role}
className={cn(aiChatMessageContentVariants({ role }), className)}
{...props}
/>
)
}
function AiChatMessageActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="ai-chat-message-actions"
className={cn(
"flex items-center gap-0.5 text-muted-foreground duration-300 motion-safe:animate-in motion-safe:fade-in-0",
className
)}
{...props}
/>
)
}
export interface AiChatLoadingProps extends React.ComponentProps<"div"> {
/** Accessible status text. @default "正在生成回复" */
label?: string
}
/** Three softly pulsing dots shown after submit and before the first token. */
function AiChatLoading({
className,
label = "正在生成回复",
...props
}: AiChatLoadingProps) {
const reduceMotion = useReducedMotion()
return (
<div
role="status"
aria-label={label}
data-slot="ai-chat-loading"
className={cn("flex h-6 items-center gap-1 text-muted-foreground", className)}
{...props}
>
{[0, 1, 2].map((index) => (
<motion.span
key={index}
aria-hidden
className="size-1.5 rounded-full bg-current"
initial={false}
animate={
reduceMotion
? { opacity: 0.6 }
: { opacity: [0.3, 1, 0.3], y: [0, -3, 0] }
}
transition={
reduceMotion
? { duration: 0 }
: {
duration: 1,
repeat: Infinity,
ease: "easeInOut",
delay: index * 0.16,
}
}
/>
))}
</div>
)
}
function AiChatScrollButton({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { atBottom, scrollToBottom } = useAiChat()
return (
<Button
type="button"
variant="outline"
size="icon"
aria-label="滚动到最新消息"
aria-hidden={atBottom || undefined}
inert={atBottom}
data-slot="ai-chat-scroll-button"
data-visible={atBottom ? "false" : "true"}
className={cn(
"absolute bottom-24 left-1/2 z-10 size-8 -translate-x-1/2 rounded-full bg-background shadow-sm transition-[opacity,scale,translate,background-color,color] duration-200 ease-out data-[visible=false]:pointer-events-none data-[visible=false]:translate-y-2 data-[visible=false]:scale-90 data-[visible=false]:opacity-0 motion-reduce:transition-none",
className
)}
onClick={(event) => {
scrollToBottom()
onClick?.(event)
}}
{...props}
>
<ArrowDownIcon />
</Button>
)
}
function AiChatPrompt({
className,
...props
}: React.ComponentProps<typeof AiPrompt>) {
return (
<AiPrompt
data-slot="ai-chat-prompt"
className={cn(
"rounded-none border-x-0 border-b-0 bg-background p-2 focus-within:border-border focus-within:ring-0",
className
)}
{...props}
/>
)
}
function AiChatTextarea({
className,
...props
}: React.ComponentProps<typeof AiPromptTextarea>) {
return (
<AiPromptTextarea
data-slot="ai-chat-textarea"
rows={1}
className={cn(
"min-h-10 min-w-0 flex-1 px-2 py-2",
className
)}
{...props}
/>
)
}
function AiChatPromptFooter({
className,
...props
}: React.ComponentProps<typeof AiPromptFooter>) {
return (
<AiPromptFooter
data-slot="ai-chat-prompt-footer"
className={cn("px-0 py-0", className)}
{...props}
/>
)
}
function AiChatPromptTools({
className,
...props
}: React.ComponentProps<typeof AiPromptTools>) {
return (
<AiPromptTools
data-slot="ai-chat-prompt-tools"
className={className}
{...props}
/>
)
}
export interface AiChatSubmitProps
extends React.ComponentProps<typeof AiPromptSubmit> {
/** Current generation state. @default "idle" */
status?: AiChatStatus
}
function AiChatSubmit({
className,
status = "idle",
children,
...props
}: AiChatSubmitProps) {
return (
<AiPromptSubmit
data-slot="ai-chat-submit"
status={status}
className={className}
{...props}
>
{children}
</AiPromptSubmit>
)
}
export {
AiChat,
AiChatAvatar,
AiChatEmptyState,
AiChatLoading,
AiChatMessage,
AiChatMessageActions,
AiChatMessageContent,
AiChatMessages,
AiChatPrompt,
AiChatPromptFooter,
AiChatPromptTools,
AiChatScrollButton,
AiChatSubmit,
AiChatTextarea,
aiChatMessageContentVariants,
aiChatMessageVariants,
}
属性 Props
AiChat (根容器)
AiChat 负责统筹整个对话区域的视口滚动上下文、触底检测与回到底部行为:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于外层会话容器的额外 CSS 类名(如控制高度 `h-[600px]`)。 |
| children | React.ReactNode | — | 会话消息列表 `AiChatMessages` 与底部输入框 `AiChatPrompt` 等子节点。 |
AiChatMessages (消息视口容器)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| followOutput | boolean | true | 当用户当前处于视口底部时,是否跟随新内容滚动到底部。除子节点变化外,也会监听内容尺寸变化(流式文本、折叠面板展开、图片加载)。 |
| className | string | — | 应用于可滚动消息视口的额外样式类名。 |
AiChatMessage (单条消息外壳)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| role | "user" | "assistant" | "system" | "assistant" | 发言角色。决定消息的左右对齐方向、视觉主题风格与进场方向:用户消息从右侧滑入,助手消息自下方淡入。 |
| className | string | — | 应用于单条消息外层容器的额外类名。 |
AiChatAvatar (角色头像)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| role | "user" | "assistant" | "assistant" | 头像所属的角色身份。 |
| children | React.ReactNode | — | 自定义头像图标或图像。默认展示角色专属矢量图标。 |
AiChatMessageContent (气泡内容主体)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| role | "user" | "assistant" | "system" | "assistant" | 角色类型。用户气泡自带主色背景,助手气泡为无衬底卡片,系统消息为居中灰色通知条。 |
AiChatLoading (等待首字指示)
提交后、首个 token 到达前显示的三点脉动指示,带 role="status"。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| label | string | "正在生成回复" | 供屏幕阅读器播报的状态文案。 |
AiChatSubmit (会话发送按钮)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| status | "idle" | "submitted" | "streaming" | "error" | "idle" | 当前生成生命周期状态。在 `streaming` 下自动呈现打断生成(Square)按钮。 |
| disabled | boolean | false | 是否禁用发送按钮交互。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onSubmit | (event: React.FormEvent<HTMLFormElement>) => void | — | 由 `AiChatPrompt` 派发的表单提交事件,通常在此调用大模型 API 或 SSE 流。 |
| onScroll | (event: React.UIEvent<HTMLDivElement>) => void | — | 消息视口发生滚动时触发,组件内部已自动维护触底状态 `atBottom`。 |
使用场景与设计规范
AiChat 是构建 AI 对话系统、智能 Copilot 侧边栏和自动化 Agent 控制台的核心框架:
- 何时使用:
- 智能问答客服、代码审查助手与知识库对话面板;
- 需要在助手中内嵌“思考链”、“工具调用”、“待办计划”和“流式回答”的复杂智能体流程;
- 多轮长对话且要求精准的“流式跟随滚动”与“向上翻阅不强制打断”体验。
- 何时不应使用:
- 团队即时通讯(IM)或传统客服工单聊天(没有模型推理与工具调用特性,应采用标准 IM 规范);
- 简单的一问一答搜索框(无需庞大的全屏聊天壳)。
- 设计最佳实践:
- 非侵入式滚动跟随:当用户向上翻阅历史消息时,严禁强制自动滚回底部;只有当用户处于底部时才跟随新字符滚动,并提供悬浮的“回到底部”快捷按钮 (
AiChatScrollButton); - 模块化状态呈现:将复杂推理过程拆分为
AiReasoning,将参数与文件读写交给AiTool,保证正文排版清晰整洁; - 丰富的操作反馈:在助手回复下方提供
AiChatMessageActions,方便用户一键复制、重新生成或进行质量反馈(点赞/点踩)。
- 非侵入式滚动跟随:当用户向上翻阅历史消息时,严禁强制自动滚回底部;只有当用户处于底部时才跟随新字符滚动,并提供悬浮的“回到底部”快捷按钮 (
场景示例
智能体完整工作流 (Agent Workflow)
一次完整的 Agent 回合按时间推进:思考步骤逐条展开、工具从执行中切换为已完成、计划进度推进,最后流式输出结论:
Loading…
欢迎页与空白引导态 (Empty State)
在会话初次创建或尚无消息时,使用 AiChatEmptyState 配合 AiPromptSuggestions 引导用户快速发起提问:
Loading…
无障碍与交互 Accessibility
- ARIA 规范与屏幕阅读器:
- 消息列表包裹在
role="log"(aria-live="polite")区域中,新消息能被屏幕阅读器平稳播报,不抢占焦点; - 每条消息使用独立的
<article>语义化标签包裹; - 回到底部按钮包含显式
aria-label="滚动到最新消息",隐藏时通过inert移出焦点顺序。
- 消息列表包裹在
- 键盘快捷交互:
- Enter:快速发送消息。
- Shift + Enter:在多行输入框中换行。
- Tab:在消息操作按钮(复制、重新生成、评价)之间顺畅切换。
- 动效降级:
- 触底滚动在系统开启
prefers-reduced-motion时会自动由smooth降级为瞬时auto跳转,防止晕动症。
- 触底滚动在系统开启