wui
组件

AI 流式文本 AI Stream

用于大语言模型打字流式输出与思考过程文字动效的高性能渲染组件,支持平滑边缘羽化与 Delta 分段增量过渡。

第三方依赖 · motion

基础用法

最经典的流式输出用法。文字随模型响应持续追加,并在最新生成的文本末端呈现细腻的渐变羽化效果:

Loading…

安装与引入

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

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

import * as React from "react"
import { motion, useReducedMotion } from "motion/react"

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

export interface AiStreamEdgeProps extends React.ComponentProps<"span"> {}

/** Renders the shared feather treatment used by streaming text edges. */
function AiStreamEdge({ className, style, ...props }: AiStreamEdgeProps) {
  return (
    <span
      data-slot="ai-stream-edge"
      className={cn("box-decoration-clone", className)}
      style={{
        backgroundImage:
          "linear-gradient(90deg, currentColor 0%, currentColor 42%, transparent 100%)",
        backgroundRepeat: "no-repeat",
        backgroundSize: "100% 100%",
        backgroundClip: "text",
        WebkitBackgroundClip: "text",
        WebkitTextFillColor: "transparent",
        ...style,
      }}
      {...props}
    />
  )
}

export interface AiStreamCaretProps extends React.ComponentProps<"span"> {}

/** A thin blinking caret appended to text that is still being generated. */
function AiStreamCaret({ className, ...props }: AiStreamCaretProps) {
  return (
    <span
      aria-hidden
      data-slot="ai-stream-caret"
      className={cn(
        "ml-0.5 inline-block h-[1.05em] w-[2px] translate-y-[0.18em] rounded-full bg-current align-baseline motion-safe:animate-caret-blink",
        className
      )}
      {...props}
    />
  )
}

export interface AiStreamProps extends Omit<
  React.ComponentProps<"div">,
  "children"
> {
  /** The complete text received so far. */
  children: string
  /** Whether more text is expected. @default false */
  isStreaming?: boolean
  /** Number of trailing characters covered by the live feather. @default 18 */
  featherLength?: number
  /** Show a blinking caret after the newest character while streaming. @default false */
  caret?: boolean
}

/**
 * Renders cumulative streamed text with a feathered right edge. When the
 * stream ends, the feather dissolves into solid text instead of snapping.
 */
function AiStream({
  className,
  children,
  isStreaming = false,
  featherLength = 18,
  caret = false,
  ...props
}: AiStreamProps) {
  const reduceMotion = useReducedMotion()
  const [settling, setSettling] = React.useState(false)
  const wasStreaming = React.useRef(isStreaming)

  React.useEffect(() => {
    if (isStreaming) setSettling(false)
    else if (wasStreaming.current && !reduceMotion) setSettling(true)
    wasStreaming.current = isStreaming
  }, [isStreaming, reduceMotion])

  const split = isStreaming || settling
  const tailLength = split
    ? Math.min(Math.max(featherLength, 0), children.length)
    : 0
  const stableText = tailLength > 0 ? children.slice(0, -tailLength) : children
  const liveEdge = tailLength > 0 ? children.slice(-tailLength) : ""

  return (
    <div
      data-slot="ai-stream"
      data-streaming={isStreaming ? "true" : "false"}
      aria-live={isStreaming ? "polite" : undefined}
      aria-busy={isStreaming}
      className={cn("whitespace-pre-wrap", className)}
      {...props}
    >
      {stableText}
      {liveEdge ? (
        <AiStreamEdge
          className="transition-[background-size] duration-500 ease-out"
          style={{ backgroundSize: settling ? "420% 100%" : "100% 100%" }}
          onTransitionEnd={() => setSettling(false)}
        >
          {liveEdge}
        </AiStreamEdge>
      ) : null}
      {caret && isStreaming ? <AiStreamCaret /> : null}
    </div>
  )
}

export interface AiStreamDeltasProps extends Omit<
  React.ComponentProps<"div">,
  "children"
> {
  /** Ordered delta segments. Append entries without rewriting previous ones. */
  deltas: readonly string[]
  /** Whether more deltas are expected. @default false */
  isStreaming?: boolean
  /** Show a blinking caret after the newest delta while streaming. @default false */
  caret?: boolean
}

