wui
组件

AI 对话 AI Chat

可组合的 AI 消息、滚动容器与提示输入框。

基础示例

Loading…
pnpm dlx wui@latest add @wui/ai-chat
components/ui/ai-chat.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"
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"

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", {
  variants: {
    role: {
      user: "justify-end",
      assistant: "justify-start",
      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 scrollToBottom = React.useCallback((behavior: ScrollBehavior = "smooth") => {
    viewportRef.current?.scrollTo({
      top: viewportRef.current.scrollHeight,
      behavior,
    })
  }, [])

  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()

  React.useImperativeHandle(ref, () => viewportRef.current as HTMLDivElement)

  React.useEffect(() => {
    if (followOutput && atBottom) scrollToBottom("auto")
  }, [children, followOutput, atBottom, 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 < 24
        )
        onScroll?.(event)
      }}
      {...props}
    >
      <div 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",
        className
      )}
      {...props}
    />
  )
}

export interface AiChatMessageProps extends React.ComponentProps<"article"> {
  /** Sender role, used for alignment and tone. @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",
        className
      )}
      {...props}
    />
  )
}

function AiChatScrollButton({
  className,
  onClick,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { atBottom, scrollToBottom } = useAiChat()
  if (atBottom) return null

  return (
    <Button
      type="button"
      variant="outline"
      size="icon"
      aria-label="滚动到最新消息"
      data-slot="ai-chat-scroll-button"
      className={cn(
        "absolute bottom-24 left-1/2 z-10 size-8 -translate-x-1/2 rounded-full bg-background",
        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: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,
  AiChatMessage,
  AiChatMessageActions,
  AiChatMessageContent,
  AiChatMessages,
  AiChatPrompt,
  AiChatPromptFooter,
  AiChatPromptTools,
  AiChatScrollButton,
  AiChatSubmit,
  AiChatTextarea,
  aiChatMessageContentVariants,
  aiChatMessageVariants,
}

组件作用

AiChat 用于搭建 Agent 助手、客服和 Copilot 对话界面。它只处理消息布局、跟随滚动、回到底部和输入区组合,不持有模型请求与消息协议,因此既可以接 AI SDK,也可以接自有流式接口。

默认头像复用通用 Avatar,输入区复用 AiPrompt 的基础部件;需要提及、模型选择、附件或引用时,可以直接用完整的 AiPrompt 替换底部输入区。

当界面只是普通评论列表或站内私信时,应继续使用业务列表组件,不要因为内容由 AI 生成就强行套用完整聊天外壳。

组件属性

No documented props for "ai-chat".

事件

AiChatPrompt 透传表单的 onSubmit,由业务层读取输入、发送请求并控制 AiChatSubmitstatusAiChatTextarea 透传原生输入与键盘事件;示例使用 Enter 发送、Shift + Enter 换行。消息操作区不预设复制、重试或反馈逻辑。

扩展使用

与工具、任务和推理组合

聊天框刻意保持为复合组件:在助手消息中直接放入 AiToolAiTodoAiReasoning,即可呈现一个完整的 Agent 执行过程,无需为每种消息类型增加新的固定模板。

流式消息

生成开始时将 AiChatSubmit status="submitted",收到内容后切换为 streaming,结束后恢复 idleAiChatMessages 只会在用户仍位于底部时跟随新内容;用户向上阅读历史记录后不会被强制拉回。

空状态

没有消息时在 AiChatMessages 内使用 AiChatEmptyState,放置一句明确的能力说明和少量起始建议。避免堆叠宣传文案、装饰插画和过多快捷入口。