wui
组件

AI 工具调用 AI Tool

用于展示智能体(Agent)工具调用的执行状态、参数入参、输出结果、错误堆栈与人工审批确认。

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

基础用法

包含执行中、成功与失败三种典型状态的可折叠工具调用卡片:

Loading…

安装与引入

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

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

import * as React from "react"
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
import { cva } from "class-variance-authority"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import {
  CheckCircle2Icon,
  ChevronDownIcon,
  CircleDashedIcon,
  CircleXIcon,
  LoaderCircleIcon,
  ShieldAlertIcon,
  WrenchIcon,
} from "lucide-react"

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

export type AiToolStatus =
  | "pending"
  | "approval"
  | "running"
  | "success"
  | "error"
  | "denied"

const AiToolContext = React.createContext<{ status: AiToolStatus }>({
  status: "pending",
})

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

const aiToolStatusVariants = cva(
  "relative inline-flex items-center gap-1.5 text-xs font-medium transition-colors duration-300",
  {
    variants: {
      status: {
        pending: "text-muted-foreground",
        approval: "text-warning",
        running: "text-info",
        success: "text-success",
        error: "text-destructive",
        denied: "text-muted-foreground",
      },
    },
    defaultVariants: { status: "pending" },
  }
)

const statusMeta = {
  pending: { label: "等待中", icon: CircleDashedIcon },
  approval: { label: "等待确认", icon: ShieldAlertIcon },
  running: { label: "执行中", icon: LoaderCircleIcon },
  success: { label: "已完成", icon: CheckCircle2Icon },
  error: { label: "执行失败", icon: CircleXIcon },
  denied: { label: "已拒绝", icon: CircleXIcon },
} as const

export interface AiToolProps
  extends React.ComponentProps<typeof CollapsiblePrimitive.Root> {
  /** Tool execution state. @default "pending" */
  status?: AiToolStatus
}

/** A compact disclosure for an AI tool invocation and its input/output. */
function AiTool({
  className,
  status = "pending",
  defaultOpen,
  ...props
}: AiToolProps) {
  return (
    <AiToolContext.Provider value={{ status }}>
      <CollapsiblePrimitive.Root
        data-slot="ai-tool"
        data-status={status}
        className={cn(
          "overflow-hidden rounded-md border bg-background transition-colors duration-300 data-[status=approval]:border-warning-border data-[status=error]:border-destructive-border",
          className
        )}
        defaultOpen={defaultOpen ?? status === "error"}
        {...props}
      />
    </AiToolContext.Provider>
  )
}

export interface AiToolTriggerProps
  extends React.ComponentProps<typeof CollapsiblePrimitive.Trigger> {
  /** Human-readable tool name. */
  name?: string
}

function AiToolTrigger({
  className,
  name = "工具调用",
  children,
  ...props
}: AiToolTriggerProps) {
  const { status } = React.useContext(AiToolContext)
  const reduceMotion = useReducedMotion()

  return (
    <CollapsiblePrimitive.Trigger
      data-slot="ai-tool-trigger"
      className={cn(
        "group relative flex w-full items-center gap-2.5 px-3 py-2.5 text-left outline-none transition-colors hover:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/35",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
          <span className="min-w-0 flex-1 truncate font-mono text-sm font-medium">
            {name}
          </span>
          <AiToolStatus />
          <ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[state=open]:rotate-180 motion-reduce:transition-none" />
        </>
      )}
      <AnimatePresence>
        {status === "running" && !reduceMotion ? (
          <motion.span
            key="progress"
            aria-hidden
            data-slot="ai-tool-progress"
            className="pointer-events-none absolute inset-x-0 bottom-0 h-px overflow-hidden"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
          >
            <motion.span
              className="block h-full w-1/3 bg-info"
              initial={{ x: "-100%" }}
              animate={{ x: "300%" }}
              transition={{
                duration: 1.3,
                ease: [0.45, 0, 0.55, 1],
                repeat: Infinity,
              }}
            />
          </motion.span>
        ) : null}
      </AnimatePresence>
    </CollapsiblePrimitive.Trigger>
  )
}

export interface AiToolStatusProps extends React.ComponentProps<"span"> {
  /** Override the state inherited from AiTool. */
  status?: AiToolStatus
  /** Override the localized status label. */
  label?: string
}

function AiToolStatus({
  className,
  status: statusProp,
  label,
  ...props
}: AiToolStatusProps) {
  const context = React.useContext(AiToolContext)
  const reduceMotion = useReducedMotion()
  const status = statusProp ?? context.status
  const meta = statusMeta[status]
  const Icon = meta.icon
  const text = label ?? meta.label

  return (
    <span
      data-slot="ai-tool-status"
      data-status={status}
      className={cn(aiToolStatusVariants({ status }), className)}
      {...props}
    >
      <span className="relative flex size-3.5 items-center justify-center">
        <AnimatePresence initial={false} mode="popLayout">
          <motion.span
            key={status}
            className="flex items-center justify-center"
            initial={reduceMotion ? false : { opacity: 0, scale: 0.4 }}
            animate={{ opacity: 1, scale: 1 }}
            exit={reduceMotion ? undefined : { opacity: 0, scale: 0.4 }}
            transition={reduceMotion ? { duration: 0 } : statusSpring}
          >
            <Icon
              className={cn(
                "size-3.5",
                status === "running" && "motion-safe:animate-spin"
              )}
            />
          </motion.span>
        </AnimatePresence>
      </span>
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={text}
          initial={reduceMotion ? false : { opacity: 0, y: 5 }}
          animate={{ opacity: 1, y: 0 }}
          exit={reduceMotion ? undefined : { opacity: 0, y: -5 }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { duration: 0.22, ease: [0.22, 1, 0.36, 1] }
          }
        >
          {text}
        </motion.span>
      </AnimatePresence>
    </span>
  )
}

