wui
组件

AI 消息操作 AI Message Actions

用于 AI 回答气泡底部的标准化快捷操作栏,包含评价点赞、多版本分支切换、一键复制与流式重新生成。

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

基础用法

在 AI 助手回复卡片底部提供标准化操作栏,支持一键复制内容、重新生成、多版本前后翻页以及点赞/点踩反馈:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/ai-message-actions
安装基础依赖与图标库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/ai-message-actions.tsx
components/ui/ai-message-actions.tsx
"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
  CheckIcon,
  ChevronLeftIcon,
  ChevronRightIcon,
  CopyIcon,
  PencilIcon,
  RotateCwIcon,
  ThumbsDownIcon,
  ThumbsUpIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"

const swapSpring = {
  type: "spring",
  stiffness: 520,
  damping: 32,
  mass: 0.6,
} as const

/* -------------------------------------------------------------------------- */
/*                              AiMessageActions                              */
/* -------------------------------------------------------------------------- */

const aiMessageActionsVariants = cva(
  "inline-flex items-center gap-0.5 text-muted-foreground transition-opacity duration-150",
  {
    variants: {
      variant: {
        ghost: "",
        bordered: "rounded-lg border bg-background p-0.5 shadow-xs",
      },
    },
    defaultVariants: {
      variant: "ghost",
    },
  }
)

export interface AiMessageActionsProps
  extends React.ComponentProps<"div">,
    VariantProps<typeof aiMessageActionsVariants> {
  /** 外观样式变体。 @default "ghost" */
  variant?: "ghost" | "bordered"
}

/** 专用于消息底部的快捷操作工具栏。 */
function AiMessageActions({
  className,
  variant,
  children,
  ...props
}: AiMessageActionsProps) {
  return (
    <div
      role="toolbar"
      data-slot="ai-message-actions"
      className={cn(aiMessageActionsVariants({ variant }), className)}
      {...props}
    >
      {children}
    </div>
  )
}

/* -------------------------------------------------------------------------- */
/*                               AiMessageAction                              */
/* -------------------------------------------------------------------------- */

export interface AiMessageActionProps extends React.ComponentProps<"button"> {
  /** Accessible label and browser tooltip. */
  label?: string
  /** Active / selected visual highlight. @default false */
  active?: boolean
}

function AiMessageAction({
  className,
  label,
  active = false,
  children,
  ...props
}: AiMessageActionProps) {
  return (
    <button
      type="button"
      data-slot="ai-message-action"
      aria-label={label}
      title={label}
      data-active={active ? "true" : "false"}
      className={cn(
        "relative inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-xs text-muted-foreground outline-none transition-[color,background-color,scale] duration-150 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/35 active:scale-90 disabled:pointer-events-none disabled:opacity-40 motion-reduce:active:scale-100",
        active && "bg-muted text-foreground",
        className
      )}
      {...props}
    >
      {children}
    </button>
  )
}

/* -------------------------------------------------------------------------- */
/*                              AiMessageFeedback                             */
/* -------------------------------------------------------------------------- */

export type AiMessageFeedbackValue = "like" | "dislike" | null

export interface AiMessageFeedbackProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** 当前点赞/点踩反馈状态。 */
  value?: AiMessageFeedbackValue
  /** 默认点赞/点踩反馈状态。 */
  defaultValue?: AiMessageFeedbackValue
  /** 反馈状态切换时的回调函数。 */
  onChange?: (value: AiMessageFeedbackValue) => void
}

