wui
组件

AI 对话 AI Chat

专为 AI 对话、智能助手与 Agent 编排设计的组合式容器,支持流式智能滚屏、角色气泡、多阶段思考链与工具调用状态渲染。

第三方依赖 · class-variance-authority第三方依赖 · lucide-react第三方依赖 · motion

基础用法

可直接发送消息:用户消息从右侧滑入,助手先显示等待指示,再流式输出回答并出现操作栏:

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
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 负责统筹整个对话区域的视口滚动上下文、触底检测与回到底部行为:

属性类型默认值说明
classNamestring—应用于外层会话容器的额外 CSS 类名(如控制高度 `h-[600px]`)。
childrenReact.ReactNode—会话消息列表 `AiChatMessages` 与底部输入框 `AiChatPrompt` 等子节点。

AiChatMessages (消息视口容器)

属性类型默认值说明
followOutputbooleantrue当用户当前处于视口底部时,是否跟随新内容滚动到底部。除子节点变化外,也会监听内容尺寸变化(流式文本、折叠面板展开、图片加载)。
classNamestring—应用于可滚动消息视口的额外样式类名。

AiChatMessage (单条消息外壳)

属性类型默认值说明
role"user" | "assistant" | "system""assistant"发言角色。决定消息的左右对齐方向、视觉主题风格与进场方向:用户消息从右侧滑入,助手消息自下方淡入。
classNamestring—应用于单条消息外层容器的额外类名。

AiChatAvatar (角色头像)

属性类型默认值说明
role"user" | "assistant""assistant"头像所属的角色身份。
childrenReact.ReactNode—自定义头像图标或图像。默认展示角色专属矢量图标。

AiChatMessageContent (气泡内容主体)

属性类型默认值说明
role"user" | "assistant" | "system""assistant"角色类型。用户气泡自带主色背景,助手气泡为无衬底卡片,系统消息为居中灰色通知条。

AiChatLoading (等待首字指示)

提交后、首个 token 到达前显示的三点脉动指示,带 role="status"。

属性类型默认值说明
labelstring"正在生成回复"供屏幕阅读器播报的状态文案。

AiChatSubmit (会话发送按钮)

属性类型默认值说明
status"idle" | "submitted" | "streaming" | "error""idle"当前生成生命周期状态。在 `streaming` 下自动呈现打断生成(Square)按钮。
disabledbooleanfalse是否禁用发送按钮交互。

事件 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 跳转,防止晕动症。