diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx index 0cd31aa9d..c4b897e53 100644 --- a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/AppMarkdownStreaming.stories.tsx @@ -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 = { 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 (
@@ -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 (
@@ -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 (
@@ -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 (
diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/index.ts b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/index.ts index 03b587e69..953f10eeb 100644 --- a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/index.ts +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/index.ts @@ -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'; diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useLLMStreaming.stories.tsx b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useLLMStreaming.stories.tsx index 6c870cbe3..9bd8024c6 100644 --- a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useLLMStreaming.stories.tsx +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useLLMStreaming.stories.tsx @@ -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 = (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({ diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useStreamTokenArray.stories.tsx b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useStreamTokenArray.stories.tsx new file mode 100644 index 000000000..ecd54d952 --- /dev/null +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useStreamTokenArray.stories.tsx @@ -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([]); + const [finished, setFinished] = React.useState(false); + const timeoutRef = React.useRef(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 ( +
+
+

Stream Token Array Demo

+ + {/* Controls */} +
+ + +
+ + {/* Status */} +
+
+ Total Tokens:{' '} + {autoStream ? currentTokens.length : tokens.length} +
+
+ Visible Tokens: {throttledTokens.length} +
+
+ Stream Finished:{' '} + {autoStream ? (finished ? 'Yes' : 'No') : isStreamFinished ? 'Yes' : 'No'} +
+
+ Is Done: {isDone ? 'Yes' : 'No'} +
+
+ + {/* Progress Bar */} +
+
+ Progress + + {throttledTokens.length} / {autoStream ? currentTokens.length : tokens.length} + +
+
+
+
+
+ + {/* Token Array Visualization */} +
+

Token Array:

+
+ {(autoStream ? currentTokens : tokens).map((token, index) => ( + + {token} + + ))} +
+
+ + {/* Content Output */} +
+

Rendered Content:

+
+ {throttledContent || No content yet...} +
+
+
+
+ ); +}; + +const meta: Meta = { + 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; + +/** + * 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 + } +}; diff --git a/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useStreamTokenArray.ts b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useStreamTokenArray.ts new file mode 100644 index 000000000..9cf42e67f --- /dev/null +++ b/apps/web/src/components/ui/streaming/AppMarkdownStreaming/useStreamTokenArray.ts @@ -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([]); + + // Refs to avoid re-renders + const shownTokenCountRef = React.useRef(0); + const lastUpdateTsRef = React.useRef(0); + const rafIdRef = React.useRef(null); + const prevSourceLenRef = React.useRef(0); + const prevSourceTsRef = React.useRef(0); + + // Keep latest tokens and finished flag in refs so RAF loop does not need to re-subscribe + const tokensRef = React.useRef(tokens); + const finishedRef = React.useRef(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 + }; +};