function FeedbackIcon({
  active,
  direction,
}: {
  active: boolean
  direction: "like" | "dislike"
}) {
  const reduceMotion = useReducedMotion()
  const Icon = direction === "like" ? ThumbsUpIcon : ThumbsDownIcon
  const tilt = direction === "like" ? -16 : 16

  return (
    <motion.span
      className="flex items-center justify-center"
      initial={false}
      animate={
        active && !reduceMotion
          ? {
              scale: [1, 1.3, 1],
              rotate: [0, tilt, 0],
              y: direction === "like" ? [0, -2, 0] : [0, 2, 0],
            }
          : { scale: 1, rotate: 0, y: 0 }
      }
      transition={{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }}
    >
      <Icon
        className={cn(
          "size-3.5 transition-[fill] duration-200",
          active ? "fill-current" : "fill-transparent"
        )}
      />
    </motion.span>
  )
}

function AiMessageFeedback({
  className,
  value: controlledValue,
  defaultValue = null,
  onChange,
  ...props
}: AiMessageFeedbackProps) {
  const [internalValue, setInternalValue] =
    React.useState<AiMessageFeedbackValue>(defaultValue)

  const value = controlledValue !== undefined ? controlledValue : internalValue

  const handleVote = (target: "like" | "dislike") => {
    const next = value === target ? null : target
    if (controlledValue === undefined) {
      setInternalValue(next)
    }
    onChange?.(next)
  }

  return (
    <div
      data-slot="ai-message-feedback"
      className={cn("flex items-center gap-0.5", className)}
      {...props}
    >
      <AiMessageAction
        label="点赞"
        aria-pressed={value === "like"}
        active={value === "like"}
        onClick={() => handleVote("like")}
      >
        <FeedbackIcon direction="like" active={value === "like"} />
      </AiMessageAction>

      <AiMessageAction
        label="点踩"
        aria-pressed={value === "dislike"}
        active={value === "dislike"}
        onClick={() => handleVote("dislike")}
      >
        <FeedbackIcon direction="dislike" active={value === "dislike"} />
      </AiMessageAction>
    </div>
  )
}

/* -------------------------------------------------------------------------- */
/*                               AiMessageBranch                              */
/* -------------------------------------------------------------------------- */

export interface AiMessageBranchProps extends React.ComponentProps<"div"> {
  /** 1-based current branch index. @default 1 */
  current?: number
  /** Total count of generated branches/versions. @default 1 */
  total?: number
  /** Callback fired when navigating to previous branch. */
  onPrev?: () => void
  /** Callback fired when navigating to next branch. */
  onNext?: () => void
}

function AiMessageBranch({
  className,
  current = 1,
  total = 1,
  onPrev,
  onNext,
  ...props
}: AiMessageBranchProps) {
  const reduceMotion = useReducedMotion()
  const previous = React.useRef(current)
  const direction = current >= previous.current ? 1 : -1

  React.useEffect(() => {
    previous.current = current
  }, [current])

  if (total <= 1) return null

  return (
    <div
      data-slot="ai-message-branch"
      className={cn(
        "flex items-center gap-0.5 font-mono text-xs text-muted-foreground",
        className
      )}
      {...props}
    >
      <AiMessageAction
        label="上一个回答"
        disabled={current <= 1}
        onClick={onPrev}
      >
        <ChevronLeftIcon className="size-3.5" />
      </AiMessageAction>

      <span
        aria-live="polite"
        className="flex select-none items-center px-0.5 text-[11px] tabular-nums"
      >
        <span className="sr-only">
          第 {current} 个回答,共 {total} 个
        </span>
        <span
          aria-hidden
          className="relative inline-flex h-4 min-w-[2ch] items-center justify-end overflow-hidden"
        >
          <AnimatePresence initial={false} mode="popLayout" custom={direction}>
            <motion.span
              key={current}
              custom={direction}
              variants={{
                enter: (d: number) => ({ y: d * 12, opacity: 0 }),
                center: { y: 0, opacity: 1 },
                exit: (d: number) => ({ y: d * -12, opacity: 0 }),
              }}
              initial={reduceMotion ? false : "enter"}
              animate="center"
              exit={reduceMotion ? undefined : "exit"}
              transition={reduceMotion ? { duration: 0 } : swapSpring}
            >
              {current}
            </motion.span>
          </AnimatePresence>
        </span>
        <span aria-hidden className="px-1 text-muted-foreground/60">
          /
        </span>
        <span aria-hidden>{total}</span>
      </span>

      <AiMessageAction
        label="下一个回答"
        disabled={current >= total}
        onClick={onNext}
      >
        <ChevronRightIcon className="size-3.5" />
      </AiMessageAction>
    </div>
  )
}

