import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button, cn, useToast } from "@vertesia/ui/core"; import { MarkdownRenderer } from "@vertesia/ui/widgets"; import { Bot, CopyIcon } from "lucide-react"; import dayjs from "dayjs"; import { useUITranslation } from '../../../../i18n/index.js'; // PERFORMANCE: Unicode cursor character - rendered inline with text // This avoids expensive DOM manipulation with TreeWalker on every update // Using thin pipe for softer visual appearance const CURSOR_CHAR = "│"; export interface StreamingMessageProps { text: string; workstreamId?: string; /** Run ID used to resolve artifact references in chart specs */ artifactRunId?: string; /** Characters per second for reveal animation (default: 300) */ revealSpeed?: number; /** Whether streaming has completed (triggers fast catch-up) */ isComplete?: boolean; /** Timestamp when streaming started */ timestamp?: number | string; /** Additional className for the outer container */ className?: string; /** Additional className for the card wrapper */ cardClassName?: string; /** Additional className for the header section */ headerClassName?: string; /** Additional className for the content section */ contentClassName?: string; /** Additional className for the prose/markdown container */ proseClassName?: string; /** Additional className for the sender label */ senderClassName?: string; /** Additional className for the icon wrapper */ iconClassName?: string; } /** className overrides for StreamingMessage — subset of StreamingMessageProps containing only className props. */ export type StreamingMessageClassNames = Partial>; /** * Displays a streaming message with adaptive reveal effect. * Automatically adjusts speed to match the model's generation rate, * creating smooth continuous text flow without bursts and pauses. */ function StreamingMessageComponent({ text, workstreamId, artifactRunId, revealSpeed = 300, isComplete = false, timestamp, className, cardClassName, headerClassName, contentClassName, proseClassName, senderClassName, iconClassName, }: StreamingMessageProps) { const { t } = useUITranslation(); const [displayedLength, setDisplayedLength] = useState(0); const [throttledText, setThrottledText] = useState(""); const animationRef = useRef(null); const targetLengthRef = useRef(text.length); const displayedLengthRef = useRef(0); const startTime = useRef(timestamp || Date.now()); const textRef = useRef(text); // Keep latest text for interval callback // Track model's generation rate for adaptive speed using exponential moving average const lastTextLengthRef = useRef(0); const lastTextTimeRef = useRef(performance.now()); const modelRateRef = useRef(revealSpeed); // chars per second from model (EMA smoothed) const fractionalCharsRef = useRef(0); // sub-character accumulation for smooth reveal // Update model rate when new text arrives using EMA for smoother tracking useEffect(() => { const now = performance.now(); const newChars = text.length - lastTextLengthRef.current; const elapsed = now - lastTextTimeRef.current; // Only update rate if meaningful time has passed (avoid divide-by-tiny-number issues) if (newChars > 0 && elapsed > 30) { const instantRate = (newChars / elapsed) * 1000; // chars per second // Exponential moving average - 20% weight to new sample // This creates smoother rate transitions than simple averaging const alpha = 0.2; modelRateRef.current = modelRateRef.current * (1 - alpha) + instantRate * alpha; // Clamp to reasonable range modelRateRef.current = Math.max(50, Math.min(2000, modelRateRef.current)); } lastTextLengthRef.current = text.length; lastTextTimeRef.current = now; }, [text.length]); // Keep refs in sync targetLengthRef.current = text.length; textRef.current = text; const animate = useCallback(() => { let lastTime = performance.now(); const step = (currentTime: number) => { const elapsed = currentTime - lastTime; lastTime = currentTime; const buffer = targetLengthRef.current - displayedLengthRef.current; // Nothing to reveal - stop animation, it will restart when new text arrives if (buffer <= 0) { fractionalCharsRef.current = 0; animationRef.current = null; return; } // Smooth adaptive speed based on buffer size // Key insight: maintain a small buffer intentionally to smooth out arrival jitter let targetRate: number; if (isComplete) { // Streaming done - finish smoothly but not instant // Use 2x baseline or minimum 500 chars/sec for responsive feel targetRate = Math.max(500, modelRateRef.current * 2); } else if (buffer < 20) { // Nearly caught up - slow down slightly to maintain buffer // This prevents the "catch up then pause" jerkiness targetRate = modelRateRef.current * 0.85; } else if (buffer < 100) { // Healthy buffer - match model rate exactly targetRate = modelRateRef.current; } else if (buffer < 500) { // Buffer growing - gently speed up using continuous curve (1x to 1.5x) // Smoother than discrete steps const t = (buffer - 100) / 400; // 0 to 1 targetRate = modelRateRef.current * (1 + t * 0.5); } else { // Large buffer - cap at 2x (not 10x like before) // This prevents jarring "catch up" bursts targetRate = modelRateRef.current * 2; } // Calculate chars to reveal with fractional accumulation for sub-char precision // This allows smoother reveal even at low rates const charsFloat = (targetRate * elapsed / 1000) + fractionalCharsRef.current; const charsToAdd = Math.floor(charsFloat); fractionalCharsRef.current = charsFloat - charsToAdd; if (charsToAdd > 0) { displayedLengthRef.current = Math.min( displayedLengthRef.current + charsToAdd, targetLengthRef.current ); setDisplayedLength(displayedLengthRef.current); } // Continue animation loop animationRef.current = requestAnimationFrame(step); }; animationRef.current = requestAnimationFrame(step); }, [isComplete]); // Start/continue animation when text grows useEffect(() => { if (text.length > displayedLengthRef.current && !animationRef.current) { animate(); } return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } }; }, [text.length, animate]); // When streaming completes, ensure we finish displaying quickly useEffect(() => { if (isComplete && displayedLengthRef.current < text.length) { if (animationRef.current) { cancelAnimationFrame(animationRef.current); animationRef.current = null; } animate(); } }, [isComplete, text.length, animate]); // Throttle markdown updates for performance using a consistent interval // This avoids the issue where timeout-based throttling gets cancelled on every displayedLength change const throttleIntervalRef = useRef(null); const lastThrottledLengthRef = useRef(0); // Handle immediate updates when caught up or complete useEffect(() => { if (displayedLength >= targetLengthRef.current || isComplete) { setThrottledText(textRef.current.slice(0, displayedLength)); lastThrottledLengthRef.current = displayedLength; } }, [displayedLength, isComplete]); // Manage the throttle interval - starts when streaming, stops when caught up const isStreaming = displayedLength < text.length && !isComplete; useEffect(() => { if (!isStreaming) { // Not streaming - clear interval if (throttleIntervalRef.current) { clearInterval(throttleIntervalRef.current); throttleIntervalRef.current = null; } return; } // Start interval if not already running (~30fps markdown updates) if (!throttleIntervalRef.current) { throttleIntervalRef.current = window.setInterval(() => { const currentLength = displayedLengthRef.current; // Only update if there's new content to show if (currentLength > lastThrottledLengthRef.current) { setThrottledText(textRef.current.slice(0, currentLength)); lastThrottledLengthRef.current = currentLength; } }, 33); } return () => { if (throttleIntervalRef.current) { clearInterval(throttleIntervalRef.current); throttleIntervalRef.current = null; } }; }, [isStreaming]); const toast = useToast(); const formattedTime = useMemo(() => dayjs(startTime.current).format("HH:mm:ss"), [] ); const isTyping = displayedLength < text.length; // PERFORMANCE: Append cursor character directly to text instead of DOM manipulation // This eliminates expensive TreeWalker traversal on every update const displayTextWithCursor = useMemo(() => { const baseText = throttledText || text.slice(0, displayedLength); return isTyping ? baseText + CURSOR_CHAR : baseText; }, [throttledText, text, displayedLength, isTyping]); if (!text) return null; const copyToClipboard = () => { navigator.clipboard.writeText(text).then(() => { toast({ status: "success", title: t('agent.copiedToClipboard'), duration: 2000, }); }); }; return (
{/* Card wrapper matching MessageItem structure */}
{/* Compact header */}
{isTyping ? ( ) : ( )}
Agent {workstreamId && workstreamId !== "main" && ( • Task {workstreamId} )}
{formattedTime}
{/* Content - cursor character is appended directly to text (no DOM manipulation) */}
{displayTextWithCursor}
); } const StreamingMessage = React.memo(StreamingMessageComponent); export default StreamingMessage;