wui
组件

AI 推理过程 AI Reasoning

用结构化的折叠步骤与连线展示大语言模型或智能体(Agent)的思考过程、决策链路与执行进度。

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

基础用法

包含用时统计、自动展开/收起以及多阶段思考步骤的基础推理容器:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/ai-reasoning
安装基础依赖与动效库
pnpm add radix-ui motion class-variance-authority lucide-react clsx tailwind-merge
复制组件源码到 components/ui/ai-reasoning.tsx
components/ui/ai-reasoning.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 {
  CheckIcon,
  ChevronDownIcon,
  CircleIcon,
  LoaderCircleIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"
import { TextShimmer } from "@/components/ui/text-shimmer"

type AiReasoningStepStatus = "pending" | "active" | "complete"

const easeOut = [0.22, 1, 0.36, 1] as const
const iconSpring = {
  type: "spring",
  stiffness: 520,
  damping: 30,
  mass: 0.6,
} as const

const AiReasoningContext = React.createContext<{
  duration?: number
  elapsed: number
  isOpen: boolean
  isStreaming: boolean
}>({ elapsed: 0, isOpen: false, isStreaming: false })

const aiReasoningStepVariants = cva(
  "relative flex gap-3 pb-4 transition-[color,opacity] duration-300 after:absolute after:left-[7.5px] after:top-5 after:bottom-0 after:w-px after:bg-border last:pb-0 last:after:hidden",
  {
    variants: {
      status: {
        pending: "text-muted-foreground opacity-65",
        active: "text-foreground",
        complete: "text-muted-foreground",
      },
    },
    defaultVariants: { status: "pending" },
  }
)

function formatSeconds(seconds: number) {
  return Number.isInteger(seconds) ? String(seconds) : seconds.toFixed(1)
}

export interface AiReasoningProps extends React.ComponentProps<
  typeof CollapsiblePrimitive.Root
> {
  /** Whether reasoning is still arriving. @default false */
  isStreaming?: boolean
  /**
   * Completed reasoning time in seconds. When omitted, the component measures
   * how long `isStreaming` stayed true and shows that instead.
   */
  duration?: number
}

/** A disclosure for model-provided reasoning summaries and progress steps. */
function AiReasoning({
  className,
  isStreaming = false,
  duration,
  open,
  defaultOpen = false,
  onOpenChange,
  children,
  ...props
}: AiReasoningProps) {
  const [internalOpen, setInternalOpen] = React.useState(
    defaultOpen || isStreaming
  )
  const [elapsed, setElapsed] = React.useState(0)
  const [measured, setMeasured] = React.useState<number>()
  const controlled = open !== undefined
  const resolvedOpen = controlled ? open : internalOpen
  const previousStreaming = React.useRef(isStreaming)
  const startedAt = React.useRef<number | null>(null)

  React.useEffect(() => {
    if (controlled) return
    if (isStreaming) setInternalOpen(true)
    if (previousStreaming.current && !isStreaming) setInternalOpen(false)
    previousStreaming.current = isStreaming
  }, [controlled, isStreaming])

  React.useEffect(() => {
    if (!isStreaming) {
      if (startedAt.current !== null) {
        setMeasured(
          Math.round((performance.now() - startedAt.current) / 100) / 10
        )
        startedAt.current = null
      }
      return
    }

    const start = performance.now()
    startedAt.current = start
    setElapsed(0)
    const timer = window.setInterval(() => {
      setElapsed(Math.floor((performance.now() - start) / 1000))
    }, 250)
    return () => window.clearInterval(timer)
  }, [isStreaming])

  return (
    <AiReasoningContext.Provider
      value={{
        duration: duration ?? measured,
        elapsed,
        isOpen: resolvedOpen,
        isStreaming,
      }}
    >
      <CollapsiblePrimitive.Root
        asChild
        open={resolvedOpen}
        onOpenChange={(next) => {
          if (!controlled) setInternalOpen(next)
          onOpenChange?.(next)
        }}
        {...props}
      >
        <div
          data-slot="ai-reasoning"
          data-streaming={isStreaming ? "true" : "false"}
          className={cn("text-sm", className)}
        >
          {children}
        </div>
      </CollapsiblePrimitive.Root>
    </AiReasoningContext.Provider>
  )
}

export interface AiReasoningTriggerProps extends React.ComponentProps<
  typeof CollapsiblePrimitive.Trigger
> {
  /** Customize the trigger summary. */
  getLabel?: (isStreaming: boolean, duration?: number) => React.ReactNode
}

function AiReasoningTrigger({
  className,
  children,
  getLabel,
  ...props
}: AiReasoningTriggerProps) {
  const { duration, elapsed, isOpen, isStreaming } =
    React.useContext(AiReasoningContext)
  const reduceMotion = useReducedMotion()
  const label = getLabel ? (
    getLabel(isStreaming, duration)
  ) : isStreaming ? (
    <span className="inline-flex items-baseline gap-1.5">
      <TextShimmer duration={1.6}>正在思考</TextShimmer>
      {elapsed > 0 ? (
        <span className="text-xs tabular-nums text-muted-foreground/70">
          {elapsed}s
        </span>
      ) : null}
    </span>
  ) : duration ? (
    `思考了 ${formatSeconds(duration)} 秒`
  ) : (
    "查看思考过程"
  )

  return (
    <CollapsiblePrimitive.Trigger
      data-slot="ai-reasoning-trigger"
      className={cn(
        "text-muted-foreground hover:text-foreground focus-visible:ring-ring/35 group relative flex items-center gap-1.5 rounded-md py-1 text-sm outline-none transition-colors focus-visible:ring-[3px]",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              key={isStreaming ? "streaming" : "settled"}
              className="inline-flex"
              initial={
                reduceMotion ? false : { opacity: 0, y: 4, filter: "blur(2px)" }
              }
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={
                reduceMotion
                  ? undefined
                  : { opacity: 0, y: -4, filter: "blur(2px)" }
              }
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { duration: 0.24, ease: easeOut }
              }
            >
              {label}
            </motion.span>
          </AnimatePresence>
          <motion.span
            className="flex size-3.5 items-center justify-center"
            initial={false}
            animate={{ rotate: isOpen ? 180 : 0 }}
            transition={
              reduceMotion
                ? { duration: 0 }
                : { type: "spring", stiffness: 480, damping: 34 }
            }
          >
            <ChevronDownIcon className="size-3.5" />
          </motion.span>
        </>
      )}
    </CollapsiblePrimitive.Trigger>
  )
}

