mirror of https://github.com/buster-so/buster.git
Merge branch 'staging' into cursor/migrate-state-to-cookie-storage-c48a
This commit is contained in:
commit
21cccddd3b
|
@ -1,8 +1,8 @@
|
|||
import { useStreamTokenArray } from '@llm-ui/react';
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs';
|
||||
import type React from 'react';
|
||||
import type { MarkdownAnimation } from '../../typography/animation-common';
|
||||
import AppMarkdownStreaming from './AppMarkdownStreaming';
|
||||
import { useStreamTokenArray } from './useStreamTokenArray';
|
||||
|
||||
const meta: Meta<typeof AppMarkdownStreaming> = {
|
||||
title: 'UI/Typography/AppMarkdownStreaming',
|
||||
|
@ -192,10 +192,10 @@ const actualTokenArray = [
|
|||
];
|
||||
|
||||
const StreamingDemo: React.FC<{ animation: MarkdownAnimation }> = ({ animation }) => {
|
||||
const { isStreamFinished, output } = useStreamTokenArray([
|
||||
...actualTokenArray,
|
||||
...redRisingPoemTokenArray
|
||||
]);
|
||||
const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
|
||||
tokens: [...actualTokenArray, ...redRisingPoemTokenArray],
|
||||
isStreamFinished: false
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full space-y-4 space-x-4">
|
||||
|
@ -401,7 +401,10 @@ const complexMarkdownTokenArray = [
|
|||
|
||||
export const ComplexStream: Story = {
|
||||
render: () => {
|
||||
const { isStreamFinished, output } = useStreamTokenArray(complexMarkdownTokenArray);
|
||||
const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
|
||||
tokens: complexMarkdownTokenArray,
|
||||
isStreamFinished: false
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex w-full space-y-4 space-x-4">
|
||||
|
@ -470,7 +473,10 @@ const paragraphToListTransitionTokens = [
|
|||
|
||||
export const ParagraphToListTransition: Story = {
|
||||
render: () => {
|
||||
const { isStreamFinished, output } = useStreamTokenArray(paragraphToListTransitionTokens);
|
||||
const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
|
||||
tokens: paragraphToListTransitionTokens,
|
||||
isStreamFinished: false
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
|
@ -712,7 +718,10 @@ const brokenInlineCodeTokens = [
|
|||
|
||||
export const InlineCodeStreaming: Story = {
|
||||
render: () => {
|
||||
const { isStreamFinished, output } = useStreamTokenArray(brokenInlineCodeTokens);
|
||||
const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
|
||||
tokens: brokenInlineCodeTokens,
|
||||
isStreamFinished: false
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-4">
|
||||
|
|
|
@ -1 +1,9 @@
|
|||
export { default as AppMarkdownStreaming } from './AppMarkdownStreaming';
|
||||
export { useLLMStreaming } from './useLLMStreaming';
|
||||
export { useStreamTokenArray } from './useStreamTokenArray';
|
||||
export type { UseLLMStreamingProps, UseLLMStreamingReturn } from './useLLMStreaming';
|
||||
export type {
|
||||
StreamToken,
|
||||
UseStreamTokenArrayProps,
|
||||
UseStreamTokenArrayReturn
|
||||
} from './useStreamTokenArray';
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import * as React from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs';
|
||||
import { useStreamTokenArray } from '@llm-ui/react';
|
||||
import { useLLMStreaming } from './useLLMStreaming';
|
||||
import { useStreamTokenArray } from './useStreamTokenArray';
|
||||
|
||||
// Simple token stream to exercise throttling + read-ahead
|
||||
const demoTokens: Array<{ token: string; delayMs: number }> = [
|
||||
|
@ -60,7 +60,10 @@ type HookHarnessProps = {
|
|||
|
||||
const HookHarness: React.FC<HookHarnessProps> = (args) => {
|
||||
// Simulate incoming streamed content
|
||||
const { output, isStreamFinished } = useStreamTokenArray(demoTokens);
|
||||
const { throttledContent: output, isDone: isStreamFinished } = useStreamTokenArray({
|
||||
tokens: demoTokens,
|
||||
isStreamFinished: false
|
||||
});
|
||||
|
||||
// Apply the throttling + read-ahead hook
|
||||
const { throttledContent, isDone, flushNow, reset } = useLLMStreaming({
|
||||
|
|
|
@ -0,0 +1,295 @@
|
|||
import type { Meta, StoryObj } from '@storybook/nextjs';
|
||||
import * as React from 'react';
|
||||
import { useStreamTokenArray } from './useStreamTokenArray';
|
||||
|
||||
/**
|
||||
* Demo component that showcases the useStreamTokenArray hook
|
||||
*/
|
||||
const StreamTokenArrayDemo = ({
|
||||
tokens,
|
||||
isStreamFinished,
|
||||
...hookProps
|
||||
}: {
|
||||
tokens: string[];
|
||||
isStreamFinished: boolean;
|
||||
targetBufferTokens?: number;
|
||||
minChunkTokens?: number;
|
||||
maxChunkTokens?: number;
|
||||
frameLookBackMs?: number;
|
||||
adjustPercentage?: number;
|
||||
flushImmediatelyOnComplete?: boolean;
|
||||
tokenSeparator?: string;
|
||||
}) => {
|
||||
const [currentTokens, setCurrentTokens] = React.useState<string[]>([]);
|
||||
const [finished, setFinished] = React.useState(false);
|
||||
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const autoStream = true;
|
||||
const streamDelay = 300;
|
||||
|
||||
// Auto-streaming simulation
|
||||
React.useEffect(() => {
|
||||
if (!autoStream) return;
|
||||
|
||||
setCurrentTokens([]);
|
||||
setFinished(false);
|
||||
let index = 0;
|
||||
|
||||
const streamNextToken = () => {
|
||||
if (index < tokens.length) {
|
||||
setCurrentTokens((prev) => [...prev, tokens[index]]);
|
||||
index++;
|
||||
timeoutRef.current = setTimeout(streamNextToken, streamDelay);
|
||||
} else {
|
||||
setFinished(true);
|
||||
}
|
||||
};
|
||||
|
||||
timeoutRef.current = setTimeout(streamNextToken, streamDelay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [tokens, autoStream, streamDelay]);
|
||||
|
||||
const { throttledTokens, throttledContent, isDone, flushNow, reset } = useStreamTokenArray({
|
||||
tokens: autoStream ? currentTokens : tokens,
|
||||
isStreamFinished: autoStream ? finished : isStreamFinished,
|
||||
...hookProps
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6 p-6">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<h3 className="mb-4 text-lg font-semibold">Stream Token Array Demo</h3>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
onClick={flushNow}
|
||||
className="rounded bg-blue-500 px-3 py-1 text-sm text-white hover:bg-blue-600">
|
||||
Flush Now
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
reset();
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setCurrentTokens([]);
|
||||
setFinished(false);
|
||||
}}
|
||||
className="rounded bg-gray-500 px-3 py-1 text-sm text-white hover:bg-gray-600">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Total Tokens:</span>{' '}
|
||||
{autoStream ? currentTokens.length : tokens.length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Visible Tokens:</span> {throttledTokens.length}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Stream Finished:</span>{' '}
|
||||
{autoStream ? (finished ? 'Yes' : 'No') : isStreamFinished ? 'Yes' : 'No'}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Is Done:</span> {isDone ? 'Yes' : 'No'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex justify-between text-sm text-gray-600">
|
||||
<span>Progress</span>
|
||||
<span>
|
||||
{throttledTokens.length} / {autoStream ? currentTokens.length : tokens.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-gray-200">
|
||||
<div
|
||||
className="h-2 rounded-full bg-blue-500 transition-all duration-200"
|
||||
style={{
|
||||
width: `${(throttledTokens.length / Math.max(1, autoStream ? currentTokens.length : tokens.length)) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Token Array Visualization */}
|
||||
<div className="mb-4">
|
||||
<h4 className="mb-2 font-medium">Token Array:</h4>
|
||||
<div className="flex flex-wrap gap-1 rounded border bg-gray-50 p-3">
|
||||
{(autoStream ? currentTokens : tokens).map((token, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={`rounded border px-2 py-1 text-sm ${
|
||||
index < throttledTokens.length
|
||||
? 'border-green-300 bg-green-100 text-green-800'
|
||||
: 'border-gray-300 bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{token}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Output */}
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium">Rendered Content:</h4>
|
||||
<div className="min-h-[100px] rounded border bg-gray-50 p-4 whitespace-pre-wrap">
|
||||
{throttledContent || <span className="text-gray-400 italic">No content yet...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof StreamTokenArrayDemo> = {
|
||||
title: 'UI/streaming/useStreamTokenArray',
|
||||
component: StreamTokenArrayDemo,
|
||||
parameters: {
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component: `
|
||||
The \`useStreamTokenArray\` hook provides progressive revelation of token arrays with configurable pacing.
|
||||
Unlike character-based streaming, this hook works with discrete tokens (words, sentences, etc.) and reveals
|
||||
them in chunks with smooth animation frame-based timing.
|
||||
|
||||
## Features
|
||||
|
||||
- **Token-based streaming**: Works with arrays of discrete tokens
|
||||
- **Configurable pacing**: Control timing, chunk sizes, and buffering
|
||||
- **Dual output**: Returns both token array and joined string
|
||||
- **Performance optimized**: Uses RAF loop and refs to minimize re-renders
|
||||
- **Auto-flush**: Optionally flush remaining tokens when stream finishes
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Word-by-word text revelation
|
||||
- Sentence-by-sentence content display
|
||||
- Progressive list building
|
||||
- Chat message token streaming
|
||||
- Any scenario requiring discrete unit streaming
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof StreamTokenArrayDemo>;
|
||||
|
||||
/**
|
||||
* Basic word-by-word streaming example with default settings.
|
||||
*/
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
tokens: [
|
||||
'Hello',
|
||||
'world!',
|
||||
'This',
|
||||
'is',
|
||||
'a',
|
||||
'demonstration',
|
||||
'of',
|
||||
'the',
|
||||
'useStreamTokenArray',
|
||||
'hook',
|
||||
'streaming',
|
||||
'tokens',
|
||||
'progressively.',
|
||||
'Each',
|
||||
'word',
|
||||
'appears',
|
||||
'with',
|
||||
'controlled',
|
||||
'timing',
|
||||
'and',
|
||||
'pacing.'
|
||||
],
|
||||
isStreamFinished: false
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fast streaming with small chunks for rapid token revelation.
|
||||
*/
|
||||
export const FastStreaming: Story = {
|
||||
args: {
|
||||
tokens: [
|
||||
'Quick',
|
||||
'fast',
|
||||
'streaming',
|
||||
'with',
|
||||
'minimal',
|
||||
'delay',
|
||||
'between',
|
||||
'tokens',
|
||||
'for',
|
||||
'a',
|
||||
'more',
|
||||
'rapid',
|
||||
'revelation',
|
||||
'effect.'
|
||||
],
|
||||
isStreamFinished: false,
|
||||
targetBufferTokens: 1,
|
||||
minChunkTokens: 1,
|
||||
maxChunkTokens: 2,
|
||||
frameLookBackMs: 50
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sentence-by-sentence streaming with periods as separators.
|
||||
*/
|
||||
export const SentenceStreaming: Story = {
|
||||
args: {
|
||||
tokens: [
|
||||
'This is the first sentence.',
|
||||
'Here comes the second sentence with more content.',
|
||||
'The third sentence demonstrates longer text streaming.',
|
||||
'Finally, this is the last sentence in our example.'
|
||||
],
|
||||
isStreamFinished: false,
|
||||
targetBufferTokens: 0,
|
||||
minChunkTokens: 1,
|
||||
maxChunkTokens: 1,
|
||||
frameLookBackMs: 150,
|
||||
tokenSeparator: '\n\n'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Manual control without auto-streaming for testing.
|
||||
*/
|
||||
export const ManualControl: Story = {
|
||||
args: {
|
||||
tokens: [
|
||||
'This',
|
||||
'example',
|
||||
'does',
|
||||
'not',
|
||||
'auto-stream.',
|
||||
'Use',
|
||||
'the',
|
||||
'controls',
|
||||
'to',
|
||||
'test',
|
||||
'flush',
|
||||
'and',
|
||||
'reset',
|
||||
'functionality.'
|
||||
],
|
||||
isStreamFinished: true
|
||||
}
|
||||
};
|
|
@ -0,0 +1,303 @@
|
|||
import * as React from 'react';
|
||||
|
||||
/**
|
||||
* Configuration for a single token in the stream.
|
||||
*/
|
||||
export type StreamToken = {
|
||||
/** The text content of this token */
|
||||
token: string;
|
||||
/** Delay in milliseconds before this token is emitted */
|
||||
delayMs: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for {@link useStreamTokenArray}.
|
||||
*
|
||||
* Supply the full array of `tokens` as it grows over time. The hook will emit
|
||||
* a progressively revealed subset to create a smooth streaming effect with
|
||||
* configurable pacing and token grouping.
|
||||
*/
|
||||
export type UseStreamTokenArrayProps = {
|
||||
/**
|
||||
* Full upstream token array accumulated so far. Provide the entire array as it
|
||||
* grows; the hook determines what subset to show.
|
||||
*/
|
||||
tokens: StreamToken[];
|
||||
/** Whether upstream stream has ended. Drives final flushing behavior. */
|
||||
isStreamFinished: boolean;
|
||||
/**
|
||||
* Number of tokens to keep buffered beyond the emitted tokens before
|
||||
* flushing. Higher values increase perceived smoothness at the cost of
|
||||
* latency.
|
||||
* @defaultValue 3
|
||||
*/
|
||||
targetBufferTokens?: number;
|
||||
/**
|
||||
* Minimum tokens to emit per frame.
|
||||
* @defaultValue 1
|
||||
*/
|
||||
minChunkTokens?: number;
|
||||
/**
|
||||
* Maximum tokens to emit per frame.
|
||||
* @defaultValue 5
|
||||
*/
|
||||
maxChunkTokens?: number;
|
||||
/**
|
||||
* Preferred frame pacing in milliseconds used to gate re-renders (100ms ≈ 10fps).
|
||||
* @defaultValue 100
|
||||
*/
|
||||
frameLookBackMs?: number;
|
||||
/**
|
||||
* Percentage in [0..1] used to scale chunk size based on recent token growth.
|
||||
* @defaultValue 0.5
|
||||
*/
|
||||
adjustPercentage?: number;
|
||||
/**
|
||||
* If true, do not delay beyond the configured gating once the stream has
|
||||
* finished; flush the remainder immediately.
|
||||
* @defaultValue true
|
||||
*/
|
||||
flushImmediatelyOnComplete?: boolean;
|
||||
/**
|
||||
* Custom separator to join tokens when returning as string.
|
||||
* @defaultValue ' '
|
||||
*/
|
||||
tokenSeparator?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return value of {@link useStreamTokenArray}.
|
||||
*/
|
||||
export type UseStreamTokenArrayReturn = {
|
||||
/** The currently visible, throttled subset of the upstream tokens as an array. */
|
||||
throttledTokens: StreamToken[];
|
||||
/** The currently visible, throttled subset joined as a string. */
|
||||
throttledContent: string;
|
||||
/** True when `isStreamFinished` is true and all tokens have been emitted. */
|
||||
isDone: boolean;
|
||||
/** Immediately reveal all available upstream tokens. */
|
||||
flushNow: () => void;
|
||||
/** Reset internal state and visible tokens back to empty. */
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute an emission chunk length given backlog and configuration.
|
||||
*
|
||||
* The result respects min/max chunk sizes and is increased proportionally
|
||||
* to recent upstream growth to keep up with fast producers.
|
||||
*
|
||||
* @param backlogLength - Tokens remaining to be emitted.
|
||||
* @param recentDelta - Increase in source token count since the last pacing window.
|
||||
* @param cfg - Required emission configuration bounds and growth scaling.
|
||||
* @returns A safe chunk size to emit on this frame.
|
||||
*/
|
||||
function computeTokenChunkLength(
|
||||
backlogLength: number,
|
||||
recentDelta: number,
|
||||
cfg: Required<
|
||||
Pick<
|
||||
UseStreamTokenArrayProps,
|
||||
'minChunkTokens' | 'maxChunkTokens' | 'targetBufferTokens' | 'adjustPercentage'
|
||||
>
|
||||
>
|
||||
): number {
|
||||
// Aim to leave targetBufferTokens un-emitted
|
||||
const desired = Math.max(0, backlogLength - cfg.targetBufferTokens);
|
||||
// React to recent growth: if backlog grew, increase the chunk size a bit
|
||||
const growthBoost = recentDelta > 0 ? Math.floor(recentDelta * cfg.adjustPercentage) : 0;
|
||||
const candidate = desired + growthBoost;
|
||||
return Math.max(cfg.minChunkTokens, Math.min(cfg.maxChunkTokens, candidate));
|
||||
}
|
||||
|
||||
/**
|
||||
* useStreamTokenArray
|
||||
*
|
||||
* React hook that progressively reveals an upstream array of tokens with frame-paced
|
||||
* updates. It aims to balance responsiveness and readability by controlling the size
|
||||
* and timing of emitted token chunks, creating a smooth streaming effect for
|
||||
* word-by-word or sentence-by-sentence content delivery.
|
||||
*
|
||||
* @remarks
|
||||
* - Provide the full accumulated `tokens` array as it grows over time.
|
||||
* - When `isStreamFinished` becomes true, the hook can flush the remaining
|
||||
* tokens immediately (configurable via `flushImmediatelyOnComplete`).
|
||||
* - Use `flushNow` to reveal everything eagerly; use `reset` to clear state for
|
||||
* a new stream.
|
||||
* - Returns both the token array and joined string for flexibility.
|
||||
*
|
||||
* @param props - {@link UseStreamTokenArrayProps}
|
||||
* @returns {@link UseStreamTokenArrayReturn}
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { throttledTokens, throttledContent, isDone, flushNow, reset } = useStreamTokenArray({
|
||||
* tokens: [
|
||||
* { token: 'Hello', delayMs: 100 },
|
||||
* { token: 'world', delayMs: 200 },
|
||||
* { token: 'from', delayMs: 150 },
|
||||
* { token: 'streaming', delayMs: 300 },
|
||||
* { token: 'tokens', delayMs: 250 }
|
||||
* ],
|
||||
* isStreamFinished: false
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const useStreamTokenArray = ({
|
||||
tokens,
|
||||
isStreamFinished,
|
||||
targetBufferTokens = 3,
|
||||
minChunkTokens = 1,
|
||||
maxChunkTokens = 5,
|
||||
frameLookBackMs = 100,
|
||||
adjustPercentage = 0.5,
|
||||
flushImmediatelyOnComplete = true,
|
||||
tokenSeparator = ' '
|
||||
}: UseStreamTokenArrayProps): UseStreamTokenArrayReturn => {
|
||||
const [throttledTokens, setThrottledTokens] = React.useState<StreamToken[]>([]);
|
||||
|
||||
// Refs to avoid re-renders
|
||||
const shownTokenCountRef = React.useRef<number>(0);
|
||||
const lastUpdateTsRef = React.useRef<number>(0);
|
||||
const rafIdRef = React.useRef<number | null>(null);
|
||||
const prevSourceLenRef = React.useRef<number>(0);
|
||||
const prevSourceTsRef = React.useRef<number>(0);
|
||||
|
||||
// Keep latest tokens and finished flag in refs so RAF loop does not need to re-subscribe
|
||||
const tokensRef = React.useRef<StreamToken[]>(tokens);
|
||||
const finishedRef = React.useRef<boolean>(isStreamFinished);
|
||||
|
||||
React.useEffect(() => {
|
||||
tokensRef.current = tokens;
|
||||
}, [tokens]);
|
||||
|
||||
React.useEffect(() => {
|
||||
finishedRef.current = isStreamFinished;
|
||||
}, [isStreamFinished]);
|
||||
|
||||
// Keep refs in sync with state
|
||||
React.useEffect(() => {
|
||||
shownTokenCountRef.current = throttledTokens.length;
|
||||
}, [throttledTokens.length]);
|
||||
|
||||
// Flush immediately if the stream has finished and we want no lag
|
||||
React.useEffect(() => {
|
||||
if (isStreamFinished && flushImmediatelyOnComplete) {
|
||||
if (throttledTokens.length !== tokens.length) {
|
||||
setThrottledTokens([...tokens]);
|
||||
shownTokenCountRef.current = tokens.length;
|
||||
}
|
||||
}
|
||||
}, [isStreamFinished, tokens, flushImmediatelyOnComplete, throttledTokens.length]);
|
||||
|
||||
const flushNow = React.useCallback(() => {
|
||||
setThrottledTokens([...tokens]);
|
||||
shownTokenCountRef.current = tokens.length;
|
||||
}, [tokens]);
|
||||
|
||||
const reset = React.useCallback(() => {
|
||||
setThrottledTokens([]);
|
||||
shownTokenCountRef.current = 0;
|
||||
lastUpdateTsRef.current = 0;
|
||||
prevSourceLenRef.current = 0;
|
||||
prevSourceTsRef.current = 0;
|
||||
}, []);
|
||||
|
||||
// Main animation frame loop
|
||||
React.useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
function loop(now: number) {
|
||||
if (!mounted) return;
|
||||
|
||||
const lastUpdate = lastUpdateTsRef.current;
|
||||
const shouldTick = now - lastUpdate >= frameLookBackMs;
|
||||
|
||||
const shownTokenCount = shownTokenCountRef.current;
|
||||
const sourceTokenCount = tokensRef.current.length;
|
||||
const backlogTokenCount = Math.max(0, sourceTokenCount - shownTokenCount);
|
||||
|
||||
// Defensive sync: if upstream tokens shrink (e.g., reset without calling `reset()`),
|
||||
// align the visible tokens to avoid a persistent RAF loop with zero backlog.
|
||||
if (shownTokenCount > sourceTokenCount && shouldTick) {
|
||||
setThrottledTokens([...tokensRef.current]);
|
||||
shownTokenCountRef.current = sourceTokenCount;
|
||||
lastUpdateTsRef.current = now;
|
||||
}
|
||||
|
||||
// Track input growth to adapt chunk size
|
||||
const prevLen = prevSourceLenRef.current;
|
||||
const prevTs = prevSourceTsRef.current || now;
|
||||
const recentDelta = sourceTokenCount - prevLen;
|
||||
if (now - prevTs >= frameLookBackMs) {
|
||||
prevSourceLenRef.current = sourceTokenCount;
|
||||
prevSourceTsRef.current = now;
|
||||
}
|
||||
|
||||
if (shouldTick && backlogTokenCount > 0) {
|
||||
// Base chunk length based on backlog and recent growth
|
||||
let chunkTokenCount = computeTokenChunkLength(backlogTokenCount, recentDelta, {
|
||||
minChunkTokens,
|
||||
maxChunkTokens,
|
||||
targetBufferTokens,
|
||||
adjustPercentage
|
||||
});
|
||||
|
||||
// Ensure chunk does not exceed backlog
|
||||
if (chunkTokenCount > backlogTokenCount) {
|
||||
chunkTokenCount = backlogTokenCount;
|
||||
}
|
||||
|
||||
// Emit the chunk
|
||||
const newTokenCount = shownTokenCount + chunkTokenCount;
|
||||
const newThrottledTokens = tokensRef.current.slice(0, newTokenCount);
|
||||
|
||||
setThrottledTokens(newThrottledTokens);
|
||||
shownTokenCountRef.current = newTokenCount;
|
||||
lastUpdateTsRef.current = now;
|
||||
}
|
||||
|
||||
// Schedule next frame if we still expect updates
|
||||
const shouldContinue =
|
||||
mounted &&
|
||||
(tokensRef.current.length !== shownTokenCountRef.current || !finishedRef.current);
|
||||
rafIdRef.current = shouldContinue ? window.requestAnimationFrame(loop) : null;
|
||||
}
|
||||
|
||||
rafIdRef.current = window.requestAnimationFrame(loop);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (rafIdRef.current != null) {
|
||||
window.cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
};
|
||||
// Loop depends only on configuration, not on streaming values
|
||||
}, [adjustPercentage, frameLookBackMs, maxChunkTokens, minChunkTokens, targetBufferTokens]);
|
||||
|
||||
const isDone = isStreamFinished && throttledTokens.length >= tokens.length;
|
||||
|
||||
// When totally done, clear internal gating state to release references and be ready for next run
|
||||
React.useEffect(() => {
|
||||
if (!isDone) return;
|
||||
// Clear timers and internal caches (do not touch visible content)
|
||||
prevSourceLenRef.current = 0;
|
||||
prevSourceTsRef.current = 0;
|
||||
// Drop lastUpdate tick so next stream starts fresh
|
||||
lastUpdateTsRef.current = 0;
|
||||
}, [isDone]);
|
||||
|
||||
// Join tokens to create content string
|
||||
const throttledContent = React.useMemo(() => {
|
||||
return throttledTokens.map((t) => t.token).join(tokenSeparator);
|
||||
}, [throttledTokens, tokenSeparator]);
|
||||
|
||||
return {
|
||||
throttledTokens,
|
||||
throttledContent,
|
||||
isDone,
|
||||
flushNow,
|
||||
reset
|
||||
};
|
||||
};
|
Loading…
Reference in New Issue