组件
AI 消息操作 AI Message Actions
用于 AI 回答气泡底部的标准化快捷操作栏,包含评价点赞、多版本分支切换、一键复制与流式重新生成。
基础用法
在 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"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 为带细边框的独立工具条。 |
| className | string | — | 应用于操作栏容器的额外 CSS 类名。 |
AiMessageFeedback Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| value | "like" | "dislike" | null | — | 受控模式下当前的点赞/点踩状态。 |
| defaultValue | "like" | "dislike" | null | null | 非受控模式下的初始反馈状态。 |
| onChange | (value: "like" | "dislike" | null) => void | — | 用户切换点赞或点踩状态时的回调函数。 |
AiMessageBranch Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| current | number | 1 | 当前展示的生成分支版本序号(从 1 开始)。 |
| total | number | 1 | 当前消息累计生成的分支版本总数。当 total <= 1 时组件自动隐藏。 |
| onPrev | () => void | — | 点击切换到上一个回答版本时的回调函数。 |
| onNext | () => void | — | 点击切换到下一个回答版本时的回调函数。 |
AiMessageCopy Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| content | string | — | 需要复制到剪贴板的原始文本或 Markdown 内容字符串。 |
| onCopy | () => void | — | 成功写入剪贴板后的回调函数,可用于触发 Toast 提示或埋点打点。 |
| label | string | "复制回答" | 按钮的无障碍标签与悬停提示文案。 |
AiMessageRetry Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| isLoading | boolean | false | 是否处于重新生成中的加载状态。设为 true 时旋转图标自动旋转并禁用交互。 |
| label | string | "重新生成" | 按钮的无障碍标签与悬停提示文案。 |
事件 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设置,开启减少动态效果后将直接跳变状态。