wui
组件

AI 推理过程 AI Reasoning

流式展示模型返回的 reasoning 文本,并在生成期间自动展开。

基础示例

Loading…

普通回答与思考过程复用

流式文字不是推理容器的专属能力。下面的响应先在 AiReasoning 中流式输出思考过程,再使用同一个 AiStream 输出普通回答。

Loading…
pnpm dlx wui@latest add @wui/ai-reasoning
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 {
  BrainCircuitIcon,
  CheckIcon,
  ChevronDownIcon,
  CircleIcon,
  LoaderCircleIcon,
} from "lucide-react"

import { cn } from "@/lib/utils"
import {
  AiStream,
  AiStreamDeltas,
  type AiStreamDeltasProps,
  type AiStreamProps,
} from "@/components/ui/ai-stream"

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

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

const aiReasoningStepVariants = cva("relative flex gap-3 pb-4 last:pb-0", {
  variants: {
    status: {
      pending: "text-muted-foreground opacity-65",
      active: "text-foreground",
      complete: "text-muted-foreground",
    },
  },
  defaultVariants: { status: "pending" },
})

export interface AiReasoningProps
  extends React.ComponentProps<typeof CollapsiblePrimitive.Root> {
  /** Whether reasoning is still arriving. @default false */
  isStreaming?: boolean
  /** Completed reasoning time in seconds. */
  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 controlled = open !== undefined
  const resolvedOpen = controlled ? open : internalOpen
  const previousStreaming = React.useRef(isStreaming)

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

  return (
    <AiReasoningContext.Provider
      value={{ duration, 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, isOpen, isStreaming } =
    React.useContext(AiReasoningContext)
  const reduceMotion = useReducedMotion()
  const label = getLabel
    ? getLabel(isStreaming, duration)
    : isStreaming
      ? "正在思考…"
      : duration
        ? `思考完成 · ${duration.toFixed(1)} 秒`
        : "查看思考过程"

  return (
    <CollapsiblePrimitive.Trigger
      data-slot="ai-reasoning-trigger"
      className={cn(
        "group flex items-center gap-2 rounded-md py-1 text-sm text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/35",
        className
      )}
      {...props}
    >
      {children ?? (
        <>
          <AnimatePresence initial={false} mode="wait">
            <motion.span
              key={isStreaming ? "streaming" : "complete"}
              className="flex size-4 items-center justify-center"
              initial={reduceMotion ? false : { opacity: 0, scale: 0.72 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={reduceMotion ? undefined : { opacity: 0, scale: 0.72 }}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { type: "spring", stiffness: 520, damping: 32 }
              }
            >
              {isStreaming ? (
                <LoaderCircleIcon className="size-4 motion-safe:animate-spin" />
              ) : (
                <BrainCircuitIcon className="size-4" />
              )}
            </motion.span>
          </AnimatePresence>
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              key={String(label)}
              initial={reduceMotion ? false : { opacity: 0, y: 3 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduceMotion ? undefined : { opacity: 0, y: -3 }}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { duration: 0.2, ease: [0.22, 1, 0.36, 1] }
              }
            >
              {label}
            </motion.span>
          </AnimatePresence>
          <motion.span
            className="flex size-3.5 items-center justify-center"
            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(
        "mt-2 overflow-hidden border-l pl-4 text-sm leading-6 text-muted-foreground data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-top-1 motion-reduce:animate-none",
        className
      )}
      {...props}
    >
      <div data-slot="ai-reasoning-content-inner" className="min-w-0">
        {children}
      </div>
    </CollapsiblePrimitive.Content>
  )
}

export interface AiReasoningStreamProps
  extends Omit<AiStreamProps, "isStreaming"> {}

/** Renders cumulative stream text with a soft live edge instead of a cursor. */
function AiReasoningStream({
  ...props
}: AiReasoningStreamProps) {
  const { isStreaming } = React.useContext(AiReasoningContext)

  return <AiStream data-slot="ai-reasoning-stream" isStreaming={isStreaming} {...props} />
}

export interface AiReasoningDeltasProps
  extends Omit<AiStreamDeltasProps, "isStreaming"> {}

/** Renders each incoming delta as a short, independent fade-in segment. */
function AiReasoningDeltas({
  ...props
}: AiReasoningDeltasProps) {
  const { isStreaming } = React.useContext(AiReasoningContext)

  return (
    <AiStreamDeltas
      data-slot="ai-reasoning-deltas"
      isStreaming={isStreaming}
      {...props}
    />
  )
}

export interface AiReasoningStepProps extends React.ComponentProps<"div"> {
  /** Progress state for this visible reasoning summary. @default "pending" */
  status?: AiReasoningStepStatus
  /** Short step label. */
  label?: React.ReactNode
  /** Optional supporting description. */
  description?: React.ReactNode
}

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

  return (
    <div
      data-slot="ai-reasoning-step"
      data-status={status}
      className={cn(aiReasoningStepVariants({ status }), className)}
      {...props}
    >
      <span className="relative z-10 mt-1 flex size-4 shrink-0 items-center justify-center rounded-full bg-background">
        <Icon
          className={cn(
            "size-3",
            status === "active" && "text-info motion-safe:animate-spin",
            status === "complete" && "text-success"
          )}
        />
      </span>
      <div className="min-w-0 flex-1">
        {label ? (
          <div data-slot="ai-reasoning-step-label" className="font-medium">
            {label}
          </div>
        ) : null}
        {description ? (
          <div
            data-slot="ai-reasoning-step-description"
            className="mt-0.5 text-xs leading-5"
          >
            {description}
          </div>
        ) : null}
        {children}
      </div>
    </div>
  )
}

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

组件作用

AiReasoning 用于呈现模型明确返回、允许展示的 reasoning 文本。业务层每收到一个流式文本片段,就把累计文本传给 AiReasoningStream;组件在 isStreaming 期间自动展开,文本最右侧以羽化边缘自然推进,结束后恢复完整文本并自动收起。

展开与收起只使用短距离位移和透明度过渡,不缩放正文。状态图标、标题与展开箭头分别做轻量衔接;羽化只作用于最新的一小段字符,不附加 blur,也不会让整段正文持续闪烁。所有动效都会响应系统的“减少动态效果”设置。

不要用它暴露系统提示词、隐藏思维链、密钥、内部策略或模型未明确返回的推理内容。面向用户时优先展示简洁的“正在搜索”“正在读取文件”“正在形成方案”等过程摘要。

组件属性

PropTypeDefaultDescription
isStreamingbooleanfalseWhether reasoning is still arriving.
durationnumberCompleted reasoning time in seconds.
disabledboolean
asChildboolean

事件

组件透传 Radix Collapsible 的 openonOpenChange。非受控模式下,isStreaming 变为 true 时自动展开,从 true 变为 false 时自动收起;受控模式由调用方决定展开策略。内容区域在流式阶段使用 aria-livearia-busy 通知辅助技术。

扩展使用

接入流式响应

将接口返回的 reasoning delta 累加到字符串状态,并把累计字符串放进 AiReasoningStreamfeatherLength 控制末尾羽化范围,文本增长速度直接反映真实响应。可以通过 getLabel 自定义“正在思考”和完成用时文案。

Delta 分段淡入

Loading…

如果业务保留了原始 delta 数组,可以将它传给 AiReasoningDeltas。组件只为新加入的片段执行一次轻微位移、模糊和淡入,既能看出内容正在到达,也不会重复动画已经稳定的文字。

与工具状态配合

搜索、读文件和数据库查询等离散动作应交给 AiTool,不要混进 reasoning 文本里伪装成模型思维。Reasoning 负责连续文本,Tool 负责结构化执行状态。