/** Renders each incoming delta as a short, independent blur-to-sharp segment. */
function AiStreamDeltas({
  className,
  deltas,
  isStreaming = false,
  caret = false,
  ...props
}: AiStreamDeltasProps) {
  const reduceMotion = useReducedMotion()

  return (
    <div
      data-slot="ai-stream-deltas"
      data-streaming={isStreaming ? "true" : "false"}
      aria-live={isStreaming ? "polite" : undefined}
      aria-busy={isStreaming}
      className={cn("whitespace-pre-wrap", className)}
      {...props}
    >
      {deltas.map((delta, index) => (
        <motion.span
          key={index}
          data-slot="ai-stream-delta"
          initial={
            reduceMotion ? false : { opacity: 0, filter: "blur(4px)" }
          }
          animate={{ opacity: 1, filter: "blur(0px)" }}
          transition={
            reduceMotion
              ? { duration: 0 }
              : { duration: 0.45, ease: [0.22, 1, 0.36, 1] }
          }
        >
          {delta}
        </motion.span>
      ))}
      {caret && isStreaming ? <AiStreamCaret /> : null}
    </div>
  )
}

export { AiStream, AiStreamCaret, AiStreamDeltas, AiStreamEdge }

属性 Props

AiStream (累计文本流式组件)

AiStream 接收当前已收到的完整累计文本,性能极佳,适合绝大多数 SSE 流式场景:

属性类型默认值说明
childrenstring—当前已接收到的全部累计字符串内容。
isStreamingbooleanfalse当前是否仍处于流式接收中。为 `true` 时文本末尾呈现动态羽化效果。
featherLengthnumber18在流式期间末尾被施加渐变羽化过渡的字符数长度。流式结束时羽化会平滑溶解为实色文本。
caretbooleanfalse流式期间在最新字符后显示闪烁光标,结束后自动隐藏。
classNamestring—应用于外层文本容器的额外 CSS 类名(默认带 `whitespace-pre-wrap`)。

AiStreamDeltas (分段增量流式组件)

AiStreamDeltas 适用于保留了独立 Delta Token 数组的服务端分块场景:

属性类型默认值说明
deltasreadonly string[]—有序的增量字符串片段数组。后续仅需持续 push 新片段,不重绘旧片段。
isStreamingbooleanfalse是否正在等待后续 Delta 片段到达。
caretbooleanfalse流式期间在最新片段后显示闪烁光标。
classNamestring—应用于外层容器的额外类名。

AiStreamCaret (流式光标)

单独导出的闪烁光标,可放在自定义渲染(如 Markdown)末尾。系统开启“减少动态效果”时保持常亮。

属性类型默认值说明
classNamestring—覆盖光标的尺寸、颜色或间距。默认使用 `currentColor`。

AiStreamEdge (羽化边缘效果)

属性类型默认值说明
styleReact.CSSProperties—覆盖默认的渐变透明度与文字裁剪样式。

事件 Events

AiStream 与 AiStreamDeltas 为纯展示组件,不产生业务事件;流式生命周期完全由外部请求状态(isStreaming)控制。

使用场景与设计规范

AiStream 将流式文字动画与具体的会话容器解耦,可灵活嵌入在普通对话、思考折叠框或全屏代码预览中:

  • 何时使用:
    • 大语言模型流式输出正文内容;
    • 推理链 (AiReasoning) 内部的动态思考文字;
    • 代码生成或日志输出过程中的渐进式打字效果。
  • 两种模式的选型建议:
    • AiStream(累计模式,推荐):只需传入单一的 string。内部将已稳定的前段文本与末尾羽化边缘分离,DOM 节点极度精简,即使面对数万字的长文本也不会引起卡顿;
    • AiStreamDeltas(分段模式):当服务端返回的是成块的句子或 Markdown Token 时使用,每个新块会独立经历微位移与淡入。
  • 设计最佳实践:
    • 避免逐字高开销重绘:避免在每一个中文字符生成时触发整段文本的重解析;
    • 错误终止处理:若后端连接中断或抛错,业务层应立即将 isStreaming 设为 false,并就地展示错误通知。

场景示例

Delta 分段增量模式

适合按句子或段落返回的大颗粒度服务端增量输出,新片段进入时伴随柔和淡入:

Loading…

交互式流式控制器

实时测试流式羽化长度、速度调节与暂停/继续状态切换:

Loading…

无障碍与交互 Accessibility

  • 实时区域播报 (ARIA Live Regions):
    • 在 isStreaming=true 时,组件外层自动挂载 aria-live="polite" 与 aria-busy="true";
    • 屏幕阅读器会在用户停顿时平稳朗读新到达的内容,不会产生连续打断或噪音。
  • 文字选择与复制:
    • 渐变羽化通过 CSS background-clip: text 实现,不插入额外的破坏性 DOM 占位符,用户在流式过程中仍可自由选中文本并直接复制。
  • 减少动态效果 (Reduced Motion):
    • 内置 useReducedMotion() 监听;当用户启用系统的“减少动态效果”偏好时,AiStreamDeltas 会跳过位移与模糊动效,直接以完整透明度渲染新文本。