AI Streaming Response

PreviousNext

Display streaming AI responses with token-by-token animation.

Docs
hextauiui

Preview

Loading preview…
registry/new-york/blocks/ai/ai-streaming-response.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import AIMessage from "./ai-message";

const STREAMING_CONFIG = {
  TOKEN_DELAY: 30,
  PAUSE_AFTER_PERIOD: 200,
  PAUSE_AFTER_COMMA: 100,
  PAUSE_AFTER_PARAGRAPH: 300,
} as const;

interface AIStreamingResponseProps {
  content: string;
  onComplete?: () => void;
  autoStart?: boolean;
  className?: string;
}

function useTokens(content: string): string[] {
  const tokens = useMemo(() => {
    const tokenRegex = /(\S+|\s+)/g;
    return content.match(tokenRegex) || [];
  }, [content]);

  return tokens;
}

function useStreaming(
  tokens: string[],
  autoStart: boolean,
  onComplete?: () => void
) {
  const [displayedTokens, setDisplayedTokens] = useState<string[]>([]);
  const [isPaused, setIsPaused] = useState(!autoStart);
  const [isComplete, setIsComplete] = useState(false);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const currentIndexRef = useRef(0);

  useEffect(() => {
    if (tokens.length === 0) return;

    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
      timeoutRef.current = null;
    }

    const timeoutId = setTimeout(() => {
      setDisplayedTokens([]);
      setIsComplete(false);
      setIsPaused(!autoStart);
    }, 0);
    currentIndexRef.current = 0;

    return () => clearTimeout(timeoutId);
  }, [tokens, autoStart]);

  useEffect(() => {
    if (tokens.length === 0) return;
    if (isPaused || isComplete || currentIndexRef.current >= tokens.length) {
      return;
    }

    const streamNext = () => {
      if (currentIndexRef.current >= tokens.length) {
        setIsComplete(true);
        if (onComplete) {
          onComplete();
        }
        return;
      }

      const token = tokens[currentIndexRef.current];
      setDisplayedTokens((prev) => [...prev, token]);
      currentIndexRef.current += 1;

      const trimmedToken = token.trim();

      let delay = STREAMING_CONFIG.TOKEN_DELAY;

      if (
        trimmedToken.endsWith(".") ||
        trimmedToken.endsWith("!") ||
        trimmedToken.endsWith("?")
      ) {
        delay += STREAMING_CONFIG.PAUSE_AFTER_PERIOD;
      } else if (
        trimmedToken.endsWith(",") ||
        trimmedToken.endsWith(";") ||
        trimmedToken.endsWith(":")
      ) {
        delay += STREAMING_CONFIG.PAUSE_AFTER_COMMA;
      } else if (token.includes("\n\n")) {
        delay += STREAMING_CONFIG.PAUSE_AFTER_PARAGRAPH;
      }

      if (typeof window !== "undefined") {
        const prefersReducedMotion = window.matchMedia(
          "(prefers-reduced-motion: reduce)"
        ).matches;

        if (prefersReducedMotion) {
          const remainingTokens = tokens.slice(currentIndexRef.current);
          setDisplayedTokens((prev) => [...prev, ...remainingTokens]);
          currentIndexRef.current = tokens.length;
          setIsComplete(true);
          if (onComplete) {
            onComplete();
          }
          return;
        }
      }

      timeoutRef.current = setTimeout(streamNext, delay);
    };

    streamNext();

    return () => {
      if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);
        timeoutRef.current = null;
      }
    };
  }, [tokens, isPaused, isComplete, onComplete]);

  const pause = () => {
    setIsPaused(true);
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current);
    }
  };

  const resume = () => {
    if (isComplete) return;
    setIsPaused(false);
  };

  return {
    displayedText: displayedTokens.join(""),
    isPaused,
    isComplete,
  };
}

export default function AIStreamingResponse({
  content,
  onComplete,
  autoStart = true,
  className,
}: AIStreamingResponseProps) {
  const tokens = useTokens(content);
  const { displayedText, isPaused, isComplete } = useStreaming(
    tokens,
    autoStart,
    onComplete
  );

  return (
    <div className={cn("relative", className)}>
      <AIMessage
        className="shadow-none"
        content={displayedText}
        isStreaming={false}
        skipCodeHighlighting={!isComplete}
      />
      {!(isComplete || isPaused) && (
        <span
          aria-hidden="true"
          className="inline-block h-4 w-0.5 bg-foreground motion-safe:animate-pulse"
        />
      )}
    </div>
  );
}

Installation

npx shadcn@latest add @hextaui/ai-streaming-response

Usage

import { AiStreamingResponse } from "@/components/ui/ai-streaming-response"
<AiStreamingResponse />