AI 推理过程 AI Reasoning
流式展示模型返回的 reasoning 文本,并在生成期间自动展开。
基础示例
普通回答与思考过程复用
流式文字不是推理容器的专属能力。下面的响应先在 AiReasoning 中流式输出思考过程,再使用同一个 AiStream 输出普通回答。
pnpm dlx wui@latest add @wui/ai-reasoning"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,也不会让整段正文持续闪烁。所有动效都会响应系统的“减少动态效果”设置。
不要用它暴露系统提示词、隐藏思维链、密钥、内部策略或模型未明确返回的推理内容。面向用户时优先展示简洁的“正在搜索”“正在读取文件”“正在形成方案”等过程摘要。
组件属性
| Prop | Type | Default | Description |
|---|---|---|---|
| isStreaming | boolean | false | Whether reasoning is still arriving. |
| duration | number | — | Completed reasoning time in seconds. |
| disabled | boolean | — | |
| asChild | boolean | — |
事件
组件透传 Radix Collapsible 的 open 与 onOpenChange。非受控模式下,isStreaming 变为 true 时自动展开,从 true 变为 false 时自动收起;受控模式由调用方决定展开策略。内容区域在流式阶段使用 aria-live 和 aria-busy 通知辅助技术。
扩展使用
接入流式响应
将接口返回的 reasoning delta 累加到字符串状态,并把累计字符串放进 AiReasoningStream。featherLength 控制末尾羽化范围,文本增长速度直接反映真实响应。可以通过 getLabel 自定义“正在思考”和完成用时文案。
Delta 分段淡入
如果业务保留了原始 delta 数组,可以将它传给 AiReasoningDeltas。组件只为新加入的片段执行一次轻微位移、模糊和淡入,既能看出内容正在到达,也不会重复动画已经稳定的文字。
与工具状态配合
搜索、读文件和数据库查询等离散动作应交给 AiTool,不要混进 reasoning 文本里伪装成模型思维。Reasoning 负责连续文本,Tool 负责结构化执行状态。