组件
AI 工具调用 AI Tool
用于展示智能体(Agent)工具调用的执行状态、参数入参、输出结果、错误堆栈与人工审批确认。
基础用法
包含执行中、成功与失败三种典型状态的可折叠工具调用卡片:
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"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" | 当前工具调用生命周期状态。对应不同的状态图标与主题色。 |
| defaultOpen | boolean | — | 默认是否展开工具详情(默认在 `error` 状态下自动展开,其他状态折叠)。 |
| open | boolean | — | 受控模式下的展开状态。 |
| onOpenChange | (open: boolean) => void | — | 工具折叠或展开状态改变时的回调函数。 |
AiToolTrigger (头部触发器)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| name | string | "工具调用" | 工具函数的标识名称(如 `execute_sql` 或 `fetch_weather`)。 |
| children | React.ReactNode | — | 完全自定义头部内容。默认自动渲染扳手图标、工具名称、状态徽标与折叠箭头。 |
AiToolStatus (状态徽章)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| status | AiToolStatus | — | 覆盖从上下文继承的工具状态。 |
| label | string | — | 自定义展示的状态文案(如覆盖「执行中」为「正在下载依赖…」)。 |
AiToolCode (代码与 JSON 展示块)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于代码块容器的样式类名(自带等宽字体、背景与水平滚动)。 |
AiToolError (错误通知块)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于错误提示区域的样式类名(自带警示红底与 `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状态下的旋转加载图标将自动停止旋转。
- 当系统开启