Merge branch 'staging' into cursor/migrate-state-to-cookie-storage-c48a

This commit is contained in:
Nate Kelley 2025-08-12 23:09:51 -06:00
commit 21cccddd3b
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
5 changed files with 628 additions and 10 deletions

View File

@ -1,8 +1,8 @@
import { useStreamTokenArray } from '@llm-ui/react';
import type { Meta, StoryObj } from '@storybook/nextjs'; import type { Meta, StoryObj } from '@storybook/nextjs';
import type React from 'react'; import type React from 'react';
import type { MarkdownAnimation } from '../../typography/animation-common'; import type { MarkdownAnimation } from '../../typography/animation-common';
import AppMarkdownStreaming from './AppMarkdownStreaming'; import AppMarkdownStreaming from './AppMarkdownStreaming';
import { useStreamTokenArray } from './useStreamTokenArray';
const meta: Meta<typeof AppMarkdownStreaming> = { const meta: Meta<typeof AppMarkdownStreaming> = {
title: 'UI/Typography/AppMarkdownStreaming', title: 'UI/Typography/AppMarkdownStreaming',
@ -192,10 +192,10 @@ const actualTokenArray = [
]; ];
const StreamingDemo: React.FC<{ animation: MarkdownAnimation }> = ({ animation }) => { const StreamingDemo: React.FC<{ animation: MarkdownAnimation }> = ({ animation }) => {
const { isStreamFinished, output } = useStreamTokenArray([ const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
...actualTokenArray, tokens: [...actualTokenArray, ...redRisingPoemTokenArray],
...redRisingPoemTokenArray isStreamFinished: false
]); });
return ( return (
<div className="flex w-full space-y-4 space-x-4"> <div className="flex w-full space-y-4 space-x-4">
@ -401,7 +401,10 @@ const complexMarkdownTokenArray = [
export const ComplexStream: Story = { export const ComplexStream: Story = {
render: () => { render: () => {
const { isStreamFinished, output } = useStreamTokenArray(complexMarkdownTokenArray); const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
tokens: complexMarkdownTokenArray,
isStreamFinished: false
});
return ( return (
<div className="flex w-full space-y-4 space-x-4"> <div className="flex w-full space-y-4 space-x-4">
@ -470,7 +473,10 @@ const paragraphToListTransitionTokens = [
export const ParagraphToListTransition: Story = { export const ParagraphToListTransition: Story = {
render: () => { render: () => {
const { isStreamFinished, output } = useStreamTokenArray(paragraphToListTransitionTokens); const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
tokens: paragraphToListTransitionTokens,
isStreamFinished: false
});
return ( return (
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
@ -712,7 +718,10 @@ const brokenInlineCodeTokens = [
export const InlineCodeStreaming: Story = { export const InlineCodeStreaming: Story = {
render: () => { render: () => {
const { isStreamFinished, output } = useStreamTokenArray(brokenInlineCodeTokens); const { isDone: isStreamFinished, throttledContent: output } = useStreamTokenArray({
tokens: brokenInlineCodeTokens,
isStreamFinished: false
});
return ( return (
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">

View File

@ -1 +1,9 @@
export { default as AppMarkdownStreaming } from './AppMarkdownStreaming'; 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';

View File

@ -1,7 +1,7 @@
import * as React from 'react'; import * as React from 'react';
import type { Meta, StoryObj } from '@storybook/nextjs'; import type { Meta, StoryObj } from '@storybook/nextjs';
import { useStreamTokenArray } from '@llm-ui/react';
import { useLLMStreaming } from './useLLMStreaming'; import { useLLMStreaming } from './useLLMStreaming';
import { useStreamTokenArray } from './useStreamTokenArray';
// Simple token stream to exercise throttling + read-ahead // Simple token stream to exercise throttling + read-ahead
const demoTokens: Array<{ token: string; delayMs: number }> = [ const demoTokens: Array<{ token: string; delayMs: number }> = [
@ -60,7 +60,10 @@ type HookHarnessProps = {
const HookHarness: React.FC<HookHarnessProps> = (args) => { const HookHarness: React.FC<HookHarnessProps> = (args) => {
// Simulate incoming streamed content // 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 // Apply the throttling + read-ahead hook
const { throttledContent, isDone, flushNow, reset } = useLLMStreaming({ const { throttledContent, isDone, flushNow, reset } = useLLMStreaming({

View File

@ -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
}
};

View File

@ -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
};
};