import React, { useState, useEffect, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Loader2, ChevronDown, ChevronUp, Terminal, SkipForward } from 'lucide-react'; interface StreamingPanelProps { visible: boolean; streamText: string; phase: string; done: boolean; onSkipThinking?: () => void; } export const StreamingPanel: React.FC = ({ visible, streamText, phase, done, onSkipThinking, }) => { const [collapsed, setCollapsed] = useState(false); const { t } = useTranslation(); const [skipRequested, setSkipRequested] = useState(false); const preRef = useRef(null); const scrollTimerRef = useRef | null>(null); // Only render the last chunk of text to avoid DOM/layout explosion const MAX_DISPLAY = 50_000; // ~50KB visible in the panel const displayText = useMemo(() => { if (streamText.length <= MAX_DISPLAY) return streamText; return '…(earlier output trimmed)…\n' + streamText.slice(-MAX_DISPLAY); }, [streamText]); // Throttled auto-scroll — fires at most every 100ms to avoid layout thrashing, // but guarantees scrolling during active streaming (unlike a debounce which // gets perpetually reset by fast rAF updates). useEffect(() => { if (collapsed) return; if (scrollTimerRef.current) return; // already scheduled — skip scrollTimerRef.current = setTimeout(() => { scrollTimerRef.current = null; if (preRef.current) preRef.current.scrollTop = preRef.current.scrollHeight; }, 100); }, [displayText, collapsed]); // Reset skip state when a new stream starts useEffect(() => { if (!done && streamText === '') { setSkipRequested(false); } }, [done, streamText]); if (!visible) return null; // Detect if model is currently inside a thinking block. // Only scan the tail of the text — we just need the last open/close tag. const isThinking = useMemo(() => { if (done || skipRequested) return false; // Check last 500 chars for unclosed thinking tags const tail = streamText.slice(-500); const lastThinkOpen = tail.lastIndexOf(''); const lastThinkClose = tail.lastIndexOf(''); const lastChannelOpen = tail.lastIndexOf('<|channel>thought'); const lastChannelClose = tail.lastIndexOf(''); return (lastThinkOpen > lastThinkClose) || (lastChannelOpen > lastChannelClose); }, [streamText, done, skipRequested]); const handleSkip = () => { setSkipRequested(true); onSkipThinking?.(); }; return (
{/* Header */}
{/* Skip Thinking button */} {isThinking && onSkipThinking && ( )} {skipRequested && !done && ( {t('lyric.skipping')} )}
{/* Content */} {!collapsed && (
          {displayText || (done ? t('lyric.noOutput') : t('lyric.waitingForLlm'))}
        
)}
); };