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 (