/* -------------------------------------------------------------------------- */
/*                                AiMessageCopy                               */
/* -------------------------------------------------------------------------- */

export interface AiMessageCopyProps
  extends Omit<AiMessageActionProps, "children"> {
  /** Content string to copy into clipboard. */
  content: string
  /** Callback fired after successfully copying content. */
  onCopy?: () => void
}

function AiMessageCopy({
  content,
  onCopy,
  label = "复制回答",
  className,
  ...props
}: AiMessageCopyProps) {
  const [copied, setCopied] = React.useState(false)
  const timerRef = React.useRef<number | undefined>(undefined)
  const reduceMotion = useReducedMotion()

  React.useEffect(() => () => window.clearTimeout(timerRef.current), [])

  const handleCopy = React.useCallback(async () => {
    try {
      await navigator.clipboard.writeText(content)
    } catch {
      return
    }
    setCopied(true)
    onCopy?.()
    window.clearTimeout(timerRef.current)
    timerRef.current = window.setTimeout(() => setCopied(false), 2000)
  }, [content, onCopy])

  return (
    <AiMessageAction
      label={copied ? "已复制" : label}
      onClick={handleCopy}
      className={className}
      {...props}
    >
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={copied ? "check" : "copy"}
          initial={
            reduceMotion
              ? false
              : { scale: 0.5, opacity: 0, filter: "blur(2px)" }
          }
          animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
          exit={
            reduceMotion
              ? undefined
              : { scale: 0.5, opacity: 0, filter: "blur(2px)" }
          }
          transition={reduceMotion ? { duration: 0 } : swapSpring}
          className={cn(
            "flex items-center justify-center",
            copied && "text-success"
          )}
        >
          {copied ? (
            <CheckIcon className="size-3.5" />
          ) : (
            <CopyIcon className="size-3.5" />
          )}
        </motion.span>
      </AnimatePresence>
    </AiMessageAction>
  )
}

/* -------------------------------------------------------------------------- */
/*                                AiMessageRetry                              */
/* -------------------------------------------------------------------------- */

export interface AiMessageRetryProps extends AiMessageActionProps {
  /** Indicates whether regenerate request is loading. @default false */
  isLoading?: boolean
}

function AiMessageRetry({
  className,
  isLoading = false,
  label = "重新生成",
  disabled,
  onClick,
  ...props
}: AiMessageRetryProps) {
  return (
    <AiMessageAction
      label={label}
      disabled={isLoading || disabled}
      aria-busy={isLoading || undefined}
      onClick={onClick}
      className={cn("group/retry", className)}
      {...props}
    >
      <RotateCwIcon
        className={cn(
          "size-3.5 transition-transform duration-300 ease-out group-hover/retry:rotate-45 motion-reduce:transition-none",
          isLoading && "text-foreground motion-safe:animate-spin"
        )}
      />
    </AiMessageAction>
  )
}

/* -------------------------------------------------------------------------- */
/*                                AiMessageEdit                               */
/* -------------------------------------------------------------------------- */

function AiMessageEdit({
  label = "编辑提问",
  className,
  ...props
}: AiMessageActionProps) {
  return (
    <AiMessageAction label={label} className={className} {...props}>
      <PencilIcon className="size-3.5" />
    </AiMessageAction>
  )
}

export {
  AiMessageAction,
  AiMessageActions,
  AiMessageBranch,
  AiMessageCopy,
  AiMessageEdit,
  AiMessageFeedback,
  AiMessageRetry,
  aiMessageActionsVariants,
}