function AiReasoningContent({
  className,
  children,
  ...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Content>) {
  return (
    <CollapsiblePrimitive.Content
      data-slot="ai-reasoning-content"
      className={cn(
        "text-muted-foreground overflow-hidden text-sm leading-6 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}
    >
      <div data-slot="ai-reasoning-content-inner" className="min-w-0 pt-2">
        {children}
      </div>
    </CollapsiblePrimitive.Content>
  )
}

export interface AiReasoningStepProps extends Omit<
  React.ComponentProps<typeof motion.div>,
  "children"
> {
  /** Progress state for this visible reasoning summary. @default "pending" */
  status?: AiReasoningStepStatus
  /** Short step label. Plain-text labels shimmer while the step is active. */
  label?: React.ReactNode
  /** Optional supporting description. */
  description?: React.ReactNode
  /** Optional icon used for this kind of reasoning activity. */
  icon?: React.ReactNode
  /** Optional metadata aligned to the end of the step. */
  meta?: React.ReactNode
  /** Arbitrary React content rendered in the step body. */
  children?: React.ReactNode
}

function AiReasoningStep({
  className,
  status = "pending",
  label,
  description,
  icon,
  meta,
  children,
  ...props
}: AiReasoningStepProps) {
  const reduceMotion = useReducedMotion()
  const Icon =
    status === "complete"
      ? CheckIcon
      : status === "active"
        ? LoaderCircleIcon
        : CircleIcon

  return (
    <motion.div
      data-slot="ai-reasoning-step"
      data-status={status}
      className={cn(aiReasoningStepVariants({ status }), className)}
      initial={reduceMotion ? false : { opacity: 0, y: 4, filter: "blur(2px)" }}
      animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
      transition={
        reduceMotion ? { duration: 0 } : { duration: 0.32, ease: easeOut }
      }
      {...props}
    >
      <span className="bg-background relative z-10 mt-1 flex size-4 shrink-0 items-center justify-center">
        {icon ?? (
          <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 } : iconSpring}
            >
              <Icon
                className={cn(
                  "size-3",
                  status === "active" && "text-info motion-safe:animate-spin",
                  status === "complete" && "text-success"
                )}
              />
            </motion.span>
          </AnimatePresence>
        )}
      </span>
      <div className="min-w-0 flex-1">
        {label || meta ? (
          <div className="flex min-w-0 items-start justify-between gap-4">
            {label ? (
              <div
                data-slot="ai-reasoning-step-label"
                className="min-w-0 font-medium"
              >
                {status === "active" && typeof label === "string" ? (
                  <TextShimmer duration={1.8}>{label}</TextShimmer>
                ) : (
                  label
                )}
              </div>
            ) : null}
            {meta ? (
              <div
                data-slot="ai-reasoning-step-meta"
                className="text-muted-foreground shrink-0 text-xs tabular-nums"
              >
                {meta}
              </div>
            ) : null}
          </div>
        ) : null}
        {description ? (
          <div
            data-slot="ai-reasoning-step-description"
            className="mt-0.5 text-xs leading-5"
          >
            {description}
          </div>
        ) : null}
        {children ? (
          <div data-slot="ai-reasoning-step-content" className="mt-1 min-w-0">
            {children}
          </div>
        ) : null}
      </div>
    </motion.div>
  )
}

export {
  AiReasoning,
  AiReasoningContent,
  AiReasoningStep,
  AiReasoningTrigger,
  aiReasoningStepVariants,
}

属性 Props

AiReasoning (根折叠容器)

底层基于 Radix Collapsible 封装,并结合流式生命周期自动控制折叠状态:

属性类型默认值说明
isStreamingbooleanfalse思考过程是否仍在持续生成。非受控模式下,进入流式时自动展开,流式结束时自动收起。
durationnumber—思考耗费的总时间(秒),将格式化显示在默认触发器文案中(如「思考了 3.2 秒」)。省略时组件会自动计量 `isStreaming` 持续的时长。
openboolean—受控模式下的展开状态。
defaultOpenbooleanfalse非受控模式下的初始展开状态。
onOpenChange(open: boolean) => void—折叠或展开状态发生改变时的回调函数。

AiReasoningTrigger (折叠触发器)

属性类型默认值说明
getLabel(isStreaming: boolean, duration?: number) => React.ReactNode—自定义触发器的文本摘要生成函数,接收当前流式状态与耗时参数。
childrenReact.ReactNode—完全自定义触发按钮内容。默认在流式期间显示带光泽扫过的「正在思考」与实时秒数,结束后切换为「思考了 X 秒」。

AiReasoningStep (单项思考步骤)

用于在思考链路中渲染带有时间连线的独立步骤:

属性类型默认值说明
status"pending" | "active" | "complete""pending"步骤当前状态。`active` 会展示旋转加载器,`complete` 展示绿色勾选,`pending` 为半透明圆点。
labelReact.ReactNode—步骤的核心标题文本。纯文本标题在 `active` 状态下带有光泽扫过效果。
descriptionReact.ReactNode—步骤的辅助详情或中间结论说明。
metaReact.ReactNode—右对齐的元信息(如耗时 `120ms` 或文件数 `3 files`)。
iconReact.ReactNode—覆盖默认状态图标的自定义专属动作图标。

事件 Events

属性类型默认值说明
onOpenChange(open: boolean) => void—用户点击触发器展开或收起思考链详情时触发。

使用场景与设计规范

AiReasoning 适用于 OpenAI o1/o3、DeepSeek R1 等具备原生思考链(CoT)能力的大模型,或多智能体协作推演场景:

  • 何时使用:
    • 展示深度推理模型的思考过程摘要;
    • 呈现 Agent 多阶段任务规划与推演步骤(规划 -> 检索 -> 代码验证 -> 总结);
    • 让用户直观理解模型得出复杂结论背后的依据与逻辑。
  • 何时不应使用:
    • 不要用于直接暴露包含系统 Prompt 密钥、敏感内部策略或内部私有数据的原始日志;
    • 简单的单步回答无需额外增加思考折叠框。
  • 设计最佳实践:
    • 默认智能折叠:思考生成中保持展开,生成完毕后自动折叠,避免冗长的推演文字占据过大垂直屏宽;
    • 步骤连线与状态明确:利用步骤间的竖直连线表达先后时序,未执行步骤淡化,当前步骤高亮旋转;
    • 精简易读:对推理内容进行关键步骤提炼,配合 meta 标注耗时,避免大段无结构纯文本倾泻。

场景示例

综合步骤与工具调用组合

在思考步骤内嵌入 Badge 状态、代码片段与下游工具调用:

Loading…

实时流式推进演练

模拟多阶段智能体在进行复杂系统设计时的思考推演与状态实时流转。示例未传入 duration,触发器会自动计量并展示思考耗时:

Loading…

无障碍与交互 Accessibility

  • 折叠控件标准 (Radix Collapsible):
    • 触发器自带 aria-expanded 与 aria-controls 属性,屏幕阅读器可清晰感知内容展开状态;
    • 箭头图标根据展开状态平滑旋转 180 度。
  • 键盘导航支持:
    • Tab:聚焦至推理触发器按钮,展示高对比度聚焦环;
    • Space / Enter:快速切换思考链面板的展开与折叠。
  • 动效降级:
    • 步骤进场淡入与折叠展开高度过渡均适配 prefers-reduced-motion,开启后直接无动效瞬时呈现。