import { useMemo, useRef, useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; /** * Streaming text with word-by-word reveal animation. * * Inspired by DeerFlow's Streamdown library: * - Splits streaming text into "words" at whitespace and CJK boundaries * - Each word gets a CSS fade-in animation * - Historical messages render statically (no animation overhead) * * For non-streaming content, falls back to react-markdown for full * markdown rendering including GFM tables, strikethrough, etc. */ interface StreamingTextProps { content: string; isStreaming: boolean; className?: string; /** Render as markdown for completed messages */ asMarkdown?: boolean; } // Split text into words at whitespace and CJK character boundaries function splitIntoTokens(text: string): string[] { const tokens: string[] = []; let current = ''; for (const char of text) { const code = char.codePointAt(0); const isCJK = code && ( (code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs (code >= 0x3400 && code <= 0x4DBF) || // CJK Extension A (code >= 0x3000 && code <= 0x303F) || // CJK Symbols and Punctuation (code >= 0xFF00 && code <= 0xFFEF) || // Fullwidth Forms (code >= 0x2E80 && code <= 0x2EFF) || // CJK Radicals Supplement (code >= 0xF900 && code <= 0xFAFF) // CJK Compatibility Ideographs ); const isWhitespace = /\s/.test(char); if (isCJK) { // CJK chars are individual tokens if (current) { tokens.push(current); current = ''; } tokens.push(char); } else if (isWhitespace) { current += char; } else { current += char; } } if (current) { tokens.push(current); } return tokens; } export function StreamingText({ content, isStreaming, className = '', asMarkdown = true, }: StreamingTextProps) { // For completed messages, use full markdown rendering if (!isStreaming && asMarkdown) { return (
{content}
); } // For streaming messages, use token-by-token animation if (isStreaming && content) { return ( ); } // Empty streaming - show nothing return null; } /** * Token-by-token streaming text with CSS animation. * Each token (word/CJK char) fades in sequentially. */ function StreamingTokenText({ content, className }: { content: string; className: string }) { const tokens = useMemo(() => splitIntoTokens(content), [content]); const containerRef = useRef(null); const [visibleCount, setVisibleCount] = useState(0); // Animate tokens appearing useEffect(() => { if (visibleCount >= tokens.length) return; const remaining = tokens.length - visibleCount; // Batch reveal: show multiple tokens per frame for fast streaming const batchSize = Math.min(remaining, 3); const timer = requestAnimationFrame(() => { setVisibleCount(prev => Math.min(prev + batchSize, tokens.length)); }); return () => cancelAnimationFrame(timer); }, [tokens.length, visibleCount]); // Reset visible count when content changes significantly useEffect(() => { setVisibleCount(tokens.length); }, [tokens.length]); return ( {tokens.map((token, i) => ( {token} ))} ); }