属性 Props

AiMessageActions Props

属性类型默认值说明
variant"ghost" | "bordered""ghost"操作栏的外观样式变体。ghost 为扁平轻量风格,bordered 为带细边框的独立工具条。
classNamestring—应用于操作栏容器的额外 CSS 类名。

AiMessageFeedback Props

属性类型默认值说明
value"like" | "dislike" | null—受控模式下当前的点赞/点踩状态。
defaultValue"like" | "dislike" | nullnull非受控模式下的初始反馈状态。
onChange(value: "like" | "dislike" | null) => void—用户切换点赞或点踩状态时的回调函数。

AiMessageBranch Props

属性类型默认值说明
currentnumber1当前展示的生成分支版本序号(从 1 开始)。
totalnumber1当前消息累计生成的分支版本总数。当 total <= 1 时组件自动隐藏。
onPrev() => void—点击切换到上一个回答版本时的回调函数。
onNext() => void—点击切换到下一个回答版本时的回调函数。

AiMessageCopy Props

属性类型默认值说明
contentstring—需要复制到剪贴板的原始文本或 Markdown 内容字符串。
onCopy() => void—成功写入剪贴板后的回调函数,可用于触发 Toast 提示或埋点打点。
labelstring"复制回答"按钮的无障碍标签与悬停提示文案。

AiMessageRetry Props

属性类型默认值说明
isLoadingbooleanfalse是否处于重新生成中的加载状态。设为 true 时旋转图标自动旋转并禁用交互。
labelstring"重新生成"按钮的无障碍标签与悬停提示文案。

事件 Events

属性类型默认值说明
AiMessageFeedback.onChange(value: "like" | "dislike" | null) => void—用户点击点赞或点踩按钮时触发,返回最新的反馈值;重复点击已激活按钮会取消并返回 null。
AiMessageBranch.onPrev() => void—用户点击上一版本箭头按钮时触发。
AiMessageBranch.onNext() => void—用户点击下一版本箭头按钮时触发。
AiMessageCopy.onCopy() => void—剪贴板写入成功后触发。
AiMessageRetry.onClick(event: React.MouseEvent<HTMLButtonElement>) => void—用户点击重新生成按钮时触发。

使用场景与设计规范

AiMessageActions 是一套高度模块化的 AI 消息工具栏,旨在统一大语言模型(LLM)对话产品中的高频操作交互。

  • 反馈闭环:利用 AiMessageFeedback 收集用户的满意度点赞/点踩数据,结合业务埋点用于强化学习(RLHF)或模型提示词迭代优化。
  • 分支历史追溯:当用户对当前回答不满意点击重新生成时,不要直接覆盖旧回答,建议使用 AiMessageBranch 保留历史生成版本(如 1/3),允许用户随时来回切换对比。
  • 即时视觉反馈:AiMessageCopy 内置了 2 秒打勾动效,点击后自动提供视觉确认,减少用户重复尝试复制的焦虑。

场景示例

带边框的工具条

通过设置 variant="bordered",操作栏呈现为带细边框的独立工具条,可自由组合收藏、分享等自定义动作按钮:

Loading…

多版本分支切换与重新生成联动

当用户触发“重新生成”后,将新生成的回答内容追加为新版本,并通过 AiMessageBranch 允许用户自由翻阅历史版本:

Loading…

无障碍与交互 Accessibility

  • ARIA 标签与 Tooltip:每个操作按钮均默认带有语义化的 aria-label 与 title 属性,读屏软件能准确播报“复制回答”、“重新生成”、“点赞”等动作。
  • 高对比度焦点环:支持完整的键盘聚焦导航,键盘通过 Tab 切换时呈现符合 WCAG 2.1 AA 标准的高对比度焦点指示。
  • 动效降级:内置的图标切换缩放微动效自动适配系统的 prefers-reduced-motion 设置,开启减少动态效果后将直接跳变状态。