fix: resolve streaming content accumulation issue

- Fixed stale closure bug in streaming content by using useRef to properly accumulate content instead of relying on state closure
- Added auto-scrolling functionality to keep latest content visible during generation
- Implemented smooth scrolling behavior and improved HTML styling for better readability
- Added content buffer reset when starting new generation
- Enhanced documentation with detailed explanation of the closure problem and solution
This commit is contained in:
Ender 2025-10-25 21:39:40 +02:00
parent 38376ab632
commit 197fd69ce3
2 changed files with 206 additions and 2 deletions

188
apps/admin/STREAMING_FIX.md Normal file
View File

@ -0,0 +1,188 @@
# Streaming Content Accumulation Fix
## Problem
The streaming content was showing individual tokens instead of the accumulated HTML article because of a **stale closure** issue.
### What Was Happening
```typescript
// ❌ WRONG - Stale closure
onContent: (data) => {
onSetStreamingContent(streamingContent + data.delta);
// streamingContent is captured from the initial render
// It never updates in this callback!
}
```
**Result**: Each delta was added to the INITIAL empty string, not the accumulated content.
```
Delta 1: "" + "TypeScript" = "TypeScript"
Delta 2: "" + " is" = " is" // Lost "TypeScript"!
Delta 3: "" + " a" = " a" // Lost everything!
```
## Solution
Use a **ref** to track the accumulated content outside the closure:
```typescript
// ✅ CORRECT - Ref accumulation
const contentBufferRef = useRef<string>('');
onContent: (data) => {
contentBufferRef.current += data.delta;
onSetStreamingContent(contentBufferRef.current);
}
```
**Result**: Each delta is properly accumulated.
```
Delta 1: "" + "TypeScript" = "TypeScript"
Delta 2: "TypeScript" + " is" = "TypeScript is"
Delta 3: "TypeScript is" + " a" = "TypeScript is a"
```
## Additional Improvements
### 1. **Auto-Scroll**
```typescript
const streamingBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isGenerating && streamingContent && streamingBoxRef.current) {
streamingBoxRef.current.scrollTop = streamingBoxRef.current.scrollHeight;
}
}, [streamingContent, isGenerating]);
```
Automatically scrolls to show new content as it arrives.
### 2. **Smooth Scrolling**
```typescript
sx={{
scrollBehavior: 'smooth',
// ...
}}
```
Smooth animation when auto-scrolling.
### 3. **Better HTML Styling**
```typescript
'& h2, & h3': { mt: 2, mb: 1 },
'& p': { mb: 1 },
'& ul, & ol': { pl: 3, mb: 1 },
'& li': { mb: 0.5 },
```
Proper spacing for HTML elements.
## Technical Explanation
### Closure Problem
In JavaScript, when you create a callback function, it "closes over" the variables from its surrounding scope. Those variables are captured at the time the function is created.
```typescript
const [content, setContent] = useState('');
// This callback is created once when component mounts
const callback = () => {
console.log(content); // Always logs the INITIAL value!
};
```
### Why Refs Work
Refs are **mutable objects** that persist across renders:
```typescript
const ref = useRef('');
// This always reads the CURRENT value
const callback = () => {
console.log(ref.current); // Gets the latest value!
};
```
## Before vs After
### Before (Broken)
```
User sees:
"TypeScript"
" is"
" a"
"powerful"
...individual tokens, no accumulation
```
### After (Fixed)
```
User sees:
"TypeScript"
"TypeScript is"
"TypeScript is a"
"TypeScript is a powerful"
...proper accumulation, rendered as HTML
```
## Files Changed
```
✅ apps/admin/src/components/steps/StepGenerate.tsx
- Added contentBufferRef for accumulation
- Added streamingBoxRef for auto-scroll
- Added useEffect for auto-scroll
- Fixed onContent callback to use ref
- Added smooth scroll behavior
```
## Testing
1. Start generation
2. Watch the "Live Generation" box
3. Should see:
- ✅ Proper HTML rendering (headings, paragraphs, lists)
- ✅ Content accumulating (not individual tokens)
- ✅ Auto-scroll to bottom
- ✅ Smooth animations
- ✅ Pulsing blue border
## Common React Pitfalls
This is a classic React pitfall that catches many developers:
### ❌ Don't Do This
```typescript
const [value, setValue] = useState(0);
setInterval(() => {
setValue(value + 1); // Always adds to initial value!
}, 1000);
```
### ✅ Do This Instead
```typescript
const [value, setValue] = useState(0);
setInterval(() => {
setValue(prev => prev + 1); // Functional update
}, 1000);
// OR use a ref
const valueRef = useRef(0);
setInterval(() => {
valueRef.current += 1;
setValue(valueRef.current);
}, 1000);
```
## Conclusion
The fix ensures that streaming content properly accumulates and displays as formatted HTML, providing a smooth, professional user experience with auto-scrolling and proper rendering.
**Status**: ✅ Fixed and working!

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Box, Stack, TextField, Typography, Button, Alert, CircularProgress, FormControlLabel, Checkbox, Link, LinearProgress } from '@mui/material'; import { Box, Stack, TextField, Typography, Button, Alert, CircularProgress, FormControlLabel, Checkbox, Link, LinearProgress } from '@mui/material';
import SelectedImages from './SelectedImages'; import SelectedImages from './SelectedImages';
import CollapsibleSection from './CollapsibleSection'; import CollapsibleSection from './CollapsibleSection';
@ -54,6 +54,16 @@ export default function StepGenerate({
}) { }) {
const [useWebSearch, setUseWebSearch] = useState(false); const [useWebSearch, setUseWebSearch] = useState(false);
const [useStreaming, setUseStreaming] = useState(true); const [useStreaming, setUseStreaming] = useState(true);
const streamingBoxRef = useRef<HTMLDivElement>(null);
const contentBufferRef = useRef<string>('');
// Auto-scroll to bottom when streaming content updates
useEffect(() => {
if (isGenerating && streamingContent && streamingBoxRef.current) {
streamingBoxRef.current.scrollTop = streamingBoxRef.current.scrollHeight;
}
}, [streamingContent, isGenerating]);
return ( return (
<Box sx={{ display: 'grid', gap: 2 }}> <Box sx={{ display: 'grid', gap: 2 }}>
<StepHeader <StepHeader
@ -160,6 +170,7 @@ export default function StepGenerate({
onSetGenerationError(''); onSetGenerationError('');
onSetStreamingContent(''); onSetStreamingContent('');
onSetTokenCount(0); onSetTokenCount(0);
contentBufferRef.current = ''; // Reset buffer
try { try {
const transcriptions = postClips const transcriptions = postClips
@ -185,7 +196,9 @@ export default function StepGenerate({
console.log('Stream started:', data.requestId); console.log('Stream started:', data.requestId);
}, },
onContent: (data) => { onContent: (data) => {
onSetStreamingContent(streamingContent + data.delta); // Accumulate in ref to avoid stale closure
contentBufferRef.current += data.delta;
onSetStreamingContent(contentBufferRef.current);
onSetTokenCount(data.tokenCount); onSetTokenCount(data.tokenCount);
}, },
onDone: (data) => { onDone: (data) => {
@ -243,6 +256,7 @@ export default function StepGenerate({
{isGenerating && useStreaming && streamingContent && ( {isGenerating && useStreaming && streamingContent && (
<CollapsibleSection title="Live Generation" defaultCollapsed={false}> <CollapsibleSection title="Live Generation" defaultCollapsed={false}>
<Box <Box
ref={streamingBoxRef}
sx={{ sx={{
p: 2, p: 2,
border: '2px solid', border: '2px solid',
@ -251,9 +265,11 @@ export default function StepGenerate({
bgcolor: 'background.paper', bgcolor: 'background.paper',
maxHeight: '500px', maxHeight: '500px',
overflowY: 'auto', overflowY: 'auto',
scrollBehavior: 'smooth',
'& h2, & h3': { mt: 2, mb: 1 }, '& h2, & h3': { mt: 2, mb: 1 },
'& p': { mb: 1 }, '& p': { mb: 1 },
'& ul, & ol': { pl: 3, mb: 1 }, '& ul, & ol': { pl: 3, mb: 1 },
'& li': { mb: 0.5 },
animation: 'pulse 2s ease-in-out infinite', animation: 'pulse 2s ease-in-out infinite',
'@keyframes pulse': { '@keyframes pulse': {
'0%, 100%': { borderColor: 'primary.main' }, '0%, 100%': { borderColor: 'primary.main' },