Files
hot-step-cpp-ROCm/ui/src/components/lyric-studio/StreamingPanel.tsx
T
2026-08-16 18:24:52 +07:00

124 lines
4.7 KiB
TypeScript

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<StreamingPanelProps> = ({
visible, streamText, phase, done, onSkipThinking,
}) => {
const [collapsed, setCollapsed] = useState(false);
const { t } = useTranslation();
const [skipRequested, setSkipRequested] = useState(false);
const preRef = useRef<HTMLPreElement>(null);
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | 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('<think>');
const lastThinkClose = tail.lastIndexOf('</think>');
const lastChannelOpen = tail.lastIndexOf('<|channel>thought');
const lastChannelClose = tail.lastIndexOf('<channel|>');
return (lastThinkOpen > lastThinkClose) || (lastChannelOpen > lastChannelClose);
}, [streamText, done, skipRequested]);
const handleSkip = () => {
setSkipRequested(true);
onSkipThinking?.();
};
return (
<div className="rounded-xl overflow-hidden transition-all bg-zinc-100/60 dark:bg-zinc-900/60 border border-zinc-200 dark:border-white/5">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5">
<button
onClick={() => setCollapsed(!collapsed)}
className="flex items-center gap-2 text-xs font-medium text-zinc-600 dark:text-zinc-400 hover:text-zinc-800 dark:text-zinc-200 transition-colors"
>
<Terminal className="w-3 h-3 text-pink-400" />
{t('lyric.llmOutput')}
{phase && (
<span className="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-pink-500 text-white">
{phase}
</span>
)}
{!done && (
<Loader2 className="w-3 h-3 animate-spin text-pink-400" />
)}
{collapsed ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />}
</button>
{/* Skip Thinking button */}
{isThinking && onSkipThinking && (
<button
onClick={handleSkip}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold bg-pink-500 text-white hover:bg-pink-600 transition-colors"
title="Stop the model's chain-of-thought and produce output immediately"
>
<SkipForward className="w-3 h-3" />
{t('lyric.skipThinking')}
</button>
)}
{skipRequested && !done && (
<span className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium text-pink-400">
<Loader2 className="w-3 h-3 animate-spin" />
{t('lyric.skipping')}
</span>
)}
</div>
{/* Content */}
{!collapsed && (
<pre
ref={preRef}
className="px-4 pb-3 text-xs leading-relaxed overflow-y-auto whitespace-pre-wrap break-words text-zinc-600 dark:text-zinc-400 scrollbar-hide"
style={{
maxHeight: '300px',
fontFamily: 'ui-monospace, "Cascadia Code", "Fira Code", Menlo, monospace',
}}
>
{displayText || (done ? t('lyric.noOutput') : t('lyric.waitingForLlm'))}
</pre>
)}
</div>
);
};