function AiToolContent({
  className,
  ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Content>) {
  return (
    <CollapsiblePrimitive.Content
      data-slot="ai-tool-content"
      className={cn(
        "overflow-hidden border-t text-sm duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down motion-reduce:animate-none",
        className
      )}
      {...props}
    />
  )
}

function AiToolSection({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-tool-section"
      className={cn("border-b px-3 py-3 last:border-b-0", className)}
      {...props}
    />
  )
}

function AiToolLabel({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="ai-tool-label"
      className={cn(
        "mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}

function AiToolCode({
  className,
  ...props
}: React.ComponentProps<"pre">) {
  return (
    <pre
      data-slot="ai-tool-code"
      className={cn(
        "overflow-x-auto rounded-md bg-muted/60 p-3 font-mono text-xs leading-5 text-foreground",
        className
      )}
      {...props}
    />
  )
}

function AiToolError({
  className,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      role="alert"
      data-slot="ai-tool-error"
      className={cn(
        "rounded-md border border-destructive-border bg-destructive-subtle px-3 py-2 text-sm text-destructive",
        className
      )}
      {...props}
    />
  )
}

export {
  AiTool,
  AiToolCode,
  AiToolContent,
  AiToolError,
  AiToolLabel,
  AiToolSection,
  AiToolStatus,
  AiToolTrigger,
  aiToolStatusVariants,
}

属性 Props

AiTool (根折叠容器)

属性类型默认值说明
status"pending" | "approval" | "running" | "success" | "error" | "denied""pending"当前工具调用生命周期状态。对应不同的状态图标与主题色。
defaultOpenboolean—默认是否展开工具详情(默认在 `error` 状态下自动展开,其他状态折叠)。
openboolean—受控模式下的展开状态。
onOpenChange(open: boolean) => void—工具折叠或展开状态改变时的回调函数。

AiToolTrigger (头部触发器)

属性类型默认值说明
namestring"工具调用"工具函数的标识名称(如 `execute_sql` 或 `fetch_weather`)。
childrenReact.ReactNode—完全自定义头部内容。默认自动渲染扳手图标、工具名称、状态徽标与折叠箭头。

AiToolStatus (状态徽章)

属性类型默认值说明
statusAiToolStatus—覆盖从上下文继承的工具状态。
labelstring—自定义展示的状态文案(如覆盖「执行中」为「正在下载依赖…」)。

AiToolCode (代码与 JSON 展示块)

属性类型默认值说明
classNamestring—应用于代码块容器的样式类名(自带等宽字体、背景与水平滚动)。

AiToolError (错误通知块)

属性类型默认值说明
classNamestring—应用于错误提示区域的样式类名(自带警示红底与 `role="alert"`)。

事件 Events

属性类型默认值说明
onOpenChange(open: boolean) => void—用户点击工具头部展开或收起参数与结果面板时触发。

使用场景与设计规范

AiTool 用于在智能体(Function Calling / MCP 架构)执行任务时向用户提供透明、可控的执行过程:

  • 何时使用:
    • 展示大模型调用外部 API、读取本地文件或执行 SQL 查询的过程;
    • 高危操作(如删除数据库记录、转账、轮换密钥)的人工介入审批(Human-in-the-loop);
    • 智能体多步链式排查流水线。
  • 何时不应使用:
    • 纯后台静默任务(如内部 Redis 读写),用户无需知晓具体工具名称;
    • 严禁直接向未授权终端用户暴露包含系统密钥、私有内网 IP 或敏感凭据的原始调用入参。
  • 设计最佳实践:
    • 错误自动展开,成功智能折叠:对于执行成功的工具调用,保持收起以节省屏幕高度;一旦发生失败,默认自动展开错误日志帮助用户定位原因;
    • 结构清晰:使用 AiToolSection 划分“调用入参”、“执行中”与“结果反馈”,配合 AiToolCode 格式化 JSON 数据;
    • 明确的确认交互:在 approval 状态下提供醒目的“批准”与“拒绝”按钮。

场景示例

人工确认与审批授权 (Human-in-the-Loop)

针对涉及修改生产配置或转账的高危工具调用,暂停执行并要求用户明确确认:

Loading…

链式工具调用流水线

展示智能体在排查系统故障时连续调用的多阶段工具流水线(查日志 -> 验连接池 -> EXPLAIN 分析):

Loading…

无障碍与交互 Accessibility

  • 状态播报与色彩无障碍:
    • 工具状态不仅通过颜色区分,同时辅以独特的语义图标(如勾选、旋转圆环、警示盾牌)与文案;
    • AiToolError 自动挂载 role="alert",确保错误信息即时通知辅助设备。
  • 键盘导航支持:
    • Tab:聚焦至工具触发器;
    • Enter / Space:展开/折叠参数面板;
    • 审批操作中的“批准”与“拒绝”按钮可通过键盘直接操作。
  • 动效降级:
    • 当系统开启 prefers-reduced-motion 时,running 状态下的旋转加载图标将自动停止旋转。